diff --git a/README.md b/README.md index ae5b8e013..54337493c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,77 @@ # Prime Agent -Fork of [pi](https://github.com/badlogic/pi-mono). +Prime Agent is a hard fork of [pi](https://github.com/badlogic/pi-mono) rebuilt around an RLM-native coding and research harness. The TypeScript host keeps pi's terminal UI, provider layer, sessions, and extension machinery, while the model-facing runtime is centered on a persistent IPython kernel and recursive subagents. + +## Architecture + +Prime Agent keeps the core pieces that make pi a strong terminal agent: the TypeScript CLI, custom TUI renderer, model provider layer, session tree, slash commands, and extension/resource system. The main change is the model-facing runtime: instead of a large set of file and shell tools, the agent is centered on a persistent Python kernel. + +```text + +----------------------+ + | prime-agent | + | typescript cli | + +----------+-----------+ + | + +---------------------------+---------------------------+ + | | | + v v v ++---------------+ +---------------+ +---------------+ +| pi-tui | | agent session | | pi-ai | +| terminal ui | | loop + events | | model clients | ++-------+-------+ +-------+-------+ +-------+-------+ + | | | + | | v + | | +----------------+ + | | | rlm-1 or | + | | | bootstrap llm | + | | +----------------+ + | | + v v ++---------------+ +---------------+ +| ipython cells | | jsonl session | +| subagent pane | | tree + resume | ++---------------+ +---------------+ + + prime agent adds + | + v + +----------------------+ + | ipython tool | + +----------+-----------+ + | + jupyter zmq + | + v + +----------------------+ + | ipython kernel | + | persistent python | + | state across turns | + +----------+-----------+ + | + +------------------+------------------+ + | | + v v + +------------------+ +------------------+ + | prime-agent- | | python skills | + | runtime | | prime envs/eval/ | + | injects rlm | | train/etc. | + +--------+---------+ +------------------+ + | + | await rlm("subtask") + v + +------------------+ + | child agent | + | session + kernel | + +------------------+ +``` + +The key loop is: + +1. The model receives a normal chat turn plus the RLM system prompt. +2. Tool use goes through the `ipython` tool, which executes code in a persistent IPython kernel. +3. Python variables, imports, loaded logs, dataframes, and helper functions survive across turns. +4. The injected `rlm` callable lets code spawn recursive child agents with `await rlm("subtask")` or fan out with `asyncio.gather(...)`. +5. Each child gets its own agent session and kernel, then returns an `RLMResult` to the parent kernel. +6. The TUI renders kernel cells and the live child-agent tree while preserving the session history on disk. + +The default model-facing tool surface is intentionally small: `ipython` first, with RLM-shaped `bash` and `edit` support available where enabled. Prime platform capabilities are exposed as Python skills inside the kernel instead of as separate LLM tools. diff --git a/assets/brand/prime-butterfly.svg b/assets/brand/prime-butterfly.svg new file mode 100644 index 000000000..e0009d14c --- /dev/null +++ b/assets/brand/prime-butterfly.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c950157bb..1c474e88b 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -126,6 +126,24 @@ export function parseSkillBlock(text: string): ParsedSkillBlock | null { }; } +export type RlmChildAgentStatus = "queued" | "running" | "done" | "error" | "cancelled"; + +export interface RlmChildAgentTranscriptLine { + role: "user" | "assistant" | "tool" | "system"; + text: string; +} + +export interface RlmChildAgentSnapshot { + id: string; + parentId?: string; + label: string; + status: RlmChildAgentStatus; + durationMs?: number; + answerPreview?: string; + sessionDir: string; + transcript: readonly RlmChildAgentTranscriptLine[]; +} + /** Session-specific events that extend the core AgentEvent */ export type AgentSessionEvent = | AgentEvent @@ -146,7 +164,8 @@ export type AgentSessionEvent = errorMessage?: string; } | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string } - | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }; + | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string } + | { type: "rlm_child_update"; child: RlmChildAgentSnapshot }; /** Listener function for agent session events */ export type AgentSessionEventListener = (event: AgentSessionEvent) => void; @@ -189,6 +208,8 @@ export interface AgentSessionConfig { rlmMaxDepth?: number; /** Directory exposed to the kernel as RLM_SESSION_DIR. */ rlmSessionDir?: string; + /** Node id for this session when it is itself an RLM child. */ + rlmParentNodeId?: string; } export interface ExtensionBindings { @@ -296,6 +317,63 @@ function addAssistantUsage(total: Usage, usage: Usage): void { total.cost.total += usage.cost.total; } +function compactRlmText(text: string, maxLength = 160): string { + const compact = text.replace(/\s+/g, " ").trim(); + if (compact.length <= maxLength) { + return compact; + } + return `${compact.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +} + +function readTextBlocks(content: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") { + return content; + } + return content + .filter((block) => block.type === "text" && typeof block.text === "string") + .map((block) => block.text ?? "") + .join("\n"); +} + +function readAssistantText(message: AssistantMessage): string { + return message.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""); +} + +function readToolResultText(result: unknown): string | undefined { + if (!result || typeof result !== "object" || !("content" in result)) { + return undefined; + } + const content = (result as { content?: unknown }).content; + if (!Array.isArray(content)) { + return undefined; + } + const text = content + .filter( + (block): block is { type: string; text: string } => + !!block && + typeof block === "object" && + "type" in block && + "text" in block && + block.type === "text" && + typeof block.text === "string", + ) + .map((block) => block.text) + .join("\n"); + return text.trim() ? text : undefined; +} + +function formatRlmToolArgs(args: unknown): string | undefined { + try { + const text = JSON.stringify(args); + return text && text !== "{}" ? compactRlmText(text, 96) : undefined; + } catch { + return undefined; + } +} + function attributeChildUsage(parentUsage: Usage, childUsage: Usage): void { const parentContextTokens = parentUsage.totalTokens || @@ -370,6 +448,7 @@ export class AgentSession { private _rlmDepth: number; private _rlmMaxDepth: number; private _rlmSessionDir?: string; + private _rlmParentNodeId?: string; // Model registry for API key resolution private _modelRegistry: ModelRegistry; @@ -401,6 +480,7 @@ export class AgentSession { this._rlmDepth = config.rlmDepth ?? parseDepth(process.env.RLM_DEPTH, 0, "RLM_DEPTH"); this._rlmMaxDepth = config.rlmMaxDepth ?? parseDepth(process.env.RLM_MAX_DEPTH, 1, "RLM_MAX_DEPTH"); this._rlmSessionDir = config.rlmSessionDir; + this._rlmParentNodeId = config.rlmParentNodeId; // Always subscribe to agent events for internal handling // (session persistence, extensions, auto-compaction, retry logic) @@ -2591,6 +2671,45 @@ export class AgentSession { } const childSessionDir = this._createChildRlmSessionDir(); + const childNodeId = basename(childSessionDir); + const startedAt = Date.now(); + const transcript: RlmChildAgentTranscriptLine[] = []; + const label = compactRlmText(prompt, 80) || "child agent"; + let status: RlmChildAgentStatus = "running"; + let answerPreview: string | undefined; + let durationMs: number | undefined; + let assistantTranscriptIndex: number | undefined; + let lastToolTranscriptIndex: number | undefined; + const emitChildUpdate = () => { + this._emit({ + type: "rlm_child_update", + child: { + id: childNodeId, + parentId: this._rlmParentNodeId, + label, + status, + durationMs, + answerPreview, + sessionDir: childSessionDir, + transcript: [...transcript], + }, + }); + }; + const setAssistantTranscript = (text: string) => { + const compact = compactRlmText(text); + if (!compact) { + return; + } + answerPreview = compact; + if (assistantTranscriptIndex === undefined) { + assistantTranscriptIndex = transcript.length; + transcript.push({ role: "assistant", text: compact }); + } else { + transcript[assistantTranscriptIndex] = { role: "assistant", text: compact }; + } + }; + emitChildUpdate(); + const childSessionManager = SessionManager.create(this._cwd, childSessionDir); childSessionManager.appendModelChange(model.provider, model.id); childSessionManager.appendThinkingLevelChange(this.thinkingLevel); @@ -2631,8 +2750,70 @@ export class AgentSession { rlmDepth: this._rlmDepth + 1, rlmMaxDepth: this._rlmMaxDepth, rlmSessionDir: childSessionDir, + rlmParentNodeId: childNodeId, sessionStartEvent: { type: "session_start", reason: "startup" }, }); + const unsubscribeChild = child.subscribe((event) => { + if (event.type === "rlm_child_update") { + this._emit(event); + return; + } + switch (event.type) { + case "message_start": { + if (event.message.role === "user") { + const text = compactRlmText(readTextBlocks(event.message.content)); + if (text) { + transcript.push({ role: "user", text }); + } + } else if (event.message.role === "assistant") { + setAssistantTranscript(readAssistantText(event.message as AssistantMessage)); + } + emitChildUpdate(); + break; + } + case "message_update": + case "message_end": { + if (event.message.role === "assistant") { + setAssistantTranscript(readAssistantText(event.message as AssistantMessage)); + emitChildUpdate(); + } + break; + } + case "tool_execution_start": { + const args = formatRlmToolArgs(event.args); + lastToolTranscriptIndex = transcript.length; + transcript.push({ + role: "tool", + text: args ? `${event.toolName} running ${args}` : `${event.toolName} running`, + }); + emitChildUpdate(); + break; + } + case "tool_execution_update": { + const text = readToolResultText(event.partialResult); + if (text && lastToolTranscriptIndex !== undefined) { + transcript[lastToolTranscriptIndex] = { + role: "tool", + text: `${event.toolName} running: ${compactRlmText(text)}`, + }; + emitChildUpdate(); + } + break; + } + case "tool_execution_end": { + const text = readToolResultText(event.result); + const summary = text ? `${event.toolName}: ${compactRlmText(text)}` : `${event.toolName} done`; + if (lastToolTranscriptIndex === undefined) { + lastToolTranscriptIndex = transcript.length; + transcript.push({ role: "tool", text: summary }); + } else { + transcript[lastToolTranscriptIndex] = { role: "tool", text: summary }; + } + emitChildUpdate(); + break; + } + } + }); try { await child.prompt(prompt, { expandPromptTemplates: false, source: "extension" }); @@ -2640,13 +2821,24 @@ export class AgentSession { const answer = child.getLastAssistantText() ?? ""; const usage = child._usageForCurrentMessages(); this._attributeRlmChildUsageToParent(child._assistantUsageForCurrentMessages()); + status = "done"; + durationMs = Date.now() - startedAt; + setAssistantTranscript(answer); + emitChildUpdate(); return { answer, usage, turns: child._assistantTurnCount(), session_dir: childSessionDir, }; + } catch (error) { + status = "error"; + durationMs = Date.now() - startedAt; + transcript.push({ role: "system", text: error instanceof Error ? error.message : String(error) }); + emitChildUpdate(); + throw error; } finally { + unsubscribeChild(); child.dispose(); } } diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index c37a45884..a16f9e168 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -70,6 +70,7 @@ const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [ "app.model.select", "app.tools.expand", "app.thinking.toggle", + "app.subagents.focus", "app.editor.external", "app.message.followUp", "tui.input.submit", diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index f1663b1ad..6ce8a7582 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -21,6 +21,7 @@ export interface AppKeybindings { "app.model.select": true; "app.tools.expand": true; "app.thinking.toggle": true; + "app.subagents.focus": true; "app.session.toggleNamedFilter": true; "app.editor.external": true; "app.message.followUp": true; @@ -87,6 +88,10 @@ export const KEYBINDINGS = { defaultKeys: "ctrl+t", description: "Toggle thinking blocks", }, + "app.subagents.focus": { + defaultKeys: "alt+a", + description: "Focus child agents", + }, "app.session.toggleNamedFilter": { defaultKeys: "ctrl+n", description: "Toggle named session filter", @@ -243,6 +248,7 @@ const KEYBINDING_NAME_MIGRATIONS = { selectModel: "app.model.select", expandTools: "app.tools.expand", toggleThinking: "app.thinking.toggle", + focusSubagents: "app.subagents.focus", toggleSessionNamedFilter: "app.session.toggleNamedFilter", externalEditor: "app.editor.external", followUp: "app.message.followUp", diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 4db920563..adf97c5a9 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -755,7 +755,7 @@ export class SettingsManager { } getQuietStartup(): boolean { - return this.settings.quietStartup ?? false; + return this.settings.quietStartup ?? true; } setQuietStartup(quiet: boolean): void { diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index bfe733f8a..a1643711f 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -54,7 +54,7 @@ export class BashExecutionComponent extends Container { // Loader this.loader = new Loader( ui, - (spinner) => theme.fg(colorKey, spinner), + (spinner) => theme.fg("muted", spinner), (text) => theme.fg("muted", text), `Running... (${keyText("tui.select.cancel")} to cancel)`, // Plain text for loader ); diff --git a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts index e9b13919f..ebe7094ee 100644 --- a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts +++ b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts @@ -17,7 +17,7 @@ export class BorderedLoader extends Container { if (this.cancellable) { this.loader = new CancellableLoader( tui, - (s) => theme.fg("accent", s), + (s) => theme.fg("muted", s), (s) => theme.fg("muted", s), message, ); @@ -25,7 +25,7 @@ export class BorderedLoader extends Container { this.signalController = new AbortController(); this.loader = new Loader( tui, - (s) => theme.fg("accent", s), + (s) => theme.fg("muted", s), (s) => theme.fg("muted", s), message, ); diff --git a/packages/coding-agent/src/modes/interactive/components/child-agent-inspector.ts b/packages/coding-agent/src/modes/interactive/components/child-agent-inspector.ts new file mode 100644 index 000000000..75bd1ae6e --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/child-agent-inspector.ts @@ -0,0 +1,289 @@ +import { + type Component, + type Focusable, + getKeybindings, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "@earendil-works/pi-tui"; +import { theme } from "../theme/theme.js"; + +export type ChildAgentStatus = "queued" | "running" | "done" | "error" | "cancelled"; + +export interface ChildAgentTranscriptLine { + role: "user" | "assistant" | "tool" | "system"; + text: string; +} + +export interface ChildAgentInspectorNode { + id: string; + label: string; + status: ChildAgentStatus; + durationMs?: number; + answerPreview?: string; + sessionDir: string; + transcript: readonly ChildAgentTranscriptLine[]; + children?: readonly ChildAgentInspectorNode[]; +} + +interface FlatChildAgentNode { + node: ChildAgentInspectorNode; + depth: number; +} + +interface SidebarLine { + text: string; + selected: boolean; +} + +export class ChildAgentInspectorComponent implements Component, Focusable { + focused = false; + private readonly paddingX = 2; + private nodes: readonly ChildAgentInspectorNode[] = []; + private selectedId: string | undefined; + + onCancel?: () => void; + onOpenDetail?: (nodeId: string) => void; + + constructor(private readonly getViewportHeight: () => number = () => 0) {} + + setNodes(nodes: readonly ChildAgentInspectorNode[]): void { + this.nodes = nodes; + const flat = this.flatten(); + if (flat.length === 0) { + this.selectedId = undefined; + return; + } + if (!this.selectedId || !flat.some((entry) => entry.node.id === this.selectedId)) { + this.selectedId = flat.find((entry) => entry.node.status === "running")?.node.id ?? flat[0]?.node.id; + } + } + + invalidate(): void { + // Render output is derived from node state. + } + + render(width: number): string[] { + if (this.nodes.length === 0) { + return []; + } + + const safeWidth = Math.max(1, width); + return this.renderSidebar(safeWidth).map((line) => this.panelLine(line.text, safeWidth, line.selected)); + } + + handleInput(data: string): void { + const kb = getKeybindings(); + const flat = this.flatten(); + if (flat.length === 0) { + return; + } + + if (kb.matches(data, "tui.select.cancel")) { + this.onCancel?.(); + return; + } + if (kb.matches(data, "tui.select.confirm")) { + if (this.selectedId) { + this.onOpenDetail?.(this.selectedId); + } + return; + } + if (kb.matches(data, "tui.select.up")) { + this.moveSelection(-1); + return; + } + if (kb.matches(data, "tui.select.down")) { + this.moveSelection(1); + return; + } + if (kb.matches(data, "tui.select.pageUp")) { + this.moveSelection(-5); + return; + } + if (kb.matches(data, "tui.select.pageDown")) { + this.moveSelection(5); + } + } + + private renderSidebar(width: number): SidebarLine[] { + const contentWidth = Math.max(1, width - this.paddingX * 2); + const flat = this.flatten(); + const running = flat.filter((entry) => entry.node.status === "running").length; + const targetHeight = Math.max(0, this.getViewportHeight()); + const lines: SidebarLine[] = [{ text: this.headerLine(running, flat.length, contentWidth), selected: false }]; + const availableRows = targetHeight > 0 ? Math.max(0, targetHeight - 2) : flat.length; + if (availableRows === 0) { + return lines; + } + lines.push({ text: "", selected: false }); + + const selectedIndex = Math.max( + 0, + flat.findIndex((entry) => entry.node.id === this.selectedId), + ); + const start = Math.max(0, Math.min(selectedIndex - Math.floor(availableRows / 2), flat.length - availableRows)); + for (const entry of flat.slice(start, start + availableRows)) { + const selected = this.focused && entry.node.id === this.selectedId; + lines.push({ text: this.renderListEntry(entry, contentWidth, selected), selected }); + } + while (targetHeight > 0 && lines.length < targetHeight) { + lines.push({ text: "", selected: false }); + } + return lines; + } + + private renderListEntry(entry: FlatChildAgentNode, width: number, selected: boolean): string { + const selector = selected ? theme.fg("accent", "▌") : " "; + const indent = " ".repeat(Math.min(6, entry.depth * 2)); + const line = `${selector} ${indent}${this.statusLabel(entry.node.status)} ${theme.fg("dim", "·")} ${theme.fg("muted", entry.node.label)}`; + return this.truncate(line, width, "…"); + } + private flatten(): FlatChildAgentNode[] { + const result: FlatChildAgentNode[] = []; + const walk = (nodes: readonly ChildAgentInspectorNode[], depth: number): void => { + for (const node of nodes) { + result.push({ node, depth }); + walk(node.children ?? [], depth + 1); + } + }; + walk(this.nodes, 0); + return result; + } + + private moveSelection(delta: number): void { + const flat = this.flatten(); + if (flat.length === 0) { + return; + } + const current = Math.max( + 0, + flat.findIndex((entry) => entry.node.id === this.selectedId), + ); + const next = (current + delta + flat.length) % flat.length; + this.selectedId = flat[next]?.node.id; + } + + private headerLine(running: number, total: number, width: number): string { + const left = theme.bold("agents"); + const right = theme.fg("muted", `${running}/${total} running`); + const gap = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); + return this.truncate(`${left}${gap}${right}`, width); + } + + private statusLabel(status: ChildAgentStatus): string { + switch (status) { + case "queued": + return theme.fg("muted", "queued"); + case "running": + return theme.fg("accent", "running"); + case "done": + return theme.fg("success", "done"); + case "error": + return theme.fg("error", "error"); + case "cancelled": + return theme.fg("warning", "cancelled"); + } + } + + private panelLine(line: string, width: number, selected: boolean): string { + const contentWidth = Math.max(1, width - this.paddingX * 2); + const truncated = this.truncate(line, contentWidth); + const paddedContent = truncated + " ".repeat(Math.max(0, contentWidth - visibleWidth(truncated))); + const padded = `${" ".repeat(this.paddingX)}${paddedContent}${" ".repeat(this.paddingX)}`; + return selected ? theme.bg("selectedBg", padded) : theme.bg("customMessageBg", padded); + } + + private truncate(line: string, width: number, ellipsis = ""): string { + return truncateToWidth(line, width, ellipsis); + } +} + +export class ChildAgentDetailComponent implements Component, Focusable { + focused = false; + private node: ChildAgentInspectorNode | undefined; + + onCancel?: () => void; + + constructor(private readonly getViewportHeight: () => number = () => 0) {} + + setNode(node: ChildAgentInspectorNode | undefined): void { + this.node = node; + } + + invalidate(): void { + // Render output is derived from node state. + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + const lines = this.renderDetail(safeWidth); + const targetHeight = Math.max(0, this.getViewportHeight()); + while (lines.length < targetHeight) { + lines.push(""); + } + return lines.map((line) => this.panelLine(line, safeWidth)); + } + + handleInput(data: string): void { + const kb = getKeybindings(); + if (kb.matches(data, "tui.select.cancel")) { + this.onCancel?.(); + } + } + + private renderDetail(width: number): string[] { + const selected = this.node; + if (!selected) { + return [this.headerLine(theme.fg("muted", "agent unavailable"), width)]; + } + + const lines = [this.headerLine(`${this.statusLabel(selected.status)} ${theme.fg("dim", selected.label)}`, width)]; + const sessionDirParts = selected.sessionDir.split("/"); + const leaf = sessionDirParts[sessionDirParts.length - 1]; + if (leaf) { + lines.push(this.truncate(theme.fg("dim", leaf), width)); + } + lines.push(theme.fg("borderMuted", "─".repeat(width))); + for (const line of selected.transcript) { + const label = theme.fg("dim", `${line.role}: `); + const wrapped = wrapTextWithAnsi(line.text, Math.max(1, width - visibleWidth(label))); + for (const [index, wrappedLine] of wrapped.entries()) { + const prefix = index === 0 ? label : " ".repeat(visibleWidth(label)); + lines.push(this.truncate(prefix + wrappedLine, width)); + } + } + return lines; + } + + private headerLine(text: string, width: number): string { + const plainWidth = visibleWidth(text); + const rule = "─".repeat(Math.max(1, width - plainWidth - 1)); + return this.truncate(`${text} ${theme.fg("borderMuted", rule)}`, width); + } + + private statusLabel(status: ChildAgentStatus): string { + switch (status) { + case "queued": + return theme.fg("muted", "queued"); + case "running": + return theme.fg("accent", "running"); + case "done": + return theme.fg("success", "done"); + case "error": + return theme.fg("error", "error"); + case "cancelled": + return theme.fg("warning", "cancelled"); + } + } + + private panelLine(line: string, width: number): string { + const truncated = this.truncate(line, width); + const padded = truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated))); + return theme.bg("customMessageBg", padded); + } + + private truncate(line: string, width: number): string { + return truncateToWidth(line, width, ""); + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 8f0653e75..beb8b4915 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -1,49 +1,30 @@ -import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import type { Component } from "@earendil-works/pi-tui"; import type { AgentSession } from "../../../core/agent-session.js"; import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.js"; -import { theme } from "../theme/theme.js"; /** - * Sanitize text for display in a single-line status. - * Removes newlines, tabs, carriage returns, and other control characters. - */ -function sanitizeStatusText(text: string): string { - // Replace newlines, tabs, carriage returns with space, then collapse multiple spaces - return text - .replace(/[\r\n\t]/g, " ") - .replace(/ +/g, " ") - .trim(); -} - -/** - * Format token counts (similar to web-ui) - */ -function formatTokens(count: number): string { - if (count < 1000) return count.toString(); - if (count < 10000) return `${(count / 1000).toFixed(1)}k`; - if (count < 1000000) return `${Math.round(count / 1000)}k`; - if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; - return `${Math.round(count / 1000000)}M`; -} - -/** - * Footer component that shows pwd, token stats, and context usage. - * Computes token/context stats from session, gets git branch and extension statuses from provider. + * Footer component for the prime brand TUI. + * + * Renders nothing by default — token counters, cost, model name, cwd, and context % + * are intentionally hidden. The setters and invalidate/dispose hooks are kept so the + * existing call sites in interactive-mode keep working without modification, and so + * a `/stats` overlay can be re-introduced later without re-plumbing. */ export class FooterComponent implements Component { - private autoCompactEnabled = true; - constructor( private session: AgentSession, private footerData: ReadonlyFooterDataProvider, - ) {} + ) { + void this.session; + void this.footerData; + } setSession(session: AgentSession): void { this.session = session; } - setAutoCompactEnabled(enabled: boolean): void { - this.autoCompactEnabled = enabled; + setAutoCompactEnabled(_enabled: boolean): void { + // no-op while the footer is empty } /** @@ -62,159 +43,9 @@ export class FooterComponent implements Component { // Git watcher cleanup handled by provider } - render(width: number): string[] { - const state = this.session.state; - - // Calculate cumulative usage from ALL session entries (not just post-compaction messages) - let totalInput = 0; - let totalOutput = 0; - let totalCacheRead = 0; - let totalCacheWrite = 0; - let totalCost = 0; - - for (const entry of this.session.sessionManager.getEntries()) { - if (entry.type === "message" && entry.message.role === "assistant") { - totalInput += entry.message.usage.input; - totalOutput += entry.message.usage.output; - totalCacheRead += entry.message.usage.cacheRead; - totalCacheWrite += entry.message.usage.cacheWrite; - totalCost += entry.message.usage.cost.total; - } - } - - // Calculate context usage from session (handles compaction correctly). - // After compaction, tokens are unknown until the next LLM response. - const contextUsage = this.session.getContextUsage(); - const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0; - const contextPercentValue = contextUsage?.percent ?? 0; - const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?"; - - // Replace home directory with ~ - let pwd = this.session.sessionManager.getCwd(); - const home = process.env.HOME || process.env.USERPROFILE; - if (home && pwd.startsWith(home)) { - pwd = `~${pwd.slice(home.length)}`; - } - - // Add git branch if available - const branch = this.footerData.getGitBranch(); - if (branch) { - pwd = `${pwd} (${branch})`; - } - - // Add session name if set - const sessionName = this.session.sessionManager.getSessionName(); - if (sessionName) { - pwd = `${pwd} • ${sessionName}`; - } - - // Build stats line - const statsParts = []; - if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`); - if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`); - if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`); - if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); - - // Show cost with "(sub)" indicator if using OAuth subscription - const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false; - if (totalCost || usingSubscription) { - const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; - statsParts.push(costStr); - } - - // Colorize context percentage based on usage - let contextPercentStr: string; - const autoIndicator = this.autoCompactEnabled ? " (auto)" : ""; - const contextPercentDisplay = - contextPercent === "?" - ? `?/${formatTokens(contextWindow)}${autoIndicator}` - : `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`; - if (contextPercentValue > 90) { - contextPercentStr = theme.fg("error", contextPercentDisplay); - } else if (contextPercentValue > 70) { - contextPercentStr = theme.fg("warning", contextPercentDisplay); - } else { - contextPercentStr = contextPercentDisplay; - } - statsParts.push(contextPercentStr); - - let statsLeft = statsParts.join(" "); - - // Add model name on the right side, plus thinking level if model supports it - const modelName = state.model?.id || "no-model"; - - let statsLeftWidth = visibleWidth(statsLeft); - - // If statsLeft is too wide, truncate it - if (statsLeftWidth > width) { - statsLeft = truncateToWidth(statsLeft, width, "..."); - statsLeftWidth = visibleWidth(statsLeft); - } - - // Calculate available space for padding (minimum 2 spaces between stats and model) - const minPadding = 2; - - // Add thinking level indicator if model supports reasoning - let rightSideWithoutProvider = modelName; - if (state.model?.reasoning) { - const thinkingLevel = state.thinkingLevel || "off"; - rightSideWithoutProvider = - thinkingLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`; - } - - // Prepend the provider in parentheses if there are multiple providers and there's enough room - let rightSide = rightSideWithoutProvider; - if (this.footerData.getAvailableProviderCount() > 1 && state.model) { - rightSide = `(${state.model!.provider}) ${rightSideWithoutProvider}`; - if (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) { - // Too wide, fall back - rightSide = rightSideWithoutProvider; - } - } - - const rightSideWidth = visibleWidth(rightSide); - const totalNeeded = statsLeftWidth + minPadding + rightSideWidth; - - let statsLine: string; - if (totalNeeded <= width) { - // Both fit - add padding to right-align model - const padding = " ".repeat(width - statsLeftWidth - rightSideWidth); - statsLine = statsLeft + padding + rightSide; - } else { - // Need to truncate right side - const availableForRight = width - statsLeftWidth - minPadding; - if (availableForRight > 0) { - const truncatedRight = truncateToWidth(rightSide, availableForRight, ""); - const truncatedRightWidth = visibleWidth(truncatedRight); - const padding = " ".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth)); - statsLine = statsLeft + padding + truncatedRight; - } else { - // Not enough space for right side at all - statsLine = statsLeft; - } - } - - // Apply dim to each part separately. statsLeft may contain color codes (for context %) - // that end with a reset, which would clear an outer dim wrapper. So we dim the parts - // before and after the colored section independently. - const dimStatsLeft = theme.fg("dim", statsLeft); - const remainder = statsLine.slice(statsLeft.length); // padding + rightSide - const dimRemainder = theme.fg("dim", remainder); - - const pwdLine = truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "...")); - const lines = [pwdLine, dimStatsLeft + dimRemainder]; - - // Add extension statuses on a single line, sorted by key alphabetically - const extensionStatuses = this.footerData.getExtensionStatuses(); - if (extensionStatuses.size > 0) { - const sortedStatuses = Array.from(extensionStatuses.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([, text]) => sanitizeStatusText(text)); - const statusLine = sortedStatuses.join(" "); - // Truncate to terminal width with dim ellipsis for consistency with footer style - lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "..."))); - } - - return lines; + render(_width: number): string[] { + // Footer is intentionally empty in the prime brand TUI. Telemetry (cost, tokens, model, + // cwd, context %) is hidden by default; bring it back via /stats or --verbose if needed. + return []; } } diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index 97cea6db5..75a85ebdb 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -4,6 +4,13 @@ export { AssistantMessageComponent } from "./assistant-message.js"; export { BashExecutionComponent } from "./bash-execution.js"; export { BorderedLoader } from "./bordered-loader.js"; export { BranchSummaryMessageComponent } from "./branch-summary-message.js"; +export { + ChildAgentDetailComponent, + ChildAgentInspectorComponent, + type ChildAgentInspectorNode, + type ChildAgentStatus, + type ChildAgentTranscriptLine, +} from "./child-agent-inspector.js"; export { CompactionSummaryMessageComponent } from "./compaction-summary-message.js"; export { CustomEditor } from "./custom-editor.js"; export { CustomMessageComponent } from "./custom-message.js"; diff --git a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts index ef65593ed..5b742b0b2 100644 --- a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts +++ b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts @@ -34,9 +34,15 @@ interface TracebackParts { preview: string; } +type CellBackground = "customMessageBg" | "toolPendingBg" | "toolErrorBg"; + const MAGIC_LINE_PATTERN = /^\s*!/; const CELL_MAGIC_PATTERN = /^\s*%%bash\b/; +// Collapse long kernel output to the last N lines with a "M earlier lines hidden" marker. +// Mirrors the bash-execution preview cap so a long-running task doesn't flood the chat. +const OUTPUT_PREVIEW_LINES = 20; + export function getIpythonCodeFromArgs(args: unknown): string { if (!args || typeof args !== "object" || !("code" in args)) { return ""; @@ -118,6 +124,7 @@ function splitTraceback(text: string, errorName: string | undefined): TracebackP } export class IPythonCellComponent implements Component { + private readonly paddingX = 2; private state: IPythonCellState; constructor(state: IPythonCellState) { @@ -137,21 +144,20 @@ export class IPythonCellComponent implements Component { const details = readDetails(this.state.details); const lines: string[] = []; - lines.push(this.rule(safeWidth, this.header(details))); - this.renderCode(lines, safeWidth); - this.renderOutput(lines, safeWidth, details); - lines.push(theme.fg(this.state.isError ? "error" : "borderMuted", "─".repeat(safeWidth))); + lines.push(this.panelLine(this.header(details), safeWidth)); + const hasCode = this.renderCode(lines, safeWidth); + this.renderOutput(lines, safeWidth, details, hasCode); return lines; } private header(details: IpythonDetails): string { const status = this.status(details); const duration = formatDuration(details.durationMs); - const parts = [theme.bold("ipython"), status.label]; + const parts = [status.label]; if (duration) { parts.push(theme.fg("muted", duration)); } - return parts.join(" "); + return parts.join(theme.fg("dim", " · ")); } private status(details: IpythonDetails): { label: string } { @@ -171,34 +177,23 @@ export class IPythonCellComponent implements Component { return { label: theme.fg("success", "done") }; } - private rule(width: number, title: string): string { - if (width <= 1) { - return theme.fg(this.state.isError ? "error" : "borderAccent", "─"); - } - const color = this.state.isError ? "error" : "borderAccent"; - const label = ` ${title} `; - const plainLabel = ` ${stripAnsi(title)} `; - if (visibleWidth(plainLabel) + 1 >= width) { - return theme.fg(color, truncateToWidth(plainLabel, width, "")); - } - const right = "─".repeat(Math.max(0, width - visibleWidth(label) - 1)); - return theme.fg(color, "─") + label + theme.fg(color, right); - } - - private renderCode(lines: string[], width: number): void { + private renderCode(lines: string[], width: number): boolean { const code = this.state.code.trimEnd(); if (!code) { - this.addWrapped(lines, theme.fg("dim", " in "), theme.fg("muted", "(waiting for code)"), width); - return; + this.addBlank(lines, width); + this.addWrapped(lines, "", theme.fg("muted", "waiting for code"), width); + return false; } + this.addBlank(lines, width); const isBashCell = CELL_MAGIC_PATTERN.test(code.split("\n")[0] ?? ""); const rawLines = code.split("\n"); for (const [index, rawLine] of rawLines.entries()) { - const prefix = index === 0 ? theme.fg("dim", " in ") : theme.fg("dim", " "); + const prefix = index === 0 ? theme.fg("dim", "› ") : theme.fg("dim", " "); const highlighted = this.highlightInputLine(rawLine, isBashCell); this.addWrapped(lines, prefix, highlighted || " ", width); } + return true; } private highlightInputLine(line: string, isBashCell: boolean): string { @@ -209,65 +204,115 @@ export class IPythonCellComponent implements Component { return highlighted[0] ?? theme.fg("mdCodeBlock", line); } - private renderOutput(lines: string[], width: number, details: IpythonDetails): void { + private renderOutput(lines: string[], width: number, details: IpythonDetails, hasCode: boolean): void { const blocks = this.state.content ?? []; const text = textFromBlocks(blocks); const imageCount = blocks.filter(isImageBlock).length; const traceback = this.state.isError || details.status === "error" ? splitTraceback(text, details.errorEname) : undefined; + let outputStarted = false; + + const startOutput = (): void => { + if (outputStarted) { + return; + } + outputStarted = true; + if (hasCode) { + this.addBlank(lines, width); + } + }; if (traceback?.output) { + startOutput(); this.renderOutputText(lines, width, traceback.output, "out"); } else if (text.trim()) { + startOutput(); this.renderOutputText(lines, width, normalizeText(text), this.state.isError ? "err" : "out"); } else if (this.state.isPartial || (this.state.executionStarted && !this.state.argsComplete)) { - this.addWrapped(lines, theme.fg("dim", " out "), theme.fg("muted", "waiting for kernel output..."), width); - } else if (this.state.executionStarted) { - this.addWrapped(lines, theme.fg("dim", " out "), theme.fg("muted", "(no output)"), width); + startOutput(); + this.addWrapped(lines, "", theme.fg("muted", "waiting for output..."), width); + } else if (this.state.executionStarted && imageCount === 0) { + startOutput(); + this.addWrapped(lines, "", theme.fg("muted", "no output"), width); } if (traceback) { + startOutput(); if (this.state.expanded) { this.renderTraceback(lines, width, traceback.traceback); } else { - this.addWrapped(lines, theme.fg("dim", " err "), theme.fg("error", traceback.preview), width); + this.addWrapped(lines, "", theme.fg("error", traceback.preview), width); this.addWrapped( lines, - theme.fg("dim", " "), - `${theme.fg("muted", "traceback collapsed")} (${keyHint("app.tools.expand", "to expand")})`, + "", + `${theme.fg("muted", "traceback collapsed")} ${theme.fg("dim", "·")} ${keyHint("app.tools.expand", "to expand")}`, width, ); } } if (imageCount > 0) { + startOutput(); const text = this.state.showImages ? `${imageCount} image${imageCount === 1 ? "" : "s"} rendered below` : `${imageCount} image${imageCount === 1 ? "" : "s"} hidden`; - this.addWrapped(lines, theme.fg("dim", " img "), theme.fg("muted", text), width); + this.addWrapped(lines, "", theme.fg("muted", text), width); } } private renderOutputText(lines: string[], width: number, text: string, label: "out" | "err"): void { const color = label === "err" ? "error" : "toolOutput"; - const prefix = label === "err" ? theme.fg("dim", " err ") : theme.fg("dim", " out "); - for (const [index, line] of text.split("\n").entries()) { - this.addWrapped(lines, index === 0 ? prefix : theme.fg("dim", " "), theme.fg(color, line || " "), width); + const allLines = text.split("\n"); + const expanded = this.state.expanded ?? false; + const showCollapsed = !expanded && allLines.length > OUTPUT_PREVIEW_LINES; + const visibleLines = showCollapsed ? allLines.slice(-OUTPUT_PREVIEW_LINES) : allLines; + + if (showCollapsed) { + const hidden = allLines.length - OUTPUT_PREVIEW_LINES; + const headerHint = `${theme.fg("muted", `${hidden} earlier line${hidden === 1 ? "" : "s"} hidden`)} ${theme.fg("dim", "·")} ${keyHint("app.tools.expand", "to expand")}`; + this.addWrapped(lines, "", headerHint, width); + } + + for (const line of visibleLines) { + this.addWrapped(lines, "", theme.fg(color, line || " "), width); } } private renderTraceback(lines: string[], width: number, traceback: string): void { - for (const [index, line] of traceback.split("\n").entries()) { - const prefix = index === 0 ? theme.fg("dim", " err ") : theme.fg("dim", " "); - this.addWrapped(lines, prefix, theme.fg("error", line || " "), width); + for (const line of traceback.split("\n")) { + this.addWrapped(lines, "", theme.fg("error", line || " "), width); } } private addWrapped(lines: string[], prefix: string, text: string, width: number): void { - const available = Math.max(1, width - visibleWidth(prefix)); + const contentWidth = Math.max(1, width - this.paddingX * 2); + const available = Math.max(1, contentWidth - visibleWidth(prefix)); const wrapped = wrapTextWithAnsi(text, available); - for (const line of wrapped.length > 0 ? wrapped : [""]) { - lines.push(truncateToWidth(prefix + line, width)); + for (const [index, line] of (wrapped.length > 0 ? wrapped : [""]).entries()) { + const linePrefix = index === 0 ? prefix : " ".repeat(visibleWidth(prefix)); + lines.push(this.panelLine(linePrefix + line, width)); + } + } + + private addBlank(lines: string[], width: number): void { + lines.push(this.panelLine("", width)); + } + + private panelLine(line: string, width: number): string { + const contentWidth = Math.max(1, width - this.paddingX * 2); + const truncated = truncateToWidth(line, contentWidth, ""); + const paddedContent = truncated + " ".repeat(Math.max(0, contentWidth - visibleWidth(truncated))); + const padded = `${" ".repeat(this.paddingX)}${paddedContent}${" ".repeat(this.paddingX)}`; + return theme.bg(this.background(), padded); + } + + private background(): CellBackground { + if (this.state.isError || readDetails(this.state.details).status === "error") { + return "toolErrorBg"; + } + if (this.state.isPartial || !this.state.executionStarted) { + return "toolPendingBg"; } + return "customMessageBg"; } } diff --git a/packages/coding-agent/src/modes/interactive/components/sub-agent-tree.ts b/packages/coding-agent/src/modes/interactive/components/sub-agent-tree.ts index 94a295cdf..cdd5efa8a 100644 --- a/packages/coding-agent/src/modes/interactive/components/sub-agent-tree.ts +++ b/packages/coding-agent/src/modes/interactive/components/sub-agent-tree.ts @@ -77,17 +77,9 @@ export class SubAgentTreeComponent implements Component { render(width: number): string[] { const safeWidth = Math.max(1, width); const lines: string[] = []; - lines.push(theme.fg("borderAccent", "─".repeat(safeWidth))); + lines.push(theme.fg("borderMuted", "─".repeat(safeWidth))); - const totalTokens = this.nodes.reduce((total, node) => total + this.tokenRollup(node), 0); - const totalCost = this.nodes.reduce((total, node) => total + this.costRollup(node), 0); const headerParts = [theme.bold(this.rootLabel)]; - if (totalTokens > 0) { - headerParts.push(theme.fg("muted", this.formatTokens(totalTokens))); - } - if (totalCost > 0) { - headerParts.push(theme.fg("muted", `$${totalCost.toFixed(4)}`)); - } lines.push(truncateToWidth(headerParts.join(" "), safeWidth)); if (this.nodes.length === 0) { @@ -116,11 +108,11 @@ export class SubAgentTreeComponent implements Component { } private renderNode(lines: string[], node: SubAgentTreeNode, prefix: string, isLast: boolean, width: number): void { - const connector = `${prefix}${isLast ? "└─ " : "├─ "}`; - const line = connector + this.nodeSummary(node); + const connector = `${prefix}${isLast ? " └─ " : " ├─ "}`; + const line = this.treeEdge(connector) + this.nodeSummary(node); lines.push(truncateToWidth(line, width)); - const childPrefix = `${prefix}${isLast ? " " : "│ "}`; + const childPrefix = `${prefix}${isLast ? " " : " │ "}`; if (this.expandAll || node.expanded || this.expandedIds.has(node.id)) { this.renderTranscript(lines, node, childPrefix, width); } @@ -133,8 +125,9 @@ export class SubAgentTreeComponent implements Component { private renderTranscript(lines: string[], node: SubAgentTreeNode, prefix: string, width: number): void { const transcript = node.transcript ?? []; + const renderedPrefix = this.treeEdge(prefix); if (transcript.length === 0) { - lines.push(truncateToWidth(`${prefix} ${theme.fg("muted", "(transcript unavailable)")}`, width)); + lines.push(truncateToWidth(`${renderedPrefix} ${theme.fg("muted", "(transcript unavailable)")}`, width)); return; } @@ -145,34 +138,25 @@ export class SubAgentTreeComponent implements Component { Math.max(1, width - visibleWidth(prefix) - 3 - visibleWidth(label)), ); for (const [index, line] of wrapped.entries()) { - const entryPrefix = index === 0 ? `${prefix} ${label}` : `${prefix} ${" ".repeat(visibleWidth(label))}`; + const entryPrefix = + index === 0 ? `${renderedPrefix} ${label}` : `${renderedPrefix} ${" ".repeat(visibleWidth(label))}`; lines.push(truncateToWidth(entryPrefix + line, width)); } } } private nodeSummary(node: SubAgentTreeNode): string { - const parts = [this.statusLabel(node.status), theme.bold(node.label)]; + const parts = [this.statusLabel(node.status), theme.fg("dim", node.label)]; const meta: string[] = []; const duration = this.formatDuration(node.durationMs); if (duration) { meta.push(duration); } - const tokenCount = this.tokenRollup(node); - if (tokenCount > 0) { - meta.push(this.formatTokens(tokenCount)); - } else if (node.status === "running") { - meta.push("... tok"); - } - const cost = this.costRollup(node); - if (cost > 0) { - meta.push(`$${cost.toFixed(4)}`); - } if (meta.length > 0) { - parts.push(theme.fg("muted", `(${meta.join(", ")})`)); + parts.push(theme.fg("muted", `· ${meta.join(" · ")}`)); } if (node.answerPreview) { - parts.push(theme.fg("toolOutput", `-> "${node.answerPreview}"`)); + parts.push(theme.fg("toolOutput", `· "${node.answerPreview}"`)); } return parts.join(" "); } @@ -180,15 +164,15 @@ export class SubAgentTreeComponent implements Component { private statusLabel(status: SubAgentStatus): string { switch (status) { case "queued": - return theme.fg("muted", "[queued]"); + return theme.fg("muted", "queued"); case "running": - return theme.fg("bashMode", "[running]"); + return theme.fg("accent", "running"); case "done": - return theme.fg("success", "[done]"); + return theme.fg("success", "done"); case "error": - return theme.fg("error", "[error]"); + return theme.fg("error", "error"); case "cancelled": - return theme.fg("warning", "[cancelled]"); + return theme.fg("warning", "cancelled"); } } @@ -202,22 +186,7 @@ export class SubAgentTreeComponent implements Component { return `${(durationMs / 1000).toFixed(1)}s`; } - private formatTokens(tokens: number): string { - if (tokens >= 1000) { - return `${(tokens / 1000).toFixed(1)}k tok`; - } - return `${tokens} tok`; - } - - private tokenRollup(node: SubAgentTreeNode): number { - const own = node.tokenCount ?? 0; - const children = node.children?.reduce((total, child) => total + this.tokenRollup(child), 0) ?? 0; - return own + children; - } - - private costRollup(node: SubAgentTreeNode): number { - const own = node.costUsd ?? 0; - const children = node.children?.reduce((total, child) => total + this.costRollup(child), 0) ?? 0; - return own + children; + private treeEdge(text: string): string { + return theme.fg("accent", text); } } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f248cb4e7..dc31a544e 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -43,6 +43,7 @@ import { Text, TruncatedText, TUI, + truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; import { spawn, spawnSync } from "child_process"; @@ -56,7 +57,12 @@ import { getShareViewerUrl, VERSION, } from "../../config.js"; -import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js"; +import { + type AgentSession, + type AgentSessionEvent, + parseSkillBlock, + type RlmChildAgentSnapshot, +} from "../../core/agent-session.js"; import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js"; import type { AutocompleteProviderFactory, @@ -81,6 +87,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js"; import type { SourceInfo } from "../../core/source-info.js"; import { isInstallTelemetryEnabled } from "../../core/telemetry.js"; import type { TruncationResult } from "../../core/tools/truncate.js"; +import { PRIME_LOGO_SMALL } from "../../themes/prime-logo.js"; import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js"; import { copyToClipboard } from "../../utils/clipboard.js"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js"; @@ -95,6 +102,12 @@ import { AssistantMessageComponent } from "./components/assistant-message.js"; import { BashExecutionComponent } from "./components/bash-execution.js"; import { BorderedLoader } from "./components/bordered-loader.js"; import { BranchSummaryMessageComponent } from "./components/branch-summary-message.js"; +import { + ChildAgentDetailComponent, + ChildAgentInspectorComponent, + type ChildAgentInspectorNode, + type ChildAgentTranscriptLine, +} from "./components/child-agent-inspector.js"; import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.js"; import { CountdownTimer } from "./components/countdown-timer.js"; import { CustomEditor } from "./components/custom-editor.js"; @@ -160,6 +173,72 @@ class ExpandableText extends Text implements Expandable { } } +function isTerminalImageLine(line: string): boolean { + return line.includes("\x1b_G") || line.includes("\x1b]1337;File="); +} + +class RightSidebarLayoutComponent implements Component { + private readonly preferredSidebarWidth = 34; + private readonly minSidebarWidth = 28; + private readonly minSideBySideWidth = 88; + private readonly gapWidth = 1; + + constructor( + private readonly main: Component, + private readonly sidebar: Component, + private readonly getViewportHeight: () => number, + ) {} + + invalidate(): void { + this.main.invalidate?.(); + this.sidebar.invalidate?.(); + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + const probeSidebarLines = this.sidebar.render(1); + if (probeSidebarLines.length === 0) { + return this.main.render(safeWidth); + } + + if (safeWidth < this.minSideBySideWidth) { + const sidebarHeight = Math.min(8, Math.max(1, this.getViewportHeight())); + const sidebarLines = this.sidebar.render(safeWidth).slice(0, sidebarHeight); + return [...sidebarLines, ...this.main.render(safeWidth)]; + } + + const sidebarWidth = Math.min( + this.preferredSidebarWidth, + Math.max(this.minSidebarWidth, Math.floor(safeWidth * 0.28)), + ); + const mainWidth = Math.max(1, safeWidth - sidebarWidth - this.gapWidth); + const mainLines = this.main.render(mainWidth); + const viewportHeight = Math.max(1, this.getViewportHeight()); + const sidebarLines = this.sidebar.render(sidebarWidth).slice(0, viewportHeight); + const height = Math.max(mainLines.length, viewportHeight); + const sidebarStart = Math.max(0, height - viewportHeight); + const blankSidebarLine = theme.bg("customMessageBg", " ".repeat(sidebarWidth)); + const lines: string[] = []; + + for (let index = 0; index < height; index++) { + const rawMainLine = mainLines[index] ?? ""; + if (isTerminalImageLine(rawMainLine)) { + lines.push(rawMainLine); + continue; + } + const mainLine = truncateToWidth(rawMainLine, mainWidth, ""); + const paddedMainLine = mainLine + " ".repeat(Math.max(0, mainWidth - visibleWidth(mainLine))); + const sidebarLine = + index >= sidebarStart + ? truncateToWidth(sidebarLines[index - sidebarStart] ?? blankSidebarLine, sidebarWidth, "") + : blankSidebarLine; + lines.push(`${paddedMainLine}${" ".repeat(this.gapWidth)}${sidebarLine}`); + } + + return lines; + } +} + type CompactionQueuedMessage = { text: string; mode: "steer" | "followUp"; @@ -238,7 +317,9 @@ export class InteractiveMode { private autocompleteProvider: AutocompleteProvider | undefined; private autocompleteProviderWrappers: AutocompleteProviderFactory[] = []; private fdPath: string | undefined; + private mainContainer: Container; private editorContainer: Container; + private shellLayout: RightSidebarLayoutComponent; private footer: FooterComponent; private footerDataProvider: FooterDataProvider; // Stored so the same manager can be injected into custom editors, selectors, and extension UI. @@ -271,6 +352,14 @@ export class InteractiveMode { // Tool execution tracking: toolCallId -> component private pendingTools = new Map(); + // RLM child-agent inspector, kept as a right sidebar instead of chat-flow content. + private childAgentInspector: ChildAgentInspectorComponent; + private childAgentDetail: ChildAgentDetailComponent; + private childAgentDetailOverlayHandle: OverlayHandle | undefined; + private childAgentSnapshots = new Map(); + private childAgentNodes: ChildAgentInspectorNode[] = []; + private childAgentDetailNodeId: string | undefined; + // Tool output expansion state private toolOutputExpanded = false; @@ -364,6 +453,11 @@ export class InteractiveMode { this.chatContainer = new Container(); this.pendingMessagesContainer = new Container(); this.statusContainer = new Container(); + this.childAgentInspector = new ChildAgentInspectorComponent(() => this.ui.terminal.rows); + this.childAgentInspector.onCancel = () => this.unfocusChildAgentInspector(); + this.childAgentInspector.onOpenDetail = (nodeId) => this.openChildAgentDetail(nodeId); + this.childAgentDetail = new ChildAgentDetailComponent(() => this.ui.terminal.rows); + this.childAgentDetail.onCancel = () => this.closeChildAgentDetail(); this.widgetContainerAbove = new Container(); this.widgetContainerBelow = new Container(); this.keybindings = KeybindingsManager.create(); @@ -375,8 +469,14 @@ export class InteractiveMode { autocompleteMaxVisible, }); this.editor = this.defaultEditor; + this.mainContainer = new Container(); this.editorContainer = new Container(); this.editorContainer.addChild(this.editor as Component); + this.shellLayout = new RightSidebarLayoutComponent( + this.mainContainer, + this.childAgentInspector, + () => this.ui.terminal.rows, + ); this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd()); this.footer = new FooterComponent(this.session, this.footerDataProvider); this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); @@ -573,13 +673,41 @@ export class InteractiveMode { // Add header container as first child this.ui.addChild(this.headerContainer); - // Add header with keybindings from config (unless silenced) - if (this.options.verbose || !this.settingsManager.getQuietStartup()) { - const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`); + // Brand splash: side-panel layout — white butterfly on the left, structured runtime + // metadata on the right. The mark carries the brand; no wordmark, no slogan. + // Cheatsheet behind --verbose. + const logoRaw = PRIME_LOGO_SMALL.split("\n"); + const logoCanvasWidth = logoRaw.reduce((max, line) => Math.max(max, visibleWidth(line)), 0); + const gutter = 4; + const cwdLabel = (() => { + let p = this.sessionManager.getCwd(); + const home = process.env.HOME || process.env.USERPROFILE; + if (home && p.startsWith(home)) p = `~${p.slice(home.length)}`; + return p; + })(); + const modelId = this.session.state.model?.id; + const labelWidth = 9; + const labelled = (label: string, value: string) => + theme.fg("dim", label.padEnd(labelWidth)) + theme.fg("muted", value); + const metaLines = [ + labelled("version", `v${this.version}`), + labelled("model", modelId ?? "—"), + labelled("cwd", cwdLabel), + "", + theme.fg("dim", "type to start"), + ]; + const metaStart = Math.max(0, Math.floor((logoRaw.length - metaLines.length) / 2)); + const composed = logoRaw.map((line, i) => { + const colored = theme.fg("text", line); + const padding = " ".repeat(Math.max(0, logoCanvasWidth - visibleWidth(line) + gutter)); + const meta = i >= metaStart && i < metaStart + metaLines.length ? metaLines[i - metaStart] : ""; + return colored + padding + meta; + }); + const brandBlock = composed.join("\n"); - // Build startup instructions using keybinding hint helpers + if (this.options.verbose) { + // Verbose: include the full keybinding cheatsheet under the brand mark. const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description); - const expandedInstructions = [ hint("app.interrupt", "to interrupt"), hint("app.clear", "to clear"), @@ -601,47 +729,26 @@ export class InteractiveMode { hint("app.clipboard.pasteImage", "to paste image"), rawKeyHint("drop files", "to attach"), ].join("\n"); - const compactInstructions = [ - hint("app.interrupt", "interrupt"), - rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"), - rawKeyHint("/", "commands"), - rawKeyHint("!", "bash"), - hint("app.tools.expand", "more"), - ].join(theme.fg("muted", " · ")); - const compactOnboarding = theme.fg( - "dim", - `Press ${keyText("app.tools.expand")} to show full startup help and loaded resources.`, - ); - const onboarding = theme.fg( - "dim", - `Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.`, - ); - this.builtInHeader = new ExpandableText( - () => `${logo}\n${compactInstructions}\n${compactOnboarding}\n\n${onboarding}`, - () => `${logo}\n${expandedInstructions}\n\n${onboarding}`, - this.getStartupExpansionState(), - 1, - 0, - ); - - // Setup UI layout - this.headerContainer.addChild(new Spacer(1)); - this.headerContainer.addChild(this.builtInHeader); - this.headerContainer.addChild(new Spacer(1)); + const verboseBlock = `${brandBlock}\n\n${expandedInstructions}`; + this.builtInHeader = new Text(verboseBlock, 1, 0); } else { - // Minimal header when silenced - this.builtInHeader = new Text("", 0, 0); - this.headerContainer.addChild(this.builtInHeader); + this.builtInHeader = new Text(brandBlock, 1, 0); } - this.ui.addChild(this.chatContainer); - this.ui.addChild(this.pendingMessagesContainer); - this.ui.addChild(this.statusContainer); + // Setup UI layout + this.headerContainer.addChild(new Spacer(1)); + this.headerContainer.addChild(this.builtInHeader); + this.headerContainer.addChild(new Spacer(1)); + + this.mainContainer.addChild(this.chatContainer); + this.mainContainer.addChild(this.pendingMessagesContainer); + this.mainContainer.addChild(this.statusContainer); this.renderWidgets(); // Initialize with default spacer - this.ui.addChild(this.widgetContainerAbove); - this.ui.addChild(this.editorContainer); - this.ui.addChild(this.widgetContainerBelow); - this.ui.addChild(this.footer); + this.mainContainer.addChild(this.widgetContainerAbove); + this.mainContainer.addChild(this.editorContainer); + this.mainContainer.addChild(this.widgetContainerBelow); + this.mainContainer.addChild(this.footer); + this.ui.addChild(this.shellLayout); this.ui.setFocus(this.editor); this.setupKeyHandlers(); @@ -1583,6 +1690,7 @@ export class InteractiveMode { this.streamingComponent = undefined; this.streamingMessage = undefined; this.pendingTools.clear(); + this.resetChildAgentInspector(); this.renderInitialMessages(); } @@ -2388,6 +2496,7 @@ export class InteractiveMode { this.defaultEditor.onAction("app.model.select", () => this.showModelSelector()); this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion()); this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility()); + this.defaultEditor.onAction("app.subagents.focus", () => this.focusChildAgentInspector()); this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor()); this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp()); this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue()); @@ -2847,7 +2956,7 @@ export class InteractiveMode { : `${event.reason === "overflow" ? "Context overflow detected, " : ""}Auto-compacting... ${cancelHint}`; this.autoCompactionLoader = new Loader( this.ui, - (spinner) => theme.fg("accent", spinner), + (spinner) => theme.fg("muted", spinner), (text) => theme.fg("muted", text), label, ); @@ -2912,7 +3021,7 @@ export class InteractiveMode { `Retrying (${event.attempt}/${event.maxAttempts}) in ${seconds}s... (${keyText("app.interrupt")} to cancel)`; this.retryLoader = new Loader( this.ui, - (spinner) => theme.fg("warning", spinner), + (spinner) => theme.fg("muted", spinner), (text) => theme.fg("muted", text), retryMessage(Math.ceil(event.delayMs / 1000)), ); @@ -2954,9 +3063,120 @@ export class InteractiveMode { this.ui.requestRender(); break; } + + case "rlm_child_update": + this.updateChildAgentInspector(event.child); + break; } } + private updateChildAgentInspector(child: RlmChildAgentSnapshot): void { + this.childAgentSnapshots.set(child.id, child); + this.childAgentNodes = this.buildChildAgentInspectorNodes(); + this.childAgentInspector.setNodes(this.childAgentNodes); + if (this.childAgentDetailNodeId) { + this.childAgentDetail.setNode(this.findChildAgentInspectorNode(this.childAgentDetailNodeId)); + } + this.ui.requestRender(); + } + + private resetChildAgentInspector(): void { + this.childAgentSnapshots.clear(); + this.childAgentNodes = []; + this.childAgentInspector.setNodes([]); + this.childAgentDetail.setNode(undefined); + this.childAgentDetailNodeId = undefined; + this.childAgentDetailOverlayHandle?.hide(); + this.childAgentDetailOverlayHandle = undefined; + } + + private openChildAgentDetail(nodeId: string): void { + const node = this.findChildAgentInspectorNode(nodeId); + if (!node) { + return; + } + this.childAgentDetailNodeId = nodeId; + this.childAgentDetail.setNode(node); + this.childAgentDetailOverlayHandle?.hide(); + this.childAgentDetailOverlayHandle = this.ui.showOverlay(this.childAgentDetail, { + width: "100%", + maxHeight: "100%", + anchor: "top-left", + margin: 0, + }); + this.ui.requestRender(); + } + + private focusChildAgentInspector(): void { + if (this.childAgentSnapshots.size === 0) { + return; + } + this.ui.setFocus(this.childAgentInspector); + this.ui.requestRender(); + } + + private unfocusChildAgentInspector(): void { + this.closeChildAgentDetail(false); + this.ui.setFocus(this.editor); + this.ui.requestRender(); + } + + private closeChildAgentDetail(focusInspector = true): void { + this.childAgentDetailOverlayHandle?.hide(); + this.childAgentDetailOverlayHandle = undefined; + this.childAgentDetailNodeId = undefined; + this.childAgentDetail.setNode(undefined); + if (focusInspector && this.childAgentSnapshots.size > 0) { + this.ui.setFocus(this.childAgentInspector); + } else { + this.ui.setFocus(this.editor); + } + this.ui.requestRender(); + } + + private buildChildAgentInspectorNodes(): ChildAgentInspectorNode[] { + const childrenByParent = new Map(); + for (const child of this.childAgentSnapshots.values()) { + const siblings = childrenByParent.get(child.parentId) ?? []; + siblings.push(child); + childrenByParent.set(child.parentId, siblings); + } + + const build = (child: RlmChildAgentSnapshot): ChildAgentInspectorNode => ({ + id: child.id, + label: child.label, + status: child.status, + durationMs: child.durationMs, + answerPreview: child.answerPreview, + sessionDir: child.sessionDir, + transcript: child.transcript.map( + (line): ChildAgentTranscriptLine => ({ + role: line.role, + text: line.text, + }), + ), + children: childrenByParent.get(child.id)?.map(build), + }); + + return (childrenByParent.get(undefined) ?? []).map(build); + } + + private findChildAgentInspectorNode(nodeId: string): ChildAgentInspectorNode | undefined { + const visit = (nodes: readonly ChildAgentInspectorNode[]): ChildAgentInspectorNode | undefined => { + for (const node of nodes) { + if (node.id === nodeId) { + return node; + } + const child = visit(node.children ?? []); + if (child) { + return child; + } + } + return undefined; + }; + return visit(this.childAgentNodes); + } + /** Extract text content from a user message */ private getUserMessageText(message: Message): string { if (message.role !== "user") return ""; @@ -3368,11 +3588,13 @@ export class InteractiveMode { } private updateEditorBorderColor(): void { + // Bash mode: green border. Otherwise: always white. The thinking-level color ramp lived + // here in pi-mono, but the prime brand reserves indigo for accent moments — the input + // frame stays neutral so the focus indicator doesn't fight the splash. if (this.isBashMode) { this.editor.borderColor = theme.getBashModeBorderColor(); } else { - const level = this.session.thinkingLevel || "off"; - this.editor.borderColor = theme.getThinkingBorderColor(level); + this.editor.borderColor = (str: string) => theme.fg("text", str); } this.ui.requestRender(); } @@ -3768,7 +3990,7 @@ export class InteractiveMode { transport: this.settingsManager.getTransport(), thinkingLevel: this.session.thinkingLevel, availableThinkingLevels: this.session.getAvailableThinkingLevels(), - currentTheme: this.settingsManager.getTheme() || "dark", + currentTheme: this.settingsManager.getTheme() || "prime", availableThemes: getAvailableThemes(), hideThinkingBlock: this.hideThinkingBlock, collapseChangelog: this.settingsManager.getCollapseChangelog(), @@ -4232,7 +4454,7 @@ export class InteractiveMode { this.chatContainer.addChild(new Spacer(1)); summaryLoader = new Loader( this.ui, - (spinner) => theme.fg("accent", spinner), + (spinner) => theme.fg("muted", spinner), (text) => theme.fg("muted", text), `Summarizing branch... (${keyText("app.interrupt")} to cancel)`, ); diff --git a/packages/coding-agent/src/modes/interactive/theme/prime.json b/packages/coding-agent/src/modes/interactive/theme/prime.json new file mode 100644 index 000000000..b37a2d615 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/theme/prime.json @@ -0,0 +1,92 @@ +{ + "$schema": "./theme-schema.json", + "name": "prime", + "vars": { + "bg": "#050506", + "surface": "#0d0d10", + "panel": "#151518", + "fg": "#f4f4f5", + "muted": "#a1a1aa", + "dim": "#71717a", + "grid": "#52525b", + "primary": "#6409D0", + "primarySoft": "#9162e6", + "success": "#84cc16", + "warning": "#f59e0b", + "error": "#ff2d55", + "info": "#38bdf8", + "infoDim": "#1f6f99", + "neutral": "#d4d4d8", + "stringMint": "#a3be8c", + "selectedBg": "#222226", + "userMsgBg": "#0d0d10", + "customMsgBg": "#151518", + "toolPendingBg": "#101015", + "toolSuccessBg": "#0c1610", + "toolErrorBg": "#1a0d12" + }, + "colors": { + "accent": "primary", + "border": "primary", + "borderAccent": "primary", + "borderMuted": "grid", + "success": "success", + "error": "error", + "warning": "warning", + "muted": "muted", + "dim": "dim", + "text": "", + "thinkingText": "muted", + + "selectedBg": "selectedBg", + "userMessageBg": "", + "userMessageText": "", + "customMessageBg": "customMsgBg", + "customMessageText": "", + "customMessageLabel": "warning", + "toolPendingBg": "toolPendingBg", + "toolSuccessBg": "toolSuccessBg", + "toolErrorBg": "toolErrorBg", + "toolTitle": "", + "toolOutput": "muted", + + "mdHeading": "warning", + "mdLink": "info", + "mdLinkUrl": "dim", + "mdCode": "neutral", + "mdCodeBlock": "stringMint", + "mdCodeBlockBorder": "grid", + "mdQuote": "muted", + "mdQuoteBorder": "grid", + "mdHr": "grid", + "mdListBullet": "muted", + + "toolDiffAdded": "success", + "toolDiffRemoved": "error", + "toolDiffContext": "muted", + + "syntaxComment": "muted", + "syntaxKeyword": "info", + "syntaxFunction": "info", + "syntaxVariable": "neutral", + "syntaxString": "stringMint", + "syntaxNumber": "warning", + "syntaxType": "warning", + "syntaxOperator": "neutral", + "syntaxPunctuation": "neutral", + + "thinkingOff": "fg", + "thinkingMinimal": "muted", + "thinkingLow": "info", + "thinkingMedium": "primarySoft", + "thinkingHigh": "primary", + "thinkingXhigh": "primary", + + "bashMode": "success" + }, + "export": { + "pageBg": "bg", + "cardBg": "surface", + "infoBg": "panel" + } +} diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 955fa44a7..465b25b9a 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -450,9 +450,11 @@ let BUILTIN_THEMES: Record | undefined; function getBuiltinThemes(): Record { if (!BUILTIN_THEMES) { const themesDir = getThemesDir(); + const primePath = path.join(themesDir, "prime.json"); const darkPath = path.join(themesDir, "dark.json"); const lightPath = path.join(themesDir, "light.json"); BUILTIN_THEMES = { + prime: JSON.parse(fs.readFileSync(primePath, "utf-8")) as ThemeJson, dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")) as ThemeJson, light: JSON.parse(fs.readFileSync(lightPath, "utf-8")) as ThemeJson, }; @@ -649,7 +651,8 @@ function detectTerminalBackground(): "dark" | "light" { } function getDefaultTheme(): string { - return detectTerminalBackground(); + // Prime brand is dark-first; only fall back to light when the terminal is light. + return detectTerminalBackground() === "light" ? "light" : "prime"; } // ============================================================================ @@ -744,7 +747,12 @@ function startThemeWatcher(): void { stopThemeWatcher(); // Only watch if it's a custom theme (not built-in) - if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") { + if ( + !currentThemeName || + currentThemeName === "prime" || + currentThemeName === "dark" || + currentThemeName === "light" + ) { return; } diff --git a/packages/coding-agent/src/themes/prime-logo.ts b/packages/coding-agent/src/themes/prime-logo.ts new file mode 100644 index 000000000..e8fbbeaa0 --- /dev/null +++ b/packages/coding-agent/src/themes/prime-logo.ts @@ -0,0 +1,40 @@ +/** + * Pre-rendered ASCII versions of the Prime butterfly mark. + * + * Source: assets/brand/prime-butterfly.svg + * Re-render at any width: `uv run scripts/render-logo.py --width N` + */ + +/** ~10 rows × 32 cols. The default brand mark — half-block butterfly, splash-ready. */ +export const PRIME_LOGO_SMALL = ` ▄▄███▀ + ▄▄▄▄▄ ▄█████▀ + ██████▄ ▄██████▀ + ▄███▀███▄ ▄███▀▄██▀ + ███ ▄████▄▄▄████▀▄▄██ + ▀██ ▀█████████▀▀▀▀▀▀ + ▄██ ██████▀▀ ▄███ + █████ ▀█▄▄▄█████▀ +███████▄ ████████▀ +▀███▀▀ █████▀`; + +/** ~20 rows × 60 cols. Welcome screen / first-run greet. */ +export const PRIME_LOGO_MEDIUM = ` ▄▄▄▄ + ▄▄███████▀ + ▄▄█████████▀ + ▄████▄▄▄ ▄███████████▀ + ██████████▄ ▄▄███████████▀ + ████████████ ▄▄████▀▀██████▀ + ▄██████▀██████ ▄▄█████▀ ██████▀ + ▄██████ ███████ ▄▄██████▀ ▄▄████▀ + ██████ ▄████████ ▄▄██████▀▀ ▀▀▀▀▀▀ + █████▀ █████████▄▄▄▄████████▀ ▄█████▄ + █████▀ ████████████████████▄▄▄▄█████▀ + ▀▀███ ▀███████████████▀▀ ▄▄ + ▄▄██ ▄█████████████▀ ▄▄████ + ▄██████ ▀█▀███████▀▀ ▄▄███████▀ + █████████ ▀██▀ ▄▄▄███████████▀ + ███████████▄ ▄████████████████▀ +██████████████ ███████████████▀▀ +████████████▀▀ █████████████▀▀ +▀███████▀▀ █████████▀▀ + ▀▀▀▀ ▀▀▀▀`; diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index e770dd3f0..ab2c9e6ba 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -164,6 +164,17 @@ describe("AgentSession rlm recursion", () => { it("runs a child session under a sub directory and returns an RLM-shaped result", async () => { const root = createSession(); + const childUpdates: Array<{ + status: string; + label: string; + answerPreview?: string; + transcript: readonly { role: string; text: string }[]; + }> = []; + root.subscribe((event) => { + if (event.type === "rlm_child_update") { + childUpdates.push(event.child); + } + }); const result = await root.runRlmChild("summarize shard 1"); @@ -174,6 +185,12 @@ describe("AgentSession rlm recursion", () => { expect(basename(result.session_dir!)).toMatch(/^sub-/); expect(existsSync(result.session_dir!)).toBe(true); expect(readdirSync(result.session_dir!).some((name) => name.endsWith(".jsonl"))).toBe(true); + expect(childUpdates[0]?.status).toBe("running"); + expect(childUpdates[0]?.label).toBe("summarize shard 1"); + const doneUpdate = [...childUpdates].reverse().find((update) => update.status === "done"); + expect(doneUpdate?.answerPreview).toBe("child answer: summarize shard 1"); + expect(doneUpdate?.transcript).toContainEqual({ role: "user", text: "summarize shard 1" }); + expect(doneUpdate?.transcript).toContainEqual({ role: "assistant", text: "child answer: summarize shard 1" }); }); it("adds child usage to the parent session aggregate", async () => { diff --git a/packages/coding-agent/test/marquee-components.test.ts b/packages/coding-agent/test/marquee-components.test.ts index 23045b069..389285def 100644 --- a/packages/coding-agent/test/marquee-components.test.ts +++ b/packages/coding-agent/test/marquee-components.test.ts @@ -2,6 +2,11 @@ import { type Component, TUI, visibleWidth } from "@earendil-works/pi-tui"; import stripAnsi from "strip-ansi"; import { beforeAll, describe, expect, test } from "vitest"; import { VirtualTerminal } from "../../tui/test/virtual-terminal.js"; +import { + ChildAgentDetailComponent, + ChildAgentInspectorComponent, + type ChildAgentInspectorNode, +} from "../src/modes/interactive/components/child-agent-inspector.js"; import { IPythonCellComponent, type IPythonCellState } from "../src/modes/interactive/components/ipython-cell.js"; import { SubAgentTreeComponent } from "../src/modes/interactive/components/sub-agent-tree.js"; import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js"; @@ -60,7 +65,8 @@ describe("marquee TUI components", () => { const component = new IPythonCellComponent(state); const collapsed = await renderInVirtualTerminal(component); - expect(collapsed).toContain("ipython error 1.2s"); + expect(collapsed).toContain("error · 1.2s"); + expect(collapsed).not.toContain("ipython"); expect(collapsed).toContain("%%bash"); expect(collapsed).toContain("hi"); expect(collapsed).toContain("ValueError: bad"); @@ -77,7 +83,7 @@ describe("marquee TUI components", () => { } }); - test("renders sub-agent tree nodes with status, costs, previews, and transcript expansion", async () => { + test("renders sub-agent tree nodes with status, previews, and transcript expansion", async () => { const component = new SubAgentTreeComponent({ rootLabel: "root: triage logs", nodes: [ @@ -87,6 +93,7 @@ describe("marquee TUI components", () => { status: "done", durationMs: 12300, tokenCount: 4200, + costUsd: 0.25, answerPreview: "no anomaly, normal loss curve", transcript: [{ role: "assistant", text: "no anomaly found in this shard" }], }, @@ -108,10 +115,12 @@ describe("marquee TUI components", () => { }); const collapsed = await renderInVirtualTerminal(component); - expect(collapsed).toContain("root: triage logs 8.9k tok"); - expect(collapsed).toContain('[done] shard-0 (12.3s, 4.2k tok) -> "no anomaly, normal loss curve"'); - expect(collapsed).toContain("[running] shard-1 (7.8s, ... tok)"); - expect(collapsed).toContain('[done] shard-2 (11.1s, 4.7k tok) -> "NaN at step 2103"'); + expect(collapsed).toContain("root: triage logs"); + expect(collapsed).toContain(' ├─ done shard-0 · 12.3s · "no anomaly, normal loss curve"'); + expect(collapsed).toContain(" ├─ running shard-1 · 7.8s"); + expect(collapsed).toContain(' └─ done shard-2 · 11.1s · "NaN at step 2103"'); + expect(collapsed).not.toContain("tok"); + expect(collapsed).not.toContain("$0.25"); expect(collapsed).not.toContain("no anomaly found in this shard"); component.setExpanded("shard-0", true); @@ -119,6 +128,74 @@ describe("marquee TUI components", () => { expect(expanded).toContain("assistant: no anomaly found in this shard"); }); + test("renders child agent inspector as a sidebar with full-screen detail handoff", () => { + const component = new ChildAgentInspectorComponent(); + const node: ChildAgentInspectorNode = { + id: "sub-a", + label: "inspect training logs", + status: "running", + sessionDir: "/tmp/session/sub-a", + transcript: [ + { role: "user", text: "inspect training logs" }, + { role: "assistant", text: "reading shard metrics" }, + ], + children: [ + { + id: "sub-b", + label: "check shard 2", + status: "done", + sessionDir: "/tmp/session/sub-b", + transcript: [{ role: "assistant", text: "no anomaly" }], + }, + ], + }; + component.setNodes([node]); + + const compact = stripAnsi(component.render(42).join("\n")); + expect(compact).toContain(" agents"); + expect(compact).toContain("1/2 running"); + expect(compact).toContain("running · inspect training logs"); + expect(compact).toContain("done · check shard 2"); + expect(compact).not.toContain("└─"); + expect(compact).not.toContain("├─"); + expect(compact).not.toContain("assistant: reading shard metrics"); + + component.focused = true; + const focused = stripAnsi(component.render(42).join("\n")); + expect(focused).toContain("▌ running · inspect training logs"); + expect(focused).not.toContain("assistant: reading shard metrics"); + + let openedNodeId: string | undefined; + component.onOpenDetail = (nodeId) => { + openedNodeId = nodeId; + }; + component.handleInput("\r"); + expect(openedNodeId).toBe("sub-a"); + expect(stripAnsi(component.render(42).join("\n"))).not.toContain("assistant: reading shard metrics"); + + const detailComponent = new ChildAgentDetailComponent(() => 12); + detailComponent.setNode(node); + const detailLines = detailComponent.render(42); + const detail = stripAnsi(detailLines.join("\n")); + expect(detail).toContain("running inspect training logs"); + expect(detail).toContain("sub-a"); + expect(detail).toContain("user: inspect training logs"); + expect(detail).toContain("assistant: reading shard metrics"); + expect(detailLines).toHaveLength(12); + + component.handleInput("\x1b"); + const returned = stripAnsi(component.render(42).join("\n")); + expect(returned).toContain("▌ running · inspect training logs"); + expect(returned).not.toContain("assistant: reading shard metrics"); + + for (const line of component.render(42)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(42); + } + + const narrow = stripAnsi(component.render(24).join("\n")); + expect(narrow).toContain("inspect…"); + }); + test("routes built-in ipython tool rows through the cell renderer", () => { const component = new ToolExecutionComponent( "ipython", @@ -138,7 +215,8 @@ describe("marquee TUI components", () => { }); const output = stripAnsi(component.render(100).join("\n")); - expect(output).toContain("ipython done 12ms"); + expect(output).toContain("done · 12ms"); + expect(output).not.toContain("ipython"); expect(output).toContain("print(55)"); expect(output).toContain("55"); expect(output).not.toContain('"code"'); diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 7bdde68f4..b70746db6 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -332,21 +332,7 @@ export class Markdown implements Component { } case "code": { - const indent = this.theme.codeBlockIndent ?? " "; - lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); - if (this.theme.highlightCode) { - const highlightedLines = this.theme.highlightCode(token.text, token.lang); - for (const hlLine of highlightedLines) { - lines.push(`${indent}${hlLine}`); - } - } else { - // Split code by newlines and style each line - const codeLines = token.text.split("\n"); - for (const codeLine of codeLines) { - lines.push(`${indent}${this.theme.codeBlock(codeLine)}`); - } - } - lines.push(this.theme.codeBlockBorder("```")); + lines.push(...this.renderCodeBlock(token)); if (nextTokenType && nextTokenType !== "space") { lines.push(""); // Add spacing after code blocks (unless space token follows) } @@ -621,20 +607,7 @@ export class Markdown implements Component { lines.push(text); } else if (token.type === "code") { // Code block in list item - const indent = this.theme.codeBlockIndent ?? " "; - lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); - if (this.theme.highlightCode) { - const highlightedLines = this.theme.highlightCode(token.text, token.lang); - for (const hlLine of highlightedLines) { - lines.push(`${indent}${hlLine}`); - } - } else { - const codeLines = token.text.split("\n"); - for (const codeLine of codeLines) { - lines.push(`${indent}${this.theme.codeBlock(codeLine)}`); - } - } - lines.push(this.theme.codeBlockBorder("```")); + lines.push(...this.renderCodeBlock(token)); } else { // Other token types - try to render as inline const text = this.renderInlineTokens([token], styleContext); @@ -647,6 +620,21 @@ export class Markdown implements Component { return lines; } + private renderCodeBlock(token: Token): string[] { + if (!("text" in token) || typeof token.text !== "string") { + return []; + } + + const indent = this.theme.codeBlockIndent ?? " "; + const lang = "lang" in token && typeof token.lang === "string" ? token.lang : undefined; + const renderedCodeLines = this.theme.highlightCode + ? this.theme.highlightCode(token.text, lang) + : token.text.split("\n").map((codeLine) => this.theme.codeBlock(codeLine)); + const codeLines = renderedCodeLines.length > 0 ? renderedCodeLines : [this.theme.codeBlock("")]; + + return codeLines.map((codeLine) => `${indent}${codeLine}`); + } + /** * Get the visible width of the longest word in a string. */ diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 029a614e5..7e7db9c0d 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -623,19 +623,27 @@ again, hello world`, const lines = markdown.render(80); const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); - const closingBackticksIndex = plainLines.indexOf("```"); - assert.ok(closingBackticksIndex !== -1, "Should have closing backticks"); + const codeBlockIndex = plainLines.indexOf(' const hello = "world";'); + assert.ok(codeBlockIndex !== -1, "Should have code block content"); - const afterBackticks = plainLines.slice(closingBackticksIndex + 1); - const emptyLineCount = afterBackticks.findIndex((line) => line !== ""); + const afterCodeBlock = plainLines.slice(codeBlockIndex + 1); + const emptyLineCount = afterCodeBlock.findIndex((line) => line !== ""); assert.strictEqual( emptyLineCount, 1, - `Expected 1 empty line after code block, but found ${emptyLineCount}. Lines after backticks: ${JSON.stringify(afterBackticks.slice(0, 5))}`, + `Expected 1 empty line after code block, but found ${emptyLineCount}. Lines after code block: ${JSON.stringify(afterCodeBlock.slice(0, 5))}`, ); }); + it("should not render code fence markers or language labels", () => { + const markdown = new Markdown("```python\nprint('hello')\n```", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + assert.deepStrictEqual(plainLines, [" print('hello')"]); + }); + it("should normalize paragraph and code block spacing to one blank line", () => { const cases = [ `hello this is text @@ -651,7 +659,7 @@ code block more text`, ]; - const expectedLines = ["hello this is text", "", "```", " code block", "```", "", "more text"]; + const expectedLines = ["hello this is text", "", " code block", "", "more text"]; for (const text of cases) { const markdown = new Markdown(text, 0, 0, defaultMarkdownTheme); diff --git a/scripts/render-logo.py b/scripts/render-logo.py new file mode 100755 index 000000000..c12a9910d --- /dev/null +++ b/scripts/render-logo.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Render the Prime butterfly SVG to ASCII via half-block encoding. + +Usage: + uv run scripts/render-logo.py [--width 60] [--threshold 96] [--svg path] + +Outputs the rendered art to stdout. + +Requires `cairo` on the system (brew install cairo). Uses cairosvg + pillow, +both pulled via uv's --with on each invocation so there's no committed venv. +""" + +# /// script +# requires-python = ">=3.10" +# dependencies = ["cairosvg", "pillow"] +# /// + +from __future__ import annotations + +import argparse +import io +import os +import sys +from pathlib import Path + + +def render(svg_path: Path, width: int, threshold: int, style: str) -> str: + # Late imports so --help works without the deps installed. + import cairosvg + from PIL import Image + + if style == "dots": + # 1 pixel = 1 character cell. Compensate for terminal cells being ~2x taller than wide + # so the rendered shape keeps the original proportions. + # Use ▪ (BLACK SMALL SQUARE) so adjacent cells read as a tightly packed dot matrix — + # mid-dot · is too small and reads as "spaced out" against the cell. + png_bytes = cairosvg.svg2png(url=str(svg_path), output_width=width * 2) + img = Image.open(io.BytesIO(png_bytes)).convert("RGBA") + bg = Image.new("RGBA", img.size, (0, 0, 0, 255)) + bg.paste(img, (0, 0), img) + img = bg.convert("L") + target_h = max(1, img.size[1] // 4) + img = img.resize((width, target_h), Image.LANCZOS) + px = img.load() + rows: list[str] = [] + for y in range(target_h): + row = "".join("▪" if px[x, y] > threshold else " " for x in range(width)) + rows.append(row.rstrip()) + else: + png_bytes = cairosvg.svg2png(url=str(svg_path), output_width=width) + img = Image.open(io.BytesIO(png_bytes)).convert("RGBA") + bg = Image.new("RGBA", img.size, (0, 0, 0, 255)) + bg.paste(img, (0, 0), img) + img = bg.convert("L") + w, h = img.size + if h % 2: + padded = Image.new("L", (w, h + 1), 0) + padded.paste(img, (0, 0)) + img = padded + h += 1 + px = img.load() + table = {(False, False): " ", (True, False): "▀", (False, True): "▄", (True, True): "█"} + rows = [] + for y in range(0, h, 2): + rows.append( + "".join( + table[(px[x, y] > threshold, px[x, y + 1] > threshold)] for x in range(w) + ).rstrip() + ) + + while rows and not rows[0].strip(): + rows.pop(0) + while rows and not rows[-1].strip(): + rows.pop() + if rows: + left = min(len(r) - len(r.lstrip(" ")) for r in rows if r.strip()) + rows = [r[left:] for r in rows] + return "\n".join(rows) + + +def main() -> int: + repo_root = Path(__file__).resolve().parent.parent + default_svg = repo_root / "assets" / "brand" / "prime-butterfly.svg" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--width", type=int, default=60, help="output columns (default: 60)") + parser.add_argument( + "--threshold", + type=int, + default=96, + help="luminance cutoff 0-255; raise for thinner strokes (default: 96)", + ) + parser.add_argument( + "--svg", type=Path, default=default_svg, help=f"input SVG (default: {default_svg})" + ) + parser.add_argument( + "--style", + choices=("blocks", "dots"), + default="blocks", + help="blocks = half-block ▀▄█; dots = mid-dot · matrix (default: blocks)", + ) + args = parser.parse_args() + + if not args.svg.exists(): + print(f"svg not found: {args.svg}", file=sys.stderr) + return 1 + + # Set DYLD_FALLBACK_LIBRARY_PATH for homebrew cairo on Apple Silicon. + if sys.platform == "darwin" and "DYLD_FALLBACK_LIBRARY_PATH" not in os.environ: + os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = "/opt/homebrew/lib:/usr/local/lib" + + print(render(args.svg, args.width, args.threshold, args.style)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())