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:21:57 +02:00
parent a25d521cab
commit b0fec42218
10 changed files with 238 additions and 40 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+2 -1
View File
@@ -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 <version>` / `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 <version>` / `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).
+6 -3
View File
@@ -105,7 +105,7 @@ export const LSP_READONLY_ACTIONS: ReadonlySet<string> = 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,
}));
}
+57 -2
View File
@@ -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<string | undefined> {
@@ -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({
@@ -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);
@@ -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`;
+6 -3
View File
@@ -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 () => {
@@ -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<RecentSessionInfo[]> {
const sessions = await getSortedSessions(sessionDir, storage);
@@ -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<number>();
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);
});
});