@f5-sales-demo/xcsh 19.56.2 → 19.57.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.56.2",
4
+ "version": "19.57.1",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -55,13 +55,13 @@
55
55
  "dependencies": {
56
56
  "@agentclientprotocol/sdk": "0.16.1",
57
57
  "@mozilla/readability": "^0.6",
58
- "@f5-sales-demo/xcsh-stats": "19.56.2",
59
- "@f5-sales-demo/pi-agent-core": "19.56.2",
60
- "@f5-sales-demo/pi-ai": "19.56.2",
61
- "@f5-sales-demo/pi-natives": "19.56.2",
62
- "@f5-sales-demo/pi-resource-management": "19.56.2",
63
- "@f5-sales-demo/pi-tui": "19.56.2",
64
- "@f5-sales-demo/pi-utils": "19.56.2",
58
+ "@f5-sales-demo/xcsh-stats": "19.57.1",
59
+ "@f5-sales-demo/pi-agent-core": "19.57.1",
60
+ "@f5-sales-demo/pi-ai": "19.57.1",
61
+ "@f5-sales-demo/pi-natives": "19.57.1",
62
+ "@f5-sales-demo/pi-resource-management": "19.57.1",
63
+ "@f5-sales-demo/pi-tui": "19.57.1",
64
+ "@f5-sales-demo/pi-utils": "19.57.1",
65
65
  "@sinclair/typebox": "^0.34",
66
66
  "@xterm/headless": "^6.0",
67
67
  "ajv": "^8.20",
@@ -284,7 +284,7 @@ async function loadExtension(
284
284
  const resolvedPath = resolvePath(extensionPath, cwd);
285
285
 
286
286
  try {
287
- const module = await import(resolvedPath);
287
+ const module = await logger.time("ext:loadModule", () => import(resolvedPath));
288
288
  const factory = (module.default ?? module) as ExtensionFactory;
289
289
 
290
290
  if (typeof factory !== "function") {
@@ -295,7 +295,13 @@ async function loadExtension(
295
295
  }
296
296
 
297
297
  const extension = createExtension(extensionPath, resolvedPath);
298
- const api = new ConcreteExtensionAPI(await import("@f5-sales-demo/xcsh"), extension, runtime, cwd, eventBus);
298
+ const api = new ConcreteExtensionAPI(
299
+ await logger.time("ext:barrelImport", () => import("@f5-sales-demo/xcsh")),
300
+ extension,
301
+ runtime,
302
+ cwd,
303
+ eventBus,
304
+ );
299
305
  await factory(api);
300
306
 
301
307
  return { extension, error: null };
@@ -316,7 +322,13 @@ export async function loadExtensionFromFactory(
316
322
  name = "<inline>",
317
323
  ): Promise<Extension> {
318
324
  const extension = createExtension(name, name);
319
- const api = new ConcreteExtensionAPI(await import("@f5-sales-demo/xcsh"), extension, runtime, cwd, eventBus);
325
+ const api = new ConcreteExtensionAPI(
326
+ await logger.time("ext:barrelImport", () => import("@f5-sales-demo/xcsh")),
327
+ extension,
328
+ runtime,
329
+ cwd,
330
+ eventBus,
331
+ );
320
332
  await factory(api);
321
333
  return extension;
322
334
  }
@@ -540,7 +552,9 @@ export async function discoverAndLoadExtensions(
540
552
  };
541
553
 
542
554
  // 1. Discover extension modules via capability API (native .omp/.pi only)
543
- const discovered = await loadCapability<ExtensionModule>(extensionModuleCapability.id, { cwd });
555
+ const discovered = await logger.time("ext:discoverCapability", () =>
556
+ loadCapability<ExtensionModule>(extensionModuleCapability.id, { cwd }),
557
+ );
544
558
  for (const ext of discovered.items) {
545
559
  if (ext._source.provider !== "native") continue;
546
560
  if (isDisabledName(ext.name)) continue;
@@ -548,29 +562,31 @@ export async function discoverAndLoadExtensions(
548
562
  }
549
563
 
550
564
  // 2. Discover extension entry points from installed plugins (node_modules path)
551
- addPaths(await getAllPluginExtensionPaths(cwd));
565
+ addPaths(await logger.time("ext:pluginPaths", () => getAllPluginExtensionPaths(cwd)));
552
566
 
553
567
  // 2b. Discover extension entry points from marketplace-cached plugins
554
- for (const root of getPreloadedPluginRoots()) {
555
- try {
556
- const pkgPath = path.join(root.path, "package.json");
557
- const pkg = await Bun.file(pkgPath).json();
558
- const manifest = pkg?.xcsh ?? pkg?.pi;
559
- const extensions = manifest?.extensions;
560
- if (Array.isArray(extensions)) {
561
- for (const entry of extensions) {
562
- if (typeof entry !== "string") continue;
563
- if (path.isAbsolute(entry) || entry.includes("..")) continue;
564
- const resolved = path.resolve(root.path, entry);
565
- if (!resolved.startsWith(root.path + path.sep) && resolved !== root.path) continue;
566
- if (isDisabledName(getExtensionNameFromPath(resolved))) continue;
567
- addPath(resolved);
568
+ await logger.time("ext:marketplaceRoots", async () => {
569
+ for (const root of getPreloadedPluginRoots()) {
570
+ try {
571
+ const pkgPath = path.join(root.path, "package.json");
572
+ const pkg = await Bun.file(pkgPath).json();
573
+ const manifest = pkg?.xcsh ?? pkg?.pi;
574
+ const extensions = manifest?.extensions;
575
+ if (Array.isArray(extensions)) {
576
+ for (const entry of extensions) {
577
+ if (typeof entry !== "string") continue;
578
+ if (path.isAbsolute(entry) || entry.includes("..")) continue;
579
+ const resolved = path.resolve(root.path, entry);
580
+ if (!resolved.startsWith(root.path + path.sep) && resolved !== root.path) continue;
581
+ if (isDisabledName(getExtensionNameFromPath(resolved))) continue;
582
+ addPath(resolved);
583
+ }
568
584
  }
585
+ } catch {
586
+ // No package.json or invalid — skip
569
587
  }
570
- } catch {
571
- // No package.json or invalid — skip
572
588
  }
573
- }
589
+ });
574
590
 
575
591
  // 3. Explicitly configured paths
576
592
  for (const configuredPath of configuredPaths) {
@@ -601,7 +617,7 @@ export async function discoverAndLoadExtensions(
601
617
  }
602
618
 
603
619
  const resolvedEventBus = eventBus ?? new EventBus();
604
- const result = await loadExtensions(allPaths, cwd, resolvedEventBus);
605
- await loadBundledExtensions(result, cwd, resolvedEventBus, isDisabledName);
620
+ const result = await logger.time("ext:loadLoop", () => loadExtensions(allPaths, cwd, resolvedEventBus));
621
+ await logger.time("ext:loadBundled", () => loadBundledExtensions(result, cwd, resolvedEventBus, isDisabledName));
606
622
  return result;
607
623
  }
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.56.2",
21
- "commit": "864f91ecbd26a814dda896b5c402a0a30494e942",
22
- "shortCommit": "864f91e",
20
+ "version": "19.57.1",
21
+ "commit": "afdece90c0203627849699502d77b189fd3fb320",
22
+ "shortCommit": "afdece9",
23
23
  "branch": "main",
24
- "tag": "v19.56.2",
25
- "commitDate": "2026-07-04T00:19:20Z",
26
- "buildDate": "2026-07-04T00:42:34.771Z",
24
+ "tag": "v19.57.1",
25
+ "commitDate": "2026-07-04T20:58:43Z",
26
+ "buildDate": "2026-07-04T21:21:44.204Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/864f91ecbd26a814dda896b5c402a0a30494e942",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.56.2"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/afdece90c0203627849699502d77b189fd3fb320",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.57.1"
33
33
  };
@@ -282,6 +282,8 @@
282
282
  "login.wizard.configSaved": "Configuration saved. You're ready to go!",
283
283
  "login.wizard.failed": "Connection failed — {error}",
284
284
  "login.wizard.retryHint": "Press Escape to skip, or enter a new value:",
285
+ "gate.noProvider": "No LLM provider configured — run /login to connect one before sending a message.",
286
+ "gate.noProviderBlock": "No LLM provider configured. Run /login to connect one, then retry. (/help and /model still work.)",
285
287
  "plugins.tabs.installed": "مثبتة",
286
288
  "plugins.tabs.recommended": "موصى بها",
287
289
  "plugins.tabs.discover": "استكشاف",
@@ -282,6 +282,8 @@
282
282
  "login.wizard.configSaved": "Configuration saved. You're ready to go!",
283
283
  "login.wizard.failed": "Connection failed — {error}",
284
284
  "login.wizard.retryHint": "Press Escape to skip, or enter a new value:",
285
+ "gate.noProvider": "No LLM provider configured — run /login to connect one before sending a message.",
286
+ "gate.noProviderBlock": "No LLM provider configured. Run /login to connect one, then retry. (/help and /model still work.)",
285
287
  "plugins.tabs.installed": "Installiert",
286
288
  "plugins.tabs.recommended": "Empfohlen",
287
289
  "plugins.tabs.discover": "Entdecken",
@@ -303,6 +303,8 @@
303
303
  "login.wizard.configSaved": "Configuration saved. You're ready to go!",
304
304
  "login.wizard.failed": "Connection failed — {error}",
305
305
  "login.wizard.retryHint": "Press Escape to skip, or enter a new value:",
306
+ "gate.noProvider": "No LLM provider configured — run /login to connect one before sending a message.",
307
+ "gate.noProviderBlock": "No LLM provider configured. Run /login to connect one, then retry. (/help and /model still work.)",
306
308
 
307
309
  "plugins.tabs.installed": "Installed",
308
310
  "plugins.tabs.recommended": "Recommended",
@@ -282,6 +282,8 @@
282
282
  "login.wizard.configSaved": "Configuration saved. You're ready to go!",
283
283
  "login.wizard.failed": "Connection failed — {error}",
284
284
  "login.wizard.retryHint": "Press Escape to skip, or enter a new value:",
285
+ "gate.noProvider": "No LLM provider configured — run /login to connect one before sending a message.",
286
+ "gate.noProviderBlock": "No LLM provider configured. Run /login to connect one, then retry. (/help and /model still work.)",
285
287
  "plugins.tabs.installed": "Instalados",
286
288
  "plugins.tabs.recommended": "Recomendados",
287
289
  "plugins.tabs.discover": "Descubrir",
@@ -282,6 +282,8 @@
282
282
  "login.wizard.configSaved": "Configuration saved. You're ready to go!",
283
283
  "login.wizard.failed": "Connection failed — {error}",
284
284
  "login.wizard.retryHint": "Press Escape to skip, or enter a new value:",
285
+ "gate.noProvider": "No LLM provider configured — run /login to connect one before sending a message.",
286
+ "gate.noProviderBlock": "No LLM provider configured. Run /login to connect one, then retry. (/help and /model still work.)",
285
287
  "plugins.tabs.installed": "Installés",
286
288
  "plugins.tabs.recommended": "Recommandés",
287
289
  "plugins.tabs.discover": "Découvrir",
@@ -282,6 +282,8 @@
282
282
  "login.wizard.configSaved": "Configuration saved. You're ready to go!",
283
283
  "login.wizard.failed": "Connection failed — {error}",
284
284
  "login.wizard.retryHint": "Press Escape to skip, or enter a new value:",
285
+ "gate.noProvider": "No LLM provider configured — run /login to connect one before sending a message.",
286
+ "gate.noProviderBlock": "No LLM provider configured. Run /login to connect one, then retry. (/help and /model still work.)",
285
287
  "plugins.tabs.installed": "इंस्टॉल किए गए",
286
288
  "plugins.tabs.recommended": "अनुशंसित",
287
289
  "plugins.tabs.discover": "खोजें",
@@ -282,6 +282,8 @@
282
282
  "login.wizard.configSaved": "Configuration saved. You're ready to go!",
283
283
  "login.wizard.failed": "Connection failed — {error}",
284
284
  "login.wizard.retryHint": "Press Escape to skip, or enter a new value:",
285
+ "gate.noProvider": "No LLM provider configured — run /login to connect one before sending a message.",
286
+ "gate.noProviderBlock": "No LLM provider configured. Run /login to connect one, then retry. (/help and /model still work.)",
285
287
  "plugins.tabs.installed": "Installati",
286
288
  "plugins.tabs.recommended": "Consigliati",
287
289
  "plugins.tabs.discover": "Scopri",
@@ -282,6 +282,8 @@
282
282
  "login.wizard.configSaved": "Configuration saved. You're ready to go!",
283
283
  "login.wizard.failed": "Connection failed — {error}",
284
284
  "login.wizard.retryHint": "Press Escape to skip, or enter a new value:",
285
+ "gate.noProvider": "No LLM provider configured — run /login to connect one before sending a message.",
286
+ "gate.noProviderBlock": "No LLM provider configured. Run /login to connect one, then retry. (/help and /model still work.)",
285
287
  "plugins.tabs.installed": "インストール済み",
286
288
  "plugins.tabs.recommended": "推奨",
287
289
  "plugins.tabs.discover": "検索",
@@ -282,6 +282,8 @@
282
282
  "login.wizard.configSaved": "Configuration saved. You're ready to go!",
283
283
  "login.wizard.failed": "Connection failed — {error}",
284
284
  "login.wizard.retryHint": "Press Escape to skip, or enter a new value:",
285
+ "gate.noProvider": "No LLM provider configured — run /login to connect one before sending a message.",
286
+ "gate.noProviderBlock": "No LLM provider configured. Run /login to connect one, then retry. (/help and /model still work.)",
285
287
  "plugins.tabs.installed": "설치됨",
286
288
  "plugins.tabs.recommended": "권장",
287
289
  "plugins.tabs.discover": "검색",
@@ -282,6 +282,8 @@
282
282
  "login.wizard.configSaved": "Configuration saved. You're ready to go!",
283
283
  "login.wizard.failed": "Connection failed — {error}",
284
284
  "login.wizard.retryHint": "Press Escape to skip, or enter a new value:",
285
+ "gate.noProvider": "No LLM provider configured — run /login to connect one before sending a message.",
286
+ "gate.noProviderBlock": "No LLM provider configured. Run /login to connect one, then retry. (/help and /model still work.)",
285
287
  "plugins.tabs.installed": "Instalados",
286
288
  "plugins.tabs.recommended": "Recomendados",
287
289
  "plugins.tabs.discover": "Descobrir",
@@ -282,6 +282,8 @@
282
282
  "login.wizard.configSaved": "Configuration saved. You're ready to go!",
283
283
  "login.wizard.failed": "Connection failed — {error}",
284
284
  "login.wizard.retryHint": "Press Escape to skip, or enter a new value:",
285
+ "gate.noProvider": "No LLM provider configured — run /login to connect one before sending a message.",
286
+ "gate.noProviderBlock": "No LLM provider configured. Run /login to connect one, then retry. (/help and /model still work.)",
285
287
  "plugins.tabs.installed": "ติดตั้งแล้ว",
286
288
  "plugins.tabs.recommended": "แนะนำ",
287
289
  "plugins.tabs.discover": "ค้นหา",
@@ -282,6 +282,8 @@
282
282
  "login.wizard.configSaved": "Configuration saved. You're ready to go!",
283
283
  "login.wizard.failed": "Connection failed — {error}",
284
284
  "login.wizard.retryHint": "Press Escape to skip, or enter a new value:",
285
+ "gate.noProvider": "No LLM provider configured — run /login to connect one before sending a message.",
286
+ "gate.noProviderBlock": "No LLM provider configured. Run /login to connect one, then retry. (/help and /model still work.)",
285
287
  "plugins.tabs.installed": "已安装",
286
288
  "plugins.tabs.recommended": "推荐",
287
289
  "plugins.tabs.discover": "发现",
@@ -282,6 +282,8 @@
282
282
  "login.wizard.configSaved": "Configuration saved. You're ready to go!",
283
283
  "login.wizard.failed": "Connection failed — {error}",
284
284
  "login.wizard.retryHint": "Press Escape to skip, or enter a new value:",
285
+ "gate.noProvider": "No LLM provider configured — run /login to connect one before sending a message.",
286
+ "gate.noProviderBlock": "No LLM provider configured. Run /login to connect one, then retry. (/help and /model still work.)",
285
287
  "plugins.tabs.installed": "已安裝",
286
288
  "plugins.tabs.recommended": "推薦",
287
289
  "plugins.tabs.discover": "探索",
package/src/main.ts CHANGED
@@ -165,33 +165,16 @@ async function runInteractiveMode(
165
165
  versionCheckPromise.catch(() => undefined),
166
166
  new Promise<string | undefined>(resolve => setTimeout(() => resolve(undefined), INITIAL_UPDATE_CHECK_TIMEOUT_MS)),
167
167
  ]);
168
- const initialUpdateStatus = initialUpdateVersion
169
- ? { available: true, latestVersion: initialUpdateVersion }
170
- : undefined;
171
-
172
- const mode = new InteractiveMode(
173
- session,
174
- version,
175
- initialUpdateStatus,
176
- setExtensionUIContext,
177
- lspServers,
178
- mcpManager,
179
- eventBus,
180
- );
168
+
169
+ const mode = new InteractiveMode(session, version, setExtensionUIContext, lspServers, mcpManager, eventBus);
181
170
 
182
171
  await mode.init();
183
172
 
184
- if (!initialUpdateVersion) {
185
- versionCheckPromise
186
- .then(newVersion => {
187
- if (!settings.get("startup.checkUpdate")) {
188
- return;
189
- }
190
- if (newVersion) {
191
- mode.setUpdateStatus({ available: true, latestVersion: newVersion });
192
- }
193
- })
194
- .catch(() => {});
173
+ // Non-blocking, one-shot update notice: only surface it if the background check
174
+ // already resolved within the startup race (no waiting, no live re-render). If it
175
+ // resolves later, the update is available on demand via /plugins.
176
+ if (initialUpdateVersion && settings.get("startup.checkUpdate")) {
177
+ mode.showStatus(`Update available: v${initialUpdateVersion} — run: xcsh update`, { dim: true });
195
178
  }
196
179
 
197
180
  mode.renderInitialMessages();
@@ -717,34 +700,38 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
717
700
  sessionManager = await SessionManager.open(selectedPath);
718
701
  }
719
702
 
720
- // Refresh stale marketplace clones before loading plugins so extensions run latest code.
703
+ // Refresh stale marketplace clones in the background so startup never blocks on
704
+ // git/network. Upgraded plugin code applies on the next launch (same trade-off as
705
+ // the "notify" branch below). Offline/corrupt data is tolerated by the try/catch.
721
706
  const autoUpdate = settings.get("marketplace.autoUpdate");
722
707
  if (autoUpdate !== "off") {
723
- try {
724
- const startupMgr = new MarketplaceManager({
725
- marketplacesRegistryPath: getMarketplacesRegistryPath(),
726
- installedRegistryPath: getInstalledPluginsRegistryPath(),
727
- projectInstalledRegistryPath: (await resolveActiveProjectRegistryPath(getProjectDir())) ?? undefined,
728
- marketplacesCacheDir: getMarketplacesCacheDir(),
729
- pluginsCacheDir: getPluginsCacheDir(),
730
- clearPluginRootsCache: (extraPaths?: readonly string[]) => {
731
- const h = os.homedir();
732
- invalidateFsCache(path.join(h, getConfigDirName(), "plugins", "installed_plugins.json"));
733
- for (const p of extraPaths ?? []) invalidateFsCache(p);
734
- clearXcshPluginRootsCache();
735
- },
736
- });
737
- await startupMgr.refreshStaleMarketplaces();
738
- if (autoUpdate === "auto") {
739
- const updates = await startupMgr.checkForUpdates();
740
- if (updates.length > 0) {
741
- await startupMgr.upgradeAllPlugins();
742
- logger.debug(`Auto-upgraded ${updates.length} marketplace plugin(s) at startup`);
708
+ void (async () => {
709
+ try {
710
+ const startupMgr = new MarketplaceManager({
711
+ marketplacesRegistryPath: getMarketplacesRegistryPath(),
712
+ installedRegistryPath: getInstalledPluginsRegistryPath(),
713
+ projectInstalledRegistryPath: (await resolveActiveProjectRegistryPath(getProjectDir())) ?? undefined,
714
+ marketplacesCacheDir: getMarketplacesCacheDir(),
715
+ pluginsCacheDir: getPluginsCacheDir(),
716
+ clearPluginRootsCache: (extraPaths?: readonly string[]) => {
717
+ const h = os.homedir();
718
+ invalidateFsCache(path.join(h, getConfigDirName(), "plugins", "installed_plugins.json"));
719
+ for (const p of extraPaths ?? []) invalidateFsCache(p);
720
+ clearXcshPluginRootsCache();
721
+ },
722
+ });
723
+ await startupMgr.refreshStaleMarketplaces();
724
+ if (autoUpdate === "auto") {
725
+ const updates = await startupMgr.checkForUpdates();
726
+ if (updates.length > 0) {
727
+ await startupMgr.upgradeAllPlugins();
728
+ logger.debug(`Auto-upgraded ${updates.length} marketplace plugin(s) at startup`);
729
+ }
743
730
  }
731
+ } catch {
732
+ // Network failure, corrupt data, offline — proceed with cached plugins.
744
733
  }
745
- } catch {
746
- // Network failure, corrupt data, offline — proceed with cached plugins.
747
- }
734
+ })();
748
735
  }
749
736
 
750
737
  // Wire --plugin-dir and preload plugin roots for sync consumers (LSP config)
@@ -1,17 +1,9 @@
1
1
  import type { Model } from "@f5-sales-demo/pi-ai";
2
2
  import { validateApiKeyAgainstModelsEndpoint } from "@f5-sales-demo/pi-ai/utils/oauth/api-key-validation";
3
3
  import { logger } from "@f5-sales-demo/pi-utils";
4
- import { MarketplaceManager } from "../../extensibility/plugins/marketplace";
5
- import {
6
- getInstalledPluginsRegistryPath,
7
- getMarketplacesCacheDir,
8
- getMarketplacesRegistryPath,
9
- getPluginsCacheDir,
10
- } from "../../extensibility/plugins/marketplace/registry";
11
4
  import { type AuthStatus, ContextService } from "../../services/xcsh-context";
12
5
  import { deriveTenantFromUrl } from "../../services/xcsh-env";
13
6
  import type { AuthStorage } from "../../session/auth-storage";
14
- import { normalizePluginDisplayName } from "./plugins/utils";
15
7
 
16
8
  // Startup validation budget. These are longer than validateToken's 3000ms default because
17
9
  // the welcome path runs during TLS/DNS cold-start — a single 3s shot races against warm-up
@@ -75,6 +67,17 @@ export interface WelcomeCheckResult {
75
67
  /** Providers that don't store API keys (local inference servers) */
76
68
  const KEYLESS_PROVIDERS = new Set(["ollama", "llama.cpp", "lm-studio", "llamafile", "local"]);
77
69
 
70
+ /**
71
+ * Instant, local check for whether the session has a usable LLM provider configured.
72
+ * Zero network: a model must be resolved and either be keyless (local inference) or
73
+ * have credentials present in the auth store. Used by the startup readiness gate to
74
+ * decide whether natural-language input can be processed.
75
+ */
76
+ export function hasActiveLlmProvider(model: Model | undefined, authStorage: Pick<AuthStorage, "hasAuth">): boolean {
77
+ if (!model) return false;
78
+ return KEYLESS_PROVIDERS.has(model.provider) || authStorage.hasAuth(model.provider);
79
+ }
80
+
78
81
  /**
79
82
  * Run blocking startup checks for the welcome screen.
80
83
  * Model check always runs. Context check only runs if model is connected.
@@ -215,99 +218,3 @@ export function mapContextStatus(status: WelcomeContextStatus): ServiceStatus {
215
218
  }
216
219
  }
217
220
  }
218
-
219
- export interface FixableService {
220
- name: string;
221
- prompt: string;
222
- command: string[];
223
- recheck: () => Promise<ServiceStatus>;
224
- }
225
-
226
- export type UnifiedPluginState = "connected" | "unauthenticated" | "unavailable" | "installed" | "not_installed";
227
-
228
- export interface UnifiedPluginStatus {
229
- name: string;
230
- state: UnifiedPluginState;
231
- hint?: string;
232
- group?: string;
233
- }
234
-
235
- export interface ServiceStatusContributionInput {
236
- name: string;
237
- group?: string;
238
- check: () => Promise<{ state: ServiceState; hint?: string }>;
239
- }
240
-
241
- export async function buildUnifiedPluginList(
242
- contributions: ServiceStatusContributionInput[],
243
- ): Promise<UnifiedPluginStatus[]> {
244
- const map = new Map<string, UnifiedPluginStatus>();
245
-
246
- for (const contribution of contributions) {
247
- try {
248
- const status = await contribution.check();
249
- map.set(contribution.name.toLowerCase(), {
250
- name: contribution.name,
251
- state: status.state,
252
- hint: status.hint,
253
- group: contribution.group,
254
- });
255
- } catch {
256
- map.set(contribution.name.toLowerCase(), {
257
- name: contribution.name,
258
- state: "unavailable",
259
- hint: "check failed",
260
- group: contribution.group,
261
- });
262
- }
263
- }
264
-
265
- try {
266
- const mgr = new MarketplaceManager({
267
- marketplacesRegistryPath: getMarketplacesRegistryPath(),
268
- installedRegistryPath: getInstalledPluginsRegistryPath(),
269
- marketplacesCacheDir: getMarketplacesCacheDir(),
270
- pluginsCacheDir: getPluginsCacheDir(),
271
- clearPluginRootsCache: () => {},
272
- });
273
-
274
- const [marketplaces, installedSummaries] = await Promise.all([
275
- mgr.listMarketplaces(),
276
- mgr.listInstalledPlugins(),
277
- ]);
278
-
279
- const installedIds = new Set(installedSummaries.map(s => s.id));
280
-
281
- for (const mkt of marketplaces) {
282
- const available = await mgr.listAvailablePlugins(mkt.name).catch(() => []);
283
- for (const entry of available) {
284
- if (!entry.recommended) continue;
285
- const displayName = normalizePluginDisplayName(entry.name);
286
- const key = displayName.toLowerCase();
287
- if (map.has(key)) continue;
288
- const pluginId = `${entry.name}@${mkt.name}`;
289
- map.set(key, {
290
- name: displayName,
291
- state: installedIds.has(pluginId) ? "installed" : "not_installed",
292
- hint: installedIds.has(pluginId) ? undefined : "run: /plugin setup",
293
- });
294
- }
295
- }
296
- } catch (err) {
297
- logger.debug("buildUnifiedPluginList marketplace check failed", { error: String(err) });
298
- }
299
-
300
- const stateOrder: Record<UnifiedPluginState, number> = {
301
- connected: 0,
302
- unauthenticated: 1,
303
- unavailable: 2,
304
- installed: 3,
305
- not_installed: 4,
306
- };
307
-
308
- return [...map.values()].sort((a, b) => {
309
- const orderDiff = stateOrder[a.state] - stateOrder[b.state];
310
- if (orderDiff !== 0) return orderDiff;
311
- return a.name.localeCompare(b.name);
312
- });
313
- }
@@ -1,92 +1,53 @@
1
1
  import { type Component, padding, truncateToWidth, visibleWidth } from "@f5-sales-demo/pi-tui";
2
- import { APP_NAME, t } from "@f5-sales-demo/pi-utils";
2
+ import { APP_NAME } from "@f5-sales-demo/pi-utils";
3
3
  import { theme } from "../../modes/theme/theme";
4
- import { formatStatusIcon } from "../../services/xcsh-context-indicators";
5
- import type { ModelStatus, ServiceStatus, UnifiedPluginStatus } from "./welcome-checks";
6
-
7
- export interface UpdateStatus {
8
- available: boolean;
9
- latestVersion?: string;
10
- }
11
4
 
5
+ /**
6
+ * Startup splash: the F5 logo under a ` xcsh vX.Y.Z ` title bar. Intentionally
7
+ * static and status-free — session/provider/plugin status lives in on-demand
8
+ * commands (/plugins, /context) so startup stays instant and never blocks or
9
+ * live-updates. See docs/superpowers/specs for the fast-startup design.
10
+ */
12
11
  export class WelcomeComponent implements Component {
13
- constructor(
14
- private readonly version: string,
15
- private modelStatus: ModelStatus,
16
- private services: ServiceStatus[] = [],
17
- private updateStatus?: UpdateStatus,
18
- private plugins: UnifiedPluginStatus[] = [],
19
- ) {}
12
+ constructor(private readonly version: string) {}
20
13
  invalidate(): void {}
21
- setModelStatus(status: ModelStatus): void {
22
- this.modelStatus = status;
23
- }
24
- setServices(services: ServiceStatus[]): void {
25
- this.services = services;
26
- }
27
- setUpdateStatus(status: UpdateStatus | undefined): void {
28
- this.updateStatus = status;
29
- }
30
- setPlugins(plugins: UnifiedPluginStatus[]): void {
31
- this.plugins = plugins;
32
- }
33
14
 
34
15
  render(termWidth: number): string[] {
35
- const minLeftCol = 48;
36
- const minRightCol = 20;
37
16
  const preferredLeftCol = 50;
17
+ const logoMaxWidth = 46;
38
18
 
39
- // Content-driven right column width
40
- const naturalRight = this.#measureStatusWidth() + 1; // +1 right padding
41
- const idealRight = Math.max(naturalRight, minRightCol);
42
- const idealBox = preferredLeftCol + idealRight + 3; // 3 border chars: │ + │ + │
43
- const boxWidth = Math.min(idealBox, Math.max(0, termWidth - 2));
19
+ const boxWidth = Math.min(preferredLeftCol + 2, Math.max(0, termWidth - 2));
44
20
  if (boxWidth < 4) return [];
45
-
46
- const dualContentWidth = boxWidth - 3;
47
- // When terminal is narrower than ideal, shrink left column toward minLeftCol first
48
- const dualLeftCol =
49
- dualContentWidth >= preferredLeftCol + idealRight
50
- ? preferredLeftCol
51
- : Math.max(minLeftCol, dualContentWidth - idealRight);
52
- const dualRightCol = Math.max(0, dualContentWidth - dualLeftCol);
53
- // Only show dual column when both columns have enough room for their content
54
- const showRightColumn = dualLeftCol >= minLeftCol && dualRightCol >= Math.max(minRightCol, naturalRight);
55
- const leftCol = showRightColumn ? dualLeftCol : boxWidth - 2;
56
- const rightCol = showRightColumn ? dualRightCol : 0;
21
+ const leftCol = boxWidth - 2;
57
22
 
58
23
  // biome-ignore format: preserve ASCII art layout
59
24
  const f5Logo = [
60
25
  " ________",
61
- " (\u2592\u2592\u2592\u2592\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2592\u2592\u2592\u2592)",
62
- " (\u2592\u2592\u2592\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2592\u2592\u2592)",
63
- " (\u2592\u2592\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588)",
64
- " (\u2592\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2588\u2592\u2592\u2592\u2592\u2592\u2588\u2588\u2588\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2592)",
65
- " (\u2592\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2588\u2592\u2593\u2593\u2593\u2593\u2593\u2592\u2592\u2592\u2593\u2588\u2588\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2593\u2592)",
66
- " (\u2592\u2593\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2588\u2588\u2592\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2592)",
67
- " (\u2592\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2593\u2593\u2593\u2593\u2592)",
68
- "(\u2592\u2593\u2593\u2593\u2592\u2592\u2592\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2592\u2592\u2592\u2592\u2592\u2593\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2593\u2593\u2593\u2592)",
69
- "|\u2592\u2593\u2593\u2593\u2593\u2593\u2593\u2592\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2592|",
70
- "|\u2592\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2592|",
71
- "(\u2592\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2592\u2592\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2592\u2592)",
72
- " (\u2592\u2593\u2593\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2592\u2592\u2592\u2588\u2588\u2588\u2588\u2592\u2592)",
73
- " (\u2592\u2593\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2592\u2592)",
74
- " (\u2592\u2592\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2593\u2593\u2593\u2592\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2588\u2588\u2588\u2592\u2592\u2592)",
75
- " (\u2592\u2592\u2592\u2592\u2592\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2593\u2593\u2592\u2592\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2592\u2592\u2593\u2592)",
76
- " (\u2592\u2593\u2593\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2593\u2593\u2593\u2593\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2592\u2593\u2592)",
77
- " (\u2592\u2592\u2592\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2592\u2592\u2592)",
78
- " (\u2592\u2592\u2592\u2592\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u2592\u2592\u2592\u2592)",
26
+ " (▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒)",
27
+ " (▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒)",
28
+ " (▒▒▓▓▓▓██████████▓▓▓▓███████████████)",
29
+ " (▒▓▓▓▓██████▒▒▒▒▒███▓▓█████████████████▒)",
30
+ " (▒▓▓▓▓██████▒▓▓▓▓▓▒▒▒▓██▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒)",
31
+ " (▒▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓██▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒)",
32
+ " (▒▓▓████████████████▓▓▓▓████████████▓▓▓▓▓▓▒)",
33
+ "(▒▓▓▓▒▒▒███████▒▒▒▒▒▓▓▓████████████████▓▓▓▓▓▒)",
34
+ "|▒▓▓▓▓▓▓▒██████▓▓▓▓▓▓▓████████████████████▓▓▒|",
35
+ "|▒▓▓▓▓▓▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒██████████▓▒|",
36
+ "(▒▓▓▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒██████████▒▒)",
37
+ " (▒▓▓▓▓▓▓██████▓▓▓▓▓▓▓███▓▓▓▓▓▓▓▓▓▒▒▒████▒▒)",
38
+ " (▒▓▓▓▓▓██████▓▓▓▓▓▓██████▓▓▓▓▓▓▓▓▓▓▓▓████▒▒)",
39
+ " (▒▒██████████▓▓▓▓▓▒██████▓▓▓▓▓▓▓▓██████▒▒▒)",
40
+ " (▒▒▒▒▒██████████▓▓▒▒██████████████▒▒▓▒)",
41
+ " (▒▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒)",
42
+ " (▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒)",
43
+ " (▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒)",
79
44
  ];
80
45
 
81
46
  const logoColored = f5Logo.map(line => this.#f5ColorLine(line));
82
- const logoMaxWidth = 46;
83
47
  const logoBlockPad = Math.max(0, Math.floor((leftCol - logoMaxWidth) / 2));
84
48
  const logoPadStr = padding(logoBlockPad);
85
- const leftLines = [...logoColored.map(l => logoPadStr + l), ""];
86
- const rightLines = this.#buildStatusLines(showRightColumn ? rightCol : leftCol);
87
- if (!showRightColumn) {
88
- leftLines.push(...rightLines);
89
- }
49
+ const contentLines = [...logoColored.map(l => logoPadStr + l), ""];
50
+
90
51
  const border = (s: string) => theme.fg("borderMuted", s);
91
52
  const hChar = theme.boxRound.horizontal;
92
53
  const h = border(hChar);
@@ -95,6 +56,7 @@ export class WelcomeComponent implements Component {
95
56
  const tr = border(theme.boxRound.topRight);
96
57
  const bl = border(theme.boxRound.bottomLeft);
97
58
  const br = border(theme.boxRound.bottomRight);
59
+
98
60
  const lines: string[] = [];
99
61
  const title = ` ${APP_NAME} v${this.version} `;
100
62
  const titlePrefixRaw = hChar.repeat(3);
@@ -106,125 +68,13 @@ export class WelcomeComponent implements Component {
106
68
  } else {
107
69
  lines.push(tl + titleStyled + border(hChar.repeat(titleSpace - titleVisLen)) + tr);
108
70
  }
109
- const maxRows = showRightColumn ? Math.max(leftLines.length, rightLines.length) : leftLines.length;
110
- for (let i = 0; i < maxRows; i++) {
111
- const left = this.#fitToWidth(leftLines[i] ?? "", leftCol);
112
- if (showRightColumn) {
113
- const right = this.#fitToWidth(rightLines[i] ?? "", rightCol);
114
- lines.push(v + left + v + right + v);
115
- } else {
116
- lines.push(v + left + v);
117
- }
118
- }
119
- if (showRightColumn) {
120
- lines.push(bl + h.repeat(leftCol) + border(theme.boxSharp.teeUp) + h.repeat(rightCol) + br);
121
- } else {
122
- lines.push(bl + h.repeat(leftCol) + br);
123
- }
124
- return lines;
125
- }
126
-
127
- #measureStatusWidth(): number {
128
- const lines: string[] = [` ${t("welcome.modelProvider")}`, ...this.#renderModelStatus()];
129
- const coreServices = this.services.filter(s => !s._isPlugin);
130
- for (const svc of coreServices) {
131
- lines.push(this.#renderServiceLine(svc));
132
- }
133
- if (this.plugins.length > 0) {
134
- lines.push(` ${t("welcome.plugins")}`);
135
- for (const p of this.plugins) {
136
- lines.push(this.#renderUnifiedPluginLine(p));
137
- }
138
- }
139
- if (this.#showUpdateSection()) {
140
- lines.push(this.#renderUpdateLine());
141
- }
142
- return Math.max(...lines.map(l => visibleWidth(l)));
143
- }
144
-
145
- #renderUnifiedPluginLine(plugin: UnifiedPluginStatus): string {
146
- if (plugin.state === "connected" || plugin.state === "installed") {
147
- return ` ${formatStatusIcon("connected")} ${theme.fg("muted", plugin.name)}`;
148
- }
149
- if (plugin.state === "not_installed") {
150
- return ` ${theme.fg("dim", "·")} ${theme.fg("dim", plugin.name)}`;
71
+ for (const line of contentLines) {
72
+ lines.push(v + this.#fitToWidth(line, leftCol) + v);
151
73
  }
152
- const hint = plugin.hint ?? "";
153
- return ` ${formatStatusIcon("warning")} ${theme.fg("muted", plugin.name)} ${theme.fg("dim", hint)}`;
154
- }
155
-
156
- #buildStatusLines(rightCol: number): string[] {
157
- const lines: string[] = [];
158
- const separatorWidth = Math.max(0, rightCol - 2);
159
- const separator = ` ${theme.fg("muted", theme.boxRound.horizontal.repeat(separatorWidth))}`;
160
- lines.push("");
161
- lines.push(` ${theme.bold(theme.fg("contentAccent", t("welcome.modelProvider")))}`);
162
- lines.push(...this.#renderModelStatus());
163
- lines.push("");
164
- const coreServices = this.services.filter(s => !s._isPlugin);
165
- const hasContent = coreServices.length > 0 || this.plugins.length > 0 || this.#showUpdateSection();
166
- if (hasContent) {
167
- lines.push(separator);
168
- for (const svc of coreServices) {
169
- lines.push(this.#renderServiceLine(svc));
170
- }
171
- if (this.plugins.length > 0) {
172
- lines.push("");
173
- lines.push(` ${theme.fg("dim", t("welcome.plugins"))}`);
174
- for (const p of this.plugins) {
175
- lines.push(this.#renderUnifiedPluginLine(p));
176
- }
177
- const missing = this.plugins.filter(p => p.state === "not_installed");
178
- if (missing.length > 0) {
179
- lines.push(` ${theme.fg("dim", t("welcome.pluginSetupHint"))}`);
180
- }
181
- }
182
-
183
- if (this.#showUpdateSection()) {
184
- lines.push(this.#renderUpdateLine());
185
- }
186
- }
187
- lines.push("");
74
+ lines.push(bl + h.repeat(leftCol) + br);
188
75
  return lines;
189
76
  }
190
77
 
191
- #showUpdateSection(): boolean {
192
- return this.updateStatus?.available === true;
193
- }
194
-
195
- #renderServiceLine(service: ServiceStatus): string {
196
- if (service.state === "connected") {
197
- return ` ${formatStatusIcon("connected")} ${theme.fg("muted", service.name)}`;
198
- }
199
- const hint = service.hint ?? "";
200
- return ` ${formatStatusIcon("warning")} ${theme.fg("muted", service.name)} ${theme.fg("dim", hint)}`;
201
- }
202
-
203
- #renderUpdateLine(): string {
204
- const latest = this.updateStatus?.latestVersion;
205
- const label = latest ? `v${latest}` : t("welcome.newVersion");
206
- return ` ${theme.fg("warning", "↑")} ${theme.fg("muted", label)} ${theme.fg("dim", t("welcome.updateHint"))}`;
207
- }
208
-
209
- #renderModelStatus(): string[] {
210
- const { state, provider } = this.modelStatus;
211
- const p = provider ?? "unknown";
212
- switch (state) {
213
- case "connected":
214
- return [` ${formatStatusIcon("connected")} ${theme.fg("muted", p)}`];
215
- case "auth_error":
216
- return [
217
- ` ${formatStatusIcon("error")} ${theme.fg("muted", p)} ${theme.fg("error", t("welcome.connectionFailed"))}`,
218
- ` ${theme.fg("dim", t("welcome.runLoginReconnect"))}`,
219
- ];
220
- case "no_provider":
221
- return [
222
- ` ${formatStatusIcon("error")} ${theme.fg("error", t("welcome.noModelProvider"))}`,
223
- ` ${theme.fg("dim", t("welcome.runLoginConnect"))}`,
224
- ];
225
- }
226
- }
227
-
228
78
  #f5ColorLine(line: string): string {
229
79
  const red = "\x1b[38;5;160m";
230
80
  const white = "\x1b[1;37m";
@@ -236,9 +86,9 @@ export class WelcomeComponent implements Component {
236
86
  const reset = "\x1b[0m";
237
87
  let result = "";
238
88
  for (const char of line) {
239
- if (char === "\u2593") result += `${red}\u2588${reset}`;
240
- else if (char === "\u2588") result += `${white}\u2588${reset}`;
241
- else if (char === "\u2592") result += `${red}${shadowBg}\u2592${reset}`;
89
+ if (char === "") result += `${red}█${reset}`;
90
+ else if (char === "") result += `${white}█${reset}`;
91
+ else if (char === "") result += `${red}${shadowBg}▒${reset}`;
242
92
  else if ("()|_".includes(char)) result += `${red}${char}${reset}`;
243
93
  else result += char;
244
94
  }
@@ -248,7 +98,7 @@ export class WelcomeComponent implements Component {
248
98
  #fitToWidth(str: string, width: number): string {
249
99
  const visLen = visibleWidth(str);
250
100
  if (visLen > width) {
251
- const ellipsis = "\u2026";
101
+ const ellipsis = "";
252
102
  const maxW = Math.max(0, width - visibleWidth(ellipsis));
253
103
  let t = "";
254
104
  let cw = 0;
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import { type AgentMessage, ThinkingLevel } from "@f5-sales-demo/pi-agent-core";
4
4
  import { sanitizeText } from "@f5-sales-demo/pi-natives";
5
5
  import { type AutocompleteProvider, ChordDispatcher, type SlashCommand } from "@f5-sales-demo/pi-tui";
6
- import { $env } from "@f5-sales-demo/pi-utils";
6
+ import { $env, t } from "@f5-sales-demo/pi-utils";
7
7
  import { settings } from "../../config/settings";
8
8
  import { createStreamingAssistantGutter } from "../../modes/components/gutter-block";
9
9
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
@@ -374,6 +374,16 @@ export class InputController {
374
374
  }
375
375
  }
376
376
 
377
+ // LLM readiness gate: without a configured provider, natural language
378
+ // cannot be processed. Slash/skill/bash/python commands are handled and
379
+ // returned above, so only genuine natural language reaches here — block it
380
+ // with a clear /login prompt and preserve the user's text.
381
+ if (!this.ctx.hasActiveLlmProvider()) {
382
+ this.ctx.showWarning(t("gate.noProviderBlock"));
383
+ this.ctx.editor.setText(text);
384
+ return;
385
+ }
386
+
377
387
  // Queue input during compaction
378
388
  if (this.ctx.session.isCompacting) {
379
389
  if (this.ctx.pendingImages.length > 0) {
@@ -479,6 +489,12 @@ export class InputController {
479
489
  const text = this.ctx.editor.getText().trim();
480
490
  if (!text) return;
481
491
 
492
+ // LLM readiness gate — a follow-up is still natural language for the model.
493
+ if (!this.ctx.hasActiveLlmProvider()) {
494
+ this.ctx.showWarning(t("gate.noProviderBlock"));
495
+ return;
496
+ }
497
+
482
498
  if (this.ctx.session.isCompacting) {
483
499
  this.ctx.queueCompactionMessage(text, "followUp");
484
500
  return;
@@ -0,0 +1,32 @@
1
+ import type { Model } from "@f5-sales-demo/pi-ai";
2
+
3
+ /**
4
+ * Minimal session surface needed to apply a model after a successful login.
5
+ * Kept structural so the login flow can call it without pulling in the full
6
+ * AgentSession type, and so it stays trivially unit-testable.
7
+ */
8
+ interface ModelApplicableSession {
9
+ model: Model | undefined;
10
+ modelRegistry: { getAll(): Model[] };
11
+ setModel(model: Model, role: "default", options?: { selector?: string }): Promise<void>;
12
+ }
13
+
14
+ /**
15
+ * After a successful `/login`, make the freshly-configured provider immediately
16
+ * usable by setting it as the session's default model — but only when the session
17
+ * has no model yet, so we never override a model the user already chose.
18
+ *
19
+ * Returns true when a model was applied. The login wizard auto-selects a model id
20
+ * from the provider's /models list; resolving it here (post-registry-refresh) is
21
+ * what lets the LLM readiness gate lift without a manual `/model` step.
22
+ */
23
+ export async function applyModelAfterLogin(
24
+ session: ModelApplicableSession,
25
+ selectedModelId: string | undefined,
26
+ ): Promise<boolean> {
27
+ if (session.model || !selectedModelId) return false;
28
+ const resolved = session.modelRegistry.getAll().find(m => m.id === selectedModelId);
29
+ if (!resolved) return false;
30
+ await session.setModel(resolved, "default", { selector: resolved.id });
31
+ return true;
32
+ }
@@ -58,6 +58,7 @@ import { ToolExecutionComponent } from "../components/tool-execution";
58
58
  import { TreeSelectorComponent } from "../components/tree-selector";
59
59
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
60
60
  import type { SessionObserverRegistry } from "../session-observer-registry";
61
+ import { applyModelAfterLogin } from "./login-model";
61
62
 
62
63
  const CALLBACK_SERVER_PROVIDERS = new Set<OAuthProvider>([
63
64
  "anthropic",
@@ -1173,6 +1174,9 @@ export class SelectorController {
1173
1174
  healConfigYmlModelRoles(configPath);
1174
1175
 
1175
1176
  await this.ctx.session.modelRegistry.refresh("online");
1177
+ // Make the freshly-configured provider usable immediately so the LLM
1178
+ // readiness gate lifts without requiring a manual /model selection.
1179
+ await applyModelAfterLogin(this.ctx.session, selectedModel);
1176
1180
  await this.ctx.refreshWelcomeAfterLogin();
1177
1181
  probeSuccess = true;
1178
1182
  } else {
@@ -15,7 +15,7 @@ import {
15
15
  } from "@f5-sales-demo/pi-ai";
16
16
  import type { Component, SlashCommand } from "@f5-sales-demo/pi-tui";
17
17
  import { Container, Loader, Markdown, ProcessTerminal, Spacer, Text, TUI, visibleWidth } from "@f5-sales-demo/pi-tui";
18
- import { getProjectDir, hsvToRgb, isEnoent, logger, postmortem, prompt } from "@f5-sales-demo/pi-utils";
18
+ import { getProjectDir, hsvToRgb, isEnoent, logger, postmortem, prompt, t } from "@f5-sales-demo/pi-utils";
19
19
  import chalk from "chalk";
20
20
  import { KeybindingsManager } from "../config/keybindings";
21
21
  import { type Settings, settings } from "../config/settings";
@@ -32,7 +32,6 @@ import { seedComputerProfile } from "../internal-urls/computer-profile";
32
32
  import { reconcileFromCollectors } from "../internal-urls/user-profile";
33
33
  import { renameApprovedPlanFile } from "../plan-mode/approved-plan";
34
34
  import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" };
35
- import { ContextService } from "../services/xcsh-context";
36
35
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
37
36
  import { HistoryStorage } from "../session/history-storage";
38
37
  import type { SessionContext, SessionManager } from "../session/session-manager";
@@ -52,14 +51,8 @@ import type { HookSelectorComponent } from "./components/hook-selector";
52
51
  import type { PythonExecutionComponent } from "./components/python-execution";
53
52
  import { StatusLineComponent } from "./components/status-line";
54
53
  import type { ToolExecutionHandle } from "./components/tool-execution";
55
- import { type UpdateStatus, WelcomeComponent } from "./components/welcome";
56
- import {
57
- buildUnifiedPluginList,
58
- type FixableService,
59
- mapContextStatus,
60
- runWelcomeChecks,
61
- type ServiceStatus,
62
- } from "./components/welcome-checks";
54
+ import { WelcomeComponent } from "./components/welcome";
55
+ import { hasActiveLlmProvider } from "./components/welcome-checks";
63
56
  import { BtwController } from "./controllers/btw-controller";
64
57
  import { CommandController } from "./controllers/command-controller";
65
58
  import { EventController } from "./controllers/event-controller";
@@ -170,7 +163,6 @@ export class InteractiveMode implements InteractiveModeContext {
170
163
  #pendingSlashCommands: SlashCommand[] = [];
171
164
  #cleanupUnsubscribe?: () => void;
172
165
  readonly #version: string;
173
- readonly #initialUpdateStatus: UpdateStatus | undefined;
174
166
  #planModePreviousTools: string[] | undefined;
175
167
  #planModePreviousModelState: { model: Model; thinkingLevel?: ThinkingLevel } | undefined;
176
168
  #pendingModelSwitch: { model: Model; thinkingLevel?: ThinkingLevel } | undefined;
@@ -197,12 +189,10 @@ export class InteractiveMode implements InteractiveModeContext {
197
189
  #eventBus?: EventBus;
198
190
  #eventBusUnsubscribers: Array<() => void> = [];
199
191
  #welcomeComponent?: WelcomeComponent;
200
- #currentPlugins: import("./components/welcome-checks").UnifiedPluginStatus[] = [];
201
192
 
202
193
  constructor(
203
194
  session: AgentSession,
204
195
  version: string,
205
- initialUpdateStatus: UpdateStatus | undefined = undefined,
206
196
  setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void = () => {},
207
197
  lspServers?: import("../tools").LspStartupServerInfo[],
208
198
  mcpManager?: import("../mcp").MCPManager,
@@ -214,7 +204,6 @@ export class InteractiveMode implements InteractiveModeContext {
214
204
  this.keybindings = KeybindingsManager.inMemory();
215
205
  this.agent = session.agent;
216
206
  this.#version = version;
217
- this.#initialUpdateStatus = initialUpdateStatus;
218
207
  this.#toolUiContextSetter = setToolUIContext;
219
208
  this.lspServers = lspServers;
220
209
  this.mcpManager = mcpManager;
@@ -317,11 +306,6 @@ export class InteractiveMode implements InteractiveModeContext {
317
306
  getProjectDir(),
318
307
  );
319
308
 
320
- // Run blocking welcome screen status checks
321
- const welcomeResult = await logger.time("InteractiveMode.init:welcomeChecks", () =>
322
- runWelcomeChecks(this.session.model, this.session.modelRegistry.authStorage),
323
- );
324
-
325
309
  // Refresh user profile in background — fire and forget
326
310
  reconcileFromCollectors().catch(err => logger.warn("Background profile refresh failed", { error: String(err) }));
327
311
  // Refresh computer profile in background — fire and forget
@@ -337,60 +321,20 @@ export class InteractiveMode implements InteractiveModeContext {
337
321
  this.ui.addChild(new Spacer(1));
338
322
  }
339
323
 
340
- // When model is not connected, the wizard will auto-launch show a clean
341
- // welcome screen without plugins or confusing provider names.
342
- const needsLogin = welcomeResult.model.state === "no_provider" || welcomeResult.model.state === "auth_error";
343
- const welcomeModelStatus = needsLogin
344
- ? { state: "no_provider" as const, provider: undefined }
345
- : welcomeResult.model;
346
-
347
- const services: ServiceStatus[] =
348
- !startupQuiet && !needsLogin ? [mapContextStatus(welcomeResult.context ?? { state: "no_context" })] : [];
349
-
350
- // Build unified plugin list from service status contributions + marketplace
351
- // Skip when model is not configured — nothing works without a model.
352
- const pluginContributions = this.session.extensionRunner?.getAllRegisteredServiceStatuses() ?? [];
353
- const plugins =
354
- !startupQuiet && !needsLogin ? await buildUnifiedPluginList(pluginContributions).catch(() => []) : [];
355
- this.#currentPlugins = plugins;
356
-
357
- const fixableServices: FixableService[] = [];
358
- if (!needsLogin) {
359
- for (const contribution of pluginContributions) {
360
- if (contribution.fix) {
361
- const plugin = plugins.find(p => p.name.toLowerCase() === contribution.name.toLowerCase());
362
- if (plugin && plugin.state === "unauthenticated") {
363
- fixableServices.push({
364
- name: contribution.name,
365
- prompt: contribution.fix.prompt,
366
- command: contribution.fix.command,
367
- recheck: async () => {
368
- try {
369
- const result = await contribution.check();
370
- return { name: contribution.name, ...result };
371
- } catch {
372
- return { name: contribution.name, state: "unavailable" as const, hint: "recheck failed" };
373
- }
374
- },
375
- });
376
- }
377
- }
378
- }
379
- }
324
+ // LLM readiness gate: instant, local check (no network). When no provider is
325
+ // configured we still initialize immediately, but warn the user to /login —
326
+ // natural-language input is blocked until then (enforced in input-controller).
327
+ const needsLogin = !hasActiveLlmProvider(this.session.model, this.session.modelRegistry.authStorage);
380
328
 
381
329
  if (!startupQuiet) {
382
- this.#welcomeComponent = new WelcomeComponent(
383
- this.#version,
384
- welcomeModelStatus,
385
- services,
386
- this.#initialUpdateStatus,
387
- plugins,
388
- );
389
-
390
- // Setup UI layout
330
+ this.#welcomeComponent = new WelcomeComponent(this.#version);
391
331
  this.ui.addChild(new Spacer(1));
392
332
  this.ui.addChild(this.#welcomeComponent);
393
333
  this.ui.addChild(new Spacer(1));
334
+ if (needsLogin) {
335
+ this.ui.addChild(new Text(theme.fg("warning", t("gate.noProvider")), 1, 0));
336
+ this.ui.addChild(new Spacer(1));
337
+ }
394
338
  }
395
339
 
396
340
  this.ui.addChild(this.chatContainer);
@@ -436,15 +380,6 @@ export class InteractiveMode implements InteractiveModeContext {
436
380
  // Initialize hooks with TUI-based UI context
437
381
  await this.initHooksAndCustomTools();
438
382
 
439
- // Offer to fix expired cloud credentials before entering the main loop
440
- if (fixableServices.length > 0) {
441
- await this.#offerCredentialFixes(fixableServices, services);
442
- // Clear any gibberish that accumulated in the editor during the
443
- // stop/start cycles (Kitty protocol re-negotiation can leave
444
- // raw escape sequences in the input buffer).
445
- this.editor.setText("");
446
- }
447
-
448
383
  // Restore mode from session (e.g. plan mode on resume)
449
384
  await this.#restoreModeFromSession();
450
385
 
@@ -944,52 +879,6 @@ export class InteractiveMode implements InteractiveModeContext {
944
879
  }
945
880
  }
946
881
 
947
- async #offerCredentialFixes(fixable: FixableService[], currentServices: ServiceStatus[]): Promise<void> {
948
- for (const service of fixable) {
949
- const confirmed = await this.#extensionUiController.showHookConfirm(
950
- `${service.name} login`,
951
- `${service.prompt}. Fix now?`,
952
- );
953
- if (!confirmed) continue;
954
-
955
- // Drain pending terminal input (e.g. an in-flight OSC 11 poll response)
956
- // before dropping raw mode, so it is consumed here instead of echoed as
957
- // gibberish by the terminal while the cooked-mode subprocess runs.
958
- await this.ui.suspendForSubprocess();
959
- try {
960
- const proc = Bun.spawn(service.command, {
961
- stdin: "inherit",
962
- stdout: "inherit",
963
- stderr: "inherit",
964
- });
965
- await proc.exited;
966
- } catch (error) {
967
- logger.warn(`Auto-fix for ${service.name} failed`, { error: String(error) });
968
- } finally {
969
- const clearScreen = settings.get("startup.clearScreen");
970
- this.ui.start(clearScreen);
971
- this.ui.requestRender(true);
972
- }
973
-
974
- const updated = await service.recheck();
975
- const idx = currentServices.findIndex(s => s.name === service.name);
976
- if (idx !== -1) {
977
- currentServices[idx] = updated;
978
- this.#welcomeComponent?.setServices([...currentServices]);
979
- }
980
- const pluginIdx = this.#currentPlugins.findIndex(p => p.name.toLowerCase() === service.name.toLowerCase());
981
- if (pluginIdx !== -1) {
982
- this.#currentPlugins[pluginIdx] = {
983
- ...this.#currentPlugins[pluginIdx],
984
- state: updated.state,
985
- hint: updated.hint,
986
- };
987
- this.#welcomeComponent?.setPlugins([...this.#currentPlugins]);
988
- }
989
- this.ui.requestRender();
990
- }
991
- }
992
-
993
882
  async #approvePlan(
994
883
  planContent: string,
995
884
  options: { planFilePath: string; finalPlanFilePath: string },
@@ -1208,6 +1097,10 @@ export class InteractiveMode implements InteractiveModeContext {
1208
1097
  this.#uiHelpers.showWarning(message);
1209
1098
  }
1210
1099
 
1100
+ hasActiveLlmProvider(): boolean {
1101
+ return hasActiveLlmProvider(this.session.model, this.session.modelRegistry.authStorage);
1102
+ }
1103
+
1211
1104
  ensureLoadingAnimation(): void {
1212
1105
  if (!this.loadingAnimation) {
1213
1106
  this.statusContainer.clear();
@@ -1251,11 +1144,6 @@ export class InteractiveMode implements InteractiveModeContext {
1251
1144
  this.setWorkingMessage(message);
1252
1145
  }
1253
1146
 
1254
- setUpdateStatus(status: UpdateStatus | undefined): void {
1255
- this.#welcomeComponent?.setUpdateStatus(status);
1256
- this.ui.requestRender();
1257
- }
1258
-
1259
1147
  clearEditor(): void {
1260
1148
  this.#uiHelpers.clearEditor();
1261
1149
  }
@@ -1471,42 +1359,8 @@ export class InteractiveMode implements InteractiveModeContext {
1471
1359
  }
1472
1360
 
1473
1361
  async refreshWelcomeAfterLogin(): Promise<void> {
1474
- this.#welcomeComponent?.setModelStatus({ state: "connected", provider: "anthropic" });
1475
-
1476
- // Validate F5 XC Context independently — call validateToken() for a live
1477
- // check instead of reading cached state (which may be "unknown" if
1478
- // validation hasn't run yet in this session).
1479
- const services: ServiceStatus[] = [];
1480
- try {
1481
- const ctxService = ContextService.instance;
1482
- const ctxStatus = ctxService.getStatus();
1483
- if (ctxStatus.isConfigured) {
1484
- const name = ctxStatus.activeContextTenant ?? ctxStatus.activeContextName ?? undefined;
1485
- const result = await ctxService.validateToken({ timeoutMs: 5000 });
1486
- services.push(
1487
- mapContextStatus({
1488
- state:
1489
- result.status === "connected"
1490
- ? "connected"
1491
- : result.status === "auth_error"
1492
- ? "auth_error"
1493
- : "offline",
1494
- name,
1495
- }),
1496
- );
1497
- }
1498
- } catch {
1499
- // ContextService not initialized — skip
1500
- }
1501
- this.#welcomeComponent?.setServices(services);
1502
-
1503
- // Run plugin checks and update the welcome screen
1504
- const pluginContributions = this.session.extensionRunner?.getAllRegisteredServiceStatuses() ?? [];
1505
- const plugins = await buildUnifiedPluginList(pluginContributions).catch(() => []);
1506
- this.#currentPlugins = plugins;
1507
- this.#welcomeComponent?.setPlugins(plugins);
1508
-
1509
- // Restore editor
1362
+ // The welcome splash is static (logo + version only); after login the model
1363
+ // gate lifts on its own (see applyModelAfterLogin). Just restore the editor.
1510
1364
  this.editorContainer.clear();
1511
1365
  this.editorContainer.addChild(this.editor);
1512
1366
  this.ui.setFocus(this.editor);
@@ -137,6 +137,8 @@ export interface InteractiveModeContext {
137
137
  showStatus(message: string, options?: { dim?: boolean }): void;
138
138
  showError(message: string): void;
139
139
  showWarning(message: string): void;
140
+ /** Instant, local check for whether a usable LLM provider is configured (no network). */
141
+ hasActiveLlmProvider(): boolean;
140
142
  clearEditor(): void;
141
143
  updatePendingMessagesDisplay(): void;
142
144
  queueCompactionMessage(text: string, mode: "steer" | "followUp"): void;