Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- Changed agents-view row usage to aligned `↑in ↓out · $agent · #sub · $total · age` columns with an explicit total-subagent count; empty sessions show only their age.
- Added a bold usage legend and session count to every agents-view section header, sharing one column layout with the rows.
- Changed empty sessions to sort last within their agents-view section, except the session the view was entered from.
156 changes: 135 additions & 21 deletions packages/coding-agent/src/modes/agents-view/agents-view-mode.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ import {
type AgentsViewSelectionKey,
buildAgentsViewRows,
buildUnifiedSessionIndex,
computeRecursiveCosts,
computeRecursiveRollups,
createUnattachableChildOpenResult,
filterUnifiedSessions,
formatHeartbeatBadge,
Expand All @@ -83,6 +83,7 @@ import {
getAgentsViewSummaryIdentity as getSummaryIdentity,
getUnifiedSessionAncestorSessionIds,
hasUnifiedSessionChildren,
isEmptyAgentsViewSession,
isSubagentSummary,
migrateAgentsViewIdentitySet,
reconcileUnifiedSessions,
Expand Down Expand Up @@ -694,6 +695,8 @@ export class AgentsViewMode implements Component, Focusable {
private pendingKillSubagent: PendingKillSubagent | undefined;
private renameTarget: { activeSessionId?: string; sessionFile?: string; summary: SessionSummary } | undefined;
private actionModeSearchQuery: string | undefined;
/** Session the view was entered from; exempt from the empty-session sort demotion. */
private readonly anchorSessionId: string | undefined;
private readonly inactiveAgentIdentities = new Set<string>();
private rosterStore: AgentsViewRosterStore | undefined;
private unsubscribeRosterUpdate: (() => void) | undefined;
Expand All @@ -715,6 +718,7 @@ export class AgentsViewMode implements Component, Focusable {
persistentState.backSession ?? options.initialSession,
);
persistentState.scopeFrames = initialFrames;
this.anchorSessionId = (persistentState.backSession ?? options.initialSession)?.sessionId;
this.scopeKey = initialFrames.at(-1)?.scope;
this.scopeRootSummary = persistentState.scopeRootSummary;
this.selectedRowIdentity = persistentState.selectedRowIdentity;
Expand Down Expand Up @@ -1287,7 +1291,8 @@ export class AgentsViewMode implements Component, Focusable {
this.expandedSubagentParents,
this.programShownParents,
this.scopeKey,
computeRecursiveCosts(this.unifiedRecords, this.unifiedIndex),
computeRecursiveRollups(this.unifiedRecords, this.unifiedIndex),
this.anchorSessionId,
);
const index =
selectedIdentity === undefined ? -1 : this.rows.findIndex((row) => row.identity === selectedIdentity);
Expand Down Expand Up @@ -2174,7 +2179,8 @@ export class AgentsViewMode implements Component, Focusable {
this.expandedSubagentParents,
this.programShownParents,
this.scopeKey,
computeRecursiveCosts(this.unifiedRecords, this.unifiedIndex),
computeRecursiveRollups(this.unifiedRecords, this.unifiedIndex),
this.anchorSessionId,
);
this.applyPendingAncestorExpansion();
this.restoreSelection();
Expand Down Expand Up @@ -2477,13 +2483,15 @@ export class AgentsViewMode implements Component, Focusable {
return [];
}
if (this.rows.length === 0) {
return [theme.bold(sectionTitle("running")), theme.fg("dim", " No sessions match your search.")].slice(
0,
maxRows,
);
const emptyLegend = buildAgentsViewUsageLayout([]).legends.get("running") ?? "";
return [
this.renderSectionHeading("running", width, emptyLegend),
theme.fg("dim", " No sessions match your search."),
].slice(0, maxRows);
}

const displayItems = buildDisplayItems(this.rows);
const usageLayout = buildAgentsViewUsageLayout(this.rows);
const selectedIdentity = this.rows[this.selectedIndex]?.identity;
const selectedDisplayIndex = displayItems.findIndex(
(item) => item.type === "row" && item.row.identity === selectedIdentity,
Expand All @@ -2502,18 +2510,22 @@ export class AgentsViewMode implements Component, Focusable {
0,
visibleRows - (showLeadingEllipsis ? 1 : 0) - (showTrailingEllipsis ? 1 : 0),
);
const visibleItems = displayItems.slice(start, start + contentVisibleRows);
// The prepended ellipsis consumes a viewport line; shift the window down
// so a selection at the very end is not pushed out of the slice.
const sliceStart =
selectedDisplayIndex >= start + contentVisibleRows ? selectedDisplayIndex - contentVisibleRows + 1 : start;
const visibleItems = displayItems.slice(sliceStart, sliceStart + contentVisibleRows);
const lines = visibleItems.map((item) => {
if (item.type === "spacer") {
return "";
}
if (item.type === "heading") {
return theme.bold(sectionTitle(item.section));
return this.renderSectionHeading(item.section, width, usageLayout.legends.get(item.section) ?? "");
}
if (item.type === "empty") {
return theme.fg("dim", " No agents");
}
return this.renderRow(item.row, width);
return this.renderRow(item.row, width, usageLayout.details);
});
if (showLeadingEllipsis) {
lines.unshift(theme.fg("dim", " ..."));
Expand All @@ -2524,8 +2536,13 @@ export class AgentsViewMode implements Component, Focusable {
return lines;
}

private renderRow(row: AgentsViewRow, width: number): string {
private renderRow(
row: AgentsViewRow,
width: number,
rowDetails: ReadonlyMap<string, string> = buildAgentsViewUsageLayout([row]).details,
): string {
const selected = row.selectable && row.identity === this.rows[this.selectedIndex]?.identity;
const markRow = (line: string): string => (selected ? `${SELECTED_ROW_MARKER}${line}` : line);
if (row.kind === "subagent-code") {
return this.renderCodeRow(row);
}
Expand All @@ -2535,16 +2552,14 @@ export class AgentsViewMode implements Component, Focusable {
const titleColor = row.runningSubagentCount > 0 ? ("success" as const) : ("dim" as const);
const label = `${theme.fg(titleColor, `${row.expanded ? "▾" : "▸"} ${row.title}`)}${hint}`;
const line = padLine(truncateToWidth(`${indent}${label}`, width, ""), width);
return selected ? `${SELECTED_ROW_MARKER}${line}` : line;
return markRow(line);
}
const pendingDelete = row.kind === "agent" && this.isPendingDeleteRow(row);
const pendingKill = row.kind === "subagent" && this.isPendingKillSubagentRow(row);
const rawIcon = this.getRowIcon(row.section);
const icon = this.formatRowIcon(row.section, rawIcon);
const indent = " ".repeat(row.depth);
const age = formatSessionDuration(row.summary);
const usageText = formatRowUsage(row);
const details = usageText ? `${usageText} · ${age}` : age;
const details = rowDetails.get(row.identity) ?? "";
const detailsWidth = Math.max(10, visibleWidth(details));
const heartbeatBadge = !pendingDelete && !pendingKill ? formatHeartbeatBadge(row.heartbeat) : "";
const heartbeatPausedOnly = (row.heartbeat?.activeCount ?? 0) < 1;
Expand Down Expand Up @@ -2590,7 +2605,20 @@ export class AgentsViewMode implements Component, Focusable {
];
const base = `${indent}${cells[0]} ${heartbeatCell ? `${heartbeatCell} ` : ""}${cells[1]} ${cells[2]}`;
const line = padLine(truncateToWidth(base, width, ""), width);
return selected ? `${SELECTED_ROW_MARKER}${line}` : line;
return markRow(line);
}

// Bold like the section title so the legend reads as part of the header line.
// The legend is right-aligned to the same edge as the row details cells, so
// its columns sit exactly above the row columns.
private renderSectionHeading(section: AgentsViewSection, width: number, legend: string): string {
const counts = countRowsBySection(this.rows);
const title = `${sectionTitle(section)} (${counts[section]})`;
const gap = width - visibleWidth(title) - visibleWidth(legend);
if (legend.length === 0 || gap < 2) {
return theme.bold(truncateToWidth(title, width, ""));
}
return `${theme.bold(title)}${" ".repeat(gap)}${theme.bold(legend)}`;
}

// Spawn-code rows are read-only context. They render deemphasized — muted
Expand Down Expand Up @@ -2831,11 +2859,97 @@ function hasLiveWork(row: AgentsViewRow): boolean {
return row.section === "running" || row.runningSubagentCount > 0 || row.summary.hasRunningRlmChildren === true;
}

function formatRowUsage(row: AgentsViewRow): string {
const usage = row.summary.usage;
return `↑${formatTokenCount(usage?.inputTokens ?? 0)} ↓${formatTokenCount(usage?.outputTokens ?? 0)} · $${(
usage?.cost ?? 0
).toFixed(2)} ($${row.recursiveCost.toFixed(2)} w/ subagents)`;
interface AgentsViewUsageParts {
inTokens: string;
outTokens: string;
agentCost: string;
count: string;
totalCost: string;
age: string;
}

const AGENTS_VIEW_USAGE_LABELS: AgentsViewUsageParts = {
inTokens: "↑in",
outTokens: "↓out",
agentCost: "$agent",
count: "#sub",
totalCost: "$total",
age: "age",
};

const AGENTS_VIEW_USAGE_COLUMNS = Object.keys(AGENTS_VIEW_USAGE_LABELS) as (keyof AgentsViewUsageParts)[];

export interface AgentsViewUsageLayout {
/** Legend line per section, padded to that section's column widths. */
legends: ReadonlyMap<AgentsViewSection, string>;
/** Details string per row identity, padded to its section's column widths. */
details: ReadonlyMap<string, string>;
}

/**
* One shared column layout per section for the header legend and every row:
* each column is as wide as the section's widest value or its legend label,
* everything right-aligned, so the ` · ` separators land in the same terminal
* column for the legend and every row. Empty sessions render only the age,
* aligned to the age column.
*/
export function buildAgentsViewUsageLayout(rows: readonly AgentsViewRow[]): AgentsViewUsageLayout {
const rowsBySection = new Map<AgentsViewSection, AgentsViewRow[]>();
// Nested rows render inside their top-level agent's section block, so group
// by the block's section rather than each row's own.
let blockSection: AgentsViewSection = "running";
for (const row of rows) {
if (row.depth === 0) blockSection = row.section;
if (row.kind !== "agent" && row.kind !== "subagent") continue;
const sectionRows = rowsBySection.get(blockSection) ?? [];
sectionRows.push(row);
rowsBySection.set(blockSection, sectionRows);
}
const legends = new Map<AgentsViewSection, string>();
const details = new Map<string, string>();
for (const section of ["running", "idle", "inactive"] as const) {
const entries = (rowsBySection.get(section) ?? []).map((row) => {
const usage = row.summary.usage;
const parts: AgentsViewUsageParts = {
inTokens: `↑${formatTokenCount(usage?.inputTokens ?? 0)}`,
outTokens: `↓${formatTokenCount(usage?.outputTokens ?? 0)}`,
agentCost: `$${(usage?.cost ?? 0).toFixed(2)}`,
count: String(row.descendantCount),
totalCost: `$${row.recursiveCost.toFixed(2)}`,
age: formatSessionDuration(row.summary),
};
return { identity: row.identity, empty: isEmptyAgentsViewSession(row.summary), parts };
});
const widths = {} as Record<keyof AgentsViewUsageParts, number>;
for (const column of AGENTS_VIEW_USAGE_COLUMNS) {
let width = visibleWidth(AGENTS_VIEW_USAGE_LABELS[column]);
for (const entry of entries) {
// Empty sessions render no usage segment; only their age takes space.
if (entry.empty && column !== "age") continue;
width = Math.max(width, visibleWidth(entry.parts[column]));
}
widths[column] = width;
}
const pad = (parts: AgentsViewUsageParts, column: keyof AgentsViewUsageParts): string =>
padCellStart(parts[column], widths[column]);
const formatLine = (parts: AgentsViewUsageParts): string =>
[
`${pad(parts, "inTokens")} ${pad(parts, "outTokens")}`,
pad(parts, "agentCost"),
pad(parts, "count"),
pad(parts, "totalCost"),
pad(parts, "age"),
].join(" · ");
legends.set(section, formatLine(AGENTS_VIEW_USAGE_LABELS));
for (const entry of entries) {
details.set(entry.identity, entry.empty ? pad(entry.parts, "age") : formatLine(entry.parts));
}
}
return { legends, details };
}

function padCellStart(value: string, width: number): string {
return " ".repeat(Math.max(0, width - visibleWidth(value))) + value;
}

// Explicit session names read bold so they stand out from fallback titles
Expand Down
Loading
Loading