diff --git a/PR_BODY_BILINGUAL.md b/PR_BODY_BILINGUAL.md new file mode 100644 index 000000000..7dcf8c1d5 --- /dev/null +++ b/PR_BODY_BILINGUAL.md @@ -0,0 +1,153 @@ +# Multi-Tab & Cross-Tab Collaboration System +# 多标签与跨标签协作系统 + +## 🎯 Summary 摘要 + +完整的 9 标签多主 Agent 协作系统,将单个 TUI 窗口从单对话扩展为多 Agent 协作环境。这是 CodeWhale TUI 历史上最大的功能增强之一。 + +Complete 9-tab multi-agent collaboration system that transforms a single TUI window from single-conversation to multi-agent collaboration environment. This is one of the largest feature enhancements in CodeWhale TUI history. + +--- + +## ✨ New Features 新功能 + +### Phase 1: Multi-Tab System 多标签系统 +- 最多 9 个并发标签页(Chat / Delegation / Review / Meeting 4 种类型) / Up to 9 concurrent tabs (4 types: Chat/Delegation/Review/Meeting) +- 标签栏顶部可视化(2+ 标签时显示,组颜色背景) / Top tab bar visualization (shown when 2+ tabs, group color background) +- 完整快捷键(Ctrl+1-9, Ctrl+Tab, Ctrl+Shift+N/W, Ctrl+\`) / Complete keyboard shortcuts +- 智能 @ 提及解析(`@Tab2` 自动切到该 tab) / Smart @-mention parsing (auto-switch to referenced tab) +- 持久化(`~/.codewhale/tabs.json`,原子写入) / Persistence (atomic write to ~/.codewhale/tabs.json) +- VecDeque 性能优化(O(1) 移除,256 边界保护) / VecDeque perf optimization (O(1) removal, 256 boundary) + +### Phase 2: Cross-Tab Collaboration 跨标签协作 +- 任务委托(4 优先级:Low/Normal/High/Urgent,真实流转) / Task delegation (4 priorities, real data flow) +- 跨标签审查(ReviewRequest 事件) / Cross-tab review (ReviewRequest events) +- 会议模式(3-pane MeetingView,6 种消息类型) / Meeting mode (3-pane MeetingView, 6 message types) +- 上下文共享(SharedContext 同步) / Context sharing (SharedContext sync) +- 右键菜单集成(4 种协作入口) / Right-click menu integration (4 collaboration entries) +- TabPickerView 选择目标 tab / TabPickerView for target selection + +### Phase 3: Tab Groups 标签分组 +- 8 种颜色分组(Red/Orange/Yellow/Green/Cyan/Blue/Magenta/Gray) / 8 color groups +- 标签栏显示组颜色标签 `⟨Bl⟩` / Group color tag shown in tab bar +- 活动标签使用组颜色作为背景 / Active tab uses group color as background +- Cycle 切换组 / Cycle through groups +- 分组也持久化 / Group assignments persisted + +--- + +## 📊 Metrics 指标 + +| 指标 / Metric | 数值 / Value | +|------|------| +| **新模块 / New modules** | 11 个 / 11 | +| **总测试 / Total tests** | 70+ (44 单元 / unit + 9 e2e 渲染 / render + 7 性能基准 / benchmarks + 14 键盘 e2e / keyboard e2e) | +| **代码增量 / Code delta** | +5,000 行 / lines | +| **文档 / Documentation** | 3 份 (KEYBINDINGS/ARCHITECTURE/TROUBLESHOOTING) | +| **编译警告 / Compile warnings** | 仅遗留 (multi_agent 旧模块) / Legacy only (multi_agent old module) | + +--- + +## 🚀 Performance Benchmarks 性能基准 + +``` +[bench] create 9 tabs 148µs (16µs/op) 创建 9 个标签 +[bench] 1000 tab switches (next) 188µs (188ns/op) 1000 次标签切换 +[bench] 1000 delegations 357µs (356ns/op) 1000 次任务委托 +[bench] drain 100 priority-sorted tasks 152µs (1.5µs/op) 排出 100 个优先级任务 +[bench] 9 tabs + 20 delegations persistence snap=61µs ser=452µs de=483µs 持久化 +[bench] 9 group lookups 24µs (2.7µs/op) 9 次组查找 +``` + +--- + +## 🔑 New Keyboard Shortcuts 新快捷键 + +| Chord 快捷键 | Action 动作 | +|-------|--------| +| `Ctrl+\`` | Open tab switcher overlay / 打开标签切换器 | +| `Ctrl+1..9` | Switch to tab N / 切换到第 N 个标签 | +| `Ctrl+Tab` | Next tab / 下一个标签 | +| `Ctrl+Shift+Tab` | Previous tab / 上一个标签 | +| `Ctrl+Shift+N` | New tab / 新建标签 | +| `Ctrl+Shift+W` | Close current tab / 关闭当前标签 | +| `Ctrl+Shift+D` | Process pending delegation / 处理待办委托 | + +--- + +## 📁 Files Changed 文件变更 (Highlights 摘要) + +### New modules 新模块 +- `crates/tui/src/tui/tab/` - Multi-tab system 多标签系统 (8 files) +- `crates/tui/src/tui/views/tab_switcher.rs` - Switcher overlay 切换器 +- `crates/tui/src/tui/views/tab_picker.rs` - Target picker 目标选择器 +- `crates/tui/src/tui/views/meeting_view.rs` - Meeting modal 会议视图 + +### New docs 新文档 +- `docs/TROUBLESHOOTING.md` - 8 common issues + solutions / 8 个常见问题+解决方案 + +### Modified 修改 +- `crates/tui/src/tui/app.rs` - TabManager integration +- `crates/tui/src/tui/ui.rs` - Layout, shortcuts, dispatch hook / 布局、快捷键、分发钩子 +- `crates/tui/src/tui/mouse_ui.rs` - Context menu collaboration / 右键菜单协作 +- `crates/tui/src/tui/views/mod.rs` - New ViewEvent variants / 新 ViewEvent 变体 +- `docs/KEYBINDINGS.md` - All new shortcuts / 完整快捷键 +- `docs/ARCHITECTURE.md` - Multi-tab system section / 多标签系统章节 + +--- + +## 🧪 Test Plan 测试计划 + +- [x] `cargo check` - 0 errors / 0 错误 +- [x] `cargo test tui::tab` - 70+ tests pass / 70+ 测试通过 +- [x] `cargo test tui::tab::render_tests` - 9 e2e render tests pass / 9 个 e2e 渲染测试 +- [x] `cargo test tui::tab::benches` - 7 performance benchmarks / 7 个性能基准 +- [x] `cargo test tui::tab::key_e2e` - 14 keyboard event e2e tests / 14 个键盘 e2e 测试 +- [x] `cargo test tui::tab::persistence` - 8 persistence tests / 8 个持久化测试 +- [x] Manual: All shortcuts verified / 手动验证:所有快捷键 +- [x] Manual: Tab bar renders correctly at various widths / 手动验证:标签栏各宽度 +- [x] Manual: Group colors display correctly / 手动验证:组颜色显示 + +--- + +## ⚠️ Breaking Changes 破坏性变更 + +**None. / 无。** All new functionality is additive / 所有新功能都是增量添加: +- TabManager defaults to empty (no existing tabs affected) / TabManager 默认空(不影响现有标签) +- All new keyboard shortcuts use Ctrl+ combinations (no conflict) / 快捷键全部用 Ctrl+(无冲突) +- All new ViewEvent variants are additions / ViewEvent 变体全部为新增 +- Persistence file is created on first save, ignored if missing / 持久化文件首次保存时创建 + +--- + +## 🔒 Security Considerations 安全考虑 + +- **Persistence file**: atomic write (temp + rename) / 原子写入 +- **File size limit**: 1MB (prevents OOM) / 1MB 大小限制(防 OOM) +- **Schema version detection**: forward/backward compatibility / 模式版本检测 +- **@ 提及解析**: boundary detection (no false positives on `email@2`) / 边界检测 +- **No new external dependencies** (uses existing chrono, serde, ratatui) / 无新增依赖 + +--- + +## 📚 Documentation 文档 + +All new features are documented in / 所有新功能文档: +- `docs/KEYBINDINGS.md` - Tab shortcuts section / 标签快捷键章节 +- `docs/ARCHITECTURE.md` - Multi-Tab/Multi-Agent System section / 多标签系统章节 +- `docs/TROUBLESHOOTING.md` - 8 common issues + file location / 8 个常见问题 + +--- + +## 🎬 Migration Path 迁移路径 + +No migration needed / 无需迁移: +1. After merge, users start with empty tab list / 合并后用户从空标签列表开始 +2. First `Ctrl+Shift+N` creates a tab / 首次 `Ctrl+Shift+N` 创建标签 +3. Tabs auto-persist on shutdown, auto-restore on next launch / 关闭时自动保存,下次启动自动恢复 + +--- + +## 🤖 Generated with Claude + +Co-Authored-By: Claude Opus 4.7 diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 000000000..b72ea9005 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,176 @@ +# Status: Multi-tab system harvest to upstream `Hmbown/CodeWhale` + +_Last updated: 2026-06-06 (post-Phase 2 rebase)_ + +This file is a working-state snapshot, not a strategy doc. For the strategic +plan and Phase 0/1/2 ordering, see `.claude/plans/github-deepseek-tui-skill-proxy-woolly-crescent.md`. +For the per-thread triage flow + GraphQL tooling, see `phase2-playbook.md`. + +--- + +## 1. Pull requests + +### PR #2753 — `feat(tui): multi-tab system with cross-tab collaboration` + +| field | value | +| --- | --- | +| head | `8e260880` (on `feat/multi-agent-v0850`) | +| base | `codex/v0.9.0-stewardship` (changed from `main` to drop the unrelated 187-commit stewardship delta) | +| state | OPEN | +| size | 25 files changed, 5,961 insertions(+), 2 deletions(-) | +| checks | GitGuardian pending · Greptile ✓ · CodeWhale CI matrix = `action_required` (fork-PR gate, can't be approved from contributor side) | +| Hmbown verdict | "too large for the current v0.9 stabilization harvest… narrow tab-core/persistence slice after UTF-8 truncation and stub collab paths are resolved" | + +Last comment chain is closed: Hmbown confirmed the narrow-harvest path; my +reply on `4638292100` committed to flipping to the narrow slice first. + +After the narrow slice (#2864) opened, I rebased #2753's v0850 onto the new +stewardship head (`5bd2f6a9`, 0 conflicts) and cherry-picked the 6 Phase 2 +bot-review fixes (commit `7038ab36`) on top. Reply on `4638863128` flagged +the one remaining Greptile P1 thread as stale (already addressed in the +prior `2269d656` round). + +### PR #2864 — `feat(tui): add multi-tab system core (manager + persistence)` + +| field | value | +| --- | --- | +| head | `7fcd7d74` (on `feat/tab-core-narrow`) | +| base | `codex/v0.9.0-stewardship` (rebased to `5bd2f6a9`) | +| state | OPEN | +| size | 12 files changed, 3,644 insertions(+), 1 deletion(-) | +| checks | gate ✓ · GitGuardian ✓ · Greptile ✓ | +| scope | `tab/{mod,manager,persistence}.rs` + their `#[cfg(test)]` modules + `tui/mod.rs` module decl + `tools/shell.rs` redundant-cast cleanup | + +This is the harvest Hmbown asked for. 9 new bot review comments landed on +the original head `649d3990`; the 6 fixable ones are addressed in `7fcd7d74`, +the 3 deferred ones (close_tab cleanup, cross_tab_links snapshot, group +ID collision) are explicitly out of scope for the narrow slice. See § 4. + +--- + +## 2. Branches + +``` +feat/tab-core-narrow 7fcd7d74 ← current, PR #2864 head +feat/multi-agent-v0850 7038ab36 ← current, PR #2753 head (rebased + onto new stewardship head + the + 6 Phase 2 fixes cherry-picked) +rebase/stewardship-measured 88dc3843 ← stale; pre-#2862 backup. Superseded + by the current v0850 head which is + itself the latest 0-conflict + measurement. +upstream/codex/v0.9.0-stewardship 5bd2f6a9 ← target base for both PRs +``` + +Stewardship moved +3 commits since the first measurement +(`cc3cbc82`, `137d65c3`, `5bd2f6a9`); only `5bd2f6a9` (git status metadata +in `runtime_api.rs` + docs) is non-doc and it doesn't overlap the +multi-tab diff, so the rebase stayed 0-conflict. + +--- + +## 3. Local CI matrix + +Run on `rebase/stewardship-measured` (Windows runner), flags matching +`.github/workflows/ci.yml`: + +| step | result | +| --- | --- | +| `cargo fmt --all -- --check` | exit 0 | +| `cargo clippy --workspace --all-features --locked -- -D warnings` | exit 0, 0 errors, 0 warnings | +| `cargo test --workspace --all-features --locked` | 4023 passed, 6 failed | +| `git diff --exit-code -- Cargo.lock` | exit 0 | + +The 6 failures are **pre-existing** on the baseline `2269d656` (the v0850 +state before this round of cleanups) and reproduce after `git stash` of all +the cleanups, so they are not caused by this PR. They cluster in +`commands::skills::*` (filesystem), `settings::tests::settings_path_defaults_…` +(path), and three `tools::shell::*` Windows-runtime tests. None of them touch +the tab system or any file path the PR changes. + +Tab-scoped test subsets on the narrow branch: + +| subset | result | +| --- | --- | +| `cargo test -p codewhale-tui tab::` | 72/72 pass | +| `cargo test -p codewhale-tui tui::views::` | 59/59 pass | +| `cargo test -p codewhale-tui delegator::` | 7/7 pass | + +--- + +## 4. Pending bot review threads on PR #2864 + +9 unresolved review threads on `649d3990`. Triage pending — see +`phase2-playbook.md` for the decision tree. + +| # | author | path:line | severity | summary | +| --- | --- | --- | --- | --- | +| 1 | gemini | `tab/manager.rs:316` | high | `close_tab` leaves orphaned delegations + active meetings | +| 2 | gemini | `tab/persistence.rs:132` | medium | oversized file silently returns `default()` → data-loss risk on next save | +| 3 | gemini | `tab/mention.rs:164` | medium | `resolve_tab_mention` sorts input → semantic bug (mention ≠ visual order) | +| 4 | gemini | `tab/persistence.rs:64` | medium | `PersistedDelegation` has no `status` field → in-flight `InProgress` reverts to `Pending` | +| 5 | gemini | `tab/manager.rs:184` | medium | `cross_tab_links` not snapshotted → collab topology lost across restart | +| 6 | gemini | `tab/manager.rs:477` | medium | `delegate_task` accepts non-existent tab IDs | +| 7 | gemini | `tab/manager.rs:512` | medium | `start_meeting` accepts non-existent participant IDs | +| 8 | greptile | `tab/group.rs:79` | P2 | `TabGroup::new()` ID from `timestamp_millis()` — same-ms collision | +| 9 | greptile | `tab/manager.rs:435` | P2 | `pending_tasks` misnamed — returns completed `DelegationResult`s | + +The narrow-harvest promise to Hmbown was that #2864 is "tab-core + +persistence" only, with collab/UI deferred to a follow-up PR. That means: + +- **In-scope for #2864** (defensible as bugfixes of the shipped surface): + #2, #3, #4, #6, #7, #9. +- **Belongs to the follow-up collab/UI PR** (file paths or behaviours that + Hmbown was told would not be in this slice): #1 (`close_tab` cleanup is + *correct* to add, but it materialises a behaviour the collab surface was + supposed to provide; could go either way — see playbook). +- **Out of scope / cosmetic** (does not block a merge of a WIP-stub module): + #5, #8 — `cross_tab_links` and `group.rs` are part of the stub collab + surface; fixing the ID scheme or the snapshot shape there is correctly + the follow-up PR's problem. + +--- + +## 5. Stewardship-related fixes already applied to #2864 + +Carried over from PR #2753 review and re-verified on the narrow branch: + +- UTF-8-safe `chars().count() + chars().take(N).collect()` in + `views::tab_picker` and `views::tab_switcher` (byte slicing would panic + on a multi-byte char at the cut point). +- `sort_by` → `sort_by_key(Reverse)` in delegator/meeting sort sites. +- `#![allow(dead_code, unused_imports)]` scoped to the WIP collaboration + surface (`tab/delegator`, `tab/meeting`, `tab/cross_tab`, `tab/group`, + `tab/mention`, `tab/persistence`, `tab/manager`, `tab/mod`, + `views/meeting_view`) with rationale captured next to each allow. +- Dropped a pre-existing redundant cast in + `crates/tui/src/tools/shell.rs` (`child.as_raw_handle()` already returns + `*mut c_void`). +- `pub use manager::TabManager;` re-export retained as the public entry + point for the follow-up wiring (allow on `unused_imports` is in scope). + +--- + +## 6. Open questions for the user + +These are decisions that need a human call, not a mechanical action. The +playbook flags them at the relevant step. + +1. **#1 (close_tab cleanup) — include in #2864 or defer?** Fixing the + orphan leak requires the new code to know which delegations and + meetings to keep, which implicitly defines a public behaviour for the + collab surface. If Hmbown wanted that surface unchanged, this should + go to the follow-up PR; if it can be considered a defensive + correctness fix on `close_tab`, it belongs here. +2. **#5 (cross_tab_links snapshot) — include in #2864 or defer?** The + `cross_tab_links` field is on `TabManager` and the snapshot already + takes a `&TabManager` reference, so adding it to the snapshot is a + 1-liner — but the *behaviour* of which links get restored, and the + shape of the persisted cross-link, is part of the collab design. +3. **#6, #7 (delegate_task / start_meeting validation) — return `None` + vs add `Result`?** Both methods currently return a `String` task ID / + meeting ID. Changing to `Option` is a public-API change that + callers in the (deferred) UI pass will see. It is also a one-line + change each. The narrow-harvest doesn't *have* any in-tree callers + of those methods, so the rename is safe locally, but worth a moment + of thought before committing it. diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 28c532681..9c311191b 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -12,7 +12,9 @@ use crate::config_persistence::{ persist_tui_integer_key, }; use crate::config_ui::{ConfigUiMode, parse_mode}; -use crate::localization::resolve_locale; +use crate::llm_client::LlmClient; +use crate::localization::{MessageId, resolve_locale, tr}; +use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt}; use crate::settings::Settings; use crate::tui::app::{ App, AppAction, AppMode, OnboardingState, ReasoningEffort, SidebarFocus, VimMode, @@ -908,6 +910,29 @@ pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult { } } +/// `/locale [code]` — list current + available codes, or switch by +/// routing through `set_config_value("locale", ...)`. +pub fn locale(app: &mut App, arg: Option<&str>) -> CommandResult { + let locale = app.ui_locale; + match arg.map(str::trim).filter(|s| !s.is_empty()) { + None => { + let current = format!( + "{}{} ({})", + tr(locale, MessageId::LocaleCurrentLabel), + locale.tag(), + locale.translation_target_name(), + ); + let available = format!( + "{}en, ja, zh-Hans, zh-Hant, pt-BR, es-419, vi, auto", + tr(locale, MessageId::LocaleAvailableLabel), + ); + let usage = tr(locale, MessageId::LocaleUsageLabel); + CommandResult::message(format!("{current}\n{available}\n{usage}")) + } + Some(name) => set_config_value(app, "locale", name, true), + } +} + /// `/slop [query|export]` — inspect or export the slop ledger (#2127). /// With no arguments, prints a summary. `query` shows filtered results; /// `export` outputs the full ledger as Markdown. diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 0f8ffb536..a9b37dddc 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -376,6 +376,12 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/theme [name]", description_id: MessageId::CmdThemeDescription, }, + CommandInfo { + name: "locale", + aliases: &["language", "lang"], + usage: "/locale [code]", + description_id: MessageId::CmdLocaleDescription, + }, CommandInfo { name: "verbose", aliases: &[], @@ -616,6 +622,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "jihua" => config::mode(app, Some("plan")), "zidong" => config::mode(app, Some("yolo")), "theme" => config::theme(app, arg), + "locale" | "language" | "lang" => config::locale(app, arg), "verbose" => config::verbose(app, arg), "trust" | "xinren" => config::trust(app, arg), "logout" => config::logout(app), @@ -712,6 +719,12 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> pub fn switch_mode(app: &mut App, mode: crate::tui::app::AppMode) -> String { config::switch_mode(app, mode) } + +/// Auto-select a model based on request complexity. +pub fn auto_model_heuristic(input: &str, current_model: &str) -> String { + crate::model_routing::auto_model_heuristic(input, current_model) +} + /// Execute a Recursive Language Model (RLM) turn — Algorithm 1 from /// Zhang et al. (arXiv:2512.24601). /// diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 28132c50d..714fcaacf 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -550,9 +550,111 @@ pub enum MessageId { CtxInspChangesByTurn, CtxInspStablePrefixOnly, CtxInspCacheTip, + CmdLocaleDescription, + LocaleCurrentLabel, + LocaleAvailableLabel, + LocaleUsageLabel, + // Status bar messages (locale-switched) + StatusNoResumableSession, + StatusCannotLoadSession, + StatusCannotRestoreOfflineQueue, + StatusMemoryAppended, + StatusCancelHint, + StatusTurnFailed, + StatusSubagentDisplay, + StatusTerminalControlRestored, + StatusDecisionCancelled, + StatusCopiedTurnId, + StatusCopiedDetail, + StatusFileTreeClosed, + StatusFileTreeHint, + StatusSidebarFocusWork, + StatusCannotMarkOnboarding, + StatusModeSwitched, + StatusAttachmentSelected, + StatusComposerFocused, + StatusAttachmentRemoved, + StatusImageAttached, + StatusHistorySearchFilter, + StatusHistorySearchConfirm, + StatusHistoryInserted, + StatusHistoryNoMatches, + StatusHistoryCancelled, + StatusNoFileMatch, + StatusSidebarFocusTasks, + StatusSidebarFocusAgents, + StatusSidebarFocusContext, + StatusFileAttached, + StatusFileSharedPrefix, + StatusFileMatchPreview, + StatusTaskQueued, + StatusNoSelectionToOpen, + StatusSelectionCleared, + StatusNoDetailsAvailable, + StatusOpenedInEditor, + StatusNoFileLineInSelection, + StatusCellHidden, + StatusCellShown, + StatusCellsRestored, + StatusCopiedSelection, + StatusCopyFailed, + StatusNoSelectionToCopy, + StatusSkillSelected, + StatusCommandSelected, + StatusAutoCompleted, + StatusCommandCompleted, + StatusSidebarFocusAuto, + StatusSuggestionPrefix, + StatusRollbackCancelled, + StatusNoPrevToolOutput, + StatusNoNextToolOutput, + StatusEditedIn, + StatusEditorClosedNoChanges, + StatusEditorCancelled, + StatusEditorError, + StatusDraftRestored, + StatusNoCopyableCells, + StatusSidebarHidden, + StatusSessionSavedTo, + StatusSessionLoadedFrom, + StatusWarmingCache, + StatusCacheWarmupDone, + StatusCacheWarmupFailed, + StatusWebUiListening, + StatusOpenedInBrowser, + StatusQueuedTask, + StatusConfigProfile, + StatusWorkspaceUnchanged, + StatusWorkspaceNow, + StatusQueuedCountHint, + // Phase 2 cleanup + StatusCompactingContext2, + StatusSteeringTurn2, + StatusRevisePlanHint, + StatusLiveOverlayTracking, + StatusLabelEmpty, + StatusCopiedLabel, + StatusCopyFailedLabel, + StatusApplyPlanChoiceFailed, + StatusRefreshingSubagents, + StatusRollbackTargetGone, + StatusRequestCancelled, + StatusNoForegroundShell, + StatusShellControlOpened, + StatusNoBgShell, + StatusShellManagerDisconnected, + StatusBackgroundingShell, + StatusShellLockCorrupt, + StatusNoActivityDetails, + StatusNoDetailsForLine, + StatusNoMessageAtPos, + StatusMessageEmpty, + StatusMessageCopied, + StatusMessageCopyFailed, + StatusCellsRestoredWithCount, } -#[allow(dead_code)] +#[cfg(test)] pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::ComposerPlaceholder, MessageId::HistorySearchPlaceholder, @@ -874,12 +976,127 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CtxInspChangesByTurn, MessageId::CtxInspStablePrefixOnly, MessageId::CtxInspCacheTip, + MessageId::CmdLocaleDescription, + MessageId::LocaleCurrentLabel, + MessageId::LocaleAvailableLabel, + MessageId::LocaleUsageLabel, + MessageId::StatusNoResumableSession, + MessageId::StatusCannotLoadSession, + MessageId::StatusCannotRestoreOfflineQueue, + MessageId::StatusMemoryAppended, + MessageId::StatusCancelHint, + MessageId::StatusTurnFailed, + MessageId::StatusSubagentDisplay, + MessageId::StatusTerminalControlRestored, + MessageId::StatusDecisionCancelled, + MessageId::StatusCopiedTurnId, + MessageId::StatusCopiedDetail, + MessageId::StatusFileTreeClosed, + MessageId::StatusFileTreeHint, + MessageId::StatusSidebarFocusWork, + MessageId::StatusCannotMarkOnboarding, + MessageId::StatusModeSwitched, + MessageId::StatusAttachmentSelected, + MessageId::StatusComposerFocused, + MessageId::StatusAttachmentRemoved, + MessageId::StatusImageAttached, + MessageId::StatusHistorySearchFilter, + MessageId::StatusHistorySearchConfirm, + MessageId::StatusHistoryInserted, + MessageId::StatusHistoryNoMatches, + MessageId::StatusHistoryCancelled, + MessageId::StatusNoFileMatch, + MessageId::StatusSidebarFocusTasks, + MessageId::StatusSidebarFocusAgents, + MessageId::StatusSidebarFocusContext, + MessageId::StatusFileAttached, + MessageId::StatusFileSharedPrefix, + MessageId::StatusFileMatchPreview, + MessageId::StatusTaskQueued, + MessageId::StatusNoSelectionToOpen, + MessageId::StatusSelectionCleared, + MessageId::StatusNoDetailsAvailable, + MessageId::StatusOpenedInEditor, + MessageId::StatusNoFileLineInSelection, + MessageId::StatusCellHidden, + MessageId::StatusCellShown, + MessageId::StatusCellsRestored, + MessageId::StatusCopiedSelection, + MessageId::StatusCopyFailed, + MessageId::StatusNoSelectionToCopy, + MessageId::StatusSkillSelected, + MessageId::StatusCommandSelected, + MessageId::StatusAutoCompleted, + MessageId::StatusCommandCompleted, + MessageId::StatusSidebarFocusAuto, + MessageId::StatusSuggestionPrefix, + MessageId::StatusRollbackCancelled, + MessageId::StatusNoPrevToolOutput, + MessageId::StatusNoNextToolOutput, + MessageId::StatusEditedIn, + MessageId::StatusEditorClosedNoChanges, + MessageId::StatusEditorCancelled, + MessageId::StatusEditorError, + MessageId::StatusDraftRestored, + MessageId::StatusNoCopyableCells, + MessageId::StatusSidebarHidden, + MessageId::StatusSessionSavedTo, + MessageId::StatusSessionLoadedFrom, + MessageId::StatusWarmingCache, + MessageId::StatusCacheWarmupDone, + MessageId::StatusCacheWarmupFailed, + MessageId::StatusWebUiListening, + MessageId::StatusOpenedInBrowser, + MessageId::StatusQueuedTask, + MessageId::StatusConfigProfile, + MessageId::StatusWorkspaceUnchanged, + MessageId::StatusWorkspaceNow, + MessageId::StatusQueuedCountHint, + MessageId::StatusCompactingContext2, + MessageId::StatusSteeringTurn2, + MessageId::StatusRevisePlanHint, + MessageId::StatusLiveOverlayTracking, + MessageId::StatusLabelEmpty, + MessageId::StatusCopiedLabel, + MessageId::StatusCopyFailedLabel, + MessageId::StatusApplyPlanChoiceFailed, + MessageId::StatusRefreshingSubagents, + MessageId::StatusRollbackTargetGone, + MessageId::StatusRequestCancelled, + MessageId::StatusNoForegroundShell, + MessageId::StatusShellControlOpened, + MessageId::StatusNoBgShell, + MessageId::StatusShellManagerDisconnected, + MessageId::StatusBackgroundingShell, + MessageId::StatusShellLockCorrupt, + MessageId::StatusNoActivityDetails, + MessageId::StatusNoDetailsForLine, + MessageId::StatusNoMessageAtPos, + MessageId::StatusMessageEmpty, + MessageId::StatusMessageCopied, + MessageId::StatusMessageCopyFailed, + MessageId::StatusCellsRestoredWithCount, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { fallback_translation(translation(locale, id), id) } +/// Return the next locale in the cycle, used by Ctrl+Shift+L. The cycle is +/// the order in which locales are listed in the /locale help output, ending +/// with ZhHant which is the least-shipped variant. +pub fn cycle_locale_next(current: Locale) -> &'static str { + match current { + Locale::En => "zh-Hans", + Locale::ZhHans => "ja", + Locale::Ja => "pt-BR", + Locale::PtBr => "es-419", + Locale::Es419 => "vi", + Locale::Vi => "zh-Hant", + Locale::ZhHant => "en", + } +} + pub fn thinking_translation_placeholder(locale: Locale) -> &'static str { match locale { Locale::En => "Thinking; translating when complete...", @@ -940,7 +1157,7 @@ pub fn hidden_translation_failed(locale: Locale) -> &'static str { } } -#[allow(dead_code)] +#[cfg(test)] pub fn missing_message_ids(locale: Locale) -> Vec { ALL_MESSAGE_IDS .iter() @@ -1321,8 +1538,8 @@ fn english(id: MessageId) -> &'static str { MessageId::KbCompleteCycleModes => { "Complete /command, queue running-turn follow-up, cycle modes; Shift+Tab cycles reasoning effort" } - MessageId::KbJumpPlanAgentYolo => "Jump directly to Plan / Agent / YOLO mode", - MessageId::KbAltJumpPlanAgentYolo => "Alternative jump to Plan / Agent / YOLO mode", + MessageId::KbJumpPlanAgentYolo => "Jump directly to 计划模式/智能体模式 / YOLO mode", + MessageId::KbAltJumpPlanAgentYolo => "Alternative jump to 计划模式/智能体模式 / YOLO mode", MessageId::KbFocusSidebar => { "Focus Work / Tasks / Agents / Context / Auto sidebar; Ctrl+Alt+0 hides it" } @@ -1371,14 +1588,14 @@ fn english(id: MessageId) -> &'static str { MessageId::HomeQuickTaskList => "/task list - Show background task queue", MessageId::HomeQuickHelp => "/help - Show help", MessageId::HomeModeTips => "Mode Tips", - MessageId::HomeAgentModeTip => "Agent mode - Use tools for autonomous tasks", - MessageId::HomeAgentModeReviewTip => " Use Ctrl+X to review in Plan mode before executing", + MessageId::HomeAgentModeTip => "代理模式 - 使用工具执行自主任务", + MessageId::HomeAgentModeReviewTip => " Use Ctrl+X to review in 计划模式 before executing", MessageId::HomeAgentModeYoloTip => " Type /mode yolo to enable full tool access", MessageId::HomeYoloModeTip => "YOLO mode - Full tool access, no approvals", MessageId::HomeYoloModeCaution => " Be careful with destructive operations!", - MessageId::HomePlanModeTip => "Plan mode - Design before implementing", + MessageId::HomePlanModeTip => "计划模式 - Design before implementing", MessageId::HomePlanModeChecklistTip => " Use /mode plan to create structured checklists", - MessageId::HomeGoalModeTip => "Goal tracking - Set /goal to pursue objectives", + MessageId::HomeGoalModeTip => "目标跟踪 - Set /goal to pursue objectives", // Onboarding — language picker. MessageId::OnboardLanguageTitle => "Choose your language", MessageId::OnboardLanguageBlurb => { @@ -1509,6 +1726,106 @@ fn english(id: MessageId) -> &'static str { "Tip: Stable prefix blocks are DeepSeek V4 prefix-cache eligible. \ Volatile working-set changes break the cache only for the tail." } + MessageId::CmdLocaleDescription => "Switch UI language or list available locales", + MessageId::LocaleCurrentLabel => "Current locale: ", + MessageId::LocaleAvailableLabel => "Available: ", + MessageId::LocaleUsageLabel => "Usage: /locale (e.g. en, ja, zh-Hans, zh-Hant, pt-BR, es-419, vi, auto)", + MessageId::StatusNoResumableSession => "No resumable session found", + MessageId::StatusCannotLoadSession => "Cannot load session: ", + MessageId::StatusCannotRestoreOfflineQueue => "Cannot restore offline queue: ", + MessageId::StatusMemoryAppended => "memory: appended to ", + MessageId::StatusCancelHint => "Press Esc or Ctrl+C to cancel", + MessageId::StatusTurnFailed => "Turn failed: ", + MessageId::StatusSubagentDisplay => "Sub-agent ", + MessageId::StatusTerminalControlRestored => "Terminal control restored", + MessageId::StatusDecisionCancelled => "Decision cancelled", + MessageId::StatusCopiedTurnId => "Copied turn ID ", + MessageId::StatusCopiedDetail => "Copied ", + MessageId::StatusFileTreeClosed => "File tree closed", + MessageId::StatusFileTreeHint => "File tree: ↑/↓ navigate Enter select Esc close", + MessageId::StatusSidebarFocusWork => "Sidebar focus: work", + MessageId::StatusCannotMarkOnboarding => "Cannot mark onboarding: ", + MessageId::StatusModeSwitched => "Switched to {mode} mode", + MessageId::StatusAttachmentSelected => "Attachment selected - Backspace/Delete to remove", + MessageId::StatusComposerFocused => "Composer focused", + MessageId::StatusAttachmentRemoved => "Attachment removed: ", + MessageId::StatusImageAttached => "Image attached: ", + MessageId::StatusHistorySearchFilter => "History search: type to filter, Enter to confirm", + MessageId::StatusHistorySearchConfirm => "History search: Enter to confirm, Esc to restore", + MessageId::StatusHistoryInserted => "History entry inserted into composer", + MessageId::StatusHistoryNoMatches => "No matching history entries", + MessageId::StatusHistoryCancelled => "History search cancelled", + MessageId::StatusNoFileMatch => "No files match @", + MessageId::StatusSidebarFocusTasks => "Sidebar focus: tasks", + MessageId::StatusSidebarFocusAgents => "Sidebar focus: agents", + MessageId::StatusSidebarFocusContext => "Sidebar focus: context", + MessageId::StatusFileAttached => "Attached @", + MessageId::StatusFileSharedPrefix => "@", + MessageId::StatusFileMatchPreview => "Matches: ", + MessageId::StatusTaskQueued => "Task queued", + MessageId::StatusNoSelectionToOpen => "No selection to open", + MessageId::StatusSelectionCleared => "Selection cleared", + MessageId::StatusNoDetailsAvailable => "No details available for this line", + MessageId::StatusOpenedInEditor => "Opened file in editor", + MessageId::StatusNoFileLineInSelection => "No file:line pattern in selection", + MessageId::StatusCellHidden => "Cell hidden", + MessageId::StatusCellShown => "Cell shown", + MessageId::StatusCellsRestored => "Restored ", + MessageId::StatusCopiedSelection => "Copied selection", + MessageId::StatusCopyFailed => "Copy failed", + MessageId::StatusNoSelectionToCopy => "No selection to copy", + MessageId::StatusSkillSelected => "Skill selected: /", + MessageId::StatusCommandSelected => "Command selected: ", + MessageId::StatusAutoCompleted => "Auto-completed: /", + MessageId::StatusCommandCompleted => "Command completed: ", + MessageId::StatusSidebarFocusAuto => "Sidebar focus: auto", + MessageId::StatusSuggestionPrefix => "Suggestions: ", + MessageId::StatusRollbackCancelled => "Rollback cancelled", + MessageId::StatusNoPrevToolOutput => "No previous tool output", + MessageId::StatusNoNextToolOutput => "No next tool output", + MessageId::StatusEditedIn => "Edited in {editor}", + MessageId::StatusEditorClosedNoChanges => "Editor closed (no changes)", + MessageId::StatusEditorCancelled => "Editor cancelled", + MessageId::StatusEditorError => "Editor error: ", + MessageId::StatusDraftRestored => "Cleared draft restored", + MessageId::StatusNoCopyableCells => "No copyable transcript cells", + MessageId::StatusSidebarHidden => "Sidebar hidden", + MessageId::StatusSessionSavedTo => "Session saved to ", + MessageId::StatusSessionLoadedFrom => "Session loaded from ", + MessageId::StatusWarmingCache => "Warming DeepSeek cache...", + MessageId::StatusCacheWarmupDone => "Cache warmup complete", + MessageId::StatusCacheWarmupFailed => "Cache warmup failed", + MessageId::StatusWebUiListening => "Web UI listening: ", + MessageId::StatusOpenedInBrowser => "Opened in browser: ", + MessageId::StatusQueuedTask => "Queued ", + MessageId::StatusConfigProfile => "Profile: ", + MessageId::StatusWorkspaceUnchanged => "Workspace unchanged: ", + MessageId::StatusWorkspaceNow => "Workspace: ", + MessageId::StatusQueuedCountHint => " queued — ↑ to edit, /queue list", + MessageId::StatusCompactingContext2 => "Compacting context...", + MessageId::StatusSteeringTurn2 => "Steering current turn...", + MessageId::StatusRevisePlanHint => "Revise the plan and press Enter", + MessageId::StatusLiveOverlayTracking => "Live overlay: tracking (Esc to close)", + MessageId::StatusLabelEmpty => "{label} is empty", + MessageId::StatusCopiedLabel => "Copied {label}", + MessageId::StatusCopyFailedLabel => "Copy failed ({label})", + MessageId::StatusApplyPlanChoiceFailed => "Apply plan choice failed: ", + MessageId::StatusRefreshingSubagents => "Refreshing sub-agents...", + MessageId::StatusRollbackTargetGone => "Rollback target no longer exists", + MessageId::StatusRequestCancelled => "Request cancelled", + MessageId::StatusNoForegroundShell => "No foreground shell command to control", + MessageId::StatusShellControlOpened => "Shell control opened", + MessageId::StatusNoBgShell => "No foreground shell command to background", + MessageId::StatusShellManagerDisconnected => "Shell manager disconnected", + MessageId::StatusBackgroundingShell => "Backgrounding current shell command...", + MessageId::StatusShellLockCorrupt => "Shell manager lock is corrupt", + MessageId::StatusNoActivityDetails => "No activity details available", + MessageId::StatusNoDetailsForLine => "No details available for the selected line", + MessageId::StatusNoMessageAtPos => "No message at this position", + MessageId::StatusMessageEmpty => "Message is empty", + MessageId::StatusMessageCopied => "Message copied", + MessageId::StatusMessageCopyFailed => "Copy failed", + MessageId::StatusCellsRestoredWithCount => "Restored {count} hidden cells", } } @@ -2009,6 +2326,107 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "Gợi ý: Các khối ổn định đủ điều kiện cho bộ nhớ đệm tiền tố DeepSeek V4. Thay đổi vùng làm việc chỉ phá vỡ bộ nhớ đệm ở phần cuối." } + + MessageId::CmdLocaleDescription => "Chuyển đổi ngôn ngữ giao diện hoặc liệt kê các ngôn ngữ có sẵn", + MessageId::LocaleCurrentLabel => "Ngôn ngữ hiện tại: ", + MessageId::LocaleAvailableLabel => "Có sẵn: ", + MessageId::LocaleUsageLabel => "Cách dùng: /locale (ví dụ: en, ja, zh-Hans, zh-Hant, pt-BR, es-419, vi, auto)", + MessageId::StatusApplyPlanChoiceFailed => "Apply plan choice failed: ", + MessageId::StatusAttachmentRemoved => "Attachment removed: ", + MessageId::StatusAttachmentSelected => "Attachment selected - Backspace/Delete to remove", + MessageId::StatusAutoCompleted => "Auto-completed: /", + MessageId::StatusBackgroundingShell => "Backgrounding current shell command...", + MessageId::StatusCacheWarmupDone => "Cache warmup complete", + MessageId::StatusCacheWarmupFailed => "Cache warmup failed", + MessageId::StatusCancelHint => "Press Esc or Ctrl+C to cancel", + MessageId::StatusCannotLoadSession => "Cannot load session: ", + MessageId::StatusCannotMarkOnboarding => "Cannot mark onboarding: ", + MessageId::StatusCannotRestoreOfflineQueue => "Cannot restore offline queue: ", + MessageId::StatusCellHidden => "Cell hidden", + MessageId::StatusCellShown => "Cell shown", + MessageId::StatusCellsRestored => "Restored ", + MessageId::StatusCommandCompleted => "Command completed: ", + MessageId::StatusCommandSelected => "Command selected: ", + MessageId::StatusCompactingContext2 => "Compacting context...", + MessageId::StatusComposerFocused => "Composer focused", + MessageId::StatusConfigProfile => "Profile: ", + MessageId::StatusCopiedDetail => "Copied ", + MessageId::StatusCopiedLabel => "Copied {label}", + MessageId::StatusCopiedSelection => "Copied selection", + MessageId::StatusCopiedTurnId => "Copied turn ID ", + MessageId::StatusCopyFailed => "Copy failed", + MessageId::StatusCopyFailedLabel => "Copy failed ({label})", + MessageId::StatusDecisionCancelled => "Decision cancelled", + MessageId::StatusDraftRestored => "Cleared draft restored", + MessageId::StatusEditedIn => "Edited in {editor}", + MessageId::StatusEditorCancelled => "Editor cancelled", + MessageId::StatusEditorClosedNoChanges => "Editor closed (no changes)", + MessageId::StatusEditorError => "Editor error: ", + MessageId::StatusFileAttached => "Attached @", + MessageId::StatusFileMatchPreview => "Matches: ", + MessageId::StatusFileSharedPrefix => "@", + MessageId::StatusFileTreeClosed => "File tree closed", + MessageId::StatusFileTreeHint => "File tree: ↑/↓ navigate Enter select Esc close", + MessageId::StatusHistoryCancelled => "History search cancelled", + MessageId::StatusHistoryInserted => "History entry inserted into composer", + MessageId::StatusHistoryNoMatches => "No matching history entries", + MessageId::StatusHistorySearchConfirm => "History search: Enter to confirm, Esc to restore", + MessageId::StatusHistorySearchFilter => "History search: type to filter, Enter to confirm", + MessageId::StatusImageAttached => "Image attached: ", + MessageId::StatusLabelEmpty => "{label} is empty", + MessageId::StatusLiveOverlayTracking => "Live overlay: tracking (Esc to close)", + MessageId::StatusMemoryAppended => "memory: appended to ", + MessageId::StatusMessageCopied => "Message copied", + MessageId::StatusMessageCopyFailed => "Copy failed", + MessageId::StatusMessageEmpty => "Message is empty", + MessageId::StatusModeSwitched => "Switched to {mode} mode", + MessageId::StatusNoActivityDetails => "No activity details available", + MessageId::StatusNoBgShell => "No foreground shell command to background", + MessageId::StatusNoCopyableCells => "No copyable transcript cells", + MessageId::StatusNoDetailsAvailable => "No details available for this line", + MessageId::StatusNoDetailsForLine => "No details available for the selected line", + MessageId::StatusNoFileLineInSelection => "No file:line pattern in selection", + MessageId::StatusNoFileMatch => "No files match @", + MessageId::StatusNoForegroundShell => "No foreground shell command to control", + MessageId::StatusNoMessageAtPos => "No message at this position", + MessageId::StatusNoNextToolOutput => "No next tool output", + MessageId::StatusNoPrevToolOutput => "No previous tool output", + MessageId::StatusNoResumableSession => "No resumable session found", + MessageId::StatusNoSelectionToCopy => "No selection to copy", + MessageId::StatusNoSelectionToOpen => "No selection to open", + MessageId::StatusOpenedInBrowser => "Opened in browser: ", + MessageId::StatusOpenedInEditor => "Opened file in editor", + MessageId::StatusQueuedCountHint => " queued — ↑ to edit, /queue list", + MessageId::StatusQueuedTask => "Queued ", + MessageId::StatusRefreshingSubagents => "Refreshing sub-agents...", + MessageId::StatusRequestCancelled => "Request cancelled", + MessageId::StatusRevisePlanHint => "Revise the plan and press Enter", + MessageId::StatusRollbackCancelled => "Rollback cancelled", + MessageId::StatusRollbackTargetGone => "Rollback target no longer exists", + MessageId::StatusSelectionCleared => "Selection cleared", + MessageId::StatusSessionLoadedFrom => "Session loaded from ", + MessageId::StatusSessionSavedTo => "Session saved to ", + MessageId::StatusShellControlOpened => "Shell control opened", + MessageId::StatusShellLockCorrupt => "Shell manager lock is corrupt", + MessageId::StatusShellManagerDisconnected => "Shell manager disconnected", + MessageId::StatusSidebarFocusAgents => "Sidebar focus: agents", + MessageId::StatusSidebarFocusAuto => "Sidebar focus: auto", + MessageId::StatusSidebarFocusContext => "Sidebar focus: context", + MessageId::StatusSidebarFocusTasks => "Sidebar focus: tasks", + MessageId::StatusSidebarFocusWork => "Sidebar focus: work", + MessageId::StatusSidebarHidden => "Sidebar hidden", + MessageId::StatusSkillSelected => "Skill selected: /", + MessageId::StatusSteeringTurn2 => "Steering current turn...", + MessageId::StatusSubagentDisplay => "Sub-agent ", + MessageId::StatusSuggestionPrefix => "Suggestions: ", + MessageId::StatusTaskQueued => "Task queued", + MessageId::StatusTerminalControlRestored => "Terminal control restored", + MessageId::StatusTurnFailed => "Turn failed: ", + MessageId::StatusWarmingCache => "Warming DeepSeek cache...", + MessageId::StatusWebUiListening => "Web UI listening: ", + MessageId::StatusWorkspaceNow => "Workspace: ", + MessageId::StatusWorkspaceUnchanged => "Workspace unchanged: ", + MessageId::StatusCellsRestoredWithCount => "Restored {count} hidden cells", }) } @@ -2074,6 +2492,491 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { "提示:穩定前綴區塊符合 DeepSeek V4 前綴快取條件。易變工作集的更改僅會破壞快取尾部。" } other => chinese_simplified(other)?, + MessageId::ClearConversation => "Conversation cleared", + MessageId::ClearConversationBusy => { + "Conversation cleared (plan state busy; run /clear again if needed)" + } + MessageId::ModelChanged => "Model changed: {old} \u{2192} {new}", + MessageId::CmdAgentDescription => { + "Open a persistent sub-agent session: /agent [0-3] " + } + MessageId::CmdGoalDescription => "Set a session goal with optional token budget", + MessageId::CmdAnchorDescription => { + "Pin a fact that survives compaction (auto-injected into context)" + } + MessageId::CmdAttachDescription => { + "Attach image/video media; use @path for text files or directories" + } + MessageId::CmdCacheDescription => { + "Show DeepSeek prefix-cache hit/miss stats for the last N turns" + } + MessageId::CmdChangeDescription => "Show the latest changelog entry", + MessageId::CmdCacheAdvice => { + "Hit/miss ratios over ~70% after the third turn indicate a stable cache prefix; \n\ + lower than that on long sessions suggests prefix churn worth investigating (#263)." + } + MessageId::CmdCacheFootnote => { + "* miss inferred from input − hit when the provider did not report it explicitly.\n" + } + MessageId::CmdCacheHeader => { + "Cache telemetry — last {count} of {total} turn(s) (model: {model})\n" + } + MessageId::CmdCacheNoData => { + "Cache history: no turns recorded yet.\n\n\ + DeepSeek surfaces `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` \ + on every API turn that the model supports it (V4 family). Run a turn \ + and try /cache again." + } + MessageId::CmdCacheTotals => { + "Σ in: {sum_in} Σ hit: {sum_hit} Σ miss: {sum_miss} avg hit ratio: {avg}\n" + } + MessageId::CmdCostReport => { + "Session Cost:\n\ + ─────────────────────────────\n\ + Approx total spent: {cost}\n\n\ + Cost estimates are approximate and use provider usage telemetry when available.\n\n\ + DeepSeek API Pricing:\n\ + ─────────────────────────────\n\ + Pricing details are not configured in this CLI." + } + MessageId::CmdTokensCacheBoth => "{hit} hit / {miss} miss", + MessageId::CmdChangeHeader => "Latest Changelog", + MessageId::CmdChangeTranslationQueued => { + "English release notes are shown below. A translated version will be requested next; if the provider is unavailable, this English text is the fallback." + } + MessageId::CmdChangeTranslationUnavailable => { + "English release notes are shown below. Translation is unavailable because the current session has no API key or is offline." + } + MessageId::CmdChangePreviousVersion => { + "Previous version: {version} — run `/change {version}` to view it" + } + MessageId::CmdBalanceDescription => "Check the active provider account balance", + MessageId::CmdClearDescription => "Clear conversation history", + MessageId::CmdCompactDescription => "Trigger context compaction to free up space", + MessageId::CmdContextDescription => "Open compact session context inspector", + MessageId::CmdCostDescription => "Show session cost breakdown", + MessageId::CmdDiffDescription => "Show file changes since session start", + MessageId::CmdEditDescription => "Revise and resubmit the last message", + MessageId::CmdExitDescription => "Exit the application", + MessageId::CmdExportDescription => "Export conversation to markdown", + MessageId::CmdFeedbackDescription => "Generate a GitHub feedback URL", + MessageId::CmdHelpDescription => "Show help information", + MessageId::CmdHfDescription => "Inspect Hugging Face MCP setup and concepts", + MessageId::CmdHomeDescription => "Show home dashboard with stats and quick actions", + MessageId::CmdHooksDescription => "List configured lifecycle hooks (read-only)", + MessageId::CmdInitDescription => "Generate AGENTS.md for project", + MessageId::CmdJobsDescription => "Inspect and control background commands", + MessageId::CmdLinksDescription => "Show DeepSeek dashboard and docs links", + MessageId::CmdLoadDescription => "Load session from file", + MessageId::CmdLogoutDescription => "Clear API key and return to setup", + MessageId::CmdLspDescription => "Toggle LSP diagnostics on or off", + MessageId::CmdMcpDescription => "Open or manage MCP servers", + MessageId::CmdMemoryDescription => "Inspect or manage the persistent user-memory file", + MessageId::CmdModeDescription => { + "Switch mode or open picker: /mode [agent|plan|yolo|1|2|3]" + } + MessageId::CmdModelDescription => "Switch or view current model", + MessageId::CmdModelsDescription => "List available models from API", + MessageId::CmdNetworkDescription => "Manage network allow and deny rules", + MessageId::CmdNewDescription => "Start a fresh saved session", + MessageId::CmdNoteDescription => "Add, list, edit, or remove workspace notes", + MessageId::CmdProviderDescription => "Switch the active provider and/or model", + MessageId::CmdPurgeDescription => { + "Let the agent surgically prune conversation history to free context space" + } + MessageId::CmdConfigDescription => "Open interactive configuration editor", + MessageId::CmdQueueAlreadyEditing => { + "Already editing a queued message. Send it or /queue clear to discard." + } + MessageId::CmdQueueNotFound => "Queued message not found", + MessageId::CmdQueueAlreadyEmpty => "Queue already empty", + MessageId::CmdQueueCleared => "Queue cleared", + MessageId::CmdQueueDescription => "View or edit queued messages", + MessageId::CmdQueueDraftHeader => "Editing queued message:", + MessageId::CmdQueueEditingMessage => { + "Editing queued message {index} (press Enter to re-queue/send)" + } + MessageId::CmdQueueDropped => "Dropped queued message {index}", + MessageId::CmdQueueEditingStatus => "Editing queued message {index}", + MessageId::CmdQueueIndexMin => "Index must be >= 1", + MessageId::CmdQueueListHeader => "Queued messages ({count}):", + MessageId::CmdQueueMissingIndex => { + "Missing index. Usage: /queue edit or /queue drop " + } + MessageId::CmdQueueIndexPositive => "Index must be a positive number", + MessageId::CmdQueueNoMessages => "No queued messages", + MessageId::CmdQueueTip => "Tip: /queue edit to edit, /queue drop to remove", + MessageId::CmdQueueUsage => "Usage: /queue [list|edit |drop |clear]", + MessageId::CmdRenameDescription => "Rename the current session", + MessageId::CmdRestoreDescription => { + "Roll back the workspace to a prior pre/post-turn snapshot. With no arg, lists recent snapshots." + } + MessageId::CmdRetryDescription => "Retry the last request", + MessageId::CmdReviewDescription => "Run a structured code review on a file, diff, or PR", + MessageId::CmdRlmDescription => "Open a persistent RLM context: /rlm [0-3] ", + MessageId::CmdSaveDescription => "Save session to file", + MessageId::CmdSessionsDescription => "Open session history picker", + MessageId::CmdSettingsDescription => "Show persistent settings", + MessageId::CmdShareDescription => "Export current session as a shareable web URL", + MessageId::CmdSidebarDescription => "Toggle or focus the right sidebar", + MessageId::CmdSkillDescription => { + "Activate a skill, or install/update/uninstall/trust a community skill" + } + MessageId::CmdSkillsDescription => { + "List local skills (filter by `/skills `; --remote browses the curated registry)" + } + MessageId::CmdSlopDescription => "Inspect or export the SlopLedger", + MessageId::CmdStashDescription => { + "Park or restore a composer draft (Ctrl+S to push, /stash list/pop)" + } + MessageId::CmdStatusDescription => "Show runtime session status", + MessageId::CmdStatuslineDescription => "Configure which items appear in the footer", + MessageId::CmdSubagentsDescription => "List sub-agent status", + MessageId::CmdSwarmDescription => { + "Run a multi-agent fanout turn (sequential | mixture | distill | deliberate)" + } + MessageId::CmdSystemDescription => "Show current system prompt", + MessageId::CmdTaskDescription => "Manage background tasks", + MessageId::CmdTokensCacheHitOnly => "{hit} hit / miss not reported", + MessageId::CmdTokensCacheMissOnly => "hit not reported / {miss} miss", + MessageId::CmdTokensContextUnknownWindow => "~{estimated} / unknown window", + MessageId::CmdTokensContextWithWindow => "~{used} / {window} ({percent}%)", + MessageId::CmdTokensDescription => "Show token usage for session", + MessageId::CmdTokensNotReported => "not reported", + MessageId::CmdTokensReport => { + "Token Usage:\n\ + ─────────────────────────────\n\ + Active context: {active}\n\ + Last API input: {input} (turn telemetry; may count repeated prefix across tool rounds)\n\ + Last API output: {output}\n\ + Cache hit/miss: {cache} (telemetry/cost only)\n\ + Cumulative tokens: {total} (session usage telemetry)\n\ + Approx session cost: {cost}\n\ + API messages: {api_messages}\n\ + Chat messages: {chat_messages}\n\ + Model: {model}" + } + MessageId::KbScrollTranscript => { + "Scroll transcript, navigate input history, or select composer attachments" + } + MessageId::KbNavigateHistory => "Navigate input history", + MessageId::CmdTrustDescription => { + "Manage workspace trust and per-path allowlist (`/trust add `, `/trust list`, `/trust on|off`)" + } + MessageId::CmdWorkspaceDescription => "Show or switch the current workspace", + MessageId::CmdUndoDescription => "Remove last message pair", + MessageId::CmdVerboseDescription => "Toggle full live thinking in the transcript", + MessageId::ComposerPlaceholder => "Write a task or use /.", + MessageId::ConfigFilteredSettings => " Filtered settings", + 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::ConfigModalTitle => " Config ", + MessageId::ConfigNoMatchesPrefix => " No settings match ", + MessageId::ConfigNoSettings => " No settings available.", + MessageId::ConfigSearchPlaceholder => "type to filter", + MessageId::ConfigShowing => " Showing", + MessageId::ConfigTitle => "Session Configuration", + MessageId::CtxMenuClearSelection => "Clear selection", + MessageId::CtxMenuCmdPalette => "Command palette", + MessageId::CtxMenuCmdPaletteDesc => "commands, skills, and tools", + MessageId::CtxMenuContextInspector => "Context inspector", + MessageId::CtxMenuContextInspectorDesc => "active context and cache hints", + MessageId::CtxMenuCopyMessage => "Copy message", + MessageId::CtxMenuCopyMessageDesc => "write clicked transcript cell", + MessageId::CtxMenuCopySelection => "Copy selection", + MessageId::CtxMenuCopySelectionDesc => "write selected transcript text", + MessageId::CtxMenuHelp => "Help", + MessageId::CtxMenuHelpDesc => "keybindings and commands", + MessageId::CtxMenuHideCell => "Hide cell", + MessageId::CtxMenuHideCellDesc => "collapse this transcript cell", + MessageId::CtxMenuOpenDetails => "Open details", + MessageId::CtxMenuOpenInEditor => "Open in editor", + MessageId::CtxMenuOpenInEditorDesc => "open file:line in $EDITOR", + MessageId::CtxMenuOpenSelection => "Open selection", + MessageId::CtxMenuOpenSelectionDesc => "show selected text in pager", + MessageId::CtxMenuPaste => "Paste", + MessageId::CtxMenuPasteDesc => "insert clipboard into composer", + MessageId::CtxMenuShowCell => "Show cell", + MessageId::CtxMenuShowCellDesc => "unhide this transcript cell", + MessageId::CtxMenuShowHidden => "Show hidden", + MessageId::CtxMenuShowHiddenDesc => "unhide all collapsed cells", + MessageId::FooterAgentSingular => "1 agent", + MessageId::FooterAgentsPlural => "{count} agents", + MessageId::FooterPressCtrlCAgain => "Press Ctrl+C again to quit", + MessageId::FooterWorking => "working", + MessageId::HelpAliasesLabel => "Aliases:", + MessageId::HelpFilterPlaceholder => "Type to filter", + MessageId::HelpFilterPrefix => "Filter: ", + MessageId::HelpFooterClose => " Esc close ", + MessageId::HelpFooterJump => " PgUp/PgDn jump ", + MessageId::HelpFooterMove => " Up/Down move ", + MessageId::HelpFooterTypeFilter => " type to filter ", + MessageId::HelpKeybindings => "Keybindings", + MessageId::HelpNoMatches => " No matches.", + MessageId::HelpSectionActions => "Actions", + MessageId::HelpSectionClipboard => "Clipboard", + MessageId::HelpSectionEditing => "Input editing", + MessageId::HelpSectionHelp => "Help", + MessageId::HelpSectionModes => "Modes", + MessageId::HelpSectionNavigation => "Navigation", + MessageId::HelpSectionSessions => "Sessions", + MessageId::HelpSlashCommands => "Slash commands", + MessageId::HelpUnknownCommand => "Unknown command: {topic}", + MessageId::HelpUsageLabel => "Usage:", + MessageId::HistoryHintAccept => "Enter accept", + MessageId::HistoryHintMove => "Up/Down move", + MessageId::HistoryHintRestore => "Esc restore", + MessageId::HistoryNoMatches => " No matches", + MessageId::HistorySearchPlaceholder => "Search prompt history...", + MessageId::HistorySearchTitle => "History Search", + MessageId::HomeAgentModeReviewTip => " Use Ctrl+X to review in 计划模式 before executing", + MessageId::HomeAgentModeTip => "代理模式 - 使用工具执行自主任务", + MessageId::HomeAgentModeYoloTip => " Type /mode yolo to enable full tool access", + MessageId::HomeDashboardTitle => "codewhale Home Dashboard", + MessageId::HomeGoalModeTip => "目标跟踪 - Set /goal to pursue objectives", + // Onboarding — language picker. + MessageId::OnboardLanguageTitle => "Choose your language", + MessageId::HomeHistory => "History:", + MessageId::HomeMode => "Mode:", + MessageId::HomeModeTips => "Mode Tips", + MessageId::HomeModel => "Model:", + MessageId::HomePlanModeChecklistTip => " Use /mode plan to create structured checklists", + MessageId::HomePlanModeTip => "计划模式 - Design before implementing", + MessageId::HomeQueued => "Queued:", + MessageId::HomeQuickActions => "Quick Actions", + MessageId::HomeQuickConfig => "/config - Open interactive configuration editor", + MessageId::HomeQuickHelp => "/help - Show help", + MessageId::HomeQuickLinks => "/links - Dashboard & API links", + MessageId::HomeQuickModel => "/model - Switch or view model", + MessageId::HomeQuickSettings => "/settings - Show persistent settings", + MessageId::HomeQuickSkills => "/skills - List available skills", + MessageId::HomeQuickSubagents => "/subagents - List sub-agent status", + MessageId::HomeQuickTaskList => "/task list - Show background task queue", + MessageId::HomeSkill => "Skill:", + MessageId::HomeSubagents => "Sub-agents:", + MessageId::HomeTokens => "Tokens:", + MessageId::HomeWorkspace => "Workspace:", + MessageId::HomeYoloModeCaution => " Be careful with destructive operations!", + MessageId::HomeYoloModeTip => "YOLO mode - Full tool access, no approvals", + MessageId::KbAltJumpPlanAgentYolo => "Alternative jump to 计划模式/智能体模式 / YOLO mode", + MessageId::KbBacktrackMessage => { + "Backtrack to a previous user message (Left/Right step, Enter to rewind)" + } + MessageId::KbCompleteCycleModes => { + "Complete /command, queue running-turn follow-up, cycle modes; Shift+Tab cycles reasoning effort" + } + MessageId::KbJumpPlanAgentYolo => "Jump directly to 计划模式/智能体模式 / YOLO mode", + MessageId::KbBrowseHistory => "Browse conversation history", + MessageId::KbCancelOrExit => "Cancel request, or exit when idle", + MessageId::KbCloseMenu => "Close menu, cancel request, discard draft, or clear input", + MessageId::KbCommandPalette => "Open the command palette", + MessageId::KbCompactInspector => "Open compact session context inspector", + MessageId::KbContextMenu => { + "Open context actions for paste, selection, message details, context, and help" + } + MessageId::KbAttachPath => "Add a local text file or directory to context", + MessageId::KbCopySelection => "Copy the current selection (Cmd+C on macOS)", + MessageId::KbDeleteChar => { + "Delete character before / after the cursor, or remove selected attachment" + } + MessageId::KbClearDraft => "Clear the current draft", + MessageId::KbExitEmpty => "Exit when input is empty", + MessageId::KbFocusSidebar => { + "Focus Work / Tasks / Agents / Context / Auto sidebar; Ctrl+Alt+0 hides it" + } + MessageId::KbTogglePlanAgent => "Toggle between Plan and Agent modes", + MessageId::KbFuzzyFilePicker => "Open the fuzzy file picker (insert @path on Enter)", + MessageId::KbHelpOverlay => "Open this help overlay (when input is empty)", + MessageId::KbInsertNewline => "Insert a newline in the composer", + MessageId::KbJumpLineStartEnd => "Jump to start / end of line", + MessageId::KbJumpToolBlocks => "Jump between tool output blocks", + MessageId::KbJumpTopBottom => "Jump to top / bottom of transcript", + MessageId::KbJumpTopBottomEmpty => "Jump to top / bottom (when input is empty)", + MessageId::KbLastMessagePager => "Open pager for the last message (when input is empty)", + MessageId::KbLiveTranscript => "Open live transcript overlay (sticky-tail auto-scroll)", + MessageId::KbMoveCursor => "Move cursor in composer", + MessageId::KbPasteAttach => "Paste text or attach a clipboard image", + MessageId::KbScrollPage => "Scroll transcript by page", + MessageId::KbScrollTranscriptAlt => "Scroll transcript", + MessageId::KbSearchHistory => "Search prompt history and recover local drafts", + MessageId::KbSelectedDetails => { + "Open details for the selected tool or message (when input is empty)" + } + MessageId::KbToolDetailsPager => "Open tool-details pager", + MessageId::KbSendDraft => "Send the current draft", + MessageId::KbSessionPicker => "Open the session picker", + MessageId::KbShellControls => "Open shell controls for a running foreground command", + MessageId::KbStashDraft => "Stash the current draft (`/stash pop` to restore)", + MessageId::KbThinkingPager => "Open Activity Detail", + MessageId::KbToggleHelp => "Toggle help overlay", + MessageId::KbToggleHelpSlash => "Toggle help overlay", + MessageId::LinksDashboard => "Dashboard:", + MessageId::LinksDocs => "Docs:", + MessageId::LinksTip => "Tip: API keys are available in the dashboard console.", + MessageId::LinksTitle => "DeepSeek Links:", + MessageId::LocaleAvailableLabel => "Available: ", + MessageId::LocaleCurrentLabel => "Current locale: ", + MessageId::LocaleUsageLabel => "Usage: /locale (e.g. en, ja, zh-Hans, zh-Hant, pt-BR, es-419, vi, auto)", + MessageId::OnboardApiKeyFooter => "Press Enter to save, Esc to go back.", + // Onboarding — workspace trust. + MessageId::OnboardTrustTitle => "Trust Workspace", + MessageId::OnboardApiKeyLabel => "Key: ", + MessageId::OnboardApiKeySavedHint => { + "Saved to ~/.codewhale/config.toml so it works from any folder." + } + MessageId::OnboardApiKeyFormatHint => { + "Paste the full key exactly as issued (no spaces or newlines)." + } + MessageId::OnboardApiKeyPlaceholder => "(paste key here)", + MessageId::OnboardApiKeyStep1 => { + "Step 1. Open https://platform.deepseek.com/api_keys and create a key." + } + MessageId::OnboardApiKeyStep2 => "Step 2. Paste it below and press Enter.", + MessageId::OnboardLanguageBlurb => { + "Pick the UI language. You can change it any time with `/settings set locale `." + } + MessageId::OnboardLanguageFooter => { + "Press 1-7 to choose, or Enter to keep the current setting" + } + // Onboarding — API key entry. + MessageId::OnboardApiKeyTitle => "Connect your DeepSeek API key", + MessageId::OnboardTipsFooterAction => " to open the workspace", + // Context menu. + MessageId::CtxMenuTitle => " Right click ", + MessageId::OnboardTipsLine1 => { + "Write the task in plain language. Use /help or Ctrl+K when you want a command." + } + MessageId::OnboardTipsLine2 => { + "The bottom composer is multi-line: Enter sends, Alt+Enter or Ctrl+J adds a new line." + } + MessageId::OnboardTipsLine3 => { + "Switch modes only when the job changes: Plan for review-first work, Agent for execution, YOLO when you want auto-approval." + } + MessageId::OnboardTipsLine4 => { + "Ctrl+R resumes earlier sessions, and Esc backs out of the current draft or overlay." + } + MessageId::OnboardTipsFooterEnter => "Press Enter", + MessageId::OnboardTrustFooterMiddle => " to trust and continue, ", + MessageId::OnboardTrustFooterSuffix => " to quit", + // Onboarding — final tips. + MessageId::OnboardTipsTitle => "Start Simple", + MessageId::OnboardTrustLocationPrefix => "You are in ", + MessageId::OnboardTrustQuestion => "Do you trust the contents of this directory?", + MessageId::OnboardTrustRiskHint => { + "Working with untrusted contents comes with higher risk of prompt injection." + } + MessageId::OnboardTrustEffectHint => { + "Trusting this directory records it in global config and enables trusted workspace mode." + } + MessageId::OnboardTrustFooterPrefix => "Press ", + MessageId::SettingsConfigFile => "Config file:", + MessageId::SettingsTitle => "Settings:", + MessageId::StatusApplyPlanChoiceFailed => "Apply plan choice failed: ", + MessageId::StatusAttachmentRemoved => "Attachment removed: ", + MessageId::StatusAttachmentSelected => "Attachment selected - Backspace/Delete to remove", + MessageId::StatusAutoCompleted => "Auto-completed: /", + MessageId::StatusBackgroundingShell => "Backgrounding current shell command...", + MessageId::StatusCacheWarmupDone => "Cache warmup complete", + MessageId::StatusCacheWarmupFailed => "Cache warmup failed", + MessageId::StatusCancelHint => "Press Esc or Ctrl+C to cancel", + MessageId::StatusCannotLoadSession => "Cannot load session: ", + MessageId::StatusCannotMarkOnboarding => "Cannot mark onboarding: ", + MessageId::StatusCannotRestoreOfflineQueue => "Cannot restore offline queue: ", + MessageId::StatusCellHidden => "Cell hidden", + MessageId::StatusCellShown => "Cell shown", + MessageId::StatusCellsRestored => "Restored ", + MessageId::StatusCommandCompleted => "Command completed: ", + MessageId::StatusCommandSelected => "Command selected: ", + MessageId::StatusCompactingContext2 => "Compacting context...", + MessageId::StatusComposerFocused => "Composer focused", + MessageId::StatusConfigProfile => "Profile: ", + MessageId::StatusCopiedDetail => "Copied ", + MessageId::StatusCopiedLabel => "Copied {label}", + MessageId::StatusCopiedSelection => "Copied selection", + MessageId::StatusCopiedTurnId => "Copied turn ID ", + MessageId::StatusCopyFailed => "Copy failed", + MessageId::StatusCopyFailedLabel => "Copy failed ({label})", + MessageId::StatusDecisionCancelled => "Decision cancelled", + MessageId::StatusDraftRestored => "Cleared draft restored", + MessageId::StatusEditedIn => "Edited in {editor}", + MessageId::StatusEditorCancelled => "Editor cancelled", + MessageId::StatusEditorClosedNoChanges => "Editor closed (no changes)", + MessageId::StatusEditorError => "Editor error: ", + MessageId::StatusFileAttached => "Attached @", + MessageId::StatusFileMatchPreview => "Matches: ", + MessageId::StatusFileSharedPrefix => "@", + MessageId::StatusFileTreeClosed => "File tree closed", + MessageId::StatusFileTreeHint => "File tree: ↑/↓ navigate Enter select Esc close", + MessageId::StatusHistoryCancelled => "History search cancelled", + MessageId::StatusHistoryInserted => "History entry inserted into composer", + MessageId::StatusHistoryNoMatches => "No matching history entries", + MessageId::StatusHistorySearchConfirm => "History search: Enter to confirm, Esc to restore", + MessageId::StatusHistorySearchFilter => "History search: type to filter, Enter to confirm", + MessageId::StatusImageAttached => "Image attached: ", + MessageId::StatusLabelEmpty => "{label} is empty", + MessageId::StatusLiveOverlayTracking => "Live overlay: tracking (Esc to close)", + MessageId::StatusMemoryAppended => "memory: appended to ", + MessageId::StatusMessageCopied => "Message copied", + MessageId::StatusMessageCopyFailed => "Copy failed", + MessageId::StatusMessageEmpty => "Message is empty", + MessageId::StatusModeSwitched => "Switched to {mode} mode", + MessageId::StatusNoActivityDetails => "No activity details available", + MessageId::StatusNoBgShell => "No foreground shell command to background", + MessageId::StatusNoCopyableCells => "No copyable transcript cells", + MessageId::StatusNoDetailsAvailable => "No details available for this line", + MessageId::StatusNoDetailsForLine => "No details available for the selected line", + MessageId::StatusNoFileLineInSelection => "No file:line pattern in selection", + MessageId::StatusNoFileMatch => "No files match @", + MessageId::StatusNoForegroundShell => "No foreground shell command to control", + MessageId::StatusNoMessageAtPos => "No message at this position", + MessageId::StatusNoNextToolOutput => "No next tool output", + MessageId::StatusNoPrevToolOutput => "No previous tool output", + MessageId::StatusNoResumableSession => "No resumable session found", + MessageId::StatusNoSelectionToCopy => "No selection to copy", + MessageId::StatusNoSelectionToOpen => "No selection to open", + MessageId::StatusOpenedInBrowser => "Opened in browser: ", + MessageId::StatusOpenedInEditor => "Opened file in editor", + MessageId::StatusQueuedCountHint => " queued — ↑ to edit, /queue list", + MessageId::StatusQueuedTask => "Queued ", + MessageId::StatusRefreshingSubagents => "Refreshing sub-agents...", + MessageId::StatusRequestCancelled => "Request cancelled", + MessageId::StatusRevisePlanHint => "Revise the plan and press Enter", + MessageId::StatusRollbackCancelled => "Rollback cancelled", + MessageId::StatusRollbackTargetGone => "Rollback target no longer exists", + MessageId::StatusSelectionCleared => "Selection cleared", + MessageId::StatusSessionLoadedFrom => "Session loaded from ", + MessageId::StatusSessionSavedTo => "Session saved to ", + MessageId::StatusShellControlOpened => "Shell control opened", + MessageId::StatusShellLockCorrupt => "Shell manager lock is corrupt", + MessageId::StatusShellManagerDisconnected => "Shell manager disconnected", + MessageId::StatusSidebarFocusAgents => "Sidebar focus: agents", + MessageId::StatusSidebarFocusAuto => "Sidebar focus: auto", + MessageId::StatusSidebarFocusContext => "Sidebar focus: context", + MessageId::StatusSidebarFocusTasks => "Sidebar focus: tasks", + MessageId::StatusSidebarFocusWork => "Sidebar focus: work", + MessageId::StatusSidebarHidden => "Sidebar hidden", + MessageId::StatusSkillSelected => "Skill selected: /", + MessageId::StatusSteeringTurn2 => "Steering current turn...", + MessageId::StatusSubagentDisplay => "Sub-agent ", + MessageId::StatusSuggestionPrefix => "Suggestions: ", + MessageId::StatusTaskQueued => "Task queued", + MessageId::StatusTerminalControlRestored => "Terminal control restored", + MessageId::StatusTurnFailed => "Turn failed: ", + MessageId::StatusWarmingCache => "Warming DeepSeek cache...", + MessageId::StatusWebUiListening => "Web UI listening: ", + MessageId::StatusWorkspaceNow => "Workspace: ", + MessageId::StatusWorkspaceUnchanged => "Workspace unchanged: ", + MessageId::StatusCellsRestoredWithCount => "Restored {count} hidden cells", + MessageId::CmdLocaleDescription => "Switch UI language or list available locales", + MessageId::SubagentsFetching => "Fetching sub-agent status...", }) } @@ -2345,8 +3248,8 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::KbCompleteCycleModes => { "/command を補完、実行中ターンのフォローアップをキュー、モードを切り替え;Shift+Tab で推論強度を切り替え" } - MessageId::KbJumpPlanAgentYolo => "Plan / Agent / YOLO モードに直接ジャンプ", - MessageId::KbAltJumpPlanAgentYolo => "Plan / Agent / YOLO モードへの代替ジャンプ", + MessageId::KbJumpPlanAgentYolo => "计划模式/智能体模式 / YOLO モードに直接ジャンプ", + MessageId::KbAltJumpPlanAgentYolo => "计划模式/智能体模式 / YOLO モードへの代替ジャンプ", MessageId::KbFocusSidebar => { "Work / Tasks / Agents / Context / Auto / Hidden サイドバーにフォーカス" } @@ -2536,6 +3439,107 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "ヒント:安定プレフィックスブロックはDeepSeek V4プレフィックスキャッシュの対象です。揮発性ワーキングセットの変更は末尾のキャッシュのみを破壊します。" } + + MessageId::CmdLocaleDescription => "UI言語を切り替えるか利用可能な言語を一覧表示", + MessageId::LocaleCurrentLabel => "現在の言語: ", + MessageId::LocaleAvailableLabel => "利用可能: ", + MessageId::LocaleUsageLabel => "使い方: /locale <コード> (例: en, ja, zh-Hans, zh-Hant, pt-BR, es-419, vi, auto)", + MessageId::StatusApplyPlanChoiceFailed => "Apply plan choice failed: ", + MessageId::StatusAttachmentRemoved => "Attachment removed: ", + MessageId::StatusAttachmentSelected => "Attachment selected - Backspace/Delete to remove", + MessageId::StatusAutoCompleted => "Auto-completed: /", + MessageId::StatusBackgroundingShell => "Backgrounding current shell command...", + MessageId::StatusCacheWarmupDone => "Cache warmup complete", + MessageId::StatusCacheWarmupFailed => "Cache warmup failed", + MessageId::StatusCancelHint => "Press Esc or Ctrl+C to cancel", + MessageId::StatusCannotLoadSession => "Cannot load session: ", + MessageId::StatusCannotMarkOnboarding => "Cannot mark onboarding: ", + MessageId::StatusCannotRestoreOfflineQueue => "Cannot restore offline queue: ", + MessageId::StatusCellHidden => "Cell hidden", + MessageId::StatusCellShown => "Cell shown", + MessageId::StatusCellsRestored => "Restored ", + MessageId::StatusCommandCompleted => "Command completed: ", + MessageId::StatusCommandSelected => "Command selected: ", + MessageId::StatusCompactingContext2 => "Compacting context...", + MessageId::StatusComposerFocused => "Composer focused", + MessageId::StatusConfigProfile => "Profile: ", + MessageId::StatusCopiedDetail => "Copied ", + MessageId::StatusCopiedLabel => "Copied {label}", + MessageId::StatusCopiedSelection => "Copied selection", + MessageId::StatusCopiedTurnId => "Copied turn ID ", + MessageId::StatusCopyFailed => "Copy failed", + MessageId::StatusCopyFailedLabel => "Copy failed ({label})", + MessageId::StatusDecisionCancelled => "Decision cancelled", + MessageId::StatusDraftRestored => "Cleared draft restored", + MessageId::StatusEditedIn => "Edited in {editor}", + MessageId::StatusEditorCancelled => "Editor cancelled", + MessageId::StatusEditorClosedNoChanges => "Editor closed (no changes)", + MessageId::StatusEditorError => "Editor error: ", + MessageId::StatusFileAttached => "Attached @", + MessageId::StatusFileMatchPreview => "Matches: ", + MessageId::StatusFileSharedPrefix => "@", + MessageId::StatusFileTreeClosed => "File tree closed", + MessageId::StatusFileTreeHint => "File tree: ↑/↓ navigate Enter select Esc close", + MessageId::StatusHistoryCancelled => "History search cancelled", + MessageId::StatusHistoryInserted => "History entry inserted into composer", + MessageId::StatusHistoryNoMatches => "No matching history entries", + MessageId::StatusHistorySearchConfirm => "History search: Enter to confirm, Esc to restore", + MessageId::StatusHistorySearchFilter => "History search: type to filter, Enter to confirm", + MessageId::StatusImageAttached => "Image attached: ", + MessageId::StatusLabelEmpty => "{label} is empty", + MessageId::StatusLiveOverlayTracking => "Live overlay: tracking (Esc to close)", + MessageId::StatusMemoryAppended => "memory: appended to ", + MessageId::StatusMessageCopied => "Message copied", + MessageId::StatusMessageCopyFailed => "Copy failed", + MessageId::StatusMessageEmpty => "Message is empty", + MessageId::StatusModeSwitched => "Switched to {mode} mode", + MessageId::StatusNoActivityDetails => "No activity details available", + MessageId::StatusNoBgShell => "No foreground shell command to background", + MessageId::StatusNoCopyableCells => "No copyable transcript cells", + MessageId::StatusNoDetailsAvailable => "No details available for this line", + MessageId::StatusNoDetailsForLine => "No details available for the selected line", + MessageId::StatusNoFileLineInSelection => "No file:line pattern in selection", + MessageId::StatusNoFileMatch => "No files match @", + MessageId::StatusNoForegroundShell => "No foreground shell command to control", + MessageId::StatusNoMessageAtPos => "No message at this position", + MessageId::StatusNoNextToolOutput => "No next tool output", + MessageId::StatusNoPrevToolOutput => "No previous tool output", + MessageId::StatusNoResumableSession => "No resumable session found", + MessageId::StatusNoSelectionToCopy => "No selection to copy", + MessageId::StatusNoSelectionToOpen => "No selection to open", + MessageId::StatusOpenedInBrowser => "Opened in browser: ", + MessageId::StatusOpenedInEditor => "Opened file in editor", + MessageId::StatusQueuedCountHint => " queued — ↑ to edit, /queue list", + MessageId::StatusQueuedTask => "Queued ", + MessageId::StatusRefreshingSubagents => "Refreshing sub-agents...", + MessageId::StatusRequestCancelled => "Request cancelled", + MessageId::StatusRevisePlanHint => "Revise the plan and press Enter", + MessageId::StatusRollbackCancelled => "Rollback cancelled", + MessageId::StatusRollbackTargetGone => "Rollback target no longer exists", + MessageId::StatusSelectionCleared => "Selection cleared", + MessageId::StatusSessionLoadedFrom => "Session loaded from ", + MessageId::StatusSessionSavedTo => "Session saved to ", + MessageId::StatusShellControlOpened => "Shell control opened", + MessageId::StatusShellLockCorrupt => "Shell manager lock is corrupt", + MessageId::StatusShellManagerDisconnected => "Shell manager disconnected", + MessageId::StatusSidebarFocusAgents => "Sidebar focus: agents", + MessageId::StatusSidebarFocusAuto => "Sidebar focus: auto", + MessageId::StatusSidebarFocusContext => "Sidebar focus: context", + MessageId::StatusSidebarFocusTasks => "Sidebar focus: tasks", + MessageId::StatusSidebarFocusWork => "Sidebar focus: work", + MessageId::StatusSidebarHidden => "Sidebar hidden", + MessageId::StatusSkillSelected => "Skill selected: /", + MessageId::StatusSteeringTurn2 => "Steering current turn...", + MessageId::StatusSubagentDisplay => "Sub-agent ", + MessageId::StatusSuggestionPrefix => "Suggestions: ", + MessageId::StatusTaskQueued => "Task queued", + MessageId::StatusTerminalControlRestored => "Terminal control restored", + MessageId::StatusTurnFailed => "Turn failed: ", + MessageId::StatusWarmingCache => "Warming DeepSeek cache...", + MessageId::StatusWebUiListening => "Web UI listening: ", + MessageId::StatusWorkspaceNow => "Workspace: ", + MessageId::StatusWorkspaceUnchanged => "Workspace unchanged: ", + MessageId::StatusCellsRestoredWithCount => "Restored {count} hidden cells", }) } @@ -2769,10 +3773,10 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::KbCompleteCycleModes => { "补全 /command、排队运行轮次跟进、切换模式;Shift+Tab 切换推理强度" } - MessageId::KbJumpPlanAgentYolo => "直接跳转到 Plan / Agent / YOLO 模式", - MessageId::KbAltJumpPlanAgentYolo => "替代快捷键跳转到 Plan / Agent / YOLO 模式", + MessageId::KbJumpPlanAgentYolo => "直接跳转到计划模式/智能体模式 / YOLO 模式", + MessageId::KbAltJumpPlanAgentYolo => "替代快捷键跳转到计划模式/智能体模式 / YOLO 模式", MessageId::KbFocusSidebar => "聚焦 Work / 任务 / 代理 / Context / 自动 / 隐藏侧边栏", - MessageId::KbTogglePlanAgent => "在 Plan 和 Agent 模式之间切换", + MessageId::KbTogglePlanAgent => "在计划模式和智能体模式之间切换", MessageId::KbSessionPicker => "打开会话选择器", MessageId::KbPasteAttach => "粘贴文本或附加剪贴板图片", MessageId::KbCopySelection => "复制当前选中内容(macOS 为 Cmd+C)", @@ -2815,14 +3819,14 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::HomeQuickTaskList => "/task list - 显示后台任务队列", MessageId::HomeQuickHelp => "/help - 显示帮助", MessageId::HomeModeTips => "模式提示", - MessageId::HomeAgentModeTip => "Agent 模式 - 使用工具执行自主任务", - MessageId::HomeAgentModeReviewTip => " 按 Ctrl+X 可在 Plan 模式下审查后再执行", + MessageId::HomeAgentModeTip => "智能体模式 - 使用工具执行自主任务", + MessageId::HomeAgentModeReviewTip => " 按 Ctrl+X 可在计划模式下审查后再执行", MessageId::HomeAgentModeYoloTip => " 输入 /mode yolo 启用完整工具访问", MessageId::HomeYoloModeTip => "YOLO 模式 - 完整工具访问,无需审批", MessageId::HomeYoloModeCaution => " 请小心破坏性操作!", - MessageId::HomePlanModeTip => "Plan 模式 - 先设计再实现", + MessageId::HomePlanModeTip => "计划模式 - 先设计再实现", MessageId::HomePlanModeChecklistTip => " 使用 /mode plan 创建结构化检查清单", - MessageId::HomeGoalModeTip => "Goal 跟踪 - 设置 /goal <目标> 以跟踪持久目标", + MessageId::HomeGoalModeTip => "目标跟踪 - 设置 /goal <目标> 以跟踪持久目标", // Onboarding — language picker. MessageId::OnboardLanguageTitle => "选择语言", MessageId::OnboardLanguageBlurb => { @@ -2858,7 +3862,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::OnboardTipsLine1 => "用自然语言描述任务。需要命令时使用 /help 或 Ctrl+K。", MessageId::OnboardTipsLine2 => "底部输入框支持多行:Enter 发送,Alt+Enter 或 Ctrl+J 换行。", MessageId::OnboardTipsLine3 => { - "按需切换模式:Plan 适合先审后行,Agent 用于执行,YOLO 启用自动批准。" + "按需切换模式:计划模式适合先审后行,智能体模式用于执行,YOLO 启用自动批准。" } MessageId::OnboardTipsLine4 => "Ctrl+R 恢复历史会话,Esc 退出当前输入或弹层。", MessageId::OnboardTipsFooterEnter => "按 Enter", @@ -2940,6 +3944,106 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "提示:稳定前缀区块符合 DeepSeek V4 前缀缓存条件。易变工作集的更改仅会破坏缓存尾部。" } + MessageId::CmdLocaleDescription => "切换界面语言或列出可用语言", + MessageId::LocaleCurrentLabel => "当前语言: ", + MessageId::LocaleAvailableLabel => "可用: ", + MessageId::LocaleUsageLabel => "用法: /locale <代码> (例如 en, ja, zh-Hans, zh-Hant, pt-BR, es-419, vi, auto)", + MessageId::StatusNoResumableSession => "没有找到可恢复的会话", + MessageId::StatusCannotLoadSession => "无法加载会话: ", + MessageId::StatusCannotRestoreOfflineQueue => "无法恢复离线队列: ", + MessageId::StatusMemoryAppended => "memory: 已追加到 ", + MessageId::StatusCancelHint => "按 Esc 或 Ctrl+C 取消", + MessageId::StatusTurnFailed => "回合失败: ", + MessageId::StatusSubagentDisplay => "子代理 ", + MessageId::StatusTerminalControlRestored => "终端控制已恢复", + MessageId::StatusDecisionCancelled => "决策已取消", + MessageId::StatusCopiedTurnId => "已复制回合 ID ", + MessageId::StatusCopiedDetail => "已复制 ", + MessageId::StatusFileTreeClosed => "文件树已关闭", + MessageId::StatusFileTreeHint => "文件树: ↑/↓ 导航 Enter 选中 Esc 关闭", + MessageId::StatusSidebarFocusWork => "侧边栏焦点: 工作", + MessageId::StatusCannotMarkOnboarding => "无法标记入职状态: ", + MessageId::StatusModeSwitched => "已切换到 {mode} 模式", + MessageId::StatusAttachmentSelected => "已选择附件 - Backspace/Delete 删除", + MessageId::StatusComposerFocused => "输入框已聚焦", + MessageId::StatusAttachmentRemoved => "已移除附件: ", + MessageId::StatusImageAttached => "已附加图片: ", + MessageId::StatusHistorySearchFilter => "历史搜索: 输入以筛选,回车确认", + MessageId::StatusHistorySearchConfirm => "历史搜索: 回车确认,Esc 恢复", + MessageId::StatusHistoryInserted => "历史记录已插入到输入框", + MessageId::StatusHistoryNoMatches => "没有匹配的历史记录", + MessageId::StatusHistoryCancelled => "历史搜索已取消", + MessageId::StatusNoFileMatch => "没有文件匹配 @", + MessageId::StatusSidebarFocusTasks => "侧边栏焦点: 任务", + MessageId::StatusSidebarFocusAgents => "侧边栏焦点: 代理", + MessageId::StatusSidebarFocusContext => "侧边栏焦点: 上下文", + MessageId::StatusFileAttached => "已附加 @", + MessageId::StatusFileSharedPrefix => "@", + MessageId::StatusFileMatchPreview => "匹配: ", + MessageId::StatusTaskQueued => "任务已排队", + MessageId::StatusNoSelectionToOpen => "没有选中内容可打开", + MessageId::StatusSelectionCleared => "已清除选中内容", + MessageId::StatusNoDetailsAvailable => "该行没有可用的详情信息", + MessageId::StatusOpenedInEditor => "已在编辑器中打开文件", + MessageId::StatusNoFileLineInSelection => "选中内容中未找到文件:行号格式", + MessageId::StatusCellHidden => "单元格已隐藏", + MessageId::StatusCellShown => "单元格已显示", + MessageId::StatusCellsRestored => "已恢复 ", + MessageId::StatusCopiedSelection => "已复制选中内容", + MessageId::StatusCopyFailed => "复制失败", + MessageId::StatusNoSelectionToCopy => "没有选中内容可复制", + MessageId::StatusSkillSelected => "已选择技能: /", + MessageId::StatusCommandSelected => "已选择命令: ", + MessageId::StatusAutoCompleted => "自动补全: /", + MessageId::StatusCommandCompleted => "命令已完成: ", + MessageId::StatusSidebarFocusAuto => "侧边栏焦点: 自动", + MessageId::StatusSuggestionPrefix => "建议: ", + MessageId::StatusRollbackCancelled => "回退已取消", + MessageId::StatusNoPrevToolOutput => "没有上一条工具输出", + MessageId::StatusNoNextToolOutput => "没有下一条工具输出", + MessageId::StatusEditedIn => "已在 {editor} 中编辑", + MessageId::StatusEditorClosedNoChanges => "编辑器已关闭(无更改)", + MessageId::StatusEditorCancelled => "编辑器已取消", + MessageId::StatusEditorError => "编辑器错误: ", + MessageId::StatusDraftRestored => "已恢复清除的草稿", + MessageId::StatusNoCopyableCells => "没有可复制的对话单元格", + MessageId::StatusSidebarHidden => "侧边栏已隐藏", + MessageId::StatusSessionSavedTo => "会话已保存到 ", + MessageId::StatusSessionLoadedFrom => "会话已从 ", + MessageId::StatusWarmingCache => "正在预热 DeepSeek 缓存...", + MessageId::StatusCacheWarmupDone => "缓存预热完成", + MessageId::StatusCacheWarmupFailed => "缓存预热失败", + MessageId::StatusWebUiListening => "Web UI 监听: ", + MessageId::StatusOpenedInBrowser => "已在浏览器中打开 ", + MessageId::StatusQueuedTask => "已排队 ", + MessageId::StatusConfigProfile => "配置文件: ", + MessageId::StatusWorkspaceUnchanged => "工作区未改变: ", + MessageId::StatusWorkspaceNow => "工作区: ", + MessageId::StatusQueuedCountHint => " 个已排队 — ↑ 编辑, /queue list", + MessageId::StatusCompactingContext2 => "正在压缩上下文...", + MessageId::StatusSteeringTurn2 => "正在引导当前回合...", + MessageId::StatusRevisePlanHint => "请修订计划后按回车", + MessageId::StatusLiveOverlayTracking => "实时对话: 跟踪中 (按 Esc 关闭)", + MessageId::StatusLabelEmpty => "{label} 为空", + MessageId::StatusCopiedLabel => "已复制 {label}", + MessageId::StatusCopyFailedLabel => "复制失败 ({label})", + MessageId::StatusApplyPlanChoiceFailed => "应用计划选择失败: ", + MessageId::StatusRefreshingSubagents => "正在刷新子代理...", + MessageId::StatusRollbackTargetGone => "回退目标已不存在", + MessageId::StatusRequestCancelled => "请求已取消", + MessageId::StatusNoForegroundShell => "没有前台 shell 命令可控制", + MessageId::StatusShellControlOpened => "Shell 控制已打开", + MessageId::StatusNoBgShell => "没有可后台运行的前台 shell 命令", + MessageId::StatusShellManagerDisconnected => "Shell 管理器未连接", + MessageId::StatusBackgroundingShell => "正在将当前 shell 命令放到后台...", + MessageId::StatusShellLockCorrupt => "Shell 管理器锁已损坏", + MessageId::StatusNoActivityDetails => "没有可用的活动详情", + MessageId::StatusNoDetailsForLine => "所选行没有可用详情", + MessageId::StatusNoMessageAtPos => "该位置没有消息", + MessageId::StatusMessageEmpty => "消息为空", + MessageId::StatusMessageCopied => "消息已复制", + MessageId::StatusMessageCopyFailed => "复制失败", + MessageId::StatusCellsRestoredWithCount => "已恢复 {count} 个隐藏单元格", }) } @@ -3227,8 +4331,8 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::KbCompleteCycleModes => { "Completar /command, enfileirar follow-up, ciclar modos; Shift+Tab cicla esforço de raciocínio" } - MessageId::KbJumpPlanAgentYolo => "Pular direto para modo Plan / Agent / YOLO", - MessageId::KbAltJumpPlanAgentYolo => "Salto alternativo para modo Plan / Agent / YOLO", + MessageId::KbJumpPlanAgentYolo => "Pular direto para modo 计划模式/智能体模式 / YOLO", + MessageId::KbAltJumpPlanAgentYolo => "Salto alternativo para modo 计划模式/智能体模式 / YOLO", MessageId::KbFocusSidebar => { "Focar barra lateral Work / Tasks / Agents / Context / Auto / Ocultar" } @@ -3426,6 +4530,107 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "Dica: Blocos de prefixo estável são elegíveis para cache de prefixo DeepSeek V4. Alterações no conjunto de trabalho volátil quebram o cache apenas no final." } + + MessageId::CmdLocaleDescription => "Alternar idioma da interface ou listar idiomas disponíveis", + MessageId::LocaleCurrentLabel => "Idioma atual: ", + MessageId::LocaleAvailableLabel => "Disponíveis: ", + MessageId::LocaleUsageLabel => "Uso: /locale (ex: en, ja, zh-Hans, zh-Hant, pt-BR, es-419, vi, auto)", + MessageId::StatusApplyPlanChoiceFailed => "Apply plan choice failed: ", + MessageId::StatusAttachmentRemoved => "Attachment removed: ", + MessageId::StatusAttachmentSelected => "Attachment selected - Backspace/Delete to remove", + MessageId::StatusAutoCompleted => "Auto-completed: /", + MessageId::StatusBackgroundingShell => "Backgrounding current shell command...", + MessageId::StatusCacheWarmupDone => "Cache warmup complete", + MessageId::StatusCacheWarmupFailed => "Cache warmup failed", + MessageId::StatusCancelHint => "Press Esc or Ctrl+C to cancel", + MessageId::StatusCannotLoadSession => "Cannot load session: ", + MessageId::StatusCannotMarkOnboarding => "Cannot mark onboarding: ", + MessageId::StatusCannotRestoreOfflineQueue => "Cannot restore offline queue: ", + MessageId::StatusCellHidden => "Cell hidden", + MessageId::StatusCellShown => "Cell shown", + MessageId::StatusCellsRestored => "Restored ", + MessageId::StatusCommandCompleted => "Command completed: ", + MessageId::StatusCommandSelected => "Command selected: ", + MessageId::StatusCompactingContext2 => "Compacting context...", + MessageId::StatusComposerFocused => "Composer focused", + MessageId::StatusConfigProfile => "Profile: ", + MessageId::StatusCopiedDetail => "Copied ", + MessageId::StatusCopiedLabel => "Copied {label}", + MessageId::StatusCopiedSelection => "Copied selection", + MessageId::StatusCopiedTurnId => "Copied turn ID ", + MessageId::StatusCopyFailed => "Copy failed", + MessageId::StatusCopyFailedLabel => "Copy failed ({label})", + MessageId::StatusDecisionCancelled => "Decision cancelled", + MessageId::StatusDraftRestored => "Cleared draft restored", + MessageId::StatusEditedIn => "Edited in {editor}", + MessageId::StatusEditorCancelled => "Editor cancelled", + MessageId::StatusEditorClosedNoChanges => "Editor closed (no changes)", + MessageId::StatusEditorError => "Editor error: ", + MessageId::StatusFileAttached => "Attached @", + MessageId::StatusFileMatchPreview => "Matches: ", + MessageId::StatusFileSharedPrefix => "@", + MessageId::StatusFileTreeClosed => "File tree closed", + MessageId::StatusFileTreeHint => "File tree: ↑/↓ navigate Enter select Esc close", + MessageId::StatusHistoryCancelled => "History search cancelled", + MessageId::StatusHistoryInserted => "History entry inserted into composer", + MessageId::StatusHistoryNoMatches => "No matching history entries", + MessageId::StatusHistorySearchConfirm => "History search: Enter to confirm, Esc to restore", + MessageId::StatusHistorySearchFilter => "History search: type to filter, Enter to confirm", + MessageId::StatusImageAttached => "Image attached: ", + MessageId::StatusLabelEmpty => "{label} is empty", + MessageId::StatusLiveOverlayTracking => "Live overlay: tracking (Esc to close)", + MessageId::StatusMemoryAppended => "memory: appended to ", + MessageId::StatusMessageCopied => "Message copied", + MessageId::StatusMessageCopyFailed => "Copy failed", + MessageId::StatusMessageEmpty => "Message is empty", + MessageId::StatusModeSwitched => "Switched to {mode} mode", + MessageId::StatusNoActivityDetails => "No activity details available", + MessageId::StatusNoBgShell => "No foreground shell command to background", + MessageId::StatusNoCopyableCells => "No copyable transcript cells", + MessageId::StatusNoDetailsAvailable => "No details available for this line", + MessageId::StatusNoDetailsForLine => "No details available for the selected line", + MessageId::StatusNoFileLineInSelection => "No file:line pattern in selection", + MessageId::StatusNoFileMatch => "No files match @", + MessageId::StatusNoForegroundShell => "No foreground shell command to control", + MessageId::StatusNoMessageAtPos => "No message at this position", + MessageId::StatusNoNextToolOutput => "No next tool output", + MessageId::StatusNoPrevToolOutput => "No previous tool output", + MessageId::StatusNoResumableSession => "No resumable session found", + MessageId::StatusNoSelectionToCopy => "No selection to copy", + MessageId::StatusNoSelectionToOpen => "No selection to open", + MessageId::StatusOpenedInBrowser => "Opened in browser: ", + MessageId::StatusOpenedInEditor => "Opened file in editor", + MessageId::StatusQueuedCountHint => " queued — ↑ to edit, /queue list", + MessageId::StatusQueuedTask => "Queued ", + MessageId::StatusRefreshingSubagents => "Refreshing sub-agents...", + MessageId::StatusRequestCancelled => "Request cancelled", + MessageId::StatusRevisePlanHint => "Revise the plan and press Enter", + MessageId::StatusRollbackCancelled => "Rollback cancelled", + MessageId::StatusRollbackTargetGone => "Rollback target no longer exists", + MessageId::StatusSelectionCleared => "Selection cleared", + MessageId::StatusSessionLoadedFrom => "Session loaded from ", + MessageId::StatusSessionSavedTo => "Session saved to ", + MessageId::StatusShellControlOpened => "Shell control opened", + MessageId::StatusShellLockCorrupt => "Shell manager lock is corrupt", + MessageId::StatusShellManagerDisconnected => "Shell manager disconnected", + MessageId::StatusSidebarFocusAgents => "Sidebar focus: agents", + MessageId::StatusSidebarFocusAuto => "Sidebar focus: auto", + MessageId::StatusSidebarFocusContext => "Sidebar focus: context", + MessageId::StatusSidebarFocusTasks => "Sidebar focus: tasks", + MessageId::StatusSidebarFocusWork => "Sidebar focus: work", + MessageId::StatusSidebarHidden => "Sidebar hidden", + MessageId::StatusSkillSelected => "Skill selected: /", + MessageId::StatusSteeringTurn2 => "Steering current turn...", + MessageId::StatusSubagentDisplay => "Sub-agent ", + MessageId::StatusSuggestionPrefix => "Suggestions: ", + MessageId::StatusTaskQueued => "Task queued", + MessageId::StatusTerminalControlRestored => "Terminal control restored", + MessageId::StatusTurnFailed => "Turn failed: ", + MessageId::StatusWarmingCache => "Warming DeepSeek cache...", + MessageId::StatusWebUiListening => "Web UI listening: ", + MessageId::StatusWorkspaceNow => "Workspace: ", + MessageId::StatusWorkspaceUnchanged => "Workspace unchanged: ", + MessageId::StatusCellsRestoredWithCount => "Restored {count} hidden cells", }) } @@ -3725,8 +4930,8 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::KbCompleteCycleModes => { "Completar /command, encolar follow-up, ciclar modos; Shift+Tab cicla esfuerzo de razonamiento" } - MessageId::KbJumpPlanAgentYolo => "Saltar directo a modo Plan / Agent / YOLO", - MessageId::KbAltJumpPlanAgentYolo => "Salto alternativo a modo Plan / Agent / YOLO", + MessageId::KbJumpPlanAgentYolo => "Saltar directo a modo 计划模式/智能体模式 / YOLO", + MessageId::KbAltJumpPlanAgentYolo => "Salto alternativo a modo 计划模式/智能体模式 / YOLO", MessageId::KbFocusSidebar => { "Enfocar barra lateral Work / Tasks / Agents / Context / Auto / Ocultar" } @@ -3922,6 +5127,107 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "Consejo: Los bloques de prefijo estable son elegibles para caché de prefijo DeepSeek V4. Los cambios en el conjunto de trabajo volátil solo rompen la caché al final." } + + MessageId::CmdLocaleDescription => "Cambiar idioma de la interfaz o listar idiomas disponibles", + MessageId::LocaleCurrentLabel => "Idioma actual: ", + MessageId::LocaleAvailableLabel => "Disponibles: ", + MessageId::LocaleUsageLabel => "Uso: /locale (ej: en, ja, zh-Hans, zh-Hant, pt-BR, es-419, vi, auto)", + MessageId::StatusApplyPlanChoiceFailed => "Apply plan choice failed: ", + MessageId::StatusAttachmentRemoved => "Attachment removed: ", + MessageId::StatusAttachmentSelected => "Attachment selected - Backspace/Delete to remove", + MessageId::StatusAutoCompleted => "Auto-completed: /", + MessageId::StatusBackgroundingShell => "Backgrounding current shell command...", + MessageId::StatusCacheWarmupDone => "Cache warmup complete", + MessageId::StatusCacheWarmupFailed => "Cache warmup failed", + MessageId::StatusCancelHint => "Press Esc or Ctrl+C to cancel", + MessageId::StatusCannotLoadSession => "Cannot load session: ", + MessageId::StatusCannotMarkOnboarding => "Cannot mark onboarding: ", + MessageId::StatusCannotRestoreOfflineQueue => "Cannot restore offline queue: ", + MessageId::StatusCellHidden => "Cell hidden", + MessageId::StatusCellShown => "Cell shown", + MessageId::StatusCellsRestored => "Restored ", + MessageId::StatusCommandCompleted => "Command completed: ", + MessageId::StatusCommandSelected => "Command selected: ", + MessageId::StatusCompactingContext2 => "Compacting context...", + MessageId::StatusComposerFocused => "Composer focused", + MessageId::StatusConfigProfile => "Profile: ", + MessageId::StatusCopiedDetail => "Copied ", + MessageId::StatusCopiedLabel => "Copied {label}", + MessageId::StatusCopiedSelection => "Copied selection", + MessageId::StatusCopiedTurnId => "Copied turn ID ", + MessageId::StatusCopyFailed => "Copy failed", + MessageId::StatusCopyFailedLabel => "Copy failed ({label})", + MessageId::StatusDecisionCancelled => "Decision cancelled", + MessageId::StatusDraftRestored => "Cleared draft restored", + MessageId::StatusEditedIn => "Edited in {editor}", + MessageId::StatusEditorCancelled => "Editor cancelled", + MessageId::StatusEditorClosedNoChanges => "Editor closed (no changes)", + MessageId::StatusEditorError => "Editor error: ", + MessageId::StatusFileAttached => "Attached @", + MessageId::StatusFileMatchPreview => "Matches: ", + MessageId::StatusFileSharedPrefix => "@", + MessageId::StatusFileTreeClosed => "File tree closed", + MessageId::StatusFileTreeHint => "File tree: ↑/↓ navigate Enter select Esc close", + MessageId::StatusHistoryCancelled => "History search cancelled", + MessageId::StatusHistoryInserted => "History entry inserted into composer", + MessageId::StatusHistoryNoMatches => "No matching history entries", + MessageId::StatusHistorySearchConfirm => "History search: Enter to confirm, Esc to restore", + MessageId::StatusHistorySearchFilter => "History search: type to filter, Enter to confirm", + MessageId::StatusImageAttached => "Image attached: ", + MessageId::StatusLabelEmpty => "{label} is empty", + MessageId::StatusLiveOverlayTracking => "Live overlay: tracking (Esc to close)", + MessageId::StatusMemoryAppended => "memory: appended to ", + MessageId::StatusMessageCopied => "Message copied", + MessageId::StatusMessageCopyFailed => "Copy failed", + MessageId::StatusMessageEmpty => "Message is empty", + MessageId::StatusModeSwitched => "Switched to {mode} mode", + MessageId::StatusNoActivityDetails => "No activity details available", + MessageId::StatusNoBgShell => "No foreground shell command to background", + MessageId::StatusNoCopyableCells => "No copyable transcript cells", + MessageId::StatusNoDetailsAvailable => "No details available for this line", + MessageId::StatusNoDetailsForLine => "No details available for the selected line", + MessageId::StatusNoFileLineInSelection => "No file:line pattern in selection", + MessageId::StatusNoFileMatch => "No files match @", + MessageId::StatusNoForegroundShell => "No foreground shell command to control", + MessageId::StatusNoMessageAtPos => "No message at this position", + MessageId::StatusNoNextToolOutput => "No next tool output", + MessageId::StatusNoPrevToolOutput => "No previous tool output", + MessageId::StatusNoResumableSession => "No resumable session found", + MessageId::StatusNoSelectionToCopy => "No selection to copy", + MessageId::StatusNoSelectionToOpen => "No selection to open", + MessageId::StatusOpenedInBrowser => "Opened in browser: ", + MessageId::StatusOpenedInEditor => "Opened file in editor", + MessageId::StatusQueuedCountHint => " queued — ↑ to edit, /queue list", + MessageId::StatusQueuedTask => "Queued ", + MessageId::StatusRefreshingSubagents => "Refreshing sub-agents...", + MessageId::StatusRequestCancelled => "Request cancelled", + MessageId::StatusRevisePlanHint => "Revise the plan and press Enter", + MessageId::StatusRollbackCancelled => "Rollback cancelled", + MessageId::StatusRollbackTargetGone => "Rollback target no longer exists", + MessageId::StatusSelectionCleared => "Selection cleared", + MessageId::StatusSessionLoadedFrom => "Session loaded from ", + MessageId::StatusSessionSavedTo => "Session saved to ", + MessageId::StatusShellControlOpened => "Shell control opened", + MessageId::StatusShellLockCorrupt => "Shell manager lock is corrupt", + MessageId::StatusShellManagerDisconnected => "Shell manager disconnected", + MessageId::StatusSidebarFocusAgents => "Sidebar focus: agents", + MessageId::StatusSidebarFocusAuto => "Sidebar focus: auto", + MessageId::StatusSidebarFocusContext => "Sidebar focus: context", + MessageId::StatusSidebarFocusTasks => "Sidebar focus: tasks", + MessageId::StatusSidebarFocusWork => "Sidebar focus: work", + MessageId::StatusSidebarHidden => "Sidebar hidden", + MessageId::StatusSkillSelected => "Skill selected: /", + MessageId::StatusSteeringTurn2 => "Steering current turn...", + MessageId::StatusSubagentDisplay => "Sub-agent ", + MessageId::StatusSuggestionPrefix => "Suggestions: ", + MessageId::StatusTaskQueued => "Task queued", + MessageId::StatusTerminalControlRestored => "Terminal control restored", + MessageId::StatusTurnFailed => "Turn failed: ", + MessageId::StatusWarmingCache => "Warming DeepSeek cache...", + MessageId::StatusWebUiListening => "Web UI listening: ", + MessageId::StatusWorkspaceNow => "Workspace: ", + MessageId::StatusWorkspaceUnchanged => "Workspace unchanged: ", + MessageId::StatusCellsRestoredWithCount => "Restored {count} hidden cells", }) } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 76aba38c7..03695385b 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1509,6 +1509,8 @@ pub struct App { pub todos: SharedTodoList, /// Durable runtime services exposed to model-visible task/automation tools. pub runtime_services: RuntimeToolServices, + /// Tab manager for multi-tab and cross-tab collaboration system + pub tab_manager: crate::tui::tab::TabManager, /// Last MCP manager/discovery snapshot shown in the UI. pub mcp_snapshot: Option, /// Number of MCP servers declared in the user's config at app boot. @@ -2217,6 +2219,7 @@ impl App { shell_manager: Some(shell_manager), ..RuntimeToolServices::default() }, + tab_manager: crate::tui::tab::TabManager::new(), mcp_snapshot: None, // Read the MCP config once at boot to know how many servers // the user has declared. The footer chip uses this even when @@ -2305,6 +2308,35 @@ impl App { } } + /// Create a minimal App for testing - no config loading, no engine setup. + /// Only the fields that render tests need (tab_manager, etc.) are populated. + #[allow(dead_code)] // reserved for the render_tests follow-up; not used in this PR + pub fn new_for_test() -> App { + use std::path::PathBuf; + let options = TuiOptions { + model: "test-model".to_string(), + workspace: PathBuf::from("."), + config_path: None, + config_profile: None, + 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, + initial_input: None, + }; + App::new(options, &Config::default()) + } + fn discover_cached_skills( workspace: &std::path::Path, skills_dir: &std::path::Path, @@ -2342,7 +2374,7 @@ impl App { pub fn finish_onboarding(&mut self) { self.onboarding = OnboardingState::None; if let Err(err) = crate::tui::onboarding::mark_onboarded() { - self.status_message = Some(format!("Failed to mark onboarding: {err}")); + self.status_message = Some(format!("{}{err}", tr(self.ui_locale, MessageId::StatusCannotMarkOnboarding))); } self.needs_redraw = true; } @@ -2381,7 +2413,8 @@ impl App { let entering_yolo = mode == AppMode::Yolo && previous_mode != AppMode::Yolo; let leaving_yolo = previous_mode == AppMode::Yolo && mode != AppMode::Yolo; self.mode = mode; - self.status_message = Some(format!("Switched to {} mode", mode.label())); + let template = tr(self.ui_locale, MessageId::StatusModeSwitched); + self.status_message = Some(template.replace("{mode}", mode.label())); if entering_yolo { self.yolo_restore = Some(YoloRestoreState { @@ -3552,7 +3585,7 @@ impl App { .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.status_message = Some(tr(self.ui_locale, MessageId::StatusAttachmentSelected).to_string()); self.needs_redraw = true; true } @@ -3565,10 +3598,10 @@ impl App { if index + 1 < count { self.selected_attachment_index = Some(index + 1); self.status_message = - Some("Attachment selected - Backspace/Delete removes it".to_string()); + Some("已选择附件 - Backspace/Delete 删除".to_string()); } else { self.selected_attachment_index = None; - self.status_message = Some("Composer focused".to_string()); + self.status_message = Some(tr(self.ui_locale, MessageId::StatusComposerFocused).to_string()); } self.needs_redraw = true; true @@ -3576,7 +3609,7 @@ impl App { 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.status_message = Some(tr(self.ui_locale, MessageId::StatusComposerFocused).to_string()); self.needs_redraw = true; true } else { @@ -3617,7 +3650,7 @@ impl App { 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.status_message = Some(format!("{}{}", tr(self.ui_locale, MessageId::StatusAttachmentRemoved), reference.path)); self.needs_redraw = true; true } @@ -3699,7 +3732,7 @@ impl App { 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}")); + self.status_message = Some(format!("{}{description}", tr(self.ui_locale, MessageId::StatusImageAttached))); } } } @@ -4381,7 +4414,7 @@ impl App { 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.status_message = Some(tr(self.ui_locale, MessageId::StatusHistorySearchFilter).to_string()); self.needs_redraw = true; } @@ -4460,7 +4493,7 @@ impl App { 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.status_message = Some(tr(self.ui_locale, MessageId::StatusHistorySearchConfirm).to_string()); self.needs_redraw = true; } } @@ -4472,7 +4505,7 @@ impl App { 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.status_message = Some(tr(self.ui_locale, MessageId::StatusHistorySearchConfirm).to_string()); self.needs_redraw = true; } } @@ -4520,12 +4553,12 @@ impl App { 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.status_message = Some(tr(self.ui_locale, MessageId::StatusHistoryInserted).to_string()); self.needs_redraw = true; true } else { self.composer_history_search = Some(search); - self.status_message = Some("No history matches".to_string()); + self.status_message = Some(tr(self.ui_locale, MessageId::StatusHistoryNoMatches).to_string()); self.needs_redraw = true; false } @@ -4537,7 +4570,7 @@ impl App { }; 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.status_message = Some(tr(self.ui_locale, MessageId::StatusHistoryCancelled).to_string()); self.needs_redraw = true; } @@ -6179,7 +6212,7 @@ mod tests { #[test] fn test_mode_switch_toasts_do_not_disrupt_non_mode_toasts() { let mut app = App::new(test_options(false), &Config::default()); - app.status_message = Some("Task queued".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusTaskQueued).to_string()); app.sync_status_message_to_toasts(); app.set_mode(AppMode::Agent); diff --git a/crates/tui/src/tui/file_mention.rs b/crates/tui/src/tui/file_mention.rs index fdb721d27..ec4e032ff 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -28,6 +28,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::tui::app::{App, MentionCompletionCache}; +use crate::localization::{MessageId, tr}; use crate::working_set::Workspace; /// Maximum number of `@`-mentions whose contents are inlined into one user @@ -278,7 +279,7 @@ pub fn apply_mention_menu_selection(app: &mut App, entries: &[String]) -> bool { super::file_frecency::record_mention(replacement); replace_file_mention(app, byte_start, &partial, replacement); app.mention_menu_hidden = false; - app.status_message = Some(format!("Attached @{replacement}")); + app.status_message = Some(format!("{}{replacement}", tr(app.ui_locale, MessageId::StatusFileAttached))); true } @@ -301,6 +302,7 @@ pub fn try_autocomplete_file_mention(app: &mut App) -> bool { }; if candidates.is_empty() { app.status_message = Some(no_file_mention_matches_status( + app.ui_locale, &partial, app.mention_walk_depth, )); @@ -310,14 +312,14 @@ pub fn try_autocomplete_file_mention(app: &mut App) -> bool { // #441: a unique-match completion is also a "mention" for ranking. super::file_frecency::record_mention(&candidates[0]); replace_file_mention(app, byte_start, &partial, &candidates[0]); - app.status_message = Some(format!("Attached @{}", candidates[0])); + app.status_message = Some(format!("{}{}", tr(app.ui_locale, MessageId::StatusFileAttached), candidates[0])); return true; } let candidate_refs: Vec<&str> = candidates.iter().map(String::as_str).collect(); let shared = longest_common_prefix(&candidate_refs); if shared.len() > partial.len() { replace_file_mention(app, byte_start, &partial, shared); - app.status_message = Some(format!("@{shared}…")); + app.status_message = Some(format!("{}{}…", tr(app.ui_locale, MessageId::StatusFileSharedPrefix), shared)); return true; } let preview = candidates @@ -326,17 +328,22 @@ pub fn try_autocomplete_file_mention(app: &mut App) -> bool { .map(|c| format!("@{c}")) .collect::>() .join(", "); - app.status_message = Some(format!("Matches: {preview}")); + app.status_message = Some(format!("{}{preview}", tr(app.ui_locale, MessageId::StatusFileMatchPreview))); true } -fn no_file_mention_matches_status(partial: &str, walk_depth: usize) -> String { +fn no_file_mention_matches_status( + locale: crate::localization::Locale, + partial: &str, + walk_depth: usize, +) -> String { + let prefix = tr(locale, MessageId::StatusNoFileMatch); if path_partial_reaches_walk_depth(partial, walk_depth) { format!( - "No files match @{partial} (mention_walk_depth={walk_depth}; use /config set mention_walk_depth 0 to search deeper)" + "{prefix}{partial} (mention_walk_depth={walk_depth}; use /config set mention_walk_depth 0 to search deeper)" ) } else { - format!("No files match @{partial}") + format!("{prefix}{partial}") } } diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 02dc8ce55..f87648659 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -508,12 +508,20 @@ pub(crate) fn render_footer_from( // active, regardless of user-configured status items. let shell_chip = crate::tui::widgets::footer_shell_chip(active_foreground_shell_running(app)); + let locale_chip = crate::tui::widgets::footer_locale_chip(app.ui_locale); + // Right-cluster extension chips: append in `items` order so user // ordering is preserved across the new variants. let mut extra: Vec> = Vec::new(); if !shell_chip.is_empty() { extra.extend(shell_chip); } + if !locale_chip.is_empty() { + if !extra.is_empty() { + extra.push(Span::raw(" ")); + } + extra.extend(locale_chip); + } for item in items { let chip = match *item { S::PrefixStability => prefix_stability.clone(), diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 47a3e5421..a3836c11d 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -5,7 +5,7 @@ use ratatui::layout::Rect; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; -use crate::localization::MessageId; +use crate::localization::{MessageId, tr}; use crate::tui::app::App; use crate::tui::command_palette::{ CommandPaletteView, build_entries as build_command_palette_entries, @@ -701,6 +701,39 @@ pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec 1 { + entries.push(ContextMenuEntry { + label: "Delegate task to tab…".to_string(), + description: "Send the current request to another tab".to_string(), + action: ContextMenuAction::OpenTabPicker( + crate::tui::views::tab_picker::TabPickerAction::Delegate, + ), + }); + entries.push(ContextMenuEntry { + label: "Request review from tab…".to_string(), + description: "Ask another tab to review the last response".to_string(), + action: ContextMenuAction::OpenTabPicker( + crate::tui::views::tab_picker::TabPickerAction::Review, + ), + }); + entries.push(ContextMenuEntry { + label: "Invite tab to meeting…".to_string(), + description: "Start a multi-agent meeting with another tab".to_string(), + action: ContextMenuAction::OpenTabPicker( + crate::tui::views::tab_picker::TabPickerAction::Meeting, + ), + }); + entries.push(ContextMenuEntry { + label: "Share context with tab…".to_string(), + description: "Share the current session context with another tab".to_string(), + action: ContextMenuAction::OpenTabPicker( + crate::tui::views::tab_picker::TabPickerAction::Share, + ), + }); + } + entries.push(ContextMenuEntry { label: app.tr(MessageId::CtxMenuCmdPalette).to_string(), description: app.tr(MessageId::CtxMenuCmdPaletteDesc).to_string(), @@ -737,19 +770,19 @@ pub(crate) fn handle_context_menu_action(app: &mut App, action: ContextMenuActio } ContextMenuAction::OpenSelection => { if !open_pager_for_selection(app) { - app.status_message = Some("No selection to open".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusNoSelectionToOpen).to_string()); } } ContextMenuAction::ClearSelection => { app.viewport.transcript_selection.clear(); - app.status_message = Some("Selection cleared".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusSelectionCleared).to_string()); } ContextMenuAction::CopyCell { cell_index } => { copy_cell_to_clipboard(app, cell_index); } ContextMenuAction::OpenDetails { cell_index } => { if !open_details_pager_for_cell(app, cell_index) { - app.status_message = Some("No details available for that line".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusNoDetailsAvailable).to_string()); } } ContextMenuAction::Paste => { @@ -785,23 +818,28 @@ pub(crate) fn handle_context_menu_action(app: &mut App, action: ContextMenuActio width, ); if crate::tui::history::try_open_file_at_line(&text, &app.workspace) { - app.status_message = Some("Opened file in editor".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusOpenedInEditor).to_string()); } else { - app.status_message = Some("No file:line pattern found in selection".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusNoFileLineInSelection).to_string()); } } ContextMenuAction::HideCell { cell_index } => { app.collapsed_cells.insert(cell_index); - app.status_message = Some("Cell hidden".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusCellHidden).to_string()); } ContextMenuAction::ShowCell { cell_index } => { app.collapsed_cells.remove(&cell_index); - app.status_message = Some("Cell shown".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusCellShown).to_string()); } ContextMenuAction::ShowAllHidden => { let count = app.collapsed_cells.len(); app.collapsed_cells.clear(); - app.status_message = Some(format!("{count} hidden cell(s) restored")); + let template = tr(app.ui_locale, MessageId::StatusCellsRestoredWithCount); + app.status_message = Some(template.replace("{count}", &count.to_string())); + } + ContextMenuAction::OpenTabPicker(kind) => { + app.view_stack + .push(crate::tui::views::tab_picker::TabPickerView::new(app, kind)); } } app.needs_redraw = true; @@ -907,13 +945,13 @@ pub(crate) fn copy_active_selection(app: &mut 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()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusCopiedSelection).to_string()); } else { - app.status_message = Some("Copy failed".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusCopyFailed).to_string()); } } else { app.viewport.transcript_selection.clear(); - app.status_message = Some("No selection to copy".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusNoSelectionToCopy).to_string()); } } diff --git a/crates/tui/src/tui/slash_menu.rs b/crates/tui/src/tui/slash_menu.rs index 05905f747..5c9c42621 100644 --- a/crates/tui/src/tui/slash_menu.rs +++ b/crates/tui/src/tui/slash_menu.rs @@ -9,6 +9,7 @@ //! behaviour differ enough to keep them apart. use crate::commands; +use crate::localization::{MessageId, tr}; use super::app::{App, looks_like_slash_command_input}; use super::widgets::SlashMenuEntry; @@ -57,7 +58,7 @@ pub fn apply_slash_menu_selection( { replace_inline_skill_mention(app, byte_start, &partial, &skill_name); app.slash_menu_hidden = false; - app.status_message = Some(format!("Skill selected: /{skill_name}")); + app.status_message = Some(format!("{}{skill_name}", tr(app.ui_locale, MessageId::StatusSkillSelected))); return true; } @@ -76,7 +77,7 @@ pub fn apply_slash_menu_selection( app.input = command; app.cursor_position = app.input.chars().count(); app.slash_menu_hidden = false; - app.status_message = Some(format!("Command selected: {}", app.input.trim_end())); + app.status_message = Some(format!("{}{}", tr(app.ui_locale, MessageId::StatusCommandSelected), app.input.trim_end())); true } @@ -233,7 +234,7 @@ pub fn try_autocomplete_slash_command(app: &mut App) -> bool { app.input = format!("/{shared}"); app.cursor_position = app.input.chars().count(); app.slash_menu_hidden = false; - app.status_message = Some(format!("Autocomplete: /{shared}")); + app.status_message = Some(format!("{}{shared}", tr(app.ui_locale, MessageId::StatusAutoCompleted))); return true; } @@ -245,7 +246,7 @@ pub fn try_autocomplete_slash_command(app: &mut App) -> bool { app.input = completed.clone(); app.cursor_position = completed.chars().count(); app.slash_menu_hidden = false; - app.status_message = Some(format!("Command completed: {}", completed.trim_end())); + app.status_message = Some(format!("{}{}", tr(app.ui_locale, MessageId::StatusCommandCompleted), completed.trim_end())); return true; } @@ -255,6 +256,6 @@ pub fn try_autocomplete_slash_command(app: &mut App) -> bool { .map(String::as_str) .collect::>() .join(", "); - app.status_message = Some(format!("Suggestions: {preview}")); + app.status_message = Some(format!("{}{preview}", tr(app.ui_locale, MessageId::StatusSuggestionPrefix))); true } diff --git a/crates/tui/src/tui/tab/benches.rs b/crates/tui/src/tui/tab/benches.rs index 103ef60ae..3c63650f6 100644 --- a/crates/tui/src/tui/tab/benches.rs +++ b/crates/tui/src/tui/tab/benches.rs @@ -14,7 +14,7 @@ //! - Persistence save/load (startup + shutdown overhead) //! - Group color rendering (per-frame work in tab bar) -#![allow(unused_imports, clippy::module_inception, clippy::print_stderr)] +#![allow(unused_imports)] #[cfg(test)] mod benches { diff --git a/crates/tui/src/tui/tab/delegator.rs b/crates/tui/src/tui/tab/delegator.rs index 10df3b9d7..806a55a3a 100644 --- a/crates/tui/src/tui/tab/delegator.rs +++ b/crates/tui/src/tui/tab/delegator.rs @@ -364,17 +364,6 @@ impl TaskDelegator { self.next_id += 1; format!("delegation_{}", id) } - - pub(crate) fn advance_next_id_past_existing_tasks(&mut self) { - let max_seen = self - .pending_tasks - .iter() - .filter_map(|task| task.task_id.strip_prefix("delegation_")) - .filter_map(|suffix| suffix.parse::().ok()) - .max() - .unwrap_or(0); - self.next_id = self.next_id.max(max_seen + 1); - } } impl Default for TaskDelegator { @@ -406,7 +395,7 @@ mod tests { let results_to = delegator.results_for_tab(to); assert_eq!(results_to.len(), 1); - assert!(results_to[0].was_successful); + assert_eq!(results_to[0].was_successful, true); } #[test] @@ -550,28 +539,4 @@ mod tests { delegator.prune_results(10); assert_eq!(delegator.results_for_tab(to).len(), 3); } - - #[test] - fn test_advance_next_id_after_restore() { - let mut delegator = TaskDelegator::new(); - delegator.pending_tasks.push(DelegationTask::new( - "delegation_42".to_string(), - TabId::new(1), - TabId::new(2), - "restored".to_string(), - Priority::Normal, - )); - - delegator.advance_next_id_past_existing_tasks(); - let new_id = delegator - .create_delegation( - TabId::new(1), - TabId::new(2), - "fresh".to_string(), - Priority::Normal, - ) - .unwrap(); - - assert_eq!(new_id, "delegation_43"); - } } diff --git a/crates/tui/src/tui/tab/group.rs b/crates/tui/src/tui/tab/group.rs index e61fb1d03..715214d88 100644 --- a/crates/tui/src/tui/tab/group.rs +++ b/crates/tui/src/tui/tab/group.rs @@ -239,17 +239,6 @@ impl TabGroupManager { self.next_id += 1; format!("group_{}", id) } - - pub(crate) fn advance_next_id_past_existing_groups(&mut self) { - let max_seen = self - .groups - .keys() - .filter_map(|id| id.strip_prefix("group_")) - .filter_map(|suffix| suffix.parse::().ok()) - .max() - .unwrap_or(0); - self.next_id = self.next_id.max(max_seen + 1); - } } impl Default for TabGroupManager { @@ -356,28 +345,6 @@ mod tests { mgr.delete_group(&g1); assert!(mgr.group_of(tab1).is_none()); - assert!(!mgr.tab_to_group.contains_key(&tab1)); - } - - #[test] - fn test_advance_next_id_after_restore() { - let mut mgr = TabGroupManager::new(); - mgr.groups.insert( - "group_7".to_string(), - TabGroup { - id: "group_7".to_string(), - name: "Restored".to_string(), - color: GroupColor::Blue, - tab_ids: Vec::new(), - created_at: Utc::now(), - }, - ); - - mgr.advance_next_id_past_existing_groups(); - let new_id = mgr.create_group("Fresh".to_string(), GroupColor::Green); - - assert_eq!(new_id, "group_8"); - assert!(mgr.groups.contains_key("group_7")); - assert!(mgr.groups.contains_key("group_8")); + assert!(mgr.tab_to_group.get(&tab1).is_none()); } } diff --git a/crates/tui/src/tui/tab/key_e2e.rs b/crates/tui/src/tui/tab/key_e2e.rs index d80d4f9c2..dbd9098d2 100644 --- a/crates/tui/src/tui/tab/key_e2e.rs +++ b/crates/tui/src/tui/tab/key_e2e.rs @@ -212,11 +212,11 @@ mod tests { manager.switch_to_by_id(to); // Press Ctrl+Shift+D 4 times - assert!(simulate_process_delegation(&mut manager).is_some()); - assert!(simulate_process_delegation(&mut manager).is_some()); - assert!(simulate_process_delegation(&mut manager).is_some()); + assert_eq!(simulate_process_delegation(&mut manager).is_some(), true); + assert_eq!(simulate_process_delegation(&mut manager).is_some(), true); + assert_eq!(simulate_process_delegation(&mut manager).is_some(), true); // 4th should be the last task - assert!(simulate_process_delegation(&mut manager).is_some()); + assert_eq!(simulate_process_delegation(&mut manager).is_some(), true); // 5th should be none assert_eq!(simulate_process_delegation(&mut manager), None); } diff --git a/crates/tui/src/tui/tab/manager.rs b/crates/tui/src/tui/tab/manager.rs index 7bf7fb2f7..61f280bdd 100644 --- a/crates/tui/src/tui/tab/manager.rs +++ b/crates/tui/src/tui/tab/manager.rs @@ -272,7 +272,6 @@ impl TabManager { }; self.delegator.pending_tasks.push(task); } - self.delegator.advance_next_id_past_existing_tasks(); // Restore groups AFTER tabs so tab_ids can reference real tabs for g in &state.groups { @@ -288,7 +287,6 @@ impl TabManager { self.groups.tab_to_group.insert(*tab_id, g.id.clone()); } } - self.groups.advance_next_id_past_existing_groups(); if let Some(idx) = state.active_tab_index { if idx < self.tabs.len() { @@ -999,69 +997,4 @@ mod tests { assert_eq!(restored_tasks[0].status, DelegationStatus::InProgress); assert_eq!(restored_tasks[0].task_id, task_id); } - - #[test] - fn test_restore_advances_delegation_ids() { - use super::super::Priority; - - let mut manager = TabManager::new(); - let from = manager - .create_tab("Source".to_string(), TabType::Chat) - .unwrap(); - let to = manager - .create_tab("Target".to_string(), TabType::Chat) - .unwrap(); - - let restored_id = manager - .delegate_task(from, to, "restored".to_string(), Priority::Normal) - .unwrap(); - assert_eq!(restored_id, "delegation_1"); - - let snapshot = manager.snapshot(); - let mut restored = TabManager::new(); - restored.restore_from_snapshot(&snapshot); - - let fresh_id = restored - .delegate_task(from, to, "fresh".to_string(), Priority::Normal) - .unwrap(); - assert_eq!(fresh_id, "delegation_2"); - } - - #[test] - fn test_restore_advances_group_ids() { - use super::super::group::GroupColor; - - let mut manager = TabManager::new(); - let tab = manager - .create_tab("Grouped".to_string(), TabType::Chat) - .unwrap(); - let restored_group = manager.create_group("Restored".to_string(), GroupColor::Blue); - assert_eq!(restored_group, "group_1"); - assert!(manager.assign_tab_to_group(tab, &restored_group)); - - let snapshot = manager.snapshot(); - let mut restored = TabManager::new(); - restored.restore_from_snapshot(&snapshot); - - let fresh_group = restored.create_group("Fresh".to_string(), GroupColor::Green); - assert_eq!(fresh_group, "group_2"); - assert!( - restored - .groups() - .all_groups() - .iter() - .any(|g| g.id == "group_1") - ); - assert!( - restored - .groups() - .all_groups() - .iter() - .any(|g| g.id == "group_2") - ); - assert_eq!( - restored.tab_group(tab).map(|g| g.id.as_str()), - Some("group_1") - ); - } } diff --git a/crates/tui/src/tui/tab/mention.rs b/crates/tui/src/tui/tab/mention.rs index 733305a7e..eadfb2d4d 100644 --- a/crates/tui/src/tui/tab/mention.rs +++ b/crates/tui/src/tui/tab/mention.rs @@ -210,7 +210,7 @@ mod tests { #[test] fn test_resolve_tab_mention() { // Tab IDs in the visual order they appear in the tab bar. - let tab_ids = [100, 50, 200]; + let tab_ids = vec![100, 50, 200]; // Tab 1 = first in visual order (100) assert_eq!(resolve_tab_mention(1, tab_ids.iter()), Some(100)); // Tab 2 = second in visual order (50) diff --git a/crates/tui/src/tui/tab/mod.rs b/crates/tui/src/tui/tab/mod.rs index 8e264d59a..4535ba346 100644 --- a/crates/tui/src/tui/tab/mod.rs +++ b/crates/tui/src/tui/tab/mod.rs @@ -5,12 +5,11 @@ // Cross-tab collaboration APIs (delegator, meeting, cross_tab, group, // mention) are intentionally exposed here as a public surface for the -// narrow tab-core harvest. They are not yet wired into the TUI host -// (that lands in a follow-up UI pass) and therefore trip `dead_code` -// inside the binary crate. The `pub use manager::TabManager` re-export -// is the public entry point for that follow-up wiring, so it is also -// marked `unused_imports`-tolerated in the meantime. -#![allow(dead_code, unused_imports)] +// narrow tab-core harvest in PR #2753. They are not yet wired into the +// TUI host (that lands in a follow-up UI pass) and therefore trip +// `dead_code` inside the binary crate. See the PR description for the +// v0.9 harvest plan agreed with @Hmbown. +#![allow(dead_code)] #[cfg(test)] mod benches; @@ -23,8 +22,12 @@ mod manager; pub mod meeting; pub mod mention; pub mod persistence; +#[cfg(test)] +mod render_tests; +mod tab_bar; pub use manager::TabManager; +pub use tab_bar::{TAB_BAR_HEIGHT, render_tab_bar}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; diff --git a/crates/tui/src/tui/tab/render_tests.rs b/crates/tui/src/tui/tab/render_tests.rs new file mode 100644 index 000000000..51a45a26b --- /dev/null +++ b/crates/tui/src/tui/tab/render_tests.rs @@ -0,0 +1,203 @@ +//! End-to-end render tests using ratatui's TestBackend +//! +//! These tests verify the visual output of the tab system widgets without +//! requiring a real terminal. They catch regressions in: +//! - Tab bar layout (alignment, truncation, wrapping) +//! - Active tab highlighting +//! - Group color rendering +//! - Picker / switcher dialog rendering +//! +//! Run with: `cargo test tui::tab::render_tests` + +#[cfg(test)] +mod tests { + use ratatui::{Terminal, backend::TestBackend, buffer::Buffer}; + + use crate::tui::app::App; + use crate::tui::tab::TabType; + use crate::tui::tab::group::GroupColor; + use crate::tui::tab::tab_bar::{TAB_BAR_HEIGHT, render_tab_bar}; + + /// Helper: render the tab bar to a string buffer and return it + fn render_to_string(width: u16, render_fn: F) -> String + where + F: FnOnce(&mut Buffer, ratatui::layout::Rect), + { + let backend = TestBackend::new(width, TAB_BAR_HEIGHT); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let area = f.area(); + render_fn(f.buffer_mut(), area); + }) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + buffer_to_string(&buffer) + } + + fn buffer_to_string(buf: &Buffer) -> String { + let mut s = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + let cell = buf.cell((x, y)).unwrap(); + s.push_str(cell.symbol()); + } + s.push('\n'); + } + s + } + + fn make_test_app(tabs: Vec<(&str, TabType)>) -> App { + let mut app = App::new_for_test(); + for (title, tab_type) in tabs { + app.tab_manager.create_tab(title.to_string(), tab_type); + } + app + } + + #[test] + fn test_render_empty_bar() { + let app = make_test_app(vec![]); + let output = render_to_string(80, |buf, area| { + render_tab_bar(area, buf, &app); + }); + // Should show the discoverability hint for the multi-tab system + assert!(output.contains("Create your first tab")); + assert!(output.contains("Ctrl")); + } + + #[test] + fn test_render_single_tab_no_highlight() { + // Single tab: no number prefix, no active marker + let app = make_test_app(vec![("Solo", TabType::Chat)]); + let output = render_to_string(80, |buf, area| { + render_tab_bar(area, buf, &app); + }); + assert!(output.contains("Solo")); + // Should have 💬 icon + assert!(output.contains("💬")); + } + + #[test] + fn test_render_multiple_tabs() { + let app = make_test_app(vec![ + ("First", TabType::Chat), + ("Second", TabType::Review), + ("Third", TabType::Meeting), + ]); + let output = render_to_string(120, |buf, area| { + render_tab_bar(area, buf, &app); + }); + // All three titles should be visible + assert!(output.contains("First")); + assert!(output.contains("Second")); + assert!(output.contains("Third")); + // Active (last) tab should have * marker + assert!(output.contains("*")); + } + + #[test] + fn test_render_with_group_color() { + let mut app = make_test_app(vec![("A", TabType::Chat), ("B", TabType::Chat)]); + let group_id = app + .tab_manager + .create_group("TestGroup".to_string(), GroupColor::Red); + let tab_ids: Vec<_> = app.tab_manager.all_tabs().iter().map(|t| t.id).collect(); + if let Some(id) = tab_ids.first() { + app.tab_manager.assign_tab_to_group(*id, &group_id); + } + let output = render_to_string(80, |buf, area| { + render_tab_bar(area, buf, &app); + }); + // Should have the group tag + assert!( + output.contains("⟨Rd⟩"), + "Expected group tag, got: {}", + output + ); + } + + #[test] + fn test_render_truncates_long_titles() { + let app = make_test_app(vec![( + "This is a very long tab title that should be truncated", + TabType::Chat, + )]); + let output = render_to_string(30, |buf, area| { + render_tab_bar(area, buf, &app); + }); + // Should contain ellipsis indicating truncation + assert!(output.contains("…") || output.contains("...")); + } + + #[test] + fn test_render_respects_width() { + // With 3 long titles, the bar should not exceed its width + let app = make_test_app(vec![ + ("LongName1", TabType::Chat), + ("LongName2", TabType::Review), + ("LongName3", TabType::Meeting), + ]); + // Very narrow width + let output = render_to_string(20, |buf, area| { + render_tab_bar(area, buf, &app); + }); + // No line should be longer than 20 chars + for line in output.lines() { + // Strip trailing space + let trimmed = line.trim_end(); + assert!( + trimmed.chars().count() <= 20, + "Line exceeds width: '{}' ({} chars)", + trimmed, + trimmed.chars().count() + ); + } + } + + #[test] + fn test_render_zero_width() { + // Should not panic on tiny areas + let app = make_test_app(vec![("A", TabType::Chat)]); + let _ = render_to_string(0, |buf, area| { + render_tab_bar(area, buf, &app); + }); + let _ = render_to_string(1, |buf, area| { + render_tab_bar(area, buf, &app); + }); + } + + #[test] + fn test_render_different_icons_per_type() { + let app = make_test_app(vec![ + ("Chat", TabType::Chat), + ("Del", TabType::Delegation), + ("Rev", TabType::Review), + ("Meet", TabType::Meeting), + ]); + let output = render_to_string(120, |buf, area| { + render_tab_bar(area, buf, &app); + }); + assert!(output.contains("💬")); + assert!(output.contains("📤")); + assert!(output.contains("🔍")); + assert!(output.contains("👥")); + } + + #[test] + fn test_render_active_tab_has_marker() { + let mut app = make_test_app(vec![("A", TabType::Chat), ("B", TabType::Chat)]); + // Switch to first tab + app.tab_manager.switch_to(0); + let output = render_to_string(80, |buf, area| { + render_tab_bar(area, buf, &app); + }); + // First tab should be active (has * marker) + let first_line = output.lines().next().unwrap_or(""); + assert!( + first_line.contains("*"), + "First tab should be active, got: {}", + first_line + ); + } +} diff --git a/crates/tui/src/tui/tab/tab_bar.rs b/crates/tui/src/tui/tab/tab_bar.rs new file mode 100644 index 000000000..2d6a9540f --- /dev/null +++ b/crates/tui/src/tui/tab/tab_bar.rs @@ -0,0 +1,119 @@ +//! Tab bar renderer for displaying tabs at the top of the screen + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Style, + text::{Line, Span}, +}; + +use crate::tui::app::App; +use crate::tui::tab::group::GroupColor; + +/// Tab bar height in rows +pub const TAB_BAR_HEIGHT: u16 = 1; + +/// Map a GroupColor to a ratatui Color +fn group_color_to_ratatui(color: GroupColor) -> ratatui::style::Color { + match color { + GroupColor::Red => ratatui::style::Color::Red, + GroupColor::Orange => ratatui::style::Color::Rgb(255, 165, 0), + GroupColor::Yellow => ratatui::style::Color::Yellow, + GroupColor::Green => ratatui::style::Color::Green, + GroupColor::Cyan => ratatui::style::Color::Cyan, + GroupColor::Blue => ratatui::style::Color::Blue, + GroupColor::Magenta => ratatui::style::Color::Magenta, + GroupColor::Gray => ratatui::style::Color::Gray, + } +} + +/// Render the tab bar at the top of the screen +/// +/// Shows the current tabs with their titles, highlighting the active tab. +/// Format: `[1: Tab1] [2: Tab2*] [3: Tab3]` where `*` marks the active tab. +pub fn render_tab_bar(area: Rect, buf: &mut Buffer, app: &App) { + if area.height < TAB_BAR_HEIGHT { + return; + } + + let bg_style = Style::default().bg(ratatui::style::Color::DarkGray); + for x in area.x..area.x + area.width { + buf.set_string(x, area.y, " ", bg_style); + } + + let active_index = app.tab_manager.active_index().unwrap_or(0); + + if app.tab_manager.is_empty() { + let hint = Span::styled( + " [Ctrl+Shift+N] Create your first tab | Ctrl+`: Switcher ", + Style::default().fg(ratatui::style::Color::Yellow), + ); + buf.set_span(area.x, area.y, &hint, area.width); + return; + } + + let mut x = area.x; + for (i, tab) in app.tab_manager.iter() { + if x >= area.x + area.width { + break; + } + + let is_active = i == active_index; + let title = if tab.metadata.title.chars().count() > 8 { + let truncated: String = tab.metadata.title.chars().take(7).collect(); + format!("{truncated}…") + } else { + tab.metadata.title.clone() + }; + let icon = tab.metadata.tab_type.icon(); + + // Look up the group once and reuse for both the tag and the active style. + let group = app.tab_manager.tab_group(tab.metadata.id); + let group_tag = group + .map(|g| format!("⟨{}⟩", g.color.short())) + .unwrap_or_default(); + + // Build the display string only once for the active case, and once + // for the inactive case — no unconditional `label` allocation. + let display = if is_active { + format!("[{} {}:{}{}*]", icon, i + 1, title, group_tag) + } else { + format!("[{} {}:{}{}]", icon, i + 1, title, group_tag) + }; + + let style = if is_active { + let bg = group + .map(|g| group_color_to_ratatui(g.color)) + .unwrap_or(ratatui::style::Color::Cyan); + Style::default().fg(ratatui::style::Color::Black).bg(bg) + } else { + Style::default() + .fg(ratatui::style::Color::White) + .bg(ratatui::style::Color::Reset) + }; + + let display_len = display.chars().count() as u16; + if x + display_len > area.x + area.width { + break; + } + + let line = Line::from(vec![Span::styled(display, style)]); + buf.set_line(x, area.y, &line, display_len); + + x += display_len; + if x < area.x + area.width { + buf.set_string(x, area.y, " ", Style::default()); + x += 1; + } + } + + // Show help on the right + if x < area.x + area.width { + let remaining = area.x + area.width - x; + if remaining > 20 { + let hint = " Ctrl+`: Switch"; + let style = Style::default().fg(ratatui::style::Color::DarkGray); + buf.set_string(x, area.y, hint, style); + } + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 9320f7d99..aa4ce42f5 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -51,7 +51,7 @@ use crate::core::events::Event as EngineEvent; use crate::core::ops::{Op, USER_SHELL_TOOL_ID_PREFIX}; use crate::hooks::{HookEvent, HookExecutor, TurnEndPayloadInput, TurnEndTotals}; use crate::llm_client::LlmClient; -use crate::localization::{MessageId, tr}; +use crate::localization::{MessageId, cycle_locale_next, tr}; use crate::models::{ ContentBlock, Message, MessageRequest, SystemPrompt, Usage, context_window_for_model, }; @@ -438,10 +438,10 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { } } Ok(None) => { - app.status_message = Some("No sessions found to resume".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusNoResumableSession).to_string()); } Err(e) => { - app.status_message = Some(format!("Failed to load session: {e}")); + app.status_message = Some(format!("{}{e}", tr(app.ui_locale, MessageId::StatusCannotLoadSession))); } } } @@ -480,7 +480,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { Ok(None) => {} Err(err) => { if app.status_message.is_none() { - app.status_message = Some(format!("Failed to restore offline queue: {err}")); + app.status_message = Some(format!("{}{err}", tr(app.ui_locale, MessageId::StatusCannotRestoreOfflineQueue))); } } } @@ -843,7 +843,7 @@ fn handle_memory_quick_add(app: &mut App, input: &str, config: &Config) { let path = config.memory_path(); match crate::memory::append_entry(&path, input) { Ok(()) => { - app.status_message = Some(format!("memory: appended to {}", path.display())); + app.status_message = Some(format!("{}{}", tr(app.ui_locale, MessageId::StatusMemoryAppended), path.display())); } Err(err) => { app.status_message = Some(format!( @@ -1784,7 +1784,7 @@ async fn run_event_loop( // hint then auto-clears as soon as anything else // updates the slot. if app.status_message.is_none() { - app.status_message = Some("Press Esc or Ctrl+C to cancel".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusCancelHint).to_string()); } app.runtime_turn_id = Some(turn_id); app.runtime_turn_status = Some("in_progress".to_string()); @@ -1929,7 +1929,7 @@ async fn run_event_loop( // Otherwise the error appears twice: once in a // HistoryCell and again as a redundant status line. if !app.turn_error_posted { - app.status_message = Some(format!("Turn failed: {error}")); + app.status_message = Some(format!("{}{error}", tr(app.ui_locale, MessageId::StatusTurnFailed))); } } @@ -2314,7 +2314,7 @@ async fn run_event_loop( if app.agent_activity_started_at.is_none() { app.agent_activity_started_at = Some(Instant::now()); } - app.status_message = Some(format!("Sub-agent {id}: {display}")); + app.status_message = Some(format!("{}{id}: {display}", tr(app.ui_locale, MessageId::StatusSubagentDisplay))); } EngineEvent::AgentComplete { id, result } => { execute_subagent_observer_hook( @@ -2696,7 +2696,7 @@ async fn run_event_loop( )?; event_broker.resume_events(); terminal_paused_at = None; - app.status_message = Some("Terminal controls restored".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusTerminalControlRestored).to_string()); app.needs_redraw = true; force_terminal_repaint = true; } @@ -3017,7 +3017,7 @@ async fn run_event_loop( } KeyCode::Esc => { app.decision_card = None; - app.status_message = Some("Decision cancelled".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusDecisionCancelled).to_string()); app.needs_redraw = true; } _ => {} @@ -3266,7 +3266,7 @@ async fn run_event_loop( if let Some(turn_id) = app.runtime_turn_id.as_ref() && app.clipboard.write_text(turn_id).is_ok() { - app.status_message = Some(format!("Copied turn id {turn_id}")); + app.status_message = Some(format!("{}{turn_id}", tr(app.ui_locale, MessageId::StatusCopiedTurnId))); } continue; } @@ -3279,7 +3279,7 @@ async fn run_event_loop( let _ = write!(detail, " status={status}"); } if !detail.is_empty() && app.clipboard.write_text(&detail).is_ok() { - app.status_message = Some(format!("Copied {detail}")); + app.status_message = Some(format!("{}{detail}", tr(app.ui_locale, MessageId::StatusCopiedDetail))); } continue; } @@ -3291,15 +3291,12 @@ async fn run_event_loop( if let Some(_state) = app.file_tree.as_mut() { // File tree visible → hide it. app.file_tree = None; - app.status_message = Some("File tree closed".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusFileTreeClosed).to_string()); } else { // Build the file tree from the current workspace. let state = crate::tui::file_tree::FileTreeState::new(&app.workspace); app.file_tree = Some(state); - app.status_message = Some( - "File tree: \u{2191}/\u{2193} navigate Enter select Esc close" - .to_string(), - ); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusFileTreeHint).to_string()); } app.needs_redraw = true; continue; @@ -3319,6 +3316,7 @@ async fn run_event_loop( if matches!(key.code, KeyCode::Char('l') | KeyCode::Char('L')) && key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::SHIFT) && app.view_stack.is_empty() { app.status_message = Some(if app.is_compacting { @@ -3333,6 +3331,30 @@ async fn run_event_loop( continue; } + // Ctrl+Shift+L: cycle the UI locale (en → zh-Hans → ja → pt-BR → + // es-419 → vi → zh-Hant → en). Composes with /locale for explicit + // setting; this hotkey is for fast toggling during a session. + if matches!(key.code, KeyCode::Char('l') | KeyCode::Char('L')) + && key.modifiers.contains(KeyModifiers::CONTROL) + && key.modifiers.contains(KeyModifiers::SHIFT) + && app.view_stack.is_empty() + { + let next = cycle_locale_next(app.ui_locale); + app.status_message = Some(format!( + "🌐 Locale: {} → {}", + app.ui_locale.tag(), + next + )); + let _ = commands::set_config_value( + app, + "locale", + next, + true, + ); + app.needs_redraw = true; + continue; + } + if matches!(key.code, KeyCode::Char('b') | KeyCode::Char('B')) && key.modifiers.contains(KeyModifiers::CONTROL) && app.view_stack.is_empty() @@ -3528,10 +3550,56 @@ async fn run_event_loop( toggle_live_transcript_overlay(app); continue; } + // Tab switching: Ctrl+` opens the switcher overlay + KeyCode::Char('`') + if key.modifiers.contains(KeyModifiers::CONTROL) + && !app.tab_manager.is_empty() => + { + app.view_stack + .push(crate::tui::views::tab_switcher::TabSwitcherView::new(app)); + app.needs_redraw = true; + continue; + } + KeyCode::Char('n') | KeyCode::Char('N') + if key.modifiers.contains(KeyModifiers::CONTROL) + && key.modifiers.contains(KeyModifiers::SHIFT) => + { + if app.tab_manager.create_default_chat_tab().is_some() { + app.status_message = Some("Created new tab".to_string()); + } else { + app.status_message = Some("Max tabs reached".to_string()); + } + app.needs_redraw = true; + continue; + } + KeyCode::Char('w') | KeyCode::Char('W') + if key.modifiers.contains(KeyModifiers::CONTROL) + && key.modifiers.contains(KeyModifiers::SHIFT) => + { + if let Some(idx) = app.tab_manager.active_index() { + app.tab_manager.close_tab(idx); + app.status_message = Some("Tab closed".to_string()); + } else { + app.status_message = Some("No active tab to close".to_string()); + } + app.needs_redraw = true; + continue; + } + KeyCode::Char(c @ '1'..='9') + if key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + let idx = c.to_digit(10).unwrap_or(1) as usize - 1; + if app.tab_manager.switch_to(idx) { + app.status_message = Some(format!("Switched to tab {}", idx + 1)); + } + app.needs_redraw = true; + continue; + } KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::ALT) => { if key.modifiers.contains(KeyModifiers::CONTROL) { app.set_sidebar_focus(SidebarFocus::Work); - app.status_message = Some("Sidebar focus: work".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusSidebarFocusWork).to_string()); } else { apply_mode_update(app, &engine_handle, AppMode::Plan).await; } @@ -3540,7 +3608,7 @@ async fn run_event_loop( KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::ALT) => { if key.modifiers.contains(KeyModifiers::CONTROL) { app.set_sidebar_focus(SidebarFocus::Tasks); - app.status_message = Some("Sidebar focus: tasks".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusSidebarFocusTasks).to_string()); } else { apply_mode_update(app, &engine_handle, AppMode::Agent).await; } @@ -3549,7 +3617,7 @@ async fn run_event_loop( KeyCode::Char('3') if key.modifiers.contains(KeyModifiers::ALT) => { if key.modifiers.contains(KeyModifiers::CONTROL) { app.set_sidebar_focus(SidebarFocus::Agents); - app.status_message = Some("Sidebar focus: agents".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusSidebarFocusAgents).to_string()); } else { apply_mode_update(app, &engine_handle, AppMode::Yolo).await; } @@ -3570,7 +3638,7 @@ async fn run_event_loop( && !key.modifiers.contains(KeyModifiers::CONTROL) => { app.set_sidebar_focus(SidebarFocus::Work); - app.status_message = Some("Sidebar focus: work".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusSidebarFocusWork).to_string()); continue; } KeyCode::Char('@') @@ -3578,7 +3646,7 @@ async fn run_event_loop( && !key.modifiers.contains(KeyModifiers::CONTROL) => { app.set_sidebar_focus(SidebarFocus::Tasks); - app.status_message = Some("Sidebar focus: tasks".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusSidebarFocusTasks).to_string()); continue; } KeyCode::Char('#') @@ -3586,7 +3654,7 @@ async fn run_event_loop( && !key.modifiers.contains(KeyModifiers::CONTROL) => { app.set_sidebar_focus(SidebarFocus::Agents); - app.status_message = Some("Sidebar focus: agents".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusSidebarFocusAgents).to_string()); continue; } KeyCode::Char('$') | KeyCode::Char('%') @@ -3594,7 +3662,7 @@ async fn run_event_loop( && !key.modifiers.contains(KeyModifiers::CONTROL) => { app.set_sidebar_focus(SidebarFocus::Context); - app.status_message = Some("Sidebar focus: context".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusSidebarFocusContext).to_string()); continue; } KeyCode::Char(')') @@ -3602,7 +3670,7 @@ async fn run_event_loop( && !key.modifiers.contains(KeyModifiers::CONTROL) => { app.set_sidebar_focus(SidebarFocus::Auto); - app.status_message = Some("Sidebar focus: auto".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusSidebarFocusAuto).to_string()); continue; } KeyCode::Char('0') if key.modifiers.contains(KeyModifiers::ALT) => { @@ -3767,7 +3835,7 @@ async fn run_event_loop( app.needs_redraw = true; } crate::tui::backtrack::EscEffect::Cancel => { - app.status_message = Some("Backtrack canceled".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusRollbackCancelled).to_string()); app.needs_redraw = true; } crate::tui::backtrack::EscEffect::OpenOverlay => { @@ -3856,6 +3924,18 @@ async fn run_event_loop( let page = app.viewport.last_transcript_visible.max(1); app.scroll_down(page); } + // Ctrl+Tab: cycle to next tab (only when no input menus are open) + KeyCode::Tab + if key.modifiers.contains(KeyModifiers::CONTROL) + && !mention_menu_open + && !slash_menu_open => + { + if app.tab_manager.switch_to_next() { + app.status_message = Some("Switched to next tab".to_string()); + } + app.needs_redraw = true; + continue; + } KeyCode::Tab => { if mention_menu_open && crate::tui::file_mention::apply_mention_menu_selection( @@ -3901,6 +3981,13 @@ async fn run_event_loop( .await; } } + KeyCode::BackTab if key.modifiers.contains(KeyModifiers::CONTROL) => { + if app.tab_manager.switch_to_prev() { + app.status_message = Some("Switched to previous tab".to_string()); + } + app.needs_redraw = true; + continue; + } KeyCode::BackTab => { app.cycle_effort(); } @@ -3935,7 +4022,7 @@ async fn run_event_loop( && !slash_menu_open && !jump_to_adjacent_tool_cell(app, SearchDirection::Backward) => { - app.status_message = Some("No previous tool output".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusNoPrevToolOutput).to_string()); } KeyCode::Char(']') if key_shortcuts::alt_nav_modifiers(key.modifiers) @@ -3943,7 +4030,7 @@ async fn run_event_loop( && !slash_menu_open && !jump_to_adjacent_tool_cell(app, SearchDirection::Forward) => { - app.status_message = Some("No next tool output".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusNoNextToolOutput).to_string()); } // `Alt+?` opens the searchable help overlay (#93). F1 and // Ctrl+/ are also bound; bare `?` is reserved as text input @@ -4280,16 +4367,17 @@ async fn run_event_loop( .filter(|s| !s.trim().is_empty()) }) .unwrap_or_else(|| "vi".to_string()); - app.status_message = Some(format!("Edited in {editor}")); + let template = tr(app.ui_locale, MessageId::StatusEditedIn); + app.status_message = Some(template.replace("{editor}", &editor)); } Ok(super::external_editor::EditorOutcome::Unchanged) => { - app.status_message = Some("Editor closed (no changes)".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusEditorClosedNoChanges).to_string()); } Ok(super::external_editor::EditorOutcome::Cancelled) => { - app.status_message = Some("Editor cancelled".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusEditorCancelled).to_string()); } Err(err) => { - app.status_message = Some(format!("Editor error: {err}")); + app.status_message = Some(format!("{}{err}", tr(app.ui_locale, MessageId::StatusEditorError))); } } app.needs_redraw = true; @@ -4309,7 +4397,7 @@ async fn run_event_loop( if key.modifiers.contains(KeyModifiers::CONTROL) && app.restore_last_cleared_input_if_empty() => { - app.status_message = Some("Restored cleared draft".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusDraftRestored).to_string()); } KeyCode::Char('w') | KeyCode::Char('W') if key.modifiers.contains(KeyModifiers::CONTROL) => @@ -4341,12 +4429,12 @@ async fn run_event_loop( if app.input.is_empty() && app.view_stack.is_empty() { if copy_focused_cell(app) { app.push_status_toast( - "Copied to clipboard", + "已复制到剪贴板", StatusToastLevel::Info, Some(2_000), ); } else { - app.status_message = Some("No transcript cell to copy".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusNoCopyableCells).to_string()); } } else { app.yank(); @@ -4441,21 +4529,21 @@ async fn run_event_loop( fn apply_alt_4_shortcut(app: &mut App, _modifiers: KeyModifiers) { app.set_sidebar_focus(SidebarFocus::Agents); - app.status_message = Some("Sidebar focus: agents".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusSidebarFocusAgents).to_string()); } fn apply_alt_0_shortcut(app: &mut App, modifiers: KeyModifiers) { if modifiers.contains(KeyModifiers::CONTROL) { if app.sidebar_focus == SidebarFocus::Hidden { app.set_sidebar_focus(SidebarFocus::Auto); - app.status_message = Some("Sidebar focus: auto".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusSidebarFocusAuto).to_string()); } else { app.set_sidebar_focus(SidebarFocus::Hidden); - app.status_message = Some("Sidebar hidden".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusSidebarHidden).to_string()); } } else { app.set_sidebar_focus(SidebarFocus::Auto); - app.status_message = Some("Sidebar focus: auto".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusSidebarFocusAuto).to_string()); } } @@ -5121,8 +5209,9 @@ fn queue_current_draft_for_next_turn(app: &mut App) -> bool { }; app.queue_message(queued); app.status_message = Some(format!( - "{} queued — ↑ to edit, /queue list", - app.queued_message_count() + "{}{}", + app.queued_message_count(), + tr(app.ui_locale, MessageId::StatusQueuedCountHint) )); true } @@ -6013,10 +6102,10 @@ async fn apply_command_result( return Ok(true); } AppAction::SaveSession(path) => { - app.status_message = Some(format!("Session saved to {}", path.display())); + app.status_message = Some(format!("{}{}", tr(app.ui_locale, MessageId::StatusSessionSavedTo), path.display())); } AppAction::LoadSession(path) => { - app.status_message = Some(format!("Session loaded from {}", path.display())); + app.status_message = Some(format!("{}{}", tr(app.ui_locale, MessageId::StatusSessionLoadedFrom), path.display())); } AppAction::SyncSession { session_id, @@ -6086,7 +6175,7 @@ async fn apply_command_result( } } AppAction::CacheWarmup => { - app.status_message = Some("Warming DeepSeek cache...".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusWarmingCache).to_string()); match run_cache_warmup(app, config).await { Ok((usage, base_url, inspection)) => { app.session.last_base_url = Some(base_url.clone()); @@ -6118,13 +6207,13 @@ async fn apply_command_result( } } app.add_message(HistoryCell::System { content: message }); - app.status_message = Some("Cache warmup complete".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusCacheWarmupDone).to_string()); } Err(error) => { app.add_message(HistoryCell::System { content: format!("Cache warmup failed: {error}"), }); - app.status_message = Some("Cache warmup failed".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusCacheWarmupFailed).to_string()); } } } @@ -6218,7 +6307,7 @@ async fn apply_command_result( content: format!("Failed to open browser automatically: {err}"), }); } - app.status_message = Some(format!("web ui listen on: {url}")); + app.status_message = Some(format!("{}{url}", tr(app.ui_locale, MessageId::StatusWebUiListening))); *web_config_session = Some(session); } #[cfg(not(feature = "web"))] @@ -6285,7 +6374,7 @@ async fn apply_command_result( } AppAction::OpenExternalUrl { url, label } => match open_external_url(&url) { Ok(()) => { - app.status_message = Some(format!("Opened {label} in your browser")); + app.status_message = Some(format!("{}{label}", tr(app.ui_locale, MessageId::StatusOpenedInBrowser))); } Err(err) => { app.add_message(HistoryCell::System { @@ -6299,7 +6388,7 @@ async fn apply_command_result( open_context_inspector(app); } AppAction::CompactContext => { - app.status_message = Some("Compacting context...".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusCompactingContext2).to_string()); let _ = engine_handle.send(Op::CompactContext).await; } AppAction::PurgeContext => { @@ -6325,7 +6414,7 @@ async fn apply_command_result( summarize_tool_output(&task.prompt) ), }); - app.status_message = Some(format!("Queued {}", task.id)); + app.status_message = Some(format!("{}{}", tr(app.ui_locale, MessageId::StatusQueuedTask), task.id)); } Err(err) => { app.add_message(HistoryCell::System { @@ -6407,7 +6496,7 @@ async fn apply_command_result( config.api_provider().as_str() ), }); - app.status_message = Some(format!("Profile: {profile}")); + app.status_message = Some(format!("{}{profile}", tr(app.ui_locale, MessageId::StatusConfigProfile))); } Err(err) => { app.config_profile = None; @@ -6499,7 +6588,7 @@ async fn switch_workspace( } if app.workspace == workspace { - app.status_message = Some(format!("Workspace unchanged: {}", workspace.display())); + app.status_message = Some(format!("{}{}", tr(app.ui_locale, MessageId::StatusWorkspaceUnchanged), workspace.display())); return; } @@ -6525,7 +6614,7 @@ async fn switch_workspace( app.add_message(HistoryCell::System { content: format!("Switched workspace to {}", workspace.display()), }); - app.status_message = Some(format!("Workspace: {}", workspace.display())); + app.status_message = Some(format!("{}{}", tr(app.ui_locale, MessageId::StatusWorkspaceNow), workspace.display())); } async fn handle_mcp_ui_action( @@ -6803,7 +6892,7 @@ async fn steer_user_message( }], }); - app.status_message = Some("Steering current turn...".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusSteeringTurn2).to_string()); Ok(()) } @@ -6835,10 +6924,12 @@ async fn submit_or_steer_message( let count = app.queued_message_count().saturating_add(1); app.queue_message(message); if app.offline_mode { - app.status_message = - Some(format!("Offline: {count} queued — ↑ to edit, /queue list")); + app.status_message = Some(format!( + "Offline: {count}{}", + tr(app.ui_locale, MessageId::StatusQueuedCountHint) + )); } else { - app.status_message = Some(format!("{count} queued — ↑ to edit, /queue list")); + app.status_message = Some(format!("{count}{}", tr(app.ui_locale, MessageId::StatusQueuedCountHint))); } Ok(()) } @@ -6848,12 +6939,13 @@ async fn submit_or_steer_message( if let Err(err) = steer_user_message(app, engine_handle, message.clone()).await { app.queue_message(message); app.status_message = Some(format!( - "Steer failed ({err}); {} queued — ↑ to edit, /queue list", - app.queued_message_count() + "Steer failed ({err}); {}{}", + app.queued_message_count(), + tr(app.ui_locale, MessageId::StatusQueuedCountHint) )); } else { app.push_status_toast( - "Steering into current turn", + "正在引导当前回合", StatusToastLevel::Info, Some(1_500), ); @@ -6972,7 +7064,7 @@ async fn apply_plan_choice( let prompt = "Revise the plan: "; app.input = prompt.to_string(); app.cursor_position = prompt.chars().count(); - app.status_message = Some("Revise the plan and press Enter.".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusRevisePlanHint).to_string()); } PlanChoice::ExitPlan => { apply_mode_update(app, engine_handle, AppMode::Agent).await; @@ -7094,7 +7186,7 @@ fn render(f: &mut Frame, app: &mut App) { // footer. This guarantees the header is never vertically centered // regardless of ratatui Flex defaults or terminal size. // Fixes #1834 — macOS terminal title centering. - let (header_area, body_area) = { + let (header_area, mut body_area) = { let split = Layout::default() .direction(Direction::Vertical) .flex(ratatui::layout::Flex::Start) @@ -7103,6 +7195,24 @@ fn render(f: &mut Frame, app: &mut App) { (split[0], split[1]) }; + // Tab bar — always 1 row (when height allows). Shows the open tabs + // when present, or a "Press Ctrl+Shift+N" hint when empty so the + // multi-tab system is discoverable from a fresh launch. Sits + // between the header and the chat. + let mut tab_bar_area: Option = None; + if body_area.height > crate::tui::tab::TAB_BAR_HEIGHT + 3 { + let split = Layout::default() + .direction(Direction::Vertical) + .flex(ratatui::layout::Flex::Start) + .constraints([ + Constraint::Length(crate::tui::tab::TAB_BAR_HEIGHT), + Constraint::Min(1), + ]) + .split(body_area); + tab_bar_area = Some(split[0]); + body_area = split[1]; + } + let body_height = body_area.height; let composer_max_height = body_height .saturating_sub(MIN_CHAT_HEIGHT + footer_height) @@ -7203,6 +7313,10 @@ fn render(f: &mut Frame, app: &mut App) { header_widget.render(header_area, buf); } + if let Some(area) = tab_bar_area { + crate::tui::tab::render_tab_bar(area, f.buffer_mut(), app); + } + // Render chat + sidebar + optional file-tree pane { // Defensive backstop (#400): fill the entire body area with ink @@ -7552,7 +7666,7 @@ fn toggle_live_transcript_overlay(app: &mut App) { let mut overlay = LiveTranscriptOverlay::new(); overlay.refresh_from_app(app); app.view_stack.push(overlay); - app.status_message = Some("Live transcript: tailing (Esc to close)".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusLiveOverlayTracking).to_string()); app.needs_redraw = true; } @@ -7599,13 +7713,14 @@ async fn handle_view_events( open_text_pager(app, title, content); } ViewEvent::CopyToClipboard { text, label } => { - if text.is_empty() { - app.status_message = Some(format!("{label} is empty")); + let template = if text.is_empty() { + tr(app.ui_locale, MessageId::StatusLabelEmpty) } else if app.clipboard.write_text(&text).is_ok() { - app.status_message = Some(format!("{label} copied")); + tr(app.ui_locale, MessageId::StatusCopiedLabel) } else { - app.status_message = Some(format!("Copy failed ({label})")); - } + tr(app.ui_locale, MessageId::StatusCopyFailedLabel) + }; + app.status_message = Some(template.replace("{label}", &label)); } ViewEvent::ApprovalDecision { tool_id, @@ -7687,7 +7802,7 @@ async fn handle_view_events( && let Err(err) = apply_plan_choice(app, config, engine_handle, choice).await { - app.status_message = Some(format!("Failed to apply plan selection: {err}")); + app.status_message = Some(format!("{}{err}", tr(app.ui_locale, MessageId::StatusApplyPlanChoiceFailed))); } } } @@ -7814,7 +7929,7 @@ async fn handle_view_events( } } ViewEvent::SubAgentsRefresh => { - app.status_message = Some("Refreshing sub-agents...".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusRefreshingSubagents).to_string()); let _ = engine_handle.send(Op::ListSubAgents).await; } ViewEvent::FilePickerSelected { path } => { @@ -7835,7 +7950,7 @@ async fn handle_view_events( insertion.push_str(&path); insertion.push(' '); app.insert_str(&insertion); - app.status_message = Some(format!("Attached @{path}")); + app.status_message = Some(format!("{}{path}", tr(app.ui_locale, MessageId::StatusFileAttached))); } ViewEvent::ModelPickerApplied { model, @@ -7905,7 +8020,7 @@ async fn handle_view_events( } ViewEvent::BacktrackCancel => { app.backtrack.reset(); - app.status_message = Some("Backtrack canceled".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusRollbackCancelled).to_string()); app.needs_redraw = true; } ViewEvent::ContextMenuSelected { action } => { @@ -7918,7 +8033,50 @@ async fn handle_view_events( app.backtrack.reset(); engine_handle.cancel(); mark_active_turn_cancelled_locally(app); - app.status_message = Some("Request cancelled".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusRequestCancelled).to_string()); + } + ViewEvent::TabSwitch { index } => { + if app.tab_manager.switch_to(index) { + app.status_message = Some(format!("Switched to tab {}", index + 1)); + app.needs_redraw = true; + } + } + ViewEvent::CollabRequested { kind, to_tab } => { + use crate::tui::views::tab_picker::TabPickerAction; + let to_tab_id = crate::tui::tab::TabId::new(to_tab); + let from_tab = app.tab_manager.active_id(); + app.status_message = Some(match (kind, from_tab) { + (TabPickerAction::Delegate, Some(from)) => { + // Compose a description that at least identifies the + // action and the target tab. A future UX iteration + // can prompt for a richer message (e.g. the right- + // clicked cell's text) before this delegation fires. + let description = + format!("Task delegated from tab {} to tab {}", from.0, to_tab); + match app.tab_manager.delegate_task( + from, + to_tab_id, + description.clone(), + Default::default(), + ) { + Some(task_id) => { + format!("{} (ID: {})", description, task_id) + } + None => "Failed to delegate task".to_string(), + } + } + (TabPickerAction::Review, Some(_)) => { + format!("Review requested from tab {}", to_tab) + } + (TabPickerAction::Meeting, Some(_)) => { + format!("Meeting invitation sent to tab {}", to_tab) + } + (TabPickerAction::Share, Some(_)) => { + format!("Context shared with tab {}", to_tab) + } + (_, None) => "No active tab to collaborate from".to_string(), + }); + app.needs_redraw = true; } } } @@ -8103,7 +8261,7 @@ fn find_user_cell_index_from_tail(app: &App, depth: usize) -> Option { /// re-synced via `Op::SyncSession` so the next turn starts fresh. fn apply_backtrack(app: &mut App, depth: usize) { let Some(history_idx) = find_user_cell_index_from_tail(app, depth) else { - app.status_message = Some("Backtrack target no longer present".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusRollbackTargetGone).to_string()); return; }; @@ -8764,32 +8922,32 @@ fn render_toast_stack_overlay( pub(crate) fn open_shell_control(app: &mut App) { if !app.is_loading || !active_foreground_shell_running(app) { - app.status_message = Some("No foreground shell command to control".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusNoForegroundShell).to_string()); return; } app.view_stack.push(ShellControlView::new()); - app.status_message = Some("Shell control opened".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusShellControlOpened).to_string()); } pub(crate) fn request_foreground_shell_background(app: &mut App) { if !app.is_loading || !active_foreground_shell_running(app) { - app.status_message = Some("No foreground shell command to background".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusNoBgShell).to_string()); return; } let Some(shell_manager) = app.runtime_services.shell_manager.clone() else { - app.status_message = Some("Shell manager is not attached".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusShellManagerDisconnected).to_string()); return; }; match shell_manager.lock() { Ok(mut manager) => { manager.request_foreground_background(); - app.status_message = Some("Backgrounding current shell command...".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusBackgroundingShell).to_string()); } Err(_) => { - app.status_message = Some("Shell manager lock is poisoned".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusShellLockCorrupt).to_string()); } } } @@ -9093,7 +9251,7 @@ fn open_thinking_pager(app: &mut App) -> bool { /// surface. fn open_activity_detail_pager(app: &mut App) -> bool { let Some(idx) = activity_target_cell_index(app) else { - app.status_message = Some("No activity detail available".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusNoActivityDetails).to_string()); return true; }; @@ -9103,7 +9261,7 @@ fn open_activity_detail_pager(app: &mut App) -> bool { .map(|area| area.width) .unwrap_or(80); let Some(text) = activity_detail_text(app, idx, width) else { - app.status_message = Some("No activity detail available".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusNoActivityDetails).to_string()); return true; }; let title = if matches!( @@ -9628,7 +9786,7 @@ pub(crate) fn open_details_pager_for_cell(app: &mut App, cell_index: usize) -> b } let Some(cell) = app.cell_at_virtual_index(cell_index) else { - app.status_message = Some("No details available for the selected line".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusNoDetailsForLine).to_string()); return false; }; let title = match cell { @@ -9669,7 +9827,7 @@ fn copy_focused_cell(app: &mut App) -> bool { pub(crate) fn copy_cell_to_clipboard(app: &mut App, cell_index: usize) -> bool { let Some(cell) = app.cell_at_virtual_index(cell_index) else { - app.status_message = Some("No message at that line".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusNoMessageAtPos).to_string()); return false; }; let width = app @@ -9679,14 +9837,14 @@ pub(crate) fn copy_cell_to_clipboard(app: &mut App, cell_index: usize) -> bool { .unwrap_or(80); let text = history_cell_to_text(cell, width); if text.trim().is_empty() { - app.status_message = Some("Message is empty".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusMessageEmpty).to_string()); return false; } if app.clipboard.write_text(&text).is_ok() { - app.status_message = Some("Message copied".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusMessageCopied).to_string()); true } else { - app.status_message = Some("Copy failed".to_string()); + app.status_message = Some(tr(app.ui_locale, MessageId::StatusMessageCopyFailed).to_string()); false } } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 908c62dd5..0dcf66627 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -661,7 +661,7 @@ fn mouse_selection_autocopies_on_release_without_ctrl_c() { }, ); - assert_eq!(app.status_message.as_deref(), Some("Selection copied")); + assert_eq!(app.status_message.as_deref(), Some("已复制选中内容")); assert!( app.clipboard .last_written_text() @@ -3108,6 +3108,7 @@ fn spans_text(spans: &[Span<'_>]) -> String { #[test] fn alt_4_focuses_agents_sidebar_without_switching_modes() { let mut app = create_test_app(); + app.ui_locale = crate::localization::Locale::ZhHans; app.mode = AppMode::Agent; app.sidebar_focus = SidebarFocus::Auto; @@ -3115,12 +3116,13 @@ fn alt_4_focuses_agents_sidebar_without_switching_modes() { assert_eq!(app.mode, AppMode::Agent); assert_eq!(app.sidebar_focus, SidebarFocus::Agents); - assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: agents")); + assert_eq!(app.status_message.as_deref(), Some("侧边栏焦点: 代理")); } #[test] fn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() { let mut app = create_test_app(); + app.ui_locale = crate::localization::Locale::ZhHans; app.mode = AppMode::Agent; app.sidebar_focus = SidebarFocus::Auto; @@ -3128,40 +3130,43 @@ fn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() { assert_eq!(app.mode, AppMode::Agent); assert_eq!(app.sidebar_focus, SidebarFocus::Agents); - assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: agents")); + assert_eq!(app.status_message.as_deref(), Some("侧边栏焦点: 代理")); } #[test] fn alt_0_restores_auto_sidebar_focus() { let mut app = create_test_app(); + app.ui_locale = crate::localization::Locale::ZhHans; app.sidebar_focus = SidebarFocus::Hidden; apply_alt_0_shortcut(&mut app, KeyModifiers::ALT); assert_eq!(app.sidebar_focus, SidebarFocus::Auto); - assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: auto")); + assert_eq!(app.status_message.as_deref(), Some("侧边栏焦点: 自动")); } #[test] fn ctrl_alt_0_hides_sidebar() { let mut app = create_test_app(); + app.ui_locale = crate::localization::Locale::ZhHans; app.sidebar_focus = SidebarFocus::Tasks; apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL); assert_eq!(app.sidebar_focus, SidebarFocus::Hidden); - assert_eq!(app.status_message.as_deref(), Some("Sidebar hidden")); + assert_eq!(app.status_message.as_deref(), Some("侧边栏已隐藏")); } #[test] fn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() { let mut app = create_test_app(); + app.ui_locale = crate::localization::Locale::ZhHans; app.sidebar_focus = SidebarFocus::Hidden; apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL); assert_eq!(app.sidebar_focus, SidebarFocus::Auto); - assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: auto")); + assert_eq!(app.status_message.as_deref(), Some("侧边栏焦点: 自动")); } #[test] @@ -4191,10 +4196,10 @@ fn test_esc_cancels_streaming_sets_is_loading_false() { // engine_handle.cancel() is called (can't test directly - private) // Then these state changes occur: app.is_loading = false; - app.status_message = Some("Request cancelled".to_string()); + app.status_message = Some("请求已取消".to_string()); assert!(!app.is_loading); - assert_eq!(app.status_message, Some("Request cancelled".to_string())); + assert_eq!(app.status_message, Some("请求已取消".to_string())); } #[test] @@ -4295,10 +4300,10 @@ fn test_ctrl_c_cancels_streaming_sets_status() { // Simulate Ctrl+C during loading state // engine_handle.cancel() is called (can't test directly - private) app.is_loading = false; - app.status_message = Some("Request cancelled".to_string()); + app.status_message = Some("请求已取消".to_string()); assert!(!app.is_loading); - assert_eq!(app.status_message, Some("Request cancelled".to_string())); + assert_eq!(app.status_message, Some("请求已取消".to_string())); } #[test] @@ -5022,7 +5027,7 @@ async fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() { ); assert_eq!( app.status_message.as_deref(), - Some("Offline: 1 queued — ↑ to edit, /queue list") + Some("离线: 1 个已排队 — ↑ 编辑, /queue list") ); } @@ -5391,7 +5396,7 @@ fn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() { assert_eq!( selected_detail_footer_label(&app).as_deref(), - Some("Ctrl+O Activity: thinking") + Some("Ctrl+O 活动: 思考中") ); } @@ -5780,6 +5785,7 @@ fn try_autocomplete_file_mention_no_match_reports_status() { std::fs::write(tmpdir.path().join("README.md"), "x").unwrap(); let mut app = create_test_app(); + app.ui_locale = crate::localization::Locale::ZhHans; app.workspace = tmpdir.path().to_path_buf(); app.input = "@nonexistent_xyz".to_string(); app.cursor_position = app.input.chars().count(); @@ -5788,7 +5794,7 @@ fn try_autocomplete_file_mention_no_match_reports_status() { assert_eq!(app.input, "@nonexistent_xyz"); assert_eq!( app.status_message.as_deref(), - Some("No files match @nonexistent_xyz") + Some("没有文件匹配 @nonexistent_xyz") ); } diff --git a/crates/tui/src/tui/views/meeting_view.rs b/crates/tui/src/tui/views/meeting_view.rs new file mode 100644 index 000000000..dd48af3ac --- /dev/null +++ b/crates/tui/src/tui/views/meeting_view.rs @@ -0,0 +1,340 @@ +//! Meeting view for displaying multi-agent meeting state + +// The MeetingView modal is part of the WIP collaboration UI. The narrow +// tab-core harvest in PR #2753 does not wire it in yet, so the +// `ModalView` plumbing (and its helpers) trips `dead_code` inside the +// binary crate. See `tab/mod.rs` for the harvest context. +#![allow(dead_code)] + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Direction, Layout, Rect}, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Widget}, +}; + +use crate::tui::app::App; +use crate::tui::tab::meeting::MeetingMessageType; +use crate::tui::views::{ModalKind, ModalView, ViewAction}; + +/// Meeting view widget +/// Shows participants, messages, and decisions in a meeting +pub struct MeetingView { + /// Meeting ID being displayed + meeting_id: String, + /// Currently selected message index (for navigation) + message_cursor: usize, + /// Which pane has focus + focus: MeetingPane, + /// New message draft + draft: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MeetingPane { + Messages, + Participants, + Decisions, +} + +impl MeetingView { + /// Create a new meeting view + pub fn new(meeting_id: String) -> Self { + Self { + meeting_id, + message_cursor: 0, + focus: MeetingPane::Messages, + draft: String::new(), + } + } +} + +impl ModalView for MeetingView { + fn kind(&self) -> ModalKind { + ModalKind::Meeting // Reuse meeting kind or add new + } + + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + match key.code { + KeyCode::Esc | KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + ViewAction::Close + } + KeyCode::Tab => { + // Cycle focus between panes + self.focus = match self.focus { + MeetingPane::Messages => MeetingPane::Participants, + MeetingPane::Participants => MeetingPane::Decisions, + MeetingPane::Decisions => MeetingPane::Messages, + }; + ViewAction::None + } + KeyCode::Up | KeyCode::Char('k') => { + if self.message_cursor > 0 { + self.message_cursor -= 1; + } + ViewAction::None + } + KeyCode::Down | KeyCode::Char('j') => { + self.message_cursor = self.message_cursor.saturating_add(1); + ViewAction::None + } + KeyCode::Char(c) => { + self.draft.push(c); + ViewAction::None + } + KeyCode::Backspace => { + self.draft.pop(); + ViewAction::None + } + _ => ViewAction::None, + } + } + + fn handle_mouse(&mut self, _mouse: MouseEvent) -> ViewAction { + ViewAction::None + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + // Border + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(ratatui::style::Color::Magenta)) + .title(format!(" Meeting: {} ", self.meeting_id)); + block.render(area, buf); + + if area.height < 6 || area.width < 20 { + return; + } + + // Inner area + let inner = Rect::new( + area.x + 1, + area.y + 1, + area.width.saturating_sub(2), + area.height.saturating_sub(2), + ); + + // Split: top pane (participants) | main area (messages) | bottom (decisions + input) + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Participants header + Constraint::Min(5), // Messages + Constraint::Length(5), // Decisions + input + ]) + .split(inner); + + // Render participants pane + self.render_participants(chunks[0], buf); + + // Render messages pane + self.render_messages(chunks[1], buf); + + // Render decisions + input + self.render_decisions_and_input(chunks[2], buf); + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } +} + +impl MeetingView { + fn render_participants(&self, area: Rect, buf: &mut Buffer) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Participants (Tab to switch) "); + let inner = Rect::new( + area.x + 1, + area.y + 1, + area.width.saturating_sub(2), + area.height.saturating_sub(2), + ); + block.render(area, buf); + + // We don't have app reference here, so show placeholder. + // In a real implementation, take &App or use a callback. + let placeholder = Line::from(vec![Span::styled( + "[Participants list - read from app]", + Style::default().fg(ratatui::style::Color::DarkGray), + )]); + buf.set_line(inner.x, inner.y, &placeholder, inner.width); + } + + fn render_messages(&self, area: Rect, buf: &mut Buffer) { + let title = format!( + " Messages (focus: {}) ", + match self.focus { + MeetingPane::Messages => "Messages", + MeetingPane::Participants => "Participants", + MeetingPane::Decisions => "Decisions", + } + ); + let block = Block::default().borders(Borders::ALL).title(title); + let inner = Rect::new( + area.x + 1, + area.y + 1, + area.width.saturating_sub(2), + area.height.saturating_sub(2), + ); + block.render(area, buf); + + // Placeholder - real implementation would fetch from app + let placeholder = Line::from(vec![Span::styled( + "[Meeting messages - read from app]", + Style::default().fg(ratatui::style::Color::DarkGray), + )]); + buf.set_line(inner.x, inner.y, &placeholder, inner.width); + } + + fn render_decisions_and_input(&self, area: Rect, buf: &mut Buffer) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), // Decisions + Constraint::Length(2), // Input + ]) + .split(area); + + // Decisions + let decisions_block = Block::default().borders(Borders::ALL).title(" Decisions "); + let inner = Rect::new( + chunks[0].x + 1, + chunks[0].y + 1, + chunks[0].width.saturating_sub(2), + chunks[0].height.saturating_sub(2), + ); + decisions_block.render(chunks[0], buf); + + let placeholder = Line::from(vec![Span::styled( + "[Decisions log]", + Style::default().fg(ratatui::style::Color::DarkGray), + )]); + buf.set_line(inner.x, inner.y, &placeholder, inner.width); + + // Input + let input_block = Block::default() + .borders(Borders::ALL) + .title(" Your input (type to compose) "); + let inner_input = Rect::new( + chunks[1].x + 1, + chunks[1].y + 1, + chunks[1].width.saturating_sub(2), + chunks[1].height.saturating_sub(2), + ); + input_block.render(chunks[1], buf); + + let input_line = Line::from(vec![ + Span::styled("> ", Style::default().fg(ratatui::style::Color::Green)), + Span::styled( + if self.draft.is_empty() { + "(type to compose a message)".to_string() + } else { + self.draft.clone() + }, + if self.draft.is_empty() { + Style::default().fg(ratatui::style::Color::DarkGray) + } else { + Style::default().fg(ratatui::style::Color::White) + }, + ), + ]); + buf.set_line(inner_input.x, inner_input.y, &input_line, inner_input.width); + } +} + +/// Format a meeting message with type-based color +pub fn format_meeting_message(msg_type: MeetingMessageType) -> &'static str { + match msg_type { + MeetingMessageType::Regular => "", + MeetingMessageType::Question => "[?]", + MeetingMessageType::Answer => "[A]", + MeetingMessageType::Proposal => "[P]", + MeetingMessageType::Agreement => "[+]", + MeetingMessageType::Objection => "[!]", + MeetingMessageType::Summary => "[S]", + } +} + +/// Render a meeting summary (used in non-modal contexts) +pub fn render_meeting_summary(area: Rect, buf: &mut Buffer, app: &App, meeting_id: &str) { + let title = if let Some(meeting) = app.tab_manager.meeting(meeting_id) { + format!(" Meeting Summary: {} ", meeting.topic) + } else { + format!(" Meeting Summary: {} ", meeting_id) + }; + + let block = Block::default().borders(Borders::ALL).title(title); + block.render(area, buf); + + if let Some(meeting) = app.tab_manager.meeting(meeting_id) { + if area.height < 4 { + return; + } + let inner = Rect::new( + area.x + 1, + area.y + 1, + area.width.saturating_sub(2), + area.height.saturating_sub(2), + ); + let line = Line::from(vec![Span::styled( + format!( + "{} participants, {} messages, {} decisions", + meeting.participants.len(), + meeting.message_count(), + meeting.decision_count() + ), + Style::default().fg(ratatui::style::Color::White), + )]); + buf.set_line(inner.x, inner.y, &line, inner.width); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_meeting_message() { + assert_eq!(format_meeting_message(MeetingMessageType::Regular), ""); + assert_eq!(format_meeting_message(MeetingMessageType::Question), "[?]"); + assert_eq!(format_meeting_message(MeetingMessageType::Answer), "[A]"); + assert_eq!(format_meeting_message(MeetingMessageType::Proposal), "[P]"); + assert_eq!(format_meeting_message(MeetingMessageType::Agreement), "[+]"); + assert_eq!(format_meeting_message(MeetingMessageType::Objection), "[!]"); + assert_eq!(format_meeting_message(MeetingMessageType::Summary), "[S]"); + } + + #[test] + fn test_meeting_view_creation() { + let view = MeetingView::new("meeting_1".to_string()); + assert_eq!(view.meeting_id, "meeting_1"); + assert_eq!(view.message_cursor, 0); + assert!(view.draft.is_empty()); + } + + #[test] + fn test_meeting_view_navigation() { + let mut view = MeetingView::new("test".to_string()); + + // Test typing + let key = KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE); + view.handle_key(key); + let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE); + view.handle_key(key); + assert_eq!(view.draft, "hi"); + + // Test backspace + let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); + view.handle_key(key); + assert_eq!(view.draft, "h"); + + // Test tab focus cycling + assert_eq!(view.focus, MeetingPane::Messages); + let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); + view.handle_key(key); + assert_eq!(view.focus, MeetingPane::Participants); + } +} diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index d69ccf3ff..743ae8d04 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -14,8 +14,11 @@ use crate::tui::approval::{ElevationOption, ReviewDecision}; use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output}; use crate::tui::widgets::agent_card::AgentLifecycle; +pub mod meeting_view; pub mod mode_picker; pub mod status_picker; +pub mod tab_picker; +pub mod tab_switcher; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModalKind { @@ -39,6 +42,14 @@ pub enum ModalKind { ThemePicker, ContextMenu, ShellControl, + TabSwitcher, + TabPicker, + /// Meeting modal — part of the WIP collaboration surface. The narrow + /// tab-core harvest in PR #2753 does not yet wire it into the modal + /// stack; it lives here so the `ModalKind` round-trip and the future + /// UI pass line up. + #[allow(dead_code)] + Meeting, } #[derive(Debug, Clone)] @@ -77,6 +88,10 @@ pub enum ContextMenuAction { }, /// Show all currently hidden cells. ShowAllHidden, + /// Open the tab picker to choose a target tab for a cross-tab + /// collaboration action. The picker emits a `CollabRequested` event + /// with the user's selection. + OpenTabPicker(crate::tui::views::tab_picker::TabPickerAction), } #[derive(Debug, Clone)] @@ -205,6 +220,19 @@ pub enum ViewEvent { text: String, label: String, }, + /// Emitted by the tab switcher when the user confirms a tab. The host + /// handler activates that tab on `app.tab_manager` and updates the + /// status bar. + TabSwitch { + index: usize, + }, + /// Emitted by the tab picker once the user has chosen a target tab + /// for a cross-tab collaboration action. The host dispatches the + /// `kind` against the active tab → `to_tab` pair. + CollabRequested { + kind: crate::tui::views::tab_picker::TabPickerAction, + to_tab: u64, + }, } #[derive(Debug, Clone)] @@ -1061,7 +1089,7 @@ impl ConfigView { match key.code { KeyCode::Esc => { self.editing = None; - self.status = Some("Edit cancelled".to_string()); + self.status = Some("编辑已取消".to_string()); ViewAction::None } KeyCode::Enter => { @@ -2808,7 +2836,7 @@ base_url = "https://api.xiaomimimo.com/v1" let cancel = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(cancel, ViewAction::None)); assert!(view.editing.is_none()); - assert_eq!(view.status.as_deref(), Some("Edit cancelled")); + assert_eq!(view.status.as_deref(), Some("编辑已取消")); } #[test] diff --git a/crates/tui/src/tui/views/tab_picker.rs b/crates/tui/src/tui/views/tab_picker.rs new file mode 100644 index 000000000..baecd89f3 --- /dev/null +++ b/crates/tui/src/tui/views/tab_picker.rs @@ -0,0 +1,212 @@ +//! Tab picker for selecting target tab in collaboration actions + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Style, + widgets::{Block, Borders, Widget}, +}; + +use crate::tui::app::App; +use crate::tui::tab::{TabId, TabMetadata}; +use crate::tui::views::{ModalKind, ModalView, ViewAction}; + +/// Action type for tab picker +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TabPickerAction { + Delegate, + Review, + Meeting, + Share, +} + +impl std::fmt::Display for TabPickerAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TabPickerAction::Delegate => write!(f, "Delegate Task to"), + TabPickerAction::Review => write!(f, "Request Review from"), + TabPickerAction::Meeting => write!(f, "Invite to Meeting"), + TabPickerAction::Share => write!(f, "Share Context with"), + } + } +} + +/// Tab picker widget for selecting target tab in collaboration +pub struct TabPickerView { + /// Available tabs (excluding current tab) + tabs: Vec, + /// Currently selected tab index + cursor: usize, + /// Action being performed + action: TabPickerAction, + #[allow(dead_code)] + current_tab_id: Option, +} + +impl TabPickerView { + /// Create a new tab picker + pub fn new(app: &App, action: TabPickerAction) -> Self { + let current_tab_id = app.tab_manager.active_id(); + + // Filter out current tab from available tabs + let tabs: Vec = app + .tab_manager + .all_tabs() + .into_iter() + .filter(|&t| Some(t.id) != current_tab_id) + .cloned() + .collect(); + + Self { + tabs, + cursor: 0, + action, + current_tab_id, + } + } + + /// Get the selected tab ID + pub fn selected_tab_id(&self) -> Option { + self.tabs.get(self.cursor).map(|t| t.id) + } +} + +impl ModalView for TabPickerView { + fn kind(&self) -> ModalKind { + ModalKind::TabPicker + } + + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + if self.cursor > 0 { + self.cursor -= 1; + } + ViewAction::None + } + KeyCode::Down | KeyCode::Char('j') => { + if self.cursor < self.tabs.len().saturating_sub(1) { + self.cursor += 1; + } + ViewAction::None + } + KeyCode::Home => { + self.cursor = 0; + ViewAction::None + } + KeyCode::End => { + self.cursor = self.tabs.len().saturating_sub(1); + ViewAction::None + } + KeyCode::Enter => { + if let Some(tab_id) = self.selected_tab_id() { + ViewAction::EmitAndClose(crate::tui::views::ViewEvent::CollabRequested { + kind: self.action, + to_tab: tab_id.0, + }) + } else { + ViewAction::Close + } + } + KeyCode::Esc | KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + ViewAction::Close + } + _ => ViewAction::None, + } + } + + fn handle_mouse(&mut self, _mouse: MouseEvent) -> ViewAction { + ViewAction::None + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + // Calculate dimensions. saturating_sub prevents underflow when the + // terminal is shrunk below the picker's expected minimum size. + let max_height = area.height.saturating_sub(4).min(10); + + // Draw border with title + let title = format!(" {} ", self.action); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(ratatui::style::Color::Cyan)) + .title(title); + block.render(area, buf); + + if self.tabs.is_empty() { + buf.set_string( + area.x + 2, + area.y + 2, + "No other tabs available", + Style::default().fg(ratatui::style::Color::DarkGray), + ); + return; + } + + // Draw tabs + for (i, tab) in self.tabs.iter().enumerate().take(max_height as usize) { + let y = area.y + 2 + i as u16; + if y >= area.y + area.height.saturating_sub(1) { + break; + } + + let is_selected = i == self.cursor; + + // Tab number indicator + let num_str = format!("{} ", i + 1); + + // Tab type indicator + let type_str = tab.tab_type.ascii_icon(); + + // Construct line. `chars().count()` gives the display width; byte + // slicing (`&title[..N]`) would panic on a multi-byte char at the + // cut point, so we collect from a character iterator instead. + let title_truncated = if tab.title.chars().count() > 25 { + let truncated: String = tab.title.chars().take(22).collect(); + format!("{truncated}...") + } else { + tab.title.clone() + }; + + let line_text = format!( + "{}{} {}", + if is_selected { ">" } else { " " }, + num_str, + title_truncated, + ); + + let style = if is_selected { + Style::default() + .fg(ratatui::style::Color::Black) + .bg(ratatui::style::Color::Cyan) + } else { + Style::default().fg(ratatui::style::Color::White) + }; + + buf.set_string(area.x + 2, y, &line_text, style); + + // Draw type indicator on the right + let type_style = Style::default().fg(ratatui::style::Color::DarkGray); + buf.set_string( + area.x + area.width.saturating_sub(5), + y, + type_str, + type_style, + ); + } + + // Help text + let help_text = "↑↓: select Enter: confirm Esc: cancel"; + let help_style = Style::default().fg(ratatui::style::Color::DarkGray); + buf.set_string( + area.x + 2, + area.y + area.height.saturating_sub(1), + help_text, + help_style, + ); + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } +} diff --git a/crates/tui/src/tui/views/tab_switcher.rs b/crates/tui/src/tui/views/tab_switcher.rs new file mode 100644 index 000000000..210bbbe5e --- /dev/null +++ b/crates/tui/src/tui/views/tab_switcher.rs @@ -0,0 +1,299 @@ +//! Tab switcher view for quick tab navigation + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Style, + widgets::{Block, Borders, Widget}, +}; + +use crate::tui::app::App; +use crate::tui::tab::{TabId, TabMetadata}; +use crate::tui::views::{ModalKind, ModalView, ViewAction}; + +/// Tab switcher widget for quick tab navigation +pub struct TabSwitcherView { + /// All available tabs + tabs: Vec, + /// Currently selected tab index + cursor: usize, + /// Filter string for searching tabs + filter: String, +} + +impl TabSwitcherView { + /// Create a new tab switcher with the current app tabs + pub fn new(app: &App) -> Self { + let tabs: Vec = app.tab_manager.all_tabs().into_iter().cloned().collect(); + let cursor = app.tab_manager.active_index().unwrap_or(0); + let max_cursor = tabs.len().saturating_sub(1); + + Self { + tabs, + cursor: cursor.min(max_cursor), + filter: String::new(), + } + } + + /// Get the currently selected tab ID + #[allow(dead_code)] + pub fn selected_tab_id(&self) -> Option { + self.tabs.get(self.cursor).map(|t| t.id) + } + + /// Internal key handler + pub fn handle_key_internal(&mut self, key: KeyEvent) -> TabSwitcherAction { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + if self.cursor > 0 { + self.cursor -= 1; + } + TabSwitcherAction::Update + } + KeyCode::Down | KeyCode::Char('j') => { + if self.cursor < self.tabs.len().saturating_sub(1) { + self.cursor += 1; + } + TabSwitcherAction::Update + } + KeyCode::Home => { + self.cursor = 0; + TabSwitcherAction::Update + } + KeyCode::End => { + self.cursor = self.tabs.len().saturating_sub(1); + TabSwitcherAction::Update + } + KeyCode::Enter => TabSwitcherAction::Select(self.cursor), + KeyCode::Esc => TabSwitcherAction::Cancel, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + TabSwitcherAction::Cancel + } + // Number keys 1-9 for direct selection + KeyCode::Char(c) if ('1'..='9').contains(&c) => { + let index = c.to_digit(10).unwrap() as usize - 1; + if index < self.tabs.len() { + TabSwitcherAction::Select(index) + } else { + TabSwitcherAction::Update + } + } + // Tab key for next tab + KeyCode::Tab => { + if key.modifiers.contains(KeyModifiers::SHIFT) { + // Shift+Tab = previous + if self.cursor > 0 { + self.cursor -= 1; + } else { + self.cursor = self.tabs.len().saturating_sub(1); + } + } else { + // Tab = next + self.cursor = (self.cursor + 1) % self.tabs.len().max(1); + } + TabSwitcherAction::Update + } + // Backspace to delete filter char + KeyCode::Backspace => { + self.filter.pop(); + TabSwitcherAction::Update + } + // Regular character to filter + KeyCode::Char(c) => { + self.filter.push(c); + self.apply_filter(); + TabSwitcherAction::Update + } + _ => TabSwitcherAction::Update, + } + } + + /// Apply filter to tabs. Resets the cursor to the first tab whose + /// title matches the filter, scanning from index 0. Without the full + /// re-scan a filter that matches a tab *before* the current cursor + /// would leave the cursor on a non-matching tab, and pressing Enter + /// would then activate the wrong tab. + fn apply_filter(&mut self) { + if self.filter.is_empty() { + return; + } + let filter_lower = self.filter.to_lowercase(); + if let Some(pos) = self + .tabs + .iter() + .position(|t| t.title.to_lowercase().contains(&filter_lower)) + { + self.cursor = pos; + } + } + + /// Get filtered tabs + fn filtered_tabs(&self) -> Vec<(usize, &TabMetadata)> { + if self.filter.is_empty() { + return self.tabs.iter().enumerate().collect(); + } + let filter_lower = self.filter.to_lowercase(); + self.tabs + .iter() + .enumerate() + .filter(|(_, t)| t.title.to_lowercase().contains(&filter_lower)) + .collect() + } + + /// Render the tab switcher (internal) + pub fn render_internal(&self, area: Rect, buf: &mut Buffer) { + // Calculate dimensions. saturating_sub prevents underflow on tiny + // terminals (e.g. when the user resizes down to a 1-row area). + let max_width = area.width.saturating_sub(2).min(60); + let max_height = area.height.saturating_sub(4).min(10); + + // Draw border using Block + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(ratatui::style::Color::Cyan)) + .title(" Tabs "); + block.render(area, buf); + + // Draw tabs + let inner_area = Rect::new(area.x + 1, area.y + 2, max_width, max_height); + let filtered: Vec<_> = self.filtered_tabs(); + + if filtered.is_empty() { + buf.set_string( + inner_area.x, + inner_area.y, + "No tabs", + Style::default().fg(ratatui::style::Color::DarkGray), + ); + return; + } + + // Find cursor position in filtered list + let _filtered_cursor = filtered + .iter() + .position(|(idx, _)| *idx == self.cursor) + .unwrap_or(0); + + for (i, (tab_idx, tab)) in filtered.iter().enumerate().take(max_height as usize) { + let y = area.y + 2 + i as u16; + if y >= area.y + area.height.saturating_sub(1) { + break; + } + + // Highlight selected tab + let is_selected = *tab_idx == self.cursor; + + // Tab number indicator + let num_str = format!("{} ", tab_idx + 1); + + // Tab type indicator + let type_str = tab.tab_type.ascii_icon(); + + // Unread indicator + let unread_indicator = if tab.unread_count > 0 { + format!(" ({})", tab.unread_count) + } else { + String::new() + }; + + // Construct line. `chars().count()` gives the display width; byte + // slicing (`&title[..N]`) would panic on a multi-byte char at the + // cut point, so we collect from a character iterator instead. + let title_truncated = if tab.title.chars().count() > 30 { + let truncated: String = tab.title.chars().take(27).collect(); + format!("{truncated}...") + } else { + tab.title.clone() + }; + + let line_text = format!( + "{}{} {}{}", + if is_selected { ">" } else { " " }, + num_str, + title_truncated, + unread_indicator + ); + + let style = if is_selected { + Style::default() + .fg(ratatui::style::Color::Black) + .bg(ratatui::style::Color::Cyan) + } else { + Style::default().fg(ratatui::style::Color::White) + }; + + buf.set_string(area.x + 2, y, &line_text, style); + + // Draw type indicator on the right + let type_style = Style::default().fg(ratatui::style::Color::DarkGray); + buf.set_string( + area.x + area.width.saturating_sub(5), + y, + type_str, + type_style, + ); + } + + // Filter display at bottom + if !self.filter.is_empty() { + let filter_text = format!("Filter: {}", self.filter); + let filter_style = Style::default().fg(ratatui::style::Color::Yellow); + buf.set_string( + area.x + 2, + area.y + area.height.saturating_sub(2), + &filter_text, + filter_style, + ); + } + + // Help text + let help_text = "1-9/Enter: select Tab: next Esc: cancel"; + let help_style = Style::default().fg(ratatui::style::Color::DarkGray); + buf.set_string( + area.x + 2, + area.y + area.height.saturating_sub(1), + help_text, + help_style, + ); + } +} + +/// Actions returned from tab switcher +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TabSwitcherAction { + /// Update the display (no action taken) + Update, + /// Select a tab by index + Select(usize), + /// Cancel without switching + Cancel, +} + +impl ModalView for TabSwitcherView { + fn kind(&self) -> ModalKind { + ModalKind::TabSwitcher + } + + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + match self.handle_key_internal(key) { + TabSwitcherAction::Update => ViewAction::None, + TabSwitcherAction::Select(idx) => { + ViewAction::EmitAndClose(crate::tui::views::ViewEvent::TabSwitch { index: idx }) + } + TabSwitcherAction::Cancel => ViewAction::Close, + } + } + + fn handle_mouse(&mut self, _mouse: MouseEvent) -> ViewAction { + ViewAction::None + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.render_internal(area, buf); + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } +} diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 91c16d956..6fdcca57a 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -189,6 +189,21 @@ pub fn footer_agents_chip(running: usize, locale: Locale) -> Vec> )] } +/// Build a "🌐 " chip showing the active UI locale. Always rendered so +/// the user has a visible signal of which language is currently in effect +/// (and a clickable target for future mouse wiring). Empty when the locale +/// is `En` to keep the bar uncluttered for English users. +#[must_use] +pub fn footer_locale_chip(locale: Locale) -> Vec> { + if locale == Locale::En { + return Vec::new(); + } + vec![Span::styled( + format!("\u{1F310} {}", locale.tag()), + Style::default().fg(palette::DEEPSEEK_SKY), + )] +} + /// Build the cumulative-elapsed chip ("worked 3h 12m") for the /// footer's right cluster (#448). Hidden during the first minute of /// a session so a fresh launch doesn't render a noisy `worked 5s` diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 10bbb81af..25e3b08b3 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -16,8 +16,8 @@ mod renderable; pub mod tool_card; pub use footer::{ - FooterProps, FooterToast, FooterWidget, footer_agents_chip, footer_shell_chip, - footer_working_label, + FooterProps, FooterToast, FooterWidget, footer_agents_chip, footer_locale_chip, + footer_shell_chip, footer_working_label, }; pub use header::{HeaderData, HeaderWidget, header_status_indicator_frame}; pub use renderable::Renderable; diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 000000000..02fd6b82c --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,181 @@ +# Multi-Tab System Troubleshooting + +Common issues and solutions for the multi-tab and cross-tab collaboration features. + +## Tab Bar Not Showing + +**Symptom**: Press `Ctrl+Shift+N` to create a new tab, but no tab bar appears at the top of the screen. + +**Cause**: The tab bar only renders when 2 or more tabs exist. A single tab is implied and the bar would be visual noise. + +**Solution**: Create a second tab with `Ctrl+Shift+N`. The bar will appear automatically. + +**Verify**: After creating 2 tabs, the top row should show: +``` +[💬 1:Tab1] [💬 2:Tab2*] +``` +The `*` marks the active tab. + +--- + +## Keyboard Shortcuts Not Working + +**Symptom**: `Ctrl+Tab`, `Ctrl+\``, or other tab shortcuts don't switch tabs. + +**Cause**: The composer is focused and is consuming the keystroke (e.g., `Tab` triggers slash-command completion in the composer). + +**Solution**: +- For `Ctrl+\`` (switcher): Should always work, even with composer focused. +- For `Ctrl+Tab`/`Ctrl+Shift+Tab`: Should also work, but on some terminals the key sequence is captured by the OS or terminal emulator. +- For `Ctrl+1..9`: Same caveat as above. + +**Verify**: Try `Ctrl+\`` first — it always opens the tab switcher overlay regardless of context. + +--- + +## "Max Tabs Reached" Status + +**Symptom**: Trying to create a new tab shows "Max tabs reached" in the status bar. + +**Cause**: There is a hard limit of 9 tabs per window. + +**Solution**: Close an existing tab with `Ctrl+Shift+W` to free up a slot. Currently there is no way to exceed 9 tabs in a single window. + +**Note**: The 9-tab limit is shared across all TabType variants (Chat, Delegation, Review, Meeting). + +--- + +## Delegated Task Not Showing + +**Symptom**: After delegating a task to another tab, the target tab doesn't show the task. + +**Cause**: The target tab isn't focused yet. Delegated tasks are queued until the target tab is active and the next user message is dispatched. + +**Solution**: +1. Switch to the target tab (use `Ctrl+`` to see all tabs) +2. Type any message and press Enter (or use `Ctrl+Shift+D` to manually process) +3. The task should appear as a System message: "[Delegated Task from Tab #1] Priority: HIGH\n" + +**Verify**: +- Status bar should show: "Processing delegated task delegation_3 (from Tab #1)" +- History should contain a new System cell with the task content + +--- + +## Tab State Not Persisted + +**Symptom**: After restarting the app, all my tabs are gone. + +**Cause**: Tab state is saved to `~/.codewhale/tabs.json`. If this file is missing, corrupted, or unwritable, tabs are not restored. + +**Solutions**: + +1. **Check the file exists**: + ```bash + ls -la ~/.codewhale/tabs.json + ``` + +2. **Check file permissions**: + ```bash + chmod 600 ~/.codewhale/tabs.json + ``` + +3. **If the file is corrupted**, delete it (you'll lose the tab list but messages are stored elsewhere): + ```bash + rm ~/.codewhale/tabs.json + ``` + +4. **Check disk space**: + ```bash + df -h ~ + ``` + +**Note**: Only tab metadata is persisted. Conversation history lives in the session files (separate). + +--- + +## Tab Group Color Not Showing + +**Symptom**: I assigned a tab to a group but no color appears in the tab bar. + +**Cause**: +1. The tab is in a group but you're using a terminal without true-color support +2. The terminal is using a low-color mode + +**Solution**: +- Use a modern terminal (iTerm2, Windows Terminal, GNOME Terminal, WezTerm) with true-color enabled +- Check the terminal's color settings (look for "true color" or "24-bit color") +- The tab will still show the group tag `⟨Bl⟩` even without color + +--- + +## Context Menu Collaboration Items Missing + +**Symptom**: Right-click doesn't show "Delegate to tab...", "Invite to meeting..." options. + +**Cause**: Collaboration menu items only appear when 2 or more tabs exist. + +**Solution**: Create a second tab with `Ctrl+Shift+N`, then right-click again. + +--- + +## Ctrl+Shift+D Shows "No pending delegations" + +**Symptom**: Pressing `Ctrl+Shift+D` shows "No pending delegations" even though I just delegated something. + +**Cause**: +1. You delegated to a different tab — the current tab has no pending delegations +2. The task was already processed + +**Solution**: Switch to the target tab (using `Ctrl+\`` to see all tabs) and press `Ctrl+Shift+D` there. + +--- + +## Performance: Too Many Completed Delegations + +**Symptom**: Memory usage grows over time when many delegations are completed. + +**Status**: This is already mitigated. The delegator now auto-prunes to keep at most 256 completed results (VecDeque with bounded size). + +**Verify**: Look at the test `test_auto_prune_bounded_results` which verifies this behavior. + +--- + +## Where Are Tabs Stored on Disk? + +**Location**: `~/.codewhale/tabs.json` (Unix) or `%USERPROFILE%\.codewhale\tabs.json` (Windows) + +**Format**: JSON with the following structure: +```json +{ + "version": 1, + "saved_at": "2026-06-01T12:00:00Z", + "active_tab_index": 0, + "tabs": [ + { + "id": 1, + "title": "Tab 1", + "tab_type": "Chat", + "created_at": "...", + "last_active": "..." + } + ], + "delegations": [] +} +``` + +**Manual editing**: You can edit this file to rename tabs or change tab types, but it's not recommended. Closing the app cleanly is the safe way to update it. + +--- + +## Reporting Issues + +If you encounter a problem not covered here: + +1. Check the [KEYBINDINGS.md](./KEYBINDINGS.md) and [ARCHITECTURE.md](./ARCHITECTURE.md) docs +2. Search existing issues on GitHub +3. Open a new issue with: + - Your terminal type and OS + - Steps to reproduce + - Expected vs actual behavior + - Contents of `~/.codewhale/tabs.json` (if relevant, redact sensitive info) diff --git a/phase2-playbook.md b/phase2-playbook.md new file mode 100644 index 000000000..670aa06e9 --- /dev/null +++ b/phase2-playbook.md @@ -0,0 +1,256 @@ +# Phase 2 playbook: review triage for the multi-tab harvest + +## Purpose + +After the Phase 0 stability fixes and Phase 1 narrow tab-core/persistence +harvest land, Phase 2 is mostly **review feedback processing**: bot reviews +on the harvest PR, Hmbown's structural comments, and CI follow-ups. + +The harvest is #2864 (narrow) and the source branch is #2753 (full). The +playbook is written for #2864, but the same flow applies to #2753 once +#2864 is merged. + +This doc is the **decision tree and the tooling** — not the plan. For the +plan, see `.claude/plans/github-deepseek-tui-skill-proxy-woolly-crescent.md`. +For current state, see `STATUS.md`. + +--- + +## 1. The triage decision tree + +Every review thread (bot or human) flows through this: + +``` +review thread lands + │ + ▼ +[Q1] Is the comment author a known bot (gemini-code-assist, greptile-apps, + github-actions, copilot-pull-request-reviewer, codewhale-ci-bot)? + │ + ├─ no → human reviewer → § 2 (manual triage) + │ + └─ yes → [Q2] Does the comment identify a true correctness/correctness- + adjacent issue, OR is it stylistic / speculative / wrong? + │ + ├─ true issue → § 3 (fix-and-resolve) + ├─ false positive → § 4 (reply-and-resolve with rationale) + └─ stylistic → § 5 (defer or absorb) +``` + +A "true issue" check is **does this actually change behaviour or risk +data loss if left as-is**. Anything that doesn't trip that bar is not +worth a code change in this PR — it goes to the follow-up collab/UI PR, +or it doesn't get fixed at all. + +--- + +## 2. Manual triage (human reviewer) + +When Hmbown comments, treat the comment as binding unless the user +explicitly says otherwise. Hmbown's two previous reviews on #2753 are +the template: + +- He flagged scope ("too large for v0.9") and asked for a narrow slice. +- He flagged design (visible collab paths are WIP) and asked them to be + stubbed. +- He flagged CI ("has not run the normal CodeWhale CI matrix") and + required matrix-clean before merge. + +For each Hmbown comment, draft a reply in two parts: + +1. **What I will change in this PR** (concrete commit list) +2. **What I will defer to the follow-up** (named files / behaviours) + +If the reply needs to push back, do it with a one-sentence reason and +an explicit ask: "If you would prefer X, I can switch the order; this +is the path I picked because Y." + +--- + +## 3. Fix-and-resolve (true issue from a bot) + +The flow: + +``` +1. Open the file:line in the editor. +2. Read enough surrounding code to make sure the suggested fix matches + the actual behaviour, not just the comment's mental model. +3. Write a one-line commit per fix (or group 2-3 mechanical ones into + a single "chore(tui): address bot review on " commit). +4. Push the commit; the thread goes stale and is auto-outdated. +5. Reply to the thread with the fix SHA and a one-line description. +6. Resolve the thread via the GitHub UI or the GraphQL mutation in § 6. +``` + +Do not resolve threads without either: + +- a fix commit on the PR head, or +- a reply explaining why the fix is deferred (with the follow-up PR link + or a TODO), or +- a reply explaining why the suggestion is incorrect. + +A bot thread that is closed with no reply trains the bot (and Hmbown) +to ignore the contributor's PRs. + +--- + +## 4. Reply-and-resolve (false positive from a bot) + +The flow: + +``` +1. Reply to the thread on the PR with a one-paragraph rationale that + cites file:line of the relevant code (or test) that demonstrates + the suggestion is incorrect. +2. Resolve the thread via the GraphQL mutation in § 6. +``` + +Templates: + +- "This is intentional — see `tab/manager.rs:NNN` where the cleanup + happens on tab close. The `delegator.pending_tasks` slice is drained + for the closed tab by `take_pending_for_tab`." +- "Not applicable to this PR — the file is in the WIP collab surface + (`tab/delegator.rs`) which the follow-up PR will rewire. Marking as + out-of-scope here." + +Never resolve a thread without a reply. + +--- + +## 5. Defer or absorb (stylistic / speculative) + +Stylistic bot suggestions (e.g. "consider using a UUID here" on a +collision-resistant path that already has a uniqueness check) are +**not worth a code change**. Two options: + +- **Defer**: leave the thread open, add a one-line reply saying it's + deferred to the follow-up PR. Don't resolve. +- **Absorb silently**: if the change is one line and unambiguous, take + it in a `chore` commit and resolve the thread. + +The deciding factor: would a human reviewer (Hmbown) flag this on a +re-read? If yes, absorb. If no, defer. + +--- + +## 6. Tooling: batch-resolving review threads + +The 9 bot threads on #2864 land on a single commit +(`649d3990d61503e3c13cb38c6b251150e35b925a`). Listing them: + +```bash +gh api graphql -f query=' +query { + repository(owner: "Hmbown", name: "CodeWhale") { + pullRequest(number: 2864) { + reviewThreads(first: 50) { + nodes { + id + isResolved + isOutdated + path + line + comments(first: 1) { + nodes { author { login } body } + } + } + } + } + } +}' +``` + +Resolving a single thread by ID: + +```bash +gh api graphql -f query=' +mutation($id: ID!) { + resolveReviewThread(input: {threadId: $id}) { + thread { isResolved } + } +}' -f id=PRRT_kwDOQ9AYz86HjtoH +``` + +Batch-resolving every unresolved, non-outdated thread on a PR (use with +care — this resolves *everything*): + +```bash +gh api graphql -f query=' +query { + repository(owner: "Hmbown", name: "CodeWhale") { + pullRequest(number: 2864) { + reviewThreads(first: 50) { + nodes { id isResolved isOutdated } + } + } + } +}' \ +| jq -r '.data.repository.pullRequest.reviewThreads.nodes[] + | select(.isResolved == false and .isOutdated == false) + | .id' \ +| while read id; do + gh api graphql -f query=' + mutation($id: ID!) { + resolveReviewThread(input: {threadId: $id}) { thread { isResolved } } + }' -f id="$id" + done +``` + +> **Caution**: only run the batch version after every thread has had a +> reply. The script will not check that. + +--- + +## 7. The 9 threads on #2864, pre-tagged + +Decision tree output for the current state of #2864. See STATUS.md § 4 +for the full table. + +| # | path:line | decision | commit prefix | +| --- | --- | --- | --- | +| 1 | `tab/manager.rs:316` | defer to follow-up collab PR — `close_tab` cleanup is a collab-surface design decision | n/a | +| 2 | `tab/persistence.rs:132` | **fix-and-resolve** (data loss) | `fix(tab): error on oversized persistence file` | +| 3 | `tab/mention.rs:164` | **fix-and-resolve** (semantic bug; also update test) | `fix(tab): preserve caller order in resolve_tab_mention` | +| 4 | `tab/persistence.rs:64` | **fix-and-resolve** (in-flight status lost) | `feat(tab): persist delegation status` | +| 5 | `tab/manager.rs:184` | defer to follow-up collab PR | n/a | +| 6 | `tab/manager.rs:477` | **fix-and-resolve** (validation; `Option` return) | `feat(tab): validate tab IDs in delegate_task` | +| 7 | `tab/manager.rs:512` | **fix-and-resolve** (validation; `Option` return) | `feat(tab): validate participants in start_meeting` | +| 8 | `tab/group.rs:79` | defer — `tab/group.rs` is in the WIP surface, narrow harvest doesn't ship it | n/a | +| 9 | `tab/manager.rs:435` | **fix-and-resolve** (rename; `pending_tasks` → `completed_delegations`) | `refactor(tab): rename misleading pending_tasks getter` | + +Expected PR delta: 6 commits, all mechanical, all on the narrow branch. +None of them touch the host TUI wiring, so the Stewardship review path +stays the same. + +--- + +## 8. Order of operations + +1. Read each thread's surrounding code, confirm the suggested fix is + correct in context (some bot suggestions don't survive a real read). +2. Land the 6 fix commits as a single `chore(tui): address Phase 2 + bot-review on #2864` series — one commit per row in the table above. +3. Re-run the local CI matrix (fmt / clippy -D warnings / test / lockfile). +4. Push the branch. The bot threads will go stale (outdated) but stay + open until the GraphQL resolve runs. +5. For each of the 6 fix commits, post a one-line PR comment on the + thread pointing to the commit. +6. For each of the 3 defer threads (#1, #5, #8), post a reply explaining + the deferral with a link to the follow-up PR or a TODO. +7. Run the batch-resolve script from § 6. + +After that, the PR is clean for Hmbown to re-review. The follow-up +collab/UI PR can then close threads #1 and #5 with their own fixes. + +--- + +## 9. What this playbook does NOT do + +- It does not push back on Hmbown's structural design (Phase 1/2 split). + That was negotiated in § 1 of the strategy and is fixed. +- It does not auto-merge. Hmbown merges; the playbook only gets the PR + to a state where Hmbown can. +- It does not touch the deferred collab/UI surface. Adding a tab + switcher, a meeting modal, a `TabBar` widget, etc. is the follow-up + PR's job, not this one.