Merge pull request #2976 from jeremymcs/splash-header-updates-clean

feat(splash): add remote channel indicator to tools row
This commit is contained in:
Jeremy McSpadden 2026-04-01 16:14:23 -05:00 committed by GitHub
commit d0555857c2
5 changed files with 336 additions and 21 deletions

View file

@ -691,10 +691,17 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) {
// Skip when the first-run banner was already printed in loader.ts (prevents double banner).
if (!process.env.GSD_FIRST_RUN_BANNER) {
const { printWelcomeScreen } = await import('./welcome-screen.js')
let remoteChannel: string | undefined
try {
const { resolveRemoteConfig } = await import('./resources/extensions/remote-questions/config.js')
const rc = resolveRemoteConfig()
if (rc) remoteChannel = rc.channel
} catch { /* non-fatal */ }
printWelcomeScreen({
version: process.env.GSD_VERSION || '0.0.0',
modelName: settingsManager.getDefaultModel() || undefined,
provider: settingsManager.getDefaultProvider() || undefined,
remoteChannel,
})
}

View file

@ -48,26 +48,20 @@ export function registerHooks(pi: ExtensionAPI): void {
const { dirname } = await import("node:path");
const { printWelcomeScreen } = await import(
join(dirname(gsdBinPath), "welcome-screen.js")
) as { printWelcomeScreen: (opts: { version: string; modelName?: string; provider?: string }) => void };
printWelcomeScreen({ version: process.env.GSD_VERSION || "0.0.0" });
) as { printWelcomeScreen: (opts: { version: string; modelName?: string; provider?: string; remoteChannel?: string }) => void };
let remoteChannel: string | undefined;
try {
const { resolveRemoteConfig } = await import("../../remote-questions/config.js");
const rc = resolveRemoteConfig();
if (rc) remoteChannel = rc.channel;
} catch { /* non-fatal */ }
printWelcomeScreen({ version: process.env.GSD_VERSION || "0.0.0", remoteChannel });
}
} catch { /* non-fatal */ }
}
loadToolApiKeys();
try {
const [{ getRemoteConfigStatus }, { getLatestPromptSummary }] = await Promise.all([
import("../../remote-questions/config.js"),
import("../../remote-questions/status.js"),
]);
const status = getRemoteConfigStatus();
const latest = getLatestPromptSummary();
if (!status.includes("not configured")) {
const suffix = latest ? `\nLast remote prompt: ${latest.id} (${latest.status})` : "";
ctx.ui.notify(`${status}${suffix}`, status.includes("disabled") ? "warning" : "info");
}
} catch {
// ignore
}
});
pi.on("session_switch", async (_event, ctx) => {

View file

@ -0,0 +1,275 @@
// GSD Watch — Header renderer: ASCII logo, session info, MCP status, remote questions
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { visibleWidth, truncateToWidth } from "@gsd/pi-tui";
import { loadEffectiveGSDPreferences } from "../preferences.js";
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* GSD ASCII logo inlined here because the canonical src/logo.ts is outside
* the resources rootDir and cannot be imported directly.
*/
const GSD_LOGO: readonly string[] = [
' ██████╗ ███████╗██████╗ ',
' ██╔════╝ ██╔════╝██╔══██╗',
' ██║ ███╗███████╗██║ ██║',
' ██║ ██║╚════██║██║ ██║',
' ╚██████╔╝███████║██████╔╝',
' ╚═════╝ ╚══════╝╚═════╝ ',
];
/** Separator character for the horizontal divider line. */
const SEPARATOR_CHAR = "─";
/** Vertical bar between logo and info panel. */
const PANEL_DIVIDER = "│";
/** Label column width for Model/Provider/Directory/Branch rows. */
const LABEL_COL_WIDTH = 10;
// ─── Data Readers ─────────────────────────────────────────────────────────────
/**
* Read the configured execution model from GSD preferences.
* Falls back through execution -> planning -> research -> first found.
* Returns "default" if nothing is configured.
*/
export function readModelFromPreferences(): string {
try {
const prefs = loadEffectiveGSDPreferences();
if (!prefs?.preferences.models) return "default";
const m = prefs.preferences.models as Record<string, unknown>;
// Try common phases in priority order
for (const phase of ["execution", "planning", "research", "discuss", "subagent"]) {
const val = m[phase];
if (typeof val === "string") return val;
if (val && typeof val === "object" && "model" in val) {
const model = (val as { model: string }).model;
if (typeof model === "string") return model;
}
}
} catch {
// Non-fatal
}
return "default";
}
/**
* Derive provider name from model ID prefix.
*/
export function deriveProvider(modelId: string): string {
if (modelId.startsWith("claude")) return "anthropic";
if (modelId.startsWith("gpt") || modelId.startsWith("o1") || modelId.startsWith("o3")) return "openai";
if (modelId.startsWith("gemini")) return "google";
if (modelId.startsWith("deepseek")) return "deepseek";
if (modelId === "default") return "anthropic";
return "unknown";
}
/**
* Shorten a directory path by replacing the home directory with ~.
*/
export function shortenPath(fullPath: string): string {
const home = homedir();
if (fullPath.startsWith(home)) {
return "~" + fullPath.slice(home.length);
}
return fullPath;
}
/**
* Read the current git branch name. Returns "unknown" on failure.
*/
export function readGitBranch(projectRoot: string): string {
try {
return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: projectRoot,
encoding: "utf-8",
timeout: 2000,
}).trim();
} catch {
return "unknown";
}
}
/**
* Read MCP server names from .mcp.json or .gsd/mcp.json.
* Returns array of server name strings.
*/
export function readMcpServerNames(projectRoot: string): string[] {
const configPaths = [
join(projectRoot, ".mcp.json"),
join(projectRoot, ".gsd", "mcp.json"),
];
const names: string[] = [];
const seen = new Set<string>();
for (const configPath of configPaths) {
try {
if (!existsSync(configPath)) continue;
const raw = readFileSync(configPath, "utf-8");
const data = JSON.parse(raw) as Record<string, unknown>;
const mcpServers = (data.mcpServers ?? data.servers) as
| Record<string, unknown>
| undefined;
if (!mcpServers || typeof mcpServers !== "object") continue;
for (const name of Object.keys(mcpServers)) {
if (!seen.has(name)) {
seen.add(name);
names.push(name);
}
}
} catch {
// Non-fatal
}
}
return names;
}
// ─── Header Layout ────────────────────────────────────────────────────────────
export interface HeaderData {
model: string;
provider: string;
directory: string;
branch: string;
mcpServers: string[];
}
/**
* Gather all header data from filesystem and preferences.
*/
export function gatherHeaderData(projectRoot: string): HeaderData {
const model = readModelFromPreferences();
const provider = deriveProvider(model);
const directory = shortenPath(projectRoot);
const branch = readGitBranch(projectRoot);
const mcpServers = readMcpServerNames(projectRoot);
return { model, provider, directory, branch, mcpServers };
}
/**
* Build an info panel line: "Label value" with proper padding.
* Returns empty string if value is empty.
*/
function formatInfoLine(label: string, value: string, availableWidth: number): string {
const bold = `\x1b[1m${label}\x1b[0m`;
const labelVis = visibleWidth(bold);
const padding = " ".repeat(Math.max(1, LABEL_COL_WIDTH - labelVis));
const maxValueWidth = Math.max(1, availableWidth - LABEL_COL_WIDTH);
const truncValue = truncateToWidth(value, maxValueWidth, "…");
return bold + padding + truncValue;
}
/**
* Format MCP server names as a dot-separated row with checkmarks.
* e.g. "Brave ✓ · Answers ✓ · Context7 ✓"
*/
export function formatMcpRow(servers: string[], width: number): string {
if (servers.length === 0) return "";
// Capitalize first letter of each server name
const items = servers.map(s => {
const cap = s.charAt(0).toUpperCase() + s.slice(1);
return `${cap}`;
});
const full = items.join(" · ");
if (visibleWidth(full) <= width) return full;
// Truncate if too wide
return truncateToWidth(full, width, "…");
}
/**
* Render the full header as an array of terminal-safe strings.
*
* Layout: GSD ASCII logo on the left, info panel on the right separated by .
* Below: MCP server row, remote questions row, separator line.
*/
export function renderHeaderLines(data: HeaderData, width: number): string[] {
const lines: string[] = [];
// Logo is 6 lines tall. Info panel has: title + blank + model + provider + directory + branch = 6 lines
const logoLines = GSD_LOGO;
const logoWidth = Math.max(...logoLines.map(l => visibleWidth(l)));
// Calculate available width for the info panel
// Layout: logo + " " + "│" + " " = logoWidth + 3
const dividerOverhead = 3; // " │ "
const infoPanelWidth = width - logoWidth - dividerOverhead;
// If terminal is too narrow for side-by-side, fall back to stacked layout
if (infoPanelWidth < 20) {
return renderStackedHeader(data, width);
}
// Build info panel lines (6 lines to match logo height)
const infoLines: string[] = [
`\x1b[1mGet Shit Done\x1b[0m`,
"",
formatInfoLine("Model", data.model, infoPanelWidth),
formatInfoLine("Provider", data.provider, infoPanelWidth),
formatInfoLine("Directory", data.directory, infoPanelWidth),
formatInfoLine("Branch", data.branch, infoPanelWidth),
];
// Merge logo and info panel side by side
const maxLines = Math.max(logoLines.length, infoLines.length);
for (let i = 0; i < maxLines; i++) {
const logoLine = i < logoLines.length ? logoLines[i] : "";
const infoLine = i < infoLines.length ? infoLines[i] : "";
// Pad logo line to consistent width
const logoPad = " ".repeat(Math.max(0, logoWidth - visibleWidth(logoLine)));
lines.push(`${logoLine}${logoPad} ${PANEL_DIVIDER} ${infoLine}`);
}
// Blank line after logo+info block
lines.push("");
// MCP server row
const mcpRow = formatMcpRow(data.mcpServers, width);
if (mcpRow) {
lines.push(` ${mcpRow}`);
}
// Separator line
lines.push(SEPARATOR_CHAR.repeat(width));
return lines;
}
/**
* Fallback stacked layout for narrow terminals (< 20 cols for info panel).
*/
function renderStackedHeader(data: HeaderData, width: number): string[] {
const lines: string[] = [];
// Title
lines.push(`\x1b[1mGet Shit Done\x1b[0m`);
lines.push("");
// Info
lines.push(formatInfoLine("Model", data.model, width));
lines.push(formatInfoLine("Provider", data.provider, width));
lines.push(formatInfoLine("Directory", data.directory, width));
lines.push(formatInfoLine("Branch", data.branch, width));
lines.push("");
// MCP
const mcpRow = formatMcpRow(data.mcpServers, width);
if (mcpRow) lines.push(` ${mcpRow}`);
// Separator
lines.push(SEPARATOR_CHAR.repeat(width));
return lines;
}

View file

@ -71,3 +71,15 @@ 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')
})
test('renders remote channel in tools row', () => {
const out = strip(capture({ version: '1.0.0', remoteChannel: 'discord' }))
assert.ok(out.includes('Discord'), 'remote channel name missing')
})
test('omits remote channel when not provided', () => {
const out = strip(capture({ version: '1.0.0' }))
assert.ok(!out.includes('Discord'), 'should not show Discord when no remote')
assert.ok(!out.includes('Slack'), 'should not show Slack when no remote')
assert.ok(!out.includes('Telegram'), 'should not show Telegram when no remote')
})

View file

@ -6,6 +6,7 @@
* Falls back to simple text on narrow terminals (<70 cols) or non-TTY.
*/
import { execFileSync } from 'node:child_process'
import os from 'node:os'
import chalk from 'chalk'
import { GSD_LOGO } from './logo.js'
@ -14,6 +15,7 @@ export interface WelcomeScreenOptions {
version: string
modelName?: string
provider?: string
remoteChannel?: string
}
function getShortCwd(): string {
@ -32,11 +34,25 @@ function rpad(s: string, w: number): string {
return s + ' '.repeat(Math.max(0, w - visLen(s)))
}
/** Read the current git branch name. Returns undefined on failure. */
function getGitBranch(): string | undefined {
try {
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
encoding: 'utf-8',
timeout: 2000,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim() || undefined
} catch {
return undefined
}
}
export function printWelcomeScreen(opts: WelcomeScreenOptions): void {
if (!process.stderr.isTTY) return
const { version, modelName, provider } = opts
const { version, modelName, provider, remoteChannel } = opts
const shortCwd = getShortCwd()
const branch = getGitBranch()
const termWidth = Math.min((process.stderr.columns || 80) - 1, 200)
// Narrow terminal fallback
@ -69,6 +85,7 @@ export function printWelcomeScreen(opts: WelcomeScreenOptions): void {
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 (remoteChannel) toolParts.push(`${remoteChannel.charAt(0).toUpperCase() + remoteChannel.slice(1)}`)
// Tools left, hint right-aligned on the same row
const toolsLeft = toolParts.length > 0 ? chalk.dim(' ' + toolParts.join(' · ')) : ''
@ -76,16 +93,26 @@ export function printWelcomeScreen(opts: WelcomeScreenOptions): void {
const footerFill = RIGHT_INNER - visLen(toolsLeft) - visLen(hintRight)
const footerRow = toolsLeft + ' '.repeat(Math.max(1, footerFill)) + hintRight
// Combined session line: "provider / model" or just model or just provider
const sessionParts = [provider, modelName].filter(Boolean)
const sessionLine = sessionParts.length > 0
? ` Session ${chalk.dim(sessionParts.join(' / '))}`
: ''
// Combined project line: "~/path [branch]"
const branchSuffix = branch ? ` [${branch}]` : ''
const projectLine = ` Project ${chalk.dim(shortCwd + branchSuffix)}`
const DIVIDER = null
const rightRows: (string | null)[] = [
titleRow,
DIVIDER,
modelName ? ` Model ${chalk.dim(modelName)}` : '',
provider ? ` Provider ${chalk.dim(provider)}` : '',
` Directory ${chalk.dim(shortCwd)}`,
'',
sessionLine,
projectLine,
'',
DIVIDER,
footerRow,
'',
]
// ── Render ──────────────────────────────────────────────────────────────────