diff --git a/README.md b/README.md index 0a81ba91..ceec4314 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ DeepSeek TUI is a coding agent that runs entirely in your terminal. It gives Dee - **Session save/resume** — checkpoint and resume long sessions - **Workspace rollback** — side-git pre/post-turn snapshots with `/restore` and `revert_turn`, without touching your repo's `.git` - **HTTP/SSE runtime API** — `deepseek serve --http` for headless agent workflows -- **MCP protocol** — connect to Model Context Protocol servers for extended tooling +- **MCP protocol** — connect to Model Context Protocol servers for extended tooling; see [docs/MCP.md](docs/MCP.md) - **Live cost tracking** — per-turn and session-level token usage and cost estimates - **Dark theme** — DeepSeek-blue palette @@ -150,23 +150,31 @@ deepseek --model deepseek-v4-flash "summarize" # model override deepseek --yolo # YOLO mode (auto-approve tools) deepseek login --api-key "..." # save API key deepseek doctor # check setup & connectivity +deepseek doctor --json # machine-readable diagnostics +deepseek setup --status # read-only setup status +deepseek setup --tools --plugins # scaffold local tool/plugin dirs deepseek models # list live API models deepseek sessions # list saved sessions deepseek resume --last # resume latest session deepseek serve --http # HTTP/SSE API server +deepseek mcp list # list configured MCP servers +deepseek mcp validate # validate MCP config/connectivity +deepseek mcp-server # run dispatcher MCP stdio server ``` ### Keyboard shortcuts | Key | Action | |---|---| -| `Tab` | Cycle mode: Plan → Agent → YOLO | +| `Tab` | Complete `/` or `@` entries; while a turn is running, queue the draft as a follow-up; otherwise cycle mode | | `Shift+Tab` | Cycle reasoning-effort: off → high → max | | `F1` | Help | | `Esc` | Back / dismiss | | `Ctrl+K` | Command palette | +| `Ctrl+R` | Resume an earlier session | +| `Alt+R` | Search prompt history and recover cleared drafts | | `@path` | Attach file/directory context in composer | -| `/attach ` | Attach image/video media references | +| `/attach ` | Attach image/video media references; select the row with `↑` at composer start and remove with `Backspace`/`Delete` | --- @@ -198,17 +206,13 @@ Key environment overrides: | `SGLANG_BASE_URL` | Self-hosted SGLang endpoint | | `SGLANG_API_KEY` | Optional SGLang bearer token | -Quick diagnostics: - -```bash -deepseek-tui setup --status # read-only status check (API key, MCP, sandbox, .env) -deepseek-tui doctor --json # machine-readable doctor output for CI -deepseek-tui setup --tools --plugins # scaffold tools/ and plugins/ directories -``` +Quick diagnostics: `deepseek setup --status` checks API key, MCP, sandbox, and +`.env` state without network calls; `deepseek doctor --json` is suitable for CI; +`deepseek setup --tools --plugins` scaffolds local tool and plugin directories. DeepSeek context caching is automatic — when the API returns cache hit/miss token fields, the TUI includes them in usage and cost tracking. -Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md) +Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md) and [docs/MCP.md](docs/MCP.md). --- diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index acaa7140..27ab97e7 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use super::CommandResult; use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name}; +use crate::localization::resolve_locale; use crate::settings::Settings; use crate::tui::app::{App, AppAction, AppMode, OnboardingState, SidebarFocus}; use crate::tui::approval::ApprovalMode; @@ -216,6 +217,10 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.show_tool_details = settings.show_tool_details; app.mark_history_updated(); } + "locale" | "language" => { + app.ui_locale = resolve_locale(&settings.locale); + app.needs_redraw = true; + } "composer_density" | "composer" => { app.composer_density = crate::tui::app::ComposerDensity::from_setting(&settings.composer_density); @@ -225,6 +230,12 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.composer_border = settings.composer_border; app.needs_redraw = true; } + "paste_burst_detection" | "paste_burst" => { + app.use_paste_burst_detection = settings.paste_burst_detection; + if !app.use_paste_burst_detection { + app.paste_burst.clear_after_explicit_paste(); + } + } "transcript_spacing" | "spacing" => { app.transcript_spacing = crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing); diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 19623a56..2066ede9 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -27,7 +27,7 @@ pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { // Show help overlay if app.view_stack.top_kind() != Some(ModalKind::Help) { - app.view_stack.push(HelpView::new()); + app.view_stack.push(HelpView::new_for_locale(app.ui_locale)); } CommandResult::ok() } diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs new file mode 100644 index 00000000..9b0e92bd --- /dev/null +++ b/crates/tui/src/localization.rs @@ -0,0 +1,637 @@ +//! Lightweight localization registry for high-visibility TUI strings. +//! +//! This intentionally covers UI chrome only. It does not change model prompts, +//! model output language, provider behavior, or media payload semantics. + +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextDirection { + Ltr, + Rtl, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LocaleCoverage { + English, + V076Core, + PlannedQa, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LocaleSpec { + pub tag: &'static str, + pub display_name: &'static str, + pub script: &'static str, + pub direction: TextDirection, + pub fallback: &'static str, + pub coverage: LocaleCoverage, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Locale { + En, + Ja, + ZhHans, + PtBr, +} + +impl Locale { + pub fn tag(self) -> &'static str { + match self { + Self::En => "en", + Self::Ja => "ja", + Self::ZhHans => "zh-Hans", + Self::PtBr => "pt-BR", + } + } + + #[allow(dead_code)] + pub fn spec(self) -> LocaleSpec { + match self { + Self::En => LocaleSpec { + tag: "en", + display_name: "English", + script: "Latin", + direction: TextDirection::Ltr, + fallback: "en", + coverage: LocaleCoverage::English, + }, + Self::Ja => LocaleSpec { + tag: "ja", + display_name: "Japanese", + script: "Jpan", + direction: TextDirection::Ltr, + fallback: "en", + coverage: LocaleCoverage::V076Core, + }, + Self::ZhHans => LocaleSpec { + tag: "zh-Hans", + display_name: "Chinese Simplified", + script: "Hans", + direction: TextDirection::Ltr, + fallback: "en", + coverage: LocaleCoverage::V076Core, + }, + Self::PtBr => LocaleSpec { + tag: "pt-BR", + display_name: "Portuguese (Brazil)", + script: "Latin", + direction: TextDirection::Ltr, + fallback: "en", + coverage: LocaleCoverage::V076Core, + }, + } + } + + #[allow(dead_code)] + pub fn shipped() -> &'static [Self] { + &[Self::En, Self::Ja, Self::ZhHans, Self::PtBr] + } +} + +#[allow(dead_code)] +pub const PLANNED_QA_LOCALES: &[LocaleSpec] = &[ + LocaleSpec { + tag: "ar", + display_name: "Arabic", + script: "Arab", + direction: TextDirection::Rtl, + fallback: "en", + coverage: LocaleCoverage::PlannedQa, + }, + LocaleSpec { + tag: "hi", + display_name: "Hindi", + script: "Deva", + direction: TextDirection::Ltr, + fallback: "en", + coverage: LocaleCoverage::PlannedQa, + }, + LocaleSpec { + tag: "bn", + display_name: "Bengali", + script: "Beng", + direction: TextDirection::Ltr, + fallback: "en", + coverage: LocaleCoverage::PlannedQa, + }, + LocaleSpec { + tag: "id", + display_name: "Indonesian", + script: "Latin", + direction: TextDirection::Ltr, + fallback: "en", + coverage: LocaleCoverage::PlannedQa, + }, + LocaleSpec { + tag: "vi", + display_name: "Vietnamese", + script: "Latin", + direction: TextDirection::Ltr, + fallback: "en", + coverage: LocaleCoverage::PlannedQa, + }, + LocaleSpec { + tag: "sw", + display_name: "Swahili", + script: "Latin", + direction: TextDirection::Ltr, + fallback: "en", + coverage: LocaleCoverage::PlannedQa, + }, + LocaleSpec { + tag: "ha", + display_name: "Hausa", + script: "Latin", + direction: TextDirection::Ltr, + fallback: "en", + coverage: LocaleCoverage::PlannedQa, + }, + LocaleSpec { + tag: "yo", + display_name: "Yoruba", + script: "Latin", + direction: TextDirection::Ltr, + fallback: "en", + coverage: LocaleCoverage::PlannedQa, + }, + LocaleSpec { + tag: "es-419", + display_name: "Spanish (Latin America)", + script: "Latin", + direction: TextDirection::Ltr, + fallback: "en", + coverage: LocaleCoverage::PlannedQa, + }, + LocaleSpec { + tag: "fr", + display_name: "French", + script: "Latin", + direction: TextDirection::Ltr, + fallback: "en", + coverage: LocaleCoverage::PlannedQa, + }, + LocaleSpec { + tag: "fil", + display_name: "Filipino/Tagalog", + script: "Latin", + direction: TextDirection::Ltr, + fallback: "en", + coverage: LocaleCoverage::PlannedQa, + }, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MessageId { + ComposerPlaceholder, + HistorySearchPlaceholder, + HistorySearchTitle, + HistoryHintMove, + HistoryHintAccept, + HistoryHintRestore, + HistoryNoMatches, + ConfigTitle, + ConfigModalTitle, + ConfigSearchPlaceholder, + ConfigNoSettings, + ConfigNoMatchesPrefix, + ConfigFilteredSettings, + ConfigShowing, + ConfigFooterDefault, + ConfigFooterScrollable, + ConfigFooterFiltered, + HelpTitle, + HelpFilterPlaceholder, + HelpFilterPrefix, + HelpNoMatches, + HelpSlashCommands, + HelpKeybindings, + HelpFooterTypeFilter, + HelpFooterMove, + HelpFooterJump, + HelpFooterClose, +} + +#[allow(dead_code)] +pub const ALL_MESSAGE_IDS: &[MessageId] = &[ + MessageId::ComposerPlaceholder, + MessageId::HistorySearchPlaceholder, + MessageId::HistorySearchTitle, + MessageId::HistoryHintMove, + MessageId::HistoryHintAccept, + MessageId::HistoryHintRestore, + MessageId::HistoryNoMatches, + MessageId::ConfigTitle, + MessageId::ConfigModalTitle, + MessageId::ConfigSearchPlaceholder, + MessageId::ConfigNoSettings, + MessageId::ConfigNoMatchesPrefix, + MessageId::ConfigFilteredSettings, + MessageId::ConfigShowing, + MessageId::ConfigFooterDefault, + MessageId::ConfigFooterScrollable, + MessageId::ConfigFooterFiltered, + MessageId::HelpTitle, + MessageId::HelpFilterPlaceholder, + MessageId::HelpFilterPrefix, + MessageId::HelpNoMatches, + MessageId::HelpSlashCommands, + MessageId::HelpKeybindings, + MessageId::HelpFooterTypeFilter, + MessageId::HelpFooterMove, + MessageId::HelpFooterJump, + MessageId::HelpFooterClose, +]; + +pub fn tr(locale: Locale, id: MessageId) -> &'static str { + fallback_translation(translation(locale, id), id) +} + +#[allow(dead_code)] +pub fn missing_message_ids(locale: Locale) -> Vec { + ALL_MESSAGE_IDS + .iter() + .copied() + .filter(|id| translation(locale, *id).is_none()) + .collect() +} + +pub fn normalize_configured_locale(input: &str) -> Option<&'static str> { + let normalized = normalize_locale_input(input); + if matches!(normalized.as_str(), "" | "auto" | "system") { + return Some("auto"); + } + parse_locale(&normalized).map(Locale::tag) +} + +pub fn resolve_locale(setting: &str) -> Locale { + resolve_locale_with_env(setting, |key| std::env::var(key).ok()) +} + +pub fn resolve_locale_with_env(setting: &str, env: F) -> Locale +where + F: Fn(&str) -> Option, +{ + let normalized = normalize_locale_input(setting); + if !matches!(normalized.as_str(), "" | "auto" | "system") { + return parse_locale(&normalized).unwrap_or(Locale::En); + } + + for key in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Some(value) = env(key) + && let Some(locale) = parse_locale(&normalize_locale_input(&value)) + { + return locale; + } + } + + Locale::En +} + +#[allow(dead_code)] +pub fn truncate_to_width(text: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + if text.width() <= max_width { + return text.to_string(); + } + + let ellipsis_width = '…'.width().unwrap_or(1); + if max_width <= ellipsis_width { + return "…".to_string(); + } + + let limit = max_width - ellipsis_width; + let mut out = String::new(); + let mut width = 0usize; + for ch in text.chars() { + let ch_width = ch.width().unwrap_or(0); + if width + ch_width > limit { + break; + } + out.push(ch); + width += ch_width; + } + out.push('…'); + out +} + +fn normalize_locale_input(input: &str) -> String { + input + .split('.') + .next() + .unwrap_or(input) + .split('@') + .next() + .unwrap_or(input) + .trim() + .replace('_', "-") + .to_lowercase() +} + +fn parse_locale(value: &str) -> Option { + if value == "c" || value == "posix" || value.starts_with("en") { + return Some(Locale::En); + } + if value.starts_with("ja") { + return Some(Locale::Ja); + } + if value.starts_with("zh") { + if value.contains("hant") + || value.contains("-tw") + || value.contains("-hk") + || value.contains("-mo") + { + return None; + } + return Some(Locale::ZhHans); + } + if value.starts_with("pt") || value == "br" { + return Some(Locale::PtBr); + } + None +} + +fn fallback_translation(candidate: Option<&'static str>, id: MessageId) -> &'static str { + candidate.unwrap_or_else(|| english(id)) +} + +fn english(id: MessageId) -> &'static str { + match id { + MessageId::ComposerPlaceholder => "Write a task or use /.", + MessageId::HistorySearchPlaceholder => "Search prompt history...", + MessageId::HistorySearchTitle => "History Search", + MessageId::HistoryHintMove => "Up/Down move", + MessageId::HistoryHintAccept => "Enter accept", + MessageId::HistoryHintRestore => "Esc restore", + MessageId::HistoryNoMatches => " No matches", + MessageId::ConfigTitle => "Session Configuration", + MessageId::ConfigModalTitle => " Config ", + MessageId::ConfigSearchPlaceholder => "type to filter", + MessageId::ConfigNoSettings => " No settings available.", + MessageId::ConfigNoMatchesPrefix => " No settings match ", + MessageId::ConfigFilteredSettings => " Filtered settings", + MessageId::ConfigShowing => " Showing", + MessageId::ConfigFooterDefault => { + " type=filter, Up/Down=select, Enter/e=edit, Esc/q=close " + } + MessageId::ConfigFooterScrollable => { + " type=filter, Up/Down=select, Enter/e=edit, PgUp/PgDn=scroll, Esc/q=close " + } + MessageId::ConfigFooterFiltered => { + " type=filter, Backspace=delete, Ctrl+U/Esc=clear, Enter=edit " + } + MessageId::HelpTitle => "Help", + MessageId::HelpFilterPlaceholder => "Type to filter", + MessageId::HelpFilterPrefix => "Filter: ", + MessageId::HelpNoMatches => " No matches.", + MessageId::HelpSlashCommands => "Slash commands", + MessageId::HelpKeybindings => "Keybindings", + MessageId::HelpFooterTypeFilter => " type to filter ", + MessageId::HelpFooterMove => " Up/Down move ", + MessageId::HelpFooterJump => " PgUp/PgDn jump ", + MessageId::HelpFooterClose => " Esc close ", + } +} + +fn translation(locale: Locale, id: MessageId) -> Option<&'static str> { + match locale { + Locale::En => Some(english(id)), + Locale::Ja => japanese(id), + Locale::ZhHans => chinese_simplified(id), + Locale::PtBr => portuguese_brazil(id), + } +} + +fn japanese(id: MessageId) -> Option<&'static str> { + Some(match id { + MessageId::ComposerPlaceholder => "タスクを書くか / を使う。", + MessageId::HistorySearchPlaceholder => "プロンプト履歴を検索...", + MessageId::HistorySearchTitle => "履歴検索", + MessageId::HistoryHintMove => "Up/Down 移動", + MessageId::HistoryHintAccept => "Enter 確定", + MessageId::HistoryHintRestore => "Esc 復元", + MessageId::HistoryNoMatches => " 一致なし", + MessageId::ConfigTitle => "セッション設定", + MessageId::ConfigModalTitle => " 設定 ", + MessageId::ConfigSearchPlaceholder => "入力して絞り込み", + MessageId::ConfigNoSettings => " 設定がありません。", + MessageId::ConfigNoMatchesPrefix => " 一致する設定なし: ", + MessageId::ConfigFilteredSettings => " 絞り込み後の設定", + MessageId::ConfigShowing => " 表示", + MessageId::ConfigFooterDefault => { + " 入力=絞り込み, Up/Down=選択, Enter/e=編集, Esc/q=閉じる " + } + MessageId::ConfigFooterScrollable => { + " 入力=絞り込み, Up/Down=選択, Enter/e=編集, PgUp/PgDn=スクロール, Esc/q=閉じる " + } + MessageId::ConfigFooterFiltered => { + " 入力=絞り込み, Backspace=削除, Ctrl+U/Esc=クリア, Enter=編集 " + } + MessageId::HelpTitle => "ヘルプ", + MessageId::HelpFilterPlaceholder => "入力して絞り込み", + MessageId::HelpFilterPrefix => "絞り込み: ", + MessageId::HelpNoMatches => " 一致なし。", + MessageId::HelpSlashCommands => "スラッシュコマンド", + MessageId::HelpKeybindings => "キー操作", + MessageId::HelpFooterTypeFilter => " 入力して絞り込み ", + MessageId::HelpFooterMove => " Up/Down 移動 ", + MessageId::HelpFooterJump => " PgUp/PgDn ジャンプ ", + MessageId::HelpFooterClose => " Esc 閉じる ", + }) +} + +fn chinese_simplified(id: MessageId) -> Option<&'static str> { + Some(match id { + MessageId::ComposerPlaceholder => "编写任务或使用 /。", + MessageId::HistorySearchPlaceholder => "搜索提示历史...", + MessageId::HistorySearchTitle => "历史搜索", + MessageId::HistoryHintMove => "Up/Down 移动", + MessageId::HistoryHintAccept => "Enter 接受", + MessageId::HistoryHintRestore => "Esc 还原", + MessageId::HistoryNoMatches => " 无匹配", + MessageId::ConfigTitle => "会话配置", + MessageId::ConfigModalTitle => " 配置 ", + MessageId::ConfigSearchPlaceholder => "输入以筛选", + MessageId::ConfigNoSettings => " 没有可用设置。", + MessageId::ConfigNoMatchesPrefix => " 没有匹配设置: ", + MessageId::ConfigFilteredSettings => " 已筛选设置", + MessageId::ConfigShowing => " 显示", + MessageId::ConfigFooterDefault => " 输入=筛选, Up/Down=选择, Enter/e=编辑, Esc/q=关闭 ", + MessageId::ConfigFooterScrollable => { + " 输入=筛选, Up/Down=选择, Enter/e=编辑, PgUp/PgDn=滚动, Esc/q=关闭 " + } + MessageId::ConfigFooterFiltered => { + " 输入=筛选, Backspace=删除, Ctrl+U/Esc=清除, Enter=编辑 " + } + MessageId::HelpTitle => "帮助", + MessageId::HelpFilterPlaceholder => "输入以筛选", + MessageId::HelpFilterPrefix => "筛选: ", + MessageId::HelpNoMatches => " 无匹配。", + MessageId::HelpSlashCommands => "斜杠命令", + MessageId::HelpKeybindings => "快捷键", + MessageId::HelpFooterTypeFilter => " 输入以筛选 ", + MessageId::HelpFooterMove => " Up/Down 移动 ", + MessageId::HelpFooterJump => " PgUp/PgDn 跳转 ", + MessageId::HelpFooterClose => " Esc 关闭 ", + }) +} + +fn portuguese_brazil(id: MessageId) -> Option<&'static str> { + Some(match id { + MessageId::ComposerPlaceholder => "Escreva uma tarefa ou use /.", + MessageId::HistorySearchPlaceholder => "Pesquisar histórico de prompts...", + MessageId::HistorySearchTitle => "Busca no histórico", + MessageId::HistoryHintMove => "Up/Down move", + MessageId::HistoryHintAccept => "Enter aceita", + MessageId::HistoryHintRestore => "Esc restaura", + MessageId::HistoryNoMatches => " Sem resultados", + MessageId::ConfigTitle => "Configuração da sessão", + MessageId::ConfigModalTitle => " Config ", + MessageId::ConfigSearchPlaceholder => "digite para filtrar", + MessageId::ConfigNoSettings => " Nenhuma configuração disponível.", + MessageId::ConfigNoMatchesPrefix => " Nenhuma configuração corresponde a ", + MessageId::ConfigFilteredSettings => " Configurações filtradas", + MessageId::ConfigShowing => " Mostrando", + MessageId::ConfigFooterDefault => { + " digite=filtrar, Up/Down=selecionar, Enter/e=editar, Esc/q=fechar " + } + MessageId::ConfigFooterScrollable => { + " digite=filtrar, Up/Down=selecionar, Enter/e=editar, PgUp/PgDn=rolar, Esc/q=fechar " + } + MessageId::ConfigFooterFiltered => { + " digite=filtrar, Backspace=apagar, Ctrl+U/Esc=limpar, Enter=editar " + } + MessageId::HelpTitle => "Ajuda", + MessageId::HelpFilterPlaceholder => "Digite para filtrar", + MessageId::HelpFilterPrefix => "Filtro: ", + MessageId::HelpNoMatches => " Sem resultados.", + MessageId::HelpSlashCommands => "Comandos com barra", + MessageId::HelpKeybindings => "Atalhos", + MessageId::HelpFooterTypeFilter => " digite para filtrar ", + MessageId::HelpFooterMove => " Up/Down move ", + MessageId::HelpFooterJump => " PgUp/PgDn salta ", + MessageId::HelpFooterClose => " Esc fecha ", + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{ + buffer::Buffer, + layout::Rect, + widgets::{Paragraph, Widget, Wrap}, + }; + + #[test] + fn locale_setting_normalizes_supported_tags() { + assert_eq!(normalize_configured_locale("auto"), Some("auto")); + assert_eq!(normalize_configured_locale("ja_JP.UTF-8"), Some("ja")); + assert_eq!(normalize_configured_locale("zh-CN"), Some("zh-Hans")); + assert_eq!(normalize_configured_locale("pt"), Some("pt-BR")); + assert_eq!(normalize_configured_locale("pt-PT"), Some("pt-BR")); + assert_eq!(normalize_configured_locale("zh-TW"), None); + } + + #[test] + fn locale_resolution_uses_config_then_environment_then_english() { + assert_eq!( + resolve_locale_with_env("ja", |_| Some("pt_BR.UTF-8".to_string())), + Locale::Ja + ); + assert_eq!( + resolve_locale_with_env("auto", |key| { + (key == "LANG").then(|| "zh_CN.UTF-8".to_string()) + }), + Locale::ZhHans + ); + assert_eq!(resolve_locale_with_env("auto", |_| None), Locale::En); + } + + #[test] + fn shipped_first_pack_has_no_missing_core_messages() { + for locale in Locale::shipped() { + assert!( + missing_message_ids(*locale).is_empty(), + "{} is missing messages", + locale.tag() + ); + } + } + + #[test] + fn unsupported_locale_falls_back_to_english() { + assert_eq!( + resolve_locale_with_env("ar", |_| None), + Locale::En, + "Arabic is planned for QA but not shipped in the v0.7.6 core pack" + ); + } + + #[test] + fn missing_translation_falls_back_to_english() { + assert_eq!( + fallback_translation(None, MessageId::ComposerPlaceholder), + english(MessageId::ComposerPlaceholder) + ); + } + + #[test] + fn width_truncation_handles_cjk_rtl_indic_and_latin_samples() { + let samples = [ + ("zh-Hans", "输入以筛选配置"), + ("ar", "تصفية الإعدادات"), + ("hi", "सेटिंग खोजें"), + ("pt-BR", "configurações filtradas"), + ]; + + for (tag, sample) in samples { + let truncated = truncate_to_width(sample, 12); + assert!( + truncated.width() <= 12, + "{tag} sample overflowed: {truncated:?}" + ); + } + } + + #[test] + fn planned_script_samples_render_in_narrow_terminal_buffer() { + let samples = [ + ("CJK", "输入以筛选配置"), + ("RTL", "تصفية الإعدادات"), + ("Indic", "सेटिंग खोजें"), + ("Latin Global South", "configurações filtradas"), + ]; + + for (label, sample) in samples { + let area = Rect::new(0, 0, 18, 4); + let mut buf = Buffer::empty(area); + Paragraph::new(sample) + .wrap(Wrap { trim: false }) + .render(area, &mut buf); + let dump = buffer_text(&buf, area); + + assert!( + dump.chars().any(|ch| !ch.is_whitespace()), + "{label} sample produced an empty render" + ); + } + } + + fn buffer_text(buf: &Buffer, area: Rect) -> String { + let mut out = String::new(); + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } +} diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 0507b92b..3e7f8c93 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -28,6 +28,7 @@ mod execpolicy; mod features; mod hooks; mod llm_client; +mod localization; mod logging; mod lsp; mod mcp; diff --git a/crates/tui/src/seam_manager.rs b/crates/tui/src/seam_manager.rs index e18e8207..555a1c46 100644 --- a/crates/tui/src/seam_manager.rs +++ b/crates/tui/src/seam_manager.rs @@ -197,11 +197,12 @@ impl SeamManager { // Use compaction pinning heuristics to identify which messages to // exclude from summarization. Pinned messages stay verbatim; the // seam summary covers everything else. + let local_pins = local_pins_for_range(pinned_indices, start_idx, end_idx, messages.len()); let plan = plan_compaction( range, workspace, KEEP_RECENT_MESSAGES.min(range.len().saturating_sub(1)), - Some(pinned_indices), + Some(&local_pins), None, ); @@ -597,6 +598,21 @@ fn truncate_chars(text: &str, max_chars: usize) -> String { text.chars().take(max_chars).collect() } +fn local_pins_for_range( + pinned_indices: &[usize], + start_idx: usize, + end_idx: usize, + message_count: usize, +) -> Vec { + let end_idx = end_idx.min(message_count); + pinned_indices + .iter() + .copied() + .filter(|idx| *idx >= start_idx && *idx < end_idx) + .map(|idx| idx - start_idx) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -664,6 +680,15 @@ mod tests { assert_eq!(truncate_chars("", 5), "".to_string()); } + #[test] + fn global_pins_are_mapped_to_soft_seam_slice_indices() { + let pins = vec![1, 4, 5, 8, 12]; + + let local = local_pins_for_range(&pins, 4, 9, 10); + + assert_eq!(local, vec![0, 1, 4]); + } + #[test] fn disabled_config() { let config = SeamConfig { diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 26956b5d..1fe8709b 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -8,6 +8,7 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use crate::config::{expand_path, normalize_model_name}; +use crate::localization::normalize_configured_locale; /// User settings with defaults #[derive(Debug, Clone, Serialize, Deserialize)] @@ -25,10 +26,15 @@ pub struct Settings { /// terminal mishandles the `\e[?2004h` escape (rare; some legacy /// terminals over SSH+screen multiplex without the cap). pub bracketed_paste: bool, + /// Enable rapid-key paste-burst detection for terminals that do not emit + /// bracketed-paste events. Independent from `bracketed_paste`. + pub paste_burst_detection: bool, /// Show thinking blocks from the model pub show_thinking: bool, /// Show detailed tool output pub show_tool_details: bool, + /// UI locale: auto, en, ja, zh-Hans, pt-BR + pub locale: String, /// Composer layout density: compact, comfortable, spacious pub composer_density: String, /// Show a border around the composer input area @@ -55,8 +61,10 @@ impl Default for Settings { low_motion: false, fancy_animations: false, bracketed_paste: true, + paste_burst_detection: true, show_thinking: true, show_tool_details: true, + locale: "auto".to_string(), composer_density: "comfortable".to_string(), composer_border: true, transcript_spacing: "comfortable".to_string(), @@ -108,6 +116,9 @@ impl Settings { settings.transcript_spacing = normalize_transcript_spacing(&settings.transcript_spacing).to_string(); settings.sidebar_focus = normalize_sidebar_focus(&settings.sidebar_focus).to_string(); + settings.locale = normalize_configured_locale(&settings.locale) + .unwrap_or("en") + .to_string(); settings.default_model = settings .default_model .as_deref() @@ -150,12 +161,23 @@ impl Settings { "bracketed_paste" | "paste" => { self.bracketed_paste = parse_bool(value)?; } + "paste_burst_detection" | "paste_burst" => { + self.paste_burst_detection = parse_bool(value)?; + } "show_thinking" | "thinking" => { self.show_thinking = parse_bool(value)?; } "show_tool_details" | "tool_details" => { self.show_tool_details = parse_bool(value)?; } + "locale" | "language" => { + let Some(locale) = normalize_configured_locale(value) else { + anyhow::bail!( + "Failed to update setting: invalid locale '{value}'. Expected: auto, en, ja, zh-Hans, pt-BR." + ); + }; + self.locale = locale.to_string(); + } "composer_density" | "composer" => { let normalized = normalize_composer_density(value); if !["compact", "comfortable", "spacious"].contains(&normalized) { @@ -260,8 +282,13 @@ impl Settings { lines.push(format!(" low_motion: {}", self.low_motion)); lines.push(format!(" fancy_animations: {}", self.fancy_animations)); lines.push(format!(" bracketed_paste: {}", self.bracketed_paste)); + lines.push(format!( + " paste_burst_detect: {}", + self.paste_burst_detection + )); lines.push(format!(" show_thinking: {}", self.show_thinking)); lines.push(format!(" show_tool_details: {}", self.show_tool_details)); + lines.push(format!(" locale: {}", self.locale)); lines.push(format!(" composer_density: {}", self.composer_density)); lines.push(format!(" composer_border: {}", self.composer_border)); lines.push(format!(" transcript_spacing: {}", self.transcript_spacing)); @@ -302,8 +329,16 @@ impl Settings { "bracketed_paste", "Terminal bracketed-paste mode: on/off (rare to disable)", ), + ( + "paste_burst_detection", + "Fallback rapid-key paste detection: on/off", + ), ("show_thinking", "Show model thinking: on/off"), ("show_tool_details", "Show detailed tool output: on/off"), + ( + "locale", + "UI locale: auto, en, ja, zh-Hans, pt-BR (model output is unchanged)", + ), ( "composer_density", "Composer density: compact, comfortable, spacious", @@ -399,4 +434,38 @@ mod tests { settings.set("auto_compact", "off").expect("disable"); assert!(!settings.auto_compact); } + + #[test] + fn paste_burst_detection_is_configurable_independent_of_bracketed_paste() { + let mut settings = Settings::default(); + assert!(settings.bracketed_paste); + assert!(settings.paste_burst_detection); + + settings + .set("paste_burst_detection", "off") + .expect("disable paste burst fallback"); + assert!(settings.bracketed_paste); + assert!(!settings.paste_burst_detection); + + settings + .set("bracketed_paste", "off") + .expect("disable bracketed paste"); + assert!(!settings.bracketed_paste); + assert!(!settings.paste_burst_detection); + } + + #[test] + fn locale_normalizes_supported_values_and_rejects_unknowns() { + let mut settings = Settings::default(); + settings.set("locale", "ja_JP.UTF-8").expect("set ja"); + assert_eq!(settings.locale, "ja"); + + settings.set("language", "pt-PT").expect("set pt fallback"); + assert_eq!(settings.locale, "pt-BR"); + + let err = settings + .set("locale", "ar") + .expect_err("Arabic is planned, not shipped"); + assert!(err.to_string().contains("invalid locale")); + } } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 784cabf0..b5fba456 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -13,6 +13,7 @@ use crate::config::{ApiProvider, Config, has_api_key, save_api_key}; use crate::core::coherence::CoherenceState; use crate::cycle_manager::{CycleBriefing, CycleConfig}; use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult}; +use crate::localization::{Locale, MessageId, resolve_locale, tr}; use crate::models::{ Message, SystemPrompt, compaction_message_threshold_for_model, compaction_threshold_for_model_and_effort, @@ -237,6 +238,25 @@ impl StatusToast { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComposerHistorySearch { + pre_search_input: String, + pre_search_cursor: usize, + query: String, + selected: usize, +} + +impl ComposerHistorySearch { + fn new(pre_search_input: String, pre_search_cursor: usize) -> Self { + Self { + pre_search_input, + pre_search_cursor, + query: String::new(), + selected: 0, + } + } +} + fn char_count(text: &str) -> usize { text.chars().count() } @@ -275,6 +295,7 @@ fn sanitize_api_key_text(text: &str) -> String { } const MAX_SUBMITTED_INPUT_CHARS: usize = 16_000; +const MAX_DRAFT_HISTORY: usize = 50; impl AppMode { #[must_use] @@ -423,10 +444,14 @@ pub struct App { pub use_alt_screen: bool, pub use_mouse_capture: bool, pub use_bracketed_paste: bool, + pub use_paste_burst_detection: bool, #[allow(dead_code)] pub system_prompt: Option, pub input_history: Vec, + pub draft_history: VecDeque, pub history_index: Option, + pub composer_history_search: Option, + pub selected_attachment_index: Option, pub auto_compact: bool, pub calm_mode: bool, pub low_motion: bool, @@ -436,6 +461,7 @@ pub struct App { pub fancy_animations: bool, pub show_thinking: bool, pub show_tool_details: bool, + pub ui_locale: Locale, pub composer_density: ComposerDensity, pub composer_border: bool, pub transcript_spacing: TranscriptSpacing, @@ -589,11 +615,9 @@ pub struct App { pub queued_messages: VecDeque, /// Draft queued message being edited pub queued_draft: Option, - /// Composer inputs the user steered with Esc during a running turn. Held - /// here until the in-flight turn aborts; then merged into a single fresh - /// turn (#122). Not the same channel as the engine's mid-turn steer - /// (`EngineHandle::steer`) — those flow through `queued_messages`/`Steer` - /// disposition and never abort the current turn. + /// Legacy pending-steer bucket retained for session compatibility. New + /// in-flight input uses Enter for same-turn steering and Tab for queued + /// follow-ups; Esc only cancels the active turn. pub pending_steers: VecDeque, /// Engine-rejected steers (e.g. a tool was already running and couldn't be /// cancelled cleanly). Surfaced in the pending-input preview so the user @@ -601,10 +625,7 @@ pub struct App { /// produces these; the field is scaffolding for a future signalling /// channel and the bucket renders identically when populated. pub rejected_steers: VecDeque, - /// Set when the user pressed Esc with non-empty input. The next - /// `TurnComplete::Interrupted` event drains `pending_steers`, merges them - /// into one user message, and dispatches a fresh turn. Cleared on drain - /// (or whenever the queue empties out). + /// Legacy resend flag for pending steer recovery. pub submit_pending_steers_after_interrupt: bool, /// Start time for current turn pub turn_started_at: Option, @@ -677,13 +698,12 @@ pub struct QueuedMessage { /// How a freshly-typed user input should be sent. /// /// Picked by [`App::decide_submit_disposition`] when the user hits Enter on a -/// non-empty composer. The Esc-to-steer path (typed input + Esc during a -/// running turn) is separate — see [`App::push_pending_steer`]. +/// non-empty composer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SubmitDisposition { - /// Engine idle (or offline mode without a busy turn): send immediately. + /// Engine idle and online: send immediately. Immediate, - /// Engine busy and offline: park on `queued_messages` for end-of-turn drain. + /// Offline mode: park on `queued_messages`. Queue, /// Engine busy and online: forward as a mid-turn steer. Steer, @@ -744,6 +764,10 @@ pub enum ApiKeyError { // === App State === impl App { + pub fn tr(&self, id: MessageId) -> &'static str { + tr(self.ui_locale, id) + } + #[allow(clippy::too_many_lines)] pub fn new(options: TuiOptions, config: &Config) -> Self { let TuiOptions { @@ -775,12 +799,14 @@ impl App { let fancy_animations = settings.fancy_animations; let show_thinking = settings.show_thinking; let show_tool_details = settings.show_tool_details; + let ui_locale = resolve_locale(&settings.locale); let composer_density = ComposerDensity::from_setting(&settings.composer_density); let composer_border = settings.composer_border; let transcript_spacing = TranscriptSpacing::from_setting(&settings.transcript_spacing); let sidebar_width_percent = settings.sidebar_width_percent; let sidebar_focus = SidebarFocus::from_setting(&settings.sidebar_focus); let max_input_history = settings.max_input_history; + let use_paste_burst_detection = settings.paste_burst_detection; let ui_theme = palette::UI_THEME; let model = settings.default_model.clone().unwrap_or(model); let compact_threshold = @@ -865,15 +891,20 @@ impl App { use_alt_screen, use_mouse_capture, use_bracketed_paste, + use_paste_burst_detection, system_prompt: None, input_history: Vec::new(), + draft_history: VecDeque::new(), history_index: None, + composer_history_search: None, + selected_attachment_index: None, auto_compact, calm_mode, low_motion, fancy_animations, show_thinking, show_tool_details, + ui_locale, composer_density, composer_border, transcript_spacing, @@ -1771,6 +1802,7 @@ impl App { if text.is_empty() { return; } + self.selected_attachment_index = None; let cursor = self.cursor_position.min(char_count(&self.input)); let byte_index = byte_index_at_char(&self.input, cursor); self.input.insert_str(byte_index, text); @@ -1782,6 +1814,9 @@ impl App { } pub fn insert_paste_text(&mut self, text: &str) { + if let Some(pending) = self.paste_burst.flush_before_modified_input() { + self.insert_str(&pending); + } let normalized = normalize_paste_text(text); if !normalized.is_empty() { self.insert_str(&normalized); @@ -1814,6 +1849,98 @@ impl App { self.paste_burst.clear_after_explicit_paste(); } + pub fn composer_attachment_count(&self) -> usize { + crate::tui::file_mention::media_attachment_references(&self.input).len() + } + + pub fn selected_composer_attachment_index(&self) -> Option { + let count = self.composer_attachment_count(); + self.selected_attachment_index + .filter(|index| *index < count) + } + + pub fn select_previous_composer_attachment(&mut self) -> bool { + let count = self.composer_attachment_count(); + if count == 0 { + self.selected_attachment_index = None; + return false; + } + + let next = self + .selected_composer_attachment_index() + .map_or(count.saturating_sub(1), |index| index.saturating_sub(1)); + self.selected_attachment_index = Some(next); + self.cursor_position = 0; + self.status_message = Some("Attachment selected - Backspace/Delete removes it".to_string()); + self.needs_redraw = true; + true + } + + pub fn select_next_composer_attachment(&mut self) -> bool { + let count = self.composer_attachment_count(); + let Some(index) = self.selected_composer_attachment_index() else { + return false; + }; + if index + 1 < count { + self.selected_attachment_index = Some(index + 1); + self.status_message = + Some("Attachment selected - Backspace/Delete removes it".to_string()); + } else { + self.selected_attachment_index = None; + self.status_message = Some("Composer focused".to_string()); + } + self.needs_redraw = true; + true + } + + pub fn clear_composer_attachment_selection(&mut self) -> bool { + if self.selected_attachment_index.take().is_some() { + self.status_message = Some("Composer focused".to_string()); + self.needs_redraw = true; + true + } else { + false + } + } + + pub fn remove_selected_composer_attachment(&mut self) -> bool { + let references = crate::tui::file_mention::media_attachment_references(&self.input); + let Some(index) = self + .selected_composer_attachment_index() + .filter(|index| *index < references.len()) + else { + self.selected_attachment_index = None; + return false; + }; + let reference = references[index].clone(); + let cursor_byte = byte_index_at_char(&self.input, self.cursor_position); + let new_cursor_byte = if cursor_byte <= reference.start_byte { + cursor_byte + } else if cursor_byte >= reference.end_byte { + cursor_byte.saturating_sub(reference.end_byte - reference.start_byte) + } else { + reference.start_byte + }; + + self.input + .replace_range(reference.start_byte..reference.end_byte, ""); + self.cursor_position = self.input[..new_cursor_byte.min(self.input.len())] + .chars() + .count(); + let remaining = self.composer_attachment_count(); + self.selected_attachment_index = if remaining == 0 { + None + } else { + Some(index.min(remaining.saturating_sub(1))) + }; + self.slash_menu_hidden = false; + self.mention_menu_hidden = false; + self.mention_menu_selected = 0; + self.status_message = Some(format!("Removed attachment: {}", reference.path)); + self.needs_redraw = true; + true + } + pub fn flush_paste_burst_if_due(&mut self, now: Instant) -> bool { match self.paste_burst.flush_if_due(now) { FlushResult::Paste(text) => { @@ -1828,6 +1955,26 @@ impl App { } } + pub fn flush_paste_burst_if_enabled(&mut self, now: Instant) -> bool { + self.use_paste_burst_detection && self.flush_paste_burst_if_due(now) + } + + pub fn paste_burst_next_flush_delay_if_enabled(&self, now: Instant) -> Option { + if self.use_paste_burst_detection { + self.paste_burst.next_flush_delay(now) + } else { + None + } + } + + pub fn flush_paste_burst_before_modified_input_if_enabled(&mut self) -> Option { + if self.use_paste_burst_detection { + self.paste_burst.flush_before_modified_input() + } else { + None + } + } + pub fn insert_api_key_char(&mut self, c: char) { let cursor = self.api_key_cursor.min(char_count(&self.api_key_input)); let byte_index = byte_index_at_char(&self.api_key_input, cursor); @@ -1859,23 +2006,19 @@ impl App { /// Paste from clipboard into input pub fn paste_from_clipboard(&mut self) { if let Some(content) = self.clipboard.read(self.workspace.as_path()) { - if let Some(pending) = self.paste_burst.flush_before_modified_input() { - self.insert_str(&pending); + self.apply_clipboard_content(content); + } + } + + pub fn apply_clipboard_content(&mut self, content: ClipboardContent) { + match content { + ClipboardContent::Text(text) => { + self.insert_paste_text(&text); } - match content { - ClipboardContent::Text(text) => { - self.insert_paste_text(&text); - } - ClipboardContent::Image(pasted) => { - let description = format!("{} ({})", pasted.short_label(), pasted.size_label()); - self.insert_media_attachment("image", &pasted.path, Some(&description)); - self.status_message = Some(format!( - "Pasted {} image ({}) -> {}", - pasted.short_label(), - pasted.size_label(), - pasted.path.display() - )); - } + ClipboardContent::Image(pasted) => { + let description = format!("{} ({})", pasted.short_label(), pasted.size_label()); + self.insert_media_attachment("image", &pasted.path, Some(&description)); + self.status_message = Some(format!("Attached image: {description}")); } } } @@ -1913,6 +2056,7 @@ impl App { } pub fn insert_char(&mut self, c: char) { + self.selected_attachment_index = None; let cursor = self.cursor_position.min(char_count(&self.input)); let byte_index = byte_index_at_char(&self.input, cursor); self.input.insert(byte_index, c); @@ -1924,6 +2068,7 @@ impl App { } pub fn delete_char(&mut self) { + self.selected_attachment_index = None; if self.cursor_position == 0 { return; } @@ -1939,6 +2084,7 @@ impl App { } pub fn delete_char_forward(&mut self) { + self.selected_attachment_index = None; if self.input.is_empty() { return; } @@ -2042,12 +2188,205 @@ impl App { pub fn clear_input(&mut self) { self.input.clear(); self.cursor_position = 0; + self.selected_attachment_index = None; self.slash_menu_selected = 0; self.slash_menu_hidden = false; self.paste_burst.clear_after_explicit_paste(); self.needs_redraw = true; } + pub fn clear_input_recoverable(&mut self) { + self.stash_current_input_for_recovery(); + self.clear_input(); + } + + pub fn stash_current_input_for_recovery(&mut self) { + let draft = self.input.clone(); + self.remember_draft_for_recovery(draft); + } + + fn remember_draft_for_recovery(&mut self, draft: String) { + if draft.trim().is_empty() { + return; + } + self.draft_history.retain(|existing| existing != &draft); + self.draft_history.push_back(draft); + while self.draft_history.len() > MAX_DRAFT_HISTORY { + let _ = self.draft_history.pop_front(); + } + } + + pub fn start_history_search(&mut self) { + if self.composer_history_search.is_some() { + return; + } + self.composer_history_search = Some(ComposerHistorySearch::new( + self.input.clone(), + self.cursor_position, + )); + self.slash_menu_hidden = true; + self.mention_menu_hidden = true; + self.paste_burst.clear_after_explicit_paste(); + self.status_message = Some("History search: type to filter, Enter accepts".to_string()); + self.needs_redraw = true; + } + + pub fn is_history_search_active(&self) -> bool { + self.composer_history_search.is_some() + } + + pub fn history_search_query(&self) -> Option<&str> { + self.composer_history_search + .as_ref() + .map(|search| search.query.as_str()) + } + + pub fn history_search_selected_index(&self) -> usize { + self.composer_history_search + .as_ref() + .map_or(0, |search| search.selected) + } + + pub fn composer_display_input(&self) -> &str { + self.history_search_query().unwrap_or(&self.input) + } + + pub fn composer_display_cursor(&self) -> usize { + self.composer_history_search + .as_ref() + .map_or(self.cursor_position, |search| char_count(&search.query)) + } + + pub fn history_search_matches(&self) -> Vec { + let Some(query) = self.history_search_query() else { + return Vec::new(); + }; + self.history_search_matches_for_query(query) + } + + fn history_search_matches_for_query(&self, query: &str) -> Vec { + let normalized_query = query.trim().to_lowercase(); + let mut seen: HashSet<&str> = HashSet::new(); + let mut matches = Vec::new(); + + for candidate in self + .draft_history + .iter() + .rev() + .chain(self.input_history.iter().rev()) + { + if candidate.trim().is_empty() || !seen.insert(candidate.as_str()) { + continue; + } + if normalized_query.is_empty() || candidate.to_lowercase().contains(&normalized_query) { + matches.push(candidate.clone()); + } + } + + matches + } + + fn clamp_history_search_selection(&mut self) { + let Some(search) = self.composer_history_search.as_ref() else { + return; + }; + let selected = search.selected; + let query = search.query.clone(); + let match_count = self.history_search_matches_for_query(&query).len(); + if let Some(search) = self.composer_history_search.as_mut() { + search.selected = if match_count == 0 { + 0 + } else { + selected.min(match_count.saturating_sub(1)) + }; + } + } + + pub fn history_search_insert_char(&mut self, ch: char) { + if let Some(search) = self.composer_history_search.as_mut() { + search.query.push(ch); + search.selected = 0; + self.status_message = Some("History search: Enter accepts, Esc restores".to_string()); + self.needs_redraw = true; + } + } + + pub fn history_search_insert_str(&mut self, text: &str) { + if text.is_empty() { + return; + } + if let Some(search) = self.composer_history_search.as_mut() { + search.query.push_str(&normalize_paste_text(text)); + search.selected = 0; + self.status_message = Some("History search: Enter accepts, Esc restores".to_string()); + self.needs_redraw = true; + } + } + + pub fn history_search_backspace(&mut self) { + if let Some(search) = self.composer_history_search.as_mut() { + search.query.pop(); + search.selected = 0; + self.needs_redraw = true; + } + self.clamp_history_search_selection(); + } + + pub fn history_search_select_previous(&mut self) { + if let Some(search) = self.composer_history_search.as_mut() { + search.selected = search.selected.saturating_sub(1); + self.needs_redraw = true; + } + } + + pub fn history_search_select_next(&mut self) { + let Some(search) = self.composer_history_search.as_ref() else { + return; + }; + let query = search.query.clone(); + let selected = search.selected; + let match_count = self.history_search_matches_for_query(&query).len(); + if let Some(search) = self.composer_history_search.as_mut() + && match_count > 0 + { + search.selected = (selected + 1).min(match_count.saturating_sub(1)); + self.needs_redraw = true; + } + } + + pub fn accept_history_search(&mut self) -> bool { + let Some(search) = self.composer_history_search.take() else { + return false; + }; + let matches = self.history_search_matches_for_query(&search.query); + if let Some(selected) = matches + .get(search.selected.min(matches.len().saturating_sub(1))) + .cloned() + { + self.input = selected; + self.cursor_position = char_count(&self.input); + self.history_index = None; + self.status_message = Some("History match inserted into composer".to_string()); + self.needs_redraw = true; + true + } else { + self.composer_history_search = Some(search); + self.status_message = Some("No history matches".to_string()); + self.needs_redraw = true; + false + } + } + + pub fn cancel_history_search(&mut self) { + let Some(search) = self.composer_history_search.take() else { + return; + }; + self.input = search.pre_search_input; + self.cursor_position = search.pre_search_cursor.min(char_count(&self.input)); + self.status_message = Some("History search canceled".to_string()); + self.needs_redraw = true; + } + pub fn submit_input(&mut self) -> Option { if self.input.trim().is_empty() { self.paste_burst.clear_after_explicit_paste(); @@ -2108,13 +2447,15 @@ impl App { }; self.input = msg.display.clone(); self.cursor_position = char_count(&self.input); + self.selected_attachment_index = None; self.queued_draft = Some(msg); self.needs_redraw = true; true } - /// Park a composer input the user steered with Esc. Re-armed each call so - /// rapid Esc taps accumulate rather than overwriting each other. + /// Park a legacy pending steer. New keyboard handling routes running-turn + /// drafts through Enter (same-turn steer) or Tab (next-turn follow-up). + #[allow(dead_code)] pub fn push_pending_steer(&mut self, message: QueuedMessage) { self.pending_steers.push_back(message); self.submit_pending_steers_after_interrupt = true; @@ -2132,21 +2473,19 @@ impl App { self.pending_steers.drain(..).collect() } - /// Decide how to route a fresh composer submit. Esc-to-steer goes through - /// [`Self::push_pending_steer`] instead; this is the Enter path. + /// Decide how to route a fresh composer submit. /// - /// Truth table (preserves the pre-refactor behaviour): + /// Truth table: /// offline=F, busy=F → Immediate /// offline=F, busy=T → Steer /// offline=T, busy=F → Queue - /// offline=T, busy=T → Steer (in-flight turn still owns the wire; the - /// steer attempt falls back to queueing on send failure) + /// offline=T, busy=T → Queue #[must_use] pub fn decide_submit_disposition(&self) -> SubmitDisposition { - if self.is_loading { - SubmitDisposition::Steer - } else if self.offline_mode { + if self.offline_mode { SubmitDisposition::Queue + } else if self.is_loading { + SubmitDisposition::Steer } else { SubmitDisposition::Immediate } @@ -2186,6 +2525,7 @@ impl App { self.history_index = Some(new_index); self.input = self.input_history[new_index].clone(); self.cursor_position = char_count(&self.input); + self.selected_attachment_index = None; self.slash_menu_hidden = false; self.paste_burst.clear_after_explicit_paste(); } @@ -2201,6 +2541,7 @@ impl App { self.history_index = Some(i + 1); self.input = self.input_history[i + 1].clone(); self.cursor_position = char_count(&self.input); + self.selected_attachment_index = None; self.slash_menu_hidden = false; self.paste_burst.clear_after_explicit_paste(); } else { @@ -2375,6 +2716,7 @@ mod tests { use super::*; use crate::config::Config; use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs}; + use crate::tui::clipboard::PastedImage; fn test_options(yolo: bool) -> TuiOptions { TuiOptions { @@ -2643,6 +2985,199 @@ mod tests { app.history_down(); } + #[test] + fn history_search_filters_matches_and_skips_duplicates() { + let mut app = App::new(test_options(false), &Config::default()); + app.input_history.push("alpha one".to_string()); + app.input_history.push("beta two".to_string()); + app.input_history.push("alpha one".to_string()); + app.draft_history.push_back("draft alpha".to_string()); + + app.start_history_search(); + app.history_search_insert_str("alpha"); + + assert_eq!( + app.history_search_matches(), + vec!["draft alpha".to_string(), "alpha one".to_string()] + ); + } + + #[test] + fn history_search_matches_unicode_case_insensitively() { + let mut app = App::new(test_options(false), &Config::default()); + app.input_history.push("CAFÉ prompt".to_string()); + + app.start_history_search(); + app.history_search_insert_str("café"); + + assert_eq!( + app.history_search_matches(), + vec!["CAFÉ prompt".to_string()] + ); + } + + #[test] + fn history_search_accepts_match_without_submitting() { + let mut app = App::new(test_options(false), &Config::default()); + app.input_history.push("older prompt".to_string()); + + app.start_history_search(); + app.history_search_insert_str("older"); + + assert!(app.accept_history_search()); + assert_eq!(app.input, "older prompt"); + assert_eq!(app.cursor_position, "older prompt".chars().count()); + assert!(app.composer_history_search.is_none()); + } + + #[test] + fn history_search_cancel_restores_pre_search_draft() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "current draft".to_string(); + app.cursor_position = 7; + app.input_history.push("older prompt".to_string()); + + app.start_history_search(); + app.history_search_insert_str("older"); + app.cancel_history_search(); + + assert_eq!(app.input, "current draft"); + assert_eq!(app.cursor_position, 7); + assert!(app.composer_history_search.is_none()); + } + + #[test] + fn recoverable_clear_stashes_nonempty_draft() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "recover this".to_string(); + app.cursor_position = app.input.chars().count(); + + app.clear_input_recoverable(); + app.start_history_search(); + app.history_search_insert_str("recover"); + + assert_eq!( + app.history_search_matches(), + vec!["recover this".to_string()] + ); + } + + #[test] + fn composer_paste_flushes_pending_burst_and_normalizes_crlf() { + let mut app = App::new(test_options(false), &Config::default()); + app.use_paste_burst_detection = true; + let now = Instant::now(); + let key = crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('x'), + crossterm::event::KeyModifiers::NONE, + ); + + assert!(crate::tui::paste::handle_paste_burst_key( + &mut app, &key, now + )); + assert!( + app.input.is_empty(), + "first burst char should stay buffered" + ); + + app.insert_paste_text("a\r\nb\rc"); + + assert_eq!(app.input, "xa\nbc"); + assert_eq!(app.cursor_position, "xa\nbc".chars().count()); + assert!(!app.paste_burst.is_active()); + } + + #[test] + fn clipboard_text_paste_matches_bracketed_paste_state() { + let text = "alpha\r\nbeta"; + let mut bracketed = App::new(test_options(false), &Config::default()); + let mut clipboard = App::new(test_options(false), &Config::default()); + + bracketed.insert_paste_text(text); + clipboard.apply_clipboard_content(ClipboardContent::Text(text.to_string())); + + assert_eq!(clipboard.input, bracketed.input); + assert_eq!(clipboard.cursor_position, bracketed.cursor_position); + assert_eq!(clipboard.slash_menu_hidden, bracketed.slash_menu_hidden); + assert_eq!(clipboard.mention_menu_hidden, bracketed.mention_menu_hidden); + } + + #[test] + fn clipboard_image_paste_keeps_adjacent_text_and_concise_status() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "before after".to_string(); + app.cursor_position = "before".chars().count(); + + app.apply_clipboard_content(ClipboardContent::Image(PastedImage { + path: PathBuf::from("/tmp/pasted.png"), + width: 8, + height: 4, + byte_len: 2048, + })); + + assert!( + app.input + .contains("before\n[Attached image: 8x4 PNG (2KB) at /tmp/pasted.png]") + ); + assert!(app.input.contains("] after")); + let status = app.status_message.as_deref().expect("status message"); + assert_eq!(status, "Attached image: 8x4 PNG (2KB)"); + } + + #[test] + fn pasted_text_and_image_placeholders_survive_history_and_queue_paths() { + let mut app = App::new(test_options(false), &Config::default()); + app.insert_paste_text("line 1\r\nline 2"); + app.insert_media_attachment("image", Path::new("/tmp/pasted.png"), Some("8x4 PNG (2KB)")); + + let submitted = app.submit_input().expect("submitted input"); + assert!(submitted.contains("line 1\nline 2")); + assert!(submitted.contains("[Attached image: 8x4 PNG (2KB) at /tmp/pasted.png]")); + + app.history_up(); + assert_eq!(app.input, submitted); + assert_eq!(app.composer_attachment_count(), 1); + + app.clear_input(); + app.queue_message(QueuedMessage::new( + submitted.clone(), + Some("Use this skill".to_string()), + )); + assert!(app.pop_last_queued_into_draft()); + assert_eq!(app.input, submitted); + assert_eq!(app.composer_attachment_count(), 1); + assert_eq!( + app.queued_draft + .as_ref() + .and_then(|draft| draft.skill_instruction.as_deref()), + Some("Use this skill") + ); + + app.push_pending_steer(QueuedMessage::new(submitted.clone(), None)); + let steers = app.drain_pending_steers(); + assert_eq!(steers[0].display, submitted); + } + + #[test] + fn selected_attachment_row_removes_placeholder_without_manual_editing() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "before".to_string(); + app.cursor_position = "before".chars().count(); + app.insert_media_attachment("image", Path::new("/tmp/pasted.png"), Some("8x4 PNG")); + app.insert_str("after"); + + app.move_cursor_start(); + assert!(app.select_previous_composer_attachment()); + assert_eq!(app.selected_composer_attachment_index(), Some(0)); + assert!(app.remove_selected_composer_attachment()); + + assert!(!app.input.contains("[Attached image:")); + assert!(app.input.contains("before")); + assert!(app.input.contains("after")); + assert_eq!(app.composer_attachment_count(), 0); + assert!(app.selected_composer_attachment_index().is_none()); + } + #[test] fn kill_to_end_of_line_cuts_from_middle_of_word() { let mut app = App::new(test_options(false), &Config::default()); @@ -2780,7 +3315,7 @@ mod tests { assert!(deadline > Instant::now(), "fresh deadline in the future"); } - // ---- Issue #122: Esc-to-steer + queue visibility ---- + // ---- Issue #208: in-flight input routing ---- #[test] fn submit_disposition_immediate_when_idle_and_online() { @@ -2810,13 +3345,11 @@ mod tests { } #[test] - fn submit_disposition_offline_busy_still_steers() { - // In-flight turn owns the wire even in offline mode; steer attempt - // catches the send error and falls back to the queue. + fn submit_disposition_offline_busy_queues() { let mut app = App::new(test_options(false), &Config::default()); app.is_loading = true; app.offline_mode = true; - assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Steer); + assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue); } #[test] diff --git a/crates/tui/src/tui/file_mention.rs b/crates/tui/src/tui/file_mention.rs index b04f7d62..540920e6 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -51,6 +51,7 @@ pub struct FileMentionPreview { pub label: String, pub detail: Option, pub included: bool, + pub removable: bool, } /// Durable, compact metadata for a user-visible context reference. @@ -338,6 +339,7 @@ pub fn pending_context_previews( label: reference.label, detail: reference.detail, included: reference.included, + removable: reference.source == ContextReferenceSource::Attachment, }) .collect() } @@ -477,15 +479,21 @@ fn context_reference_for_mention( } } -#[derive(Debug, Clone)] -struct MediaAttachmentReference { - kind: String, - path: String, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MediaAttachmentReference { + pub kind: String, + pub path: String, + pub start_byte: usize, + pub end_byte: usize, } -fn extract_media_attachment_references(input: &str) -> Vec { +pub fn media_attachment_references(input: &str) -> Vec { let mut out = Vec::new(); - for line in input.lines() { + let mut offset = 0usize; + for line in input.split_inclusive('\n') { + let start_byte = offset; + let end_byte = offset + line.len(); + offset = end_byte; let trimmed = line.trim(); let Some(body) = trimmed .strip_prefix("[Attached ") @@ -504,12 +512,18 @@ fn extract_media_attachment_references(input: &str) -> Vec Vec { + media_attachment_references(input) +} + fn local_context_from_file_mentions( input: &str, workspace: &Path, @@ -919,6 +933,22 @@ mod tests { ); } + #[test] + fn media_attachment_references_include_removable_line_ranges() { + let input = "before\n[Attached image: 8x4 PNG at /tmp/pasted.png]\nafter"; + + let references = media_attachment_references(input); + + assert_eq!(references.len(), 1); + let reference = &references[0]; + assert_eq!(reference.kind, "image"); + assert_eq!(reference.path, "/tmp/pasted.png"); + assert_eq!( + &input[reference.start_byte..reference.end_byte], + "[Attached image: 8x4 PNG at /tmp/pasted.png]\n" + ); + } + #[test] fn context_references_preserve_exact_targets_and_roundtrip() { let tmp = TempDir::new().expect("tempdir"); diff --git a/crates/tui/src/tui/keybindings.rs b/crates/tui/src/tui/keybindings.rs index 124c2357..7e0c019a 100644 --- a/crates/tui/src/tui/keybindings.rs +++ b/crates/tui/src/tui/keybindings.rs @@ -78,7 +78,7 @@ pub const KEYBINDINGS: &[KeybindingEntry] = &[ // --- Navigation --- KeybindingEntry { chord: "↑ / ↓", - description: "Scroll transcript or navigate input history", + description: "Scroll transcript, navigate input history, or select composer attachments", section: KeybindingSection::Navigation, }, KeybindingEntry { @@ -124,7 +124,7 @@ pub const KEYBINDINGS: &[KeybindingEntry] = &[ }, KeybindingEntry { chord: "Backspace / Delete", - description: "Delete character before / after the cursor", + description: "Delete character before / after the cursor, or remove selected attachment", section: KeybindingSection::Editing, }, KeybindingEntry { @@ -132,6 +132,11 @@ pub const KEYBINDINGS: &[KeybindingEntry] = &[ description: "Clear the current draft", section: KeybindingSection::Editing, }, + KeybindingEntry { + chord: "Alt+R", + description: "Search prompt history and recover local drafts", + section: KeybindingSection::Editing, + }, KeybindingEntry { chord: "Ctrl+J / Alt+Enter", description: "Insert a newline in the composer", @@ -206,7 +211,7 @@ pub const KEYBINDINGS: &[KeybindingEntry] = &[ // --- Modes --- KeybindingEntry { chord: "Tab / Shift+Tab", - description: "Complete /command or cycle modes (Shift+Tab cycles reasoning effort)", + description: "Complete /command, queue running-turn follow-up, cycle modes; Shift+Tab cycles reasoning effort", section: KeybindingSection::Modes, }, KeybindingEntry { diff --git a/crates/tui/src/tui/paste.rs b/crates/tui/src/tui/paste.rs index d8004c96..e81e56a4 100644 --- a/crates/tui/src/tui/paste.rs +++ b/crates/tui/src/tui/paste.rs @@ -17,6 +17,10 @@ use super::paste_burst::CharDecision; /// further input handling); `false` when the key still needs the normal /// composer path. pub fn handle_paste_burst_key(app: &mut App, key: &KeyEvent, now: Instant) -> bool { + if !app.use_paste_burst_detection { + return false; + } + let has_ctrl_alt_or_super = key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::ALT) || key.modifiers.contains(KeyModifiers::SUPER); @@ -110,3 +114,103 @@ fn apply_paste_burst_retro_capture( fn in_command_context(app: &App) -> bool { app.input.starts_with('/') } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::TuiOptions; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use std::path::PathBuf; + use std::time::{Duration, Instant}; + + fn test_app() -> App { + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: PathBuf::from("."), + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: PathBuf::from("."), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + }; + let mut app = App::new(options, &Config::default()); + app.use_paste_burst_detection = true; + app + } + + fn plain(ch: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE) + } + + #[test] + fn raw_multiline_paste_buffers_enter_instead_of_submitting() { + let mut app = test_app(); + let t0 = Instant::now(); + + assert!(handle_paste_burst_key(&mut app, &plain('a'), t0)); + assert!(handle_paste_burst_key( + &mut app, + &plain('b'), + t0 + Duration::from_millis(1) + )); + assert!(handle_paste_burst_key( + &mut app, + &plain('c'), + t0 + Duration::from_millis(2) + )); + assert!(handle_paste_burst_key( + &mut app, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + t0 + Duration::from_millis(3) + )); + + assert!(app.input.is_empty(), "paste remains buffered until idle"); + assert!(app.flush_paste_burst_if_due( + t0 + Duration::from_millis(3) + + crate::tui::paste_burst::PasteBurst::recommended_active_flush_delay() + )); + assert_eq!(app.input, "abc\n"); + } + + #[test] + fn paste_buffered_question_mark_does_not_fall_through_to_help_shortcut() { + let mut app = test_app(); + let t0 = Instant::now(); + + assert!(handle_paste_burst_key(&mut app, &plain('?'), t0)); + + assert!(app.input.is_empty(), "shortcut char stays buffered first"); + assert!(app.view_stack.is_empty(), "help modal must not open"); + assert!(app.flush_paste_burst_if_due( + t0 + crate::tui::paste_burst::PasteBurst::recommended_flush_delay() + )); + assert_eq!(app.input, "?"); + } + + #[test] + fn paste_burst_detection_can_be_disabled_without_disabling_bracketed_paste() { + let mut app = test_app(); + app.use_paste_burst_detection = false; + + assert!(!handle_paste_burst_key( + &mut app, + &plain('a'), + Instant::now() + )); + assert!(app.input.is_empty()); + + app.insert_paste_text("line 1\r\nline 2"); + assert_eq!(app.input, "line 1\nline 2"); + assert!(app.use_bracketed_paste); + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 9f462bc2..21c1635d 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -750,9 +750,9 @@ async fn run_event_loop( } app.plan_tool_used_in_turn = false; - // Esc-to-steer (#122): the user interrupted with input - // pending. Merge every steered message into one fresh - // turn so the model sees a single coherent prompt. + // Legacy pending-steer recovery. Current keyboard + // handling keeps Esc as cancel-only, but older saved + // state may still carry pending steers. if status == crate::core::events::TurnOutcomeStatus::Interrupted && app.submit_pending_steers_after_interrupt { @@ -1098,7 +1098,7 @@ async fn run_event_loop( } let now = Instant::now(); - app.flush_paste_burst_if_due(now); + app.flush_paste_burst_if_enabled(now); app.sync_status_message_to_toasts(); // Expire the "Press Ctrl+C again to quit" prompt silently after its // window. Triggers a redraw if the prompt was visible. @@ -1134,7 +1134,7 @@ async fn run_event_loop( } else { Duration::from_millis(idle_poll_ms(app)) }; - if let Some(until_flush) = app.paste_burst.next_flush_delay(now) { + if let Some(until_flush) = app.paste_burst_next_flush_delay_if_enabled(now) { poll_timeout = poll_timeout.min(until_flush); } if let Some(until_draw) = draw_wait { @@ -1161,11 +1161,10 @@ async fn run_event_loop( // Paste into API key input app.insert_api_key_str(text); sync_api_key_validation_status(app, false); + } else if app.is_history_search_active() { + app.history_search_insert_str(text); } else { // Paste into main input - if let Some(pending) = app.paste_burst.flush_before_modified_input() { - app.insert_str(&pending); - } app.insert_paste_text(text); } continue; @@ -1343,7 +1342,7 @@ async fn run_event_loop( if app.view_stack.top_kind() == Some(ModalKind::Help) { app.view_stack.pop(); } else { - app.view_stack.push(HelpView::new()); + app.view_stack.push(HelpView::new_for_locale(app.ui_locale)); } continue; } @@ -1352,7 +1351,7 @@ async fn run_event_loop( if app.view_stack.top_kind() == Some(ModalKind::Help) { app.view_stack.pop(); } else { - app.view_stack.push(HelpView::new()); + app.view_stack.push(HelpView::new_for_locale(app.ui_locale)); } continue; } @@ -1407,8 +1406,22 @@ async fn run_event_loop( continue; } + if app.is_history_search_active() { + handle_history_search_key(app, key); + continue; + } + + if matches!(key.code, KeyCode::Char('r') | KeyCode::Char('R')) + && key.modifiers.contains(KeyModifiers::ALT) + && !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::SUPER) + { + app.start_history_search(); + continue; + } + let now = Instant::now(); - app.flush_paste_burst_if_due(now); + app.flush_paste_burst_if_enabled(now); // On Windows, AltGr is delivered as `Ctrl+Alt`; treat // AltGr-typed chars (e.g. European layouts producing `@`, `\`, @@ -1421,7 +1434,7 @@ async fn run_event_loop( if !is_plain_char && !is_enter - && let Some(pending) = app.paste_burst.flush_before_modified_input() + && let Some(pending) = app.flush_paste_burst_before_modified_input_if_enabled() { app.insert_str(&pending); } @@ -1599,6 +1612,9 @@ async fn run_event_loop( let _ = engine_handle.send(Op::Shutdown).await; return Ok(()); } + KeyCode::Esc if app.clear_composer_attachment_selection() => { + continue; + } KeyCode::Esc if mention_menu_open => { app.mention_menu_hidden = true; app.mention_menu_selected = 0; @@ -1623,24 +1639,6 @@ async fn run_event_loop( app.finalize_streaming_assistant_as_interrupted(); app.status_message = Some("Request cancelled".to_string()); } - EscapeAction::SteerAndAbort => { - app.backtrack.reset(); - if let Some(input) = app.submit_input() { - let queued = build_queued_message(app, input); - app.push_pending_steer(queued); - engine_handle.cancel(); - app.is_loading = false; - app.streaming_state.reset(); - app.runtime_turn_status = None; - app.finalize_streaming_assistant_as_interrupted(); - let count = app.pending_steers.len(); - app.status_message = Some(if count == 1 { - "Steering: aborting turn and resending input".to_string() - } else { - format!("Steering: aborting turn and resending {count} input(s)") - }); - } - } EscapeAction::DiscardQueuedDraft => { app.backtrack.reset(); app.queued_draft = None; @@ -1648,7 +1646,7 @@ async fn run_event_loop( } EscapeAction::ClearInput => { app.backtrack.reset(); - app.clear_input(); + app.clear_input_recoverable(); } EscapeAction::Noop => { // Nothing else cares about this Esc — route it @@ -1709,6 +1707,22 @@ async fn run_event_loop( { app.slash_menu_selected = app.slash_menu_selected.saturating_sub(1); } + KeyCode::Up + if key.modifiers.is_empty() + && app.selected_composer_attachment_index().is_some() => + { + let _ = app.select_previous_composer_attachment(); + } + KeyCode::Up + if key.modifiers.is_empty() + && app.cursor_position == 0 + && !mention_menu_open + && !slash_menu_open + && app.composer_attachment_count() > 0 => + { + let _ = app.select_previous_composer_attachment(); + continue; + } KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => { app.scroll_down(3); } @@ -1720,6 +1734,12 @@ async fn run_event_loop( app.slash_menu_selected = (app.slash_menu_selected + 1) .min(slash_menu_entries.len().saturating_sub(1)); } + KeyCode::Down + if key.modifiers.is_empty() + && app.selected_composer_attachment_index().is_some() => + { + let _ = app.select_next_composer_attachment(); + } KeyCode::PageUp => { let page = app.last_transcript_visible.max(1); app.scroll_up(page); @@ -1747,6 +1767,9 @@ async fn run_event_loop( if crate::tui::file_mention::try_autocomplete_file_mention(app) { continue; } + if app.is_loading && queue_current_draft_for_next_turn(app) { + continue; + } let prior_model = app.model.clone(); app.cycle_mode(); if app.model != prior_model { @@ -1803,7 +1826,7 @@ async fn run_event_loop( && !slash_menu_open => { if app.view_stack.top_kind() != Some(ModalKind::Help) { - app.view_stack.push(HelpView::new()); + app.view_stack.push(HelpView::new_for_locale(app.ui_locale)); } continue; } @@ -1851,12 +1874,14 @@ async fn run_event_loop( } } } - KeyCode::Backspace => { + KeyCode::Backspace if !app.remove_selected_composer_attachment() => { app.delete_char(); } - KeyCode::Delete => { + KeyCode::Backspace => {} + KeyCode::Delete if !app.remove_selected_composer_attachment() => { app.delete_char_forward(); } + KeyCode::Delete => {} KeyCode::Left => { app.move_cursor_left(); } @@ -1941,7 +1966,7 @@ async fn run_event_loop( } } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.clear_input(); + app.clear_input_recoverable(); } KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => { // Emacs-style yank from the kill buffer at the cursor. @@ -2269,9 +2294,6 @@ fn finalize_streaming_thinking_active_entry( enum EscapeAction { CloseSlashMenu, CancelRequest, - /// Composer non-empty during a running turn — capture the input as a - /// pending steer, abort the turn, and re-submit on TurnComplete (#122). - SteerAndAbort, DiscardQueuedDraft, ClearInput, Noop, @@ -2281,11 +2303,7 @@ fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeAction { if slash_menu_open { EscapeAction::CloseSlashMenu } else if app.is_loading { - if app.input.trim().is_empty() { - EscapeAction::CancelRequest - } else { - EscapeAction::SteerAndAbort - } + EscapeAction::CancelRequest } else if app.queued_draft.is_some() && app.input.is_empty() { EscapeAction::DiscardQueuedDraft } else if !app.input.is_empty() { @@ -2295,6 +2313,47 @@ fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeAction { } } +fn handle_history_search_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Enter => { + let _ = app.accept_history_search(); + } + KeyCode::Esc => { + app.cancel_history_search(); + } + KeyCode::Char('c') | KeyCode::Char('C') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.cancel_history_search(); + } + KeyCode::Backspace => { + app.history_search_backspace(); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + while app + .history_search_query() + .is_some_and(|query| !query.is_empty()) + { + app.history_search_backspace(); + } + } + KeyCode::Up => { + app.history_search_select_previous(); + } + KeyCode::Down => { + app.history_search_select_next(); + } + KeyCode::Char(ch) + if key.modifiers.is_empty() + || key.modifiers == KeyModifiers::SHIFT + || key.modifiers == KeyModifiers::NONE => + { + app.history_search_insert_char(ch); + } + _ => {} + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum ApiKeyValidation { Accept { warning: Option }, @@ -2350,6 +2409,24 @@ fn build_queued_message(app: &mut App, input: String) -> QueuedMessage { QueuedMessage::new(input, skill_instruction) } +fn queue_current_draft_for_next_turn(app: &mut App) -> bool { + let Some(input) = app.submit_input() else { + return false; + }; + let queued = if let Some(mut draft) = app.queued_draft.take() { + draft.display = input; + draft + } else { + build_queued_message(app, input) + }; + app.queue_message(queued); + app.status_message = Some(format!( + "Queued follow-up for next turn ({} queued) - /queue to review", + app.queued_message_count() + )); + true +} + fn queued_message_content_for_app( app: &App, message: &QueuedMessage, @@ -3261,6 +3338,8 @@ async fn steer_user_message( let content = queued_message_content_for_app(app, &message, cwd); let message_index = app.api_messages.len(); + engine_handle.steer(content.clone()).await?; + // Mirror steer input in local transcript/session state. app.add_message(HistoryCell::User { content: format!("+ {}", message.display), @@ -3275,7 +3354,6 @@ async fn steer_user_message( }], }); - engine_handle.steer(content).await?; app.status_message = Some("Steering current turn...".to_string()); Ok(()) } @@ -3459,17 +3537,30 @@ async fn handle_plan_choice( /// end-of-turn. fn build_pending_input_preview(app: &App) -> PendingInputPreview { let mut preview = PendingInputPreview::new(); + let selected_attachment = app.selected_composer_attachment_index(); + let mut attachment_index = 0usize; preview.context_items = crate::tui::file_mention::pending_context_previews( &app.input, &app.workspace, std::env::current_dir().ok(), ) .into_iter() - .map(|item| ContextPreviewItem { - kind: item.kind, - label: item.label, - detail: item.detail, - included: item.included, + .map(|item| { + let selected = if item.removable { + let selected = selected_attachment == Some(attachment_index); + attachment_index += 1; + selected + } else { + false + }; + ContextPreviewItem { + kind: item.kind, + label: item.label, + detail: item.detail, + included: item.included, + removable: item.removable, + selected, + } }) .collect(); preview.pending_steers = app @@ -5261,7 +5352,8 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) { MouseEventKind::Up(MouseButton::Left) if app.transcript_selection.dragging => { app.transcript_selection.dragging = false; if selection_has_content(app) { - copy_active_selection(app); + app.status_message = + Some("Selection ready - press Cmd+C or Ctrl+Shift+C to copy".to_string()); } } _ => {} @@ -5327,12 +5419,15 @@ fn copy_active_selection(app: &mut App) { if !app.transcript_selection.is_active() { return; } - if let Some(text) = selection_to_text(app) { + if let Some(text) = selection_to_text(app).filter(|text| !text.is_empty()) { if app.clipboard.write_text(&text).is_ok() { app.status_message = Some("Selection copied".to_string()); } else { app.status_message = Some("Copy failed".to_string()); } + } else { + app.transcript_selection.clear(); + app.status_message = Some("No selection to copy".to_string()); } } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 27d2ed35..84cf994f 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -68,6 +68,62 @@ fn selection_point_from_position_ignores_top_padding() { assert_eq!(p1.column, 0); } +#[test] +fn selection_to_text_handles_multiline_and_reversed_endpoints() { + let mut app = create_test_app(); + app.history = vec![HistoryCell::Assistant { + content: "alpha beta\ngamma delta".to_string(), + streaming: false, + }]; + app.resync_history_revisions(); + app.transcript_cache.ensure( + &app.history, + &app.history_revisions, + 80, + app.transcript_render_options(), + ); + + app.transcript_selection.anchor = Some(TranscriptSelectionPoint { + line_index: 1, + column: 5, + }); + app.transcript_selection.head = Some(TranscriptSelectionPoint { + line_index: 0, + column: 6, + }); + + assert_eq!(selection_to_text(&app).as_deref(), Some("a beta\n gam")); +} + +#[test] +fn selection_has_content_rejects_zero_width_selection() { + let mut app = create_test_app(); + let point = TranscriptSelectionPoint { + line_index: 0, + column: 3, + }; + app.transcript_selection.anchor = Some(point); + app.transcript_selection.head = Some(point); + + assert!(!selection_has_content(&app)); +} + +#[test] +fn copy_shortcut_accepts_cmd_and_ctrl_shift_only() { + assert!(is_copy_shortcut(&KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::SUPER, + ))); + assert!(is_copy_shortcut(&KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ))); + assert!(!is_copy_shortcut(&KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + ))); +} + #[test] fn parse_plan_choice_accepts_numbers() { assert_eq!(parse_plan_choice("1"), Some(PlanChoice::AcceptAgent)); @@ -186,6 +242,38 @@ fn create_test_app() -> App { App::new(options, &Config::default()) } +#[test] +fn backtrack_prefill_rehydrates_attachment_rows() { + let mut app = create_test_app(); + let user_text = "inspect this\n[Attached image: /tmp/pasted.png]"; + app.add_message(HistoryCell::User { + content: user_text.to_string(), + }); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: user_text.to_string(), + cache_control: None, + }], + }); + app.add_message(HistoryCell::Assistant { + content: "done".to_string(), + streaming: false, + }); + app.api_messages.push(Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "done".to_string(), + cache_control: None, + }], + }); + + apply_backtrack(&mut app, 0); + + assert_eq!(app.input, user_text); + assert_eq!(app.composer_attachment_count(), 1); +} + #[test] fn active_tool_status_label_summarizes_live_tool_group() { let mut app = create_test_app(); @@ -887,8 +975,7 @@ fn test_esc_priority_order_matches_cancel_stack() { app.is_loading = true; app.input = "draft".to_string(); app.mode = AppMode::Yolo; - // #122: typing during a running turn now steers instead of cancelling. - assert_eq!(next_escape_action(&app, false), EscapeAction::SteerAndAbort); + assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest); app.input.clear(); assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest); @@ -2216,7 +2303,7 @@ fn non_recoverable_engine_error_enters_offline_mode() { ); } -// ---- Issue #122: Esc-to-steer routing + steer merge ---- +// ---- Issue #208: in-flight input routing ---- #[test] fn next_escape_action_cancels_when_loading_with_empty_input() { @@ -2227,11 +2314,11 @@ fn next_escape_action_cancels_when_loading_with_empty_input() { } #[test] -fn next_escape_action_steers_when_loading_with_input() { +fn next_escape_action_cancels_when_loading_with_input() { let mut app = create_test_app(); app.is_loading = true; app.input = "hold on, look at this instead".to_string(); - assert_eq!(next_escape_action(&app, false), EscapeAction::SteerAndAbort); + assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest); } #[test] @@ -2266,6 +2353,47 @@ fn next_escape_action_slash_menu_takes_priority() { assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu); } +#[test] +fn tab_queues_running_turn_draft_for_next_turn() { + let mut app = create_test_app(); + app.is_loading = true; + app.input = "follow up next".to_string(); + app.cursor_position = app.input.chars().count(); + + assert!(queue_current_draft_for_next_turn(&mut app)); + + assert!(app.input.is_empty()); + assert_eq!(app.queued_message_count(), 1); + assert_eq!( + app.queued_messages.front().map(|msg| msg.display.as_str()), + Some("follow up next") + ); + assert!( + app.status_message + .as_deref() + .is_some_and(|msg| msg.contains("Queued follow-up")) + ); +} + +#[test] +fn tab_queue_preserves_queued_draft_skill_instruction() { + let mut app = create_test_app(); + app.is_loading = true; + app.input = "edited queued follow-up".to_string(); + app.cursor_position = app.input.chars().count(); + app.queued_draft = Some(QueuedMessage::new( + "original".to_string(), + Some("skill body".to_string()), + )); + + assert!(queue_current_draft_for_next_turn(&mut app)); + + let queued = app.queued_messages.front().expect("queued message"); + assert_eq!(queued.display, "edited queued follow-up"); + assert_eq!(queued.skill_instruction.as_deref(), Some("skill body")); + assert!(app.queued_draft.is_none()); +} + #[test] fn merge_pending_steers_returns_none_when_empty() { let mut app = create_test_app(); diff --git a/crates/tui/src/tui/views/help.rs b/crates/tui/src/tui/views/help.rs index acb20a87..e06ad5db 100644 --- a/crates/tui/src/tui/views/help.rs +++ b/crates/tui/src/tui/views/help.rs @@ -22,6 +22,7 @@ use ratatui::{ use unicode_width::UnicodeWidthStr; use crate::commands; +use crate::localization::{Locale, MessageId, tr}; use crate::palette; use crate::tui::keybindings::KEYBINDINGS; use crate::tui::views::{ModalKind, ModalView, ViewAction}; @@ -34,10 +35,10 @@ enum HelpSection { } impl HelpSection { - fn label(self) -> &'static str { + fn label(self, locale: Locale) -> &'static str { match self { - Self::Command => "Slash commands", - Self::Keybinding => "Keybindings", + Self::Command => tr(locale, MessageId::HelpSlashCommands), + Self::Keybinding => tr(locale, MessageId::HelpKeybindings), } } @@ -67,6 +68,7 @@ struct HelpEntry { } pub struct HelpView { + locale: Locale, entries: Vec, /// Indices into `entries`, in display order, after filtering. filtered: Vec, @@ -82,8 +84,13 @@ impl Default for HelpView { impl HelpView { pub fn new() -> Self { + Self::new_for_locale(Locale::En) + } + + pub fn new_for_locale(locale: Locale) -> Self { let entries = build_entries(); let mut view = Self { + locale, entries, filtered: Vec::new(), query: String::new(), @@ -93,6 +100,10 @@ impl HelpView { view } + fn tr(&self, id: MessageId) -> &'static str { + tr(self.locale, id) + } + fn refilter(&mut self) { // Substring matching is intentional — fuzzy matchers can hide the // exact-prefix hit a user is typing toward, which is the wrong @@ -295,9 +306,9 @@ impl ModalView for HelpView { let mut lines: Vec> = Vec::new(); let query_label = if self.query.is_empty() { - "Type to filter".to_string() + self.tr(MessageId::HelpFilterPlaceholder).to_string() } else { - format!("Filter: {}", self.query) + format!("{}{}", self.tr(MessageId::HelpFilterPrefix), self.query) }; lines.push(Line::from(Span::styled( query_label, @@ -321,7 +332,7 @@ impl ModalView for HelpView { if self.filtered.is_empty() { lines.push(Line::from(Span::styled( - " No matches.", + self.tr(MessageId::HelpNoMatches), Style::default() .fg(palette::TEXT_MUTED) .add_modifier(Modifier::ITALIC), @@ -372,7 +383,7 @@ impl ModalView for HelpView { .filter(|idx| self.entries[**idx].section == entry.section) .count(); lines.push(Line::from(Span::styled( - format!(" {} ({})", entry.section.label(), count), + format!(" {} ({})", entry.section.label(self.locale), count), Style::default() .fg(palette::DEEPSEEK_BLUE) .add_modifier(Modifier::BOLD), @@ -403,16 +414,28 @@ impl ModalView for HelpView { let block = modal_block() .title(Line::from(vec![Span::styled( - " Help ", + format!(" {} ", self.tr(MessageId::HelpTitle)), Style::default() .fg(palette::DEEPSEEK_BLUE) .add_modifier(Modifier::BOLD), )])) .title_bottom(Line::from(vec![ - Span::styled(" type to filter ", Style::default().fg(palette::TEXT_MUTED)), - Span::styled(" ↑/↓ move ", Style::default().fg(palette::TEXT_MUTED)), - Span::styled(" PgUp/PgDn jump ", Style::default().fg(palette::TEXT_MUTED)), - Span::styled(" Esc close ", Style::default().fg(palette::TEXT_MUTED)), + Span::styled( + self.tr(MessageId::HelpFooterTypeFilter), + Style::default().fg(palette::TEXT_MUTED), + ), + Span::styled( + self.tr(MessageId::HelpFooterMove), + Style::default().fg(palette::TEXT_MUTED), + ), + Span::styled( + self.tr(MessageId::HelpFooterJump), + Style::default().fg(palette::TEXT_MUTED), + ), + Span::styled( + self.tr(MessageId::HelpFooterClose), + Style::default().fg(palette::TEXT_MUTED), + ), ])); Paragraph::new(lines).block(block).render(popup_area, buf); @@ -592,6 +615,24 @@ mod tests { ); } + #[test] + fn localized_help_chrome_renders_without_missing_markers() { + let view = HelpView::new_for_locale(Locale::ZhHans); + let area = Rect::new(0, 0, 48, 18); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let dump = buffer_text(&buf, area); + assert!( + dump.contains('帮') && dump.contains('助'), + "missing localized title:\n{dump}" + ); + assert!( + !dump.contains("MISSING"), + "missing-key marker leaked:\n{dump}" + ); + } + fn buffer_text(buf: &Buffer, area: Rect) -> String { let mut out = String::new(); for y in area.top()..area.bottom() { diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index cc4e122b..540ff010 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -3,6 +3,7 @@ use ratatui::{buffer::Buffer, layout::Rect}; use std::cell::Cell; use std::fmt; +use crate::localization::{Locale, MessageId, tr}; use crate::palette; use crate::settings::Settings; use crate::tools::UserInputResponse; @@ -279,12 +280,44 @@ impl ConfigScope { #[derive(Debug, Clone)] struct ConfigRow { + section: ConfigSection, key: String, value: String, editable: bool, scope: ConfigScope, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConfigSection { + Model, + Permissions, + Display, + Composer, + Sidebar, + History, + Mcp, +} + +impl ConfigSection { + fn label(self) -> &'static str { + match self { + ConfigSection::Model => "Model", + ConfigSection::Permissions => "Permissions", + ConfigSection::Display => "Display", + ConfigSection::Composer => "Composer", + ConfigSection::Sidebar => "Sidebar", + ConfigSection::History => "History", + ConfigSection::Mcp => "MCP", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConfigListItem { + Section(ConfigSection), + Row(usize), +} + #[derive(Debug, Clone)] struct ConfigEdit { key: String, @@ -300,7 +333,9 @@ pub struct ConfigView { selected: usize, scroll: usize, editing: Option, + filter: String, status: Option, + locale: Locale, last_visible_rows: Cell, } @@ -309,90 +344,14 @@ impl ConfigView { let settings = Settings::load().unwrap_or_else(|_| Settings::default()); let rows = vec![ ConfigRow { + section: ConfigSection::Model, key: "model".to_string(), value: app.model.clone(), editable: true, scope: ConfigScope::Session, }, ConfigRow { - key: "approval_mode".to_string(), - value: app.approval_mode.label().to_string(), - editable: true, - scope: ConfigScope::Session, - }, - ConfigRow { - key: "auto_compact".to_string(), - value: settings.auto_compact.to_string(), - editable: true, - scope: ConfigScope::Saved, - }, - ConfigRow { - key: "calm_mode".to_string(), - value: settings.calm_mode.to_string(), - editable: true, - scope: ConfigScope::Saved, - }, - ConfigRow { - key: "low_motion".to_string(), - value: settings.low_motion.to_string(), - editable: true, - scope: ConfigScope::Saved, - }, - ConfigRow { - key: "show_thinking".to_string(), - value: settings.show_thinking.to_string(), - editable: true, - scope: ConfigScope::Saved, - }, - ConfigRow { - key: "show_tool_details".to_string(), - value: settings.show_tool_details.to_string(), - editable: true, - scope: ConfigScope::Saved, - }, - ConfigRow { - key: "composer_density".to_string(), - value: settings.composer_density.clone(), - editable: true, - scope: ConfigScope::Saved, - }, - ConfigRow { - key: "composer_border".to_string(), - value: settings.composer_border.to_string(), - editable: true, - scope: ConfigScope::Saved, - }, - ConfigRow { - key: "transcript_spacing".to_string(), - value: settings.transcript_spacing.clone(), - editable: true, - scope: ConfigScope::Saved, - }, - ConfigRow { - key: "default_mode".to_string(), - value: settings.default_mode.clone(), - editable: true, - scope: ConfigScope::Saved, - }, - ConfigRow { - key: "sidebar_width".to_string(), - value: settings.sidebar_width_percent.to_string(), - editable: true, - scope: ConfigScope::Saved, - }, - ConfigRow { - key: "sidebar_focus".to_string(), - value: settings.sidebar_focus.clone(), - editable: true, - scope: ConfigScope::Saved, - }, - ConfigRow { - key: "max_history".to_string(), - value: settings.max_input_history.to_string(), - editable: true, - scope: ConfigScope::Saved, - }, - ConfigRow { + section: ConfigSection::Model, key: "default_model".to_string(), value: settings .default_model @@ -403,6 +362,112 @@ impl ConfigView { scope: ConfigScope::Saved, }, ConfigRow { + section: ConfigSection::Permissions, + key: "approval_mode".to_string(), + value: app.approval_mode.label().to_string(), + editable: true, + scope: ConfigScope::Session, + }, + ConfigRow { + section: ConfigSection::Permissions, + key: "default_mode".to_string(), + value: settings.default_mode.clone(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Display, + key: "locale".to_string(), + value: settings.locale.clone(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Display, + key: "calm_mode".to_string(), + value: settings.calm_mode.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Display, + key: "low_motion".to_string(), + value: settings.low_motion.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Display, + key: "show_thinking".to_string(), + value: settings.show_thinking.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Display, + key: "show_tool_details".to_string(), + value: settings.show_tool_details.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Display, + key: "transcript_spacing".to_string(), + value: settings.transcript_spacing.clone(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Composer, + key: "composer_density".to_string(), + value: settings.composer_density.clone(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Composer, + key: "composer_border".to_string(), + value: settings.composer_border.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Composer, + key: "paste_burst_detection".to_string(), + value: settings.paste_burst_detection.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Sidebar, + key: "sidebar_width".to_string(), + value: settings.sidebar_width_percent.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Sidebar, + key: "sidebar_focus".to_string(), + value: settings.sidebar_focus.clone(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::History, + key: "auto_compact".to_string(), + value: settings.auto_compact.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::History, + key: "max_history".to_string(), + value: settings.max_input_history.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Mcp, key: "mcp_config_path".to_string(), value: app.mcp_config_path.display().to_string(), editable: true, @@ -415,49 +480,147 @@ impl ConfigView { selected: 0, scroll: 0, editing: None, + filter: String::new(), status: None, + locale: app.ui_locale, last_visible_rows: Cell::new(0), } } + fn tr(&self, id: MessageId) -> &'static str { + tr(self.locale, id) + } + fn visible_rows_cached(&self) -> usize { let cached = self.last_visible_rows.get(); if cached == 0 { 8 } else { cached } } - fn adjust_scroll(&mut self, visible_rows: usize) { - if self.rows.is_empty() { + fn row_matches_filter(&self, row: &ConfigRow) -> bool { + let filter = self.filter.trim().to_lowercase(); + if filter.is_empty() { + return true; + } + + let section = row.section.label().to_lowercase(); + let key = row.key.to_lowercase(); + let value = row.value.to_lowercase(); + let scope = row.scope.label().to_lowercase(); + + filter.split_whitespace().all(|term| { + section.contains(term) + || key.contains(term) + || value.contains(term) + || scope.contains(term) + }) + } + + fn matching_row_indices(&self) -> Vec { + self.rows + .iter() + .enumerate() + .filter_map(|(idx, row)| self.row_matches_filter(row).then_some(idx)) + .collect() + } + + fn visible_items(&self) -> Vec { + let mut items = Vec::new(); + let mut current_section = None; + + for (idx, row) in self.rows.iter().enumerate() { + if !self.row_matches_filter(row) { + continue; + } + + if current_section != Some(row.section) { + current_section = Some(row.section); + items.push(ConfigListItem::Section(row.section)); + } + items.push(ConfigListItem::Row(idx)); + } + + items + } + + fn selected_row_index(&self) -> Option { + let selected = self.selected; + self.matching_row_indices() + .into_iter() + .any(|idx| idx == selected) + .then_some(selected) + } + + fn selected_display_position(&self, items: &[ConfigListItem]) -> Option { + items + .iter() + .position(|item| matches!(item, ConfigListItem::Row(idx) if *idx == self.selected)) + } + + fn sync_selection_to_filter(&mut self) { + let matches = self.matching_row_indices(); + if matches.is_empty() { self.selected = 0; self.scroll = 0; return; } - let max = self.rows.len().saturating_sub(1); - self.selected = self.selected.min(max); + if !matches.contains(&self.selected) { + self.selected = matches[0]; + } + } - if self.selected < self.scroll { - self.scroll = self.selected; + fn update_filter(&mut self, update: impl FnOnce(&mut String)) { + update(&mut self.filter); + self.status = None; + self.sync_selection_to_filter(); + self.adjust_scroll(self.visible_rows_cached()); + } + + fn adjust_scroll(&mut self, visible_rows: usize) { + self.sync_selection_to_filter(); + + let items = self.visible_items(); + if items.is_empty() { + self.scroll = 0; + return; } let visible_rows = visible_rows.max(1); - if self.selected >= self.scroll + visible_rows { - self.scroll = self.selected.saturating_sub(visible_rows.saturating_sub(1)); + let max_scroll = items.len().saturating_sub(visible_rows); + self.scroll = self.scroll.min(max_scroll); + + let Some(selected_pos) = self.selected_display_position(&items) else { + self.scroll = 0; + return; + }; + + if selected_pos < self.scroll { + self.scroll = selected_pos; + } + + if selected_pos >= self.scroll + visible_rows { + self.scroll = selected_pos.saturating_sub(visible_rows.saturating_sub(1)); } } fn move_selection(&mut self, delta: isize) { - if self.rows.is_empty() { + let matches = self.matching_row_indices(); + if matches.is_empty() { return; } - let max = self.rows.len().saturating_sub(1); + let current = matches + .iter() + .position(|idx| *idx == self.selected) + .unwrap_or(0); + let max = matches.len().saturating_sub(1); let next = if delta.is_negative() { - self.selected.saturating_sub(delta.unsigned_abs()) + current.saturating_sub(delta.unsigned_abs()) } else { - (self.selected + delta as usize).min(max) + (current + delta as usize).min(max) }; - self.selected = next; + self.selected = matches[next]; let visible_rows = self.visible_rows_cached(); self.adjust_scroll(visible_rows); } @@ -576,7 +739,10 @@ impl ConfigView { } fn start_edit(&mut self) { - let Some(row) = self.rows.get(self.selected) else { + let Some(row_idx) = self.selected_row_index() else { + return; + }; + let Some(row) = self.rows.get(row_idx) else { return; }; let key = row.key.clone(); @@ -598,20 +764,35 @@ impl ConfigView { }); self.status = None; } + + fn clear_filter(&mut self) { + if self.filter.is_empty() { + return; + } + + self.update_filter(|filter| filter.clear()); + } } fn config_hint_for_key(key: &str) -> &'static str { match key { "model" => "deepseek-v4-pro | deepseek-v4-flash | deepseek-*", "approval_mode" => "auto | suggest | never", - "auto_compact" | "calm_mode" | "low_motion" | "show_thinking" | "show_tool_details" - | "composer_border" => "on/off, true/false, yes/no, 1/0", + "auto_compact" + | "calm_mode" + | "low_motion" + | "show_thinking" + | "show_tool_details" + | "composer_border" + | "paste_burst_detection" => "on/off, true/false, yes/no, 1/0", "composer_density" | "transcript_spacing" => "compact | comfortable | spacious", + "locale" => "auto | en | ja | zh-Hans | pt-BR", "default_mode" => "agent | plan | yolo", "sidebar_width" => "10..=50", "sidebar_focus" => "auto | plan | todos | tasks | agents", "max_history" => "integer (0 allowed)", "default_model" => "deepseek-v4-pro | deepseek-v4-flash | deepseek-* | none/default", + "mcp_config_path" => "path to mcp.json", _ => "", } } @@ -677,12 +858,28 @@ impl ModalView for ConfigView { } match key.code { - KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, - KeyCode::Up | KeyCode::Char('k') => { + KeyCode::Esc => { + if self.filter.is_empty() { + ViewAction::Close + } else { + self.clear_filter(); + ViewAction::None + } + } + KeyCode::Char('q') if self.filter.is_empty() => ViewAction::Close, + KeyCode::Up => { self.move_selection(-1); ViewAction::None } - KeyCode::Down | KeyCode::Char('j') => { + KeyCode::Char('k') if self.filter.is_empty() => { + self.move_selection(-1); + ViewAction::None + } + KeyCode::Down => { + self.move_selection(1); + ViewAction::None + } + KeyCode::Char('j') if self.filter.is_empty() => { self.move_selection(1); ViewAction::None } @@ -694,12 +891,44 @@ impl ModalView for ConfigView { self.move_selection(5); ViewAction::None } - KeyCode::Char('e') | KeyCode::Char('E') | KeyCode::Enter => { - if self.rows.get(self.selected).is_some_and(|row| row.editable) { + KeyCode::Backspace => { + if !self.filter.is_empty() { + self.update_filter(|filter| { + filter.pop(); + }); + } + ViewAction::None + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.clear_filter(); + ViewAction::None + } + KeyCode::Char('e') | KeyCode::Char('E') if self.filter.is_empty() => { + if self + .selected_row_index() + .and_then(|idx| self.rows.get(idx)) + .is_some_and(|row| row.editable) + { self.start_edit(); } ViewAction::None } + KeyCode::Enter => { + if self + .selected_row_index() + .and_then(|idx| self.rows.get(idx)) + .is_some_and(|row| row.editable) + { + self.start_edit(); + } + ViewAction::None + } + KeyCode::Char(ch) + if !key.modifiers.contains(KeyModifiers::CONTROL) && !ch.is_control() => + { + self.update_filter(|filter| filter.push(ch)); + ViewAction::None + } _ => ViewAction::None, } } @@ -763,59 +992,105 @@ impl ModalView for ConfigView { ) } else { let content_height = usize::from(inner.height); - let header_lines = 4usize; + let header_lines = 5usize; let bottom_lines = 1usize; let visible_rows = content_height .saturating_sub(header_lines + bottom_lines) .max(1); self.last_visible_rows.set(visible_rows); - let start = self.scroll.min(self.rows.len()); - let end = (start + visible_rows).min(self.rows.len()); - let scrollable = self.rows.len() > visible_rows; + let items = self.visible_items(); + let match_count = self.matching_row_indices().len(); + let start = self.scroll.min(items.len()); + let end = (start + visible_rows).min(items.len()); + let scrollable = items.len() > visible_rows; + let search_value = if self.filter.is_empty() { + self.tr(MessageId::ConfigSearchPlaceholder).to_string() + } else { + self.filter.clone() + }; let mut lines: Vec = vec![ Line::from(vec![Span::styled( - "Session Configuration", + self.tr(MessageId::ConfigTitle), Style::default().fg(palette::DEEPSEEK_BLUE).bold(), )]), + Line::from(vec![ + Span::styled(" Search: ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw(search_value), + Span::styled( + format!(" ({match_count}/{})", self.rows.len()), + Style::default().fg(palette::TEXT_MUTED), + ), + ]), Line::from(""), - Line::from(" Key Value Scope"), - Line::from(" ─────────────────────────────────────────────────────────────────"), + Line::from(" Key Value Scope"), + Line::from(" ----------------------------------------------------------------"), ]; - for (idx, row) in self.rows.iter().enumerate().skip(start).take(visible_rows) { - let selected = idx == self.selected; - let style = if selected { - Style::default() - .fg(palette::SELECTION_TEXT) - .bg(palette::SELECTION_BG) - } else { - Style::default().fg(palette::TEXT_PRIMARY) - }; - let value = truncate_view_text(&row.value, 44); - let mut line = Line::from(format!( - " {:<17} {:<44} {}", - row.key, - value, - row.scope.label() - )); - line.style = style; - lines.push(line); + for item in items.iter().skip(start).take(visible_rows) { + match item { + ConfigListItem::Section(section) => { + lines.push(Line::from(Span::styled( + format!(" {}", section.label()), + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + ))); + } + ConfigListItem::Row(idx) => { + let Some(row) = self.rows.get(*idx) else { + continue; + }; + let selected = *idx == self.selected; + let style = if selected { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + } else { + Style::default().fg(palette::TEXT_PRIMARY) + }; + let value = truncate_view_text(&row.value, 44); + let mut line = Line::from(format!( + " {:<19} {:<44} {}", + row.key, + value, + row.scope.label() + )); + line.style = style; + lines.push(line); + } + } } - if self.rows.is_empty() { - lines.push(Line::from(" No settings available.")); + if items.is_empty() { + let message = if self.filter.is_empty() { + self.tr(MessageId::ConfigNoSettings).to_string() + } else { + format!( + "{}\"{}\".", + self.tr(MessageId::ConfigNoMatchesPrefix), + self.filter + ) + }; + lines.push(Line::from(Span::styled( + message, + Style::default().fg(palette::TEXT_MUTED), + ))); } let bottom_text = if let Some(status) = self.status.as_ref() { status.clone() - } else if scrollable && !self.rows.is_empty() { + } else if !self.filter.is_empty() { format!( - " Showing {}-{} / {}", + "{}: {match_count}", + self.tr(MessageId::ConfigFilteredSettings) + ) + } else if scrollable && !items.is_empty() { + format!( + "{} {}-{} / {}", + self.tr(MessageId::ConfigShowing), self.scroll.saturating_add(1), end, - self.rows.len() + items.len() ) } else { String::new() @@ -825,17 +1100,19 @@ impl ModalView for ConfigView { Style::default().fg(palette::TEXT_MUTED), ))); - let footer = if scrollable { - " ↑/↓=select, Enter=edit, PgUp/PgDn=scroll, Esc=close " + let footer = if !self.filter.is_empty() { + self.tr(MessageId::ConfigFooterFiltered) + } else if scrollable { + self.tr(MessageId::ConfigFooterScrollable) } else { - " ↑/↓=select, Enter=edit, Esc=close " + self.tr(MessageId::ConfigFooterDefault) }; (lines, footer.to_string()) }; let block = Block::default() .title(Line::from(vec![Span::styled( - " Config ", + self.tr(MessageId::ConfigModalTitle), Style::default().fg(palette::DEEPSEEK_BLUE).bold(), )])) .title_bottom(Line::from(Span::styled( @@ -1223,10 +1500,15 @@ fn truncate_view_text(text: &str, max_chars: usize) -> String { #[cfg(test)] mod tests { - use super::{ConfigView, ModalView, ViewAction, ViewEvent, truncate_view_text}; + use super::{ + ConfigListItem, ConfigSection, ConfigView, ModalView, ViewAction, ViewEvent, + truncate_view_text, + }; use crate::config::Config; + use crate::localization::Locale; use crate::tui::app::{App, TuiOptions}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::{buffer::Buffer, layout::Rect}; use std::path::PathBuf; fn create_test_app() -> App { @@ -1251,6 +1533,33 @@ mod tests { App::new(options, &Config::default()) } + fn type_filter(view: &mut ConfigView, text: &str) { + for ch in text.chars() { + let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); + } + } + + fn visible_section_labels(view: &ConfigView) -> Vec<&'static str> { + view.visible_items() + .into_iter() + .filter_map(|item| match item { + ConfigListItem::Section(section) => Some(section.label()), + ConfigListItem::Row(_) => None, + }) + .collect() + } + + fn visible_row_keys(view: &ConfigView) -> Vec<&str> { + view.visible_items() + .into_iter() + .filter_map(|item| match item { + ConfigListItem::Row(idx) => Some(view.rows[idx].key.as_str()), + ConfigListItem::Section(_) => None, + }) + .collect() + } + #[test] fn truncate_view_text_handles_unicode() { let text = "abc😀é"; @@ -1261,6 +1570,24 @@ mod tests { assert_eq!(truncate_view_text(text, 5), "abc😀é"); } + #[test] + fn config_view_groups_rows_by_expected_sections() { + let app = create_test_app(); + let view = ConfigView::new_for_app(&app); + assert_eq!( + visible_section_labels(&view), + vec![ + ConfigSection::Model.label(), + ConfigSection::Permissions.label(), + ConfigSection::Display.label(), + ConfigSection::Composer.label(), + ConfigSection::Sidebar.label(), + ConfigSection::History.label(), + ConfigSection::Mcp.label(), + ] + ); + } + #[test] fn config_view_includes_expected_editable_rows() { let app = create_test_app(); @@ -1272,11 +1599,113 @@ mod tests { .collect::>(); assert!(keys.contains(&"model")); assert!(keys.contains(&"approval_mode")); + assert!(keys.contains(&"locale")); assert!(keys.contains(&"auto_compact")); assert!(keys.contains(&"composer_border")); + assert!(keys.contains(&"mcp_config_path")); assert!(view.rows.iter().all(|row| row.editable)); } + #[test] + fn config_view_filter_matches_group_and_rows() { + let app = create_test_app(); + let mut view = ConfigView::new_for_app(&app); + + type_filter(&mut view, "side"); + + assert_eq!(view.filter, "side"); + assert_eq!(visible_section_labels(&view), vec!["Sidebar"]); + assert_eq!( + visible_row_keys(&view), + vec!["sidebar_width", "sidebar_focus"] + ); + assert_eq!(view.rows[view.selected].key, "sidebar_width"); + } + + #[test] + fn config_view_filter_accepts_j_k_and_unicode_case() { + let app = create_test_app(); + let mut view = ConfigView::new_for_app(&app); + + type_filter(&mut view, "thinking"); + assert_eq!(visible_row_keys(&view), vec!["show_thinking"]); + + view.clear_filter(); + view.rows[0].value = "CAFÉ".to_string(); + type_filter(&mut view, "café"); + assert_eq!(visible_row_keys(&view), vec!["model"]); + } + + #[test] + fn localized_config_view_renders_at_narrow_width() { + let mut app = create_test_app(); + app.ui_locale = Locale::PtBr; + let view = ConfigView::new_for_app(&app); + let area = Rect::new(0, 0, 60, 18); + let mut buf = Buffer::empty(area); + + view.render(area, &mut buf); + + let dump = buffer_text(&buf, area); + assert!( + dump.contains("Configuração") || dump.contains("Configura"), + "missing localized config title:\n{dump}" + ); + assert!( + !dump.contains("MISSING"), + "missing-key marker leaked:\n{dump}" + ); + } + + #[test] + fn config_view_filter_no_match_does_not_edit_hidden_row() { + let app = create_test_app(); + let mut view = ConfigView::new_for_app(&app); + + type_filter(&mut view, "zzzz"); + assert!(visible_row_keys(&view).is_empty()); + + let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); + assert!(view.editing.is_none()); + + let clear = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(clear, ViewAction::None)); + assert!(view.filter.is_empty()); + assert!(!visible_row_keys(&view).is_empty()); + } + + #[test] + fn config_view_can_edit_filtered_row() { + let app = create_test_app(); + let mut view = ConfigView::new_for_app(&app); + + type_filter(&mut view, "mcp"); + assert_eq!(visible_row_keys(&view), vec!["mcp_config_path"]); + + let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(start, ViewAction::None)); + assert!(view.editing.is_some()); + + let clear = view.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert!(matches!(clear, ViewAction::None)); + type_filter(&mut view, "servers.json"); + + let submit = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match submit { + ViewAction::Emit(ViewEvent::ConfigUpdated { + key, + value, + persist, + }) => { + assert_eq!(key, "mcp_config_path"); + assert_eq!(value, "servers.json"); + assert!(persist); + } + other => panic!("expected config update emit, got {other:?}"), + } + } + #[test] fn config_view_enter_and_ctrl_u_emit_config_updated() { let app = create_test_app(); @@ -1341,4 +1770,15 @@ mod tests { assert!(view.editing.is_none()); assert_eq!(view.status.as_deref(), Some("Edit cancelled")); } + + fn buffer_text(buf: &Buffer, area: Rect) -> String { + let mut out = String::new(); + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 7be2d6b3..a869a742 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -277,6 +277,14 @@ impl<'a> ComposerWidget<'a> { } } + fn active_menu_row_count(&self) -> usize { + if self.app.is_history_search_active() { + self.app.history_search_matches().len().max(1) + } else { + self.active_menu_entries().len() + } + } + fn has_panel(&self, area: Rect) -> bool { self.app.composer_border && area.height >= 3 && area.width >= 12 } @@ -307,24 +315,54 @@ impl Renderable for ComposerWidget<'_> { let background = Style::default().bg(self.app.ui_theme.composer_bg); let has_panel = self.has_panel(area); let inner_area = self.inner_area(area); + let input_text = self.app.composer_display_input(); + let input_cursor = self.app.composer_display_cursor(); + let history_search_matches = if self.app.is_history_search_active() { + self.app.history_search_matches() + } else { + Vec::new() + }; let menu_entries = self.active_menu_entries(); - let menu_lines = menu_entries.len(); + let menu_lines = if self.app.is_history_search_active() { + history_search_matches.len().max(1) + } else { + menu_entries.len() + }; let input_rows_budget = composer_input_rows_budget(inner_area.height, menu_lines); let content_width = usize::from(inner_area.width.max(1)); - let (visible_lines, _cursor_row, _cursor_col) = layout_input( - &self.app.input, - self.app.cursor_position, - content_width, - input_rows_budget, - ); - let is_draft_mode = self.app.input.contains('\n') || visible_lines.len() > 1; + let (visible_lines, _cursor_row, _cursor_col) = + layout_input(input_text, input_cursor, content_width, input_rows_budget); + let is_draft_mode = input_text.contains('\n') || visible_lines.len() > 1; if has_panel { - let border_color = if self.app.input.trim().is_empty() { + let border_color = if input_text.trim().is_empty() { palette::BORDER_COLOR } else { self.mode_color() }; - let hint_line = if self.slash_menu_entries.is_empty() { + let hint_line = if self.app.is_history_search_active() { + Some(Line::from(vec![ + Span::styled( + format!( + " {} ", + self.app.tr(crate::localization::MessageId::HistoryHintMove) + ), + Style::default().fg(palette::TEXT_MUTED), + ), + Span::styled( + format!( + "{} ", + self.app + .tr(crate::localization::MessageId::HistoryHintAccept) + ), + Style::default().fg(palette::TEXT_MUTED), + ), + Span::styled( + self.app + .tr(crate::localization::MessageId::HistoryHintRestore), + Style::default().fg(palette::TEXT_MUTED), + ), + ])) + } else if self.slash_menu_entries.is_empty() { None } else { Some(Line::from(vec![ @@ -336,7 +374,14 @@ impl Renderable for ComposerWidget<'_> { let mut block = Block::default() .title(Line::from(Span::styled( - if is_draft_mode { "Draft" } else { "Composer" }, + if self.app.is_history_search_active() { + self.app + .tr(crate::localization::MessageId::HistorySearchTitle) + } else if is_draft_mode { + "Draft" + } else { + "Composer" + }, Style::default().fg(palette::TEXT_MUTED), ))) .borders(Borders::ALL) @@ -351,9 +396,16 @@ impl Renderable for ComposerWidget<'_> { } let mut input_lines = Vec::new(); - if self.app.input.is_empty() { + if input_text.is_empty() { + let placeholder = if self.app.is_history_search_active() { + self.app + .tr(crate::localization::MessageId::HistorySearchPlaceholder) + } else { + self.app + .tr(crate::localization::MessageId::ComposerPlaceholder) + }; input_lines.push(Line::from(Span::styled( - COMPOSER_PLACEHOLDER, + placeholder, Style::default().fg(palette::TEXT_MUTED).italic(), ))); } else { @@ -369,8 +421,15 @@ impl Renderable for ComposerWidget<'_> { // layout_input. For the empty-input placeholder, Paragraph::wrap will // wrap the single Line at render time, so we must estimate the wrapped // row count ourselves to keep padding accurate on narrow widths. - let visual_rows = if self.app.input.is_empty() { - placeholder_visual_lines(content_width) + let visual_rows = if input_text.is_empty() { + let placeholder = if self.app.is_history_search_active() { + self.app + .tr(crate::localization::MessageId::HistorySearchPlaceholder) + } else { + self.app + .tr(crate::localization::MessageId::ComposerPlaceholder) + }; + placeholder_visual_lines_for(placeholder, content_width) } else { input_lines.len() }; @@ -381,7 +440,63 @@ impl Renderable for ComposerWidget<'_> { } lines.extend(input_lines); - if !menu_entries.is_empty() { + if self.app.is_history_search_active() { + if history_search_matches.is_empty() { + lines.push(Line::from(Span::styled( + self.app + .tr(crate::localization::MessageId::HistoryNoMatches), + Style::default().fg(palette::TEXT_MUTED), + ))); + } else { + let selected = self + .app + .history_search_selected_index() + .min(history_search_matches.len().saturating_sub(1)); + let menu_visible_rows = inner_area + .height + .saturating_sub(visual_rows as u16) + .saturating_sub(top_padding as u16) + .saturating_sub(1) + .max(1) as usize; + let menu_total = history_search_matches.len(); + let menu_top = if menu_total <= menu_visible_rows { + 0 + } else { + let half = menu_visible_rows / 2; + if selected <= half { + 0 + } else if selected + half >= menu_total { + menu_total.saturating_sub(menu_visible_rows) + } else { + selected.saturating_sub(half) + } + }; + let menu_bottom = (menu_top + menu_visible_rows).min(menu_total); + + for (idx, entry) in history_search_matches + .iter() + .enumerate() + .take(menu_bottom) + .skip(menu_top) + { + let is_selected = idx == selected; + let style = if is_selected { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + } else { + Style::default().fg(palette::TEXT_MUTED) + }; + let marker = if is_selected { "▸" } else { " " }; + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(marker, style), + Span::styled(" ", style), + Span::styled(entry.clone(), style), + ])); + } + } + } else if !menu_entries.is_empty() { let selected = self .active_menu_selected() .min(menu_entries.len().saturating_sub(1)); @@ -450,10 +565,10 @@ impl Renderable for ComposerWidget<'_> { fn desired_height(&self, width: u16) -> u16 { composer_height( - &self.app.input, + self.app.composer_display_input(), width, self.max_height.min(self.max_height_cap()), - self.active_menu_entries().len(), + self.active_menu_row_count(), self.app.composer_density, self.app.composer_border, ) @@ -461,18 +576,23 @@ impl Renderable for ComposerWidget<'_> { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { let inner_area = self.inner_area(area); + let input_text = self.app.composer_display_input(); + let input_cursor = self.app.composer_display_cursor(); let content_width = usize::from(inner_area.width.max(1)); let input_rows_budget = - composer_input_rows_budget(inner_area.height, self.active_menu_entries().len()); + composer_input_rows_budget(inner_area.height, self.active_menu_row_count()); - let (visible_lines, cursor_row, cursor_col) = layout_input( - &self.app.input, - self.app.cursor_position, - content_width, - input_rows_budget, - ); - let visual_rows = if self.app.input.is_empty() { - placeholder_visual_lines(content_width) + let (visible_lines, cursor_row, cursor_col) = + layout_input(input_text, input_cursor, content_width, input_rows_budget); + let visual_rows = if input_text.is_empty() { + let placeholder = if self.app.is_history_search_active() { + self.app + .tr(crate::localization::MessageId::HistorySearchPlaceholder) + } else { + self.app + .tr(crate::localization::MessageId::ComposerPlaceholder) + }; + placeholder_visual_lines_for(placeholder, content_width) } else { visible_lines.len() }; @@ -1241,11 +1361,17 @@ fn composer_top_padding(content_lines: usize, rows_budget: usize) -> usize { } /// Placeholder text shown when the composer input is empty. +#[cfg(test)] const COMPOSER_PLACEHOLDER: &str = "Write a task or use /."; /// How many visual rows the empty-input placeholder occupies after wrapping. +#[cfg(test)] fn placeholder_visual_lines(content_width: usize) -> usize { - wrap_text(COMPOSER_PLACEHOLDER, content_width).len().max(1) + placeholder_visual_lines_for(COMPOSER_PLACEHOLDER, content_width) +} + +fn placeholder_visual_lines_for(placeholder: &str, content_width: usize) -> usize { + wrap_text(placeholder, content_width).len().max(1) } fn composer_min_input_rows(density: ComposerDensity) -> usize { @@ -1467,6 +1593,7 @@ mod tests { should_render_empty_state, slash_completion_hints, wrap_input_lines, wrap_text, }; use crate::config::Config; + use crate::localization::Locale; use crate::palette; use crate::tui::app::{App, ComposerDensity, TuiOptions}; use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus}; @@ -1823,6 +1950,33 @@ mod tests { assert_eq!(widget.cursor_pos(area), Some((0, 2))); } + #[test] + fn localized_composer_placeholders_render_at_narrow_widths() { + for locale in [Locale::Ja, Locale::ZhHans, Locale::PtBr] { + let mut app = create_test_app(); + app.ui_locale = locale; + app.composer_density = ComposerDensity::Comfortable; + let slash_menu_entries = Vec::::new(); + let mention_menu_entries = Vec::::new(); + let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries); + let area = Rect { + x: 0, + y: 0, + width: 18, + height: 5, + }; + let mut buf = Buffer::empty(area); + + widget.render(area, &mut buf); + let Some((cursor_x, cursor_y)) = widget.cursor_pos(area) else { + panic!("localized composer should expose cursor position"); + }; + + assert!(cursor_x < area.width, "{locale:?} cursor x overflow"); + assert!(cursor_y < area.height, "{locale:?} cursor y overflow"); + } + } + #[test] fn composer_top_padding_uses_clamp() { // content_lines=0 is clamped to 1 diff --git a/crates/tui/src/tui/widgets/pending_input_preview.rs b/crates/tui/src/tui/widgets/pending_input_preview.rs index 785b0e9f..665a1949 100644 --- a/crates/tui/src/tui/widgets/pending_input_preview.rs +++ b/crates/tui/src/tui/widgets/pending_input_preview.rs @@ -3,21 +3,14 @@ //! 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. Three buckets: -//! -//! 1. **Pending steers** — messages submitted *during* a tool call boundary -//! (next round-trip), with hint that Esc force-sends them now. -//! 2. **Rejected steers** — engine declined the steer (e.g., tool already -//! running); will be replayed at end-of-turn. -//! 3. **Queued follow-ups** — ordinary messages held until the turn ends. +//! 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 in -//! v0.6.6 (Phase 2 of #85). The full Esc-to-steer flow is a follow-up -//! (TODO_BACKEND.md §4); v0.6.6 ships the visibility half (`queued_messages`) -//! which is the larger UX win — the user can see their input was captured. +//! 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; @@ -45,8 +38,7 @@ impl EditBinding { pub const ALT_UP: EditBinding = EditBinding { label: "Alt+↑" }; } -/// Widget showing pending steers + rejected steers + queued follow-up -/// messages while a turn is in progress. +/// Widget showing pending input while a turn is in progress. #[derive(Debug, Clone)] pub struct PendingInputPreview { pub context_items: Vec, @@ -65,6 +57,8 @@ pub struct ContextPreviewItem { pub label: String, pub detail: Option, pub included: bool, + pub removable: bool, + pub selected: bool, } impl PendingInputPreview { @@ -78,16 +72,17 @@ impl PendingInputPreview { } } + fn has_pending_inputs(&self) -> bool { + !self.pending_steers.is_empty() + || !self.rejected_steers.is_empty() + || !self.queued_messages.is_empty() + } + /// 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.pending_steers.is_empty() - && self.rejected_steers.is_empty() - && self.queued_messages.is_empty()) - || width < 4 - { + if (self.context_items.is_empty() && !self.has_pending_inputs()) || width < 4 { return Vec::new(); } @@ -108,57 +103,29 @@ impl PendingInputPreview { } } - if !self.pending_steers.is_empty() { + if self.has_pending_inputs() { if !lines.is_empty() { lines.push(Line::from("")); } push_section_header( &mut lines, - Line::from(vec![ - Span::raw("• "), - Span::raw("Messages to be submitted after next tool call"), - Span::styled(" (press Esc to send now)", dim), - ]), + Line::from(vec![Span::raw("• "), Span::raw("Pending inputs")]), ); for steer in &self.pending_steers { push_truncated_item(&mut lines, steer, width, dim, " ↳ ", " "); } - } - - if !self.rejected_steers.is_empty() { - if !lines.is_empty() { - lines.push(Line::from("")); - } - push_section_header( - &mut lines, - Line::from(vec![ - Span::raw("• "), - Span::raw("Messages to be submitted at end of turn"), - ]), - ); for steer in &self.rejected_steers { push_truncated_item(&mut lines, steer, width, dim, " ↳ ", " "); } - } - - if !self.queued_messages.is_empty() { - if !lines.is_empty() { - lines.push(Line::from("")); - } - push_section_header( - &mut lines, - Line::from(vec![Span::raw("• "), Span::raw("Queued follow-up inputs")]), - ); for message in &self.queued_messages { push_truncated_item(&mut lines, message, width, dim_italic, " ↳ ", " "); } - // Edit-last-queued hint only when there's actually something to - // pop — pending steers don't get an Alt+↑ hint because the engine - // owns when they get sent. - lines.push(Line::from(vec![Span::styled( - format!(" {} edit last queued message", self.edit_binding.label), - dim, - )])); + if !self.queued_messages.is_empty() { + lines.push(Line::from(vec![Span::styled( + format!(" {} edit last queued message", self.edit_binding.label), + dim, + )])); + } } lines @@ -194,12 +161,21 @@ fn push_section_header(lines: &mut Vec>, header: Line<'static>) { } fn push_context_item(lines: &mut Vec>, item: &ContextPreviewItem, width: u16) { - let status_style = if item.included { + let status_style = if item.selected { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + .add_modifier(Modifier::BOLD) + } else if item.included { Style::default().fg(palette::TEXT_MUTED) } else { Style::default().fg(palette::STATUS_WARNING) }; - let label_style = if item.included { + let label_style = if item.selected { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + } else if item.included { Style::default().fg(palette::TEXT_PRIMARY) } else { Style::default().fg(palette::TEXT_MUTED) @@ -210,10 +186,21 @@ fn push_context_item(lines: &mut Vec>, item: &ContextPreviewItem, .filter(|detail| !detail.trim().is_empty()) .map(|detail| format!(" · {detail}")) .unwrap_or_default(); - let body = format!("[{}] {}{}", item.kind, item.label, detail); + let action = if item.selected { + " · Backspace/Delete removes" + } else if item.removable { + " · removable" + } else { + "" + }; + 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 { " ↳ " } else { " " }; + let prefix = if idx == 0 { + if item.selected { " ▸ " } else { " ↳ " } + } else { + " " + }; lines.push(Line::from(vec![ Span::styled(prefix.to_string(), status_style), Span::styled(segment, label_style), @@ -350,7 +337,7 @@ mod tests { 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("Queued follow-up inputs")); + assert!(rows[0].contains("Pending inputs")); assert!(rows[1].contains("Hello, world!")); assert!(rows[2].contains("edit last queued message")); } @@ -363,12 +350,16 @@ mod tests { label: "src/main.rs".to_string(), detail: Some("included".to_string()), included: true, + removable: false, + selected: false, }); preview.context_items.push(ContextPreviewItem { kind: "missing".to_string(), label: "nope.txt".to_string(), detail: Some("not found".to_string()), included: false, + removable: false, + selected: false, }); let rows = render_to_string(&preview, 64); assert!(rows[0].contains("Context for next send")); @@ -377,20 +368,38 @@ mod tests { } #[test] - fn pending_steer_shows_esc_hint_no_alt_up_hint() { + fn selected_removable_attachment_renders_delete_hint() { + let mut preview = PendingInputPreview::new(); + preview.context_items.push(ContextPreviewItem { + kind: "image".to_string(), + label: "/tmp/pasted.png".to_string(), + detail: Some("attached media".to_string()), + included: true, + removable: true, + selected: true, + }); + + let rows = render_to_string(&preview, 96); + + assert!( + rows.iter() + .any(|row| row.contains("Backspace/Delete removes")) + ); + assert!(rows.iter().any(|row| row.contains("▸"))); + } + + #[test] + fn pending_steer_renders_without_esc_or_alt_up_hint() { let mut preview = PendingInputPreview::new(); preview.pending_steers.push("Please continue.".to_string()); - // Use a wide-enough column budget that the section header does not - // wrap — keeps the assertions targeted at content rather than at - // wrap boundaries. let rows = render_to_string(&preview, 80); assert!( - rows.iter().any(|r| r.contains("after next tool call")), - "missing pending-steer header: {rows:?}" + rows.iter().any(|r| r.contains("Pending inputs")), + "missing pending input header: {rows:?}" ); assert!( - rows.iter().any(|r| r.contains("Esc")), - "missing Esc hint: {rows:?}" + !rows.iter().any(|r| r.contains("Esc")), + "unexpected Esc hint: {rows:?}" ); assert!( !rows.iter().any(|r| r.contains("Alt+↑")), @@ -399,16 +408,20 @@ mod tests { } #[test] - fn three_sections_render_with_blank_line_separators() { + fn all_pending_inputs_render_as_one_list() { let mut preview = PendingInputPreview::new(); preview.pending_steers.push("steer".to_string()); preview.rejected_steers.push("rejected".to_string()); preview.queued_messages.push("queued".to_string()); let rows = render_to_string(&preview, 60); - // Sections are separated by blank lines + headers + items + final hint. - assert!(rows.iter().any(|r| r.contains("after next tool call"))); - assert!(rows.iter().any(|r| r.contains("end of turn"))); - assert!(rows.iter().any(|r| r.contains("Queued follow-up"))); + assert!(rows[0].contains("Pending inputs")); + assert_eq!( + rows.iter().filter(|r| r.contains("Pending inputs")).count(), + 1 + ); + assert!(rows.iter().any(|r| r.contains("steer"))); + assert!(rows.iter().any(|r| r.contains("rejected"))); + assert!(rows.iter().any(|r| r.contains("queued"))); assert!(rows.iter().any(|r| r.contains("Alt+↑"))); } @@ -421,7 +434,7 @@ mod tests { 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("Queued follow-up")); + assert!(rows[0].contains("Pending inputs")); assert!(rows[1].contains("line1")); assert!(rows[2].contains("line2")); assert!(rows[3].contains("line3")); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f7408c84..01b85940 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -138,16 +138,29 @@ Common settings keys: - `theme` (default, dark, light, whale) - `auto_compact` (on/off, default off) +- `paste_burst_detection` (on/off, default on): fallback rapid-key paste + detection for terminals that do not emit bracketed-paste events. This is + independent of terminal bracketed-paste mode. - `show_thinking` (on/off) - `show_tool_details` (on/off) +- `locale` (`auto`, `en`, `ja`, `zh-Hans`, `pt-BR`; default `auto`): UI chrome + locale. `auto` checks `LC_ALL`, `LC_MESSAGES`, then `LANG`; unsupported or + missing locales fall back to English. This does not force model output + language. - `default_mode` (agent, plan, yolo; legacy `normal` is accepted and normalized to `agent`) -- `max_history` (number of input history entries) +- `max_history` (number of submitted input history entries; cleared drafts are + also kept locally for composer history search) - `default_model` (model name override) Only `agent`, `plan`, and `yolo` are visible modes in the UI. For compatibility, older settings files with `default_mode = "normal"` still load as `agent`, and the hidden `/normal` slash command switches to `Agent`. +Localization scope is tracked in [LOCALIZATION.md](LOCALIZATION.md). The v0.7.6 +core pack covers high-visibility TUI chrome only; provider/tool schemas, +personality prompts, and full documentation remain English unless explicitly +translated later. + Readability semantics: - Selection uses a unified style across transcript, composer menus, and modals. @@ -290,6 +303,9 @@ to the next message. Use `/attach ` for local image/video media paths, or `Ctrl+V` to attach an image from the clipboard. DeepSeek's public Chat Completions API currently accepts text message content, so media attachments are sent as explicit local path references instead of native image/video payloads. +Attachment rows appear above the composer before submit; move to the start of +the composer, press `↑` to select an attachment row, then press `Backspace` or +`Delete` to remove it without editing the placeholder text by hand. ## Managed Configuration and Requirements diff --git a/docs/LEGACY_RUST_AUDIT_0_7_6.md b/docs/LEGACY_RUST_AUDIT_0_7_6.md new file mode 100644 index 00000000..e189ca91 --- /dev/null +++ b/docs/LEGACY_RUST_AUDIT_0_7_6.md @@ -0,0 +1,37 @@ +# v0.7.6 Legacy Rust Audit + +Status date: 2026-04-29 + +This audit is deliberately non-destructive. No compatibility code is removed in v0.7.6 unless tests prove public CLI, saved-session, tool-schema, and documented command paths no longer depend on it. + +## Summary + +| Surface | Owner module | Current consumer | Reference check | Compatibility reason | Current warning | Recommended action | +|---|---|---|---|---|---|---| +| Legacy MCP sync API (`McpServerInput`, `list`, `add`, `remove`, `call_tool`, `load_legacy`) | `crates/tui/src/mcp.rs` | Not wired into current `/mcp` command path; retained behind `#[allow(dead_code)]` | Direct Rust references and current MCP command path inspected; saved/config JSON compatibility still needs a dedicated smoke | Preserves old JSON shape including `mcpServers` alias and sync call helpers while the async MCP manager is the active path | Code TODO only | Gate behind an explicit legacy module or remove after CLI/runtime parity tests prove no caller uses it. Tracked by #218. | +| Legacy prompt constants/functions (`AGENT_PROMPT`, `YOLO_PROMPT`, `PLAN_PROMPT`, `base_system_prompt`, `normal_system_prompt`, etc.) | `crates/tui/src/prompts.rs` | Tests and older callers that still import prompt constants directly | Direct Rust references remain; public-crate and older harness imports are not proven absent | Layered prompt API replaced monolithic prompts, but older call sites may still compile against constants | None | Keep for v0.7.6; add deprecation annotations only after internal callers are migrated. Tracked by #219. | +| `/compact` slash command positioning | `crates/tui/src/commands/mod.rs` | Public slash-command registry and help overlay | Public command registry/docs path still references it | Current cycle/seam policy prefers restart/cycle flows, but users may still run `/compact` manually | Description says legacy and points at cycle restart | Keep as a manual compatibility command; do not remove until context/token issues are resolved. | +| `todo_*` compatibility tools | `crates/tui/src/tools/todo.rs` | Tool registry/model calls that still use `todo_add`, `todo_update`, `todo_list`, `todo_write` | Tool registry compatibility and saved tool-call risk remain | `checklist_*` is canonical, but old tool names may appear in saved prompts, traces, or model priors | Metadata marks `compat_alias: true`; descriptions say compatibility alias | Add explicit deprecation metadata with target version, then remove only after tool-schema migration evidence. Tracked by #220. | +| Deprecated sub-agent alias tools (`spawn_agent`, `send_input`, delegate aliases) | `crates/tui/src/tools/subagent/mod.rs` | Tool registry and model/tool-call compatibility | Tool registry compatibility and saved tool-call risk remain | Canonical names are `agent_spawn`, `agent_send_input`, etc.; alias names preserve older tool-call compatibility | `_deprecation` metadata and tracing warn; removal target is `v0.8.0` | Keep through v0.7.x; removal already has metadata. Tracked by #221. | +| Legacy root/provider TOML `api_key` compatibility | `crates/tui/src/config.rs`, `crates/config/src/lib.rs` | Config resolver; users with existing `api_key` in config files | Public config loading and docs still mention migration behavior | Keyring migration is preferred, but breaking existing configs would block startup/auth | Tracing warnings point to `deepseek auth set` / `deepseek auth migrate` | Keep; warnings are user-actionable. Removal should wait for a migration command and release-note window. | +| Model alias canonicalization (`deepseek-chat`, `deepseek-reasoner`, older V3/R1 aliases) | `crates/tui/src/config.rs`, `crates/config/src/lib.rs` | Config/env/model picker normalization | Public docs and existing configs may still use aliases | Preserves old documented DeepSeek aliases and maps them to `deepseek-v4-flash` | Silent alias by design | Keep; removing aliases would break configs without meaningful benefit. | +| Deprecated palette constants and aliases | `crates/tui/src/palette.rs`, `crates/tui/tests/palette_audit.rs` | Existing call sites plus audit tests | Palette audit enforces the remaining allowlist | Semantic aliases are preferred, but old constants exist to prevent broad style churn | Palette audit blocks direct deprecated uses outside allowlist | Keep aliases; continue moving call sites to semantic roles opportunistically. | + +## Follow-Up Removal Candidates + +These are not safe to remove in v0.7.6: + +1. #218 Legacy MCP sync API: requires a call-graph check and explicit CLI/runtime parity tests for `/mcp`, `deepseek mcp`, and MCP server validation flows. +2. #219 Legacy prompt constants/functions: requires proving no public crate or older test harness imports them. +3. #220 `todo_*` tool aliases: requires deprecation metadata and a saved-trace/tool-schema migration window. +4. #221 Deprecated sub-agent alias tools: removal target is already encoded as `v0.8.0`, but the actual removal should be tracked and tested separately. + +## Verification Checklist + +Before removing any compatibility surface: + +1. Search direct Rust references with `rg`. +2. Search docs and README command examples. +3. Run workspace tests with all features. +4. Run a saved-session/tool-call compatibility smoke if the surface affects tool schemas or persisted history. +5. Keep a release-note entry and, for user-visible config/tool changes, a migration hint for at least one minor release. diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md new file mode 100644 index 00000000..c3f683a9 --- /dev/null +++ b/docs/LOCALIZATION.md @@ -0,0 +1,91 @@ +# Localization Matrix + +Status date: 2026-04-29 + +This document tracks UI localization only. It does not change model output language, provider behavior, or DeepSeek payload support. Media attachments remain local path text references unless native media payload support is added separately. + +## Source Audit + +The v0.7.6 parity check used live GitHub sources with `/opt/homebrew/bin/gh`. + +| Project | Ref | Evidence | Result | +|---|---:|---|---| +| Codex CLI | `openai/codex@df966996a75333add031fca47b72655e9ee504fd` | `gh repo view openai/codex`; recursive tree scan for `locale`, `i18n`, `l10n`, `translation`, `messages`; README language scan | No checked-in CLI UI localization registry found in the audited tree. Treat Codex CLI parity as English-first terminal UI behavior, not a source for shipped locale tags. | +| opencode | `anomalyco/opencode@00bb9836a60f1dcdd0ce5078b05d12f749fdde66` | `packages/console/app/src/lib/language.ts`, `packages/app/src/context/language.tsx`, `packages/web/src/i18n/locales.ts`, `packages/app/src/i18n/parity.test.ts` | opencode ships app/docs locale infrastructure with language detection, locale labels, docs locale aliases, RTL direction for Arabic, and parity tests for targeted keys. | + +## v0.7.6 Shipped Core Pack + +These locales are supported by `locale` in `settings.toml` and by `LANG` / `LC_ALL` auto-detection. + +| Locale | Display | Script | Direction | Fallback | Priority tier | v0.7.6 scope | Notes | +|---|---|---|---|---|---|---|---| +| `en` | English | Latin | LTR | `en` | Baseline | Source strings remain canonical. | English is always available. | +| `ja` | Japanese | Jpan | LTR | `en` | v0.7.6 must-have | Core TUI chrome | Covers composer placeholder/history search, help chrome, and `/config` chrome. | +| `zh-Hans` | Chinese Simplified | Hans | LTR | `en` | v0.7.6 must-have | Core TUI chrome | `zh`, `zh-CN`, and `zh-Hans` resolve here. Traditional Chinese is not shipped. | +| `pt-BR` | Portuguese (Brazil) | Latin | LTR | `en` | v0.7.6 must-have | Core TUI chrome | `pt` and `pt-PT` currently fall back to Brazilian Portuguese; European Portuguese is not separately shipped. | + +Selection: + +```toml +locale = "auto" # default; checks LC_ALL, LC_MESSAGES, then LANG +locale = "ja" +locale = "zh-Hans" +locale = "pt-BR" +``` + +Fallback: + +- Missing or unsupported configured locales fall back to English. +- `auto` falls back to English when no supported environment locale is detected. +- UI locale is separate from model prompt language. Users still ask the model for a response language in the prompt. + +## Planned Global South QA Matrix + +These are not claimed as shipped translations in v0.7.6 unless a later change adds complete message coverage and QA evidence. + +| Locale | Display | Script | Direction | Priority tier | Coverage status | Fallback | QA status | Layout risks | +|---|---|---|---|---|---|---|---|---| +| `ar` | Arabic | Arab | RTL | Follow-up | Planned | `en` | Automated renderer sample only; native review required before shipping | RTL ordering, punctuation, key-chord mixing | +| `hi` | Hindi | Deva | LTR | Follow-up | Planned | `en` | Automated renderer sample only; native review preferred before shipping | Combining marks, cursor width, truncation | +| `bn` | Bengali | Beng | LTR | Follow-up | Planned | `en` | Matrix only; native review required before shipping | Combining marks, line wrapping | +| `id` | Indonesian | Latin | LTR | Follow-up | Planned | `en` | Matrix only; automated narrow-width snapshots and reviewer pass required | Longer labels than English | +| `vi` | Vietnamese | Latin | LTR | Follow-up | Planned | `en` | Matrix only; automated width snapshots and reviewer pass required | Diacritics and wrapped labels | +| `sw` | Swahili | Latin | LTR | Follow-up | Planned | `en` | Matrix only; native or fluent review required before shipping | Translation quality, longer command descriptions | +| `ha` | Hausa | Latin | LTR | Follow-up | Planned | `en` | Matrix only; native or fluent review required before shipping | Diacritics and terminology | +| `yo` | Yoruba | Latin | LTR | Follow-up | Planned | `en` | Matrix only; native or fluent review required before shipping | Tone marks and terminology | +| `fil` | Filipino/Tagalog | Latin | LTR | Follow-up | Planned | `en` | Matrix only; source strings required before shipping | Terminology consistency | +| `es-419` | Spanish (Latin America) | Latin | LTR | Follow-up | Planned | `en` | Matrix only; reviewer pass required before shipping | Regional terminology | +| `fr` | French | Latin | LTR | Follow-up | Planned | `en` | Matrix only; reviewer pass required before shipping | African locale terminology varies | + +## Message Coverage + +The first registry pass covers stable message IDs for high-visibility terminal chrome: + +- composer placeholder +- composer history search title, placeholder, hints, and no-match state +- `/config` title, filter placeholder, no-match state, filtered count, and footer hints +- help overlay title, filter placeholder, no-match state, section labels, and footer hints + +Not yet translated in v0.7.6: + +- model/system prompts and personalities +- provider or tool schemas +- full slash-command descriptions and every status/toast/error path +- README/docs content beyond this configuration note + +## Translator Notes + +Keep these technical terms stable unless a later glossary explicitly changes +them: `Plan`, `Agent`, `YOLO`, `/config`, `/mcp`, `@path`, `/attach`, `DeepSeek`, +`MCP`, `CLI`, `TUI`, and key chords such as `Enter`, `Esc`, `Tab`, `PgUp`, and +`PgDn`. + +## QA Checklist + +Before promoting a planned locale to shipped: + +1. Add complete message coverage in `crates/tui/src/localization.rs`. +2. Add locale resolution tests and missing-key tests. +3. Add narrow-width render coverage for at least composer, help, and `/config`. +4. Verify CJK width, RTL punctuation, combining marks, and truncation. +5. Record native/fluent review status, or mark the locale as automated-QA-only. diff --git a/docs/MODES.md b/docs/MODES.md index 1b664cb9..a4c943ef 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -7,7 +7,9 @@ DeepSeek TUI has two related concepts: ## TUI Modes -Press `Tab` to cycle through the visible modes: **Plan → Agent → YOLO → Plan**. +Press `Tab` to complete composer menus, queue a draft as a next-turn follow-up +while a turn is running, or cycle through the visible modes when the composer is +otherwise idle: **Plan → Agent → YOLO → Plan**. Press `Shift+Tab` to cycle reasoning effort. - **Plan**: design-first prompting. Read-only investigation tools stay available; shell and patch execution stay off. Use this when you want to think out loud and produce a plan to hand to a human (yourself later, or a reviewer).