/** * Pure text/format helpers for the jobs extension. * * No host APIs and no theme logic beyond `Theme` colour helpers: these turn job * state and raw log bytes into short, terminal-safe strings for the status * chip, the `/jobs` screen, the transcript, and the model. * * Raw log output is untrusted: it can contain ANSI escapes, carriage-return * progress rewrites, and control characters. Every boundary that surfaces log * text goes through `sanitizeUntrustedLine`/`sanitizeUntrustedText` from * `lib/format.ts`, so a background process cannot restyle or corrupt the * terminal. The raw log file is never modified. */ import type { Theme } from "@earendil-works/pi-coding-agent"; import { boundedTail, formatDuration, sanitizeUntrustedLine } from "../../lib/format.ts"; import { clip, clipLabel, LABEL_WIDTH } from "../../lib/tui/columns.ts"; import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts"; import { byRunningFirst } from "../../lib/tui/rows.ts"; import { paint, type TuiRole } from "../../lib/tui/tokens.ts"; import type { JobRecord, JobStatus } from "./types.ts"; /** The last `count` entries of `lines`. */ export function tailLines(lines: string[], count: number): string[] { if (count <= 0) return []; return lines.slice(Math.max(0, lines.length - count)); } /** Elapsed time for a job: running jobs measure to now, finished jobs to their end. */ export function elapsedMs(job: JobRecord, now: number): number { return (job.finishedAt ?? now) - job.startedAt; } /** The design role a job status is drawn in. */ export function statusRole(status: JobStatus): TuiRole { switch (status) { case "exited": return "ok"; case "failed": return "bad"; case "killed": return "meta"; case "unknown": return "faint"; default: return "accent"; } } /** Width-1 glyph for a status, from the shared vocabulary. */ export function statusGlyphChar(status: JobStatus): string { switch (status) { case "exited": return GLYPHS.check; case "failed": return GLYPHS.jobsFailure; case "killed": return GLYPHS.killed; case "unknown": return GLYPHS.unknown; default: return GLYPHS.jobsRunning; } } /** Short status word for text output. */ function statusWord(job: JobRecord): string { if (job.status === "failed" && job.exitCode !== null) return `failed (exit ${job.exitCode})`; return job.status; } /** A job's label (falling back to its command) clipped to one safe display line. */ export function shortLabel(job: JobRecord): string { return clipLabel(job.label || job.command); } /** One-line job summary shared by the model output and the `/jobs` screen. */ function jobSummary(job: JobRecord, now: number, ownSessionId?: string): string { const parts = [`${job.id}`, statusWord(job), formatDuration(elapsedMs(job, now))]; if (job.status === "running" && job.pid) parts.push(`pid ${job.pid}`); parts.push(shortLabel(job)); // A peer session's job is shown but belongs to it: name the owner so the // `clear` refusal reads as intent rather than a bug. if (ownSessionId !== undefined && job.sessionId !== ownSessionId) parts.push("(another session)"); return parts.join(" "); } /** Model-facing list of jobs, or a note when there are none. */ export function formatJobList(jobs: JobRecord[], now = Date.now(), ownSessionId?: string): string { if (jobs.length === 0) return "No jobs."; const lines = jobs.map((job) => ` ${jobSummary(job, now, ownSessionId)}`); return `Jobs (${jobs.length}):\n${lines.join("\n")}`; } /** Model-facing detail for one job, including its last output line. */ export function formatJobStatus(job: JobRecord, now = Date.now(), ownSessionId?: string): string { const lines = [ `${job.id}: ${statusWord(job)}`, `command: ${sanitizeUntrustedLine(job.command)}`, `cwd: ${sanitizeUntrustedLine(job.cwd)}`, `elapsed: ${formatDuration(elapsedMs(job, now))}`, ]; if (job.pid) lines.push(`pid: ${job.pid}`); if (job.logPath) lines.push(`log: ${sanitizeUntrustedLine(job.logPath)}`); if (ownSessionId !== undefined && job.sessionId !== ownSessionId) lines.push("session: another session"); if (job.lastLine) lines.push(`last: ${job.lastLine}`); return lines.join("\n"); } /** Model-facing render of a sanitized log tail. */ export function formatLogs(job: JobRecord, lines: string[], maxChars: number): { text: string; truncated: boolean } { if (lines.length === 0) { return { text: `${job.id}: no output yet${job.logPath ? ` (log: ${job.logPath})` : ""}.`, truncated: false }; } const { text: body, truncated } = boundedTail(lines, maxChars); const suffix = truncated ? `\n… truncated; full log: ${job.logPath}` : ""; return { text: `${job.id} (${statusWord(job)}) output:\n${body}${suffix}`, truncated }; } /** One-line summary of a completed job, for context injection and wake messages. */ export function formatCompletion(job: JobRecord, now = Date.now()): string { const tail = job.lastLine ? ` — ${job.lastLine}` : ""; return `${job.id} ${statusWord(job)} after ${formatDuration(elapsedMs(job, now))} (${shortLabel(job)})${tail}`; } /** Transcript call text for the `job` tool. */ export function formatCallText(action: string, args: Record, argsComplete = true): string { const target = typeof args.id === "string" && args.id ? sanitizeUntrustedLine(args.id) : undefined; switch (action) { case "start": { const command = typeof args.command === "string" ? sanitizeUntrustedLine(args.command.trim()) : ""; if (!command) return argsComplete ? "start" : "start …"; const preview = clip(command, LABEL_WIDTH); return `start → ${preview}`; } case "kill": return `kill ${target ?? ""}`.trim(); case "send": { if (!target) return "send"; const input = typeof args.input === "string" ? sanitizeUntrustedLine(args.input) : ""; const preview = input ? clip(input, LABEL_WIDTH) : ""; const parts = [preview, args.eof === true ? "EOF" : ""].filter(Boolean).join(" + "); return parts ? `send ${target} → ${parts}` : `send ${target}`; } case "logs": case "status": case "wait": return `${action} ${target ?? ""}`.trim(); case "clear": return args.all ? "clear all" : `clear ${target ?? "finished"}`.trim(); default: return action; } } /** Order jobs for display: running first, then by most recent start. */ export const compareJobs = byRunningFirst((job) => job.status === "running"); export interface JobCounts { total: number; running: number; failed: number; } /** One-pass status counts for the `/jobs` summary. */ export function jobCounts(jobs: Iterable): JobCounts { let total = 0; let running = 0; let failed = 0; for (const job of jobs) { total++; if (job.status === "running") running++; else if (job.status === "failed") failed++; } return { total, running, failed }; } /** Finished failures whose completion has not been reported to the model yet. */ export function pendingFailures(jobs: Iterable): JobRecord[] { return [...jobs].filter((job) => !job.seen && job.status === "failed"); } /** Short outcome word for a job: pid while running, else exit code or signal. */ export function jobOutcome(job: JobRecord): string { switch (job.status) { case "running": return job.pid !== null ? `pid ${job.pid}` : "running"; case "exited": return `exit ${job.exitCode ?? 0}`; case "failed": return job.exitCode !== null ? `exit ${job.exitCode}` : "failed"; case "killed": return job.signal ?? "killed"; default: return "—"; } } /** One-line outcome for a single job, themed for the transcript result. */ export function formatJobOutcomeLine(job: JobRecord, theme: Theme, now = Date.now()): string { const sep = paint(theme, "faint", SEPARATORS.item); const outcome = job.status === "failed" ? paint(theme, "bad", jobOutcome(job)) : paint(theme, "faint", jobOutcome(job)); return ( paint(theme, statusRole(job.status), statusGlyphChar(job.status)) + " " + paint(theme, "accent", job.id) + " " + paint(theme, "meta", shortLabel(job)) + sep + outcome + sep + paint(theme, "faint", formatDuration(elapsedMs(job, now))) ); } /** Unstyled, sanitized detail lines for the `/jobs` focus pane. */ export function formatJobDetail(job: JobRecord, now = Date.now(), ownSessionId?: string): string[] { const lines = [ sanitizeUntrustedLine(`command: ${job.command}`), sanitizeUntrustedLine(`cwd: ${job.cwd} · ${jobOutcome(job)} · ${formatDuration(elapsedMs(job, now))}`), ]; if (ownSessionId !== undefined && job.sessionId !== ownSessionId) lines.push("owner: another session"); const last = sanitizeUntrustedLine(job.lastLine); if (last) lines.push(`last: ${last}`); return lines; }