diff --git a/src/cli.ts b/src/cli.ts index 548f20c4c..32b19a43f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -577,6 +577,16 @@ if (enabledModelPatterns && enabledModelPatterns.length > 0) { } } +// Welcome screen — shown on every fresh interactive session before TUI takes over +{ + const { printWelcomeScreen } = await import('./welcome-screen.js') + printWelcomeScreen({ + version: process.env.GSD_VERSION || '0.0.0', + modelName: settingsManager.getDefaultModel() || undefined, + provider: settingsManager.getDefaultProvider() || undefined, + }) +} + const interactiveMode = new InteractiveMode(session) markStartup('InteractiveMode') printStartupTimings() diff --git a/src/tests/welcome-screen.test.ts b/src/tests/welcome-screen.test.ts new file mode 100644 index 000000000..347f4fda9 --- /dev/null +++ b/src/tests/welcome-screen.test.ts @@ -0,0 +1,73 @@ +/** + * Welcome screen unit tests. + */ + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { printWelcomeScreen } from '../../dist/welcome-screen.js' + +function capture(opts: Parameters[0]): string { + const chunks: string[] = [] + const original = process.stderr.write.bind(process.stderr) + ;(process.stderr as any).write = (chunk: string) => { chunks.push(chunk); return true } + const origIsTTY = (process.stderr as any).isTTY + ;(process.stderr as any).isTTY = true + + try { + printWelcomeScreen(opts) + } finally { + ;(process.stderr as any).write = original + ;(process.stderr as any).isTTY = origIsTTY + } + + return chunks.join('') +} + +function strip(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/\x1b\[[0-9;]*m/g, '') +} + +test('renders GSD logo', () => { + const out = strip(capture({ version: '1.0.0' })) + assert.ok(out.includes('██'), 'logo block characters missing') +}) + +test('renders version', () => { + const out = strip(capture({ version: '2.38.0' })) + assert.ok(out.includes('v2.38.0'), 'version missing') + assert.ok(out.includes('Get Shit Done'), 'brand name missing') +}) + +test('renders model and provider', () => { + const out = strip(capture({ version: '1.0.0', modelName: 'claude-opus-4-6', provider: 'Anthropic' })) + assert.ok(out.includes('claude-opus-4-6'), 'model name missing') + assert.ok(out.includes('Anthropic'), 'provider missing') +}) + +test('renders cwd hint', () => { + const out = strip(capture({ version: '1.0.0' })) + assert.ok(out.includes('/gsd to begin'), 'hint line missing') +}) + +test('skips when not a TTY', () => { + const chunks: string[] = [] + const original = process.stderr.write.bind(process.stderr) + ;(process.stderr as any).write = (chunk: string) => { chunks.push(chunk); return true } + const origIsTTY = (process.stderr as any).isTTY + ;(process.stderr as any).isTTY = false + + try { + printWelcomeScreen({ version: '1.0.0' }) + assert.equal(chunks.join(''), '', 'should produce no output when not TTY') + } finally { + ;(process.stderr as any).write = original + ;(process.stderr as any).isTTY = origIsTTY + } +}) + +test('renders without model or provider', () => { + const out = strip(capture({ version: '3.0.0' })) + assert.ok(out.includes('v3.0.0'), 'version missing when no model provided') +}) diff --git a/src/welcome-screen.ts b/src/welcome-screen.ts new file mode 100644 index 000000000..4d4b13772 --- /dev/null +++ b/src/welcome-screen.ts @@ -0,0 +1,64 @@ +/** + * GSD Welcome Screen + * + * Rendered to stderr before the TUI takes over. + * No box, no panels — logo with metadata alongside, dim hint below. + */ + +import os from 'node:os' +import chalk from 'chalk' +import { GSD_LOGO } from './logo.js' + +export interface WelcomeScreenOptions { + version: string + modelName?: string + provider?: string +} + +function getShortCwd(): string { + const cwd = process.cwd() + const home = os.homedir() + return cwd.startsWith(home) ? '~' + cwd.slice(home.length) : cwd +} + +export function printWelcomeScreen(opts: WelcomeScreenOptions): void { + if (!process.stderr.isTTY) return + + const { version, modelName, provider } = opts + const shortCwd = getShortCwd() + + // Info lines to sit alongside the logo (one per logo row) + const modelLine = [modelName, provider].filter(Boolean).join(' · ') + const INFO: (string | undefined)[] = [ + ` ${chalk.bold('Get Shit Done')} ${chalk.dim('v' + version)}`, + undefined, + modelLine ? ` ${chalk.dim(modelLine)}` : undefined, + ` ${chalk.dim(shortCwd)}`, + undefined, + undefined, + ] + + const lines: string[] = [''] + for (let i = 0; i < GSD_LOGO.length; i++) { + lines.push(chalk.cyan(GSD_LOGO[i]) + (INFO[i] ?? '')) + } + + // Tool status + hint — dim, aligned under the info text + const pad = ' '.repeat(28) + ' ' // aligns with the info text column + + const toolParts: string[] = [] + if (process.env.BRAVE_API_KEY) toolParts.push('Brave ✓') + if (process.env.BRAVE_ANSWERS_KEY) toolParts.push('Answers ✓') + if (process.env.JINA_API_KEY) toolParts.push('Jina ✓') + if (process.env.TAVILY_API_KEY) toolParts.push('Tavily ✓') + if (process.env.CONTEXT7_API_KEY) toolParts.push('Context7 ✓') + + if (toolParts.length > 0) { + lines.push(chalk.dim(pad + ['Web search loaded', ...toolParts].join(' · '))) + } + + lines.push(chalk.dim(pad + '/gsd to begin · /gsd help for all commands')) + lines.push('') + + process.stderr.write(lines.join('\n') + '\n') +}