From 5bb0e69dea3403463a9869cd703e92397cad3257 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 18 Feb 2026 17:25:13 +0900 Subject: [PATCH] fix(cli-run): silence wait noise and suppress raw arrow escape input --- .../run/completion-verbose-logging.test.ts | 76 ++++++++++++++++ src/cli/run/completion.ts | 27 ++++-- src/cli/run/runner.ts | 12 ++- src/cli/run/stdin-suppression.test.ts | 89 +++++++++++++++++++ src/cli/run/stdin-suppression.ts | 52 +++++++++++ 5 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 src/cli/run/completion-verbose-logging.test.ts create mode 100644 src/cli/run/stdin-suppression.test.ts create mode 100644 src/cli/run/stdin-suppression.ts diff --git a/src/cli/run/completion-verbose-logging.test.ts b/src/cli/run/completion-verbose-logging.test.ts new file mode 100644 index 00000000..3c2fc039 --- /dev/null +++ b/src/cli/run/completion-verbose-logging.test.ts @@ -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 + statuses?: Record + 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") + ) + }) +}) diff --git a/src/cli/run/completion.ts b/src/cli/run/completion.ts index a6127806..77f00ebd 100644 --- a/src/cli/run/completion.ts +++ b/src/cli/run/completion.ts @@ -12,7 +12,7 @@ export async function checkCompletionConditions(ctx: RunContext): Promise { ) 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}`)) +} diff --git a/src/cli/run/runner.ts b/src/cli/run/runner.ts index 0da683de..3e512259 100644 --- a/src/cli/run/runner.ts +++ b/src/cli/run/runner.ts @@ -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 { 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 { } catch (err) { cleanup() throw err + } finally { + process.removeListener("SIGINT", handleSigint) + restoreInput() } } catch (err) { if (jsonManager) jsonManager.restore() diff --git a/src/cli/run/stdin-suppression.test.ts b/src/cli/run/stdin-suppression.test.ts new file mode 100644 index 00000000..d8943423 --- /dev/null +++ b/src/cli/run/stdin-suppression.test.ts @@ -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 void>> + isPaused: ReturnType boolean>> + resume: ReturnType void>> + pause: ReturnType 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() + }) +}) diff --git a/src/cli/run/stdin-suppression.ts b/src/cli/run/stdin-suppression.ts new file mode 100644 index 00000000..ae3bf445 --- /dev/null +++ b/src/cli/run/stdin-suppression.ts @@ -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() + } + } +}