@astralyn/pi 0.82.5 → 0.82.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/cli/project-trust.d.ts.map +1 -1
- package/dist/cli/project-trust.js +4 -1
- package/dist/cli/project-trust.js.map +1 -1
- package/dist/core/bash-executor.d.ts.map +1 -1
- package/dist/core/bash-executor.js +4 -22
- package/dist/core/bash-executor.js.map +1 -1
- package/dist/core/extensions/index.d.ts +1 -1
- package/dist/core/extensions/index.d.ts.map +1 -1
- package/dist/core/extensions/index.js.map +1 -1
- package/dist/core/extensions/types.d.ts +28 -5
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/tools/output-accumulator.d.ts +3 -13
- package/dist/core/tools/output-accumulator.d.ts.map +1 -1
- package/dist/core/tools/output-accumulator.js +6 -36
- package/dist/core/tools/output-accumulator.js.map +1 -1
- package/dist/extensions/plan/constants.d.ts +11 -2
- package/dist/extensions/plan/constants.d.ts.map +1 -1
- package/dist/extensions/plan/constants.js +14 -2
- package/dist/extensions/plan/constants.js.map +1 -1
- package/dist/extensions/plan/index.d.ts +0 -18
- package/dist/extensions/plan/index.d.ts.map +1 -1
- package/dist/extensions/plan/index.js +35 -3
- package/dist/extensions/plan/index.js.map +1 -1
- package/dist/extensions/plan/view.d.ts +36 -0
- package/dist/extensions/plan/view.d.ts.map +1 -0
- package/dist/extensions/plan/view.js +74 -0
- package/dist/extensions/plan/view.js.map +1 -0
- package/dist/extensions/router/stream.d.ts.map +1 -1
- package/dist/extensions/router/stream.js +44 -0
- package/dist/extensions/router/stream.js.map +1 -1
- package/dist/extensions/subagent/activity.d.ts.map +1 -1
- package/dist/extensions/subagent/activity.js +25 -4
- package/dist/extensions/subagent/activity.js.map +1 -1
- package/dist/extensions/subagent/index.d.ts.map +1 -1
- package/dist/extensions/subagent/index.js +14 -0
- package/dist/extensions/subagent/index.js.map +1 -1
- package/dist/extensions/subagent/render.d.ts.map +1 -1
- package/dist/extensions/subagent/render.js +243 -116
- package/dist/extensions/subagent/render.js.map +1 -1
- package/dist/extensions/subagent/resolve.d.ts.map +1 -1
- package/dist/extensions/subagent/resolve.js +13 -9
- package/dist/extensions/subagent/resolve.js.map +1 -1
- package/dist/extensions/subagent/runner.d.ts.map +1 -1
- package/dist/extensions/subagent/runner.js +4 -2
- package/dist/extensions/subagent/runner.js.map +1 -1
- package/dist/extensions/subagent/schema.d.ts.map +1 -1
- package/dist/extensions/subagent/schema.js +2 -2
- package/dist/extensions/subagent/schema.js.map +1 -1
- package/dist/extensions/todo/constants.d.ts +17 -6
- package/dist/extensions/todo/constants.d.ts.map +1 -1
- package/dist/extensions/todo/constants.js +22 -9
- package/dist/extensions/todo/constants.js.map +1 -1
- package/dist/extensions/todo/index.d.ts +0 -14
- package/dist/extensions/todo/index.d.ts.map +1 -1
- package/dist/extensions/todo/index.js +13 -1
- package/dist/extensions/todo/index.js.map +1 -1
- package/dist/extensions/todo/schema.d.ts +53 -2
- package/dist/extensions/todo/schema.d.ts.map +1 -1
- package/dist/extensions/todo/schema.js +57 -8
- package/dist/extensions/todo/schema.js.map +1 -1
- package/dist/extensions/todo/state.d.ts +16 -5
- package/dist/extensions/todo/state.d.ts.map +1 -1
- package/dist/extensions/todo/state.js +866 -130
- package/dist/extensions/todo/state.js.map +1 -1
- package/dist/extensions/todo/view.d.ts +16 -1
- package/dist/extensions/todo/view.d.ts.map +1 -1
- package/dist/extensions/todo/view.js +283 -1
- package/dist/extensions/todo/view.js.map +1 -1
- package/dist/modes/interactive/components/extension-selector.d.ts +6 -1
- package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/extension-selector.js +14 -6
- package/dist/modes/interactive/components/extension-selector.js.map +1 -1
- package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-execution.js +6 -0
- package/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +7 -1
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +8 -1
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/docs/bundled/extensions/plan.md +6 -4
- package/docs/bundled/extensions/subagent.md +2 -2
- package/docs/bundled/extensions/todo.md +8 -6
- package/docs/bundled/tool-presentation.md +1 -1
- package/docs/extensions.md +14 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/utils/output-decoder.d.ts +0 -52
- package/dist/utils/output-decoder.d.ts.map +0 -1
- package/dist/utils/output-decoder.js +0 -134
- package/dist/utils/output-decoder.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../../src/extensions/subagent/render.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAqC,MAAM,wBAAwB,CAAC;AAC3F,OAAO,KAAK,EAAE,eAAe,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AAE/F,OAAO,EAAoB,KAAK,KAAK,EAAE,MAAM,wCAAwC,CAAC;AAEtF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,KAAK,EAAE,eAAe,EAAyC,MAAM,YAAY,CAAC;AAsXzF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,cAAc,EAAE,KAAK,EAAE,KAAK,GAAG,SAAS,CAqBhF;AAED,wBAAgB,oBAAoB,CACnC,MAAM,EAAE,eAAe,CAAC,eAAe,CAAC,EACxC,OAAO,EAAE,uBAAuB,EAChC,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,OAAO,GACd,SAAS,CAmDX","sourcesContent":["import { type Component, Container, Markdown, Spacer, Text } from \"@earendil-works/pi-tui\";\nimport type { AgentToolResult, ToolRenderResultOptions } from \"../../core/extensions/types.ts\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.ts\";\nimport { getMarkdownTheme, type Theme } from \"../../modes/interactive/theme/theme.ts\";\nimport { statusSummary } from \"./runner.ts\";\nimport type { SubagentParams } from \"./schema.ts\";\nimport type { SubagentDetails, SubagentRunDetails, SubagentRunStatus } from \"./types.ts\";\n\nconst SINGLE_EXCERPT_LIMIT = 200;\nconst RUN_LINE_EXCERPT_LIMIT = 64;\nconst LIVE_TAIL_LIMIT = 100;\nconst COLLAPSED_RUN_LIMIT = 4;\n\nconst ACTIVITY_GROUPS = [\n\t{ toolName: \"read\", verb: \"read\", singular: \"file\", plural: \"files\" },\n\t{ toolName: \"grep\", verb: \"searched\", singular: \"pattern\", plural: \"patterns\" },\n\t{ toolName: \"find\", verb: \"searched\", singular: \"path pattern\", plural: \"path patterns\" },\n\t{ toolName: \"ls\", verb: \"listed\", singular: \"directory\", plural: \"directories\" },\n\t{ toolName: \"bash\", verb: \"ran\", singular: \"command\", plural: \"commands\" },\n\t{ toolName: \"edit\", verb: \"edited\", singular: \"file\", plural: \"files\" },\n\t{ toolName: \"write\", verb: \"wrote\", singular: \"file\", plural: \"files\" },\n] as const;\n\n// Display-only: profile names stay lowercase everywhere the model sees\n// them; the transcript shows \"general\" as \"General\", \"code-reviewer\" as\n// \"Code Reviewer\".\nfunction displayAgentName(name: string): string {\n\treturn name\n\t\t.split(/[-_]/u)\n\t\t.filter(Boolean)\n\t\t.map((word) => `${word[0]?.toUpperCase() ?? \"\"}${word.slice(1)}`)\n\t\t.join(\" \");\n}\n\nfunction singleAgentName(args: { agent?: string | null }): string {\n\treturn displayAgentName(args.agent ?? \"general\");\n}\n\nfunction truncate(text: string, limit: number): string {\n\treturn text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;\n}\n\n// Transcript excerpts render inside plain Text components, so markdown\n// punctuation would show up literally; strip the common inline syntax.\nfunction stripInlineMarkdown(text: string): string {\n\treturn text\n\t\t.replace(/```[a-zA-Z0-9-]*/gu, \"\")\n\t\t.replace(/`([^`]*)`/gu, \"$1\")\n\t\t.replace(/\\*\\*([^*]+)\\*\\*/gu, \"$1\")\n\t\t.replace(/__([^_]+)__/gu, \"$1\")\n\t\t.replace(/\\[([^\\]]+)\\]\\([^)]*\\)/gu, \"$1\")\n\t\t.replace(/^#{1,6}\\s+/gmu, \"\");\n}\n\n// Markdown tables and horizontal rules turn into symbol soup when\n// flattened onto one line; drop those lines before inline stripping.\nfunction stripBlockMarkdown(text: string): string {\n\treturn text\n\t\t.split(\"\\n\")\n\t\t.filter((line) => {\n\t\t\tconst trimmed = line.trim();\n\t\t\tif (trimmed.startsWith(\"|\")) return false;\n\t\t\treturn !(trimmed.length >= 3 && /^[-—─═=*_|:\\s]+$/u.test(trimmed));\n\t\t})\n\t\t.join(\"\\n\");\n}\n\nfunction excerpt(text: string, limit: number): string {\n\t// finalOutput/liveText carry model-facing truncation notices from\n\t// boundText/tailText; in the transcript an ellipsis is enough.\n\tconst cleaned = text.replace(/\\[(?:Output truncated(?:: \\d+ bytes omitted\\.)?|Earlier output omitted\\.)\\]/gu, \"…\");\n\treturn truncate(stripInlineMarkdown(stripBlockMarkdown(cleaned)).replace(/\\s+/gu, \" \").trim(), limit);\n}\n\nfunction liveTail(run: SubagentRunDetails): string | undefined {\n\tconst lines = run.liveText\n\t\t.split(\"\\n\")\n\t\t.map((line) => line.trim())\n\t\t.filter(Boolean);\n\tconst last = lines.at(-1);\n\treturn last ? excerpt(last, LIVE_TAIL_LIMIT) : undefined;\n}\n\n// Completed-run excerpts read better cut at a sentence boundary than\n// mid-word; fall back to a hard truncate when no boundary lands in the\n// second half of the window.\nfunction leadingSentences(text: string, limit: number): string {\n\tconst cleaned = excerpt(text, Number.MAX_SAFE_INTEGER);\n\tif (cleaned.length <= limit) return cleaned;\n\tconst window = cleaned.slice(0, limit);\n\tlet boundary = -1;\n\tfor (const match of window.matchAll(/[.!?。!?](?=\\s|$)/gu)) {\n\t\tboundary = (match.index ?? 0) + match[0].length;\n\t}\n\tif (boundary >= limit / 2) return window.slice(0, boundary).trimEnd();\n\treturn truncate(window, limit);\n}\n\nconst VERIFY_COMMAND_PATTERN = /\\b(test|lint|typecheck|tsc|vitest|jest|pytest|build|check|fmt|format|compile)\\b/iu;\nconst INSPECT_COMMAND_PATTERN = /\\b(rg|grep|find|git (?:diff|status|log|show)|ls|tree|cat|head|tail)\\b/iu;\nconst RETRY_MARKER_PATTERN = /^Retrying\\b/u;\nconst STREAM_MARKERS = new Set([\"Thinking…\", \"Writing response…\"]);\n\n// Translates raw tool traffic into what the worker is doing right now, so\n// the collapsed card reads as a phase instead of a command line. Modelled\n// on tool-type classification; the raw tool summary stays as evidence on\n// the next line.\nfunction runIntent(run: SubagentRunDetails): string {\n\tconst current = run.currentActivity;\n\tif (current && RETRY_MARKER_PATTERN.test(current)) return current;\n\tconst running = [...run.activities].reverse().find((activity) => activity.status === \"running\");\n\tif (running) {\n\t\tif (running.toolName === \"edit\" || running.toolName === \"write\") return \"Applying changes\";\n\t\tif (running.toolName === \"bash\") {\n\t\t\tif (VERIFY_COMMAND_PATTERN.test(running.summary)) return \"Verifying changes\";\n\t\t\treturn INSPECT_COMMAND_PATTERN.test(running.summary) ? \"Exploring code\" : \"Running commands\";\n\t\t}\n\t\treturn \"Exploring code\";\n\t}\n\tconst recent = run.activities.slice(-5);\n\tif (current && STREAM_MARKERS.has(current)) {\n\t\tif (recent.some((activity) => activity.status === \"failed\")) return \"Investigating a failure\";\n\t\treturn current;\n\t}\n\tif (recent.some((activity) => activity.status === \"failed\")) return \"Investigating a failure\";\n\tif (recent.some((activity) => activity.toolName === \"edit\" || activity.toolName === \"write\"))\n\t\treturn \"Reviewing changes\";\n\tif (recent.length > 0) return \"Exploring code\";\n\treturn current ?? \"Thinking…\";\n}\n\n// The raw tool summary backs up the intent line; suppress it when it would\n// just repeat a stream/retry marker.\nfunction runEvidence(run: SubagentRunDetails): string | undefined {\n\tconst current = run.currentActivity;\n\tif (!current || STREAM_MARKERS.has(current) || RETRY_MARKER_PATTERN.test(current)) return undefined;\n\treturn current;\n}\n\nfunction liveElapsed(run: SubagentRunDetails): string | undefined {\n\tif (run.startedAt === undefined) return undefined;\n\tconst end = run.endedAt ?? Date.now();\n\treturn formatDuration(Math.max(0, (end - run.startedAt) / 1000));\n}\n\n// The tool shell already paints a call-level status dot (● in\n// warning/success/error), so `●` is off-limits here: `›` marks where the\n// work currently is, and the remaining glyphs are terminal states.\nfunction statusMarker(status: SubagentRunStatus, theme: Theme): string {\n\tswitch (status) {\n\t\tcase \"completed\":\n\t\t\treturn theme.fg(\"success\", \"✓\");\n\t\tcase \"failed\":\n\t\t\treturn theme.fg(\"error\", \"×\");\n\t\tcase \"aborted\":\n\t\t\treturn theme.fg(\"warning\", \"■\");\n\t\tcase \"running\":\n\t\t\treturn theme.fg(\"accent\", \"›\");\n\t\tdefault:\n\t\t\treturn theme.fg(\"muted\", \"○\");\n\t}\n}\n\nfunction runTitle(run: SubagentRunDetails, theme: Theme, index?: number): string {\n\tconst number = index === undefined ? \"\" : theme.fg(\"dim\", `${index} · `);\n\treturn `${statusMarker(run.status, theme)} ${number}${theme.fg(\"accent\", displayAgentName(run.agent))}${theme.fg(\"dim\", ` · ${truncate(run.description, 48)}`)}`;\n}\n\nfunction runProgressText(run: SubagentRunDetails): string {\n\tconst items = [`${run.usage.toolUses} tool use${run.usage.toolUses === 1 ? \"\" : \"s\"}`];\n\tif (run.usage.totalTokens) items.push(`${formatTokens(run.usage.totalTokens)} tokens`);\n\treturn items.join(\" · \");\n}\n\nfunction runLine(run: SubagentRunDetails, theme: Theme, index?: number): string {\n\tlet line = runTitle(run, theme, index);\n\tconst detail =\n\t\trun.status === \"running\"\n\t\t\t? runIntent(run)\n\t\t\t: run.status === \"failed\" || run.status === \"aborted\"\n\t\t\t\t? run.error && excerpt(run.error, RUN_LINE_EXCERPT_LIMIT)\n\t\t\t\t: run.finalOutput && excerpt(run.finalOutput, RUN_LINE_EXCERPT_LIMIT);\n\tif (detail) line += theme.fg(run.status === \"failed\" ? \"error\" : \"dim\", ` — ${detail}`);\n\tif (run.status === \"running\") {\n\t\tline += theme.fg(\"dim\", ` · ${runProgressText(run)}`);\n\t\tconst elapsed = liveElapsed(run);\n\t\tif (elapsed) line += theme.fg(\"dim\", ` · ${elapsed}`);\n\t}\n\tconst settled = run.status !== \"running\" && run.status !== \"queued\";\n\tif (settled && run.startedAt !== undefined && run.endedAt !== undefined) {\n\t\tline += theme.fg(\"dim\", ` · ${formatDuration(Math.max(0, (run.endedAt - run.startedAt) / 1000))}`);\n\t}\n\treturn line;\n}\n\n// Keep active work visible while a batch is in flight: completed runs\n// may otherwise crowd a still-running task out of the collapsed window.\nfunction selectCollapsedRuns(runs: SubagentRunDetails[], isPartial: boolean): SubagentRunDetails[] {\n\tif (runs.length <= COLLAPSED_RUN_LIMIT) return runs;\n\tif (!isPartial) return runs.slice(0, COLLAPSED_RUN_LIMIT);\n\tconst active = runs.filter((run) => run.status === \"running\" || run.status === \"queued\");\n\tconst settled = runs.filter((run) => run.status !== \"running\" && run.status !== \"queued\");\n\treturn [...active, ...settled].slice(0, COLLAPSED_RUN_LIMIT);\n}\n\nfunction singleCollapsedLines(details: SubagentDetails, theme: Theme): string[] {\n\tconst run = details.runs[0];\n\tif (!run) return [theme.fg(\"muted\", \"Initializing…\")];\n\tif (run.status === \"running\" || run.status === \"queued\") {\n\t\tconst isInitializing =\n\t\t\trun.status === \"running\" && run.usage.turns === 0 && run.usage.toolUses === 0 && run.liveText.length === 0;\n\t\tconst intent =\n\t\t\trun.status === \"queued\"\n\t\t\t\t? (run.currentActivity ?? \"queued\")\n\t\t\t\t: isInitializing\n\t\t\t\t\t? \"Initializing…\"\n\t\t\t\t\t: runIntent(run);\n\t\t// No status marker here: the shell's call-level dot already covers\n\t\t// the whole card, and a single run has nothing to disambiguate.\n\t\tlet status = theme.fg(\"dim\", intent);\n\t\tif (run.status === \"running\" && !isInitializing) {\n\t\t\tstatus += theme.fg(\"dim\", ` · ${runProgressText(run)}`);\n\t\t\tconst elapsed = liveElapsed(run);\n\t\t\tif (elapsed) status += theme.fg(\"dim\", ` · ${elapsed}`);\n\t\t}\n\t\tconst lines = [status];\n\t\tconst evidence = runEvidence(run);\n\t\tif (evidence && evidence !== intent)\n\t\t\tlines.push(`${theme.fg(\"accent\", \"›\")} ${theme.fg(\"dim\", excerpt(evidence, LIVE_TAIL_LIMIT))}`);\n\t\tconst tail = liveTail(run);\n\t\tif (tail) lines.push(theme.fg(\"dim\", tail));\n\t\treturn lines;\n\t}\n\tif (run.error) return [theme.fg(\"error\", excerpt(run.error, SINGLE_EXCERPT_LIMIT))];\n\tif (run.finalOutput) return [theme.fg(\"toolOutput\", leadingSentences(run.finalOutput, SINGLE_EXCERPT_LIMIT))];\n\treturn [theme.fg(\"muted\", run.status)];\n}\n\nfunction activitySummaryText(details: SubagentDetails): string {\n\tconst toolCounts = new Map<string, number>();\n\tlet activityCount = 0;\n\tfor (const run of details.runs) {\n\t\tfor (const activity of run.activities) {\n\t\t\tactivityCount++;\n\t\t\ttoolCounts.set(activity.toolName, (toolCounts.get(activity.toolName) ?? 0) + 1);\n\t\t}\n\t}\n\tconst items: string[] = [];\n\tlet groupedCount = 0;\n\tfor (const group of ACTIVITY_GROUPS) {\n\t\tconst count = toolCounts.get(group.toolName) ?? 0;\n\t\tif (count === 0) continue;\n\t\tgroupedCount += count;\n\t\titems.push(`${group.verb} ${count} ${count === 1 ? group.singular : group.plural}`);\n\t}\n\tconst otherCount = activityCount - groupedCount;\n\tif (otherCount > 0) items.push(`used ${otherCount} other tool${otherCount === 1 ? \"\" : \"s\"}`);\n\tif (items.length === 0) return \"\";\n\tconst earlierCount = Math.max(0, details.usage.toolUses - activityCount);\n\tif (earlierCount > 0) items.push(`${earlierCount} earlier tool use${earlierCount === 1 ? \"\" : \"s\"}`);\n\treturn `${items[0]![0]!.toUpperCase()}${items[0]!.slice(1)}${items\n\t\t.slice(1)\n\t\t.map((item) => ` · ${item}`)\n\t\t.join(\"\")}`;\n}\n\nfunction usageText(details: SubagentDetails, includeToolUses = true): string {\n\tconst usage = details.usage;\n\tconst items: string[] = [];\n\tif (includeToolUses && usage.toolUses) items.push(`${usage.toolUses} tool use${usage.toolUses === 1 ? \"\" : \"s\"}`);\n\tif (usage.turns) items.push(`${usage.turns} turn${usage.turns === 1 ? \"\" : \"s\"}`);\n\tif (usage.output) items.push(`↓${formatTokens(usage.output)}`);\n\tif (usage.contextTokens) items.push(`ctx:${formatTokens(usage.contextTokens)}`);\n\tif (usage.cost) items.push(`$${usage.cost.toFixed(3)}`);\n\tconst duration = details.endedAt ? Math.max(0, (details.endedAt - details.startedAt) / 1000) : undefined;\n\tif (duration !== undefined) items.push(formatDuration(duration));\n\treturn items.join(\" · \");\n}\n\nfunction formatTokens(value: number): string {\n\tif (value < 1_000) return String(value);\n\tif (value < 1_000_000) return `${(value / 1_000).toFixed(value < 10_000 ? 1 : 0)}k`;\n\treturn `${(value / 1_000_000).toFixed(1)}M`;\n}\n\nfunction formatDuration(seconds: number): string {\n\tif (seconds >= 90) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;\n\treturn `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;\n}\n\n// A single run's title and usage would duplicate the call header and\n// the call-level usage line, so `single` drops both and folds the\n// duration into the metadata line instead. Multi-run sections carry a\n// numbered `──` header that matches the table of contents above them.\nfunction renderRunDetails(run: SubagentRunDetails, theme: Theme, single: boolean, index?: number): Component {\n\tconst container = new Container();\n\tconst duration =\n\t\trun.startedAt !== undefined && run.endedAt !== undefined\n\t\t\t? formatDuration(Math.max(0, (run.endedAt - run.startedAt) / 1000))\n\t\t\t: undefined;\n\tif (!single) {\n\t\tconst label = index === undefined ? \"\" : `${index} · `;\n\t\tcontainer.addChild(\n\t\t\tnew Text(\n\t\t\t\t`${theme.fg(\"dim\", `── ${label}`)}${theme.fg(\"accent\", displayAgentName(run.agent))}${theme.fg(\"dim\", ` · ${truncate(run.description, 48)}`)}`,\n\t\t\t\t0,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t}\n\tcontainer.addChild(new Text(theme.fg(\"muted\", \"Task\"), 0, 0));\n\tcontainer.addChild(new Text(theme.fg(\"dim\", run.prompt), 0, 0));\n\tif (run.activities.length > 0) {\n\t\tcontainer.addChild(new Spacer(1));\n\t\tcontainer.addChild(new Text(theme.fg(\"muted\", \"Activity\"), 0, 0));\n\t\tfor (const activity of run.activities) {\n\t\t\tconst marker =\n\t\t\t\tactivity.status === \"succeeded\"\n\t\t\t\t\t? theme.fg(\"success\", \"✓\")\n\t\t\t\t\t: activity.status === \"failed\"\n\t\t\t\t\t\t? theme.fg(\"error\", \"×\")\n\t\t\t\t\t\t: theme.fg(\"accent\", \"›\");\n\t\t\tlet text = `${marker} ${theme.fg(\"toolOutput\", excerpt(activity.summary, 96))}`;\n\t\t\tif (activity.resultSummary) {\n\t\t\t\tconst color = activity.status === \"failed\" ? \"error\" : \"dim\";\n\t\t\t\ttext += ` ${theme.fg(color, `· ${excerpt(activity.resultSummary, 64)}`)}`;\n\t\t\t}\n\t\t\tif (activity.endedAt !== undefined && activity.endedAt - activity.startedAt >= 1_000) {\n\t\t\t\ttext += ` ${theme.fg(\"dim\", `· ${formatDuration((activity.endedAt - activity.startedAt) / 1000)}`)}`;\n\t\t\t}\n\t\t\tcontainer.addChild(new Text(text, 0, 0));\n\t\t}\n\t}\n\t// The live tail is the \"now\" end of the timeline, so it sits after the\n\t// activity list; once the run settles, Response takes its place.\n\tif (run.status === \"running\" && run.liveText) {\n\t\tconst tail = run.liveText\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => line.trim())\n\t\t\t.filter(Boolean)\n\t\t\t.slice(-4);\n\t\tif (tail.length > 0) {\n\t\t\tcontainer.addChild(new Spacer(1));\n\t\t\tfor (const line of tail) {\n\t\t\t\tcontainer.addChild(new Text(theme.fg(\"dim\", excerpt(line, LIVE_TAIL_LIMIT)), 0, 0));\n\t\t\t}\n\t\t}\n\t}\n\tif (run.error) {\n\t\tcontainer.addChild(new Spacer(1));\n\t\tcontainer.addChild(new Text(theme.fg(\"error\", `Error: ${run.error}`), 0, 0));\n\t}\n\tif (run.finalOutput) {\n\t\tcontainer.addChild(new Spacer(1));\n\t\tcontainer.addChild(new Text(theme.fg(\"muted\", \"Response\"), 0, 0));\n\t\tcontainer.addChild(new Markdown(run.finalOutput, 0, 0, getMarkdownTheme()));\n\t}\n\tconst metadata = [\n\t\t`${run.model} · ${run.thinking}`,\n\t\trun.cwd ? `cwd: ${run.cwd}` : undefined,\n\t\trun.usage.toolUses ? `${run.usage.toolUses} tool use${run.usage.toolUses === 1 ? \"\" : \"s\"}` : undefined,\n\t\trun.usage.turns ? `${run.usage.turns} turn${run.usage.turns === 1 ? \"\" : \"s\"}` : undefined,\n\t\trun.usage.output ? `↓${formatTokens(run.usage.output)}` : undefined,\n\t\trun.usage.contextTokens ? `ctx:${formatTokens(run.usage.contextTokens)}` : undefined,\n\t\trun.usage.cost ? `$${run.usage.cost.toFixed(3)}` : undefined,\n\t\tsingle ? duration : undefined,\n\t]\n\t\t.filter((item): item is string => Boolean(item))\n\t\t.join(\" · \");\n\tif (metadata) {\n\t\tcontainer.addChild(new Spacer(1));\n\t\tcontainer.addChild(new Text(theme.fg(\"dim\", metadata), 0, 0));\n\t}\n\treturn container;\n}\n\n// The call header stays a single line: run rows in the result area take\n// over within the first update and carry richer per-task state, so a\n// task list here would render everything twice. The task count alone\n// distinguishes a batch from a single delegation.\nexport function renderSubagentCall(args: SubagentParams, theme: Theme): Component {\n\tconst modes = [args.prompt != null && \"prompt\", args.tasks != null && \"tasks\"].filter((mode): mode is string =>\n\t\tBoolean(mode),\n\t);\n\tif (modes.length > 1) {\n\t\treturn new Text(\n\t\t\t`${theme.fg(\"toolTitle\", theme.bold(\"Subagent \"))}${theme.fg(\"error\", `invalid · ${modes.join(\" + \")}`)}`,\n\t\t\t0,\n\t\t\t0,\n\t\t);\n\t}\n\tif (args.tasks) {\n\t\treturn new Text(\n\t\t\t`${theme.fg(\"toolTitle\", theme.bold(\"Multi-Agent\"))}${theme.fg(\"dim\", ` · ${args.tasks.length} task${args.tasks.length === 1 ? \"\" : \"s\"}`)}`,\n\t\t\t0,\n\t\t\t0,\n\t\t);\n\t}\n\tlet text = theme.fg(\"toolTitle\", theme.bold(`${singleAgentName(args)} Agent`));\n\tif (args.description) text += theme.fg(\"dim\", ` · ${truncate(args.description, 72)}`);\n\treturn new Text(text, 0, 0);\n}\n\nexport function renderSubagentResult(\n\tresult: AgentToolResult<SubagentDetails>,\n\toptions: ToolRenderResultOptions,\n\ttheme: Theme,\n\tisError: boolean,\n): Component {\n\tconst details = result.details;\n\tif (!details) {\n\t\tconst text = result.content.find((part) => part.type === \"text\");\n\t\treturn new Text(text?.type === \"text\" ? text.text : \"(no output)\", 0, 0);\n\t}\n\tif (!options.expanded) {\n\t\tconst lines: string[] = [];\n\t\tif (details.mode === \"single\") {\n\t\t\tlines.push(...singleCollapsedLines(details, theme));\n\t\t} else {\n\t\t\tlet header = statusSummary(details);\n\t\t\tif (options.isPartial) {\n\t\t\t\theader += ` · ${formatDuration(Math.max(0, (Date.now() - details.startedAt) / 1000))}`;\n\t\t\t}\n\t\t\tlines.push(theme.fg(isError ? \"error\" : options.isPartial ? \"accent\" : \"muted\", header));\n\t\t\tconst shown = selectCollapsedRuns(details.runs, Boolean(options.isPartial));\n\t\t\tfor (const run of shown) lines.push(runLine(run, theme));\n\t\t\tconst hidden = details.runs.length - shown.length;\n\t\t\tif (hidden > 0) lines.push(theme.fg(\"muted\", `+${hidden} more`));\n\t\t}\n\t\tconst activitySummary = activitySummaryText(details);\n\t\tif (activitySummary) lines.push(theme.fg(\"dim\", activitySummary));\n\t\tconst usage = usageText(details, !activitySummary);\n\t\tif (usage) lines.push(theme.fg(\"dim\", usage));\n\t\tif (!options.isPartial) lines.push(theme.fg(\"muted\", `(${keyHint(\"app.tools.expand\", \"to expand\")})`));\n\t\treturn new Text(lines.join(\"\\n\"), 0, 0);\n\t}\n\tconst container = new Container();\n\tconst single = details.mode === \"single\" && details.runs.length === 1;\n\tif (!single) {\n\t\tcontainer.addChild(new Text(theme.fg(isError ? \"error\" : \"toolTitle\", statusSummary(details)), 0, 0));\n\t\t// Numbered table of contents: the TUI cannot jump, but matching the\n\t\t// numbers against the `──` section headers below makes an 8-run\n\t\t// batch navigable by eye.\n\t\tdetails.runs.forEach((run, index) => {\n\t\t\tcontainer.addChild(new Text(runLine(run, theme, index + 1), 0, 0));\n\t\t});\n\t}\n\tdetails.runs.forEach((run, index) => {\n\t\tif (!single || index > 0) container.addChild(new Spacer(1));\n\t\tcontainer.addChild(renderRunDetails(run, theme, single, single ? undefined : index + 1));\n\t});\n\tif (!single) {\n\t\tconst usage = usageText(details);\n\t\tif (usage) {\n\t\t\tcontainer.addChild(new Spacer(1));\n\t\t\tcontainer.addChild(new Text(theme.fg(\"dim\", usage), 0, 0));\n\t\t}\n\t}\n\treturn container;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../../src/extensions/subagent/render.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAqC,MAAM,wBAAwB,CAAC;AAC3F,OAAO,KAAK,EAAE,eAAe,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AAE/F,OAAO,EAAoB,KAAK,KAAK,EAAE,MAAM,wCAAwC,CAAC;AAEtF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,KAAK,EAAE,eAAe,EAAyC,MAAM,YAAY,CAAC;AAuezF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,cAAc,EAAE,KAAK,EAAE,KAAK,GAAG,SAAS,CAqBhF;AAED,wBAAgB,oBAAoB,CACnC,MAAM,EAAE,eAAe,CAAC,eAAe,CAAC,EACxC,OAAO,EAAE,uBAAuB,EAChC,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,OAAO,GACd,SAAS,CA0DX","sourcesContent":["import { type Component, Container, Markdown, Spacer, Text } from \"@earendil-works/pi-tui\";\nimport type { AgentToolResult, ToolRenderResultOptions } from \"../../core/extensions/types.ts\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.ts\";\nimport { getMarkdownTheme, type Theme } from \"../../modes/interactive/theme/theme.ts\";\nimport { statusSummary } from \"./runner.ts\";\nimport type { SubagentParams } from \"./schema.ts\";\nimport type { SubagentDetails, SubagentRunDetails, SubagentRunStatus } from \"./types.ts\";\n\nconst SINGLE_EXCERPT_LIMIT = 200;\nconst RUN_LINE_EXCERPT_LIMIT = 64;\nconst LIVE_TAIL_LIMIT = 100;\nconst COLLAPSED_RUN_LIMIT = 4;\nconst PROMPT_PREVIEW_LINES = 2;\nconst PROMPT_PREVIEW_LINE_LIMIT = 120;\nconst ACTIVITY_SUMMARY_LIMIT = 96;\nconst ACTIVITY_RESULT_LIMIT = 64;\n// Sub-10s tool calls are routine; a duration only earns ink when it\n// explains where the time went.\nconst ACTIVITY_DURATION_MIN_MS = 10_000;\nconst EXPANDED_LIVE_TAIL_LINES = 3;\n\n// Display-only: profile names stay lowercase everywhere the model sees\n// them; the transcript shows \"general\" as \"General\", \"code-reviewer\" as\n// \"Code Reviewer\".\nfunction displayAgentName(name: string): string {\n\treturn name\n\t\t.split(/[-_]/u)\n\t\t.filter(Boolean)\n\t\t.map((word) => `${word[0]?.toUpperCase() ?? \"\"}${word.slice(1)}`)\n\t\t.join(\" \");\n}\n\nfunction singleAgentName(args: { agent?: string | null }): string {\n\treturn displayAgentName(args.agent ?? \"general\");\n}\n\nfunction truncate(text: string, limit: number): string {\n\treturn text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;\n}\n\n// Transcript excerpts render inside plain Text components, so markdown\n// punctuation would show up literally; strip the common inline syntax.\nfunction stripInlineMarkdown(text: string): string {\n\treturn text\n\t\t.replace(/```[a-zA-Z0-9-]*/gu, \"\")\n\t\t.replace(/`([^`]*)`/gu, \"$1\")\n\t\t.replace(/\\*\\*([^*]+)\\*\\*/gu, \"$1\")\n\t\t.replace(/__([^_]+)__/gu, \"$1\")\n\t\t.replace(/\\[([^\\]]+)\\]\\([^)]*\\)/gu, \"$1\")\n\t\t.replace(/^#{1,6}\\s+/gmu, \"\");\n}\n\n// Markdown tables and horizontal rules turn into symbol soup when\n// flattened onto one line; drop those lines before inline stripping.\nfunction stripBlockMarkdown(text: string): string {\n\treturn text\n\t\t.split(\"\\n\")\n\t\t.filter((line) => {\n\t\t\tconst trimmed = line.trim();\n\t\t\tif (trimmed.startsWith(\"|\")) return false;\n\t\t\treturn !(trimmed.length >= 3 && /^[-—─═=*_|:\\s]+$/u.test(trimmed));\n\t\t})\n\t\t.join(\"\\n\");\n}\n\n// finalOutput/liveText carry model-facing truncation notices from\n// boundText/tailText. Only recognize their exact boundary lines so literal\n// discussion of the marker remains visible in the transcript.\nconst OUTPUT_TRUNCATION_NOTICE_PATTERN = /^\\[Output truncated(?:: \\d+ bytes omitted)?\\.\\]$/u;\nconst EARLIER_OUTPUT_NOTICE = \"[Earlier output omitted.]\";\n\nfunction terminalOutputNotice(text: string): { text: string; truncated: boolean } {\n\tconst lines = text.split(\"\\n\");\n\tlet index = lines.length - 1;\n\twhile (index >= 0 && !lines[index]?.trim()) index--;\n\tif (index < 0 || !OUTPUT_TRUNCATION_NOTICE_PATTERN.test(lines[index]?.trim() ?? \"\")) {\n\t\treturn { text, truncated: false };\n\t}\n\tlines.splice(index, 1);\n\treturn { text: lines.join(\"\\n\").trimEnd(), truncated: true };\n}\n\nfunction replaceBoundaryNotices(text: string): string {\n\tconst lines = text.split(\"\\n\");\n\tconst first = lines.findIndex((line) => Boolean(line.trim()));\n\tif (first >= 0 && lines[first]?.trim() === EARLIER_OUTPUT_NOTICE) lines[first] = \"…\";\n\tlet last = lines.length - 1;\n\twhile (last >= 0 && !lines[last]?.trim()) last--;\n\tif (last >= 0 && OUTPUT_TRUNCATION_NOTICE_PATTERN.test(lines[last]?.trim() ?? \"\")) lines[last] = \"…\";\n\treturn lines.join(\"\\n\");\n}\n\nfunction plainExcerpt(text: string, limit: number): string {\n\treturn truncate(stripInlineMarkdown(stripBlockMarkdown(text)).replace(/\\s+/gu, \" \").trim(), limit);\n}\n\nfunction excerpt(text: string, limit: number): string {\n\treturn plainExcerpt(replaceBoundaryNotices(text), limit);\n}\n\n// Generated notice-only boundary lines must never become the live line the\n// user watches; identical text in the body remains ordinary model output.\nfunction liveTailLines(run: SubagentRunDetails): string[] {\n\tconst { text } = terminalOutputNotice(run.liveText);\n\tconst lines = text\n\t\t.split(\"\\n\")\n\t\t.map((line) => line.trim())\n\t\t.filter(Boolean);\n\tif (lines[0] === EARLIER_OUTPUT_NOTICE) lines.shift();\n\treturn lines;\n}\n\nfunction liveTail(run: SubagentRunDetails): string | undefined {\n\tconst last = liveTailLines(run).at(-1);\n\treturn last ? excerpt(last, LIVE_TAIL_LIMIT) : undefined;\n}\n\n// Completed-run excerpts read better cut at a sentence boundary than\n// mid-word; fall back to a hard truncate when no boundary lands in the\n// second half of the window.\nfunction leadingSentences(text: string, limit: number): string {\n\tconst cleaned = excerpt(text, Number.MAX_SAFE_INTEGER);\n\tif (cleaned.length <= limit) return cleaned;\n\tconst window = cleaned.slice(0, limit);\n\tlet boundary = -1;\n\tfor (const match of window.matchAll(/[.!?。!?](?=\\s|$)/gu)) {\n\t\tboundary = (match.index ?? 0) + match[0].length;\n\t}\n\tif (boundary >= limit / 2) return window.slice(0, boundary).trimEnd();\n\treturn truncate(window, limit);\n}\n\nconst VERIFY_COMMAND_PATTERN = /\\b(test|lint|typecheck|tsc|vitest|jest|pytest|build|check|fmt|format|compile)\\b/iu;\nconst INSPECT_COMMAND_PATTERN = /\\b(rg|grep|find|git (?:diff|status|log|show)|ls|tree|cat|head|tail)\\b/iu;\nconst RETRY_MARKER_PATTERN = /^Retrying\\b/u;\nconst STREAM_MARKERS = new Set([\"Thinking…\", \"Writing response…\"]);\n\n// Translates raw tool traffic into what the worker is doing right now, so\n// the collapsed card reads as a phase instead of a command line. Modelled\n// on tool-type classification; the raw tool summary stays as evidence on\n// the next line.\nfunction runIntent(run: SubagentRunDetails): string {\n\tconst current = run.currentActivity;\n\tif (current && RETRY_MARKER_PATTERN.test(current)) return current;\n\tconst running = [...run.activities].reverse().find((activity) => activity.status === \"running\");\n\tif (running) {\n\t\tif (running.toolName === \"edit\" || running.toolName === \"write\") return \"Applying changes\";\n\t\tif (running.toolName === \"bash\") {\n\t\t\tif (VERIFY_COMMAND_PATTERN.test(running.summary)) return \"Verifying changes\";\n\t\t\treturn INSPECT_COMMAND_PATTERN.test(running.summary) ? \"Exploring code\" : \"Running commands\";\n\t\t}\n\t\treturn \"Exploring code\";\n\t}\n\tconst recent = run.activities.slice(-5);\n\tif (current && STREAM_MARKERS.has(current)) {\n\t\tif (recent.some((activity) => activity.status === \"failed\")) return \"Investigating a failure\";\n\t\treturn current;\n\t}\n\tif (recent.some((activity) => activity.status === \"failed\")) return \"Investigating a failure\";\n\tif (recent.some((activity) => activity.toolName === \"edit\" || activity.toolName === \"write\"))\n\t\treturn \"Reviewing changes\";\n\tif (recent.length > 0) return \"Exploring code\";\n\treturn current ?? \"Thinking…\";\n}\n\n// The raw tool summary backs up the intent line; suppress it when it would\n// just repeat a stream/retry marker.\nfunction runEvidence(run: SubagentRunDetails): string | undefined {\n\tconst current = run.currentActivity;\n\tif (!current || STREAM_MARKERS.has(current) || RETRY_MARKER_PATTERN.test(current)) return undefined;\n\treturn current;\n}\n\nfunction liveElapsed(run: SubagentRunDetails): string | undefined {\n\tif (run.startedAt === undefined) return undefined;\n\tconst end = run.endedAt ?? Date.now();\n\treturn formatDuration(Math.max(0, (end - run.startedAt) / 1000));\n}\n\nfunction statusColor(status: SubagentRunStatus): \"success\" | \"error\" | \"warning\" | \"accent\" | \"muted\" {\n\tswitch (status) {\n\t\tcase \"completed\":\n\t\t\treturn \"success\";\n\t\tcase \"failed\":\n\t\t\treturn \"error\";\n\t\tcase \"aborted\":\n\t\t\treturn \"warning\";\n\t\tcase \"running\":\n\t\t\treturn \"accent\";\n\t\tdefault:\n\t\t\treturn \"muted\";\n\t}\n}\n\n// The tool shell already paints a call-level status dot (● in\n// warning/success/error), so `●` is off-limits here: `›` marks where the\n// work currently is, and the remaining glyphs are terminal states.\nfunction statusMarker(status: SubagentRunStatus, theme: Theme): string {\n\tconst glyph =\n\t\tstatus === \"completed\"\n\t\t\t? \"✓\"\n\t\t\t: status === \"failed\"\n\t\t\t\t? \"×\"\n\t\t\t\t: status === \"aborted\"\n\t\t\t\t\t? \"■\"\n\t\t\t\t\t: status === \"running\"\n\t\t\t\t\t\t? \"›\"\n\t\t\t\t\t\t: \"○\";\n\treturn theme.fg(statusColor(status), glyph);\n}\n\nfunction statusWord(status: SubagentRunStatus): string {\n\tswitch (status) {\n\t\tcase \"completed\":\n\t\t\treturn \"Completed\";\n\t\tcase \"failed\":\n\t\t\treturn \"Failed\";\n\t\tcase \"aborted\":\n\t\t\treturn \"Aborted\";\n\t\tcase \"running\":\n\t\t\treturn \"Running\";\n\t\tdefault:\n\t\t\treturn \"Queued\";\n\t}\n}\n\nfunction runTitle(run: SubagentRunDetails, theme: Theme, index?: number): string {\n\tconst number = index === undefined ? \"\" : theme.fg(\"dim\", `${index} · `);\n\treturn `${statusMarker(run.status, theme)} ${number}${theme.fg(\"accent\", displayAgentName(run.agent))}${theme.fg(\"dim\", ` · ${truncate(run.description, 48)}`)}`;\n}\n\n// Mid-run token totals are cache-inflated (totalTokens sums cache reads\n// across turns), so live lines quote tool uses and the context watermark\n// instead; settled lines get the real total.\nfunction runProgressText(run: SubagentRunDetails): string {\n\tconst items = [`${run.usage.toolUses} tool use${run.usage.toolUses === 1 ? \"\" : \"s\"}`];\n\tif (run.usage.contextTokens) items.push(`ctx:${formatTokens(run.usage.contextTokens)}`);\n\treturn items.join(\" · \");\n}\n\nfunction runLine(run: SubagentRunDetails, theme: Theme, index?: number): string {\n\tlet line = runTitle(run, theme, index);\n\tconst detail =\n\t\trun.status === \"running\"\n\t\t\t? runIntent(run)\n\t\t\t: run.status === \"failed\" || run.status === \"aborted\"\n\t\t\t\t? run.error && excerpt(run.error, RUN_LINE_EXCERPT_LIMIT)\n\t\t\t\t: run.finalOutput && excerpt(run.finalOutput, RUN_LINE_EXCERPT_LIMIT);\n\tif (detail) line += theme.fg(run.status === \"failed\" ? \"error\" : \"dim\", ` — ${detail}`);\n\tif (run.status === \"running\") {\n\t\tline += theme.fg(\"dim\", ` · ${runProgressText(run)}`);\n\t\tconst elapsed = liveElapsed(run);\n\t\tif (elapsed) line += theme.fg(\"dim\", ` · ${elapsed}`);\n\t}\n\tconst settled = run.status !== \"running\" && run.status !== \"queued\";\n\tif (settled && run.startedAt !== undefined && run.endedAt !== undefined) {\n\t\tline += theme.fg(\"dim\", ` · ${formatDuration(Math.max(0, (run.endedAt - run.startedAt) / 1000))}`);\n\t}\n\treturn line;\n}\n\n// Keep active work visible while a batch is in flight: completed runs\n// may otherwise crowd a still-running task out of the collapsed window.\nfunction selectCollapsedRuns(runs: SubagentRunDetails[], isPartial: boolean): SubagentRunDetails[] {\n\tif (runs.length <= COLLAPSED_RUN_LIMIT) return runs;\n\tif (!isPartial) return runs.slice(0, COLLAPSED_RUN_LIMIT);\n\tconst active = runs.filter((run) => run.status === \"running\" || run.status === \"queued\");\n\tconst settled = runs.filter((run) => run.status !== \"running\" && run.status !== \"queued\");\n\treturn [...active, ...settled].slice(0, COLLAPSED_RUN_LIMIT);\n}\n\nfunction singleCollapsedLines(details: SubagentDetails, theme: Theme): string[] {\n\tconst run = details.runs[0];\n\tif (!run) return [theme.fg(\"muted\", \"Initializing…\")];\n\tif (run.status === \"running\" || run.status === \"queued\") {\n\t\tconst isInitializing =\n\t\t\trun.status === \"running\" && run.usage.turns === 0 && run.usage.toolUses === 0 && run.liveText.length === 0;\n\t\tconst intent =\n\t\t\trun.status === \"queued\"\n\t\t\t\t? (run.currentActivity ?? \"queued\")\n\t\t\t\t: isInitializing\n\t\t\t\t\t? \"Initializing…\"\n\t\t\t\t\t: runIntent(run);\n\t\t// No status marker here: the shell's call-level dot already covers\n\t\t// the whole card, and a single run has nothing to disambiguate.\n\t\tlet status = theme.fg(\"dim\", intent);\n\t\tif (run.status === \"running\" && !isInitializing) {\n\t\t\tstatus += theme.fg(\"dim\", ` · ${runProgressText(run)}`);\n\t\t\tif (run.usage.cost) status += theme.fg(\"dim\", ` · $${run.usage.cost.toFixed(3)}`);\n\t\t\tconst elapsed = liveElapsed(run);\n\t\t\tif (elapsed) status += theme.fg(\"dim\", ` · ${elapsed}`);\n\t\t}\n\t\tconst lines = [status];\n\t\tconst evidence = runEvidence(run);\n\t\tif (evidence && evidence !== intent)\n\t\t\tlines.push(`${theme.fg(\"accent\", \"›\")} ${theme.fg(\"dim\", excerpt(evidence, LIVE_TAIL_LIMIT))}`);\n\t\tconst tail = liveTail(run);\n\t\tif (tail) lines.push(theme.fg(\"dim\", tail));\n\t\treturn lines;\n\t}\n\tif (run.error) return [theme.fg(\"error\", excerpt(run.error, SINGLE_EXCERPT_LIMIT))];\n\tif (run.finalOutput) return [theme.fg(\"toolOutput\", leadingSentences(run.finalOutput, SINGLE_EXCERPT_LIMIT))];\n\treturn [theme.fg(\"muted\", run.status)];\n}\n\n// Deep-trimmed settled footer: turns and per-run breakdowns belong to the\n// expanded view. Parallel batches keep only whole-call numbers — the run\n// rows above already localize the work.\nfunction collapsedUsageText(details: SubagentDetails, extended: boolean): string {\n\tconst usage = details.usage;\n\tconst items: string[] = [];\n\tif (usage.totalTokens) items.push(`${formatTokens(usage.totalTokens)} tok`);\n\tif (extended && usage.toolUses) items.push(`${usage.toolUses} tool use${usage.toolUses === 1 ? \"\" : \"s\"}`);\n\tif (extended && usage.contextTokens) items.push(`ctx:${formatTokens(usage.contextTokens)}`);\n\tif (usage.cost) items.push(`$${usage.cost.toFixed(3)}`);\n\tif (details.endedAt !== undefined) {\n\t\titems.push(formatDuration(Math.max(0, (details.endedAt - details.startedAt) / 1000)));\n\t}\n\treturn items.join(\" · \");\n}\n\nfunction batchUsageText(details: SubagentDetails, isPartial: boolean): string {\n\tconst usage = details.usage;\n\tconst items: string[] = [];\n\t// The aggregate mixes settled and running runs while partial, and the\n\t// running share is cache-inflated — quote the total only once settled.\n\tif (!isPartial && usage.totalTokens) items.push(`${formatTokens(usage.totalTokens)} tok`);\n\tif (usage.toolUses) items.push(`${usage.toolUses} tool use${usage.toolUses === 1 ? \"\" : \"s\"}`);\n\tif (usage.turns) items.push(`${usage.turns} turn${usage.turns === 1 ? \"\" : \"s\"}`);\n\tif (usage.contextTokens) items.push(`ctx:${formatTokens(usage.contextTokens)}`);\n\tif (usage.cost) items.push(`$${usage.cost.toFixed(3)}`);\n\tif (details.endedAt !== undefined) {\n\t\titems.push(formatDuration(Math.max(0, (details.endedAt - details.startedAt) / 1000)));\n\t}\n\treturn items.join(\" · \");\n}\n\nfunction formatTokens(value: number): string {\n\tif (value < 1_000) return String(value);\n\tif (value < 1_000_000) return `${(value / 1_000).toFixed(value < 10_000 ? 1 : 0)}k`;\n\treturn `${(value / 1_000_000).toFixed(1)}M`;\n}\n\nfunction formatDuration(seconds: number): string {\n\tif (seconds >= 90) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;\n\treturn `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;\n}\n\nfunction runDurationText(run: SubagentRunDetails): string | undefined {\n\tif (run.startedAt === undefined || run.endedAt === undefined) return undefined;\n\treturn formatDuration(Math.max(0, (run.endedAt - run.startedAt) / 1000));\n}\n\n// One dim line per run: what it cost. Settled runs quote the real token\n// total; running runs skip it (cache-inflated mid-run) and tick elapsed\n// time instead. Batch sections fold model/thinking/cwd in here because\n// their `──` header already carries verdict and duration.\nfunction runMetricsText(run: SubagentRunDetails, includeIdentity: boolean): string {\n\tconst usage = run.usage;\n\tconst settled = run.status !== \"running\" && run.status !== \"queued\";\n\tconst items: string[] = [];\n\tif (includeIdentity) {\n\t\titems.push(run.model);\n\t\tif (run.thinking !== \"off\") items.push(run.thinking);\n\t\tif (run.cwd) items.push(`cwd: ${run.cwd}`);\n\t}\n\tif (settled && usage.totalTokens) items.push(`${formatTokens(usage.totalTokens)} tok`);\n\tif (usage.toolUses) items.push(`${usage.toolUses} tool use${usage.toolUses === 1 ? \"\" : \"s\"}`);\n\tif (usage.turns) items.push(`${usage.turns} turn${usage.turns === 1 ? \"\" : \"s\"}`);\n\tif (usage.contextTokens) items.push(`ctx:${formatTokens(usage.contextTokens)}`);\n\tif (usage.cost) items.push(`$${usage.cost.toFixed(3)}`);\n\tconst duration = settled ? (includeIdentity ? undefined : runDurationText(run)) : liveElapsed(run);\n\tif (duration) items.push(duration);\n\treturn items.join(\" · \");\n}\n\n// The details prompt is a 1KB bounded copy of the briefing; the preview\n// shows the first content lines (blank lines carry nothing at two lines\n// of budget) and never pretends to more fidelity than that.\nfunction promptSection(run: SubagentRunDetails, theme: Theme): string[] {\n\tconst boundedPrompt = terminalOutputNotice(run.prompt);\n\tconst lines = boundedPrompt.text\n\t\t.split(\"\\n\")\n\t\t.map((line) => plainExcerpt(line.trim(), Number.MAX_SAFE_INTEGER))\n\t\t.filter(Boolean);\n\tconst shown = lines.slice(0, PROMPT_PREVIEW_LINES);\n\tconst preview = shown.map((line) => theme.fg(\"dim\", truncate(line, PROMPT_PREVIEW_LINE_LIMIT)));\n\t// A one-line briefing clipped mid-sentence must still say it continues.\n\tconst clipped = shown.some((line) => line.length > PROMPT_PREVIEW_LINE_LIMIT);\n\tconst remaining = lines.length - shown.length;\n\tconst capped = boundedPrompt.truncated;\n\tconst section = [theme.fg(\"muted\", \"Prompt\"), ...preview];\n\tif (remaining > 0 || capped || clipped) {\n\t\tlet note = \"… continues\";\n\t\tif (remaining > 0) note += `, ${remaining} more line${remaining === 1 ? \"\" : \"s\"}`;\n\t\tif (capped) note += \" · capped at 1KB\";\n\t\tsection.push(theme.fg(\"muted\", note));\n\t}\n\treturn section;\n}\n\n// Each run expands into a report cover sheet: verdict and cost up top, a\n// bounded Prompt/Activity digest in the middle, then the full Report —\n// the one unbounded element, placed last so the card ends where reading\n// begins. Single runs skip the `──` header (the call header already names\n// them); batch sections carry a numbered header matching the contents\n// list above.\nfunction renderRunDetails(run: SubagentRunDetails, theme: Theme, single: boolean, index?: number): Component {\n\tconst container = new Container();\n\tconst settled = run.status !== \"running\" && run.status !== \"queued\";\n\tif (single) {\n\t\tconst identity = [run.model];\n\t\tif (run.thinking !== \"off\") identity.push(`${run.thinking} thinking`);\n\t\tif (run.cwd) identity.push(`cwd: ${run.cwd}`);\n\t\tcontainer.addChild(\n\t\t\tnew Text(\n\t\t\t\t`${statusMarker(run.status, theme)} ${theme.fg(statusColor(run.status), statusWord(run.status))}${theme.fg(\"dim\", ` · ${identity.join(\" · \")}`)}`,\n\t\t\t\t0,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t} else {\n\t\tconst label = index === undefined ? \"\" : `${index} `;\n\t\tconst duration = settled ? runDurationText(run) : undefined;\n\t\tcontainer.addChild(\n\t\t\tnew Text(\n\t\t\t\t`${theme.fg(\"dim\", `── ${label}`)}${statusMarker(run.status, theme)} ${theme.fg(\"accent\", displayAgentName(run.agent))}${theme.fg(\"dim\", ` · ${truncate(run.description, 48)}`)}${duration ? theme.fg(\"dim\", ` · ${duration}`) : \"\"}`,\n\t\t\t\t0,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t}\n\tif (run.status !== \"queued\") {\n\t\tconst metrics = runMetricsText(run, !single);\n\t\tif (metrics) container.addChild(new Text(theme.fg(\"dim\", metrics), 0, 0));\n\t}\n\tcontainer.addChild(new Spacer(1));\n\tfor (const line of promptSection(run, theme)) container.addChild(new Text(line, 0, 0));\n\tif (run.activities.length > 0) {\n\t\tcontainer.addChild(new Spacer(1));\n\t\tconst total = run.usage.toolUses;\n\t\tconst label = total > run.activities.length ? `Activity · last ${run.activities.length} of ${total}` : \"Activity\";\n\t\tcontainer.addChild(new Text(theme.fg(\"muted\", label), 0, 0));\n\t\tfor (const activity of run.activities) {\n\t\t\t// Deviation earns ink: × and › are marked, success rows are quiet\n\t\t\t// one-liners without result echoes.\n\t\t\tlet text: string;\n\t\t\tif (activity.status === \"failed\") {\n\t\t\t\ttext = `${theme.fg(\"error\", \"×\")} ${theme.fg(\"toolOutput\", excerpt(activity.summary, ACTIVITY_SUMMARY_LIMIT))}`;\n\t\t\t\tif (activity.resultSummary) {\n\t\t\t\t\ttext += ` ${theme.fg(\"error\", `· ${excerpt(activity.resultSummary, ACTIVITY_RESULT_LIMIT)}`)}`;\n\t\t\t\t}\n\t\t\t} else if (activity.status === \"running\") {\n\t\t\t\ttext = `${theme.fg(\"accent\", \"›\")} ${theme.fg(\"toolOutput\", excerpt(activity.summary, ACTIVITY_SUMMARY_LIMIT))}`;\n\t\t\t} else {\n\t\t\t\ttext = theme.fg(\"toolOutput\", excerpt(activity.summary, ACTIVITY_SUMMARY_LIMIT));\n\t\t\t}\n\t\t\tif (activity.endedAt !== undefined && activity.endedAt - activity.startedAt >= ACTIVITY_DURATION_MIN_MS) {\n\t\t\t\ttext += ` ${theme.fg(\"dim\", `· ${formatDuration((activity.endedAt - activity.startedAt) / 1000)}`)}`;\n\t\t\t}\n\t\t\tcontainer.addChild(new Text(text, 0, 0));\n\t\t}\n\t}\n\t// The live tail is the \"now\" end of the timeline; once the run settles,\n\t// Report takes this slot without moving anything above it.\n\tif (run.status === \"running\" && run.liveText) {\n\t\tconst tail = liveTailLines(run).slice(-EXPANDED_LIVE_TAIL_LINES);\n\t\tif (tail.length > 0) {\n\t\t\tcontainer.addChild(new Spacer(1));\n\t\t\tcontainer.addChild(new Text(theme.fg(\"muted\", \"Working\"), 0, 0));\n\t\t\tfor (const line of tail) {\n\t\t\t\tcontainer.addChild(new Text(theme.fg(\"dim\", excerpt(line, LIVE_TAIL_LIMIT)), 0, 0));\n\t\t\t}\n\t\t}\n\t}\n\tif (run.error) {\n\t\tcontainer.addChild(new Spacer(1));\n\t\tcontainer.addChild(new Text(theme.fg(\"muted\", \"Error\"), 0, 0));\n\t\tcontainer.addChild(new Text(theme.fg(\"error\", run.error), 0, 0));\n\t}\n\tif (run.finalOutput) {\n\t\tcontainer.addChild(new Spacer(1));\n\t\tconst label = settled && run.status !== \"completed\" ? \"Report · partial\" : \"Report\";\n\t\tcontainer.addChild(new Text(theme.fg(\"muted\", label), 0, 0));\n\t\tcontainer.addChild(new Markdown(run.finalOutput, 0, 0, getMarkdownTheme()));\n\t}\n\treturn container;\n}\n\n// The call header stays a single line: run rows in the result area take\n// over within the first update and carry richer per-task state, so a\n// task list here would render everything twice. The task count alone\n// distinguishes a batch from a single delegation.\nexport function renderSubagentCall(args: SubagentParams, theme: Theme): Component {\n\tconst modes = [args.prompt != null && \"prompt\", args.tasks != null && \"tasks\"].filter((mode): mode is string =>\n\t\tBoolean(mode),\n\t);\n\tif (modes.length > 1) {\n\t\treturn new Text(\n\t\t\t`${theme.fg(\"toolTitle\", theme.bold(\"Subagent \"))}${theme.fg(\"error\", `invalid · ${modes.join(\" + \")}`)}`,\n\t\t\t0,\n\t\t\t0,\n\t\t);\n\t}\n\tif (args.tasks) {\n\t\treturn new Text(\n\t\t\t`${theme.fg(\"toolTitle\", theme.bold(\"Multi-Agent\"))}${theme.fg(\"dim\", ` · ${args.tasks.length} task${args.tasks.length === 1 ? \"\" : \"s\"}`)}`,\n\t\t\t0,\n\t\t\t0,\n\t\t);\n\t}\n\tlet text = theme.fg(\"toolTitle\", theme.bold(`${singleAgentName(args)} Agent`));\n\tif (args.description) text += theme.fg(\"dim\", ` · ${truncate(args.description, 72)}`);\n\treturn new Text(text, 0, 0);\n}\n\nexport function renderSubagentResult(\n\tresult: AgentToolResult<SubagentDetails>,\n\toptions: ToolRenderResultOptions,\n\ttheme: Theme,\n\tisError: boolean,\n): Component {\n\tconst details = result.details;\n\tif (!details) {\n\t\tconst text = result.content.find((part) => part.type === \"text\");\n\t\treturn new Text(text?.type === \"text\" ? text.text : \"(no output)\", 0, 0);\n\t}\n\t// The pre-resolution first paint carries no runs yet, in either mode.\n\tif (details.runs.length === 0) {\n\t\treturn new Text(theme.fg(\"muted\", \"Initializing…\"), 0, 0);\n\t}\n\tif (!options.expanded) {\n\t\tconst lines: string[] = [];\n\t\tif (details.mode === \"single\") {\n\t\t\tlines.push(...singleCollapsedLines(details, theme));\n\t\t} else {\n\t\t\tlet header = statusSummary(details);\n\t\t\tif (options.isPartial) {\n\t\t\t\theader += ` · ${formatDuration(Math.max(0, (Date.now() - details.startedAt) / 1000))}`;\n\t\t\t}\n\t\t\tlines.push(theme.fg(isError ? \"error\" : options.isPartial ? \"accent\" : \"muted\", header));\n\t\t\tconst shown = selectCollapsedRuns(details.runs, Boolean(options.isPartial));\n\t\t\t// Stable ordinals (the run's task position, not its display slot)\n\t\t\t// keep identity readable while active-first ordering reshuffles\n\t\t\t// rows, and match the expanded section numbers.\n\t\t\tfor (const run of shown) lines.push(runLine(run, theme, details.runs.indexOf(run) + 1));\n\t\t\tconst hidden = details.runs.length - shown.length;\n\t\t\tif (hidden > 0) lines.push(theme.fg(\"muted\", `+${hidden} more`));\n\t\t}\n\t\tif (!options.isPartial) {\n\t\t\tconst usage = collapsedUsageText(details, details.mode === \"single\");\n\t\t\tif (usage) lines.push(theme.fg(\"dim\", usage));\n\t\t\tlines.push(theme.fg(\"muted\", `(${keyHint(\"app.tools.expand\", \"to expand\")})`));\n\t\t}\n\t\treturn new Text(lines.join(\"\\n\"), 0, 0);\n\t}\n\tconst container = new Container();\n\tconst single = details.mode === \"single\" && details.runs.length === 1;\n\tif (!single) {\n\t\tcontainer.addChild(new Text(theme.fg(isError ? \"error\" : \"toolTitle\", statusSummary(details)), 0, 0));\n\t\t// Numbered table of contents: the TUI cannot jump, but matching the\n\t\t// numbers against the `──` section headers below makes an 8-run\n\t\t// batch navigable by eye.\n\t\tdetails.runs.forEach((run, index) => {\n\t\t\tcontainer.addChild(new Text(runLine(run, theme, index + 1), 0, 0));\n\t\t});\n\t}\n\tdetails.runs.forEach((run, index) => {\n\t\tif (!single || index > 0) container.addChild(new Spacer(1));\n\t\tcontainer.addChild(renderRunDetails(run, theme, single, single ? undefined : index + 1));\n\t});\n\tif (!single) {\n\t\tconst usage = batchUsageText(details, Boolean(options.isPartial));\n\t\tif (usage) {\n\t\t\tcontainer.addChild(new Spacer(1));\n\t\t\tcontainer.addChild(new Text(theme.fg(\"dim\", usage), 0, 0));\n\t\t}\n\t}\n\treturn container;\n}\n"]}
|
|
@@ -6,15 +6,14 @@ const SINGLE_EXCERPT_LIMIT = 200;
|
|
|
6
6
|
const RUN_LINE_EXCERPT_LIMIT = 64;
|
|
7
7
|
const LIVE_TAIL_LIMIT = 100;
|
|
8
8
|
const COLLAPSED_RUN_LIMIT = 4;
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
];
|
|
9
|
+
const PROMPT_PREVIEW_LINES = 2;
|
|
10
|
+
const PROMPT_PREVIEW_LINE_LIMIT = 120;
|
|
11
|
+
const ACTIVITY_SUMMARY_LIMIT = 96;
|
|
12
|
+
const ACTIVITY_RESULT_LIMIT = 64;
|
|
13
|
+
// Sub-10s tool calls are routine; a duration only earns ink when it
|
|
14
|
+
// explains where the time went.
|
|
15
|
+
const ACTIVITY_DURATION_MIN_MS = 10_000;
|
|
16
|
+
const EXPANDED_LIVE_TAIL_LINES = 3;
|
|
18
17
|
// Display-only: profile names stay lowercase everywhere the model sees
|
|
19
18
|
// them; the transcript shows "general" as "General", "code-reviewer" as
|
|
20
19
|
// "Code Reviewer".
|
|
@@ -55,18 +54,54 @@ function stripBlockMarkdown(text) {
|
|
|
55
54
|
})
|
|
56
55
|
.join("\n");
|
|
57
56
|
}
|
|
57
|
+
// finalOutput/liveText carry model-facing truncation notices from
|
|
58
|
+
// boundText/tailText. Only recognize their exact boundary lines so literal
|
|
59
|
+
// discussion of the marker remains visible in the transcript.
|
|
60
|
+
const OUTPUT_TRUNCATION_NOTICE_PATTERN = /^\[Output truncated(?:: \d+ bytes omitted)?\.\]$/u;
|
|
61
|
+
const EARLIER_OUTPUT_NOTICE = "[Earlier output omitted.]";
|
|
62
|
+
function terminalOutputNotice(text) {
|
|
63
|
+
const lines = text.split("\n");
|
|
64
|
+
let index = lines.length - 1;
|
|
65
|
+
while (index >= 0 && !lines[index]?.trim())
|
|
66
|
+
index--;
|
|
67
|
+
if (index < 0 || !OUTPUT_TRUNCATION_NOTICE_PATTERN.test(lines[index]?.trim() ?? "")) {
|
|
68
|
+
return { text, truncated: false };
|
|
69
|
+
}
|
|
70
|
+
lines.splice(index, 1);
|
|
71
|
+
return { text: lines.join("\n").trimEnd(), truncated: true };
|
|
72
|
+
}
|
|
73
|
+
function replaceBoundaryNotices(text) {
|
|
74
|
+
const lines = text.split("\n");
|
|
75
|
+
const first = lines.findIndex((line) => Boolean(line.trim()));
|
|
76
|
+
if (first >= 0 && lines[first]?.trim() === EARLIER_OUTPUT_NOTICE)
|
|
77
|
+
lines[first] = "…";
|
|
78
|
+
let last = lines.length - 1;
|
|
79
|
+
while (last >= 0 && !lines[last]?.trim())
|
|
80
|
+
last--;
|
|
81
|
+
if (last >= 0 && OUTPUT_TRUNCATION_NOTICE_PATTERN.test(lines[last]?.trim() ?? ""))
|
|
82
|
+
lines[last] = "…";
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
function plainExcerpt(text, limit) {
|
|
86
|
+
return truncate(stripInlineMarkdown(stripBlockMarkdown(text)).replace(/\s+/gu, " ").trim(), limit);
|
|
87
|
+
}
|
|
58
88
|
function excerpt(text, limit) {
|
|
59
|
-
|
|
60
|
-
// boundText/tailText; in the transcript an ellipsis is enough.
|
|
61
|
-
const cleaned = text.replace(/\[(?:Output truncated(?:: \d+ bytes omitted\.)?|Earlier output omitted\.)\]/gu, "…");
|
|
62
|
-
return truncate(stripInlineMarkdown(stripBlockMarkdown(cleaned)).replace(/\s+/gu, " ").trim(), limit);
|
|
89
|
+
return plainExcerpt(replaceBoundaryNotices(text), limit);
|
|
63
90
|
}
|
|
64
|
-
|
|
65
|
-
|
|
91
|
+
// Generated notice-only boundary lines must never become the live line the
|
|
92
|
+
// user watches; identical text in the body remains ordinary model output.
|
|
93
|
+
function liveTailLines(run) {
|
|
94
|
+
const { text } = terminalOutputNotice(run.liveText);
|
|
95
|
+
const lines = text
|
|
66
96
|
.split("\n")
|
|
67
97
|
.map((line) => line.trim())
|
|
68
98
|
.filter(Boolean);
|
|
69
|
-
|
|
99
|
+
if (lines[0] === EARLIER_OUTPUT_NOTICE)
|
|
100
|
+
lines.shift();
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
|
103
|
+
function liveTail(run) {
|
|
104
|
+
const last = liveTailLines(run).at(-1);
|
|
70
105
|
return last ? excerpt(last, LIVE_TAIL_LIMIT) : undefined;
|
|
71
106
|
}
|
|
72
107
|
// Completed-run excerpts read better cut at a sentence boundary than
|
|
@@ -136,31 +171,60 @@ function liveElapsed(run) {
|
|
|
136
171
|
const end = run.endedAt ?? Date.now();
|
|
137
172
|
return formatDuration(Math.max(0, (end - run.startedAt) / 1000));
|
|
138
173
|
}
|
|
174
|
+
function statusColor(status) {
|
|
175
|
+
switch (status) {
|
|
176
|
+
case "completed":
|
|
177
|
+
return "success";
|
|
178
|
+
case "failed":
|
|
179
|
+
return "error";
|
|
180
|
+
case "aborted":
|
|
181
|
+
return "warning";
|
|
182
|
+
case "running":
|
|
183
|
+
return "accent";
|
|
184
|
+
default:
|
|
185
|
+
return "muted";
|
|
186
|
+
}
|
|
187
|
+
}
|
|
139
188
|
// The tool shell already paints a call-level status dot (● in
|
|
140
189
|
// warning/success/error), so `●` is off-limits here: `›` marks where the
|
|
141
190
|
// work currently is, and the remaining glyphs are terminal states.
|
|
142
191
|
function statusMarker(status, theme) {
|
|
192
|
+
const glyph = status === "completed"
|
|
193
|
+
? "✓"
|
|
194
|
+
: status === "failed"
|
|
195
|
+
? "×"
|
|
196
|
+
: status === "aborted"
|
|
197
|
+
? "■"
|
|
198
|
+
: status === "running"
|
|
199
|
+
? "›"
|
|
200
|
+
: "○";
|
|
201
|
+
return theme.fg(statusColor(status), glyph);
|
|
202
|
+
}
|
|
203
|
+
function statusWord(status) {
|
|
143
204
|
switch (status) {
|
|
144
205
|
case "completed":
|
|
145
|
-
return
|
|
206
|
+
return "Completed";
|
|
146
207
|
case "failed":
|
|
147
|
-
return
|
|
208
|
+
return "Failed";
|
|
148
209
|
case "aborted":
|
|
149
|
-
return
|
|
210
|
+
return "Aborted";
|
|
150
211
|
case "running":
|
|
151
|
-
return
|
|
212
|
+
return "Running";
|
|
152
213
|
default:
|
|
153
|
-
return
|
|
214
|
+
return "Queued";
|
|
154
215
|
}
|
|
155
216
|
}
|
|
156
217
|
function runTitle(run, theme, index) {
|
|
157
218
|
const number = index === undefined ? "" : theme.fg("dim", `${index} · `);
|
|
158
219
|
return `${statusMarker(run.status, theme)} ${number}${theme.fg("accent", displayAgentName(run.agent))}${theme.fg("dim", ` · ${truncate(run.description, 48)}`)}`;
|
|
159
220
|
}
|
|
221
|
+
// Mid-run token totals are cache-inflated (totalTokens sums cache reads
|
|
222
|
+
// across turns), so live lines quote tool uses and the context watermark
|
|
223
|
+
// instead; settled lines get the real total.
|
|
160
224
|
function runProgressText(run) {
|
|
161
225
|
const items = [`${run.usage.toolUses} tool use${run.usage.toolUses === 1 ? "" : "s"}`];
|
|
162
|
-
if (run.usage.
|
|
163
|
-
items.push(
|
|
226
|
+
if (run.usage.contextTokens)
|
|
227
|
+
items.push(`ctx:${formatTokens(run.usage.contextTokens)}`);
|
|
164
228
|
return items.join(" · ");
|
|
165
229
|
}
|
|
166
230
|
function runLine(run, theme, index) {
|
|
@@ -211,6 +275,8 @@ function singleCollapsedLines(details, theme) {
|
|
|
211
275
|
let status = theme.fg("dim", intent);
|
|
212
276
|
if (run.status === "running" && !isInitializing) {
|
|
213
277
|
status += theme.fg("dim", ` · ${runProgressText(run)}`);
|
|
278
|
+
if (run.usage.cost)
|
|
279
|
+
status += theme.fg("dim", ` · $${run.usage.cost.toFixed(3)}`);
|
|
214
280
|
const elapsed = liveElapsed(run);
|
|
215
281
|
if (elapsed)
|
|
216
282
|
status += theme.fg("dim", ` · ${elapsed}`);
|
|
@@ -230,53 +296,43 @@ function singleCollapsedLines(details, theme) {
|
|
|
230
296
|
return [theme.fg("toolOutput", leadingSentences(run.finalOutput, SINGLE_EXCERPT_LIMIT))];
|
|
231
297
|
return [theme.fg("muted", run.status)];
|
|
232
298
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
activityCount++;
|
|
239
|
-
toolCounts.set(activity.toolName, (toolCounts.get(activity.toolName) ?? 0) + 1);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
299
|
+
// Deep-trimmed settled footer: turns and per-run breakdowns belong to the
|
|
300
|
+
// expanded view. Parallel batches keep only whole-call numbers — the run
|
|
301
|
+
// rows above already localize the work.
|
|
302
|
+
function collapsedUsageText(details, extended) {
|
|
303
|
+
const usage = details.usage;
|
|
242
304
|
const items = [];
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
305
|
+
if (usage.totalTokens)
|
|
306
|
+
items.push(`${formatTokens(usage.totalTokens)} tok`);
|
|
307
|
+
if (extended && usage.toolUses)
|
|
308
|
+
items.push(`${usage.toolUses} tool use${usage.toolUses === 1 ? "" : "s"}`);
|
|
309
|
+
if (extended && usage.contextTokens)
|
|
310
|
+
items.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
311
|
+
if (usage.cost)
|
|
312
|
+
items.push(`$${usage.cost.toFixed(3)}`);
|
|
313
|
+
if (details.endedAt !== undefined) {
|
|
314
|
+
items.push(formatDuration(Math.max(0, (details.endedAt - details.startedAt) / 1000)));
|
|
250
315
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (items.length === 0)
|
|
255
|
-
return "";
|
|
256
|
-
const earlierCount = Math.max(0, details.usage.toolUses - activityCount);
|
|
257
|
-
if (earlierCount > 0)
|
|
258
|
-
items.push(`${earlierCount} earlier tool use${earlierCount === 1 ? "" : "s"}`);
|
|
259
|
-
return `${items[0][0].toUpperCase()}${items[0].slice(1)}${items
|
|
260
|
-
.slice(1)
|
|
261
|
-
.map((item) => ` · ${item}`)
|
|
262
|
-
.join("")}`;
|
|
263
|
-
}
|
|
264
|
-
function usageText(details, includeToolUses = true) {
|
|
316
|
+
return items.join(" · ");
|
|
317
|
+
}
|
|
318
|
+
function batchUsageText(details, isPartial) {
|
|
265
319
|
const usage = details.usage;
|
|
266
320
|
const items = [];
|
|
267
|
-
|
|
321
|
+
// The aggregate mixes settled and running runs while partial, and the
|
|
322
|
+
// running share is cache-inflated — quote the total only once settled.
|
|
323
|
+
if (!isPartial && usage.totalTokens)
|
|
324
|
+
items.push(`${formatTokens(usage.totalTokens)} tok`);
|
|
325
|
+
if (usage.toolUses)
|
|
268
326
|
items.push(`${usage.toolUses} tool use${usage.toolUses === 1 ? "" : "s"}`);
|
|
269
327
|
if (usage.turns)
|
|
270
328
|
items.push(`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`);
|
|
271
|
-
if (usage.output)
|
|
272
|
-
items.push(`↓${formatTokens(usage.output)}`);
|
|
273
329
|
if (usage.contextTokens)
|
|
274
330
|
items.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
275
331
|
if (usage.cost)
|
|
276
332
|
items.push(`$${usage.cost.toFixed(3)}`);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
333
|
+
if (details.endedAt !== undefined) {
|
|
334
|
+
items.push(formatDuration(Math.max(0, (details.endedAt - details.startedAt) / 1000)));
|
|
335
|
+
}
|
|
280
336
|
return items.join(" · ");
|
|
281
337
|
}
|
|
282
338
|
function formatTokens(value) {
|
|
@@ -291,51 +347,131 @@ function formatDuration(seconds) {
|
|
|
291
347
|
return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
|
|
292
348
|
return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
|
|
293
349
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
350
|
+
function runDurationText(run) {
|
|
351
|
+
if (run.startedAt === undefined || run.endedAt === undefined)
|
|
352
|
+
return undefined;
|
|
353
|
+
return formatDuration(Math.max(0, (run.endedAt - run.startedAt) / 1000));
|
|
354
|
+
}
|
|
355
|
+
// One dim line per run: what it cost. Settled runs quote the real token
|
|
356
|
+
// total; running runs skip it (cache-inflated mid-run) and tick elapsed
|
|
357
|
+
// time instead. Batch sections fold model/thinking/cwd in here because
|
|
358
|
+
// their `──` header already carries verdict and duration.
|
|
359
|
+
function runMetricsText(run, includeIdentity) {
|
|
360
|
+
const usage = run.usage;
|
|
361
|
+
const settled = run.status !== "running" && run.status !== "queued";
|
|
362
|
+
const items = [];
|
|
363
|
+
if (includeIdentity) {
|
|
364
|
+
items.push(run.model);
|
|
365
|
+
if (run.thinking !== "off")
|
|
366
|
+
items.push(run.thinking);
|
|
367
|
+
if (run.cwd)
|
|
368
|
+
items.push(`cwd: ${run.cwd}`);
|
|
369
|
+
}
|
|
370
|
+
if (settled && usage.totalTokens)
|
|
371
|
+
items.push(`${formatTokens(usage.totalTokens)} tok`);
|
|
372
|
+
if (usage.toolUses)
|
|
373
|
+
items.push(`${usage.toolUses} tool use${usage.toolUses === 1 ? "" : "s"}`);
|
|
374
|
+
if (usage.turns)
|
|
375
|
+
items.push(`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`);
|
|
376
|
+
if (usage.contextTokens)
|
|
377
|
+
items.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
378
|
+
if (usage.cost)
|
|
379
|
+
items.push(`$${usage.cost.toFixed(3)}`);
|
|
380
|
+
const duration = settled ? (includeIdentity ? undefined : runDurationText(run)) : liveElapsed(run);
|
|
381
|
+
if (duration)
|
|
382
|
+
items.push(duration);
|
|
383
|
+
return items.join(" · ");
|
|
384
|
+
}
|
|
385
|
+
// The details prompt is a 1KB bounded copy of the briefing; the preview
|
|
386
|
+
// shows the first content lines (blank lines carry nothing at two lines
|
|
387
|
+
// of budget) and never pretends to more fidelity than that.
|
|
388
|
+
function promptSection(run, theme) {
|
|
389
|
+
const boundedPrompt = terminalOutputNotice(run.prompt);
|
|
390
|
+
const lines = boundedPrompt.text
|
|
391
|
+
.split("\n")
|
|
392
|
+
.map((line) => plainExcerpt(line.trim(), Number.MAX_SAFE_INTEGER))
|
|
393
|
+
.filter(Boolean);
|
|
394
|
+
const shown = lines.slice(0, PROMPT_PREVIEW_LINES);
|
|
395
|
+
const preview = shown.map((line) => theme.fg("dim", truncate(line, PROMPT_PREVIEW_LINE_LIMIT)));
|
|
396
|
+
// A one-line briefing clipped mid-sentence must still say it continues.
|
|
397
|
+
const clipped = shown.some((line) => line.length > PROMPT_PREVIEW_LINE_LIMIT);
|
|
398
|
+
const remaining = lines.length - shown.length;
|
|
399
|
+
const capped = boundedPrompt.truncated;
|
|
400
|
+
const section = [theme.fg("muted", "Prompt"), ...preview];
|
|
401
|
+
if (remaining > 0 || capped || clipped) {
|
|
402
|
+
let note = "… continues";
|
|
403
|
+
if (remaining > 0)
|
|
404
|
+
note += `, ${remaining} more line${remaining === 1 ? "" : "s"}`;
|
|
405
|
+
if (capped)
|
|
406
|
+
note += " · capped at 1KB";
|
|
407
|
+
section.push(theme.fg("muted", note));
|
|
408
|
+
}
|
|
409
|
+
return section;
|
|
410
|
+
}
|
|
411
|
+
// Each run expands into a report cover sheet: verdict and cost up top, a
|
|
412
|
+
// bounded Prompt/Activity digest in the middle, then the full Report —
|
|
413
|
+
// the one unbounded element, placed last so the card ends where reading
|
|
414
|
+
// begins. Single runs skip the `──` header (the call header already names
|
|
415
|
+
// them); batch sections carry a numbered header matching the contents
|
|
416
|
+
// list above.
|
|
298
417
|
function renderRunDetails(run, theme, single, index) {
|
|
299
418
|
const container = new Container();
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
419
|
+
const settled = run.status !== "running" && run.status !== "queued";
|
|
420
|
+
if (single) {
|
|
421
|
+
const identity = [run.model];
|
|
422
|
+
if (run.thinking !== "off")
|
|
423
|
+
identity.push(`${run.thinking} thinking`);
|
|
424
|
+
if (run.cwd)
|
|
425
|
+
identity.push(`cwd: ${run.cwd}`);
|
|
426
|
+
container.addChild(new Text(`${statusMarker(run.status, theme)} ${theme.fg(statusColor(run.status), statusWord(run.status))}${theme.fg("dim", ` · ${identity.join(" · ")}`)}`, 0, 0));
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
const label = index === undefined ? "" : `${index} `;
|
|
430
|
+
const duration = settled ? runDurationText(run) : undefined;
|
|
431
|
+
container.addChild(new Text(`${theme.fg("dim", `── ${label}`)}${statusMarker(run.status, theme)} ${theme.fg("accent", displayAgentName(run.agent))}${theme.fg("dim", ` · ${truncate(run.description, 48)}`)}${duration ? theme.fg("dim", ` · ${duration}`) : ""}`, 0, 0));
|
|
432
|
+
}
|
|
433
|
+
if (run.status !== "queued") {
|
|
434
|
+
const metrics = runMetricsText(run, !single);
|
|
435
|
+
if (metrics)
|
|
436
|
+
container.addChild(new Text(theme.fg("dim", metrics), 0, 0));
|
|
306
437
|
}
|
|
307
|
-
container.addChild(new
|
|
308
|
-
|
|
438
|
+
container.addChild(new Spacer(1));
|
|
439
|
+
for (const line of promptSection(run, theme))
|
|
440
|
+
container.addChild(new Text(line, 0, 0));
|
|
309
441
|
if (run.activities.length > 0) {
|
|
310
442
|
container.addChild(new Spacer(1));
|
|
311
|
-
|
|
443
|
+
const total = run.usage.toolUses;
|
|
444
|
+
const label = total > run.activities.length ? `Activity · last ${run.activities.length} of ${total}` : "Activity";
|
|
445
|
+
container.addChild(new Text(theme.fg("muted", label), 0, 0));
|
|
312
446
|
for (const activity of run.activities) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
text += ` ${theme.fg(color, `· ${excerpt(activity.resultSummary, 64)}`)}`;
|
|
447
|
+
// Deviation earns ink: × and › are marked, success rows are quiet
|
|
448
|
+
// one-liners without result echoes.
|
|
449
|
+
let text;
|
|
450
|
+
if (activity.status === "failed") {
|
|
451
|
+
text = `${theme.fg("error", "×")} ${theme.fg("toolOutput", excerpt(activity.summary, ACTIVITY_SUMMARY_LIMIT))}`;
|
|
452
|
+
if (activity.resultSummary) {
|
|
453
|
+
text += ` ${theme.fg("error", `· ${excerpt(activity.resultSummary, ACTIVITY_RESULT_LIMIT)}`)}`;
|
|
454
|
+
}
|
|
322
455
|
}
|
|
323
|
-
if (activity.
|
|
456
|
+
else if (activity.status === "running") {
|
|
457
|
+
text = `${theme.fg("accent", "›")} ${theme.fg("toolOutput", excerpt(activity.summary, ACTIVITY_SUMMARY_LIMIT))}`;
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
text = theme.fg("toolOutput", excerpt(activity.summary, ACTIVITY_SUMMARY_LIMIT));
|
|
461
|
+
}
|
|
462
|
+
if (activity.endedAt !== undefined && activity.endedAt - activity.startedAt >= ACTIVITY_DURATION_MIN_MS) {
|
|
324
463
|
text += ` ${theme.fg("dim", `· ${formatDuration((activity.endedAt - activity.startedAt) / 1000)}`)}`;
|
|
325
464
|
}
|
|
326
465
|
container.addChild(new Text(text, 0, 0));
|
|
327
466
|
}
|
|
328
467
|
}
|
|
329
|
-
// The live tail is the "now" end of the timeline
|
|
330
|
-
//
|
|
468
|
+
// The live tail is the "now" end of the timeline; once the run settles,
|
|
469
|
+
// Report takes this slot without moving anything above it.
|
|
331
470
|
if (run.status === "running" && run.liveText) {
|
|
332
|
-
const tail = run.
|
|
333
|
-
.split("\n")
|
|
334
|
-
.map((line) => line.trim())
|
|
335
|
-
.filter(Boolean)
|
|
336
|
-
.slice(-4);
|
|
471
|
+
const tail = liveTailLines(run).slice(-EXPANDED_LIVE_TAIL_LINES);
|
|
337
472
|
if (tail.length > 0) {
|
|
338
473
|
container.addChild(new Spacer(1));
|
|
474
|
+
container.addChild(new Text(theme.fg("muted", "Working"), 0, 0));
|
|
339
475
|
for (const line of tail) {
|
|
340
476
|
container.addChild(new Text(theme.fg("dim", excerpt(line, LIVE_TAIL_LIMIT)), 0, 0));
|
|
341
477
|
}
|
|
@@ -343,29 +479,15 @@ function renderRunDetails(run, theme, single, index) {
|
|
|
343
479
|
}
|
|
344
480
|
if (run.error) {
|
|
345
481
|
container.addChild(new Spacer(1));
|
|
346
|
-
container.addChild(new Text(theme.fg("
|
|
482
|
+
container.addChild(new Text(theme.fg("muted", "Error"), 0, 0));
|
|
483
|
+
container.addChild(new Text(theme.fg("error", run.error), 0, 0));
|
|
347
484
|
}
|
|
348
485
|
if (run.finalOutput) {
|
|
349
486
|
container.addChild(new Spacer(1));
|
|
350
|
-
|
|
487
|
+
const label = settled && run.status !== "completed" ? "Report · partial" : "Report";
|
|
488
|
+
container.addChild(new Text(theme.fg("muted", label), 0, 0));
|
|
351
489
|
container.addChild(new Markdown(run.finalOutput, 0, 0, getMarkdownTheme()));
|
|
352
490
|
}
|
|
353
|
-
const metadata = [
|
|
354
|
-
`${run.model} · ${run.thinking}`,
|
|
355
|
-
run.cwd ? `cwd: ${run.cwd}` : undefined,
|
|
356
|
-
run.usage.toolUses ? `${run.usage.toolUses} tool use${run.usage.toolUses === 1 ? "" : "s"}` : undefined,
|
|
357
|
-
run.usage.turns ? `${run.usage.turns} turn${run.usage.turns === 1 ? "" : "s"}` : undefined,
|
|
358
|
-
run.usage.output ? `↓${formatTokens(run.usage.output)}` : undefined,
|
|
359
|
-
run.usage.contextTokens ? `ctx:${formatTokens(run.usage.contextTokens)}` : undefined,
|
|
360
|
-
run.usage.cost ? `$${run.usage.cost.toFixed(3)}` : undefined,
|
|
361
|
-
single ? duration : undefined,
|
|
362
|
-
]
|
|
363
|
-
.filter((item) => Boolean(item))
|
|
364
|
-
.join(" · ");
|
|
365
|
-
if (metadata) {
|
|
366
|
-
container.addChild(new Spacer(1));
|
|
367
|
-
container.addChild(new Text(theme.fg("dim", metadata), 0, 0));
|
|
368
|
-
}
|
|
369
491
|
return container;
|
|
370
492
|
}
|
|
371
493
|
// The call header stays a single line: run rows in the result area take
|
|
@@ -391,6 +513,10 @@ export function renderSubagentResult(result, options, theme, isError) {
|
|
|
391
513
|
const text = result.content.find((part) => part.type === "text");
|
|
392
514
|
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
393
515
|
}
|
|
516
|
+
// The pre-resolution first paint carries no runs yet, in either mode.
|
|
517
|
+
if (details.runs.length === 0) {
|
|
518
|
+
return new Text(theme.fg("muted", "Initializing…"), 0, 0);
|
|
519
|
+
}
|
|
394
520
|
if (!options.expanded) {
|
|
395
521
|
const lines = [];
|
|
396
522
|
if (details.mode === "single") {
|
|
@@ -403,20 +529,21 @@ export function renderSubagentResult(result, options, theme, isError) {
|
|
|
403
529
|
}
|
|
404
530
|
lines.push(theme.fg(isError ? "error" : options.isPartial ? "accent" : "muted", header));
|
|
405
531
|
const shown = selectCollapsedRuns(details.runs, Boolean(options.isPartial));
|
|
532
|
+
// Stable ordinals (the run's task position, not its display slot)
|
|
533
|
+
// keep identity readable while active-first ordering reshuffles
|
|
534
|
+
// rows, and match the expanded section numbers.
|
|
406
535
|
for (const run of shown)
|
|
407
|
-
lines.push(runLine(run, theme));
|
|
536
|
+
lines.push(runLine(run, theme, details.runs.indexOf(run) + 1));
|
|
408
537
|
const hidden = details.runs.length - shown.length;
|
|
409
538
|
if (hidden > 0)
|
|
410
539
|
lines.push(theme.fg("muted", `+${hidden} more`));
|
|
411
540
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if (usage)
|
|
417
|
-
lines.push(theme.fg("dim", usage));
|
|
418
|
-
if (!options.isPartial)
|
|
541
|
+
if (!options.isPartial) {
|
|
542
|
+
const usage = collapsedUsageText(details, details.mode === "single");
|
|
543
|
+
if (usage)
|
|
544
|
+
lines.push(theme.fg("dim", usage));
|
|
419
545
|
lines.push(theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`));
|
|
546
|
+
}
|
|
420
547
|
return new Text(lines.join("\n"), 0, 0);
|
|
421
548
|
}
|
|
422
549
|
const container = new Container();
|
|
@@ -436,7 +563,7 @@ export function renderSubagentResult(result, options, theme, isError) {
|
|
|
436
563
|
container.addChild(renderRunDetails(run, theme, single, single ? undefined : index + 1));
|
|
437
564
|
});
|
|
438
565
|
if (!single) {
|
|
439
|
-
const usage =
|
|
566
|
+
const usage = batchUsageText(details, Boolean(options.isPartial));
|
|
440
567
|
if (usage) {
|
|
441
568
|
container.addChild(new Spacer(1));
|
|
442
569
|
container.addChild(new Text(theme.fg("dim", usage), 0, 0));
|