diff --git a/scripts/measure-tool-rounds.ts b/scripts/measure-tool-rounds.ts new file mode 100644 index 000000000..7ff2b419d --- /dev/null +++ b/scripts/measure-tool-rounds.ts @@ -0,0 +1,315 @@ +#!/usr/bin/env tsx +/** + * Measure tool-use rounds per subagent in a session. + * + * A "round" is one assistant turn that requested ≥1 tool call — 5 parallel + * calls in one reply cost 1 round, not 5. This is the unit the subagent + * budget system uses, and the metric the architect critic identified as the + * right proxy for "redundant work" in the workspace A/B experiment. + * + * Reads a session's witness trace and reports: + * - Total tool rounds per subagent (and root) + * - Total tool calls per subagent + * - Tool breakdown by name per subagent + * - Aggregate totals for the session + * + * Usage: + * tsx scripts/measure-tool-rounds.ts --session + * tsx scripts/measure-tool-rounds.ts --latest + * tsx scripts/measure-tool-rounds.ts --json + * + * @module scripts/measure-tool-rounds + */ + +import { createReadStream, existsSync, readdirSync, statSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const AFK_HOME = process.env['AFK_HOME'] || join(homedir(), '.afk'); +const STATE_DIR = process.env['AFK_STATE_DIR'] || join(AFK_HOME, 'state'); +const WITNESS_DIR = join(STATE_DIR, 'witness'); + +// ─── CLI args ──────────────────────────────────────────────────────────── + +interface CliArgs { + session?: string; + latest: boolean; + json: boolean; +} + +function parseArgs(): CliArgs { + const args = process.argv.slice(2); + const result: CliArgs = { latest: false, json: false }; + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === '--session' && args[i + 1]) { result.session = args[++i]; } + else if (arg === '--latest') { result.latest = true; } + else if (arg === '--json') { result.json = true; } + else if (arg === '--help' || arg === '-h') { + console.log('Usage: tsx scripts/measure-tool-rounds.ts [--session ] [--latest] [--json]'); + process.exit(0); + } else { + console.error(`Unknown argument: ${arg}`); + process.exit(2); + } + } + const specified = [result.session, result.latest].filter(Boolean).length; + if (specified === 0) result.latest = true; + if (specified > 1) { + console.error('Specify at most one of --session or --latest.'); + process.exit(2); + } + return result; +} + +// ─── Trace resolution ──────────────────────────────────────────────────── + +function resolveTraceFile(args: CliArgs): string { + if (!existsSync(WITNESS_DIR)) { + console.error(`Witness directory not found: ${WITNESS_DIR}`); + process.exit(2); + } + const sessions = readdirSync(WITNESS_DIR) + .filter(d => existsSync(join(WITNESS_DIR, d, 'trace.jsonl'))) + .map(d => ({ + name: d, + tracePath: join(WITNESS_DIR, d, 'trace.jsonl'), + mtime: statSync(join(WITNESS_DIR, d, 'trace.jsonl')).mtime.getTime(), + })) + .sort((a, b) => b.mtime - a.mtime); + + if (sessions.length === 0) { + console.error('No sessions with traces found.'); + process.exit(2); + } + if (args.latest) return sessions[0]!.tracePath; + const match = sessions.filter(s => s.name.startsWith(args.session!)); + if (match.length === 0) { console.error(`No session matching: ${args.session}`); process.exit(2); } + if (match.length > 1) { console.error(`Ambiguous: ${match.map(m => m.name).join(', ')}`); process.exit(2); } + return match[0]!.tracePath; +} + +// ─── Trace parsing ─────────────────────────────────────────────────────── + +interface ToolCallEvent { + name: string; + subagentId: string; + toolUseId: string; + seq: number; + ts: string; + phase: 'started' | 'completed'; + ok?: boolean; + durationMs?: number; +} + +interface SubagentLifecycle { + subagentId: string; + phase: string; // 'started' | 'completed' | 'failed' + seq: number; +} + +async function parseTrace(tracePath: string): Promise<{ + toolCalls: ToolCallEvent[]; + subagentLifecycles: SubagentLifecycle[]; +}> { + const toolCalls: ToolCallEvent[] = []; + const subagentLifecycles: SubagentLifecycle[] = []; + + const rl = createInterface({ input: createReadStream(tracePath), crlfDelay: Infinity }); + for await (const line of rl) { + if (!line.trim()) continue; + let event: { kind: string; payload: Record; seq: number; ts: string }; + try { event = JSON.parse(line); } catch { continue; } + + if (event.kind === 'tool_call') { + const p = event.payload; + toolCalls.push({ + name: p['name'] as string, + subagentId: (p['subagentId'] as string) ?? 'root', + toolUseId: p['toolUseId'] as string, + seq: event.seq, + ts: event.ts, + phase: p['phase'] as 'started' | 'completed', + ok: p['ok'] as boolean | undefined, + durationMs: p['durationMs'] as number | undefined, + }); + } + + if (event.kind === 'subagent_lifecycle') { + const p = event.payload; + subagentLifecycles.push({ + subagentId: p['id'] as string ?? p['subagentId'] as string ?? 'unknown', + phase: p['phase'] as string, + seq: event.seq, + }); + } + } + + return { toolCalls, subagentLifecycles }; +} + +// ─── Analysis ──────────────────────────────────────────────────────────── + +interface AgentStats { + agentId: string; + totalCalls: number; + totalRounds: number; + toolBreakdown: Record; + /** Unique toolUseIds seen in 'started' events — each is one call. */ + uniqueCallIds: Set; +} + +interface RoundReport { + tracePath: string; + agents: Array<{ + agentId: string; + totalCalls: number; + totalRounds: number; + toolBreakdown: Record; + }>; + totals: { + agents: number; + calls: number; + rounds: number; + }; + workspacePublishCalls: number; + workspaceQueryCalls: number; +} + +function analyze(toolCalls: ToolCallEvent[], tracePath: string): RoundReport { + // Group started events by agent, tracking calls and rounds. + const agentMap = new Map(); + + // Round detection via phase-transition: + // A "round" is one assistant turn that issued ≥1 tool call. + // - If the last event for an agent was 'started' and now we see another + // 'started', they belong to the same parallel batch (same round). + // - If the last event was 'completed' and now we see 'started', the + // model issued a new assistant reply → new round. + // + // Process events in global seq order so the interleaving of started/ + // completed events across parallel calls is faithfully represented. + const lastPhaseByAgent = new Map(); + + // Sort all events by seq so we process them in trace order. + const sorted = [...toolCalls].sort((a, b) => a.seq - b.seq); + + for (const tc of sorted) { + let stats = agentMap.get(tc.subagentId); + if (!stats) { + stats = { + agentId: tc.subagentId, + totalCalls: 0, + totalRounds: 0, + toolBreakdown: {}, + uniqueCallIds: new Set(), + }; + agentMap.set(tc.subagentId, stats); + } + + if (tc.phase === 'started') { + // Count each unique tool call once. + if (!stats.uniqueCallIds.has(tc.toolUseId)) { + stats.uniqueCallIds.add(tc.toolUseId); + stats.totalCalls++; + stats.toolBreakdown[tc.name] = (stats.toolBreakdown[tc.name] ?? 0) + 1; + } + + // New round when transitioning from completed → started. + // First started event for this agent always starts round 1. + const lastPhase = lastPhaseByAgent.get(tc.subagentId); + if (lastPhase === undefined) { + // First tool call: start round 1. + stats.totalRounds = 1; + } else if (lastPhase === 'completed') { + // Transition from completed → started: new assistant turn. + stats.totalRounds++; + } + // lastPhase === 'started' means parallel batch in same round — no increment. + + lastPhaseByAgent.set(tc.subagentId, 'started'); + } else if (tc.phase === 'completed') { + lastPhaseByAgent.set(tc.subagentId, 'completed'); + } + } + + // Count workspace tool usage + let workspacePublishCalls = 0; + let workspaceQueryCalls = 0; + for (const tc of toolCalls) { + if (tc.phase !== 'started') continue; + if (tc.name === 'workspace_publish') workspacePublishCalls++; + if (tc.name === 'workspace_query') workspaceQueryCalls++; + } + + const agents = [...agentMap.values()].map(s => ({ + agentId: s.agentId, + totalCalls: s.totalCalls, + totalRounds: s.totalRounds, + toolBreakdown: s.toolBreakdown, + })); + + // Sort by seq order (root first, then subagents) + agents.sort((a, b) => { + if (a.agentId === 'root') return -1; + if (b.agentId === 'root') return 1; + return a.agentId.localeCompare(b.agentId); + }); + + return { + tracePath, + agents, + totals: { + agents: agents.length, + calls: agents.reduce((s, a) => s + a.totalCalls, 0), + rounds: agents.reduce((s, a) => s + a.totalRounds, 0), + }, + workspacePublishCalls, + workspaceQueryCalls, + }; +} + +// ─── Output ────────────────────────────────────────────────────────────── + +function printHuman(report: RoundReport): void { + console.log(`\n╭─ Tool-Round Report ────────────────────────────────────────╮`); + console.log(`│ Trace: ${report.tracePath.replace(homedir(), '~')}`); + console.log(`╰────────────────────────────────────────────────────────────╯\n`); + + console.log(` Agents: ${report.totals.agents}`); + console.log(` Total tool calls: ${report.totals.calls}`); + console.log(` Total tool rounds: ${report.totals.rounds}`); + console.log(` workspace_publish: ${report.workspacePublishCalls}`); + console.log(` workspace_query: ${report.workspaceQueryCalls}`); + console.log(); + + for (const a of report.agents) { + const label = a.agentId === 'root' ? 'root (orchestrator)' : a.agentId; + console.log(` ┌─ ${label}`); + console.log(` │ Calls: ${a.totalCalls} Rounds: ${a.totalRounds}`); + const tools = Object.entries(a.toolBreakdown).sort((x, y) => y[1] - x[1]); + for (const [name, count] of tools.slice(0, 8)) { + console.log(` │ ${name}: ${count}`); + } + console.log(` └──────────────────────`); + } + console.log(); +} + +// ─── Main ──────────────────────────────────────────────────────────────── + +async function main(): Promise { + const args = parseArgs(); + const tracePath = resolveTraceFile(args); + const { toolCalls } = await parseTrace(tracePath); + const report = analyze(toolCalls, tracePath); + + if (args.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + printHuman(report); + } +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/run-workspace-ab-test.sh b/scripts/run-workspace-ab-test.sh new file mode 100755 index 000000000..7f72d0aa8 --- /dev/null +++ b/scripts/run-workspace-ab-test.sh @@ -0,0 +1,241 @@ +#!/bin/sh +# ───────────────────────────────────────────────────────────────────────────── +# Shared Agent Workspace A/B Experiment +# ───────────────────────────────────────────────────────────────────────────── +# +# Runs the SAME multi-agent task twice: +# ARM A (control): AFK_WORKSPACE_DISABLED=1 — agents work in full isolation +# ARM B (treatment): AFK_WORKSPACE_DISABLED unset — shared workspace enabled +# +# After both runs, measures cross-agent file-read deduplication and compares. +# +# Usage: +# ./scripts/run-workspace-ab-test.sh [--model sonnet] [--dry-run] +# +# Output: scripts/ab-results/ with per-arm traces and a comparison report. +# ───────────────────────────────────────────────────────────────────────────── +set -e + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +AFK_BIN="$REPO_ROOT/dist/cli/index.js" +MEASURE_SCRIPT="$REPO_ROOT/scripts/measure-read-dedup.ts" +RESULTS_DIR="$REPO_ROOT/scripts/ab-results" +MODEL="sonnet" +DRY_RUN="" +MAX_TURNS=25 +MAX_BUDGET=3 + +# ─── Credential check ────────────────────────────────────────────────────── +# afk chat in non-TTY mode (piped stdout) hard-exits at src/cli/index.ts:224 +# when no credential is found, because it can't prompt the auth wizard. +# The credential must be available via env var or keychain BEFORE this script +# runs. Three ways to satisfy: +# 1. export ANTHROPIC_API_KEY=sk-ant-... (metered API key) +# 2. afk login (refreshes keychain OAuth) +# 3. Set ANTHROPIC_API_KEY in ~/.afk/config/afk.env +# ─────────────────────────────────────────────────────────────────────────── +if [ -z "$ANTHROPIC_API_KEY" ] && [ -z "$CLAUDE_CODE_OAUTH_TOKEN" ]; then + # Try to read OAuth token from keychain (macOS only) + KEYCHAIN_TOKEN=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null \ + | python3 -c "import sys,json; d=json.load(sys.stdin); t=d.get('claudeAiOauth',{}).get('accessToken',''); print(t)" 2>/dev/null || true) + if [ -n "$KEYCHAIN_TOKEN" ]; then + export CLAUDE_CODE_OAUTH_TOKEN="$KEYCHAIN_TOKEN" + echo " [auth] Using Claude Code OAuth token from keychain" + else + echo "ERROR: No Anthropic credential found for non-TTY subprocess." + echo "" + echo " afk chat exits immediately in piped mode without a credential." + echo " Fix: run one of these before this script:" + echo "" + echo " export ANTHROPIC_API_KEY=sk-ant-... # metered API key" + echo " afk login # refresh keychain OAuth" + echo " afk config set env ANTHROPIC_API_KEY # persist in afk.env" + echo "" + exit 1 + fi +fi + +# Parse flags +while [ $# -gt 0 ]; do + case "$1" in + --model) MODEL="$2"; shift 2;; + --dry-run) DRY_RUN=1; shift;; + --max-turns) MAX_TURNS="$2"; shift 2;; + --budget) MAX_BUDGET="$2"; shift 2;; + *) echo "Unknown flag: $1"; exit 2;; + esac +done + +mkdir -p "$RESULTS_DIR" +TIMESTAMP=$(date -u +%Y%m%dT%H%M%SZ) + +# ─── The experiment prompt ────────────────────────────────────────────────── +# This prompt is designed to trigger multiple parallel subagent dispatches +# that read overlapping files in the agent-afk codebase. +# ───────────────────────────────────────────────────────────────────────────── +PROMPT_FILE="$RESULTS_DIR/prompt.md" +cat > "$PROMPT_FILE" <<'PROMPT_EOF' +Investigate how agent-afk handles rate limiting and retries across its two provider implementations (anthropic-direct and openai-compatible). Use the compose tool to dispatch three parallel investigation subagents: + +1. **Provider A investigator**: Read src/agent/providers/anthropic-direct/ — find every retry loop, rate-limit handler, backoff strategy, and error recovery path. Report each mechanism with file:line citations. + +2. **Provider B investigator**: Read src/agent/providers/openai-compatible/ — find every retry loop, rate-limit handler, backoff strategy, and error recovery path. Report each mechanism with file:line citations. + +3. **Shared infrastructure investigator**: Read src/agent/providers/index.ts, src/agent/session.ts, src/agent/subagent.ts, and src/config/env.ts — find retry-related env vars, shared error classification, and any provider-agnostic retry/backoff infrastructure. Report with file:line citations. + +After all three complete, synthesize a comparison table showing: +- Which retry mechanisms are provider-specific vs shared +- Whether the two providers handle 429s consistently +- Any gaps where one provider has retry coverage the other lacks + +Write the comparison to a file at /tmp/workspace-ab-result.md. +PROMPT_EOF + +echo "╔══════════════════════════════════════════════════════════════╗" +echo "║ Shared Agent Workspace A/B Experiment ║" +echo "║ Timestamp: $TIMESTAMP ║" +echo "║ Model: $MODEL Max-turns: $MAX_TURNS Budget: \$$MAX_BUDGET ║" +echo "╚══════════════════════════════════════════════════════════════╝" +echo "" + +if [ -n "$DRY_RUN" ]; then + echo "[DRY RUN] Would run two arms with prompt:" + cat "$PROMPT_FILE" + echo "" + echo "[DRY RUN] Arm A: AFK_WORKSPACE_DISABLED=1 node $AFK_BIN chat -m $MODEL --max-turns $MAX_TURNS ..." + echo "[DRY RUN] Arm B: (workspace enabled) node $AFK_BIN chat -m $MODEL --max-turns $MAX_TURNS ..." + exit 0 +fi + +# ─── ARM A: Control (workspace disabled) ──────────────────────────────────── +echo "" +echo "════════════════════════════════════════════════════════════════" +echo " ARM A — CONTROL (AFK_WORKSPACE_DISABLED=1)" +echo "════════════════════════════════════════════════════════════════" +echo "" + +ARM_A_START=$(date +%s) +AFK_WORKSPACE_DISABLED=1 \ + node "$AFK_BIN" chat \ + -m "$MODEL" \ + --max-turns "$MAX_TURNS" \ + --max-budget-usd "$MAX_BUDGET" \ + -f json \ + "$(cat "$PROMPT_FILE")" \ + > "$RESULTS_DIR/arm-a-output-$TIMESTAMP.json" 2>&1 || true +ARM_A_END=$(date +%s) +ARM_A_DURATION=$((ARM_A_END - ARM_A_START)) + +echo "" +echo " Arm A completed in ${ARM_A_DURATION}s" + +# Capture the session ID from the most recent witness trace +sleep 2 # let trace flush +ARM_A_SESSION=$(ls -t "$HOME/.afk/state/witness/" | head -1) +echo " Arm A session: $ARM_A_SESSION" + +# Measure dedup for arm A +echo "" +echo " Measuring Arm A dedup..." +npx tsx "$MEASURE_SCRIPT" --session "$ARM_A_SESSION" --json > "$RESULTS_DIR/arm-a-dedup-$TIMESTAMP.json" 2>&1 || true +npx tsx "$MEASURE_SCRIPT" --session "$ARM_A_SESSION" 2>&1 | tee "$RESULTS_DIR/arm-a-dedup-$TIMESTAMP.txt" || true + +# ─── ARM B: Treatment (workspace enabled) ─────────────────────────────────── +echo "" +echo "════════════════════════════════════════════════════════════════" +echo " ARM B — TREATMENT (workspace enabled)" +echo "════════════════════════════════════════════════════════════════" +echo "" + +ARM_B_START=$(date +%s) +env -u AFK_WORKSPACE_DISABLED node "$AFK_BIN" chat \ + -m "$MODEL" \ + --max-turns "$MAX_TURNS" \ + --max-budget-usd "$MAX_BUDGET" \ + -f json \ + "$(cat "$PROMPT_FILE")" \ +> "$RESULTS_DIR/arm-b-output-$TIMESTAMP.json" 2>&1 || true +ARM_B_END=$(date +%s) +ARM_B_DURATION=$((ARM_B_END - ARM_B_START)) + +echo "" +echo " Arm B completed in ${ARM_B_DURATION}s" + +sleep 2 # let trace flush +ARM_B_SESSION=$(ls -t "$HOME/.afk/state/witness/" | head -1) +echo " Arm B session: $ARM_B_SESSION" + +# Measure dedup for arm B +echo "" +echo " Measuring Arm B dedup..." +npx tsx "$MEASURE_SCRIPT" --session "$ARM_B_SESSION" --json > "$RESULTS_DIR/arm-b-dedup-$TIMESTAMP.json" 2>&1 || true +npx tsx "$MEASURE_SCRIPT" --session "$ARM_B_SESSION" 2>&1 | tee "$RESULTS_DIR/arm-b-dedup-$TIMESTAMP.txt" || true + +# ─── Comparison ───────────────────────────────────────────────────────────── +echo "" +echo "╔══════════════════════════════════════════════════════════════╗" +echo "║ COMPARISON ║" +echo "╚══════════════════════════════════════════════════════════════╝" +echo "" + +# Extract key metrics from JSON reports +ARM_A_RATIO=$(node -e "try { const r=require('$RESULTS_DIR/arm-a-dedup-$TIMESTAMP.json'); console.log((r.crossAgentDedupRatio*100).toFixed(1)+'%'); } catch(e) { console.log('N/A'); }") +ARM_B_RATIO=$(node -e "try { const r=require('$RESULTS_DIR/arm-b-dedup-$TIMESTAMP.json'); console.log((r.crossAgentDedupRatio*100).toFixed(1)+'%'); } catch(e) { console.log('N/A'); }") +ARM_A_CALLS=$(node -e "try { const r=require('$RESULTS_DIR/arm-a-dedup-$TIMESTAMP.json'); console.log(r.totalCalls); } catch(e) { console.log('N/A'); }") +ARM_B_CALLS=$(node -e "try { const r=require('$RESULTS_DIR/arm-b-dedup-$TIMESTAMP.json'); console.log(r.totalCalls); } catch(e) { console.log('N/A'); }") +ARM_A_DUPES=$(node -e "try { const r=require('$RESULTS_DIR/arm-a-dedup-$TIMESTAMP.json'); console.log(r.crossAgentDuplicates); } catch(e) { console.log('N/A'); }") +ARM_B_DUPES=$(node -e "try { const r=require('$RESULTS_DIR/arm-b-dedup-$TIMESTAMP.json'); console.log(r.crossAgentDuplicates); } catch(e) { console.log('N/A'); }") +ARM_A_AGENTS=$(node -e "try { const r=require('$RESULTS_DIR/arm-a-dedup-$TIMESTAMP.json'); console.log(r.distinctAgents); } catch(e) { console.log('N/A'); }") +ARM_B_AGENTS=$(node -e "try { const r=require('$RESULTS_DIR/arm-b-dedup-$TIMESTAMP.json'); console.log(r.distinctAgents); } catch(e) { console.log('N/A'); }") + +REPORT="$RESULTS_DIR/comparison-$TIMESTAMP.md" +cat > "$REPORT" < number | undefined; isTtfbDone?: () => boolean; + /** + * Live accessor for the active subagent status bar specs, keyed by subagentId. + * Optional so existing non-TTY callers and tests register slots unchanged; + * when absent the subagent-status slot always renders empty. + */ + getActiveSubagents?: () => ReadonlyMap; }, ): void { - // Register overlay slots (thinking-live, markdown-pending, tool-lane, - // progress-banner, interrupt). The stage-rail slot has been promoted to a - // reserved footer row via LoopStageBar and is no longer part of the overlay. + // Register overlay slots (thinking-live, subagent-status, markdown-pending, + // tool-lane, progress-banner, interrupt). The stage-rail slot has been promoted + // to a reserved footer row via LoopStageBar and is no longer part of the overlay. overlayComposer.register({ key: 'thinking-live', render: () => { @@ -135,6 +142,17 @@ export function registerOverlaySlots( }, }); + // Subagent status bars — one line per active subagent dispatch, capped at 3. + // Reads live from getActiveSubagents(); renders '' when no subagents are running + // (slot occupies no space in the composed frame when all returns are empty). + overlayComposer.register({ + key: 'subagent-status', + render: () => { + const entries = ctx.getActiveSubagents ? [...ctx.getActiveSubagents().values()] : []; + return subagentStatusStack(entries); + }, + }); + overlayComposer.register({ key: 'markdown-pending', render: () => { diff --git a/src/cli/_lib/stream-renderer-orchestrator-emit.ts b/src/cli/_lib/stream-renderer-orchestrator-emit.ts index ef2b70236..00c8cf5b7 100644 --- a/src/cli/_lib/stream-renderer-orchestrator-emit.ts +++ b/src/cli/_lib/stream-renderer-orchestrator-emit.ts @@ -8,7 +8,7 @@ import type { SourceState } from './stream-renderer-source.js'; import type { Writer } from '../slash/types.js'; import type { CardSpec } from '../render.js'; -import { card, errorBox } from '../render.js'; +import { card, errorCard } from '../render.js'; import { renderMarkdownToTerminal } from '../formatter.js'; import { getTerminalWidth } from '../terminal-size.js'; import { capToMeasure } from '../render/measure.js'; @@ -383,7 +383,8 @@ export function emitMarkdown(text: string, out: Writer): void { * Emit an error box. Splits the rendered box by newlines and emits each line. */ export function emitErrorBox(err: Error, out: Writer): void { - const box = errorBox(err.message, err.stack); + const stackTrace = err.stack?.split('\n').slice(1).join('\n'); + const box = errorCard({ body: err.message, hint: stackTrace }); for (const line of box.split('\n')) { out.line(line); } diff --git a/src/cli/_lib/stream-renderer-process.ts b/src/cli/_lib/stream-renderer-process.ts index 9b8cb787e..510774cd7 100644 --- a/src/cli/_lib/stream-renderer-process.ts +++ b/src/cli/_lib/stream-renderer-process.ts @@ -22,6 +22,7 @@ import type { ChildActivityTracker } from './child-activity-select.js'; import type { InFlightToolTracker } from '../input/work-derived-verb.js'; import type { OrchestratorCtx } from './stream-renderer-orchestrator.js'; import type { LoopStage, StageSignals } from '../commands/interactive/loop-stage.js'; +import type { SubagentStatusBarSpec } from '../render.js'; import { ORCHESTRATOR_SOURCE_KEY, type SourceState, freshSourceState } from './stream-renderer-source.js'; import { noteToolEvent } from '../input/work-derived-verb.js'; import { handleOrchestratorEvent, setComposedOverlay } from './stream-renderer-orchestrator.js'; @@ -84,6 +85,16 @@ export interface ProcessCtx { * processEvent never references the class directly. */ buildOrchestratorCtx: () => OrchestratorCtx; + /** + * Live status bar specs for active subagent dispatches, keyed by subagentId. + * Mutated here: entries are added on first subagent event, removed on terminal + * (done/error) events. The 250ms ticker in arm() reads this to update elapsedMs. + */ + activeSubagents: Map; + /** Dispatch timestamps (Date.now()) for each active subagent, keyed by subagentId. */ + subagentStartedAt: Map; + /** Live OverlayComposer for triggering subagent-status slot dirty marks. */ + overlayComposerForStatus: OverlayComposer | null; } /** @@ -125,6 +136,15 @@ export function processEvent(ctx: ProcessCtx, event: OutputEvent, meta?: Subagen thinkingMode: ctx.thinkingMode, orchestratorCtx: ctx.buildOrchestratorCtx(), }), parentSyntheticId); + // Register a status bar entry for the new subagent source. + const label = meta?.agentType ?? sourceId; + const now = Date.now(); + ctx.subagentStartedAt.set(sourceId, now); + ctx.activeSubagents.set(sourceId, { label, elapsedMs: 0 }); + if (ctx.overlayComposerForStatus) { + ctx.overlayComposerForStatus.markDirty('subagent-status'); + ctx.overlayComposerForStatus.flush(); + } } } @@ -216,6 +236,15 @@ export function processEvent(ctx: ProcessCtx, event: OutputEvent, meta?: Subagen // summary line for any subagent that produced events before // terminating. const isTerminal = event.type === 'done' || event.type === 'error'; + // Remove the subagent status bar on terminal events regardless of TTY mode. + if (isTerminal && ctx.activeSubagents.has(sourceId)) { + ctx.activeSubagents.delete(sourceId); + ctx.subagentStartedAt.delete(sourceId); + if (ctx.overlayComposerForStatus) { + ctx.overlayComposerForStatus.markDirty('subagent-status'); + ctx.overlayComposerForStatus.flush(); + } + } if (isTerminal && ctx.isTTY) { // Flush only this subagent's entries (parent + children) — other // sources' entries remain in the overlay for still-running sub-agents. diff --git a/src/cli/_lib/stream-renderer.ts b/src/cli/_lib/stream-renderer.ts index 3f56adb2f..99966effb 100644 --- a/src/cli/_lib/stream-renderer.ts +++ b/src/cli/_lib/stream-renderer.ts @@ -53,6 +53,7 @@ import { makeOrchestratorCtx } from './stream-renderer-contexts.js'; import { processEvent, type ProcessCtx } from './stream-renderer-process.js'; import { disposeRenderer, type DisposeCtx } from './stream-renderer-dispose.js'; import { applyFirstContent } from './stream-renderer-ttfb.js'; +import { type SubagentStatusBarSpec } from '../render.js'; export type { StreamRendererOptions } from './stream-renderer-options.js'; import type { StreamRendererOptions } from './stream-renderer-options.js'; @@ -157,6 +158,12 @@ export class StreamRenderer { private pauseTickInterval: ReturnType | null = null; /** ResizeBus unsubscriber — re-derives the overlay at the new terminal width on resize. */ private resizeUnsub: (() => void) | null = null; + /** Ticker for subagent elapsed-time updates (250ms); cleared in dispose(). */ + private subagentTickInterval: ReturnType | null = null; + /** Live status bars for active subagent dispatches, keyed by subagentId. */ + private activeSubagents = new Map(); + /** Start timestamps (Date.now()) for each active subagent, keyed by subagentId. */ + private subagentStartedAt = new Map(); /** TTFB elapsed timer: start timestamp + done flag. See stream-renderer-ttfb.ts. */ private readonly ttfbStartedAt: number | undefined; @@ -314,13 +321,14 @@ export class StreamRenderer { // BackgroundStatusBar) and painted independently of the compositor frame. this.overlayComposer = new OverlayComposer(compositor, [ 'thinking-live', + 'subagent-status', // live status bars for active subagent dispatches 'markdown-pending', 'tool-lane', 'progress-banner', 'interrupt', ]); - // Register all five slots via the lifecycle module, which preserves + // Register all six slots via the lifecycle module, which preserves // the exact slot order. Each slot's render() method reads the // corresponding live state from the renderer's fields at flush time. registerOverlaySlots(this.overlayComposer, { @@ -336,12 +344,27 @@ export class StreamRenderer { getSoftStopping: () => this.softStopping, getTtfbStartedAt: () => this.ttfbStartedAt, isTtfbDone: () => this.ttfbDone, + getActiveSubagents: () => this.activeSubagents, }); // Reduced-motion suppresses the spinner ticker at the source. State-transition // repaints remain active — only the high-frequency 12.5 Hz animation is gated. compositor.setSpinner({ enabled: !this.reducedMotion, rotateVerbEveryMs: 3500 }); - this.pauseTickInterval = setInterval(() => this.checkPauseAnnotations(), 80); + this.pauseTickInterval = setInterval(() => this.checkPauseAnnotations(), 80).unref(); + // Subagent elapsed-time ticker: updates activeSubagents' elapsedMs fields and + // flushes the 'subagent-status' overlay slot every 250ms. Stopped in dispose(). + this.subagentTickInterval = setInterval(() => { + if (this.disposed || this.activeSubagents.size === 0) return; + const now = Date.now(); + for (const [id, spec] of this.activeSubagents) { + const startedAt = this.subagentStartedAt.get(id) ?? now; + this.activeSubagents.set(id, { ...spec, elapsedMs: now - startedAt }); + } + if (this.overlayComposer) { + this.overlayComposer.markDirty('subagent-status'); + this.overlayComposer.flush(); + } + }, 250).unref(); // Re-derive the composed overlay (tool lane / thinking / progress) at the // current terminal width whenever the window resizes. The markdown stream // owns its own resize subscription; this covers the rest of the overlay @@ -477,6 +500,9 @@ export class StreamRenderer { activeSkillName: this.activeSkillName, onStageChange: this.onStageChange, buildOrchestratorCtx: () => this.buildOrchestratorCtx(), + activeSubagents: this.activeSubagents, + subagentStartedAt: this.subagentStartedAt, + overlayComposerForStatus: this.overlayComposer, }; processEvent(ctx, event, meta); } @@ -488,6 +514,12 @@ export class StreamRenderer { async dispose(): Promise { if (this.disposed) return; this.disposed = true; + // Clear the subagent elapsed-time ticker immediately — it guards against + // `this.disposed` but clearing here is cleaner and avoids one extra tick. + if (this.subagentTickInterval !== null) { + clearInterval(this.subagentTickInterval); + this.subagentTickInterval = null; + } // Contract: clear softStopping on the class BEFORE building the DisposeCtx // snapshot. The overlay's progress-banner slot reads this.softStopping via // the getSoftStopping closure registered in arm(), not through the ref diff --git a/src/cli/commands/interactive/__snapshots__/tool-lane.test.ts.snap b/src/cli/commands/interactive/__snapshots__/tool-lane.test.ts.snap index 93d363ecd..66945e622 100644 --- a/src/cli/commands/interactive/__snapshots__/tool-lane.test.ts.snap +++ b/src/cli/commands/interactive/__snapshots__/tool-lane.test.ts.snap @@ -4,11 +4,11 @@ exports[`Snapshot pins — tool-lane-render.ts representative outputs > scenario exports[`Snapshot pins — tool-lane-render.ts representative outputs > scenario 2 — multi-tool entry (3 tools, all completed) 1`] = ` " ● Read("a.ts") — ✓ 10 lines - $ Bash("ls -la") — ✓ 5 lines + ▸ Bash("ls -la") — ✓ 5 lines ● Glob("**/*.ts") — ✓ 23 paths" `; -exports[`Snapshot pins — tool-lane-render.ts representative outputs > scenario 3 — tool entry with error result 1`] = `" $ Bash("npm test") — ✗ Error: 3 tests failed"`; +exports[`Snapshot pins — tool-lane-render.ts representative outputs > scenario 3 — tool entry with error result 1`] = `" ▸ Bash("npm test") — ✗ Error: 3 tests failed"`; exports[`Snapshot pins — tool-lane-render.ts representative outputs > scenario 4 — subagent Agent entry with children, completed 1`] = ` "◉ → Agent(snap-researcher) [worker] @@ -20,7 +20,7 @@ exports[`Snapshot pins — tool-lane-render.ts representative outputs > scenario exports[`Snapshot pins — tool-lane-render.ts representative outputs > scenario 5 — subagent Agent entry with overflow (4 children, MAX=3) 1`] = ` "◉ → Agent(snap-overflower) [worker] — 4 tool calls │ ├─ … +1 (1 Read) -│ ├─ $ Bash("snap1.ts") — ✓ snap-result-1 +│ ├─ ▸ Bash("snap1.ts") — ✓ snap-result-1 │ ├─ ● Grep("snap2.ts") — ✓ snap-result-2 │ ├─ ● Glob("snap3.ts") — ✓ snap-result-3 │ ╰─ Done (4 tool calls · 4.0s)" diff --git a/src/cli/commands/interactive/tool-lane.overlay.test.ts b/src/cli/commands/interactive/tool-lane.overlay.test.ts index e33f0d866..43b760254 100644 --- a/src/cli/commands/interactive/tool-lane.overlay.test.ts +++ b/src/cli/commands/interactive/tool-lane.overlay.test.ts @@ -577,7 +577,7 @@ describe('Spine renderer scenarios', () => { // Both rows: `│ `. The connector + pad spans // 3 cells (`╰─ ` for child, `⌇ ` for tail), placing content at col 5 // in both. The 1-col drift pre-fix had tail content at col 6. - const childContentCol = childLine!.indexOf('$ bash'); + const childContentCol = childLine!.indexOf('▸ bash'); const tailContentCol = tailLine!.indexOf('narration content'); expect(childContentCol).toBe(5); expect(tailContentCol).toBe(5); diff --git a/src/cli/commands/interactive/tool-lane.test.ts b/src/cli/commands/interactive/tool-lane.test.ts index a3ba42d3a..b9e38df56 100644 --- a/src/cli/commands/interactive/tool-lane.test.ts +++ b/src/cli/commands/interactive/tool-lane.test.ts @@ -1244,7 +1244,7 @@ describe('Bug #5 — agentResultSummary must use └ tree connector and appear a expect(stripped).toMatchInlineSnapshot(` "◉ → Agent(overflow-tester) [worker] — 4 tool calls │ ├─ … +1 (1 Read) - │ ├─ $ Bash("file1.ts") — ✓ result1 + │ ├─ ▸ Bash("file1.ts") — ✓ result1 │ ├─ ● Grep("file2.ts") — ✓ result2 │ ├─ ● Glob("file3.ts") — ✓ result3 │ ╰─ Done (4 tool calls · 2.5s)" diff --git a/src/cli/errors/presenter.ts b/src/cli/errors/presenter.ts index f09cbd019..53a24dab2 100644 --- a/src/cli/errors/presenter.ts +++ b/src/cli/errors/presenter.ts @@ -1,13 +1,13 @@ /** * Error presenter: renders a ClassifiedError to the terminal. * - * TTY surfaces get a full errorBox with borders; non-TTY surfaces get a + * TTY surfaces get a full errorCard with borders; non-TTY surfaces get a * plain "afk: error:" line on stderr. Debug mode appends the raw stack. * * @module cli/errors/presenter */ -import { errorBox } from '../render.js'; +import { errorCard } from '../render.js'; import { isDebugEnabled } from '../../utils/debug.js'; import type { ClassifiedError } from './classifier.js'; @@ -25,7 +25,7 @@ export function presentError( const write = opts?.write ?? ((s: string) => { process.stderr.write(s); }); if (isTTY) { - write(errorBox(classified.userMessage, classified.hint) + '\n'); + write(errorCard({ body: classified.userMessage, hint: classified.hint }) + '\n'); } else { const hint = classified.hint ? ` (${classified.hint})` : ''; write(`afk: error: ${classified.userMessage}${hint}\n`); diff --git a/src/cli/interactive-progress-banner.test.ts b/src/cli/interactive-progress-banner.test.ts index baa9b2b6f..d81e8536a 100644 --- a/src/cli/interactive-progress-banner.test.ts +++ b/src/cli/interactive-progress-banner.test.ts @@ -156,7 +156,7 @@ describe('interactive REPL — progress banner rendering', () => { it('colorizes the via segment by tool category and uses a category-specific glyph', () => { const cases: Array<{ tool: string; glyph: string }> = [ { tool: 'Read', glyph: '●' }, // read - { tool: 'Bash', glyph: '$' }, // shell + { tool: 'Bash', glyph: '▸' }, // shell { tool: 'Agent', glyph: '→' }, // subagent { tool: 'mcp__github__create_issue', glyph: '⊡' }, // mcp ]; diff --git a/src/cli/render/error-card.test.ts b/src/cli/render/error-card.test.ts new file mode 100644 index 000000000..3da23b90a --- /dev/null +++ b/src/cli/render/error-card.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { stripAnsi } from '../display.js'; +import { errorCard } from './error-card.js'; + +describe('errorCard', () => { + it('renders with default ERROR title', () => { + const result = stripAnsi(errorCard({ body: 'Something went wrong' })); + expect(result).toContain('ERROR'); + expect(result).toContain('Something went wrong'); + }); + + it('renders with custom title', () => { + const result = stripAnsi( + errorCard({ title: 'RATE LIMIT', body: 'Too many requests' }), + ); + expect(result).toContain('RATE LIMIT'); + expect(result).toContain('Too many requests'); + }); + + it('renders multi-line body', () => { + const result = stripAnsi( + errorCard({ body: ['Line one', 'Line two', 'Line three'] }), + ); + expect(result).toContain('Line one'); + expect(result).toContain('Line two'); + expect(result).toContain('Line three'); + }); + + it('renders hint when provided', () => { + const result = stripAnsi( + errorCard({ + body: 'Connection refused', + hint: 'Check that the server is running', + }), + ); + expect(result).toContain('Connection refused'); + expect(result).toContain('Check that the server is running'); + }); + + it('omits hint when not provided', () => { + const result = errorCard({ body: 'Oops' }); + // Should still render without error + expect(stripAnsi(result)).toContain('Oops'); + }); + + it('has bordered output (rounded corners)', () => { + const result = errorCard({ body: 'test' }); + expect(result).toContain('╭'); + expect(result).toContain('╰'); + }); +}); diff --git a/src/cli/render/error-card.ts b/src/cli/render/error-card.ts new file mode 100644 index 000000000..592029029 --- /dev/null +++ b/src/cli/render/error-card.ts @@ -0,0 +1,49 @@ +import { drawBox } from './box.js'; +import { palette } from '../palette.js'; + +// ─── Error Card ────────────────────────────────────────────────────────────── + +/** + * Render a structured error card with an optional recovery hint. + * + * Visual output: + * ╭─ ERROR ──────────────────────────────╮ + * │ Rate limit exceeded (429) │ + * │ │ + * │ Retrying in 12s… │ + * ╰─────────────────────────────────────╯ + * + * Replaces ad-hoc `errorBox()` callers with a richer, consistent format. + * The hint line (dim, italic) surfaces recovery guidance directly in the + * error card — reducing the "red box then silence" failure mode. + * + * @param spec - Error card configuration. + * @returns Multi-line ANSI string. + */ +export function errorCard(spec: ErrorCardSpec): string { + const title = spec.title ?? 'ERROR'; + + const bodyLines = Array.isArray(spec.body) ? spec.body : [spec.body]; + const content: string[] = [...bodyLines]; + + if (spec.hint) { + content.push(''); + content.push(palette.dim(palette.italic(spec.hint))); + } + + return drawBox(content, { + border: palette.error, + title, + }); +} + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface ErrorCardSpec { + /** Title chip in the top border (default: "ERROR"). */ + title?: string; + /** Error body — one string per line, or a single string. */ + body: string | string[]; + /** Optional recovery hint — rendered dim/italic below the body. */ + hint?: string; +} diff --git a/src/cli/render/index.ts b/src/cli/render/index.ts index c05b55617..295cad19a 100644 --- a/src/cli/render/index.ts +++ b/src/cli/render/index.ts @@ -17,3 +17,6 @@ export * from './card.js'; export * from './divider.js'; export * from './progress-bar.js'; export * from './box.js'; +export * from './subagent-status-bar.js'; +export * from './stream-progress.js'; +export * from './error-card.js'; diff --git a/src/cli/render/stream-progress.test.ts b/src/cli/render/stream-progress.test.ts new file mode 100644 index 000000000..249ca4e65 --- /dev/null +++ b/src/cli/render/stream-progress.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { stripAnsi } from '../display.js'; +import { streamProgress } from './stream-progress.js'; + +describe('streamProgress', () => { + it('renders spinner, label, and elapsed', () => { + const result = stripAnsi( + streamProgress({ + label: 'Generating…', + spinnerFrame: 0, + elapsedMs: 4700, + }), + ); + expect(result).toContain('⠋'); + expect(result).toContain('Generating…'); + expect(result).toContain('4s'); + }); + + it('cycles spinner frames', () => { + const frame0 = stripAnsi( + streamProgress({ label: 'test', spinnerFrame: 0, elapsedMs: 1000 }), + ); + const frame3 = stripAnsi( + streamProgress({ label: 'test', spinnerFrame: 3, elapsedMs: 1000 }), + ); + expect(frame0).toContain('⠋'); + expect(frame3).toContain('⠸'); + }); + + it('wraps spinner frames modulo length', () => { + const result = stripAnsi( + streamProgress({ label: 'test', spinnerFrame: 10, elapsedMs: 1000 }), + ); + // 10 % 10 = 0 → first frame + expect(result).toContain('⠋'); + }); + + it('renders token count when provided', () => { + const result = stripAnsi( + streamProgress({ + label: 'Streaming…', + spinnerFrame: 0, + elapsedMs: 2000, + tokenCount: 1234, + }), + ); + expect(result).toContain('1.2k tokens'); + }); + + it('renders raw count for small numbers', () => { + const result = stripAnsi( + streamProgress({ + label: 'test', + spinnerFrame: 0, + elapsedMs: 1000, + tokenCount: 42, + }), + ); + expect(result).toContain('42 tokens'); + }); + + it('renders M suffix for large counts', () => { + const result = stripAnsi( + streamProgress({ + label: 'test', + spinnerFrame: 0, + elapsedMs: 1000, + tokenCount: 1_500_000, + }), + ); + expect(result).toContain('1.5M tokens'); + }); + + it('renders cost when provided', () => { + const result = stripAnsi( + streamProgress({ + label: 'test', + spinnerFrame: 0, + elapsedMs: 1000, + costCents: 4.5, + }), + ); + expect(result).toContain('$0.04'); + }); + + it('renders sub-cent cost with 4 decimal places', () => { + const result = stripAnsi( + streamProgress({ + label: 'test', + spinnerFrame: 0, + elapsedMs: 1000, + costCents: 0.5, + }), + ); + expect(result).toContain('$0.0050'); + }); + + it('renders large cost as whole dollars', () => { + const result = stripAnsi( + streamProgress({ + label: 'test', + spinnerFrame: 0, + elapsedMs: 1000, + costCents: 300, + }), + ); + expect(result).toContain('$3'); + expect(result).not.toContain('$3.'); + }); + + it('omits cost when zero', () => { + const result = stripAnsi( + streamProgress({ + label: 'test', + spinnerFrame: 0, + elapsedMs: 1000, + costCents: 0, + }), + ); + expect(result).not.toContain('$'); + }); + + it('formats sub-second elapsed as <1s', () => { + const result = stripAnsi( + streamProgress({ label: 'test', spinnerFrame: 0, elapsedMs: 200 }), + ); + expect(result).toContain('<1s'); + }); +}); diff --git a/src/cli/render/stream-progress.ts b/src/cli/render/stream-progress.ts new file mode 100644 index 000000000..48c42bd9b --- /dev/null +++ b/src/cli/render/stream-progress.ts @@ -0,0 +1,85 @@ +import { palette } from '../palette.js'; + +// ─── Stream Progress ───────────────────────────────────────────────────────── + +/** + * Spinner glyphs for the progress line — 10-frame cycle. + * Caller drives the tick by incrementing `spinnerFrame`. + */ +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const; + +/** + * Render a single-line progress indicator for a streaming operation. + * + * Visual output: + * ⠹ Generating… 1.2k tokens 4.7s + * + * Designed for the OverlayComposer live region — called on every repaint + * tick. The caller is responsible for incrementing `spinnerFrame` to + * animate the spinner (typically via a setInterval). + * + * @param spec - Progress configuration. + * @returns Single-line ANSI string. + */ +export function streamProgress(spec: StreamProgressSpec): string { + const frame = SPINNER_FRAMES[spec.spinnerFrame % SPINNER_FRAMES.length]!; + const spinner = palette.brand(frame); + + const label = palette.bold(spec.label); + + const parts = [spinner, label]; + + if (spec.tokenCount != null) { + parts.push(palette.dim(formatTokenCount(spec.tokenCount))); + } + + if (spec.costCents != null && spec.costCents > 0) { + parts.push(palette.dim(formatCost(spec.costCents))); + } + + parts.push(palette.dim(formatElapsed(spec.elapsedMs))); + + return parts.join(' '); +} + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface StreamProgressSpec { + /** Human-readable label — e.g. "Generating…", "Running tests…". */ + label: string; + /** Current spinner frame index (caller increments). */ + spinnerFrame: number; + /** Elapsed time in milliseconds. */ + elapsedMs: number; + /** Accumulated token count (input + output). */ + tokenCount?: number; + /** Cost in cents (displayed as $0.XX). */ + costCents?: number; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Format token count with k/M suffixes. */ +function formatTokenCount(tokens: number): string { + if (tokens < 1000) return `${tokens} tokens`; + if (tokens < 100_000) return `${(tokens / 1000).toFixed(1)}k tokens`; + if (tokens < 1_000_000) return `${Math.round(tokens / 1000)}k tokens`; + return `${(tokens / 1_000_000).toFixed(1)}M tokens`; +} + +/** Format cost in cents as dollars. */ +function formatCost(cents: number): string { + if (cents < 1) return `$${(cents / 100).toFixed(4)}`; + if (cents < 100) return `$${(cents / 100).toFixed(2)}`; + return `$${(cents / 100).toFixed(0)}`; +} + +/** Format elapsed milliseconds as a compact human string. */ +function formatElapsed(ms: number): string { + if (ms < 1000) return '<1s'; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + return rem > 0 ? `${m}m ${rem}s` : `${m}m`; +} diff --git a/src/cli/render/subagent-status-bar.test.ts b/src/cli/render/subagent-status-bar.test.ts new file mode 100644 index 000000000..17278cf3b --- /dev/null +++ b/src/cli/render/subagent-status-bar.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { stripAnsi } from '../display.js'; +import { subagentStatusBar, subagentStatusStack } from './subagent-status-bar.js'; + +describe('subagentStatusBar', () => { + it('renders label and elapsed', () => { + const result = stripAnsi( + subagentStatusBar({ label: 'research-agent', elapsedMs: 5000 }), + ); + expect(result).toContain('◉'); + expect(result).toContain('research-agent'); + expect(result).toContain('5s'); + }); + + it('renders phase when provided', () => { + const result = stripAnsi( + subagentStatusBar({ + label: 'Agent(review)', + phase: 'thinking…', + elapsedMs: 12000, + }), + ); + expect(result).toContain('thinking…'); + expect(result).toContain('12s'); + }); + + it('renders batch badge when batch info provided', () => { + const result = stripAnsi( + subagentStatusBar({ + label: 'explore', + elapsedMs: 3000, + batchIndex: 2, + batchSize: 4, + }), + ); + expect(result).toContain('∥2/4'); + }); + + it('omits batch badge when no batch info', () => { + const result = stripAnsi( + subagentStatusBar({ label: 'explore', elapsedMs: 3000 }), + ); + expect(result).not.toContain('∥'); + }); + + it('formats sub-second elapsed as <1s', () => { + const result = stripAnsi( + subagentStatusBar({ label: 'test', elapsedMs: 500 }), + ); + expect(result).toContain('<1s'); + }); + + it('formats minutes correctly', () => { + const result = stripAnsi( + subagentStatusBar({ label: 'test', elapsedMs: 125000 }), + ); + expect(result).toContain('2m 5s'); + }); + + it('formats exact minutes without remainder', () => { + const result = stripAnsi( + subagentStatusBar({ label: 'test', elapsedMs: 120000 }), + ); + expect(result).toContain('2m'); + expect(result).not.toContain('2m 0s'); + }); +}); + +describe('subagentStatusStack', () => { + it('returns empty string for no entries', () => { + expect(subagentStatusStack([])).toBe(''); + }); + + it('renders all entries when under maxLines', () => { + const entries = [ + { label: 'agent-a', elapsedMs: 1000 }, + { label: 'agent-b', elapsedMs: 2000 }, + ]; + const result = stripAnsi(subagentStatusStack(entries)); + expect(result).toContain('agent-a'); + expect(result).toContain('agent-b'); + expect(result.split('\n')).toHaveLength(2); + }); + + it('caps at maxLines and shows overflow', () => { + // 5 entries, maxLines=3: reserve 1 slot for overflow → show 2 entries + + // 1 overflow line = 3 total lines (respects the cap). + const entries = [ + { label: 'a', elapsedMs: 1000 }, + { label: 'b', elapsedMs: 2000 }, + { label: 'c', elapsedMs: 3000 }, + { label: 'd', elapsedMs: 4000 }, + { label: 'e', elapsedMs: 5000 }, + ]; + const result = stripAnsi(subagentStatusStack(entries, 3)); + expect(result).toContain('a'); + expect(result).toContain('b'); + // 'c', 'd', 'e' are in the overflow summary, not rendered individually + expect(result).not.toContain(' c '); + expect(result).not.toContain(' d '); + expect(result).not.toContain(' e '); + expect(result).toContain('+3 more running'); + // Total line count must not exceed maxLines (3) + expect(result.split('\n')).toHaveLength(3); + }); + + it('respects custom maxLines', () => { + // 3 entries, maxLines=1: reserve 1 slot for overflow → show 0 entries + + // 1 overflow line = 1 total line (respects the cap). + const entries = [ + { label: 'a', elapsedMs: 1000 }, + { label: 'b', elapsedMs: 2000 }, + { label: 'c', elapsedMs: 3000 }, + ]; + const result = stripAnsi(subagentStatusStack(entries, 1)); + expect(result).toContain('+3 more running'); + expect(result.split('\n')).toHaveLength(1); + }); +}); diff --git a/src/cli/render/subagent-status-bar.ts b/src/cli/render/subagent-status-bar.ts new file mode 100644 index 000000000..6a6081943 --- /dev/null +++ b/src/cli/render/subagent-status-bar.ts @@ -0,0 +1,129 @@ +import { displayWidth, truncateDisplayWidth } from '../display.js'; +import { getTerminalWidth } from '../terminal-size.js'; +import { palette } from '../palette.js'; + +// ─── Subagent Status Bar ───────────────────────────────────────────────────── + +/** + * Render a single-line status bar for an active subagent dispatch. + * + * Visual output: + * ◉ research-agent ── thinking… 3.2s ∥2/4 + * + * Designed for the OverlayComposer live region — called on every repaint + * tick while the subagent is running, then removed on completion. + * + * @param spec - Status bar configuration. + * @returns Single-line ANSI string. + */ +export function subagentStatusBar(spec: SubagentStatusBarSpec): string { + const width = Math.min(getTerminalWidth(), 120); + + // ── Left: glyph + label ── + // Truncate the label so the assembled row does not exceed `width`. Reserve + // space for: glyph(1) + space(1) + separator gap(2) + elapsed(≤5) = 9 chars + // minimum, plus optional phase and batch. We compute a conservative label + // budget first, then clamp. + const glyphWidth = 1; // '◉' + const elapsedPlain = formatElapsed(spec.elapsedMs); + const phasePlainRaw = spec.phase ?? ''; + const batchPlainRaw = + spec.batchIndex != null && spec.batchSize != null + ? `∥${spec.batchIndex}/${spec.batchSize}` + : ''; + const fixedOtherWidth = + glyphWidth + + 1 + // space after glyph + 2 + // gap between label and fill/phase + (phasePlainRaw ? displayWidth(phasePlainRaw) + 2 : 0) + + displayWidth(elapsedPlain) + + (batchPlainRaw ? 2 + displayWidth(batchPlainRaw) : 0); + const labelBudget = Math.max(3, width - fixedOtherWidth); + const labelRaw = truncateDisplayWidth(spec.label, labelBudget); + + const glyph = palette.chrome('◉'); + const label = palette.tool(labelRaw); + const left = `${glyph} ${label}`; + const leftPlain = `◉ ${labelRaw}`; + + // ── Center: phase + elapsed ── + const elapsed = elapsedPlain; + const phase = phasePlainRaw ? palette.dim(phasePlainRaw) : ''; + const phasePlain = phasePlainRaw; + + // ── Right: batch badge (optional) ── + const batch = batchPlainRaw ? palette.dim(batchPlainRaw) : ''; + const batchPlain = batchPlainRaw; + + // ── Assemble with fill ── + const fixedWidth = + displayWidth(leftPlain) + + 2 + // gap after label + (phasePlain ? displayWidth(phasePlain) + 2 : 0) + // phase + gap after phase (omitted when absent) + displayWidth(elapsed) + + (batchPlain ? 2 + displayWidth(batchPlain) : 0); + + const fillLen = Math.max(0, width - fixedWidth); + const fill = palette.dim('─'.repeat(Math.min(fillLen, 20))); + + const parts = [left, fill, ...(phase ? [phase] : []), palette.dim(elapsed)]; + if (batch) parts.push(batch); + + return parts.join(' '); +} + +/** + * Render multiple subagent status bars stacked vertically. + * + * Caps at `maxLines` (default 3) — excess entries are summarized as + * `… +N more running` to prevent overlay height overflow. + * + * @param entries - Active subagent specs. + * @param maxLines - Maximum visible lines (default 3). + * @returns Multi-line ANSI string (may be empty if no entries). + */ +export function subagentStatusStack( + entries: SubagentStatusBarSpec[], + maxLines: number = 3, +): string { + if (entries.length === 0) return ''; + + const hasOverflow = entries.length > maxLines; + const visible = entries.slice(0, hasOverflow ? maxLines - 1 : maxLines); + const overflow = entries.length - visible.length; + + const lines = visible.map((e) => subagentStatusBar(e)); + + if (overflow > 0) { + lines.push(palette.dim(` … +${overflow} more running`)); + } + + return lines.join('\n'); +} + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface SubagentStatusBarSpec { + /** Display label — e.g. "research-agent", "Agent(review)". */ + label: string; + /** Current phase — e.g. "thinking…", "writing…", "running bash". */ + phase?: string; + /** Elapsed time in milliseconds since dispatch. */ + elapsedMs: number; + /** 1-based batch index when running as part of a parallel wave. */ + batchIndex?: number; + /** Total batch size. */ + batchSize?: number; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Format elapsed milliseconds as a compact human string. */ +function formatElapsed(ms: number): string { + if (ms < 1000) return '<1s'; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + return rem > 0 ? `${m}m ${rem}s` : `${m}m`; +} diff --git a/src/cli/tool-category.ts b/src/cli/tool-category.ts index 0978f9d9a..b177dd33b 100644 --- a/src/cli/tool-category.ts +++ b/src/cli/tool-category.ts @@ -101,7 +101,7 @@ function categoryColor(cat: ToolCategory): ChalkInstance { const CATEGORY_GLYPH: Record = { read: '●', write: '✎', - shell: '$', + shell: '▸', subagent: '→', skill: '◆', // hexagon evokes the "node graph" / DAG shape; distinct from ◆ (skill) @@ -114,7 +114,7 @@ const CATEGORY_GLYPH: Record = { planning: '▱', // calendar icon — evokes cron scheduling; single-cell in standard fonts. schedule: '⏲', - other: '●', + other: '◌', }; /** diff --git a/src/cli/turn-handler-format.test.ts b/src/cli/turn-handler-format.test.ts index 91ba7ae1c..9677b6dd5 100644 --- a/src/cli/turn-handler-format.test.ts +++ b/src/cli/turn-handler-format.test.ts @@ -30,7 +30,7 @@ describe('formatToolLine', () => { it.each([ { content: 'Read(file.ts)', glyph: '●', name: 'Read' }, { content: 'Write(out.txt)', glyph: '✎', name: 'Write' }, - { content: 'Bash(ls -la)', glyph: '$', name: 'Bash' }, + { content: 'Bash(ls -la)', glyph: '▸', name: 'Bash' }, { content: 'Agent(research)', glyph: '→', name: 'Agent' }, { content: 'Skill(/spec)', glyph: '◆', name: 'Skill' }, { content: 'compose(3 nodes)', glyph: '⬡', name: 'compose' }, @@ -41,7 +41,7 @@ describe('formatToolLine', () => { { content: 'read_file(file.ts)', glyph: '●', name: 'read_file' }, { content: 'write_file(out.txt)', glyph: '✎', name: 'write_file' }, { content: 'edit_file(f.ts)', glyph: '✎', name: 'edit_file' }, - { content: 'bash(ls)', glyph: '$', name: 'bash' }, + { content: 'bash(ls)', glyph: '▸', name: 'bash' }, { content: 'list_directory(/tmp)', glyph: '●', name: 'list_directory' }, { content: 'send_telegram(hi)', glyph: '⌖', name: 'send_telegram' }, ])('renders $name with glyph $glyph', ({ content, glyph, name }) => { @@ -151,7 +151,7 @@ describe('formatToolLine', () => { it('preserves multi-line tool args (the regex captures the full tail with /s)', () => { const out = formatToolLine('Bash(echo hi\n && echo bye)'); const stripped = strip(out); - expect(stripped).toContain('$ Bash'); + expect(stripped).toContain('▸ Bash'); expect(stripped).toContain('echo bye'); });