feat(coding-agent): surfaced lazy LSP servers as available in welcome screen

- Added "available" status so recognized servers show under lazy mode without warmup.
- Rendered full welcome box as pre-TUI splash with fixed slot heights to avoid layout shift.
- Reported lazy servers as available in /status instead of omitting the section.
This commit is contained in:
can1357
2026-06-10 07:22:22 +02:00
parent a25d521cab
commit b0fec42218
10 changed files with 238 additions and 40 deletions
@@ -17,6 +17,24 @@ const TIPS: readonly string[] = tipsText
.map(line => line.trim())
.filter(line => line.length > 0);
/**
* Tip chosen once per process so the pre-TUI startup splash and the in-TUI
* welcome screen show the same tip instead of shuffling on the swap.
*/
const PROCESS_TIP: string | undefined = TIPS.length > 0 ? TIPS[Math.floor(Math.random() * TIPS.length)] : undefined;
/**
* Fixed number of session rows in the welcome box so its height doesn't shift
* between the pre-TUI splash (loading placeholder) and the loaded state.
*/
export const WELCOME_SESSION_SLOTS = 4;
/**
* Fixed number of LSP-server rows, for the same reason. Overflow is sliced so
* the box height is constant regardless of how many servers a project has.
*/
export const WELCOME_LSP_SLOTS = 4;
export function renderWelcomeTip(tip: string, boxWidth: number): string[] {
const label = "Tip: ";
const labelWidth = visibleWidth(label);
@@ -48,7 +66,7 @@ export interface RecentSession {
export interface LspServerInfo {
name: string;
status: "ready" | "error" | "connecting";
status: "ready" | "error" | "connecting" | "available";
fileTypes: string[];
}
@@ -58,18 +76,38 @@ export interface LspServerInfo {
export class WelcomeComponent implements Component {
#animStart: number | null = null;
#animTimer: ReturnType<typeof setInterval> | null = null;
/** Tip chosen once per instance so re-renders (intro, LSP updates) don't shuffle it. */
readonly #tip: string | undefined = TIPS.length > 0 ? TIPS[Math.floor(Math.random() * TIPS.length)] : undefined;
/** When set, a non-animating render shows the intro's first frame instead of the resting frame. */
#holdIntroFirstFrame = false;
/** Per-process tip so re-renders (intro, LSP updates, splash swap) don't shuffle it. */
readonly #tip: string | undefined = PROCESS_TIP;
// Render cache: the welcome box is the first transcript-area component, so
// returning a stable array reference keeps the whole frame prefix stable.
// Bypassed while the intro animation runs (every frame differs).
#cachedWidth = -1;
#cachedLines: string[] | undefined;
constructor(
private readonly version: string,
private modelName: string,
private providerName: string,
private recentSessions: RecentSession[] = [],
private recentSessions: RecentSession[] | null = [],
private lspServers: LspServerInfo[] = [],
) {}
invalidate(): void {}
invalidate(): void {
this.#cachedWidth = -1;
this.#cachedLines = undefined;
}
/**
* Freeze the logo on the intro animation's first frame. The pre-TUI startup
* splash uses this so the in-TUI intro — which starts at that exact frame —
* picks up seamlessly from the splash's static box.
*/
holdIntroFirstFrame(): void {
this.#holdIntroFirstFrame = true;
this.invalidate();
}
/**
* Play a one-shot intro that sweeps the gradient through every phase
@@ -78,6 +116,7 @@ export class WelcomeComponent implements Component {
*/
playIntro(requestRender: () => void): void {
this.#stopAnimation();
this.#holdIntroFirstFrame = false;
this.#animStart = performance.now();
requestRender();
this.#animTimer = setInterval(() => {
@@ -95,22 +134,43 @@ export class WelcomeComponent implements Component {
this.#animTimer = null;
}
this.#animStart = null;
// The settled (resting) frame differs from the last intro frame.
this.invalidate();
}
setModel(modelName: string, providerName: string): void {
this.modelName = modelName;
this.providerName = providerName;
this.invalidate();
}
setRecentSessions(sessions: RecentSession[]): void {
this.recentSessions = sessions;
this.invalidate();
}
setLspServers(servers: LspServerInfo[]): void {
this.lspServers = servers;
this.invalidate();
}
render(termWidth: number): string[] {
render(termWidth: number): readonly string[] {
const animating = this.#animStart != null;
if (!animating && this.#cachedLines && this.#cachedWidth === termWidth) {
return this.#cachedLines;
}
const lines = this.#renderLines(termWidth);
if (animating) {
this.#cachedLines = undefined;
this.#cachedWidth = -1;
} else {
this.#cachedLines = lines;
this.#cachedWidth = termWidth;
}
return lines;
}
#renderLines(termWidth: number): string[] {
// Box dimensions - responsive with max width and small-terminal support
const maxWidth = 100;
const boxWidth = Math.min(maxWidth, Math.max(0, termWidth - 2));
@@ -157,7 +217,9 @@ export class WelcomeComponent implements Component {
// Recent sessions content
const sessionLines: string[] = [];
if (this.recentSessions.length === 0) {
if (this.recentSessions === null) {
sessionLines.push(` ${theme.fg("dim", "Loading…")}`);
} else if (this.recentSessions.length === 0) {
sessionLines.push(` ${theme.fg("dim", "No recent sessions")}`);
} else {
// Reserve width for the bullet prefix (" • ") and the trailing " (timeAgo)"
@@ -165,7 +227,7 @@ export class WelcomeComponent implements Component {
// absorbs whatever space is left.
const bulletPrefix = ` ${theme.md.bullet} `;
const prefixWidth = visibleWidth(bulletPrefix);
for (const session of this.recentSessions.slice(0, 3)) {
for (const session of this.recentSessions.slice(0, WELCOME_SESSION_SLOTS)) {
const timeSuffixRaw = ` (${session.timeAgo})`;
const timeWidth = visibleWidth(timeSuffixRaw);
const nameBudget = Math.max(1, rightCol - prefixWidth - timeWidth);
@@ -176,23 +238,33 @@ export class WelcomeComponent implements Component {
);
}
}
// Pad to the fixed slot count so the box doesn't grow when sessions load in.
while (sessionLines.length < WELCOME_SESSION_SLOTS) {
sessionLines.push("");
}
// LSP servers content
const lspLines: string[] = [];
if (this.lspServers.length === 0) {
lspLines.push(` ${theme.fg("dim", "No LSP servers")}`);
} else {
for (const server of this.lspServers) {
for (const server of this.lspServers.slice(0, WELCOME_LSP_SLOTS)) {
const icon =
server.status === "ready"
? theme.styledSymbol("status.enabled", "success")
: server.status === "connecting"
? theme.styledSymbol("status.pending", "muted")
: theme.styledSymbol("status.error", "error");
: server.status === "available"
? theme.styledSymbol("status.enabled", "dim")
: server.status === "connecting"
? theme.styledSymbol("status.pending", "muted")
: theme.styledSymbol("status.error", "error");
const exts = server.fileTypes.slice(0, 3).join(" ");
lspLines.push(` ${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", exts)}`);
}
}
// Pad to the fixed slot count so the box height doesn't depend on server count.
while (lspLines.length < WELCOME_LSP_SLOTS) {
lspLines.push("");
}
// Right column
const rightLines = [
@@ -305,23 +377,12 @@ export class WelcomeComponent implements Component {
return str + padding(width - visLen);
}
/** Pick the logo frame for the current intro phase, or the resting frame. */
/** Pick the logo frame for the current intro phase, or the resting/held frame. */
#currentLogoFrame(): readonly string[] {
if (this.#animStart == null) return REST_FRAME;
if (this.#animStart == null) return this.#holdIntroFirstFrame ? INTRO_FIRST_FRAME : REST_FRAME;
const elapsed = performance.now() - this.#animStart;
if (elapsed >= INTRO_MS) return REST_FRAME;
// Ease-out cubic so the spin decelerates into the resting state.
const progress = elapsed / INTRO_MS;
const eased = 1 - (1 - progress) ** 3;
// Sweep backward through INTRO_SWEEPS full rotations so the gradient
// visibly spins multiple times. `eased == 1` → phase = 0 = resting frame.
const phase = ((((1 - eased) * INTRO_SWEEPS) % 1) + 1) % 1;
// Shine traverses the diagonal at a steady pace, decoupled from the
// gradient phase so the two layers parallax. Strength fades out with
// the same ease-out curve so the highlight is gone by the resting frame.
const shinePos = (((progress * INTRO_SHINE_TRAVERSALS) % 1) + 1) % 1;
const shineStrength = (1 - eased) ** 1.5;
return gradientLogo(PI_LOGO, phase, { strength: shineStrength, pos: shinePos });
return introLogoFrame(elapsed / INTRO_MS);
}
}
@@ -431,5 +492,26 @@ const INTRO_SWEEPS = 2.5;
/** Number of times the shine highlight crosses the diagonal across the intro. */
const INTRO_SHINE_TRAVERSALS = 3;
/**
* Logo frame for a normalized intro progress in [0, 1).
*
* Ease-out cubic so the spin decelerates into the resting state. The gradient
* sweeps backward through INTRO_SWEEPS full rotations (`eased == 1` → phase =
* 0 = resting frame) while the shine traverses the diagonal at a steady pace,
* decoupled from the gradient phase so the two layers parallax; its strength
* fades with the same ease-out curve so the highlight is gone by the resting
* frame.
*/
function introLogoFrame(progress: number): string[] {
const eased = 1 - (1 - progress) ** 3;
const phase = ((((1 - eased) * INTRO_SWEEPS) % 1) + 1) % 1;
const shinePos = (((progress * INTRO_SHINE_TRAVERSALS) % 1) + 1) % 1;
const shineStrength = (1 - eased) ** 1.5;
return gradientLogo(PI_LOGO, phase, { strength: shineStrength, pos: shinePos });
}
/** First intro frame, cached for splash-held renders (resize re-renders reuse it). */
const INTRO_FIRST_FRAME = introLogoFrame(0);
/** Resting gradient frame, cached for re-renders outside of the intro. */
const REST_FRAME = gradientLogo(PI_LOGO, 0);