footerball

This commit is contained in:
liruifengv 2026-07-04 11:51:56 +08:00
parent ec758c747a
commit 102da1ebbe
7 changed files with 385 additions and 11 deletions

View file

@ -10,6 +10,7 @@ import type { BtwPanelController } from '../controllers/btw-panel';
import type { StreamingUIController } from '../controllers/streaming-ui';
import type { TasksBrowserController } from '../controllers/tasks-browser';
import { tryHandleDanceCommand } from '../easter-eggs/dance';
import { tryHandleFootballCommand } from '../easter-eggs/football';
import type { ResolvedTheme } from '../theme/colors';
import type { TUIState } from '../tui-state';
import type {
@ -220,12 +221,15 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi
return;
}
case 'message':
// Unknown slash command: let /dance claim it before it falls through to
// the model as a normal message. This runs *after* builtin and skill
// resolution, so a real command or a same-named skill always wins.
// Unknown slash command: let the easter eggs claim it before it falls
// through to the model as a normal message. This runs *after* builtin and
// skill resolution, so a real command or a same-named skill always wins.
if (parsedCommand !== null && tryHandleDanceCommand(host, parsedCommand)) {
return;
}
if (parsedCommand !== null && tryHandleFootballCommand(host, parsedCommand)) {
return;
}
host.sendNormalUserInput(intent.input);
return;
case 'builtin':

View file

@ -4,12 +4,14 @@ import type { TUI } from '@moonshot-ai/pi-tui';
import {
BRAILLE_SPINNER_FRAMES,
BRAILLE_SPINNER_INTERVAL_MS,
FOOTBALL_SPINNER_FRAMES,
FOOTBALL_SPINNER_INTERVAL_MS,
MOON_SPINNER_FRAMES,
MOON_SPINNER_INTERVAL_MS,
} from '#/tui/constant/rendering';
import { currentTheme } from '#/tui/theme';
export type SpinnerStyle = 'moon' | 'braille';
export type SpinnerStyle = 'moon' | 'braille' | 'football';
export class MoonLoader extends Text {
private currentFrame = 0;
@ -37,8 +39,21 @@ export class MoonLoader extends Text {
) {
super('', 1, 0);
this.ui = ui;
this.frames = style === 'moon' ? [...MOON_SPINNER_FRAMES] : [...BRAILLE_SPINNER_FRAMES];
this.interval = style === 'moon' ? MOON_SPINNER_INTERVAL_MS : BRAILLE_SPINNER_INTERVAL_MS;
switch (style) {
case 'football':
this.frames = [...FOOTBALL_SPINNER_FRAMES];
this.interval = FOOTBALL_SPINNER_INTERVAL_MS;
break;
case 'braille':
this.frames = [...BRAILLE_SPINNER_FRAMES];
this.interval = BRAILLE_SPINNER_INTERVAL_MS;
break;
case 'moon':
default:
this.frames = [...MOON_SPINNER_FRAMES];
this.interval = MOON_SPINNER_INTERVAL_MS;
break;
}
this.colorFn = colorFn;
this.label = label;
this.start();

View file

@ -18,3 +18,10 @@ export const BRAILLE_SPINNER_INTERVAL_MS = 80;
export const MOON_SPINNER_FRAMES = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'];
export const MOON_SPINNER_INTERVAL_MS = 120;
// The `/football` easter egg swaps the moon loader for a spinning football.
// A single emoji can't rotate in place, so the ball itself rolls back and forth
// within a fixed-width field — a rolling ball is a spinning ball. Every frame
// stays the same width so the label never jitters.
export const FOOTBALL_SPINNER_FRAMES = ['⚽ ', ' ⚽ ', ' ⚽', ' ⚽ '];
export const FOOTBALL_SPINNER_INTERVAL_MS = 120;

View file

@ -0,0 +1,136 @@
/**
* `/football` easter egg everything it needs lives in this one file: the
* one-shot kick animation, the "loader is now a football" flag, and the
* command handler. Removing the feature is "delete this file + its import
* sites".
*
* Like `/dance`, it is deliberately NOT registered in BUILTIN_SLASH_COMMANDS,
* so it stays out of `/help` and autocomplete; `executeSlashCommand` calls the
* handler as a fallback after builtin/skill resolution, so a real command or a
* same-named skill always wins. `footerball` is accepted as a typo-tolerant
* alias.
*/
import type { Container, TUI } from '@moonshot-ai/pi-tui';
import type { SlashCommandHost } from '../commands/dispatch';
import type { ParsedSlashInput } from '../commands/types';
import { MoonLoader } from '../components/chrome/moon-loader';
import { currentTheme } from '../theme';
/** How long the football spins before settling into the tip line. */
export const FOOTBALL_KICK_MS = 2200;
let footballActive = false;
let currentKick: FootballKick | undefined;
/** Whether the session's moon loader should render as a spinning football. */
export function isFootballActive(): boolean {
return footballActive;
}
export function setFootballActive(active: boolean): void {
footballActive = active;
}
/** The easter-egg tip shown once the kick animation settles. */
export function footballTipLine(): string {
const cmd = (text: string): string => currentTheme.boldFg('primary', text);
return `⚽ Goooal! Easter egg unlocked — the moon loader is now a spinning football. Use ${cmd('/football off')} to revert.`;
}
/**
* A one-shot football animation: a spinning football in the transcript that
* settles into the easter-egg tip after `FOOTBALL_KICK_MS`. It reuses the
* `MoonLoader` with the `football` style, so the kick previews exactly what the
* loader will look like.
*/
export class FootballKick {
private readonly loader: MoonLoader;
private readonly container: Container;
private settleTimer: ReturnType<typeof setTimeout> | null = null;
private settled = false;
constructor(ui: TUI, container: Container) {
this.container = container;
this.loader = new MoonLoader(ui, 'football');
this.container.addChild(this.loader);
}
/** Begin the spin; the loader is already spinning from its constructor. */
start(): void {
this.settleTimer = setTimeout(() => {
this.settle();
}, FOOTBALL_KICK_MS);
}
/** Stop the spin and freeze on the easter-egg tip line. */
settle(): void {
if (this.settled) return;
this.settled = true;
if (this.settleTimer !== null) {
clearTimeout(this.settleTimer);
this.settleTimer = null;
}
this.loader.stop();
this.loader.setText(footballTipLine());
}
/**
* Clear timers without rendering the tip for shutdown or when a newer
* `/football` supersedes this one. Removes the still-spinning loader so no
* half-finished frame is left behind.
*/
dispose(): void {
if (this.settleTimer !== null) {
clearTimeout(this.settleTimer);
this.settleTimer = null;
}
this.loader.stop();
if (!this.settled) {
// pi-tui Container.removeChild (not a DOM node); `child.remove()` does not exist.
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
this.container.removeChild(this.loader);
}
}
}
/** Drop the in-flight kick, if any. Safe to call when idle. */
export function disposeFootballKick(): void {
currentKick?.dispose();
currentKick = undefined;
}
/**
* Handle `/football`:
* /football play the kick animation and turn the moon loader into a football
* /football off bring the moon loader back
*
* (`footerball` is accepted as a typo-tolerant alias.)
*
* Returns true when it claimed the input.
*/
export function tryHandleFootballCommand(
host: SlashCommandHost,
parsed: ParsedSlashInput,
): boolean {
if (parsed.name !== 'football' && parsed.name !== 'footerball') return false;
const sub = parsed.args.trim().toLowerCase();
if (sub === 'off') {
setFootballActive(false);
disposeFootballKick();
host.showStatus('The moon loader is back.');
return true;
}
setFootballActive(true);
// Supersede any in-flight kick so repeated `/football` doesn't stack
// half-finished animations in the transcript.
disposeFootballKick();
const kick = new FootballKick(host.state.ui, host.state.transcriptContainer);
currentKick = kick;
kick.start();
host.state.ui.requestRender();
return true;
}

View file

@ -106,6 +106,7 @@ import { SessionReplayRenderer } from './controllers/session-replay';
import { StreamingUIController } from './controllers/streaming-ui';
import { TasksBrowserController } from './controllers/tasks-browser';
import { installRainbowDance } from './easter-eggs/dance';
import { disposeFootballKick, isFootballActive } from './easter-eggs/football';
import { adaptPanelResponse } from './reverse-rpc/approval/adapter';
import { ApprovalController } from './reverse-rpc/approval/controller';
import { createApprovalRequestHandler } from './reverse-rpc/approval/handler';
@ -794,6 +795,7 @@ export class KimiTUI {
} finally {
this.sessionEventHandler.stopAllMcpServerStatusSpinners();
this.uninstallRainbowDance();
disposeFootballKick();
try {
await this.state.terminal.drainInput();
} catch {
@ -2238,7 +2240,7 @@ export class KimiTUI {
this.state.ui.requestRender();
return;
case 'waiting': {
const spinner = this.ensureActivitySpinner('moon');
const spinner = this.ensureActivitySpinner(this.moonStyle());
this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined);
if (placeSpinnerInAgentSwarm) break;
this.state.activityContainer.addChild(
@ -2270,7 +2272,7 @@ export class KimiTUI {
break;
}
case 'tool': {
const spinner = this.ensureActivitySpinner('moon');
const spinner = this.ensureActivitySpinner(this.moonStyle());
this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined);
if (placeSpinnerInAgentSwarm) break;
this.state.activityContainer.addChild(
@ -2554,6 +2556,11 @@ export class KimiTUI {
this.state.terminalState.progressActive = active;
}
/** The `/football` easter egg swaps the moon loader for a spinning football. */
private moonStyle(): SpinnerStyle {
return isFootballActive() ? 'football' : 'moon';
}
private ensureActivitySpinner(
style: SpinnerStyle,
label = '',

View file

@ -1,15 +1,17 @@
import type { TUI } from '@moonshot-ai/pi-tui';
import { visibleWidth } from '@moonshot-ai/pi-tui';
import { afterEach, describe, expect, it } from 'vitest';
import { MoonLoader } from '#/tui/components/chrome/moon-loader';
import { MoonLoader, type SpinnerStyle } from '#/tui/components/chrome/moon-loader';
import { FOOTBALL_SPINNER_FRAMES } from '#/tui/constant/rendering';
// MoonLoader starts a real setInterval in its constructor, so every loader
// created in these tests must be stopped to avoid leaving live timers behind.
const loaders: MoonLoader[] = [];
function createLoader(): MoonLoader {
function createLoader(style: SpinnerStyle = 'moon'): MoonLoader {
const ui = { requestRender() {} } as unknown as TUI;
const loader = new MoonLoader(ui, 'moon');
const loader = new MoonLoader(ui, style);
loaders.push(loader);
return loader;
}
@ -39,4 +41,15 @@ describe('MoonLoader', () => {
const row = loader.render(80).join('\n');
expect(row).toContain('Tip: ctrl+s: steer mid-turn');
});
it('renders a football for the football style', () => {
const loader = createLoader('football');
expect(loader.renderInline()).toContain('⚽');
});
it('keeps every football frame the same width so the label never jitters', () => {
const widths = FOOTBALL_SPINNER_FRAMES.map((frame) => visibleWidth(frame));
expect(new Set(widths).size).toBe(1);
});
});

View file

@ -0,0 +1,192 @@
import type { Container, TUI } from '@moonshot-ai/pi-tui';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SlashCommandHost } from '#/tui/commands/dispatch';
import { MoonLoader } from '#/tui/components/chrome/moon-loader';
import {
disposeFootballKick,
FOOTBALL_KICK_MS,
FootballKick,
isFootballActive,
setFootballActive,
tryHandleFootballCommand,
} from '#/tui/easter-eggs/football';
function makeContainer(): { container: Container; children: unknown[] } {
const children: unknown[] = [];
const container = {
addChild: (child: unknown) => children.push(child),
removeChild: (child: unknown) => {
const index = children.indexOf(child);
if (index >= 0) children.splice(index, 1);
},
} as unknown as Container;
return { container, children };
}
function makeUi(): { ui: TUI; requestRender: ReturnType<typeof vi.fn> } {
const requestRender = vi.fn();
const ui = { requestRender } as unknown as TUI;
return { ui, requestRender };
}
describe('FootballKick', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
disposeFootballKick();
vi.useRealTimers();
});
it('adds a spinning football loader to the container on construction', () => {
const { ui } = makeUi();
const { container, children } = makeContainer();
const kick = new FootballKick(ui, container);
expect(children).toHaveLength(1);
expect(children[0]).toBeInstanceOf(MoonLoader);
kick.dispose();
});
it('requests renders while spinning', () => {
const { ui, requestRender } = makeUi();
const { container } = makeContainer();
const kick = new FootballKick(ui, container);
kick.start();
requestRender.mockClear();
vi.advanceTimersByTime(80 * 3);
expect(requestRender).toHaveBeenCalled();
});
it('settles on the easter-egg tip after the kick duration', () => {
const { ui } = makeUi();
const { container, children } = makeContainer();
const kick = new FootballKick(ui, container);
kick.start();
vi.advanceTimersByTime(FOOTBALL_KICK_MS);
const loader = children[0] as MoonLoader;
const row = loader.render(200).join('\n');
expect(row).toContain('Goooal!');
expect(row).toContain('spinning football');
expect(row).toContain('/football off');
});
it('dispose before settle removes the loader and skips the tip', () => {
const { ui } = makeUi();
const { container, children } = makeContainer();
const kick = new FootballKick(ui, container);
kick.start();
vi.advanceTimersByTime(80 * 2);
kick.dispose();
expect(children).toHaveLength(0);
// The settle timer must not fire after dispose.
vi.advanceTimersByTime(FOOTBALL_KICK_MS + 1000);
expect(children).toHaveLength(0);
});
});
function makeHost(): {
host: SlashCommandHost;
children: unknown[];
status: string[];
requestRender: ReturnType<typeof vi.fn>;
} {
const { ui, requestRender } = makeUi();
const { container, children } = makeContainer();
const status: string[] = [];
const host = {
state: { ui, transcriptContainer: container },
showStatus: (msg: string) => status.push(msg),
} as unknown as SlashCommandHost;
return { host, children, status, requestRender };
}
describe('tryHandleFootballCommand', () => {
beforeEach(() => {
vi.useFakeTimers();
setFootballActive(false);
});
afterEach(() => {
disposeFootballKick();
setFootballActive(false);
vi.useRealTimers();
});
it('claims /football, turns the loader into a football, and plays the animation', () => {
const { host, children, status } = makeHost();
const handled = tryHandleFootballCommand(host, { name: 'football', args: '' });
expect(handled).toBe(true);
expect(isFootballActive()).toBe(true);
expect(children).toHaveLength(1);
expect(children[0]).toBeInstanceOf(MoonLoader);
expect(status).toHaveLength(0);
});
it('accepts the typo-tolerant /footerball alias', () => {
const { host } = makeHost();
const handled = tryHandleFootballCommand(host, { name: 'footerball', args: '' });
expect(handled).toBe(true);
expect(isFootballActive()).toBe(true);
});
it('/football off restores the moon loader and shows a status', () => {
const { host, status } = makeHost();
tryHandleFootballCommand(host, { name: 'football', args: '' });
expect(isFootballActive()).toBe(true);
const handled = tryHandleFootballCommand(host, { name: 'football', args: 'off' });
expect(handled).toBe(true);
expect(isFootballActive()).toBe(false);
expect(status.join(' ')).toContain('moon loader');
});
it('ignores case and surrounding whitespace in the sub-command', () => {
const { host } = makeHost();
setFootballActive(true);
tryHandleFootballCommand(host, { name: 'football', args: ' OFF ' });
expect(isFootballActive()).toBe(false);
});
it('does not claim other commands', () => {
const { host, children } = makeHost();
const handled = tryHandleFootballCommand(host, { name: 'help', args: '' });
expect(handled).toBe(false);
expect(isFootballActive()).toBe(false);
expect(children).toHaveLength(0);
});
it('supersedes a previous in-flight kick so animations do not stack', () => {
const { host, children } = makeHost();
tryHandleFootballCommand(host, { name: 'football', args: '' });
expect(children).toHaveLength(1);
tryHandleFootballCommand(host, { name: 'football', args: '' });
// The first kick was disposed (removed); only the new loader remains.
expect(children).toHaveLength(1);
});
});