@oh-my-pi/pi-coding-agent 15.11.4 → 15.11.7

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 (98) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/dist/cli.js +520 -451
  3. package/dist/types/cli/bench-cli.d.ts +78 -0
  4. package/dist/types/cli/usage-cli.d.ts +10 -1
  5. package/dist/types/commands/bench.d.ts +29 -0
  6. package/dist/types/commands/usage.d.ts +9 -0
  7. package/dist/types/config/model-resolver.d.ts +3 -2
  8. package/dist/types/config/settings-schema.d.ts +125 -3
  9. package/dist/types/edit/renderer.d.ts +1 -0
  10. package/dist/types/modes/components/oauth-selector.d.ts +10 -1
  11. package/dist/types/modes/components/reset-usage-selector.d.ts +12 -0
  12. package/dist/types/modes/components/session-selector.d.ts +1 -1
  13. package/dist/types/modes/components/settings-selector.d.ts +8 -1
  14. package/dist/types/modes/components/snapcompact-shape-preview.d.ts +31 -0
  15. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  16. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  17. package/dist/types/modes/interactive-mode.d.ts +10 -0
  18. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  19. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +3 -0
  20. package/dist/types/modes/setup-wizard/scenes/types.d.ts +10 -1
  21. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +3 -0
  22. package/dist/types/modes/types.d.ts +2 -0
  23. package/dist/types/modes/utils/context-usage.d.ts +6 -1
  24. package/dist/types/session/agent-session.d.ts +14 -1
  25. package/dist/types/session/auth-storage.d.ts +1 -1
  26. package/dist/types/session/codex-auto-reset.d.ts +107 -0
  27. package/dist/types/session/snapcompact-inline.d.ts +107 -4
  28. package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
  29. package/dist/types/task/render.d.ts +1 -0
  30. package/dist/types/tools/bash.d.ts +2 -0
  31. package/dist/types/tools/eval-render.d.ts +1 -0
  32. package/dist/types/tools/renderers.d.ts +13 -0
  33. package/dist/types/tools/ssh.d.ts +1 -0
  34. package/dist/types/tools/todo.d.ts +0 -11
  35. package/package.json +11 -11
  36. package/src/cli/bench-cli.ts +437 -0
  37. package/src/cli/usage-cli.ts +187 -16
  38. package/src/cli-commands.ts +1 -0
  39. package/src/commands/bench.ts +42 -0
  40. package/src/commands/usage.ts +8 -0
  41. package/src/config/model-registry.ts +52 -5
  42. package/src/config/model-resolver.ts +36 -5
  43. package/src/config/settings-schema.ts +148 -3
  44. package/src/config/settings.ts +9 -0
  45. package/src/edit/renderer.ts +5 -0
  46. package/src/hindsight/client.ts +26 -1
  47. package/src/hindsight/state.ts +6 -2
  48. package/src/internal-urls/docs-index.generated.ts +2 -2
  49. package/src/mcp/transports/stdio.ts +81 -7
  50. package/src/modes/components/oauth-selector.ts +67 -7
  51. package/src/modes/components/reset-usage-selector.ts +161 -0
  52. package/src/modes/components/session-selector.ts +8 -2
  53. package/src/modes/components/settings-selector.ts +89 -47
  54. package/src/modes/components/snapcompact-shape-preview-doc.md +11 -0
  55. package/src/modes/components/snapcompact-shape-preview.ts +192 -0
  56. package/src/modes/components/tool-execution.ts +26 -0
  57. package/src/modes/components/transcript-container.ts +23 -1
  58. package/src/modes/controllers/command-controller.ts +24 -1
  59. package/src/modes/controllers/input-controller.ts +8 -6
  60. package/src/modes/controllers/selector-controller.ts +72 -2
  61. package/src/modes/interactive-mode.ts +83 -0
  62. package/src/modes/session-observer-registry.ts +61 -3
  63. package/src/modes/setup-wizard/index.ts +1 -0
  64. package/src/modes/setup-wizard/scenes/glyph.ts +24 -6
  65. package/src/modes/setup-wizard/scenes/providers.ts +36 -2
  66. package/src/modes/setup-wizard/scenes/sign-in.ts +10 -1
  67. package/src/modes/setup-wizard/scenes/theme.ts +28 -1
  68. package/src/modes/setup-wizard/scenes/types.ts +10 -1
  69. package/src/modes/setup-wizard/scenes/web-search.ts +22 -6
  70. package/src/modes/setup-wizard/wizard-overlay.ts +38 -1
  71. package/src/modes/theme/theme.ts +2 -2
  72. package/src/modes/types.ts +2 -0
  73. package/src/modes/utils/context-usage.ts +75 -1
  74. package/src/prompts/bench.md +7 -0
  75. package/src/prompts/system/snapcompact-context-frames-note.md +1 -0
  76. package/src/prompts/system/snapcompact-context-stub.md +1 -0
  77. package/src/prompts/system/snapcompact-toolresult-note.md +1 -1
  78. package/src/prompts/tools/browser.md +33 -43
  79. package/src/prompts/tools/eval.md +27 -50
  80. package/src/prompts/tools/irc.md +29 -31
  81. package/src/prompts/tools/read.md +31 -37
  82. package/src/prompts/tools/todo.md +1 -2
  83. package/src/sdk.ts +4 -2
  84. package/src/session/agent-session.ts +136 -6
  85. package/src/session/auth-storage.ts +3 -0
  86. package/src/session/codex-auto-reset.ts +190 -0
  87. package/src/session/snapcompact-inline.ts +404 -75
  88. package/src/slash-commands/builtin-registry.ts +145 -8
  89. package/src/slash-commands/helpers/context-report.ts +28 -1
  90. package/src/slash-commands/helpers/reset-usage.ts +66 -0
  91. package/src/slash-commands/helpers/usage-report.ts +12 -0
  92. package/src/task/index.ts +30 -7
  93. package/src/task/render.ts +34 -19
  94. package/src/tools/bash.ts +3 -0
  95. package/src/tools/eval-render.ts +4 -0
  96. package/src/tools/renderers.ts +13 -0
  97. package/src/tools/ssh.ts +3 -0
  98. package/src/tools/todo.ts +8 -128
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
5
+ import { setNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug";
5
6
  import { Snowflake, setProjectDir } from "@oh-my-pi/pi-utils";
6
7
  import { $ } from "bun";
7
8
  import type { SettingPath, SettingValue } from "../config/settings";
@@ -21,7 +22,7 @@ import {
21
22
  } from "../extensibility/plugins/marketplace";
22
23
  import { resolveMemoryBackend } from "../memory-backend";
23
24
  import type { InteractiveModeContext } from "../modes/types";
24
- import type { FreshSessionResult } from "../session/agent-session";
25
+ import type { AgentSession, FreshSessionResult } from "../session/agent-session";
25
26
  import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
26
27
  import { getChangelogPath, parseChangelog } from "../utils/changelog";
27
28
  import { buildContextReportText } from "./helpers/context-report";
@@ -29,6 +30,7 @@ import { formatDuration } from "./helpers/format";
29
30
  import { createMarketplaceManager } from "./helpers/marketplace-manager";
30
31
  import { handleMcpAcp } from "./helpers/mcp";
31
32
  import { commandConsumed, errorMessage, parseSlashCommand, parseSubcommand, usage } from "./helpers/parse";
33
+ import { describeRedeemOutcome, type ResetUsageAccount, toResetUsageAccounts } from "./helpers/reset-usage";
32
34
  import { handleSshAcp } from "./helpers/ssh";
33
35
  import { launchStatsDashboard, parseStatsDashboardArgs } from "./helpers/stats-dashboard";
34
36
  import { handleTodoAcp } from "./helpers/todo";
@@ -65,6 +67,95 @@ const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashComma
65
67
  return commandConsumed();
66
68
  };
67
69
 
70
+ async function handleUsageResetCommand(
71
+ arg: string,
72
+ session: AgentSession,
73
+ output: SlashCommandRuntime["output"],
74
+ ): Promise<void> {
75
+ let accounts: ResetUsageAccount[];
76
+ try {
77
+ accounts = toResetUsageAccounts(await session.listResetCredits());
78
+ } catch (error) {
79
+ await output(`Could not load saved resets: ${errorMessage(error)}`);
80
+ return;
81
+ }
82
+ if (accounts.length === 0) {
83
+ await output("No Codex accounts found. Use /login to add one.");
84
+ return;
85
+ }
86
+ const targetArg = arg.trim();
87
+ if (!targetArg) {
88
+ const lines = ["Saved Codex rate-limit resets:"];
89
+ for (const account of accounts) {
90
+ const detail = account.error ? `unavailable (${account.error})` : `${account.availableCount} available`;
91
+ lines.push(`- ${account.label}: ${detail}${account.active ? " (active)" : ""}`);
92
+ }
93
+ lines.push("", "Spend one with `/usage reset <account email>` or `/usage reset active`.");
94
+ await output(lines.join("\n"));
95
+ return;
96
+ }
97
+ const wanted = targetArg.toLowerCase();
98
+ const target =
99
+ wanted === "active"
100
+ ? accounts.find(account => account.active)
101
+ : accounts.find(
102
+ account =>
103
+ account.label.toLowerCase() === wanted ||
104
+ account.target.email?.toLowerCase() === wanted ||
105
+ account.target.accountId?.toLowerCase() === wanted,
106
+ );
107
+ if (!target) {
108
+ await output(`No Codex account matches "${targetArg}".`);
109
+ return;
110
+ }
111
+ if (target.availableCount <= 0) {
112
+ await output(`${target.label}: no saved resets to spend.`);
113
+ return;
114
+ }
115
+ const outcome = await session.redeemResetCredit(target.target);
116
+ await output(describeRedeemOutcome(outcome, target.label));
117
+ }
118
+
119
+ const DEBUG_DUMP_NEXT_REQUEST_USAGE = "Usage: /debug dump-next-request <path>";
120
+
121
+ function resolveDebugRequestDumpPath(target: string, cwd: string): string {
122
+ const expanded =
123
+ target === "~"
124
+ ? os.homedir()
125
+ : target.startsWith("~/") || target.startsWith("~\\")
126
+ ? path.join(os.homedir(), target.slice(2))
127
+ : target;
128
+ return path.resolve(cwd, expanded);
129
+ }
130
+
131
+ async function handleDebugSubcommand(
132
+ args: string,
133
+ cwd: string,
134
+ output: (text: string) => Promise<void> | void,
135
+ ): Promise<SlashCommandResult> {
136
+ const { verb, rest } = parseSubcommand(args);
137
+ switch (verb) {
138
+ case "":
139
+ await output(DEBUG_DUMP_NEXT_REQUEST_USAGE);
140
+ return commandConsumed();
141
+ case "dump-next-request":
142
+ case "dump-request":
143
+ case "next-request": {
144
+ if (!rest) {
145
+ await output(DEBUG_DUMP_NEXT_REQUEST_USAGE);
146
+ return commandConsumed();
147
+ }
148
+ const requestPath = resolveDebugRequestDumpPath(rest, cwd);
149
+ setNextRequestDebugPath(requestPath);
150
+ await output(`Next AI provider request will be dumped to ${requestPath}`);
151
+ return commandConsumed();
152
+ }
153
+ default:
154
+ await output(`Unknown /debug subcommand "${verb}". ${DEBUG_DUMP_NEXT_REQUEST_USAGE}`);
155
+ return commandConsumed();
156
+ }
157
+ }
158
+
68
159
  /** Parse the `/shake` subcommand into a {@link ShakeMode}; empty defaults to elide. */
69
160
  function parseShakeMode(args: string): ShakeMode | { error: string } {
70
161
  const verb = args.trim().toLowerCase();
@@ -551,12 +642,41 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
551
642
  name: "usage",
552
643
  description: "Show provider usage and limits",
553
644
  acpDescription: "Show token usage",
554
- handle: async (_command, runtime) => {
555
- await runtime.output(await buildUsageReportText(runtime));
556
- return commandConsumed();
645
+ acpInputHint: "[show|reset [account|active]]",
646
+ subcommands: [
647
+ { name: "show", description: "Show provider usage and limits" },
648
+ { name: "reset", description: "Spend a saved Codex rate-limit reset", usage: "[account|active]" },
649
+ ],
650
+ allowArgs: true,
651
+ handle: async (command, runtime) => {
652
+ const { verb, rest } = parseSubcommand(command.args);
653
+ if (!verb || (verb === "show" && !rest)) {
654
+ await runtime.output(await buildUsageReportText(runtime));
655
+ return commandConsumed();
656
+ }
657
+ if (verb === "reset") {
658
+ await handleUsageResetCommand(rest, runtime.session, runtime.output);
659
+ return commandConsumed();
660
+ }
661
+ return usage("Usage: /usage [show|reset [account|active]]", runtime);
557
662
  },
558
- handleTui: async (_command, runtime) => {
559
- await runtime.ctx.handleUsageCommand();
663
+ handleTui: async (command, runtime) => {
664
+ const { verb, rest } = parseSubcommand(command.args);
665
+ if (!verb || (verb === "show" && !rest)) {
666
+ await runtime.ctx.handleUsageCommand();
667
+ runtime.ctx.editor.setText("");
668
+ return;
669
+ }
670
+ if (verb === "reset") {
671
+ if (rest) {
672
+ await handleUsageResetCommand(rest, runtime.ctx.session, text => runtime.ctx.showStatus(text));
673
+ } else {
674
+ await runtime.ctx.showResetUsageSelector();
675
+ }
676
+ runtime.ctx.editor.setText("");
677
+ return;
678
+ }
679
+ runtime.ctx.showStatus("Usage: /usage [show|reset [account|active]]");
560
680
  runtime.ctx.editor.setText("");
561
681
  },
562
682
  },
@@ -974,8 +1094,25 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
974
1094
  {
975
1095
  name: "debug",
976
1096
  description: "Open debug tools selector",
977
- handleTui: (_command, runtime) => {
978
- runtime.ctx.showDebugSelector();
1097
+ allowArgs: true,
1098
+ subcommands: [
1099
+ {
1100
+ name: "dump-next-request",
1101
+ description: "Dump the next AI provider HTTP request as JSON",
1102
+ usage: "<path>",
1103
+ },
1104
+ ],
1105
+ handle: async (command, runtime) =>
1106
+ handleDebugSubcommand(command.args, runtime.cwd, text => runtime.output(text)),
1107
+ handleTui: async (command, runtime) => {
1108
+ const args = command.args.trim();
1109
+ if (args.length === 0) {
1110
+ runtime.ctx.showDebugSelector();
1111
+ } else {
1112
+ await handleDebugSubcommand(args, runtime.ctx.sessionManager.getCwd(), text =>
1113
+ runtime.ctx.showStatus(text),
1114
+ );
1115
+ }
979
1116
  runtime.ctx.editor.setText("");
980
1117
  },
981
1118
  },
@@ -9,7 +9,7 @@ import { renderAsciiBar } from "./format";
9
9
  */
10
10
  export function buildContextReportText(runtime: SlashCommandRuntime): string {
11
11
  try {
12
- const breakdown = computeContextBreakdown(runtime.session);
12
+ const breakdown = computeContextBreakdown(runtime.session, { snapcompactSavings: true });
13
13
  if (breakdown.contextWindow <= 0) {
14
14
  return "Context usage is unavailable: no model is selected for this session.";
15
15
  }
@@ -30,6 +30,33 @@ export function buildContextReportText(runtime: SlashCommandRuntime): string {
30
30
  const fraction = breakdown.freeTokens / breakdown.contextWindow;
31
31
  lines.push(` ${"Free".padEnd(16)} ${renderAsciiBar(fraction)} ${breakdown.freeTokens} tokens`);
32
32
  }
33
+ const snap = breakdown.snapcompact;
34
+ if (snap) {
35
+ if (!snap.visionCapable) {
36
+ lines.push("Snapcompact: inactive (model has no image input)");
37
+ } else {
38
+ lines.push("Snapcompact (estimated wire savings):");
39
+ if (snap.systemPrompt) {
40
+ const sp = snap.systemPrompt;
41
+ lines.push(
42
+ sp.applied
43
+ ? ` System prompt: ${sp.textTokens} text tokens → ${sp.frames} frame${sp.frames === 1 ? "" : "s"} ≈ ${sp.imageTokens} tokens (saves ~${sp.savedTokens})`
44
+ : " System prompt: stays text (no net savings)",
45
+ );
46
+ }
47
+ if (snap.toolResults) {
48
+ const tr = snap.toolResults;
49
+ lines.push(
50
+ tr.swapped > 0
51
+ ? ` Tool results: ${tr.swapped} of ${tr.total} imaged, ${tr.textTokens} text tokens → ${tr.frames} frames ≈ ${tr.imageTokens} tokens (saves ~${tr.savedTokens})`
52
+ : ` Tool results: none imaged (${tr.total} in history)`,
53
+ );
54
+ }
55
+ if (snap.savedTokens > 0) {
56
+ lines.push(` Estimated next request: ~${breakdown.usedTokens - snap.savedTokens} tokens on the wire`);
57
+ }
58
+ }
59
+ }
33
60
  return lines.join("\n");
34
61
  } catch {
35
62
  const fallback = runtime.session.getContextUsage();
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Shared helpers for the `/usage reset` command (TUI selector + ACP): turn the
3
+ * live per-account reset-credit status into selector rows, and map a redeem
4
+ * outcome code to a human message.
5
+ */
6
+ import type { ResetCreditAccountStatus, ResetCreditRedeemOutcome, ResetCreditTarget } from "../../session/auth-storage";
7
+
8
+ export const CODEX_PROVIDER_ID = "openai-codex";
9
+
10
+ /** One Codex account row for the reset-usage selector. */
11
+ export interface ResetUsageAccount {
12
+ /** Display label (email, else account id). */
13
+ label: string;
14
+ /** Saved resets redeemable for this account right now. */
15
+ availableCount: number;
16
+ /** Identifies the account when redeeming. */
17
+ target: ResetCreditTarget;
18
+ /** Whether this is the session's active Codex account. */
19
+ active: boolean;
20
+ /** Set when this account could not be reached (token/list failure). */
21
+ error?: string;
22
+ }
23
+
24
+ /**
25
+ * Map live per-account reset status to selector rows. Sorted with the active
26
+ * account first, then most-credits, then label.
27
+ */
28
+ export function toResetUsageAccounts(statuses: ResetCreditAccountStatus[]): ResetUsageAccount[] {
29
+ return statuses
30
+ .map(status => ({
31
+ label: status.email ?? status.accountId ?? "account",
32
+ availableCount: status.availableCount,
33
+ target: {
34
+ credentialId: status.credentialId,
35
+ accountId: status.accountId,
36
+ email: status.email,
37
+ } satisfies ResetCreditTarget,
38
+ active: status.active,
39
+ error: status.error,
40
+ }))
41
+ .sort((a, b) => {
42
+ if (a.active !== b.active) return a.active ? -1 : 1;
43
+ if (a.availableCount !== b.availableCount) return b.availableCount - a.availableCount;
44
+ return a.label.localeCompare(b.label);
45
+ });
46
+ }
47
+
48
+ /** Human-facing summary of a redeem outcome for status lines and ACP output. */
49
+ export function describeRedeemOutcome(outcome: ResetCreditRedeemOutcome, label: string): string {
50
+ switch (outcome.code) {
51
+ case "reset":
52
+ return `Reset applied for ${label} — your rate-limit window has been refreshed.`;
53
+ case "already_redeemed":
54
+ return `${label}: that reset was already redeemed.`;
55
+ case "no_credit":
56
+ return `${label}: no saved resets available to spend.`;
57
+ case "nothing_to_reset":
58
+ return `${label}: nothing to reset right now — your limits aren't constrained, so no credit was spent.`;
59
+ case "no_account":
60
+ return `Could not find a stored Codex account matching "${label}".`;
61
+ case "account_unavailable":
62
+ return `${label}: could not authenticate this account — try /login.`;
63
+ default:
64
+ return `${label}: reset did not apply (${outcome.code}).`;
65
+ }
66
+ }
@@ -54,6 +54,18 @@ function renderUsageReports(
54
54
  const activeAccount = resolveActiveAccount?.(provider);
55
55
  for (const report of providerReports) {
56
56
  const inUse = reportMatchesActiveAccount(report, activeAccount);
57
+ const savedResets = report.resetCredits?.availableCount ?? 0;
58
+ if (savedResets > 0) {
59
+ const resetLabel =
60
+ typeof report.metadata?.email === "string"
61
+ ? report.metadata.email
62
+ : typeof report.metadata?.accountId === "string"
63
+ ? report.metadata.accountId
64
+ : "account";
65
+ lines.push(
66
+ `- ${resetLabel}: ${savedResets} saved rate-limit reset${savedResets === 1 ? "" : "s"} available — /usage reset to spend`,
67
+ );
68
+ }
57
69
  if (report.limits.length === 0) {
58
70
  const email = typeof report.metadata?.email === "string" ? report.metadata.email : "account";
59
71
  lines.push(`- ${email}: no limits reported`);
package/src/task/index.ts CHANGED
@@ -698,7 +698,14 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
698
698
  buildDetails("running", ownJobId) as unknown as Record<string, unknown>,
699
699
  );
700
700
  try {
701
- const result = await this.#executeSync(toolCallId, spawnParams, runSignal, undefined, agentId);
701
+ const result = await this.#executeSync(
702
+ toolCallId,
703
+ spawnParams,
704
+ runSignal,
705
+ undefined,
706
+ agentId,
707
+ progress.index,
708
+ );
702
709
  const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)";
703
710
  const singleResult = result.details?.results[0];
704
711
  // A missing result means the sync path failed at the tool level
@@ -781,7 +788,14 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
781
788
  if (spawnItems.length === 1) {
782
789
  await semaphore.acquire();
783
790
  try {
784
- return await this.#executeSync(toolCallId, spawnParamsFor(params, spawnItems[0]), signal, onUpdate);
791
+ return await this.#executeSync(
792
+ toolCallId,
793
+ spawnParamsFor(params, spawnItems[0]),
794
+ signal,
795
+ onUpdate,
796
+ undefined,
797
+ 0,
798
+ );
785
799
  } finally {
786
800
  semaphore.release();
787
801
  }
@@ -818,7 +832,14 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
818
832
  }
819
833
  }
820
834
  : undefined;
821
- return await this.#executeSync(toolCallId, spawnParamsFor(params, item), workerSignal, itemOnUpdate);
835
+ return await this.#executeSync(
836
+ toolCallId,
837
+ spawnParamsFor(params, item),
838
+ workerSignal,
839
+ itemOnUpdate,
840
+ undefined,
841
+ index,
842
+ );
822
843
  } finally {
823
844
  semaphore.release();
824
845
  }
@@ -875,8 +896,9 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
875
896
  signal?: AbortSignal,
876
897
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
877
898
  preAllocatedId?: string,
899
+ spawnIndex = 0,
878
900
  ): Promise<AgentToolResult<TaskToolDetails>> {
879
- return this.#runSpawn(toolCallId, params, signal, onUpdate, preAllocatedId);
901
+ return this.#runSpawn(toolCallId, params, signal, onUpdate, preAllocatedId, spawnIndex);
880
902
  }
881
903
 
882
904
  /** Spawn a fresh subagent and run it to completion. */
@@ -886,6 +908,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
886
908
  signal?: AbortSignal,
887
909
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
888
910
  preAllocatedId?: string,
911
+ spawnIndex = 0,
889
912
  ): Promise<AgentToolResult<TaskToolDetails>> {
890
913
  const startTime = Date.now();
891
914
  const { agents, projectAgentsDir } = await discoverAgents(this.session.cwd);
@@ -1070,7 +1093,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1070
1093
 
1071
1094
  // Progress tracking for the single agent
1072
1095
  let latestProgress: AgentProgress = {
1073
- index: 0,
1096
+ index: spawnIndex,
1074
1097
  id: agentId,
1075
1098
  agent: agentName,
1076
1099
  agentSource: agent.source,
@@ -1120,7 +1143,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1120
1143
  context: sharedContext,
1121
1144
  planReference,
1122
1145
  description: params.description,
1123
- index: 0,
1146
+ index: spawnIndex,
1124
1147
  parentToolCallId: toolCallId,
1125
1148
  id: agentId,
1126
1149
  taskDepth,
@@ -1226,7 +1249,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1226
1249
  } catch (err) {
1227
1250
  const message = err instanceof Error ? err.message : String(err);
1228
1251
  return {
1229
- index: 0,
1252
+ index: spawnIndex,
1230
1253
  id: agentId,
1231
1254
  agent: agent.name,
1232
1255
  agentSource: agent.source,
@@ -165,7 +165,7 @@ function formatJsonScalar(value: unknown, _theme: Theme): string {
165
165
  return "";
166
166
  }
167
167
 
168
- function formatTaskId(id: string): string {
168
+ export function formatTaskId(id: string): string {
169
169
  // Ids are name-based (e.g. "Anna", "Anna-2"); a "." separates nesting levels
170
170
  // (e.g. "Anna.Bob"). Render the hierarchy with a ">" breadcrumb.
171
171
  const segments = id.split(".");
@@ -627,7 +627,13 @@ function createMarkdownSectionRenderer(text: string, theme: Theme): AssignmentSe
627
627
  */
628
628
  export function renderCall(args: TaskParams, options: TaskRenderOptions, theme: Theme): Component {
629
629
  const showIsolated = "isolated" in args && args.isolated === true;
630
- const header = renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme);
630
+ // Dispatch glyph from the first frame: spawning is non-blocking, so a
631
+ // pending/hourglass icon would misread the call as something the turn
632
+ // waits on.
633
+ const header = renderStatusLine(
634
+ { iconOverride: theme.styledSymbol("tool.task", "accent"), title: "Task", description: args.agent },
635
+ theme,
636
+ );
631
637
  const assignmentSection = createAssignmentSectionRenderer(args, theme);
632
638
  const contextSection = createContextSectionRenderer(args, theme);
633
639
  return framedBlock(theme, width => {
@@ -692,21 +698,23 @@ function renderAgentProgress(
692
698
  const indent = prefix ? `${prefix} ` : "";
693
699
  let statusLine: string;
694
700
  if (progress.status === "running" || progress.status === "pending") {
695
- // Live (or queued) agents use the task icon: detached async spawns can
696
- // stay "pending" while real work is running, so a pending/hourglass glyph
697
- // reads wrong in the transcript. Keep the row static; the Task tool header
698
- // already carries any live animation.
699
- const taskIcon = theme.styledSymbol("tool.task", frozen ? "dim" : "accent");
701
+ // Live (or queued) agents use the same dot finished rows keep: detached
702
+ // async spawns can stay "pending" while real work is running, so a
703
+ // pending/hourglass or spinner glyph reads wrong in the transcript. Keep
704
+ // the row static; the Task tool header already carries the dispatch icon.
705
+ const dot = theme.styledSymbol("status.done", frozen ? "dim" : "accent");
700
706
  const nameColor = frozen ? "dim" : "accent";
701
707
  const name = theme.fg(nameColor, description ? theme.bold(displayId) : displayId);
702
- statusLine = `${indent}${taskIcon} ${name}`;
708
+ statusLine = `${indent}${dot} ${name}`;
703
709
  if (description) {
704
710
  statusLine += `${theme.fg(nameColor, ":")} ${theme.fg(nameColor, description)}`;
705
711
  }
712
+ } else if (progress.status === "completed") {
713
+ // Finished rows keep the dot but settle from accent to the plain
714
+ // foreground: completion reads as a color change, not a new glyph.
715
+ statusLine = `${indent}${theme.styledSymbol("status.done", "text")} ${theme.fg("text", titlePart)}`;
706
716
  } else {
707
- const glyph =
708
- progress.status === "completed" ? theme.styledSymbol("status.done", "accent") : theme.fg(iconColor, icon);
709
- statusLine = `${indent}${glyph} ${theme.fg("accent", titlePart)}`;
717
+ statusLine = `${indent}${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
710
718
  }
711
719
 
712
720
  // Show retry-blocked badge so the parent immediately sees that a child
@@ -982,7 +990,7 @@ function renderAgentResult(
982
990
  : needsWarning
983
991
  ? theme.status.warning
984
992
  : success
985
- ? theme.styledSymbol("status.done", "accent")
993
+ ? theme.styledSymbol("status.done", "text")
986
994
  : theme.status.error;
987
995
  const iconColor = needsWarning ? "warning" : success ? "success" : mergeFailed ? "warning" : "error";
988
996
  const statusText = aborted
@@ -999,11 +1007,10 @@ function renderAgentResult(
999
1007
  const description = result.description?.trim();
1000
1008
  const displayId = formatTaskId(result.id);
1001
1009
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
1002
- let statusLine = `${prefix ? `${prefix} ` : ""}${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)} ${formatBadge(
1003
- statusText,
1004
- iconColor,
1005
- theme,
1006
- )}`;
1010
+ let statusLine = `${prefix ? `${prefix} ` : ""}${theme.fg(iconColor, icon)} ${theme.fg(
1011
+ success && !needsWarning ? "text" : "accent",
1012
+ titlePart,
1013
+ )} ${formatBadge(statusText, iconColor, theme)}`;
1007
1014
  const showBadge = settings.get("task.showResolvedModelBadge");
1008
1015
  statusLine = appendAgentStats(
1009
1016
  statusLine,
@@ -1217,8 +1224,16 @@ export function renderResult(
1217
1224
  const metaLabel = countLabel ? (agentLabel ? `${countLabel}: ${agentLabel}` : countLabel) : agentLabel;
1218
1225
  const header = renderStatusLine(
1219
1226
  {
1220
- icon: icon === "success" ? undefined : icon,
1221
- iconOverride: icon === "success" ? theme.styledSymbol("status.done", "accent") : undefined,
1227
+ icon: icon === "success" || icon === "running" ? undefined : icon,
1228
+ // While agents are in flight the header shows the dispatch glyph, not a
1229
+ // spinner: async spawns return immediately, so "running" means
1230
+ // "delegated to peers", not "this call is blocking the turn".
1231
+ iconOverride:
1232
+ icon === "running"
1233
+ ? theme.styledSymbol("tool.task", "accent")
1234
+ : icon === "success"
1235
+ ? theme.styledSymbol("status.done", "accent")
1236
+ : undefined,
1222
1237
  title: "Task",
1223
1238
  meta: metaLabel ? [metaLabel] : undefined,
1224
1239
  },
package/src/tools/bash.ts CHANGED
@@ -1385,6 +1385,9 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1385
1385
  },
1386
1386
  mergeCallAndResult: true,
1387
1387
  inline: true,
1388
+ // Pending preview caps the command to a viewport-sized tail window that
1389
+ // shifts while args stream; keep it out of native scrollback mid-run.
1390
+ provisionalPendingPreview: true,
1388
1391
  };
1389
1392
  }
1390
1393
 
@@ -754,4 +754,8 @@ export const evalToolRenderer = {
754
754
 
755
755
  mergeCallAndResult: true,
756
756
  inline: true,
757
+ // Pending preview shows tail-window code cells; the result render
758
+ // interleaves each cell's output under its code, re-laying-out every row
759
+ // below the first cell. Keep the preview out of native scrollback mid-run.
760
+ provisionalPendingPreview: true,
757
761
  };
@@ -43,6 +43,19 @@ export type ToolRenderer = {
43
43
  mergeCallAndResult?: boolean;
44
44
  /** Render without background box, inline in the response flow */
45
45
  inline?: boolean;
46
+ /**
47
+ * Collapsed pending preview is provisional — a tail-window or otherwise
48
+ * re-anchored view the result render replaces wholesale (an edit's
49
+ * streamed-diff tail, bash/ssh command caps, eval cells whose outputs
50
+ * interleave under each cell). Its rows must never commit to native
51
+ * scrollback mid-run; see
52
+ * `ToolExecutionComponent.isTranscriptBlockCommitStable`. Absent = the
53
+ * pending preview streams top-anchored append-shaped rows the result
54
+ * render preserves (task context/assignment, write content), which stay
55
+ * commit-eligible so a call taller than the viewport scrolls into history
56
+ * instead of reading as cut off.
57
+ */
58
+ provisionalPendingPreview?: boolean;
46
59
  };
47
60
 
48
61
  export const toolRenderers: Record<string, ToolRenderer> = {
package/src/tools/ssh.ts CHANGED
@@ -346,4 +346,7 @@ export const sshToolRenderer = {
346
346
  });
347
347
  },
348
348
  mergeCallAndResult: true,
349
+ // Pending preview caps the command to a viewport-sized tail window that
350
+ // shifts while args stream; keep it out of native scrollback mid-run.
351
+ provisionalPendingPreview: true,
349
352
  };