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())