feat: feat(ui): minimal GSD welcome screen on startup (#1584)

* 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
This commit is contained in:
Jeremy McSpadden 2026-03-20 09:11:06 -05:00 committed by GitHub
parent f1a27b02b8
commit 1b6b16f2d5
3 changed files with 147 additions and 0 deletions

View file

@ -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()

View file

@ -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<typeof printWelcomeScreen>[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')
})

64
src/welcome-screen.ts Normal file
View file

@ -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')
}