Add startup progress indicators (#71720)

* Add startup progress indicators

* Narrow startup progress scope

* Revert startup spinner delay to immediate feedback

* Improve install.sh progress feedback for quiet steps

* Show progress for installer download phases
This commit is contained in:
Sebastien Tardif
2026-04-25 14:16:00 -07:00
committed by GitHub
parent 8f1a214a23
commit ea4da7dfcc
4 changed files with 159 additions and 86 deletions

View File

@@ -179,11 +179,13 @@ bootstrap_gum_temp() {
gum_tmpdir="$(mktemp -d)"
TMPFILES+=("$gum_tmpdir")
ui_info "Preparing spinner support"
if ! download_file "${base}/${asset}" "$gum_tmpdir/$asset"; then
GUM_REASON="download failed"
return 1
fi
ui_info "Verifying spinner support download"
if ! download_file "${base}/checksums.txt" "$gum_tmpdir/checksums.txt"; then
GUM_REASON="checksum unavailable or failed"
return 1
@@ -444,6 +446,7 @@ run_quiet_step() {
local log
log="$(mktempfile)"
local showed_progress=false
if [[ -n "$GUM" ]] && gum_is_tty && ! is_shell_function "${1:-}"; then
local cmd_quoted=""
@@ -453,12 +456,20 @@ run_quiet_step() {
if run_with_spinner "$title" bash -c "${cmd_quoted}>${log_quoted} 2>&1"; then
return 0
fi
showed_progress=true
else
# Keep users informed even when gum spinner cannot run (for example shell functions).
ui_info "${title}"
showed_progress=true
if "$@" >"$log" 2>&1; then
return 0
fi
fi
if [[ "$showed_progress" == "false" ]]; then
ui_info "${title}"
fi
ui_error "${title} failed — re-run with --verbose for details"
if [[ -s "$log" ]]; then
tail -n 80 "$log" >&2 || true
@@ -1432,7 +1443,7 @@ install_node() {
if command -v apt-get &> /dev/null; then
local tmp
tmp="$(mktempfile)"
download_file "https://deb.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
run_quiet_step "Downloading NodeSource setup script" download_file "https://deb.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
if is_root; then
run_quiet_step "Configuring NodeSource repository" bash "$tmp"
run_quiet_step "Installing Node.js" apt-get install -y -qq nodejs
@@ -1443,7 +1454,7 @@ install_node() {
elif command -v dnf &> /dev/null; then
local tmp
tmp="$(mktempfile)"
download_file "https://rpm.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
run_quiet_step "Downloading NodeSource setup script" download_file "https://rpm.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
if is_root; then
run_quiet_step "Configuring NodeSource repository" bash "$tmp"
run_quiet_step "Installing Node.js" dnf install -y -q nodejs
@@ -1454,7 +1465,7 @@ install_node() {
elif command -v yum &> /dev/null; then
local tmp
tmp="$(mktempfile)"
download_file "https://rpm.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
run_quiet_step "Downloading NodeSource setup script" download_file "https://rpm.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
if is_root; then
run_quiet_step "Configuring NodeSource repository" bash "$tmp"
run_quiet_step "Installing Node.js" yum install -y -q nodejs
@@ -2265,6 +2276,8 @@ main() {
return 0
fi
# bootstrap_gum_temp may perform network downloads before any spinner is available.
echo -e "${INFO}Preparing installer interface...${NC}"
bootstrap_gum_temp || true
print_installer_banner
print_gum_status

View File

@@ -15,6 +15,7 @@ import { enableConsoleCapture } from "../logging.js";
import type { PluginManifestCommandAliasRegistry } from "../plugins/manifest-command-aliases.js";
import { resolveManifestCommandAliasOwner } from "../plugins/manifest-command-aliases.runtime.js";
import { hasMemoryRuntime } from "../plugins/memory-state.js";
import { createCliProgress } from "./progress.js";
import { maybeWarnAboutDebugProxyCoverage } from "../proxy-capture/coverage.js";
import {
finalizeDebugProxyCapture,
@@ -248,7 +249,25 @@ export async function runCli(argv: string[] = process.argv) {
return;
}
const { runCrestodian } = await import("../crestodian/crestodian.js");
await runCrestodian();
const progress = createCliProgress({
label: "Starting Crestodian…",
indeterminate: true,
delayMs: 0,
fallback: "none",
});
let progressStopped = false;
const stopProgress = () => {
if (progressStopped) {
return;
}
progressStopped = true;
progress.done();
};
try {
await runCrestodian({ onReady: stopProgress });
} finally {
stopProgress();
}
return;
}
@@ -268,95 +287,116 @@ export async function runCli(argv: string[] = process.argv) {
return;
}
// Capture all console output into structured logs while keeping stdout/stderr behavior.
enableConsoleCapture();
const [
{ buildProgram },
{ runFatalErrorHooks },
{ installUnhandledRejectionHandler },
{ restoreTerminalState },
] = await Promise.all([
import("./program.js"),
import("../infra/fatal-error-hooks.js"),
import("../infra/unhandled-rejections.js"),
import("../terminal/restore.js"),
]);
const program = buildProgram();
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
// These log the error and exit gracefully instead of crashing without trace.
installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => {
console.error("[openclaw] Uncaught exception:", formatUncaughtError(error));
for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) {
console.error("[openclaw]", message);
}
restoreTerminalState("uncaught exception", { resumeStdinIfPaused: false });
process.exit(1);
const startupProgress = createCliProgress({
label: "Loading OpenClaw CLI…",
indeterminate: true,
delayMs: 0,
fallback: "none",
});
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
const invocation = resolveCliArgvInvocation(parseArgv);
// Register the primary command (builtin or subcli) so help and command parsing
// are correct even with lazy command registration.
const { primary } = invocation;
if (primary && shouldRegisterPrimaryCommandOnly(parseArgv)) {
const { getProgramContext } = await import("./program/program-context.js");
const ctx = getProgramContext(program);
if (ctx) {
const { registerCoreCliByName } = await import("./program/command-registry.js");
await registerCoreCliByName(program, ctx, primary, parseArgv);
let startupProgressStopped = false;
const stopStartupProgress = () => {
if (startupProgressStopped) {
return;
}
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
startupProgressStopped = true;
startupProgress.done();
};
const hasBuiltinPrimary =
primary !== null &&
program.commands.some(
(command) => command.name() === primary || command.aliases().includes(primary),
);
const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({
argv: parseArgv,
primary,
hasBuiltinPrimary,
});
if (!shouldSkipPluginRegistration) {
// Register plugin CLI commands before parsing
const { registerPluginCliCommandsFromValidatedConfig } = await import("../plugins/cli.js");
const config = await registerPluginCliCommandsFromValidatedConfig(
program,
undefined,
undefined,
{
mode: "lazy",
primary,
},
);
if (config) {
if (
primary &&
!program.commands.some(
(command) => command.name() === primary || command.aliases().includes(primary),
)
) {
const missingPluginCommandMessage = resolveMissingPluginCommandMessage(primary, config);
if (missingPluginCommandMessage) {
throw new Error(missingPluginCommandMessage);
try {
// Capture all console output into structured logs while keeping stdout/stderr behavior.
enableConsoleCapture();
const [
{ buildProgram },
{ runFatalErrorHooks },
{ installUnhandledRejectionHandler },
{ restoreTerminalState },
] = await Promise.all([
import("./program.js"),
import("../infra/fatal-error-hooks.js"),
import("../infra/unhandled-rejections.js"),
import("../terminal/restore.js"),
]);
const program = buildProgram();
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
// These log the error and exit gracefully instead of crashing without trace.
installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => {
console.error("[openclaw] Uncaught exception:", formatUncaughtError(error));
for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) {
console.error("[openclaw]", message);
}
restoreTerminalState("uncaught exception", { resumeStdinIfPaused: false });
process.exit(1);
});
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
const invocation = resolveCliArgvInvocation(parseArgv);
// Register the primary command (builtin or subcli) so help and command parsing
// are correct even with lazy command registration.
const { primary } = invocation;
if (primary && shouldRegisterPrimaryCommandOnly(parseArgv)) {
const { getProgramContext } = await import("./program/program-context.js");
const ctx = getProgramContext(program);
if (ctx) {
const { registerCoreCliByName } = await import("./program/command-registry.js");
await registerCoreCliByName(program, ctx, primary, parseArgv);
}
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
const hasBuiltinPrimary =
primary !== null &&
program.commands.some(
(command) => command.name() === primary || command.aliases().includes(primary),
);
const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({
argv: parseArgv,
primary,
hasBuiltinPrimary,
});
if (!shouldSkipPluginRegistration) {
// Register plugin CLI commands before parsing
const { registerPluginCliCommandsFromValidatedConfig } = await import("../plugins/cli.js");
const config = await registerPluginCliCommandsFromValidatedConfig(
program,
undefined,
undefined,
{
mode: "lazy",
primary,
},
);
if (config) {
if (
primary &&
!program.commands.some(
(command) => command.name() === primary || command.aliases().includes(primary),
)
) {
const missingPluginCommandMessage = resolveMissingPluginCommandMessage(primary, config);
if (missingPluginCommandMessage) {
throw new Error(missingPluginCommandMessage);
}
}
}
}
}
try {
await program.parseAsync(parseArgv);
} catch (error) {
if (!(error instanceof CommanderError)) {
throw error;
stopStartupProgress();
try {
await program.parseAsync(parseArgv);
} catch (error) {
if (!(error instanceof CommanderError)) {
throw error;
}
process.exitCode = error.exitCode;
}
process.exitCode = error.exitCode;
} finally {
stopStartupProgress();
}
} finally {
await closeCliMemoryManagers();

View File

@@ -54,11 +54,13 @@ describe("runCrestodian", () => {
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json"));
const { runtime, lines } = createCrestodianTestRuntime();
const runGatewayRestart = vi.fn(async () => {});
const onReady = vi.fn();
await runCrestodian(
{
message: "the local bridge looks sleepy, poke it",
deps: { runGatewayRestart },
onReady,
planWithAssistant: async () => ({
reply: "I can queue a Gateway restart.",
command: "restart gateway",
@@ -69,6 +71,7 @@ describe("runCrestodian", () => {
);
expect(runGatewayRestart).not.toHaveBeenCalled();
expect(onReady).not.toHaveBeenCalled();
expect(lines.join("\n")).toContain("[crestodian] planner: openai/gpt-5.5");
expect(lines.join("\n")).toContain("[crestodian] interpreted: restart gateway");
expect(lines.join("\n")).toContain("Plan: restart the Gateway. Say yes to apply.");
@@ -80,16 +83,19 @@ describe("runCrestodian", () => {
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json"));
const { runtime, lines } = createCrestodianTestRuntime();
const planner = vi.fn(async () => ({ command: "restart gateway" }));
const onReady = vi.fn();
await runCrestodian(
{
message: "models",
planWithAssistant: planner,
onReady,
},
runtime,
);
expect(planner).not.toHaveBeenCalled();
expect(onReady).not.toHaveBeenCalled();
expect(lines.join("\n")).toContain("Default model:");
});
@@ -99,12 +105,14 @@ describe("runCrestodian", () => {
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json"));
const { runtime, lines } = createCrestodianTestRuntime();
const runInteractiveTui = vi.fn(async () => {});
const onReady = vi.fn();
await runCrestodian(
{
input: { isTTY: true } as unknown as NodeJS.ReadableStream,
output: { isTTY: true } as unknown as NodeJS.WritableStream,
runInteractiveTui,
onReady,
},
runtime,
);
@@ -113,6 +121,7 @@ describe("runCrestodian", () => {
expect.objectContaining({ runInteractiveTui }),
runtime,
);
expect(onReady).toHaveBeenCalledTimes(1);
expect(lines.join("\n")).not.toContain("Say: status");
});
});

View File

@@ -1,5 +1,6 @@
import { stdin as defaultStdin, stdout as defaultStdout } from "node:process";
import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js";
import { withProgress } from "../cli/progress.js";
import type { CrestodianAssistantPlanner } from "./assistant.js";
import { resolveCrestodianOperation } from "./dialogue.js";
import {
@@ -19,6 +20,7 @@ export type RunCrestodianOptions = {
yes?: boolean;
json?: boolean;
interactive?: boolean;
onReady?: () => void;
deps?: CrestodianCommandDeps;
planWithAssistant?: CrestodianAssistantPlanner;
input?: NodeJS.ReadableStream;
@@ -49,7 +51,15 @@ export async function runCrestodian(
}
if (opts.message?.trim()) {
const overview = await loadCrestodianOverview();
const overview = await withProgress(
{
label: "Loading Crestodian overview…",
indeterminate: true,
delayMs: 0,
fallback: "none",
},
async () => await loadCrestodianOverview(),
);
runtime.log(formatCrestodianOverview(overview));
runtime.log("");
await runOneShot(opts.message, runtime, opts);
@@ -68,5 +78,6 @@ export async function runCrestodian(
const runInteractiveTui =
opts.runInteractiveTui ?? (await import("./tui-backend.js")).runCrestodianTui;
opts.onReady?.();
await runInteractiveTui(opts, runtime);
}