diff --git a/src/headless-ui.ts b/src/headless-ui.ts index ca6fa4563..6200bb92b 100644 --- a/src/headless-ui.ts +++ b/src/headless-ui.ts @@ -76,24 +76,45 @@ export function summarizeToolArgs(toolName: unknown, toolInput: unknown): string const name = String(toolName ?? '') const input = (toolInput && typeof toolInput === 'object') ? toolInput as Record : {} + // Helper: extract file path from either 'path' or 'file_path' (tools use both) + const filePath = (): string => shortPath(input.path ?? input.file_path) || '' + switch (name) { case 'Read': case 'read': - return shortPath(input.file_path) || '' + return filePath() case 'Write': case 'write': - return shortPath(input.file_path) || '' + return filePath() case 'Edit': case 'edit': - return shortPath(input.file_path) || '' + return filePath() + case 'hashline_edit': + return filePath() case 'Bash': case 'bash': { const cmd = String(input.command ?? '') return cmd.length > 80 ? cmd.slice(0, 77) + '...' : cmd } + case 'async_bash': { + const cmd = String(input.command ?? '') + return cmd.length > 80 ? cmd.slice(0, 77) + '...' : cmd + } + case 'await_job': { + const jobs = input.jobs + if (Array.isArray(jobs) && jobs.length > 0) return jobs.join(', ') + return '' + } + case 'cancel_job': + return String(input.job_id ?? '') case 'Glob': case 'glob': return String(input.pattern ?? '') + case 'find': { + const pat = String(input.pattern ?? '') + const p = shortPath(input.path) + return p ? `${pat} in ${p}` : pat + } case 'Grep': case 'grep': case 'Search': @@ -102,12 +123,32 @@ export function summarizeToolArgs(toolName: unknown, toolInput: unknown): string const g = input.glob ? ` ${input.glob}` : '' return `${pat}${g}` } + case 'ls': + return shortPath(input.path) || '' + case 'lsp': { + const action = String(input.action ?? '') + const file = shortPath(input.file) + const sym = input.symbol ? ` ${input.symbol}` : '' + return file ? `${action} ${file}${sym}` : action + } case 'Task': case 'task': { const desc = String(input.description ?? input.prompt ?? '') return desc.length > 60 ? desc.slice(0, 57) + '...' : desc } + case 'subagent': { + const agent = String(input.agent ?? '') + const t = String(input.task ?? '') + const summary = t.length > 50 ? t.slice(0, 47) + '...' : t + return agent ? `${agent}: ${summary}` : summary + } + case 'browser_navigate': + return String(input.url ?? '') default: { + // GSD tools: show milestone/slice/task IDs when present + if (name.startsWith('gsd_')) { + return summarizeGsdTool(name, input) + } // Fallback: show first string-valued key up to 60 chars for (const v of Object.values(input)) { if (typeof v === 'string' && v.length > 0) { @@ -119,6 +160,29 @@ export function summarizeToolArgs(toolName: unknown, toolInput: unknown): string } } +/** Summarize GSD extension tool args into a compact identifier string. */ +function summarizeGsdTool(name: string, input: Record): string { + const parts: string[] = [] + if (input.milestoneId) parts.push(String(input.milestoneId)) + if (input.sliceId) parts.push(String(input.sliceId)) + if (input.taskId) parts.push(String(input.taskId)) + if (parts.length > 0) { + const id = parts.join('/') + // For completion tools, add the one-liner if present + if (name.includes('complete') && typeof input.oneLiner === 'string') { + const ol = input.oneLiner.length > 50 ? input.oneLiner.slice(0, 47) + '...' : input.oneLiner + return `${id} ${ol}` + } + return id + } + // Fallback for GSD tools without IDs (e.g. gsd_decision_save) + if (input.decision) { + const d = String(input.decision) + return d.length > 60 ? d.slice(0, 57) + '...' : d + } + return '' +} + function shortPath(p: unknown): string { if (typeof p !== 'string') return '' // Strip common CWD prefix to save space @@ -268,6 +332,7 @@ export function formatProgress(event: Record, ctx: ProgressCont /** * Format a thinking preview line from accumulated LLM text deltas. + * Used as a fallback when streaming is not enabled — shows a truncated one-liner. */ export function formatThinkingLine(text: string): string { const trimmed = text.replace(/\s+/g, ' ').trim() @@ -275,6 +340,38 @@ export function formatThinkingLine(text: string): string { return `${c.dim}${c.italic}[thinking] ${truncated}${c.reset}` } +// --------------------------------------------------------------------------- +// Streaming Text / Thinking Formatters +// --------------------------------------------------------------------------- + +/** + * Format a text_start marker — printed once when the assistant begins a text block. + */ +export function formatTextStart(): string { + return `${c.dim}[text]${c.reset}` +} + +/** + * Format a text_end marker — printed after the last text_delta. + */ +export function formatTextEnd(): string { + return '' // empty — newline handled by caller +} + +/** + * Format a thinking_start marker. + */ +export function formatThinkingStart(): string { + return `${c.dim}${c.italic}[thinking]${c.reset}` +} + +/** + * Format a thinking_end marker. + */ +export function formatThinkingEnd(): string { + return '' // empty — newline handled by caller +} + /** * Format a cost line (used for periodic cost updates in verbose mode). */ diff --git a/src/headless.ts b/src/headless.ts index 492a73f63..503ca9afd 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -44,6 +44,10 @@ import { handleExtensionUIRequest, formatProgress, formatThinkingLine, + formatTextStart, + formatTextEnd, + formatThinkingStart, + formatThinkingEnd, startSupervisedStdinReader, } from './headless-ui.js' import type { ExtensionUIRequest, ProgressContext } from './headless-ui.js' @@ -374,6 +378,9 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): const toolStartTimes = new Map() let lastCostData: { costUsd: number; inputTokens: number; outputTokens: number } | undefined let thinkingBuffer = '' + // Streaming state: tracks whether we're inside a text or thinking block + let inTextBlock = false + let inThinkingBlock = false // Emit HeadlessJsonResult to stdout for --output-format json batch mode function emitBatchJsonResult(): void { @@ -526,11 +533,56 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): } } - // Accumulate thinking text from message_update text_delta events + // Stream assistant text and thinking deltas in verbose mode if (eventType === 'message_update') { const ame = eventObj.assistantMessageEvent as Record | undefined - if (ame?.type === 'text_delta') { - thinkingBuffer += String(ame.text ?? '') + if (ame && options.verbose) { + const ameType = String(ame.type ?? '') + + // --- Text streaming --- + if (ameType === 'text_start') { + inTextBlock = true + process.stderr.write(formatTextStart()) + } else if (ameType === 'text_delta') { + const delta = String(ame.delta ?? ame.text ?? '') + if (delta) { + if (!inTextBlock) { + // Edge case: delta without start + inTextBlock = true + process.stderr.write(formatTextStart()) + } + process.stderr.write(delta) + } + } else if (ameType === 'text_end') { + if (inTextBlock) { + process.stderr.write(formatTextEnd() + '\n') + inTextBlock = false + } + } + + // --- Thinking streaming --- + else if (ameType === 'thinking_start') { + inThinkingBlock = true + process.stderr.write(formatThinkingStart()) + } else if (ameType === 'thinking_delta') { + const delta = String(ame.delta ?? ame.text ?? '') + if (delta) { + if (!inThinkingBlock) { + inThinkingBlock = true + process.stderr.write(formatThinkingStart()) + } + process.stderr.write(delta) + } + } else if (ameType === 'thinking_end') { + if (inThinkingBlock) { + process.stderr.write(formatThinkingEnd() + '\n') + inThinkingBlock = false + } + } + } + // Non-verbose: accumulate text_delta for truncated one-liner + else if (ame?.type === 'text_delta') { + thinkingBuffer += String(ame.delta ?? ame.text ?? '') } } @@ -540,8 +592,19 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): if (toolCallId) toolStartTimes.set(toolCallId, Date.now()) } - // Flush thinking buffer before tool calls or message end - if (options.verbose && thinkingBuffer.trim() && + // Close any open streaming blocks before tool calls or message end + if (options.verbose && (eventType === 'tool_execution_start' || eventType === 'message_end')) { + if (inTextBlock) { + process.stderr.write('\n') + inTextBlock = false + } + if (inThinkingBlock) { + process.stderr.write('\n') + inThinkingBlock = false + } + } + // Non-verbose: flush accumulated buffer as truncated one-liner + else if (!options.verbose && thinkingBuffer.trim() && (eventType === 'tool_execution_start' || eventType === 'message_end')) { process.stderr.write(formatThinkingLine(thinkingBuffer) + '\n') thinkingBuffer = '' diff --git a/src/tests/headless-progress.test.ts b/src/tests/headless-progress.test.ts index febae1aa4..fd6763870 100644 --- a/src/tests/headless-progress.test.ts +++ b/src/tests/headless-progress.test.ts @@ -27,7 +27,7 @@ describe('formatProgress', () => { const result = formatProgress({ type: 'tool_execution_start', toolName: 'Read', - args: { file_path: 'src/main.ts' }, + args: { path: 'src/main.ts' }, }, ctx()) assert.ok(result) assert.ok(result.includes('Read')) @@ -179,12 +179,20 @@ describe('formatProgress', () => { }) describe('summarizeToolArgs', () => { - it('extracts file_path for Read', () => { - assert.equal(summarizeToolArgs('Read', { file_path: 'src/index.ts' }), 'src/index.ts') + it('extracts path for Read', () => { + assert.equal(summarizeToolArgs('Read', { path: 'src/index.ts' }), 'src/index.ts') }) - it('extracts file_path for write', () => { - assert.equal(summarizeToolArgs('write', { file_path: '/tmp/out.json' }), '/tmp/out.json') + it('extracts path for write', () => { + assert.equal(summarizeToolArgs('write', { path: '/tmp/out.json' }), '/tmp/out.json') + }) + + it('extracts file_path for legacy compatibility', () => { + assert.equal(summarizeToolArgs('read', { file_path: 'src/foo.ts' }), 'src/foo.ts') + }) + + it('prefers path over file_path when both present', () => { + assert.equal(summarizeToolArgs('read', { path: 'real.ts', file_path: 'legacy.ts' }), 'real.ts') }) it('extracts command for bash', () => { @@ -198,18 +206,70 @@ describe('summarizeToolArgs', () => { assert.ok(result.length < 100) }) + it('extracts command for async_bash', () => { + assert.equal(summarizeToolArgs('async_bash', { command: 'npm run build' }), 'npm run build') + }) + + it('extracts jobs for await_job', () => { + assert.equal(summarizeToolArgs('await_job', { jobs: ['bg_abc', 'bg_def'] }), 'bg_abc, bg_def') + }) + it('extracts pattern for grep', () => { const result = summarizeToolArgs('grep', { pattern: 'TODO', glob: '*.ts' }) assert.equal(result, 'TODO *.ts') }) + it('extracts pattern and path for find', () => { + assert.equal(summarizeToolArgs('find', { pattern: '*.ts', path: 'src' }), '*.ts in src') + }) + + it('extracts action and file for lsp', () => { + const result = summarizeToolArgs('lsp', { action: 'definition', file: 'src/main.ts', symbol: 'foo' }) + assert.equal(result, 'definition src/main.ts foo') + }) + + it('extracts path for ls', () => { + assert.equal(summarizeToolArgs('ls', { path: 'src/utils' }), 'src/utils') + }) + + it('summarizes gsd tool with milestone/slice/task IDs', () => { + assert.equal(summarizeToolArgs('gsd_task_complete', { + milestoneId: 'M001', sliceId: 'S01', taskId: 'T01', oneLiner: 'Built the thing', + }), 'M001/S01/T01 Built the thing') + }) + + it('summarizes gsd_plan_milestone with milestone ID', () => { + assert.equal(summarizeToolArgs('gsd_plan_milestone', { milestoneId: 'M002' }), 'M002') + }) + + it('summarizes gsd_decision_save with decision text', () => { + const result = summarizeToolArgs('gsd_decision_save', { decision: 'Use SQLite for persistence' }) + assert.equal(result, 'Use SQLite for persistence') + }) + it('returns first string value for unknown tools', () => { - assert.equal(summarizeToolArgs('gsd_task_complete', { taskId: 'T01' }), 'T01') + assert.equal(summarizeToolArgs('custom_tool', { someKey: 'hello' }), 'hello') }) it('returns empty string for no args', () => { assert.equal(summarizeToolArgs('unknown', {}), '') }) + + it('extracts path for edit', () => { + assert.equal(summarizeToolArgs('edit', { path: 'src/config.ts' }), 'src/config.ts') + }) + + it('extracts path for hashline_edit', () => { + assert.equal(summarizeToolArgs('hashline_edit', { path: 'src/main.ts' }), 'src/main.ts') + }) + + it('extracts agent and task for subagent', () => { + assert.equal(summarizeToolArgs('subagent', { agent: 'scout', task: 'Find auth patterns' }), 'scout: Find auth patterns') + }) + + it('extracts url for browser_navigate', () => { + assert.equal(summarizeToolArgs('browser_navigate', { url: 'http://localhost:3000' }), 'http://localhost:3000') + }) }) describe('formatThinkingLine', () => {