diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 28132c50d..d400709dc 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -550,6 +550,17 @@ pub enum MessageId { CtxInspChangesByTurn, CtxInspStablePrefixOnly, CtxInspCacheTip, + // Pending input preview. + PendingContextHeader, + PendingInputHeader, + PendingSteerLabel, + PendingRejectedLabel, + PendingQueuedLabel, + PendingEditingLabel, + PendingRestoreHint, + PendingEditHint, + PendingDeleteHint, + PendingRemovable, } #[allow(dead_code)] @@ -874,6 +885,16 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CtxInspChangesByTurn, MessageId::CtxInspStablePrefixOnly, MessageId::CtxInspCacheTip, + MessageId::PendingContextHeader, + MessageId::PendingInputHeader, + MessageId::PendingSteerLabel, + MessageId::PendingRejectedLabel, + MessageId::PendingQueuedLabel, + MessageId::PendingEditingLabel, + MessageId::PendingRestoreHint, + MessageId::PendingEditHint, + MessageId::PendingDeleteHint, + MessageId::PendingRemovable, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { @@ -1509,6 +1530,16 @@ fn english(id: MessageId) -> &'static str { "Tip: Stable prefix blocks are DeepSeek V4 prefix-cache eligible. \ Volatile working-set changes break the cache only for the tail." } + MessageId::PendingContextHeader => "Context for next send", + MessageId::PendingInputHeader => "Pending inputs", + MessageId::PendingSteerLabel => "Steer pending: ", + MessageId::PendingRejectedLabel => "Rejected steer: ", + MessageId::PendingQueuedLabel => "Queued follow-up: ", + MessageId::PendingEditingLabel => "Editing queued follow-up: ", + MessageId::PendingRestoreHint => "Esc restores queued follow-up", + MessageId::PendingEditHint => "{key} edit last queued message", + MessageId::PendingDeleteHint => "Backspace/Delete removes", + MessageId::PendingRemovable => "removable", } } @@ -2009,6 +2040,16 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "Gợi ý: Các khối ổn định đủ điều kiện cho bộ nhớ đệm tiền tố DeepSeek V4. Thay đổi vùng làm việc chỉ phá vỡ bộ nhớ đệm ở phần cuối." } + MessageId::PendingContextHeader => "Ngữ cảnh cho lần gửi tiếp theo", + MessageId::PendingInputHeader => "Đầu vào đang chờ", + MessageId::PendingSteerLabel => "Đang chờ chỉ đạo: ", + MessageId::PendingRejectedLabel => "Chỉ đạo bị từ chối: ", + MessageId::PendingQueuedLabel => "Tin nhắn tiếp theo đã xếp hàng: ", + MessageId::PendingEditingLabel => "Đang chỉnh sửa tin nhắn tiếp theo đã xếp hàng: ", + MessageId::PendingRestoreHint => "Esc khôi phục tin nhắn tiếp theo đã xếp hàng", + MessageId::PendingEditHint => "{key} chỉnh sửa tin nhắn cuối cùng đã xếp hàng", + MessageId::PendingDeleteHint => "Backspace/Delete xóa", + MessageId::PendingRemovable => "có thể xóa", }) } @@ -2073,6 +2114,16 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "提示:穩定前綴區塊符合 DeepSeek V4 前綴快取條件。易變工作集的更改僅會破壞快取尾部。" } + MessageId::PendingContextHeader => "本次發送的上下文", + MessageId::PendingInputHeader => "待處理輸入", + MessageId::PendingSteerLabel => "待引導:", + MessageId::PendingRejectedLabel => "已拒絕引導:", + MessageId::PendingQueuedLabel => "已排隊後續訊息:", + MessageId::PendingEditingLabel => "正在編輯已排隊的後續訊息:", + MessageId::PendingRestoreHint => "Esc 恢復已排隊的後續訊息", + MessageId::PendingEditHint => "{key} 編輯最後一條已排隊訊息", + MessageId::PendingDeleteHint => "Backspace/Delete 刪除", + MessageId::PendingRemovable => "可刪除", other => chinese_simplified(other)?, }) } @@ -2536,6 +2587,16 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "ヒント:安定プレフィックスブロックはDeepSeek V4プレフィックスキャッシュの対象です。揮発性ワーキングセットの変更は末尾のキャッシュのみを破壊します。" } + MessageId::PendingContextHeader => "次回送信のコンテキスト", + MessageId::PendingInputHeader => "保留中の入力", + MessageId::PendingSteerLabel => "保留中のステア: ", + MessageId::PendingRejectedLabel => "拒否されたステア: ", + MessageId::PendingQueuedLabel => "キューされたフォローアップ: ", + MessageId::PendingEditingLabel => "キューされたフォローアップを編集中: ", + MessageId::PendingRestoreHint => "Esc でキューされたフォローアップを復元", + MessageId::PendingEditHint => "{key} 最後のキューされたメッセージを編集", + MessageId::PendingDeleteHint => "Backspace/Delete で削除", + MessageId::PendingRemovable => "削除可能", }) } @@ -2940,6 +3001,16 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "提示:稳定前缀区块符合 DeepSeek V4 前缀缓存条件。易变工作集的更改仅会破坏缓存尾部。" } + MessageId::PendingContextHeader => "本次发送的上下文", + MessageId::PendingInputHeader => "待处理输入", + MessageId::PendingSteerLabel => "待引导:", + MessageId::PendingRejectedLabel => "已拒绝引导:", + MessageId::PendingQueuedLabel => "已排队后续消息:", + MessageId::PendingEditingLabel => "正在编辑已排队的后续消息:", + MessageId::PendingRestoreHint => "Esc 恢复已排队的后续消息", + MessageId::PendingEditHint => "{key} 编辑最后一条已排队消息", + MessageId::PendingDeleteHint => "Backspace/Delete 删除", + MessageId::PendingRemovable => "可删除", }) } @@ -3426,6 +3497,16 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "Dica: Blocos de prefixo estável são elegíveis para cache de prefixo DeepSeek V4. Alterações no conjunto de trabalho volátil quebram o cache apenas no final." } + MessageId::PendingContextHeader => "Contexto para o próximo envio", + MessageId::PendingInputHeader => "Entradas pendentes", + MessageId::PendingSteerLabel => "Direção pendente: ", + MessageId::PendingRejectedLabel => "Direção rejeitada: ", + MessageId::PendingQueuedLabel => "Acompanhamento na fila: ", + MessageId::PendingEditingLabel => "Editando acompanhamento na fila: ", + MessageId::PendingRestoreHint => "Esc restaura acompanhamento na fila", + MessageId::PendingEditHint => "{key} editar última mensagem na fila", + MessageId::PendingDeleteHint => "Backspace/Delete remove", + MessageId::PendingRemovable => "removível", }) } @@ -3922,6 +4003,16 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "Consejo: Los bloques de prefijo estable son elegibles para caché de prefijo DeepSeek V4. Los cambios en el conjunto de trabajo volátil solo rompen la caché al final." } + MessageId::PendingContextHeader => "Contexto para el próximo envío", + MessageId::PendingInputHeader => "Entradas pendientes", + MessageId::PendingSteerLabel => "Dirección pendiente: ", + MessageId::PendingRejectedLabel => "Dirección rechazada: ", + MessageId::PendingQueuedLabel => "Seguimiento en cola: ", + MessageId::PendingEditingLabel => "Editando seguimiento en cola: ", + MessageId::PendingRestoreHint => "Esc restaura seguimiento en cola", + MessageId::PendingEditHint => "{key} editar último mensaje en cola", + MessageId::PendingDeleteHint => "Backspace/Delete elimina", + MessageId::PendingRemovable => "removible", }) } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 9320f7d99..3a263b08e 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -7017,7 +7017,7 @@ async fn handle_plan_choice( /// - `queued_messages` — Enter while busy (offline-mode FIFO); drained at /// end-of-turn. fn build_pending_input_preview(app: &App) -> PendingInputPreview { - let mut preview = PendingInputPreview::new(); + let mut preview = PendingInputPreview::new(app.ui_locale); let selected_attachment = app.selected_composer_attachment_index(); let mut attachment_index = 0usize; preview.context_items = crate::tui::file_mention::pending_context_previews( diff --git a/crates/tui/src/tui/widgets/pending_input_preview.rs b/crates/tui/src/tui/widgets/pending_input_preview.rs index a9e2cb4fb..061da6c1b 100644 --- a/crates/tui/src/tui/widgets/pending_input_preview.rs +++ b/crates/tui/src/tui/widgets/pending_input_preview.rs @@ -1,17 +1,3 @@ -//! Pending-input preview widget for the composer area. -//! -//! Port of `codex-rs/tui/src/bottom_pane/pending_input_preview.rs` for -//! issue #85. Renders queued/steered messages above the composer when a -//! turn is in flight, so user input typed during a running turn doesn't -//! disappear silently. The backing state still distinguishes queue/steer -//! origins, but the UI renders one coherent pending-input list. -//! -//! Empty state renders zero rows so the composer doesn't gain wasted height -//! when there's nothing to show. -//! -//! Wired into `ui.rs::render` between the chat area and the composer; the user -//! can see when typed input has been captured for later delivery. - use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; @@ -19,15 +5,12 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Paragraph, Widget}; use unicode_width::UnicodeWidthChar; +use crate::localization::{Locale, MessageId, tr}; use crate::palette; use crate::tui::widgets::Renderable; /// Per-item line cap before we collapse the rest into a `…` overflow row. const PREVIEW_LINE_LIMIT: usize = 3; -const PENDING_STEER_PREFIX: &str = " ↳ Steer pending: "; -const REJECTED_STEER_PREFIX: &str = " ↳ Rejected steer: "; -const QUEUED_MESSAGE_PREFIX: &str = " ↳ Queued follow-up: "; -const EDITING_QUEUED_PREFIX: &str = " ↳ Editing queued follow-up: "; /// Description of the keybinding the hint line at the bottom should advertise /// for the "edit last queued message" action. @@ -43,6 +26,7 @@ impl EditBinding { /// Widget showing pending input while a turn is in progress. #[derive(Debug, Clone)] pub struct PendingInputPreview { + pub locale: Locale, pub context_items: Vec, pub pending_steers: Vec, pub rejected_steers: Vec, @@ -65,8 +49,9 @@ pub struct ContextPreviewItem { } impl PendingInputPreview { - pub fn new() -> Self { + pub fn new(locale: Locale) -> Self { Self { + locale, context_items: Vec::new(), pending_steers: Vec::new(), rejected_steers: Vec::new(), @@ -76,6 +61,22 @@ impl PendingInputPreview { } } + fn pending_steer_prefix(&self) -> String { + format!(" ↳ {}", tr(self.locale, MessageId::PendingSteerLabel)) + } + + fn rejected_steer_prefix(&self) -> String { + format!(" ↳ {}", tr(self.locale, MessageId::PendingRejectedLabel)) + } + + fn queued_message_prefix(&self) -> String { + format!(" ↳ {}", tr(self.locale, MessageId::PendingQueuedLabel)) + } + + fn editing_queued_prefix(&self) -> String { + format!(" ↳ {}", tr(self.locale, MessageId::PendingEditingLabel)) + } + fn has_pending_inputs(&self) -> bool { !self.pending_steers.is_empty() || !self.rejected_steers.is_empty() @@ -83,9 +84,6 @@ impl PendingInputPreview { || self.editing_queued_message.is_some() } - /// Build the (possibly empty) ordered line list this widget would render - /// at `width`. Pulled out so `desired_height` can ask the same renderer - /// without duplicating wrapping logic. fn lines(&self, width: u16) -> Vec> { if (self.context_items.is_empty() && !self.has_pending_inputs()) || width < 4 { return Vec::new(); @@ -101,10 +99,13 @@ impl PendingInputPreview { if !self.context_items.is_empty() { push_section_header( &mut lines, - Line::from(vec![Span::raw("• "), Span::raw("Context for next send")]), + Line::from(vec![ + Span::raw("• "), + Span::raw(tr(self.locale, MessageId::PendingContextHeader)), + ]), ); for item in &self.context_items { - push_context_item(&mut lines, item, width); + push_context_item(&mut lines, item, width, self.locale); } } @@ -114,61 +115,72 @@ impl PendingInputPreview { } push_section_header( &mut lines, - Line::from(vec![Span::raw("• "), Span::raw("Pending inputs")]), + Line::from(vec![ + Span::raw("• "), + Span::raw(tr(self.locale, MessageId::PendingInputHeader)), + ]), ); - let pending_steer_indent = continuation_indent(PENDING_STEER_PREFIX); + let pending_steer_prefix = self.pending_steer_prefix(); + let pending_steer_indent = continuation_indent(&pending_steer_prefix); for steer in &self.pending_steers { push_truncated_item( &mut lines, steer, width, dim, - PENDING_STEER_PREFIX, + &pending_steer_prefix, &pending_steer_indent, ); } - let rejected_steer_indent = continuation_indent(REJECTED_STEER_PREFIX); + let rejected_steer_prefix = self.rejected_steer_prefix(); + let rejected_steer_indent = continuation_indent(&rejected_steer_prefix); for steer in &self.rejected_steers { push_truncated_item( &mut lines, steer, width, dim, - REJECTED_STEER_PREFIX, + &rejected_steer_prefix, &rejected_steer_indent, ); } if let Some(draft) = self.editing_queued_message.as_deref() { - let editing_indent = continuation_indent(EDITING_QUEUED_PREFIX); + let editing_prefix = self.editing_queued_prefix(); + let editing_indent = continuation_indent(&editing_prefix); push_truncated_item( &mut lines, draft, width, dim_italic, - EDITING_QUEUED_PREFIX, + &editing_prefix, &editing_indent, ); - lines.push(Line::from(vec![Span::styled( - " Esc restores queued follow-up".to_string(), - dim, - )])); + lines.push(Line::from(vec![ + Span::styled(" ", dim), + Span::styled(tr(self.locale, MessageId::PendingRestoreHint), dim), + ])); } - let queued_message_indent = continuation_indent(QUEUED_MESSAGE_PREFIX); + let queued_message_prefix = self.queued_message_prefix(); + let queued_message_indent = continuation_indent(&queued_message_prefix); for message in &self.queued_messages { push_truncated_item( &mut lines, message, width, dim_italic, - QUEUED_MESSAGE_PREFIX, + &queued_message_prefix, &queued_message_indent, ); } if !self.queued_messages.is_empty() { - lines.push(Line::from(vec![Span::styled( - format!(" {} edit last queued message", self.edit_binding.label), - dim, - )])); + lines.push(Line::from(vec![ + Span::styled(" ", dim), + Span::styled( + tr(self.locale, MessageId::PendingEditHint) + .replace("{key}", self.edit_binding.label), + dim, + ), + ])); } } @@ -178,7 +190,7 @@ impl PendingInputPreview { impl Default for PendingInputPreview { fn default() -> Self { - Self::new() + Self::new(Locale::En) } } @@ -208,7 +220,12 @@ fn push_section_header(lines: &mut Vec>, header: Line<'static>) { lines.push(header); } -fn push_context_item(lines: &mut Vec>, item: &ContextPreviewItem, width: u16) { +fn push_context_item( + lines: &mut Vec>, + item: &ContextPreviewItem, + width: u16, + locale: Locale, +) { let status_style = if item.selected { Style::default() .fg(palette::SELECTION_TEXT) @@ -234,14 +251,18 @@ fn push_context_item(lines: &mut Vec>, item: &ContextPreviewItem, .filter(|detail| !detail.trim().is_empty()) .map(|detail| format!(" · {detail}")) .unwrap_or_default(); - let action = if item.selected { - " · Backspace/Delete removes" + let action_label = if item.selected { + Some(tr(locale, MessageId::PendingDeleteHint)) } else if item.removable { - " · removable" + Some(tr(locale, MessageId::PendingRemovable)) + } else { + None + }; + let body = if let Some(label) = action_label { + format!("[{}] {}{} · {}", item.kind, item.label, detail, label) } else { - "" + format!("[{}] {}{}", item.kind, item.label, detail) }; - let body = format!("[{}] {}{}{}", item.kind, item.label, detail, action); let body_width = width.saturating_sub(4).max(1) as usize; for (idx, segment) in wrap_to_width(&body, body_width).into_iter().enumerate() { let prefix = if idx == 0 { @@ -256,10 +277,6 @@ fn push_context_item(lines: &mut Vec>, item: &ContextPreviewItem, } } -/// Render a single bucket item with `↳` prefix, truncating to -/// [`PREVIEW_LINE_LIMIT`] visible rows. Multi-line input wraps at the given -/// column budget and the continuation rows get the `subsequent_indent` so -/// the prefix and the body stay column-aligned. fn push_truncated_item( lines: &mut Vec>, raw: &str, @@ -305,10 +322,6 @@ fn push_truncated_item( } } -/// Naive word-aware wrap that respects unicode display widths. Matches the -/// behavior expected by snapshot tests in the codex source — long URL-like -/// tokens that exceed `width` are emitted on their own row instead of being -/// hard-broken mid-character. fn wrap_to_width(text: &str, width: usize) -> Vec { if width == 0 || text.is_empty() { return vec![text.to_string()]; @@ -325,9 +338,6 @@ fn wrap_to_width(text: &str, width: usize) -> Vec { current_width = 0; } if word_width > width { - // Token longer than the budget: flush current, emit the word as - // its own row even though it overflows. Avoids the codex-issue - // of a long URL fanning out into N junk-ellipsis rows. if !current.is_empty() { out.push(std::mem::take(&mut current)); current_width = 0; @@ -374,16 +384,15 @@ mod tests { #[test] fn empty_widget_has_zero_height() { - let preview = PendingInputPreview::new(); + let preview = PendingInputPreview::new(Locale::En); assert_eq!(preview.desired_height(40), 0); } #[test] fn single_queued_message_renders_header_item_and_hint() { - let mut preview = PendingInputPreview::new(); + let mut preview = PendingInputPreview::new(Locale::En); preview.queued_messages.push("Hello, world!".to_string()); let rows = render_to_string(&preview, 40); - // Expect: header line, message line, hint line. assert_eq!(rows.len(), 3, "got rows: {rows:?}"); assert!(rows[0].contains("Pending inputs")); assert!(rows[1].contains("Hello, world!")); @@ -392,7 +401,7 @@ mod tests { #[test] fn editing_queued_message_renders_explicit_state_and_restore_hint() { - let mut preview = PendingInputPreview::new(); + let mut preview = PendingInputPreview::new(Locale::En); preview.editing_queued_message = Some("revise before sending".to_string()); let rows = render_to_string(&preview, 80); @@ -418,7 +427,7 @@ mod tests { #[test] fn context_items_render_before_queue_buckets() { - let mut preview = PendingInputPreview::new(); + let mut preview = PendingInputPreview::new(Locale::En); preview.context_items.push(ContextPreviewItem { kind: "file".to_string(), label: "src/main.rs".to_string(), @@ -443,7 +452,7 @@ mod tests { #[test] fn selected_removable_attachment_renders_delete_hint() { - let mut preview = PendingInputPreview::new(); + let mut preview = PendingInputPreview::new(Locale::En); preview.context_items.push(ContextPreviewItem { kind: "image".to_string(), label: "/tmp/pasted.png".to_string(), @@ -464,7 +473,7 @@ mod tests { #[test] fn pending_steer_renders_without_queue_edit_hint() { - let mut preview = PendingInputPreview::new(); + let mut preview = PendingInputPreview::new(Locale::En); preview.pending_steers.push("Please continue.".to_string()); let rows = render_to_string(&preview, 80); assert!( @@ -483,7 +492,7 @@ mod tests { #[test] fn all_pending_inputs_render_as_one_list() { - let mut preview = PendingInputPreview::new(); + let mut preview = PendingInputPreview::new(Locale::En); preview.pending_steers.push("steer".to_string()); preview.rejected_steers.push("rejected".to_string()); preview.queued_messages.push("queued".to_string()); @@ -501,7 +510,7 @@ mod tests { #[test] fn pending_input_rows_label_each_delivery_mode() { - let mut preview = PendingInputPreview::new(); + let mut preview = PendingInputPreview::new(Locale::En); preview.pending_steers.push("steer".to_string()); preview.rejected_steers.push("rejected".to_string()); preview.queued_messages.push("queued".to_string()); @@ -532,7 +541,7 @@ mod tests { #[test] fn wrapped_pending_input_aligns_continuation_under_label() { - let mut preview = PendingInputPreview::new(); + let mut preview = PendingInputPreview::new(Locale::En); preview .queued_messages .push("alpha beta gamma delta epsilon zeta".to_string()); @@ -540,8 +549,9 @@ mod tests { let rows = render_to_string(&preview, 34); assert!(rows[1].contains("Queued follow-up: alpha")); + let q_prefix = preview.queued_message_prefix(); assert!( - rows[2].starts_with(&continuation_indent(QUEUED_MESSAGE_PREFIX)), + rows[2].starts_with(&continuation_indent(&q_prefix)), "continuation should align under label: {rows:?}" ); assert!( @@ -552,12 +562,11 @@ mod tests { #[test] fn message_truncates_to_three_visible_lines() { - let mut preview = PendingInputPreview::new(); + let mut preview = PendingInputPreview::new(Locale::En); preview .queued_messages .push("line1\nline2\nline3\nline4\nline5".to_string()); let rows = render_to_string(&preview, 40); - // Header + 3 visible lines + ellipsis row + hint = 6 rows. assert_eq!(rows.len(), 6, "got rows: {rows:?}"); assert!(rows[0].contains("Pending inputs")); assert!(rows[1].contains("line1")); @@ -569,21 +578,19 @@ mod tests { #[test] fn long_url_does_not_explode_into_ellipsis_rows() { - let mut preview = PendingInputPreview::new(); + let mut preview = PendingInputPreview::new(Locale::En); preview.queued_messages.push( "example.test/api/v1/projects/alpha/releases/2026-02-17/build/1234567890/artifacts/x" .to_string(), ); let rows = render_to_string(&preview, 36); - // Header + URL row + hint = 3 rows; the URL must NOT cause a chain of - // wrapped-ellipsis rows. assert_eq!(rows.len(), 3, "got rows: {rows:?}"); assert!(!rows.iter().any(|r| r.contains("…"))); } #[test] fn narrow_width_renders_nothing() { - let mut preview = PendingInputPreview::new(); + let mut preview = PendingInputPreview::new(Locale::En); preview.queued_messages.push("hi".to_string()); assert_eq!(preview.desired_height(2), 0); }