Batch Aider output into structured blocks instead of per-line events

Aider runner now buffers entire turn output and parses it into thinking,
text, shell command, and cost blocks. Adapter updated for new event types.
Fixes console UI showing individual chevrons per output line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DexterFromLab 2026-03-12 16:09:54 +01:00
parent 862ddfcbb8
commit fd355ab6fe
3 changed files with 501 additions and 71 deletions

View file

@ -145,19 +145,83 @@ function shouldSuppress(line: string): boolean {
return t === '' || SUPPRESS_RE.some(p => p.test(t));
}
// --- Output line classification ---
// Thinking blocks: ► THINKING ... ► ANSWER
let inThinking = false;
// --- Turn output parsing ---
// Parses complete turn output into structured blocks:
// thinking, answer text, shell commands, cost info
function classifyLine(line: string): 'thinking' | 'shell' | 'cost' | 'prompt' | 'text' {
const t = line.trim();
if (t === '► THINKING' || t === '► THINKING') { inThinking = true; return 'thinking'; }
if (t === '► ANSWER' || t === '► ANSWER') { inThinking = false; return 'thinking'; }
if (inThinking) return 'thinking';
if (t.startsWith('$ ') || t.startsWith('Running ')) return 'shell';
if (/^Tokens: .+Cost:/.test(t)) return 'cost';
if (PROMPT_RE.test(t)) return 'prompt';
return 'text';
interface TurnBlock {
type: 'thinking' | 'text' | 'shell' | 'cost';
content: string;
}
function parseTurnOutput(buffer: string): TurnBlock[] {
const blocks: TurnBlock[] = [];
const lines = buffer.split('\n');
let thinkingLines: string[] = [];
let answerLines: string[] = [];
let inThinking = false;
let inAnswer = false;
for (const line of lines) {
const t = line.trim();
// Skip suppressed lines
if (shouldSuppress(line)) continue;
// Prompt markers — skip
if (PROMPT_RE.test(t)) continue;
// Thinking block markers
if (t === '► THINKING' || t === '► THINKING') {
inThinking = true;
inAnswer = false;
continue;
}
if (t === '► ANSWER' || t === '► ANSWER') {
if (thinkingLines.length > 0) {
blocks.push({ type: 'thinking', content: thinkingLines.join('\n') });
thinkingLines = [];
}
inThinking = false;
inAnswer = true;
continue;
}
// Cost line
if (/^Tokens: .+Cost:/.test(t)) {
blocks.push({ type: 'cost', content: t });
continue;
}
// Shell command
if (t.startsWith('$ ') || t.startsWith('Running ')) {
// Flush accumulated answer text first
if (answerLines.length > 0) {
blocks.push({ type: 'text', content: answerLines.join('\n') });
answerLines = [];
}
blocks.push({ type: 'shell', content: t.replace(/^(Running |\$ )/, '') });
continue;
}
// Accumulate into thinking or answer
if (inThinking) {
thinkingLines.push(line);
} else {
answerLines.push(line);
}
}
// Flush remaining
if (thinkingLines.length > 0) {
blocks.push({ type: 'thinking', content: thinkingLines.join('\n') });
}
if (answerLines.length > 0) {
blocks.push({ type: 'text', content: answerLines.join('\n').trim() });
}
return blocks;
}
// --- Main query handler ---
@ -182,7 +246,6 @@ async function handleQuery(msg: QueryMessage) {
existing.lineBuffer = '';
existing.turnStartTime = Date.now();
existing.turns++;
inThinking = false;
send({ type: 'agent_started', sessionId });
@ -277,95 +340,82 @@ async function handleQuery(msg: QueryMessage) {
session.ready = true;
session.turns = 1;
session.turnStartTime = Date.now();
inThinking = false;
log(`Aider ready, sending initial prompt (${fullPrompt.length} chars)`);
child.stdin?.write(fullPrompt + '\n');
}
return;
}
// Phase 2: accumulate output, emit complete lines
session.lineBuffer += text;
// Phase 2: accumulate entire turn output, emit as batched blocks
session.turnBuffer += text;
// Process complete lines only
const parts = session.lineBuffer.split('\n');
session.lineBuffer = parts.pop() || ''; // keep incomplete last part
// Only process when turn is complete (aider shows prompt again)
if (!looksLikePrompt(session.turnBuffer)) return;
for (const line of parts) {
if (shouldSuppress(line)) continue;
const duration = Date.now() - session.turnStartTime;
const blocks = parseTurnOutput(session.turnBuffer);
const cls = classifyLine(line);
switch (cls) {
// Emit structured blocks
for (const block of blocks) {
switch (block.type) {
case 'thinking':
// Emit thinking as a collapsed block
send({
type: 'agent_event',
sessionId,
event: { type: 'assistant', message: { role: 'assistant', content: line } },
event: { type: 'thinking', content: block.content },
});
break;
case 'text':
if (block.content) {
send({
type: 'agent_event',
sessionId,
event: { type: 'assistant', message: { role: 'assistant', content: block.content } },
});
}
break;
case 'shell':
send({
type: 'agent_event',
sessionId,
event: {
type: 'tool_call',
content: {
toolName: 'shell',
toolUseId: `shell-${Date.now()}`,
input: { command: line.replace(/^(Running |\$ )/, '') },
},
type: 'tool_use',
id: `shell-${Date.now()}`,
name: 'Bash',
input: { command: block.content },
},
});
break;
case 'cost':
// Parse cost and include in result
break;
case 'prompt':
// Prompt marker — turn is complete
break;
case 'text':
send({
type: 'agent_event',
sessionId,
event: { type: 'assistant', message: { role: 'assistant', content: line } },
});
// Parsed below for the result event
break;
}
}
// Check if turn is complete (aider showing prompt again)
if (looksLikePrompt(session.turnBuffer)) {
const duration = Date.now() - session.turnStartTime;
const costMatch = session.turnBuffer.match(/Cost: \$([0-9.]+) message, \$([0-9.]+) session/);
const costUsd = costMatch ? parseFloat(costMatch[2]) : 0;
// Extract cost and emit result
const costMatch = session.turnBuffer.match(/Cost: \$([0-9.]+) message, \$([0-9.]+) session/);
const costUsd = costMatch ? parseFloat(costMatch[2]) : 0;
send({
type: 'agent_event',
sessionId,
event: {
type: 'result',
subtype: 'result',
result: '',
cost_usd: costUsd,
duration_ms: duration,
num_turns: session.turns,
is_error: false,
session_id: sessionId,
},
});
send({
type: 'agent_event',
sessionId,
event: {
type: 'result',
subtype: 'result',
result: '',
cost_usd: costUsd,
duration_ms: duration,
num_turns: session.turns,
is_error: false,
session_id: sessionId,
},
});
send({ type: 'agent_stopped', sessionId, exitCode: 0, signal: null });
session.turnBuffer = '';
session.lineBuffer = '';
inThinking = false;
}
send({ type: 'agent_stopped', sessionId, exitCode: 0, signal: null });
session.turnBuffer = '';
});
child.stderr?.on('data', (data: Buffer) => {