headless: clean up sf headless auto stderr output
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 <noreply@anthropic.com>
This commit is contained in:
parent
55ee2cb5c7
commit
941eb4c830
3 changed files with 111 additions and 20 deletions
|
|
@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -389,6 +389,11 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
|
|||
const toolStartTimes = new Map<string, number>()
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue