framework
Version:
The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.
205 lines • 9.89 kB
JavaScript
import { pickedIds } from './events.js';
// The terminal surface for the agent's event stream: render one {@link FrameworkEvent} as one
// human-readable line. This is the CLI's counterpart to the dashboard's read-model
// projections (run-view.ts) — a pure formatter over the same union, kept out of events.ts so
// the event contract stays a plain data module (and browser-safe for the client bundle).
/** Render a {@link FrameworkEvent} as one human-readable line (terminal surface). */
export function formatFrameworkEvent(event) {
switch (event.kind) {
case 'session':
return `◆ ${event.fake ? 'fake' : event.driver}${event.model ? ` (${event.model})` : ''} in ${event.workspace}${event.sessionLink ? ` — ${event.sessionLink}` : ''}`;
case 'session-update':
return ` session ${event.sessionId}${event.sessionLink ? ` — ${event.sessionLink}` : ''}`;
case 'system-prompt':
return ` system prompt sent (${event.text.length} chars)`;
case 'preview':
return `▶ your app is running at ${event.url}`;
case 'browser-stream':
return `◆ browser preview: http://127.0.0.1:${event.port}/stream`;
case 'browser':
return `◆ browser: ${event.url}`;
case 'log':
return ` ${event.message}`;
case 'error':
return `✗ ${event.headline}${event.detail ? `\n ${event.detail.replace(/\n/g, '\n ')}` : ''}`;
case 'view':
return `▶ view: ${event.title}`;
case 'session-name':
return ` session: ${event.name}`;
case 'ready-for-merge':
return `✓ ready for merge`;
case 'open-pr':
return ` pull request written${event.title ? `: ${event.title}` : ''}`;
case 'settled':
return `◆ done for now — waiting for your next message`;
case 'ticket':
return ` implementing ${event.path}`;
case 'branch':
return ` branch: ${event.branch}`;
case 'cloud-anchor':
return ` hand-off anchor: ${event.sha.slice(0, 7)}`;
case 'pull-request':
return ` pull request: #${event.number}`;
case 'on-before-mergeable':
switch (event.outcome) {
case 'queued':
return `✓ post-merge cleanup: quality follow-ups queued`;
case 'incomplete':
return ` ! post-merge cleanup: queueing did not complete cleanly`;
case 'skipped':
return ` ~ post-merge cleanup skipped: ${skipReason(event.reason)}`;
}
case 'handoff-armed': {
// Said as what will happen, not as two flags: the line is read once, at a glance.
// A merge-armed agent's PR is opened ready, not draft, and lands by itself — the line must say
// so (#1382): merging unattended is the one consequence a reader cannot be left to infer.
if (event.pr && event.merge)
return ` when this ends: push the branch, open a PR, and merge it`;
if (event.pr)
return ` when this ends: push the branch and open a draft PR`;
if (event.push)
return ` when this ends: push the branch`;
return ` when this ends: nothing — push and PR are both off`;
}
case 'handoff': {
// The merge half rides the same event (#1216) and gets its own line: after "auto-merge
// was on", silence about the merge reads as "it merged" (#1363), so every outcome —
// armed, merged, withheld, failed — is said.
const merge = event.outcome !== 'failed' && event.merge ? `\n${mergeLine(event.merge)}` : '';
switch (event.outcome) {
case 'done':
return (event.url ? `✓ opened ${event.url}` : `✓ branch pushed`) + merge;
case 'skipped':
return ` ~ handoff skipped: ${handoffSkipReason(event.reason)}` + merge;
case 'failed':
return ` ! could not ${event.step === 'pr' ? 'open the PR' : 'push the branch'}: ${event.error}`;
}
}
case 'usage': {
const turns = `over ${event.turns} turn${event.turns === 1 ? '' : 's'}`;
// No price to show: report the tokens the agent *did* report, rather than a
// `$0.0000` that would read as free (#540).
if (event.costUsd === undefined) {
const tokens = event.inputTokens + event.cacheReadTokens + event.outputTokens;
return ` tokens: ${tokens.toLocaleString('en-US')} (${event.outputTokens.toLocaleString('en-US')} out) ${turns} — no price reported`;
}
return ` spend: $${event.costUsd.toFixed(4)} ${turns}`;
}
case 'choice': {
const mark = (o) => event.multi ? (o.default ? '[x]' : '[ ]') : o.id === event.recommended ? '●' : '○';
const opts = event.options.map(o => ` ${mark(o)} ${o.label}`).join('\n');
return `? ${event.title}\n${opts}`;
}
case 'choice-resolved':
return ` ✓ chose ${pickedIds(event.picked).join(', ') || '(none)'} (${event.by})`;
case 'driver':
return formatDriverEvent(event.event);
case 'intent':
return `▶ "${truncate(event.text)}"`;
case 'end':
return event.ok ? '✓ finished' : event.stopped ? '■ stopped' : `✗ failed: ${event.detail ?? 'unknown error'}`;
}
}
/** One line for the merge half of a handoff (#1216/#1363), matching the CLI's own wording. */
function mergeLine(merge) {
switch (merge.outcome) {
case 'auto-armed':
return '✓ auto-merge armed: the PR lands when its checks pass';
case 'watched':
return '✓ merge on green: the daemon merges the PR when its checks pass';
case 'merged':
return '✓ merged the PR';
case 'withheld':
return ` ~ merge withheld: ${mergeWithheldWhy(merge.reason)}`;
case 'failed':
return ` ! could not merge the PR: ${merge.error}`;
}
}
/**
* Say why an armed merge was withheld (#1363) in the reader's terms. Exported for the CLI's
* own stdout line, so the two surfaces cannot drift.
*/
export function mergeWithheldWhy(reason) {
switch (reason) {
case 'not-ready-for-merge':
return 'the session never signalled ready-for-merge';
case 'session-todo-open':
return "the session's own TODO file still has open entries";
}
}
/** Say why the post-merge cleanup declined in the reader's terms, not the guard's (#835). */
function skipReason(reason) {
switch (reason) {
case 'not-ready-for-merge':
return 'the session never signalled ready-for-merge';
case 'run-stopped':
return 'the run was stopped';
case 'fake-run':
return 'this was a fake run';
case 'no-session-name':
return 'the session never called setSessionName()';
case 'no-bin-path':
return 'the framework binary path is unknown';
}
}
/** Why the end-of-session handoff did nothing (#1102), as a reason rather than a code. */
function handoffSkipReason(reason) {
switch (reason) {
case 'not-armed':
return 'push and PR are both off for this session';
case 'branch-gone':
return 'the branch no longer exists';
case 'no-commits':
return 'the session committed nothing';
case 'commit-failed':
return "the session's pending work could not be committed, so nothing was published (the work stays on its branch)";
case 'no-remote':
return 'this repo has no remote to push to';
case 'already-open':
return 'the branch already has a pull request';
case 'already-landed':
return "the branch's pull request already landed everything the session did";
case 'already-pushed':
return 'the branch is already on the remote';
case 'run-stopped':
return 'the run was stopped';
case 'fake-run':
return 'this was a fake run';
}
}
function formatDriverEvent(event) {
switch (event.type) {
case 'start':
return ` › prompt: ${truncate(event.prompt, 140)}`;
// Normally consumed by telemetry before it reaches a stream (#1322); rendered anyway so a
// stray one reads as what it is rather than crashing the formatter.
case 'session':
return ` session ${event.sessionId}`;
case 'text':
return ` ${truncate(event.text)}`;
case 'action':
return ` · ${event.label}`;
case 'result':
return ` ‹ turn complete`;
case 'rate-limit':
return ` ${formatRateLimit(event.limit)}`;
case 'error':
return ` ! agent error: ${event.message}`;
case 'notice':
return ` ~ ${event.message}`;
}
}
/** Quiet on the happy path: only worth a line when the quota is actually tight. */
function formatRateLimit(limit) {
const resets = new Date(limit.resetsAt).toISOString();
if (limit.status === 'rejected')
return `✗ quota exhausted (${limit.window}), resets ${resets}`;
if (limit.status === 'allowed_warning')
return `! quota running low (${limit.window}), resets ${resets}`;
return `· quota ${limit.status} (${limit.window}), resets ${resets}`;
}
function truncate(text, max = 100) {
const flat = text.replace(/\s+/g, ' ').trim();
return flat.length > max ? flat.slice(0, max - 1) + '…' : flat;
}
//# sourceMappingURL=terminal.js.map