fix(tui): the pet tank paints a resting whale and says when it is offline

In a terminal without Kitty graphics and no pet companion, /pet painted
an empty tank captioned " · unobserved · Sound off", starting with an
orphan separator, and pet mode still took over the screen for a running
turn.

- With no companion frame the tank paints the launch screen's braille
  whale (largest rung that fits) above its caption. Once the companion
  has said it cannot be reached, the caption is "offline — codewhale pet
  serve wakes it"; before that the whale rests.
- The caption joins only non-empty parts, so no leading " · ".
- "unobserved" reads "resting" (presence vocabulary), in the habitat and
  in the cameo widget; the reconnect notice drops the word.
- Pet mode does not auto-enter for a turn while the companion is
  unavailable, and if the companion drops while a turn is running the
  habitat closes so the transcript stays in view.

New message PetOffline in every locale pack (translated), keeping parity.

Checks: cargo test -p codewhale-tui --lib tui::pet_watch tui::ambient_life
(within the 2551-pass targeted run; no pet or ambient failures), including
the new unavailable_companion_paints_the_resting_whale_and_keeps_the_turn_
visible; cargo test -p codewhale-localization 50 passed; staged locale
key parity checked across all 15 packs.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01TvKGZz4LrKfrQiAXip48JS
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 20:30:04 -07:00
1 parent 6e7410be3b
commit afa53c84d1
20 files changed
+144 -25

No files matched your search

+1
View File
@@ -20,6 +20,7 @@
"PetWatchExportQueued": "S’està desant la reproducció de Watch…",
"PetWatchExportUnavailable": "Obre /pet en una sessió desada o espera que acabi l’exportació actual.",
"PetUnobserved": "sense observació",
"PetOffline": "fora de línia — codewhale pet serve el desperta",
"PetDozing": "endormiscat",
"PetWatchUnavailable": "La telemetria de la mascota està en pausa. /pet on ho torna a intentar.",
"SessionArchiveExported": "Sessió exportada",
+1
View File
@@ -20,6 +20,7 @@
"PetWatchExportQueued": "Watch-Aufzeichnung wird gespeichert…",
"PetWatchExportUnavailable": "Öffne /pet in einer gespeicherten Sitzung oder warte auf den laufenden Export.",
"PetUnobserved": "unbeobachtet",
"PetOffline": "offline — codewhale pet serve weckt ihn",
"PetDozing": "dösend",
"PetWatchUnavailable": "Die Haustier-Telemetrie pausiert. /pet on versucht es erneut.",
"SessionArchiveExported": "Sitzung exportiert",
+2 -1
View File
@@ -19,7 +19,8 @@
"PetWatchExportFailed": "Watch replay could not be saved. The recording remains in memory.",
"PetWatchExportQueued": "Saving Watch replay…",
"PetWatchExportUnavailable": "Open /pet in a saved session, or wait for the current export.",
"PetUnobserved": "unobserved",
"PetUnobserved": "resting",
"PetOffline": "offline — codewhale pet serve wakes it",
"PetDozing": "dozing",
"PetWatchUnavailable": "Pet telemetry paused. /pet on retries.",
"SessionArchiveExported": "Exported session",
+1
View File
@@ -20,6 +20,7 @@
"PetWatchExportQueued": "Guardando reproducción de Watch…",
"PetWatchExportUnavailable": "Abre /pet en una sesión guardada o espera a que termine la exportación actual.",
"PetUnobserved": "sin observación",
"PetOffline": "sin conexión — codewhale pet serve lo despierta",
"PetDozing": "dormitando",
"PetWatchUnavailable": "La telemetría de la mascota está en pausa. /pet on reintenta.",
"SessionArchiveExported": "Sesión exportada",
+1
View File
@@ -20,6 +20,7 @@
"PetWatchExportQueued": "Sauvegarde de l’enregistrement Watch…",
"PetWatchExportUnavailable": "Ouvrez /pet dans une session enregistrée ou attendez la fin de l’exportation en cours.",
"PetUnobserved": "non observé",
"PetOffline": "hors ligne — codewhale pet serve le réveille",
"PetDozing": "assoupi",
"PetWatchUnavailable": "La télémétrie de la mascotte est en pause. /pet on réessaie.",
"SessionArchiveExported": "Session exportée",
+1
View File
@@ -20,6 +20,7 @@
"PetWatchExportQueued": "Watch रिकॉर्डिंग सहेजी जा रही है…",
"PetWatchExportUnavailable": "सहेजे गए सत्र में /pet खोलें या मौजूदा निर्यात पूरा होने की प्रतीक्षा करें।",
"PetUnobserved": "अवलोकन नहीं हुआ",
"PetOffline": "ऑफ़लाइन — codewhale pet serve इसे जगाता है",
"PetDozing": "ऊँघ रहा है",
"PetWatchUnavailable": "पालतू की टेलीमेट्री रुकी हुई है। /pet on से फिर कोशिश करें।",
"SessionArchiveExported": "सत्र निर्यात किया गया",
+1
View File
@@ -20,6 +20,7 @@
"PetWatchExportQueued": "Menyimpan rekaman Watch…",
"PetWatchExportUnavailable": "Buka /pet dalam sesi tersimpan atau tunggu ekspor saat ini selesai.",
"PetUnobserved": "belum teramati",
"PetOffline": "luring — codewhale pet serve membangunkannya",
"PetDozing": "terlelap",
"PetWatchUnavailable": "Telemetri hewan peliharaan dijeda. /pet on mencoba lagi.",
"SessionArchiveExported": "Sesi diekspor",
+1
View File
@@ -20,6 +20,7 @@
"PetWatchExportQueued": "Watch の記録を保存中…",
"PetWatchExportUnavailable": "保存済みのセッションで /pet を開くか、現在のエクスポートが完了するまでお待ちください。",
"PetUnobserved": "未観測",
"PetOffline": "オフライン — codewhale pet serve で起こせます",
"PetDozing": "うたた寝",
"PetWatchUnavailable": "ペットの計測が一時停止しました。/pet on で再試行します。",
"SessionArchiveExported": "セッションをエクスポートしました",
+1
View File
@@ -20,6 +20,7 @@
"PetWatchExportQueued": "Watch 기록 저장 중…",
"PetWatchExportUnavailable": "저장된 세션에서 /pet를 열거나 현재 내보내기가 끝날 때까지 기다리세요.",
"PetUnobserved": "미관측",
"PetOffline": "오프라인 — codewhale pet serve로 깨울 수 있어요",
"PetDozing": "졸고 있음",
"PetWatchUnavailable": "펫 원격 측정이 일시 중지되었습니다. /pet on 으로 재시도합니다.",
"SessionArchiveExported": "세션을 내보냈습니다",
+1
View File
@@ -20,6 +20,7 @@
"PetWatchExportQueued": "Salvando gravação do Watch…",
"PetWatchExportUnavailable": "Abra o /pet em uma sessão salva ou aguarde a exportação atual.",
"PetUnobserved": "sem observação",
"PetOffline": "offline — codewhale pet serve o acorda",
"PetDozing": "cochilando",
"PetWatchUnavailable": "A telemetria do pet foi pausada. /pet on tenta novamente.",
"SessionArchiveExported": "Sessão exportada",
+1
View File
@@ -20,6 +20,7 @@
"PetWatchExportQueued": "Сохранение записи Watch…",
"PetWatchExportUnavailable": "Откройте /pet в сохранённой сессии или дождитесь завершения текущего экспорта.",
"PetUnobserved": "нет наблюдений",
"PetOffline": "не в сети — codewhale pet serve разбудит его",
"PetDozing": "дремлет",
"PetWatchUnavailable": "Телеметрия питомца приостановлена. /pet on повторит попытку.",
"SessionArchiveExported": "Сеанс экспортирован",
+1
View File
@@ -20,6 +20,7 @@
"PetWatchExportQueued": "Збереження запису Watch…",
"PetWatchExportUnavailable": "Відкрийте /pet у збереженій сесії або дочекайтеся завершення поточного експорту.",
"PetUnobserved": "немає спостережень",
"PetOffline": "не в мережі — codewhale pet serve розбудить його",
"PetDozing": "дрімає",
"PetWatchUnavailable": "Телеметрію улюбленця призупинено. /pet on повторить спробу.",
"SessionArchiveExported": "Сеанс експортовано",
+1
View File
@@ -20,6 +20,7 @@
"PetWatchExportQueued": "Đang lưu bản ghi Watch…",
"PetWatchExportUnavailable": "Mở /pet trong một phiên đã lưu hoặc đợi quá trình xuất hiện tại hoàn tất.",
"PetUnobserved": "chưa quan sát",
"PetOffline": "ngoại tuyến — codewhale pet serve sẽ đánh thức nó",
"PetDozing": "ngủ gật",
"PetWatchUnavailable": "Đã tạm dừng dữ liệu thú cưng. /pet on sẽ thử lại.",
"SessionArchiveExported": "Đã xuất phiên",
+1
View File
@@ -20,6 +20,7 @@
"PetWatchExportQueued": "正在保存 Watch 回放…",
"PetWatchExportUnavailable": "请在已保存的会话中打开 /pet,或等待当前导出完成。",
"PetUnobserved": "未观测",
"PetOffline": "离线 — 运行 codewhale pet serve 唤醒它",
"PetDozing": "打盹",
"PetWatchUnavailable": "宠物遥测已暂停。/pet on 重试。",
"SessionArchiveExported": "会话已导出",
+1
View File
@@ -20,6 +20,7 @@
"PetWatchExportQueued": "正在儲存 Watch 重播…",
"PetWatchExportUnavailable": "請在已儲存的工作階段中開啟 /pet,或等待目前的匯出完成。",
"PetUnobserved": "未觀測",
"PetOffline": "離線 — 執行 codewhale pet serve 喚醒它",
"PetDozing": "打盹",
"PetWatchUnavailable": "寵物遙測已暫停。/pet on 重試。",
"SessionArchiveExported": "工作階段已匯出",
+2
View File
@@ -783,6 +783,7 @@ pub enum MessageId {
CmdStructcopyReceiptTooLarge,
CmdFleetDescription,
PetUnobserved,
PetOffline,
PetDozing,
PetWatchUnavailable,
PetWatchRestored,
@@ -3123,6 +3124,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CmdStructcopyReceiptTooLarge,
MessageId::CmdFleetDescription,
MessageId::PetUnobserved,
MessageId::PetOffline,
MessageId::PetDozing,
MessageId::PetWatchUnavailable,
MessageId::PetWatchRestored,
@@ -22,7 +22,7 @@ impl Widget for PetWidget<'_> {
"{} · {}{}",
frame.channel,
frame.arch,
if frame.hollow { " · unobserved" } else { "" }
if frame.hollow { " · resting" } else { "" }
);
// The creature and its non-colour cue are one unit. A narrow surface
// withholds both instead of silently dropping uncertainty or the gait.
+1 -1
View File
@@ -162,7 +162,7 @@ fn pet_widget_keeps_unknown_distinct_from_sleep_and_keeps_its_label() {
}
.render(buf.area, &mut buf);
let text: String = buf.content.iter().map(|cell| cell.symbol()).collect();
assert!(text.contains("other · drift · unobserved"));
assert!(text.contains("other · drift · resting"));
let mut narrow = Buffer::empty(Rect::new(0, 0, 18, 6));
let before = narrow.clone();
pet_widget::PetWidget {
+1 -3
View File
@@ -422,9 +422,7 @@ fn run(
producer_seq = None;
if last_failure.elapsed() > Duration::from_secs(3) {
last_failure = Instant::now();
let _ = notices.try_send(Notice::Message(
"Shared pet reconnecting · unobserved".into(),
));
let _ = notices.try_send(Notice::Message("Shared pet reconnecting".into()));
if let Ok(next) = Client::connect() {
client = next;
}
+123 -19
View File
@@ -48,6 +48,8 @@ pub struct PetWatch {
session: Option<String>,
last_tick: Option<Instant>,
failed: bool,
/// The companion said it cannot be reached. Cleared by the next frame.
unavailable: bool,
exporting: bool,
sound_requested: bool,
pub(crate) area: Option<Rect>,
@@ -88,6 +90,7 @@ impl PetWatch {
self.session = session;
self.raster = None;
self.failed = false;
self.unavailable = false;
self.last_tick = None;
self.work_enter_pending = false;
self.work_complete = false;
@@ -331,7 +334,11 @@ pub fn tick(app: &mut App, now: Instant) {
&& app.onboarding == crate::tui::app::OnboardingState::None
{
app.pet_watch.work_enter_pending = false;
open_habitat(app);
// Pet mode never hides a running turn behind a companion that is
// not there; the transcript stays in view until it answers again.
if !app.pet_watch.unavailable {
open_habitat(app);
}
}
// The habitat is the pet's only terminal view: it owns the whole content
// viewport or nothing. Reduced motion follows the shell's motion setting.
@@ -372,6 +379,7 @@ pub fn tick(app: &mut App, now: Instant) {
state.sound_requested = false;
}
state.raster = Some(update);
state.unavailable = false;
if visible {
app.needs_redraw = true;
}
@@ -432,13 +440,19 @@ pub fn tick(app: &mut App, now: Instant) {
.replace("{path}", &path.display().to_string()),
StatusToastLevel::Info,
),
Notice::Message(message) => (
format!(
"{} · {message}",
tr(app.ui_locale, MessageId::PetWatchUnavailable)
),
StatusToastLevel::Warning,
),
Notice::Message(message) => {
app.pet_watch.unavailable = true;
if app.is_loading && is_open(app) {
app.view_stack.pop();
}
(
format!(
"{} · {message}",
tr(app.ui_locale, MessageId::PetWatchUnavailable)
),
StatusToastLevel::Warning,
)
}
};
app.add_message(crate::tui::history::HistoryCell::System {
content: text.clone(),
@@ -451,7 +465,7 @@ fn render_tank(frame: &mut Frame, area: Rect, app: &mut App) {
app.pet_watch.area = Some(area);
let raster = app.pet_watch.raster.as_ref();
let hollow = raster.is_none_or(|r| !r.scene.producer_connected || r.scene.style.hollow);
let mut label = raster
let scene = raster
.map(|r| {
let mut text = format!(
"{} · {} · {}",
@@ -473,16 +487,24 @@ fn render_tank(frame: &mut Frame, area: Rect, app: &mut App) {
text
})
.unwrap_or_default();
if hollow {
label.push_str(&format!(
" · {}",
tr(app.ui_locale, MessageId::PetUnobserved)
));
}
label.push_str(&format!(
" · {}",
tr(app.ui_locale, app.pet_watch.sound_label())
));
// With no companion frame the tank still paints the resting whale and
// says why, instead of a blank tank under an orphan separator.
let label = if raster.is_none() && app.pet_watch.unavailable {
tr(app.ui_locale, MessageId::PetOffline).into_owned()
} else {
let presence = hollow.then(|| tr(app.ui_locale, MessageId::PetUnobserved));
let sound = tr(app.ui_locale, app.pet_watch.sound_label());
[
Some(scene.as_str()),
presence.as_deref(),
Some(sound.as_ref()),
]
.into_iter()
.flatten()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" · ")
};
let image = (app.view_stack.is_empty()
|| app.view_stack.top_kind() == Some(ModalKind::PetHabitat))
&& raster.is_some_and(|r| {
@@ -529,6 +551,40 @@ fn render_tank(frame: &mut Frame, area: Rect, app: &mut App) {
&label,
Style::default().fg(ink),
);
if raster.is_none() {
paint_resting_whale(frame, area, Style::default().fg(ink));
}
}
}
/// The launch screen's braille whale, centred in the tank above its caption,
/// at the largest rung that fits. Static: it rests until the companion's own
/// frames take over the tank.
fn paint_resting_whale(frame: &mut Frame, area: Rect, style: Style) {
use crate::tui::mark::MarkSize;
let tank_height = area.height.saturating_sub(1);
let Some(size) = [MarkSize::Large, MarkSize::Small, MarkSize::Tiny]
.into_iter()
.find(|size| {
let (cols, rows) = size.cells();
cols <= area.width && rows <= tank_height
})
else {
return;
};
let (cols, rows) = size.cells();
let x0 = area.x + (area.width - cols) / 2;
let y0 = area.y + (tank_height - rows) / 2;
let buf = frame.buffer_mut();
for (dy, row) in size.rows().iter().enumerate() {
for (dx, ch) in row.chars().enumerate() {
if ch == ' ' {
continue;
}
if let Some(cell) = buf.cell_mut((x0 + dx as u16, y0 + dy as u16)) {
cell.set_char(ch).set_style(style);
}
}
}
}
pub fn render_full(frame: &mut Frame, app: &mut App) {
@@ -675,6 +731,54 @@ mod tests {
assert!(app.pet_watch.worker.is_none());
}
#[test]
fn unavailable_companion_paints_the_resting_whale_and_keeps_the_turn_visible() {
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
app.onboarding = crate::tui::app::OnboardingState::None;
app.redaction_gate = false;
app.pet_watch.session = app.current_session_id.clone();
app.pet_watch.detach_for_test();
app.pet_watch.unavailable = true;
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 16)).unwrap();
terminal
.draw(|frame| render_tank(frame, frame.area(), &mut app))
.unwrap();
let text = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(
text.contains(crate::tui::mark::MarkSize::Large.rows()[3].trim()),
"the tank paints the resting whale: {text}"
);
assert!(
text.contains("offline — codewhale pet serve wakes it"),
"{text}"
);
assert!(!text.contains(" · offline"), "no orphan separator: {text}");
app.pet_watch.enabled = true;
observe(
&mut app,
&Event::TurnStarted {
turn_id: "turn".into(),
created_at: chrono::Utc::now(),
route: None,
},
Instant::now(),
);
tick(&mut app, Instant::now());
assert!(
app.view_stack.is_empty(),
"pet mode must not cover a turn while the companion is unavailable"
);
}
#[test]
fn pet_off_stops_automatic_entry_and_keeps_the_draft() {
let mut app =