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:
+1
-1
@@ -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
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user