
























@@ -286,10 +286,27 @@ function resolveInstallDefaultChoice(params: {
286286async function promptInstallChoice(params: {
287287entry: OnboardingPluginInstallEntry;
288288localPath?: string | null;
289+bundledLocalPath?: string | null;
289290defaultChoice: InstallChoice;
290291prompter: WizardPrompter;
292+/** When true and only one real install source (npm *or* local, not both)
293+ * exists, skip the "Install <plugin>? / Skip" prompt and resolve directly
294+ * to that source. Useful when the caller already knows the user's intent
295+ * (e.g. they just picked the channel in a previous menu). */
296+autoConfirmSingleSource?: boolean;
291297}): Promise<InstallChoice> {
292-const npmSpec = resolveNpmSpecForOnboarding(params.entry.install);
298+const rawNpmSpec = resolveNpmSpecForOnboarding(params.entry.install);
299+// When the plugin already ships bundled with the host (i.e. lives under
300+// `extensions/<id>` and is discovered via `resolveBundledPluginSources`),
301+// the bundled copy is the source of truth: it is version-locked to the
302+// current host build and is what `defaultChoice` will pick anyway (see
303+// `resolveInstallDefaultChoice`). Surfacing a "Download from npm (...)"
304+// option in that case is misleading — it suggests the plugin is missing
305+// and forces the user to reason about an npm catalog channel that, for
306+// bundled channels, only exists as a fallback for non-bundled builds.
307+// Hide the npm option entirely in this scenario so bundled channels like
308+// Tlon look identical to Twitch / Slack in the menu.
309+const npmSpec = params.bundledLocalPath ? null : rawNpmSpec;
293310const safeLabel = sanitizeTerminalText(params.entry.label);
294311const safeNpmSpec = npmSpec ? sanitizeTerminalText(npmSpec) : null;
295312const safeLocalPath = params.localPath ? sanitizeTerminalText(params.localPath) : null;
@@ -307,6 +324,20 @@ async function promptInstallChoice(params: {
307324 ...(safeLocalPath ? { hint: safeLocalPath } : {}),
308325});
309326}
327+328+if (params.autoConfirmSingleSource) {
329+const realSources: InstallChoice[] = [];
330+if (safeNpmSpec) {
331+realSources.push("npm");
332+}
333+if (params.localPath) {
334+realSources.push("local");
335+}
336+if (realSources.length === 1) {
337+return realSources[0];
338+}
339+}
340+310341options.push({ value: "skip", label: "Skip for now" });
311342312343const initialValue =
@@ -366,6 +397,120 @@ async function applyPluginEnablement(params: {
366397return enableResult;
367398}
368399400+type AnimatedProgress = {
401+setLabel: (label: string) => void;
402+stop: () => void;
403+};
404+405+const PROGRESS_BAR_WIDTH = 16;
406+const PROGRESS_BAR_TICK_MS = 200;
407+const PROGRESS_BAR_DURATION_MS = 10_000;
408+const PROGRESS_BAR_MAX_PERCENT = 99;
409+410+/**
411+ * Maps a verbose install log line (e.g. `Downloading @scope/pkg@1.2.3 from
412+ * ClawHub…`, `Extracting /tmp/…/wecom-…-2026.4.23.tgz…`, `Installing to
413+ * /home/.../plugins/demo…`) to a short verb suitable for a progress label.
414+ *
415+ * Falls back to the raw message when no known verb prefix is recognised so
416+ * that unexpected log lines still surface to the user instead of being
417+ * swallowed.
418+ */
419+function shortenInstallLabel(message: string): string {
420+const trimmed = message.trim();
421+// Match a leading verb phrase. Order matters: more specific phrases first.
422+const patterns: Array<[RegExp, string]> = [
423+[/^Downloading\b/i, "Downloading"],
424+[/^Extracting\b/i, "Extracting"],
425+[/^Installing\s+to\b/i, "Installing"],
426+[/^Installing\b/i, "Installing"],
427+[/^Resolving\b/i, "Resolving"],
428+[/^Cloning\b/i, "Cloning"],
429+[/^Verifying\b/i, "Verifying"],
430+[/^Preparing\b/i, "Preparing"],
431+[/^Linking\b/i, "Linking"],
432+[/^Linked\b/i, "Linking"],
433+[/^Compatibility\b/i, "Resolving"],
434+[/^ClawHub\b/i, "Resolving"],
435+];
436+for (const [pattern, label] of patterns) {
437+if (pattern.test(trimmed)) {
438+return label;
439+}
440+}
441+return trimmed;
442+}
443+444+/**
445+ * Wraps a {@link WizardProgress} so the spinner message keeps a steadily
446+ * growing ASCII bar attached to whatever the current install step label is.
447+ *
448+ * The plugin install pipeline only emits coarse `info` log lines, so without
449+ * animation the spinner can sit on the same string for many seconds with no
450+ * visible feedback. We render a deterministic left-to-right filling bar that
451+ * advances linearly over {@link PROGRESS_BAR_DURATION_MS} (default 10s) up to
452+ * {@link PROGRESS_BAR_MAX_PERCENT} (99%). If the install takes longer than the
453+ * preset duration the bar simply stays pinned at 99% — never wrapping back to
454+ * 0% — so the user always sees forward motion and a ceiling that signals
455+ * "almost there, just waiting on the last bit".
456+ *
457+ * The bare label is forwarded to `progress.update` first on every label
458+ * change so callers/tests that assert on the unadorned message continue to
459+ * observe it before any decorated frame is overlaid.
460+ */
461+function createAnimatedInstallProgress(
462+progress: { update: (message: string) => void },
463+options: { totalMs?: number } = {},
464+): AnimatedProgress {
465+const totalMs = options.totalMs ?? PROGRESS_BAR_DURATION_MS;
466+let currentLabel = "";
467+const startedAt = Date.now();
468+469+const computePercent = (): number => {
470+const elapsed = Date.now() - startedAt;
471+const raw = Math.floor((elapsed / totalMs) * 100);
472+return Math.max(0, Math.min(PROGRESS_BAR_MAX_PERCENT, raw));
473+};
474+475+const renderBar = (): string => {
476+const percent = computePercent();
477+const filled = Math.round((percent / 100) * PROGRESS_BAR_WIDTH);
478+const bar =
479+"█".repeat(filled) + "░".repeat(Math.max(0, PROGRESS_BAR_WIDTH - filled));
480+return `[${bar}] ${percent}%`;
481+};
482+483+const decorate = (label: string): string => {
484+if (!label) {
485+return renderBar();
486+}
487+return `${label} ${renderBar()}`;
488+};
489+490+const timer = setInterval(() => {
491+if (currentLabel) {
492+progress.update(decorate(currentLabel));
493+}
494+}, PROGRESS_BAR_TICK_MS);
495+// Animation is decorative: never let it hold the event loop open if a caller
496+// forgets to stop us (e.g. an unexpected throw bypasses the `finally`).
497+if (typeof timer.unref === "function") {
498+timer.unref();
499+}
500+501+return {
502+setLabel: (label: string) => {
503+currentLabel = label;
504+// Always emit the bare label first so existing log/test expectations
505+// continue to observe the unadorned message before any animation frame.
506+progress.update(label);
507+},
508+stop: () => {
509+clearInterval(timer);
510+},
511+};
512+}
513+369514async function installPluginFromNpmSpecWithProgress(params: {
370515entry: OnboardingPluginInstallEntry;
371516npmSpec: string;
@@ -380,12 +525,14 @@ async function installPluginFromNpmSpecWithProgress(params: {
380525> {
381526const safeLabel = sanitizeTerminalText(params.entry.label);
382527const progress = params.prompter.progress(`Installing ${safeLabel} plugin…`);
528+const animated = createAnimatedInstallProgress(progress);
529+animated.setLabel("Preparing");
383530const updateProgress = (message: string) => {
384-const next = sanitizeTerminalText(message).trim();
385-if (!next) {
531+const sanitized = sanitizeTerminalText(message).trim();
532+if (!sanitized) {
386533return;
387534}
388-progress.update(next);
535+animated.setLabel(shortenInstallLabel(sanitized));
389536};
390537391538try {
@@ -405,6 +552,7 @@ async function installPluginFromNpmSpecWithProgress(params: {
405552}),
406553ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS,
407554);
555+animated.stop();
408556if (result.ok) {
409557progress.stop(`Installed ${safeLabel} plugin`);
410558} else {
@@ -415,6 +563,7 @@ async function installPluginFromNpmSpecWithProgress(params: {
415563 result,
416564};
417565} catch (error) {
566+animated.stop();
418567if (isTimeoutError(error)) {
419568progress.stop(`Install timed out: ${safeLabel}`);
420569return { status: "timed_out" };
@@ -437,6 +586,7 @@ export async function ensureOnboardingPluginInstalled(params: {
437586runtime: RuntimeEnv;
438587workspaceDir?: string;
439588promptInstall?: boolean;
589+autoConfirmSingleSource?: boolean;
440590}): Promise<OnboardingPluginInstallResult> {
441591const { entry, prompter, runtime, workspaceDir } = params;
442592let next = params.cfg;
@@ -463,8 +613,10 @@ export async function ensureOnboardingPluginInstalled(params: {
463613 : await promptInstallChoice({
464614 entry,
465615 localPath,
616+ bundledLocalPath,
466617 defaultChoice,
467618 prompter,
619+autoConfirmSingleSource: params.autoConfirmSingleSource,
468620});
469621470622if (choice === "skip") {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。