diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 96cd0aa7c..8346c81b3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ - Changed `irc` to treat the attached human as a first-class `User` peer, merging human prompts into `irc call User` with optional structured question payloads and adding `/dm ` for user-to-agent routing without switching views. - Changed the `--resume` session picker (and the in-session resume selector) to also rank sessions by prompt-history matches from `history.db`, not just the session-list metadata. Because the session list only indexes the first 4KB of each file, this surfaces sessions by prompts typed deep into long conversations. Sessions matched by both signals lead, then metadata-only matches, then history-only matches — no metadata match is dropped. +- Changed the `task` tool's streaming call preview to list each dispatched agent's `id` and UI description as a tree instead of a bare `N agents` count, so the individual agents are visible while the tool-call arguments are still streaming. The collapsed view caps at 12 entries (`… N more agents`); the expanded view shows all. ### Removed @@ -19,6 +20,7 @@ ### Fixed - Fixed `Esc` in a delegated agent view returning to the main session instead of aborting the delegated agent's active turn. +- Fixed the subagent stats line to separate the cost with the theme dot separator (was a stray literal `.`) and to render context usage as `%/` (e.g. `21.3%/272K`) matching the status line gauge, via a shared `formatContextUsage` helper now used by the footer, status-line segment, session observer overlay, and `task` renderer. - Fixed the agent roster staying pinned under the editor when all delegated agents are idle or dormant; it now reappears when explicitly focused with `Alt+Down` / session observe. - Fixed selector-style UI components to honor `tui.select.up` and `tui.select.down` keybindings instead of hard-coding raw Up/Down arrow bytes ([#1535](https://github.com/can1357/oh-my-pi/issues/1535)). diff --git a/packages/coding-agent/src/modes/components/footer.ts b/packages/coding-agent/src/modes/components/footer.ts index 1588afdd9..eeb3edfef 100644 --- a/packages/coding-agent/src/modes/components/footer.ts +++ b/packages/coding-agent/src/modes/components/footer.ts @@ -7,7 +7,7 @@ import type { AgentSession } from "../../session/agent-session"; import { shortenPath } from "../../tools/render-utils"; import * as git from "../../utils/git"; import { sanitizeStatusText } from "../shared"; -import { getContextUsageLevel, getContextUsageThemeColor } from "./status-line/context-thresholds"; +import { formatContextUsage, getContextUsageLevel, getContextUsageThemeColor } from "./status-line/context-thresholds"; /** * Footer component that shows pwd, token stats, and context usage @@ -136,7 +136,6 @@ export class FooterComponent implements Component { 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 = shortenPath(getProjectDir()); @@ -180,10 +179,10 @@ export class FooterComponent implements Component { // Colorize context percentage based on usage let contextPercentStr: string; const autoIndicator = this.#autoCompactEnabled ? " (auto)" : ""; - const contextPercentDisplay = - contextPercent === "?" - ? `?/${formatNumber(contextWindow)}${autoIndicator}` - : `${contextPercent}%/${formatNumber(contextWindow)}${autoIndicator}`; + const contextPercentDisplay = `${formatContextUsage( + contextUsage?.percent === null ? null : contextPercentValue, + contextWindow, + )}${autoIndicator}`; if (contextUsage?.percent !== null && contextUsage?.percent !== undefined) { const color = getContextUsageThemeColor(getContextUsageLevel(contextPercentValue, contextWindow)); contextPercentStr = diff --git a/packages/coding-agent/src/modes/components/session-observer-overlay.ts b/packages/coding-agent/src/modes/components/session-observer-overlay.ts index 77f587471..8f62b4673 100644 --- a/packages/coding-agent/src/modes/components/session-observer-overlay.ts +++ b/packages/coding-agent/src/modes/components/session-observer-overlay.ts @@ -27,6 +27,7 @@ import type { ObservableSession, SessionObserverRegistry } from "../session-obse import { getMarkdownTheme, theme } from "../theme/theme"; import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; import { DynamicBorder } from "./dynamic-border"; +import { formatContextUsage } from "./status-line/context-thresholds"; /** Max thinking characters in collapsed state */ const MAX_THINKING_CHARS_COLLAPSED = 200; @@ -267,12 +268,11 @@ export class SessionObserverOverlayComponent extends Container { const progress = session?.progress; if (!progress) return ""; const stats: string[] = []; - // Current per-turn context — what the user reads as "how full is the context". - // Used as a compact progress gauge instead of cumulative billing volume. + // Current per-turn context — match the status line's `%/` gauge (e.g. `5.1%/1M`). if (progress.contextTokens && progress.contextTokens > 0) { const ctx = progress.contextWindow && progress.contextWindow > 0 - ? `${formatNumber(progress.contextTokens)}/${formatNumber(progress.contextWindow)}` + ? formatContextUsage((progress.contextTokens / progress.contextWindow) * 100, progress.contextWindow) : `${formatNumber(progress.contextTokens)}`; stats.push(ctx); } @@ -287,7 +287,7 @@ export class SessionObserverOverlayComponent extends Container { parts.push(theme.fg("dim", statSegments.join(theme.sep.dot))); } if (progress.cost > 0) { - parts.push(`. ${theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`)}`); + parts.push(theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`)); } return parts.join(theme.sep.dot); } diff --git a/packages/coding-agent/src/modes/components/status-line/context-thresholds.ts b/packages/coding-agent/src/modes/components/status-line/context-thresholds.ts index edd55cd2e..bda33a23e 100644 --- a/packages/coding-agent/src/modes/components/status-line/context-thresholds.ts +++ b/packages/coding-agent/src/modes/components/status-line/context-thresholds.ts @@ -1,3 +1,4 @@ +import { formatNumber } from "@oh-my-pi/pi-utils"; import type { ThemeColor } from "../../../modes/theme/theme"; export type ContextUsageLevel = "normal" | "warning" | "purple" | "error"; @@ -54,6 +55,16 @@ export function getContextUsageLevel(contextPercent: number, contextWindow: numb return "normal"; } +/** + * Format context usage as `%/` (e.g. `5.1%/1M`), matching the + * status line's context gauge so subagent and footer renderers stay in sync. + * A `null`/`undefined` percent (unknown, e.g. right after compaction) renders as `?`. + */ +export function formatContextUsage(contextPercent: number | null | undefined, contextWindow: number): string { + const pct = contextPercent === null || contextPercent === undefined ? "?" : `${contextPercent.toFixed(1)}%`; + return `${pct}/${formatNumber(contextWindow)}`; +} + export function getContextUsageThemeColor(level: ContextUsageLevel): ThemeColor { switch (level) { case "error": diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index 41a06060d..a763152b2 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -7,7 +7,7 @@ import { type ThemeColor, theme } from "../../../modes/theme/theme"; import { shortenPath } from "../../../tools/render-utils"; import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color"; import { sanitizeStatusText } from "../../shared"; -import { getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds"; +import { formatContextUsage, getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds"; import type { RenderedSegment, SegmentContext, StatusLineSegment, StatusLineSegmentId } from "./types"; export type { SegmentContext } from "./types"; @@ -350,7 +350,7 @@ const contextPctSegment: StatusLineSegment = { const window = ctx.contextWindow; const autoIcon = ctx.autoCompactEnabled && theme.icon.auto ? ` ${theme.icon.auto}` : ""; - const text = `${pct.toFixed(1)}%/${formatNumber(window)}${autoIcon}`; + const text = `${formatContextUsage(pct, window)}${autoIcon}`; const color = getContextUsageThemeColor(getContextUsageLevel(pct, window)); const content = withIcon(theme.icon.context, theme.fg(color, text)); diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index 3c2630039..21cfbc504 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -10,6 +10,7 @@ import { Container, Text } from "@oh-my-pi/pi-tui"; import { formatNumber } from "@oh-my-pi/pi-utils"; import { settings } from "../config/settings"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; +import { formatContextUsage } from "../modes/components/status-line/context-thresholds"; import type { Theme } from "../modes/theme/theme"; import { formatBadge, @@ -29,7 +30,7 @@ import { } from "../tools/review"; import { Ellipsis, Hasher, type RenderCache, renderStatusLine } from "../tui"; import { subprocessToolRegistry } from "./subprocess-tool-registry"; -import type { AgentProgress, SingleResult, TaskParams, TaskToolDetails } from "./types"; +import type { AgentProgress, SingleResult, TaskItem, TaskParams, TaskToolDetails } from "./types"; /** * Get status icon for agent state. @@ -70,16 +71,16 @@ function appendAgentStats( if (opts.toolCount) { line += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(opts.toolCount)} ${theme.icon.extensionTool}`)}`; } - // Current per-turn context — what the user reads as "how full is the context". + // Current per-turn context — match the status line's `%/` gauge (e.g. `5.1%/1M`). if (opts.contextTokens && opts.contextTokens > 0) { const ctx = opts.contextWindow && opts.contextWindow > 0 - ? `${formatNumber(opts.contextTokens)}/${formatNumber(opts.contextWindow)}` + ? formatContextUsage((opts.contextTokens / opts.contextWindow) * 100, opts.contextWindow) : `${formatNumber(opts.contextTokens)}`; line += `${theme.sep.dot}${theme.fg("dim", ctx)}`; } if (opts.cost > 0) { - line += ` . ${theme.fg("statusLineCost", `$${opts.cost.toFixed(2)}`)}`; + line += `${theme.sep.dot}${theme.fg("statusLineCost", `$${opts.cost.toFixed(2)}`)}`; } if (opts.resolvedModel && opts.showResolvedModelBadge) { line += `${theme.sep.dot}${theme.fg("dim", truncateToWidth(replaceTabs(opts.resolvedModel), 30))}`; @@ -485,20 +486,59 @@ function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string return `Output: ${pairs.join(", ")}`; } +/** + * Render the per-task list (`id` + ui `description`) for the streaming call + * preview. The args stream in token by token, so the array grows over time and + * trailing entries may be partially parsed — every field access is defensive. + */ +function renderTaskItemLines( + tasks: TaskItem[] | undefined, + contPrefix: string, + expanded: boolean, + theme: Theme, +): string[] { + const items = tasks ?? []; + if (items.length === 0) return []; + + const branch = theme.fg("dim", theme.tree.branch); + const last = theme.fg("dim", theme.tree.last); + const cap = expanded ? items.length : Math.min(items.length, 12); + const truncated = cap < items.length; + + const lines: string[] = []; + for (let i = 0; i < cap; i++) { + const task = items[i] as Partial | undefined; + const isLastLine = !truncated && i === items.length - 1; + const connector = isLastLine ? last : branch; + const rawId = task?.id?.trim(); + const idLabel = rawId ? formatTaskId(rawId) : `#${i + 1}`; + let line = `${contPrefix}${connector} ${theme.fg("accent", theme.bold(idLabel))}`; + const desc = task?.description?.trim(); + if (desc) { + line += `: ${theme.fg("muted", truncateToWidth(replaceTabs(desc), 64))}`; + } + lines.push(line); + } + if (truncated) { + lines.push(`${contPrefix}${last} ${theme.fg("dim", formatMoreItems(items.length - cap, "agent"))}`); + } + return lines; +} + /** * Render the tool call arguments. */ -export function renderCall(args: TaskParams, _options: RenderResultOptions, theme: Theme): Component { +export function renderCall(args: TaskParams, options: RenderResultOptions, theme: Theme): Component { const lines: string[] = []; lines.push(renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme)); - const contextTemplate = args.context ?? ""; - const context = contextTemplate.trim(); + const context = (args.context ?? "").trim(); const hasContext = context.length > 0; const branch = theme.fg("dim", theme.tree.branch); const last = theme.fg("dim", theme.tree.last); const vertical = theme.fg("dim", theme.tree.vertical); const showIsolated = "isolated" in args && args.isolated === true; + const taskCount = args.tasks?.length ?? 0; if (hasContext) { lines.push(` ${branch} ${theme.fg("dim", "Context")}`); @@ -506,19 +546,17 @@ export function renderCall(args: TaskParams, _options: RenderResultOptions, them const content = line ? theme.fg("muted", replaceTabs(line)) : ""; lines.push(` ${vertical} ${content}`); } - const taskPrefix = showIsolated ? branch : last; - lines.push( - ` ${taskPrefix} ${theme.fg("dim", "Tasks")}: ${theme.fg("muted", `${args.tasks?.length ?? 0} agents`)}`, - ); - if (showIsolated) { - lines.push(` ${last} ${theme.fg("dim", "Isolated")}: ${theme.fg("muted", "true")}`); - } - return new Text(lines.join("\n"), 0, 0); } - lines.push(`${theme.fg("dim", "Tasks")}: ${theme.fg("muted", `${args.tasks?.length ?? 0} agents`)}`); + // `Tasks` is the last child unless the isolation flag follows it. + const tasksIsLast = !showIsolated; + const tasksPrefix = tasksIsLast ? last : branch; + lines.push(` ${tasksPrefix} ${theme.fg("dim", "Tasks")} ${theme.fg("muted", `(${taskCount})`)}`); + const tasksContPrefix = tasksIsLast ? " " : ` ${vertical} `; + lines.push(...renderTaskItemLines(args.tasks, tasksContPrefix, options.expanded, theme)); + if (showIsolated) { - lines.push(`${theme.fg("dim", "Isolated")}: ${theme.fg("muted", "true")}`); + lines.push(` ${last} ${theme.fg("dim", "Isolated")}: ${theme.fg("muted", "true")}`); } return new Text(lines.join("\n"), 0, 0); diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index de0d80862..eba6ab1c0 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -629,9 +629,7 @@ describe("hashline executor", () => { it("rejects file creation and directs to the write tool", async () => { await withTempDir(async tempDir => { const input = `¶new.ts\ninsert head:\n${repl("export const x = 1;")}\n`; - await expect(executeHashlineSingle(hashlineExecuteOptions(tempDir, input))).rejects.toThrow( - /write tool/, - ); + await expect(executeHashlineSingle(hashlineExecuteOptions(tempDir, input))).rejects.toThrow(/write tool/); expect(await Bun.file(path.join(tempDir, "new.ts")).exists()).toBe(false); }); }); diff --git a/packages/coding-agent/test/task/render-call.test.ts b/packages/coding-agent/test/task/render-call.test.ts new file mode 100644 index 000000000..ead652b00 --- /dev/null +++ b/packages/coding-agent/test/task/render-call.test.ts @@ -0,0 +1,101 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { getThemeByName, setThemeInstance, type Theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { TaskParams } from "@oh-my-pi/pi-coding-agent/task"; +import { taskToolRenderer } from "@oh-my-pi/pi-coding-agent/task/render"; + +describe("task renderer: streaming call preview", () => { + let theme: Theme; + + beforeAll(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true, cwd: process.cwd() }); + const resolved = await getThemeByName("dark"); + expect(resolved).toBeDefined(); + theme = resolved!; + setThemeInstance(theme); + }); + + afterAll(() => { + resetSettingsForTest(); + }); + + function render(args: TaskParams, expanded = false): string { + const component = taskToolRenderer.renderCall(args, { expanded, isPartial: true }, theme); + return Bun.stripANSI(component.render(160).join("\n")); + } + + // The preview must surface each agent's id + ui description so the user can + // see which agents are being dispatched, not a bare "N agents" count. + it("lists each task's id and description instead of only a count", () => { + const args: TaskParams = { + agent: "reviewer", + tasks: [ + { id: "ReviewAuth", description: "Audit the auth module", assignment: "..." }, + { id: "ReviewDb", description: "Audit the db layer", assignment: "..." }, + ], + }; + const out = render(args); + + expect(out).toContain("ReviewAuth"); + expect(out).toContain("Audit the auth module"); + expect(out).toContain("ReviewDb"); + expect(out).toContain("Audit the db layer"); + // Count is kept as a compact header, but the old flat "N agents" line is gone. + expect(out).toContain("Tasks (2)"); + expect(out).not.toContain("2 agents"); + }); + + it("renders a partially-streamed entry without a description and missing trailing entry", () => { + const args = { + agent: "task", + // Trailing entry mimics streaming JSON: id arrived, description not yet, + // plus a not-yet-materialized slot. + tasks: [{ id: "First", description: "Do the first thing", assignment: "..." }, { id: "Second" }, undefined], + } as unknown as TaskParams; + + const out = render(args); + + expect(out).toContain("First"); + expect(out).toContain("Do the first thing"); + expect(out).toContain("Second"); + // Missing-id slot falls back to a positional placeholder rather than crashing. + expect(out).toContain("#3"); + expect(out).toContain("Tasks (3)"); + }); + + it("caps the collapsed list and reports the overflow as agents", () => { + const tasks = Array.from({ length: 15 }, (_, i) => ({ + id: `Agent${i + 1}`, + description: `Task ${i + 1}`, + assignment: "...", + })); + const args: TaskParams = { agent: "task", tasks }; + + const collapsed = render(args, false); + expect(collapsed).toContain("Agent1"); + expect(collapsed).toContain("Agent12"); + expect(collapsed).not.toContain("Agent13"); + expect(collapsed).toContain("3 more agents"); + + const expanded = render(args, true); + expect(expanded).toContain("Agent13"); + expect(expanded).toContain("Agent15"); + expect(expanded).not.toContain("more agents"); + }); + + it("keeps the isolation flag as the final child after the task list", () => { + const args: TaskParams = { + agent: "task", + isolated: true, + tasks: [{ id: "Only", description: "Single task", assignment: "..." }], + }; + const out = render(args); + const lines = out.split("\n"); + + expect(out).toContain("Only"); + expect(out).toContain("Isolated"); + // Isolation flag is rendered last, after every task entry. + expect(lines.at(-1)).toContain("Isolated"); + }); +}); diff --git a/packages/coding-agent/test/task/render-nested-live.test.ts b/packages/coding-agent/test/task/render-nested-live.test.ts index df7d85c6a..9fc3ddb33 100644 --- a/packages/coding-agent/test/task/render-nested-live.test.ts +++ b/packages/coding-agent/test/task/render-nested-live.test.ts @@ -196,7 +196,9 @@ describe("task renderer: nested live rendering", () => { }), ); - const expectedStats = `${formatNumber(19)} ${theme.icon.extensionTool} · ${formatNumber(58_000)}/${formatNumber(272_000)} . $2.10`; + // Context now matches the status line gauge: 58000/272000 → 21.3%/272K. + // Cost is separated by the theme dot separator, not a literal ".". + const expectedStats = `${formatNumber(19)} ${theme.icon.extensionTool}${theme.sep.dot}21.3%/272K${theme.sep.dot}$2.10`; expect(text).toContain(expectedStats); expect(text).not.toContain("tools"); expect(text).not.toContain("ctx");