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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user