fix(cli-run): silence wait noise and suppress raw arrow escape input

This commit is contained in:
YeonGyu-Kim 2026-02-18 17:25:13 +09:00
parent a60a153d19
commit 5bb0e69dea
5 changed files with 245 additions and 11 deletions

View File

@ -0,0 +1,76 @@
import { describe, it, expect, mock, spyOn } from "bun:test"
import type { RunContext, ChildSession, SessionStatus } from "./types"
const createMockContext = (overrides: {
childrenBySession?: Record<string, ChildSession[]>
statuses?: Record<string, SessionStatus>
verbose?: boolean
} = {}): RunContext => {
const {
childrenBySession = { "test-session": [] },
statuses = {},
verbose = false,
} = overrides
return {
client: {
session: {
todo: mock(() => Promise.resolve({ data: [] })),
children: mock((opts: { path: { id: string } }) =>
Promise.resolve({ data: childrenBySession[opts.path.id] ?? [] })
),
status: mock(() => Promise.resolve({ data: statuses })),
},
} as unknown as RunContext["client"],
sessionID: "test-session",
directory: "/test",
abortController: new AbortController(),
verbose,
}
}
describe("checkCompletionConditions verbose waiting logs", () => {
it("does not print busy waiting line when verbose is disabled", async () => {
// given
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [],
},
statuses: { "child-1": { type: "busy" } },
verbose: false,
})
const { checkCompletionConditions } = await import("./completion")
// when
const result = await checkCompletionConditions(ctx)
// then
expect(result).toBe(false)
expect(consoleLogSpy).not.toHaveBeenCalled()
})
it("prints busy waiting line when verbose is enabled", async () => {
// given
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [],
},
statuses: { "child-1": { type: "busy" } },
verbose: true,
})
const { checkCompletionConditions } = await import("./completion")
// when
const result = await checkCompletionConditions(ctx)
// then
expect(result).toBe(false)
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Waiting: session child-1... is busy")
)
})
})

View File

@ -12,7 +12,7 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
if (continuationState.hasActiveHookMarker) {
const reason = continuationState.activeHookMarkerReason ?? "continuation hook is active"
console.log(pc.dim(` Waiting: ${reason}`))
logWaiting(ctx, reason)
return false
}
@ -24,7 +24,7 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
return false
}
if (!areContinuationHooksIdle(continuationState)) {
if (!areContinuationHooksIdle(ctx, continuationState)) {
return false
}
@ -35,14 +35,17 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
}
}
function areContinuationHooksIdle(continuationState: ContinuationState): boolean {
function areContinuationHooksIdle(
ctx: RunContext,
continuationState: ContinuationState
): boolean {
if (continuationState.hasActiveBoulder) {
console.log(pc.dim(" Waiting: boulder continuation is active"))
logWaiting(ctx, "boulder continuation is active")
return false
}
if (continuationState.hasActiveRalphLoop) {
console.log(pc.dim(" Waiting: ralph-loop continuation is active"))
logWaiting(ctx, "ralph-loop continuation is active")
return false
}
@ -61,7 +64,7 @@ async function areAllTodosComplete(ctx: RunContext): Promise<boolean> {
)
if (incompleteTodos.length > 0) {
console.log(pc.dim(` Waiting: ${incompleteTodos.length} todos remaining`))
logWaiting(ctx, `${incompleteTodos.length} todos remaining`)
return false
}
@ -96,9 +99,7 @@ async function areAllDescendantsIdle(
for (const child of children) {
const status = allStatuses[child.id]
if (status && status.type !== "idle") {
console.log(
pc.dim(` Waiting: session ${child.id.slice(0, 8)}... is ${status.type}`)
)
logWaiting(ctx, `session ${child.id.slice(0, 8)}... is ${status.type}`)
return false
}
@ -114,3 +115,11 @@ async function areAllDescendantsIdle(
return true
}
function logWaiting(ctx: RunContext, message: string): void {
if (!ctx.verbose) {
return
}
console.log(pc.dim(` Waiting: ${message}`))
}

View File

@ -9,6 +9,7 @@ import { executeOnCompleteHook } from "./on-complete-hook"
import { resolveRunAgent } from "./agent-resolver"
import { pollForCompletion } from "./poll-for-completion"
import { loadAgentProfileColors } from "./agent-profile-colors"
import { suppressRunInput } from "./stdin-suppression"
export { resolveRunAgent }
@ -53,11 +54,15 @@ export async function run(options: RunOptions): Promise<number> {
serverCleanup()
}
process.on("SIGINT", () => {
const restoreInput = suppressRunInput()
const handleSigint = () => {
console.log(pc.yellow("\nInterrupted. Shutting down..."))
restoreInput()
cleanup()
process.exit(130)
})
}
process.on("SIGINT", handleSigint)
try {
const sessionID = await resolveSession({
@ -124,6 +129,9 @@ export async function run(options: RunOptions): Promise<number> {
} catch (err) {
cleanup()
throw err
} finally {
process.removeListener("SIGINT", handleSigint)
restoreInput()
}
} catch (err) {
if (jsonManager) jsonManager.restore()

View File

@ -0,0 +1,89 @@
import { describe, it, expect, mock } from "bun:test"
import { EventEmitter } from "node:events"
import { suppressRunInput } from "./stdin-suppression"
type FakeStdin = EventEmitter & {
isTTY?: boolean
isRaw?: boolean
setRawMode: ReturnType<typeof mock<(mode: boolean) => void>>
isPaused: ReturnType<typeof mock<() => boolean>>
resume: ReturnType<typeof mock<() => void>>
pause: ReturnType<typeof mock<() => void>>
}
function createFakeStdin(options: {
isTTY?: boolean
isRaw?: boolean
paused?: boolean
} = {}): FakeStdin {
const emitter = new EventEmitter() as FakeStdin
emitter.isTTY = options.isTTY ?? true
emitter.isRaw = options.isRaw ?? false
emitter.setRawMode = mock((mode: boolean) => {
emitter.isRaw = mode
})
emitter.isPaused = mock(() => options.paused ?? false)
emitter.resume = mock(() => {})
emitter.pause = mock(() => {})
return emitter
}
describe("suppressRunInput", () => {
it("ignores non-tty stdin", () => {
// given
const stdin = createFakeStdin({ isTTY: false })
const onInterrupt = mock(() => {})
// when
const restore = suppressRunInput(stdin, onInterrupt)
restore()
// then
expect(stdin.setRawMode).not.toHaveBeenCalled()
expect(stdin.resume).not.toHaveBeenCalled()
expect(onInterrupt).not.toHaveBeenCalled()
})
it("enables raw mode and restores it", () => {
// given
const stdin = createFakeStdin({ isRaw: false, paused: true })
// when
const restore = suppressRunInput(stdin)
restore()
// then
expect(stdin.setRawMode).toHaveBeenNthCalledWith(1, true)
expect(stdin.resume).toHaveBeenCalledTimes(1)
expect(stdin.setRawMode).toHaveBeenNthCalledWith(2, false)
expect(stdin.pause).toHaveBeenCalledTimes(1)
})
it("calls interrupt handler on ctrl-c", () => {
// given
const stdin = createFakeStdin()
const onInterrupt = mock(() => {})
const restore = suppressRunInput(stdin, onInterrupt)
// when
stdin.emit("data", "\u0003")
restore()
// then
expect(onInterrupt).toHaveBeenCalledTimes(1)
})
it("does not call interrupt handler on arrow-key escape", () => {
// given
const stdin = createFakeStdin()
const onInterrupt = mock(() => {})
const restore = suppressRunInput(stdin, onInterrupt)
// when
stdin.emit("data", "\u001b[A")
restore()
// then
expect(onInterrupt).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,52 @@
type StdinLike = {
isTTY?: boolean
isRaw?: boolean
setRawMode?: (mode: boolean) => void
isPaused?: () => boolean
resume: () => void
pause: () => void
on: (event: "data", listener: (chunk: string | Uint8Array) => void) => void
removeListener: (event: "data", listener: (chunk: string | Uint8Array) => void) => void
}
function includesCtrlC(chunk: string | Uint8Array): boolean {
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")
return text.includes("\u0003")
}
export function suppressRunInput(
stdin: StdinLike = process.stdin,
onInterrupt: () => void = () => {
process.kill(process.pid, "SIGINT")
}
): () => void {
if (!stdin.isTTY) {
return () => {}
}
const wasRaw = stdin.isRaw === true
const wasPaused = stdin.isPaused?.() ?? false
const canSetRawMode = typeof stdin.setRawMode === "function"
const onData = (chunk: string | Uint8Array) => {
if (includesCtrlC(chunk)) {
onInterrupt()
}
}
if (canSetRawMode) {
stdin.setRawMode!(true)
}
stdin.on("data", onData)
stdin.resume()
return () => {
stdin.removeListener("data", onData)
if (canSetRawMode) {
stdin.setRawMode!(wasRaw)
}
if (wasPaused) {
stdin.pause()
}
}
}