@oh-my-pi/pi-coding-agent 15.10.0 → 15.10.1

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.
Files changed (176) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/dist/types/cli/dry-balance-cli.d.ts +15 -1
  3. package/dist/types/commit/analysis/conventional.d.ts +2 -2
  4. package/dist/types/commit/analysis/summary.d.ts +2 -2
  5. package/dist/types/commit/changelog/generate.d.ts +2 -2
  6. package/dist/types/commit/changelog/index.d.ts +2 -2
  7. package/dist/types/commit/map-reduce/index.d.ts +3 -3
  8. package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
  9. package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
  10. package/dist/types/commit/model-selection.d.ts +10 -4
  11. package/dist/types/config/api-key-resolver.d.ts +34 -0
  12. package/dist/types/config/model-registry.d.ts +17 -1
  13. package/dist/types/config/settings-schema.d.ts +9 -0
  14. package/dist/types/dap/config.d.ts +14 -1
  15. package/dist/types/dap/types.d.ts +10 -0
  16. package/dist/types/lsp/utils.d.ts +3 -2
  17. package/dist/types/modes/components/chat-block.d.ts +64 -0
  18. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  19. package/dist/types/modes/components/overlay-box.d.ts +17 -0
  20. package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
  21. package/dist/types/modes/components/plan-toc.d.ts +41 -0
  22. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  23. package/dist/types/modes/components/transcript-container.d.ts +11 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  25. package/dist/types/modes/controllers/event-controller.d.ts +0 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
  27. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  28. package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
  29. package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
  30. package/dist/types/modes/interactive-mode.d.ts +15 -5
  31. package/dist/types/modes/theme/theme.d.ts +1 -1
  32. package/dist/types/modes/types.d.ts +18 -5
  33. package/dist/types/modes/utils/copy-targets.d.ts +21 -1
  34. package/dist/types/plan-mode/approved-plan.d.ts +27 -8
  35. package/dist/types/plan-mode/plan-protection.d.ts +4 -4
  36. package/dist/types/sdk.d.ts +2 -0
  37. package/dist/types/session/agent-session.d.ts +21 -0
  38. package/dist/types/session/messages.d.ts +12 -0
  39. package/dist/types/session/session-manager.d.ts +3 -1
  40. package/dist/types/slash-commands/types.d.ts +4 -6
  41. package/dist/types/task/executor.d.ts +7 -0
  42. package/dist/types/task/index.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +3 -2
  44. package/dist/types/tools/archive-reader.d.ts +5 -0
  45. package/dist/types/tools/ast-edit.d.ts +3 -0
  46. package/dist/types/tools/ast-grep.d.ts +3 -0
  47. package/dist/types/tools/bash.d.ts +1 -0
  48. package/dist/types/tools/find.d.ts +8 -4
  49. package/dist/types/tools/grouped-file-output.d.ts +95 -12
  50. package/dist/types/tools/memory-render.d.ts +4 -1
  51. package/dist/types/tools/plan-mode-guard.d.ts +8 -9
  52. package/dist/types/tools/render-utils.d.ts +5 -9
  53. package/dist/types/tools/search.d.ts +4 -0
  54. package/dist/types/tools/sqlite-reader.d.ts +1 -0
  55. package/dist/types/tools/todo.d.ts +3 -2
  56. package/dist/types/tools/write.d.ts +3 -0
  57. package/dist/types/tui/output-block.d.ts +16 -4
  58. package/dist/types/tui/status-line.d.ts +3 -0
  59. package/dist/types/utils/enhanced-paste.d.ts +20 -0
  60. package/dist/types/web/search/providers/kimi.d.ts +1 -1
  61. package/package.json +9 -9
  62. package/src/auto-thinking/classifier.ts +5 -1
  63. package/src/cli/dry-balance-cli.ts +52 -17
  64. package/src/cli/gallery-cli.ts +4 -1
  65. package/src/cli/gallery-fixtures/misc.ts +29 -0
  66. package/src/commit/analysis/conventional.ts +2 -2
  67. package/src/commit/analysis/summary.ts +2 -2
  68. package/src/commit/changelog/generate.ts +2 -2
  69. package/src/commit/changelog/index.ts +2 -2
  70. package/src/commit/map-reduce/index.ts +3 -3
  71. package/src/commit/map-reduce/map-phase.ts +2 -2
  72. package/src/commit/map-reduce/reduce-phase.ts +2 -2
  73. package/src/commit/model-selection.ts +33 -9
  74. package/src/commit/pipeline.ts +4 -4
  75. package/src/config/api-key-resolver.ts +58 -0
  76. package/src/config/model-registry.ts +25 -2
  77. package/src/config/settings-schema.ts +10 -0
  78. package/src/config/settings.ts +20 -2
  79. package/src/dap/config.ts +41 -2
  80. package/src/dap/defaults.json +1 -0
  81. package/src/dap/session.ts +1 -0
  82. package/src/dap/types.ts +10 -0
  83. package/src/debug/index.ts +40 -54
  84. package/src/edit/renderer.ts +82 -78
  85. package/src/eval/__tests__/llm-bridge.test.ts +90 -31
  86. package/src/eval/llm-bridge.ts +8 -3
  87. package/src/goals/tools/goal-tool.ts +36 -26
  88. package/src/internal-urls/docs-index.generated.ts +6 -6
  89. package/src/lsp/utils.ts +3 -2
  90. package/src/main.ts +9 -7
  91. package/src/memories/index.ts +12 -5
  92. package/src/mnemopi/backend.ts +5 -1
  93. package/src/modes/acp/acp-agent.ts +33 -26
  94. package/src/modes/components/assistant-message.ts +2 -9
  95. package/src/modes/components/chat-block.ts +111 -0
  96. package/src/modes/components/copy-selector.ts +1 -44
  97. package/src/modes/components/custom-editor.ts +23 -0
  98. package/src/modes/components/custom-message.ts +1 -3
  99. package/src/modes/components/execution-shared.ts +1 -2
  100. package/src/modes/components/hook-message.ts +1 -3
  101. package/src/modes/components/overlay-box.ts +108 -0
  102. package/src/modes/components/plan-review-overlay.ts +799 -0
  103. package/src/modes/components/plan-toc.ts +138 -0
  104. package/src/modes/components/read-tool-group.ts +20 -4
  105. package/src/modes/components/skill-message.ts +0 -1
  106. package/src/modes/components/tips.txt +1 -0
  107. package/src/modes/components/todo-reminder.ts +0 -2
  108. package/src/modes/components/tool-execution.ts +68 -88
  109. package/src/modes/components/transcript-container.ts +84 -24
  110. package/src/modes/components/user-message.ts +1 -2
  111. package/src/modes/controllers/command-controller-shared.ts +7 -6
  112. package/src/modes/controllers/command-controller.ts +57 -55
  113. package/src/modes/controllers/event-controller.ts +41 -40
  114. package/src/modes/controllers/extension-ui-controller.ts +10 -73
  115. package/src/modes/controllers/input-controller.ts +124 -119
  116. package/src/modes/controllers/mcp-command-controller.ts +69 -60
  117. package/src/modes/controllers/selector-controller.ts +23 -25
  118. package/src/modes/controllers/streaming-reveal.ts +212 -0
  119. package/src/modes/controllers/tan-command-controller.ts +173 -0
  120. package/src/modes/interactive-mode.ts +169 -94
  121. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  122. package/src/modes/theme/theme-schema.json +1 -1
  123. package/src/modes/theme/theme.ts +8 -4
  124. package/src/modes/types.ts +18 -7
  125. package/src/modes/utils/copy-targets.ts +133 -27
  126. package/src/modes/utils/ui-helpers.ts +44 -46
  127. package/src/plan-mode/approved-plan.ts +66 -43
  128. package/src/plan-mode/plan-protection.ts +4 -4
  129. package/src/prompts/system/background-tan-dispatch.md +8 -0
  130. package/src/prompts/system/plan-mode-active.md +67 -58
  131. package/src/prompts/system/plan-mode-approved.md +1 -1
  132. package/src/sdk.ts +11 -37
  133. package/src/session/agent-session.ts +82 -6
  134. package/src/session/messages.ts +26 -0
  135. package/src/session/session-manager.ts +13 -5
  136. package/src/slash-commands/builtin-registry.ts +36 -9
  137. package/src/slash-commands/types.ts +4 -6
  138. package/src/task/executor.ts +5 -2
  139. package/src/task/index.ts +4 -0
  140. package/src/task/render.ts +212 -147
  141. package/src/tools/archive-reader.ts +64 -0
  142. package/src/tools/ask.ts +119 -164
  143. package/src/tools/ast-edit.ts +98 -71
  144. package/src/tools/ast-grep.ts +37 -43
  145. package/src/tools/bash.ts +50 -6
  146. package/src/tools/debug.ts +20 -8
  147. package/src/tools/fetch.ts +297 -7
  148. package/src/tools/find.ts +44 -30
  149. package/src/tools/gh-renderer.ts +81 -42
  150. package/src/tools/grouped-file-output.ts +272 -48
  151. package/src/tools/image-gen.ts +150 -103
  152. package/src/tools/inspect-image-renderer.ts +63 -41
  153. package/src/tools/inspect-image.ts +8 -1
  154. package/src/tools/job.ts +3 -4
  155. package/src/tools/memory-render.ts +4 -1
  156. package/src/tools/plan-mode-guard.ts +21 -39
  157. package/src/tools/read.ts +23 -16
  158. package/src/tools/render-utils.ts +21 -37
  159. package/src/tools/resolve.ts +14 -0
  160. package/src/tools/search-tool-bm25.ts +36 -23
  161. package/src/tools/search.ts +80 -78
  162. package/src/tools/sqlite-reader.ts +9 -12
  163. package/src/tools/todo.ts +118 -52
  164. package/src/tools/write.ts +81 -62
  165. package/src/tui/output-block.ts +60 -13
  166. package/src/tui/status-line.ts +5 -1
  167. package/src/utils/commit-message-generator.ts +9 -1
  168. package/src/utils/enhanced-paste.ts +202 -0
  169. package/src/utils/title-generator.ts +2 -1
  170. package/src/web/search/providers/anthropic.ts +25 -19
  171. package/src/web/search/providers/exa.ts +11 -3
  172. package/src/web/search/providers/kimi.ts +28 -17
  173. package/src/web/search/providers/parallel.ts +35 -24
  174. package/src/web/search/providers/synthetic.ts +8 -6
  175. package/src/web/search/providers/tavily.ts +9 -8
  176. package/src/web/search/providers/zai.ts +8 -6
@@ -21,6 +21,7 @@ import {
21
21
  } from "../extensibility/plugins/marketplace";
22
22
  import { resolveMemoryBackend } from "../memory-backend";
23
23
  import type { InteractiveModeContext } from "../modes/types";
24
+ import type { FreshSessionResult } from "../session/agent-session";
24
25
  import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
25
26
  import { getChangelogPath, parseChangelog } from "../utils/changelog";
26
27
  import { buildContextReportText } from "./helpers/context-report";
@@ -52,6 +53,11 @@ function refreshStatusLine(ctx: InteractiveModeContext): void {
52
53
  ctx.ui.requestRender();
53
54
  }
54
55
 
56
+ function formatFreshSessionResult(result: FreshSessionResult): string {
57
+ const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states";
58
+ return `Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`;
59
+ }
60
+
55
61
  const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashCommandRuntime): SlashCommandResult => {
56
62
  runtime.ctx.editor.setText("");
57
63
  void runtime.ctx.shutdown();
@@ -770,6 +776,25 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
770
776
  await runtime.ctx.handleClearCommand();
771
777
  },
772
778
  },
779
+ {
780
+ name: "fresh",
781
+ description: "Reset provider stream state without changing the local transcript",
782
+ handle: async (_command, runtime) => {
783
+ const result = runtime.session.freshSession();
784
+ if (!result) {
785
+ await runtime.output(
786
+ "Wait for the current response to finish or abort it before refreshing provider state.",
787
+ );
788
+ return commandConsumed();
789
+ }
790
+ await runtime.output(formatFreshSessionResult(result));
791
+ return commandConsumed();
792
+ },
793
+ handleTui: async (_command, runtime) => {
794
+ runtime.ctx.editor.setText("");
795
+ await runtime.ctx.handleFreshCommand();
796
+ },
797
+ },
773
798
  {
774
799
  name: "drop",
775
800
  description: "Delete the current session and start a new one",
@@ -868,6 +893,17 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
868
893
  await runtime.ctx.handleBtwCommand(question);
869
894
  },
870
895
  },
896
+ {
897
+ name: "tan",
898
+ description: "Run a full background agent on tangential work",
899
+ inlineHint: "<work>",
900
+ allowArgs: true,
901
+ handleTui: async (command, runtime) => {
902
+ const work = command.text.slice(`/${command.name}`.length).trim();
903
+ runtime.ctx.editor.setText("");
904
+ await runtime.ctx.handleTanCommand(work);
905
+ },
906
+ },
871
907
  {
872
908
  name: "omfg",
873
909
  description: "Forge a TTSR rule from a complaint to stop a recurring behavior",
@@ -890,15 +926,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
890
926
  runtime.ctx.editor.setText("");
891
927
  },
892
928
  },
893
- {
894
- name: "background",
895
- aliases: ["bg"],
896
- description: "Detach UI and continue running in background",
897
- handleTui: (_command, runtime) => {
898
- runtime.ctx.editor.setText("");
899
- runtime.handleBackgroundCommand();
900
- },
901
- },
902
929
  {
903
930
  name: "debug",
904
931
  description: "Open debug tools selector",
@@ -71,15 +71,13 @@ export interface SlashCommandRuntime {
71
71
 
72
72
  /**
73
73
  * Runtime visible to TUI-only handlers (`handleTui`). Carries the interactive
74
- * mode context plus the background-detach hook. Intentionally narrower than
75
- * `SlashCommandRuntime` so existing callers can keep building it from just
76
- * `{ ctx, handleBackgroundCommand }`; when the TUI dispatcher needs to invoke
77
- * a `handle` (no `handleTui` override), it synthesizes a `SlashCommandRuntime`
78
- * from `ctx`.
74
+ * mode context. Intentionally narrower than `SlashCommandRuntime` so existing
75
+ * callers can keep building it from just `{ ctx }`; when the TUI dispatcher
76
+ * needs to invoke a `handle` (no `handleTui` override), it synthesizes a
77
+ * `SlashCommandRuntime` from `ctx`.
79
78
  */
80
79
  export interface TuiSlashCommandRuntime {
81
80
  ctx: InteractiveModeContext;
82
- handleBackgroundCommand: () => void;
83
81
  }
84
82
 
85
83
  /** Unified slash-command spec consumed by both TUI and ACP dispatchers. */
@@ -488,7 +488,7 @@ function getUsageTokens(usage: unknown): number {
488
488
  /**
489
489
  * Create proxy tools that reuse the parent's MCP connections.
490
490
  */
491
- function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
491
+ export function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
492
492
  return mcpManager.getTools().map(tool => {
493
493
  const mcpTool = tool as { mcpToolName?: string; mcpServerName?: string };
494
494
  return {
@@ -538,7 +538,10 @@ function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
538
538
  });
539
539
  }
540
540
 
541
- function createSubagentSettings(baseSettings: Settings, overrides?: Partial<Record<SettingPath, unknown>>): Settings {
541
+ export function createSubagentSettings(
542
+ baseSettings: Settings,
543
+ overrides?: Partial<Record<SettingPath, unknown>>,
544
+ ): Settings {
542
545
  const snapshot: Partial<Record<SettingPath, unknown>> = {};
543
546
  for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
544
547
  snapshot[key] = baseSettings.get(key);
package/src/task/index.ts CHANGED
@@ -275,6 +275,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
275
275
  readonly strict = true;
276
276
  readonly loadMode = "discoverable";
277
277
  readonly renderResult = renderResult;
278
+ // Suppress the streaming call preview once a (partial or final) result exists
279
+ // so the task renders as ONE block that transitions in place — not a pending
280
+ // call frame stacked above the result frame. Mirrors `taskToolRenderer`.
281
+ readonly mergeCallAndResult = true;
278
282
  readonly #discoveredAgents: AgentDefinition[];
279
283
  readonly #blockedAgent: string | undefined;
280
284
 
@@ -6,18 +6,20 @@
6
6
  */
7
7
  import path from "node:path";
8
8
  import type { Component } from "@oh-my-pi/pi-tui";
9
- import { Container, Text } from "@oh-my-pi/pi-tui";
9
+ import { Container, Markdown, Text } from "@oh-my-pi/pi-tui";
10
10
  import { formatNumber } from "@oh-my-pi/pi-utils";
11
11
  import { settings } from "../config/settings";
12
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import { formatContextUsage } from "../modes/components/status-line/context-thresholds";
14
- import type { Theme } from "../modes/theme/theme";
14
+ import { shimmerEnabled, shimmerText } from "../modes/theme/shimmer";
15
+ import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
15
16
  import {
16
17
  formatBadge,
17
18
  formatDuration,
18
19
  formatMoreItems,
19
20
  formatStatusIcon,
20
21
  replaceTabs,
22
+ type ToolUIStatus,
21
23
  truncateToWidth,
22
24
  } from "../tools/render-utils";
23
25
  import {
@@ -28,7 +30,8 @@ import {
28
30
  type ReportFindingDetails,
29
31
  type SubmitReviewDetails,
30
32
  } from "../tools/review";
31
- import { Ellipsis, Hasher, type RenderCache, renderStatusLine } from "../tui";
33
+ import { framedBlock, renderStatusLine } from "../tui";
34
+ import { repairDoubleEncodedJsonString } from "./repair-args";
32
35
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
33
36
  import type { AgentProgress, SingleResult, TaskItem, TaskParams, TaskToolDetails } from "./types";
34
37
 
@@ -506,28 +509,20 @@ function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string
506
509
  * preview. The args stream in token by token, so the array grows over time and
507
510
  * trailing entries may be partially parsed — every field access is defensive.
508
511
  */
509
- function renderTaskItemLines(
510
- tasks: TaskItem[] | undefined,
511
- contPrefix: string,
512
- expanded: boolean,
513
- theme: Theme,
514
- ): string[] {
512
+ function renderTaskItemLines(tasks: TaskItem[] | undefined, expanded: boolean, theme: Theme): string[] {
515
513
  const items = tasks ?? [];
516
514
  if (items.length === 0) return [];
517
515
 
518
- const branch = theme.fg("dim", theme.tree.branch);
519
- const last = theme.fg("dim", theme.tree.last);
516
+ const bullet = theme.fg("dim", "•");
520
517
  const cap = expanded ? items.length : Math.min(items.length, 12);
521
518
  const truncated = cap < items.length;
522
519
 
523
520
  const lines: string[] = [];
524
521
  for (let i = 0; i < cap; i++) {
525
522
  const task = items[i] as Partial<TaskItem> | undefined;
526
- const isLastLine = !truncated && i === items.length - 1;
527
- const connector = isLastLine ? last : branch;
528
523
  const rawId = task?.id?.trim();
529
524
  const idLabel = rawId ? formatTaskId(rawId) : `#${i + 1}`;
530
- let line = `${contPrefix}${connector} ${theme.fg("accent", theme.bold(idLabel))}`;
525
+ let line = `${bullet} ${theme.fg("accent", theme.bold(idLabel))}`;
531
526
  const desc = task?.description?.trim();
532
527
  if (desc) {
533
528
  line += `: ${theme.fg("muted", truncateToWidth(replaceTabs(desc), 64))}`;
@@ -535,11 +530,42 @@ function renderTaskItemLines(
535
530
  lines.push(line);
536
531
  }
537
532
  if (truncated) {
538
- lines.push(`${contPrefix}${last} ${theme.fg("dim", formatMoreItems(items.length - cap, "agent"))}`);
533
+ lines.push(`${bullet} ${theme.fg("dim", formatMoreItems(items.length - cap, "agent"))}`);
539
534
  }
540
535
  return lines;
541
536
  }
542
537
 
538
+ /**
539
+ * Build the shared-context section (the `# Goal / # Constraints` background
540
+ * passed to every subagent). Rendered in both the streaming call preview and
541
+ * the merged result frame so the brief stays visible for the whole task
542
+ * lifecycle — not just until the first progress snapshot replaces the call view.
543
+ */
544
+ type TaskRenderSection = { lines: string[] };
545
+ type ContextSectionRenderer = (width: number) => TaskRenderSection;
546
+
547
+ // Default output-block layout is: left border + one-cell content inset + right
548
+ // border. Render markdown at that inner width so the output block does not need
549
+ // to rewrap already-rendered context lines.
550
+ const CONTEXT_FRAME_INSET = 3;
551
+
552
+ function contextMarkdownWidth(frameWidth: number): number {
553
+ return Math.max(1, frameWidth - CONTEXT_FRAME_INSET);
554
+ }
555
+
556
+ function createContextSectionRenderer(args: TaskParams | undefined, theme: Theme): ContextSectionRenderer | undefined {
557
+ // `renderResult` receives the raw tool args (unlike `renderCall`, which is
558
+ // fed through `repairTaskParams`), so undo any per-field double-encoding here
559
+ // too. The repair is idempotent on already-clean text.
560
+ const context = repairDoubleEncodedJsonString(args?.context ?? "").trim();
561
+ if (!context) return undefined;
562
+
563
+ const markdown = new Markdown(context, 0, 0, getMarkdownTheme(), {
564
+ color: text => theme.fg("muted", text),
565
+ });
566
+ return width => ({ lines: markdown.render(contextMarkdownWidth(width)) });
567
+ }
568
+
543
569
  /**
544
570
  * Render the tool call arguments.
545
571
  */
@@ -548,44 +574,34 @@ export function renderCall(
548
574
  options: RenderResultOptions & { renderContext?: { hasResult?: boolean } },
549
575
  theme: Theme,
550
576
  ): Component {
551
- const lines: string[] = [];
552
- lines.push(renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme));
553
-
554
- const context = (args.context ?? "").trim();
555
- const hasContext = context.length > 0;
556
- const branch = theme.fg("dim", theme.tree.branch);
557
- const last = theme.fg("dim", theme.tree.last);
558
- const vertical = theme.fg("dim", theme.tree.vertical);
559
577
  const showIsolated = "isolated" in args && args.isolated === true;
560
- const taskCount = args.tasks?.length ?? 0;
561
-
562
- if (hasContext) {
563
- lines.push(` ${branch} ${theme.fg("dim", "Context")}`);
564
- const contextLines = context.split("\n").map(line => {
565
- const content = line ? theme.fg("muted", replaceTabs(line)) : "";
566
- return ` ${vertical} ${content}`;
567
- });
568
- lines.push(...contextLines);
569
- }
570
-
571
- // `Tasks` is the last child unless the isolation flag follows it.
572
- const tasksIsLast = !showIsolated;
573
- const tasksPrefix = tasksIsLast ? last : branch;
574
- lines.push(` ${tasksPrefix} ${theme.fg("dim", "Tasks")} ${theme.fg("muted", `(${taskCount})`)}`);
575
- const tasksContPrefix = tasksIsLast ? " " : ` ${vertical} `;
576
- // The per-task preview list only exists to surface dispatched agents while
577
- // the call args stream in. Once a result snapshot exists, `renderResult`
578
- // draws the same agents as progress/result lines (id + description), so
579
- // emitting the preview here would render every task twice.
580
- if (!options.renderContext?.hasResult) {
581
- lines.push(...renderTaskItemLines(args.tasks, tasksContPrefix, options.expanded, theme));
582
- }
583
-
584
- if (showIsolated) {
585
- lines.push(` ${last} ${theme.fg("dim", "Isolated")}: ${theme.fg("muted", "true")}`);
586
- }
578
+ const header = renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme);
579
+ const contextSectionRenderer = createContextSectionRenderer(args, theme);
580
+ return framedBlock(theme, width => {
581
+ const sections: Array<{ label?: string; lines: string[]; separator?: boolean }> = [];
582
+
583
+ if (contextSectionRenderer) sections.push(contextSectionRenderer(width));
584
+
585
+ // The per-task preview list only exists to surface dispatched agents while
586
+ // the call args stream in. Once a result snapshot exists, `renderResult`
587
+ // draws the same agents as progress/result lines, so showing the Tasks
588
+ // section here would just repeat the count the result frame already shows.
589
+ if (!options.renderContext?.hasResult) {
590
+ sections.push({
591
+ separator: true,
592
+ lines: renderTaskItemLines(args.tasks, options.expanded, theme),
593
+ });
594
+ }
587
595
 
588
- return new Text(lines.join("\n"), 0, 0);
596
+ return {
597
+ header,
598
+ headerMeta: showIsolated ? "isolated" : undefined,
599
+ sections,
600
+ state: "pending",
601
+ borderColor: "borderMuted",
602
+ width,
603
+ };
604
+ });
589
605
  }
590
606
 
591
607
  /**
@@ -593,14 +609,13 @@ export function renderCall(
593
609
  */
594
610
  function renderAgentProgress(
595
611
  progress: AgentProgress,
596
- isLast: boolean,
612
+ prefix: string,
613
+ continuePrefix: string,
597
614
  expanded: boolean,
598
615
  theme: Theme,
599
616
  spinnerFrame?: number,
600
617
  ): string[] {
601
618
  const lines: string[] = [];
602
- const prefix = isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch);
603
- const continuePrefix = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `;
604
619
 
605
620
  const icon = getStatusIcon(progress.status, theme, spinnerFrame);
606
621
  const iconColor =
@@ -614,11 +629,24 @@ function renderAgentProgress(
614
629
  const description = progress.description?.trim();
615
630
  const displayId = formatTaskId(progress.id);
616
631
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
617
- let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
632
+ const indent = prefix ? `${prefix} ` : "";
633
+ let statusLine: string;
634
+ if (progress.status === "running") {
635
+ const bullet = theme.fg("accent", "•");
636
+ const name = shimmerEnabled()
637
+ ? shimmerText(displayId, theme)
638
+ : theme.fg("accent", description ? theme.bold(displayId) : displayId);
639
+ statusLine = `${indent}${bullet} ${name}`;
640
+ if (description) {
641
+ statusLine += theme.fg("accent", `: ${description}`);
642
+ }
643
+ } else {
644
+ statusLine = `${indent}${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
645
+ }
618
646
 
619
647
  // Show retry-blocked badge so the parent immediately sees that a child
620
648
  // is sleeping on a provider 429, not silently progressing. Wins over the
621
- // generic running spinner because "we're waiting on a quota window" is
649
+ // generic running marker because "we're waiting on a quota window" is
622
650
  // the operationally meaningful state.
623
651
  if (progress.retryState && progress.status === "running") {
624
652
  statusLine += ` ${formatBadge("retrying", "warning", theme)}`;
@@ -867,10 +895,14 @@ function renderFindings(
867
895
  /**
868
896
  * Render final result for a single agent.
869
897
  */
870
- function renderAgentResult(result: SingleResult, isLast: boolean, expanded: boolean, theme: Theme): string[] {
898
+ function renderAgentResult(
899
+ result: SingleResult,
900
+ prefix: string,
901
+ continuePrefix: string,
902
+ expanded: boolean,
903
+ theme: Theme,
904
+ ): string[] {
871
905
  const lines: string[] = [];
872
- const prefix = isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch);
873
- const continuePrefix = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `;
874
906
 
875
907
  const { warning: missingCompleteWarning, rest: outputWithoutWarning } = extractMissingYieldWarning(result.output);
876
908
  const aborted = result.aborted ?? false;
@@ -899,7 +931,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
899
931
  const description = result.description?.trim();
900
932
  const displayId = formatTaskId(result.id);
901
933
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
902
- let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)} ${formatBadge(
934
+ let statusLine = `${prefix ? `${prefix} ` : ""}${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)} ${formatBadge(
903
935
  statusText,
904
936
  iconColor,
905
937
  theme,
@@ -1043,101 +1075,123 @@ export function renderResult(
1043
1075
  result: { content: Array<{ type: string; text?: string }>; details?: TaskToolDetails },
1044
1076
  options: RenderResultOptions,
1045
1077
  theme: Theme,
1078
+ args?: TaskParams,
1046
1079
  ): Component {
1047
1080
  const fallbackText = result.content.find(c => c.type === "text")?.text ?? "";
1048
1081
  const details = result.details;
1082
+ const contextSectionRenderer = createContextSectionRenderer(args, theme);
1049
1083
 
1050
1084
  if (!details) {
1051
1085
  const text = result.content.find(c => c.type === "text")?.text || "";
1052
- return new Text(theme.fg("dim", truncateToWidth(text, 100)), 0, 0);
1086
+ const header = renderStatusLine({ icon: "success", title: "Task" }, theme);
1087
+ return framedBlock(theme, width => ({
1088
+ header,
1089
+ sections: [
1090
+ ...(contextSectionRenderer ? [contextSectionRenderer(width)] : []),
1091
+ ...(text ? [{ separator: true, lines: [theme.fg("dim", truncateToWidth(text, width))] }] : []),
1092
+ ],
1093
+ state: "success",
1094
+ borderColor: "borderMuted",
1095
+ width,
1096
+ }));
1053
1097
  }
1054
1098
 
1055
- let cached: RenderCache | undefined;
1099
+ const hasResults = Boolean(details.results && details.results.length > 0);
1100
+ const aborted = hasResults && details.results.some(r => r.aborted);
1101
+ const failed = hasResults && details.results.some(r => !r.aborted && r.exitCode !== 0);
1102
+ const mergeFailed = hasResults && details.results.some(r => !r.aborted && r.exitCode === 0 && Boolean(r.error));
1103
+ const isError = aborted || failed;
1104
+ const agentCount = hasResults ? details.results.length : (details.progress?.length ?? 0);
1105
+ const icon: ToolUIStatus = options.isPartial ? "running" : isError ? "error" : mergeFailed ? "warning" : "success";
1106
+ const header = renderStatusLine(
1107
+ {
1108
+ icon,
1109
+ title: "Task",
1110
+ meta: agentCount > 0 ? [`${agentCount} ${agentCount === 1 ? "agent" : "agents"}`] : undefined,
1111
+ },
1112
+ theme,
1113
+ );
1056
1114
 
1057
- return {
1058
- render(width) {
1059
- const { expanded, isPartial, spinnerFrame } = options;
1060
- const key = new Hasher()
1061
- .bool(expanded)
1062
- .bool(isPartial)
1063
- .u32(spinnerFrame ?? 0)
1064
- .u32(width)
1065
- .digest();
1066
- if (cached?.key === key) return cached.lines;
1067
-
1068
- const lines: string[] = [];
1069
-
1070
- const shouldRenderProgress =
1071
- Boolean(details.progress && details.progress.length > 0) && (isPartial || details.results.length === 0);
1072
- if (shouldRenderProgress && details.progress) {
1073
- details.progress.forEach((progress, i) => {
1074
- const isLast = i === details.progress!.length - 1;
1075
- lines.push(...renderAgentProgress(progress, isLast, expanded, theme, spinnerFrame));
1076
- });
1077
- } else if (details.results && details.results.length > 0) {
1078
- details.results.forEach((res, i) => {
1079
- const isLast = i === details.results.length - 1;
1080
- lines.push(...renderAgentResult(res, isLast, expanded, theme));
1081
- });
1082
-
1083
- const abortedCount = details.results.filter(r => r.aborted).length;
1084
- const mergeFailedCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && r.error).length;
1085
- const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && !r.error).length;
1086
- const failCount = details.results.length - successCount - mergeFailedCount - abortedCount;
1087
- let summary = `${theme.fg("dim", "Total:")} `;
1088
- if (abortedCount > 0) {
1089
- summary += theme.fg("error", `${abortedCount} aborted`);
1090
- if (successCount > 0 || mergeFailedCount > 0 || failCount > 0) summary += theme.sep.dot;
1091
- }
1092
- if (successCount > 0) {
1093
- summary += theme.fg("success", `${successCount} succeeded`);
1094
- if (mergeFailedCount > 0 || failCount > 0) summary += theme.sep.dot;
1095
- }
1096
- if (mergeFailedCount > 0) {
1097
- summary += theme.fg("warning", `${mergeFailedCount} merge failed`);
1098
- if (failCount > 0) summary += theme.sep.dot;
1099
- }
1100
- if (failCount > 0) {
1101
- summary += theme.fg("error", `${failCount} failed`);
1102
- }
1103
- summary += `${theme.sep.dot}${theme.fg("dim", formatDuration(details.totalDurationMs))}`;
1104
- lines.push(summary);
1105
- }
1115
+ return framedBlock(theme, width => {
1116
+ const { expanded, isPartial, spinnerFrame } = options;
1117
+ const lines: string[] = [];
1106
1118
 
1107
- if (lines.length === 0) {
1108
- const text = fallbackText.trim() ? fallbackText : "No results";
1109
- const result = [theme.fg("dim", truncateToWidth(text, width))];
1110
- cached = { key, lines: result };
1111
- return result;
1112
- }
1119
+ const shouldRenderProgress =
1120
+ Boolean(details.progress && details.progress.length > 0) && (isPartial || details.results.length === 0);
1121
+ if (shouldRenderProgress && details.progress) {
1122
+ details.progress.forEach(progress => {
1123
+ lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame));
1124
+ });
1125
+ } else if (details.results && details.results.length > 0) {
1126
+ details.results.forEach(res => {
1127
+ lines.push(...renderAgentResult(res, "", " ", expanded, theme));
1128
+ });
1113
1129
 
1114
- if (fallbackText.trim()) {
1115
- const summaryLines = fallbackText.split("\n");
1116
- const markerIndex = summaryLines.findIndex(
1117
- line =>
1118
- line.includes("<system-notification>") ||
1119
- line.startsWith("Applied patches:") ||
1120
- line.startsWith("No changes to apply."),
1121
- );
1122
- if (markerIndex >= 0) {
1123
- const extra = summaryLines.slice(markerIndex);
1124
- for (const line of extra) {
1125
- if (!line.trim()) continue;
1126
- lines.push(theme.fg("dim", line));
1127
- }
1130
+ const abortedCount = details.results.filter(r => r.aborted).length;
1131
+ const mergeFailedCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && r.error).length;
1132
+ const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && !r.error).length;
1133
+ const failCount = details.results.length - successCount - mergeFailedCount - abortedCount;
1134
+ const summaryParts: string[] = [];
1135
+ if (abortedCount > 0) summaryParts.push(theme.fg("error", `${abortedCount} aborted`));
1136
+ if (successCount > 0) summaryParts.push(theme.fg("success", `${successCount} succeeded`));
1137
+ if (mergeFailedCount > 0) summaryParts.push(theme.fg("warning", `${mergeFailedCount} merge failed`));
1138
+ if (failCount > 0) summaryParts.push(theme.fg("error", `${failCount} failed`));
1139
+ summaryParts.push(theme.fg("dim", formatDuration(details.totalDurationMs)));
1140
+ // Wrap the run summary in the theme's bracket glyphs (dim chrome, colored
1141
+ // counts) to match the bash tool's `[Wall: … | Exit: …]` footer.
1142
+ lines.push(
1143
+ theme.fg("dim", theme.format.bracketLeft) +
1144
+ summaryParts.join(theme.fg("dim", theme.sep.dot)) +
1145
+ theme.fg("dim", theme.format.bracketRight),
1146
+ );
1147
+ }
1148
+
1149
+ const state = isPartial ? "running" : isError ? "error" : mergeFailed ? "warning" : "success";
1150
+ const borderColor = isError ? "error" : "borderMuted";
1151
+
1152
+ if (lines.length === 0) {
1153
+ const text = fallbackText.trim() ? fallbackText : "No results";
1154
+ return {
1155
+ header,
1156
+ sections: [
1157
+ ...(contextSectionRenderer ? [contextSectionRenderer(width)] : []),
1158
+ { separator: true, lines: [theme.fg("dim", truncateToWidth(text, width))] },
1159
+ ],
1160
+ state,
1161
+ borderColor,
1162
+ width,
1163
+ };
1164
+ }
1165
+
1166
+ if (fallbackText.trim()) {
1167
+ const summaryLines = fallbackText.split("\n");
1168
+ const markerIndex = summaryLines.findIndex(
1169
+ line =>
1170
+ line.includes("<system-notification>") ||
1171
+ line.startsWith("Applied patches:") ||
1172
+ line.startsWith("No changes to apply."),
1173
+ );
1174
+ if (markerIndex >= 0) {
1175
+ const extra = summaryLines.slice(markerIndex);
1176
+ for (const line of extra) {
1177
+ if (!line.trim()) continue;
1178
+ lines.push(theme.fg("dim", line));
1128
1179
  }
1129
1180
  }
1181
+ }
1130
1182
 
1131
- const indented = lines.map(line =>
1132
- line.length > 0 ? truncateToWidth(` ${line}`, width, Ellipsis.Omit) : "",
1133
- );
1134
- cached = { key, lines: indented };
1135
- return indented;
1136
- },
1137
- invalidate() {
1138
- cached = undefined;
1139
- },
1140
- };
1183
+ while (lines.length > 0 && lines[0].trim() === "") lines.shift();
1184
+ return {
1185
+ header,
1186
+ sections: [
1187
+ ...(contextSectionRenderer ? [contextSectionRenderer(width)] : []),
1188
+ ...(lines.length > 0 ? [{ separator: true, lines }] : []),
1189
+ ],
1190
+ state,
1191
+ borderColor,
1192
+ width,
1193
+ };
1194
+ });
1141
1195
  }
1142
1196
 
1143
1197
  function isTaskToolDetails(value: unknown): value is TaskToolDetails {
@@ -1149,13 +1203,23 @@ function isTaskToolDetails(value: unknown): value is TaskToolDetails {
1149
1203
  );
1150
1204
  }
1151
1205
 
1206
+ // Nested subagent snapshots sit one or more levels below the frame border, so
1207
+ // they keep tree guides to convey depth (the parent prepends its own continue
1208
+ // prefix). Only the top-level agent list drops guides (the frame is its box).
1209
+ function nestedMarkers(isLast: boolean, theme: Theme): { prefix: string; continuePrefix: string } {
1210
+ return {
1211
+ prefix: isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch),
1212
+ continuePrefix: isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `,
1213
+ };
1214
+ }
1215
+
1152
1216
  function renderNestedTaskResults(detailsList: TaskToolDetails[], expanded: boolean, theme: Theme): string[] {
1153
1217
  const lines: string[] = [];
1154
1218
  for (const details of detailsList) {
1155
1219
  if (!details.results || details.results.length === 0) continue;
1156
1220
  details.results.forEach((result, index) => {
1157
- const isLast = index === details.results.length - 1;
1158
- lines.push(...renderAgentResult(result, isLast, expanded, theme));
1221
+ const { prefix, continuePrefix } = nestedMarkers(index === details.results.length - 1, theme);
1222
+ lines.push(...renderAgentResult(result, prefix, continuePrefix, expanded, theme));
1159
1223
  });
1160
1224
  }
1161
1225
  return lines;
@@ -1177,16 +1241,16 @@ function renderNestedTaskTree(
1177
1241
  const hasResults = Boolean(details.results && details.results.length > 0);
1178
1242
  if (hasResults) {
1179
1243
  details.results.forEach((result, index) => {
1180
- const isLast = index === details.results.length - 1;
1181
- lines.push(...renderAgentResult(result, isLast, expanded, theme));
1244
+ const { prefix, continuePrefix } = nestedMarkers(index === details.results.length - 1, theme);
1245
+ lines.push(...renderAgentResult(result, prefix, continuePrefix, expanded, theme));
1182
1246
  });
1183
1247
  continue;
1184
1248
  }
1185
1249
  const inflight = details.progress;
1186
1250
  if (inflight && inflight.length > 0) {
1187
1251
  inflight.forEach((prog, index) => {
1188
- const isLast = index === inflight.length - 1;
1189
- lines.push(...renderAgentProgress(prog, isLast, expanded, theme, spinnerFrame));
1252
+ const { prefix, continuePrefix } = nestedMarkers(index === inflight.length - 1, theme);
1253
+ lines.push(...renderAgentProgress(prog, prefix, continuePrefix, expanded, theme, spinnerFrame));
1190
1254
  });
1191
1255
  }
1192
1256
  }
@@ -1207,4 +1271,5 @@ subprocessToolRegistry.register<TaskToolDetails>("task", {
1207
1271
  export const taskToolRenderer = {
1208
1272
  renderCall,
1209
1273
  renderResult,
1274
+ mergeCallAndResult: true,
1210
1275
  };