From f26ceade017576145e78dced2507303dd81c24e9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 11 May 2026 19:46:36 +0900 Subject: [PATCH 1/2] feat(cli): add Braille spinner for argue run and argue act (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The act command had no output between dispatch and awaitResult, leaving users unsure whether anything was running. The run command had event prints but the gaps between RoundDispatched and the first response could still feel silent on slow agents. Add a Braille spinner (⣾⣽⣻⢿⡿⣟⣯⣷) wired into: - runAction: spins from dispatch until awaitResult settles - runHeadless: spins between *Dispatched events and the next event, tracking still-waiting participants in a per-round set so the label updates as agents come back Spinner writes to stderr so it never pollutes act's stdout output. Non-TTY falls back to a single breadcrumb line; NO_COLOR disables ANSI. Also remove timer.unref() in two places: - spinner.setInterval: keeps the event loop alive while spinning so the animation actually renders instead of getting cut off after one frame - delegate.withTimeout: combined with mock provider's unref'd setTimeout it was causing "Detected unsettled top-level await" exits in act runs against the mock provider. clearTimeout is called on both settle paths so keeping the timer ref'd is safe. Closes #60 --- packages/argue-cli/src/index.ts | 13 +- packages/argue-cli/src/output.ts | 37 ++++++ packages/argue-cli/src/runtime/delegate.ts | 6 +- packages/argue-cli/src/spinner.ts | 77 +++++++++++ packages/argue-cli/test/output.test.ts | 95 ++++++++++++++ packages/argue-cli/test/spinner.test.ts | 143 +++++++++++++++++++++ 6 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 packages/argue-cli/src/spinner.ts create mode 100644 packages/argue-cli/test/spinner.test.ts diff --git a/packages/argue-cli/src/index.ts b/packages/argue-cli/src/index.ts index 5ca75a2..875a63e 100644 --- a/packages/argue-cli/src/index.ts +++ b/packages/argue-cli/src/index.ts @@ -21,6 +21,7 @@ import { createOutputFormatter } from "./output.js"; import { loadRunInput } from "./run-input.js"; import { defaultOutputDirTemplate, resolveRunPlan } from "./run-plan.js"; import { createTaskDelegate } from "./runtime/delegate.js"; +import { createSpinner } from "./spinner.js"; import { MAX_ENCODED_BYTES, openReportInViewer, resolveLatestRequestId } from "./view.js"; import { VENDOR_PRESETS, getVendorNames } from "./vendors.js"; export type { CliSdkProviderAdapter, CreateCliSdkProviderAdapter, ProviderTaskRunnerArgs } from "./runtime/types.js"; @@ -168,7 +169,9 @@ async function runHeadless(args: string[], io: Pick; @@ -36,6 +39,15 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {}) const tag = c.cyan("[argue]"); const verbose = options.verbose ?? false; + const spinnerStream = options.spinnerStream ?? null; + const spinner = spinnerStream + ? createSpinner(spinnerStream, "", { + isTTY: options.spinnerIsTTY ?? spinnerStream.isTTY ?? false, + noColor: options.noColor + }) + : null; + let waitingFor: Set = new Set(); + function stanceIcon(stance: string): string { if (stance === "agree") return c.green("✓"); if (stance === "disagree") return c.red("✗"); @@ -80,9 +92,18 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {}) const round = readNumber(payload.round); const roundTag = formatRoundTag(phase, round); + // Any event arriving means there is news to print. Stop the spinner + // first so log lines start at column 0; the spinner is rearmed below + // only when we are about to wait again. + spinner?.stop(); + if (event.type === "RoundDispatched") { const participants = readStringArray(payload.participants); io.log(`${tag} ${c.bold(roundTag)} dispatched ${c.dim("-> " + participants.join(", "))}`); + waitingFor = new Set(participants); + if (waitingFor.size > 0) { + spinner?.start(`${roundTag} waiting on ${[...waitingFor].join(", ")}…`); + } return; } @@ -112,6 +133,10 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {}) if (verbose) { printVerboseResponse(payload); } + waitingFor.delete(participantId); + if (waitingFor.size > 0) { + spinner?.start(`${roundTag} waiting on ${[...waitingFor].join(", ")}…`); + } return; } @@ -126,6 +151,10 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {}) } io.log(`${tag} ${c.bold(roundTag)} ${c.red(`${participantId} eliminated`)} ${c.dim(suffix)}`); + waitingFor.delete(participantId); + if (waitingFor.size > 0) { + spinner?.start(`${roundTag} waiting on ${[...waitingFor].join(", ")}…`); + } return; } @@ -133,6 +162,9 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {}) const source = readString(payload.sourceClaimId) ?? "?"; const mergedInto = readString(payload.mergedInto) ?? "?"; io.log(`${tag} ${c.bold(roundTag)} ${c.yellow(`claim merged ${source} -> ${mergedInto}`)}`); + if (waitingFor.size > 0) { + spinner?.start(`${roundTag} waiting on ${[...waitingFor].join(", ")}…`); + } return; } @@ -148,6 +180,7 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {}) `${tag} ${roundTag} completed: done=${completed} timeout=${timedOut} failed=${failed} claims=${claimCatalogSize} (+${newClaims}, -${mergeCount})` ) ); + waitingFor.clear(); return; } @@ -164,6 +197,7 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {}) if (event.type === "ReportDispatched") { const reporterId = readString(payload.reporterId) ?? "unknown"; io.log(`${tag} ${c.magenta(`report dispatched -> ${reporterId}`)}`); + spinner?.start(`composing report via ${reporterId}…`); return; } @@ -174,6 +208,7 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {}) if (verbose && prompt) { io.log(c.dim(` prompt: ${singleLine(prompt)}`)); } + spinner?.start(`${actorId} executing action…`); return; } @@ -210,6 +245,7 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {}) }, runCompleted(result: ArgueResult, paths: { resultPath: string; summaryPath: string }) { + spinner?.stop(); io.log(""); io.log(c.dim("─".repeat(60))); io.log(""); @@ -237,6 +273,7 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {}) }, runFailed(error: unknown, errorPath: string) { + spinner?.stop(); io.log(""); io.log(c.dim("─".repeat(60))); io.log(""); diff --git a/packages/argue-cli/src/runtime/delegate.ts b/packages/argue-cli/src/runtime/delegate.ts index 38a7279..b04cca4 100644 --- a/packages/argue-cli/src/runtime/delegate.ts +++ b/packages/argue-cli/src/runtime/delegate.ts @@ -168,8 +168,12 @@ async function withTimeout(promise: Promise, timeoutMs?: number): Promise< } return new Promise((resolve, reject) => { + // Intentionally not unref'd: if every other handle is unref'd (e.g. mock + // providers that unref their own setTimeout), unref'ing this one too lets + // the event loop exit before `promise` ever settles, leaving the top-level + // await hung. Keeping it ref'd is safe because clearTimeout is called on + // both resolve and reject paths. const timer = setTimeout(() => reject(new TimeoutError()), timeoutMs); - timer.unref?.(); promise.then( (value) => { diff --git a/packages/argue-cli/src/spinner.ts b/packages/argue-cli/src/spinner.ts new file mode 100644 index 0000000..9106cdc --- /dev/null +++ b/packages/argue-cli/src/spinner.ts @@ -0,0 +1,77 @@ +const BRAILLE_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]; + +export type SpinnerStream = { + write(chunk: string): boolean | void; + isTTY?: boolean; +}; + +export type SpinnerOptions = { + intervalMs?: number; + isTTY?: boolean; + noColor?: boolean; +}; + +export type Spinner = { + start(label?: string): void; + stop(finalLine?: string): void; + setLabel(next: string): void; + isActive(): boolean; +}; + +export function createSpinner(stream: SpinnerStream, label: string, options: SpinnerOptions = {}): Spinner { + const isTTY = options.isTTY ?? stream.isTTY ?? false; + const useColor = !options.noColor && !process.env.NO_COLOR && isTTY; + const intervalMs = options.intervalMs ?? 80; + + let currentLabel = label; + let frame = 0; + let timer: ReturnType | null = null; + let active = false; + + const dim = useColor ? "\x1b[2m" : ""; + const cyan = useColor ? "\x1b[36m" : ""; + const reset = useColor ? "\x1b[0m" : ""; + + function render(): void { + const glyph = BRAILLE_FRAMES[frame % BRAILLE_FRAMES.length]; + stream.write(`\r${cyan}${glyph}${reset} ${dim}${currentLabel}${reset}\x1b[K`); + frame += 1; + } + + return { + start(nextLabel?: string): void { + if (nextLabel !== undefined) currentLabel = nextLabel; + if (active) return; + active = true; + if (!isTTY) { + // No animation in non-TTY contexts (logs, CI). Still emit one line so + // there is a visible breadcrumb that the command is running. + stream.write(`${currentLabel}\n`); + return; + } + stream.write("\x1b[?25l"); + render(); + timer = setInterval(render, intervalMs); + }, + stop(finalLine?: string): void { + if (!active) return; + active = false; + if (timer) { + clearInterval(timer); + timer = null; + } + if (isTTY) { + stream.write("\r\x1b[K\x1b[?25h"); + } + if (finalLine) { + stream.write(`${finalLine}\n`); + } + }, + setLabel(next: string): void { + currentLabel = next; + }, + isActive(): boolean { + return active; + } + }; +} diff --git a/packages/argue-cli/test/output.test.ts b/packages/argue-cli/test/output.test.ts index ecd21af..7b0ddef 100644 --- a/packages/argue-cli/test/output.test.ts +++ b/packages/argue-cli/test/output.test.ts @@ -1,6 +1,19 @@ import type { ArgueEvent, ArgueResult } from "@onevcat/argue"; import { describe, expect, it } from "vitest"; import { createOutputFormatter, resultStatusTone } from "../src/output.js"; +import type { SpinnerStream } from "../src/spinner.js"; + +function createSpinnerStream(): SpinnerStream & { written: string[] } { + const written: string[] = []; + return { + isTTY: true, + write(chunk: string) { + written.push(chunk); + return true; + }, + written + }; +} function createIO(): { logs: string[]; errors: string[]; log: (msg: string) => void; error: (msg: string) => void } { const logs: string[] = []; @@ -365,6 +378,88 @@ describe("output formatter", () => { }); }); +describe("spinner integration", () => { + it("starts the spinner with remaining participants after RoundDispatched", () => { + const io = createIO(); + const stream = createSpinnerStream(); + const fmt = createOutputFormatter(io, { noColor: true, spinnerStream: stream, spinnerIsTTY: true }); + const handler = fmt.createEventHandler(); + + handler({ + type: "RoundDispatched", + at: new Date().toISOString(), + requestId: "req-1", + sessionId: "sess-1", + payload: { phase: "debate", round: 1, participants: ["a", "b"] } + }); + + const all = stream.written.join(""); + expect(all).toContain("waiting on"); + expect(all).toContain("a"); + expect(all).toContain("b"); + }); + + it("updates spinner label after each ParticipantResponded and stops on RoundCompleted", () => { + const io = createIO(); + const stream = createSpinnerStream(); + const fmt = createOutputFormatter(io, { noColor: true, spinnerStream: stream, spinnerIsTTY: true }); + const handler = fmt.createEventHandler(); + + handler({ + type: "RoundDispatched", + at: new Date().toISOString(), + requestId: "req-1", + sessionId: "sess-1", + payload: { phase: "debate", round: 1, participants: ["a", "b"] } + }); + handler(makeParticipantRespondedEvent({ participantId: "a" })); + + // After "a" responded, the spinner should restart with only "b" in label. + const afterA = stream.written.join(""); + expect(afterA).toMatch(/waiting on[^a-z]*b/); + + handler(makeParticipantRespondedEvent({ participantId: "b" })); + handler({ + type: "RoundCompleted", + at: new Date().toISOString(), + requestId: "req-1", + sessionId: "sess-1", + payload: { phase: "debate", round: 1, completed: 2, timedOut: 0, failed: 0 } + }); + + // RoundCompleted must end with clear-line + cursor-show, no further animation. + const last = stream.written[stream.written.length - 1] ?? ""; + expect(last).toContain("\x1b[?25h"); + }); + + it("starts a spinner on ActionDispatched and stops on ActionCompleted", () => { + const io = createIO(); + const stream = createSpinnerStream(); + const fmt = createOutputFormatter(io, { noColor: true, spinnerStream: stream, spinnerIsTTY: true }); + const handler = fmt.createEventHandler(); + + handler({ + type: "ActionDispatched", + at: new Date().toISOString(), + requestId: "req-1", + sessionId: "sess-1", + payload: { actorId: "agent-a", prompt: "do it" } + }); + expect(stream.written.join("")).toContain("agent-a executing action"); + + handler({ + type: "ActionCompleted", + at: new Date().toISOString(), + requestId: "req-1", + sessionId: "sess-1", + payload: { actorId: "agent-a", summary: "done" } + }); + + const last = stream.written[stream.written.length - 1] ?? ""; + expect(last).toContain("\x1b[?25h"); + }); +}); + describe("view hint", () => { it("prints `→ View report: argue view ` after runCompleted", () => { const logs: string[] = []; diff --git a/packages/argue-cli/test/spinner.test.ts b/packages/argue-cli/test/spinner.test.ts new file mode 100644 index 0000000..30c9623 --- /dev/null +++ b/packages/argue-cli/test/spinner.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createSpinner, type SpinnerStream } from "../src/spinner.js"; + +function createMockStream(isTTY: boolean): SpinnerStream & { written: string[] } { + const written: string[] = []; + return { + isTTY, + write(chunk: string) { + written.push(chunk); + return true; + }, + written + }; +} + +describe("spinner", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("animates Braille frames in TTY mode", () => { + const stream = createMockStream(true); + const spinner = createSpinner(stream, "working", { intervalMs: 80, isTTY: true, noColor: true }); + + spinner.start(); + // First frame is rendered synchronously. + expect(stream.written.join("")).toContain("⣾"); + + vi.advanceTimersByTime(80); + expect(stream.written.join("")).toContain("⣽"); + + vi.advanceTimersByTime(80); + expect(stream.written.join("")).toContain("⣻"); + + spinner.stop(); + }); + + it("hides and restores the cursor", () => { + const stream = createMockStream(true); + const spinner = createSpinner(stream, "working", { isTTY: true, noColor: true }); + + spinner.start(); + expect(stream.written.some((c) => c.includes("\x1b[?25l"))).toBe(true); + + spinner.stop(); + expect(stream.written.some((c) => c.includes("\x1b[?25h"))).toBe(true); + }); + + it("clears the line when stopped", () => { + const stream = createMockStream(true); + const spinner = createSpinner(stream, "working", { isTTY: true, noColor: true }); + + spinner.start(); + spinner.stop(); + + // Final write contains carriage return + erase-in-line. + const last = stream.written[stream.written.length - 1] ?? ""; + expect(last).toContain("\r"); + expect(last).toContain("\x1b[K"); + }); + + it("emits one breadcrumb line in non-TTY mode and does not animate", () => { + const stream = createMockStream(false); + const spinner = createSpinner(stream, "working", { isTTY: false, noColor: true }); + + spinner.start(); + vi.advanceTimersByTime(800); + spinner.stop(); + + const all = stream.written.join(""); + expect(all).toBe("working\n"); + expect(all).not.toContain("⣾"); + expect(all).not.toContain("\x1b[?25l"); + }); + + it("is idempotent on repeated start and stop", () => { + const stream = createMockStream(true); + const spinner = createSpinner(stream, "working", { isTTY: true, noColor: true }); + + spinner.start(); + spinner.start(); // no-op + const writesAfterDoubleStart = stream.written.length; + + spinner.stop(); + spinner.stop(); // no-op + const writesAfterDoubleStop = stream.written.length; + + // Second start did not add anything beyond a no-op. + // Second stop did not write a second clear sequence. + expect(writesAfterDoubleStart).toBeGreaterThan(0); + expect(writesAfterDoubleStop - writesAfterDoubleStart).toBeLessThanOrEqual(1); + }); + + it("writes ANSI color escapes only when color is enabled", () => { + const colored = createMockStream(true); + const plain = createMockStream(true); + + const a = createSpinner(colored, "working", { isTTY: true, noColor: false }); + const b = createSpinner(plain, "working", { isTTY: true, noColor: true }); + + a.start(); + b.start(); + + expect(colored.written.join("").includes("\x1b[36m")).toBe(true); + expect(plain.written.join("").includes("\x1b[36m")).toBe(false); + + a.stop(); + b.stop(); + }); + + it("supports updating the label via start argument or setLabel", () => { + const stream = createMockStream(true); + const spinner = createSpinner(stream, "first", { isTTY: true, noColor: true }); + + spinner.start(); + expect(stream.written.join("")).toContain("first"); + + spinner.setLabel("second"); + vi.advanceTimersByTime(80); + expect(stream.written.join("")).toContain("second"); + + spinner.stop(); + spinner.start("third"); + expect(stream.written.join("")).toContain("third"); + + spinner.stop(); + }); + + it("isActive() reflects start/stop state", () => { + const stream = createMockStream(true); + const spinner = createSpinner(stream, "label", { isTTY: true, noColor: true }); + + expect(spinner.isActive()).toBe(false); + spinner.start(); + expect(spinner.isActive()).toBe(true); + spinner.stop(); + expect(spinner.isActive()).toBe(false); + }); +}); From dfa12c9f38f8b49aadf07f2f110df13113ba9a69 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 11 May 2026 20:36:40 +0900 Subject: [PATCH 2/2] fix(cli): wire --no-color through argue act and stop spinner before error - parseActOptions now accepts --no-color and forwards noColor to the spinner so the act command honors the flag, matching the PR claim. - runAction's catch path now stops the spinner before printing the error so the line is not interleaved with an in-flight frame or written under a hidden cursor. - Help text updated to list --no-color under argue act. --- packages/argue-cli/src/index.ts | 36 ++++++++++-- .../argue-cli/test/index-branches.test.ts | 55 +++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/packages/argue-cli/src/index.ts b/packages/argue-cli/src/index.ts index 875a63e..c98392a 100644 --- a/packages/argue-cli/src/index.ts +++ b/packages/argue-cli/src/index.ts @@ -473,7 +473,8 @@ async function runAction(args: string[], io: Pick