mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
footerball
This commit is contained in:
parent
ec758c747a
commit
102da1ebbe
7 changed files with 385 additions and 11 deletions
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
136
apps/kimi-code/src/tui/easter-eggs/football.ts
Normal file
136
apps/kimi-code/src/tui/easter-eggs/football.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 = '',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
192
apps/kimi-code/test/tui/easter-eggs/football.test.ts
Normal file
192
apps/kimi-code/test/tui/easter-eggs/football.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue