@howaboua/pi-codex-conversion 1.0.9 → 1.0.11

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.
@@ -1,8 +1,9 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
- import { Text } from "@mariozechner/pi-tui";
3
+ import { Container, Text } from "@mariozechner/pi-tui";
4
4
  import { executePatch } from "../patch/core.ts";
5
- import type { ExecutePatchResult } from "../patch/types.ts";
5
+ import { ExecutePatchError, type ExecutePatchResult } from "../patch/types.ts";
6
+ import { formatApplyPatchSummary, formatPatchTarget, renderApplyPatchCall } from "./apply-patch-rendering.ts";
6
7
 
7
8
  const APPLY_PATCH_PARAMETERS = Type.Object({
8
9
  input: Type.String({
@@ -10,6 +11,38 @@ const APPLY_PATCH_PARAMETERS = Type.Object({
10
11
  }),
11
12
  });
12
13
 
14
+ interface ApplyPatchRenderState {
15
+ cwd: string;
16
+ patchText: string;
17
+ collapsed: string;
18
+ expanded: string;
19
+ status: "pending" | "partial_failure";
20
+ failedTarget?: string;
21
+ }
22
+
23
+ interface ApplyPatchSuccessDetails {
24
+ status: "success";
25
+ result: ExecutePatchResult;
26
+ }
27
+
28
+ interface ApplyPatchPartialFailureDetails {
29
+ status: "partial_failure";
30
+ result: ExecutePatchResult;
31
+ error: string;
32
+ failedTarget?: string;
33
+ }
34
+
35
+ type ApplyPatchToolDetails = ApplyPatchSuccessDetails | ApplyPatchPartialFailureDetails;
36
+
37
+ const applyPatchRenderStates = new Map<string, ApplyPatchRenderState>();
38
+
39
+ interface ApplyPatchRenderContextLike {
40
+ toolCallId?: string;
41
+ cwd?: string;
42
+ expanded?: boolean;
43
+ argsComplete?: boolean;
44
+ }
45
+
13
46
  function parseApplyPatchParams(params: unknown): { patchText: string } {
14
47
  if (!params || typeof params !== "object" || !("input" in params) || typeof params.input !== "string") {
15
48
  throw new Error("apply_patch requires a string 'input' parameter");
@@ -17,27 +50,179 @@ function parseApplyPatchParams(params: unknown): { patchText: string } {
17
50
  return { patchText: params.input };
18
51
  }
19
52
 
20
- function isExecutePatchResult(details: unknown): details is ExecutePatchResult {
21
- return typeof details === "object" && details !== null;
53
+ function isApplyPatchToolDetails(details: unknown): details is ApplyPatchToolDetails {
54
+ return typeof details === "object" && details !== null && "status" in details && "result" in details;
55
+ }
56
+
57
+ function setApplyPatchRenderState(
58
+ toolCallId: string,
59
+ patchText: string,
60
+ cwd: string,
61
+ status: "pending" | "partial_failure" = "pending",
62
+ failedTarget?: string,
63
+ ): void {
64
+ const collapsed = formatApplyPatchSummary(patchText, cwd);
65
+ const expanded = renderApplyPatchCall(patchText, cwd);
66
+ applyPatchRenderStates.set(toolCallId, {
67
+ cwd,
68
+ patchText,
69
+ collapsed,
70
+ expanded,
71
+ status,
72
+ failedTarget,
73
+ });
74
+ }
75
+
76
+ function markApplyPatchPartialFailure(toolCallId: string, failedTarget?: string): void {
77
+ const existing = applyPatchRenderStates.get(toolCallId);
78
+ if (!existing) {
79
+ return;
80
+ }
81
+ applyPatchRenderStates.set(toolCallId, {
82
+ ...existing,
83
+ status: "partial_failure",
84
+ failedTarget,
85
+ });
86
+ }
87
+
88
+ function renderPartialFailureCall(
89
+ text: string,
90
+ theme: { fg(role: string, text: string): string },
91
+ failedTarget?: string,
92
+ ): string {
93
+ const lines = text.split("\n");
94
+ if (lines.length === 0) {
95
+ return theme.fg("warning", "• Edit partially failed");
96
+ }
97
+ lines[0] = lines[0].replace(/^• (Added|Edited|Deleted)\b/, "• Edit partially failed");
98
+ const failedLineIndexes = new Set<number>();
99
+ if (failedTarget) {
100
+ for (let i = 0; i < lines.length; i += 1) {
101
+ const failedLine = markFailedTargetLine(lines[i], failedTarget);
102
+ if (failedLine) {
103
+ lines[i] = failedLine;
104
+ failedLineIndexes.add(i);
105
+ }
106
+ }
107
+ }
108
+ return lines
109
+ .map((line, index) => {
110
+ if (failedLineIndexes.has(index)) {
111
+ return theme.fg("error", line);
112
+ }
113
+ if (index === 0) {
114
+ return theme.fg("warning", line);
115
+ }
116
+ return line;
117
+ })
118
+ .join("\n");
119
+ }
120
+
121
+ function markFailedTargetLine(line: string, failedTarget: string): string | undefined {
122
+ const suffixMatch = line.match(/ \(\+\d+ -\d+\)$/);
123
+ if (!suffixMatch) {
124
+ return undefined;
125
+ }
126
+ const suffix = suffixMatch[0];
127
+ const prefixAndTarget = line.slice(0, -suffix.length);
128
+ const candidatePrefixes = ["• Edit partially failed ", "• Added ", "• Edited ", "• Deleted ", " └ ", " "];
129
+ for (const prefix of candidatePrefixes) {
130
+ if (prefixAndTarget === `${prefix}${failedTarget}`) {
131
+ return `${prefix}${failedTarget} failed${suffix}`;
132
+ }
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ function summarizePatchCounts(result: ExecutePatchResult): string {
138
+ return [
139
+ `changed ${result.changedFiles.length} file${result.changedFiles.length === 1 ? "" : "s"}`,
140
+ `created ${result.createdFiles.length}`,
141
+ `deleted ${result.deletedFiles.length}`,
142
+ `moved ${result.movedFiles.length}`,
143
+ ].join(", ");
144
+ }
145
+
146
+ function describeFailedAction(error: ExecutePatchError, cwd: string): string | undefined {
147
+ if (!error.failedAction) {
148
+ return undefined;
149
+ }
150
+ return formatPatchTarget(error.failedAction.path, error.failedAction.type === "update" ? error.failedAction.movePath : undefined, cwd);
22
151
  }
23
152
 
24
153
  export type { ExecutePatchResult } from "../patch/types.ts";
25
154
 
155
+ export function clearApplyPatchRenderState(): void {
156
+ applyPatchRenderStates.clear();
157
+ }
158
+
159
+ const renderApplyPatchCallWithOptionalContext: any = (
160
+ args: { input?: unknown },
161
+ theme: { fg(role: string, text: string): string; bold(text: string): string },
162
+ context?: ApplyPatchRenderContextLike,
163
+ ) => {
164
+ if (context?.argsComplete === false) {
165
+ return new Text(`${theme.fg("dim", "•")} ${theme.bold("Patching")}`, 0, 0);
166
+ }
167
+ const patchText = typeof args.input === "string" ? args.input : "";
168
+ if (patchText.trim().length === 0) {
169
+ return new Text(`${theme.fg("dim", "•")} ${theme.bold("Patching")}`, 0, 0);
170
+ }
171
+ const cached = context?.toolCallId ? applyPatchRenderStates.get(context.toolCallId) : undefined;
172
+ const cwd = context?.cwd ?? cached?.cwd;
173
+ const effectivePatchText = cached?.patchText ?? patchText;
174
+ const baseText = context?.expanded
175
+ ? cached?.expanded ?? renderApplyPatchCall(effectivePatchText, cwd)
176
+ : cached?.collapsed ?? formatApplyPatchSummary(effectivePatchText, cwd);
177
+ const text = cached?.status === "partial_failure" ? renderPartialFailureCall(baseText, theme, cached.failedTarget) : baseText;
178
+ return new Text(text, 0, 0);
179
+ };
180
+
26
181
  export function registerApplyPatchTool(pi: ExtensionAPI): void {
27
182
  pi.registerTool({
28
183
  name: "apply_patch",
29
184
  label: "apply_patch",
30
185
  description: "Use `apply_patch` to edit files. Send the full patch in `input`.",
31
186
  promptSnippet: "Edit files with a patch.",
32
- promptGuidelines: ["Prefer apply_patch for focused textual edits instead of rewriting whole files."],
187
+ promptGuidelines: [
188
+ "Prefer apply_patch for focused textual edits instead of rewriting whole files.",
189
+ "When one task needs coordinated edits across multiple files, send them in a single apply_patch call when one coherent patch will do.",
190
+ ],
33
191
  parameters: APPLY_PATCH_PARAMETERS,
34
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
192
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
35
193
  if (signal?.aborted) {
36
194
  throw new Error("apply_patch aborted");
37
195
  }
38
196
 
39
197
  const typedParams = parseApplyPatchParams(params);
40
- const result = executePatch({ cwd: ctx.cwd, patchText: typedParams.patchText });
198
+ setApplyPatchRenderState(toolCallId, typedParams.patchText, ctx.cwd);
199
+ let result: ExecutePatchResult;
200
+ try {
201
+ result = executePatch({ cwd: ctx.cwd, patchText: typedParams.patchText });
202
+ } catch (error) {
203
+ if (error instanceof ExecutePatchError) {
204
+ const partial = error.hasPartialSuccess();
205
+ const failedTarget = describeFailedAction(error, ctx.cwd);
206
+ const prefix = partial
207
+ ? `apply_patch partially failed after ${summarizePatchCounts(error.result)}`
208
+ : "apply_patch failed";
209
+ const message = failedTarget ? `${prefix} while patching ${failedTarget}: ${error.message}` : `${prefix}: ${error.message}`;
210
+ if (partial) {
211
+ markApplyPatchPartialFailure(toolCallId, failedTarget);
212
+ return {
213
+ content: [{ type: "text", text: message }],
214
+ details: {
215
+ status: "partial_failure",
216
+ result: error.result,
217
+ error: message,
218
+ failedTarget,
219
+ } satisfies ApplyPatchPartialFailureDetails,
220
+ };
221
+ }
222
+ throw new Error(message);
223
+ }
224
+ throw error;
225
+ }
41
226
  const summary = [
42
227
  "Applied patch successfully.",
43
228
  `Changed files: ${result.changedFiles.length}`,
@@ -49,36 +234,27 @@ export function registerApplyPatchTool(pi: ExtensionAPI): void {
49
234
 
50
235
  return {
51
236
  content: [{ type: "text", text: summary }],
52
- details: result,
237
+ details: {
238
+ status: "success",
239
+ result,
240
+ } satisfies ApplyPatchSuccessDetails,
53
241
  };
54
242
  },
55
- renderCall(_args, theme) {
56
- return new Text(`${theme.fg("toolTitle", theme.bold("apply_patch"))} ${theme.fg("muted", "patch")}`, 0, 0);
57
- },
243
+ renderCall: renderApplyPatchCallWithOptionalContext,
58
244
  renderResult(result, { isPartial, expanded }, theme) {
59
245
  if (isPartial) {
60
- return new Text(theme.fg("warning", "Applying patch..."), 0, 0);
246
+ return new Text(`${theme.fg("dim", "•")} ${theme.bold("Patching")}`, 0, 0);
61
247
  }
62
248
 
63
- const details = isExecutePatchResult(result.details) ? result.details : undefined;
64
- if (!details) {
65
- return new Text(theme.fg("success", "Patch applied"), 0, 0);
249
+ if (!isApplyPatchToolDetails(result.details)) {
250
+ return new Container();
66
251
  }
67
252
 
68
- let text = theme.fg("success", "Patch applied");
69
- text += theme.fg(
70
- "dim",
71
- ` (${details.changedFiles.length} changed, ${details.createdFiles.length} created, ${details.deletedFiles.length} deleted)`,
72
- );
73
- if (expanded) {
74
- for (const file of details.changedFiles) {
75
- text += `\n${theme.fg("dim", file)}`;
76
- }
77
- for (const move of details.movedFiles) {
78
- text += `\n${theme.fg("accent", move)}`;
79
- }
253
+ if (result.details.status === "partial_failure") {
254
+ return new Container();
80
255
  }
81
- return new Text(text, 0, 0);
256
+
257
+ return new Container();
82
258
  },
83
259
  });
84
260
  }
@@ -8,7 +8,11 @@ export interface RenderTheme {
8
8
 
9
9
  export function renderExecCommandCall(command: string, state: ExecCommandStatus, theme: RenderTheme): string {
10
10
  const summary = summarizeShellCommand(command);
11
- return summary.maskAsExplored ? renderExplorationText(summary.actions, state, theme) : renderCommandText(command, state, theme);
11
+ return summary.maskAsExplored ? renderExplorationText([summary.actions], state, theme) : renderCommandText(command, state, theme);
12
+ }
13
+
14
+ export function renderGroupedExecCommandCall(actionGroups: ShellAction[][], state: ExecCommandStatus, theme: RenderTheme): string {
15
+ return renderExplorationText(actionGroups, state, theme);
12
16
  }
13
17
 
14
18
  export function renderWriteStdinCall(
@@ -32,11 +36,11 @@ export function renderWriteStdinCall(
32
36
  return text;
33
37
  }
34
38
 
35
- function renderExplorationText(actions: ShellAction[], state: ExecCommandStatus, theme: RenderTheme): string {
39
+ function renderExplorationText(actionGroups: ShellAction[][], state: ExecCommandStatus, theme: RenderTheme): string {
36
40
  const header = state === "running" ? "Exploring" : "Explored";
37
41
  let text = `${theme.fg("dim", "•")} ${theme.bold(header)}`;
38
42
 
39
- for (const [index, line] of coalesceReads(actions).map(formatActionLine).entries()) {
43
+ for (const [index, line] of coalesceReadGroups(actionGroups).map(formatActionLine).entries()) {
40
44
  const prefix = index === 0 ? " └ " : " ";
41
45
  text += `\n${theme.fg("dim", prefix)}${theme.fg("accent", line.title)} ${theme.fg("muted", line.body)}`;
42
46
  }
@@ -83,13 +87,56 @@ function formatActionLine(action: ShellAction): { title: string; body: string }
83
87
  return { title: "Run", body: action.command };
84
88
  }
85
89
 
86
- function coalesceReads(actions: ShellAction[]): ShellAction[] {
87
- if (!actions.every((action) => action.kind !== "run")) return actions;
88
- const reads = actions.filter((action): action is Extract<ShellAction, { kind: "read" }> => action.kind === "read");
89
- if (reads.length !== actions.length) {
90
- return actions;
90
+ function coalesceReadGroups(actionGroups: ShellAction[][]): ShellAction[] {
91
+ const flattened: ShellAction[] = [];
92
+
93
+ for (let index = 0; index < actionGroups.length; index += 1) {
94
+ const actions = actionGroups[index];
95
+ if (actions.every((action) => action.kind === "read")) {
96
+ const reads: Extract<ShellAction, { kind: "read" }>[] = [];
97
+ const seenPaths = new Set<string>();
98
+ let lastRead: Extract<ShellAction, { kind: "read" }> | undefined;
99
+
100
+ for (let readIndex = index; readIndex < actionGroups.length; readIndex += 1) {
101
+ const readActions = actionGroups[readIndex];
102
+ if (!readActions.every((action) => action.kind === "read")) {
103
+ break;
104
+ }
105
+
106
+ for (const action of readActions) {
107
+ if (action.kind !== "read") continue;
108
+ lastRead = action;
109
+ if (seenPaths.has(action.path)) continue;
110
+ seenPaths.add(action.path);
111
+ reads.push(action);
112
+ }
113
+
114
+ index = readIndex;
115
+ }
116
+
117
+ if (lastRead) {
118
+ const duplicateNames = new Set<string>();
119
+ const seenNames = new Set<string>();
120
+ for (const read of reads) {
121
+ if (seenNames.has(read.name)) {
122
+ duplicateNames.add(read.name);
123
+ continue;
124
+ }
125
+ seenNames.add(read.name);
126
+ }
127
+ const labels = reads.map((read) => (duplicateNames.has(read.name) ? read.path : read.name));
128
+ flattened.push({
129
+ kind: "read",
130
+ command: labels.join(" && "),
131
+ name: labels.join(", "),
132
+ path: lastRead.path,
133
+ });
134
+ }
135
+ continue;
136
+ }
137
+
138
+ flattened.push(...actions);
91
139
  }
92
140
 
93
- const names = [...new Set(reads.map((action) => action.name))];
94
- return [{ kind: "read", command: reads.map((action) => action.command).join(" && "), name: names.join(", "), path: reads[0].path }];
141
+ return flattened;
95
142
  }
@@ -1,43 +1,215 @@
1
+ import { summarizeShellCommand, type CommandSummary, type ShellAction } from "../shell/summary.ts";
2
+
1
3
  export type ExecCommandStatus = "running" | "done";
2
4
 
5
+ export interface ExecCommandRenderInfo {
6
+ hidden: boolean;
7
+ status: ExecCommandStatus;
8
+ actionGroups?: ShellAction[][];
9
+ }
10
+
11
+ interface ExecEntry {
12
+ toolCallId: string;
13
+ command: string;
14
+ summary: CommandSummary;
15
+ status: ExecCommandStatus;
16
+ hidden: boolean;
17
+ groupId?: number;
18
+ invalidate?: () => void;
19
+ }
20
+
21
+ interface ExecGroup {
22
+ id: number;
23
+ entryIds: string[];
24
+ visibleEntryId: string;
25
+ }
26
+
3
27
  export interface ExecCommandTracker {
4
28
  getState(command: string): ExecCommandStatus;
29
+ getRenderInfo(toolCallId: string | undefined, command: string): ExecCommandRenderInfo;
30
+ registerRenderContext(toolCallId: string | undefined, invalidate: () => void): void;
5
31
  recordStart(toolCallId: string, command: string): void;
6
- recordPersistentSession(command: string): void;
32
+ recordPersistentSession(toolCallId: string, sessionId: number): void;
7
33
  recordEnd(toolCallId: string): void;
8
- recordCommandFinished(command: string): void;
34
+ recordSessionFinished(sessionId: number): void;
35
+ resetExplorationGroup(): void;
36
+ clear(): void;
9
37
  }
10
38
 
11
39
  export function createExecCommandTracker(): ExecCommandTracker {
12
40
  const commandByToolCallId = new Map<string, string>();
13
- const executionStateByCommand = new Map<string, ExecCommandStatus>();
14
- const persistentCommands = new Set<string>();
41
+ const runningCountsByCommand = new Map<string, number>();
42
+ const sessionBackedToolCallIds = new Set<string>();
43
+ const toolCallIdBySessionId = new Map<number, string>();
44
+ const entriesByToolCallId = new Map<string, ExecEntry>();
45
+ const groupsById = new Map<number, ExecGroup>();
46
+ let activeExplorationGroupId: number | undefined;
47
+ let nextGroupId = 1;
48
+
49
+ function incrementCommand(command: string): void {
50
+ runningCountsByCommand.set(command, (runningCountsByCommand.get(command) ?? 0) + 1);
51
+ }
52
+
53
+ function decrementCommand(command: string): void {
54
+ const next = (runningCountsByCommand.get(command) ?? 0) - 1;
55
+ if (next > 0) {
56
+ runningCountsByCommand.set(command, next);
57
+ return;
58
+ }
59
+ runningCountsByCommand.delete(command);
60
+ }
61
+
62
+ function invalidateToolCall(toolCallId: string | undefined): void {
63
+ if (!toolCallId) return;
64
+ entriesByToolCallId.get(toolCallId)?.invalidate?.();
65
+ }
66
+
67
+ function findLatestEntryByCommand(command: string): ExecEntry | undefined {
68
+ let latest: ExecEntry | undefined;
69
+ for (const entry of entriesByToolCallId.values()) {
70
+ if (entry.command !== command) continue;
71
+ latest = entry;
72
+ }
73
+ return latest;
74
+ }
75
+
76
+ function getGroupForEntry(entry: ExecEntry | undefined): ExecGroup | undefined {
77
+ if (!entry?.groupId) return undefined;
78
+ return groupsById.get(entry.groupId);
79
+ }
80
+
81
+ function getVisibleEntry(group: ExecGroup | undefined): ExecEntry | undefined {
82
+ if (!group) return undefined;
83
+ return entriesByToolCallId.get(group.visibleEntryId);
84
+ }
15
85
 
16
86
  return {
17
87
  getState(command) {
18
- return executionStateByCommand.get(command) ?? "running";
88
+ return (runningCountsByCommand.get(command) ?? 0) > 0 ? "running" : "done";
89
+ },
90
+ getRenderInfo(toolCallId, command) {
91
+ if (!toolCallId) {
92
+ return { hidden: false, status: (runningCountsByCommand.get(command) ?? 0) > 0 ? "running" : "done" };
93
+ }
94
+
95
+ const entry = entriesByToolCallId.get(toolCallId);
96
+ if (!entry) {
97
+ return { hidden: false, status: (runningCountsByCommand.get(command) ?? 0) > 0 ? "running" : "done" };
98
+ }
99
+
100
+ if (entry.hidden) {
101
+ return { hidden: true, status: entry.status };
102
+ }
103
+
104
+ const group = getGroupForEntry(entry);
105
+ if (!group) {
106
+ return {
107
+ hidden: false,
108
+ status: entry.status,
109
+ actionGroups: entry.summary.maskAsExplored ? [entry.summary.actions] : undefined,
110
+ };
111
+ }
112
+
113
+ const entries = group.entryIds
114
+ .map((groupEntryId) => entriesByToolCallId.get(groupEntryId))
115
+ .filter((groupEntry): groupEntry is ExecEntry => Boolean(groupEntry));
116
+ return {
117
+ hidden: false,
118
+ status: entries.some((groupEntry) => groupEntry.status === "running") ? "running" : "done",
119
+ actionGroups: entries.map((groupEntry) => groupEntry.summary.actions),
120
+ };
121
+ },
122
+ registerRenderContext(toolCallId, invalidate) {
123
+ if (!toolCallId) return;
124
+ const entry = entriesByToolCallId.get(toolCallId);
125
+ if (!entry) return;
126
+ entry.invalidate = invalidate;
19
127
  },
20
128
  recordStart(toolCallId, command) {
21
129
  commandByToolCallId.set(toolCallId, command);
22
- executionStateByCommand.set(command, "running");
130
+ incrementCommand(command);
131
+
132
+ const summary = summarizeShellCommand(command);
133
+ const entry: ExecEntry = {
134
+ toolCallId,
135
+ command,
136
+ summary,
137
+ status: "running",
138
+ hidden: false,
139
+ };
140
+ entriesByToolCallId.set(toolCallId, entry);
141
+
142
+ if (!summary.maskAsExplored) {
143
+ activeExplorationGroupId = undefined;
144
+ return;
145
+ }
146
+
147
+ let group = activeExplorationGroupId ? groupsById.get(activeExplorationGroupId) : undefined;
148
+ if (!group) {
149
+ group = { id: nextGroupId++, entryIds: [toolCallId], visibleEntryId: toolCallId };
150
+ groupsById.set(group.id, group);
151
+ activeExplorationGroupId = group.id;
152
+ entry.groupId = group.id;
153
+ return;
154
+ }
155
+
156
+ const previousVisibleEntry = getVisibleEntry(group);
157
+ if (previousVisibleEntry) {
158
+ previousVisibleEntry.hidden = true;
159
+ invalidateToolCall(previousVisibleEntry.toolCallId);
160
+ }
161
+
162
+ group.entryIds.push(toolCallId);
163
+ group.visibleEntryId = toolCallId;
164
+ entry.groupId = group.id;
23
165
  },
24
- recordPersistentSession(command) {
25
- persistentCommands.add(command);
26
- executionStateByCommand.set(command, "running");
166
+ recordPersistentSession(toolCallId, sessionId) {
167
+ sessionBackedToolCallIds.add(toolCallId);
168
+ toolCallIdBySessionId.set(sessionId, toolCallId);
169
+ const entry = entriesByToolCallId.get(toolCallId);
170
+ if (!entry) return;
171
+ entry.status = "running";
172
+ const group = getGroupForEntry(entry);
173
+ invalidateToolCall(group?.visibleEntryId ?? entry.toolCallId);
27
174
  },
28
175
  recordEnd(toolCallId) {
29
176
  const command = commandByToolCallId.get(toolCallId);
30
177
  if (!command) return;
31
- // Pi renderers do not currently receive toolCallId, so we track the
32
- // last-known state per command string for compact Exploring/Explored UI.
33
- if (!persistentCommands.has(command)) {
34
- executionStateByCommand.set(command, "done");
178
+ const entry = entriesByToolCallId.get(toolCallId);
179
+ if (!sessionBackedToolCallIds.has(toolCallId)) {
180
+ decrementCommand(command);
181
+ if (entry) {
182
+ entry.status = "done";
183
+ }
35
184
  }
185
+ const group = getGroupForEntry(entry);
186
+ invalidateToolCall(group?.visibleEntryId ?? toolCallId);
36
187
  commandByToolCallId.delete(toolCallId);
37
188
  },
38
- recordCommandFinished(command) {
39
- persistentCommands.delete(command);
40
- executionStateByCommand.set(command, "done");
189
+ recordSessionFinished(sessionId) {
190
+ const toolCallId = toolCallIdBySessionId.get(sessionId);
191
+ if (!toolCallId) return;
192
+ toolCallIdBySessionId.delete(sessionId);
193
+ const entry = entriesByToolCallId.get(toolCallId);
194
+ if (!entry) return;
195
+ decrementCommand(entry.command);
196
+ entry.status = "done";
197
+ sessionBackedToolCallIds.delete(toolCallId);
198
+ const group = getGroupForEntry(entry);
199
+ invalidateToolCall(group?.visibleEntryId ?? entry.toolCallId);
200
+ },
201
+ resetExplorationGroup() {
202
+ activeExplorationGroupId = undefined;
203
+ },
204
+ clear() {
205
+ commandByToolCallId.clear();
206
+ runningCountsByCommand.clear();
207
+ sessionBackedToolCallIds.clear();
208
+ toolCallIdBySessionId.clear();
209
+ entriesByToolCallId.clear();
210
+ groupsById.clear();
211
+ activeExplorationGroupId = undefined;
212
+ nextGroupId = 1;
41
213
  },
42
214
  };
43
215
  }