@howaboua/pi-codex-conversion 1.0.9-dev.0 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,7 @@ This package replaces Pi's default Codex/GPT experience with a narrower Codex-li
7
7
  - swaps active tools to `exec_command`, `write_stdin`, `apply_patch`, `view_image`, and native OpenAI Codex Responses `web_search` on `openai-codex`
8
8
  - preserves Pi's composed system prompt and applies a narrow Codex-oriented delta on top
9
9
  - renders exec activity with Codex-style command and background-terminal labels
10
+ - renders `apply_patch` calls with Codex-style `Added` / `Edited` / `Deleted` diff blocks and Pi-style colored diff lines
10
11
 
11
12
  ![Available tools](./available-tools.png)
12
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howaboua/pi-codex-conversion",
3
- "version": "1.0.9-dev.0",
3
+ "version": "1.0.10",
4
4
  "description": "Codex-oriented tool and prompt adapter for pi coding agent",
5
5
  "type": "module",
6
6
  "repository": {
@@ -51,8 +51,8 @@
51
51
  "access": "public"
52
52
  },
53
53
  "peerDependencies": {
54
- "@mariozechner/pi-coding-agent": "*",
55
- "@mariozechner/pi-tui": "*",
54
+ "@mariozechner/pi-coding-agent": "^0.62.0",
55
+ "@mariozechner/pi-tui": "^0.62.0",
56
56
  "@sinclair/typebox": "*"
57
57
  },
58
58
  "devDependencies": {
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
  import { getCodexRuntimeShell } from "./adapter/runtime-shell.ts";
3
3
  import { CORE_ADAPTER_TOOL_NAMES, DEFAULT_TOOL_NAMES, STATUS_KEY, STATUS_TEXT, VIEW_IMAGE_TOOL_NAME, WEB_SEARCH_TOOL_NAME } from "./adapter/tool-set.ts";
4
- import { registerApplyPatchTool } from "./tools/apply-patch-tool.ts";
4
+ import { clearApplyPatchRenderState, registerApplyPatchTool } from "./tools/apply-patch-tool.ts";
5
5
  import { isCodexLikeContext, isOpenAICodexContext } from "./adapter/codex-model.ts";
6
6
  import { createExecCommandTracker } from "./tools/exec-command-state.ts";
7
7
  import { registerExecCommandTool } from "./tools/exec-command-tool.ts";
@@ -35,6 +35,16 @@ function getCommandArg(args: unknown): string | undefined {
35
35
  return args.cmd;
36
36
  }
37
37
 
38
+ function isToolCallOnlyAssistantMessage(message: unknown): boolean {
39
+ if (!message || typeof message !== "object" || !("role" in message) || message.role !== "assistant") {
40
+ return false;
41
+ }
42
+ if (!("content" in message) || !Array.isArray(message.content) || message.content.length === 0) {
43
+ return false;
44
+ }
45
+ return message.content.every((item) => typeof item === "object" && item !== null && "type" in item && item.type === "toolCall");
46
+ }
47
+
38
48
  export default function codexConversion(pi: ExtensionAPI) {
39
49
  const tracker = createExecCommandTracker();
40
50
  const state: AdapterState = { enabled: false, promptSkills: [], webSearchNoticeShown: false };
@@ -46,12 +56,14 @@ export default function codexConversion(pi: ExtensionAPI) {
46
56
  registerWebSearchTool(pi);
47
57
  registerWebSearchSessionNoteRenderer(pi);
48
58
 
49
- sessions.onSessionExit((_sessionId, command) => {
50
- tracker.recordCommandFinished(command);
59
+ sessions.onSessionExit((sessionId) => {
60
+ tracker.recordSessionFinished(sessionId);
51
61
  });
52
62
 
53
63
  pi.on("session_start", async (_event, ctx) => {
54
64
  state.webSearchNoticeShown = false;
65
+ clearApplyPatchRenderState();
66
+ tracker.clear();
55
67
  syncAdapter(pi, ctx, state);
56
68
  });
57
69
 
@@ -59,8 +71,17 @@ export default function codexConversion(pi: ExtensionAPI) {
59
71
  syncAdapter(pi, ctx, state);
60
72
  });
61
73
 
74
+ pi.on("message_start", async (event) => {
75
+ if (event.message.role === "toolResult") return;
76
+ if (isToolCallOnlyAssistantMessage(event.message)) return;
77
+ tracker.resetExplorationGroup();
78
+ });
79
+
62
80
  pi.on("tool_execution_start", async (event) => {
63
- if (event.toolName !== "exec_command") return;
81
+ if (event.toolName !== "exec_command") {
82
+ tracker.resetExplorationGroup();
83
+ return;
84
+ }
64
85
  const command = getCommandArg(event.args);
65
86
  if (!command) return;
66
87
  tracker.recordStart(event.toolCallId, command);
@@ -72,6 +93,7 @@ export default function codexConversion(pi: ExtensionAPI) {
72
93
  });
73
94
 
74
95
  pi.on("session_shutdown", async () => {
96
+ clearApplyPatchRenderState();
75
97
  sessions.shutdown();
76
98
  });
77
99
 
@@ -61,8 +61,6 @@ function parseCommandImpl(command: string[]): ParsedShellCommand[] {
61
61
  const parsed = summarizeMainTokens(tokens);
62
62
  if (parsed.kind === "read" && cwd) {
63
63
  commands.push({ ...parsed, path: joinPaths(cwd, parsed.path) });
64
- } else if (parsed.kind === "list" && cwd && !parsed.path) {
65
- commands.push({ ...parsed, path: shortDisplayPath(cwd) });
66
64
  } else {
67
65
  commands.push(parsed);
68
66
  }
@@ -18,12 +18,6 @@ export function parseShellPart(tokens: string[], cwd?: string): ShellAction | nu
18
18
  path: joinPaths(cwd, parsed.path),
19
19
  };
20
20
  }
21
- if (parsed.kind === "list" && cwd && !parsed.path) {
22
- return {
23
- ...parsed,
24
- path: shortDisplayPath(cwd),
25
- };
26
- }
27
21
 
28
22
  return parsed;
29
23
  }
@@ -84,10 +84,21 @@ export function shellSplit(input: string): string[] {
84
84
 
85
85
  export function shellQuote(token: string): string {
86
86
  if (token.length === 0) return "''";
87
- if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(token)) return token;
87
+ if (canEmitUnquoted(token)) return token;
88
88
  return `'${token.replace(/'/g, `'"'"'`)}'`;
89
89
  }
90
90
 
91
+ function canEmitUnquoted(token: string): boolean {
92
+ for (const char of token) {
93
+ if (!isUnquotedOk(char)) return false;
94
+ }
95
+ return true;
96
+ }
97
+
98
+ function isUnquotedOk(char: string): boolean {
99
+ return /^[+\-./:@\]_0-9A-Za-z]$/.test(char);
100
+ }
101
+
91
102
  export function joinCommandTokens(tokens: string[]): string {
92
103
  return tokens
93
104
  .map((token) => (token === "&&" || token === "||" || token === "|" || token === ";" ? token : shellQuote(token)))
@@ -0,0 +1,352 @@
1
+ import { isAbsolute, relative } from "node:path";
2
+ import { renderDiff } from "@mariozechner/pi-coding-agent";
3
+ import { openFileAtPath } from "../patch/paths.ts";
4
+ import { parsePatchActions } from "../patch/parser.ts";
5
+ import type { ParsedPatchAction } from "../patch/types.ts";
6
+
7
+ interface PreviewLine {
8
+ lineNumber: number;
9
+ marker: " " | "+" | "-";
10
+ text: string;
11
+ }
12
+
13
+ interface FilePreview {
14
+ verb: "Added" | "Deleted" | "Edited";
15
+ path: string;
16
+ movePath?: string;
17
+ added: number;
18
+ removed: number;
19
+ lines: PreviewLine[];
20
+ }
21
+
22
+ export function formatApplyPatchSummary(patchText: string, cwd = process.cwd()): string {
23
+ let actions: ParsedPatchAction[];
24
+ try {
25
+ actions = parsePatchActions({ text: patchText });
26
+ } catch {
27
+ return "apply_patch patch";
28
+ }
29
+
30
+ const files = actions.map((action) => buildFilePreview(action, cwd));
31
+ if (files.length === 0) {
32
+ return "apply_patch patch";
33
+ }
34
+
35
+ const totalAdded = files.reduce((sum, file) => sum + file.added, 0);
36
+ const totalRemoved = files.reduce((sum, file) => sum + file.removed, 0);
37
+ const lines: string[] = [];
38
+
39
+ if (files.length === 1) {
40
+ const [file] = files;
41
+ lines.push(`${bulletHeader(file.verb, renderPath(file.path, file.movePath, cwd))} ${renderCounts(file.added, file.removed)}`);
42
+ return lines.join("\n");
43
+ }
44
+
45
+ lines.push(`${bulletHeader("Edited", `${files.length} files`)} ${renderCounts(totalAdded, totalRemoved)}`);
46
+ for (const [index, file] of files.entries()) {
47
+ const prefix = index === 0 ? " └ " : " ";
48
+ lines.push(`${prefix}${renderPath(file.path, file.movePath, cwd)} ${renderCounts(file.added, file.removed)}`);
49
+ }
50
+
51
+ return lines.join("\n");
52
+ }
53
+
54
+ export function formatApplyPatchCall(patchText: string, cwd = process.cwd()): string {
55
+ let actions: ParsedPatchAction[];
56
+ try {
57
+ actions = parsePatchActions({ text: patchText });
58
+ } catch {
59
+ return "apply_patch patch";
60
+ }
61
+
62
+ const files = actions.map((action) => buildFilePreview(action, cwd));
63
+ if (files.length === 0) {
64
+ return "apply_patch patch";
65
+ }
66
+
67
+ const totalAdded = files.reduce((sum, file) => sum + file.added, 0);
68
+ const totalRemoved = files.reduce((sum, file) => sum + file.removed, 0);
69
+ const lines: string[] = [];
70
+
71
+ if (files.length === 1) {
72
+ const [file] = files;
73
+ lines.push(`${bulletHeader(file.verb, renderPath(file.path, file.movePath, cwd))} ${renderCounts(file.added, file.removed)}`);
74
+ lines.push(...file.lines.map((line) => formatPreviewLine(line, file.lines)));
75
+ return lines.join("\n");
76
+ }
77
+
78
+ lines.push(`${bulletHeader("Edited", `${files.length} files`)} ${renderCounts(totalAdded, totalRemoved)}`);
79
+ for (const [index, file] of files.entries()) {
80
+ if (index > 0) {
81
+ lines.push("");
82
+ }
83
+ lines.push(` └ ${renderPath(file.path, file.movePath, cwd)} ${renderCounts(file.added, file.removed)}`);
84
+ lines.push(...file.lines.map((line) => formatPreviewLine(line, file.lines)));
85
+ }
86
+
87
+ return lines.join("\n");
88
+ }
89
+
90
+ export function renderApplyPatchCall(patchText: string, cwd = process.cwd()): string {
91
+ let actions: ParsedPatchAction[];
92
+ try {
93
+ actions = parsePatchActions({ text: patchText });
94
+ } catch {
95
+ return "apply_patch patch";
96
+ }
97
+
98
+ const files = actions.map((action) => buildFilePreview(action, cwd));
99
+ if (files.length === 0) {
100
+ return "apply_patch patch";
101
+ }
102
+
103
+ const totalAdded = files.reduce((sum, file) => sum + file.added, 0);
104
+ const totalRemoved = files.reduce((sum, file) => sum + file.removed, 0);
105
+ const lines: string[] = [];
106
+
107
+ if (files.length === 1) {
108
+ const [file] = files;
109
+ lines.push(`${bulletHeader(file.verb, renderPath(file.path, file.movePath, cwd))} ${renderCounts(file.added, file.removed)}`);
110
+ lines.push(...renderPreviewLines(file.lines));
111
+ return lines.join("\n");
112
+ }
113
+
114
+ lines.push(`${bulletHeader("Edited", `${files.length} files`)} ${renderCounts(totalAdded, totalRemoved)}`);
115
+ for (const [index, file] of files.entries()) {
116
+ if (index > 0) {
117
+ lines.push("");
118
+ }
119
+ lines.push(` └ ${renderPath(file.path, file.movePath, cwd)} ${renderCounts(file.added, file.removed)}`);
120
+ lines.push(...renderPreviewLines(file.lines));
121
+ }
122
+
123
+ return lines.join("\n");
124
+ }
125
+
126
+ function buildFilePreview(action: ParsedPatchAction, cwd: string): FilePreview {
127
+ if (action.type === "add") {
128
+ const lines = splitFileLines(action.newFile ?? "");
129
+ return {
130
+ verb: "Added",
131
+ path: action.path,
132
+ added: lines.length,
133
+ removed: 0,
134
+ lines: lines.map((text, index) => ({ lineNumber: index + 1, marker: "+", text })),
135
+ };
136
+ }
137
+
138
+ if (action.type === "delete") {
139
+ const deletedLines = readFileLines(action.path, cwd);
140
+ return {
141
+ verb: "Deleted",
142
+ path: action.path,
143
+ added: 0,
144
+ removed: deletedLines.length,
145
+ lines: deletedLines.map((text, index) => ({ lineNumber: index + 1, marker: "-", text })),
146
+ };
147
+ }
148
+
149
+ const preview = buildUpdatePreview(action, cwd);
150
+ return {
151
+ verb: "Edited",
152
+ path: action.path,
153
+ movePath: action.movePath,
154
+ added: preview.added,
155
+ removed: preview.removed,
156
+ lines: preview.lines,
157
+ };
158
+ }
159
+
160
+ function buildUpdatePreview(action: ParsedPatchAction, cwd: string): { added: number; removed: number; lines: PreviewLine[] } {
161
+ if (!action.lines) {
162
+ return { added: 0, removed: 0, lines: [] };
163
+ }
164
+
165
+ const originalLines = readFileLines(action.path, cwd);
166
+ const renderedLines: PreviewLine[] = [];
167
+ let added = 0;
168
+ let removed = 0;
169
+ let searchStart = 0;
170
+ let delta = 0;
171
+ let index = 0;
172
+
173
+ while (index < action.lines.length) {
174
+ const line = action.lines[index];
175
+ if (line === "*** End of File") {
176
+ break;
177
+ }
178
+ if (!line.startsWith("@@")) {
179
+ index += 1;
180
+ continue;
181
+ }
182
+
183
+ index += 1;
184
+ const sectionLines: string[] = [];
185
+ while (index < action.lines.length && !action.lines[index].startsWith("@@") && action.lines[index] !== "*** End of File") {
186
+ sectionLines.push(action.lines[index]);
187
+ index += 1;
188
+ }
189
+
190
+ if (sectionLines.length === 0) {
191
+ continue;
192
+ }
193
+
194
+ const oldSequence = sectionLines
195
+ .map(normalizePatchLine)
196
+ .filter((entry) => entry.marker === " " || entry.marker === "-")
197
+ .map((entry) => entry.text);
198
+ const sectionStart = findMatchingSequence(originalLines, oldSequence, searchStart);
199
+ let oldLineNumber = sectionStart + 1;
200
+ let newLineNumber = sectionStart + 1 + delta;
201
+
202
+ for (const rawLine of sectionLines) {
203
+ const entry = normalizePatchLine(rawLine);
204
+ if (entry.marker === "+") {
205
+ added += 1;
206
+ renderedLines.push({ lineNumber: newLineNumber, marker: "+", text: entry.text });
207
+ newLineNumber += 1;
208
+ continue;
209
+ }
210
+
211
+ if (entry.marker === "-") {
212
+ removed += 1;
213
+ renderedLines.push({ lineNumber: oldLineNumber, marker: "-", text: entry.text });
214
+ oldLineNumber += 1;
215
+ continue;
216
+ }
217
+
218
+ renderedLines.push({ lineNumber: newLineNumber, marker: " ", text: entry.text });
219
+ oldLineNumber += 1;
220
+ newLineNumber += 1;
221
+ }
222
+
223
+ searchStart = sectionStart + oldSequence.length;
224
+ delta += sectionLines.reduce((sum, rawLine) => {
225
+ const marker = normalizePatchLine(rawLine).marker;
226
+ if (marker === "+") return sum + 1;
227
+ if (marker === "-") return sum - 1;
228
+ return sum;
229
+ }, 0);
230
+ }
231
+
232
+ return { added, removed, lines: renderedLines };
233
+ }
234
+
235
+ function formatPreviewLine(line: PreviewLine, lines: PreviewLine[]): string {
236
+ const numberWidth = Math.max(1, ...lines.map((entry) => String(entry.lineNumber).length));
237
+ return ` ${String(line.lineNumber).padStart(numberWidth, " ")} ${line.marker}${line.text}`;
238
+ }
239
+
240
+ function renderPreviewLines(lines: PreviewLine[]): string[] {
241
+ if (lines.length === 0) {
242
+ return [];
243
+ }
244
+
245
+ const numberWidth = Math.max(1, ...lines.map((entry) => String(entry.lineNumber).length));
246
+ const diffText = lines
247
+ .map((line) => `${line.marker}${String(line.lineNumber).padStart(numberWidth, " ")} ${line.text}`)
248
+ .join("\n");
249
+ try {
250
+ return renderDiff(diffText)
251
+ .split("\n")
252
+ .map((line) => ` ${line}`);
253
+ } catch {
254
+ return lines.map((line) => formatPreviewLine(line, lines));
255
+ }
256
+ }
257
+
258
+ function normalizePatchLine(rawLine: string): PreviewLine {
259
+ const normalized = rawLine === "" ? " " : rawLine;
260
+ const marker = normalized[0];
261
+ if (marker !== " " && marker !== "+" && marker !== "-") {
262
+ return { lineNumber: 0, marker: " ", text: rawLine };
263
+ }
264
+ return { lineNumber: 0, marker, text: normalized.slice(1) };
265
+ }
266
+
267
+ function findMatchingSequence(lines: string[], context: string[], start: number): number {
268
+ if (context.length === 0) {
269
+ return start;
270
+ }
271
+
272
+ const exact = findSequence(lines, context, start, (value) => value);
273
+ if (exact !== -1) {
274
+ return exact;
275
+ }
276
+
277
+ const trimEnd = findSequence(lines, context, start, (value) => value.trimEnd());
278
+ if (trimEnd !== -1) {
279
+ return trimEnd;
280
+ }
281
+
282
+ const trim = findSequence(lines, context, start, (value) => value.trim());
283
+ if (trim !== -1) {
284
+ return trim;
285
+ }
286
+
287
+ return start;
288
+ }
289
+
290
+ function findSequence(lines: string[], context: string[], start: number, normalize: (value: string) => string): number {
291
+ for (let lineIndex = start; lineIndex <= lines.length - context.length; lineIndex += 1) {
292
+ let matches = true;
293
+ for (let contextIndex = 0; contextIndex < context.length; contextIndex += 1) {
294
+ if (normalize(lines[lineIndex + contextIndex]) !== normalize(context[contextIndex])) {
295
+ matches = false;
296
+ break;
297
+ }
298
+ }
299
+ if (matches) {
300
+ return lineIndex;
301
+ }
302
+ }
303
+ return -1;
304
+ }
305
+
306
+ function renderPath(path: string, movePath: string | undefined, cwd: string): string {
307
+ const from = displayPath(path, cwd);
308
+ if (!movePath) {
309
+ return from;
310
+ }
311
+ return `${from} → ${displayPath(movePath, cwd)}`;
312
+ }
313
+
314
+ function displayPath(path: string, cwd: string): string {
315
+ if (!isAbsolute(path)) {
316
+ return path;
317
+ }
318
+
319
+ const relativePath = relative(cwd, path);
320
+ if (relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath)) {
321
+ return relativePath;
322
+ }
323
+
324
+ return path;
325
+ }
326
+
327
+ function readFileLines(path: string, cwd: string): string[] {
328
+ try {
329
+ return splitFileLines(openFileAtPath({ cwd, path }));
330
+ } catch {
331
+ return [];
332
+ }
333
+ }
334
+
335
+ function splitFileLines(text: string): string[] {
336
+ if (text.length === 0) {
337
+ return [];
338
+ }
339
+ const lines = text.split("\n");
340
+ if (lines.at(-1) === "") {
341
+ lines.pop();
342
+ }
343
+ return lines;
344
+ }
345
+
346
+ function bulletHeader(verb: string, label: string): string {
347
+ return `• ${verb} ${label}`;
348
+ }
349
+
350
+ function renderCounts(added: number, removed: number): string {
351
+ return `(+${added} -${removed})`;
352
+ }
@@ -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
5
  import type { ExecutePatchResult } from "../patch/types.ts";
6
+ import { formatApplyPatchSummary, renderApplyPatchCall } from "./apply-patch-rendering.ts";
6
7
 
7
8
  const APPLY_PATCH_PARAMETERS = Type.Object({
8
9
  input: Type.String({
@@ -10,6 +11,21 @@ const APPLY_PATCH_PARAMETERS = Type.Object({
10
11
  }),
11
12
  });
12
13
 
14
+ interface ApplyPatchRenderState {
15
+ cwd: string;
16
+ collapsed: string;
17
+ expanded: string;
18
+ }
19
+
20
+ const applyPatchRenderStates = new Map<string, ApplyPatchRenderState>();
21
+
22
+ interface ApplyPatchRenderContextLike {
23
+ toolCallId?: string;
24
+ cwd?: string;
25
+ expanded?: boolean;
26
+ argsComplete?: boolean;
27
+ }
28
+
13
29
  function parseApplyPatchParams(params: unknown): { patchText: string } {
14
30
  if (!params || typeof params !== "object" || !("input" in params) || typeof params.input !== "string") {
15
31
  throw new Error("apply_patch requires a string 'input' parameter");
@@ -23,6 +39,28 @@ function isExecutePatchResult(details: unknown): details is ExecutePatchResult {
23
39
 
24
40
  export type { ExecutePatchResult } from "../patch/types.ts";
25
41
 
42
+ export function clearApplyPatchRenderState(): void {
43
+ applyPatchRenderStates.clear();
44
+ }
45
+
46
+ const renderApplyPatchCallWithOptionalContext: any = (
47
+ args: { input?: unknown },
48
+ theme: { fg(role: string, text: string): string; bold(text: string): string },
49
+ context?: ApplyPatchRenderContextLike,
50
+ ) => {
51
+ if (context?.argsComplete === false) {
52
+ return new Text(`${theme.fg("dim", "•")} ${theme.bold("Patching")}`, 0, 0);
53
+ }
54
+ const patchText = typeof args.input === "string" ? args.input : "";
55
+ if (patchText.trim().length === 0) {
56
+ return new Text(`${theme.fg("dim", "•")} ${theme.bold("Patching")}`, 0, 0);
57
+ }
58
+ const cached = context?.toolCallId ? applyPatchRenderStates.get(context.toolCallId) : undefined;
59
+ const cwd = context?.cwd ?? cached?.cwd;
60
+ const text = context?.expanded ? cached?.expanded ?? renderApplyPatchCall(patchText, cwd) : cached?.collapsed ?? formatApplyPatchSummary(patchText, cwd);
61
+ return new Text(text, 0, 0);
62
+ };
63
+
26
64
  export function registerApplyPatchTool(pi: ExtensionAPI): void {
27
65
  pi.registerTool({
28
66
  name: "apply_patch",
@@ -31,12 +69,17 @@ export function registerApplyPatchTool(pi: ExtensionAPI): void {
31
69
  promptSnippet: "Edit files with a patch.",
32
70
  promptGuidelines: ["Prefer apply_patch for focused textual edits instead of rewriting whole files."],
33
71
  parameters: APPLY_PATCH_PARAMETERS,
34
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
72
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
35
73
  if (signal?.aborted) {
36
74
  throw new Error("apply_patch aborted");
37
75
  }
38
76
 
39
77
  const typedParams = parseApplyPatchParams(params);
78
+ applyPatchRenderStates.set(toolCallId, {
79
+ cwd: ctx.cwd,
80
+ collapsed: formatApplyPatchSummary(typedParams.patchText, ctx.cwd),
81
+ expanded: renderApplyPatchCall(typedParams.patchText, ctx.cwd),
82
+ });
40
83
  const result = executePatch({ cwd: ctx.cwd, patchText: typedParams.patchText });
41
84
  const summary = [
42
85
  "Applied patch successfully.",
@@ -52,33 +95,17 @@ export function registerApplyPatchTool(pi: ExtensionAPI): void {
52
95
  details: result,
53
96
  };
54
97
  },
55
- renderCall(_args, theme) {
56
- return new Text(`${theme.fg("toolTitle", theme.bold("apply_patch"))} ${theme.fg("muted", "patch")}`, 0, 0);
57
- },
58
- renderResult(result, { isPartial, expanded }, theme) {
98
+ renderCall: renderApplyPatchCallWithOptionalContext,
99
+ renderResult(result, { isPartial }, theme) {
59
100
  if (isPartial) {
60
- return new Text(theme.fg("warning", "Applying patch..."), 0, 0);
101
+ return new Text(`${theme.fg("dim", "•")} ${theme.bold("Patching")}`, 0, 0);
61
102
  }
62
103
 
63
- const details = isExecutePatchResult(result.details) ? result.details : undefined;
64
- if (!details) {
65
- return new Text(theme.fg("success", "Patch applied"), 0, 0);
104
+ if (!isExecutePatchResult(result.details)) {
105
+ return new Container();
66
106
  }
67
107
 
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
- }
80
- }
81
- return new Text(text, 0, 0);
108
+ return new Container();
82
109
  },
83
110
  });
84
111
  }
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { Type } from "@sinclair/typebox";
3
- import { Text } from "@mariozechner/pi-tui";
4
- import { renderExecCommandCall } from "./codex-rendering.ts";
3
+ import { Container, Text } from "@mariozechner/pi-tui";
4
+ import { renderExecCommandCall, renderGroupedExecCommandCall } from "./codex-rendering.ts";
5
5
  import type { ExecCommandTracker } from "./exec-command-state.ts";
6
6
  import type { ExecSessionManager, UnifiedExecResult } from "./exec-session-manager.ts";
7
7
  import { formatUnifiedExecResult } from "./unified-exec-format.ts";
@@ -56,6 +56,62 @@ function isUnifiedExecResult(details: unknown): details is UnifiedExecResult {
56
56
  return typeof details === "object" && details !== null;
57
57
  }
58
58
 
59
+ function createEmptyResultComponent(): Container {
60
+ return new Container();
61
+ }
62
+
63
+ interface ExecCommandRenderContextLike {
64
+ toolCallId?: string;
65
+ invalidate?: () => void;
66
+ }
67
+
68
+ const renderExecCommandCallWithOptionalContext: any = (
69
+ args: { cmd?: unknown },
70
+ theme: { fg(role: string, text: string): string; bold(text: string): string },
71
+ context: ExecCommandRenderContextLike | undefined,
72
+ tracker: ExecCommandTracker,
73
+ ) => {
74
+ const command = typeof args.cmd === "string" ? args.cmd : "";
75
+ tracker.registerRenderContext(context?.toolCallId, context?.invalidate ?? (() => {}));
76
+ const renderInfo = tracker.getRenderInfo(context?.toolCallId, command);
77
+ if (renderInfo.hidden) {
78
+ return new Text("", 0, 0);
79
+ }
80
+ const text = renderInfo.actionGroups
81
+ ? renderGroupedExecCommandCall(renderInfo.actionGroups, renderInfo.status, theme)
82
+ : renderExecCommandCall(command, renderInfo.status, theme);
83
+ return new Text(text, 0, 0);
84
+ };
85
+
86
+ const renderExecCommandResultWithOptionalContext: any = (
87
+ result: { content: Array<{ type: string; text?: string }>; details?: unknown },
88
+ options: { expanded: boolean; isPartial: boolean },
89
+ theme: { fg(role: string, text: string): string },
90
+ context: ExecCommandRenderContextLike | undefined,
91
+ tracker: ExecCommandTracker,
92
+ ) => {
93
+ if (options.isPartial || !options.expanded) {
94
+ return createEmptyResultComponent();
95
+ }
96
+
97
+ const command = context && "args" in context && context.args && typeof (context as any).args.cmd === "string" ? (context as any).args.cmd : undefined;
98
+ if (tracker.getRenderInfo(context?.toolCallId, command ?? "").hidden) {
99
+ return createEmptyResultComponent();
100
+ }
101
+
102
+ const details = isUnifiedExecResult(result.details) ? result.details : undefined;
103
+ const content = result.content.find((item) => item.type === "text");
104
+ const output = details?.output ?? (content?.type === "text" ? content.text : "");
105
+ let text = theme.fg("dim", output || "(no output)");
106
+ if (details?.session_id !== undefined) {
107
+ text += `\n${theme.fg("accent", `Session ${details.session_id} still running`)}`;
108
+ }
109
+ if (details?.exit_code !== undefined) {
110
+ text += `\n${theme.fg("muted", `Exit code: ${details.exit_code}`)}`;
111
+ }
112
+ return new Text(text, 0, 0);
113
+ };
114
+
59
115
  export function registerExecCommandTool(pi: ExtensionAPI, tracker: ExecCommandTracker, sessions: ExecSessionManager): void {
60
116
  pi.registerTool({
61
117
  name: "exec_command",
@@ -68,40 +124,27 @@ export function registerExecCommandTool(pi: ExtensionAPI, tracker: ExecCommandTr
68
124
  "Keep tty disabled unless the command truly needs interactive terminal behavior.",
69
125
  ],
70
126
  parameters: EXEC_COMMAND_PARAMETERS,
71
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
127
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
72
128
  if (signal?.aborted) {
73
129
  throw new Error("exec_command aborted");
74
130
  }
75
131
  const typedParams = parseExecCommandParams(params);
76
132
  const result = await sessions.exec(typedParams, ctx.cwd, signal);
77
133
  if (result.session_id !== undefined) {
78
- tracker.recordPersistentSession(typedParams.cmd);
134
+ tracker.recordPersistentSession(toolCallId, result.session_id);
79
135
  }
80
136
  return {
81
137
  content: [{ type: "text", text: formatUnifiedExecResult(result, typedParams.cmd) }],
82
138
  details: result,
83
139
  };
84
140
  },
85
- renderCall(args, theme) {
86
- const command = typeof args.cmd === "string" ? args.cmd : "";
87
- return new Text(renderExecCommandCall(command, tracker.getState(command), theme), 0, 0);
88
- },
89
- renderResult(result, { expanded, isPartial }, theme) {
90
- if (isPartial || !expanded) {
91
- return undefined;
92
- }
93
-
94
- const details = isUnifiedExecResult(result.details) ? result.details : undefined;
95
- const content = result.content.find((item) => item.type === "text");
96
- const output = details?.output ?? (content?.type === "text" ? content.text : "");
97
- let text = theme.fg("dim", output || "(no output)");
98
- if (details?.session_id !== undefined) {
99
- text += `\n${theme.fg("accent", `Session ${details.session_id} still running`)}`;
100
- }
101
- if (details?.exit_code !== undefined) {
102
- text += `\n${theme.fg("muted", `Exit code: ${details.exit_code}`)}`;
103
- }
104
- return new Text(text, 0, 0);
105
- },
141
+ renderCall: ((args: { cmd?: unknown }, theme: { fg(role: string, text: string): string; bold(text: string): string }, context?: ExecCommandRenderContextLike) =>
142
+ renderExecCommandCallWithOptionalContext(args, theme, context, tracker)) as any,
143
+ renderResult: ((
144
+ result: { content: Array<{ type: string; text?: string }>; details?: unknown },
145
+ options: { expanded: boolean; isPartial: boolean },
146
+ theme: { fg(role: string, text: string): string },
147
+ context?: ExecCommandRenderContextLike,
148
+ ) => renderExecCommandResultWithOptionalContext(result, options, theme, context, tracker)) as any,
106
149
  });
107
150
  }
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@mariozechner/pi-coding-agent";
2
2
  import { Type } from "@sinclair/typebox";
3
- import { Box, Text } from "@mariozechner/pi-tui";
3
+ import { Box, Container, Text } from "@mariozechner/pi-tui";
4
4
  import { isOpenAICodexModel } from "../adapter/codex-model.ts";
5
5
 
6
6
  export const WEB_SEARCH_UNSUPPORTED_MESSAGE = "web_search is only available with the openai-codex provider";
@@ -56,6 +56,10 @@ function isWebSearchFunctionTool(tool: unknown): tool is FunctionToolPayload {
56
56
  return !!tool && typeof tool === "object" && (tool as FunctionToolPayload).type === "function" && (tool as FunctionToolPayload).name === "web_search";
57
57
  }
58
58
 
59
+ function createEmptyResultComponent(): Container {
60
+ return new Container();
61
+ }
62
+
59
63
  export function rewriteNativeWebSearchTool(payload: unknown, model: ExtensionContext["model"]): unknown {
60
64
  if (!supportsNativeWebSearch(model) || !payload || typeof payload !== "object") {
61
65
  return payload;
@@ -113,7 +117,7 @@ export function createWebSearchTool(): ToolDefinition<typeof WEB_SEARCH_PARAMETE
113
117
  },
114
118
  renderResult(result, { expanded }, theme) {
115
119
  if (!expanded) {
116
- return undefined;
120
+ return createEmptyResultComponent();
117
121
  }
118
122
  const textBlock = result.content.find((item) => item.type === "text");
119
123
  const text = textBlock?.type === "text" ? textBlock.text : "(no output)";
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { Type } from "@sinclair/typebox";
3
- import { Text } from "@mariozechner/pi-tui";
3
+ import { Container, Text } from "@mariozechner/pi-tui";
4
4
  import { renderWriteStdinCall } from "./codex-rendering.ts";
5
5
  import type { ExecSessionManager, UnifiedExecResult } from "./exec-session-manager.ts";
6
6
  import { formatUnifiedExecResult } from "./unified-exec-format.ts";
@@ -100,6 +100,10 @@ function isUnifiedExecResult(details: unknown): details is UnifiedExecResult {
100
100
  return typeof details === "object" && details !== null;
101
101
  }
102
102
 
103
+ function createEmptyResultComponent(): Container {
104
+ return new Container();
105
+ }
106
+
103
107
  export function registerWriteStdinTool(pi: ExtensionAPI, sessions: ExecSessionManager): void {
104
108
  pi.registerTool({
105
109
  name: "write_stdin",
@@ -129,7 +133,7 @@ export function registerWriteStdinTool(pi: ExtensionAPI, sessions: ExecSessionMa
129
133
  return new Text(renderWriteStdinCall(sessionId, input, command, theme), 0, 0);
130
134
  },
131
135
  renderResult(result, { expanded, isPartial }, theme) {
132
- if (isPartial || !expanded) return undefined;
136
+ if (isPartial || !expanded) return createEmptyResultComponent();
133
137
  const state = getResultState(result);
134
138
  const output = renderTerminalText(state.output);
135
139
  let text = theme.fg("dim", output || "(no output)");