Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/init-no-animation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fission-ai/openspec': patch
---

Respect reduced-motion preferences in `openspec init`: the welcome animation is skipped when the OS reduced-motion setting is on (macOS Reduce Motion, GNOME animations disabled), when `OPENSPEC_NO_ANIMATION` is set, or when the new `--no-animation` flag is passed. The static welcome screen is shown instead.
4 changes: 4 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,12 @@ openspec init [path] [options]
| `--tools <list>` | Configure AI tools non-interactively. Use `all`, `none`, or comma-separated list |
| `--force` | Auto-cleanup legacy files without prompting |
| `--profile <profile>` | Override global profile for this init run (`core` or `custom`) |
| `--no-animation` | Show a static welcome screen instead of the animated one |

`--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`).

The welcome animation is also skipped when the `OPENSPEC_NO_ANIMATION` environment variable is set (any value, including empty), when `NO_COLOR` is set to a non-empty value, or when the OS reduced-motion preference is enabled (macOS Reduce Motion, GNOME animations disabled).

**Supported tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`, `zcode`

> This list mirrors `AI_TOOLS` in `src/core/config.ts`. See [Supported Tools](supported-tools.md) for each tool's skill and command paths.
Expand Down Expand Up @@ -1200,6 +1203,7 @@ openspec completion uninstall
| `OPENSPEC_CONCURRENCY` | Default concurrency for bulk validation (default: 6) |
| `EDITOR` or `VISUAL` | Editor for `openspec config edit` |
| `NO_COLOR` | Disable color output when set |
| `OPENSPEC_NO_ANIMATION` | Disable the `openspec init` welcome animation when set |

---

Expand Down
4 changes: 3 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ program
.option('--tools <tools>', toolsOptionDescription)
.option('--force', 'Auto-cleanup legacy files without prompting')
.option('--profile <profile>', 'Override global config profile (core or custom)')
.action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string }) => {
.option('--no-animation', 'Show a static welcome screen instead of the animated one')
.action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string; animation?: boolean }) => {
try {
// Validate that the path is a valid directory
const resolvedPath = path.resolve(targetPath);
Expand All @@ -170,6 +171,7 @@ program
tools: options?.tools,
force: options?.force,
profile: options?.profile,
animation: options?.animation,
});
await initCommand.execute(targetPath);
} catch (error) {
Expand Down
4 changes: 4 additions & 0 deletions src/core/completions/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
takesValue: true,
values: ['core', 'custom'],
},
{
name: 'no-animation',
description: 'Show a static welcome screen instead of the animated one',
},
],
},
{
Expand Down
6 changes: 5 additions & 1 deletion src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ type InitCommandOptions = {
force?: boolean;
interactive?: boolean;
profile?: string;
/** Commander's --no-animation flag: false disables the welcome animation. */
animation?: boolean;
};

/**
Expand All @@ -116,12 +118,14 @@ export class InitCommand {
private readonly force: boolean;
private readonly interactiveOption?: boolean;
private readonly profileOverride?: string;
private readonly animation: boolean;

constructor(options: InitCommandOptions = {}) {
this.toolsArg = options.tools;
this.force = options.force ?? false;
this.interactiveOption = options.interactive;
this.profileOverride = options.profile;
this.animation = options.animation ?? true;
}

async execute(targetPath: string): Promise<void> {
Expand Down Expand Up @@ -184,7 +188,7 @@ export class InitCommand {
const canPrompt = this.canPromptInteractively();
if (canPrompt) {
const { showWelcomeScreen } = await import('../ui/welcome-screen.js');
await showWelcomeScreen(this.getActiveWorkflows());
await showWelcomeScreen(this.getActiveWorkflows(), { animate: this.animation });
}

// Get tool states before processing
Expand Down
59 changes: 57 additions & 2 deletions src/ui/welcome-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
*/

import chalk from 'chalk';
import {
execFileSync,
type ExecFileSyncOptionsWithStringEncoding,
} from 'node:child_process';
import { WELCOME_ANIMATION } from './ascii-patterns.js';
import { getOnboardingCommands } from '../core/onboarding-commands.js';

Expand Down Expand Up @@ -66,6 +70,47 @@ function renderFrame(artLines: string[], textLines: string[]): string {
return lines.join('\n');
}

const REDUCED_MOTION_EXEC_OPTIONS: ExecFileSyncOptionsWithStringEncoding = {
encoding: 'utf8',
timeout: 500,
// SIGKILL so a wedged lookup can never outlive the timeout and stall init.
killSignal: 'SIGKILL',
stdio: ['ignore', 'pipe', 'ignore'],
};

/**
* Best-effort check of the OS-level reduced-motion preference (#722).
* Any lookup failure (missing binary, unset key, timeout) means
* "no preference detected" and animation stays enabled.
*/
export function prefersReducedMotion(
platform: NodeJS.Platform = process.platform
): boolean {
try {
if (platform === 'darwin') {
// The key only exists once the user has toggled Reduce Motion; when it
// is unset `defaults` exits non-zero and lands in the catch below.
const out = execFileSync(
'defaults',
['read', 'com.apple.universalaccess', 'reduceMotion'],
REDUCED_MOTION_EXEC_OPTIONS
);
return out.trim() === '1';
}
if (platform === 'linux') {
const out = execFileSync(
'gsettings',
['get', 'org.gnome.desktop.interface', 'enable-animations'],
REDUCED_MOTION_EXEC_OPTIONS
);
return out.trim() === 'false';
}
} catch {
// Detection is best-effort only.
}
return false;
}

/**
* Checks if the terminal supports animation
*/
Expand All @@ -76,10 +121,17 @@ function canAnimate(): boolean {
// Respect NO_COLOR
if (process.env.NO_COLOR) return false;

// Manual override for users who need reduced motion (#722). Presence is
// what counts: even an empty value disables the animation.
if (process.env.OPENSPEC_NO_ANIMATION !== undefined) return false;

// Check terminal width
const columns = process.stdout.columns || 80;
if (columns < MIN_WIDTH) return false;

// Last so only interactive terminals pay for the OS lookup
if (prefersReducedMotion()) return false;

return true;
}

Expand Down Expand Up @@ -116,10 +168,13 @@ async function waitForEnter(): Promise<void> {
* Shows the animated welcome screen.
* Returns when user presses Enter.
*/
export async function showWelcomeScreen(workflows: readonly string[]): Promise<void> {
export async function showWelcomeScreen(
workflows: readonly string[],
options: { animate?: boolean } = {}
): Promise<void> {
const textLines = getWelcomeText(workflows);

if (!canAnimate()) {
if (options.animate === false || !canAnimate()) {
// Fallback: show static welcome
const frame = WELCOME_ANIMATION.frames[3]; // Peak frame
process.stdout.write('\n' + renderFrame(frame, textLines) + '\n\n');
Expand Down
2 changes: 1 addition & 1 deletion test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ describe('InitCommand - profile and detection features', () => {
expect(showWelcomeScreenMock).toHaveBeenCalled();
// The welcome screen must be handed the profile's workflows, otherwise it
// advertises commands this profile never installs.
expect(showWelcomeScreenMock).toHaveBeenCalledWith(['explore', 'new']);
expect(showWelcomeScreenMock).toHaveBeenCalledWith(['explore', 'new'], { animate: true });
expect(confirmMock).not.toHaveBeenCalled();

const exploreSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
Expand Down
117 changes: 116 additions & 1 deletion test/ui/welcome-screen.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ALL_WORKFLOWS, CORE_WORKFLOWS } from '../../src/core/profiles.js';

const { useKeypressMock } = vi.hoisted(() => ({
const { useKeypressMock, execFileSyncMock } = vi.hoisted(() => ({
useKeypressMock: vi.fn(),
execFileSyncMock: vi.fn(),
}));

vi.mock('node:child_process', () => ({
execFileSync: execFileSyncMock,
}));

vi.mock('@inquirer/core', () => ({
Expand All @@ -23,6 +28,7 @@ vi.mock('@inquirer/core', () => ({

describe('welcome screen', () => {
const originalNoColor = process.env.NO_COLOR;
const originalNoAnimation = process.env.OPENSPEC_NO_ANIMATION;
const originalStdinIsTTY = process.stdin.isTTY;
const originalStdoutIsTTY = process.stdout.isTTY;
const originalColumns = process.stdout.columns;
Expand All @@ -38,11 +44,18 @@ describe('welcome screen', () => {

beforeEach(() => {
delete process.env.NO_COLOR;
delete process.env.OPENSPEC_NO_ANIMATION;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
Object.defineProperty(process.stdout, 'columns', { value: 100, configurable: true });
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
useKeypressMock.mockClear();
// Deterministic default: no OS-level reduced-motion preference detectable,
// so animated-path tests behave the same on every machine.
execFileSyncMock.mockReset();
execFileSyncMock.mockImplementation(() => {
throw new Error('not available in tests');
});
});

afterEach(() => {
Expand All @@ -51,6 +64,11 @@ describe('welcome screen', () => {
} else {
process.env.NO_COLOR = originalNoColor;
}
if (originalNoAnimation === undefined) {
delete process.env.OPENSPEC_NO_ANIMATION;
} else {
process.env.OPENSPEC_NO_ANIMATION = originalNoAnimation;
}
Object.defineProperty(process.stdin, 'isTTY', { value: originalStdinIsTTY, configurable: true });
Object.defineProperty(process.stdout, 'isTTY', { value: originalStdoutIsTTY, configurable: true });
Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true });
Expand Down Expand Up @@ -119,4 +137,101 @@ describe('welcome screen', () => {
expect(line.length).toBeLessThanOrEqual(59);
}
});

it('renders statically when OPENSPEC_NO_ANIMATION is set', async () => {
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
process.env.OPENSPEC_NO_ANIMATION = '1';

await showWelcomeScreen(CORE_WORKFLOWS);

expect(useKeypressMock).not.toHaveBeenCalled();
const output = writtenOutput();
expect(output).toContain('Welcome to OpenSpec');
// No cursor-up repaints: the frame is drawn exactly once.
expect(output).not.toMatch(/\x1b\[\d+A/);
});

it('honors OPENSPEC_NO_ANIMATION even when set to an empty value', async () => {
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
process.env.OPENSPEC_NO_ANIMATION = '';

await showWelcomeScreen(CORE_WORKFLOWS);

expect(useKeypressMock).not.toHaveBeenCalled();
expect(writtenOutput()).not.toMatch(/\x1b\[\d+A/);
});

it('renders statically when animate is disabled via options', async () => {
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');

await showWelcomeScreen(CORE_WORKFLOWS, { animate: false });

expect(useKeypressMock).not.toHaveBeenCalled();
const output = writtenOutput();
expect(output).toContain('Welcome to OpenSpec');
expect(output).not.toMatch(/\x1b\[\d+A/);
});

it.runIf(process.platform === 'darwin' || process.platform === 'linux')(
'renders statically when the OS prefers reduced motion',
async () => {
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
execFileSyncMock.mockImplementation((file: string) =>
file === 'defaults' ? '1\n' : 'false\n'
);

await showWelcomeScreen(CORE_WORKFLOWS);

expect(useKeypressMock).not.toHaveBeenCalled();
expect(writtenOutput()).toContain('Welcome to OpenSpec');
}
);
});

describe('prefersReducedMotion', () => {
beforeEach(() => {
execFileSyncMock.mockReset();
});

it('detects macOS Reduce Motion', async () => {
const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js');
execFileSyncMock.mockReturnValue('1\n');

expect(prefersReducedMotion('darwin')).toBe(true);
expect(execFileSyncMock).toHaveBeenCalledWith(
'defaults',
['read', 'com.apple.universalaccess', 'reduceMotion'],
expect.objectContaining({ timeout: 500 })
);
});

it('treats a disabled or unset macOS preference as no preference', async () => {
const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js');

execFileSyncMock.mockReturnValue('0\n');
expect(prefersReducedMotion('darwin')).toBe(false);

// `defaults read` exits non-zero while the key has never been toggled.
execFileSyncMock.mockImplementation(() => {
throw new Error('The domain/default pair does not exist');
});
expect(prefersReducedMotion('darwin')).toBe(false);
});

it('detects GNOME reduced motion via disabled animations', async () => {
const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js');

execFileSyncMock.mockReturnValue('false\n');
expect(prefersReducedMotion('linux')).toBe(true);

execFileSyncMock.mockReturnValue('true\n');
expect(prefersReducedMotion('linux')).toBe(false);
});

it('returns false without spawning anything on other platforms', async () => {
const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js');

expect(prefersReducedMotion('win32')).toBe(false);
expect(execFileSyncMock).not.toHaveBeenCalled();
});
});
Loading