feat(tui): integrate native full pet mode into the 0.10.2 candidate

Merge origin/feat/tui-native-pet-mode (fdae4dd1a0) into the 0.10.2
candidate (1bddf50a7e) on integrate/0.10.2-pet. The two changelogs
conflicted: the pet note now sits under 0.10.2 Added, cites the shared
components as codewhale-ratatui #23 and #25, and the 0.10.2 intro names
the pet view. Everything else auto-merged.

The vendored ratatui kit already carries upstream 6f0ab44 (#25) for the
pet path: lib.rs, whale_motion/ and assets/whale-motion/ are byte-identical.
It differs from upstream in three deliberate ways: the 0.10.2 Terminal
panel added by 1c82c935c9 (components/workbar.rs, gallery/workbar.rs,
gallery/showcase.rs); upstream's gallery avatar showcase, which the TUI pet
path does not use; and a doc line plus module order in avatar_sprite.rs and
components/mod.rs. The vendor manifest (UPSTREAM.json) still records
2ad795b3, so the kit owner should re-sync it or record these differences.

Not yet compiled, tested or installed. Build, focused tests, the npm gate
and installed-binary checks follow on this commit.

Refs #6920 #6907 SHA-6533 SHA-6645

Signed-off-by: CodeWhale Bot <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Haiku 5.5 committed 2026-10-08 17:54:39 -07:00
commit 80e1d2a9be
47 files changed
+4037 -308

No files matched your search

+15 -4
View File
@@ -9,13 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.10.2] - 2026-10-08
Codewhale v0.10.2 adds a live Terminal work dock and improves recovery,
plugin approval and provider reliability. `/undo` takes back a whole request,
`/diff` works outside a Git repository, and contributor fixes improve token
accounting, localized replies and Windows launcher guidance.
Codewhale v0.10.2 adds an animated pet view (`/pet on`) and a live Terminal work
dock, and improves recovery, plugin approval and provider reliability. `/undo`
takes back a whole request, `/diff` works outside a Git repository, and
contributor fixes improve token accounting, localized replies and Windows
launcher guidance.
### Added
- `/pet on` makes the animated GPUI whale the main terminal view, with the
existing message box, queued messages and permission controls always available.
F5 or `/pet inspect` opens streamed replies, errors and the current session's
agents; Escape returns to the same pet view and draft. `/pet off` restores the
ordinary shell. The selected view is remembered across launches, and the
inspector can copy the last finished reply with c or its footer action.
Motion preferences and unknown usage stay truthful (#6920),
using the shared `codewhale-ratatui` components
([#23](https://github.com/codewhale-hq/codewhale-ratatui/pull/23), [#25](https://github.com/codewhale-hq/codewhale-ratatui/pull/25)).
- A Terminal work dock (`/workbar terminal`) shows the model’s live PTY sessions
in the current workspace, with ANSI-styled output, session selection, resize
and direct keyboard input. `Ctrl+N` starts a separate shell; `Alt+Down` and
+11
View File
@@ -545,6 +545,17 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"off",
None,
),
def(
"pet_mode",
SettingKind::Bool(ON_OFF),
"false",
ui(
TAB_APPEARANCE,
"display",
"ConfigLabelPetMode",
"ConfigHintPetMode",
),
),
def(
"calm_mode",
SettingKind::Bool(ON_OFF),
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "Connectat",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Un hàbitat compartit",
"PetHabitatHints": "{back} enrere · {sound} so · {browser} aparença · {window} finestra",
"PetHabitatQueued": "Enviat a l’hàbitat compartit. /pet on obre la vista completa.",
"PetHabitatQueued": "Enviat a l’hàbitat compartit. /pet on mostra la mascota i el quadre de missatge.",
"PetWatchSoundOn": "So activat",
"PetWatchSoundOff": "So desactivat",
"PetWatchSoundPaused": "So en pausa",
"PetWatchSoundUnavailable": "El so de Codewhale s’ha aturat. Comprova l’FFmpeg (ffplay) i la sortida d’àudio i torna a activar el so.",
"CmdPetDescription": "Cedeix la pantalla a la mascota Codewhale mentre treballa",
"PetModeOn": "Mode mascota activat. L’hàbitat ocupa la pantalla mentre Codewhale treballa; Esc torna al compositor.",
"CmdPetDescription": "Fes servir la mascota animada com a vista principal de Codewhale",
"ConfigLabelPetMode": "Mode mascota",
"ConfigHintPetMode": "Mostra la mascota animada com a vista principal quan s’inicia Codewhale.",
"PetModeCopyHint": "{copy} copia la resposta acabada",
"PetModeOn": "Mode mascota activat. Escriu al quadre de missatge; /pet inspect obre les respostes i els agents. /pet off restaura la vista estàndard.",
"PetModeOff": "Mode mascota desactivat. La mascota continua vivint al seu company; /pet el torna a activar.",
"PetModeReplyTitle": "Resposta",
"PetModeMessageHint": "{back} escriure",
"PetModePaneHint": "{switch} canviar vista",
"PetModeAgentHints": "{select} seleccionar · {open} conversa",
"PetModeReplyHints": "{scroll} desplaçar · {latest} més recent",
"PetModeCompanionHints": "{sound} so · {browser} aparença · {window} finestra",
"PetModeInspectHint": "{inspect} respostes i agents · /pet off vista estàndard",
"PetModeOnLabel": "mode mascota activat",
"PetModeOffLabel": "mode mascota desactivat",
"PetViewOpen": "hàbitat obert",
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "Verbunden",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Ein gemeinsamer Lebensraum",
"PetHabitatHints": "{back} zurück · {sound} Ton · {browser} Aussehen · {window} Fenster",
"PetHabitatQueued": "An den gemeinsamen Lebensraum gesendet. /pet on öffnet die Vollansicht.",
"PetHabitatQueued": "An den gemeinsamen Lebensraum gesendet. /pet on zeigt das Haustier und das Nachrichtenfeld.",
"PetWatchSoundOn": "Ton an",
"PetWatchSoundOff": "Ton aus",
"PetWatchSoundPaused": "Ton pausiert",
"PetWatchSoundUnavailable": "Die Tonausgabe wurde gestoppt. Prüfe FFmpeg (ffplay) und deinen Audioausgang und schalte den Ton erneut ein.",
"CmdPetDescription": "Überlässt dem Codewhale-Haustier den Bildschirm, während es arbeitet",
"PetModeOn": "Haustiermodus an. Der Lebensraum übernimmt den Bildschirm, während Codewhale arbeitet; Esc kehrt zur Eingabe zurück.",
"CmdPetDescription": "Nutze das animierte Haustier als Hauptansicht von Codewhale",
"ConfigLabelPetMode": "Haustiermodus",
"ConfigHintPetMode": "Beim Start von Codewhale das animierte Haustier als Hauptansicht verwenden.",
"PetModeCopyHint": "{copy} fertige Antwort kopieren",
"PetModeOn": "Haustiermodus aktiviert. Schreibe im Nachrichtenfeld; /pet inspect öffnet Antworten und Agenten. /pet off stellt die Standardansicht wieder her.",
"PetModeOff": "Haustiermodus aus. Das Haustier lebt in seinem Begleiter weiter; /pet schaltet es wieder ein.",
"PetModeReplyTitle": "Antwort",
"PetModeMessageHint": "{back} Nachricht schreiben",
"PetModePaneHint": "{switch} Ansicht wechseln",
"PetModeAgentHints": "{select} auswählen · {open} Verlauf",
"PetModeReplyHints": "{scroll} scrollen · {latest} neueste",
"PetModeCompanionHints": "{sound} Ton · {browser} Aussehen · {window} Fenster",
"PetModeInspectHint": "{inspect} Antworten und Agenten · /pet off Standardansicht",
"PetModeOnLabel": "Haustiermodus an",
"PetModeOffLabel": "Haustiermodus aus",
"PetViewOpen": "Lebensraum offen",
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "Connected",
"PetHabitatTitle": "CODEWHALE / WHALESONG · One shared habitat",
"PetHabitatHints": "{back} back · {sound} sound · {browser} appearance · {window} window",
"PetHabitatQueued": "Sent to the shared habitat. /pet on opens the full view.",
"PetHabitatQueued": "Sent to the shared habitat. /pet on shows the pet with your message box.",
"PetWatchSoundOn": "Sound on",
"PetWatchSoundOff": "Sound off",
"PetWatchSoundPaused": "Sound paused",
"PetWatchSoundUnavailable": "Pet sound stopped. Check FFmpeg (ffplay) and your audio output, then enable sound again.",
"CmdPetDescription": "Turn the screen over to the Codewhale pet while it works",
"PetModeOn": "Pet mode on. The habitat takes the screen while Codewhale works; Esc returns to the composer.",
"CmdPetDescription": "Use the animated pet as your main Codewhale view",
"ConfigLabelPetMode": "Pet mode",
"ConfigHintPetMode": "Use the animated pet as the main view when Codewhale starts.",
"PetModeCopyHint": "{copy} copy finished reply",
"PetModeOn": "Pet mode on. Write in the message box; /pet inspect opens replies and agents. /pet off restores the standard view.",
"PetModeOff": "Pet mode off. The pet keeps living in its companion; /pet turns it back on.",
"PetModeReplyTitle": "Reply",
"PetModeMessageHint": "{back} message",
"PetModePaneHint": "{switch} switch view",
"PetModeAgentHints": "{select} select · {open} transcript",
"PetModeReplyHints": "{scroll} scroll · {latest} latest",
"PetModeCompanionHints": "{sound} sound · {browser} appearance · {window} window",
"PetModeInspectHint": "{inspect} replies & agents · /pet off standard view",
"PetModeOnLabel": "pet mode on",
"PetModeOffLabel": "pet mode off",
"PetViewOpen": "habitat open",
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "Conectado",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Un hábitat compartido",
"PetHabitatHints": "{back} volver · {sound} sonido · {browser} apariencia · {window} ventana",
"PetHabitatQueued": "Enviado al hábitat compartido. /pet on abre la vista completa.",
"PetHabitatQueued": "Enviado al hábitat compartido. /pet on muestra la mascota y el cuadro de mensaje.",
"PetWatchSoundOn": "Sonido activado",
"PetWatchSoundOff": "Sonido desactivado",
"PetWatchSoundPaused": "Sonido en pausa",
"PetWatchSoundUnavailable": "El sonido de Codewhale se detuvo. Revisa FFmpeg (ffplay) y la salida de audio, y vuelve a activar el sonido.",
"CmdPetDescription": "Cede la pantalla a la mascota Codewhale mientras trabaja",
"PetModeOn": "Modo mascota activado. El hábitat ocupa la pantalla mientras Codewhale trabaja; Esc vuelve al compositor.",
"CmdPetDescription": "Usa la mascota animada como vista principal de Codewhale",
"ConfigLabelPetMode": "Modo mascota",
"ConfigHintPetMode": "Usa la mascota animada como vista principal al iniciar Codewhale.",
"PetModeCopyHint": "{copy} copiar respuesta terminada",
"PetModeOn": "Modo mascota activado. Escribe en el cuadro de mensaje; /pet inspect abre respuestas y agentes. /pet off restaura la vista estándar.",
"PetModeOff": "Modo mascota desactivado. La mascota sigue viviendo en su compañero; /pet lo vuelve a activar.",
"PetModeReplyTitle": "Respuesta",
"PetModeMessageHint": "{back} escribir",
"PetModePaneHint": "{switch} cambiar vista",
"PetModeAgentHints": "{select} seleccionar · {open} conversación",
"PetModeReplyHints": "{scroll} desplazar · {latest} más reciente",
"PetModeCompanionHints": "{sound} sonido · {browser} apariencia · {window} ventana",
"PetModeInspectHint": "{inspect} respuestas y agentes · /pet off vista estándar",
"PetModeOnLabel": "modo mascota activado",
"PetModeOffLabel": "modo mascota desactivado",
"PetViewOpen": "hábitat abierto",
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "Connecté",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Un habitat partagé",
"PetHabitatHints": "{back} retour · {sound} son · {browser} apparence · {window} fenêtre",
"PetHabitatQueued": "Envoyé à l’habitat partagé. /pet on ouvre la vue complète.",
"PetHabitatQueued": "Envoyé à l’habitat partagé. /pet on affiche l’animal et le champ de message.",
"PetWatchSoundOn": "Son activé",
"PetWatchSoundOff": "Son désactivé",
"PetWatchSoundPaused": "Son en pause",
"PetWatchSoundUnavailable": "Le son de Codewhale s’est arrêté. Vérifiez FFmpeg (ffplay) et la sortie audio, puis réactivez le son.",
"CmdPetDescription": "Confie l’écran à la mascotte Codewhale pendant le travail",
"PetModeOn": "Mode mascotte activé. L’habitat occupe l’écran pendant que Codewhale travaille ; Échap revient au compositeur.",
"CmdPetDescription": "Utiliser l’animal animé comme vue principale de Codewhale",
"ConfigLabelPetMode": "Mode compagnon",
"ConfigHintPetMode": "Utiliser le compagnon animé comme vue principale au démarrage de Codewhale.",
"PetModeCopyHint": "{copy} copier la réponse terminée",
"PetModeOn": "Mode animal activé. Écrivez dans le champ de message ; /pet inspect ouvre les réponses et les agents. /pet off rétablit la vue standard.",
"PetModeOff": "Mode mascotte désactivé. La mascotte continue de vivre dans son compagnon ; /pet le réactive.",
"PetModeReplyTitle": "Réponse",
"PetModeMessageHint": "{back} écrire",
"PetModePaneHint": "{switch} changer de vue",
"PetModeAgentHints": "{select} sélectionner · {open} conversation",
"PetModeReplyHints": "{scroll} défiler · {latest} dernière",
"PetModeCompanionHints": "{sound} son · {browser} apparence · {window} fenêtre",
"PetModeInspectHint": "{inspect} réponses et agents · /pet off vue standard",
"PetModeOnLabel": "mode mascotte activé",
"PetModeOffLabel": "mode mascotte désactivé",
"PetViewOpen": "habitat ouvert",
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "कनेक्ट हो गया",
"PetHabitatTitle": "CODEWHALE / WHALESONG · एक साझा आवास",
"PetHabitatHints": "{back} वापस · {sound} ध्वनि · {browser} रूप · {window} विंडो",
"PetHabitatQueued": "साझा आवास को भेजा गया। /pet on से पूरा दृश्य खुलता है।",
"PetHabitatQueued": "साझा आवास में भेज दिया गया। /pet on से पेट और संदेश बॉक्स दिखते हैं।",
"PetWatchSoundOn": "ध्वनि चालू",
"PetWatchSoundOff": "ध्वनि बंद",
"PetWatchSoundPaused": "ध्वनि रुकी हुई",
"PetWatchSoundUnavailable": "Codewhale की ध्वनि रुक गई। FFmpeg (ffplay) और ऑडियो आउटपुट की जाँच करें, फिर ध्वनि फिर से चालू करें।",
"CmdPetDescription": "काम के दौरान स्क्रीन Codewhale पालतू को सौंपें",
"PetModeOn": "पालतू मोड चालू। Codewhale के काम करते समय आवास स्क्रीन लेता है; Esc से कंपोज़र पर लौटें।",
"CmdPetDescription": "Codewhale के मुख्य दृश्य में एनिमेटेड पेट इस्तेमाल करें",
"ConfigLabelPetMode": "पेट मोड",
"ConfigHintPetMode": "Codewhale शुरू होने पर एनिमेटेड पेट को मुख्य दृश्य के रूप में दिखाएँ।",
"PetModeCopyHint": "{copy} पूरा उत्तर कॉपी करें",
"PetModeOn": "पेट मोड चालू है। संदेश बॉक्स में लिखें; /pet inspect से उत्तर और एजेंट खोलें। /pet off से सामान्य दृश्य पर लौटें।",
"PetModeOff": "पालतू मोड बंद। पालतू अपने साथी में रहता है; /pet से फिर चालू करें।",
"PetModeReplyTitle": "उत्तर",
"PetModeMessageHint": "{back} संदेश लिखें",
"PetModePaneHint": "{switch} दृश्य बदलें",
"PetModeAgentHints": "{select} चुनें · {open} बातचीत",
"PetModeReplyHints": "{scroll} स्क्रॉल · {latest} नवीनतम",
"PetModeCompanionHints": "{sound} ध्वनि · {browser} रूप · {window} विंडो",
"PetModeInspectHint": "{inspect} उत्तर और एजेंट · /pet off सामान्य दृश्य",
"PetModeOnLabel": "पालतू मोड चालू",
"PetModeOffLabel": "पालतू मोड बंद",
"PetViewOpen": "आवास खुला",
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "Terhubung",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Satu habitat bersama",
"PetHabitatHints": "{back} kembali · {sound} suara · {browser} tampilan · {window} jendela",
"PetHabitatQueued": "Dikirim ke habitat bersama. /pet on membuka tampilan penuh.",
"PetHabitatQueued": "Dikirim ke habitat bersama. /pet on menampilkan peliharaan dan kotak pesan.",
"PetWatchSoundOn": "Suara aktif",
"PetWatchSoundOff": "Suara nonaktif",
"PetWatchSoundPaused": "Suara dijeda",
"PetWatchSoundUnavailable": "Suara Codewhale berhenti. Periksa FFmpeg (ffplay) dan keluaran audio, lalu aktifkan suara kembali.",
"CmdPetDescription": "Serahkan layar ke hewan peliharaan Codewhale saat bekerja",
"PetModeOn": "Mode hewan peliharaan aktif. Habitat mengambil layar saat Codewhale bekerja; Esc kembali ke komposer.",
"CmdPetDescription": "Gunakan peliharaan animasi sebagai tampilan utama Codewhale",
"ConfigLabelPetMode": "Mode peliharaan",
"ConfigHintPetMode": "Gunakan peliharaan animasi sebagai tampilan utama saat Codewhale dimulai.",
"PetModeCopyHint": "{copy} salin jawaban selesai",
"PetModeOn": "Mode peliharaan aktif. Tulis di kotak pesan; /pet inspect membuka balasan dan agen. /pet off mengembalikan tampilan biasa.",
"PetModeOff": "Mode hewan peliharaan nonaktif. Hewan peliharaan tetap hidup di pendampingnya; /pet mengaktifkannya lagi.",
"PetModeReplyTitle": "Balasan",
"PetModeMessageHint": "{back} tulis pesan",
"PetModePaneHint": "{switch} ganti tampilan",
"PetModeAgentHints": "{select} pilih · {open} percakapan",
"PetModeReplyHints": "{scroll} gulir · {latest} terbaru",
"PetModeCompanionHints": "{sound} suara · {browser} tampilan · {window} jendela",
"PetModeInspectHint": "{inspect} balasan dan agen · /pet off tampilan biasa",
"PetModeOnLabel": "mode hewan peliharaan aktif",
"PetModeOffLabel": "mode hewan peliharaan nonaktif",
"PetViewOpen": "habitat terbuka",
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "接続済み",
"PetHabitatTitle": "CODEWHALE / WHALESONG · 共有ハビタット",
"PetHabitatHints": "{back} 戻る · {sound} 音 · {browser} 外観 · {window} ウィンドウ",
"PetHabitatQueued": "共有ハビタットに送信しました。/pet on で全画面表示を開きます。",
"PetHabitatQueued": "共有の生息地に送りました。/pet on でペットと入力欄を表示します。",
"PetWatchSoundOn": "サウンド有効",
"PetWatchSoundOff": "サウンド無効",
"PetWatchSoundPaused": "サウンド一時停止",
"PetWatchSoundUnavailable": "Codewhaleのサウンドが停止しました。FFmpeg (ffplay) とオーディオ出力を確認し、サウンドを再度有効にしてください。",
"CmdPetDescription": "作業中は画面を Codewhale のペットに任せる",
"PetModeOn": "ペットモード オン。Codewhale の作業中はハビタットが画面を占めます。Esc で入力欄に戻ります。",
"CmdPetDescription": "動くペットをCodewhaleのメイン画面にする",
"ConfigLabelPetMode": "ペットモード",
"ConfigHintPetMode": "Codewhale の起動時にアニメーションするペットをメイン画面に表示します。",
"PetModeCopyHint": "{copy} 完了した返信をコピー",
"PetModeOn": "ペットモードを有効にしました。入力欄からメッセージを送れます。/pet inspect で返信とエージェントを表示し、/pet off で通常表示に戻ります。",
"PetModeOff": "ペットモード オフ。ペットはコンパニオンで生き続けます。/pet で再びオンにします。",
"PetModeReplyTitle": "返信",
"PetModeMessageHint": "{back} メッセージへ",
"PetModePaneHint": "{switch} 表示を切替",
"PetModeAgentHints": "{select} 選択 · {open} 会話履歴",
"PetModeReplyHints": "{scroll} スクロール · {latest} 最新へ",
"PetModeCompanionHints": "{sound} 音声 · {browser} 外観 · {window} ウィンドウ",
"PetModeInspectHint": "{inspect} 返信・エージェント · /pet off 通常表示",
"PetModeOnLabel": "ペットモード オン",
"PetModeOffLabel": "ペットモード オフ",
"PetViewOpen": "ハビタット表示中",
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "연결됨",
"PetHabitatTitle": "CODEWHALE / WHALESONG · 하나의 공유 서식지",
"PetHabitatHints": "{back} 뒤로 · {sound} 소리 · {browser} 외관 · {window} 창",
"PetHabitatQueued": "공유 서식지로 보냈습니다. /pet on 명령으로 전체 화면을 엽니다.",
"PetHabitatQueued": "공유 서식지로 보냈습니다. /pet on으로 펫과 메시지 입력란을 표시합니다.",
"PetWatchSoundOn": "소리 켜짐",
"PetWatchSoundOff": "소리 꺼짐",
"PetWatchSoundPaused": "소리 일시 중지",
"PetWatchSoundUnavailable": "Codewhale 소리가 멈췄습니다. FFmpeg (ffplay)와 오디오 출력을 확인한 후 소리를 다시 켜세요.",
"CmdPetDescription": "작업 중 화면을 Codewhale 펫에게 넘깁니다",
"PetModeOn": "펫 모드 켜짐. Codewhale가 작업하는 동안 서식지가 화면을 차지합니다. Esc로 입력창으로 돌아갑니다.",
"CmdPetDescription": "움직이는 펫을 Codewhale의 기본 화면으로 사용",
"ConfigLabelPetMode": "펫 모드",
"ConfigHintPetMode": "Codewhale를 시작할 때 애니메이션 펫을 기본 화면으로 표시합니다.",
"PetModeCopyHint": "{copy} 완료된 답변 복사",
"PetModeOn": "펫 모드를 켰습니다. 메시지 입력란에 작성하세요. /pet inspect로 답변과 에이전트를 열고 /pet off로 기본 보기로 돌아갑니다.",
"PetModeOff": "펫 모드 꺼짐. 펫은 컴패니언에서 계속 살아갑니다. /pet 으로 다시 켭니다.",
"PetModeReplyTitle": "답변",
"PetModeMessageHint": "{back} 메시지 작성",
"PetModePaneHint": "{switch} 보기 전환",
"PetModeAgentHints": "{select} 선택 · {open} 대화 기록",
"PetModeReplyHints": "{scroll} 스크롤 · {latest} 최신으로",
"PetModeCompanionHints": "{sound} 소리 · {browser} 외형 · {window} 창",
"PetModeInspectHint": "{inspect} 답변 및 에이전트 · /pet off 기본 보기",
"PetModeOnLabel": "펫 모드 켜짐",
"PetModeOffLabel": "펫 모드 꺼짐",
"PetViewOpen": "서식지 열림",
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "Conectado",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Um habitat compartilhado",
"PetHabitatHints": "{back} voltar · {sound} som · {browser} aparência · {window} janela",
"PetHabitatQueued": "Enviado ao habitat compartilhado. /pet on abre a visualização completa.",
"PetHabitatQueued": "Enviado ao habitat compartilhado. /pet on mostra o pet e a caixa de mensagem.",
"PetWatchSoundOn": "Som ligado",
"PetWatchSoundOff": "Som desligado",
"PetWatchSoundPaused": "Som pausado",
"PetWatchSoundUnavailable": "O som do Codewhale parou. Verifique o FFmpeg (ffplay) e a saída de áudio e ligue o som novamente.",
"CmdPetDescription": "Entrega a tela ao pet do Codewhale enquanto ele trabalha",
"PetModeOn": "Modo pet ligado. O habitat ocupa a tela enquanto o Codewhale trabalha; Esc volta ao compositor.",
"CmdPetDescription": "Use o pet animado como a visão principal do Codewhale",
"ConfigLabelPetMode": "Modo pet",
"ConfigHintPetMode": "Use o pet animado como visualização principal ao iniciar o Codewhale.",
"PetModeCopyHint": "{copy} copiar resposta concluída",
"PetModeOn": "Modo pet ativado. Escreva na caixa de mensagem; /pet inspect abre respostas e agentes. /pet off restaura a visão padrão.",
"PetModeOff": "Modo pet desligado. O pet continua vivendo no seu companheiro; /pet liga de novo.",
"PetModeReplyTitle": "Resposta",
"PetModeMessageHint": "{back} escrever",
"PetModePaneHint": "{switch} mudar visão",
"PetModeAgentHints": "{select} selecionar · {open} conversa",
"PetModeReplyHints": "{scroll} rolar · {latest} mais recente",
"PetModeCompanionHints": "{sound} som · {browser} aparência · {window} janela",
"PetModeInspectHint": "{inspect} respostas e agentes · /pet off visão padrão",
"PetModeOnLabel": "modo pet ligado",
"PetModeOffLabel": "modo pet desligado",
"PetViewOpen": "habitat aberto",
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "Подключено",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Общая среда обитания",
"PetHabitatHints": "{back} назад · {sound} звук · {browser} внешний вид · {window} окно",
"PetHabitatQueued": "Отправлено в общую среду обитания. /pet on открывает полный вид.",
"PetHabitatQueued": "Отправлено в общую среду обитания. /pet on показывает питомца и поле сообщения.",
"PetWatchSoundOn": "Звук включён",
"PetWatchSoundOff": "Звук выключен",
"PetWatchSoundPaused": "Звук приостановлен",
"PetWatchSoundUnavailable": "Звук Codewhale остановлен. Проверьте FFmpeg (ffplay) и аудиовыход, затем снова включите звук.",
"CmdPetDescription": "Отдаёт экран питомцу Codewhale на время работы",
"PetModeOn": "Режим питомца включён. Среда занимает экран, пока Codewhale работает; Esc возвращает к вводу.",
"CmdPetDescription": "Сделать анимированного питомца главным экраном Codewhale",
"ConfigLabelPetMode": "Режим питомца",
"ConfigHintPetMode": "Показывать анимированного питомца на главном экране при запуске Codewhale.",
"PetModeCopyHint": "{copy} копировать готовый ответ",
"PetModeOn": "Режим питомца включён. Пишите в поле сообщения; /pet inspect открывает ответы и агентов. /pet off возвращает обычный вид.",
"PetModeOff": "Режим питомца выключен. Питомец продолжает жить в компаньоне; /pet включает его снова.",
"PetModeReplyTitle": "Ответ",
"PetModeMessageHint": "{back} написать",
"PetModePaneHint": "{switch} сменить вид",
"PetModeAgentHints": "{select} выбрать · {open} переписка",
"PetModeReplyHints": "{scroll} прокрутка · {latest} к последнему",
"PetModeCompanionHints": "{sound} звук · {browser} внешний вид · {window} окно",
"PetModeInspectHint": "{inspect} ответы и агенты · /pet off обычный вид",
"PetModeOnLabel": "режим питомца включён",
"PetModeOffLabel": "режим питомца выключен",
"PetViewOpen": "среда открыта",
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "Підключено",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Спільне середовище",
"PetHabitatHints": "{back} назад · {sound} звук · {browser} вигляд · {window} вікно",
"PetHabitatQueued": "Надіслано до спільного середовища. /pet on відкриває повний вигляд.",
"PetHabitatQueued": "Надіслано до спільного середовища. /pet on показує улюбленця й поле повідомлення.",
"PetWatchSoundOn": "Звук увімкнено",
"PetWatchSoundOff": "Звук вимкнено",
"PetWatchSoundPaused": "Звук призупинено",
"PetWatchSoundUnavailable": "Звук Codewhale зупинено. Перевірте FFmpeg (ffplay) та аудіовихід, а потім знову ввімкніть звук.",
"CmdPetDescription": "Віддає екран улюбленцю Codewhale на час роботи",
"PetModeOn": "Режим улюбленця ввімкнено. Середовище займає екран, поки Codewhale працює; Esc повертає до поля вводу.",
"CmdPetDescription": "Зробити анімованого улюбленця головним екраном Codewhale",
"ConfigLabelPetMode": "Режим улюбленця",
"ConfigHintPetMode": "Показувати анімованого улюбленця на головному екрані під час запуску Codewhale.",
"PetModeCopyHint": "{copy} копіювати готову відповідь",
"PetModeOn": "Режим улюбленця ввімкнено. Пишіть у полі повідомлення; /pet inspect відкриває відповіді й агентів. /pet off повертає звичайний вигляд.",
"PetModeOff": "Режим улюбленця вимкнено. Улюбленець далі живе в компаньйоні; /pet вмикає його знову.",
"PetModeReplyTitle": "Відповідь",
"PetModeMessageHint": "{back} написати",
"PetModePaneHint": "{switch} змінити вигляд",
"PetModeAgentHints": "{select} вибрати · {open} розмова",
"PetModeReplyHints": "{scroll} прокручування · {latest} до останнього",
"PetModeCompanionHints": "{sound} звук · {browser} зовнішність · {window} вікно",
"PetModeInspectHint": "{inspect} відповіді й агенти · /pet off звичайний вигляд",
"PetModeOnLabel": "режим улюбленця ввімкнено",
"PetModeOffLabel": "режим улюбленця вимкнено",
"PetViewOpen": "середовище відкрите",
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "Đã kết nối",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Một môi trường sống chung",
"PetHabitatHints": "{back} quay lại · {sound} âm thanh · {browser} giao diện · {window} cửa sổ",
"PetHabitatQueued": "Đã gửi đến môi trường sống chung. /pet on mở chế độ xem toàn màn hình.",
"PetHabitatQueued": "Đã gửi đến môi trường sống chung. /pet on hiển thị thú cưng và ô tin nhắn.",
"PetWatchSoundOn": "Âm thanh bật",
"PetWatchSoundOff": "Âm thanh tắt",
"PetWatchSoundPaused": "Âm thanh tạm dừng",
"PetWatchSoundUnavailable": "Âm thanh Codewhale đã dừng. Hãy kiểm tra FFmpeg (ffplay) và đầu ra âm thanh, rồi bật lại âm thanh.",
"CmdPetDescription": "Giao màn hình cho thú cưng Codewhale khi đang làm việc",
"PetModeOn": "Đã bật chế độ thú cưng. Môi trường sống chiếm màn hình khi Codewhale làm việc; Esc quay lại ô soạn.",
"CmdPetDescription": "Dùng thú cưng hoạt hình làm giao diện chính của Codewhale",
"ConfigLabelPetMode": "Chế độ thú cưng",
"ConfigHintPetMode": "Dùng thú cưng hoạt hình làm giao diện chính khi khởi động Codewhale.",
"PetModeCopyHint": "{copy} sao chép trả lời hoàn tất",
"PetModeOn": "Đã bật chế độ thú cưng. Nhập vào ô tin nhắn; /pet inspect mở phản hồi và tác tử. /pet off trở về giao diện thường.",
"PetModeOff": "Đã tắt chế độ thú cưng. Thú cưng vẫn sống trong trình đồng hành; /pet bật lại.",
"PetModeReplyTitle": "Phản hồi",
"PetModeMessageHint": "{back} viết tin nhắn",
"PetModePaneHint": "{switch} đổi chế độ xem",
"PetModeAgentHints": "{select} chọn · {open} hội thoại",
"PetModeReplyHints": "{scroll} cuộn · {latest} mới nhất",
"PetModeCompanionHints": "{sound} âm thanh · {browser} giao diện · {window} cửa sổ",
"PetModeInspectHint": "{inspect} phản hồi và tác tử · /pet off giao diện thường",
"PetModeOnLabel": "chế độ thú cưng bật",
"PetModeOffLabel": "chế độ thú cưng tắt",
"PetViewOpen": "môi trường sống đang mở",
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "已连接",
"PetHabitatTitle": "CODEWHALE / WHALESONG · 共享栖息地",
"PetHabitatHints": "{back} 返回 · {sound} 声音 · {browser} 外观 · {window} 窗口",
"PetHabitatQueued": "已发送到共享栖息地。使用 /pet on 打开全屏视图。",
"PetHabitatQueued": "已发送到共享栖息地。/pet on 显示宠物与消息框。",
"PetWatchSoundOn": "声音已开启",
"PetWatchSoundOff": "声音已关闭",
"PetWatchSoundPaused": "声音已暂停",
"PetWatchSoundUnavailable": "Codewhale 声音已停止。请检查 FFmpeg (ffplay) 和音频输出,然后重新开启声音。",
"CmdPetDescription": "工作时把屏幕交给 Codewhale 宠物",
"PetModeOn": "宠物模式已开启。Codewhale 工作时栖息地占据屏幕;Esc 返回输入框。",
"CmdPetDescription": "将动画宠物设为 Codewhale 主视图",
"ConfigLabelPetMode": "宠物模式",
"ConfigHintPetMode": "启动 Codewhale 时,将动画宠物设为主界面。",
"PetModeCopyHint": "{copy} 复制已完成的回复",
"PetModeOn": "宠物模式已开启。在消息框中输入;/pet inspect 查看回复与智能体,/pet off 恢复标准视图。",
"PetModeOff": "宠物模式已关闭。宠物继续住在伴侣程序里;/pet 重新开启。",
"PetModeReplyTitle": "回复",
"PetModeMessageHint": "{back} 写消息",
"PetModePaneHint": "{switch} 切换视图",
"PetModeAgentHints": "{select} 选择 · {open} 对话记录",
"PetModeReplyHints": "{scroll} 滚动 · {latest} 最新",
"PetModeCompanionHints": "{sound} 声音 · {browser} 外观 · {window} 窗口",
"PetModeInspectHint": "{inspect} 回复与智能体 · /pet off 标准视图",
"PetModeOnLabel": "宠物模式开",
"PetModeOffLabel": "宠物模式关",
"PetViewOpen": "栖息地已打开",
+13 -3
View File
@@ -33,14 +33,24 @@
"MobileStreamConnected": "已連線",
"PetHabitatTitle": "CODEWHALE / WHALESONG · 共享棲息地",
"PetHabitatHints": "{back} 返回 · {sound} 聲音 · {browser} 外觀 · {window} 視窗",
"PetHabitatQueued": "已傳送至共享棲息地。使用 /pet on 開啟全螢幕檢視。",
"PetHabitatQueued": "已傳送到共享棲息地。/pet on 顯示寵物與訊息框。",
"PetWatchSoundOn": "聲音已開啟",
"PetWatchSoundOff": "聲音已關閉",
"PetWatchSoundPaused": "聲音已暫停",
"PetWatchSoundUnavailable": "Codewhale 聲音已停止。請檢查 FFmpeg (ffplay) 和音訊輸出,然後重新開啟聲音。",
"CmdPetDescription": "工作時把畫面交給 Codewhale 寵物",
"PetModeOn": "寵物模式已開啟。Codewhale 工作時棲息地佔據畫面;Esc 返回輸入框。",
"CmdPetDescription": "將動畫寵物設為 Codewhale 主檢視",
"ConfigLabelPetMode": "寵物模式",
"ConfigHintPetMode": "啟動 Codewhale 時,將動畫寵物設為主介面。",
"PetModeCopyHint": "{copy} 複製已完成的回覆",
"PetModeOn": "寵物模式已開啟。在訊息框中輸入;/pet inspect 查看回覆與代理程式,/pet off 恢復標準檢視。",
"PetModeOff": "寵物模式已關閉。寵物繼續住在伴侶程式裡;/pet 重新開啟。",
"PetModeReplyTitle": "回覆",
"PetModeMessageHint": "{back} 寫訊息",
"PetModePaneHint": "{switch} 切換檢視",
"PetModeAgentHints": "{select} 選取 · {open} 對話紀錄",
"PetModeReplyHints": "{scroll} 捲動 · {latest} 最新",
"PetModeCompanionHints": "{sound} 音效 · {browser} 外觀 · {window} 視窗",
"PetModeInspectHint": "{inspect} 回覆與代理程式 · /pet off 標準檢視",
"PetModeOnLabel": "寵物模式開",
"PetModeOffLabel": "寵物模式關",
"PetViewOpen": "棲息地已開啟",
+20
View File
@@ -866,9 +866,19 @@ pub enum MessageId {
PetWatchSoundPaused,
PetWatchSoundUnavailable,
CmdPetDescription,
ConfigLabelPetMode,
ConfigHintPetMode,
PetModeCopyHint,
PetModeOn,
PetModeOff,
PetModeOnLabel,
PetModeInspectHint,
PetModeReplyTitle,
PetModeMessageHint,
PetModePaneHint,
PetModeAgentHints,
PetModeReplyHints,
PetModeCompanionHints,
PetModeOffLabel,
PetViewOpen,
PetViewClosed,
@@ -3485,9 +3495,19 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::PetWatchSoundPaused,
MessageId::PetWatchSoundUnavailable,
MessageId::CmdPetDescription,
MessageId::ConfigLabelPetMode,
MessageId::ConfigHintPetMode,
MessageId::PetModeCopyHint,
MessageId::PetModeOn,
MessageId::PetModeOff,
MessageId::PetModeOnLabel,
MessageId::PetModeInspectHint,
MessageId::PetModeReplyTitle,
MessageId::PetModeMessageHint,
MessageId::PetModePaneHint,
MessageId::PetModeAgentHints,
MessageId::PetModeReplyHints,
MessageId::PetModeCompanionHints,
MessageId::PetModeOffLabel,
MessageId::PetViewOpen,
MessageId::PetViewClosed,
+15 -4
View File
@@ -9,13 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.10.2] - 2026-10-08
Codewhale v0.10.2 adds a live Terminal work dock and improves recovery,
plugin approval and provider reliability. `/undo` takes back a whole request,
`/diff` works outside a Git repository, and contributor fixes improve token
accounting, localized replies and Windows launcher guidance.
Codewhale v0.10.2 adds an animated pet view (`/pet on`) and a live Terminal work
dock, and improves recovery, plugin approval and provider reliability. `/undo`
takes back a whole request, `/diff` works outside a Git repository, and
contributor fixes improve token accounting, localized replies and Windows
launcher guidance.
### Added
- `/pet on` makes the animated GPUI whale the main terminal view, with the
existing message box, queued messages and permission controls always available.
F5 or `/pet inspect` opens streamed replies, errors and the current session's
agents; Escape returns to the same pet view and draft. `/pet off` restores the
ordinary shell. The selected view is remembered across launches, and the
inspector can copy the last finished reply with c or its footer action.
Motion preferences and unknown usage stay truthful (#6920),
using the shared `codewhale-ratatui` components
([#23](https://github.com/codewhale-hq/codewhale-ratatui/pull/23), [#25](https://github.com/codewhale-hq/codewhale-ratatui/pull/25)).
- A Terminal work dock (`/workbar terminal`) shows the model’s live PTY sessions
in the current workspace, with ANSI-styled output, session selection, resize
and direct keyboard input. `Ctrl+N` starts a separate shell; `Alt+Down` and
+48 -23
View File
@@ -341,6 +341,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult {
Some(if app.auto_compact { "true" } else { "false" }.to_string())
}
"calm_mode" | "calm" => Some(if app.calm_mode { "true" } else { "false" }.to_string()),
"pet_mode" => Some(app.pet_watch.enabled.to_string()),
"low_motion" | "motion" => Some(if app.low_motion { "true" } else { "false" }.to_string()),
"fancy_animations" | "fancy" | "animations" => Some(
if app.fancy_animations {
@@ -705,17 +706,30 @@ pub fn sidebar(app: &mut App, arg: Option<&str>) -> CommandResult {
CommandResult::message(rail_status_message(app))
}
/// `/pet`: turn the terminal over to the Codewhale pet.
///
/// Bare `/pet` toggles. `on` enters the full habitat now and lets every
/// accepted turn re-enter it until `off`. The habitat is a modal over the
/// existing shell: composer draft, transcript, selection and the active
/// Engine turn stay underneath, and Escape returns without cancelling
/// anything. The remaining verbs address the shared companion: the browser
/// appearance studio, the native window, source selection, replay export and
/// the single audio lease. The pet has no workbar panel.
fn select_pet_mode(app: &mut App, enabled: bool, persist: bool) -> CommandResult {
crate::tui::pet_watch::set_enabled(app, enabled);
if persist {
app.startup_defaults
.spawn(crate::tui::startup_defaults::StartupDefaults::pet_mode(
enabled,
));
}
CommandResult::message(tr(
app.ui_locale,
if enabled {
MessageId::PetModeOn
} else {
MessageId::PetModeOff
},
))
}
/// `/pet on` makes the pet the main shell surface, with the real composer
/// always available. `/pet inspect` opens replies and agents on demand;
/// Escape returns to the same backdrop and draft. Companion verbs keep the
/// existing shared simulation, appearance, replay and single audio lease.
pub fn pet(app: &mut App, arg: Option<&str>) -> CommandResult {
const USAGE: &str = "Usage: /pet [on|off|status|appearance|window|source|export|sound on|off|avatar [key]|action [name|live]|view [name|live]]";
const USAGE: &str = "Usage: /pet [on|off|inspect|status|appearance|window|source|export|sound on|off|avatar [key]|action [name|live]|view [name|live]]";
use crate::tui::pet_watch::{self, Control};
let words = arg
.map(str::trim)
@@ -724,22 +738,16 @@ pub fn pet(app: &mut App, arg: Option<&str>) -> CommandResult {
.map(str::to_ascii_lowercase)
.collect::<Vec<_>>();
let words = words.iter().map(String::as_str).collect::<Vec<_>>();
let mode = |app: &mut App, enabled: bool| {
pet_watch::set_enabled(app, enabled);
CommandResult::message(tr(
app.ui_locale,
if enabled {
MessageId::PetModeOn
} else {
MessageId::PetModeOff
},
))
};
let mode = |app: &mut App, enabled: bool| select_pet_mode(app, enabled, true);
let queued = |app: &mut App, control: Control| {
pet_watch::command(app, control);
CommandResult::message(tr(app.ui_locale, MessageId::PetHabitatQueued))
};
match words.as_slice() {
["inspect"] => {
pet_watch::open_habitat(app);
CommandResult::ok()
}
[] => {
let enabled = !app.pet_watch.enabled;
mode(app, enabled)
@@ -1973,6 +1981,22 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
}
match key.as_str() {
"pet_mode" => {
let enabled = match parse_config_bool(value) {
Ok(enabled) => enabled,
Err(_) => {
return CommandResult::error(
tr(app.ui_locale, MessageId::ConfigCommandInvalidValue)
.replace("{key}", &key)
.replace("{value}", value)
.replace("{choices}", "on/off"),
);
}
};
// /pet and the settings editor share the ordered background writer.
// A failed save is reported by the existing warning mailbox.
return select_pet_mode(app, enabled, persist);
}
"contextual_tips" => {
let enabled = match parse_config_bool(value) {
Ok(enabled) => enabled,
@@ -3932,7 +3956,7 @@ mod tests {
}
#[test]
fn pet_command_toggles_the_habitat_and_automatic_entry() {
fn pet_command_selects_the_main_view_without_taking_composer_focus() {
let mut app = create_test_app();
app.onboarding = crate::tui::app::OnboardingState::None;
app.redaction_gate = false;
@@ -3947,7 +3971,8 @@ mod tests {
on.message.as_deref(),
Some(&*tr(app.ui_locale, MessageId::PetModeOn))
);
// Repeating `on` is harmless: still one habitat, still enabled.
assert!(app.view_stack.is_empty());
// Repeating `on` leaves the composer in the same shell focus.
assert!(!pet(&mut app, Some(" ON ")).is_error);
assert!(app.pet_watch.enabled);
assert!(crate::tui::pet_watch::is_open(&app));
+1 -1
View File
@@ -74,7 +74,7 @@ static RAIL_INFO: CommandInfo = CommandInfo {
static PET_INFO: CommandInfo = CommandInfo {
name: "pet",
aliases: &[],
usage: "/pet [on|off|status|appearance|window|source|export|sound on|off|avatar [key]|action [name|live]|view [name|live]]",
usage: "/pet [on|off|inspect|status|appearance|window|source|export|sound on|off|avatar [key]|action [name|live]|view [name|live]]",
description_id: MessageId::CmdPetDescription,
};
static SETTINGS_INFO: CommandInfo = CommandInfo {
+31 -1
View File
@@ -21,7 +21,19 @@ impl RegisterCommand for CopyCmd {
}
fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult {
execute_copy(app)
let result = execute_copy(app);
if let Some(message) = result.message.as_ref() {
use crate::tui::app::{StatusToast, StatusToastLevel};
let level = if result.is_error {
StatusToastLevel::Error
} else {
StatusToastLevel::Info
};
app.push_status_toast_record(
StatusToast::new(message.clone(), level, Some(8_000)).for_command_receipt(),
);
}
result
}
}
@@ -420,4 +432,22 @@ mod tests {
let message = result.message.as_deref().unwrap_or_default();
assert!(message.contains("/export file <path>"), "{message}");
}
#[test]
fn copy_command_failure_surfaces_the_existing_recovery_receipt_over_a_finished_turn() {
let tmp = TempDir::new().unwrap();
let _env_lock = crate::test_support::lock_test_env();
let (_home, _state) = isolate_state_home(tmp.path());
let mut app = test_app();
app.clipboard = ClipboardHandler::unavailable_for_test(false);
add_completed_assistant(&mut app, "recoverable answer");
let result = CopyCmd::execute(&mut app, None);
assert!(result.is_error);
let toast = app
.active_status_toast(crate::tui::underwater::ShellPhase::Done)
.expect("copy failure must stay visible");
assert_eq!(toast.text, result.message.unwrap());
assert_eq!(toast.level, crate::tui::app::StatusToastLevel::Error);
assert_eq!(toast.ttl_ms, Some(8_000));
assert!(toast.text.contains("last-copy.md"));
}
}
+8
View File
@@ -254,6 +254,8 @@ pub struct Settings {
pub(crate) auto_compact_explicit: bool,
/// Reduce status noise and collapse details more aggressively
pub calm_mode: bool,
/// Use the pet as the main view on the next launch.
pub pet_mode: bool,
/// Dense tool-run collapse mode: compact, expanded, or calm.
pub tool_collapse_mode: String,
/// Reduce decorative motion. This must never synthesize model text speed;
@@ -541,6 +543,7 @@ impl Default for Settings {
auto_compact_explicit: false,
// #4095: default presentation is compact/calm; verbose detail is opt-in.
calm_mode: true,
pet_mode: false,
tool_collapse_mode: "compact".to_string(),
low_motion: false,
load_error: None,
@@ -1436,6 +1439,7 @@ impl Settings {
"auto_compact_threshold_percent"
}
"calm_mode" | "calm" => "calm_mode",
"pet_mode" => "pet_mode",
"tool_collapse" | "tool_collapse_mode" | "collapse" => "tool_collapse",
"low_motion" | "motion" => "low_motion",
"fancy_animations" | "fancy" | "animations" => "fancy_animations",
@@ -1505,6 +1509,9 @@ impl Settings {
"calm_mode" | "calm" => {
self.calm_mode = parse_bool(value)?;
}
"pet_mode" => {
self.pet_mode = parse_bool(value)?;
}
"tool_collapse" | "tool_collapse_mode" | "collapse" => {
let normalized = normalize_tool_collapse_mode(value);
if !matches!(normalized, "compact" | "expanded" | "calm") {
@@ -1808,6 +1815,7 @@ impl Settings {
self.auto_compact_threshold_percent
));
lines.push(format!(" calm_mode: {}", self.calm_mode));
lines.push(format!(" pet_mode: {}", self.pet_mode));
lines.push(format!(" tool_collapse: {}", self.tool_collapse_mode));
lines.push(format!(" low_motion: {}", self.low_motion));
lines.push(format!(" fancy_animations: {}", self.fancy_animations));
+1
View File
@@ -3518,6 +3518,7 @@ impl App {
self.tr(match subject {
StartupDefaultSubject::Mode => MessageId::StartupDefaultSubjectMode,
StartupDefaultSubject::Thinking => MessageId::StartupDefaultSubjectThinking,
StartupDefaultSubject::PetMode => MessageId::ConfigLabelPetMode,
})
.into_owned()
})
+5
View File
@@ -1210,6 +1210,11 @@ impl App {
receipt_started_at: None,
tool_evidence: Vec::new(),
};
// Restore presentation only; the visible-frame tick owns companion startup.
app.pet_watch.enabled = settings.pet_mode;
if settings.pet_mode {
app.launch.visible = false;
}
if yolo_compat {
app.notify_yolo_compat_once();
}
+8
View File
@@ -42,6 +42,8 @@ pub struct StatusToast {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StatusToastKind {
Ordinary,
/// An explicit command result stays visible over a completed turn.
CommandReceipt,
ActionRequired,
RedactionGate(RedactionGateNotice),
BehavioralTip(crate::tui::behavioral_tips::BehavioralTip),
@@ -95,6 +97,11 @@ impl StatusToast {
self
}
pub(crate) fn for_command_receipt(mut self) -> Self {
self.kind = StatusToastKind::CommandReceipt;
self
}
pub(crate) fn for_redaction_gate(mut self, notice: RedactionGateNotice) -> Self {
self.kind = StatusToastKind::RedactionGate(notice);
self
@@ -394,6 +401,7 @@ impl App {
let eligible = |toast: &&StatusToast| {
phase != crate::tui::underwater::ShellPhase::Done
|| toast.kind == StatusToastKind::CommandReceipt
|| matches!(
toast.level,
StatusToastLevel::Warning | StatusToastLevel::Error
+121
View File
@@ -8083,3 +8083,124 @@ fn a_typed_drop_with_the_question_on_its_line_still_sends_the_image() {
.any(|block| matches!(block, codewhale_models::ContentBlock::ImageUrl { .. }))
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn pet_view_choice_restores_without_launch_overlay_and_preserves_other_settings() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().unwrap();
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let path = tmp.path().join(".codewhale/settings.toml");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
"# keep this comment\nlow_motion = true\ncontextual_tips = false\n",
)
.unwrap();
let mut app = App::new(test_options(false), &Config::default());
assert!(
!app.pet_watch.enabled,
"old settings keep their existing view"
);
app.pet_watch.detach_for_test();
app.input = "unfinished draft".into();
assert!(
!crate::commands::execute_with_config("/pet on", &mut app, &Config::default()).is_error
);
assert!(app.pet_watch.enabled);
assert!(app.view_stack.is_empty());
assert_eq!(app.input, "unfinished draft");
app.startup_defaults.flush();
let restored = App::new(test_options(false), &Config::default());
assert!(restored.pet_watch.enabled);
assert!(
!restored.launch.visible,
"the welcome overlay must not hide the remembered view"
);
assert!(restored.low_motion);
assert!(restored.view_stack.is_empty());
let saved = std::fs::read_to_string(&path).unwrap();
assert!(saved.contains("# keep this comment"));
assert!(!Settings::load_persisted().unwrap().contextual_tips);
// Both public selectors enqueue in user-action order; quit immediately
// after the last choice and prove the existing shutdown join persists it.
for value in [false, true, false, true] {
app.pet_watch.detach_for_test();
let result = crate::commands::execute_with_config(
&format!(
"/config pet_mode {} --save",
if value { "true" } else { "false" }
),
&mut app,
&Config::default(),
);
assert!(!result.is_error);
}
assert!(
!crate::commands::execute_with_config("/pet off", &mut app, &Config::default()).is_error
);
assert!(app.startup_defaults.shutdown().is_empty());
assert!(!Settings::load_persisted().unwrap().pet_mode);
assert!(
!App::new(test_options(false), &Config::default())
.pet_watch
.enabled
);
// Session overrides and rejected values must not change the saved choice.
app.pet_watch.detach_for_test();
assert!(
!crate::commands::execute_with_config(
"/config pet_mode true",
&mut app,
&Config::default()
)
.is_error
);
assert!(
crate::commands::execute_with_config(
"/config pet_mode banana --save",
&mut app,
&Config::default()
)
.is_error
);
assert!(app.pet_watch.enabled);
app.startup_defaults.flush();
assert!(!Settings::load_persisted().unwrap().pet_mode);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pet_view_save_failure_keeps_the_live_view_and_reports_without_overwriting_bad_settings() {
let _lock = lock_test_env();
let tmp = tempfile::TempDir::new().unwrap();
let _env = sealed_settings_home(tmp.path());
let _writes = crate::tui::startup_defaults::allow_writes_in_tests();
let path = tmp.path().join(".codewhale/settings.toml");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let bad = "# preserve for repair\npet_mode = [broken";
std::fs::write(&path, bad).unwrap();
let mut app = App::new(test_options(false), &Config::default());
app.pet_watch.detach_for_test();
app.ui_locale = codewhale_localization::Locale::Fr;
assert!(
!crate::commands::execute_with_config("/pet on", &mut app, &Config::default()).is_error
);
assert!(app.pet_watch.enabled);
app.startup_defaults.flush();
let failures = app.startup_defaults.drain_failures();
assert_eq!(failures.len(), 1);
assert_eq!(
failures[0].subjects,
vec![crate::tui::startup_defaults::StartupDefaultSubject::PetMode]
);
let message = app.startup_default_failure_message(&failures[0]);
assert!(message.contains(app.tr(MessageId::ConfigLabelPetMode).as_ref()));
assert!(!message.contains(tmp.path().to_str().unwrap()));
assert_eq!(std::fs::read_to_string(&path).unwrap(), bad);
assert!(
app.pet_watch.enabled,
"a failed save does not undo the live view"
);
}
+13
View File
@@ -483,6 +483,19 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
return app.view_stack.handle_mouse(mouse);
}
if crate::tui::pet_watch::main_view(app) {
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
&& mouse_hits_rect(mouse, app.pet_watch.inspect_area)
{
crate::tui::pet_watch::open_habitat(app);
return Vec::new();
}
if mouse_hits_rect(mouse, app.pet_watch.area) {
crate::tui::pet_watch::command(app, crate::tui::pet_watch::Control::Mouse(mouse));
return Vec::new();
}
}
// A drag can finish outside the composer/transcript that started it.
// Publish once before other visible surfaces consume the release event.
if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left))
+766
View File
@@ -0,0 +1,766 @@
//! The actual `/pet on` surface over the foreground Engine session. This
//! replaces the old full-screen tank layout; the companion still owns its
//! durable world and audio. No prompt, worker or execution authority lives here.
use super::{PetWatch, habitat};
use crate::{
agent_roster::RosterState,
tools::subagent::AgentWorkerStatus,
tui::{
app::App,
history::HistoryCell,
shell_key_routing::{self, ShellBindingId as Id},
underwater::ShellPhase,
},
};
use codewhale_localization::{MessageId, tr};
use codewhale_protocol::engine_owner::OwnerFreshness;
use codewhale_ratatui::{
AgentCard, Caps, MotionMode, PetMode, PetModeAreas, State, StatusMark, Subagent,
SubagentControls, SubagentUsage, SubagentViewWords, Theme,
whale_motion::{Activity, Context, Inputs, Presence, Span},
};
use ratatui::{Frame, layout::Rect, text::Line};
use std::time::{Duration, Instant};
pub(super) fn canonical(state: &PetWatch) -> bool {
matches!(state.avatars.key.as_str(), "" | "whale")
&& state.avatars.action.is_none()
&& state.avatars.view.is_none()
}
fn theme(app: &App) -> Theme {
let mut caps = Caps::detect();
if let Some(appearance) =
codewhale_ratatui::detect::appearance_for_background(app.ui_theme.surface_bg)
{
caps.appearance = appearance;
}
let theme = Theme::new(caps);
codewhale_ratatui::TuiPalette::ALL
.into_iter()
.find(|palette| palette.name() == app.ui_theme.name)
.map_or(theme, |palette| theme.tui_palette(palette))
}
pub(super) fn agents(app: &App) -> Vec<Subagent<'static>> {
app.current_agent_roster()
.iter()
.map(|row| {
let (state, label) = match row.state {
RosterState::Running if row.status == AgentWorkerStatus::Queued => {
(State::Ready, MessageId::AgentRailQueuedCount)
}
RosterState::Running => (State::Working, MessageId::SubagentsStatusRunning),
RosterState::Waiting => (State::NeedsYou, MessageId::PhaseWaitingOnYou),
RosterState::Parked => (State::Stopped, MessageId::AgentStatusParked),
RosterState::Done => (State::Done, MessageId::SubagentsStatusCompleted),
RosterState::Failed => (State::Failed, MessageId::SubagentsStatusFailed),
RosterState::Cancelled => (State::Stopped, MessageId::SubagentsStatusCancelled),
};
let mut card = AgentCard::new(row.display_name.clone(), state)
.status_word(tr(app.ui_locale, label).replace("{count}", "1"))
.task(row.activity.clone().unwrap_or_default());
if !row.model.trim().is_empty() {
card = card.route(row.model.clone());
}
let mut agent = Subagent::new(row.worker_id.clone(), card);
agent.elapsed = row.millis.map(Duration::from_millis);
agent.usage = Some(SubagentUsage {
input_tokens: row.input_tokens,
output_tokens: row.output_tokens,
cost_microusd: row.cost_microusd,
});
agent.steps = Some(row.steps_taken);
// This pane is the retained roster. Enter opens the existing full
// agent transcript, so long outcomes are not cloned on animation frames.
agent.controls = SubagentControls {
open: true,
..Default::default()
};
agent
})
.collect()
}
fn inputs(app: &App, now: Instant) -> Inputs {
let presence = match ShellPhase::from_app(app) {
ShellPhase::Working | ShellPhase::Verifying => Presence::Working,
ShellPhase::Waiting | ShellPhase::Approval => Presence::NeedsYou,
ShellPhase::Typing => Presence::Listening,
ShellPhase::Done => Presence::Done,
ShellPhase::Idle | ShellPhase::Failed => Presence::Idle,
};
let raster = app.pet_watch.raster.as_ref().filter(|r| {
r.scene.producer_connected
&& now.saturating_duration_since(r.frame_changed) < Duration::from_millis(800)
&& Some(r.scene.source.as_str()) == app.current_session_id.as_deref()
});
let owner = raster.and_then(|r| r.scene.activity.as_ref()).filter(|a| {
a.observed && a.freshness == OwnerFreshness::Fresh && a.session_id == app.current_session_id
});
Inputs {
presence,
activity: owner.map(|a| Activity {
kind: a.activity_kind.map(|k| k.as_str().into()),
observed: true,
parallel: Some(f64::from(a.parallel_agent_count)),
active: a
.active_spans
.iter()
.map(|s| Span {
kind: s.activity_kind.as_str().into(),
since_ms: s.started_at_ms,
})
.collect(),
}),
context: Context {
live: owner.is_some(),
turn_id: app.runtime_turn_id.clone(),
status: app.runtime_turn_status.clone(),
// Only owner time, never a local timer pretending to be an error receipt.
now_ms: raster.map(|r| r.scene.time_ms),
..Default::default()
},
}
}
pub(super) fn motion(app: &App) -> MotionMode {
// The habitat owns the viewport through the modal stack. The ordinary
// shell's ambient helper intentionally stops under every modal, so using
// it here would freeze the pet simply because its own view is open.
match app.motion_policy().mode() {
crate::tui::motion::MotionMode::Still => MotionMode::Still,
crate::tui::motion::MotionMode::Reduced => MotionMode::Reduced,
crate::tui::motion::MotionMode::Full
if matches!(
ShellPhase::from_app(app),
ShellPhase::Approval | ShellPhase::Waiting
) =>
{
MotionMode::Reduced
}
crate::tui::motion::MotionMode::Full => MotionMode::Full,
}
}
pub(super) fn tick(app: &mut App, visible: bool, now: Instant) {
app.pet_watch.full.set_visible(visible);
if !visible {
app.pet_watch.full_next = None;
return;
}
let agents = agents(app);
// Revalidate the painted identity against the current session before the
// modal can return an open intent. A removed/ambiguous worker never turns
// into the new fallback selection before it has actually been painted.
if let Ok(mut selected) = app.pet_watch.selection.lock()
&& selected.as_deref().is_some_and(|id| {
agents
.iter()
.filter(|agent| agent.id == id && agent.controls.open)
.count()
!= 1
})
{
*selected = None;
}
let inputs = inputs(app, now);
let changed = app.pet_watch.full_inputs.as_ref().is_none_or(|old| {
old.presence != inputs.presence
|| old.activity != inputs.activity
|| old.context.live != inputs.context.live
|| old.context.turn_id != inputs.context.turn_id
|| old.context.status != inputs.context.status
});
let motion = motion(app);
let theme = theme(app);
let state = &mut app.pet_watch;
state.full.update(
app.current_session_id.as_deref(),
inputs.clone(),
&agents,
now,
motion,
);
state.full_inputs = Some(inputs);
if changed {
state.full_next = None;
app.needs_redraw = true;
}
if let Some(interval) = state.full.next_frame_in(&agents, &theme) {
if state.full_next.is_none_or(|deadline| now >= deadline) {
state.full_next = now.checked_add(interval);
app.needs_redraw = true;
}
} else {
state.full_next = None;
}
}
pub fn render_full(frame: &mut Frame, app: &mut App) {
render(frame, frame.area(), app, true);
}
/// The same stage becomes the shell backdrop. Inspection is opt-in so streamed
/// replies and a retained roster never displace the whale or the composer.
pub fn render_main(frame: &mut Frame, area: Rect, app: &mut App) {
render(frame, area, app, false);
}
fn render(frame: &mut Frame, area: Rect, app: &mut App, inspect: bool) {
if inspect {
app.resync_history_revisions();
}
let agents = agents(app);
let theme = theme(app);
let phase = ShellPhase::from_app(app);
let notice = app.active_status_toast(phase).map(|toast| {
Line::styled(
toast.text,
codewhale_palette::chrome_style(&app.ui_theme, toast.level.ink()),
)
});
let mark = StatusMark::new(match phase {
ShellPhase::Working | ShellPhase::Verifying => State::Working,
ShellPhase::Approval | ShellPhase::Waiting => State::NeedsYou,
ShellPhase::Done => State::Done,
ShellPhase::Failed => State::Failed,
ShellPhase::Idle | ShellPhase::Typing => State::Ready,
})
.word(phase.label(app.ui_locale).into_owned());
// ActiveCell is the same live transcript the normal shell reads. Include
// streamed replies and terminal errors, without waiting for a successful turn.
let response_index = inspect
.then(|| {
(app.pet_watch.work_history_start..app.virtual_cell_count()).rfind(|&index| {
matches!(
app.cell_at_virtual_index(index),
Some(HistoryCell::Assistant { .. } | HistoryCell::Error { .. })
)
})
})
.flatten();
let active = app.active_cell.as_ref();
let response = response_index.and_then(|index| {
if index < app.history.len() {
app.history.get(index)
} else {
active.and_then(|cell| cell.entries().get(index - app.history.len()))
}
});
let plan = PetModeAreas::new(
area,
inspect && !agents.is_empty(),
response.is_some(),
app.pet_watch.full.focus_agents,
);
let owner = inputs(app, Instant::now());
let motion = motion(app);
// Revisions invalidate the selected cell inside the existing incremental
// cache. Only source replacement or a destructive history change discards
// its parser; streaming chunks and ambient frames keep the stable prefix.
let key = response_index.map(|index| {
(
app.transcript_identity_epoch,
index,
index >= app.history.len(),
)
});
if inspect && app.pet_watch.full_response_key != key {
app.pet_watch.full_response = None;
app.pet_watch.full_response_key = key;
}
let mut options = app.transcript_render_options();
options.low_motion = true;
options.motion_mode = crate::tui::motion::MotionMode::Reduced;
let copy_hint = (inspect && app.completed_assistant_output_receipt().is_some()).then(|| {
tr(app.ui_locale, MessageId::PetModeCopyHint).replace(
"{copy}",
shell_key_routing::binding(Id::PetCopyReply).footer_chord,
)
});
let lines = if inspect {
let revision = response_index.map_or(0, |index| {
if index < app.history.len() {
app.history_revisions[index]
} else {
active.map_or(0, |cell| cell.revision())
}
});
let revisions = [revision];
let cache = app
.pet_watch
.full_response
.get_or_insert_with(crate::tui::transcript::TranscriptViewCache::new);
cache.set_streaming_source_receipt(
app.streaming_source_receipt
.filter(|receipt| Some(receipt.cell_index) == response_index),
);
cache.ensure_split(
&[response.map_or(&[], std::slice::from_ref)],
if response.is_some() { &revisions } else { &[] },
plan.output.width.max(1),
options,
&std::collections::HashMap::new(),
response_index.as_ref().map(std::slice::from_ref),
None,
);
if let Some(receipt) = app.streaming_source_receipt.as_mut()
&& Some(receipt.cell_index) == response_index
{
receipt.from_revision = receipt.to_revision;
}
cache.lines()
} else {
&[]
};
let mut hints = tr(app.ui_locale, MessageId::PetModeMessageHint).replace(
"{back}",
shell_key_routing::binding(Id::PetBack).footer_chord,
);
if !agents.is_empty() {
hints.push_str(&format!(
" · {}",
tr(app.ui_locale, MessageId::PetModePaneHint).replace(
"{switch}",
shell_key_routing::binding(Id::PetFocusAgents).footer_chord,
)
));
}
if area.width >= 100 {
hints.push_str(&format!(" · {}", habitat::companion_hints(app.ui_locale)));
}
let arrows = format!(
"{}{}",
shell_key_routing::binding(Id::PetResultUp).footer_chord,
shell_key_routing::binding(Id::PetResultDown).footer_chord,
);
let mut pane_hints = if app.pet_watch.full.focus_agents && !agents.is_empty() {
tr(app.ui_locale, MessageId::PetModeAgentHints)
.replace("{select}", &arrows)
.replace(
"{open}",
shell_key_routing::binding(Id::PetOpenAgent).footer_chord,
)
} else if !lines.is_empty() {
tr(app.ui_locale, MessageId::PetModeReplyHints)
.replace("{scroll}", &arrows)
.replace(
"{latest}",
shell_key_routing::binding(Id::PetResultEnd).footer_chord,
)
} else {
String::new()
};
if let Some(copy_hint) = &copy_hint {
pane_hints = if pane_hints.is_empty() {
copy_hint.clone()
} else {
format!("{copy_hint} · {pane_hints}")
};
}
let mut view = PetMode::new(&theme, mark);
view.notice = notice;
view.title = "Codewhale".into();
view.agents = if inspect { &agents } else { &[] };
view.agent_words = SubagentViewWords {
title: tr(app.ui_locale, MessageId::ConfigChoiceRailAgents),
empty: tr(
app.ui_locale,
MessageId::SubagentsNoCurrentSessionFleetWorkers,
),
activity: tr(app.ui_locale, MessageId::AutomationStatusLabel),
output: tr(app.ui_locale, MessageId::AgentFocusResult),
// The retained roster supplies no event log or copied outcome.
// Enter opens those through the existing localized transcript view.
toggle_output: "".into(),
no_events: "".into(),
hints: "".into(),
open: "".into(),
message: "".into(),
stop: "".into(),
summary: Some(
tr(app.ui_locale, MessageId::SubagentsSummaryItem)
.replace(
"{label}",
tr(app.ui_locale, MessageId::CtxInspTotal).as_ref(),
)
.replace("{count}", &agents.len().to_string())
.into(),
),
steps_label: Some(tr(app.ui_locale, MessageId::SessionMetricsSteps)),
};
view.output = lines;
view.output_title = tr(
app.ui_locale,
match response {
Some(HistoryCell::Error { .. }) => MessageId::ExtensionsStateError,
_ => MessageId::PetModeReplyTitle,
},
);
view.pane_hints = if inspect {
pane_hints.into()
} else {
"".into()
};
let inspect_hint = tr(app.ui_locale, MessageId::PetModeInspectHint).replace(
"{inspect}",
shell_key_routing::binding(Id::PetInspect).footer_chord,
);
view.hints = if inspect {
hints.into()
} else {
inspect_hint.clone().into()
};
app.pet_watch
.full
.set_visible(inspect || app.view_stack.is_empty());
app.pet_watch.full.update(
app.current_session_id.as_deref(),
owner,
&agents,
Instant::now(),
motion,
);
frame.render_stateful_widget(view, area, &mut app.pet_watch.full);
if let Ok(mut copy_area) = app.pet_watch.copy_area.lock() {
*copy_area = copy_hint.as_ref().and_then(|hint| {
let width = codewhale_ratatui::text::width(hint);
// Match the actual second-row truncation; a hidden/truncated label
// never leaves a clickable target behind.
(plan.footer.height > 1 && width + 4 <= usize::from(plan.footer.width))
.then(|| Rect::new(plan.footer.x, plan.footer.y + 1, width as u16, 1))
});
}
if let Ok(mut selection) = app.pet_watch.selection.lock() {
*selection = inspect
.then(|| app.pet_watch.full.selected_agent_id().map(str::to_owned))
.flatten();
}
app.pet_watch.inspect_area = (!inspect).then(|| {
Rect::new(
plan.footer.x,
plan.footer.y,
(codewhale_ratatui::text::width(inspect_hint.split(" · ").next().unwrap_or_default())
as u16)
.min(plan.footer.width),
plan.footer.height.min(1),
)
});
if canonical(&app.pet_watch) {
app.pet_watch.area = Some(plan.pet);
} else {
// Existing reviewed avatar packs and explicit action/view previews keep
// their renderer and permissions; only the full-screen layout is shared.
super::render_tank(frame, plan.pet, app);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::{active_cell::ActiveCell, app::OnboardingState};
fn app() -> App {
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
app.onboarding = OnboardingState::None;
app.redaction_gate = false;
app.pet_watch.session = app.current_session_id.clone();
app.pet_watch.detach_for_test();
app
}
fn paint(app: &mut App, width: u16, height: u16) -> String {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| render_full(frame, app)).unwrap();
terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect()
}
#[test]
fn native_main_stage_wakes_the_existing_idle_loop_and_stops_when_hidden() {
let mut app = app();
app.low_motion = false;
app.fancy_animations = true;
app.pet_watch.enabled = true;
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 32)).unwrap();
terminal
.draw(|frame| render_main(frame, frame.area(), &mut app))
.unwrap();
let now = Instant::now();
super::super::tick(&mut app, now);
assert!(app.pet_watch.next_frame_in(now).is_some());
app.view_stack
.push(crate::tui::views::HelpView::new_for_locale(app.ui_locale));
super::super::tick(&mut app, now + Duration::from_secs(2));
assert!(
app.pet_watch
.next_frame_in(now + Duration::from_secs(2))
.is_none()
);
}
#[test]
fn full_mode_animates_under_its_own_modal_and_respects_motion_preferences() {
let mut app = app();
app.is_loading = true;
app.low_motion = false;
app.fancy_animations = true;
super::super::open_habitat(&mut app);
assert!(super::super::is_open(&app));
assert_eq!(motion(&app), MotionMode::Full);
paint(&mut app, 100, 32);
tick(&mut app, true, Instant::now());
assert!(app.pet_watch.full_next.is_some());
app.low_motion = true;
tick(&mut app, true, Instant::now());
assert!(app.pet_watch.full_next.is_none());
app.low_motion = false;
app.fancy_animations = false;
assert_eq!(motion(&app), MotionMode::Still);
}
#[test]
fn roster_scope_and_missing_vs_zero_receipts_come_from_the_existing_owner_guard() {
let mut app = app();
app.current_session_id = Some("foreground".into());
app.agent_roster_session_id = Some("other-session".into());
app.agent_roster = vec![crate::agent_roster::AgentRosterRow {
worker_id: "retained-worker".into(),
display_name: "Retained worker".into(),
model: "reported-model".into(),
state: RosterState::Waiting,
status: AgentWorkerStatus::WaitingForUser,
activity: Some("Owner activity".into()),
outcome: None,
millis: None,
input_tokens: Some(0),
output_tokens: None,
cost_microusd: Some(1),
steps_taken: 0,
parent_run_id: None,
run_id: "actual-run".into(),
}];
assert!(agents(&app).is_empty());
*app.pet_watch.selection.lock().unwrap() = Some("retained-worker".into());
tick(&mut app, true, Instant::now());
assert!(app.pet_watch.selection.lock().unwrap().is_none());
app.agent_roster_session_id = app.current_session_id.clone();
let rows = agents(&app);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "retained-worker");
assert_eq!(rows[0].card.status.state, State::NeedsYou);
assert_eq!(rows[0].usage.unwrap().input_tokens, Some(0));
assert_eq!(rows[0].usage.unwrap().output_tokens, None);
assert_eq!(rows[0].usage.unwrap().cost_microusd, Some(1));
assert_eq!(rows[0].elapsed, None);
app.agent_roster[0].state = RosterState::Parked;
assert_eq!(
agents(&app)[0].card.status.word,
tr(app.ui_locale, MessageId::AgentStatusParked)
);
app.ui_locale = codewhale_localization::Locale::Fr;
app.pet_watch.full.focus_agents = true;
let detail = paint(&mut app, 95, 40);
assert!(detail.contains(tr(app.ui_locale, MessageId::AutomationStatusLabel).as_ref()));
assert!(!detail.contains("No events reported"));
assert!(!detail.contains("Activity"));
}
#[test]
fn pet_reply_reuses_incremental_markdown_and_rejects_unproven_edits() {
let mut app = app();
app.is_loading = true;
let index = crate::tui::ui::ensure_streaming_assistant_history_cell(&mut app);
let initial: String = (0..120)
.map(|row| format!("Cached row {row}\n\n"))
.collect();
crate::tui::ui::append_streaming_text(&mut app, index, &initial);
paint(&mut app, 80, 24);
let work = |app: &App| {
app.pet_watch
.full_response
.as_ref()
.unwrap()
.streaming_render_work(0)
.unwrap()
};
assert_eq!(work(&app).invalidations, 1);
for row in 0..20 {
let before = work(&app);
let tail = format!("APPENDED_ROW_{row}");
crate::tui::ui::append_streaming_text(&mut app, index, &format!("{tail}\n\n"));
assert!(paint(&mut app, 80, 24).contains(&tail));
let after = work(&app);
assert_eq!(after.invalidations, 1);
assert!(after.classified_lines - before.classified_lines <= 2);
let receipt = app.streaming_source_receipt.unwrap();
assert_eq!(receipt.from_revision, receipt.to_revision);
}
// Returning to the main view must preserve the inspector's parser.
let before = work(&app);
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| render_main(frame, frame.area(), &mut app))
.unwrap();
assert_eq!(work(&app), before);
paint(&mut app, 80, 24);
assert_eq!(work(&app), before);
let HistoryCell::Assistant { content, .. } = &mut app.history[index] else {
panic!("expected the real streaming history cell");
};
content.insert_str(0, "REWRITTEN_PREFIX\n\n");
app.bump_history_cell(index);
assert!(app.streaming_source_receipt.is_none());
paint(&mut app, 80, 24);
assert_eq!(work(&app).invalidations, 2);
assert!(
app.pet_watch
.full_response
.as_ref()
.unwrap()
.lines()
.iter()
.any(|line| line.to_string().contains("REWRITTEN_PREFIX"))
);
crate::tui::ui::append_streaming_text(&mut app, index, "AFTER_REWRITE\n\n");
assert!(paint(&mut app, 80, 24).contains("AFTER_REWRITE"));
assert_eq!(work(&app).invalidations, 2);
let mut options = app.transcript_render_options();
options.low_motion = true;
options.motion_mode = crate::tui::motion::MotionMode::Reduced;
let width = PetModeAreas::new(Rect::new(0, 0, 80, 24), false, true, false)
.output
.width;
let mut cold = crate::tui::transcript::TranscriptViewCache::new();
cold.ensure(
&app.history[index..=index],
&[app.history_revisions[index]],
width,
options,
);
assert_eq!(
app.pet_watch.full_response.as_ref().unwrap().lines(),
cold.lines()
);
}
#[test]
fn streamed_tail_follows_until_the_reader_scrolls_back() {
let mut app = app();
app.is_loading = true;
app.runtime_turn_id = Some("streamed-turn".into());
let mut active = ActiveCell::new();
active.push_untracked(HistoryCell::Assistant {
content: (0..30).map(|n| format!("Stream row {n}\n\n")).collect(),
streaming: true,
});
app.active_cell = Some(active);
assert!(paint(&mut app, 40, 12).contains("Stream row 29"));
app.pet_watch.full.scroll(&[], -3);
let top = app.pet_watch.full.output_scroll;
let Some(HistoryCell::Assistant { content, .. }) =
app.active_cell.as_mut().unwrap().entry_mut(0)
else {
panic!("expected the live assistant response");
};
content.push_str("Appended streamed tail\n\n");
assert!(!paint(&mut app, 40, 12).contains("Appended streamed tail"));
assert_eq!(app.pet_watch.full.output_scroll, top);
app.pet_watch.full.scroll_end(&[], true);
assert!(paint(&mut app, 40, 12).contains("Appended streamed tail"));
}
#[test]
fn full_mode_reads_streaming_response_then_failure_and_preserves_the_session() {
let mut app = app();
app.is_loading = true;
app.input = "retained draft".into();
let session = app.current_session_id.clone();
let mut active = ActiveCell::new();
active.push_untracked(HistoryCell::Assistant {
content: "Streaming response from this session".into(),
streaming: true,
});
app.active_cell = Some(active);
assert!(paint(&mut app, 80, 24).contains("Streaming response from this session"));
app.active_cell = None;
app.is_loading = false;
app.runtime_turn_status = Some("failed".into());
app.add_message(HistoryCell::Error {
message: "Actual failure receipt".into(),
severity: crate::error_taxonomy::ErrorSeverity::Error,
});
assert!(paint(&mut app, 40, 12).contains("Actual failure receipt"));
assert_eq!(app.input, "retained draft");
assert_eq!(app.current_session_id, session);
assert!(app.pet_watch.worker.is_none());
}
#[test]
fn reduced_completed_view_reuses_the_response_and_does_not_schedule_paints() {
let mut app = app();
app.runtime_turn_status = Some("completed".into());
app.add_message(HistoryCell::Assistant {
content: "Finished answer".into(),
streaming: false,
});
let first = paint(&mut app, 100, 32);
let lines = app
.pet_watch
.full_response
.as_ref()
.unwrap()
.lines()
.as_ptr();
assert_eq!(paint(&mut app, 100, 32), first);
assert_eq!(
app.pet_watch
.full_response
.as_ref()
.unwrap()
.lines()
.as_ptr(),
lines
);
tick(&mut app, true, Instant::now() + Duration::from_secs(2));
assert!(app.pet_watch.full_next.is_none());
tick(&mut app, false, Instant::now() + Duration::from_secs(3));
assert!(app.pet_watch.full_next.is_none());
}
#[test]
fn pet_copy_footer_tracks_completed_output_and_only_keeps_fully_painted_hitboxes() {
let mut app = app();
assert!(!paint(&mut app, 100, 32).contains("copy finished reply"));
assert!(app.pet_watch.copy_area.lock().unwrap().is_none());
let index = app.history.len();
app.add_message(HistoryCell::Assistant {
content: "finished answer".into(),
streaming: false,
});
app.record_completed_assistant_output(index, "finished answer");
app.add_message(HistoryCell::Assistant {
content: "current partial reply".into(),
streaming: true,
});
assert!(paint(&mut app, 100, 32).contains("c copy finished reply"));
assert!(app.pet_watch.copy_area.lock().unwrap().is_some());
paint(&mut app, 16, 8);
assert!(
app.pet_watch.copy_area.lock().unwrap().is_none(),
"truncated labels have no invisible click area"
);
paint(&mut app, 100, 32);
app.pet_watch.prepare_frame();
assert!(
app.pet_watch.copy_area.lock().unwrap().is_none(),
"another focus owner clears old targets before painting"
);
paint(&mut app, 100, 32);
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 32)).unwrap();
terminal
.draw(|frame| render_main(frame, frame.area(), &mut app))
.unwrap();
assert!(
app.pet_watch.copy_area.lock().unwrap().is_none(),
"the composer backdrop does not expose an inspector action"
);
}
}
+149 -12
View File
@@ -1,21 +1,44 @@
//! Full viewport ownership uses the existing modal stack. Hidden composer,
//! history, selection and active Engine state are untouched.
//! history, selection and active Engine state are untouched. Escape returns
//! to the pet backdrop and the real composer when pet mode is enabled.
use super::Control;
use crate::tui::{
shell_key_routing::{self, Focus, ShellBindingId as Id},
views::{ModalKind, ModalView, ViewAction},
views::{CommandPaletteAction, ModalKind, ModalView, ViewAction, ViewEvent},
};
use crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::{
buffer::Buffer,
layout::{Position, Rect},
};
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::{buffer::Buffer, layout::Rect};
use std::sync::{Arc, Mutex};
pub struct Habitat {
controls: Arc<Mutex<Vec<Control>>>,
selection: Arc<Mutex<Option<String>>>,
copy_area: Arc<Mutex<Option<Rect>>>,
}
impl Habitat {
pub fn new(controls: Arc<Mutex<Vec<Control>>>) -> Self {
Self { controls }
pub fn new(
controls: Arc<Mutex<Vec<Control>>>,
selection: Arc<Mutex<Option<String>>>,
copy_area: Arc<Mutex<Option<Rect>>>,
) -> Self {
Self {
controls,
selection,
copy_area,
}
}
}
fn copy_reply() -> ViewAction {
// Keep completion authority, Markdown, recovery and clipboard acknowledgements
// with /copy. This deliberately copies the last finished reply during a stream.
ViewAction::Emit(ViewEvent::CommandPaletteSelected {
action: CommandPaletteAction::ExecuteCommand {
command: "/copy".into(),
},
})
}
impl ModalView for Habitat {
fn kind(&self) -> ModalKind {
ModalKind::PetHabitat
@@ -27,6 +50,16 @@ impl ModalView for Habitat {
Some(Id::PetResultPageUp) => Some(Control::Scroll(-10)),
Some(Id::PetResultPageDown) => Some(Control::Scroll(10)),
Some(Id::PetBack) => return ViewAction::Close,
Some(Id::PetCopyReply) => return copy_reply(),
Some(Id::PetFocusAgents) => Some(Control::FocusAgents),
Some(Id::PetResultStart) => Some(Control::ScrollEnd(false)),
Some(Id::PetResultEnd) => Some(Control::ScrollEnd(true)),
Some(Id::PetOpenAgent) => {
if let Some(agent_id) = self.selection.lock().ok().and_then(|s| s.clone()) {
return ViewAction::EmitAndClose(ViewEvent::OpenAgentTranscript { agent_id });
}
None
}
Some(Id::PetSound) => Some(Control::Sound),
Some(Id::PetBrowser) => Some(Control::Browser),
Some(Id::PetWindow) => Some(Control::Window),
@@ -40,7 +73,22 @@ impl ModalView for Habitat {
}
ViewAction::None
}
fn handle_mouse(&mut self, _mouse: MouseEvent) -> ViewAction {
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
if mouse.kind == MouseEventKind::Down(MouseButton::Left)
&& self
.copy_area
.lock()
.ok()
.and_then(|area| *area)
.is_some_and(|area| area.contains(Position::new(mouse.column, mouse.row)))
{
return copy_reply();
}
if let Ok(mut queue) = self.controls.lock()
&& queue.len() < 16
{
queue.push(Control::Mouse(mouse));
}
ViewAction::None
}
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
@@ -48,11 +96,10 @@ impl ModalView for Habitat {
self
}
}
pub fn hints(locale: codewhale_localization::Locale) -> String {
pub fn companion_hints(locale: codewhale_localization::Locale) -> String {
use codewhale_localization::{MessageId, tr};
let mut text = tr(locale, MessageId::PetHabitatHints).into_owned();
let mut text = tr(locale, MessageId::PetModeCompanionHints).into_owned();
for (name, id) in [
("back", Id::PetBack),
("sound", Id::PetSound),
("browser", Id::PetBrowser),
("window", Id::PetWindow),
@@ -70,6 +117,18 @@ mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyModifiers};
#[test]
fn enter_opens_only_the_painted_worker_through_the_existing_event_and_closes_pet_focus() {
let controls = Arc::new(Mutex::new(Vec::new()));
let selection = Arc::new(Mutex::new(None));
let mut view = Habitat::new(controls, selection.clone(), Arc::new(Mutex::new(None)));
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
assert!(matches!(view.handle_key(enter), ViewAction::None));
*selection.lock().unwrap() = Some("worker-id".into());
assert!(
matches!(view.handle_key(enter), ViewAction::EmitAndClose(ViewEvent::OpenAgentTranscript { agent_id }) if agent_id == "worker-id")
);
}
#[test]
fn pet_habitat_full_view_preserves_composer_history_and_session_on_escape() {
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
@@ -81,14 +140,18 @@ mod tests {
let session = app.current_session_id.clone();
// Push the actual focus owner without starting a network client.
let controls = Arc::new(Mutex::new(Vec::new()));
app.view_stack.push(Habitat::new(controls.clone()));
app.view_stack.push(Habitat::new(
controls.clone(),
Arc::new(Mutex::new(None)),
Arc::new(Mutex::new(None)),
));
assert_eq!(app.focus(), Focus::Modal(ModalKind::PetHabitat));
assert_eq!(
shell_key_routing::route(
Focus::Composer,
&KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
),
None
Some(Id::PetInspect)
);
app.view_stack
.handle_key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
@@ -105,4 +168,78 @@ mod tests {
assert_eq!(app.history.len(), history);
assert_eq!(app.current_session_id, session);
}
#[test]
fn pet_copy_keyboard_and_mouse_share_the_finished_reply_receipt_without_closing_or_editing() {
let _env = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().unwrap();
let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
let _state = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
app.clipboard = crate::tui::clipboard::ClipboardHandler::for_test(false, false);
app.input = "unfinished draft".into();
let answer = "Finished **Markdown**\n```rust\nlet x = 1;\n```";
let index = app.history.len();
app.add_message(crate::tui::history::HistoryCell::Assistant {
content: answer.into(),
streaming: false,
});
app.record_completed_assistant_output(index, answer);
app.add_message(crate::tui::history::HistoryCell::Assistant {
content: "partial new answer".into(),
streaming: true,
});
let copy_area = Arc::new(Mutex::new(Some(Rect::new(2, 8, 24, 1))));
let mut view = Habitat::new(
Arc::new(Mutex::new(Vec::new())),
Arc::new(Mutex::new(None)),
copy_area.clone(),
);
let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
assert_eq!(shell_key_routing::route(Focus::Composer, &key), None);
let actions = [
view.handle_key(key),
view.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 3,
row: 8,
modifiers: KeyModifiers::NONE,
}),
];
for action in actions {
let ViewAction::Emit(ViewEvent::CommandPaletteSelected {
action: CommandPaletteAction::ExecuteCommand { command },
}) = action
else {
panic!("copy must emit without closing the inspector")
};
assert_eq!(command, "/copy");
let result = crate::commands::execute(&command, &mut app);
assert!(!result.is_error, "{:?}", result.message);
let toast = app
.active_status_toast(crate::tui::underwater::ShellPhase::Done)
.expect("manual copy feedback must survive the completed phase");
assert_eq!(toast.text, result.message.unwrap());
assert_eq!(toast.level, crate::tui::app::StatusToastLevel::Info);
assert_eq!(toast.ttl_ms, Some(8_000));
assert_eq!(
std::fs::read_to_string(tmp.path().join("exports/last-copy.md")).unwrap(),
answer
);
assert_eq!(app.input, "unfinished draft");
}
*copy_area.lock().unwrap() = None;
assert!(
matches!(
view.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 3,
row: 8,
modifiers: KeyModifiers::NONE
}),
ViewAction::None
),
"hidden copy targets must not remain active"
);
}
}
+135 -198
View File
@@ -24,8 +24,10 @@ mod appearance;
mod audio;
mod audio_cursor;
mod avatars;
mod full;
mod graphics;
mod habitat;
pub use full::{render_full, render_main};
mod live;
pub(crate) mod owner;
mod persistence;
@@ -39,6 +41,9 @@ pub enum Control {
Window,
Select,
Scroll(i16),
FocusAgents,
ScrollEnd(bool),
Mouse(crossterm::event::MouseEvent),
}
#[derive(Default)]
pub struct PetWatch {
@@ -53,6 +58,8 @@ pub struct PetWatch {
exporting: bool,
sound_requested: bool,
pub(crate) area: Option<Rect>,
pub(crate) inspect_area: Option<Rect>,
copy_area: Arc<Mutex<Option<Rect>>>,
raster: Option<Presentation>,
controls: Arc<Mutex<Vec<Control>>>,
desired: Option<Rect>,
@@ -63,12 +70,15 @@ pub struct PetWatch {
bytes: u64,
render_ms: f64,
output_ms: f64,
/// `/pet on`: accepted turns enter the full habitat automatically.
/// `/pet on`: the native pet replaces the ordinary shell backdrop.
pub(crate) enabled: bool,
work_enter_pending: bool,
work_complete: bool,
work_history_start: usize,
result_scroll: u16,
full: codewhale_ratatui::PetModeState,
full_next: Option<Instant>,
full_response: Option<crate::tui::transcript::TranscriptViewCache>,
full_response_key: Option<(u64, usize, bool)>,
full_inputs: Option<codewhale_ratatui::whale_motion::Inputs>,
selection: Arc<Mutex<Option<String>>>,
}
impl PetWatch {
pub fn avatar_choices(&self) -> String {
@@ -102,9 +112,15 @@ impl PetWatch {
self.failed = false;
self.unavailable = false;
self.last_tick = None;
self.work_enter_pending = false;
self.work_complete = false;
self.result_scroll = 0;
self.work_history_start = 0;
self.full = codewhale_ratatui::PetModeState::default();
self.full_next = None;
self.full_response = None;
self.full_response_key = None;
self.full_inputs = None;
if let Ok(mut selection) = self.selection.lock() {
*selection = None;
}
self.avatars.view = None;
self.avatars.action = None;
}
@@ -168,7 +184,17 @@ impl PetWatch {
self.failed = true;
}
}
/// Wake the existing terminal loop for the native stage, even at idle.
/// Hidden and reduced/still stages clear this deadline in `full::tick`.
pub fn next_frame_in(&self, now: Instant) -> Option<Duration> {
self.full_next
.map(|deadline| deadline.saturating_duration_since(now))
}
pub fn prepare_frame(&mut self) {
self.inspect_area = None;
if let Ok(mut area) = self.copy_area.lock() {
*area = None;
}
self.desired = None;
}
pub fn present(&mut self, output: &mut impl Write) -> io::Result<()> {
@@ -318,50 +344,79 @@ fn metadata(event: &Event, turn_id: Option<&str>) -> Option<String> {
pub fn command(app: &mut App, control: Control) {
app.pet_watch.ensure(app.current_session_id.clone());
apply(&mut app.pet_watch, control);
let agents = full::agents(app);
apply(&mut app.pet_watch, control, &agents);
app.needs_redraw = true;
}
fn apply(state: &mut PetWatch, control: Control) {
fn apply(state: &mut PetWatch, control: Control, agents: &[codewhale_ratatui::Subagent<'_>]) {
match control {
Control::Browser => state.send(Command::Browser),
Control::Window => state.send(Command::Window),
Control::Select => state.send(Command::Select),
Control::Sound => state.sound_requested = !state.sound_requested,
Control::Scroll(delta) => {
state.result_scroll = state.result_scroll.saturating_add_signed(delta)
Control::Scroll(delta) => state.full.scroll(agents, delta),
Control::FocusAgents => state.full.toggle_agents(agents),
Control::ScrollEnd(end) => state.full.scroll_end(agents, end),
Control::Mouse(mouse) => {
state.full.handle_mouse(agents, mouse);
}
}
}
pub fn open_habitat(app: &mut App) {
app.pet_watch.ensure(app.current_session_id.clone());
if app.view_stack.top_kind() != Some(ModalKind::PetHabitat) {
app.view_stack
.push(habitat::Habitat::new(app.pet_watch.controls.clone()));
app.view_stack.push(habitat::Habitat::new(
app.pet_watch.controls.clone(),
app.pet_watch.selection.clone(),
app.pet_watch.copy_area.clone(),
));
}
app.needs_redraw = true;
}
/// Explicit tools and focused workers remain available on demand.
pub fn main_view(app: &App) -> bool {
app.pet_watch.enabled
&& app.agent_focus.is_none()
&& !app.work_surface.focused
&& app.file_tree.is_none()
}
pub fn is_open(app: &App) -> bool {
app.view_stack.top_kind() == Some(ModalKind::PetHabitat)
|| (main_view(app) && app.view_stack.is_empty())
}
/// `/pet on|off`. Enabling enters the habitat now and lets every accepted
/// turn re-enter it; disabling closes the view and stops automatic entry.
/// The durable pet keeps living in its companion either way, and the
/// composer draft, transcript and active Engine turn are never touched.
/// `/pet on|off` selects the shell backdrop without taking composer focus.
/// Drafts, history, selection and the active Engine turn retain their owners.
pub fn set_enabled(app: &mut App, enabled: bool) {
app.pet_watch.enabled = enabled;
if enabled {
open_habitat(app);
return;
}
app.pet_watch.work_enter_pending = false;
app.pet_watch.work_complete = false;
if is_open(app) {
if app.view_stack.top_kind() == Some(ModalKind::PetHabitat) {
app.view_stack.pop();
}
let session = app.pet_watch.session.clone();
app.pet_watch.reset(session);
if enabled {
crate::tui::work_surface::release_focus(app);
app.launch.visible = false;
crate::tui::agent_focus::exit_focus(app);
app.file_tree = None;
app.file_tree_visible = false;
app.pet_watch.ensure(app.current_session_id.clone());
} else {
let session = app.pet_watch.session.clone();
app.pet_watch.reset(session);
}
app.needs_redraw = true;
}
/// F5 and `/pet inspect` enter the same existing modal stack. Printable keys,
/// paste, submit and editing remain with the normal composer on the main view.
pub fn handle_inspect_key(app: &mut App, key: &crossterm::event::KeyEvent) -> bool {
use crate::tui::shell_key_routing::{self, ShellBindingId};
if app.pet_watch.enabled
&& shell_key_routing::route(app.focus(), key) == Some(ShellBindingId::PetInspect)
{
open_habitat(app);
true
} else {
false
}
}
/// The existing Engine determines work boundaries. Only the shell reads the
/// answer; no conversation text enters the pet owner or recording.
pub fn observe(app: &mut App, event: &Event, now: Instant) {
@@ -369,35 +424,18 @@ pub fn observe(app: &mut App, event: &Event, now: Instant) {
.observe(event, app.current_session_id.as_deref(), now);
if matches!(event, Event::TurnStarted { .. }) {
app.pet_watch.work_history_start = app.history.len();
app.pet_watch.work_complete = false;
app.pet_watch.result_scroll = 0;
app.pet_watch.work_enter_pending = app.pet_watch.enabled;
} else if let Event::TurnComplete { status, .. } = event {
app.pet_watch.work_enter_pending = false;
app.pet_watch.work_complete = *status == TurnOutcomeStatus::Completed
&& app.view_stack.top_kind() == Some(ModalKind::PetHabitat);
app.pet_watch.full.reset_output();
} else if matches!(event, Event::TurnComplete { .. }) {
app.needs_redraw = true;
}
}
pub fn tick(app: &mut App, now: Instant) {
if app.pet_watch.work_enter_pending
&& app.view_stack.is_empty()
&& !app.redaction_gate
&& app.onboarding == crate::tui::app::OnboardingState::None
{
app.pet_watch.work_enter_pending = false;
// Pet mode never hides a running turn behind a companion that is
// not there; the transcript stays in view until it answers again.
if !app.pet_watch.unavailable {
open_habitat(app);
}
}
// The habitat is the pet's only terminal view: it owns the whole content
// viewport or nothing. Reduced motion follows the shell's motion setting.
// Only the visible native backdrop or its inspector advances the stage.
// Other modal owners pause it; the existing composer never becomes a modal.
let visible = !app.redaction_gate
&& app.onboarding == crate::tui::app::OnboardingState::None
&& is_open(app);
let motion = visible && crate::tui::underwater::decorative_shell_motion_enabled(app);
let motion = visible && full::motion(app).animates();
if visible && app.pet_watch.avatars.refresh(&app.workspace, now) {
app.needs_redraw = true;
}
@@ -415,6 +453,11 @@ pub fn tick(app: &mut App, now: Instant) {
.lock()
.map(|mut c| std::mem::take(&mut *c))
.unwrap_or_default();
let agents = if controls.is_empty() {
Vec::new()
} else {
full::agents(app)
};
let state = &mut app.pet_watch;
if state.session != app.current_session_id {
state.reset(app.current_session_id.clone());
@@ -423,7 +466,8 @@ pub fn tick(app: &mut App, now: Instant) {
state.ensure(app.current_session_id.clone());
}
for control in controls {
apply(state, control)
apply(state, control, &agents);
app.needs_redraw = true;
}
if let Some(update) = state
.worker
@@ -435,7 +479,7 @@ pub fn tick(app: &mut App, now: Instant) {
}
state.raster = Some(update);
state.unavailable = false;
if visible {
if visible && !full::canonical(state) {
app.needs_redraw = true;
}
}
@@ -467,8 +511,9 @@ pub fn tick(app: &mut App, now: Instant) {
height: a.height.saturating_sub(1).clamp(1, 256),
cell_width: cell.0,
cell_height: cell.1,
motion,
pixels: matches!(state.avatars.key.as_str(), "" | "whale")
motion: motion && !full::canonical(state),
pixels: !full::canonical(state)
&& matches!(state.avatars.key.as_str(), "" | "whale")
&& crate::tui::mark::kitty_graphics_supported()
&& app.synchronized_output_enabled
&& std::env::var("CODEWHALE_PET_GRAPHICS").as_deref() != Ok("braille"),
@@ -507,7 +552,10 @@ pub fn tick(app: &mut App, now: Instant) {
),
Notice::Unreachable(message) => {
app.pet_watch.unavailable = true;
if app.is_loading && is_open(app) {
if app.is_loading
&& app.view_stack.top_kind() == Some(ModalKind::PetHabitat)
&& !full::canonical(&app.pet_watch)
{
app.view_stack.pop();
}
(
@@ -525,6 +573,7 @@ pub fn tick(app: &mut App, now: Instant) {
app.push_status_toast(text, level, Some(12000));
app.needs_redraw = true;
}
full::tick(app, visible, now);
}
fn render_tank(frame: &mut Frame, area: Rect, app: &mut App) {
app.pet_watch.area = Some(area);
@@ -592,7 +641,7 @@ fn render_tank(frame: &mut Frame, area: Rect, app: &mut App) {
area,
);
let (act, clock) = avatars::act(raster, app.is_loading);
let reduced = !crate::tui::underwater::decorative_shell_motion_enabled(app);
let reduced = !full::motion(app).animates();
let tank = Rect {
height: area.height.saturating_sub(1),
..area
@@ -678,86 +727,6 @@ fn paint_resting_whale(frame: &mut Frame, area: Rect, style: Style) {
}
}
}
pub fn render_full(frame: &mut Frame, app: &mut App) {
let area = frame.area();
frame.render_widget(
Block::default().style(Style::default().bg(app.ui_theme.surface_bg)),
area,
);
frame.render_widget(
Paragraph::new(tr(app.ui_locale, MessageId::PetHabitatTitle))
.style(chrome_style(&app.ui_theme, ChromeInk::Active)),
Rect { height: 1, ..area },
);
let tank = Rect {
x: area.x,
y: area.y + 2,
width: area.width,
height: if app.pet_watch.work_complete {
area.height.saturating_sub(5) / 3
} else {
area.height.saturating_sub(5)
},
};
render_tank(frame, tank, app);
if app.pet_watch.work_complete {
let result_area = Rect {
x: area.x.saturating_add(3),
y: tank.bottom().saturating_add(1),
width: area.width.saturating_sub(6),
height: area
.bottom()
.saturating_sub(tank.bottom())
.saturating_sub(4),
};
let result = app
.history
.iter()
.skip(app.pet_watch.work_history_start)
.rfind(|cell| {
matches!(
cell,
crate::tui::history::HistoryCell::Assistant { .. }
| crate::tui::history::HistoryCell::Error { .. }
)
});
let lines = result
.map(|cell| cell.transcript_lines(result_area.width))
.unwrap_or_else(|| {
vec![ratatui::text::Line::from(
tr(app.ui_locale, MessageId::NotificationTurnComplete).into_owned(),
)]
});
app.pet_watch.result_scroll = app.pet_watch.result_scroll.min(
lines
.len()
.saturating_sub(usize::from(result_area.height))
.min(usize::from(u16::MAX)) as u16,
);
frame.render_widget(
Paragraph::new(lines).scroll((app.pet_watch.result_scroll, 0)),
result_area,
);
}
let hints = if app.pet_watch.work_complete {
format!(
"↑↓ / PgUp/PgDn {} · {}",
tr(app.ui_locale, MessageId::SetupActionScrollBody),
habitat::hints(app.ui_locale)
)
} else {
habitat::hints(app.ui_locale)
};
frame.render_widget(
Paragraph::new(hints).style(chrome_style(&app.ui_theme, ChromeInk::Metadata)),
Rect {
x: area.x,
y: area.bottom().saturating_sub(2),
width: area.width,
height: 2,
},
);
}
pub(crate) fn clear_images(output: &mut impl Write) -> io::Result<()> {
graphics::clear(output)
}
@@ -790,7 +759,11 @@ mod tests {
Instant::now(),
);
tick(&mut app, Instant::now());
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::PetHabitat));
assert!(
app.view_stack.is_empty(),
"accepted work keeps the composer focused"
);
assert!(main_view(&app));
app.add_message(crate::tui::history::HistoryCell::Assistant {
content: "Prepared result stays in the transcript".into(),
streaming: false,
@@ -808,7 +781,6 @@ mod tests {
},
Instant::now(),
);
assert!(app.pet_watch.work_complete);
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 40)).unwrap();
terminal.draw(|frame| render_full(frame, &mut app)).unwrap();
@@ -825,7 +797,7 @@ mod tests {
}
#[test]
fn unavailable_companion_paints_the_resting_whale_and_keeps_the_turn_visible() {
fn unavailable_companion_keeps_legacy_fallback_and_native_response_available() {
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
app.onboarding = crate::tui::app::OnboardingState::None;
@@ -856,6 +828,7 @@ mod tests {
assert!(!text.contains(" · offline"), "no orphan separator: {text}");
app.pet_watch.enabled = true;
app.is_loading = true;
observe(
&mut app,
&Event::TurnStarted {
@@ -866,11 +839,25 @@ mod tests {
},
Instant::now(),
);
app.add_message(crate::tui::history::HistoryCell::Assistant {
content: "Fixture response while telemetry reconnects".into(),
streaming: true,
});
tick(&mut app, Instant::now());
assert!(
app.view_stack.is_empty(),
"pet mode must not cover a turn while the companion is unavailable"
is_open(&app),
"native pet mode keeps the actual turn available without a companion raster"
);
terminal.draw(|frame| render_full(frame, &mut app)).unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("Fixture response while telemetry reconnects"));
assert!(app.pet_watch.worker.is_none());
}
#[test]
@@ -910,13 +897,13 @@ mod tests {
tick(&mut app, Instant::now());
assert!(app.pet_watch.unavailable);
assert!(
!is_open(&app),
"an unreachable companion hands the running turn back"
is_open(&app),
"the canonical whale and Engine response remain available while telemetry reconnects"
);
}
#[test]
fn pet_off_stops_automatic_entry_and_keeps_the_draft() {
fn pet_off_restores_the_shell_and_keeps_the_draft() {
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
app.onboarding = crate::tui::app::OnboardingState::None;
@@ -935,75 +922,24 @@ mod tests {
},
Instant::now(),
);
assert!(app.pet_watch.work_enter_pending);
assert!(main_view(&app));
assert!(app.view_stack.is_empty());
set_enabled(&mut app, false);
tick(&mut app, Instant::now());
assert!(!app.pet_watch.enabled);
assert!(!app.pet_watch.work_enter_pending);
assert!(app.view_stack.is_empty());
assert_eq!(app.input, "kept draft");
assert!(app.pet_watch.worker.is_none());
}
#[test]
fn only_completed_turns_set_the_pet_done_state() {
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
app.onboarding = crate::tui::app::OnboardingState::None;
app.redaction_gate = false;
app.pet_watch.detach_for_test();
open_habitat(&mut app);
let finish = |status| Event::TurnComplete {
usage: Default::default(),
parent_route_usage: Default::default(),
routed_usage_dropped_records: 0,
status,
error: None,
tool_catalog: None,
base_url: None,
};
for status in [TurnOutcomeStatus::Interrupted, TurnOutcomeStatus::Failed] {
observe(
&mut app,
&Event::TurnStarted {
turn_id: format!("turn-{:?}", status),
created_at: chrono::Utc::now(),
route: None,
submission_id: None,
},
Instant::now(),
);
observe(&mut app, &finish(status), Instant::now());
assert!(!app.pet_watch.work_complete, "{status:?} cannot mark Done");
}
observe(
&mut app,
&Event::TurnStarted {
turn_id: "turn-completed".into(),
created_at: chrono::Utc::now(),
route: None,
submission_id: None,
},
Instant::now(),
);
observe(
&mut app,
&finish(TurnOutcomeStatus::Completed),
Instant::now(),
);
assert!(app.pet_watch.work_complete);
assert!(app.pet_watch.worker.is_none());
}
#[test]
fn session_switch_resets_the_view_and_tracks_the_switching_turn() {
let mut watch = PetWatch::default();
watch.detach_for_test();
watch.session = Some("session-a".into());
watch.active_turn_id = Some("turn-a".into());
watch.work_complete = true;
watch.full.output_scroll = 100;
watch.work_history_start = 50;
watch.observe(
&Event::TurnStarted {
@@ -1019,7 +955,8 @@ mod tests {
// The event that switched sessions is not dropped: its turn is the
// one a later `turn_complete` must carry to claim Done.
assert_eq!(watch.active_turn_id.as_deref(), Some("turn-b"));
assert!(!watch.work_complete, "Done never survives a session switch");
assert_eq!(watch.full.output_scroll, 0);
assert_eq!(watch.work_history_start, 0);
assert!(!watch.failed, "a switch clears a prior failure");
assert!(
watch.worker.is_none(),
+50
View File
@@ -111,11 +111,17 @@ pub enum ShellBindingId {
ElevationDown,
ElevationConfirm,
ElevationAbort,
PetInspect,
PetResultUp,
PetResultDown,
PetResultPageUp,
PetResultPageDown,
PetBack,
PetResultStart,
PetResultEnd,
PetFocusAgents,
PetOpenAgent,
PetCopyReply,
PetSound,
PetBrowser,
PetWindow,
@@ -173,6 +179,7 @@ impl ShellBinding {
key.code == KeyCode::Enter && key.modifiers.is_empty()
}
ShellBindingId::ElevationAbort => key.code == KeyCode::Esc && key.modifiers.is_empty(),
ShellBindingId::PetInspect => key.code == KeyCode::F(5) && key.modifiers.is_empty(),
ShellBindingId::PetResultUp => key.code == KeyCode::Up && key.modifiers.is_empty(),
ShellBindingId::PetResultDown => key.code == KeyCode::Down && key.modifiers.is_empty(),
ShellBindingId::PetResultPageUp => {
@@ -182,6 +189,13 @@ impl ShellBinding {
key.code == KeyCode::PageDown && key.modifiers.is_empty()
}
ShellBindingId::PetBack => key.code == KeyCode::Esc && key.modifiers.is_empty(),
ShellBindingId::PetResultStart => key.code == KeyCode::Home && key.modifiers.is_empty(),
ShellBindingId::PetResultEnd => key.code == KeyCode::End && key.modifiers.is_empty(),
ShellBindingId::PetFocusAgents => key.code == KeyCode::Tab && key.modifiers.is_empty(),
ShellBindingId::PetOpenAgent => key.code == KeyCode::Enter && key.modifiers.is_empty(),
ShellBindingId::PetCopyReply => {
key.code == KeyCode::Char('c') && key.modifiers.is_empty()
}
ShellBindingId::PetSound => key.code == KeyCode::F(6) && key.modifiers.is_empty(),
ShellBindingId::PetBrowser => key.code == KeyCode::F(8) && key.modifiers.is_empty(),
ShellBindingId::PetWindow => key.code == KeyCode::F(9) && key.modifiers.is_empty(),
@@ -281,6 +295,12 @@ pub const SHELL_BINDINGS: &[ShellBinding] = &[
footer_chord: "Esc",
focus: FocusScope::Elevation,
},
ShellBinding {
id: ShellBindingId::PetInspect,
catalog_chord: "F5 / /pet inspect",
footer_chord: "F5",
focus: FocusScope::AnyShell,
},
ShellBinding {
id: ShellBindingId::PetResultUp,
catalog_chord: "Up",
@@ -311,6 +331,36 @@ pub const SHELL_BINDINGS: &[ShellBinding] = &[
footer_chord: "Esc",
focus: FocusScope::PetHabitat,
},
ShellBinding {
id: ShellBindingId::PetResultStart,
catalog_chord: "Home",
footer_chord: "Home",
focus: FocusScope::PetHabitat,
},
ShellBinding {
id: ShellBindingId::PetResultEnd,
catalog_chord: "End",
footer_chord: "End",
focus: FocusScope::PetHabitat,
},
ShellBinding {
id: ShellBindingId::PetFocusAgents,
catalog_chord: "Tab",
footer_chord: "Tab",
focus: FocusScope::PetHabitat,
},
ShellBinding {
id: ShellBindingId::PetOpenAgent,
catalog_chord: "Enter",
footer_chord: "Enter",
focus: FocusScope::PetHabitat,
},
ShellBinding {
id: ShellBindingId::PetCopyReply,
catalog_chord: "c",
footer_chord: "c",
focus: FocusScope::PetHabitat,
},
ShellBinding {
id: ShellBindingId::PetSound,
catalog_chord: "F6",
+21 -2
View File
@@ -1,4 +1,4 @@
//! The single owner for mode and reasoning defaults written back by interactive
//! The single owner for mode, reasoning and pet-view defaults written back by interactive
//! TUI selectors. Provider/model choices persist through `config_persistence`.
//!
//! Before this module there were three unrelated writers for the same
@@ -128,6 +128,8 @@ pub struct StartupDefaults {
/// `settings.reasoning_effort` — normalized for the active route by the
/// caller, because only the caller knows the route.
reasoning_effort: Option<String>,
/// `settings.pet_mode` — the main view a fresh session starts in.
pet_mode: Option<bool>,
}
impl StartupDefaults {
@@ -154,6 +156,15 @@ impl StartupDefaults {
}
}
/// Persist the selected main view without delaying the event loop.
#[must_use]
pub fn pet_mode(enabled: bool) -> Self {
Self {
pet_mode: Some(enabled),
..Self::default()
}
}
#[cfg(test)]
#[must_use]
pub fn with_reasoning_effort(mut self, effort: &str) -> Self {
@@ -163,7 +174,7 @@ impl StartupDefaults {
#[must_use]
pub fn is_empty(&self) -> bool {
self.mode.is_none() && self.reasoning_effort.is_none()
self.mode.is_none() && self.reasoning_effort.is_none() && self.pet_mode.is_none()
}
/// Which user-facing settings this update touches, as typed subjects.
@@ -182,6 +193,9 @@ impl StartupDefaults {
if self.reasoning_effort.is_some() {
subjects.push(StartupDefaultSubject::Thinking);
}
if self.pet_mode.is_some() {
subjects.push(StartupDefaultSubject::PetMode);
}
subjects
}
@@ -205,6 +219,9 @@ impl StartupDefaults {
if let Some(effort) = self.reasoning_effort.as_deref() {
settings.set("reasoning_effort", effort)?;
}
if let Some(enabled) = self.pet_mode {
settings.set("pet_mode", if enabled { "true" } else { "false" })?;
}
Ok(())
})
}
@@ -239,6 +256,8 @@ pub enum StartupDefaultSubject {
Mode,
/// `settings.reasoning_effort`.
Thinking,
/// `settings.pet_mode`.
PetMode,
}
/// One startup-default write that did not land.
+12
View File
@@ -191,6 +191,18 @@ impl TranscriptViewCache {
self.streaming_source_receipt = receipt;
}
#[cfg(test)]
pub(crate) fn streaming_render_work(
&self,
cell_index: usize,
) -> Option<crate::tui::markdown_render::MarkdownRenderWork> {
self.per_cell
.get(cell_index)?
.incremental_markdown
.as_ref()
.map(|cache| cache.work())
}
pub(crate) fn take_transcript_action(
&mut self,
) -> Option<(TranscriptActionOwner, Option<CellFoldActionTarget>)> {
+7
View File
@@ -5029,6 +5029,9 @@ pub(crate) async fn run_event_loop(
if let Some(until_anim) = frame_requester.due_in(now) {
poll_timeout = poll_timeout.min(until_anim);
}
if let Some(until_pet) = app.pet_watch.next_frame_in(Instant::now()) {
poll_timeout = poll_timeout.min(until_pet);
}
// While the quit-confirmation prompt is armed, ensure we wake up to
// expire it on time even if no input event arrives.
if let Some(deadline) = app.quit_armed_until {
@@ -5561,6 +5564,10 @@ pub(crate) async fn run_event_loop(
continue;
}
if crate::tui::pet_watch::handle_inspect_key(app, &key) {
continue;
}
// The shell's key admission runs through one table
// (`shell_key_routing::SHELL_BINDINGS`) keyed by one focus owner
// (`app.focus()`) — never by whether the composer happens to hold
+282 -14
View File
@@ -1498,6 +1498,8 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
return None;
}
let pet_main = crate::tui::pet_watch::main_view(app);
// Mini-window mode: when the host terminal window is pinned into its
// small always-on-top form, hide the shell chrome and keep only what the
// user opted to keep (`[mini_window]` in config.toml, or mutated live by
@@ -1517,7 +1519,8 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
// `tui.metrics_line = "hidden"` gives the row to the transcript (#5950).
// The empty shell keeps route identity visible; render_info_row omits
// session readings until a conversation exists.
let info_height = if (mini && !mini_cfg.keep_header)
let info_height = if pet_main
|| (mini && !mini_cfg.keep_header)
|| app.metrics_line == crate::config::ChromeRowPreset::Hidden
{
0
@@ -1529,7 +1532,9 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
// left, depth·keys on the right. It hides with the rest of the footer
// chrome in mini mode, never with the composer.
// `tui.posture_bar = "hidden"` likewise (#5950).
let footer_height = if (mini && !mini_cfg.keep_footer)
let footer_height = if pet_main {
crate::tui::phase_strip::height()
} else if (mini && !mini_cfg.keep_footer)
|| app.posture_bar == crate::config::ChromeRowPreset::Hidden
{
0
@@ -1544,7 +1549,10 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
}
let rail_budget = rail_row_budget(app, shell_area.width, shell_area.height, idle_empty);
let top_work_strip_height = if mini && !mini_cfg.keep_todo {
let top_work_strip_height = if pet_main {
crate::tui::work_surface::collapse_strip(app);
0
} else if mini && !mini_cfg.keep_todo {
// Mini mode hides the strip; when the side rail is also hidden (the
// default), drop the work-surface interaction state so stale
// hitboxes from the pre-pin layout cannot swallow transcript clicks
@@ -1573,7 +1581,7 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
.saturating_add(top_work_strip_height),
)
.max(MIN_COMPOSER_HEIGHT);
let composer_height = if mini && !mini_cfg.keep_input {
let composer_height = if !pet_main && mini && !mini_cfg.keep_input {
0
} else {
let composer_widget = ComposerWidget::new(
@@ -1590,7 +1598,7 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
// "messages typed during a running turn vanish" complaint by giving the
// user immediate visible feedback above the composer.
let pending_preview = build_pending_input_preview(app);
let desired_preview_height = if mini {
let desired_preview_height = if !pet_main && mini {
0
} else {
pending_preview.desired_height(shell_area.width)
@@ -1605,12 +1613,12 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
// posture bar, so live progress sits beside the controls that act on it
// and never between the transcript and the composer. Zero rows when no
// run is showing.
let desired_workbar_height = if mini {
let desired_workbar_height = if pet_main || mini {
0
} else {
crate::tui::widgets::workbar::desired_rows(app.workflow_runs.len())
};
let plugin_cta_height = if mini && !mini_cfg.keep_input {
let plugin_cta_height = if !pet_main && mini && !mini_cfg.keep_input {
0
} else {
app.plugin_cta_row_height()
@@ -1692,7 +1700,7 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
.height
.min(prompt.y.saturating_sub(visible_chat_area.y));
}
let (work_chat_area, side_work_area) = if mini && !mini_cfg.keep_sidebar {
let (work_chat_area, side_work_area) = if pet_main || (mini && !mini_cfg.keep_sidebar) {
// Mini mode without the side rail: the transcript takes the whole
// chat row. split_chat is skipped so the rail never reserves columns.
(visible_chat_area, None)
@@ -1749,7 +1757,16 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
};
app.sidebar_hover_tooltip = None;
if app.agent_focus.is_some() && !app.launch.return_to_session {
if pet_main {
// Hidden shell surfaces must relinquish their pointer targets.
// The common composer below still publishes its exact native plan.
app.viewport.last_transcript_area = None;
app.viewport.pinned_prompt_area = None;
app.viewport.pinned_prompt_message = None;
app.launch.row_hitboxes.clear();
crate::tui::pet_watch::render_main(f, chat_area, app);
shell_ocean = None;
} else if app.agent_focus.is_some() && !app.launch.return_to_session {
// A focused worker's full transcript owns the conversation area;
// the ocean column and every other shell surface stay as they are.
//
@@ -1799,10 +1816,12 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
// hitbox cannot describe a row the transcript did not draw — and a
// fully dissolved card painted nothing this frame, so it owns no
// rows either.
if app.launch.card_paintable(
app.ambient_clock_ms,
app.motion_policy().allows_decorative(),
) {
if !pet_main
&& app.launch.card_paintable(
app.ambient_clock_ms,
app.motion_policy().allows_decorative(),
)
{
crate::tui::underwater::refresh_launch_row_hitboxes(app, chat_area);
} else if !app.launch.row_hitboxes.is_empty() {
app.launch.row_hitboxes.clear();
@@ -1844,7 +1863,17 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
// the remote-control state or a live notice pinned right.
if footer_height > 0 {
let area = body_chunks[footer_slot];
let facts = crate::tui::phase_strip::tideline_footer_from_app(app, area.width);
let mut facts = crate::tui::phase_strip::tideline_footer_from_app(app, area.width);
if pet_main {
// The pet header owns phase and notices. This row retains the
// existing permission/mode controls, live counts and cap warning.
facts.turn_clock = None;
facts.session_clock = None;
facts.right = None;
if !app.is_loading {
facts.hint = None;
}
}
let footer = facts
.widget(
&app.ui_theme,
@@ -3084,3 +3113,242 @@ mod tests {
#[cfg(test)]
mod one_owner_tests;
#[cfg(test)]
mod pet_main_tests {
use super::*;
use crate::tui::{
approval::{ApprovalRequest, ApprovalView},
pet_watch,
shell_key_routing::Focus,
};
use ratatui::{Terminal, backend::TestBackend};
fn app() -> App {
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
app.onboarding = OnboardingState::None;
app.redaction_gate = false;
app.ui_locale = codewhale_localization::Locale::En;
app.pet_watch.detach_for_test();
pet_watch::set_enabled(&mut app, true);
app
}
fn paint(
app: &mut App,
width: u16,
height: u16,
) -> (ratatui::buffer::Buffer, Option<(u16, u16)>) {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
let mut cursor = None;
terminal
.draw(|frame| cursor = render(frame, app, &Config::default()))
.unwrap();
(terminal.backend().buffer().clone(), cursor)
}
fn text(buf: &ratatui::buffer::Buffer) -> String {
buf.content.iter().map(|cell| cell.symbol()).collect()
}
fn capture(app: &App, name: &str, buf: &ratatui::buffer::Buffer, light: bool) {
// Use the actual output backend's color projection, including light
// contrast and terminal depth, before exporting the terminal cells.
let mut buf = buf.clone();
for cell in &mut buf.content {
crate::tui::color_compat::adapt_cell_colors(
cell,
codewhale_palette::ColorDepth::detect(),
app.ui_theme.mode,
app.theme_id,
&app.ui_theme,
Some(app.ui_theme.surface_bg),
);
}
if let Some(dir) = std::env::var_os("CODEWHALE_PET_CAPTURE_DIR") {
let dir = PathBuf::from(dir);
std::fs::create_dir_all(&dir).unwrap();
let profile = if light {
codewhale_ratatui::testing::Profile::LightTrue
} else {
codewhale_ratatui::testing::Profile::DarkTrue
};
std::fs::write(
dir.join(format!("{name}.svg")),
codewhale_ratatui::testing::svg(&buf, &profile.theme()),
)
.unwrap();
}
}
#[test]
fn pet_main_retains_native_composer_caret_pointer_and_paste_at_every_size() {
for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)] {
let mut app = app();
app.composer_border = true;
app.input = "Help me build 中文".into();
app.cursor_position = app.input.chars().count();
let (buf, cursor) = paint(&mut app, width, height);
assert_eq!(app.focus(), Focus::Composer);
let cursor = cursor.expect("pet mode keeps the actual composer caret");
let composer = app.viewport.last_composer_area.unwrap();
assert!(composer.contains(cursor.into()), "{width}x{height}");
assert!(app.viewport.last_transcript_area.is_none());
assert!(app.work_surface.last_area.is_none());
assert!(app.viewport.last_infoline_hitboxes.is_empty());
assert!(text(&buf).contains("F5"));
capture(&app, &format!("main-{width}x{height}"), &buf, false);
assert!(handle_composer_mouse(
&mut app,
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(
crossterm::event::MouseButton::Left
),
column: cursor.0,
row: cursor.1,
modifiers: KeyModifiers::NONE,
}
));
assert_eq!(app.cursor_position, app.input.chars().count());
handle_bracketed_paste(&mut app, " pasted");
assert!(app.input.ends_with(" pasted"));
assert!(app.view_stack.is_empty());
}
}
#[test]
fn pet_main_inspection_uses_the_same_key_and_pointer_route_and_returns_to_the_draft() {
let mut app = app();
app.input = "kept draft".into();
app.cursor_position = app.input.chars().count();
app.add_message(HistoryCell::Assistant {
content: "INSPECTION_RECEIPT".into(),
streaming: true,
});
let session = app.current_session_id.clone();
let (buf, _) = paint(&mut app, 100, 32);
assert!(!text(&buf).contains("INSPECTION_RECEIPT"));
assert!(!pet_watch::handle_inspect_key(
&mut app,
&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)
));
assert!(pet_watch::handle_inspect_key(
&mut app,
&KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
));
let (buf, cursor) = paint(&mut app, 100, 32);
assert!(text(&buf).contains("INSPECTION_RECEIPT"));
assert!(cursor.is_none());
app.view_stack
.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.focus(), Focus::Composer);
assert!(pet_watch::main_view(&app));
paint(&mut app, 100, 32);
let hint = app.pet_watch.inspect_area.unwrap();
crate::tui::mouse_ui::handle_mouse_event(
&mut app,
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: hint.x,
row: hint.y,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::PetHabitat));
app.view_stack
.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.input, "kept draft");
assert_eq!(app.current_session_id, session);
pet_watch::set_enabled(&mut app, false);
let (buf, cursor) = paint(&mut app, 100, 32);
assert!(text(&buf).contains("INSPECTION_RECEIPT"));
assert!(cursor.is_some());
}
#[test]
fn pet_main_keeps_queue_feedback_and_cannot_take_approval_focus() {
let mut app = app();
app.is_loading = true;
app.queue_message(crate::tui::app::QueuedMessage::new(
"QUEUED_FIXTURE_MESSAGE".into(),
None,
));
let (buf, _) = paint(&mut app, 80, 24);
assert!(text(&buf).contains("QUEUED_FIXTURE_MESSAGE"));
app.view_stack.push(ApprovalView::new(ApprovalRequest::new(
"pet-approval",
"exec_shell",
"Review command",
&serde_json::json!({"command":"git status"}),
"pet-approval-key",
)));
assert!(!pet_watch::handle_inspect_key(
&mut app,
&KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
));
let (_, cursor) = paint(&mut app, 80, 24);
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Approval));
assert_eq!(app.focus(), Focus::Modal(ModalKind::Approval));
assert!(cursor.is_none());
assert_eq!(app.queued_messages.len(), 1);
}
#[test]
fn pet_main_light_render_keeps_the_same_live_composer() {
let mut app = app();
app.theme_id = codewhale_palette::ThemeId::ShorelineLight;
app.ui_theme = app.theme_id.ui_theme();
app.input = "What should we work on?".into();
app.cursor_position = app.input.chars().count();
let (buf, cursor) = paint(&mut app, 100, 32);
assert!(cursor.is_some());
assert_eq!(app.focus(), Focus::Composer);
capture(&app, "main-light-100x32", &buf, true);
}
#[test]
fn pet_inspector_keeps_the_copy_receipt_visible_and_returns_to_the_draft() {
let _env = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().unwrap();
let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
let _state = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
for light in [false, true] {
let mut app = app();
if light {
app.theme_id = codewhale_palette::ThemeId::ShorelineLight;
app.ui_theme = app.theme_id.ui_theme();
}
app.clipboard = crate::tui::clipboard::ClipboardHandler::for_test(false, false);
app.input = "My next message".into();
let reply = "Pet mode\n\nYour view is remembered when Codewhale starts.\n\n- F5 opens replies and agents.\n- Escape returns to your draft.\n- c copies the last finished reply.";
let index = app.history.len();
app.add_message(HistoryCell::Assistant {
content: reply.into(),
streaming: false,
});
app.record_completed_assistant_output(index, reply);
assert!(pet_watch::handle_inspect_key(
&mut app,
&KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
));
let result =
crate::commands::execute_with_config("/copy", &mut app, &Config::default());
assert!(!result.is_error);
let (buf, cursor) = paint(&mut app, 100, 32);
let output = text(&buf);
assert!(
output.contains("Accepted the last completed assistant response"),
"copy feedback must be painted above the hidden transcript: {output}"
);
assert!(output.contains("c copy finished reply"));
assert!(cursor.is_none());
capture(
&app,
if light {
"inspector-copy-light-100x32"
} else {
"inspector-copy-dark-100x32"
},
&buf,
light,
);
app.view_stack
.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.input, "My next message");
assert_eq!(app.focus(), Focus::Composer);
}
}
}
+13 -3
View File
@@ -895,11 +895,17 @@ fn shell_binding_probe(id: ShellBindingId) -> KeyEvent {
ShellBindingId::ElevationDown => KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
ShellBindingId::ElevationConfirm => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
ShellBindingId::ElevationAbort => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
ShellBindingId::PetInspect => KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE),
ShellBindingId::PetResultUp => KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
ShellBindingId::PetResultDown => KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
ShellBindingId::PetResultPageUp => KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
ShellBindingId::PetResultPageDown => KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE),
ShellBindingId::PetBack => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
ShellBindingId::PetResultStart => KeyEvent::new(KeyCode::Home, KeyModifiers::NONE),
ShellBindingId::PetResultEnd => KeyEvent::new(KeyCode::End, KeyModifiers::NONE),
ShellBindingId::PetFocusAgents => KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
ShellBindingId::PetOpenAgent => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
ShellBindingId::PetCopyReply => KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
ShellBindingId::PetSound => KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE),
ShellBindingId::PetBrowser => KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE),
ShellBindingId::PetWindow => KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE),
@@ -967,9 +973,13 @@ fn no_shell_binding_changes_meaning_once_the_composer_has_text() {
.focus
.admits(crate::tui::shell_key_routing::Focus::Composer)
{
assert_eq!(
on_typed, None,
"exclusive view keys must not act on a draft"
// A chord may serve a different owner: Tab cycles the composer's
// mode and switches panes inside pet mode. The modal binding
// itself must never act on the composer draft.
assert_ne!(
on_typed,
Some(binding.id),
"exclusive view bindings must not act on a draft"
);
continue;
}
+37
View File
@@ -2447,6 +2447,13 @@ impl ConfigView {
scope: ConfigScope::Saved,
facts: ConfigRowFacts::saved_setting(),
},
ConfigRow {
key: "pet_mode".to_string(),
value: settings.pet_mode.to_string(),
editable: true,
scope: ConfigScope::Saved,
facts: ConfigRowFacts::saved_setting().effective(app.pet_watch.enabled.to_string()),
},
ConfigRow {
key: "calm_mode".to_string(),
value: settings.calm_mode.to_string(),
@@ -9205,6 +9212,36 @@ base_url = "https://api.xiaomimimo.com/v1"
/// Every message key declared by the schema must resolve to a localized
/// string in every shipped locale. `tr_key` returns the key itself when a
/// pack is missing the entry, so this fails fast on a stale binding.
#[test]
fn pet_mode_setting_shows_saved_and_live_choices_and_emits_the_existing_commit_event() {
let _guard = ConfigSettingsEnvGuard::new("pet_mode = false\n");
let mut app = create_test_app();
app.pet_watch.enabled = true;
let mut view = ConfigView::new_for_app(&app);
let row = view
.rows
.iter()
.find(|row| row.key == "pet_mode")
.expect("pet view setting");
assert_eq!(row.value, "false");
assert_eq!(row.facts.effective.as_deref(), Some("true"));
assert!(row.editable);
assert_eq!(row.ui().unwrap().label, "ConfigLabelPetMode");
view.focus_key("pet_mode");
view.start_edit();
let edit = view.editing.as_mut().expect("pet mode editor");
edit.selected_choice = edit
.choices
.as_ref()
.unwrap()
.iter()
.position(|value| value == "true")
.unwrap();
assert!(
matches!(view.handle_choice_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), ViewAction::Emit(ViewEvent::ConfigUpdated { key, value, persist: true }) if key == "pet_mode" && value == "true")
);
}
#[test]
fn settings_schema_message_keys_are_localized() {
let mut keys: Vec<&'static str> = Vec::new();
+1 -1
View File
@@ -559,7 +559,7 @@ owner = "crates/tui/src/voice.rs"
[[feature]]
id = "pet"
name = "Pet"
summary = "/pet turns the screen over to the Codewhale pet while it works."
summary = "/pet on remembers the animated main view across launches; /pet inspect opens replies and agents with a copy-finished-reply action."
status = "stable"
surfaces = { tui = "stable" }
owner = "crates/tui/src/tui/pet_watch"
+5
View File
@@ -22,6 +22,11 @@ pub struct Sprite<'a> {
frame: usize,
}
impl<'a> Sprite<'a> {
/// One bounded RGBA image, such as the canonical pet's live cove.
pub fn image(pixels: &'a [u8], width: usize, height: usize) -> Result<Self, String> {
Self::tiles(pixels, width, height, 0, 1)
}
pub fn new(
pack: &crate::avatar::Pack,
pixels: &'a [u8],
+7
View File
@@ -118,3 +118,10 @@ pub use dock_tabs::*;
mod braille_frame;
pub(crate) use braille_frame::paint_braille_cells;
pub use braille_frame::*;
mod whale_pet;
pub use whale_pet::*;
mod subagents;
pub use subagents::*;
mod pet_mode;
pub use pet_mode::*;
+573
View File
@@ -0,0 +1,573 @@
//! The full pet work surface. Replaces host-specific full-screen tank layout;
//! the host still owns sessions, roster receipts, responses and every action.
use crate::{
MotionMode, Role, StatusMark, Subagent, SubagentIntent, SubagentView, SubagentViewState,
SubagentViewWords, Theme, WhalePet, text,
whale_motion::{Inputs, Presence, Stage, Tier},
};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind};
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::Modifier,
text::{Line, Span},
widgets::{Paragraph, StatefulWidget, Widget, Wrap},
};
use std::{
borrow::Cow,
time::{Duration, Instant},
};
/// Geometry shared by paint, response wrapping and host pointer routing.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PetModeAreas {
pub header: Rect,
pub pet: Rect,
pub agents: Rect,
pub output: Rect,
pub footer: Rect,
}
impl PetModeAreas {
#[must_use]
pub fn new(area: Rect, agents: bool, output: bool, focus_agents: bool) -> Self {
let header = Rect {
height: area.height.min(2),
..area
};
let footer_height = area.height.saturating_sub(header.height).min(2);
let footer = Rect::new(
area.x,
area.bottom().saturating_sub(footer_height),
area.width,
footer_height,
);
let body = Rect::new(
area.x,
header.bottom(),
area.width,
area.height.saturating_sub(header.height + footer_height),
);
let mut plan = Self {
header,
footer,
..Self::default()
};
let wide = agents && body.width >= 100 && body.height >= 12;
let main = if wide {
let width = (body.width / 3).clamp(32, 44);
plan.agents = Rect::new(body.right() - width, body.y, width, body.height);
Rect {
width: body.width.saturating_sub(width + 2),
..body
}
} else if agents && focus_agents {
plan.agents = body;
return plan;
} else {
body
};
if output && main.height >= 8 {
// Reading has priority over scenery, including at 80x24.
let height = (u32::from(main.height) * 2 / 3).max(6) as u16;
plan.output = Rect::new(main.x, main.bottom() - height, main.width, height);
plan.pet = Rect {
height: main.height - height,
..main
};
} else if output {
plan.output = main;
} else {
plan.pet = main;
}
plan
}
}
/// One canonical parent performance plus local selection/scroll. Never Clone.
#[derive(Default)]
pub struct PetModeState {
stage: Stage,
agents: SubagentViewState,
pub output_scroll: usize,
pub focus_agents: bool,
output_detached: bool,
output_end: usize,
areas: PetModeAreas,
session: Option<String>,
visible: bool,
motion: MotionMode,
stage_animates: bool,
}
impl PetModeState {
/// The owner supplies current facts and one clock before painting.
pub fn update(
&mut self,
session: Option<&str>,
inputs: Inputs,
agents: &[Subagent<'_>],
now: Instant,
motion: MotionMode,
) {
if self.session.as_deref() != session {
self.agents = SubagentViewState::default();
self.agents.set_visible(self.visible);
self.reset_output();
self.focus_agents = false;
self.session = session.map(str::to_owned);
} else if self.stage.director().context.turn_id != inputs.context.turn_id {
self.reset_output();
}
let settled = inputs.presence == Presence::Done
|| (matches!(
inputs.presence,
Presence::Idle | Presence::Listening | Presence::Offline
) && matches!(
inputs.context.status.as_deref(),
Some("completed" | "failed" | "error" | "cancelled" | "canceled" | "interrupted")
));
self.motion = motion;
self.stage.set_visible(self.visible);
self.agents.set_visible(self.visible);
self.stage
.observe(session, inputs.clone(), !motion.animates());
self.stage.advance(now);
self.stage_animates = !settled || self.stage.director().completing();
if !self.stage_animates {
self.stage.observe(session, inputs, true);
}
self.agents.update(agents, now, motion);
if agents.is_empty() {
self.focus_agents = false;
}
}
pub fn set_visible(&mut self, visible: bool) {
self.visible = visible;
self.stage.set_visible(visible);
self.agents.set_visible(visible);
}
#[must_use]
pub fn next_frame_in(&self, agents: &[Subagent<'_>], theme: &Theme) -> Option<Duration> {
if !self.visible || !self.motion.animates() {
return None;
}
let pet = (!self.areas.pet.is_empty() && self.stage_animates)
.then(|| {
self.stage.cadence(if theme.paints_grounds() {
Tier::Hero
} else {
Tier::Terminal
})
})
.flatten();
[pet, self.agents.next_frame_in(agents, theme)]
.into_iter()
.flatten()
.min()
}
/// Painted selection only; hosts validate the ID again before opening.
#[must_use]
pub fn selected_agent_id(&self) -> Option<&str> {
(self.visible && self.focus_agents)
.then(|| self.agents.selected_id())
.flatten()
}
pub fn toggle_agents(&mut self, agents: &[Subagent<'_>]) {
if !agents.is_empty() {
self.focus_agents = !self.focus_agents;
}
}
/// Follow the next response from the host's authoritative turn boundary.
pub fn reset_output(&mut self) {
self.output_scroll = 0;
self.output_end = 0;
self.output_detached = false;
}
fn scroll_output(&mut self, lines: i16) {
self.output_scroll = self
.output_scroll
.saturating_add_signed(isize::from(lines))
.min(self.output_end);
self.output_detached = self.output_scroll < self.output_end;
}
/// Result scrolling or roster navigation, depending on the visible focus.
pub fn scroll(&mut self, agents: &[Subagent<'_>], lines: i16) {
if lines == 0 {
return;
}
if self.focus_agents {
let index = self
.agents
.selected_id()
.and_then(|id| agents.iter().position(|agent| agent.id == id))
.unwrap_or(0);
let target = index
.saturating_add_signed(isize::from(lines))
.min(agents.len().saturating_sub(1));
if let Some(agent) = agents.get(target) {
self.agents.select(agents, &agent.id);
}
} else {
self.scroll_output(lines);
}
}
/// Home/End route to the focused roster or the complete response.
pub fn scroll_end(&mut self, agents: &[Subagent<'_>], end: bool) {
if self.focus_agents {
self.agents.handle_key(
agents,
KeyEvent::from(if end { KeyCode::End } else { KeyCode::Home }),
);
} else {
self.output_scroll = if end { usize::MAX } else { 0 };
self.output_detached = !end;
}
}
pub fn open_agent(&mut self, agents: &[Subagent<'_>]) -> Option<SubagentIntent> {
if !self.focus_agents {
return None;
}
self.agents
.handle_key(agents, KeyEvent::from(KeyCode::Enter))
}
/// Tab switches the composed pane. Focused roster keys return the same
/// capability-gated intents as SubagentView; the host owns every action.
pub fn handle_key(&mut self, agents: &[Subagent<'_>], key: KeyEvent) -> Option<SubagentIntent> {
if !self.visible
|| key.kind == KeyEventKind::Release
|| !key.modifiers.difference(KeyModifiers::SHIFT).is_empty()
{
return None;
}
if key.code == KeyCode::Tab && key.modifiers.is_empty() {
self.toggle_agents(agents);
return None;
}
if self.focus_agents {
return self.agents.handle_key(agents, key);
}
match key.code {
KeyCode::Up => self.scroll(agents, -1),
KeyCode::Down => self.scroll(agents, 1),
KeyCode::PageUp => self.scroll(agents, -10),
KeyCode::PageDown => self.scroll(agents, 10),
KeyCode::Home => self.scroll_end(agents, false),
KeyCode::End => self.scroll_end(agents, true),
_ => {}
}
None
}
/// A click/wheel on the roster uses its recorded IDs; a wheel on the
/// response scrolls it. No pointer input executes a worker action.
pub fn handle_mouse(&mut self, agents: &[Subagent<'_>], mouse: MouseEvent) -> bool {
if !self.visible {
return false;
}
let point = (mouse.column, mouse.row).into();
let cove_point = WhalePet::point(self.areas.pet, mouse.column, mouse.row);
if cove_point.is_none() {
self.stage.cove_leave();
}
if self.areas.agents.contains(point) && self.agents.handle_mouse(agents, mouse) {
self.focus_agents = true;
return true;
}
if self.areas.output.contains(point) {
match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_output(-3),
MouseEventKind::ScrollDown => self.scroll_output(3),
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {}
_ => return false,
}
self.focus_agents = false;
return true;
}
if let Some((x, y)) = cove_point {
self.stage.cove_observe(x, y);
if matches!(
mouse.kind,
MouseEventKind::Down(crossterm::event::MouseButton::Left)
) {
self.stage.cove_tap(x, y);
}
return true;
}
false
}
}
/// Full pet-mode composition. Response lines are already wrapped by the host's
/// existing Markdown/transcript renderer at [`PetModeAreas::output`] width.
/// Pass only actual roster facts and localized words. Paint never advances time.
pub struct PetMode<'a> {
pub theme: &'a Theme,
pub title: Cow<'a, str>,
pub status: StatusMark,
/// The host's existing notice, using its semantic severity ink.
pub notice: Option<Line<'a>>,
pub agents: &'a [Subagent<'a>],
pub agent_words: SubagentViewWords<'a>,
pub output: &'a [Line<'a>],
pub output_title: Cow<'a, str>,
/// Optional second footer row for the focused pane's controls.
pub pane_hints: Cow<'a, str>,
pub hints: Cow<'a, str>,
}
impl<'a> PetMode<'a> {
#[must_use]
pub fn new(theme: &'a Theme, status: StatusMark) -> Self {
Self {
theme,
title: "Codewhale".into(),
status,
notice: None,
agents: &[],
agent_words: SubagentViewWords::default(),
output: &[],
output_title: "Response".into(),
pane_hints: "".into(),
hints: "Esc back".into(),
}
}
}
impl StatefulWidget for PetMode<'_> {
type State = PetModeState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let area = area.intersection(buf.area);
state.areas = PetModeAreas::default();
if !state.visible || area.is_empty() {
return;
}
buf.set_style(
area,
self.theme
.bg(Role::Background)
.patch(self.theme.fg(Role::Foreground)),
);
let plan = PetModeAreas::new(
area,
!self.agents.is_empty(),
!self.output.is_empty(),
state.focus_agents,
);
state.areas = plan;
if !plan.header.is_empty() {
let status = self.status.spans(self.theme);
let status_width: usize = status.iter().map(|span| text::width(&span.content)).sum();
let title = text::display_safe(&self.title);
let mut spans = Vec::new();
// Shed identity before phase; narrow/ASCII views still name the
// activity even when a caller supplies a long title.
if text::width(&title)
.saturating_add(3)
.saturating_add(status_width)
<= usize::from(plan.header.width)
{
spans.push(Span::styled(
title.into_owned(),
self.theme.fg(Role::Primary),
));
spans.push(Span::raw(" "));
}
spans.extend(status);
Paragraph::new(Line::from(spans)).render(
Rect {
height: 1,
..plan.header
},
buf,
);
}
if plan.header.height > 1
&& let Some(line) = self.notice
{
let safe = Line::from(
line.spans
.iter()
.map(|span| {
Span::styled(text::display_safe(&span.content).into_owned(), span.style)
})
.collect::<Vec<_>>(),
)
.style(line.style);
Paragraph::new(safe).render(
Rect::new(plan.header.x, plan.header.y + 1, plan.header.width, 1),
buf,
);
}
if !plan.pet.is_empty() {
WhalePet::new(self.theme)
.words("")
.paint(plan.pet, buf, &mut state.stage);
}
let marker = if self.theme.ascii() { "> " } else { "› " };
let mut agent_words = self.agent_words;
if state.focus_agents {
agent_words.title = format!("{marker}{}", agent_words.title).into();
}
if !plan.agents.is_empty() && plan.agents.x > area.x {
// The shared layout already reserves this gap; no reading space
// or pointer geometry is consumed by the divider.
for y in plan.agents.y..plan.agents.bottom() {
buf[(plan.agents.x - 1, y)]
.set_symbol(if self.theme.ascii() { "|" } else { "│" })
.set_style(self.theme.fg(Role::Muted));
}
}
// Render even the hidden roster's empty area to clear stale hitboxes.
SubagentView::new(self.agents, self.theme)
.words(agent_words)
.render(plan.agents, buf, &mut state.agents);
if !plan.output.is_empty() {
let body = Rect::new(
plan.output.x,
plan.output.y + 1,
plan.output.width,
plan.output.height.saturating_sub(1),
);
state.output_end = self.output.len().saturating_sub(usize::from(body.height));
state.output_scroll = if state.output_detached {
state.output_scroll.min(state.output_end)
} else {
state.output_end
};
let heading = Rect {
height: 1,
..plan.output
};
let mut title_area = heading;
if body.height > 0 && state.output_end > 0 {
let range = format!(
"{}-{} / {}",
state.output_scroll + 1,
(state.output_scroll + usize::from(body.height)).min(self.output.len()),
self.output.len()
);
let width = text::width(&range) as u16;
if heading.width >= width.saturating_add(8) {
title_area.width -= width + 1;
Paragraph::new(range)
.alignment(Alignment::Right)
.style(self.theme.fg(Role::Muted))
.render(heading, buf);
}
}
let title = if state.focus_agents {
self.output_title.into_owned()
} else {
format!("{marker}{}", self.output_title)
};
Paragraph::new(text::display_safe(&title).into_owned())
.style(if state.focus_agents {
self.theme.fg(Role::Muted)
} else {
self.theme.fg(Role::Primary).add_modifier(Modifier::BOLD)
})
.render(title_area, buf);
// usize selection preserves arbitrarily long already-wrapped output.
for (y, line) in self
.output
.iter()
.skip(state.output_scroll)
.take(usize::from(body.height))
.enumerate()
{
let safe = Line::from(
line.spans
.iter()
.map(|span| {
Span::styled(text::display_safe(&span.content).into_owned(), span.style)
})
.collect::<Vec<_>>(),
)
.style(line.style);
Paragraph::new(safe)
.render(Rect::new(body.x, body.y + y as u16, body.width, 1), buf);
}
}
if !plan.footer.is_empty() {
let split = plan.footer.height > 1 && !self.pane_hints.is_empty();
let hints = text::display_safe(&self.hints);
let hints = if split {
text::truncate(&hints, usize::from(plan.footer.width), self.theme.ascii())
.into_owned()
} else {
hints.into_owned()
};
Paragraph::new(hints)
.wrap(Wrap { trim: true })
.style(self.theme.fg(Role::Muted))
.render(
Rect {
height: if split { 1 } else { plan.footer.height },
..plan.footer
},
buf,
);
if split {
Paragraph::new(
text::truncate(
&text::display_safe(&self.pane_hints),
usize::from(plan.footer.width),
self.theme.ascii(),
)
.into_owned(),
)
.style(self.theme.fg(Role::Muted))
.render(
Rect::new(plan.footer.x, plan.footer.y + 1, plan.footer.width, 1),
buf,
);
}
}
}
}
#[cfg(test)]
mod visibility_tests {
use super::*;
#[test]
fn default_hidden_updates_do_not_advance_the_parent_clock() {
let mut state = PetModeState::default();
let now = Instant::now();
let inputs = Inputs {
presence: Presence::Working,
activity: None,
context: Default::default(),
};
state.update(Some("session"), inputs.clone(), &[], now, MotionMode::Full);
state.update(
Some("session"),
inputs.clone(),
&[],
now + Duration::from_secs(30),
MotionMode::Full,
);
assert!(!state.stage.is_visible());
assert_eq!(state.stage.director().f, 0.);
state.set_visible(true);
state.update(
Some("session"),
inputs,
&[],
now + Duration::from_secs(30),
MotionMode::Full,
);
assert_eq!(state.stage.director().f, 0.);
}
}
File diff suppressed because it is too large. Load diff
+211
View File
@@ -0,0 +1,211 @@
//! The fully animated GPUI pet as a stateful Ratatui component. The host
//! observes and advances one shared Stage; painting never advances time.
use std::borrow::Cow;
use ratatui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
use crate::whale_motion::{CoveScene, Stage, View, pixels, rasterize_colored, rig};
use crate::{Paint, Theme, Whale, WhaleState, avatar_sprite::Sprite};
/// The native pet's rendering style. Both read the same live contours.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PetStyle {
/// Full color curves, apertures, props and cove, using terminal half-blocks.
#[default]
Color,
/// The canonical colored Braille geometry, without scenery.
Braille,
}
/// A live whale in its GPUI cove: springs, fins, eyes, blinks, props, particles
/// and calves all come from the existing canonical performance. Pointer input
/// is decorative and enters through the host's [`Stage::cove_observe`] and
/// [`Stage::cove_tap`]; it never changes Engine state.
///
/// The host supplies authoritative [`crate::whale_motion::Inputs`], calls
/// [`Stage::advance`] once per frame, and schedules with
/// [`Stage::cadence`] (Hero for color, Terminal for Braille). Reduced motion
/// paints a settled poster and schedules no animation. Unsupported color
/// profiles use Braille; ASCII and small areas show the status words alone.
///
/// ```no_run
/// use codewhale_ratatui::{Theme, WhalePet, whale_motion::Stage};
/// # fn draw(frame: &mut ratatui::Frame<'_>, stage: &mut Stage) {
/// let theme = Theme::detect();
/// stage.advance(std::time::Instant::now());
/// frame.render_stateful_widget(WhalePet::new(&theme), frame.area(), stage);
/// # }
/// ```
pub struct WhalePet<'a> {
theme: &'a Theme,
words: Option<Cow<'a, str>>,
cove: bool,
style: PetStyle,
direction: rig::Direction,
}
impl<'a> WhalePet<'a> {
#[must_use]
pub const fn new(theme: &'a Theme) -> Self {
Self {
theme,
words: None,
cove: true,
style: PetStyle::Color,
direction: rig::MARK_DIRECTION,
}
}
/// Localized words from the host. The default names the current action.
#[must_use]
pub fn words(mut self, words: impl Into<Cow<'a, str>>) -> Self {
self.words = Some(words.into());
self
}
#[must_use]
pub const fn cove(mut self, shown: bool) -> Self {
self.cove = shown;
self
}
#[must_use]
pub const fn style(mut self, style: PetStyle) -> Self {
self.style = style;
self
}
/// The shared rig's mark, cruise or open view; never a separate animation.
#[must_use]
pub const fn direction(mut self, direction: rig::Direction) -> Self {
self.direction = direction;
self
}
/// The bounded square art rectangle, reserving one row for state words.
/// Hosts use this same rectangle to map mouse input with [`Self::point`].
#[must_use]
pub fn art_area(area: Rect) -> Rect {
let side = area
.width
.min(area.height.saturating_sub(1).saturating_mul(2))
.min(pixels::MAX_SIDE as u16);
let side = side - side % 2;
Rect::new(
area.x.saturating_add((area.width - side) / 2),
area.y
.saturating_add((area.height.saturating_sub(1) - side / 2) / 2),
side,
side / 2,
)
}
/// Convert a terminal cell to the GPUI cove's −62…62 design coordinates.
/// Outside points return `None` so the host can release the resting gaze.
#[must_use]
pub fn point(area: Rect, column: u16, row: u16) -> Option<(f64, f64)> {
let art = Self::art_area(area);
if art.is_empty() || !art.contains((column, row).into()) {
return None;
}
Some((
(f64::from(column - art.x) + 0.5) / f64::from(art.width) * 124. - 62.,
(f64::from(row - art.y) + 0.5) / f64::from(art.height) * 124. - 62.,
))
}
/// Paint the current shared frame. The only mutable decoration is the
/// cove's idempotent gaze sample; time and owner truth remain host-owned.
pub fn paint(&self, area: Rect, buf: &mut Buffer, stage: &mut Stage) {
let area = area.intersection(buf.area);
if area.is_empty() {
return;
}
let mut state = WhaleState::from_key(stage.acting().id()).unwrap_or(WhaleState::Rest);
let mut words = None;
if matches!(state, WhaleState::Pod { .. }) {
let calves = stage
.director()
.activity
.as_ref()
.and_then(|a| a.parallel)
.filter(|n| n.is_finite() && *n >= 0. && n.fract() == 0.)
.unwrap_or(0.);
state = WhaleState::Pod {
calves: calves.min(f64::from(u8::MAX)) as u8,
};
if calves > f64::from(u8::MAX) {
words = Some(format!("Working with {calves:.0} agents"));
}
}
let mut whale = Whale::new(state);
if let Some(words) = words {
whale = whale.words(words);
}
if let Some(words) = &self.words {
whale = whale.words(words.to_string());
}
let art = Self::art_area(area);
let words_only = crate::whale::Grid {
cols: 0,
rows: 0,
cells: Vec::new(),
};
if self.theme.ascii() || art.width < 16 || art.height < 8 {
// A deliberately invalid frame asks the shared painter for words.
whale.paint_frame(area, buf, self.theme, &words_only);
return;
}
let dark = self.theme.caps().appearance != crate::detect::Appearance::Light;
let view = View {
dir: self.direction,
..View::hero(f64::from(art.width), 1.)
};
if self.style == PetStyle::Braille || !self.theme.paints_grounds() {
let parts = stage.scene(view);
rasterize_colored(
&parts,
usize::from(art.width),
usize::from(art.height),
0.5,
dark,
)
.paint_with_contrast(&whale, area, buf, self.theme, 3.);
return;
}
let scene = if self.cove {
stage.cove_scene(view, dark)
} else {
CoveScene {
behind: Vec::new(),
parts: stage.scene(view),
front: Vec::new(),
}
};
let pixels = pixels::render(&scene, usize::from(art.width), dark);
if let Ok(sprite) = Sprite::image(&pixels, usize::from(art.width), usize::from(art.width)) {
sprite.paint(art, buf, self.theme);
}
whale.paint_frame(
Rect::new(area.x, art.bottom(), area.width, 1),
buf,
self.theme,
&words_only,
);
}
}
impl StatefulWidget for WhalePet<'_> {
type State = Stage;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Stage) {
self.paint(area, buf, state);
}
}
impl StatefulWidget for &WhalePet<'_> {
type State = Stage;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Stage) {
self.paint(area, buf, state);
}
}
+8
View File
@@ -907,6 +907,14 @@ impl Director {
self.pose_out
}
/// An authorized, once-per-turn completion clip is still playing.
/// Surfaces use the canonical clip to settle without a second timer.
pub fn completing(&self) -> bool {
self.shots
.iter()
.any(|shot| shot.clip == ClipId::Enter(Act::Done))
}
/// Whether anything is still moving beyond the settled loop: a clip, a
/// departure fade, a particle or a calf in transit. Surfaces use it to
/// choose their active or resting paint cap.
+1
View File
@@ -18,6 +18,7 @@ pub mod data;
pub mod habitat;
pub mod ink;
pub mod math;
pub(crate) mod pixels;
pub mod props;
pub mod rig;
pub mod scene;
+123
View File
@@ -0,0 +1,123 @@
//! The GPUI cove/contour paint order in a bounded RGBA plane. The existing
//! Sprite adapter handles terminal half-blocks, alpha and color capabilities.
use super::{CoveScene, Layer, Path, Role, ink::Fill, rig::holes_for, scene::segments};
pub(crate) const MAX_SIDE: usize = 96;
struct Canvas {
side: usize,
// Premultiplied RGBA: averaging transparent edges must not darken them.
pixels: Vec<[f64; 4]>,
}
impl Canvas {
fn fill(&mut self, paths: &[Path], fill: Fill, alpha: f64) {
if !alpha.is_finite() || alpha <= 0. {
return;
}
let alpha = alpha.min(1.);
let side = self.side as f64;
let scale = side / 124.;
let edges: Vec<_> = paths
.iter()
.filter(|path| {
path.iter()
.flat_map(super::rig::Cmd::coords)
.all(|v| v.is_finite())
})
.flat_map(segments)
.map(|[a, b, c, d]| {
[
side / 2. + a * scale,
side / 2. + b * scale,
side / 2. + c * scale,
side / 2. + d * scale,
]
})
.collect();
let mut crossings = Vec::new();
for y in 0..self.side {
let cy = y as f64 + 0.5;
crossings.clear();
for &[x1, y1, x2, y2] in &edges {
if (y1 > cy) != (y2 > cy) {
crossings.push(x1 + (cy - y1) * (x2 - x1) / (y2 - y1));
}
}
crossings.sort_by(f64::total_cmp);
for pair in crossings.as_chunks::<2>().0 {
let left = (pair[0] - 0.5).ceil().clamp(0., side) as usize;
let right = (pair[1] - 0.5).ceil().clamp(0., side) as usize;
for x in left..right {
let t = (((x as f64 + cy + 0.5 - side) / scale + 100.) / 200.).clamp(0., 1.);
let channel = |hex: u32, shift: u32| f64::from((hex >> shift) & 255_u32);
let color = match fill {
Fill::Solid(hex) => [16, 8, 0].map(|s| channel(hex, s)),
Fill::Gradient([a, b]) => {
[16, 8, 0].map(|s| channel(a, s) + (channel(b, s) - channel(a, s)) * t)
}
};
let pixel = &mut self.pixels[y * self.side + x];
for (dst, src) in pixel[..3].iter_mut().zip(color) {
*dst = src * alpha + *dst * (1. - alpha);
}
pixel[3] = alpha + pixel[3] * (1. - alpha);
}
}
}
}
fn layers(&mut self, layers: &[Layer]) {
for layer in layers {
self.fill(&layer.paths, Fill::Solid(layer.color), layer.alpha);
}
}
}
/// Square design space, with two samples per axis for clean curved edges.
/// Public terminal rectangles never control an unbounded allocation.
pub(crate) fn render(scene: &CoveScene, side: usize, dark: bool) -> Vec<u8> {
if !(1..=MAX_SIDE).contains(&side) {
return Vec::new();
}
let mut canvas = Canvas {
side: side * 2,
pixels: vec![[0.; 4]; side * side * 4],
};
canvas.layers(&scene.behind);
for shape in &scene.parts.shapes {
if shape.role == Role::Cutout {
continue;
}
if let Some(fill) = super::ink::fill(shape.role, dark) {
let mut paths = vec![shape.path.clone()];
paths.extend(holes_for(&shape.id, &scene.parts.shapes, false));
canvas.fill(&paths, fill, shape.opacity);
}
}
canvas.layers(&scene.front);
let mut out = Vec::with_capacity(side * side * 4);
for y in 0..side {
for x in 0..side {
let mut rgba = [0.; 4];
for dy in 0..2 {
for dx in 0..2 {
let p = canvas.pixels[(y * 2 + dy) * canvas.side + x * 2 + dx];
for (dst, src) in rgba.iter_mut().zip(p) {
*dst += src / 4.;
}
}
}
for c in &rgba[..3] {
out.push(if rgba[3] > 0. {
(c / rgba[3]).round() as u8
} else {
0
});
}
out.push((rgba[3] * 255.).round() as u8);
}
}
out
}