From 1b6b16f2d5aae7c25471629702a1c2eeca53e1b1 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Fri, 20 Mar 2026 09:11:06 -0500 Subject: [PATCH] feat: feat(ui): minimal GSD welcome screen on startup (#1584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add GSD welcome screen on interactive startup Renders a two-panel boxed welcome screen to stderr before the TUI takes over, mirroring the style of the Claude Code welcome screen. Left panel — personalized greeting, GSD ASCII logo, active model + cwd Right panel — getting-started tips, recent session activity The screen is printed to stderr immediately before InteractiveMode.run(), so it appears on launch and reappears when the TUI exits (alternate-screen buffer swap). It silently skips when not a TTY or terminal < 60 cols. Files: src/welcome-screen.ts — printWelcomeScreen() implementation src/cli.ts — call site before interactiveMode.run() src/tests/welcome-screen.test.ts — 11 unit tests (all passing) * refactor(ui): minimal welcome screen — logo + metadata, no box Replace two-panel boxed layout with a minimal design: logo block with version/model/cwd alongside it, dim hint below. No box borders, no tips panel. Clean and fast. * feat(ui): show tool status line (Brave/Jina/Tavily) when keys are configured --- src/cli.ts | 10 +++++ src/tests/welcome-screen.test.ts | 73 ++++++++++++++++++++++++++++++++ src/welcome-screen.ts | 64 ++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 src/tests/welcome-screen.test.ts create mode 100644 src/welcome-screen.ts 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') +}