Merge branch 'agent/v094-rail-rebase-20260803' (resolved: accept panel unification)

This commit is contained in:
Hmbown
2026-08-03 15:57:16 -07:00
43 changed files with 1176 additions and 4408 deletions
+2 -2
View File
@@ -290,7 +290,7 @@
"CmdNewDescription": "Inicia una sessió desada nova",
"CmdSessionsDescription": "Obre el selector de l'historial de sessions",
"CmdSettingsDescription": "Obre l'editor de configuració amb tipus",
"CmdSidebarDescription": "Commuta o enfoca la barra lateral dreta",
"CmdSidebarDescription": "Situa el rail (top/left/right/off) o tria el seu panell",
"CmdSkillDescription": "Activa una skill, o instal·la/actualitza/desinstal·la/confia en una skill de la comunitat",
"CmdSkillsDescription": "Llista les skills locals, filtra per prefix o explora el registre remot curat",
"CmdStashDescription": "Aparca o restaura un esborrany del redactor",
@@ -398,7 +398,7 @@
"KbCyclePermissions": "Canvia la postura de permisos: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Activa les ranures de la Hotbar",
"KbAltJumpPlanAgentYolo": "Salt alternatiu al mode Plan / Act / Operate",
"KbFocusSidebar": "Enfoca la barra lateral Pinned / Activity / Agents / Context / Auto; Ctrl+Alt+0 commuta la barra lateral fixada",
"KbFocusSidebar": "Enfoca el panell del rail Tasks / Agents / Context / Pinned",
"KbSessionPicker": "Obre el selector de sessions",
"KbTerminalPaste": "Enganxa text amb el teu terminal local",
"KbPasteAttach": "Enganxa text o adjunta una imatge des d'un porta-retalls gràfic local o reenviat",
+2 -2
View File
@@ -290,7 +290,7 @@
"CmdNewDescription": "Neue gespeicherte Sitzung starten",
"CmdSessionsDescription": "Sitzungsverlauf-Auswahl öffnen",
"CmdSettingsDescription": "Typisierten Einstellungs-Editor öffnen",
"CmdSidebarDescription": "Rechte Seitenleiste umschalten oder fokussieren",
"CmdSidebarDescription": "Rail platzieren (top/left/right/off) oder sein Panel wählen",
"CmdSkillDescription": "Skill aktivieren oder Community-Skill installieren/aktualisieren/deinstallieren/vertrauen",
"CmdSkillsDescription": "Lokale Skills auflisten, nach Präfix filtern oder kuratierte Remote-Registry durchsuchen",
"CmdStashDescription": "Composer-Entwurf parken oder wiederherstellen",
@@ -398,7 +398,7 @@
"KbCyclePermissions": "Berechtigungshaltung wechseln: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Hotbar-Slots auslösen",
"KbAltJumpPlanAgentYolo": "Alternativer Sprung in den Plan- / Act- / Operate-Modus",
"KbFocusSidebar": "Seitenleiste Pinned / Activity / Agents / Context / Auto fokussieren; Ctrl+Alt+0 schaltet die angeheftete Seitenleiste um",
"KbFocusSidebar": "Rail-Panel Tasks / Agents / Context / Pinned fokussieren",
"KbSessionPicker": "Sitzungsauswahl öffnen",
"KbTerminalPaste": "Text über das lokale Terminal einfügen",
"KbPasteAttach": "Text einfügen oder Bild aus einer lokalen bzw. weitergeleiteten grafischen Zwischenablage anhängen",
+2 -2
View File
@@ -293,7 +293,7 @@
"CmdNewDescription": "Start a fresh saved session",
"CmdSessionsDescription": "Open session history picker",
"CmdSettingsDescription": "Open the typed settings editor",
"CmdSidebarDescription": "Toggle or focus the right sidebar",
"CmdSidebarDescription": "Place the rail (top/left/right/off) or pick its panel",
"CmdSkillDescription": "Activate a skill, or install/update/uninstall/trust a community skill",
"CmdSkillsDescription": "List local skills, filter by prefix, or browse the curated remote registry",
"CmdStashDescription": "Park or restore a composer draft",
@@ -415,7 +415,7 @@
"KbCyclePermissions": "Cycle permission posture: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Trigger hotbar slots",
"KbAltJumpPlanAgentYolo": "Alternative jump to Plan / Act / Operate mode",
"KbFocusSidebar": "Focus Pinned / Activity / Agents / Context / Auto sidebar; Ctrl+Alt+0 toggles pinned sidebar",
"KbFocusSidebar": "Focus the Tasks / Agents / Context / Pinned rail panel",
"KbSessionPicker": "Open the session picker",
"KbTerminalPaste": "Paste text using your local terminal",
"KbPasteAttach": "Paste text or attach an image from a local or forwarded graphical clipboard",
+2 -2
View File
@@ -293,7 +293,7 @@
"CmdNewDescription": "Iniciar una nueva sesión guardada",
"CmdSessionsDescription": "Abrir el selector de sesiones",
"CmdSettingsDescription": "Abrir el editor de ajustes con tipos",
"CmdSidebarDescription": "Alternar o enfocar la barra lateral derecha",
"CmdSidebarDescription": "Colocar el riel (top/left/right/off) o elegir su panel",
"CmdSkillDescription": "Activar una skill, o instalar/actualizar/desinstalar/confiar en una skill de la comunidad",
"CmdSkillsDescription": "Listar skills locales, filtrar por prefijo o explorar el registro remoto curado",
"CmdStashDescription": "Estacionar o restaurar el borrador del compositor",
@@ -415,7 +415,7 @@
"KbCyclePermissions": "Ciclar postura de permiso: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Activar ranuras de la hotbar",
"KbAltJumpPlanAgentYolo": "Salto alternativo a modo Plan / Act / Operate",
"KbFocusSidebar": "Enfocar barra lateral Pinned / Activity / Agents / Context / Auto / Ocultar",
"KbFocusSidebar": "Enfocar el panel del riel Tasks / Agents / Context / Pinned",
"KbSessionPicker": "Abrir selector de sesiones",
"KbTerminalPaste": "Pegar texto con el terminal local",
"KbPasteAttach": "Pegar texto o adjuntar una imagen desde un portapapeles gráfico local o reenviado",
+2 -2
View File
@@ -290,7 +290,7 @@
"CmdNewDescription": "Démarrer une nouvelle session enregistrée",
"CmdSessionsDescription": "Ouvrir le sélecteur d'historique des sessions",
"CmdSettingsDescription": "Ouvrir l'éditeur de paramètres typés",
"CmdSidebarDescription": "Basculer ou cibler la barre latérale droite",
"CmdSidebarDescription": "Placer le rail (top/left/right/off) ou choisir son panneau",
"CmdSkillDescription": "Activer une skill, ou installer/mettre à jour/désinstaller/approuver une skill communautaire",
"CmdSkillsDescription": "Lister les skills locales, filtrer par préfixe ou parcourir le registre distant sélectionné",
"CmdStashDescription": "Mettre de côté ou restaurer un brouillon du composer",
@@ -398,7 +398,7 @@
"KbCyclePermissions": "Cycler la posture de permission : Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Déclencher les slots de la hotbar",
"KbAltJumpPlanAgentYolo": "Saut alternatif vers le mode Plan / Act / Operate",
"KbFocusSidebar": "Cibler la barre latérale Pinned / Activity / Agents / Context / Auto ; Ctrl+Alt+0 bascule la barre épinglée",
"KbFocusSidebar": "Cibler le panneau du rail Tasks / Agents / Context / Pinned",
"KbSessionPicker": "Ouvrir le sélecteur de sessions",
"KbTerminalPaste": "Coller du texte avec votre terminal local",
"KbPasteAttach": "Coller du texte ou joindre une image depuis un presse-papiers graphique local ou transféré",
+2 -2
View File
@@ -290,7 +290,7 @@
"CmdNewDescription": "नया सहेजा गया सत्र शुरू करें",
"CmdSessionsDescription": "सत्र इतिहास चयनकर्ता खोलें",
"CmdSettingsDescription": "टाइप किया सेटिंग्स संपादक खोलें",
"CmdSidebarDescription": "दायाँ साइडबार टॉगल करें या फ़ोकस करें",
"CmdSidebarDescription": "रेल रखें (top/left/right/off) या उसका पैनल चुनें",
"CmdSkillDescription": "स्किल सक्रिय करें, या सामुदायिक स्किल इंस्टॉल/अपडेट/अनइंस्टॉल/ट्रस्ट करें",
"CmdSkillsDescription": "लोकल स्किल सूचीबद्ध करें, प्रीफ़िक्स से फ़िल्टर करें, या क्यूरेटेड रिमोट रजिस्ट्री ब्राउज़ करें",
"CmdStashDescription": "कम्पोज़र मसौदा पार्क करें या वापस लाएँ",
@@ -398,7 +398,7 @@
"KbCyclePermissions": "अनुमति स्तर बदलें: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Hotbar स्लॉट ट्रिगर करें",
"KbAltJumpPlanAgentYolo": "Plan / Act / Operate मोड पर वैकल्पिक जंप",
"KbFocusSidebar": "Pinned / Activity / Agents / Context / Auto साइडबार फ़ोकस करें; Ctrl+Alt+0 पिन किया साइडबार टॉगल करता है",
"KbFocusSidebar": "Tasks / Agents / Context / Pinned रेल पैनल फ़ोकस करें",
"KbSessionPicker": "सत्र चयनकर्ता खोलें",
"KbTerminalPaste": "अपने लोकल टर्मिनल से टेक्स्ट पेस्ट करें",
"KbPasteAttach": "लोकल या फ़ॉरवर्ड किए ग्राफ़िकल क्लिपबोर्ड से टेक्स्ट पेस्ट करें या इमेज अटैच करें",
+2 -2
View File
@@ -290,7 +290,7 @@
"CmdNewDescription": "Mulai sesi tersimpan baru",
"CmdSessionsDescription": "Buka pemilih riwayat sesi",
"CmdSettingsDescription": "Buka editor pengaturan bertipe",
"CmdSidebarDescription": "Alihkan atau fokuskan bilah sisi kanan",
"CmdSidebarDescription": "Tempatkan rail (top/left/right/off) atau pilih panelnya",
"CmdSkillDescription": "Aktifkan keterampilan, atau pasang/perbarui/copot/percayai keterampilan komunitas",
"CmdSkillsDescription": "Tampilkan keterampilan lokal, saring menurut prefiks, atau telusuri registri remote terkurasi",
"CmdStashDescription": "Simpan sementara atau pulihkan draf komposer",
@@ -398,7 +398,7 @@
"KbCyclePermissions": "Ganti postur izin: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Picu slot hotbar",
"KbAltJumpPlanAgentYolo": "Lompatan alternatif ke mode Plan / Act / Operate",
"KbFocusSidebar": "Fokuskan bilah sisi Pinned / Activity / Agents / Context / Auto; Ctrl+Alt+0 mengalihkan bilah sisi tersemat",
"KbFocusSidebar": "Fokuskan panel rail Tasks / Agents / Context / Pinned",
"KbSessionPicker": "Buka pemilih sesi",
"KbTerminalPaste": "Tempel teks memakai terminal lokal Anda",
"KbPasteAttach": "Tempel teks atau lampirkan gambar dari papan klip grafis lokal atau yang diteruskan",
+2 -2
View File
@@ -293,7 +293,7 @@
"CmdNewDescription": "新しい保存済みセッションを開始",
"CmdSessionsDescription": "セッション履歴ピッカーを開く",
"CmdSettingsDescription": "型付き設定エディターを開く",
"CmdSidebarDescription": "右サイドバーの表示/フォーカスを切り替え",
"CmdSidebarDescription": "レールの配置 (top/left/right/off) またはパネルの選択",
"CmdSkillDescription": "スキルを有効化、またはコミュニティスキルをインストール/更新/アンインストール/信頼",
"CmdSkillsDescription": "ローカルスキルを一覧表示し、接頭辞で絞り込むか、精選レジストリを参照する",
"CmdStashDescription": "コンポーザーの下書きを退避/復元する",
@@ -415,7 +415,7 @@
"KbCyclePermissions": "権限を順に切り替え: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "ホットバースロットを起動",
"KbAltJumpPlanAgentYolo": "Plan / Act / Operate モードへの代替ジャンプ",
"KbFocusSidebar": "Pinned / Activity / Agents / Context / Auto / Hidden サイドバーにフォーカス",
"KbFocusSidebar": "Tasks / Agents / Context / Pinned レールパネルにフォーカス",
"KbSessionPicker": "セッションピッカーを開く",
"KbTerminalPaste": "ローカル端末を使ってテキストを貼り付け",
"KbPasteAttach": "ローカルまたは転送されたグラフィカルクリップボードからテキストを貼り付けるか画像を添付",
+2 -2
View File
@@ -293,7 +293,7 @@
"CmdNewDescription": "새 저장 세션을 시작합니다",
"CmdSessionsDescription": "세션 기록 선택 화면을 엽니다",
"CmdSettingsDescription": "형식화된 설정 편집기 열기",
"CmdSidebarDescription": "오른쪽 사이드바를 전환하거나 포커스를 옮깁니다",
"CmdSidebarDescription": "레일 위치 지정 (top/left/right/off) 또는 패널 선택",
"CmdSkillDescription": "스킬을 활성화하거나 커뮤니티 스킬을 설치/업데이트/제거/신뢰합니다",
"CmdSkillsDescription": "로컬 스킬을 나열하고, 접두사로 필터링하거나 큐레이션된 원격 레지스트리를 조회합니다",
"CmdStashDescription": "입력창 초안을 보관하거나 복원합니다",
@@ -415,7 +415,7 @@
"KbCyclePermissions": "권한 모드 순환: 확인 → 자동 검토 → 전체 권한 (Shift+Tab)",
"KbJumpPlanAgentYolo": "핫바 슬롯 실행",
"KbAltJumpPlanAgentYolo": "계획 / 실행 / 운영 모드로 대체 이동",
"KbFocusSidebar": "고정 / 활동 / 에이전트 / 컨텍스트 / 자동 사이드바에 포커스; Ctrl+Alt+0으로 고정 사이드바 전환",
"KbFocusSidebar": "Tasks / Agents / Context / Pinned 레일 패널에 포커스",
"KbSessionPicker": "세션 선택 화면 열기",
"KbTerminalPaste": "로컬 터미널로 텍스트 붙여넣기",
"KbPasteAttach": "로컬 또는 전달된 그래픽 클립보드에서 텍스트를 붙여넣거나 이미지 첨부",
+2 -2
View File
@@ -293,7 +293,7 @@
"CmdNewDescription": "Iniciar uma nova sessão salva",
"CmdSessionsDescription": "Abrir seletor de histórico de sessões",
"CmdSettingsDescription": "Abrir o editor de configurações tipadas",
"CmdSidebarDescription": "Alternar ou focar a barra lateral direita",
"CmdSidebarDescription": "Posicionar o trilho (top/left/right/off) ou escolher seu painel",
"CmdSkillDescription": "Ativar uma skill, ou instalar/atualizar/desinstalar/confiar em uma skill da comunidade",
"CmdSkillsDescription": "Listar skills locais, filtrar por prefixo ou navegar o registro remoto curado",
"CmdStashDescription": "Estacionar ou restaurar o rascunho do compositor",
@@ -415,7 +415,7 @@
"KbCyclePermissions": "Ciclar postura de permissão: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Acionar slots da hotbar",
"KbAltJumpPlanAgentYolo": "Salto alternativo para modo Plan / Act / Operate",
"KbFocusSidebar": "Focar barra lateral Pinned / Activity / Agents / Context / Auto / Ocultar",
"KbFocusSidebar": "Focar o painel do trilho Tasks / Agents / Context / Pinned",
"KbSessionPicker": "Abrir seletor de sessões",
"KbTerminalPaste": "Colar texto usando o terminal local",
"KbPasteAttach": "Colar texto ou anexar uma imagem de uma área de transferência gráfica local ou encaminhada",
+2 -2
View File
@@ -290,7 +290,7 @@
"CmdNewDescription": "Начать новую сохранённую сессию",
"CmdSessionsDescription": "Открыть выбор сессии из истории",
"CmdSettingsDescription": "Открыть типизированный редактор настроек",
"CmdSidebarDescription": "Переключить или сфокусировать правую боковую панель",
"CmdSidebarDescription": "Разместить рейку (top/left/right/off) или выбрать её панель",
"CmdSkillDescription": "Активировать навык или установить/обновить/удалить/доверить навык сообщества",
"CmdSkillsDescription": "Показать локальные навыки, отфильтровать по префиксу или просмотреть удалённый реестр",
"CmdStashDescription": "Отложить или восстановить черновик",
@@ -398,7 +398,7 @@
"KbCyclePermissions": "Перебор режима разрешений: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Активировать слоты Hotbar",
"KbAltJumpPlanAgentYolo": "Альтернативный переход в режим Plan / Act / Operate",
"KbFocusSidebar": "Фокус на боковой панели Pinned / Activity / Agents / Context / Auto; Ctrl+Alt+0 переключает закреплённую панель",
"KbFocusSidebar": "Фокус на панели рейки Tasks / Agents / Context / Pinned",
"KbSessionPicker": "Открыть выбор сессии",
"KbTerminalPaste": "Вставить текст через локальный терминал",
"KbPasteAttach": "Вставить текст или прикрепить изображение из локального или проброшенного графического буфера обмена",
+2 -2
View File
@@ -290,7 +290,7 @@
"CmdNewDescription": "Почати новий збережений сеанс",
"CmdSessionsDescription": "Відкрити вибір з історії сеансів",
"CmdSettingsDescription": "Відкрити типізований редактор налаштувань",
"CmdSidebarDescription": "Перемкнути або сфокусувати праву бічну панель",
"CmdSidebarDescription": "Розмістити рейку (top/left/right/off) або вибрати її панель",
"CmdSkillDescription": "Активувати навичку або встановити/оновити/видалити/довірити навичку спільноти",
"CmdSkillsDescription": "Показати локальні навички, відфільтрувати за префіксом або переглянути курований віддалений реєстр",
"CmdStashDescription": "Відкласти або відновити чернетку композера",
@@ -398,7 +398,7 @@
"KbCyclePermissions": "Перемикати режим дозволів: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Активувати слоти Hotbar",
"KbAltJumpPlanAgentYolo": "Альтернативний перехід до режиму Plan / Act / Operate",
"KbFocusSidebar": "Фокус на бічній панелі Pinned / Activity / Agents / Context / Auto; Ctrl+Alt+0 перемикає закріплену бічну панель",
"KbFocusSidebar": "Фокус на панелі рейки Tasks / Agents / Context / Pinned",
"KbSessionPicker": "Відкрити вибір сеансу",
"KbTerminalPaste": "Вставити текст через локальний термінал",
"KbPasteAttach": "Вставити текст або додати зображення з локального чи перенаправленого графічного буфера обміну",
+2 -2
View File
@@ -293,7 +293,7 @@
"CmdNewDescription": "Bắt đầu một phiên lưu mới",
"CmdSessionsDescription": "Mở bảng chọn lịch sử phiên làm việc",
"CmdSettingsDescription": "Mở trình chỉnh sửa cài đặt có kiểu",
"CmdSidebarDescription": "Bật/tắt hoặc lấy nét thanh bên phải",
"CmdSidebarDescription": "Đặt thanh ray (top/left/right/off) hoặc chọn bảng của nó",
"CmdSkillDescription": "Kích hoạt một kỹ năng, hoặc cài đặt/cập nhật/gỡ bỏ/tin cậy một kỹ năng cộng đồng",
"CmdSkillsDescription": "Liệt kê kỹ năng cục bộ, lọc theo tiền tố, hoặc duyệt kho đăng ký từ xa",
"CmdStashDescription": "Tạm cất hoặc khôi phục bản nháp",
@@ -415,7 +415,7 @@
"KbCyclePermissions": "Xoay vòng quyền: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Kích hoạt các ô hotbar",
"KbAltJumpPlanAgentYolo": "Phím tắt thay thế nhảy sang Plan / Act / Operate",
"KbFocusSidebar": "Focus vào thanh bên Pinned / Activity / Agents / Context / Auto; Ctrl+Alt+0 để ẩn",
"KbFocusSidebar": "Focus vào bảng thanh ray Tasks / Agents / Context / Pinned",
"KbSessionPicker": "Mở bảng chọn phiên làm việc",
"KbTerminalPaste": "Dán văn bản bằng terminal cục bộ",
"KbPasteAttach": "Dán văn bản hoặc đính kèm ảnh từ bộ nhớ tạm đồ họa cục bộ hoặc được chuyển tiếp",
+2 -2
View File
@@ -293,7 +293,7 @@
"CmdNewDescription": "开始一个新的已保存会话",
"CmdSessionsDescription": "打开会话历史选择器",
"CmdSettingsDescription": "打开类型化设置编辑器",
"CmdSidebarDescription": "切换或聚焦右侧边栏",
"CmdSidebarDescription": "放置工作栏(top/left/right/off)或选择其面板",
"CmdSkillDescription": "激活技能,或安装/更新/卸载/信任社区技能",
"CmdSkillsDescription": "列出本地技能、按前缀筛选,或浏览精选远程注册表",
"CmdStashDescription": "暂存或恢复输入草稿",
@@ -415,7 +415,7 @@
"KbCyclePermissions": "循环权限: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "触发快捷栏槽位",
"KbAltJumpPlanAgentYolo": "替代快捷键跳转到 Plan / Act / Operate 模式",
"KbFocusSidebar": "聚焦 Pinned / Activity / 代理 / Context / 自动 / 隐藏侧边栏; Ctrl+Alt+0 切换固定侧边栏",
"KbFocusSidebar": "聚焦 Tasks / Agents / Context / Pinned 工作栏面板",
"KbSessionPicker": "打开会话选择器",
"KbTerminalPaste": "使用本地终端粘贴文本",
"KbPasteAttach": "从本地或转发的图形剪贴板粘贴文本或附加图片",
+144 -103
View File
@@ -19,11 +19,9 @@ use crate::config_ui::{ConfigUiMode, parse_mode};
use crate::localization::{MessageId, resolve_locale};
use crate::settings::Settings;
use crate::tui::app::{
App, AppAction, AppMode, OnboardingState, ReasoningEffort, SettingSelection, SidebarFocus,
VimMode,
App, AppAction, AppMode, OnboardingState, ReasoningEffort, SettingSelection, VimMode,
};
use crate::tui::approval::ApprovalMode;
use crate::tui::ui::{SidebarRenderState, sidebar_render_state};
use anyhow::Result;
use std::path::{Path, PathBuf};
@@ -368,11 +366,10 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult {
}
"mode" | "default_mode" => Some(app.mode.as_setting().to_string()),
"max_history" | "history" => Some(app.max_input_history.to_string()),
"sidebar_width" | "sidebar" => Some(app.sidebar_width_percent.to_string()),
"sidebar_focus" | "focus" => Some(app.sidebar_focus.as_setting().to_string()),
"work_surface_placement" | "work_surface" | "work_rail" => {
Some(app.work_surface.placement.as_setting().to_string())
}
"rail_panel" | "rail" => Some(app.work_surface.panel.as_setting().to_string()),
"work_surface_top_height" | "work_top_height" => {
Some(app.work_surface.top_height.to_string())
}
@@ -517,12 +514,15 @@ pub fn verbose(app: &mut App, arg: Option<&str>) -> CommandResult {
})
}
/// Toggle or focus the right sidebar.
/// Place the work rail or pick its panel.
///
/// Bare `/sidebar` toggles between hidden and pinned. Explicit values mirror
/// `sidebar_focus` so users have a discoverable copy-friendly path that does
/// not depend on terminal-specific key translations.
/// `/rail top|left|right|off` sets placement; `/rail tasks|agents|context|
/// pinned` picks the panel. The two are orthogonal: where the rail sits and
/// what it shows. `/sidebar` remains registered as the alias users know.
/// Bare `/rail` reports the rail's *actual* rendered state — never a claim
/// about a surface that cannot render.
pub fn sidebar(app: &mut App, arg: Option<&str>) -> CommandResult {
const USAGE: &str = "Usage: /rail [top|left|right|off|tasks|agents|context|pinned] [--save]";
let raw = arg.map(str::trim).unwrap_or("");
let mut tokens = raw.split_whitespace().collect::<Vec<_>>();
let persist = matches!(tokens.last(), Some(&"--save" | &"-s"));
@@ -530,65 +530,100 @@ pub fn sidebar(app: &mut App, arg: Option<&str>) -> CommandResult {
tokens.pop();
}
let target = match tokens.as_slice() {
[] | ["toggle"] => {
if app.sidebar_focus == SidebarFocus::Hidden {
SidebarFocus::Pinned
} else {
SidebarFocus::Hidden
match tokens.as_slice() {
[] => return CommandResult::message(rail_status_message(app)),
[value] => {
let value = value.to_ascii_lowercase();
// Legacy focus words map onto the closest rail concept so muscle
// memory keeps working: "on" restores the default top rail,
// "off" hides it, panel names select panels.
let placement = match value.as_str() {
"top" | "on" | "show" | "visible" => {
Some(crate::tui::work_surface::WorkSurfacePlacement::Top)
}
"left" => Some(crate::tui::work_surface::WorkSurfacePlacement::Left),
"right" => Some(crate::tui::work_surface::WorkSurfacePlacement::Right),
"off" | "hide" | "hidden" | "closed" | "none" => {
Some(crate::tui::work_surface::WorkSurfacePlacement::Off)
}
_ => None,
};
let panel = match value.as_str() {
"tasks" | "activity" | "live" | "running" => {
Some(crate::tui::work_surface::RailPanel::Tasks)
}
"agents" | "subagents" | "sub-agents" => {
Some(crate::tui::work_surface::RailPanel::Agents)
}
"context" | "session" => Some(crate::tui::work_surface::RailPanel::Context),
"pinned" | "work" | "plan" | "todos" => {
Some(crate::tui::work_surface::RailPanel::Pinned)
}
_ => None,
};
match (placement, panel) {
(Some(placement), None) => {
app.work_surface.placement = placement;
app.work_surface.focused = false;
if persist {
let result = set_config_value(
app,
"work_surface_placement",
placement.as_setting(),
true,
);
if result.is_error {
return result;
}
}
}
(None, Some(panel)) => {
app.work_surface.panel = panel;
if persist {
let result = set_config_value(app, "rail_panel", panel.as_setting(), true);
if result.is_error {
return result;
}
}
}
_ => return CommandResult::error(USAGE),
}
}
[value] => match value.to_ascii_lowercase().as_str() {
"on" | "show" | "visible" | "pinned" => SidebarFocus::Pinned,
"off" | "hide" | "hidden" | "closed" | "none" => SidebarFocus::Hidden,
"auto" => SidebarFocus::Auto,
"work" | "plan" | "todos" => SidebarFocus::Pinned,
// Panel label is Activity; "tasks" remains the config/compat key (#4147/#4135).
"tasks" | "activity" | "live" | "running" => SidebarFocus::Tasks,
"agents" | "subagents" | "sub-agents" => SidebarFocus::Agents,
"context" => SidebarFocus::Context,
"sessions" | "session_history" | "sessions_rail" => SidebarFocus::Sessions,
_ => {
return CommandResult::error(
"Usage: /sidebar [on|off|pinned|auto|activity|tasks|agents|context|sessions] [--save]",
);
}
},
_ => {
return CommandResult::error(
"Usage: /sidebar [on|off|pinned|auto|activity|tasks|agents|context|sessions] [--save]",
);
}
};
if persist {
let result = set_config_value(app, "sidebar_focus", target.as_setting(), true);
if result.is_error {
return result;
}
} else {
app.set_sidebar_focus(target);
_ => return CommandResult::error(USAGE),
}
app.needs_redraw = true;
let message = sidebar_status_message(app);
CommandResult::message(message)
CommandResult::message(rail_status_message(app))
}
fn sidebar_status_message(app: &mut App) -> String {
match sidebar_render_state(app) {
SidebarRenderState::Hidden => "Sidebar is hidden".to_string(),
SidebarRenderState::SuppressedByWidth {
available_width,
min_width,
} => format!(
"Sidebar is on, but hidden because the terminal is too narrow ({available_width} cols; needs at least {min_width})"
),
SidebarRenderState::AutoCollapsed => {
"Sidebar auto mode is on, but currently collapsed while idle".to_string()
}
SidebarRenderState::Visible => "Sidebar is visible".to_string(),
/// Truthful rail readout: the placement and panel that actually render, with
/// the narrow-terminal fallback and an empty-Tasks collapse spelled out.
/// Never claims a panel is visible when no rail area was produced.
fn rail_status_message(app: &App) -> String {
use crate::tui::work_surface::{RailPanel, WorkSurfacePlacement};
let placement = app.work_surface.placement;
if placement == WorkSurfacePlacement::Off {
return "Rail is off — no panel renders (/rail top|left|right to show it)".to_string();
}
let panel = app.work_surface.panel;
let mut message = format!(
"Rail: {} placement, {} panel",
placement.as_setting(),
panel.title()
);
let effective = app.work_surface.effective_placement();
if effective != placement && effective == WorkSurfacePlacement::Top {
message.push_str(" — side rails need a wider terminal, showing top for now");
}
if app.work_surface.last_area.is_none() {
if panel == RailPanel::Tasks {
message.push_str(" (currently hidden — no work to show)");
} else {
message.push_str(" (renders next frame)");
}
}
message
}
fn resolve_provider_url_value(provider: ApiProvider, value: &str) -> Result<String, String> {
@@ -1853,6 +1888,11 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
app.work_surface.last_area = None;
app.needs_redraw = true;
}
"rail_panel" | "rail" => {
app.work_surface.panel =
crate::tui::work_surface::RailPanel::parse(&settings.rail_panel);
app.needs_redraw = true;
}
"work_surface_top_height" | "work_top_height" => {
app.work_surface.top_height = settings.work_surface_top_height;
app.needs_redraw = true;
@@ -2057,13 +2097,6 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
app.update_model_compaction_budget();
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
}
"sidebar_width" | "sidebar" => {
app.sidebar_width_percent = settings.sidebar_width_percent;
app.mark_history_updated();
}
"sidebar_focus" | "focus" => {
app.set_sidebar_focus(SidebarFocus::from_setting(&settings.sidebar_focus));
}
"context_panel" | "context" | "session_panel" => {
app.context_panel = settings.context_panel;
app.needs_redraw = true;
@@ -2644,7 +2677,7 @@ mod tests {
"{key} mutates the active route and must be locked mid-turn"
);
}
for key in ["default_mode", "theme", "calm_mode", "sidebar_width"] {
for key in ["default_mode", "theme", "calm_mode", "rail_panel"] {
assert!(
live_route_setting_subject(key).is_none(),
"{key} does not mutate the active route and must stay settable"
@@ -2752,62 +2785,70 @@ mod tests {
}
#[test]
fn sidebar_config_command_restores_pinned_sidebar_by_default() {
fn rail_command_on_restores_default_top_placement() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Hidden;
app.last_sidebar_host_width = Some(120);
app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Off;
let result = sidebar(&mut app, Some("on"));
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Pinned);
assert_eq!(result.message.as_deref(), Some("Sidebar is visible"));
assert_eq!(
app.work_surface.placement,
crate::tui::work_surface::WorkSurfacePlacement::Top
);
let message = result.message.unwrap_or_default();
assert!(message.contains("top placement"), "got: {message}");
}
#[test]
fn sidebar_config_command_reports_width_suppression() {
fn rail_command_reports_narrow_terminal_top_fallback() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Hidden;
app.last_sidebar_host_width = Some(59);
app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Left;
// A 60-column host is below the side-rail floor, so the effective
// placement falls back to top; the status must say so rather than
// claim a left rail renders.
let _ = crate::tui::work_surface::height(&mut app, 60, 24);
let result = sidebar(&mut app, Some("on"));
let result = sidebar(&mut app, None);
assert!(!result.is_error);
let message = result.message.unwrap_or_default();
assert!(message.contains("left placement"), "got: {message}");
assert!(message.contains("showing top for now"), "got: {message}");
}
#[test]
fn rail_command_off_never_claims_visibility() {
let mut app = create_test_app();
let result = sidebar(&mut app, Some("off"));
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Pinned);
assert_eq!(
result.message.as_deref(),
Some(
"Sidebar is on, but hidden because the terminal is too narrow (59 cols; needs at least 60)"
)
app.work_surface.placement,
crate::tui::work_surface::WorkSurfacePlacement::Off
);
let message = result.message.unwrap_or_default();
assert!(message.contains("Rail is off"), "got: {message}");
assert!(
!message.contains("Sidebar is visible"),
"the readout must never claim a dead surface renders: {message}"
);
}
#[test]
fn sidebar_config_command_is_visible_at_minimum_width() {
fn rail_command_rejects_retired_auto_mode() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Hidden;
app.last_sidebar_host_width = Some(60);
let result = sidebar(&mut app, Some("on"));
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Pinned);
assert_eq!(result.message.as_deref(), Some("Sidebar is visible"));
}
#[test]
fn sidebar_config_command_reports_auto_idle_collapse() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Hidden;
app.last_sidebar_host_width = Some(120);
let result = sidebar(&mut app, Some("auto"));
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Auto);
assert_eq!(
result.message.as_deref(),
Some("Sidebar auto mode is on, but currently collapsed while idle")
assert!(result.is_error);
assert!(
result
.message
.as_deref()
.unwrap_or_default()
.contains("Usage: /rail")
);
}
+9 -8
View File
@@ -21,7 +21,7 @@ impl CommandGroup for ConfigCommands {
Box::new(FunctionCommand::new(&CONFIG_INFO, run_config)),
Box::new(FunctionCommand::new(&PERMISSIONS_INFO, run_permissions)),
Box::new(FunctionCommand::new(&AUTH_INFO, run_auth)),
Box::new(FunctionCommand::new(&SIDEBAR_INFO, run_sidebar)),
Box::new(FunctionCommand::new(&RAIL_INFO, run_rail)),
Box::new(FunctionCommand::new(&SETTINGS_INFO, run_settings)),
Box::new(FunctionCommand::new(&STATUS_INFO, run_status)),
Box::new(FunctionCommand::new(&STATUSLINE_INFO, run_statusline)),
@@ -54,10 +54,11 @@ static AUTH_INFO: CommandInfo = CommandInfo {
usage: "/auth xai-device",
description_id: MessageId::CmdAuthDescription,
};
static SIDEBAR_INFO: CommandInfo = CommandInfo {
name: "sidebar",
aliases: &[],
usage: "/sidebar [on|off|auto|work|activity|tasks|agents|context] [--save]",
static RAIL_INFO: CommandInfo = CommandInfo {
name: "rail",
// /sidebar is the name users already know; it now drives the one rail.
aliases: &["sidebar"],
usage: "/rail [top|left|right|off|tasks|agents|context|pinned] [--save]",
description_id: MessageId::CmdSidebarDescription,
};
static SETTINGS_INFO: CommandInfo = CommandInfo {
@@ -121,8 +122,8 @@ fn run_permissions(app: &mut App, arg: Option<&str>) -> CommandResult {
fn run_auth(app: &mut App, arg: Option<&str>) -> CommandResult {
run_registered(app, "auth", arg)
}
fn run_sidebar(app: &mut App, arg: Option<&str>) -> CommandResult {
run_registered(app, "sidebar", arg)
fn run_rail(app: &mut App, arg: Option<&str>) -> CommandResult {
run_registered(app, "rail", arg)
}
fn run_settings(app: &mut App, arg: Option<&str>) -> CommandResult {
run_registered(app, "settings", arg)
@@ -164,7 +165,7 @@ pub(in crate::commands) fn dispatch(
}
_ => CommandResult::error("Usage: /auth xai-device"),
},
"sidebar" => config::sidebar(app, arg),
"rail" | "sidebar" => config::sidebar(app, arg),
"settings" => config::settings_command(app, arg),
"status" => status::status(app),
"statusline" => config::status_line(app),
+70 -48
View File
@@ -363,7 +363,8 @@ mod tests {
use crate::localization::{Locale, MessageId};
use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
use crate::tools::todo::TodoStatus;
use crate::tui::app::{App, AppAction, SidebarFocus, TuiOptions};
use crate::tui::app::{App, AppAction, TuiOptions};
use crate::tui::work_surface::{RailPanel, WorkSurfacePlacement};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use tempfile::tempdir;
@@ -465,16 +466,13 @@ mod tests {
#[test]
fn command_registry_contains_config_and_links_but_not_set_or_deepseek() {
assert!(command_infos().iter().any(|cmd| cmd.name == "config"));
let sidebar = command_infos()
let rail = command_infos()
.into_iter()
.find(|cmd| cmd.name == "sidebar")
.expect("sidebar command should exist");
assert_eq!(sidebar.description_id, MessageId::CmdSidebarDescription);
assert!(
sidebar
.description_for(Locale::En)
.contains("right sidebar")
);
.find(|cmd| cmd.name == "rail")
.expect("rail command should exist");
assert_eq!(rail.aliases, &["sidebar"]);
assert_eq!(rail.description_id, MessageId::CmdSidebarDescription);
assert!(rail.description_for(Locale::En).contains("rail"));
assert!(command_infos().iter().any(|cmd| cmd.name == "links"));
let hf = command_infos()
.into_iter()
@@ -1145,74 +1143,98 @@ mod tests {
}
#[test]
fn execute_sidebar_toggles_visibility() {
fn execute_rail_sets_placement_and_reports_actual_state() {
let mut app = create_test_app();
app.set_sidebar_focus(SidebarFocus::Pinned);
app.last_sidebar_host_width = Some(120);
let result = execute("/sidebar", &mut app);
let result = execute("/rail off", &mut app);
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);
assert!(app.status_message.is_none());
assert_eq!(result.message.as_deref(), Some("Sidebar is hidden"));
assert_eq!(app.work_surface.placement, WorkSurfacePlacement::Off);
assert!(
result
.message
.as_deref()
.unwrap_or_default()
.contains("Rail is off")
);
let result = execute("/sidebar", &mut app);
let result = execute("/rail right", &mut app);
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Pinned);
assert!(app.status_message.is_none());
assert_eq!(result.message.as_deref(), Some("Sidebar is visible"));
assert_eq!(app.work_surface.placement, WorkSurfacePlacement::Right);
assert!(
result
.message
.as_deref()
.unwrap_or_default()
.contains("right placement")
);
// The /sidebar alias drives the same rail.
let result = execute("/sidebar left", &mut app);
assert!(!result.is_error);
assert_eq!(app.work_surface.placement, WorkSurfacePlacement::Left);
// Bare /rail reports the actual rendered state; it must never claim
// visibility for a surface that cannot render.
app.work_surface.placement = WorkSurfacePlacement::Off;
let result = execute("/rail", &mut app);
assert!(!result.is_error);
assert!(
result
.message
.as_deref()
.unwrap_or_default()
.contains("Rail is off")
);
}
#[test]
fn execute_sidebar_accepts_explicit_focus_targets() {
fn execute_rail_accepts_panel_targets_and_legacy_words() {
let mut app = create_test_app();
app.last_sidebar_host_width = Some(120);
let result = execute("/sidebar tasks", &mut app);
let result = execute("/rail agents", &mut app);
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Tasks);
assert!(app.status_message.is_none());
assert_eq!(app.work_surface.panel, RailPanel::Agents);
let result = execute("/sidebar activity", &mut app);
let result = execute("/sidebar context", &mut app);
assert!(!result.is_error);
assert_eq!(app.work_surface.panel, RailPanel::Context);
let result = execute("/rail activity", &mut app);
assert!(!result.is_error);
assert_eq!(
app.sidebar_focus,
SidebarFocus::Tasks,
"activity is the user-facing alias for the Activity panel"
app.work_surface.panel,
RailPanel::Tasks,
"activity maps onto the Tasks panel"
);
let result = execute("/sidebar off", &mut app);
let result = execute("/rail pinned", &mut app);
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);
assert!(app.status_message.is_none());
let result = execute("/sidebar closed", &mut app);
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);
assert!(app.status_message.is_none());
let result = execute("/sidebar none", &mut app);
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);
assert!(app.status_message.is_none());
assert_eq!(app.work_surface.panel, RailPanel::Pinned);
let result = execute("/sidebar on", &mut app);
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Pinned);
assert!(app.status_message.is_none());
assert_eq!(
app.work_surface.placement,
WorkSurfacePlacement::Top,
"on restores the default top rail"
);
let result = execute("/sidebar none", &mut app);
assert!(!result.is_error);
assert_eq!(app.work_surface.placement, WorkSurfacePlacement::Off);
}
#[test]
fn execute_sidebar_rejects_invalid_args() {
fn execute_rail_rejects_invalid_args() {
let mut app = create_test_app();
let result = execute("/sidebar maybe", &mut app);
let result = execute("/rail maybe", &mut app);
assert!(result.is_error);
assert!(
result
.message
.as_deref()
.unwrap_or_default()
.contains("Usage: /sidebar")
.contains("Usage: /rail")
);
}
+1 -1
View File
@@ -47,7 +47,7 @@ pub(crate) const ADVANCED_DISCOVERY_COMMANDS: &[&str] = &[
"rlm",
"settings",
"share",
"sidebar",
"rail",
"status",
"system",
"theme",
+1 -50
View File
@@ -13,9 +13,7 @@ use crate::commands;
use crate::config::{Config, StatusItem, normalize_model_name_for_provider, validate_route};
use crate::localization::{normalize_configured_locale, resolve_locale};
use crate::settings::Settings;
use crate::tui::app::{
App, AppMode, ComposerDensity, ReasoningEffort, SidebarFocus, TranscriptSpacing,
};
use crate::tui::app::{App, AppMode, ComposerDensity, ReasoningEffort, TranscriptSpacing};
use crate::tui::approval::ApprovalMode;
#[cfg(feature = "web")]
@@ -104,9 +102,6 @@ pub struct SettingsSection {
pub status_indicator: StatusIndicatorValue,
pub synchronized_output: SynchronizedOutputValue,
pub default_mode: DefaultModeValue,
#[schemars(range(min = 10, max = 50))]
pub sidebar_width: u16,
pub sidebar_focus: SidebarFocusValue,
pub context_panel: bool,
#[schemars(range(min = 0))]
pub max_history: usize,
@@ -338,18 +333,6 @@ pub enum CostCurrencyValue {
Cny,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SidebarFocusValue {
Auto,
Pinned,
Tasks,
Agents,
Context,
Sessions,
Hidden,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningEffortValue {
@@ -471,8 +454,6 @@ pub fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> {
status_indicator: settings.status_indicator.as_str().into(),
synchronized_output: settings.synchronized_output.as_str().into(),
default_mode: settings.default_mode.as_str().into(),
sidebar_width: settings.sidebar_width_percent,
sidebar_focus: settings.sidebar_focus.as_str().into(),
context_panel: settings.context_panel,
max_history: settings.max_input_history,
cost_currency: CostCurrencyValue::from_setting(&settings.cost_currency)?,
@@ -706,8 +687,6 @@ pub fn apply_document(
doc.settings.synchronized_output.as_setting(),
),
("default_mode", doc.settings.default_mode.as_setting()),
("sidebar_width", &doc.settings.sidebar_width.to_string()),
("sidebar_focus", doc.settings.sidebar_focus.as_setting()),
("context_panel", bool_str(doc.settings.context_panel)),
("max_history", &doc.settings.max_history.to_string()),
("cost_currency", doc.settings.cost_currency.as_setting()),
@@ -1256,20 +1235,6 @@ impl CostCurrencyValue {
}
}
impl SidebarFocusValue {
fn as_setting(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Pinned => "pinned",
Self::Tasks => "tasks",
Self::Agents => "agents",
Self::Context => "context",
Self::Sessions => "sessions",
Self::Hidden => "hidden",
}
}
}
impl From<ApprovalMode> for ApprovalModeValue {
fn from(value: ApprovalMode) -> Self {
match value {
@@ -1401,20 +1366,6 @@ impl From<&str> for StatusIndicatorValue {
}
}
impl From<&str> for SidebarFocusValue {
fn from(value: &str) -> Self {
match SidebarFocus::from_setting(value) {
SidebarFocus::Auto => Self::Auto,
SidebarFocus::Pinned => Self::Pinned,
SidebarFocus::Tasks => Self::Tasks,
SidebarFocus::Agents => Self::Agents,
SidebarFocus::Context => Self::Context,
SidebarFocus::Sessions => Self::Sessions,
SidebarFocus::Hidden => Self::Hidden,
}
}
}
impl From<StatusItem> for StatusItemValue {
fn from(value: StatusItem) -> Self {
match value {
+198 -156
View File
@@ -319,6 +319,14 @@ pub struct Settings {
pub work_surface_top_height: u16,
/// Remembered total width (content plus divider) for side Work placement.
pub work_surface_side_width: u16,
/// Which panel the rail shows: tasks, agents, context, or pinned.
/// Orthogonal to `work_surface_placement` (rail unification, 0.9.4).
pub rail_panel: String,
/// Runtime-only: whether the loaded settings document explicitly named
/// `rail_panel`. The sidebar→rail migration must not override an
/// explicit choice that happens to equal the default ("tasks").
#[serde(skip)]
pub(crate) rail_panel_explicit: bool,
/// Runtime-only 30 FPS cap for terminals that flicker at high redraw
/// rates. Separate from accessibility motion and text delivery.
#[serde(skip)]
@@ -384,13 +392,15 @@ pub struct Settings {
/// Default mode: "agent" (Act), "plan", or "operate". Legacy permission
/// shorthands are accepted for migration but never advertised as modes.
pub default_mode: String,
/// Sidebar width as percentage of terminal width
/// Legacy sidebar width as percentage of terminal width. Load-only
/// migration shim (0.9.4 rail unification): read by
/// `migrate_sidebar_settings_to_rail`, never written back.
#[serde(skip_serializing)]
pub sidebar_width_percent: u16,
/// Sidebar focus mode: pinned, auto, tasks, agents, context, hidden
/// Legacy sidebar focus mode: pinned, auto, tasks, agents, context,
/// hidden. Load-only migration shim, never written back.
#[serde(skip_serializing)]
pub sidebar_focus: String,
/// Migration marker for users who explicitly opt into idle auto-collapse.
#[serde(default, skip_serializing_if = "is_false")]
pub sidebar_auto_collapse_opt_in: bool,
/// Enable the session-context panel (#504). Shows working set, tokens,
/// cost, MCP/LSP status, cycle count, and memory info.
pub context_panel: bool,
@@ -529,6 +539,8 @@ impl Default for Settings {
// only grows to this many lines (user request, 2026-07-23).
work_surface_top_height: 8,
work_surface_side_width: 30,
rail_panel: "tasks".to_string(),
rail_panel_explicit: false,
constrained_frame_rate: false,
bracketed_paste: true,
paste_burst_detection: true,
@@ -553,7 +565,6 @@ impl Default for Settings {
default_mode: "agent".to_string(),
sidebar_width_percent: 28,
sidebar_focus: "auto".to_string(),
sidebar_auto_collapse_opt_in: true,
context_panel: false,
sessions_rail: false,
session_auto_resume: false,
@@ -608,10 +619,64 @@ fn normalize_work_surface_placement(value: &str) -> &'static str {
match value.trim().to_ascii_lowercase().as_str() {
"left" => "left",
"right" => "right",
"off" => "off",
_ => "top",
}
}
fn normalize_rail_panel(value: &str) -> &'static str {
match value.trim().to_ascii_lowercase().as_str() {
"agents" => "agents",
"context" => "context",
"pinned" => "pinned",
_ => "tasks",
}
}
/// Rail unification (0.9.4): carry the classic sidebar's settings forward
/// instead of stranding them. `sidebar_focus` picks the rail panel —
/// pinned/tasks/agents/context map onto the same-named panels, auto folds
/// into the always-on pinned work panel, and hidden turns the rail off.
/// `sidebar_width_percent` maps onto the absolute side width at a
/// 120-column reference. Auto-collapse itself is deliberately dropped: the
/// rail hides via placement off. Explicit new keys win over migrated ones.
fn migrate_sidebar_settings_to_rail(s: &mut Settings) {
match s.sidebar_focus.trim().to_ascii_lowercase().as_str() {
"hidden" | "hide" | "closed" | "off" | "none" => {
if s.work_surface_placement == "top" {
s.work_surface_placement = "off".to_string();
}
}
// #5141 let users pin a dedicated sessions panel in the classic
// sidebar; on the unified rail the equivalent surface is the
// first-class sessions rail, so carry the intent forward by
// enabling it.
"sessions" | "sessions_rail" | "session_history" => {
s.sessions_rail = true;
}
panel @ ("pinned" | "work" | "plan" | "todos" | "tasks" | "activity" | "live"
| "running" | "agents" | "subagents" | "sub-agents" | "context" | "session"
| "auto") => {
// `rail_panel == "tasks"` is the default, so only treat it as
// unset when the document did not name the key explicitly.
if s.rail_panel == "tasks" && !s.rail_panel_explicit {
s.rail_panel = match panel {
"tasks" | "activity" | "live" | "running" => "tasks",
"agents" | "subagents" | "sub-agents" => "agents",
"context" | "session" => "context",
_ => "pinned",
}
.to_string();
}
}
_ => {}
}
if s.sidebar_width_percent != 28 {
let cols = (u32::from(s.sidebar_width_percent) * 120 / 100) as u16;
s.work_surface_side_width = cols.clamp(26, 80);
}
}
fn normalize_inline_diffs(value: &str) -> &'static str {
InlineDiffMode::parse(value).as_setting()
}
@@ -765,6 +830,10 @@ impl Settings {
s.auto_compact_explicit = parsed_document
.as_ref()
.is_some_and(auto_compact_explicitly_configured_in_document);
s.rail_panel_explicit = parsed_document
.as_ref()
.and_then(toml::Value::as_table)
.is_some_and(|table| table.contains_key("rail_panel"));
if parsed_document.as_ref().is_some_and(|document| {
document.as_table().is_some_and(|table| {
!table.contains_key("auto_compact")
@@ -789,18 +858,14 @@ impl Settings {
s.transcript_spacing = normalize_transcript_spacing(&s.transcript_spacing).to_string();
s.tool_collapse_mode = normalize_tool_collapse_mode(&s.tool_collapse_mode).to_string();
s.sidebar_focus = normalize_sidebar_focus(&s.sidebar_focus).to_string();
if s.sidebar_focus == "auto" && !s.sidebar_auto_collapse_opt_in {
// v0.8.62 wrote the surprising auto-collapse default into many
// full settings files. Treat unmarked saved "auto" as that
// legacy default so upgraded users get the sidebar back, while
// `/sidebar auto --save` and `/set sidebar_focus auto` below
// preserve an explicit opt-in from this release onward (#3328).
s.sidebar_focus = "pinned".to_string();
}
// Rail unification (0.9.4) migration: the classic sidebar is
// gone, so its settings carry forward instead of stranding.
migrate_sidebar_settings_to_rail(&mut s);
s.status_indicator = normalize_status_indicator(&s.status_indicator).to_string();
s.ocean_treatment = normalize_ocean_treatment(&s.ocean_treatment).to_string();
s.work_surface_placement =
normalize_work_surface_placement(&s.work_surface_placement).to_string();
s.rail_panel = normalize_rail_panel(&s.rail_panel).to_string();
s.work_surface_top_height = s.work_surface_top_height.clamp(2, 16);
s.work_surface_side_width = s.work_surface_side_width.clamp(26, 80);
s.inline_diffs = normalize_inline_diffs(&s.inline_diffs).to_string();
@@ -1087,12 +1152,6 @@ impl Settings {
atomically_replace_settings_file(path, body.as_bytes())
}
/// Update and persist sidebar width percentage (10-50) — used by the
/// drag-to-resize handle in the TUI.
pub fn update_sidebar_width(&mut self, percent: u16) {
self.sidebar_width_percent = percent.clamp(10, 50);
}
/// Set a single setting by key
pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
match key {
@@ -1144,13 +1203,26 @@ impl Settings {
}
"work_surface_placement" | "work_surface" | "work_rail" => {
let normalized = value.trim().to_ascii_lowercase();
if !matches!(normalized.as_str(), "top" | "left" | "right") {
if !matches!(normalized.as_str(), "top" | "left" | "right" | "off") {
anyhow::bail!(
"Failed to update setting: invalid work surface placement '{value}'. Expected: top, left, or right."
"Failed to update setting: invalid work surface placement '{value}'. Expected: top, left, right, or off."
);
}
self.work_surface_placement = normalized;
}
"rail_panel" | "rail" => {
let normalized = value.trim().to_ascii_lowercase();
if !matches!(
normalized.as_str(),
"tasks" | "agents" | "context" | "pinned"
) {
anyhow::bail!(
"Failed to update setting: invalid rail panel '{value}'. Expected: tasks, agents, context, or pinned."
);
}
self.rail_panel = normalized;
self.rail_panel_explicit = true;
}
"work_surface_top_height" | "work_top_height" => {
self.work_surface_top_height =
parse_u16_range("work_surface_top_height", value, 2, 16)?;
@@ -1279,40 +1351,6 @@ impl Settings {
),
};
}
"sidebar_width" | "sidebar" => {
let width: u16 = value
.parse()
.map_err(|_| {
anyhow::anyhow!(
"Failed to update setting: invalid width '{value}'. Expected a number between 10-50."
)
})?;
if !(10..=50).contains(&width) {
anyhow::bail!(
"Failed to update setting: width must be between 10 and 50 percent."
);
}
self.sidebar_width_percent = width;
}
"sidebar_focus" | "focus" => {
let normalized = match value.trim().to_ascii_lowercase().as_str() {
"auto" => "auto",
"pinned" | "visible" | "show" | "on" | "work" | "plan" | "todos" => "pinned",
// Persist as "tasks"; user-facing panel label is Activity (#4147/#4135).
"tasks" | "activity" | "live" | "running" => "tasks",
"agents" | "subagents" | "sub-agents" => "agents",
"context" => "context",
"sessions" | "sessions_rail" | "session_history" => "sessions",
"hidden" | "hide" | "closed" | "off" | "none" => "hidden",
_ => {
anyhow::bail!(
"Failed to update setting: invalid sidebar focus '{value}'. Expected: pinned, auto, activity (tasks), agents, context, sessions, hidden."
)
}
};
self.sidebar_focus = normalized.to_string();
self.sidebar_auto_collapse_opt_in = normalized == "auto";
}
"context_panel" | "context" | "session_panel" => {
self.context_panel = parse_bool(value)?;
}
@@ -1440,6 +1478,7 @@ impl Settings {
" work_side_width: {}",
self.work_surface_side_width
));
lines.push(format!(" rail_panel: {}", self.rail_panel));
lines.push(format!(" bracketed_paste: {}", self.bracketed_paste));
lines.push(format!(
" paste_burst_detect: {}",
@@ -1480,11 +1519,6 @@ impl Settings {
));
lines.push(format!(" default_mode: {}", self.default_mode));
lines.push(format!(" launch_screen: {}", self.launch_screen));
lines.push(format!(
" sidebar_width: {}%",
self.sidebar_width_percent
));
lines.push(format!(" sidebar_focus: {}", self.sidebar_focus));
lines.push(format!(" context_panel: {}", self.context_panel));
lines.push(format!(" cost_currency: {}", self.cost_currency));
lines.push(format!(" max_history: {}", self.max_input_history));
@@ -1579,6 +1613,10 @@ impl Settings {
"work_surface_side_width",
"Resizable To-do/Sub-agent side bar width: 26-80 columns",
),
(
"rail_panel",
"Which panel the rail shows: tasks/agents/context/pinned",
),
(
"bracketed_paste",
"Terminal bracketed-paste mode: on/off (rare to disable)",
@@ -1662,11 +1700,6 @@ impl Settings {
"default_mode",
"Default mode: act (agent), plan, or operate",
),
("sidebar_width", "Sidebar width percentage: 10-50"),
(
"sidebar_focus",
"Sidebar focus: auto, work, activity (tasks), agents, context, hidden",
),
(
"context_panel",
"Show the session context sidebar panel: on/off",
@@ -2961,11 +2994,11 @@ mod tests {
}
#[test]
fn work_surface_placement_persists_only_top_left_or_right() {
fn work_surface_placement_persists_top_left_right_and_off() {
let mut settings = Settings::default();
assert_eq!(settings.work_surface_placement, "top");
for placement in ["left", "right", "top"] {
for placement in ["left", "right", "top", "off"] {
settings
.set("work_surface_placement", placement)
.expect("valid placement");
@@ -2978,8 +3011,31 @@ mod tests {
let err = settings
.set("work_surface_placement", "bottom")
.expect_err("bottom is owned by composer/footer");
assert!(err.to_string().contains("top, left, or right"));
assert_eq!(settings.work_surface_placement, "top");
assert!(err.to_string().contains("top, left, right, or off"));
assert_eq!(settings.work_surface_placement, "off");
}
#[test]
fn rail_panel_persists_tasks_agents_context_and_pinned() {
let mut settings = Settings::default();
assert_eq!(settings.rail_panel, "tasks");
for panel in ["agents", "context", "pinned", "tasks"] {
settings.set("rail_panel", panel).expect("valid panel");
assert_eq!(settings.rail_panel, panel);
let body = toml::to_string(&settings).expect("serialize settings");
let restored: Settings = toml::from_str(&body).expect("restore settings");
assert_eq!(restored.rail_panel, panel);
}
let err = settings
.set("rail_panel", "auto")
.expect_err("auto-collapse was dropped with the legacy sidebar");
assert!(
err.to_string()
.contains("tasks, agents, context, or pinned")
);
assert_eq!(settings.rail_panel, "tasks");
}
#[test]
@@ -3264,25 +3320,76 @@ mod tests {
}
#[test]
fn default_settings_keep_the_water_field_open_until_inspection_is_needed() {
let settings = Settings::default();
assert_eq!(settings.sidebar_focus, "auto");
assert!(settings.sidebar_auto_collapse_opt_in);
fn legacy_sidebar_focus_migrates_to_rail_panel_and_placement() {
let migrate = |focus: &str| {
let mut settings = Settings {
sidebar_focus: focus.to_string(),
..Settings::default()
};
migrate_sidebar_settings_to_rail(&mut settings);
settings
};
assert_eq!(migrate("agents").rail_panel, "agents");
assert_eq!(migrate("subagents").rail_panel, "agents");
assert_eq!(migrate("context").rail_panel, "context");
assert_eq!(migrate("session").rail_panel, "context");
assert_eq!(migrate("tasks").rail_panel, "tasks");
assert_eq!(migrate("activity").rail_panel, "tasks");
assert_eq!(migrate("pinned").rail_panel, "pinned");
assert_eq!(migrate("work").rail_panel, "pinned");
// Auto-collapse is dropped: auto folds into the always-on pinned
// work panel rather than a mode that hides itself.
assert_eq!(migrate("auto").rail_panel, "pinned");
// A hidden sidebar becomes rail placement off.
let hidden = migrate("hidden");
assert_eq!(hidden.work_surface_placement, "off");
// #5141's pinned sessions panel carries forward as the first-class
// sessions rail.
assert!(migrate("sessions").sessions_rail);
assert!(migrate("sessions_rail").sessions_rail);
// An explicit `rail_panel = "tasks"` in the document wins over the
// auto→pinned migration even though "tasks" is the default value.
let mut explicit = Settings {
sidebar_focus: "auto".to_string(),
rail_panel: "tasks".to_string(),
rail_panel_explicit: true,
..Settings::default()
};
migrate_sidebar_settings_to_rail(&mut explicit);
assert_eq!(explicit.rail_panel, "tasks");
// Placement panels keep their placement when the rail hides.
let mut left = Settings {
sidebar_focus: "hidden".to_string(),
work_surface_placement: "left".to_string(),
..Settings::default()
};
migrate_sidebar_settings_to_rail(&mut left);
assert_eq!(left.work_surface_placement, "left");
}
#[test]
fn sidebar_auto_opt_in_marker_is_serialized_only_when_enabled() {
let default_body = toml::to_string_pretty(&Settings::default()).expect("serialize");
assert!(default_body.contains("sidebar_auto_collapse_opt_in = true"));
fn legacy_sidebar_width_maps_to_side_columns_and_new_keys_win() {
let mut settings = Settings {
sidebar_width_percent: 40,
..Settings::default()
};
migrate_sidebar_settings_to_rail(&mut settings);
assert_eq!(settings.work_surface_side_width, 48);
// The default percent leaves the default side width alone.
let mut settings = Settings::default();
settings
.set("sidebar_focus", "auto")
.expect("enable auto collapse");
migrate_sidebar_settings_to_rail(&mut settings);
assert_eq!(settings.work_surface_side_width, 30);
let auto_body = toml::to_string_pretty(&settings).expect("serialize");
assert!(auto_body.contains("sidebar_focus = \"auto\""));
assert!(auto_body.contains("sidebar_auto_collapse_opt_in = true"));
// An explicit rail panel wins over the migrated sidebar focus.
let mut settings = Settings {
sidebar_focus: "context".to_string(),
rail_panel: "agents".to_string(),
..Settings::default()
};
migrate_sidebar_settings_to_rail(&mut settings);
assert_eq!(settings.rail_panel, "agents");
}
#[test]
@@ -3462,68 +3569,6 @@ mod tests {
assert!(err.to_string().contains("invalid cost currency"));
}
#[test]
fn sidebar_focus_accepts_pinned_values_and_legacy_aliases() {
let mut settings = Settings::default();
settings.set("sidebar_focus", "pinned").expect("set pinned");
assert_eq!(settings.sidebar_focus, "pinned");
settings.set("sidebar_focus", "work").expect("set work");
assert_eq!(settings.sidebar_focus, "pinned");
settings.set("focus", "plan").expect("legacy plan alias");
assert_eq!(settings.sidebar_focus, "pinned");
settings.set("focus", "todos").expect("legacy todos alias");
assert_eq!(settings.sidebar_focus, "pinned");
settings.set("focus", "context").expect("context focus");
assert_eq!(settings.sidebar_focus, "context");
settings.set("focus", "hidden").expect("hidden focus");
assert_eq!(settings.sidebar_focus, "hidden");
settings.set("focus", "off").expect("off alias");
assert_eq!(settings.sidebar_focus, "hidden");
assert!(!settings.sidebar_auto_collapse_opt_in);
settings.set("focus", "auto").expect("auto focus");
assert_eq!(settings.sidebar_focus, "auto");
assert!(settings.sidebar_auto_collapse_opt_in);
settings
.set("focus", "visible")
.expect("pinned alias clears auto marker");
assert_eq!(settings.sidebar_focus, "pinned");
assert!(!settings.sidebar_auto_collapse_opt_in);
// Activity is the user-facing panel name; config key remains "tasks" (#4135).
settings
.set("focus", "activity")
.expect("activity alias for Activity panel");
assert_eq!(settings.sidebar_focus, "tasks");
settings.set("focus", "live").expect("live alias");
assert_eq!(settings.sidebar_focus, "tasks");
// Sessions panel (#2934).
settings.set("focus", "sessions").expect("sessions focus");
assert_eq!(settings.sidebar_focus, "sessions");
settings
.set("focus", "sessions_rail")
.expect("sessions_rail alias");
assert_eq!(settings.sidebar_focus, "sessions");
let err = settings
.set("sidebar_focus", "classic")
.expect_err("classic is not a supported public focus");
assert!(err.to_string().contains("invalid sidebar focus"));
assert!(
err.to_string().contains("activity (tasks)"),
"error should teach the Activity alias: {err}"
);
}
#[test]
fn context_panel_is_configurable() {
let mut settings = Settings::default();
@@ -4758,7 +4803,7 @@ mod tests {
}
#[test]
fn settings_load_migrates_legacy_saved_auto_sidebar_focus_to_pinned() {
fn settings_load_migrates_legacy_saved_auto_sidebar_focus_to_rail() {
let _g = config_path_test_guard();
let tmp = tempfile::tempdir().expect("tempdir");
let settings_path = tmp.path().join("settings.toml");
@@ -4768,27 +4813,24 @@ mod tests {
let loaded = Settings::load().expect("load settings");
assert_eq!(loaded.sidebar_focus, "pinned");
assert!(!loaded.sidebar_auto_collapse_opt_in);
// Auto-collapse is dropped: saved auto folds into the always-on
// pinned rail panel.
assert_eq!(loaded.rail_panel, "pinned");
assert_eq!(loaded.work_surface_placement, "top");
}
#[test]
fn settings_load_preserves_explicit_auto_sidebar_opt_in() {
fn settings_load_migrates_hidden_sidebar_to_rail_off() {
let _g = config_path_test_guard();
let tmp = tempfile::tempdir().expect("tempdir");
let settings_path = tmp.path().join("settings.toml");
std::fs::write(
&settings_path,
"sidebar_focus = \"auto\"\nsidebar_auto_collapse_opt_in = true\n",
)
.expect("settings");
std::fs::write(&settings_path, "sidebar_focus = \"hidden\"\n").expect("settings");
let _config_override =
EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
let loaded = Settings::load().expect("load settings");
assert_eq!(loaded.sidebar_focus, "auto");
assert!(loaded.sidebar_auto_collapse_opt_in);
assert_eq!(loaded.work_surface_placement, "off");
}
#[test]
-81
View File
@@ -292,21 +292,6 @@ pub struct TurnCacheRecord {
pub recorded_at: Instant,
}
/// Sidebar content focus mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SidebarFocus {
Auto,
Pinned,
Tasks,
Agents,
Context,
/// Dedicated Sessions browser panel (#2934). Renders the persistent
/// sessions rail as the sole sidebar content, regardless of whether the
/// `sessions_rail` setting is enabled. Focuses session navigation.
Sessions,
Hidden,
}
/// Browsing context captured when the `/model` picker is dismissed (#4109).
/// Plain data so `App` does not depend on the picker's internal view enum.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -482,36 +467,6 @@ impl Default for LspRepairState {
}
}
impl SidebarFocus {
#[must_use]
pub fn from_setting(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"pinned" | "visible" | "show" | "on" | "work" | "plan" | "todos" => Self::Pinned,
// Persist/compat key remains "tasks"; user-facing panel is Activity (#4147/#4135).
"tasks" | "activity" | "live" | "running" => Self::Tasks,
"agents" | "subagents" | "sub-agents" => Self::Agents,
"context" => Self::Context,
"sessions" | "sessions_rail" | "session_history" => Self::Sessions,
"hidden" | "hide" | "closed" | "off" | "none" => Self::Hidden,
_ => Self::Auto,
}
}
#[must_use]
#[allow(dead_code)]
pub fn as_setting(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Pinned => "pinned",
Self::Tasks => "tasks",
Self::Agents => "agents",
Self::Context => "context",
Self::Sessions => "sessions",
Self::Hidden => "hidden",
}
}
}
/// Pre-session launch menu state for the underwater shell.
///
/// This is deliberately separate from onboarding and from the post-launch
@@ -685,10 +640,6 @@ pub struct ViewportState {
/// Painted band occupied by the active inline approval. Stored so wheel
/// routing can prefer the visible card over side surfaces underneath it.
pub last_approval_area: Option<Rect>,
/// Outer rect of the right-hand sidebar (when visible), stored at render
/// time so mouse hit-testing can keep scroll events over the sidebar from
/// leaking into the transcript viewport.
pub last_sidebar_area: Option<Rect>,
/// WorkflowPanel rect above the composer (#4121), for mouse toggle/cancel.
pub last_workflow_panel_area: Option<Rect>,
pub last_workflow_cancel_area: Option<Rect>,
@@ -721,7 +672,6 @@ impl Default for ViewportState {
last_transcript_area: None,
last_composer_area: None,
last_approval_area: None,
last_sidebar_area: None,
last_workflow_panel_area: None,
last_workflow_cancel_area: None,
last_transcript_top: 0,
@@ -1396,8 +1346,6 @@ pub struct App {
/// Toggled by `/voice-control`.
pub voice_control_enabled: bool,
pub transcript_spacing: TranscriptSpacing,
pub sidebar_width_percent: u16,
pub sidebar_focus: SidebarFocus,
/// Sidebar hover state for mouse tooltip support.
pub sidebar_hover: SidebarHoverState,
/// Current hover tooltip text, if any.
@@ -1413,27 +1361,6 @@ pub struct App {
pub provider_picker_memory: Option<ProviderPickerMemory>,
/// Last known mouse position for tooltip placement.
pub last_mouse_pos: Option<(u16, u16)>,
/// Whether the user is currently dragging the sidebar resize handle.
pub sidebar_resizing: bool,
/// Whether the pointer is over the classic sidebar resize handle.
pub sidebar_resize_hovered: bool,
/// Mouse column at the start of a sidebar-resize drag.
pub sidebar_resize_anchor_x: u16,
/// Sidebar width in columns at the start of a sidebar-resize drag.
pub sidebar_resize_anchor_width: u16,
/// Last sidebar area rendered (for mouse hit-testing the resize handle).
pub last_sidebar_area: Option<Rect>,
/// Last total chat/sidebar width considered for sidebar rendering.
pub last_sidebar_host_width: Option<u16>,
/// Handle rect painted on the left edge of the sidebar (1 col).
pub last_sidebar_handle_area: Option<Rect>,
/// Total horizontal space (chat + sidebar) used to compute the percentage
/// during sidebar resize drag.
pub sidebar_resize_total_width: u16,
/// Sidebar width changed during this drag and needs persistence.
pub sidebar_width_dirty: bool,
/// Sidebar focus/hidden state changed and needs persistence.
pub sidebar_focus_dirty: bool,
/// Whether the session-context panel is enabled (#504).
pub context_panel: bool,
/// Whether the persistent Sessions rail is enabled (#2934). Opt-in.
@@ -4106,14 +4033,6 @@ impl App {
}
}
pub fn set_sidebar_focus(&mut self, focus: SidebarFocus) {
if self.sidebar_focus != focus {
self.sidebar_focus = focus;
self.sidebar_focus_dirty = true;
}
self.needs_redraw = true;
}
pub fn close_slash_menu(&mut self) {
self.slash_menu_hidden = true;
self.needs_redraw = true;
+9 -19
View File
@@ -241,8 +241,6 @@ impl App {
.trim()
.eq_ignore_ascii_case("vim");
let transcript_spacing = TranscriptSpacing::from_setting(&settings.transcript_spacing);
let sidebar_width_percent = settings.sidebar_width_percent;
let sidebar_focus = SidebarFocus::from_setting(&settings.sidebar_focus);
let max_input_history = settings.max_input_history;
let use_paste_burst_detection = settings.paste_burst_detection;
// Resolve the named theme from settings; unknown values were already
@@ -584,11 +582,15 @@ impl App {
selection_anchor: None,
},
viewport: ViewportState::default(),
work_surface: crate::tui::work_surface::WorkSurfaceState::with_layout(
work_surface_placement,
work_surface_top_height,
work_surface_side_width,
),
work_surface: {
let mut state = crate::tui::work_surface::WorkSurfaceState::with_layout(
work_surface_placement,
work_surface_top_height,
work_surface_side_width,
);
state.panel = crate::tui::work_surface::RailPanel::parse(&settings.rail_panel);
state
},
hunt: HuntState::default(),
session: SessionState::default(),
active_allowed_tools: None,
@@ -711,24 +713,12 @@ impl App {
voice_send_enabled: false,
voice_control_enabled: false,
transcript_spacing,
sidebar_width_percent,
sidebar_focus,
sidebar_hover: SidebarHoverState::default(),
sidebar_hover_tooltip: None,
cached_work_summary: None,
model_picker_memory: None,
provider_picker_memory: None,
last_mouse_pos: None,
sidebar_resizing: false,
sidebar_resize_hovered: false,
sidebar_resize_anchor_x: 0,
sidebar_resize_anchor_width: 0,
last_sidebar_area: None,
last_sidebar_host_width: None,
last_sidebar_handle_area: None,
sidebar_resize_total_width: 0,
sidebar_width_dirty: false,
sidebar_focus_dirty: false,
context_panel: settings.context_panel,
sessions_rail: settings.sessions_rail,
sessions_rail_cache: None,
+6 -35
View File
@@ -1682,38 +1682,6 @@ fn cny_cache_savings_falls_back_to_usd_for_usd_only_models() {
assert!((savings - 0.79).abs() < 1e-9, "got {savings}");
}
#[test]
fn sidebar_focus_accepts_pinned_and_maps_legacy_trackers_to_pinned() {
assert_eq!(SidebarFocus::from_setting("auto"), SidebarFocus::Auto);
assert_eq!(SidebarFocus::from_setting("pinned"), SidebarFocus::Pinned);
assert_eq!(SidebarFocus::from_setting("work"), SidebarFocus::Pinned);
assert_eq!(SidebarFocus::from_setting("plan"), SidebarFocus::Pinned);
assert_eq!(SidebarFocus::from_setting("todos"), SidebarFocus::Pinned);
assert_eq!(SidebarFocus::from_setting("tasks"), SidebarFocus::Tasks);
assert_eq!(SidebarFocus::from_setting("activity"), SidebarFocus::Tasks);
assert_eq!(SidebarFocus::from_setting("live"), SidebarFocus::Tasks);
assert_eq!(SidebarFocus::from_setting("running"), SidebarFocus::Tasks);
assert_eq!(SidebarFocus::from_setting("agents"), SidebarFocus::Agents);
assert_eq!(SidebarFocus::from_setting("context"), SidebarFocus::Context);
assert_eq!(
SidebarFocus::from_setting("sessions"),
SidebarFocus::Sessions
);
assert_eq!(
SidebarFocus::from_setting("sessions_rail"),
SidebarFocus::Sessions
);
assert_eq!(
SidebarFocus::from_setting("session_history"),
SidebarFocus::Sessions
);
assert_eq!(SidebarFocus::from_setting("hidden"), SidebarFocus::Hidden);
assert_eq!(SidebarFocus::from_setting("off"), SidebarFocus::Hidden);
assert_eq!(SidebarFocus::Pinned.as_setting(), "pinned");
assert_eq!(SidebarFocus::Hidden.as_setting(), "hidden");
assert_eq!(SidebarFocus::Sessions.as_setting(), "sessions");
}
#[test]
fn slash_command_classifier_treats_absolute_path_as_message() {
assert!(looks_like_slash_command_input("/"));
@@ -2876,12 +2844,15 @@ fn clear_todos_is_atomic_and_invalidates_cached_work_summary() {
}
#[test]
fn entering_operate_preserves_user_sidebar_focus() {
fn entering_operate_preserves_user_rail_panel() {
let mut app = App::new(test_options(false), &Config::default());
app.sidebar_focus = SidebarFocus::Tasks;
app.work_surface.panel = crate::tui::work_surface::RailPanel::Agents;
assert!(app.set_mode(AppMode::Operate));
assert_eq!(app.sidebar_focus, SidebarFocus::Tasks);
assert_eq!(
app.work_surface.panel,
crate::tui::work_surface::RailPanel::Agents
);
}
#[test]
+4 -3
View File
@@ -385,10 +385,11 @@ mod tests {
let options = TuiOptions {
..crate::test_support::test_tui_options(PathBuf::from("."))
};
// Pin sidebar so dogfood machines with Agents-visible settings.toml
// do not hide the footer agents chip this test asserts.
// Pin the rail panel so dogfood machines whose settings.toml migrates
// a legacy Agents sidebar focus do not hide the footer agents chip
// this test asserts.
let mut app = App::new(options, &Config::default());
app.sidebar_focus = crate::tui::app::SidebarFocus::Hidden;
app.work_surface.panel = crate::tui::work_surface::RailPanel::Tasks;
app
}
+24 -12
View File
@@ -8,7 +8,7 @@ use crate::commands::{self, CommandInfo, CommandResult};
use crate::config::{ApiProvider, Config};
use crate::localization::{Locale, MessageId, tr};
use crate::provider_lake::all_catalog_models_for_provider;
use crate::tui::app::{App, AppAction, AppMode, SidebarFocus};
use crate::tui::app::{App, AppAction, AppMode};
use crate::tui::command_palette::{
CommandPaletteView, build_entries as build_command_palette_entries,
};
@@ -985,7 +985,9 @@ impl HotbarAction for AppHotbarAction {
AppHotbarKind::ReasoningCycle => {
app.reasoning_effort != crate::tui::app::ReasoningEffort::Off
}
AppHotbarKind::SidebarToggle => app.sidebar_focus != SidebarFocus::Hidden,
AppHotbarKind::SidebarToggle => {
app.work_surface.placement != crate::tui::work_surface::WorkSurfacePlacement::Off
}
AppHotbarKind::FileTreeToggle => app.file_tree.is_some(),
AppHotbarKind::PaletteOpen => false,
AppHotbarKind::TrustToggle => app.trust_mode,
@@ -1030,13 +1032,17 @@ impl HotbarAction for AppHotbarAction {
}
}
AppHotbarKind::SidebarToggle => {
if app.sidebar_focus == SidebarFocus::Hidden {
app.set_sidebar_focus(SidebarFocus::Pinned);
app.status_message = Some("Sidebar focus: pinned".to_string());
if app.work_surface.placement == crate::tui::work_surface::WorkSurfacePlacement::Off
{
app.work_surface.placement =
crate::tui::work_surface::WorkSurfacePlacement::Top;
app.status_message = Some("Rail: top placement".to_string());
} else {
app.set_sidebar_focus(SidebarFocus::Hidden);
app.status_message = Some("Sidebar hidden".to_string());
app.work_surface.placement =
crate::tui::work_surface::WorkSurfacePlacement::Off;
app.status_message = Some("Rail is off".to_string());
}
app.needs_redraw = true;
Ok(HotbarDispatch::Handled)
}
AppHotbarKind::FileTreeToggle => {
@@ -2547,18 +2553,24 @@ mod tests {
let registry = HotbarActionRegistry::with_builtins();
let sidebar = registry.get("sidebar.toggle").expect("sidebar action");
let mut app = test_app();
app.sidebar_focus = SidebarFocus::Pinned;
app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Top;
assert!(sidebar.is_active(&app));
assert_eq!(
sidebar.dispatch(&mut app).expect("dispatch sidebar hide"),
sidebar.dispatch(&mut app).expect("dispatch rail hide"),
HotbarDispatch::Handled
);
assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);
assert_eq!(
app.work_surface.placement,
crate::tui::work_surface::WorkSurfacePlacement::Off
);
assert!(!sidebar.is_active(&app));
sidebar.dispatch(&mut app).expect("dispatch sidebar show");
assert_eq!(app.sidebar_focus, SidebarFocus::Pinned);
sidebar.dispatch(&mut app).expect("dispatch rail show");
assert_eq!(
app.work_surface.placement,
crate::tui::work_surface::WorkSurfacePlacement::Top
);
assert!(sidebar.is_active(&app));
}
+8 -82
View File
@@ -44,7 +44,6 @@ pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) ->
// these events wedges the resize state mid-drag (#3063).
!app.viewport.transcript_selection.dragging
&& !app.viewport.transcript_scrollbar_dragging
&& !app.sidebar_resizing
&& !app.work_surface.is_resizing()
}
_ => false,
@@ -65,56 +64,6 @@ fn toggle_tool_run_expand(app: &mut App, mouse: MouseEvent) -> bool {
app.toggle_tool_run_expansion_at(original_idx)
}
/// Handle mouse events on the sidebar resize handle (the 1-col vertical bar
/// between the chat area and the sidebar). Returns true when the event was
/// consumed so other handlers skip it.
fn handle_sidebar_resize_mouse(app: &mut App, mouse: MouseEvent) -> bool {
let Some(handle) = app.last_sidebar_handle_area else {
return false;
};
let hit = mouse.column == handle.x
&& mouse.row >= handle.y
&& mouse.row < handle.y.saturating_add(handle.height);
match mouse.kind {
MouseEventKind::Moved => {
if app.sidebar_resize_hovered != hit {
app.sidebar_resize_hovered = hit;
app.needs_redraw = true;
}
false
}
MouseEventKind::Down(MouseButton::Left) if hit => {
app.sidebar_resizing = true;
app.sidebar_resize_hovered = true;
app.sidebar_resize_anchor_x = mouse.column;
app.sidebar_resize_anchor_width = app.last_sidebar_area.map(|a| a.width).unwrap_or(28);
app.needs_redraw = true;
true
}
MouseEventKind::Drag(MouseButton::Left) if app.sidebar_resizing => {
let delta = app.sidebar_resize_anchor_x as i32 - mouse.column as i32;
let new_width = (app.sidebar_resize_anchor_width as i32 + delta).max(24) as u16;
let total = app.sidebar_resize_total_width.max(1);
let new_pct = ((new_width as u32 * 100) / total as u32).clamp(10, 50) as u16;
if new_pct != app.sidebar_width_percent {
app.sidebar_width_percent = new_pct;
app.needs_redraw = true;
}
true
}
MouseEventKind::Up(MouseButton::Left) if app.sidebar_resizing => {
app.sidebar_resizing = false;
app.sidebar_resize_hovered = hit;
app.sidebar_width_dirty = true;
app.needs_redraw = true;
true
}
_ => false,
}
}
/// Map a mouse (column, row) within the composer area to a char index
/// in the composer input string. Uses the canonical prompt-adjusted text rect
/// for coordinate mapping, and accounts for vertical padding and scroll offset.
@@ -348,8 +297,7 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
// Other modals still own their wheel input exclusively (#4371).
if app.view_stack.top_kind() == Some(ModalKind::Approval) {
let over_approval = mouse_hits_rect(mouse, app.viewport.last_approval_area);
let over_side_surface = mouse_hits_rect(mouse, app.viewport.last_sidebar_area)
|| mouse_hits_rect(mouse, app.work_surface.last_area);
let over_side_surface = mouse_hits_rect(mouse, app.work_surface.last_area);
match mouse.kind {
MouseEventKind::ScrollUp => {
if over_approval || !over_side_surface {
@@ -413,12 +361,6 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
return Vec::new();
}
// Sidebar resize handle — check before composer so it doesn't compete
// with text selection / scrolling.
if handle_sidebar_resize_mouse(app, mouse) {
return Vec::new();
}
// Ocean work surface owns its rect, scrolling, focus, and row actions.
// Route it before workflow/composer/transcript so wheel events never leak
// into an unrelated viewport.
@@ -441,22 +383,6 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
return Vec::new();
}
// Scroll events while the cursor is over the right-hand sidebar must not
// drive the transcript scroll. The sidebar is a fixed dashboard with no
// scroll state of its own, so consume the wheel event instead of leaking
// it into the transcript viewport behind it.
if matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
) && app.viewport.last_sidebar_area.is_some_and(|area| {
mouse.column >= area.x
&& mouse.column < area.x.saturating_add(area.width)
&& mouse.row >= area.y
&& mouse.row < area.y.saturating_add(area.height)
}) {
return Vec::new();
}
match mouse.kind {
MouseEventKind::Moved => {
// Update last mouse position for tooltip rendering + hover layer.
@@ -1163,7 +1089,7 @@ pub(crate) fn open_context_menu(app: &mut App, mouse: MouseEvent) {
pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec<ContextMenuEntry> {
let mut entries = Vec::new();
let mut git_path = None;
let on_sidebar = mouse_hits_rect(mouse, app.viewport.last_sidebar_area);
let on_sidebar = mouse_hits_rect(mouse, app.work_surface.last_area);
if on_sidebar {
if let Some(command) = sidebar_click_action(app, mouse)
@@ -1736,7 +1662,7 @@ mod tests {
#[test]
fn context_menu_keeps_paste_first_outside_sidebar() {
let mut app = create_test_app();
app.viewport.last_sidebar_area = Some(Rect::new(60, 4, 20, 6));
app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
let entries = build_context_menu_entries(&app, right_click(10, 4));
@@ -1749,7 +1675,7 @@ mod tests {
#[test]
fn sidebar_context_menu_omits_paste_without_row_action() {
let mut app = create_test_app();
app.viewport.last_sidebar_area = Some(Rect::new(60, 4, 20, 6));
app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
app.sidebar_hover.sections.push(SidebarHoverSection {
content_area: Rect::new(60, 4, 20, 6),
lines: vec!["header".to_string()],
@@ -1769,7 +1695,7 @@ mod tests {
#[test]
fn sidebar_context_menu_runs_clickable_row_action() {
let mut app = create_test_app();
app.viewport.last_sidebar_area = Some(Rect::new(60, 4, 20, 6));
app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
app.sidebar_hover.sections.push(SidebarHoverSection {
content_area: Rect::new(60, 4, 20, 6),
lines: vec!["job row".to_string()],
@@ -1849,7 +1775,7 @@ mod tests {
#[test]
fn sidebar_click_routes_inline_stop_zone_before_row_action() {
let mut app = create_test_app();
app.viewport.last_sidebar_area = Some(Rect::new(60, 4, 20, 4));
app.work_surface.last_area = Some(Rect::new(60, 4, 20, 4));
app.sidebar_hover.sections.push(SidebarHoverSection {
content_area: Rect::new(60, 4, 20, 4),
lines: vec!["job row [x]".to_string()],
@@ -1875,7 +1801,7 @@ mod tests {
#[test]
fn sidebar_click_routes_agent_inline_stop_zone_before_peek_action() {
let mut app = create_test_app();
app.viewport.last_sidebar_area = Some(Rect::new(60, 4, 24, 4));
app.work_surface.last_area = Some(Rect::new(60, 4, 24, 4));
app.sidebar_hover.sections.push(SidebarHoverSection {
content_area: Rect::new(60, 4, 24, 4),
lines: vec!["[~] worker Agent 1 [x]".to_string()],
@@ -1910,7 +1836,7 @@ mod tests {
#[test]
fn sidebar_context_menu_offers_copy_of_hovered_row() {
let mut app = create_test_app();
app.viewport.last_sidebar_area = Some(Rect::new(60, 4, 20, 6));
app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
app.sidebar_hover.sections.push(SidebarHoverSection {
content_area: Rect::new(60, 4, 20, 6),
lines: vec!["agent row".to_string()],
+2 -19
View File
@@ -23,10 +23,6 @@ pub enum OceanTreatment {
Ombre,
/// Plain theme surface with the same state grammar and ambient life.
Flat,
/// Legacy full-chrome compatibility shell. Persisted settings normalize
/// unknown values to ombre, so this is reachable only through explicit
/// internal selection (tests and future compatibility wiring).
Classic,
}
impl OceanTreatment {
@@ -35,9 +31,9 @@ impl OceanTreatment {
let value = value.trim();
if value.eq_ignore_ascii_case("flat") {
Self::Flat
} else if value.eq_ignore_ascii_case("classic") {
Self::Classic
} else {
// Migration shim: the legacy "classic" shell was removed in 0.9.4;
// persisted settings carrying it load as the default ombre.
Self::Ombre
}
}
@@ -51,19 +47,6 @@ impl OceanTreatment {
pub fn is_flat(self) -> bool {
self == Self::Flat
}
#[must_use]
pub fn is_classic(self) -> bool {
self == Self::Classic
}
/// Every underwater treatment keeps idle ambient life; only the legacy
/// classic shell stays still. Flat means a plain surface, not a lifeless
/// ocean, and Terminal-owned backgrounds still carry foreground life.
#[must_use]
pub fn supports_ambient_life(self) -> bool {
!self.is_classic()
}
}
/// Minimum empty-water size that earns decorative ambient life. Below this,
+10 -4
View File
@@ -156,17 +156,23 @@ fn every_shipped_theme_has_an_intentional_ocean_treatment() {
fn treatment_parses_saved_values_and_defaults_to_ombre() {
assert_eq!(OceanTreatment::parse("flat"), OceanTreatment::Flat);
assert_eq!(OceanTreatment::parse(" FLAT "), OceanTreatment::Flat);
assert_eq!(OceanTreatment::parse("classic"), OceanTreatment::Classic);
assert_eq!(OceanTreatment::parse("ombre"), OceanTreatment::Ombre);
assert_eq!(OceanTreatment::parse("kelp"), OceanTreatment::Ombre);
assert_eq!(OceanTreatment::parse(""), OceanTreatment::Ombre);
// Migration shim: settings saved by pre-0.9.4 builds may still carry the
// removed classic shell; they load as the default ombre treatment.
assert_eq!(OceanTreatment::parse("classic"), OceanTreatment::Ombre);
}
#[test]
fn every_underwater_treatment_keeps_ambient_life() {
assert!(OceanTreatment::Ombre.supports_ambient_life());
assert!(OceanTreatment::Flat.supports_ambient_life());
assert!(!OceanTreatment::Classic.supports_ambient_life());
// The classic shell was the only treatment that stilled ambient life; with
// it removed there is no per-treatment ambient-life flag left to test.
// What remains worth pinning: both live treatments stay distinct so the
// flat/ombre choice keeps its meaning.
assert_ne!(OceanTreatment::Ombre, OceanTreatment::Flat);
assert!(OceanTreatment::Ombre.is_ombre());
assert!(OceanTreatment::Flat.is_flat());
}
#[test]
File diff suppressed because it is too large Load Diff
+5 -15
View File
@@ -9,7 +9,7 @@ use crate::tools::subagent::{
};
use crate::tui::app::{
AgentCurrentActivity, AgentCurrentActivityStatus, AgentProgressMeta, AgentRecentAction, App,
AppMode, MAX_AGENT_RECENT_ACTIONS, SidebarFocus, TaskPanelEntry, TaskPanelEntryKind,
AppMode, MAX_AGENT_RECENT_ACTIONS, TaskPanelEntry, TaskPanelEntryKind,
bound_agent_activity_text,
};
use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output};
@@ -23,13 +23,6 @@ use crate::tui::workspace_context;
const SUBAGENT_TERMINAL_CARD_TTL: Duration = Duration::from_secs(5 * 60);
const SUBAGENT_TERMINAL_CARD_MAX_RETAINED: usize = 24;
fn agents_panel_has_content(app: &App) -> bool {
!app.subagent_cache.is_empty()
|| !app.agent_progress.is_empty()
|| active_fanout_counts(app).is_some()
|| foreground_rlm_running(app)
}
fn foreground_rlm_running(app: &App) -> bool {
use crate::tui::history::{HistoryCell, ToolCell, ToolStatus};
app.active_cell.as_ref().is_some_and(|active| {
@@ -46,14 +39,11 @@ fn foreground_rlm_running(app: &App) -> bool {
})
}
/// True when the Agents sidebar panel is on-screen and already owns fanout summary.
/// True when the rail's Agents panel is on-screen and already owns fanout
/// summary, so the footer chip would be redundant.
pub(super) fn agents_sidebar_surface_visible(app: &App) -> bool {
match app.sidebar_focus {
SidebarFocus::Hidden => false,
SidebarFocus::Agents => true,
SidebarFocus::Auto => agents_panel_has_content(app),
_ => false,
}
app.work_surface.panel == crate::tui::work_surface::RailPanel::Agents
&& app.work_surface.last_area.is_some()
}
pub(super) fn running_agent_count(app: &App) -> usize {
+67 -350
View File
@@ -35,7 +35,6 @@ use crossterm::{
};
use ratatui::{
Frame, Terminal,
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect, Size},
prelude::Widget,
style::Style,
@@ -93,9 +92,7 @@ use crate::tui::context_inspector::ContextInspectorView;
use crate::tui::event_broker::EventBroker;
use crate::tui::file_mention::ContextReference;
use crate::tui::file_picker_relevance;
use crate::tui::footer_ui::{
friendly_subagent_progress, is_noisy_subagent_progress, render_footer,
};
use crate::tui::footer_ui::{friendly_subagent_progress, is_noisy_subagent_progress};
use crate::tui::format_helpers;
use crate::tui::hotbar::actions::HotbarDispatch;
use crate::tui::key_shortcuts;
@@ -107,7 +104,6 @@ use crate::tui::onboarding;
use crate::tui::pager::PagerView;
use crate::tui::persistence_actor::{self, PersistRequest};
use crate::tui::scrolling::TranscriptScroll;
use crate::tui::sidebar::sidebar_width_for_chat_area;
use crate::turn_route_plan::{PlannedTurnRoute, TurnRoutePlanRequest, plan_turn_route};
use crate::work_graph::task_owner_snapshot;
// SelectionAutoscroll unused
@@ -142,10 +138,10 @@ use super::key_actions;
use super::app::{
ActiveTurnMetadata, AgentCurrentActivity, AgentCurrentActivityStatus, App, AppAction, AppMode,
ComposerSubmitAction, ComposerSubmitChord, EffectiveReasoningEffort, HuntVerdict,
OnboardingState, PendingProviderSwitch, QueuedMessage, ReasoningEffort, SidebarFocus,
StatusToast, StatusToastLevel, SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind,
ToolEvidence, TuiOptions, bound_agent_activity_text, is_stop_word,
looks_like_slash_command_input, shell_command_from_bang_input,
OnboardingState, PendingProviderSwitch, QueuedMessage, ReasoningEffort, StatusToast,
StatusToastLevel, SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind, ToolEvidence,
TuiOptions, bound_agent_activity_text, is_stop_word, looks_like_slash_command_input,
shell_command_from_bang_input,
};
use super::approval::{
ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision,
@@ -159,7 +155,7 @@ use super::slash_menu::{
};
use super::views::{ConfigView, ContextMenuAction, HelpView, ModalKind, ViewEvent};
use super::widgets::pending_input_preview::{ContextPreviewItem, PendingInputPreview};
use super::widgets::{ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Renderable};
use super::widgets::{ChatWidget, ComposerWidget, Renderable};
// Activity Detail / raw-detail / pager-text helpers extracted into `activity_detail`
// (issue #4103). Re-export the cross-module entry points so existing
@@ -214,14 +210,16 @@ const TOOL_HANG_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(600);
// braille pattern reads as continuous motion instead of teleport-frames.
const UI_STATUS_ANIMATION_MS: u64 = crate::tui::spinner::BRAILLE_SPINNER_FRAME_MS;
/// Ambient fish, the idle-mark caustic, and the completion wake use a modest
/// ~8fps clock by default (calmed from ~12.5fps for v0.9.4). On measured
/// high-Hz displays the adaptive probe may raise this (still bounded);
/// low_motion always freezes the cadence.
/// Active markers run at 5fps; atmosphere stays subordinate.
pub(crate) const UI_UNDERWATER_ANIMATION_MS: u64 = 120;
// At an 80-column terminal the file tree owns 20 columns, leaving a 60-column
// chat host. Keep a compact 20-column sidebar plus a 40-column transcript.
pub(crate) const SIDEBAR_VISIBLE_MIN_WIDTH: u16 = 60;
/// ~12.5fps clock by default. On measured high-Hz displays the adaptive probe
/// may raise this (still bounded); low_motion always freezes the cadence.
/// Active markers run at 8fps; atmosphere stays subordinate.
pub(crate) const UI_UNDERWATER_ANIMATION_MS: u64 = 80;
// Minimum chat-host width at which the file-tree pane renders. At an
// 80-column terminal the file tree owns 20 columns, leaving a 60-column chat
// host; below this floor the tree is hidden rather than squeezing the
// transcript under 40 columns. (Named for the file tree — the legacy sidebar
// this constant once described no longer gates on it.)
pub(crate) const FILE_TREE_MIN_HOST_WIDTH: u16 = 60;
const DEFAULT_TERMINAL_PROBE_TIMEOUT_MS: u64 = 500;
const TURN_META_PREFIX: &str = "<turn_meta>";
const SESSION_TITLE_MAX_CHARS: usize = 32;
@@ -366,51 +364,6 @@ fn should_suppress_user_input_prompt(app: &App) -> bool {
!crate::core::authority::permission_posture_allows_questions(effective_posture)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SidebarRenderState {
Hidden,
SuppressedByWidth {
available_width: u16,
min_width: u16,
},
AutoCollapsed,
Visible,
}
pub(crate) fn sidebar_render_state(app: &mut App) -> SidebarRenderState {
if app.sidebar_focus == SidebarFocus::Hidden {
return SidebarRenderState::Hidden;
}
if let Some(available_width) = sidebar_host_width_hint(app)
&& available_width < SIDEBAR_VISIBLE_MIN_WIDTH
{
return SidebarRenderState::SuppressedByWidth {
available_width,
min_width: SIDEBAR_VISIBLE_MIN_WIDTH,
};
}
if crate::tui::sidebar::sidebar_auto_idle(app) {
return SidebarRenderState::AutoCollapsed;
}
SidebarRenderState::Visible
}
fn sidebar_host_width_hint(app: &App) -> Option<u16> {
app.last_sidebar_host_width.or_else(|| {
let transcript_width = app.viewport.last_transcript_area.map(|area| area.width)?;
let sidebar_width = app
.viewport
.last_sidebar_area
.or(app.last_sidebar_area)
.map(|area| area.width)
.unwrap_or(0);
Some(transcript_width.saturating_add(sidebar_width))
})
}
type AppTerminal = Terminal<ColorCompatBackend<Stdout>>;
type PendingToolUses = Vec<(String, String, serde_json::Value)>;
@@ -5749,7 +5702,6 @@ async fn run_event_loop(
}
}
}
persist_sidebar_settings_if_dirty(app);
continue;
}
@@ -6260,12 +6212,13 @@ async fn run_event_loop(
continue;
}
// y / Y in the Activity sidebar: yank the current turn id (y)
// y / Y in the rail's Tasks panel: yank the current turn id (y)
// or copy full task detail (Y) to the system clipboard.
// Only active when the composer is empty to avoid stealing
// keystrokes from typed input (#2000).
if app.view_stack.is_empty()
&& app.sidebar_focus == SidebarFocus::Tasks
&& app.work_surface.panel == crate::tui::work_surface::RailPanel::Tasks
&& app.work_surface.last_area.is_some()
&& app.input.is_empty()
&& !app.runtime_turn_id.as_deref().unwrap_or("").is_empty()
{
@@ -6413,7 +6366,6 @@ async fn run_event_loop(
{
return Ok(());
}
persist_sidebar_settings_if_dirty(app);
continue;
}
@@ -6634,24 +6586,21 @@ async fn run_event_loop(
if key.modifiers.contains(KeyModifiers::ALT)
&& key_shortcuts::has_control_like_modifier(key.modifiers) =>
{
app.set_sidebar_focus(SidebarFocus::Pinned);
app.status_message = Some("Sidebar focus: pinned".to_string());
rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Tasks);
continue;
}
KeyCode::Char('2')
if key.modifiers.contains(KeyModifiers::ALT)
&& key_shortcuts::has_control_like_modifier(key.modifiers) =>
{
app.set_sidebar_focus(SidebarFocus::Tasks);
app.status_message = Some("Sidebar focus: activity".to_string());
rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Agents);
continue;
}
KeyCode::Char('3')
if key.modifiers.contains(KeyModifiers::ALT)
&& key_shortcuts::has_control_like_modifier(key.modifiers) =>
{
app.set_sidebar_focus(SidebarFocus::Agents);
app.status_message = Some("Sidebar focus: agents".to_string());
rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Context);
continue;
}
KeyCode::Char('4')
@@ -6661,15 +6610,7 @@ async fn run_event_loop(
apply_alt_4_shortcut(app, key.modifiers);
continue;
}
KeyCode::Char('5')
if key.modifiers.contains(KeyModifiers::ALT)
&& key_shortcuts::has_control_like_modifier(key.modifiers) =>
{
app.set_sidebar_focus(SidebarFocus::Sessions);
app.status_message = Some("Sidebar focus: sessions".to_string());
continue;
}
// Sidebar focus via Alt+! / Alt+@ / Alt+# / Alt+$ / Alt+%)
// Rail panel selection via Alt+! / Alt+@ / Alt+# / Alt+$ / Alt+%
// AltGr on European keyboards emits Ctrl+Alt on Windows, so
// exclude Ctrl to avoid swallowing AltGr-typed characters
// like @ (AltGr+0 on French AZERTY) and # (AltGr+3). This
@@ -6679,51 +6620,34 @@ async fn run_event_loop(
if key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::CONTROL) =>
{
app.set_sidebar_focus(SidebarFocus::Pinned);
app.status_message = Some("Sidebar focus: pinned".to_string());
rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Tasks);
continue;
}
KeyCode::Char('@')
if key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::CONTROL) =>
{
app.set_sidebar_focus(SidebarFocus::Tasks);
app.status_message = Some("Sidebar focus: activity".to_string());
rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Agents);
continue;
}
KeyCode::Char('#')
if key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::CONTROL) =>
{
app.set_sidebar_focus(SidebarFocus::Agents);
app.status_message = Some("Sidebar focus: agents".to_string());
rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Context);
continue;
}
KeyCode::Char('$') | KeyCode::Char('%')
if key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::CONTROL) =>
{
app.set_sidebar_focus(SidebarFocus::Context);
app.status_message = Some("Sidebar focus: context".to_string());
rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Pinned);
continue;
}
KeyCode::Char('^')
KeyCode::Char('0')
if key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::CONTROL) =>
&& key.modifiers.contains(KeyModifiers::CONTROL) =>
{
app.set_sidebar_focus(SidebarFocus::Sessions);
app.status_message = Some("Sidebar focus: sessions".to_string());
continue;
}
KeyCode::Char(')')
if key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::CONTROL) =>
{
app.set_sidebar_focus(SidebarFocus::Auto);
app.status_message = Some("Sidebar focus: auto".to_string());
continue;
}
KeyCode::Char('0') if key.modifiers.contains(KeyModifiers::ALT) => {
apply_alt_0_shortcut(app, key.modifiers);
continue;
}
@@ -7827,46 +7751,36 @@ fn dispatch_hotbar_slot(
action.dispatch(app).map(Some)
}
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());
/// Select a rail panel from a keyboard shortcut and say what happened.
/// When the rail is off the panel change is real but invisible, so the
/// status names that instead of implying something rendered.
fn rail_panel_shortcut(app: &mut App, panel: crate::tui::work_surface::RailPanel) {
app.work_surface.panel = panel;
app.needs_redraw = true;
let mut message = format!("Rail panel: {}", panel.as_setting());
if app.work_surface.placement == crate::tui::work_surface::WorkSurfacePlacement::Off {
message.push_str(" (rail is off — /rail top to show)");
}
app.status_message = Some(message);
}
fn persist_sidebar_settings_if_dirty(app: &mut App) {
if !app.sidebar_width_dirty && !app.sidebar_focus_dirty {
return;
}
let width_dirty = app.sidebar_width_dirty;
let focus_dirty = app.sidebar_focus_dirty;
app.sidebar_width_dirty = false;
app.sidebar_focus_dirty = false;
let width_percent = app.sidebar_width_percent;
let focus_setting = app.sidebar_focus.as_setting();
let _ = Settings::transact(|settings| {
if width_dirty {
settings.update_sidebar_width(width_percent);
}
if focus_dirty {
let _ = settings.set("sidebar_focus", focus_setting);
}
Ok(())
});
fn apply_alt_4_shortcut(app: &mut App, _modifiers: KeyModifiers) {
rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Pinned);
}
fn apply_alt_0_shortcut(app: &mut App, modifiers: KeyModifiers) {
// Ctrl+Alt+0 toggles the rail off and back to the default top
// placement. Plain Alt+0 is unbound: it used to select the retired
// auto-collapse mode.
if modifiers.contains(KeyModifiers::CONTROL) {
if app.sidebar_focus == SidebarFocus::Hidden {
app.set_sidebar_focus(SidebarFocus::Pinned);
app.status_message = Some("Sidebar focus: pinned".to_string());
if app.work_surface.placement == crate::tui::work_surface::WorkSurfacePlacement::Off {
app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Top;
app.status_message = Some("Rail: top placement".to_string());
} else {
app.set_sidebar_focus(SidebarFocus::Hidden);
app.status_message = Some("Sidebar hidden".to_string());
app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Off;
app.status_message = Some("Rail is off".to_string());
}
} else {
app.set_sidebar_focus(SidebarFocus::Auto);
app.status_message = Some("Sidebar focus: auto".to_string());
app.needs_redraw = true;
}
}
@@ -13731,66 +13645,8 @@ fn build_pending_input_preview(app: &App) -> PendingInputPreview {
preview
}
fn classic_header_indicator_started_at(app: &App) -> Option<Instant> {
app.motion_policy()
.allows_status_spin()
.then_some(app.turn_started_at)
.flatten()
}
fn render_classic_header(area: Rect, buf: &mut Buffer, app: &App) {
let context_usage = context_usage_snapshot(app);
let context_window = context_usage.as_ref().map(|(_, max, _)| *max).or_else(|| {
Some(crate::route_budget::route_context_window_tokens(
app.api_provider,
app.effective_model_for_budget(),
app.active_route_limits,
))
});
let prompt_tokens = context_usage
.as_ref()
.and_then(|(used, _, _)| u32::try_from(*used).ok());
let workspace = app
.workspace
.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
.unwrap_or("workspace");
let model = app.model_display_label();
let effort = app.reasoning_effort_display_label();
let started_at = classic_header_indicator_started_at(app);
let workflow_chip = app
.workflow_panel
.as_ref()
.map(super::widgets::workflow_panel::WorkflowPanel::top_bar_chip);
let data = HeaderData::new(
app.mode,
&model,
workspace,
app.is_loading,
app.ui_theme.header_bg,
)
.with_usage(
app.session.total_conversation_tokens,
context_window,
app.session.session_cost,
prompt_tokens,
)
.with_reasoning_effort(Some(&effort))
.with_provider(None)
.with_status_indicator(crate::tui::widgets::header_status_indicator_frame(
started_at,
&app.status_indicator,
))
.with_running_agents(running_agent_count(app))
.with_workflow_status(workflow_chip.as_deref())
.with_update_available(app.update_available.as_deref());
HeaderWidget::new(data).render(area, buf);
}
fn render(f: &mut Frame, app: &mut App, config: &Config) {
fn render(f: &mut Frame, app: &mut App, _config: &Config) {
let size = f.area();
let classic_shell = app.ocean_treatment.is_classic();
// Keep the view stack's focus-context texture prototype (#4823) in step
// with the parsed setting each frame: a plain enum/theme copy, no
// allocation. `Off` leaves the render byte-identical to before.
@@ -13832,11 +13688,7 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
return;
}
let header_height = if classic_shell || size.height < 16 {
1
} else {
2
};
let header_height = if size.height < 16 { 1 } else { 2 };
let footer_height = crate::tui::phase_strip::height();
let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
let mention_menu_limit = app.mention_menu_limit;
@@ -13845,8 +13697,7 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
if !mention_menu_entries.is_empty() && app.mention_menu_selected >= mention_menu_entries.len() {
app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
}
let top_work_strip_height =
super::work_surface::height(app, size.width, size.height, classic_shell);
let top_work_strip_height = super::work_surface::height(app, size.width, size.height);
// Defensive two-pass layout: pin the header to the absolute top row,
// then split the remaining body area for chat / preview / composer /
@@ -13909,11 +13760,10 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
// Ocean live phases put the phase strip above the composer so activity
// stays attached to the transcript and the prompt is the final bottom
// object. Idle/typing keep a quiet phase under the prompt. Classic keeps
// the legacy composer-then-footer stack.
// object. Idle/typing keep a quiet phase under the prompt.
let phase = crate::tui::underwater::ShellPhase::from_app(app);
let phase_above = !classic_shell
&& crate::tui::phase_strip::PhaseStripPlacement::for_phase(phase).is_above_composer();
let phase_above =
crate::tui::phase_strip::PhaseStripPlacement::for_phase(phase).is_above_composer();
let (composer_slot, footer_slot, tail_constraints) = if phase_above {
(
5,
@@ -13947,8 +13797,7 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
])
.split(body_area);
let (work_chat_area, side_work_area) =
super::work_surface::split_chat(app, body_chunks[1], classic_shell);
let (work_chat_area, side_work_area) = super::work_surface::split_chat(app, body_chunks[1]);
if top_work_strip_height > 0 {
super::work_surface::render(f, body_chunks[0], app);
@@ -13956,11 +13805,7 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
super::work_surface::render(f, work_area, app);
}
if classic_shell {
render_classic_header(header_area, f.buffer_mut(), app);
} else {
crate::tui::underwater::render_header(header_area, f.buffer_mut(), app);
}
crate::tui::underwater::render_header(header_area, f.buffer_mut(), app);
// Render the transcript and optional file-tree sidecar. The underwater
// default deliberately has no legacy right sidebar: Tasks and To-do own
@@ -13979,8 +13824,8 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
// When the file-tree pane is visible and the terminal is wide
// enough, reserve the left ~25% for the file tree.
let mut chat_area =
if app.file_tree.is_some() && work_chat_area.width >= SIDEBAR_VISIBLE_MIN_WIDTH {
let chat_area =
if app.file_tree.is_some() && work_chat_area.width >= FILE_TREE_MIN_HOST_WIDTH {
app.file_tree_visible = true;
let split = Layout::default()
.direction(Direction::Horizontal)
@@ -13999,61 +13844,12 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
app.file_tree_visible = false;
work_chat_area
};
app.last_sidebar_host_width = Some(chat_area.width);
let sidebar_area = if classic_shell
&& !crate::tui::sidebar::sidebar_auto_idle(app)
&& let Some(sidebar_width) = sidebar_width_for_chat_area(app, chat_area.width)
{
app.sidebar_resize_total_width = chat_area.width;
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(1), Constraint::Length(sidebar_width)])
.split(chat_area);
chat_area = split[0];
Some(split[1])
} else {
None
};
app.viewport.last_sidebar_area = sidebar_area;
if sidebar_area.is_none() {
app.last_sidebar_area = None;
app.last_sidebar_handle_area = None;
app.sidebar_resizing = false;
app.sidebar_resize_hovered = false;
app.sidebar_hover_tooltip = None;
}
app.sidebar_hover_tooltip = None;
let chat_widget = ChatWidget::new(app, chat_area).with_ocean_viewport(size);
shell_ocean = chat_widget.ocean_column();
let buf = f.buffer_mut();
chat_widget.render(chat_area, buf);
// The rejected shell remains available only as an explicitly selected
// compatibility treatment. It is never composed into the underwater
// default path.
if let Some(sidebar_area) = sidebar_area {
app.last_sidebar_area = Some(sidebar_area);
super::sidebar::render_sidebar(f, sidebar_area, app, config);
let handle_area = Rect {
x: sidebar_area.x,
y: sidebar_area.y,
width: 1,
height: sidebar_area.height,
};
app.last_sidebar_handle_area = Some(handle_area);
let handle_active = app.sidebar_resizing || app.sidebar_resize_hovered;
let handle_symbol = if handle_active { "\n" } else { "\n" };
let handle_color = if handle_active {
app.ui_theme.accent_primary
} else {
palette::TEXT_MUTED
};
let handle = ratatui::widgets::Paragraph::new(
handle_symbol.repeat(usize::from(handle_area.height)),
)
.style(Style::default().fg(handle_color));
f.render_widget(handle, handle_area);
}
}
// Workflow panel between chat and pending-input preview (#4121).
@@ -14158,11 +13954,7 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
f.set_cursor_position(cursor_pos);
}
if classic_shell {
render_footer(f, body_chunks[footer_slot], app);
} else {
crate::tui::underwater::render_footer(body_chunks[footer_slot], f.buffer_mut(), app);
}
crate::tui::underwater::render_footer(body_chunks[footer_slot], f.buffer_mut(), app);
// The underwater shell is one water column, not a stack of independently
// shaded panels. Continue the transcript's absolute-row ramp through each
@@ -14191,19 +13983,6 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
app.ui_theme.footer_bg,
);
}
// Toast stack overlay (#439): when multiple status toasts are queued,
// surface the older ones as a 1-2 line strip above the footer so a
// burst of events isn't collapsed to a single visible message.
if classic_shell {
render_toast_stack_overlay(
f,
size,
body_chunks[composer_slot],
body_chunks[footer_slot],
app,
);
}
// Decision card overlay (v0.8.43 truth-surface). When a decision card is
// active, render it centered on top of the transcript.
if let Some(ref card) = app.decision_card {
@@ -17588,64 +17367,6 @@ pub(crate) fn status_color(level: StatusToastLevel) -> ratatui::style::Color {
}
}
/// Maximum stacked toasts rendered above the footer (#439). The footer line
/// itself stays the most-recent; this overlay surfaces up to two older
/// queued toasts so a burst of status events isn't dropped silently.
const TOAST_STACK_MAX_VISIBLE: usize = 3;
/// Render up to `TOAST_STACK_MAX_VISIBLE - 1` *additional* toasts as an
/// overlay just above the footer when multiple are active. The most recent
/// toast continues to render in the footer line itself; this strip is for
/// the older entries the user would otherwise miss when statuses arrive in
/// bursts.
fn render_toast_stack_overlay(
f: &mut Frame,
full_area: Rect,
composer_area: Rect,
footer_area: Rect,
app: &mut App,
) {
let toasts = app.active_status_toasts(TOAST_STACK_MAX_VISIBLE);
if toasts.len() < 2 || footer_area.y == 0 {
return;
}
// Drop the most recent (rendered inline by the footer), keep the rest.
let extra = toasts.len() - 1;
let stack_height = extra.min(TOAST_STACK_MAX_VISIBLE - 1) as u16;
// Toast stack can only use space between composer and footer.
// Composer occupies rows [composer_area.y, composer_area.y + composer_area.height).
// Toast must start at or after row (composer_area.y + composer_area.height).
let composer_end = composer_area.y + composer_area.height;
let max_above = footer_area.y.saturating_sub(composer_end);
if stack_height == 0 || max_above == 0 {
return;
}
let height = stack_height.min(max_above);
let stack_area = Rect {
x: full_area.x,
y: footer_area.y.saturating_sub(height),
width: full_area.width,
height,
};
// Iterate oldest-first so the freshest *non-inline* toast is closest to
// the footer (visually nearest the most-recent message in the line below).
let visible = &toasts[..extra];
for (i, toast) in visible.iter().take(height as usize).enumerate() {
let row_y = stack_area.y + i as u16;
let row = Rect {
x: stack_area.x,
y: row_y,
width: stack_area.width,
height: 1,
};
let style = ratatui::style::Style::default()
.fg(status_color(toast.level))
.add_modifier(ratatui::style::Modifier::DIM);
let line = ratatui::text::Line::styled(format!(" {} ", toast.text), style);
f.render_widget(ratatui::widgets::Paragraph::new(line), row);
}
}
pub(crate) fn request_foreground_shell_background(app: &mut App) {
if !app.is_loading {
app.status_message = Some("No foreground shell wait to move to /jobs".to_string());
@@ -17697,7 +17418,8 @@ fn request_active_foreground_shell_background(app: &App) -> Result<()> {
pub(crate) fn prefill_jobs_cancel_all_if_tasks_sidebar(app: &mut App) -> bool {
if !app.view_stack.is_empty()
|| app.sidebar_focus != SidebarFocus::Tasks
|| app.work_surface.panel != crate::tui::work_surface::RailPanel::Tasks
|| app.work_surface.last_area.is_none()
|| !app
.task_panel
.iter()
@@ -18091,13 +17813,8 @@ fn should_tick_status_animation(
}
fn visible_background_task_has_live_motion(app: &App) -> bool {
matches!(
app.sidebar_focus,
SidebarFocus::Auto | SidebarFocus::Pinned | SidebarFocus::Tasks
) && app
.last_sidebar_area
.or(app.viewport.last_sidebar_area)
.is_some()
app.work_surface.panel == crate::tui::work_surface::RailPanel::Tasks
&& app.work_surface.last_area.is_some()
&& app.task_panel.iter().any(|task| task.status == "running")
}
+53 -490
View File
@@ -812,7 +812,7 @@ fn approval_mouse_wheel_reviews_transcript_without_closing_card() {
}
#[test]
fn approval_wheel_preserves_sidebar_and_work_surface_ownership() {
fn approval_wheel_preserves_work_surface_ownership() {
let mut app = create_test_app();
app.view_stack.push(ApprovalView::new(ApprovalRequest::new(
"approval-scroll",
@@ -821,11 +821,10 @@ fn approval_wheel_preserves_sidebar_and_work_surface_ownership() {
&serde_json::json!({"command": "git status"}),
"approval-scroll-key",
)));
app.viewport.last_sidebar_area = Some(Rect::new(60, 0, 20, 20));
app.work_surface.last_area = Some(Rect::new(0, 0, 30, 20));
app.viewport.last_approval_area = Some(Rect::new(0, 12, 80, 8));
for (column, row) in [(65, 4), (10, 4)] {
for (column, row) in [(10, 4), (20, 4)] {
let events = handle_mouse_event(
&mut app,
MouseEvent {
@@ -1980,12 +1979,7 @@ fn loading_mouse_filter_keeps_hover_and_active_drags() {
app.viewport.transcript_scrollbar_dragging = true;
assert!(!should_drop_loading_mouse_motion(&app, drag));
// Sidebar drag-to-resize must also survive the loading filter (#3063).
app.viewport.transcript_scrollbar_dragging = false;
app.sidebar_resizing = true;
assert!(!should_drop_loading_mouse_motion(&app, drag));
app.sidebar_resizing = false;
app.work_surface.last_area = Some(Rect::new(0, 0, 80, 3));
let started = crate::tui::work_surface::handle_mouse(
&mut app,
@@ -2003,90 +1997,10 @@ fn loading_mouse_filter_keeps_hover_and_active_drags() {
);
}
#[test]
fn loading_mouse_filter_allows_sidebar_resize_down_drag_up() {
let mut app = create_test_app();
app.is_loading = true;
setup_resize_handle(&mut app, 80, 33, 120);
let down = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 80,
row: 5,
modifiers: KeyModifiers::NONE,
};
assert!(!should_drop_loading_mouse_motion(&app, down));
handle_mouse_event(&mut app, down);
assert!(app.sidebar_resizing, "down on handle starts resize");
let drag = MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 76,
row: 5,
modifiers: KeyModifiers::NONE,
};
assert!(
!should_drop_loading_mouse_motion(&app, drag),
"resize drag must not be dropped while loading"
);
handle_mouse_event(&mut app, drag);
let expected = ((37u32 * 100) / 120) as u16;
assert_eq!(app.sidebar_width_percent, expected);
let up = MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 76,
row: 5,
modifiers: KeyModifiers::NONE,
};
assert!(!should_drop_loading_mouse_motion(&app, up));
handle_mouse_event(&mut app, up);
assert!(!app.sidebar_resizing);
assert!(app.sidebar_width_dirty);
}
#[test]
fn loading_mouse_filter_allows_sidebar_hover_popovers() {
let mut app = create_test_app();
app.is_loading = true;
app.viewport.last_sidebar_area = Some(Rect::new(60, 4, 20, 6));
app.sidebar_hover.sections.push(SidebarHoverSection {
content_area: Rect::new(60, 4, 20, 6),
lines: vec!["Visible row".to_string()],
rows: vec![SidebarHoverRow {
row_y: 5,
display_text: "Truncated".to_string(),
full_text: "Full sidebar task label".to_string(),
detail: Some("Detailed context".to_string()),
is_truncated: true,
click_action: None,
stop_action: None,
stop_zone_start_col: None,
stop_zone_end_col: None,
}],
});
let moved = MouseEvent {
kind: MouseEventKind::Moved,
column: 65,
row: 5,
modifiers: KeyModifiers::NONE,
};
assert!(!should_drop_loading_mouse_motion(&app, moved));
handle_mouse_event(&mut app, moved);
assert_eq!(
app.sidebar_hover_tooltip.as_deref(),
Some("Full sidebar task label\nDetailed context")
);
assert_eq!(app.last_mouse_pos, Some((65, 5)));
}
#[test]
fn loading_mouse_filter_allows_sidebar_hover_to_clear() {
let mut app = create_test_app();
app.is_loading = true;
app.viewport.last_sidebar_area = Some(Rect::new(60, 4, 20, 6));
app.sidebar_hover_tooltip = Some("Stale sidebar tooltip".to_string());
let moved = MouseEvent {
kind: MouseEventKind::Moved,
@@ -2106,7 +2020,6 @@ fn loading_mouse_filter_allows_sidebar_hover_to_clear() {
fn loading_mouse_filter_allows_sidebar_exit_to_clear_highlight() {
let mut app = create_test_app();
app.is_loading = true;
app.viewport.last_sidebar_area = Some(Rect::new(60, 4, 20, 6));
app.last_mouse_pos = Some((60, 5));
let exit_left = MouseEvent {
@@ -8869,16 +8782,18 @@ fn spans_text(spans: &[Span<'_>]) -> String {
}
#[test]
fn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {
fn ctrl_alt_4_selects_pinned_rail_panel_without_switching_modes() {
let mut app = create_test_app();
app.mode = AppMode::Agent;
app.sidebar_focus = SidebarFocus::Auto;
apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);
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.work_surface.panel,
crate::tui::work_surface::RailPanel::Pinned
);
assert_eq!(app.status_message.as_deref(), Some("Rail panel: pinned"));
}
#[test]
@@ -8911,10 +8826,10 @@ fn hotbar_alt_digit_fires_from_composer_and_sidebar_states() {
app.input = " ".to_string();
assert_eq!(hotbar_slot_from_key(&app, &alt_four), Some(4));
app.sidebar_focus = SidebarFocus::Hidden;
app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Off;
assert_eq!(hotbar_slot_from_key(&app, &alt_four), Some(4));
app.sidebar_focus = SidebarFocus::Agents;
app.work_surface.panel = crate::tui::work_surface::RailPanel::Agents;
assert_eq!(hotbar_slot_from_key(&app, &alt_four), Some(4));
}
@@ -9165,203 +9080,67 @@ fn hotbar_bound_reasoning_action_updates_auto_model_preference() {
}
#[test]
fn alt_0_restores_auto_sidebar_focus() {
fn alt_0_without_ctrl_is_unbound_after_auto_mode_retired() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Hidden;
app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Left;
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"));
// Auto-collapse was dropped with the legacy sidebar; plain Alt+0 must
// not mutate placement or claim anything about the rail.
assert_eq!(
app.work_surface.placement,
crate::tui::work_surface::WorkSurfacePlacement::Left
);
assert!(app.status_message.is_none());
}
#[test]
fn ctrl_alt_0_hides_sidebar() {
fn ctrl_alt_0_turns_rail_off() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Tasks;
app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Top;
apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);
assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);
assert!(app.sidebar_focus_dirty);
assert_eq!(app.status_message.as_deref(), Some("Sidebar hidden"));
assert_eq!(
app.work_surface.placement,
crate::tui::work_surface::WorkSurfacePlacement::Off
);
assert_eq!(app.status_message.as_deref(), Some("Rail is off"));
}
#[test]
fn ctrl_alt_0_restores_pinned_sidebar_when_already_hidden() {
fn ctrl_alt_0_restores_top_rail_when_already_off() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Hidden;
app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Off;
apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);
assert_eq!(app.sidebar_focus, SidebarFocus::Pinned);
assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: pinned"));
}
#[test]
fn sidebar_focus_dirty_persists_saved_focus() {
let _guard = ConfigPathEnvGuard::new();
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Hidden;
app.sidebar_focus_dirty = true;
persist_sidebar_settings_if_dirty(&mut app);
assert!(!app.sidebar_focus_dirty);
let settings = crate::settings::Settings::load().expect("load settings");
assert_eq!(settings.sidebar_focus, "hidden");
}
#[test]
fn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {
let mut app = create_test_app();
app.sidebar_width_percent = 28;
app.sidebar_focus = SidebarFocus::Pinned;
assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));
app.sidebar_focus = SidebarFocus::Hidden;
assert_eq!(sidebar_width_for_chat_area(&app, 120), None);
}
#[test]
fn compact_sidebar_split_survives_eighty_column_file_tree_host() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Pinned;
// 80-column body -> 20-column file tree + 60-column chat host.
assert_eq!(sidebar_width_for_chat_area(&app, 60), Some(20));
assert_eq!(sidebar_width_for_chat_area(&app, 59), None);
}
#[test]
fn sidebar_width_floor_raises_with_chat_width() {
// The classic 24-column floor leaves status/status-adjacent info cramped
// at ultrawide sizes. The floor is now width-aware: at least 28 columns,
// scaling to 10% of the chat host on very wide terminals.
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Pinned;
app.sidebar_width_percent = 10;
// Minimum percent at ordinary width: 12 preferred -> 28 floor.
assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(28));
// Ultrawide: 10% floor grows past the 28-column constant.
assert_eq!(sidebar_width_for_chat_area(&app, 320), Some(32));
// The chat host still caps the rail (chat_width - 40).
assert_eq!(sidebar_width_for_chat_area(&app, 80), Some(28));
// Above the floor the configured percent still rules.
app.sidebar_width_percent = 50;
assert_eq!(sidebar_width_for_chat_area(&app, 200), Some(100));
}
#[test]
fn sidebar_width_gate_uses_compact_sixty_column_boundary() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Pinned;
app.last_sidebar_host_width = Some(SIDEBAR_VISIBLE_MIN_WIDTH - 1);
assert_eq!(
sidebar_render_state(&mut app),
SidebarRenderState::SuppressedByWidth {
available_width: SIDEBAR_VISIBLE_MIN_WIDTH - 1,
min_width: SIDEBAR_VISIBLE_MIN_WIDTH,
}
app.work_surface.placement,
crate::tui::work_surface::WorkSurfacePlacement::Top
);
app.last_sidebar_host_width = Some(SIDEBAR_VISIBLE_MIN_WIDTH);
assert_eq!(sidebar_render_state(&mut app), SidebarRenderState::Visible);
assert_eq!(app.status_message.as_deref(), Some("Rail: top placement"));
}
#[test]
fn pinned_sidebar_is_visible_when_idle_and_wide() {
fn rail_command_reports_off_without_claiming_visibility() {
// Replaces the old sidebar_render_state tests: the render-state machine
// is gone with the classic sidebar, and the /rail status readout is the
// contract that replaces it. It must never claim a surface that cannot
// render is visible.
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Pinned;
app.last_sidebar_host_width = Some(120);
assert_eq!(sidebar_render_state(&mut app), SidebarRenderState::Visible);
}
#[test]
fn auto_sidebar_status_reports_idle_collapse_when_wide() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Auto;
app.last_sidebar_host_width = Some(120);
let result = crate::commands::execute("/rail off", &mut app);
assert!(!result.is_error);
assert_eq!(
sidebar_render_state(&mut app),
SidebarRenderState::AutoCollapsed
app.work_surface.placement,
crate::tui::work_surface::WorkSurfacePlacement::Off
);
}
#[test]
fn sidebar_auto_idle_collapses_when_nothing_active() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Auto;
// A fresh session has no To-do, no fleet, no background jobs, no context.
assert!(crate::tui::sidebar::sidebar_auto_idle(&mut app));
}
#[test]
fn sidebar_auto_idle_false_when_fleet_active() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Auto;
app.agent_progress
.insert("agent_1".to_string(), "running".to_string());
assert!(!crate::tui::sidebar::sidebar_auto_idle(&mut app));
}
#[test]
fn sidebar_auto_idle_false_for_explicit_focus() {
let mut app = create_test_app();
// An explicit panel pin is never auto-collapsed.
app.sidebar_focus = SidebarFocus::Agents;
assert!(!crate::tui::sidebar::sidebar_auto_idle(&mut app));
}
#[test]
fn jobs_panel_ignores_completed_history_but_shows_for_real_jobs() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Auto;
// Completed background history must not reopen the auto Tasks panel.
app.task_panel.push(crate::tui::app::TaskPanelEntry {
id: "shell_1".to_string(),
status: "completed".to_string(),
prompt_summary: "shell: cargo fmt".to_string(),
duration_ms: Some(10),
kind: crate::tui::app::TaskPanelEntryKind::Background,
stale: false,
elapsed_since_output_ms: None,
owner_agent_id: None,
owner_agent_name: None,
current_tool: None,
role: None,
files_touched: 0,
});
let message = result.message.unwrap_or_default();
assert!(message.contains("Rail is off"), "got: {message}");
assert!(
crate::tui::sidebar::sidebar_auto_idle(&mut app),
"completed background jobs must not reopen the auto jobs panel"
);
// A live background job (Background + running/queued) does surface it.
app.task_panel.push(crate::tui::app::TaskPanelEntry {
id: "shell_2".to_string(),
status: "running".to_string(),
prompt_summary: "shell: cargo test".to_string(),
duration_ms: Some(10),
kind: crate::tui::app::TaskPanelEntryKind::Background,
stale: false,
elapsed_since_output_ms: None,
owner_agent_id: None,
owner_agent_name: None,
current_tool: None,
role: None,
files_touched: 0,
});
assert!(
!crate::tui::sidebar::sidebar_auto_idle(&mut app),
"a live background job must surface the jobs panel"
!message.contains("Sidebar is visible"),
"no control may claim the dead sidebar renders: {message}"
);
}
@@ -9378,7 +9157,8 @@ fn background_receipt_tip_only_detects_a_visible_active_to_completed_transition(
#[test]
fn ctrl_x_jobs_prefill_only_catches_running_shell_jobs_in_tasks_sidebar() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Tasks;
app.work_surface.panel = crate::tui::work_surface::RailPanel::Tasks;
app.work_surface.last_area = Some(ratatui::layout::Rect::new(0, 0, 100, 3));
app.input = "draft".to_string();
app.cursor_position = app.input.len();
app.task_panel.push(TaskPanelEntry {
@@ -9408,7 +9188,8 @@ fn ctrl_x_jobs_prefill_only_catches_running_shell_jobs_in_tasks_sidebar() {
#[test]
fn ctrl_x_jobs_prefill_falls_through_outside_tasks_sidebar_shell_jobs() {
let mut non_shell = create_test_app();
non_shell.sidebar_focus = SidebarFocus::Tasks;
non_shell.work_surface.panel = crate::tui::work_surface::RailPanel::Tasks;
non_shell.work_surface.last_area = Some(ratatui::layout::Rect::new(0, 0, 100, 3));
non_shell.input = "draft".to_string();
non_shell.cursor_position = non_shell.input.len();
non_shell.task_panel.push(TaskPanelEntry {
@@ -9430,7 +9211,8 @@ fn ctrl_x_jobs_prefill_falls_through_outside_tasks_sidebar_shell_jobs() {
assert_eq!(non_shell.input, "draft");
let mut other_sidebar = create_test_app();
other_sidebar.sidebar_focus = SidebarFocus::Agents;
other_sidebar.work_surface.panel = crate::tui::work_surface::RailPanel::Agents;
other_sidebar.work_surface.last_area = Some(ratatui::layout::Rect::new(0, 0, 100, 3));
other_sidebar.input = "draft".to_string();
other_sidebar.cursor_position = other_sidebar.input.len();
other_sidebar.task_panel.push(TaskPanelEntry {
@@ -9454,208 +9236,6 @@ fn ctrl_x_jobs_prefill_falls_through_outside_tasks_sidebar_shell_jobs() {
assert_eq!(other_sidebar.input, "draft");
}
// ── Sidebar resize-handle mouse tests ──────────────────────────────
fn setup_resize_handle(app: &mut App, handle_x: u16, sidebar_width: u16, total_width: u16) {
let y = 2;
let h = 10;
app.last_sidebar_handle_area = Some(Rect {
x: handle_x,
y,
width: 1,
height: h,
});
app.last_sidebar_area = Some(Rect {
x: handle_x,
y,
width: sidebar_width,
height: h,
});
app.sidebar_resize_total_width = total_width;
app.sidebar_width_percent = 28;
}
#[test]
fn sidebar_resize_down_on_handle_starts_resizing() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 80,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(
app.sidebar_resizing,
"should start resizing on handle click"
);
assert_eq!(app.sidebar_resize_anchor_x, 80);
assert_eq!(app.sidebar_resize_anchor_width, 33);
}
#[test]
fn sidebar_resize_handle_tracks_hover_for_visible_feedback() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Moved,
column: 80,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(app.sidebar_resize_hovered);
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Moved,
column: 79,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(!app.sidebar_resize_hovered);
}
#[test]
fn sidebar_resize_down_outside_handle_does_not_start_resizing() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 79, // one column left of handle
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(
!app.sidebar_resizing,
"should not resize on non-handle click"
);
}
#[test]
fn sidebar_resize_drag_adjusts_width_percent() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
// 33 / 120 * 100 ≈ 27.5 → initial percent = 28 (the setup defaults to 28)
app.sidebar_width_percent = 28;
app.sidebar_resizing = true;
app.sidebar_resize_anchor_x = 80;
app.sidebar_resize_anchor_width = 33;
// Drag left by 4 cols (making sidebar wider): 33 + 4 = 37 → 37/120*100 ≈ 30
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 76,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
let expected = ((37u32 * 100) / 120) as u16; // ~30
assert_eq!(app.sidebar_width_percent, expected);
}
#[test]
fn sidebar_resize_drag_clamps_to_10_50_range() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
app.sidebar_resizing = true;
app.sidebar_resize_anchor_x = 80;
app.sidebar_resize_anchor_width = 33;
// Drag far right → sidebar should shrink but not below 10%
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 200,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(app.sidebar_width_percent >= 10);
// Drag far left → sidebar should grow but not above 50%
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 0,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(app.sidebar_width_percent <= 50);
}
#[test]
fn sidebar_resize_up_ends_resizing_and_marks_dirty() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
app.sidebar_resizing = true;
app.sidebar_resize_anchor_x = 80;
app.sidebar_resize_anchor_width = 33;
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 76,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(!app.sidebar_resizing, "should stop resizing on mouse up");
assert!(
app.sidebar_width_dirty,
"should mark width dirty for persistence"
);
}
#[test]
fn sidebar_resize_up_outside_handle_still_ends_resizing() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
app.sidebar_resizing = true;
app.sidebar_resize_anchor_x = 80;
app.sidebar_resize_anchor_width = 33;
// Release far away from the handle and the sidebar entirely.
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 5,
row: 20,
modifiers: KeyModifiers::NONE,
},
);
assert!(
!app.sidebar_resizing,
"mouse up must clear resize state even outside the handle"
);
assert!(app.sidebar_width_dirty);
}
fn make_subagent(
id: &str,
status: crate::tools::subagent::SubAgentStatus,
@@ -20489,8 +20069,8 @@ fn status_animation_ticks_for_a_visible_background_task() {
let mut app = create_test_app();
app.low_motion = false;
app.fancy_animations = true;
app.sidebar_focus = SidebarFocus::Tasks;
app.last_sidebar_area = Some(Rect::new(80, 0, 20, 20));
app.work_surface.panel = crate::tui::work_surface::RailPanel::Tasks;
app.work_surface.last_area = Some(Rect::new(80, 0, 20, 20));
app.task_panel.push(TaskPanelEntry {
id: "shell_smooth".to_string(),
status: "running".to_string(),
@@ -20511,7 +20091,7 @@ fn status_animation_ticks_for_a_visible_background_task() {
&app, false, false, false, false
));
app.last_sidebar_area = None;
app.work_surface.last_area = None;
assert!(!visible_background_task_has_live_motion(&app));
assert!(!should_tick_status_animation(
&app, false, false, false, false
@@ -20572,23 +20152,6 @@ fn translation_placeholder_keeps_a_calm_refresh_without_repainting_still_mode()
));
}
#[test]
fn classic_header_indicator_animates_only_in_full_motion() {
let mut app = create_test_app();
app.turn_started_at = Some(Instant::now());
app.low_motion = false;
app.fancy_animations = true;
assert!(classic_header_indicator_started_at(&app).is_some());
app.low_motion = true;
assert!(classic_header_indicator_started_at(&app).is_none());
app.low_motion = false;
app.fancy_animations = false;
assert!(classic_header_indicator_started_at(&app).is_none());
}
#[test]
fn subagent_completion_notification_modes_gate_correctly() {
use crate::config::SubagentCompletionNotification as Mode;
+2 -3
View File
@@ -1288,7 +1288,6 @@ pub(crate) fn empty_state_mark_visible(area: Rect) -> bool {
#[must_use]
pub(crate) fn decorative_shell_motion_enabled(app: &App) -> bool {
app.motion_policy().allows_decorative()
&& app.ocean_treatment.supports_ambient_life()
&& !app.attention_hold_active()
&& app.onboarding == OnboardingState::None
&& !app.launch.visible
@@ -2414,8 +2413,8 @@ mod tests {
assert!(!idle_mark_animation_enabled(&app));
app.fancy_animations = true;
app.ocean_treatment = crate::tui::ocean::OceanTreatment::Classic;
assert!(!idle_mark_animation_enabled(&app));
app.ocean_treatment = crate::tui::ocean::OceanTreatment::Flat;
assert!(idle_mark_animation_enabled(&app));
app.ocean_treatment = crate::tui::ocean::OceanTreatment::Ombre;
app.launch.visible = true;
+18 -24
View File
@@ -1883,15 +1883,8 @@ impl ConfigView {
},
ConfigRow {
section: ConfigSection::Sidebar,
key: "sidebar_width".to_string(),
value: settings.sidebar_width_percent.to_string(),
editable: true,
scope: ConfigScope::Saved,
},
ConfigRow {
section: ConfigSection::Sidebar,
key: "sidebar_focus".to_string(),
value: settings.sidebar_focus.clone(),
key: "rail_panel".to_string(),
value: settings.rail_panel.clone(),
editable: true,
scope: ConfigScope::Saved,
},
@@ -2853,8 +2846,6 @@ fn config_label_message(key: &str) -> Option<MessageId> {
"mention_menu_behavior" => MessageId::ConfigLabelMentionMenuBehavior,
"mention_walk_depth" => MessageId::ConfigLabelMentionWalkDepth,
"workspace_follow_symlinks" => MessageId::ConfigLabelWorkspaceFollowSymlinks,
"sidebar_width" => MessageId::ConfigLabelSidebarWidth,
"sidebar_focus" => MessageId::ConfigLabelSidebarFocus,
"context_panel" => MessageId::ConfigLabelContextPanel,
"sessions_rail" => MessageId::ConfigLabelSessionsRail,
"session_auto_resume" => MessageId::ConfigLabelSessionAutoResume,
@@ -2948,8 +2939,9 @@ fn config_hint_for_key(key: &str) -> &'static str {
}
"background_color" => "#RRGGBB | default",
"work_surface_placement" => {
"top | left | right · side rails require Ocean mode and at least 72 columns"
"top | left | right | off · side rails require Ocean mode and at least 72 columns"
}
"rail_panel" => "tasks | agents | context | pinned · which panel the rail shows",
"work_surface_top_height" => "2..=16 rows · also adjustable by dragging the divider",
"work_surface_side_width" => "26..=80 columns · also adjustable by dragging the divider",
"base_url" => "global DeepSeek/root fallback; e.g. https://api.deepseek.com/beta",
@@ -2976,8 +2968,6 @@ fn config_hint_for_key(key: &str) -> &'static str {
}
"synchronized_output" => "auto | on | off; terminal redraw pacing, not model speed",
"default_mode" => "act (agent) | plan | operate",
"sidebar_width" => "10..=50",
"sidebar_focus" => "auto | work | tasks | agents | context | hidden",
"max_history" => "integer (0 allowed)",
"auto_compact_threshold_percent" => "10..=100",
"default_model" => {
@@ -3043,7 +3033,6 @@ fn config_integer_key(key: &str) -> bool {
| "work_surface_side_width"
| "mention_menu_limit"
| "mention_walk_depth"
| "sidebar_width"
| "auto_compact_threshold_percent"
| "max_history"
| "fleet.exec.max_spawn_depth"
@@ -3065,7 +3054,8 @@ fn config_choice_values(key: &str, provider: ApiProvider) -> Option<Vec<String>>
}
"ocean_treatment" => vec!["ombre", "flat"],
"focus_texture" => vec!["off", "scrim", "grain"],
"work_surface_placement" => vec!["top", "left", "right"],
"work_surface_placement" => vec!["top", "left", "right", "off"],
"rail_panel" => vec!["tasks", "agents", "context", "pinned"],
"status_indicator" => vec!["cw", "whale", "dots", "off"],
"synchronized_output" => vec!["auto", "on", "off"],
"cost_currency" => vec!["usd", "cny"],
@@ -3076,7 +3066,6 @@ fn config_choice_values(key: &str, provider: ApiProvider) -> Option<Vec<String>>
"inline_diffs" => vec!["full", "summary", "off"],
"composer_vim_mode" => vec!["normal", "vim"],
"mention_menu_behavior" => vec!["fuzzy", "browser"],
"sidebar_focus" => vec!["pinned", "auto", "tasks", "agents", "context", "hidden"],
"theme" => {
return Some(
crate::palette::SELECTABLE_THEMES
@@ -3157,6 +3146,11 @@ fn config_choice_label(locale: Locale, key: &str, value: &str) -> String {
("work_surface_placement", "top") => "Top".to_string(),
("work_surface_placement", "left") => "Left sidebar".to_string(),
("work_surface_placement", "right") => "Right sidebar".to_string(),
("work_surface_placement", "off") => "Off".to_string(),
("rail_panel", "tasks") => "Tasks".to_string(),
("rail_panel", "agents") => "Agents".to_string(),
("rail_panel", "context") => "Context".to_string(),
("rail_panel", "pinned") => "Pinned".to_string(),
("reasoning_effort", "default") => "Provider default".to_string(),
("status_indicator", "cw") => "Codewhale mark".to_string(),
("status_indicator", "whale") => "Animated whale".to_string(),
@@ -3165,9 +3159,6 @@ fn config_choice_label(locale: Locale, key: &str, value: &str) -> String {
("inline_diffs", "full") => "Full diff".to_string(),
("inline_diffs", "summary") => "Summary".to_string(),
("inline_diffs", "off") => "Off".to_string(),
("sidebar_focus", "pinned") => "Work pinned".to_string(),
("sidebar_focus", "tasks") => "Activity".to_string(),
("sidebar_focus", "agents") => "Workers".to_string(),
_ => value.to_string(),
};
@@ -3214,6 +3205,11 @@ fn config_choice_detail(locale: Locale, key: &str, value: &str) -> Cow<'static,
("work_surface_placement", "right") => {
"Show Tasks, To-do, and Workers in a right sidebar when the terminal is wide enough."
}
("work_surface_placement", "off") => "Hide the rail entirely.",
("rail_panel", "tasks") => "Rail shows the live Tasks / To-do / Workers list.",
("rail_panel", "agents") => "Rail shows sub-agents and fan-out state.",
("rail_panel", "context") => "Rail shows workspace, token, and cost context.",
("rail_panel", "pinned") => "Rail shows the pinned goal and checklist summary.",
("low_motion", "true") => "Stops live-state movement without changing model output.",
("low_motion", "false") => "Allows motion selected by the other appearance settings.",
("fancy_animations", "true") => "Animates truthful tool, status, and ocean live state.",
@@ -5929,8 +5925,7 @@ context_window = 262144
"work_surface_placement",
"work_surface_top_height",
"work_surface_side_width",
"sidebar_width",
"sidebar_focus",
"rail_panel",
"context_panel",
"sessions_rail",
"session_auto_resume",
@@ -5953,8 +5948,7 @@ context_window = 262144
"work_surface_placement",
"work_surface_top_height",
"work_surface_side_width",
"sidebar_width",
"sidebar_focus",
"rail_panel",
"context_panel",
"sessions_rail",
"session_auto_resume",
+2 -75
View File
@@ -34,7 +34,7 @@ use unicode_width::UnicodeWidthStr;
use crate::localization::{Locale, MessageId, tr};
use crate::palette;
use crate::tui::app::{App, AppMode, SidebarFocus};
use crate::tui::app::{App, AppMode};
use super::Renderable;
@@ -74,9 +74,6 @@ pub struct FooterProps {
pub mcp: Vec<Span<'static>>,
/// Permission posture chip (Ask / Auto-Review / Full Access) when visible.
pub permission: Vec<Span<'static>>,
/// Compact nonempty Work indicator when terminal width suppresses the
/// sidebar. Empty when Work is visible or explicitly hidden.
pub work: Vec<Span<'static>>,
/// Cumulative model-work chip spans ("worked 3m 12s"). Sums the
/// elapsed time of completed turns (from `App::cumulative_turn_duration`),
/// **not** wall-clock since launch — an idle TUI shouldn't claim
@@ -289,7 +286,6 @@ impl FooterProps {
.map(|s| s.servers.iter().filter(|server| server.connected).count());
let mcp = footer_mcp_chip(mcp_connected, mcp_configured);
let permission = footer_permission_chip(app);
let work = footer_compact_work_chip(app);
// #448: cumulative work-time chip. Sums actual turn durations
// (set on `TurnComplete`) rather than wall-clock uptime — a TUI
// that's been open and idle for 4 minutes shouldn't claim
@@ -311,7 +307,6 @@ impl FooterProps {
cache,
mcp,
permission,
work,
worked,
cost,
balance,
@@ -353,22 +348,6 @@ pub fn footer_permission_chip(app: &App) -> Vec<Span<'static>> {
]
}
fn footer_compact_work_chip(app: &App) -> Vec<Span<'static>> {
if app.sidebar_focus == SidebarFocus::Hidden
|| app
.last_sidebar_host_width
.is_none_or(|width| width >= crate::tui::ui::SIDEBAR_VISIBLE_MIN_WIDTH)
{
return Vec::new();
}
crate::tui::sidebar::compact_work_indicator(app).map_or_else(Vec::new, |label| {
vec![Span::styled(
label,
Style::default().fg(palette::WHALE_INFO),
)]
})
}
/// Pure-render footer. Build once per frame, then `render(area, buf)`.
pub struct FooterWidget {
props: FooterProps,
@@ -387,7 +366,6 @@ impl FooterWidget {
// disappear without disturbing the steady mode·model·cost line.
let parts: Vec<&Vec<Span<'static>>> = [
&self.props.permission,
&self.props.work,
&self.props.agents,
&self.props.reasoning_replay,
&self.props.cache,
@@ -702,9 +680,7 @@ impl Renderable for FooterWidget {
// long toast/model label to consume the row, then let lower-priority
// auxiliary chips fill whatever remains.
let permission_width = span_width(&self.props.permission);
let work_width = span_width(&self.props.work);
let critical_inner_gap = usize::from(permission_width > 0 && work_width > 0) * 2;
let critical_width = permission_width + critical_inner_gap + work_width;
let critical_width = permission_width;
let reserved_gap = usize::from(critical_width > 0) * 2;
let preview_left_budget = if critical_width > 0 {
available_width
@@ -1303,55 +1279,6 @@ mod tests {
);
}
#[test]
fn width_suppressed_sidebar_falls_back_to_compact_work_chip() {
let mut app = make_app();
app.mode = AppMode::Operate;
app.approval_mode = crate::tui::approval::ApprovalMode::Bypass;
app.last_sidebar_host_width = Some(59);
{
let mut todos = app.todos.try_lock().expect("todos lock");
todos.add(
"inspect".to_string(),
crate::tools::todo::TodoStatus::Completed,
);
todos.add(
"patch".to_string(),
crate::tools::todo::TodoStatus::InProgress,
);
}
let props = FooterProps::from_app(
&app,
None,
"ready",
palette::TEXT_MUTED,
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
);
assert_eq!(super::spans_text(&props.work), "To-do 2 · 50%");
let line = render_at_width(props, 59);
assert!(line.contains("To-do 2 · 50%"), "{line:?}");
assert!(line.contains("perm Full Access"), "{line:?}");
app.sidebar_focus = crate::tui::app::SidebarFocus::Hidden;
let hidden = FooterProps::from_app(
&app,
None,
"ready",
palette::TEXT_MUTED,
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
);
assert!(hidden.work.is_empty());
}
#[test]
fn permission_safety_chip_survives_long_toast_at_release_widths() {
let mut app = make_app();
File diff suppressed because it is too large Load Diff
+22 -113
View File
@@ -16,7 +16,7 @@ pub use footer::{
FooterProps, FooterToast, FooterWidget, footer_agents_chip, footer_shell_label_chip,
footer_working_label,
};
pub use header::{HeaderData, HeaderWidget, header_status_indicator_frame};
pub use header::header_status_indicator_frame;
pub use renderable::Renderable;
use std::borrow::Cow;
@@ -111,10 +111,7 @@ impl ChatWidget {
.is_ombre()
.then(|| crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme))
.flatten();
let ambient_inks = app
.ocean_treatment
.supports_ambient_life()
.then(|| crate::tui::ocean::ambient_inks(&app.ui_theme));
let ambient_inks = Some(crate::tui::ocean::ambient_inks(&app.ui_theme));
let completion_elapsed_ms = (!app.low_motion && app.fancy_animations)
.then_some(())
.and(app.ocean_completion_started_at)
@@ -541,16 +538,8 @@ impl ChatWidget {
// rows upward, producing repeated thousand-cell repaints and the
// visible "slab" motion recorded in live QA. Empty-state centering is
// handled separately; active work starts at the top and appends in
// place until scrolling is genuinely necessary. The old anchoring is
// retained only inside the explicitly selected classic treatment.
if app.ocean_treatment.is_classic() && app.viewport.transcript_scroll.is_at_tail() {
let padding_top = visible_lines.saturating_sub(lines.len());
app.viewport.last_transcript_padding_top = padding_top;
pad_lines_to_bottom(&mut lines, visible_lines);
line_links.splice(0..0, std::iter::repeat_n(Vec::new(), padding_top));
} else {
app.viewport.last_transcript_padding_top = 0;
}
// place until scrolling is genuinely necessary.
app.viewport.last_transcript_padding_top = 0;
let scrollbar = (total_lines > visible_lines && content_area.width > 1).then_some(
TranscriptScrollbar {
@@ -1313,14 +1302,6 @@ impl Renderable for ComposerWidget<'_> {
Style::default().fg(palette::TEXT_MUTED),
)));
}
// Top-right corner: editor state plus transient turn receipts.
// Receipts are lifecycle chrome, not transcript content; they
// should appear briefly without displacing conversation rows.
if self.app.ocean_treatment.is_classic()
&& let Some(chrome) = composer_top_right_chrome(self.app, area.width)
{
top_border = top_border.title_top(chrome.right_aligned());
}
top_border.render(area, buf);
let mut bottom_border = Block::default()
@@ -1332,15 +1313,10 @@ impl Renderable for ComposerWidget<'_> {
}
bottom_border.render(area, buf);
} else if area.height >= 2 {
let mut block = Block::default()
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(self.app.ui_theme.border))
.style(background);
if self.app.ocean_treatment.is_classic()
&& let Some(chrome) = composer_top_right_chrome(self.app, area.width)
{
block = block.title_top(chrome.right_aligned());
}
block.render(area, buf);
} else {
Block::default().style(background).render(area, buf);
@@ -3258,82 +3234,6 @@ fn vim_mode_style(mode: VimMode) -> Style {
Style::default().fg(color).bold()
}
fn composer_top_right_chrome(app: &App, area_width: u16) -> Option<Line<'static>> {
let receipt = app.active_receipt_text();
let session_title = app.session_title.as_deref();
if !app.composer.vim_enabled && receipt.is_none() && session_title.is_none() {
return None;
}
// Leave room for the left title and both borders. On narrow panes, skip
// extra chrome rather than letting status text collide with "Composer".
let max_width = usize::from(area_width.saturating_sub(18));
if max_width < 4 {
return None;
}
let receipt_style = Style::default()
.fg(palette::STATUS_SUCCESS)
.add_modifier(Modifier::DIM);
if let Some(receipt) = receipt {
let receipt_text = receipt.trim();
if app.composer.vim_enabled {
let vim_label = app.composer.vim_mode.label_localized(app.ui_locale);
let vim_width = UnicodeWidthStr::width(&*vim_label);
let sep_width = UnicodeWidthStr::width(" · ");
if vim_width + sep_width + 4 <= max_width {
let receipt_width = max_width.saturating_sub(vim_width + sep_width);
return Some(Line::from(vec![
Span::styled(vim_label.to_string(), vim_mode_style(app.composer.vim_mode)),
Span::styled(" · ", Style::default().fg(palette::TEXT_MUTED)),
Span::styled(
truncate_display_width(receipt_text, receipt_width),
receipt_style,
),
]));
}
}
return Some(Line::from(Span::styled(
truncate_display_width(receipt_text, max_width),
receipt_style,
)));
}
let mut spans: Vec<Span> = Vec::new();
if app.composer.vim_enabled {
spans.push(Span::styled(
truncate_display_width(
&app.composer.vim_mode.label_localized(app.ui_locale),
max_width,
),
vim_mode_style(app.composer.vim_mode),
));
}
if let Some(title) = session_title {
let used: usize = spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
let sep = if spans.is_empty() { 0 } else { 2 };
let remaining = max_width.saturating_sub(used + sep);
if remaining >= 4 {
if !spans.is_empty() {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
truncate_display_width(title, remaining),
Style::default().fg(palette::TEXT_MUTED),
));
}
}
if spans.is_empty() {
None
} else {
Some(Line::from(spans))
}
}
fn should_render_empty_state(app: &App) -> bool {
let active_is_empty = app
.active_cell
@@ -3349,7 +3249,15 @@ fn should_render_empty_state(app: &App) -> bool {
.task_panel
.iter()
.any(|task| task.kind == crate::tui::app::TaskPanelEntryKind::Background)
&& crate::tui::sidebar::compact_work_indicator(app).is_none()
// Live work suppresses the empty state. On lock contention, treat
// the todo store as non-empty rather than flash the empty ocean.
&& !app
.todos
.try_lock()
.map(|todos| !todos.snapshot().is_empty())
.unwrap_or(true)
&& app.hunt.quarry.is_none()
&& app.paused_quarry.is_none()
}
fn build_empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
@@ -5767,9 +5675,11 @@ mod tests {
}
#[test]
fn composer_border_renders_session_title() {
fn composer_border_omits_session_title_chrome() {
// The top-right composer chrome (session title / receipts / vim mode)
// was classic-shell-only; with the classic shell removed the composer
// border never carries it. Session identity lives in the header.
let mut app = create_test_app();
app.ocean_treatment = crate::tui::ocean::OceanTreatment::Classic;
app.composer_density = ComposerDensity::Comfortable;
app.session_title = Some("my-session".to_string());
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
@@ -5787,13 +5697,12 @@ mod tests {
let rendered = buffer_text(&buf, area);
assert!(!rendered.contains("Composer"));
assert!(rendered.contains("my-session"));
assert!(!rendered.contains("my-session"));
}
#[test]
fn composer_border_renders_active_turn_receipt() {
fn composer_border_omits_active_turn_receipt_chrome() {
let mut app = create_test_app();
app.ocean_treatment = crate::tui::ocean::OceanTreatment::Classic;
app.composer_density = ComposerDensity::Comfortable;
app.set_receipt_text("✓ turn completed · 2 tool(s) used");
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
@@ -5811,8 +5720,8 @@ mod tests {
let rendered = buffer_text(&buf, area);
assert!(!rendered.contains("Composer"));
assert!(rendered.contains("turn completed"));
assert!(rendered.contains("tool(s) used"));
assert!(!rendered.contains("turn completed"));
assert!(!rendered.contains("tool(s) used"));
}
#[test]
+3
View File
@@ -90,6 +90,7 @@ pub fn handle_mouse(app: &mut App, mouse: MouseEvent) -> MouseOutcome {
};
let placement = app.work_surface.effective_placement;
let on_divider = match placement {
WorkSurfacePlacement::Off => false,
WorkSurfacePlacement::Top => {
mouse.row == area.bottom().saturating_sub(1)
&& mouse.column >= area.x
@@ -126,6 +127,7 @@ pub fn handle_mouse(app: &mut App, mouse: MouseEvent) -> MouseOutcome {
app.work_surface.resize_anchor_size = match placement {
WorkSurfacePlacement::Top => area.height,
WorkSurfacePlacement::Left | WorkSurfacePlacement::Right => area.width,
WorkSurfacePlacement::Off => area.width,
};
app.needs_redraw = true;
return MouseOutcome {
@@ -157,6 +159,7 @@ pub fn handle_mouse(app: &mut App, mouse: MouseEvent) -> MouseOutcome {
.clamp(i32::from(SIDE_WIDTH_MIN), i32::from(SIDE_WIDTH_MAX))
as u16;
}
WorkSurfacePlacement::Off => {}
}
app.needs_redraw = true;
return MouseOutcome {
+118 -12
View File
@@ -6,11 +6,12 @@
mod input;
mod interaction;
mod model;
mod panels;
mod render;
pub use input::{handle_key, handle_mouse};
pub(crate) use interaction::agent_details_closed;
pub use model::{WorkSurfacePlacement, WorkSurfaceState};
pub use model::{RailPanel, WorkSurfacePlacement, WorkSurfaceState};
pub use render::{height, render, split_chat};
#[cfg(test)]
@@ -51,6 +52,11 @@ mod tests {
};
let mut app = App::new(options, &Config::default());
app.ui_locale = crate::localization::Locale::En;
// Dogfood guard: App::new reads the developer's real settings.toml,
// and the 0.9.4 migration maps a legacy sidebar_focus onto the rail
// panel. These tests exercise the Tasks panel's row machinery, so
// pin it rather than depend on the host file.
app.work_surface.panel = super::RailPanel::Tasks;
app
}
@@ -503,25 +509,25 @@ mod tests {
let mut two_steps = app();
two_steps.work_surface.top_height = 8;
add_todos(&mut two_steps, 2);
assert_eq!(super::height(&mut two_steps, 100, 40, false), 4);
assert_eq!(super::height(&mut two_steps, 100, 40), 4);
// Ten steps: content wants 12 lines, the default 8-line cap wins.
let mut ten_steps = app();
ten_steps.work_surface.top_height = 8;
add_todos(&mut ten_steps, 10);
assert_eq!(super::height(&mut ten_steps, 100, 40, false), 8);
assert_eq!(super::height(&mut ten_steps, 100, 40), 8);
// Short terminal: the half-terminal cap beats both content and the
// configured cap.
let mut short_terminal = app();
short_terminal.work_surface.top_height = 8;
add_todos(&mut short_terminal, 10);
assert_eq!(super::height(&mut short_terminal, 100, 12, false), 6);
assert_eq!(super::height(&mut short_terminal, 100, 12), 6);
// Nothing to show: no strip at all.
let mut empty = app();
empty.work_surface.top_height = 8;
assert_eq!(super::height(&mut empty, 100, 40, false), 0);
assert_eq!(super::height(&mut empty, 100, 40), 0);
}
#[test]
@@ -994,7 +1000,7 @@ mod tests {
let rows = super::model::project(&mut app);
assert!(rows.is_empty());
assert_eq!(super::height(&mut app, 120, 32, false), 0);
assert_eq!(super::height(&mut app, 120, 32), 0);
}
#[test]
@@ -1020,11 +1026,111 @@ mod tests {
app.work_surface.placement = placement;
let area = ratatui::layout::Rect::new(0, 0, 120, 32);
assert_eq!(super::height(&mut app, area.width, area.height, false), 0);
assert_eq!(super::split_chat(&mut app, area, false), (area, None));
assert_eq!(super::height(&mut app, area.width, area.height), 0);
assert_eq!(super::split_chat(&mut app, area), (area, None));
}
}
fn terminal_text(terminal: &Terminal<TestBackend>) -> String {
let buf = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
text.push_str(buf[(x, y)].symbol());
}
}
text
}
/// Render-level smoke coverage for the ported rail panels — reinstates
/// the sidebar render smoke tests removed with the classic shell
/// (739616787). Every non-Tasks panel must render its title in every
/// placement the rail supports.
#[test]
fn rail_panels_render_in_all_placements() {
for panel in [
super::RailPanel::Agents,
super::RailPanel::Context,
super::RailPanel::Pinned,
] {
for placement in [
super::WorkSurfacePlacement::Top,
super::WorkSurfacePlacement::Left,
super::WorkSurfacePlacement::Right,
] {
let mut app = app();
app.work_surface.placement = placement;
app.work_surface.panel = panel;
let area = ratatui::layout::Rect::new(0, 0, 100, 24);
let strip = super::height(&mut app, area.width, area.height);
let (_chat, rail) = super::split_chat(&mut app, area);
let backend = TestBackend::new(area.width, area.height);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| {
if strip > 0 {
super::render(
frame,
ratatui::layout::Rect::new(0, 0, area.width, strip),
&mut app,
);
} else if let Some(rail) = rail {
super::render(frame, rail, &mut app);
}
})
.expect("draw");
let text = terminal_text(&terminal);
assert!(
text.contains(panel.title()),
"{panel:?} in {placement:?} should render its title; got: {text}"
);
}
}
}
#[test]
fn off_placement_reserves_no_rail_in_any_panel() {
for panel in [
super::RailPanel::Tasks,
super::RailPanel::Agents,
super::RailPanel::Context,
super::RailPanel::Pinned,
] {
let mut app = app();
add_todos(&mut app, 2);
app.work_surface.placement = super::WorkSurfacePlacement::Off;
app.work_surface.panel = panel;
let area = ratatui::layout::Rect::new(0, 0, 120, 32);
assert_eq!(super::height(&mut app, area.width, area.height), 0);
assert_eq!(super::split_chat(&mut app, area), (area, None));
assert_eq!(app.work_surface.last_area, None);
}
}
#[test]
fn context_panel_renders_session_facts_in_side_rail() {
let mut app = app();
app.work_surface.placement = super::WorkSurfacePlacement::Right;
app.work_surface.panel = super::RailPanel::Context;
let area = ratatui::layout::Rect::new(0, 0, 100, 24);
let strip = super::height(&mut app, area.width, area.height);
assert_eq!(strip, 0, "side placements take no top strip");
let (_chat, rail) = super::split_chat(&mut app, area);
let rail = rail.expect("context panel reserves a side rail");
let backend = TestBackend::new(area.width, area.height);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| super::render(frame, rail, &mut app))
.expect("draw");
let text = terminal_text(&terminal);
assert!(text.contains("Context"), "panel title; got: {text}");
assert!(text.contains("lsp:"), "session facts; got: {text}");
}
#[test]
fn missing_runtime_renders_disconnected_state() {
let mut app = app();
@@ -1587,9 +1693,9 @@ mod tests {
let mut app = app();
add_todos(&mut app, 2);
app.work_surface.placement = placement;
assert_eq!(super::height(&mut app, 100, 24, false), 0);
assert_eq!(super::height(&mut app, 100, 24), 0);
let area = ratatui::layout::Rect::new(0, 0, 100, 12);
let (chat, rail) = super::split_chat(&mut app, area, false);
let (chat, rail) = super::split_chat(&mut app, area);
let rail = rail.expect("side rail");
assert_eq!(chat.x, expected_chat_x);
assert_eq!(rail.x, expected_rail_x);
@@ -1708,12 +1814,12 @@ mod tests {
let graph = operation_graph(NodeState::Failed);
restore_graph(&mut operation_app, &graph);
assert_eq!(super::height(&mut operation_app, 100, 24, false), 0);
assert_eq!(super::height(&mut operation_app, 100, 24), 0);
assert!(operation_app.work_surface.latest_rows.is_empty());
let mut todo_app = app();
add_todos(&mut todo_app, 2);
assert!(super::height(&mut todo_app, 100, 24, false) > 0);
assert!(super::height(&mut todo_app, 100, 24) > 0);
assert!(
todo_app
.work_surface
+63 -1
View File
@@ -19,13 +19,63 @@ use crate::work_graph::{
};
/// Persisted Ocean work-surface placement. Bottom is deliberately absent: the
/// composer and phase footer own the shell's lower edge.
/// composer and phase footer own the shell's lower edge. `Off` hides the rail
/// outright (rail unification, 0.9.4).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum WorkSurfacePlacement {
#[default]
Top,
Left,
Right,
Off,
}
/// Which panel the rail shows. Orthogonal to placement: the user picks
/// *where* the rail sits and *what* it shows (rail unification, 0.9.4).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum RailPanel {
/// Tasks / to-do / workers — the live work projection rendered through
/// the row/hitbox machinery in `render.rs`.
#[default]
Tasks,
/// Sub-agents, ported from the legacy sidebar's Agents panel.
Agents,
/// Workspace / token / cost context, ported from the Context panel.
Context,
/// Pinned work summary (goal + checklist), ported from the Pinned panel.
Pinned,
}
impl RailPanel {
#[must_use]
pub fn parse(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"agents" => Self::Agents,
"context" => Self::Context,
"pinned" => Self::Pinned,
_ => Self::Tasks,
}
}
#[must_use]
pub const fn as_setting(self) -> &'static str {
match self {
Self::Tasks => "tasks",
Self::Agents => "agents",
Self::Context => "context",
Self::Pinned => "pinned",
}
}
#[must_use]
pub const fn title(self) -> &'static str {
match self {
Self::Tasks => "Tasks",
Self::Agents => "Agents",
Self::Context => "Context",
Self::Pinned => "Pinned",
}
}
}
impl WorkSurfacePlacement {
@@ -34,6 +84,7 @@ impl WorkSurfacePlacement {
match value.trim().to_ascii_lowercase().as_str() {
"left" => Self::Left,
"right" => Self::Right,
"off" => Self::Off,
_ => Self::Top,
}
}
@@ -44,6 +95,7 @@ impl WorkSurfacePlacement {
Self::Top => "top",
Self::Left => "left",
Self::Right => "right",
Self::Off => "off",
}
}
}
@@ -127,6 +179,8 @@ pub(crate) struct SessionInstanceScope {
pub struct WorkSurfaceState {
pub placement: WorkSurfacePlacement,
pub(super) effective_placement: WorkSurfacePlacement,
/// Panel selection — orthogonal to placement.
pub panel: RailPanel,
pub top_height: u16,
pub side_width: u16,
pub(super) resizing: bool,
@@ -186,6 +240,13 @@ impl WorkSurfaceState {
self.resizing
}
/// The placement actually rendered this frame (after the narrow-terminal
/// fallback), for truthful status readouts.
#[must_use]
pub fn effective_placement(&self) -> WorkSurfacePlacement {
self.effective_placement
}
#[must_use]
pub fn with_placement(placement: WorkSurfacePlacement) -> Self {
Self::with_layout(placement, 3, 30)
@@ -196,6 +257,7 @@ impl WorkSurfaceState {
Self {
placement,
effective_placement: placement,
panel: RailPanel::default(),
top_height: top_height.clamp(TOP_HEIGHT_MIN, TOP_HEIGHT_MAX),
side_width: side_width.clamp(SIDE_WIDTH_MIN, SIDE_WIDTH_MAX),
resizing: false,
+99
View File
@@ -0,0 +1,99 @@
//! Non-Tasks rail panels, ported from the legacy classic-shell sidebar
//! during the 0.9.4 rail unification (spec step 2). Agents, Context, and
//! Pinned render as titled line lists inside the one work-surface rail, in
//! whatever placement the user picked; panel selection is orthogonal to
//! placement. The Tasks panel is *not* here — it renders through the
//! row/hitbox machinery in `render.rs`.
//!
//! The line builders themselves still live in `tui::sidebar` (they are
//! `pub(crate)` there) while the sidebar module is wound down; the rail is
//! their only production caller now.
use ratatui::text::Line;
use crate::tui::app::App;
use crate::tui::sidebar::{self, SidebarSubagentSummary};
use crate::tui::subagent_routing::active_fanout_counts;
use super::model::RailPanel;
/// Display lines for a non-Tasks rail panel, or `None` for Tasks (which the
/// caller renders through the row machinery instead).
pub(crate) fn panel_lines(
app: &mut App,
panel: RailPanel,
content_width: usize,
max_rows: usize,
) -> Option<Vec<Line<'static>>> {
let content_width = content_width.max(1);
let max_rows = max_rows.max(1);
match panel {
RailPanel::Tasks => None,
RailPanel::Agents => Some(agents_panel_lines(app, content_width, max_rows)),
RailPanel::Context => Some(sidebar::context_panel_lines(app, content_width)),
RailPanel::Pinned => Some(pinned_panel_lines(app, content_width, max_rows)),
}
}
/// Agents panel: cached sub-agents plus progress-only and fanout signals.
/// The summary projection is lifted from the legacy `render_sidebar_subagents`
/// so the panel keeps its exact content in the rail.
fn agents_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec<Line<'static>> {
let cached_ids: std::collections::HashSet<&str> = app
.subagent_cache
.iter()
.map(|agent| agent.agent_id.as_str())
.collect();
let progress_only_count = app
.agent_progress
.keys()
.filter(|id| !cached_ids.contains(id.as_str()))
.count();
let cached_running = app
.subagent_cache
.iter()
.filter(|agent| sidebar::cached_agent_activity_is_live(app, agent))
.count();
let role_counts: std::collections::BTreeMap<String, usize> =
app.subagent_cache
.iter()
.fold(std::collections::BTreeMap::new(), |mut acc, agent| {
*acc.entry(agent.agent_type.as_str().to_string())
.or_insert(0) += 1;
acc
});
let (fanout_running, fanout_total) = active_fanout_counts(app)
.map(|(running, total)| (running, Some(total)))
.unwrap_or((0, None));
let summary = SidebarSubagentSummary {
cached_total: app.subagent_cache.len(),
cached_running,
progress_only_count,
fanout_total,
fanout_running,
foreground_rlm_running: sidebar::foreground_rlm_running(app),
role_counts,
};
let rows = sidebar::sidebar_agent_rows(app);
sidebar::subagent_panel_lines(
&summary,
&rows,
app.ui_locale,
content_width,
max_rows,
&app.ui_theme,
)
}
/// Pinned panel: the durable work summary (goal + checklist) the legacy
/// sidebar showed in Pinned focus.
fn pinned_panel_lines(app: &mut App, content_width: usize, max_rows: usize) -> Vec<Line<'static>> {
let summary = sidebar::sidebar_work_summary(app);
sidebar::work_panel_lines(
&summary,
content_width,
max_rows,
app.ui_theme.mode,
&app.ui_theme,
)
}
+132 -15
View File
@@ -14,17 +14,18 @@ use crate::localization::MessageId;
use crate::tui::app::{App, SidebarHoverRow, SidebarHoverSection};
use crate::tui::ui_text::truncate_line_to_width;
use super::model::{WorkHitbox, WorkRow, WorkSurfacePlacement, WorkTone, project_visible};
use super::model::{
RailPanel, WorkHitbox, WorkRow, WorkSurfacePlacement, WorkTone, project_visible,
};
const SIDE_RAIL_MIN_HOST_WIDTH: u16 = 72;
const SIDE_RAIL_MIN_CHAT_WIDTH: u16 = 40;
fn effective_placement(
configured: WorkSurfacePlacement,
host_width: u16,
classic_shell: bool,
) -> WorkSurfacePlacement {
if classic_shell || host_width < SIDE_RAIL_MIN_HOST_WIDTH {
fn effective_placement(configured: WorkSurfacePlacement, host_width: u16) -> WorkSurfacePlacement {
if configured == WorkSurfacePlacement::Off {
return WorkSurfacePlacement::Off;
}
if host_width < SIDE_RAIL_MIN_HOST_WIDTH {
WorkSurfacePlacement::Top
} else {
configured
@@ -33,9 +34,29 @@ fn effective_placement(
/// Responsive work-surface height. The component owns a bounded window; long
/// work lists scroll instead of consuming the transcript.
pub fn height(app: &mut App, width: u16, terminal_height: u16, classic_shell: bool) -> u16 {
app.work_surface.effective_placement =
effective_placement(app.work_surface.placement, width, classic_shell);
pub fn height(app: &mut App, width: u16, terminal_height: u16) -> u16 {
app.work_surface.effective_placement = effective_placement(app.work_surface.placement, width);
// Off hides the rail outright: no strip, no side reservation, no stale
// interaction state.
if app.work_surface.effective_placement == WorkSurfacePlacement::Off {
app.work_surface.last_area = None;
app.work_surface.hitboxes.clear();
return 0;
}
// Non-Tasks panels always own a strip once selected: the user asked for
// the panel, so an empty panel collapses to a hint line, not a vanished
// rail.
if app.work_surface.panel != RailPanel::Tasks {
if app.work_surface.effective_placement != WorkSurfacePlacement::Top {
return 0;
}
let terminal_cap = terminal_height
.saturating_div(2)
.clamp(super::model::TOP_HEIGHT_MIN, super::model::TOP_HEIGHT_MAX);
let cap = app.work_surface.top_height.min(terminal_cap);
// Title row plus three content rows, bounded by the configured cap.
return 4.clamp(super::model::TOP_HEIGHT_MIN, cap);
}
let rows = project_visible(app);
if rows.is_empty() {
app.work_surface.focused = false;
@@ -75,11 +96,13 @@ pub fn height(app: &mut App, width: u16, terminal_height: u16, classic_shell: bo
/// Split the transcript slot for a side rail. Top placement consumes its own
/// vertical row before this point, so it returns the chat area unchanged.
/// Classic always resolves to Top and therefore preserves its existing layout.
pub fn split_chat(app: &mut App, area: Rect, classic_shell: bool) -> (Rect, Option<Rect>) {
let placement = effective_placement(app.work_surface.placement, area.width, classic_shell);
pub fn split_chat(app: &mut App, area: Rect) -> (Rect, Option<Rect>) {
let placement = effective_placement(app.work_surface.placement, area.width);
app.work_surface.effective_placement = placement;
if app.work_surface.latest_rows.is_empty() || placement == WorkSurfacePlacement::Top {
if placement == WorkSurfacePlacement::Top
|| placement == WorkSurfacePlacement::Off
|| (app.work_surface.panel == RailPanel::Tasks && app.work_surface.latest_rows.is_empty())
{
return (area, None);
}
@@ -117,7 +140,7 @@ pub fn split_chat(app: &mut App, area: Rect, classic_shell: bool) -> (Rect, Opti
..area
}),
),
WorkSurfacePlacement::Top => (area, None),
WorkSurfacePlacement::Top | WorkSurfacePlacement::Off => (area, None),
}
}
@@ -134,6 +157,11 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
}
let placement = app.work_surface.effective_placement;
// Off renders no rail; height()/split_chat() never hand us an area for it.
if placement == WorkSurfacePlacement::Off {
app.work_surface.last_area = None;
return;
}
let body_area = match placement {
WorkSurfacePlacement::Top => Rect {
height: area.height.saturating_sub(1),
@@ -148,8 +176,16 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
width: area.width.saturating_sub(1),
..area
},
WorkSurfacePlacement::Off => unreachable!("off placement returned above"),
};
// Non-Tasks panels render as a titled line list and skip the row
// machinery (hitboxes, selection, todo ordinals) entirely.
if app.work_surface.panel != RailPanel::Tasks {
render_panel(frame, area, body_area, app);
return;
}
let mut rows = project_visible(app);
if placement == WorkSurfacePlacement::Top {
// The top bar is the literal list: to-dos first, then sub-agents.
@@ -320,6 +356,86 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
});
}
/// Render a non-Tasks rail panel (Agents / Context / Pinned) as a titled
/// line list in the same body area and with the same divider and scrollbar
/// the Tasks list would use. Row interactivity (hitboxes, selection,
/// click actions) is Tasks-only for now; panels scroll via the shared
/// `scroll_offset`.
fn render_panel(frame: &mut Frame, area: Rect, body_area: Rect, app: &mut App) {
let panel = app.work_surface.panel;
let placement = app.work_surface.effective_placement;
Block::default()
.style(Style::default().bg(app.ui_theme.surface_bg))
.render(area, frame.buffer_mut());
// Title row.
Paragraph::new(Line::from(Span::styled(
truncate_line_to_width(panel.title(), usize::from(body_area.width).max(1)),
Style::default()
.fg(app.ui_theme.accent_primary)
.bg(app.ui_theme.surface_bg)
.add_modifier(Modifier::BOLD),
)))
.render(
Rect {
height: 1,
..body_area
},
frame.buffer_mut(),
);
let content_area = Rect {
y: body_area.y.saturating_add(1),
height: body_area.height.saturating_sub(1),
..body_area
};
let body_height = usize::from(content_area.height);
let lines = super::panels::panel_lines(
app,
panel,
usize::from(content_area.width),
body_height.max(1),
)
.unwrap_or_default();
let max_offset = lines.len().saturating_sub(body_height.max(1));
app.work_surface.scroll_offset = app.work_surface.scroll_offset.min(max_offset);
let overflow = lines.len() > body_height;
let visible: Vec<Line> = lines
.iter()
.skip(app.work_surface.scroll_offset)
.take(body_height)
.cloned()
.collect();
Paragraph::new(visible).render(content_area, frame.buffer_mut());
render_divider(frame, area, placement, app);
if overflow {
render_scrollbar(
frame,
Rect {
x: body_area.right().saturating_sub(1),
y: content_area.y,
width: 1,
height: content_area.height,
},
app.work_surface.scroll_offset,
body_height,
lines.len(),
app,
);
}
app.work_surface.last_area = Some(area);
app.work_surface.visible_rows = body_height;
app.work_surface.total_rows = lines.len();
app.work_surface.hitboxes.clear();
app.work_surface.selected = None;
app.work_surface.opened = None;
app.work_surface.hovered = None;
}
fn todo_ordinals(rows: &[WorkRow]) -> HashMap<String, usize> {
rows.iter()
.filter(|row| row.id.0.starts_with("graph:"))
@@ -390,6 +506,7 @@ fn render_divider(frame: &mut Frame, area: Rect, placement: WorkSurfacePlacement
app.ui_theme.border
};
match placement {
WorkSurfacePlacement::Off => {}
WorkSurfacePlacement::Top => {
let y = area.bottom().saturating_sub(1);
for x in area.left()..area.right() {
+11 -5
View File
@@ -411,7 +411,10 @@ fn assert_empty_state_hierarchy(frame: &qa_harness::Frame, ascii_safe: bool) {
>= 8
}
});
if frame.cols() >= 80 && frame.rows() >= 24 {
// The unified rail's top strip owns up to four rows when a non-Tasks
// panel is selected, so the big BlueWhale is earned at 28+ rows now
// (pre-rail: 24). Smaller terminals legitimately shed the whale.
if frame.cols() >= 80 && frame.rows() >= 28 {
assert!(
whale_row.is_some(),
"idle whale missing where the terminal earns decorative water:\n{dump}"
@@ -1248,7 +1251,7 @@ fn real_coordination_details_use_typed_persisted_receipts_in_a_unix_pty() -> any
std::fs::write(
ws.home().join(".codewhale").join("settings.toml"),
"work_surface_placement = \"right\"\n",
"work_surface_placement = \"right\"\nrail_panel = \"tasks\"\n",
)?;
let (_ws, mut h) = spawn_minimal_with_env(ws, &[])?;
h.wait_for_text("Coordination Work", KEY_TIMEOUT)?;
@@ -1404,7 +1407,7 @@ fn work_and_permission_are_visible_at_release_terminal_sizes() -> anyhow::Result
)?;
std::fs::write(
codewhale_home.join("settings.toml"),
"permission_posture = \"full-access\"\n",
"permission_posture = \"full-access\"\nrail_panel = \"tasks\"\n",
)?;
std::fs::write(
codex_home.join("models_cache.json"),
@@ -1545,7 +1548,7 @@ fn legacy_work_ctrl_t_save_export_and_restart_are_consistent() -> anyhow::Result
)?;
std::fs::write(
codewhale_home.join("settings.toml"),
"permission_posture = \"full-access\"\n",
"permission_posture = \"full-access\"\nrail_panel = \"tasks\"\n",
)?;
std::fs::write(
codex_home.join("models_cache.json"),
@@ -3619,7 +3622,10 @@ fn real_tool_lifecycle_crosses_work_status_resize_and_scroll_in_a_unix_pty() ->
)?;
std::fs::write(
codewhale_home.join("settings.toml"),
"theme = \"dark\"\nlocale = \"en\"\ndefault_mode = \"agent\"\npermission_posture = \"full-access\"\nlow_motion = false\nfancy_animations = true\ncomposer_border = true\n",
// Pin the rail's Tasks panel: the running phase asserts the To-do
// strip chrome, and an empty Tasks strip hides outright, so the
// idle whale phase keeps its full-height ocean.
"theme = \"dark\"\nlocale = \"en\"\ndefault_mode = \"agent\"\npermission_posture = \"full-access\"\nlow_motion = false\nfancy_animations = true\ncomposer_border = true\nrail_panel = \"tasks\"\n",
)?;
std::fs::write(
codex_home.join("models_cache.json"),