diff --git a/packages/cli/src/commands/app/call.ts b/packages/cli/src/commands/app/call.ts index 365071c..ab13f8c 100644 --- a/packages/cli/src/commands/app/call.ts +++ b/packages/cli/src/commands/app/call.ts @@ -13,6 +13,7 @@ import { } from "bailian-cli-core"; import { failIfMissing } from "../../output/prompt.ts"; import { emitResult, emitBare } from "../../output/output.ts"; +import { createSpinner, shouldShowWaitSpinner } from "../../output/progress.ts"; export default defineCommand({ name: "app call", @@ -107,16 +108,32 @@ export default defineCommand({ } const url = appCompletionEndpoint(config.baseUrl, appId); + const showWait = shouldShowWaitSpinner(config); + const spinner = createSpinner("Waiting for app response..."); + if (showWait) spinner.start(); + let waitStopped = false; + const stopWait = () => { + if (!waitStopped && showWait) { + waitStopped = true; + spinner.stop(); + } + }; if (shouldStream) { - const headers: Record = { "X-DashScope-SSE": "enable" }; - const res = await request(config, { - url, - method: "POST", - body, - headers, - stream: true, - }); + let res: Awaited>; + try { + const headers: Record = { "X-DashScope-SSE": "enable" }; + res = await request(config, { + url, + method: "POST", + body, + headers, + stream: true, + }); + } catch (err) { + stopWait(); + throw err; + } let fullText = ""; let sessionId = ""; @@ -124,41 +141,43 @@ export default defineCommand({ const dim = config.noColor ? "" : "\x1b[2m"; const reset = config.noColor ? "" : "\x1b[0m"; - for await (const event of parseSSE(res)) { - if (event.data === "[DONE]") break; - try { - const chunk = JSON.parse(event.data) as AppStreamChunk; - const text = chunk.output?.text; - - if (text) { - // incremental_output: text is delta - if (writesStreamingStdout) process.stdout.write(text); - fullText += text; - } + try { + for await (const event of parseSSE(res)) { + if (event.data === "[DONE]") break; + try { + const chunk = JSON.parse(event.data) as AppStreamChunk; + const text = chunk.output?.text; + + if (text) { + stopWait(); + if (writesStreamingStdout) process.stdout.write(text); + fullText += text; + } - // Capture session_id for multi-turn - if (chunk.output?.session_id) { - sessionId = chunk.output.session_id; - } + if (chunk.output?.session_id) { + sessionId = chunk.output.session_id; + } - // Show thoughts if available - if (chunk.output?.thoughts && flags.hasThoughts) { - for (const t of chunk.output.thoughts) { - if (t.thought) process.stderr.write(`${dim}[Thinking] ${t.thought}${reset}\n`); - if (t.action_name) - process.stderr.write( - `${dim}[Action] ${t.action_name}: ${t.action_input || ""}${reset}\n`, - ); - if (t.observation) - process.stderr.write(`${dim}[Observation] ${t.observation}${reset}\n`); + if (chunk.output?.thoughts && flags.hasThoughts) { + stopWait(); + for (const t of chunk.output.thoughts) { + if (t.thought) process.stderr.write(`${dim}[Thinking] ${t.thought}${reset}\n`); + if (t.action_name) + process.stderr.write( + `${dim}[Action] ${t.action_name}: ${t.action_input || ""}${reset}\n`, + ); + if (t.observation) + process.stderr.write(`${dim}[Observation] ${t.observation}${reset}\n`); + } } + } catch { + // skip unparseable } - } catch { - // skip unparseable } + } finally { + stopWait(); } - // Show session_id for multi-turn conversation if (sessionId && !config.quiet) { process.stderr.write(`${dim}Session ID: ${sessionId}${reset}\n`); } @@ -169,18 +188,22 @@ export default defineCommand({ process.stdout.write("\n"); } } else { - const response = await requestJson(config, { - url, - method: "POST", - body, - }); - - const text = response.output?.text ?? ""; - - if (config.quiet || format === "text") { - emitBare(text); - } else { - emitResult(response, format); + try { + const response = await requestJson(config, { + url, + method: "POST", + body, + }); + + const text = response.output?.text ?? ""; + + if (config.quiet || format === "text") { + emitBare(text); + } else { + emitResult(response, format); + } + } finally { + stopWait(); } } }, diff --git a/packages/cli/src/commands/text/chat.ts b/packages/cli/src/commands/text/chat.ts index 9741c6d..064d183 100644 --- a/packages/cli/src/commands/text/chat.ts +++ b/packages/cli/src/commands/text/chat.ts @@ -15,6 +15,7 @@ import { } from "bailian-cli-core"; import { promptText, failIfMissing } from "../../output/prompt.ts"; import { emitResult, emitBare } from "../../output/output.ts"; +import { createSpinner, shouldShowWaitSpinner } from "../../output/progress.ts"; import { readFileSync } from "fs"; interface ParsedMessages { @@ -181,14 +182,30 @@ export default defineCommand({ } const url = chatEndpoint(config.baseUrl); + const showWait = shouldShowWaitSpinner(config); + const spinner = createSpinner("Waiting for model response..."); + if (showWait) spinner.start(); + let waitStopped = false; + const stopWait = () => { + if (!waitStopped && showWait) { + waitStopped = true; + spinner.stop(); + } + }; if (shouldStream) { - const res = await request(config, { - url, - method: "POST", - body, - stream: true, - }); + let res: Awaited>; + try { + res = await request(config, { + url, + method: "POST", + body, + stream: true, + }); + } catch (err) { + stopWait(); + throw err; + } let textContent = ""; let inThinking = false; @@ -200,36 +217,40 @@ export default defineCommand({ format === "json" ? process.stderr : isTTY ? process.stdout : process.stderr; const resultOut = process.stdout; - for await (const event of parseSSE(res)) { - if (event.data === "[DONE]") break; - try { - const parsed = JSON.parse(event.data) as StreamChunk; + try { + for await (const event of parseSSE(res)) { + if (event.data === "[DONE]") break; + try { + const parsed = JSON.parse(event.data) as StreamChunk; - for (const choice of parsed.choices) { - const delta = choice.delta; + for (const choice of parsed.choices) { + const delta = choice.delta; - // Handle thinking/reasoning content - if (delta.reasoning_content) { - if (writesStreamingStdout && !inThinking) { - inThinking = true; - statusOut.write(`${dim}Thinking:\n`); + if (delta.reasoning_content) { + stopWait(); + if (writesStreamingStdout && !inThinking) { + inThinking = true; + statusOut.write(`${dim}Thinking:\n`); + } + if (writesStreamingStdout) statusOut.write(delta.reasoning_content); } - if (writesStreamingStdout) statusOut.write(delta.reasoning_content); - } - // Handle regular content - if (delta.content) { - if (writesStreamingStdout && inThinking) { - statusOut.write(`${reset}\n\nResponse:\n`); - inThinking = false; + if (delta.content) { + stopWait(); + if (writesStreamingStdout && inThinking) { + statusOut.write(`${reset}\n\nResponse:\n`); + inThinking = false; + } + textContent += delta.content; + if (writesStreamingStdout) resultOut.write(delta.content); } - textContent += delta.content; - if (writesStreamingStdout) resultOut.write(delta.content); } + } catch { + // Skip unparseable chunks } - } catch { - // Skip unparseable chunks } + } finally { + stopWait(); } if (inThinking) statusOut.write(reset); @@ -239,18 +260,22 @@ export default defineCommand({ resultOut.write("\n"); } } else { - const response = await requestJson(config, { - url, - method: "POST", - body, - }); + try { + const response = await requestJson(config, { + url, + method: "POST", + body, + }); - const text = response.choices?.[0]?.message?.content ?? ""; + const text = response.choices?.[0]?.message?.content ?? ""; - if (config.quiet || format === "text") { - emitBare(text); - } else { - emitResult(response, format); + if (config.quiet || format === "text") { + emitBare(text); + } else { + emitResult(response, format); + } + } finally { + stopWait(); } } }, diff --git a/packages/cli/src/output/progress.ts b/packages/cli/src/output/progress.ts index d450799..7937853 100644 --- a/packages/cli/src/output/progress.ts +++ b/packages/cli/src/output/progress.ts @@ -1,29 +1,56 @@ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +export function formatElapsed(ms: number): string { + const sec = ms / 1000; + if (sec < 60) return `${sec.toFixed(1)}s`; + const m = Math.floor(sec / 60); + const s = Math.round(sec % 60); + return `${m}m ${s}s`; +} + +export interface SpinnerOptions { + /** Show elapsed time since start(), e.g. `(12.3s)`. Default: true */ + showElapsed?: boolean; + /** Append elapsed time to final line on stop(). Default: true */ + showElapsedOnStop?: boolean; +} + export interface Spinner { start(): void; update(text: string): void; stop(finalText?: string): void; + elapsedMs(): number; } -export function createSpinner(label: string): Spinner { +export function createSpinner(label: string, options: SpinnerOptions = {}): Spinner { + const showElapsed = options.showElapsed !== false; + const showElapsedOnStop = options.showElapsedOnStop !== false; const isTTY = process.stderr.isTTY; let frame = 0; let interval: ReturnType | null = null; let currentLabel = label; + let startedAt: number | null = null; + + const renderLine = (): string => { + const elapsed = + showElapsed && startedAt != null ? ` (${formatElapsed(performance.now() - startedAt)})` : ""; + return `${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${currentLabel}${elapsed}`; + }; return { start() { + startedAt = performance.now(); if (!isTTY) return; interval = setInterval(() => { - process.stderr.write(`\r${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${currentLabel}`); + process.stderr.write(`\r${renderLine()}`); frame++; - }, 80); + }, 100); }, update(text: string) { currentLabel = text; }, stop(finalText?: string) { + const elapsedMs = startedAt != null ? performance.now() - startedAt : 0; if (interval) { clearInterval(interval); interval = null; @@ -31,13 +58,23 @@ export function createSpinner(label: string): Spinner { if (isTTY) { process.stderr.write("\r\x1b[K"); if (finalText) { - process.stderr.write(`${finalText}\n`); + const suffix = showElapsedOnStop && elapsedMs > 0 ? ` (${formatElapsed(elapsedMs)})` : ""; + process.stderr.write(`${finalText}${suffix}\n`); } } + startedAt = null; + }, + elapsedMs() { + return startedAt != null ? performance.now() - startedAt : 0; }, }; } +/** Spinner for long network waits (TTY + not quiet). */ +export function shouldShowWaitSpinner(config: { quiet: boolean }): boolean { + return !config.quiet && process.stderr.isTTY; +} + export interface ProgressBar { update(current: number): void; finish(): void; diff --git a/packages/cli/tests/index.test.ts b/packages/cli/tests/index.test.ts index f6853c0..7049e25 100644 --- a/packages/cli/tests/index.test.ts +++ b/packages/cli/tests/index.test.ts @@ -6,8 +6,12 @@ import { getByJsonPointer } from "../src/pipeline/schema.ts"; import { normalizeConcurrency } from "../src/pipeline/scheduler.ts"; import { WORKFLOW_VERSION, type PipelineDefinition } from "../src/pipeline/types.ts"; -test("cli package skeleton", () => { - expect(true).toBe(true); +import { formatElapsed } from "../src/output/progress.ts"; + +test("formatElapsed formats sub-minute and minute durations", () => { + expect(formatElapsed(0)).toBe("0.0s"); + expect(formatElapsed(12_345)).toBe("12.3s"); + expect(formatElapsed(90_000)).toBe("1m 30s"); }); test("pipeline execution can use an isolated step dispatcher", async () => {