pet: one pet mode that switches views, keys that reach a Mac, a crisp live whale

The founder, on macOS Terminal.app 26.1 with an Apple keyboard, reported
that F8 in pet mode does nothing and the pet looks blurry and wrong. A
read-only map of every pet view found:
- F8 was admitted only inside the F5 inspector, and on Apple keyboards F5
  and F8 are Dictation and Play/Pause unless Fn is held; nothing in pet
  mode switched views, and Ctrl+] silently replaced the pet with the dock.
- The default live whale is the companion's DotWhale, which the 0.1.2
  sprite fix never touched: 980 points stretched over the whole pet area
  (4.13 lit dots per cell at 80x18, 1.13 at 200x50), each cell the mean of
  its dots' colours, alpha-blended toward the ground and DIMmed.

Vendored codewhale-ratatui (0.1.2 publish source, version unchanged;
UPSTREAM.json and its CHANGELOG record the local change):
- dot_whale.rs: the dot scale is capped so density stays about 4.2 lit dots
  per cell at every size, the whale and caption are centred together, each
  cell keeps its strongest dot's colour, lit dots draw at full ink, DIM is
  gone and hollow frames use the theme's Muted role.
- avatar_sprite.rs: Sprite::paint picks one integer scale (n or 1/n) for
  both axes with the same rounding, nearest sampling, centred.
- pet_mode.rs: PetMode gains `style` (force Braille) and `cove` (draw the
  bare whale without the translucent cove), both defaulting to today's
  behaviour; a forced Braille whale uses the Terminal cadence.

TUI:
- Ctrl+] / Ctrl+Tab (and Ctrl+Shift+Tab back) cycle the pet's views on the
  pet main view: whale, cove, braille, pixels, particles, whale-girl, then
  plugin packs. The choice lives in Avatars.key (in memory). A view that
  cannot draw shows its fallback and one notice line naming what it needs
  (PetViewNeeds). The footer names the chord and the view
  (PetModeViewHint) and the segment is clickable. Elsewhere the chord keeps
  cycling the workbar, and its idle chip is hidden on the pet main view.
- F6, F8 and F9 work on the pet main view, not only in the inspector, via
  a focus scope that admits the composer and the habitat and no other
  modal. On macOS the pet hints read fn+F5 / fn+F8.
- F8 failures: /pet on clears the failed latch, launch errors toast their
  own message without "Pet telemetry paused", a successful launch says so,
  and repeated presses while connecting no longer open several tabs.
- Avatar views clear the pet rect before their Block so the whale no
  longer shows through; avatars use the pet mode theme and the same ASCII
  check as the rest of the theme.
- The pixels view uses the kitty raster where supported with an
  integer-divisor size cap, and is disabled when the cell pixel size is
  unknown; on Terminal.app it shows its fallback and says why.
- The pet view worker sleeps until its next deadline instead of waking
  every 2 ms, and a visibility change wakes it.
- /pet usage lists the view keys, style and language. Localization: two new
  ids in all 15 packs; the workbar-cycle help names pet mode.
- docs/features.toml pet row, PET_MODE.md (en, zh-Hans) with known limits
  (not persisted across restarts, no cycling inside the F5 inspector,
  browser and window are not ring stops), pet/SHARED.md,
  DOT_WHALE_PET.md and CHANGELOG (synced).

Evidence at commit time:
- vendor/codewhale-ratatui `cargo test --locked`: test result: ok. 128
  passed; 0 failed (doc tests 10 passed) before and after; clippy --lib
  clean; fmt clean. A probe over the real DOT_BODY points measured the
  density above, before and after.
- cargo fmt --all -- --check: clean.
- The codewhale-tui focused test run (pet_watch, shell_key_routing,
  mouse_ui, phase_strip, frame, ui::tests, commands) was still compiling
  when this was committed so the work is on the remote; its counts, clippy
  and the localization and feature_registry runs follow in the next commit
  or the PR body. Hosted CI on this head is the other gate. Adversarial
  review has not run yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BsXVvReYca2UxzimQtj7Mj
This commit is contained in:
Claude committed 2026-10-12 00:27:40 +00:00
1 parent 4d0efacf17
commit 2002b2890e
41 files changed
+689 -248

No files matched your search

+24
View File
@@ -85,6 +85,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Full Access.
- An idle pet view no longer builds animation frames while nobody is
looking at it (#6728).
- On the pet main view, `Ctrl+]` (or `Ctrl+Tab`) cycles the pet's views: the
companion `whale`, a crisp local `cove` whale without the fog, `braille`,
`pixels` (kitty graphics), `particles`, `whale-girl` and installed avatar
packs; `Ctrl+Shift+Tab` goes back. The footer names the view, and clicking
it cycles. A view that cannot draw shows its fallback and says what it
needs, for example `pixels needs kitty graphics · showing particles`.
`/pet avatar <key>` jumps to a view, and the `/pet` usage lists every view,
`style` and `language`.
- `F6`, `F8` and `F9` work on the pet main view, not only in the `F5`
inspector, and on macOS the pet's key hints read `fn+F5`. `/pet on`, `F8`
and `F9` restart a companion view that stopped, so "/pet on retries" is
true. A failed launch shows its own reason, a successful one says
`habitat open`, and pressing `F8` several times while the companion starts
opens one tab.
- The live dot whale stays dense in large panes, the whale-girl sprite scales
by whole pixels, and avatars, the particle grid and the pixel view no longer
show the whale through them. Avatars follow your theme and
`CODEWHALE_ASCII_SAFE`, the pixel view's image is a whole fraction of the
terminal's pixel size, and the pet's view worker sleeps until its next
deadline instead of waking every 2 ms.
- README: `codewhale exec --auto` is shown for headless runs that edit files
or run commands.
- `bash` now moves a foreground command to the background after 30 s and
@@ -409,6 +429,10 @@ launcher guidance.
with their owner, not in the agent's own detail view; named terminal
sessions live in the terminal dock
([#6931](https://github.com/codewhale-hq/Codewhale/issues/6931)).
- The pet view chosen with `Ctrl+]` is not saved across restarts and cannot
be changed inside the `F5` inspector. The browser studio and the macOS
window are not views in the cycle, and `Ctrl+]` cannot open the work bar
while the pet main view is showing.
### Maintenance
+4 -2
View File
@@ -54,6 +54,8 @@
"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",
"PetModeViewHint": "{cycle} vista: {view}",
"PetViewNeeds": "{view} necessita {requirement} · es mostra {shown}",
"PetModeOnLabel": "mode mascota activat",
"PetModeOffLabel": "mode mascota desactivat",
"PetViewOpen": "hàbitat obert",
@@ -806,8 +808,8 @@
"KbPointerDrag": "Selecciona text de la transcripció, o arrossega la barra de desplaçament",
"KbAttachPath": "Afegeix un fitxer de text o directori local al context",
"KbHelpOverlay": "Obre aquesta superposició d'ajuda (quan l'entrada és buida)",
"KbCycleWorkDock": "Recorre el moll de treball (tasques, agents, feines, segon pla)",
"KbCycleWorkDockBack": "Recorre el moll de treball enrere",
"KbCycleWorkDock": "Recorre el moll de treball (tasques, agents, feines, segon pla) o, en mode mascota, les vistes de la mascota",
"KbCycleWorkDockBack": "Recorre el moll de treball o les vistes de la mascota enrere",
"KbToggleHelp": "Commuta la superposició d'ajuda",
"KbToggleHelpSlash": "Commuta la superposició d'ajuda",
"HelpUsageLabel": "Ús:",
+4 -2
View File
@@ -54,6 +54,8 @@
"PetModeReplyHints": "{scroll} scrollen · {latest} neueste",
"PetModeCompanionHints": "{sound} Ton · {browser} Aussehen · {window} Fenster",
"PetModeInspectHint": "{inspect} Antworten und Agenten · /pet off Standardansicht",
"PetModeViewHint": "{cycle} Ansicht: {view}",
"PetViewNeeds": "{view} braucht {requirement} · zeigt {shown}",
"PetModeOnLabel": "Haustiermodus an",
"PetModeOffLabel": "Haustiermodus aus",
"PetViewOpen": "Lebensraum offen",
@@ -806,8 +808,8 @@
"KbPointerDrag": "Transkripttext auswählen oder die Bildlaufleiste ziehen",
"KbAttachPath": "Lokale Textdatei oder Ordner zum Kontext hinzufügen",
"KbHelpOverlay": "Dieses Hilfe-Overlay öffnen (bei leerer Eingabe)",
"KbCycleWorkDock": "Durch die Arbeitsleiste blättern (Todo, Agenten, Jobs, Hintergrund)",
"KbCycleWorkDockBack": "Rückwärts durch die Arbeitsleiste blättern",
"KbCycleWorkDock": "Durch die Arbeitsleiste blättern (Todo, Agenten, Jobs, Hintergrund), im Haustiermodus durch die Haustier-Ansichten",
"KbCycleWorkDockBack": "Rückwärts durch die Arbeitsleiste oder die Haustier-Ansichten blättern",
"KbToggleHelp": "Hilfe-Overlay umschalten",
"KbToggleHelpSlash": "Hilfe-Overlay umschalten",
"HelpUsageLabel": "Verwendung:",
+4 -2
View File
@@ -54,6 +54,8 @@
"PetModeReplyHints": "{scroll} scroll · {latest} latest",
"PetModeCompanionHints": "{sound} sound · {browser} appearance · {window} window",
"PetModeInspectHint": "{inspect} replies & agents · /pet off standard view",
"PetModeViewHint": "{cycle} view: {view}",
"PetViewNeeds": "{view} needs {requirement} · showing {shown}",
"PetModeOnLabel": "pet mode on",
"PetModeOffLabel": "pet mode off",
"PetViewOpen": "habitat open",
@@ -823,8 +825,8 @@
"KbPointerDrag": "Select transcript text, or drag the scrollbar",
"KbAttachPath": "Add a file or folder to context",
"KbHelpOverlay": "Open help (empty input)",
"KbCycleWorkDock": "Cycle the workbar (to-do, agents, jobs, background)",
"KbCycleWorkDockBack": "Cycle the workbar backwards",
"KbCycleWorkDock": "Cycle the workbar (to-do, agents, jobs, background), or the pet's views in pet mode",
"KbCycleWorkDockBack": "Cycle the workbar or the pet's views backwards",
"KbToggleHelp": "Toggle help overlay",
"KbToggleHelpSlash": "Toggle help overlay",
"HelpUsageLabel": "Usage:",
+4 -2
View File
@@ -54,6 +54,8 @@
"PetModeReplyHints": "{scroll} desplazar · {latest} más reciente",
"PetModeCompanionHints": "{sound} sonido · {browser} apariencia · {window} ventana",
"PetModeInspectHint": "{inspect} respuestas y agentes · /pet off vista estándar",
"PetModeViewHint": "{cycle} vista: {view}",
"PetViewNeeds": "{view} necesita {requirement} · mostrando {shown}",
"PetModeOnLabel": "modo mascota activado",
"PetModeOffLabel": "modo mascota desactivado",
"PetViewOpen": "hábitat abierto",
@@ -823,8 +825,8 @@
"KbPointerDrag": "Selecciona texto de la transcripción, o arrastra la barra de desplazamiento",
"KbAttachPath": "Agregar archivo o directorio local al contexto",
"KbHelpOverlay": "Abrir esta superposición de ayuda (cuando la entrada está vacía)",
"KbCycleWorkDock": "Recorrer la barra de trabajo (tareas, agentes, trabajos, segundo plano)",
"KbCycleWorkDockBack": "Recorrer la barra de trabajo hacia atrás",
"KbCycleWorkDock": "Recorrer la barra de trabajo (tareas, agentes, trabajos, segundo plano) o, en modo mascota, las vistas de la mascota",
"KbCycleWorkDockBack": "Recorrer la barra de trabajo o las vistas de la mascota hacia atrás",
"KbToggleHelp": "Alternar superposición de ayuda",
"KbToggleHelpSlash": "Alternar superposición de ayuda",
"HelpUsageLabel": "Uso:",
+4 -2
View File
@@ -54,6 +54,8 @@
"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",
"PetModeViewHint": "{cycle} vue : {view}",
"PetViewNeeds": "{view} nécessite {requirement} · affiche {shown}",
"PetModeOnLabel": "mode mascotte activé",
"PetModeOffLabel": "mode mascotte désactivé",
"PetViewOpen": "habitat ouvert",
@@ -806,8 +808,8 @@
"KbPointerDrag": "Sélectionne du texte de la transcription, ou fait glisser la barre de défilement",
"KbAttachPath": "Ajouter un fichier texte ou dossier local au contexte",
"KbHelpOverlay": "Ouvrir cette aide (quand la saisie est vide)",
"KbCycleWorkDock": "Parcourir la barre de travail (tâches, agents, jobs, arrière-plan)",
"KbCycleWorkDockBack": "Parcourir la barre de travail en arrière",
"KbCycleWorkDock": "Parcourir la barre de travail (tâches, agents, jobs, arrière-plan) ou, en mode compagnon, les vues de la mascotte",
"KbCycleWorkDockBack": "Parcourir la barre de travail ou les vues de la mascotte en arrière",
"KbToggleHelp": "Basculer l'aide",
"KbToggleHelpSlash": "Basculer l'aide",
"HelpUsageLabel": "Usage :",
+4 -2
View File
@@ -54,6 +54,8 @@
"PetModeReplyHints": "{scroll} स्क्रॉल · {latest} नवीनतम",
"PetModeCompanionHints": "{sound} ध्वनि · {browser} रूप · {window} विंडो",
"PetModeInspectHint": "{inspect} उत्तर और एजेंट · /pet off सामान्य दृश्य",
"PetModeViewHint": "{cycle} दृश्य: {view}",
"PetViewNeeds": "{view} के लिए {requirement} चाहिए · {shown} दिखाया जा रहा है",
"PetModeOnLabel": "पालतू मोड चालू",
"PetModeOffLabel": "पालतू मोड बंद",
"PetViewOpen": "आवास खुला",
@@ -806,8 +808,8 @@
"KbPointerDrag": "ट्रांसक्रिप्ट टेक्स्ट चुनें, या स्क्रॉलबार खींचें",
"KbAttachPath": "संदर्भ में लोकल टेक्स्ट फ़ाइल या डायरेक्टरी जोड़ें",
"KbHelpOverlay": "यह मदद ओवरले खोलें (जब इनपुट खाली हो)",
"KbCycleWorkDock": "वर्क डॉक बदलें (टूडू, एजेंट, जॉब, बैकग्राउंड)",
"KbCycleWorkDockBack": "वर्क डॉक पीछे की ओर बदलें",
"KbCycleWorkDock": "वर्क डॉक बदलें (टूडू, एजेंट, जॉब, बैकग्राउंड), या पेट मोड में पालतू के दृश्य बदलें",
"KbCycleWorkDockBack": "वर्क डॉक या पालतू के दृश्य पीछे की ओर बदलें",
"KbToggleHelp": "मदद ओवरले टॉगल करें",
"KbToggleHelpSlash": "मदद ओवरले टॉगल करें",
"HelpUsageLabel": "उपयोग:",
+4 -2
View File
@@ -54,6 +54,8 @@
"PetModeReplyHints": "{scroll} gulir · {latest} terbaru",
"PetModeCompanionHints": "{sound} suara · {browser} tampilan · {window} jendela",
"PetModeInspectHint": "{inspect} balasan dan agen · /pet off tampilan biasa",
"PetModeViewHint": "{cycle} tampilan: {view}",
"PetViewNeeds": "{view} memerlukan {requirement} · menampilkan {shown}",
"PetModeOnLabel": "mode hewan peliharaan aktif",
"PetModeOffLabel": "mode hewan peliharaan nonaktif",
"PetViewOpen": "habitat terbuka",
@@ -806,8 +808,8 @@
"KbPointerDrag": "Pilih teks transkrip, atau seret bilah gulir",
"KbAttachPath": "Tambahkan file teks atau direktori lokal ke konteks",
"KbHelpOverlay": "Buka overlay bantuan ini (saat input kosong)",
"KbCycleWorkDock": "Putar bilah kerja (todo, agen, pekerjaan, latar belakang)",
"KbCycleWorkDockBack": "Putar bilah kerja mundur",
"KbCycleWorkDock": "Putar bilah kerja (todo, agen, pekerjaan, latar belakang), atau tampilan peliharaan di mode peliharaan",
"KbCycleWorkDockBack": "Putar bilah kerja atau tampilan peliharaan mundur",
"KbToggleHelp": "Alihkan overlay bantuan",
"KbToggleHelpSlash": "Alihkan overlay bantuan",
"HelpUsageLabel": "Penggunaan:",
+4 -2
View File
@@ -54,6 +54,8 @@
"PetModeReplyHints": "{scroll} スクロール · {latest} 最新へ",
"PetModeCompanionHints": "{sound} 音声 · {browser} 外観 · {window} ウィンドウ",
"PetModeInspectHint": "{inspect} 返信・エージェント · /pet off 通常表示",
"PetModeViewHint": "{cycle} 表示: {view}",
"PetViewNeeds": "{view} には {requirement} が必要 · {shown} を表示中",
"PetModeOnLabel": "ペットモード オン",
"PetModeOffLabel": "ペットモード オフ",
"PetViewOpen": "ハビタット表示中",
@@ -823,8 +825,8 @@
"KbPointerDrag": "トランスクリプトのテキストを選択、またはスクロールバーをドラッグ",
"KbAttachPath": "ローカルのテキストファイルまたはディレクトリをコンテキストに追加",
"KbHelpOverlay": "このヘルプオーバーレイを開く(入力が空の時)",
"KbCycleWorkDock": "ワークバーを切り替え(TODO・エージェント・ジョブ・バックグラウンド)",
"KbCycleWorkDockBack": "ワークバーを逆順に切り替え",
"KbCycleWorkDock": "ワークバーを切り替え(TODO・エージェント・ジョブ・バックグラウンド)、ペットモードではペットの表示を切り替え",
"KbCycleWorkDockBack": "ワークバーまたはペットの表示を逆順に切り替え",
"KbToggleHelp": "ヘルプオーバーレイを切り替え",
"KbToggleHelpSlash": "ヘルプオーバーレイを切り替え",
"HelpUsageLabel": "使い方:",
+4 -2
View File
@@ -54,6 +54,8 @@
"PetModeReplyHints": "{scroll} 스크롤 · {latest} 최신으로",
"PetModeCompanionHints": "{sound} 소리 · {browser} 외형 · {window} 창",
"PetModeInspectHint": "{inspect} 답변 및 에이전트 · /pet off 기본 보기",
"PetModeViewHint": "{cycle} 보기: {view}",
"PetViewNeeds": "{view}에는 {requirement} 필요 · {shown} 표시 중",
"PetModeOnLabel": "펫 모드 켜짐",
"PetModeOffLabel": "펫 모드 꺼짐",
"PetViewOpen": "서식지 열림",
@@ -823,8 +825,8 @@
"KbPointerDrag": "트랜스크립트 텍스트 선택 또는 스크롤바 드래그",
"KbAttachPath": "로컬 텍스트 파일이나 디렉터리를 컨텍스트에 추가",
"KbHelpOverlay": "이 도움말 오버레이 열기 (입력창이 비어 있을 때)",
"KbCycleWorkDock": "작업 바 순환 (할 일, 에이전트, 작업, 백그라운드)",
"KbCycleWorkDockBack": "작업 바 역순 순환",
"KbCycleWorkDock": "작업 바 순환 (할 일, 에이전트, 작업, 백그라운드), 펫 모드에서는 펫 보기 순환",
"KbCycleWorkDockBack": "작업 바 또는 펫 보기 역순 순환",
"KbToggleHelp": "도움말 오버레이 전환",
"KbToggleHelpSlash": "도움말 오버레이 전환",
"HelpUsageLabel": "사용법:",
+4 -2
View File
@@ -54,6 +54,8 @@
"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",
"PetModeViewHint": "{cycle} visão: {view}",
"PetViewNeeds": "{view} precisa de {requirement} · mostrando {shown}",
"PetModeOnLabel": "modo pet ligado",
"PetModeOffLabel": "modo pet desligado",
"PetViewOpen": "habitat aberto",
@@ -823,8 +825,8 @@
"KbPointerDrag": "Seleciona texto da transcrição, ou arrasta a barra de rolagem",
"KbAttachPath": "Adicionar arquivo ou diretório local ao contexto",
"KbHelpOverlay": "Abrir esta sobreposição de ajuda (quando entrada vazia)",
"KbCycleWorkDock": "Percorrer a barra de trabalho (tarefas, agentes, jobs, segundo plano)",
"KbCycleWorkDockBack": "Percorrer a barra de trabalho para trás",
"KbCycleWorkDock": "Percorrer a barra de trabalho (tarefas, agentes, jobs, segundo plano) ou, no modo pet, as visões do pet",
"KbCycleWorkDockBack": "Percorrer a barra de trabalho ou as visões do pet para trás",
"KbToggleHelp": "Alternar sobreposição de ajuda",
"KbToggleHelpSlash": "Alternar sobreposição de ajuda",
"HelpUsageLabel": "Uso:",
+4 -2
View File
@@ -54,6 +54,8 @@
"PetModeReplyHints": "{scroll} прокрутка · {latest} к последнему",
"PetModeCompanionHints": "{sound} звук · {browser} внешний вид · {window} окно",
"PetModeInspectHint": "{inspect} ответы и агенты · /pet off обычный вид",
"PetModeViewHint": "{cycle} вид: {view}",
"PetViewNeeds": "Для {view} нужно {requirement} · показан {shown}",
"PetModeOnLabel": "режим питомца включён",
"PetModeOffLabel": "режим питомца выключен",
"PetViewOpen": "среда открыта",
@@ -806,8 +808,8 @@
"KbPointerDrag": "Выделяет текст расшифровки или перетаскивает полосу прокрутки",
"KbAttachPath": "Добавить локальный текстовый файл или каталог в контекст",
"KbHelpOverlay": "Открыть эту справку поверх (при пустом вводе)",
"KbCycleWorkDock": "Переключать рабочую панель (задачи, агенты, задания, фон)",
"KbCycleWorkDockBack": "Переключать рабочую панель назад",
"KbCycleWorkDock": "Переключать рабочую панель (задачи, агенты, задания, фон), а в режиме питомца — виды питомца",
"KbCycleWorkDockBack": "Переключать рабочую панель или виды питомца назад",
"KbToggleHelp": "Переключить справку поверх",
"KbToggleHelpSlash": "Переключить справку поверх",
"HelpUsageLabel": "Использование:",
+4 -2
View File
@@ -54,6 +54,8 @@
"PetModeReplyHints": "{scroll} прокручування · {latest} до останнього",
"PetModeCompanionHints": "{sound} звук · {browser} зовнішність · {window} вікно",
"PetModeInspectHint": "{inspect} відповіді й агенти · /pet off звичайний вигляд",
"PetModeViewHint": "{cycle} вигляд: {view}",
"PetViewNeeds": "Для {view} потрібно {requirement} · показано {shown}",
"PetModeOnLabel": "режим улюбленця ввімкнено",
"PetModeOffLabel": "режим улюбленця вимкнено",
"PetViewOpen": "середовище відкрите",
@@ -806,8 +808,8 @@
"KbPointerDrag": "Виділяє текст розшифровки або перетягує смугу прокручування",
"KbAttachPath": "Додати локальний текстовий файл або каталог до контексту",
"KbHelpOverlay": "Відкрити це накладення довідки (коли ввід порожній)",
"KbCycleWorkDock": "Перемикати робочу панель (задачі, агенти, завдання, фон)",
"KbCycleWorkDockBack": "Перемикати робочу панель назад",
"KbCycleWorkDock": "Перемикати робочу панель (задачі, агенти, завдання, фон), а в режимі улюбленця — вигляди улюбленця",
"KbCycleWorkDockBack": "Перемикати робочу панель або вигляди улюбленця назад",
"KbToggleHelp": "Перемкнути накладення довідки",
"KbToggleHelpSlash": "Перемкнути накладення довідки",
"HelpUsageLabel": "Використання:",
+4 -2
View File
@@ -54,6 +54,8 @@
"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",
"PetModeViewHint": "{cycle} chế độ xem: {view}",
"PetViewNeeds": "{view} cần {requirement} · đang hiển thị {shown}",
"PetModeOnLabel": "chế độ thú cưng bật",
"PetModeOffLabel": "chế độ thú cưng tắt",
"PetViewOpen": "môi trường sống đang mở",
@@ -823,8 +825,8 @@
"KbPointerDrag": "Chọn văn bản bản ghi, hoặc kéo thanh cuộn",
"KbAttachPath": "Thêm một tệp văn bản cục bộ hoặc thư mục vào ngữ cảnh",
"KbHelpOverlay": "Mở lớp phủ trợ giúp này (khi khung nhập trống)",
"KbCycleWorkDock": "Chuyển thanh làm việc (todo, tác nhân, job, nền)",
"KbCycleWorkDockBack": "Chuyển thanh làm việc ngược lại",
"KbCycleWorkDock": "Chuyển thanh làm việc (todo, tác nhân, job, nền), hoặc chế độ xem thú cưng khi ở chế độ thú cưng",
"KbCycleWorkDockBack": "Chuyển thanh làm việc hoặc chế độ xem thú cưng ngược lại",
"KbToggleHelp": "Bật/Tắt lớp phủ trợ giúp",
"KbToggleHelpSlash": "Bật/Tắt lớp phủ trợ giúp",
"HelpUsageLabel": "Sử dụng:",
+4 -2
View File
@@ -54,6 +54,8 @@
"PetModeReplyHints": "{scroll} 滚动 · {latest} 回到最新",
"PetModeCompanionHints": "{sound} 声音 · {browser} 外观 · {window} 窗口",
"PetModeInspectHint": "{inspect} 回复与智能体 · /pet off 标准视图",
"PetModeViewHint": "{cycle} 视图:{view}",
"PetViewNeeds": "{view} 需要 {requirement} · 正在显示 {shown}",
"PetModeOnLabel": "宠物模式已开启",
"PetModeOffLabel": "宠物模式已关闭",
"PetViewOpen": "栖息地已打开",
@@ -823,8 +825,8 @@
"KbPointerDrag": "选择记录文本,或拖动滚动条",
"KbAttachPath": "添加本地文本文件或目录到上下文",
"KbHelpOverlay": "打开此帮助覆盖层(输入框为空时)",
"KbCycleWorkDock": "切换任务面板(待办、智能体、作业、后台)",
"KbCycleWorkDockBack": "反向切换任务面板",
"KbCycleWorkDock": "切换任务面板(待办、智能体、作业、后台),宠物模式下切换宠物视图",
"KbCycleWorkDockBack": "反向切换任务面板或宠物视图",
"KbToggleHelp": "切换帮助覆盖层",
"KbToggleHelpSlash": "切换帮助覆盖层",
"HelpUsageLabel": "用法:",
+4 -2
View File
@@ -54,6 +54,8 @@
"PetModeReplyHints": "{scroll} 捲動 · {latest} 回到最新",
"PetModeCompanionHints": "{sound} 音效 · {browser} 外觀 · {window} 視窗",
"PetModeInspectHint": "{inspect} 回覆與代理程式 · /pet off 標準檢視",
"PetModeViewHint": "{cycle} 檢視:{view}",
"PetViewNeeds": "{view} 需要 {requirement} · 正在顯示 {shown}",
"PetModeOnLabel": "寵物模式已開啟",
"PetModeOffLabel": "寵物模式已關閉",
"PetViewOpen": "棲息地已開啟",
@@ -821,8 +823,8 @@
"KbPointerDrag": "選取記錄文字,或拖曳捲軸",
"KbAttachPath": "將檔案或資料夾加入上下文",
"KbHelpOverlay": "開啟此說明涵蓋層(輸入框為空時)",
"KbCycleWorkDock": "切換任務面板(待辦、代理、作業、背景)",
"KbCycleWorkDockBack": "反向切換任務面板",
"KbCycleWorkDock": "切換任務面板(待辦、代理、作業、背景),寵物模式下切換寵物檢視",
"KbCycleWorkDockBack": "反向切換任務面板或寵物檢視",
"KbToggleHelp": "切換說明涵蓋層",
"KbToggleHelpSlash": "切換說明涵蓋層",
"HelpUsageLabel": "用法:",
+4
View File
@@ -874,6 +874,8 @@ pub enum MessageId {
PetModeOff,
PetModeOnLabel,
PetModeInspectHint,
PetModeViewHint,
PetViewNeeds,
PetModeReplyTitle,
PetActivityBrowsing,
PetActivityComputer,
@@ -3515,6 +3517,8 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::PetModeOff,
MessageId::PetModeOnLabel,
MessageId::PetModeInspectHint,
MessageId::PetModeViewHint,
MessageId::PetViewNeeds,
MessageId::PetModeReplyTitle,
MessageId::PetActivityBrowsing,
MessageId::PetActivityComputer,
+24
View File
@@ -85,6 +85,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Full Access.
- An idle pet view no longer builds animation frames while nobody is
looking at it (#6728).
- On the pet main view, `Ctrl+]` (or `Ctrl+Tab`) cycles the pet's views: the
companion `whale`, a crisp local `cove` whale without the fog, `braille`,
`pixels` (kitty graphics), `particles`, `whale-girl` and installed avatar
packs; `Ctrl+Shift+Tab` goes back. The footer names the view, and clicking
it cycles. A view that cannot draw shows its fallback and says what it
needs, for example `pixels needs kitty graphics · showing particles`.
`/pet avatar <key>` jumps to a view, and the `/pet` usage lists every view,
`style` and `language`.
- `F6`, `F8` and `F9` work on the pet main view, not only in the `F5`
inspector, and on macOS the pet's key hints read `fn+F5`. `/pet on`, `F8`
and `F9` restart a companion view that stopped, so "/pet on retries" is
true. A failed launch shows its own reason, a successful one says
`habitat open`, and pressing `F8` several times while the companion starts
opens one tab.
- The live dot whale stays dense in large panes, the whale-girl sprite scales
by whole pixels, and avatars, the particle grid and the pixel view no longer
show the whale through them. Avatars follow your theme and
`CODEWHALE_ASCII_SAFE`, the pixel view's image is a whole fraction of the
terminal's pixel size, and the pet's view worker sleeps until its next
deadline instead of waking every 2 ms.
- README: `codewhale exec --auto` is shown for headless runs that edit files
or run commands.
- `bash` now moves a foreground command to the background after 30 s and
@@ -409,6 +429,10 @@ launcher guidance.
with their owner, not in the agent's own detail view; named terminal
sessions live in the terminal dock
([#6931](https://github.com/codewhale-hq/Codewhale/issues/6931)).
- The pet view chosen with `Ctrl+]` is not saved across restarts and cannot
be changed inside the `F5` inspector. The browser studio and the macOS
window are not views in the cycle, and `Ctrl+]` cannot open the work bar
while the pet main view is showing.
### Maintenance
@@ -731,8 +731,8 @@ fn select_pet_mode(app: &mut App, enabled: bool, persist: bool) -> CommandResult
/// 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|inspect|status|appearance|style smooth|pixel|language auto|en|zh-Hans|zh-Hant|ja|window|source|export|sound on|off|avatar [key]|action [name|live]|view [name|live]]";
use crate::tui::pet_watch::{self, Control};
let usage = || CommandResult::error(format!("Usage: {}", super::PET_INFO.usage));
let words = arg
.map(str::trim)
.unwrap_or("")
@@ -742,8 +742,11 @@ pub fn pet(app: &mut App, arg: Option<&str>) -> CommandResult {
let words = words.iter().map(String::as_str).collect::<Vec<_>>();
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))
if pet_watch::command(app, control) {
CommandResult::message(tr(app.ui_locale, MessageId::PetHabitatQueued))
} else {
CommandResult::error(tr(app.ui_locale, MessageId::PetWatchUnavailable))
}
};
match words.as_slice() {
["inspect"] => {
@@ -779,14 +782,14 @@ pub fn pet(app: &mut App, arg: Option<&str>) -> CommandResult {
["avatar"] => CommandResult::message(app.pet_watch.avatar_choices()),
["avatar", key] => {
if !app.pet_watch.select_avatar(key) {
return CommandResult::error(USAGE);
return usage();
}
app.needs_redraw = true;
CommandResult::message(format!("/pet avatar {key}"))
}
[kind @ ("action" | "view"), name] => {
if !app.pet_watch.preview_avatar(name, *kind == "view") {
return CommandResult::error(USAGE);
return usage();
}
app.needs_redraw = true;
CommandResult::message(format!("/pet {kind} {name}"))
@@ -794,7 +797,7 @@ pub fn pet(app: &mut App, arg: Option<&str>) -> CommandResult {
["appearance"] => queued(app, Control::Browser),
["style", value] => {
let Some(body) = pet_watch::BodyStyle::parse(value) else {
return CommandResult::error(USAGE);
return usage();
};
queued(
app,
@@ -806,7 +809,7 @@ pub fn pet(app: &mut App, arg: Option<&str>) -> CommandResult {
}
["language", value] => {
let Some(language) = pet_watch::TextLanguage::parse(value) else {
return CommandResult::error(USAGE);
return usage();
};
queued(
app,
@@ -830,7 +833,7 @@ pub fn pet(app: &mut App, arg: Option<&str>) -> CommandResult {
[] => None,
["on"] => Some(true),
["off"] => Some(false),
_ => return CommandResult::error(USAGE),
_ => return usage(),
};
if let Some(enabled) = enabled {
app.pet_watch.set_sound(enabled);
@@ -843,7 +846,7 @@ pub fn pet(app: &mut App, arg: Option<&str>) -> CommandResult {
};
CommandResult::message(format!("{} · /pet sound on|off", tr(app.ui_locale, label)))
}
_ => CommandResult::error(USAGE),
_ => usage(),
}
}
+1 -1
View File
@@ -88,7 +88,7 @@ static RAIL_INFO: CommandInfo = CommandInfo {
static PET_INFO: CommandInfo = CommandInfo {
name: "pet",
aliases: &[],
usage: "/pet [on|off|inspect|status|appearance|window|source|export|sound on|off|avatar [key]|action [name|live]|view [name|live]]",
usage: "/pet [on|off|inspect|status|appearance|style [smooth|pixel]|language [auto|en|zh-Hans|zh-Hant|ja]|window|source|export|sound [on|off]|avatar [whale|cove|braille|pixels|particles|whale-girl|key]|action [name|live]|view [name|live]]",
description_id: MessageId::CmdPetDescription,
};
static SETTINGS_INFO: CommandInfo = CommandInfo {
+6
View File
@@ -490,6 +490,12 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
crate::tui::pet_watch::open_habitat(app);
return Vec::new();
}
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
&& mouse_hits_rect(mouse, app.pet_watch.view_area)
{
crate::tui::pet_watch::cycle_view(app, true);
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();
+54 -15
View File
@@ -12,11 +12,21 @@ use std::{
sync::mpsc::{self, Receiver},
};
const VIEWS: [&str; 6] = [
"whale",
"cove",
"braille",
"pixels",
"particles",
"whale-girl",
];
#[derive(Default)]
pub(super) struct Avatars {
pub key: String,
pub view: Option<String>,
pub action: Option<String>,
pub fallback: bool,
catalog: Vec<RegisteredPack>,
workspace: PathBuf,
next: Option<Instant>,
@@ -117,25 +127,45 @@ impl Avatars {
}
changed
}
fn ring(&self) -> impl Iterator<Item = &str> {
VIEWS
.into_iter()
.chain(self.catalog.iter().map(|p| p.key.as_str()))
}
pub fn choices(&self) -> String {
std::iter::once("whale".to_string())
.chain(["particles".to_string(), "whale-girl".to_string()])
.chain(self.catalog.iter().map(|p| p.key.clone()))
.collect::<Vec<_>>()
.join("\n")
self.ring().collect::<Vec<_>>().join("\n")
}
pub fn current(&self) -> &str {
if self.key.is_empty() {
"whale"
} else {
&self.key
}
}
pub fn select(&mut self, key: &str) -> bool {
if !matches!(key, "whale" | "whale-girl" | "particles")
&& !self.catalog.iter().any(|p| p.key == key)
{
if !self.ring().any(|view| view == key) {
return false;
}
self.key = key.into();
self.view = None;
self.action = None;
self.decoded = None;
self.fallback = false;
true
}
pub fn cycle(&mut self, forward: bool) {
let ring: Vec<String> = self.ring().map(str::to_owned).collect();
let at = ring
.iter()
.position(|view| view == self.current())
.unwrap_or(0);
let next = if forward {
(at + 1) % ring.len()
} else {
(at + ring.len() - 1) % ring.len()
};
self.select(&ring[next]);
}
fn pack(&self) -> Option<&Pack> {
if self.key == "whale-girl" {
Some(whale_girl::pack())
@@ -174,6 +204,20 @@ impl Avatars {
act: &str,
clock: f64,
reduced: bool,
theme: &codewhale_ratatui::Theme,
) -> bool {
let painted = self.paint_pack(area, buf, act, clock, reduced, theme);
self.fallback = !painted && self.pack().is_some();
painted
}
fn paint_pack(
&mut self,
area: Rect,
buf: &mut ratatui::buffer::Buffer,
act: &str,
clock: f64,
reduced: bool,
theme: &codewhale_ratatui::Theme,
) -> bool {
let Some(pack) = self.pack() else {
return false;
@@ -185,14 +229,9 @@ impl Avatars {
self.view.as_deref(),
self.action.as_deref(),
);
let theme = codewhale_ratatui::Theme::new(codewhale_ratatui::Caps {
depth: codewhale_ratatui::color::ColorDepth::detect(),
ascii: std::env::var_os("CODEWHALE_ASCII_SAFE").is_some(),
appearance: codewhale_ratatui::detect::Appearance::Dark,
});
if self.key == "whale-girl" {
if let Ok(sprite) = Sprite::new(pack, whale_girl::TERMINAL, 96, 96, sample.index) {
sprite.paint(area, buf, &theme);
sprite.paint(area, buf, theme);
return true;
}
return false;
@@ -236,7 +275,7 @@ impl Avatars {
return false;
};
if let Ok(sprite) = Sprite::page(&entry.pack, pixels, *w, *h, sample.index) {
sprite.paint(area, buf, &theme);
sprite.paint(area, buf, theme);
return true;
}
false
+128 -40
View File
@@ -15,19 +15,68 @@ use crate::{
use codewhale_localization::{Locale, MessageId, tr};
use codewhale_protocol::engine_owner::{OwnerActivityKind, OwnerFreshness, OwnerPresence};
use codewhale_ratatui::{
AgentCard, Caps, DotWhale, MotionMode, PetMode, PetModeAreas, State, StatusMark, Subagent,
SubagentControls, SubagentUsage, SubagentViewWords, Theme,
AgentCard, Caps, DotWhale, MotionMode, PetMode, PetModeAreas, PetStyle, State, StatusMark,
Subagent, SubagentControls, SubagentUsage, SubagentViewWords, Theme, WhalePet,
color::ColorDepth,
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")
matches!(state.avatars.current(), "whale" | "cove" | "braille")
&& state.avatars.action.is_none()
&& state.avatars.view.is_none()
}
fn theme(app: &App) -> Theme {
fn needs(
watch: &PetWatch,
synchronized: bool,
theme: &Theme,
pet: Rect,
live: bool,
) -> Option<(&'static str, &'static str)> {
const ASCII: &str = "CODEWHALE_ASCII_SAFE unset";
let colour = || match theme.depth() {
ColorDepth::Monochrome => "NO_COLOR unset",
ColorDepth::Ansi16 => "COLORTERM=truecolor",
_ => "CODEWHALE_APPEARANCE=dark|light",
};
let art = WhalePet::art_area(pet);
let local = |style: PetStyle| {
if theme.ascii() {
Err((ASCII, "words"))
} else if art.width < 16 || art.height < 8 {
Err(("16x8 cells", "words"))
} else if style == PetStyle::Braille || !theme.paints_grounds() {
Ok("braille")
} else {
Ok("cove")
}
};
match watch.avatars.current() {
"whale" if theme.ascii() => Some((ASCII, "words")),
"whale" if !live => Some((
if watch.raster.is_none() {
"codewhale pet serve"
} else {
"/pet source"
},
local(PetStyle::Color).unwrap_or_else(|(_, shown)| shown),
)),
"cove" => match local(PetStyle::Color) {
Ok("braille") => Some((colour(), "braille")),
Ok(_) => None,
Err(need) => Some(need),
},
"braille" => local(PetStyle::Braille).err(),
"pixels" => super::pixels_need(synchronized).map(|need| (need, "particles")),
"whale-girl" if theme.ascii() => Some((ASCII, "ascii")),
"whale-girl" if !theme.paints_grounds() => Some((colour(), "ascii")),
_ if watch.avatars.fallback => Some(("atlas", "particles")),
_ => None,
}
}
pub(super) 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)
@@ -273,12 +322,13 @@ pub(super) fn tick(app: &mut App, visible: bool, now: Instant) {
});
let motion = motion(app);
let theme = theme(app);
let prepared = presentation(
app.pet_watch.raster.as_ref(),
app.current_session_id.as_deref(),
now,
)
.is_some_and(|r| r.scene.materials.is_some());
let prepared = app.pet_watch.avatars.current() == "whale"
&& presentation(
app.pet_watch.raster.as_ref(),
app.current_session_id.as_deref(),
now,
)
.is_some_and(|r| r.scene.materials.is_some());
let state = &mut app.pet_watch;
state.full.update(
app.current_session_id.as_deref(),
@@ -384,6 +434,50 @@ fn render(frame: &mut Frame, area: Rect, app: &mut App, inspect: bool) {
);
let owner = inputs(app, Instant::now());
let motion = motion(app);
let view_key = app.pet_watch.avatars.current().to_owned();
let dot_whale = prepared.filter(|_| view_key == "whale").and_then(|r| {
let (points, materials, hollow) = if motion.animates() {
(
&r.scene.points,
r.scene.materials.as_ref(),
r.scene.style.hollow,
)
} else {
(
&r.scene.still.points,
r.scene.still.materials.as_ref(),
r.scene.still.style.hollow,
)
};
materials.map(|materials| {
DotWhale::new(points, materials)
.caption(&words)
.hollow(hollow)
})
});
let missing = (!plan.pet.is_empty())
.then(|| {
needs(
&app.pet_watch,
app.synchronized_output_enabled,
&theme,
plan.pet,
dot_whale.is_some(),
)
})
.flatten()
.map(|(requirement, shown)| {
Line::styled(
tr(app.ui_locale, MessageId::PetViewNeeds)
.replace("{view}", &view_key)
.replace("{requirement}", requirement)
.replace("{shown}", shown),
codewhale_palette::chrome_style(
&app.ui_theme,
crate::tui::app::StatusToastLevel::Info.ink(),
),
)
});
// 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.
@@ -401,6 +495,7 @@ fn render(frame: &mut Frame, area: Rect, app: &mut App, inspect: bool) {
let mut options = app.transcript_render_options();
options.low_motion = true;
options.motion_mode = crate::tui::motion::MotionMode::Reduced;
let chord = |id| shell_key_routing::display_chord(shell_key_routing::binding(id).footer_chord);
let copy_hint = (inspect && app.completed_assistant_output_receipt().is_some()).then(|| {
tr(app.ui_locale, MessageId::PetModeCopyHint).replace(
"{copy}",
@@ -455,9 +550,10 @@ fn render(frame: &mut Frame, area: Rect, app: &mut App, inspect: bool) {
)
));
}
if area.width >= 100 {
hints.push_str(&format!(" · {}", habitat::companion_hints(app.ui_locale)));
}
let companion = (area.width >= 100)
.then(|| format!(" · {}", habitat::companion_hints(app.ui_locale)))
.unwrap_or_default();
hints.push_str(&companion);
let arrows = format!(
"{}{}",
shell_key_routing::binding(Id::PetResultUp).footer_chord,
@@ -488,27 +584,14 @@ fn render(frame: &mut Frame, area: Rect, app: &mut App, inspect: bool) {
};
}
let mut view = PetMode::new(&theme, mark);
view.dot_whale = prepared.and_then(|r| {
let (points, materials, hollow) = if motion.animates() {
(
&r.scene.points,
r.scene.materials.as_ref(),
r.scene.style.hollow,
)
} else {
(
&r.scene.still.points,
r.scene.still.materials.as_ref(),
r.scene.still.style.hollow,
)
};
materials.map(|materials| {
DotWhale::new(points, materials)
.caption(&words)
.hollow(hollow)
})
});
view.notice = notice;
view.style = if view_key == "braille" {
PetStyle::Braille
} else {
PetStyle::Color
};
view.cove = false;
view.dot_whale = dot_whale;
view.notice = notice.or(missing);
view.title = "Codewhale".into();
view.agents = if inspect { &agents } else { &[] };
view.agent_words = SubagentViewWords {
@@ -546,19 +629,20 @@ fn render(frame: &mut Frame, area: Rect, app: &mut App, inspect: bool) {
_ => MessageId::PetModeReplyTitle,
},
);
let inspect_hint = tr(app.ui_locale, MessageId::PetModeInspectHint)
.replace("{inspect}", &chord(Id::PetInspect));
let view_hint = tr(app.ui_locale, MessageId::PetModeViewHint)
.replace("{cycle}", &chord(Id::ViewCycle))
.replace("{view}", &view_key);
view.pane_hints = if inspect {
pane_hints.into()
} else {
"".into()
view_hint.clone().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()
format!("{inspect_hint}{companion}").into()
};
app.pet_watch
.full
@@ -595,6 +679,10 @@ fn render(frame: &mut Frame, area: Rect, app: &mut App, inspect: bool) {
plan.footer.height.min(1),
)
});
let width = codewhale_ratatui::text::width(&view_hint);
app.pet_watch.view_area =
(!inspect && plan.footer.height > 1 && width <= usize::from(plan.footer.width))
.then(|| Rect::new(plan.footer.x, plan.footer.y + 1, width as u16, 1));
if canonical(&app.pet_watch) {
app.pet_watch.area = Some(plan.pet);
} else {
+5 -5
View File
@@ -44,11 +44,11 @@ impl Renderer {
} else {
16.0
};
let width = cols as f64 * cw;
let height = rows as f64 * ch;
let resolution = (960.0 / width).min(560.0 / height).min(1.0);
let w = (width * resolution).round().clamp(1.0, 960.0) as usize;
let h = (height * resolution).round().clamp(1.0, 560.0) as usize;
let width = (cols as f64 * cw).round() as usize;
let height = (rows as f64 * ch).round() as usize;
let divisor = width.div_ceil(960).max(height.div_ceil(560)).max(1);
let w = width.div_ceil(divisor);
let h = height.div_ceil(divisor);
if self.dimensions != (w, h) {
self.dimensions = (w, h);
self.background = vec![0; w * h * 3];
+1 -1
View File
@@ -106,7 +106,7 @@ pub fn companion_hints(locale: codewhale_localization::Locale) -> String {
] {
text = text.replace(
&format!("{{{name}}}"),
shell_key_routing::binding(id).footer_chord,
&shell_key_routing::display_chord(shell_key_routing::binding(id).footer_chord),
);
}
text
+60 -31
View File
@@ -147,10 +147,12 @@ pub enum Command {
Browser,
Window,
Export,
Wake,
}
pub enum Notice {
AppearanceLegacy,
Exported(PathBuf),
Opened,
Message(String),
/// The companion cannot be reached: the view thread stopped, or a frame
/// fetch failed and it is reconnecting. Only this marks the pet offline.
@@ -453,13 +455,49 @@ fn run(
let mut last_produce = Instant::now();
let mut last_audio = Instant::now();
let mut last_failure = Instant::now() - Duration::from_secs(10);
loop {
let view = settings
let read_view = || {
settings
.lock()
.map_err(|_| io::Error::other("Pet view lock failed"))?
.clone();
match rx.recv_timeout(Duration::from_millis(2)) {
Ok(command) => match command {
.map(|view| view.clone())
.map_err(|_| io::Error::other("Pet view lock failed"))
};
let encode_every = |view: &View| {
Duration::from_millis(if view.motion && view.pixels {
16
} else if view.motion {
33
} else {
200
})
};
let mut view = read_view()?;
loop {
let mut due = fetched + Duration::from_millis(if view.visible { 30 } else { 400 });
if let Some(s) = &scene {
if action.is_some() || appearance_patch.is_some() {
due = due.min(last_action + Duration::from_millis(250));
}
if source.as_deref() == Some(s.source.as_str()) {
due = due.min(last_produce + Duration::from_millis(100));
}
due = due.min(last_audio + Duration::from_millis(500));
if view.visible {
due = due.min(encoded + encode_every(&view));
}
}
let received = rx.recv_timeout(due.saturating_duration_since(Instant::now()));
view = read_view()?;
let commands: Vec<Command> = match received {
Ok(command) => std::iter::once(command).chain(rx.try_iter()).collect(),
Err(mpsc::RecvTimeoutError::Disconnected) => {
let _ = client.post("/v1/audio", &json!({"client":id,"enabled":false}));
return Ok(());
}
Err(mpsc::RecvTimeoutError::Timeout) => Vec::new(),
};
let (mut browser, mut window) = (false, false);
for command in commands {
match command {
Command::Appearance { body, language } => {
let patch = appearance_patch.get_or_insert((None, None));
patch.0 = body.or(patch.0);
@@ -486,16 +524,8 @@ fn run(
));
}
}
Command::Browser => {
if let Err(e) = client.open_browser() {
let _ = notices.try_send(Notice::Message(e.to_string()));
}
}
Command::Window => {
if let Err(e) = client.open_window() {
let _ = notices.try_send(Notice::Message(e.to_string()));
}
}
Command::Browser => browser = true,
Command::Window => window = true,
Command::Export => {
let result = client.get("/v1/export").and_then(|r| {
super::persistence::export(
@@ -510,12 +540,20 @@ fn run(
Err(e) => Notice::Message(e.to_string()),
});
}
},
Err(mpsc::RecvTimeoutError::Disconnected) => {
let _ = client.post("/v1/audio", &json!({"client":id,"enabled":false}));
return Ok(());
Command::Wake => {}
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
for launched in [
browser.then(|| client.open_browser()),
window.then(|| client.open_window()),
]
.into_iter()
.flatten()
{
let _ = notices.try_send(match launched {
Ok(()) => Notice::Opened,
Err(e) => Notice::Message(e.to_string()),
});
}
if fetched.elapsed() >= Duration::from_millis(if view.visible { 30 } else { 400 }) {
match client
@@ -632,16 +670,7 @@ fn run(
let _=client.post("/v1/audio",&json!({"client":id,"enabled":view.sound&&view.visible&&changed.elapsed()<Duration::from_millis(500)}));
last_audio = Instant::now();
}
if view.visible
&& encoded.elapsed()
>= Duration::from_millis(if view.motion && view.pixels {
16
} else if view.motion {
33
} else {
200
})
{
if view.visible && encoded.elapsed() >= encode_every(&view) {
let began = Instant::now();
let mut pose = if view.motion {
Pose {
+112 -40
View File
@@ -12,7 +12,7 @@ use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
widgets::{Block, Paragraph},
widgets::{Block, Clear, Paragraph},
};
use serde_json::json;
use std::{
@@ -64,6 +64,7 @@ pub struct PetWatch {
sound_requested: bool,
pub(crate) area: Option<Rect>,
pub(crate) inspect_area: Option<Rect>,
pub(crate) view_area: Option<Rect>,
copy_area: Arc<Mutex<Option<Rect>>>,
raster: Option<Presentation>,
controls: Arc<Mutex<Vec<Control>>>,
@@ -84,6 +85,8 @@ pub struct PetWatch {
full_response_key: Option<(u64, usize, bool)>,
full_inputs: Option<codewhale_ratatui::whale_motion::Inputs>,
selection: Arc<Mutex<Option<String>>>,
#[cfg(test)]
detached: bool,
}
impl PetWatch {
pub fn avatar_choices(&self) -> String {
@@ -133,6 +136,10 @@ impl PetWatch {
if self.session != session {
self.reset(session)
}
#[cfg(test)]
if self.detached {
return;
}
if self.worker.is_none() && !self.failed {
match Worker::start(self.session.clone()) {
Ok(worker) => self.worker = Some(worker),
@@ -153,6 +160,19 @@ impl PetWatch {
pub(crate) fn detach_for_test(&mut self) {
self.worker = None;
self.failed = true;
self.detached = true;
}
fn revive(&mut self) {
if self.worker.as_ref().is_some_and(|w| {
matches!(
w.tx.try_send(Command::Wake),
Err(std::sync::mpsc::TrySendError::Disconnected(_))
)
}) {
self.worker = None;
self.raster = None;
}
self.failed = false;
}
pub fn observe(&mut self, event: &Event, session: Option<&str>, _now: Instant) {
if self.session.as_deref() != session {
@@ -197,6 +217,7 @@ impl PetWatch {
}
pub fn prepare_frame(&mut self) {
self.inspect_area = None;
self.view_area = None;
if let Ok(mut area) = self.copy_area.lock() {
*area = None;
}
@@ -357,11 +378,22 @@ fn metadata(event: &Event, turn_id: Option<&str>) -> Option<String> {
(json.len() <= 16_384).then_some(json)
}
pub fn command(app: &mut App, control: Control) {
pub fn command(app: &mut App, control: Control) -> bool {
if matches!(
control,
Control::Appearance { .. } | Control::Browser | Control::Window | Control::Select
) {
app.pet_watch.revive();
}
app.pet_watch.ensure(app.current_session_id.clone());
let agents = full::agents(app);
apply(&mut app.pet_watch, control, &agents);
app.needs_redraw = true;
app.pet_watch.worker.is_some()
}
pub fn cycle_view(app: &mut App, forward: bool) {
app.pet_watch.avatars.cycle(forward);
app.needs_redraw = true;
}
fn apply(state: &mut PetWatch, control: Control, agents: &[codewhale_ratatui::Subagent<'_>]) {
match control {
@@ -415,6 +447,7 @@ pub fn set_enabled(app: &mut App, enabled: bool) {
crate::tui::agent_focus::exit_focus(app);
app.file_tree = None;
app.file_tree_visible = false;
app.pet_watch.revive();
app.pet_watch.ensure(app.current_session_id.clone());
} else {
let session = app.pet_watch.session.clone();
@@ -424,16 +457,35 @@ pub fn set_enabled(app: &mut App, enabled: bool) {
}
/// 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
pub fn handle_key(app: &mut App, key: &crossterm::event::KeyEvent) -> bool {
use crate::tui::shell_key_routing::{self, ShellBindingId as Id};
if !app.pet_watch.enabled {
return false;
}
let showing = main_view(app) && app.view_stack.is_empty();
match shell_key_routing::route(app.focus(), key) {
Some(Id::PetInspect) => open_habitat(app),
Some(Id::ViewCycle) if showing => cycle_view(app, true),
Some(Id::ViewCycleBack) if showing => cycle_view(app, false),
Some(Id::PetSound) if showing => {
command(app, Control::Sound);
let label = if app.pet_watch.sound_requested {
MessageId::PetWatchSoundOn
} else {
MessageId::PetWatchSoundOff
};
let text = tr(app.ui_locale, label).into_owned();
app.push_status_toast(text, StatusToastLevel::Info, Some(4000));
}
Some(Id::PetBrowser) if showing => {
command(app, Control::Browser);
}
Some(Id::PetWindow) if showing => {
command(app, Control::Window);
}
_ => return false,
}
true
}
/// The existing Engine determines work boundaries. Only the shell reads the
/// answer; no conversation text enters the pet owner or recording.
@@ -517,16 +569,7 @@ pub fn tick(app: &mut App, now: Instant) {
ShellPhase::Waiting | ShellPhase::Approval
);
let a = state.area.unwrap_or(Rect::new(0, 0, 40, 8));
let cell = crossterm::terminal::window_size()
.ok()
.filter(|s| s.columns > 0 && s.rows > 0 && s.width > 0 && s.height > 0)
.map(|s| {
(
f64::from(s.width) / f64::from(s.columns),
f64::from(s.height) / f64::from(s.rows),
)
})
.unwrap_or((8.0, 16.0));
let cell = cell_pixels().unwrap_or((8.0, 16.0));
let next = live::View {
locale: app.ui_locale.tag().into(),
width: a.width.clamp(1, 512),
@@ -534,17 +577,19 @@ pub fn tick(app: &mut App, now: Instant) {
cell_width: cell.0,
cell_height: cell.1,
motion,
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"),
pixels: state.avatars.current() == "pixels"
&& pixels_need(app.synchronized_output_enabled).is_none(),
visible,
waiting,
sound: state.sound_requested && sound_allowed,
};
if let Ok(mut view) = worker.view.lock() {
let woke = worker.view.lock().is_ok_and(|mut view| {
let woke = view.visible != next.visible;
*view = next;
woke
});
if woke {
let _ = worker.tx.try_send(Command::Wake);
}
}
}
@@ -555,8 +600,13 @@ pub fn tick(app: &mut App, now: Instant) {
.map(|w| w.notices.try_iter().take(16).collect())
.unwrap_or_default();
for notice in notices {
app.pet_watch.exporting = false;
let recorded = !matches!(notice, Notice::Opened);
app.pet_watch.exporting &= !recorded;
let (text, level) = match notice {
Notice::Opened => (
tr(app.ui_locale, MessageId::PetViewOpen).into_owned(),
StatusToastLevel::Info,
),
Notice::AppearanceLegacy => (
tr(app.ui_locale, MessageId::PetAppearanceLegacy).into_owned(),
StatusToastLevel::Warning,
@@ -568,13 +618,7 @@ pub fn tick(app: &mut App, now: Instant) {
),
// A refused action (select, export, open) leaves a reachable
// companion and the habitat as they are.
Notice::Message(message) => (
format!(
"{} · {message}",
tr(app.ui_locale, MessageId::PetWatchUnavailable)
),
StatusToastLevel::Warning,
),
Notice::Message(message) => (message, StatusToastLevel::Warning),
Notice::Unreachable(message) => {
app.pet_watch.unavailable = true;
if app.is_loading
@@ -592,10 +636,12 @@ pub fn tick(app: &mut App, now: Instant) {
)
}
};
app.add_message(crate::tui::history::HistoryCell::System {
content: text.clone(),
});
app.push_status_toast(text, level, Some(12000));
if recorded {
app.add_message(crate::tui::history::HistoryCell::System {
content: text.clone(),
});
}
app.push_status_toast(text, level, Some(if recorded { 12000 } else { 4000 }));
app.needs_redraw = true;
}
full::tick(app, visible, now);
@@ -663,12 +709,14 @@ fn render_tank(frame: &mut Frame, area: Rect, app: &mut App) {
)
})
.unwrap_or(Color::Rgb(180, 210, 216));
frame.render_widget(Clear, area);
frame.render_widget(
Block::default().style(Style::default().bg(Color::Rgb(bg[0], bg[1], bg[2]))),
area,
);
let (act, clock) = avatars::act(raster, app.is_loading);
let reduced = !full::motion(app).animates();
let theme = full::theme(app);
let tank = Rect {
height: area.height.saturating_sub(1),
..area
@@ -676,7 +724,7 @@ fn render_tank(frame: &mut Frame, area: Rect, app: &mut App) {
if app
.pet_watch
.avatars
.paint(tank, frame.buffer_mut(), act, clock, reduced)
.paint(tank, frame.buffer_mut(), act, clock, reduced, &theme)
{
let preview = app.pet_watch.avatars.action.as_deref().or(app
.pet_watch
@@ -774,6 +822,30 @@ fn paint_resting_whale(frame: &mut Frame, area: Rect, style: Style) {
pub(crate) fn clear_images(output: &mut impl Write) -> io::Result<()> {
graphics::clear(output)
}
fn cell_pixels() -> Option<(f64, f64)> {
crossterm::terminal::window_size()
.ok()
.filter(|s| s.columns > 0 && s.rows > 0 && s.width > 0 && s.height > 0)
.map(|s| {
(
f64::from(s.width) / f64::from(s.columns),
f64::from(s.height) / f64::from(s.rows),
)
})
}
fn pixels_need(synchronized: bool) -> Option<&'static str> {
if !crate::tui::mark::kitty_graphics_supported() {
Some("kitty graphics")
} else if !synchronized {
Some("synchronized output")
} else if std::env::var("CODEWHALE_PET_GRAPHICS").as_deref() == Ok("braille") {
Some("CODEWHALE_PET_GRAPHICS unset")
} else if cell_pixels().is_none() {
Some("cell pixel size")
} else {
None
}
}
#[cfg(test)]
mod tests {
+4 -1
View File
@@ -989,7 +989,10 @@ fn live_counts(
// exact grey as the separators around it and the one clickable word in an
// idle footer disappears into punctuation. Live counts already carry
// `Active`; this is the idle case earning the same "you can click this".
if counts.is_empty() && app.work_surface.last_area.is_none() {
if counts.is_empty()
&& app.work_surface.last_area.is_none()
&& !crate::tui::pet_watch::main_view(app)
{
// The word alone did not say it was an affordance, let alone which
// key opened it — founder live-test: "what do we press at the bottom
// to get the workbar to show up?". It carries its chord until the
+23 -7
View File
@@ -55,6 +55,7 @@ pub enum Focus {
pub enum FocusScope {
TerminalPanel,
PetHabitat,
Pet,
/// Only the sandbox-elevation decision card.
Elevation,
/// Only the model-bound redaction consent gate.
@@ -78,6 +79,7 @@ impl FocusScope {
match self {
Self::TerminalPanel => focus == Focus::TerminalPanel,
Self::PetHabitat => focus == Focus::Modal(ModalKind::PetHabitat),
Self::Pet => matches!(focus, Focus::Composer | Focus::Modal(ModalKind::PetHabitat)),
Self::Elevation => focus == Focus::Modal(ModalKind::Elevation),
Self::RedactionGate => focus == Focus::RedactionGate,
Self::SessionShell => {
@@ -138,9 +140,10 @@ pub enum ShellBindingId {
ModeCycle,
/// Shift+Tab: cycle the permission posture.
PermissionCycle,
/// Ctrl+Tab / Ctrl+]: the next bottom-dock view.
/// Ctrl+Tab / Ctrl+]: the next bottom-dock view, or the next pet view
/// while the pet main view is showing.
ViewCycle,
/// Ctrl+Shift+Tab: the previous bottom-dock view.
/// Ctrl+Shift+Tab: the previous bottom-dock view, or the previous pet view.
ViewCycleBack,
}
@@ -365,19 +368,19 @@ pub const SHELL_BINDINGS: &[ShellBinding] = &[
id: ShellBindingId::PetSound,
catalog_chord: "F6",
footer_chord: "F6",
focus: FocusScope::PetHabitat,
focus: FocusScope::Pet,
},
ShellBinding {
id: ShellBindingId::PetBrowser,
catalog_chord: "F8",
footer_chord: "F8",
focus: FocusScope::PetHabitat,
focus: FocusScope::Pet,
},
ShellBinding {
id: ShellBindingId::PetWindow,
catalog_chord: "F9",
footer_chord: "F9",
focus: FocusScope::PetHabitat,
focus: FocusScope::Pet,
},
ShellBinding {
id: ShellBindingId::RedactionGateConfirm,
@@ -537,7 +540,8 @@ pub fn tool_details_chord() -> Cow<'static, str> {
}
/// Render a portable `Alt+X` chord for the current platform. macOS normally
/// shows `⌥X`; ASCII-safe terminals retain the portable `Alt+X` spelling.
/// shows `⌥X` and `fn+F5`, because an Apple keyboard sends media keys from the
/// top row unless Fn is held; ASCII-safe terminals retain the portable spelling.
#[must_use]
pub fn display_chord(chord: &'static str) -> Cow<'static, str> {
display_chord_for_platform_and_ascii(
@@ -564,7 +568,19 @@ fn display_chord_for_platform_and_ascii(
if !is_macos {
return Cow::Borrowed(chord);
}
let rendered = chord.replace("Alt+", "⌥").replace("F1", "fn+F1");
let mut rendered = String::with_capacity(chord.len() + 6);
let mut previous = None;
for (index, ch) in chord.char_indices() {
if ch == 'F'
&& !previous.is_some_and(char::is_alphanumeric)
&& chord[index + 1..].starts_with(|next: char| next.is_ascii_digit())
{
rendered.push_str("fn+");
}
rendered.push(ch);
previous = Some(ch);
}
let rendered = rendered.replace("Alt+", "⌥");
if rendered == chord {
Cow::Borrowed(chord)
} else {
+1 -1
View File
@@ -5605,7 +5605,7 @@ pub(crate) async fn run_event_loop(
continue;
}
if crate::tui::pet_watch::handle_inspect_key(app, &key) {
if crate::tui::pet_watch::handle_key(app, &key) {
continue;
}
+4 -4
View File
@@ -3193,11 +3193,11 @@ mod pet_main_tests {
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(
assert!(!pet_watch::handle_key(
&mut app,
&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)
));
assert!(pet_watch::handle_inspect_key(
assert!(pet_watch::handle_key(
&mut app,
&KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
));
@@ -3246,7 +3246,7 @@ mod pet_main_tests {
&serde_json::json!({"command":"git status"}),
"pet-approval-key",
)));
assert!(!pet_watch::handle_inspect_key(
assert!(!pet_watch::handle_key(
&mut app,
&KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
));
@@ -3289,7 +3289,7 @@ mod pet_main_tests {
streaming: false,
});
app.record_completed_assistant_output(index, reply);
assert!(pet_watch::handle_inspect_key(
assert!(pet_watch::handle_key(
&mut app,
&KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
));
+48 -3
View File
@@ -16,6 +16,7 @@ Codewhale ● working
(the whale)
●
F5 replies & agents · /pet off standard view
Ctrl+] view: whale
╭──────────────────────────────────────────────────────────╮
│❯ Type a follow-up… │
╰──────────────────────────────────────────────────────────╯
@@ -54,6 +55,29 @@ count (`2 agents`) and the work bar. Transient notices, such as a retry
(`Retry attempt: transport 1/3; network error; waiting 0.94s`), show on the
second line.
## Views
`Ctrl+]` (or `Ctrl+Tab` where the terminal sends it) switches the pet to its
next view while the pet main view is showing; `Ctrl+Shift+Tab` goes back. The
footer's second line names the current view, `Ctrl+] view: whale`, and
clicking it also moves to the next view. `/pet avatar <key>` jumps straight to
one.
| View | What it draws | When its requirement is missing |
| --- | --- | --- |
| `whale` | The companion's live dot whale. | Shows `cove` (or `braille`) until the companion is reachable (`codewhale pet serve`) and this terminal's session is its source (`/pet source`). |
| `cove` | The local half-block whale, without the translucent cove scenery. | Needs colour: `braille` is shown under `NO_COLOR`, a 16-colour terminal (`COLORTERM=truecolor`), or an unknown background (`CODEWHALE_APPEARANCE=dark` or `light`). |
| `braille` | The local whale in coloured Braille dots. | Shows only the state words under `CODEWHALE_ASCII_SAFE` or when the art area is smaller than 16x8 cells. |
| `pixels` | The companion's whale as a kitty-graphics image. | Needs kitty graphics, synchronized output, a known cell size in pixels, and `CODEWHALE_PET_GRAPHICS` unset; otherwise shows `particles`. Terminal.app has no kitty graphics. |
| `particles` | The companion's Braille particle grid. | Shows the resting whale and `offline` while the companion is unreachable. |
| `whale-girl` | The bundled sprite avatar. | Needs colour, as `cove` does; otherwise shows a one-ink ASCII form. |
| Installed avatar packs | Each pack, in catalog order. | Shows `particles` while the pack's atlas loads. |
The order is `whale → cove → braille → pixels → particles → whale-girl →`
installed packs, then back to `whale`. A view that cannot draw is never
skipped: it shows its fallback, and the line under the title says what is
missing, for example `pixels needs kitty graphics · showing particles`.
## Replies and agents on demand
The main view stays quiet. Press `F5`, or run `/pet inspect`, to open the
@@ -66,12 +90,21 @@ inspector over it:
| Key | Effect |
| --- | --- |
| `F5` or `/pet inspect` | Open replies and agents. |
| `Ctrl+]` / `Ctrl+Tab`, `Ctrl+Shift+Tab` | Next or previous pet view (main view only). |
| `F6` | Turn the companion's sound on or off. |
| `F8` | Open the appearance studio in your browser. |
| `F9` | Open the native Codewhale Pet window (macOS). |
| `Esc` | Back to the message box. Your draft, the transcript, and any running turn are untouched. |
| `Tab` | Switch between the Reply and Agents panes. |
| `Up` / `Down`, `PgUp` / `PgDn`, `Home` / `End` | Scroll the focused pane. |
| `Enter` (Agents pane) | Open the selected agent's transcript. |
| `c` | Copy the last finished reply, through the same path as `/copy`. |
`F5`, `F6`, `F8` and `F9` work on the main view and in the inspector. On a Mac
keyboard the top row sends media keys unless Fn is held, so the hints read
`fn+F5` there; press Fn with the key. A successful `F8` or `F9` says
`habitat open`; a failed launch shows its own reason.
## Several terminals, one pet each
Pet mode is per terminal. Start Codewhale in two terminals and run `/pet on` in
@@ -105,7 +138,7 @@ The next view starts it again.
| `/pet appearance` | Open the appearance studio (palette, brightness, dot size, glow) in your browser, on loopback. Same as `F8`. |
| `/pet window` | Open the native Codewhale Pet window, if the macOS app is installed. Same as `F9`. If it is not installed, Codewhale says to build or install it. |
| `/pet sound on`, `/pet sound off` | Turn the companion's sound on or off. Same toggle as `F6`. |
| `/pet avatar [key]` | List avatars, or choose one: `whale`, `particles`, `whale-girl`, and any avatar packs you have installed. |
| `/pet avatar [key]` | List the views, or choose one: `whale`, `cove`, `braille`, `pixels`, `particles`, `whale-girl`, and any avatar packs you have installed. |
| `/pet action <name>`, `/pet view <name>`, `/pet action live` | Preview a named pose or view of the chosen avatar, or return to live. |
| `/pet export` | Save a replay of the pet's recording to this saved session's artifacts. |
@@ -129,10 +162,12 @@ stays off.
- **`CODEWHALE_ASCII_SAFE=1`.** Marks, borders, and the pointer use ASCII
(`o idle`, `|`, `>`), and the animated art is replaced by the mark and word.
- **Reduced motion.** Follows the shell's motion setting and shows a still pose.
- **`CODEWHALE_PET_GRAPHICS=braille`.** Forces text rendering where the
terminal view would otherwise use pixel graphics.
- **`CODEWHALE_PET_GRAPHICS=braille`.** Keeps the `pixels` view on text: it
shows `particles` and says why.
- **No companion.** If the companion cannot be reached, the view says so and the
state word and mark continue to work, because they come from your session.
`/pet on`, `F8`, `F9` and the companion commands such as `/pet appearance`
start it again.
## Privacy
@@ -164,6 +199,16 @@ execution API.
Other terminals show their own state word and mark, not the detail.
- **`/pet window` is macOS only** and needs the Codewhale Pet app.
- **Sound needs `ffplay`** and one terminal holds the output at a time.
- **The chosen view is not saved.** It lasts for this run of Codewhale,
including session switches; a restart begins on `whale`.
- **No view cycling inside the inspector.** Press `Esc` first; `Ctrl+]` cycles
only on the main view.
- **`Ctrl+]` cannot open the work bar** while the pet main view is showing. Use
`/pet off` first.
- **The browser studio and the macOS window are not views in the ring**, because
each press would start a process. Use `F8` and `F9`.
- **No sixel or iTerm2 images.** The `pixels` view draws only through kitty
graphics.
See also [Keybindings](KEYBINDINGS.md), [Modes](MODES.md), and the design notes
in [`design/DOT_WHALE_PET.md`](design/DOT_WHALE_PET.md) and
+5 -4
View File
@@ -152,10 +152,11 @@ Sleep dimming does not set unknown coverage.
### `/pet` habitat
`/pet` (or `/pet on`) opens the full habitat over the existing shell. The pet
has no workbar panel: no dock cycling, tab, placement or dismissal applies. The
tank uses the whole content viewport and never overlays transcript cells; narrow
views keep the text cue. Opening it starts a read-only observer; it does not start an Engine
`/pet` (or `/pet on`) makes the pet the main view above the existing composer;
F5 or `/pet inspect` opens replies and agents over it. The pet has no workbar
panel: no placement or dismissal applies, and on the pet main view `Ctrl+]`
cycles the pet's views instead of the dock. The pet never overlays transcript
cells; narrow views keep the text cue. Opening it starts a read-only observer; it does not start an Engine
turn or silently substitute demonstration data. It begins with unknown coverage
and does not backfill events from before attachment.
+1 -1
View File
@@ -550,7 +550,7 @@ owner = "crates/tui/src/voice.rs"
[[feature]]
id = "pet"
name = "Pet"
summary = "A per-terminal whale view of working, waiting, done and failed; F5 or /pet inspect opens replies and agents."
summary = "A per-terminal whale view of working, waiting, done and failed; Ctrl+] cycles its views, F5 opens replies and agents."
status = "stable"
surfaces = { tui = "stable" }
owner = "crates/tui/src/tui/pet_watch"
+30 -3
View File
@@ -32,6 +32,22 @@ Pet 模式把你正在使用的终端变成一个一眼可读的状态视图。
下方状态栏保留原有的实时读数,例如智能体数量(`2 agents`)和任务面板。重试等临时提示显示在第二行。
## 视图
主视图显示时,按 `Ctrl+]`(终端能发送时也可按 `Ctrl+Tab`)切换到下一个视图,`Ctrl+Shift+Tab` 切回上一个。页脚第二行显示当前视图,例如 `Ctrl+] view: whale`,点击它也会切到下一个视图。`/pet avatar <key>` 可直接跳到某个视图。
| 视图 | 显示内容 | 条件不满足时 |
| --- | --- | --- |
| `whale` | 伴生进程的实时点阵鲸鱼。 | 在伴生进程可达(`codewhale pet serve`)且本终端的会话是来源(`/pet source`)之前,显示 `cove`(或 `braille`)。 |
| `cove` | 本地半块字符鲸鱼,不带半透明的海湾背景。 | 需要颜色:在 `NO_COLOR`、16 色终端(`COLORTERM=truecolor`)或背景未知(`CODEWHALE_APPEARANCE=dark` 或 `light`)时显示 `braille`。 |
| `braille` | 本地彩色盲文点阵鲸鱼。 | 在 `CODEWHALE_ASCII_SAFE` 下或图案区域小于 16x8 个单元格时,只显示状态文字。 |
| `pixels` | 伴生进程鲸鱼的 kitty 图形图像。 | 需要 kitty 图形、同步输出、已知的单元格像素尺寸,且未设置 `CODEWHALE_PET_GRAPHICS`;否则显示 `particles`。Terminal.app 不支持 kitty 图形。 |
| `particles` | 伴生进程的盲文粒子网格。 | 伴生进程不可达时,显示静止的鲸鱼和 `offline`。 |
| `whale-girl` | 内置的精灵头像。 | 与 `cove` 一样需要颜色;否则显示单色 ASCII 形式。 |
| 已安装的头像包 | 按目录顺序显示各头像包。 | 图集加载期间显示 `particles`。 |
顺序为 `whale → cove → braille → pixels → particles → whale-girl →` 已安装的头像包,然后回到 `whale`。无法绘制的视图不会被跳过:它显示降级内容,标题下一行说明缺少什么,例如 `pixels needs kitty graphics · showing particles`。
## 按需查看回复与智能体
主视图保持安静。按 `F5` 或运行 `/pet inspect`,在其上打开检查视图:
@@ -42,12 +58,18 @@ Pet 模式把你正在使用的终端变成一个一眼可读的状态视图。
| 按键 | 效果 |
| --- | --- |
| `F5` 或 `/pet inspect` | 打开回复与智能体。 |
| `Ctrl+]` / `Ctrl+Tab`、`Ctrl+Shift+Tab` | 下一个或上一个 Pet 视图(仅主视图)。 |
| `F6` | 开关伴生进程的声音。 |
| `F8` | 在浏览器中打开外观工作室。 |
| `F9` | 打开原生 Codewhale Pet 窗口(macOS)。 |
| `Esc` | 回到消息框。草稿、对话记录和正在运行的回合都不受影响。 |
| `Tab` | 在 Reply 与 Agents 面板之间切换。 |
| `Up` / `Down`、`PgUp` / `PgDn`、`Home` / `End` | 滚动当前聚焦的面板。 |
| `Enter`(Agents 面板) | 打开所选智能体的对话记录。 |
| `c` | 复制最近一条已完成的回复,与 `/copy` 走同一路径。 |
`F5`、`F6`、`F8` 和 `F9` 在主视图和检查视图中都可用。Mac 键盘的顶行默认发送媒体键,需按住 Fn,所以那里的提示显示为 `fn+F5`。`F8` 或 `F9` 成功时提示 `habitat open`;启动失败时显示失败原因。
## 多个终端,各有各的 Pet
Pet 模式按终端独立。在两个终端里各运行 `/pet on`:每头鲸鱼跟随自己的会话,所以一个可以显示 `✓ Done`,另一个仍显示 `● working`。不同会话之间不会混在一起。
@@ -71,7 +93,7 @@ Pet 模式按终端独立。在两个终端里各运行 `/pet on`:每头鲸鱼
| `/pet appearance` | 在浏览器(仅回环地址)中打开外观工作室,可调整配色、亮度、点大小和光晕。同 `F8`。 |
| `/pet window` | 若已安装 macOS 的 Codewhale Pet 应用,则打开原生窗口。同 `F9`。未安装时会提示构建或安装。 |
| `/pet sound on`、`/pet sound off` | 开关伴生进程的声音。与 `F6` 是同一个开关。 |
| `/pet avatar [key]` | 列出头像,或选择其一:`whale`、`particles`、`whale-girl`,以及你已安装的头像包。 |
| `/pet avatar [key]` | 列出视图,或选择其一:`whale`、`cove`、`braille`、`pixels`、`particles`、`whale-girl`,以及你已安装的头像包。 |
| `/pet action <name>`、`/pet view <name>`、`/pet action live` | 预览所选头像的某个姿态或视图,或回到实时。 |
| `/pet export` | 把 Pet 的录制回放保存到当前已保存会话的产物目录。 |
@@ -87,8 +109,8 @@ Pet 模式按终端独立。在两个终端里各运行 `/pet on`:每头鲸鱼
- **`NO_COLOR`。** 颜色交由终端决定;标记和文字仍然表达状态。
- **`CODEWHALE_ASCII_SAFE=1`。** 标记、边框和提示符改用 ASCII(`o idle`、`|`、`>`),动画图案由标记和文字取代。
- **减弱动效。** 遵循 shell 的动效设置,显示静态姿态。
- **`CODEWHALE_PET_GRAPHICS=braille`。** 在终端视图本会使用像素图形时,强制使用文字渲染。
- **伴生进程不可用。** 视图会提示;状态文字和标记仍然可用,因为它们来自你的会话。
- **`CODEWHALE_PET_GRAPHICS=braille`。** 让 `pixels` 视图保持文字渲染:它显示 `particles` 并说明原因。
- **伴生进程不可用。** 视图会提示;状态文字和标记仍然可用,因为它们来自你的会话。`/pet on`、`F8`、`F9` 以及 `/pet appearance` 等伴生命令会重新启动它。
## 隐私
@@ -107,5 +129,10 @@ Pet 模式按终端独立。在两个终端里各运行 `/pet on`:每头鲸鱼
- **一次只有一个来源。** 伴生进程的活动细节只跟随一个会话。其他终端显示各自的状态文字和标记,不显示细节。
- **`/pet window` 仅限 macOS**,且需要 Codewhale Pet 应用。
- **声音需要 `ffplay`**,且同一时间只有一个终端持有输出。
- **所选视图不会保存。** 它在本次运行中保持,包括切换会话;重启后从 `whale` 开始。
- **检查视图中不能切换视图。** 先按 `Esc`;`Ctrl+]` 只在主视图中切换。
- **Pet 主视图显示时,`Ctrl+]` 不能打开任务面板。** 请先 `/pet off`。
- **浏览器工作室和 macOS 窗口不在视图循环中**,因为每次按键都会启动一个进程。请使用 `F8` 和 `F9`。
- **没有 sixel 或 iTerm2 图像。** `pixels` 视图只通过 kitty 图形绘制。
另见 [键位](KEYBINDINGS.md)、[模式](MODES.md),以及设计说明 [`design/DOT_WHALE_PET.md`](../design/DOT_WHALE_PET.md) 和 [`../../pet/SHARED.md`](../../pet/SHARED.md)。
+8 -5
View File
@@ -52,9 +52,12 @@ English reader learn Chinese or Japanese phrases beside the normal English
status. Both whale styles turn their 980 drones into these same activity words.
The saved presentation settings leave the pet’s identity and recording intact.
The pet has no workbar panel; the habitat is its only terminal view. Escape
leaves it without cancelling the turn or touching the composer draft. F6
toggles sound, F8 opens the appearance studio, and F9 opens the native window;
The pet has no workbar panel. In the terminal it is the pet main view above the
composer, with the F5 inspector over it. On the main view Ctrl+] cycles the
pet's views: whale, cove, braille, pixels, particles, whale-girl and installed
avatar packs. Escape leaves the inspector without cancelling the turn or
touching the composer draft. F6 toggles sound, F8 opens the appearance studio,
and F9 opens the native window, on the main view and in the inspector;
reduced motion follows the shell's existing motion setting. Hints and key
admission use the existing shell binding table. The habitat uses the modal
stack; composer contents, transcript, selection and active Engine state stay
@@ -64,8 +67,8 @@ underneath it. Consent and approval views retain their existing priority.
selects an isolated companion directory; its default is
`~/.codewhale/pet-shared`. `CODEWHALE_PET_PORT` chooses the initial loopback port
(default 4633; zero allocates an unused port for tests). A saved world retains
its selected port. `CODEWHALE_PET_GRAPHICS=braille` forces the text fallback.
None of these commands replace an installed global CLI.
its selected port. `CODEWHALE_PET_GRAPHICS=braille` keeps the `pixels` view on
its particles fallback. None of these commands replace an installed global CLI.
## Appearance and work-to-result preview
+14
View File
@@ -8,6 +8,20 @@
cell's centre instead of box-filtering, and the whale cove rasterizes one
sample per pixel so shape edges no longer blend into the terminal
background.
- Keep `DotWhale` dense in large panes. Its scale is capped at 32 braille dots
(about four lit dots per cell, as at 80x18), and the whale and caption are
centred together. Each cell takes the colour of its strongest dot, drawn at
full ink instead of an alpha-weighted mean. Hollow (unobserved) frames use
the theme's `Muted` ink instead of `DIM`.
- `Sprite` scales by one integer factor (n, or 1/n) on both axes, with the
same rounding for width and rows. Every source pixel stays whole. A sprite
that is slightly too large for its area now shrinks by a whole factor.
- `PetMode::style` and `PetMode::cove` reach the `WhalePet` fallback, so a
host can force the Braille whale or drop the translucent cove. A forced
Braille whale is scheduled at the Terminal cadence.
- Known limits: `DotWhale` does not grow past the cap, so a large pane shows a
whale of the same size with more space around it. Both painters still assume
a 1:2 cell aspect. On profiles without colour, `Muted` falls back to `DIM`.
## 0.1.1 - 2026-10-11
+4 -3
View File
@@ -189,11 +189,12 @@
"pet_update": {
"commit": "8f51c6bccadeb11278cda1e2481a116573e5d233",
"scope": "Selected prepared pet renderer files; non-pet components retain the base snapshot and existing Engine patches.",
"local_changes": "Not yet upstream: src/avatar_sprite.rs (one integer scale for both axes), src/components/dot_whale.rs (scale capped at 32 dots, strongest dot's colour per cell, full ink, hollow in the Muted role instead of DIM) and src/components/pet_mode.rs (PetMode::style and PetMode::cove reach the WhalePet fallback). Their hashes below are the local files.",
"files": {
"src/avatar_sprite.rs": "2d76da6667d24fbcd36a22d5a70c8ef9136eb1b853bb32884cf236ee0cce92b6",
"src/avatar_sprite.rs": "a2b6553802c4be509aa9772ff94bc9fef15c8b9d6ff00f262332b92aecf4fd52",
"src/color.rs": "96c44be1540c4756f251ed5ffb74aa88a5ebf131b104bf25866ad7ab8e3c6ba8",
"src/components/dot_whale.rs": "191bacf808037ba4dd7d4dfffbd06fe9c951a258a31495380591ad6ab4a715fa",
"src/components/pet_mode.rs": "35142d709073a09720f1ce1cfb57b18d6ea4880530a5c8d96755c5b9fc203214",
"src/components/dot_whale.rs": "7c84fa84dd28b6a0910ac7fc41663ed96269560b5cc9741fbabc0162c2d767a9",
"src/components/pet_mode.rs": "b9be163495feced79505cf3907d2d462430eaa699db01a4998af14e8d84dd3b0",
"src/components/whale_pet.rs": "bb29442f8db20e8387482e2e1dcb4fa5259ef99c3a37a77bfe9e4b56eaa05aed",
"src/whale.rs": "126a6efc8cac550ccd7f9c8edc61745b8c959186de118c2f4d479f5a5c5ffcf7",
"src/whale_motion/pixels.rs": "f83113863c7928cc93dbbc7b187d6f9346d1848ffb9636fd3d8551faa9de69d9"
+23 -17
View File
@@ -93,10 +93,10 @@ impl<'a> Sprite<'a> {
.expect("validated RGBA pixel")
}
fn sample(&self, x: usize, y: usize, width: usize, height: usize) -> [u8; 4] {
fn sample(&self, x: usize, y: usize, up: usize, down: usize) -> [u8; 4] {
self.pixel(
((2 * x + 1) * self.width / (2 * width)).min(self.width - 1),
((2 * y + 1) * self.height / (2 * height)).min(self.height - 1),
((2 * x + 1) * down / (2 * up)).min(self.width - 1),
((2 * y + 1) * down / (2 * up)).min(self.height - 1),
)
}
}
@@ -108,26 +108,32 @@ impl Paint for Sprite<'_> {
}
// One cell is approximately twice as tall as wide. Fit without changing
// the artwork's aspect ratio and never allocate from terminal geometry.
let scale = (f64::from(area.width) / self.width as f64)
.min(f64::from(area.height) * 2. / self.height as f64);
let width = ((self.width as f64 * scale).floor() as u16)
.max(1)
.min(area.width);
let rows = ((self.height as f64 * scale / 2.).ceil() as u16)
.max(1)
.min(area.height);
let (columns, pixel_rows) = (usize::from(area.width), usize::from(area.height) * 2);
let (up, down) = if self.width <= columns && self.height <= pixel_rows {
((columns / self.width).min(pixel_rows / self.height), 1)
} else {
(
1,
self.width
.div_ceil(columns)
.max(self.height.div_ceil(pixel_rows)),
)
};
let height = (self.height * up).div_ceil(down);
let width = (self.width * up).div_ceil(down) as u16;
let rows = height.div_ceil(2) as u16;
let left = area.x + (area.width - width) / 2;
let top = area.y + (area.height - rows) / 2;
let colored = theme.caps().paints_tokens() && !theme.ascii();
for y in 0..rows {
for x in 0..width {
let sample = |half: usize| {
self.sample(
usize::from(x),
usize::from(y) * 2 + half,
usize::from(width),
usize::from(rows) * 2,
)
let y = usize::from(y) * 2 + half;
if y < height {
self.sample(usize::from(x), y, up, down)
} else {
[0; 4]
}
};
let a = sample(0);
let b = sample(1);
+17 -20
View File
@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::{Modifier, Style},
style::Style,
widgets::{Paragraph, Widget, Wrap},
};
@@ -89,8 +89,7 @@ impl<'a> DotWhale<'a> {
struct DotCell {
bits: u8,
pigment: [f64; 3],
weight: f64,
count: u16,
strength: f64,
}
fn caption(label: &str, area: Rect, buf: &mut Buffer, style: Style) {
@@ -159,8 +158,12 @@ impl Paint for DotWhale<'_> {
return;
}
let width = f64::from(area.width) * 2.0;
let scale = ((width - 1.0).min(f64::from(art_height) * 4.0 - 1.0) / 2.0).min(32.0);
let art_height = ((2.0 * scale + 1.0) / 4.0).ceil() as u16;
let top = area
.y
.saturating_add(area.height.saturating_sub(art_height + label_height) / 2);
let height = f64::from(art_height) * 4.0;
let scale = (width - 1.0).min(height - 1.0) / 2.0;
let bits = [[1, 8], [2, 16], [4, 32], [64, 128]];
let mut cells: BTreeMap<(u16, u16), DotCell> = BTreeMap::new();
for (point, material) in self.points.iter().zip(self.materials) {
@@ -171,7 +174,7 @@ impl Paint for DotWhale<'_> {
let y = ((height - 1.0) / 2.0 + point[1] * scale).round() as u32;
let (Ok(col), Ok(row)) = (
u16::try_from(u32::from(area.x) + x / 2),
u16::try_from(u32::from(area.y) + y / 4),
u16::try_from(u32::from(top) + y / 4),
) else {
continue;
};
@@ -180,24 +183,18 @@ impl Paint for DotWhale<'_> {
}
let cell = cells.entry((col, row)).or_default();
cell.bits |= bits[(y % 4) as usize][(x % 2) as usize];
for (sum, value) in cell.pigment.iter_mut().zip(material) {
*sum += value * material[3];
if material[3] > cell.strength {
cell.strength = material[3];
cell.pigment = [material[0], material[1], material[2]];
}
cell.weight += material[3];
cell.count += 1;
}
for ((x, y), dot) in cells {
if let Some(cell) = buf.cell_mut((x, y)) {
let [r, g, b] = dot.pigment.map(|sum| (sum / dot.weight).round() as u8);
let mut style = color::prepared_ink(
[r, g, b],
(dot.weight / f64::from(dot.count)) as f32,
cell.bg,
theme,
);
if self.hollow {
style = style.add_modifier(Modifier::DIM);
}
let style = if self.hollow {
theme.fg(Role::Muted)
} else {
color::prepared_ink(dot.pigment.map(|v| v.round() as u8), 1.0, cell.bg, theme)
};
let glyph = char::from_u32(0x2800 + u32::from(dot.bits)).unwrap_or(' ');
let mut encoded = [0; 4];
cell.set_symbol(glyph.encode_utf8(&mut encoded))
@@ -208,7 +205,7 @@ impl Paint for DotWhale<'_> {
&label,
Rect::new(
area.x,
area.y.saturating_add(art_height),
top.saturating_add(art_height),
area.width,
label_height,
),
+16 -7
View File
@@ -2,8 +2,8 @@
//! the host still owns sessions, roster receipts, responses and every action.
use crate::{
DotWhale, MotionMode, Paint, Role, StatusMark, Subagent, SubagentIntent, SubagentView,
SubagentViewState, SubagentViewWords, Theme, WhalePet, text,
DotWhale, MotionMode, Paint, PetStyle, 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};
@@ -99,6 +99,7 @@ pub struct PetModeState {
visible: bool,
motion: MotionMode,
stage_animates: bool,
style: PetStyle,
}
impl PetModeState {
@@ -158,11 +159,12 @@ impl PetModeState {
let art = WhalePet::art_area(self.areas.pet);
let pet = (!theme.ascii() && art.width >= 16 && art.height >= 8 && self.stage_animates)
.then(|| {
self.stage.cadence(if theme.paints_grounds() {
Tier::Hero
} else {
Tier::Terminal
})
self.stage
.cadence(if theme.paints_grounds() && self.style == PetStyle::Color {
Tier::Hero
} else {
Tier::Terminal
})
})
.flatten();
[pet, self.agents.next_frame_in(agents, theme)]
@@ -318,6 +320,8 @@ pub struct PetMode<'a> {
pub title: Cow<'a, str>,
pub status: StatusMark,
pub dot_whale: Option<DotWhale<'a>>,
pub style: PetStyle,
pub cove: bool,
/// The host's existing notice, using its semantic severity ink.
pub notice: Option<Line<'a>>,
pub agents: &'a [Subagent<'a>],
@@ -337,6 +341,8 @@ impl<'a> PetMode<'a> {
title: "Codewhale".into(),
status,
dot_whale: None,
style: PetStyle::Color,
cove: true,
notice: None,
agents: &[],
agent_words: SubagentViewWords::default(),
@@ -353,6 +359,7 @@ impl StatefulWidget for PetMode<'_> {
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let area = area.intersection(buf.area);
state.areas = PetModeAreas::default();
state.style = self.style;
if !state.visible || area.is_empty() {
return;
}
@@ -419,6 +426,8 @@ impl StatefulWidget for PetMode<'_> {
} else {
WhalePet::new(self.theme)
.words("")
.style(self.style)
.cove(self.cove)
.paint(plan.pet, buf, &mut state.stage);
}
}