Merge branch 'agent/v094-rail-rebase-20260803' (resolved: accept panel unification)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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": "लोकल या फ़ॉरवर्ड किए ग्राफ़िकल क्लिपबोर्ड से टेक्स्ट पेस्ट करें या इमेज अटैच करें",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ローカルまたは転送されたグラフィカルクリップボードからテキストを貼り付けるか画像を添付",
|
||||
|
||||
@@ -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": "로컬 또는 전달된 그래픽 클립보드에서 텍스트를 붙여넣거나 이미지 첨부",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Вставить текст или прикрепить изображение из локального или проброшенного графического буфера обмена",
|
||||
|
||||
@@ -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": "Вставити текст або додати зображення з локального чи перенаправленого графічного буфера обміну",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "从本地或转发的图形剪贴板粘贴文本或附加图片",
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ pub(crate) const ADVANCED_DISCOVERY_COMMANDS: &[&str] = &[
|
||||
"rlm",
|
||||
"settings",
|
||||
"share",
|
||||
"sidebar",
|
||||
"rail",
|
||||
"status",
|
||||
"system",
|
||||
"theme",
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
+62
-1516
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user