diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index 964ba13b69bd..f2192dbdc9f2 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -9,6 +9,28 @@ const GRADIENT_TO = [0x2d, 0x7b, 0xff] as const export class CancelledError extends Schema.TaggedErrorClass()("UICancelledError", {}) {} +// Suppresses non-essential stderr chatter (print/println); errors always print. +let quiet = false +export function setQuiet(value: boolean) { + quiet = value +} + +/** Honor https://no-color.org: any non-empty NO_COLOR value disables ANSI colors. */ +export function colors() { + return !process.env.NO_COLOR +} + +/** Remove ANSI color/style sequences from a string. */ +export function strip(text: string) { + return text.replaceAll(/\x1b\[[0-9;]*m/g, "") +} + +function render(message: string[]) { + const text = message.join(" ") + if (colors()) return text + return strip(text) +} + export const Style = { TEXT_HIGHLIGHT: "\x1b[96m", TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m", @@ -27,13 +49,15 @@ export const Style = { } export function println(...message: string[]) { + if (quiet) return print(...message) process.stderr.write(EOL) } export function print(...message: string[]) { + if (quiet) return blank = false - process.stderr.write(message.join(" ")) + process.stderr.write(render(message)) } let blank = false @@ -47,7 +71,7 @@ export function logo(pad?: string) { const leftWidth = glyphs.left[0].length const totalWidth = leftWidth + 1 + glyphs.right[0].length - if (!process.stdout.isTTY && !process.stderr.isTTY) { + if (!colors() || (!process.stdout.isTTY && !process.stderr.isTTY)) { return glyphs.left.map((row, index) => `${pad ?? ""}${row} ${glyphs.right[index] ?? ""}`).join(EOL) } @@ -105,7 +129,9 @@ export function error(message: string) { if (message.startsWith("Error: ")) { message = message.slice("Error: ".length) } - println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message) + // Errors bypass --quiet: write directly instead of going through println. + blank = false + process.stderr.write(render([Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message]) + EOL) } export function markdown(text: string): string { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index c88625960b01..9e938fe259ee 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -127,7 +127,20 @@ const cli = yargs(args) describe: "use a named config profile", type: "string", }) + .option("quiet", { + describe: "suppress non-essential output on stderr (errors still print)", + type: "boolean", + }) + .option("verbose", { + describe: "print debug logs to stderr (implies --print-logs and --log-level DEBUG)", + type: "boolean", + }) .middleware(async (opts) => { + if (opts.quiet) UI.setQuiet(true) + if (opts.verbose) { + process.env.OPENCODE_PRINT_LOGS = "1" + process.env.OPENCODE_LOG_LEVEL = "DEBUG" + } if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1" if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel if (opts.profile) process.env.OPENCODE_PROFILE = opts.profile diff --git a/packages/opencode/test/cli/ui.test.ts b/packages/opencode/test/cli/ui.test.ts new file mode 100644 index 000000000000..56591f0b1613 --- /dev/null +++ b/packages/opencode/test/cli/ui.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { UI } from "../../src/cli/ui" + +const saved = process.env.NO_COLOR + +afterEach(() => { + if (saved === undefined) { + delete process.env.NO_COLOR + return + } + process.env.NO_COLOR = saved +}) + +describe("strip", () => { + test("removes ANSI style sequences", () => { + expect(UI.strip(`${UI.Style.TEXT_DANGER_BOLD}Error:${UI.Style.TEXT_NORMAL} boom`)).toBe("Error: boom") + }) + + test("keeps plain text untouched", () => { + expect(UI.strip("plain text")).toBe("plain text") + }) +}) + +describe("colors", () => { + test("enabled when NO_COLOR is unset", () => { + delete process.env.NO_COLOR + expect(UI.colors()).toBe(true) + }) + + test("enabled when NO_COLOR is empty per the spec", () => { + process.env.NO_COLOR = "" + expect(UI.colors()).toBe(true) + }) + + test("disabled when NO_COLOR is set to any value", () => { + process.env.NO_COLOR = "1" + expect(UI.colors()).toBe(false) + }) +})