mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
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 by1c82c935c9(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:
commit
80e1d2a9be
47 files changed
+4037
-308
No files matched your search
+15
-4
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "आवास खुला",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ハビタット表示中",
|
||||
|
||||
@@ -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": "서식지 열림",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "среда открыта",
|
||||
|
||||
@@ -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": "середовище відкрите",
|
||||
|
||||
@@ -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ở",
|
||||
|
||||
@@ -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": "栖息地已打开",
|
||||
|
||||
@@ -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": "棲息地已開啟",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -3518,6 +3518,7 @@ impl App {
|
||||
self.tr(match subject {
|
||||
StartupDefaultSubject::Mode => MessageId::StartupDefaultSubjectMode,
|
||||
StartupDefaultSubject::Thinking => MessageId::StartupDefaultSubjectThinking,
|
||||
StartupDefaultSubject::PetMode => MessageId::ConfigLabelPetMode,
|
||||
})
|
||||
.into_owned()
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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) = ©_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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>)> {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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.);
|
||||
}
|
||||
}
|
||||
+1144
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user