Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,11 @@ export class AgentManager {
},
onSessionCreated: (session) => {
record.session = session;
record.invocation ??= {};
record.invocation.modelName = session.model
? `${session.model.provider}/${session.model.id}`
: undefined;
record.invocation.thinking = session.thinkingLevel;
// Flush any steers that arrived before the session was ready
if (record.pendingSteers?.length) {
for (const msg of record.pendingSteers) {
Expand Down
57 changes: 45 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
formatTurns,
getDisplayName,
getPromptModeLabel,
prepareModelNameForDisplay,
SPINNER,
type Theme,
type UICtx,
Expand Down Expand Up @@ -179,6 +180,16 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number): stri
].filter(Boolean).join('\n');
}

/** Read current runtime model/thinking, falling back to the pre-session invocation snapshot. */
function getRuntimeInvocation(record: Pick<AgentRecord, "invocation" | "session"> | undefined): AgentInvocation | undefined {
if (!record?.session?.model) return record?.invocation;
return {
...record.invocation,
modelName: `${record.session.model.provider}/${record.session.model.id}`,
thinking: record.session.thinkingLevel,
};
}

/** Build AgentDetails from a base + record-specific fields. */
function buildDetails(
base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
Expand Down Expand Up @@ -976,10 +987,11 @@ Terse command-style prompts produce shallow, generic work.
return new Text(text, 0, 0);
}

// Helper: build "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string
// Helper: build "model · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string
const stats = (d: AgentDetails) => {
const parts: string[] = [];
if (d.modelName) parts.push(d.modelName);
const modelName = prepareModelNameForDisplay(d.modelName);
if (modelName) parts.push(modelName);
if (d.tags) parts.push(...d.tags);
if (d.turnCount != null && d.turnCount > 0) {
parts.push(formatTurns(d.turnCount, d.maxTurns));
Expand All @@ -998,7 +1010,9 @@ Terse command-style prompts produce shallow, generic work.

// ---- Background agent launched ----
if (details.status === "background") {
return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`), 0, 0);
const s = stats(details);
const line = (s ? `${s}\n` : "") + theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`);
return new Text(line, 0, 0);
}

// ---- Completed / Steered ----
Expand Down Expand Up @@ -1134,11 +1148,7 @@ Terse command-style prompts produce shallow, generic work.
writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
};

const parentModelId = ctx.model?.id;
const effectiveModelId = model?.id;
const modelName = effectiveModelId && effectiveModelId !== parentModelId
? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
: undefined;
const modelName = model ? `${model.provider}/${model.id}` : undefined;
const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
const agentInvocation: AgentInvocation = {
modelName,
Expand Down Expand Up @@ -1219,14 +1229,22 @@ Terse command-style prompts produce shallow, generic work.
if (!record) {
return textResult(`Failed to resume agent "${params.resume}".`);
}
const resumedInvocation = buildInvocationTags(getRuntimeInvocation(record));
const resumedDetails = {
displayName: getDisplayName(record.type),
description: record.description,
subagentType: record.type,
modelName: resumedInvocation.modelName,
tags: resumedInvocation.tags.length > 0 ? resumedInvocation.tags : undefined,
};
// A failed resume surfaces the error, plus any partial output THIS
// resume produced (never the previous turn's answer, #144).
if (record.status === "error") {
return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBase, record));
return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(resumedDetails, record));
}
return textResult(
record.result?.trim() || "No output.",
buildDetails(detailBase, record),
buildDetails(resumedDetails, record),
);
}

Expand Down Expand Up @@ -1301,6 +1319,12 @@ Terse command-style prompts produce shallow, generic work.
});

const isQueued = record?.status === "queued";
const runtimeInvocation = buildInvocationTags(getRuntimeInvocation(record));
const backgroundDetails = {
...detailBase,
modelName: runtimeInvocation.modelName ?? detailBase.modelName,
tags: runtimeInvocation.tags.length > 0 ? runtimeInvocation.tags : detailBase.tags,
};
return textResult(
`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
`Agent ID: ${id}\n` +
Expand All @@ -1311,7 +1335,7 @@ Terse command-style prompts produce shallow, generic work.
`\nYou will be notified when this agent completes.\n` +
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
`Do not duplicate this agent's work.`,
{ ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
{ ...backgroundDetails, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
);
}

Expand All @@ -1321,8 +1345,11 @@ Terse command-style prompts produce shallow, generic work.
let fgId: string | undefined;

const streamUpdate = () => {
const runtimeInvocation = buildInvocationTags(getRuntimeInvocation(fgId ? manager.getRecord(fgId) : undefined));
const details: AgentDetails = {
...detailBase,
modelName: runtimeInvocation.modelName ?? detailBase.modelName,
tags: runtimeInvocation.tags.length > 0 ? runtimeInvocation.tags : detailBase.tags,
toolUses: fgState.toolUses,
tokens: formatLifetimeTokens(fgState),
turnCount: fgState.turnCount,
Expand Down Expand Up @@ -1411,7 +1438,13 @@ Terse command-style prompts produce shallow, generic work.
// Get final token count
const tokenText = formatLifetimeTokens(fgState);

const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
const runtimeInvocation = buildInvocationTags(getRuntimeInvocation(record));
const runtimeDetails = {
...detailBase,
modelName: runtimeInvocation.modelName ?? detailBase.modelName,
tags: runtimeInvocation.tags.length > 0 ? runtimeInvocation.tags : detailBase.tags,
};
const details = buildDetails(runtimeDetails, record, fgState, { tokens: tokenText });

if (record.status === "error") {
// Error headline + any partial output the run produced before failing.
Expand Down
8 changes: 8 additions & 0 deletions src/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,14 @@ export class SubagentScheduler {
isolated: job.isolated,
thinkingLevel: job.thinking,
isolation: job.isolation,
invocation: {
modelName: resolvedModel ? `${resolvedModel.provider}/${resolvedModel.id}` : undefined,
thinking: job.thinking,
maxTurns: job.max_turns,
isolated: job.isolated,
runInBackground: true,
isolation: job.isolation,
},
});
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
Expand Down
5 changes: 3 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,10 @@ export interface AgentRecord {
}

export interface AgentInvocation {
/** Short display name, e.g. "haiku" — only set when different from parent. */
/** Canonical runtime model identifier (`provider/modelId`). */
modelName?: string;
thinking?: ThinkingLevel;
/** Effective runtime thinking level after Pi applies defaults and model clamping. */
thinking?: string;
maxTurns?: number;
isolated?: boolean;
inheritContext?: boolean;
Expand Down
86 changes: 72 additions & 14 deletions src/ui/agent-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Uses the callback form of setWidget for themed rendering.
*/

import { stripVTControlCharacters } from "node:util";
import { truncateToWidth } from "@earendil-works/pi-tui";
import type { AgentManager } from "../agent-manager.js";
import { getConfig } from "../agent-types.js";
Expand Down Expand Up @@ -76,7 +77,7 @@ export interface AgentDetails {
activity?: string;
/** Current spinner frame index (for animated running indicator). */
spinnerFrame?: number;
/** Short model name if different from parent (e.g. "haiku", "sonnet"). */
/** Effective model display name used by this run. */
modelName?: string;
/** Notable config tags (e.g. ["thinking: high", "isolated"]). */
tags?: string[];
Expand Down Expand Up @@ -160,6 +161,12 @@ export function getPromptModeLabel(type: SubagentType): string | undefined {
return config.promptMode === "append" ? "twin" : undefined;
}

/** Make a model name safe for single-line terminal display. */
export function prepareModelNameForDisplay(modelName: string | undefined): string | undefined {
if (!modelName) return undefined;
return stripVTControlCharacters(modelName).replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
}

/** Mode label is not included — callers add it where they want it. */
export function buildInvocationTags(
invocation: AgentInvocation | undefined,
Expand All @@ -172,7 +179,7 @@ export function buildInvocationTags(
if (invocation.inheritContext) tags.push("inherit context");
if (invocation.runInBackground) tags.push("background");
if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
return { modelName: invocation.modelName, tags };
return { modelName: prepareModelNameForDisplay(invocation.modelName), tags };
}

/** Truncate text to a single line, max `len` chars. */
Expand Down Expand Up @@ -305,8 +312,43 @@ export class AgentWidget {
}
}

/** Model and thinking metadata paired for the current run. */
private invocationStats(
a: {
invocation?: AgentInvocation;
session?: { model?: { provider: string; id: string }; thinkingLevel?: string };
},
theme: Theme,
): string | undefined {
const runtimeInvocation = a.session?.model
? {
...a.invocation,
modelName: `${a.session.model.provider}/${a.session.model.id}`,
thinking: a.session.thinkingLevel,
}
: a.invocation;
const { modelName, tags } = buildInvocationTags(runtimeInvocation);
const safeModelName = prepareModelNameForDisplay(modelName);
const parts = safeModelName ? [safeModelName, ...tags.filter(tag => tag.startsWith("thinking: "))] : [];
return parts.length > 0 ? theme.fg("dim", parts.join(" · ")) : undefined;
}

/** Render a finished agent line. */
private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string }, theme: Theme): string {
private renderFinishedLine(
a: {
id: string;
type: SubagentType;
status: string;
description: string;
toolUses: number;
startedAt: number;
completedAt?: number;
error?: string;
invocation?: AgentInvocation;
session?: { model?: { provider: string; id: string }; thinkingLevel?: string };
},
theme: Theme,
): string {
const name = getDisplayName(a.type);
const modeLabel = getPromptModeLabel(a.type);
const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
Expand All @@ -333,6 +375,8 @@ export class AgentWidget {
}

const parts: string[] = [];
const invocationStats = this.invocationStats(a, theme);
if (invocationStats) parts.push(invocationStats);
const activity = this.agentActivity.get(a.id);
if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
Expand Down Expand Up @@ -389,6 +433,8 @@ export class AgentWidget {
const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";

const parts: string[] = [];
const invocationStats = this.invocationStats(a, theme);
if (invocationStats) parts.push(invocationStats);
if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
if (tokenText) parts.push(tokenText);
Expand All @@ -403,29 +449,35 @@ export class AgentWidget {
]);
}

const queuedLine = queued.length > 0
? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
: undefined;
const queuedLines = queued.map(a => {
const invocationStats = this.invocationStats(a, theme);
const suffix = invocationStats ? ` · ${invocationStats}` : "";
return truncate(
theme.fg("dim", "├─") +
` ${theme.fg("muted", "◦")} ${theme.bold(getDisplayName(a.type))} ${theme.fg("muted", a.description)}` +
theme.fg("dim", suffix),
);
});

// Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
const totalBody = finishedLines.length + runningLines.length * 2 + queuedLines.length;

const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];

if (totalBody <= maxBody) {
// Everything fits — add all lines and fix up connectors for the last item.
lines.push(...finishedLines);
for (const pair of runningLines) lines.push(...pair);
if (queuedLine) lines.push(queuedLine);
lines.push(...queuedLines);

// Fix last connector: swap ├─ → └─ and │ → space for activity lines.
if (lines.length > 1) {
const last = lines.length - 1;
lines[last] = lines[last].replace("├─", "└─");
// If last item is a running agent activity line, fix indent of that line
// and fix the header line above it.
if (runningLines.length > 0 && !queuedLine) {
if (runningLines.length > 0 && queuedLines.length === 0) {
// The last two lines are the last running agent's header + activity.
if (last >= 2) {
lines[last - 1] = lines[last - 1].replace("├─", "└─");
Expand All @@ -438,6 +490,7 @@ export class AgentWidget {
// Reserve 1 line for overflow indicator.
let budget = maxBody - 1;
let hiddenRunning = 0;
let hiddenQueued = 0;
let hiddenFinished = 0;

// 1. Running agents (2 lines each)
Expand All @@ -450,10 +503,14 @@ export class AgentWidget {
}
}

// 2. Queued line
if (queuedLine && budget >= 1) {
lines.push(queuedLine);
budget--;
// 2. Queued agents
for (const line of queuedLines) {
if (budget >= 1) {
lines.push(line);
budget--;
} else {
hiddenQueued++;
}
}

// 3. Finished agents
Expand All @@ -469,9 +526,10 @@ export class AgentWidget {
// Overflow summary
const overflowParts: string[] = [];
if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
if (hiddenQueued > 0) overflowParts.push(`${hiddenQueued} queued`);
if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
const overflowText = overflowParts.join(", ");
lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`)
lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenQueued + hiddenFinished} more (${overflowText})`)}`)
);
}

Expand Down
9 changes: 8 additions & 1 deletion src/ui/conversation-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,14 @@ export class ConversationViewer implements Component {
}

private invocationLine(): string | undefined {
const { modelName, tags } = buildInvocationTags(this.record.invocation);
const runtimeInvocation = this.session.model
? {
...this.record.invocation,
modelName: `${this.session.model.provider}/${this.session.model.id}`,
thinking: this.session.thinkingLevel,
}
: this.record.invocation;
const { modelName, tags } = buildInvocationTags(runtimeInvocation);
const parts = modelName ? [modelName, ...tags] : tags;
if (parts.length === 0) return undefined;
return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
Expand Down
Loading
Loading