feat(tui): remember pet mode and copy finished replies

Persist /pet on|off through Settings and the existing ordered background
writer. Restore the main pet backdrop without the launch overlay. The
Appearance setting reports saved and effective values separately; session
only overrides and rejected values leave the startup choice alone.

The inspector's c key and painted footer target invoke /copy, retaining its
completed-answer receipt, raw Markdown, recovery backup and queued-vs-accepted
clipboard wording. A bounded command receipt remains visible over Done.
Hidden, resized and truncated footer targets are cleared before input.
All three new labels are translated in the fifteen complete locale packs.

Validation, final source:
- Pet: 60 passed, 0 failed (restart/order/shutdown, malformed settings, drafts,
  real keyboard/pointer routes, copy authority and dark/light renderer).
- Ordered writer: 9 passed; shell routing: 17 passed; /copy: 14 passed;
  toast lifecycle: 6 passed; localized settings schema: 1 passed. No failures.
- Config schema: 4 passed; localization: 54 passed. Total: 165 passed, 0 failed.
- npm test: 1,317 passed, 7 skipped, 0 failed (545 Node + 772 Vitest).
- npm run check:web passed. Changed Rust formatting: 17 files passed.
- Production TUI Clippy passed with -D warnings and the CI's existing
  uninlined_format_args, too_many_arguments and unnecessary_map_or allowances.

Earlier local receipts retained: a test import used a retired private command
path (E0433), then the first compiled run passed 58 and failed 1 because its
unknown-key fixture assumed preservation beyond the existing serializer's
contract. Tests now use the public command entry and supported preference
annotations; malformed TOML remains byte-preserved. Final checks above pass.

Stage only 35 declared paths. Preserve unrelated shared documentation/web/
avatar changes, including the two avatar guards in pet_watch/mod.rs. The
shared library remains the merged #25 component; no new execution authority.
This source slice updates #6920. Actual 0.10.2 candidate #6907 integration,
local installed binary and live terminal/provider acceptance remain separate.

Refs SHA-6533
This commit is contained in:
CodeWhale Bot committed 2026-10-08 13:01:59 -07:00
1 parent 4eebe540c6
commit fdae4dd1a0
35 files changed
+580 -25

No files matched your search

+3 -1
View File
@@ -13,7 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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. Motion preferences and unknown usage stay truthful (#6920),
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)).
This Unreleased feature is separate from the 0.10.2 candidate in #6907.
+11
View File
@@ -545,6 +545,17 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"off",
None,
),
def(
"pet_mode",
SettingKind::Bool(ON_OFF),
"false",
ui(
TAB_APPEARANCE,
"display",
"ConfigLabelPetMode",
"ConfigHintPetMode",
),
),
def(
"calm_mode",
SettingKind::Bool(ON_OFF),
+3
View File
@@ -25,6 +25,9 @@
"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": "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",
+3
View File
@@ -25,6 +25,9 @@
"PetWatchSoundPaused": "Ton pausiert",
"PetWatchSoundUnavailable": "Die Tonausgabe wurde gestoppt. Prüfe FFmpeg (ffplay) und deinen Audioausgang und schalte den Ton erneut ein.",
"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",
+3
View File
@@ -25,6 +25,9 @@
"PetWatchSoundPaused": "Sound paused",
"PetWatchSoundUnavailable": "Pet sound stopped. Check FFmpeg (ffplay) and your audio output, then enable sound again.",
"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",
+3
View File
@@ -25,6 +25,9 @@
"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": "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",
+3
View File
@@ -25,6 +25,9 @@
"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": "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",
+3
View File
@@ -25,6 +25,9 @@
"PetWatchSoundPaused": "ध्वनि रुकी हुई",
"PetWatchSoundUnavailable": "Codewhale की ध्वनि रुक गई। FFmpeg (ffplay) और ऑडियो आउटपुट की जाँच करें, फिर ध्वनि फिर से चालू करें।",
"CmdPetDescription": "Codewhale के मुख्य दृश्य में एनिमेटेड पेट इस्तेमाल करें",
"ConfigLabelPetMode": "पेट मोड",
"ConfigHintPetMode": "Codewhale शुरू होने पर एनिमेटेड पेट को मुख्य दृश्य के रूप में दिखाएँ।",
"PetModeCopyHint": "{copy} पूरा उत्तर कॉपी करें",
"PetModeOn": "पेट मोड चालू है। संदेश बॉक्स में लिखें; /pet inspect से उत्तर और एजेंट खोलें। /pet off से सामान्य दृश्य पर लौटें।",
"PetModeOff": "पालतू मोड बंद। पालतू अपने साथी में रहता है; /pet से फिर चालू करें।",
"PetModeReplyTitle": "उत्तर",
+3
View File
@@ -25,6 +25,9 @@
"PetWatchSoundPaused": "Suara dijeda",
"PetWatchSoundUnavailable": "Suara Codewhale berhenti. Periksa FFmpeg (ffplay) dan keluaran audio, lalu aktifkan suara kembali.",
"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",
+3
View File
@@ -25,6 +25,9 @@
"PetWatchSoundPaused": "サウンド一時停止",
"PetWatchSoundUnavailable": "Codewhaleのサウンドが停止しました。FFmpeg (ffplay) とオーディオ出力を確認し、サウンドを再度有効にしてください。",
"CmdPetDescription": "動くペットをCodewhaleのメイン画面にする",
"ConfigLabelPetMode": "ペットモード",
"ConfigHintPetMode": "Codewhale の起動時にアニメーションするペットをメイン画面に表示します。",
"PetModeCopyHint": "{copy} 完了した返信をコピー",
"PetModeOn": "ペットモードを有効にしました。入力欄からメッセージを送れます。/pet inspect で返信とエージェントを表示し、/pet off で通常表示に戻ります。",
"PetModeOff": "ペットモード オフ。ペットはコンパニオンで生き続けます。/pet で再びオンにします。",
"PetModeReplyTitle": "返信",
+3
View File
@@ -25,6 +25,9 @@
"PetWatchSoundPaused": "소리 일시 중지",
"PetWatchSoundUnavailable": "Codewhale 소리가 멈췄습니다. FFmpeg (ffplay)와 오디오 출력을 확인한 후 소리를 다시 켜세요.",
"CmdPetDescription": "움직이는 펫을 Codewhale의 기본 화면으로 사용",
"ConfigLabelPetMode": "펫 모드",
"ConfigHintPetMode": "Codewhale를 시작할 때 애니메이션 펫을 기본 화면으로 표시합니다.",
"PetModeCopyHint": "{copy} 완료된 답변 복사",
"PetModeOn": "펫 모드를 켰습니다. 메시지 입력란에 작성하세요. /pet inspect로 답변과 에이전트를 열고 /pet off로 기본 보기로 돌아갑니다.",
"PetModeOff": "펫 모드 꺼짐. 펫은 컴패니언에서 계속 살아갑니다. /pet 으로 다시 켭니다.",
"PetModeReplyTitle": "답변",
+3
View File
@@ -25,6 +25,9 @@
"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": "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",
+3
View File
@@ -25,6 +25,9 @@
"PetWatchSoundPaused": "Звук приостановлен",
"PetWatchSoundUnavailable": "Звук Codewhale остановлен. Проверьте FFmpeg (ffplay) и аудиовыход, затем снова включите звук.",
"CmdPetDescription": "Сделать анимированного питомца главным экраном Codewhale",
"ConfigLabelPetMode": "Режим питомца",
"ConfigHintPetMode": "Показывать анимированного питомца на главном экране при запуске Codewhale.",
"PetModeCopyHint": "{copy} копировать готовый ответ",
"PetModeOn": "Режим питомца включён. Пишите в поле сообщения; /pet inspect открывает ответы и агентов. /pet off возвращает обычный вид.",
"PetModeOff": "Режим питомца выключен. Питомец продолжает жить в компаньоне; /pet включает его снова.",
"PetModeReplyTitle": "Ответ",
+3
View File
@@ -25,6 +25,9 @@
"PetWatchSoundPaused": "Звук призупинено",
"PetWatchSoundUnavailable": "Звук Codewhale зупинено. Перевірте FFmpeg (ffplay) та аудіовихід, а потім знову ввімкніть звук.",
"CmdPetDescription": "Зробити анімованого улюбленця головним екраном Codewhale",
"ConfigLabelPetMode": "Режим улюбленця",
"ConfigHintPetMode": "Показувати анімованого улюбленця на головному екрані під час запуску Codewhale.",
"PetModeCopyHint": "{copy} копіювати готову відповідь",
"PetModeOn": "Режим улюбленця ввімкнено. Пишіть у полі повідомлення; /pet inspect відкриває відповіді й агентів. /pet off повертає звичайний вигляд.",
"PetModeOff": "Режим улюбленця вимкнено. Улюбленець далі живе в компаньйоні; /pet вмикає його знову.",
"PetModeReplyTitle": "Відповідь",
+3
View File
@@ -25,6 +25,9 @@
"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": "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",
+3
View File
@@ -25,6 +25,9 @@
"PetWatchSoundPaused": "声音已暂停",
"PetWatchSoundUnavailable": "Codewhale 声音已停止。请检查 FFmpeg (ffplay) 和音频输出,然后重新开启声音。",
"CmdPetDescription": "将动画宠物设为 Codewhale 主视图",
"ConfigLabelPetMode": "宠物模式",
"ConfigHintPetMode": "启动 Codewhale 时,将动画宠物设为主界面。",
"PetModeCopyHint": "{copy} 复制已完成的回复",
"PetModeOn": "宠物模式已开启。在消息框中输入;/pet inspect 查看回复与智能体,/pet off 恢复标准视图。",
"PetModeOff": "宠物模式已关闭。宠物继续住在伴侣程序里;/pet 重新开启。",
"PetModeReplyTitle": "回复",
+3
View File
@@ -25,6 +25,9 @@
"PetWatchSoundPaused": "聲音已暫停",
"PetWatchSoundUnavailable": "Codewhale 聲音已停止。請檢查 FFmpeg (ffplay) 和音訊輸出,然後重新開啟聲音。",
"CmdPetDescription": "將動畫寵物設為 Codewhale 主檢視",
"ConfigLabelPetMode": "寵物模式",
"ConfigHintPetMode": "啟動 Codewhale 時,將動畫寵物設為主介面。",
"PetModeCopyHint": "{copy} 複製已完成的回覆",
"PetModeOn": "寵物模式已開啟。在訊息框中輸入;/pet inspect 查看回覆與代理程式,/pet off 恢復標準檢視。",
"PetModeOff": "寵物模式已關閉。寵物繼續住在伴侶程式裡;/pet 重新開啟。",
"PetModeReplyTitle": "回覆",
+6
View File
@@ -860,6 +860,9 @@ pub enum MessageId {
PetWatchSoundPaused,
PetWatchSoundUnavailable,
CmdPetDescription,
ConfigLabelPetMode,
ConfigHintPetMode,
PetModeCopyHint,
PetModeOn,
PetModeOff,
PetModeOnLabel,
@@ -3454,6 +3457,9 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::PetWatchSoundPaused,
MessageId::PetWatchSoundUnavailable,
MessageId::CmdPetDescription,
MessageId::ConfigLabelPetMode,
MessageId::ConfigHintPetMode,
MessageId::PetModeCopyHint,
MessageId::PetModeOn,
MessageId::PetModeOff,
MessageId::PetModeOnLabel,
+3 -1
View File
@@ -13,7 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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. Motion preferences and unknown usage stay truthful (#6920),
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)).
This Unreleased feature is separate from the 0.10.2 candidate in #6907.
+36 -11
View File
@@ -341,6 +341,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult {
Some(if app.auto_compact { "true" } else { "false" }.to_string())
}
"calm_mode" | "calm" => Some(if app.calm_mode { "true" } else { "false" }.to_string()),
"pet_mode" => Some(app.pet_watch.enabled.to_string()),
"low_motion" | "motion" => Some(if app.low_motion { "true" } else { "false" }.to_string()),
"fancy_animations" | "fancy" | "animations" => Some(
if app.fancy_animations {
@@ -704,6 +705,24 @@ pub fn sidebar(app: &mut App, arg: Option<&str>) -> CommandResult {
CommandResult::message(rail_status_message(app))
}
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
@@ -718,17 +737,7 @@ 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))
@@ -1971,6 +1980,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,
+31 -1
View File
@@ -21,7 +21,19 @@ impl RegisterCommand for CopyCmd {
}
fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult {
execute_copy(app)
let result = execute_copy(app);
if let Some(message) = result.message.as_ref() {
use crate::tui::app::{StatusToast, StatusToastLevel};
let level = if result.is_error {
StatusToastLevel::Error
} else {
StatusToastLevel::Info
};
app.push_status_toast_record(
StatusToast::new(message.clone(), level, Some(8_000)).for_command_receipt(),
);
}
result
}
}
@@ -420,4 +432,22 @@ mod tests {
let message = result.message.as_deref().unwrap_or_default();
assert!(message.contains("/export file <path>"), "{message}");
}
#[test]
fn copy_command_failure_surfaces_the_existing_recovery_receipt_over_a_finished_turn() {
let tmp = TempDir::new().unwrap();
let _env_lock = crate::test_support::lock_test_env();
let (_home, _state) = isolate_state_home(tmp.path());
let mut app = test_app();
app.clipboard = ClipboardHandler::unavailable_for_test(false);
add_completed_assistant(&mut app, "recoverable answer");
let result = CopyCmd::execute(&mut app, None);
assert!(result.is_error);
let toast = app
.active_status_toast(crate::tui::underwater::ShellPhase::Done)
.expect("copy failure must stay visible");
assert_eq!(toast.text, result.message.unwrap());
assert_eq!(toast.level, crate::tui::app::StatusToastLevel::Error);
assert_eq!(toast.ttl_ms, Some(8_000));
assert!(toast.text.contains("last-copy.md"));
}
}
+8
View File
@@ -254,6 +254,8 @@ pub struct Settings {
pub(crate) auto_compact_explicit: bool,
/// Reduce status noise and collapse details more aggressively
pub calm_mode: bool,
/// Use the pet as the main view on the next launch.
pub pet_mode: bool,
/// Dense tool-run collapse mode: compact, expanded, or calm.
pub tool_collapse_mode: String,
/// Reduce decorative motion. This must never synthesize model text speed;
@@ -541,6 +543,7 @@ impl Default for Settings {
auto_compact_explicit: false,
// #4095: default presentation is compact/calm; verbose detail is opt-in.
calm_mode: true,
pet_mode: false,
tool_collapse_mode: "compact".to_string(),
low_motion: false,
load_error: None,
@@ -1435,6 +1438,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",
@@ -1504,6 +1508,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") {
@@ -1806,6 +1813,7 @@ impl Settings {
self.auto_compact_threshold_percent
));
lines.push(format!(" calm_mode: {}", self.calm_mode));
lines.push(format!(" pet_mode: {}", self.pet_mode));
lines.push(format!(" tool_collapse: {}", self.tool_collapse_mode));
lines.push(format!(" low_motion: {}", self.low_motion));
lines.push(format!(" fancy_animations: {}", self.fancy_animations));
+1
View File
@@ -3513,6 +3513,7 @@ impl App {
self.tr(match subject {
StartupDefaultSubject::Mode => MessageId::StartupDefaultSubjectMode,
StartupDefaultSubject::Thinking => MessageId::StartupDefaultSubjectThinking,
StartupDefaultSubject::PetMode => MessageId::ConfigLabelPetMode,
})
.into_owned()
})
+5
View File
@@ -1209,6 +1209,11 @@ impl App {
receipt_started_at: None,
tool_evidence: Vec::new(),
};
// Restore presentation only; the visible-frame tick owns companion startup.
app.pet_watch.enabled = settings.pet_mode;
if settings.pet_mode {
app.launch.visible = false;
}
if yolo_compat {
app.notify_yolo_compat_once();
}
+8
View File
@@ -42,6 +42,8 @@ pub struct StatusToast {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StatusToastKind {
Ordinary,
/// An explicit command result stays visible over a completed turn.
CommandReceipt,
ActionRequired,
RedactionGate(RedactionGateNotice),
BehavioralTip(crate::tui::behavioral_tips::BehavioralTip),
@@ -95,6 +97,11 @@ impl StatusToast {
self
}
pub(crate) fn for_command_receipt(mut self) -> Self {
self.kind = StatusToastKind::CommandReceipt;
self
}
pub(crate) fn for_redaction_gate(mut self, notice: RedactionGateNotice) -> Self {
self.kind = StatusToastKind::RedactionGate(notice);
self
@@ -394,6 +401,7 @@ impl App {
let eligible = |toast: &&StatusToast| {
phase != crate::tui::underwater::ShellPhase::Done
|| toast.kind == StatusToastKind::CommandReceipt
|| matches!(
toast.level,
StatusToastLevel::Warning | StatusToastLevel::Error
+121
View File
@@ -8093,3 +8093,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"
);
}
+62 -1
View File
@@ -267,6 +267,12 @@ 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 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() {
@@ -323,7 +329,7 @@ fn render(frame: &mut Frame, area: Rect, app: &mut App, inspect: bool) {
shell_key_routing::binding(Id::PetResultUp).footer_chord,
shell_key_routing::binding(Id::PetResultDown).footer_chord,
);
let pane_hints = if app.pet_watch.full.focus_agents && !agents.is_empty() {
let mut pane_hints = if app.pet_watch.full.focus_agents && !agents.is_empty() {
tr(app.ui_locale, MessageId::PetModeAgentHints)
.replace("{select}", &arrows)
.replace(
@@ -340,6 +346,13 @@ fn render(frame: &mut Frame, area: Rect, app: &mut App, inspect: bool) {
} else {
String::new()
};
if let Some(copy_hint) = &copy_hint {
pane_hints = if pane_hints.is_empty() {
copy_hint.clone()
} else {
format!("{copy_hint} · {pane_hints}")
};
}
let mut view = PetMode::new(&theme, mark);
view.notice = notice;
view.title = "Codewhale".into();
@@ -404,6 +417,15 @@ fn render(frame: &mut Frame, area: Rect, app: &mut App, inspect: bool) {
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))
@@ -702,4 +724,43 @@ mod tests {
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"
);
}
}
+113 -7
View File
@@ -4,23 +4,41 @@
use super::Control;
use crate::tui::{
shell_key_routing::{self, Focus, ShellBindingId as Id},
views::{ModalKind, ModalView, ViewAction, ViewEvent},
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>>>, selection: Arc<Mutex<Option<String>>>) -> Self {
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
@@ -32,6 +50,7 @@ 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)),
@@ -55,6 +74,16 @@ impl ModalView for Habitat {
ViewAction::None
}
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
{
@@ -91,7 +120,7 @@ mod tests {
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());
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());
@@ -111,8 +140,11 @@ 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(), Arc::new(Mutex::new(None))));
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(
@@ -136,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"
);
}
}
+5
View File
@@ -59,6 +59,7 @@ pub struct PetWatch {
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>,
@@ -191,6 +192,9 @@ impl PetWatch {
}
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<()> {
@@ -364,6 +368,7 @@ pub fn open_habitat(app: &mut App) {
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;
+10
View File
@@ -104,6 +104,7 @@ pub enum ShellBindingId {
PetResultEnd,
PetFocusAgents,
PetOpenAgent,
PetCopyReply,
PetSound,
PetBrowser,
PetWindow,
@@ -165,6 +166,9 @@ impl ShellBinding {
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(),
@@ -292,6 +296,12 @@ pub const SHELL_BINDINGS: &[ShellBinding] = &[
footer_chord: "Enter",
focus: FocusScope::PetHabitat,
},
ShellBinding {
id: ShellBindingId::PetCopyReply,
catalog_chord: "c",
footer_chord: "c",
focus: FocusScope::PetHabitat,
},
ShellBinding {
id: ShellBindingId::PetSound,
catalog_chord: "F6",
+21 -2
View File
@@ -1,4 +1,4 @@
//! The single owner for mode and reasoning defaults written back by interactive
//! The single owner for mode, reasoning and pet-view defaults written back by interactive
//! TUI selectors. Provider/model choices persist through `config_persistence`.
//!
//! Before this module there were three unrelated writers for the same
@@ -128,6 +128,8 @@ pub struct StartupDefaults {
/// `settings.reasoning_effort` — normalized for the active route by the
/// caller, because only the caller knows the route.
reasoning_effort: Option<String>,
/// `settings.pet_mode` — the main view a fresh session starts in.
pet_mode: Option<bool>,
}
impl StartupDefaults {
@@ -154,6 +156,15 @@ impl StartupDefaults {
}
}
/// Persist the selected main view without delaying the event loop.
#[must_use]
pub fn pet_mode(enabled: bool) -> Self {
Self {
pet_mode: Some(enabled),
..Self::default()
}
}
#[cfg(test)]
#[must_use]
pub fn with_reasoning_effort(mut self, effort: &str) -> Self {
@@ -163,7 +174,7 @@ impl StartupDefaults {
#[must_use]
pub fn is_empty(&self) -> bool {
self.mode.is_none() && self.reasoning_effort.is_none()
self.mode.is_none() && self.reasoning_effort.is_none() && self.pet_mode.is_none()
}
/// Which user-facing settings this update touches, as typed subjects.
@@ -182,6 +193,9 @@ impl StartupDefaults {
if self.reasoning_effort.is_some() {
subjects.push(StartupDefaultSubject::Thinking);
}
if self.pet_mode.is_some() {
subjects.push(StartupDefaultSubject::PetMode);
}
subjects
}
@@ -205,6 +219,9 @@ impl StartupDefaults {
if let Some(effort) = self.reasoning_effort.as_deref() {
settings.set("reasoning_effort", effort)?;
}
if let Some(enabled) = self.pet_mode {
settings.set("pet_mode", if enabled { "true" } else { "false" })?;
}
Ok(())
})
}
@@ -239,6 +256,8 @@ pub enum StartupDefaultSubject {
Mode,
/// `settings.reasoning_effort`.
Thinking,
/// `settings.pet_mode`.
PetMode,
}
/// One startup-default write that did not land.
+52
View File
@@ -3299,4 +3299,56 @@ mod pet_main_tests {
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);
}
}
}
+1
View File
@@ -900,6 +900,7 @@ fn shell_binding_probe(id: ShellBindingId) -> KeyEvent {
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),
+37
View File
@@ -2447,6 +2447,13 @@ impl ConfigView {
scope: ConfigScope::Saved,
facts: ConfigRowFacts::saved_setting(),
},
ConfigRow {
key: "pet_mode".to_string(),
value: settings.pet_mode.to_string(),
editable: true,
scope: ConfigScope::Saved,
facts: ConfigRowFacts::saved_setting().effective(app.pet_watch.enabled.to_string()),
},
ConfigRow {
key: "calm_mode".to_string(),
value: settings.calm_mode.to_string(),
@@ -9205,6 +9212,36 @@ base_url = "https://api.xiaomimimo.com/v1"
/// Every message key declared by the schema must resolve to a localized
/// string in every shipped locale. `tr_key` returns the key itself when a
/// pack is missing the entry, so this fails fast on a stale binding.
#[test]
fn pet_mode_setting_shows_saved_and_live_choices_and_emits_the_existing_commit_event() {
let _guard = ConfigSettingsEnvGuard::new("pet_mode = false\n");
let mut app = create_test_app();
app.pet_watch.enabled = true;
let mut view = ConfigView::new_for_app(&app);
let row = view
.rows
.iter()
.find(|row| row.key == "pet_mode")
.expect("pet view setting");
assert_eq!(row.value, "false");
assert_eq!(row.facts.effective.as_deref(), Some("true"));
assert!(row.editable);
assert_eq!(row.ui().unwrap().label, "ConfigLabelPetMode");
view.focus_key("pet_mode");
view.start_edit();
let edit = view.editing.as_mut().expect("pet mode editor");
edit.selected_choice = edit
.choices
.as_ref()
.unwrap()
.iter()
.position(|value| value == "true")
.unwrap();
assert!(
matches!(view.handle_choice_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), ViewAction::Emit(ViewEvent::ConfigUpdated { key, value, persist: true }) if key == "pet_mode" && value == "true")
);
}
#[test]
fn settings_schema_message_keys_are_localized() {
let mut keys: Vec<&'static str> = Vec::new();
+1 -1
View File
@@ -550,7 +550,7 @@ owner = "crates/tui/src/voice.rs"
[[feature]]
id = "pet"
name = "Pet"
summary = "/pet on makes the animated pet the main view with the normal message box; /pet inspect opens replies and agents."
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"