From 941eb4c83081ab6bf519c4901ef3f11d21eafd2c Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 19 Apr 2026 05:47:02 +0200 Subject: [PATCH] headless: clean up sf headless auto stderr output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to make the headless progress stream readable at a glance: 1. Filter TUI footer widget keys from setStatus — 0-emoji, 0-color-band, authority, ollama, sf-fast, and sf-auto are sticky indicators for the interactive TUI footer, not workflow phases. They no longer leak through as [phase] ollama / [phase] sf-fast noise. 2. Unify tag prefix column width at 11 chars via a new tag() helper in headless-ui.ts. All of [tool], [agent], [forge], [phase], [thinking], [cost], [text] now align on the same column, matching the existing [headless] and [thinking] widths. 3. Dedupe consecutive identical progress lines in headless.ts so a widget that re-emits the same setStatus on every LLM call prints once instead of flooding stderr. Two different lines still both show; only adjacent duplicates collapse. Also tightens parsePhaseLabel so an unknown bare statusKey with no message returns null rather than leaking the raw key — a defense in depth if the footer-widget allowlist drifts behind a new extension. Tests: 4 new cases in headless-progress.test.ts covering footer-key suppression, bare-key suppression, workflow-phase passthrough, and tag-alignment. 88/88 pass. Co-Authored-By: Claude Sonnet 4.6 --- src/headless-ui.ts | 67 +++++++++++++++++++++-------- src/headless.ts | 10 ++++- src/tests/headless-progress.test.ts | 54 +++++++++++++++++++++++ 3 files changed, 111 insertions(+), 20 deletions(-) diff --git a/src/headless-ui.ts b/src/headless-ui.ts index 6ae3483c8..9d3da21bd 100644 --- a/src/headless-ui.ts +++ b/src/headless-ui.ts @@ -63,6 +63,32 @@ function noColor(): typeof _c { const colorsDisabled = !!process.env['NO_COLOR'] || !process.stderr.isTTY const c: typeof _c = colorsDisabled ? noColor() : _c +// --------------------------------------------------------------------------- +// Tag prefix helper (uniform column width across all formatters) +// --------------------------------------------------------------------------- + +// Width chosen so every tag lines up on the same column. 11 covers the +// widest existing labels ("[headless] ", "[thinking] "). +const TAG_WIDTH = 11 + +function tag(label: string): string { + const t = `[${label}]` + return t + ' '.repeat(Math.max(1, TAG_WIDTH - t.length)) +} + +// TUI footer widget keys registered by extensions (emoji indicator, +// color band, permissions tier, ollama status, service-tier fast/auto). +// These are sticky indicators for the interactive footer — not workflow +// phases — so suppress them in headless output. +const TUI_FOOTER_STATUS_KEYS = new Set([ + '0-emoji', + '0-color-band', + 'authority', + 'ollama', + 'sf-fast', + 'sf-auto', +]) + // --------------------------------------------------------------------------- // Tool-Arg Summarizer // --------------------------------------------------------------------------- @@ -268,7 +294,7 @@ export function formatProgress(event: Record, ctx: ProgressCont const name = String(event.toolName ?? 'unknown') const args = summarizeToolArgs(event.toolName, event.args) const argStr = args ? ` ${c.dim}${args}${c.reset}` : '' - return ` ${c.dim}[tool]${c.reset} ${name}${argStr}` + return `${c.dim}${tag('tool')}${c.reset}${name}${argStr}` } case 'tool_execution_end': { @@ -276,16 +302,16 @@ export function formatProgress(event: Record, ctx: ProgressCont const name = String(event.toolName ?? 'unknown') const durationStr = ctx.toolDuration != null ? ` ${c.dim}${formatDuration(ctx.toolDuration)}${c.reset}` : '' if (ctx.isError) { - return ` ${c.red}[tool] ${name} error${c.reset}${durationStr}` + return `${c.red}${tag('tool')}${name} error${c.reset}${durationStr}` } - return ` ${c.dim}[tool] ${name} done${c.reset}${durationStr}` + return `${c.dim}${tag('tool')}${name} done${c.reset}${durationStr}` } case 'agent_start': - return `${c.dim}[agent] Session started${c.reset}` + return `${c.dim}${tag('agent')}Session started${c.reset}` case 'agent_end': { - let line = `${c.dim}[agent] Session ended${c.reset}` + let line = `${c.dim}${tag('agent')}Session ended${c.reset}` if (ctx.lastCost) { const cost = `$${ctx.lastCost.costUsd.toFixed(4)}` const tokens = `${ctx.lastCost.inputTokens + ctx.lastCost.outputTokens} tokens` @@ -303,22 +329,23 @@ export function formatProgress(event: Record, ctx: ProgressCont // Bold important notifications const isImportant = /^(committed:|verification gate:|milestone|blocked:)/i.test(msg) return isImportant - ? `${c.bold}[forge] ${msg}${c.reset}` - : `[forge] ${msg}` + ? `${c.bold}${tag('forge')}${msg}${c.reset}` + : `${tag('forge')}${msg}` } if (method === 'setStatus') { - // Parse statusKey for phase transitions const statusKey = String(event.statusKey ?? '') const msg = String(event.message ?? '') if (!statusKey && !msg) return null // suppress empty status lines - // Show meaningful phase transitions + // Drop sticky TUI footer widgets — they have no meaning in a headless run. + if (TUI_FOOTER_STATUS_KEYS.has(statusKey)) return null + // Show meaningful phase transitions. if (statusKey) { const label = parsePhaseLabel(statusKey, msg) - if (label) return `${c.cyan}[phase] ${label}${c.reset}` + if (label) return `${c.cyan}${tag('phase')}${label}${c.reset}` } - // Fallback: show message if non-empty - if (msg) return `${c.cyan}[phase] ${msg}${c.reset}` + // Fallback: show message if non-empty. + if (msg) return `${c.cyan}${tag('phase')}${msg}${c.reset}` return null } @@ -337,7 +364,7 @@ export function formatProgress(event: Record, ctx: ProgressCont export function formatThinkingLine(text: string): string { const trimmed = text.replace(/\s+/g, ' ').trim() const truncated = trimmed.length > 120 ? trimmed.slice(0, 117) + '...' : trimmed - return `${c.dim}${c.italic}[thinking] ${truncated}${c.reset}` + return `${c.dim}${c.italic}${tag('thinking')}${truncated}${c.reset}` } // --------------------------------------------------------------------------- @@ -348,7 +375,7 @@ export function formatThinkingLine(text: string): string { * Format a text_start marker — printed once when the assistant begins a text block. */ export function formatTextStart(): string { - return `${c.dim}[text]${c.reset}` + return `${c.dim}${tag('text')}${c.reset}`.trimEnd() } /** @@ -362,7 +389,7 @@ export function formatTextEnd(): string { * Format a thinking_start marker. */ export function formatThinkingStart(): string { - return `${c.dim}${c.italic}[thinking]${c.reset}` + return `${c.dim}${c.italic}${tag('thinking')}${c.reset}`.trimEnd() } /** @@ -376,7 +403,7 @@ export function formatThinkingEnd(): string { * Format a cost line (used for periodic cost updates in verbose mode). */ export function formatCostLine(costUsd: number, inputTokens: number, outputTokens: number): string { - return `${c.dim}[cost] $${costUsd.toFixed(4)} (${inputTokens + outputTokens} tokens)${c.reset}` + return `${c.dim}${tag('cost')}$${costUsd.toFixed(4)} (${inputTokens + outputTokens} tokens)${c.reset}` } // --------------------------------------------------------------------------- @@ -407,9 +434,11 @@ function parsePhaseLabel(statusKey: string, message: string): string | null { } } - // Single-word status keys with a message - if (message) return `${statusKey}: ${message}` - return statusKey || null + // Unknown single-word keys: only surface when there's an accompanying + // message worth showing. Bare widget keys (e.g. a new footer indicator + // not yet added to TUI_FOOTER_STATUS_KEYS) are suppressed rather than + // leaking through. + return message ? `${statusKey}: ${message}` : null } // --------------------------------------------------------------------------- diff --git a/src/headless.ts b/src/headless.ts index 36e959d4c..561b05a44 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -389,6 +389,11 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): const toolStartTimes = new Map() let lastCostData: { costUsd: number; inputTokens: number; outputTokens: number } | undefined let thinkingBuffer = '' + // Drop only adjacent identical formatProgress output. A widget that + // re-emits the same setStatus on every LLM call would otherwise print + // the same line N times in a row. Two different lines still both show; + // a run of identical ones collapses to one. + let lastProgressLine: string | null = null // Streaming state: tracks whether we're inside a text or thinking block let inTextBlock = false let inThinkingBlock = false @@ -655,7 +660,10 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): } const line = formatProgress(eventObj, ctx) - if (line) process.stderr.write(line + '\n') + if (line && line !== lastProgressLine) { + process.stderr.write(line + '\n') + lastProgressLine = line + } } // Handle execution_complete (v2 structured completion) diff --git a/src/tests/headless-progress.test.ts b/src/tests/headless-progress.test.ts index 080bbee00..4678b8fcf 100644 --- a/src/tests/headless-progress.test.ts +++ b/src/tests/headless-progress.test.ts @@ -169,6 +169,60 @@ describe('formatProgress', () => { }, ctx()) assert.equal(result, null) }) + + it('suppresses TUI footer widget setStatus keys', () => { + for (const key of ['0-emoji', '0-color-band', 'authority', 'ollama', 'sf-fast', 'sf-auto']) { + const r = formatProgress({ + type: 'extension_ui_request', + method: 'setStatus', + statusKey: key, + message: '⏳', + }, ctx()) + assert.equal(r, null, `expected null for ${key}`) + } + }) + + it('suppresses bare unknown statusKey with no message', () => { + const r = formatProgress({ + type: 'extension_ui_request', + method: 'setStatus', + statusKey: 'something-new', + message: '', + }, ctx()) + assert.equal(r, null) + }) + + it('keeps workflow phases with colon prefix', () => { + const r = formatProgress({ + type: 'extension_ui_request', + method: 'setStatus', + statusKey: 'phase:discuss', + message: 'starting', + }, ctx()) + assert.ok(r && r.includes('Phase: discuss')) + }) + }) + + describe('tag prefix alignment', () => { + it('pads all tag prefixes to the same column width', () => { + const prefixes = [ + formatProgress({ type: 'agent_start' }, ctx())!, + formatProgress({ + type: 'extension_ui_request', + method: 'notify', + message: 'hi', + }, ctx())!, + formatCostLine(0.01, 100, 50), + formatThinkingLine('x'), + ] + // Under NO_COLOR / non-TTY, prefixes begin with "[tag]" followed by + // spaces to column 11, then content. + for (const p of prefixes) { + const m = p.match(/^\[[a-z]+\]\s+/) + assert.ok(m, `prefix missing on: ${JSON.stringify(p)}`) + assert.equal(m![0].length, 11, `wrong width on: ${JSON.stringify(p)}`) + } + }) }) describe('unknown events', () => {