Skip to content

Commit 4a4a32a

Browse files
authored
feat(cli,webchat): lobster wild cards — a CLI cousin, moving day, and opt-in sounds (#103412)
* feat(cli,webchat): lobster wild cards - a CLI cousin, moving day, and opt-in sounds The openclaw banner gains a rare day-seeded ASCII lobster (rich TTYs, random tagline mode, never CI). The web pet notices gateway upgrades and arrives carrying a bindle for one load. A new default-off Lobster sounds quick setting adds tiny WebAudio chirps on pokes and pets. The lobster docs page learns about petting, sounds, and this batch's field notes. * chore(webchat): regenerate keyless raw-copy i18n baseline after rebase
1 parent b07ebe3 commit 4a4a32a

73 files changed

Lines changed: 568 additions & 89 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/web/lobster.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ The lobster is a guest, not furniture. It wanders in when it feels like it, stay
2929
- **Hover** to learn its name.
3030
- **Click it** to say hi. It startles, which is rude of you, but it forgives quickly.
3131
- **Click it repeatedly** and you will learn something about lobster patience. Keep going and you will learn something about lobster dignity.
32-
- **Right-click it** to shoo it away for the rest of the page load. It will not take it personally. It will, however, be gone.
33-
- **Watch it when a run finishes.** Lobsters take genuine pride in your completed work.
32+
- **Press and hold** to pet it. There is a heart. Any accumulated grudges are forgotten.
33+
- **Right-click it** to shoo it away for the rest of the page load. It will not take it personally. It will, however, remember.
34+
- **Watch it when a run finishes.** Lobsters take genuine pride in your completed work, and it shows when things went well. When things did not go well, they take that seriously too.
35+
- **Move your cursor around.** You are being watched. Affectionately.
3436

3537
## Turning visits off (or back on)
3638

@@ -40,12 +42,16 @@ The toggle is browser-local and does exactly what it says. Off means never, incl
4042

4143
Reduced-motion users get calm, stationary lobsters automatically.
4244

45+
Next to it sits **Lobster sounds**, which is off by default and stays off until you decide otherwise. Turn it on and the lobster becomes very quietly audible when touched. It is a small sound. It is a good sound.
46+
4347
## The Lobsterdex
4448

4549
Next to the visits toggle lives the **Lobsterdex**: one silhouette for every coloration known to science, filling in with color as each kind visits you for the first time. It lives entirely in your browser. Nothing is tracked, nothing is synced, nothing is gamified. It is just a shelf.
4650

4751
Most slots fill in on their own over time. A few will take considerably longer. One of them glows.
4852

53+
Hover a filled slot and it will tell you who visited first, and when. The Lobsterdex never forgets a first meeting, which is more than can be said for most of us.
54+
4955
## Field notes
5056

5157
Collected observations from people who spend too much time watching their sidebar:
@@ -56,6 +62,12 @@ Collected observations from people who spend too much time watching their sideba
5662
- Some visitors do not come alone.
5763
- Around certain times of the year, lobsters have been observed wearing things.
5864
- There is one day each year when every lobster looks like it walked out of an old logo. Nobody will tell you which day.
65+
- Not every lobster that crosses your sidebar is your lobster. Most of them do not even stop.
66+
- At least one recorded visitor was not a lobster. It maintains that it is.
67+
- People who have used the same browser for a long time report their lobster arrives sooner, stays longer, and waves at them. People who shoo a lot describe a certain distance.
68+
- After a Gateway upgrade, pay attention to what the first visitor is carrying.
69+
- The lobster in the sidebar has a cousin in the terminal. It shows up in the `openclaw` banner on days of its own choosing, and no one has successfully predicted which.
70+
- During very long runs, the lobster stops playing and settles in to wait with you. Solidarity.
5971

6072
## Privacy
6173

src/cli/banner.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,47 @@ describe("emitCliBanner", () => {
133133
expect(hasEmittedCliBanner()).toBe(true);
134134
});
135135

136+
it("adds the ASCII lobster on lobster days for rich random-mode terminals", async () => {
137+
const { emitCliBanner } = await importFreshBannerModule();
138+
setStdoutIsTty(true);
139+
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
140+
141+
emitCliBanner("2026.3.7", {
142+
argv: ["node", "openclaw"],
143+
commit: "abc1234",
144+
env: { LANG: "en_US.UTF-8" },
145+
isTty: true,
146+
mode: "random",
147+
now: () => new Date(2026, 1, 26),
148+
platform: "darwin",
149+
richTty: true,
150+
});
151+
152+
const written = writeSpy.mock.calls.map(([chunk]) => String(chunk)).join("");
153+
expect(written).toContain("( o.o )");
154+
});
155+
156+
it("keeps lobster day out of plain terminals and pinned tagline modes", async () => {
157+
const { emitCliBanner, testing } = await importFreshBannerModule();
158+
setStdoutIsTty(true);
159+
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
160+
const base = {
161+
argv: ["node", "openclaw"],
162+
commit: "abc1234",
163+
env: { LANG: "en_US.UTF-8" },
164+
isTty: true,
165+
now: () => new Date(2026, 1, 26),
166+
platform: "darwin" as const,
167+
};
168+
169+
emitCliBanner("2026.3.7", { ...base, mode: "random", richTty: false });
170+
testing.resetBannerEmittedForTests();
171+
emitCliBanner("2026.3.7", { ...base, mode: "off", richTty: true });
172+
173+
const written = writeSpy.mock.calls.map(([chunk]) => String(chunk)).join("");
174+
expect(written).not.toContain("( o.o )");
175+
});
176+
136177
it("can reset banner emission state for same-module tests", async () => {
137178
const { emitCliBanner, hasEmittedCliBanner, testing } = await importFreshBannerModule();
138179
setStdoutIsTty(true);

src/cli/banner.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
1010
import { resolveCommitHash } from "../infra/git-commit.js";
1111
import { hasRootVersionAlias } from "./argv.js";
1212
import { parseTaglineMode, readCliBannerTaglineMode } from "./banner-config-lite.js";
13+
import { pickCliLobsterArt } from "./lobster-art.js";
1314
import { pickTagline, type TaglineMode, type TaglineOptions } from "./tagline.js";
1415

1516
type BannerOptions = TaglineOptions & {
@@ -92,6 +93,21 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
9293
return `${line1}\n${line2}`;
9394
}
9495

96+
// Rare day-seeded ASCII lobster above the banner: random-tagline mode only,
97+
// rich terminals only, never in CI (see lobster-art.ts for the odds).
98+
function resolveLobsterArt(options: BannerOptions): string | null {
99+
const mode = resolveTaglineMode(options);
100+
if (mode === "off" || mode === "default") {
101+
return null;
102+
}
103+
if (!(options.richTty ?? isRich())) {
104+
return null;
105+
}
106+
const now = options.now ? options.now() : new Date();
107+
const art = pickCliLobsterArt(now, options.env ?? process.env);
108+
return art ? theme.accentDim(art) : null;
109+
}
110+
95111
/** Emit the CLI banner once for interactive, non-JSON, non-version invocations. */
96112
export function emitCliBanner(version: string, options: BannerOptions = {}) {
97113
if (bannerEmitted) {
@@ -109,7 +125,8 @@ export function emitCliBanner(version: string, options: BannerOptions = {}) {
109125
return;
110126
}
111127
const line = formatCliBannerLine(version, options);
112-
process.stdout.write(`\n${line}\n\n`);
128+
const art = resolveLobsterArt(options);
129+
process.stdout.write(`\n${art ? `${art}\n` : ""}${line}\n\n`);
113130
bannerEmitted = true;
114131
}
115132

src/cli/lobster-art.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Lobster-day art is deterministic per calendar day; tests pin dates probed
2+
// against the day hash (2026-01-05 and 2026-02-26 hit, 2026-01-01 misses).
3+
import { describe, expect, it } from "vitest";
4+
import { pickCliLobsterArt } from "./lobster-art.ts";
5+
6+
const cleanEnv = {} as NodeJS.ProcessEnv;
7+
8+
describe("pickCliLobsterArt", () => {
9+
it("is deterministic for a given day", () => {
10+
const day = new Date(2026, 0, 5);
11+
const art = pickCliLobsterArt(day, cleanEnv);
12+
expect(art).toBeTruthy();
13+
expect(pickCliLobsterArt(new Date(2026, 0, 5), cleanEnv)).toBe(art);
14+
});
15+
16+
it("returns null on non-lobster days", () => {
17+
expect(pickCliLobsterArt(new Date(2026, 0, 1), cleanEnv)).toBeNull();
18+
});
19+
20+
it("stays rare across a year of days", () => {
21+
let hits = 0;
22+
for (let offset = 0; offset < 400; offset++) {
23+
if (pickCliLobsterArt(new Date(2026, 0, 1 + offset), cleanEnv)) {
24+
hits++;
25+
}
26+
}
27+
expect(hits).toBeGreaterThan(5);
28+
expect(hits).toBeLessThan(60);
29+
});
30+
31+
it("stays out of CI and test environments", () => {
32+
const day = new Date(2026, 0, 5);
33+
expect(pickCliLobsterArt(day, { CI: "1" } as NodeJS.ProcessEnv)).toBeNull();
34+
expect(pickCliLobsterArt(day, { VITEST: "true" } as NodeJS.ProcessEnv)).toBeNull();
35+
});
36+
});

src/cli/lobster-art.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// The Control UI lobster pet has a CLI cousin: on roughly one day in sixteen
2+
// the interactive banner gains a tiny ASCII lobster. Deterministic per
3+
// calendar day (like the holiday taglines) so every terminal agrees on
4+
// lobster day, and callers can pin the date in tests.
5+
6+
const LOBSTER_ARTS: readonly string[] = [
7+
// Claws up, saying hi.
8+
[" (\\/) (\\/)", " \\_\\ /_/", " ( o.o )", " /|__|\\"].join("\n"),
9+
// Just the eyestalks, watching from below the waterline.
10+
[" o o", " ) (", " ~~~~~~~~~~~"].join("\n"),
11+
] as const;
12+
13+
function hashDay(key: string): number {
14+
let hash = 0x811c9dc5;
15+
for (let i = 0; i < key.length; i++) {
16+
hash ^= key.charCodeAt(i);
17+
hash = Math.imul(hash, 0x01000193);
18+
}
19+
return hash >>> 0;
20+
}
21+
22+
/**
23+
* Return the ASCII lobster for `now`'s calendar day, or null on non-lobster
24+
* days and in CI/test environments (banner tests assert exact bytes).
25+
*/
26+
export function pickCliLobsterArt(now: Date, env: NodeJS.ProcessEnv = process.env): string | null {
27+
if (env.CI || env.VITEST) {
28+
return null;
29+
}
30+
const key = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`;
31+
const hash = hashDay(key);
32+
if (hash % 16 !== 3) {
33+
return null;
34+
}
35+
return LOBSTER_ARTS[(hash >>> 8) % LOBSTER_ARTS.length];
36+
}

ui/src/app/app-host.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,8 @@ class OpenClawShell extends OpenClawLightDomElement {
790790
// The settings sidebar has a fixed width, so the collapse state pauses too.
791791
const navCollapsed = navigationSnapshot.navCollapsed && !navDrawerOpen && !settingsTakeover;
792792
const shellWidth = Math.max(globalThis.innerWidth || 0, NAV_WIDTH_MAX);
793+
// One storage read per render; theme.refresh() re-renders on pref changes.
794+
const uiSettings = loadSettings();
793795
return html`
794796
<openclaw-command-palette
795797
.onNavigate=${(routeId: RouteId) => this.navigate(routeId)}
@@ -874,7 +876,11 @@ class OpenClawShell extends OpenClawLightDomElement {
874876
.sidebarPinnedRoutes=${navigationSnapshot.sidebarPinnedRoutes}
875877
.sidebarMoreExpanded=${navigationSnapshot.sidebarMoreExpanded}
876878
.themeMode=${context.theme.mode}
877-
.lobsterPetVisits=${loadSettings().lobsterPetVisits !== false}
879+
.lobsterPetVisits=${uiSettings.lobsterPetVisits !== false}
880+
.lobsterPetSounds=${uiSettings.lobsterPetSounds === true}
881+
.gatewayVersion=${context.config.current.serverVersion ??
882+
gatewaySnapshot.hello?.server?.version ??
883+
null}
878884
.onToggleMore=${() =>
879885
context.navigation.update({
880886
sidebarMoreExpanded: !context.navigation.snapshot.sidebarMoreExpanded,

ui/src/app/settings.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export type UiSettings = {
115115
customTheme?: ImportedCustomTheme;
116116
locale?: string;
117117
lobsterPetVisits?: boolean; // Whether the sidebar lobster pet drops by (default true)
118+
lobsterPetSounds?: boolean; // Opt-in poke/pet chirps from the lobster (default false)
118119
};
119120

120121
type LastActiveSessionHost = {
@@ -625,6 +626,7 @@ export function loadSettings(): UiSettings {
625626
customTheme: customTheme ?? undefined,
626627
locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined,
627628
...(parsed.lobsterPetVisits === false ? { lobsterPetVisits: false } : {}),
629+
...(parsed.lobsterPetSounds === true ? { lobsterPetSounds: true } : {}),
628630
};
629631
if (source.legacy || "token" in parsed) {
630632
persistSettings(settings, { selectGateway: true });
@@ -713,8 +715,10 @@ function persistSettings(next: UiSettings, options: { selectGateway?: boolean }
713715
...(next.customTheme ? { customTheme: next.customTheme } : {}),
714716
sessionsByGateway,
715717
...(next.locale ? { locale: next.locale } : {}),
716-
// Visits default on; only an explicit opt-out persists.
718+
// Visits default on; only an explicit opt-out persists. Sounds default
719+
// off; only an explicit opt-in persists.
717720
...(next.lobsterPetVisits === false ? { lobsterPetVisits: false } : {}),
721+
...(next.lobsterPetSounds === true ? { lobsterPetSounds: true } : {}),
718722
};
719723
const serialized = JSON.stringify(persisted);
720724
try {

ui/src/components/app-sidebar.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ class AppSidebar extends OpenClawLightDomContentsElement {
178178
@property({ attribute: false }) sidebarMoreExpanded = false;
179179
@property({ attribute: false }) themeMode: ThemeMode = "system";
180180
@property({ attribute: false }) lobsterPetVisits = true;
181+
@property({ attribute: false }) lobsterPetSounds = false;
182+
@property({ attribute: false }) gatewayVersion: string | null = null;
181183
@property({ attribute: false }) onToggleMore?: () => void;
182184
@property({ attribute: false }) onUpdatePinnedRoutes?: (routes: SidebarNavRoute[]) => void;
183185
@property({ attribute: false }) onPairMobile?: () => void;
@@ -1647,6 +1649,8 @@ class AppSidebar extends OpenClawLightDomContentsElement {
16471649
.mode=${resolveLobsterPetMode(this.connected, this.sessionsResult?.sessions)}
16481650
.runOutcome=${resolveLobsterRunOutcome(this.sessionsResult?.sessions)}
16491651
.visitsEnabled=${this.lobsterPetVisits}
1652+
.soundsEnabled=${this.lobsterPetSounds}
1653+
.gatewayVersion=${this.gatewayVersion}
16501654
></openclaw-lobster-pet>
16511655
</div>
16521656
</div>
@@ -1679,6 +1683,8 @@ class AppSidebar extends OpenClawLightDomContentsElement {
16791683
.mode=${resolveLobsterPetMode(this.connected, this.sessionsResult?.sessions)}
16801684
.runOutcome=${resolveLobsterRunOutcome(this.sessionsResult?.sessions)}
16811685
.visitsEnabled=${this.lobsterPetVisits}
1686+
.soundsEnabled=${this.lobsterPetSounds}
1687+
.gatewayVersion=${this.gatewayVersion}
16821688
></openclaw-lobster-pet>
16831689
<div class="sidebar-footer-bar">
16841690
<openclaw-tooltip .content=${gatewayStatus}>

ui/src/components/lobster-pet.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,73 @@ describe("lobster pet element", () => {
742742
expect(passers).toBeLessThan(2000 * 0.2);
743743
});
744744

745+
it("carries a bindle on the first load after a gateway upgrade", async () => {
746+
vi.useFakeTimers();
747+
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
748+
vi.stubGlobal("localStorage", window.localStorage);
749+
localStorage.setItem("openclaw.control.lobsterpet.gatewayVersion.v1", "2026.6.1");
750+
const element = createPet(42);
751+
element.gatewayVersion = "2026.7.1";
752+
await arrive(element);
753+
754+
expect(element.querySelector(".lob-bindle")).not.toBeNull();
755+
expect(element.querySelector(".lobster-pet")?.getAttribute("title")).toContain("just moved in");
756+
expect(localStorage.getItem("openclaw.control.lobsterpet.gatewayVersion.v1")).toBe("2026.7.1");
757+
});
758+
759+
it("travels light on first sighting and on same-version reloads", async () => {
760+
vi.useFakeTimers();
761+
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
762+
vi.stubGlobal("localStorage", window.localStorage);
763+
// First version ever seen: record a baseline, no bindle.
764+
const first = createPet(42);
765+
first.gatewayVersion = "2026.7.1";
766+
await arrive(first);
767+
expect(first.querySelector(".lob-bindle")).toBeNull();
768+
expect(localStorage.getItem("openclaw.control.lobsterpet.gatewayVersion.v1")).toBe("2026.7.1");
769+
first.remove();
770+
771+
// Same version on the next load: still no bindle.
772+
const second = createPet(42);
773+
second.gatewayVersion = "2026.7.1";
774+
await arrive(second);
775+
expect(second.querySelector(".lob-bindle")).toBeNull();
776+
});
777+
778+
it("stays silent by default and chirps only when sounds are enabled", async () => {
779+
vi.useFakeTimers();
780+
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
781+
const audioContextCtor = vi.fn(() => {
782+
const param = () => ({ setValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn() });
783+
return {
784+
state: "running",
785+
currentTime: 0,
786+
destination: {},
787+
resume: vi.fn(),
788+
close: vi.fn(() => Promise.resolve()),
789+
createOscillator: vi.fn(() => ({
790+
type: "sine",
791+
frequency: param(),
792+
connect: (node: unknown) => node,
793+
start: vi.fn(),
794+
stop: vi.fn(),
795+
})),
796+
createGain: vi.fn(() => ({ gain: param(), connect: vi.fn() })),
797+
};
798+
});
799+
vi.stubGlobal("AudioContext", audioContextCtor);
800+
const element = createPet(42);
801+
await arrive(element);
802+
803+
poke(element);
804+
expect(audioContextCtor).not.toHaveBeenCalled();
805+
806+
element.soundsEnabled = true;
807+
await element.updateComplete;
808+
poke(element);
809+
expect(audioContextCtor).toHaveBeenCalledTimes(1);
810+
});
811+
745812
it("stays static when reduced motion is preferred, including visibility resumes", async () => {
746813
vi.useFakeTimers();
747814
vi.stubGlobal(

0 commit comments

Comments
 (0)