Skip to content

Commit ea4da7d

Browse files
authored
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
1 parent 8f1a214 commit ea4da7d

4 files changed

Lines changed: 156 additions & 83 deletions

File tree

scripts/install.sh

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,13 @@ bootstrap_gum_temp() {
179179
gum_tmpdir="$(mktemp -d)"
180180
TMPFILES+=("$gum_tmpdir")
181181

182+
ui_info "Preparing spinner support"
182183
if ! download_file "${base}/${asset}" "$gum_tmpdir/$asset"; then
183184
GUM_REASON="download failed"
184185
return 1
185186
fi
186187

188+
ui_info "Verifying spinner support download"
187189
if ! download_file "${base}/checksums.txt" "$gum_tmpdir/checksums.txt"; then
188190
GUM_REASON="checksum unavailable or failed"
189191
return 1
@@ -444,6 +446,7 @@ run_quiet_step() {
444446

445447
local log
446448
log="$(mktempfile)"
449+
local showed_progress=false
447450

448451
if [[ -n "$GUM" ]] && gum_is_tty && ! is_shell_function "${1:-}"; then
449452
local cmd_quoted=""
@@ -453,12 +456,20 @@ run_quiet_step() {
453456
if run_with_spinner "$title" bash -c "${cmd_quoted}>${log_quoted} 2>&1"; then
454457
return 0
455458
fi
459+
showed_progress=true
456460
else
461+
# Keep users informed even when gum spinner cannot run (for example shell functions).
462+
ui_info "${title}"
463+
showed_progress=true
457464
if "$@" >"$log" 2>&1; then
458465
return 0
459466
fi
460467
fi
461468

469+
if [[ "$showed_progress" == "false" ]]; then
470+
ui_info "${title}"
471+
fi
472+
462473
ui_error "${title} failed — re-run with --verbose for details"
463474
if [[ -s "$log" ]]; then
464475
tail -n 80 "$log" >&2 || true
@@ -1432,7 +1443,7 @@ install_node() {
14321443
if command -v apt-get &> /dev/null; then
14331444
local tmp
14341445
tmp="$(mktempfile)"
1435-
download_file "https://deb.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
1446+
run_quiet_step "Downloading NodeSource setup script" download_file "https://deb.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
14361447
if is_root; then
14371448
run_quiet_step "Configuring NodeSource repository" bash "$tmp"
14381449
run_quiet_step "Installing Node.js" apt-get install -y -qq nodejs
@@ -1443,7 +1454,7 @@ install_node() {
14431454
elif command -v dnf &> /dev/null; then
14441455
local tmp
14451456
tmp="$(mktempfile)"
1446-
download_file "https://rpm.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
1457+
run_quiet_step "Downloading NodeSource setup script" download_file "https://rpm.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
14471458
if is_root; then
14481459
run_quiet_step "Configuring NodeSource repository" bash "$tmp"
14491460
run_quiet_step "Installing Node.js" dnf install -y -q nodejs
@@ -1454,7 +1465,7 @@ install_node() {
14541465
elif command -v yum &> /dev/null; then
14551466
local tmp
14561467
tmp="$(mktempfile)"
1457-
download_file "https://rpm.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
1468+
run_quiet_step "Downloading NodeSource setup script" download_file "https://rpm.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
14581469
if is_root; then
14591470
run_quiet_step "Configuring NodeSource repository" bash "$tmp"
14601471
run_quiet_step "Installing Node.js" yum install -y -q nodejs
@@ -2265,6 +2276,8 @@ main() {
22652276
return 0
22662277
fi
22672278

2279+
# bootstrap_gum_temp may perform network downloads before any spinner is available.
2280+
echo -e "${INFO}Preparing installer interface...${NC}"
22682281
bootstrap_gum_temp || true
22692282
print_installer_banner
22702283
print_gum_status

src/cli/run-main.ts

Lines changed: 119 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { enableConsoleCapture } from "../logging.js";
1515
import type { PluginManifestCommandAliasRegistry } from "../plugins/manifest-command-aliases.js";
1616
import { resolveManifestCommandAliasOwner } from "../plugins/manifest-command-aliases.runtime.js";
1717
import { hasMemoryRuntime } from "../plugins/memory-state.js";
18+
import { createCliProgress } from "./progress.js";
1819
import { maybeWarnAboutDebugProxyCoverage } from "../proxy-capture/coverage.js";
1920
import {
2021
finalizeDebugProxyCapture,
@@ -248,7 +249,25 @@ export async function runCli(argv: string[] = process.argv) {
248249
return;
249250
}
250251
const { runCrestodian } = await import("../crestodian/crestodian.js");
251-
await runCrestodian();
252+
const progress = createCliProgress({
253+
label: "Starting Crestodian…",
254+
indeterminate: true,
255+
delayMs: 0,
256+
fallback: "none",
257+
});
258+
let progressStopped = false;
259+
const stopProgress = () => {
260+
if (progressStopped) {
261+
return;
262+
}
263+
progressStopped = true;
264+
progress.done();
265+
};
266+
try {
267+
await runCrestodian({ onReady: stopProgress });
268+
} finally {
269+
stopProgress();
270+
}
252271
return;
253272
}
254273

@@ -268,95 +287,116 @@ export async function runCli(argv: string[] = process.argv) {
268287
return;
269288
}
270289

271-
// Capture all console output into structured logs while keeping stdout/stderr behavior.
272-
enableConsoleCapture();
290+
const startupProgress = createCliProgress({
291+
label: "Loading OpenClaw CLI…",
292+
indeterminate: true,
293+
delayMs: 0,
294+
fallback: "none",
295+
});
296+
let startupProgressStopped = false;
297+
const stopStartupProgress = () => {
298+
if (startupProgressStopped) {
299+
return;
300+
}
301+
startupProgressStopped = true;
302+
startupProgress.done();
303+
};
304+
305+
try {
306+
// Capture all console output into structured logs while keeping stdout/stderr behavior.
307+
enableConsoleCapture();
273308

274-
const [
275-
{ buildProgram },
276-
{ runFatalErrorHooks },
277-
{ installUnhandledRejectionHandler },
278-
{ restoreTerminalState },
279-
] = await Promise.all([
280-
import("./program.js"),
281-
import("../infra/fatal-error-hooks.js"),
282-
import("../infra/unhandled-rejections.js"),
283-
import("../terminal/restore.js"),
284-
]);
285-
const program = buildProgram();
309+
const [
310+
{ buildProgram },
311+
{ runFatalErrorHooks },
312+
{ installUnhandledRejectionHandler },
313+
{ restoreTerminalState },
314+
] = await Promise.all([
315+
import("./program.js"),
316+
import("../infra/fatal-error-hooks.js"),
317+
import("../infra/unhandled-rejections.js"),
318+
import("../terminal/restore.js"),
319+
]);
320+
const program = buildProgram();
286321

287-
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
288-
// These log the error and exit gracefully instead of crashing without trace.
289-
installUnhandledRejectionHandler();
322+
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
323+
// These log the error and exit gracefully instead of crashing without trace.
324+
installUnhandledRejectionHandler();
290325

291-
process.on("uncaughtException", (error) => {
292-
console.error("[openclaw] Uncaught exception:", formatUncaughtError(error));
293-
for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) {
294-
console.error("[openclaw]", message);
295-
}
296-
restoreTerminalState("uncaught exception", { resumeStdinIfPaused: false });
297-
process.exit(1);
298-
});
326+
process.on("uncaughtException", (error) => {
327+
console.error("[openclaw] Uncaught exception:", formatUncaughtError(error));
328+
for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) {
329+
console.error("[openclaw]", message);
330+
}
331+
restoreTerminalState("uncaught exception", { resumeStdinIfPaused: false });
332+
process.exit(1);
333+
});
299334

300-
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
301-
const invocation = resolveCliArgvInvocation(parseArgv);
302-
// Register the primary command (builtin or subcli) so help and command parsing
303-
// are correct even with lazy command registration.
304-
const { primary } = invocation;
305-
if (primary && shouldRegisterPrimaryCommandOnly(parseArgv)) {
306-
const { getProgramContext } = await import("./program/program-context.js");
307-
const ctx = getProgramContext(program);
308-
if (ctx) {
309-
const { registerCoreCliByName } = await import("./program/command-registry.js");
310-
await registerCoreCliByName(program, ctx, primary, parseArgv);
335+
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
336+
const invocation = resolveCliArgvInvocation(parseArgv);
337+
// Register the primary command (builtin or subcli) so help and command parsing
338+
// are correct even with lazy command registration.
339+
const { primary } = invocation;
340+
if (primary && shouldRegisterPrimaryCommandOnly(parseArgv)) {
341+
const { getProgramContext } = await import("./program/program-context.js");
342+
const ctx = getProgramContext(program);
343+
if (ctx) {
344+
const { registerCoreCliByName } = await import("./program/command-registry.js");
345+
await registerCoreCliByName(program, ctx, primary, parseArgv);
346+
}
347+
const { registerSubCliByName } = await import("./program/register.subclis.js");
348+
await registerSubCliByName(program, primary);
311349
}
312-
const { registerSubCliByName } = await import("./program/register.subclis.js");
313-
await registerSubCliByName(program, primary);
314-
}
315350

316-
const hasBuiltinPrimary =
317-
primary !== null &&
318-
program.commands.some(
319-
(command) => command.name() === primary || command.aliases().includes(primary),
320-
);
321-
const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({
322-
argv: parseArgv,
323-
primary,
324-
hasBuiltinPrimary,
325-
});
326-
if (!shouldSkipPluginRegistration) {
327-
// Register plugin CLI commands before parsing
328-
const { registerPluginCliCommandsFromValidatedConfig } = await import("../plugins/cli.js");
329-
const config = await registerPluginCliCommandsFromValidatedConfig(
330-
program,
331-
undefined,
332-
undefined,
333-
{
334-
mode: "lazy",
335-
primary,
336-
},
337-
);
338-
if (config) {
339-
if (
340-
primary &&
341-
!program.commands.some(
342-
(command) => command.name() === primary || command.aliases().includes(primary),
343-
)
344-
) {
345-
const missingPluginCommandMessage = resolveMissingPluginCommandMessage(primary, config);
346-
if (missingPluginCommandMessage) {
347-
throw new Error(missingPluginCommandMessage);
351+
const hasBuiltinPrimary =
352+
primary !== null &&
353+
program.commands.some(
354+
(command) => command.name() === primary || command.aliases().includes(primary),
355+
);
356+
const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({
357+
argv: parseArgv,
358+
primary,
359+
hasBuiltinPrimary,
360+
});
361+
if (!shouldSkipPluginRegistration) {
362+
// Register plugin CLI commands before parsing
363+
const { registerPluginCliCommandsFromValidatedConfig } = await import("../plugins/cli.js");
364+
const config = await registerPluginCliCommandsFromValidatedConfig(
365+
program,
366+
undefined,
367+
undefined,
368+
{
369+
mode: "lazy",
370+
primary,
371+
},
372+
);
373+
if (config) {
374+
if (
375+
primary &&
376+
!program.commands.some(
377+
(command) => command.name() === primary || command.aliases().includes(primary),
378+
)
379+
) {
380+
const missingPluginCommandMessage = resolveMissingPluginCommandMessage(primary, config);
381+
if (missingPluginCommandMessage) {
382+
throw new Error(missingPluginCommandMessage);
383+
}
348384
}
349385
}
350386
}
351-
}
352387

353-
try {
354-
await program.parseAsync(parseArgv);
355-
} catch (error) {
356-
if (!(error instanceof CommanderError)) {
357-
throw error;
388+
stopStartupProgress();
389+
390+
try {
391+
await program.parseAsync(parseArgv);
392+
} catch (error) {
393+
if (!(error instanceof CommanderError)) {
394+
throw error;
395+
}
396+
process.exitCode = error.exitCode;
358397
}
359-
process.exitCode = error.exitCode;
398+
} finally {
399+
stopStartupProgress();
360400
}
361401
} finally {
362402
await closeCliMemoryManagers();

src/crestodian/crestodian.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,13 @@ describe("runCrestodian", () => {
5454
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json"));
5555
const { runtime, lines } = createCrestodianTestRuntime();
5656
const runGatewayRestart = vi.fn(async () => {});
57+
const onReady = vi.fn();
5758

5859
await runCrestodian(
5960
{
6061
message: "the local bridge looks sleepy, poke it",
6162
deps: { runGatewayRestart },
63+
onReady,
6264
planWithAssistant: async () => ({
6365
reply: "I can queue a Gateway restart.",
6466
command: "restart gateway",
@@ -69,6 +71,7 @@ describe("runCrestodian", () => {
6971
);
7072

7173
expect(runGatewayRestart).not.toHaveBeenCalled();
74+
expect(onReady).not.toHaveBeenCalled();
7275
expect(lines.join("\n")).toContain("[crestodian] planner: openai/gpt-5.5");
7376
expect(lines.join("\n")).toContain("[crestodian] interpreted: restart gateway");
7477
expect(lines.join("\n")).toContain("Plan: restart the Gateway. Say yes to apply.");
@@ -80,16 +83,19 @@ describe("runCrestodian", () => {
8083
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json"));
8184
const { runtime, lines } = createCrestodianTestRuntime();
8285
const planner = vi.fn(async () => ({ command: "restart gateway" }));
86+
const onReady = vi.fn();
8387

8488
await runCrestodian(
8589
{
8690
message: "models",
8791
planWithAssistant: planner,
92+
onReady,
8893
},
8994
runtime,
9095
);
9196

9297
expect(planner).not.toHaveBeenCalled();
98+
expect(onReady).not.toHaveBeenCalled();
9399
expect(lines.join("\n")).toContain("Default model:");
94100
});
95101

@@ -99,12 +105,14 @@ describe("runCrestodian", () => {
99105
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json"));
100106
const { runtime, lines } = createCrestodianTestRuntime();
101107
const runInteractiveTui = vi.fn(async () => {});
108+
const onReady = vi.fn();
102109

103110
await runCrestodian(
104111
{
105112
input: { isTTY: true } as unknown as NodeJS.ReadableStream,
106113
output: { isTTY: true } as unknown as NodeJS.WritableStream,
107114
runInteractiveTui,
115+
onReady,
108116
},
109117
runtime,
110118
);
@@ -113,6 +121,7 @@ describe("runCrestodian", () => {
113121
expect.objectContaining({ runInteractiveTui }),
114122
runtime,
115123
);
124+
expect(onReady).toHaveBeenCalledTimes(1);
116125
expect(lines.join("\n")).not.toContain("Say: status");
117126
});
118127
});

0 commit comments

Comments
 (0)