From b0fec42218cffdcbc41f8dbe7af8f4eed8b674a8 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 10 Jun 2026 07:21:57 +0200 Subject: [PATCH] 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. --- docs/sdk.md | 2 +- docs/tools/lsp.md | 2 +- packages/coding-agent/CHANGELOG.md | 3 +- packages/coding-agent/src/lsp/index.ts | 9 +- packages/coding-agent/src/main.ts | 59 +++++++- .../src/modes/components/welcome.ts | 134 ++++++++++++++---- .../modes/controllers/command-controller.ts | 8 +- packages/coding-agent/src/sdk.ts | 9 +- .../src/session/session-manager.ts | 4 +- .../test/welcome-fixed-height.test.ts | 48 +++++++ 10 files changed, 238 insertions(+), 40 deletions(-) create mode 100644 packages/coding-agent/test/welcome-fixed-height.test.ts diff --git a/docs/sdk.md b/docs/sdk.md index f0175bd4d..8cdfe5140 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -320,7 +320,7 @@ Use `setToolUIContext(...)` only if your embedder provides UI capabilities that - `options.hasUI === true` (interactive TUI), **and** - the `lsp.lazy` setting is disabled (it defaults to `true`). - With `lsp.lazy` enabled — the default — no language servers are launched at startup at all; each server cold-starts on first use, i.e. when the agent invokes the `lsp` tool or an edit/write touches a file whose extension matches the server's `fileTypes`. Print / script / RPC / ACP invocations (`hasUI=false`) skip the warmup regardless of the setting: they don't render the warmup status indicator and typically finish before the language servers would stabilize, so warming them just spends CPU parsing big `initialize` responses concurrently with the LLM stream consumer and jitters perceived latency. Tools that actually need an LSP server still spin one up on demand through `getOrCreateClient()` — only the _startup_ warmup is skipped. The returned `lspServers` field in `CreateAgentSessionResult` is therefore `undefined` (not an empty array) whenever the warmup branch was bypassed. + With `lsp.lazy` enabled — the default — no language servers are launched at startup at all; each server cold-starts on first use, i.e. when the agent invokes the `lsp` tool or an edit/write touches a file whose extension matches the server's `fileTypes`. Print / script / RPC / ACP invocations (`hasUI=false`) skip the warmup regardless of the setting: they don't render the warmup status indicator and typically finish before the language servers would stabilize, so warming them just spends CPU parsing big `initialize` responses concurrently with the LLM stream consumer and jitters perceived latency. Tools that actually need an LSP server still spin one up on demand through `getOrCreateClient()` — only the _startup_ warmup is skipped. The returned `lspServers` field in `CreateAgentSessionResult` is still populated for UI sessions in lazy mode — recognized servers are discovered (no processes spawned) and reported with status `"available"` so the welcome screen and `/status` can list them; it is `undefined` only when `enableLsp === false` or `hasUI === false`. ## Minimal controlled embed example diff --git a/docs/tools/lsp.md b/docs/tools/lsp.md index aaf6c40f9..fbb059ff7 100644 --- a/docs/tools/lsp.md +++ b/docs/tools/lsp.md @@ -310,5 +310,5 @@ Same as `definition`, but sends `textDocument/implementation` and reports `imple - `reload` does not recreate a client immediately after killing it; the next request triggers reinitialization. - `workspace/applyEdit` can apply edits initiated by the server outside the direct tool action result path. - `detectLspmux()` can be disabled with `PI_DISABLE_LSPMUX=1`; only `rust-analyzer` is in `DEFAULT_SUPPORTED_SERVERS`. -- Startup LSP warmup (`discoverStartupLspServers(cwd)` in `sdk.ts`) is gated on `enableLsp && options.hasUI && !settings.get("lsp.lazy")` — `lsp.lazy` defaults to `true`, so by default servers cold-start through `getOrCreateClient()` on first use (lsp tool call or edit/write on a matching file type). Print/RPC/ACP/script sessions skip the warmup regardless. See `docs/sdk.md` § Startup performance. +- Startup LSP discovery (`discoverStartupLspServers(cwd)` in `sdk.ts`) runs for `enableLsp && options.hasUI`; the background warmup additionally requires `!settings.get("lsp.lazy")`. `lsp.lazy` defaults to `true`, so by default discovered servers are surfaced with status `"available"` (gray dot in the welcome screen) and cold-start through `getOrCreateClient()` on first use (lsp tool call or edit/write on a matching file type). Print/RPC/ACP/script sessions skip discovery and warmup entirely. See `docs/sdk.md` § Startup performance. - `configCache` is per-process and never auto-invalidated; config changes require a fresh process to be observed by `getConfig()` callers. \ No newline at end of file diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d6f37acfc..3a8c438b0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,7 +8,7 @@ - New `omp usage` command: a detailed per-account breakdown of provider usage limits (bars, windows, reset times, plan metadata) covering every stored credential — accounts with no usage endpoint are listed as "no usage data" rows. Each provider section ends with per-window capacity stats ("capacity: 5h → 2.40/5 accounts used (2.60× quota left)"). Flags: `--provider` to filter, `--json` for the broker-shaped report payload, and `--redact` to mask account emails/ids down to a two-char anchor plus a minimal middle-out differentiator (`ca*9*`) for screenshot-safe sharing. - Startup hangs are now self-diagnosing (speculative fix for the "zero output, hangs even on `omp -h`" report class): a watchdog prints a stderr line every 10s naming the deepest in-flight startup phase (via `logger.openSpanPath()`) until a mode runner takes over, pausing around legitimate interactive waits (fork/move prompts, the `--resume` session picker); `PI_DEBUG_STARTUP` is restored as streaming synchronous `[startup]` phase markers covering command-module imports and the native addon load, which the post-startup `PI_TIMING` tree structurally cannot show for a hang; and waiting on piped-stdin EOF announces itself after 1s instead of blocking silently. - npm installs now execute a prebundled single-file entry: the published `bin.omp` points at `dist/cli.js` (built by `scripts/bundle-dist.ts` during `prepack`, ~18MB minified, natives/transformers/mupdf external), cutting npm-install cold start by roughly 3x versus transpiling the raw TypeScript graph per launch; `src/**` stays published for SDK consumers and worker fallbacks. The on-repo manifest keeps `bin.omp` at `src/cli.ts` — release rewrites it via the `publishBin` override in `scripts/ci-release-publish.ts` — so source installs (`bun link`, `install.sh --source`) keep working without a build step -- Plain interactive TTY launches print a dim two-line startup splash (`omp ` / `Initializing session…`) before session construction so first pixels appear immediately; suppressed for resume/fork/continue flows, quiet mode, `PI_TIMING`, and non-TTY stdio +- Plain interactive TTY launches render the full welcome box (logo held on the intro's first frame, model, tips, LSP servers, recent-sessions loading placeholder) before session construction, clearing the screen so the TUI's first paint replaces it in place; the welcome box now reserves fixed slot counts (4 recent sessions, 4 LSP servers) so its height no longer shifts between the splash, loading, and loaded states. First-run launches keep the dim two-line splash (`omp ` / `Initializing session…`); resume/fork/continue flows, quiet mode, `PI_TIMING`, and non-TTY stdio still skip it - Added `/stats` to launch the local stats dashboard from an active session, syncing session files first and opening the same browser dashboard as `omp stats`. - `/settings` now supports type-to-search filtering on setting labels, paths, descriptions, and values; Escape clears an active search before closing the panel. @@ -44,6 +44,7 @@ ### Fixed +- Fixed the welcome screen showing "No LSP servers" when `lsp.lazy` is enabled: recognized servers are now still discovered at startup and listed with a dim "available" dot (no warmup), and `/status` reports them as `available` instead of omitting the section - Fixed an uncaught `questions.map is not a function` TUI crash in the ask tool's call renderer when a model double-encoded the `questions` array as a JSON string (a bare string passes a truthy `.length` check but has no `.map`): the renderer now normalizes untrusted call args — parsing double-encoded `questions`, dropping malformed entries/options, and falling back to the "No question provided" frame instead of throwing - Fixed model-provider detection for append-only mode, authoritative Vertex endpoint checks, and upstream-routing selection by switching from URL substring checks to catalog host-matching helpers - Fixed pasting into the ask tool's "Other (type your own)" text box (and hook input/editor dialogs) on terminals with OSC 5522 enhanced paste (kitty protocol): the enhanced-paste focus routing only targets components exposing a `pasteText` hook, and the dialog wrappers had none, so the payload was stuffed into the main prompt editor hidden behind the dialog. `HookEditorComponent` and `HookInputComponent` now forward `pasteText` to their inner editor/input (pasting also resets the input dialog's timeout countdown like any keystroke). diff --git a/packages/coding-agent/src/lsp/index.ts b/packages/coding-agent/src/lsp/index.ts index 2fab9f38b..a8cf78bed 100644 --- a/packages/coding-agent/src/lsp/index.ts +++ b/packages/coding-agent/src/lsp/index.ts @@ -105,7 +105,7 @@ export const LSP_READONLY_ACTIONS: ReadonlySet = new Set([ export interface LspStartupServerInfo { name: string; - status: "connecting" | "ready" | "error"; + status: "connecting" | "ready" | "error" | "available"; fileTypes: string[]; error?: string; } @@ -121,11 +121,14 @@ export interface LspWarmupOptions { onConnecting?: (serverNames: string[]) => void; } -export function discoverStartupLspServers(cwd: string): LspStartupServerInfo[] { +export function discoverStartupLspServers( + cwd: string, + status: LspStartupServerInfo["status"] = "connecting", +): LspStartupServerInfo[] { const config = loadConfig(cwd); return getLspServers(config).map(([name, serverConfig]) => ({ name, - status: "connecting", + status, fileTypes: serverConfig.fileTypes, })); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 772aabbc8..3fe16d8b0 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -51,6 +51,7 @@ import { ExtensionRunner } from "./extensibility/extensions/runner"; import type { ExtensionUIContext } from "./extensibility/extensions/types"; import { scheduleMarketplaceAutoUpdate } from "./extensibility/plugins/marketplace-auto-update"; import type { MCPManager } from "./mcp"; +import { WelcomeComponent } from "./modes/components/welcome"; import { InteractiveMode } from "./modes/interactive-mode"; import type { PrintModeOptions } from "./modes/print-mode"; import { CURRENT_SETUP_VERSION } from "./modes/setup-version"; @@ -69,7 +70,7 @@ import { resolveResumableSession, type SessionInfo, SessionManager } from "./ses import { resolvePromptInput } from "./system-prompt"; import { initTelemetryExport, isTelemetryExportEnabled } from "./telemetry-export"; import { AUTO_THINKING } from "./thinking"; -import type { LspStartupServerInfo } from "./tools"; +import { discoverStartupLspServers, type LspStartupServerInfo } from "./tools"; import { getChangelogPath, getNewEntries, @@ -91,12 +92,37 @@ function maybeShowStartupSplash(options: { resuming: boolean; quiet: boolean; version: string; + setupPending: boolean; + modelName?: string; + providerName?: string; + lspServers?: LspStartupServerInfo[]; }): void { if (!options.isInteractive) return; if (options.resuming || options.quiet) return; if ($env.PI_TIMING) return; if (!process.stdin.isTTY || !process.stdout.isTTY) return; - process.stdout.write(`${chalk.dim(`omp ${options.version}`)}\n${chalk.dim("Initializing session…")}\n`); + // First-run launches go straight into the setup wizard, which paints its own + // splash — keep the minimal two-line notice there. + if (options.setupPending) { + process.stdout.write(`${chalk.dim(`omp ${options.version}`)}\n${chalk.dim("Initializing session…")}\n`); + return; + } + // Render the same welcome box the TUI paints first: recent sessions as a + // loading placeholder (the fixed slot count keeps the box height stable) and + // the logo held on the intro animation's first frame so the in-TUI intro + // continues from the frame shown here. Clearing the screen first puts the + // box at the same origin the TUI's first full paint (clearScrollback) uses, + // so the live welcome replaces this frame in place without shifting. + const welcome = new WelcomeComponent( + options.version, + options.modelName ?? "", + options.providerName ?? "", + null, + options.lspServers ?? [], + ); + welcome.holdIntroFirstFrame(); + const lines = welcome.render(process.stdout.columns || 80); + process.stdout.write(`\x1b[2J\x1b[H\x1b[3J\n${lines.join("\n")}\n`); } async function checkForNewVersion(currentVersion: string): Promise { @@ -1202,11 +1228,40 @@ export async function runRootCommand( stdinContent: pipedInput, }); + // Resolve the model the session will most likely start with so the splash + // box matches the final welcome screen (the raw role selector, e.g. + // "anthropic/claude-fable-5:high", is wider than the left column and would + // collapse the box into the single-column layout). + let splashModel = sessionOptions.model; + if (!splashModel) { + const remembered = settingsInstance.getModelRole("default"); + if (remembered) { + splashModel = resolveModelRoleValue(remembered, modelRegistry.getAll(), { + settings: settingsInstance, + matchPreferences: modelMatchPreferences, + modelRegistry, + }).model; + } + } + // Mirror createAgentSession's startup LSP discovery (sync and cheap: root + // markers + binary lookup) so the splash lists the same servers the live + // welcome screen will show. + const splashLspServers = + (sessionOptions.enableLsp ?? true) + ? discoverStartupLspServers( + sessionOptions.cwd ?? cwd, + settingsInstance.get("lsp.lazy") ? "available" : "connecting", + ) + : []; maybeShowStartupSplash({ isInteractive, resuming: Boolean(parsedArgs.continue || parsedArgs.resume || parsedArgs.fork), quiet: settingsInstance.get("startup.quiet"), version: VERSION, + setupPending: deps.forceSetupWizard === true || settingsInstance.get("setupVersion") < CURRENT_SETUP_VERSION, + modelName: splashModel?.name, + providerName: splashModel?.provider, + lspServers: splashLspServers, }); const { session, setToolUIContext, modelFallbackMessage, lspServers, mcpManager } = await createSession({ diff --git a/packages/coding-agent/src/modes/components/welcome.ts b/packages/coding-agent/src/modes/components/welcome.ts index 1517cd333..caf68858f 100644 --- a/packages/coding-agent/src/modes/components/welcome.ts +++ b/packages/coding-agent/src/modes/components/welcome.ts @@ -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 | 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); diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 03d4e13cf..7bec04c68 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -315,7 +315,13 @@ export class CommandController { info += `\n${theme.bold("LSP Servers")}\n`; for (const server of this.ctx.lspServers) { const statusColor = - server.status === "ready" ? "success" : server.status === "connecting" ? "warning" : "error"; + server.status === "ready" + ? "success" + : server.status === "available" + ? "dim" + : server.status === "connecting" + ? "warning" + : "error"; const statusText = server.status === "error" && server.error ? `${server.status}: ${server.error}` : server.status; info += `${theme.fg("dim", `${server.name}:`)} ${theme.fg(statusColor, statusText)} ${theme.fg("dim", `(${server.fileTypes.join(", ")})`)}\n`; diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index e7a5843d0..de869ee2f 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -2381,14 +2381,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } // Start LSP warmup in the background so startup does not block on language server initialization. - // With `lsp.lazy` (the default) the warmup is skipped entirely: servers cold-start on first use — - // the lsp tool or an edit/write touching a matching file type — through `getOrCreateClient`. + // With `lsp.lazy` (the default) the warmup is skipped: recognized servers are still discovered and + // surfaced in the UI as "available", but cold-start on first use — the lsp tool or an edit/write + // touching a matching file type — through `getOrCreateClient`. // Print/script invocations (`hasUI=false`) skip it regardless: they don't render the warmup status // indicator AND typically finish before LSP servers would have stabilized — warming them just spends // CPU parsing big `initialize` responses concurrently with the LLM stream consumer, jittering // perceived latency. let lspServers: CreateAgentSessionResult["lspServers"]; - if (enableLsp && options.hasUI && !settings.get("lsp.lazy")) { + if (enableLsp && options.hasUI && settings.get("lsp.lazy")) { + lspServers = discoverStartupLspServers(cwd, "available"); + } else if (enableLsp && options.hasUI) { lspServers = discoverStartupLspServers(cwd); if (lspServers.length > 0) { void (async () => { diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 1d42c9f57..5252730a7 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1516,10 +1516,10 @@ class NdjsonFileWriter { } } -/** Get recent sessions for display in welcome screen */ +/** Get recent sessions for display in welcome screen (which reserves WELCOME_SESSION_SLOTS rows) */ export async function getRecentSessions( sessionDir: string, - limit = 3, + limit = 4, storage: SessionStorage = new FileSessionStorage(), ): Promise { const sessions = await getSortedSessions(sessionDir, storage); diff --git a/packages/coding-agent/test/welcome-fixed-height.test.ts b/packages/coding-agent/test/welcome-fixed-height.test.ts new file mode 100644 index 000000000..605155f24 --- /dev/null +++ b/packages/coding-agent/test/welcome-fixed-height.test.ts @@ -0,0 +1,48 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { + type LspServerInfo, + type RecentSession, + WelcomeComponent, +} from "@oh-my-pi/pi-coding-agent/modes/components/welcome"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; + +beforeAll(async () => { + await initTheme(false); +}); + +function lspServers(count: number): LspServerInfo[] { + return Array.from({ length: count }, (_, i) => ({ + name: `server-${i}`, + status: "connecting" as const, + fileTypes: [".ts"], + })); +} + +function sessions(count: number): RecentSession[] { + return Array.from({ length: count }, (_, i) => ({ name: `session ${i}`, timeAgo: "just now" })); +} + +describe("WelcomeComponent fixed geometry", () => { + // The pre-TUI startup splash renders the box before recent sessions are + // loaded; the TUI then repaints it with live data at the same origin. Any + // height difference between those two states shows up as a visible jump. + it("keeps box height constant from splash placeholder to loaded state", () => { + const splash = new WelcomeComponent("1.0.0", "Model", "provider", null, lspServers(2)); + splash.holdIntroFirstFrame(); + const splashLines = splash.render(120); + const loaded = new WelcomeComponent("1.0.0", "Model", "provider", sessions(4), lspServers(2)); + expect(splashLines.length).toBe(loaded.render(120).length); + expect(Bun.stripANSI(splashLines.join("\n"))).toContain("Loading…"); + }); + + it("renders the same height regardless of session and LSP server counts", () => { + const heights = new Set(); + for (const sessionCount of [0, 1, 4, 6]) { + for (const lspCount of [0, 1, 4, 6]) { + const welcome = new WelcomeComponent("1.0.0", "Model", "provider", sessions(sessionCount), lspServers(lspCount)); + heights.add(welcome.render(120).length); + } + } + expect(heights.size).toBe(1); + }); +});