pi-compact-tools 0.13.1 → 0.14.0
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 +52 -26
- package/extensions/compact-tools-activity.ts +10 -2
- package/extensions/compact-tools-claude.ts +20 -26
- package/extensions/compact-tools-codex-rows.ts +465 -0
- package/extensions/compact-tools-codex.ts +453 -0
- package/extensions/compact-tools-command.ts +196 -0
- package/extensions/compact-tools-custom.ts +9 -0
- package/extensions/compact-tools-grouping.ts +130 -31
- package/extensions/compact-tools-invocation.ts +30 -1
- package/extensions/compact-tools-layout.ts +124 -27
- package/extensions/compact-tools-palette.ts +6 -7
- package/extensions/compact-tools-release.ts +30 -7
- package/extensions/compact-tools-runtime.ts +16 -0
- package/extensions/compact-tools-shell.ts +277 -0
- package/extensions/compact-tools-silent.ts +4 -1
- package/extensions/compact-tools-types.ts +10 -2
- package/extensions/compact-tools-viewport.ts +5 -7
- package/extensions/compact-tools.ts +115 -38
- package/package.json +10 -6
- package/release-notes.json +23 -0
- package/schemas/compact-tools.schema.json +3 -3
- package/scripts/check-resources.mjs +0 -82
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import type { AgentToolResult, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { stripTerminalSequences, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
CODEX_BULLET,
|
|
5
|
+
CODEX_COMMAND_INDENT,
|
|
6
|
+
CODEX_INDENT,
|
|
7
|
+
CODEX_MARGIN,
|
|
8
|
+
CODEX_PREVIEW_ROWS,
|
|
9
|
+
CODEX_RESULT_PREFIX,
|
|
10
|
+
CODEX_TAB,
|
|
11
|
+
codexDisclosure,
|
|
12
|
+
codexPath,
|
|
13
|
+
formatCodexDuration,
|
|
14
|
+
isEmptyOutput,
|
|
15
|
+
openExplorations,
|
|
16
|
+
paintSegments,
|
|
17
|
+
shellOutcome,
|
|
18
|
+
tailLines,
|
|
19
|
+
textLines,
|
|
20
|
+
wrapSegments,
|
|
21
|
+
type Segment,
|
|
22
|
+
type ThemeColor,
|
|
23
|
+
} from "./compact-tools-codex.ts";
|
|
24
|
+
import { normalizeLineEndings, type RowStatus } from "./compact-tools-core.ts";
|
|
25
|
+
import { countEditChanges, getEditDiff, getEditPatch, splitReadFooter } from "./compact-tools-invocation.ts";
|
|
26
|
+
import { parseCodeDiff, renderCodexDiff, type CodeDiffLine } from "./compact-tools-layout.ts";
|
|
27
|
+
import type { RowState, ToolArgs } from "./compact-tools-types.ts";
|
|
28
|
+
|
|
29
|
+
/** How a row is drawn: a file tool's result is code, a shell's is output, a custom tool's is its author's. */
|
|
30
|
+
export type CodexRowKind = "file" | "shell" | "custom";
|
|
31
|
+
|
|
32
|
+
/** Lines drawn from state read as they are drawn, laid out again only when that state or the width changes. */
|
|
33
|
+
class LiveLines implements Component {
|
|
34
|
+
private cache: { key: string; width: number; lines: string[] } | undefined;
|
|
35
|
+
|
|
36
|
+
constructor(private readonly key: (width: number) => string, private readonly draw: (width: number) => string[]) {}
|
|
37
|
+
|
|
38
|
+
render(width: number): string[] {
|
|
39
|
+
const key = this.key(width);
|
|
40
|
+
if (!this.cache || this.cache.key !== key || this.cache.width !== width) this.cache = { key, width, lines: withinWidth(this.draw(width), width) };
|
|
41
|
+
return this.cache.lines;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
invalidate(): void {
|
|
45
|
+
this.cache = undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One row cut to the width with "…", in the style of what it cuts when that is given. */
|
|
50
|
+
const clip = (line: string, width: number, ellipsis = "…") => truncateToWidth(line, Math.max(1, width), ellipsis);
|
|
51
|
+
|
|
52
|
+
/** Whatever a row lays out, a pane too narrow for its chrome still never draws past its edge. */
|
|
53
|
+
function withinWidth(lines: string[], width: number): string[] {
|
|
54
|
+
return lines.map((line) => visibleWidth(line) > width ? clip(line, width) : line);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function commandOf(args: ToolArgs): string {
|
|
58
|
+
return typeof args.command === "string" ? normalizeLineEndings(stripTerminalSequences(args.command)).trim() : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SHELL_OPERATORS = ["&&", "||", ">>", ">&", "&>", "|", ";", "&", ">", "<", "(", ")"];
|
|
62
|
+
/** Operators that send a command's output somewhere rather than start another command. */
|
|
63
|
+
const REDIRECTIONS = new Set([">", ">>", ">&", "&>", "<", ")"]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A command's lines with the shell's colors, as Codex highlights what it ran: the
|
|
67
|
+
* program each command runs, its flags, strings, variables, and the operators
|
|
68
|
+
* between them, in the theme's syntax colors. It is tokenized here rather than by
|
|
69
|
+
* the host's highlighter, whose grammar changes between Pi versions.
|
|
70
|
+
*/
|
|
71
|
+
export function highlightCommand(command: string, theme: Theme): string[] {
|
|
72
|
+
return commandSegments(command).map((line) => paintSegments(line, theme));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The pieces of a command, line by line, each with the syntax color it is painted in. */
|
|
76
|
+
function commandSegments(command: string): Segment[][] {
|
|
77
|
+
const lines: Segment[][] = [[]];
|
|
78
|
+
const add = (color: ThemeColor | undefined, token: string) => {
|
|
79
|
+
token.split("\n").forEach((part, index) => {
|
|
80
|
+
if (index > 0) lines.push([]);
|
|
81
|
+
if (part) lines[lines.length - 1]!.push({ text: part, color });
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
let commandStart = true;
|
|
85
|
+
let index = 0;
|
|
86
|
+
while (index < command.length) {
|
|
87
|
+
const rest = command.slice(index);
|
|
88
|
+
const space = rest.match(/^[ \t]+/u)?.[0];
|
|
89
|
+
if (space) {
|
|
90
|
+
add(undefined, space);
|
|
91
|
+
index += space.length;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (rest[0] === "\n") {
|
|
95
|
+
add(undefined, "\n");
|
|
96
|
+
commandStart = true;
|
|
97
|
+
index++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (rest[0] === "#") {
|
|
101
|
+
const comment = rest.match(/^#[^\n]*/u)![0];
|
|
102
|
+
add("syntaxComment", comment);
|
|
103
|
+
index += comment.length;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const operator = SHELL_OPERATORS.find((candidate) => rest.startsWith(candidate));
|
|
107
|
+
if (operator) {
|
|
108
|
+
add("syntaxOperator", operator);
|
|
109
|
+
if (!REDIRECTIONS.has(operator)) commandStart = true;
|
|
110
|
+
index += operator.length;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (rest[0] === "'" || rest[0] === "\"") {
|
|
114
|
+
const quote = rest[0];
|
|
115
|
+
let end = 1;
|
|
116
|
+
while (end < rest.length && rest[end] !== quote) end += quote === "\"" && rest[end] === "\\" ? 2 : 1;
|
|
117
|
+
const string = rest.slice(0, Math.min(end + 1, rest.length));
|
|
118
|
+
add("syntaxString", string);
|
|
119
|
+
commandStart = false;
|
|
120
|
+
index += string.length;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (rest[0] === "$") {
|
|
124
|
+
const variable = rest.match(/^\$(?:\{[^}]*\}?|[A-Za-z_][A-Za-z0-9_]*|[0-9#?@*$!-])?/u)![0];
|
|
125
|
+
add("syntaxVariable", variable);
|
|
126
|
+
commandStart = false;
|
|
127
|
+
index += variable.length;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const word = rest.match(/^[^\s'"$#&|;<>()]+/u)?.[0] ?? rest[0]!;
|
|
131
|
+
const assignment = commandStart && /^[A-Za-z_][A-Za-z0-9_]*=/u.test(word);
|
|
132
|
+
add(commandStart && !assignment ? "syntaxFunction" : word.startsWith("-") ? "syntaxKeyword"
|
|
133
|
+
: /^\d+$/u.test(word) ? "syntaxNumber" : undefined, word);
|
|
134
|
+
if (!assignment) commandStart = false;
|
|
135
|
+
index += word.length;
|
|
136
|
+
}
|
|
137
|
+
return lines;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Lines wrapped under a prefix, the first row after `first` and every later one after `rest`. */
|
|
141
|
+
function prefixedRows(lines: readonly Segment[][], first: string, rest: string, width: number, theme: Theme): string[] {
|
|
142
|
+
const available = Math.max(1, width - Math.max(visibleWidth(first), visibleWidth(rest)));
|
|
143
|
+
return lines.flatMap((line) => wrapSegments(line, available, available))
|
|
144
|
+
.map((row, index) => (index === 0 ? first : rest) + paintSegments(row, theme));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function durationOf(state: RowState): string | undefined {
|
|
148
|
+
return state.startedAt !== undefined && state.endedAt !== undefined ? formatCodexDuration(state.endedAt - state.startedAt) : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What each status reads as at the head of a row. */
|
|
152
|
+
const VERBS: Record<"shell" | "custom" | "edit" | "write", Record<"running" | "success" | "error", string>> = {
|
|
153
|
+
shell: { running: "Running", success: "Ran", error: "Failed" },
|
|
154
|
+
custom: { running: "Calling", success: "Called", error: "Failed" },
|
|
155
|
+
edit: { running: "Editing", success: "Edited", error: "Failed to edit" },
|
|
156
|
+
write: { running: "Writing", success: "Wrote", error: "Failed to write" },
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const settled = (status: RowStatus) => status === "success" || status === "error" ? status : "running";
|
|
160
|
+
|
|
161
|
+
/** An exploration tool's verb and what it looked at, as a lone row names them. */
|
|
162
|
+
function lookTitle(name: string, args: ToolArgs, theme: Theme): { verb: string; target: string } {
|
|
163
|
+
const path = codexPath(args);
|
|
164
|
+
if (name === "read") return { verb: "Read", target: path };
|
|
165
|
+
if (name === "ls") return { verb: "List", target: path || "." };
|
|
166
|
+
const pattern = typeof args.pattern === "string" ? normalizeLineEndings(args.pattern).split("\n")[0]!.trim() : "";
|
|
167
|
+
return { verb: "Search", target: path ? `${pattern}${theme.fg("dim", " in ")}${path}` : pattern };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function changeCount(theme: Theme, added: number, removed: number): string {
|
|
171
|
+
return `(${theme.fg("toolDiffAdded", `+${added}`)} ${theme.fg("toolDiffRemoved", `-${removed}`)})`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function writeLineCount(args: ToolArgs): number {
|
|
175
|
+
const content = typeof args.content === "string" ? normalizeLineEndings(args.content) : "";
|
|
176
|
+
return content ? content.replace(/\n$/u, "").split("\n").length : 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A custom tool's call as Codex writes an MCP call it opened: `name({"query":"x"})`. */
|
|
180
|
+
function invocation(name: string, args: ToolArgs): Segment[] {
|
|
181
|
+
let json = "";
|
|
182
|
+
try {
|
|
183
|
+
json = JSON.stringify(args) ?? "";
|
|
184
|
+
} catch {
|
|
185
|
+
json = "";
|
|
186
|
+
}
|
|
187
|
+
return [{ text: name, color: "accent" }, { text: "(" }, { text: json === "{}" ? "" : json, color: "dim" }, { text: ")" }];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Where a custom tool's opened header starts its invocation: beside the verb when it fits, else beneath it. */
|
|
191
|
+
function invocationInline(verb: string, call: readonly Segment[], width: number): boolean {
|
|
192
|
+
return visibleWidth(call.map((segment) => segment.text).join("")) <= width - visibleWidth(`${CODEX_MARGIN}${CODEX_BULLET} ${verb} `);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The head of a Codex row, drawn from the row's state each time it is painted: the
|
|
197
|
+
* verb follows the call from running to its outcome, and an edit's counts and a
|
|
198
|
+
* command's exit code arrive with the result.
|
|
199
|
+
*/
|
|
200
|
+
export function renderCodexCall(
|
|
201
|
+
name: string,
|
|
202
|
+
kind: CodexRowKind,
|
|
203
|
+
args: ToolArgs,
|
|
204
|
+
theme: Theme,
|
|
205
|
+
state: RowState,
|
|
206
|
+
status: () => RowStatus,
|
|
207
|
+
indicator: () => string,
|
|
208
|
+
toolCallId: string,
|
|
209
|
+
): Component {
|
|
210
|
+
const expanded = state.expanded === true;
|
|
211
|
+
const command = kind === "shell" ? commandOf(args) : "";
|
|
212
|
+
const segments = kind === "shell" ? commandSegments(command) : [];
|
|
213
|
+
let current: RowStatus = "pending";
|
|
214
|
+
let dot = "";
|
|
215
|
+
// Opened, a command reads as its transcript; one with nothing left out keeps its preview, as in Codex.
|
|
216
|
+
const opened = (width: number) => expanded && kind === "shell" && (openExplorations.has(toolCallId)
|
|
217
|
+
|| (state.codex?.opens?.(width) ?? headerCut(command, VERBS.shell.running, width)));
|
|
218
|
+
return new LiveLines((width) => {
|
|
219
|
+
dot = indicator();
|
|
220
|
+
current = status();
|
|
221
|
+
const outcome = state.codex;
|
|
222
|
+
return `${dot}|${current}|${outcome?.exitCode ?? ""}|${outcome?.changes?.added ?? ""}/${outcome?.changes?.removed ?? ""}|${opened(width)}`;
|
|
223
|
+
}, (width) => {
|
|
224
|
+
const phase = settled(current);
|
|
225
|
+
const bullet = phase === "success" ? theme.bold(theme.fg("success", CODEX_BULLET))
|
|
226
|
+
: phase === "error" ? theme.bold(theme.fg("error", CODEX_BULLET)) : dot;
|
|
227
|
+
const head = (verb: string, rest: string) => `${CODEX_MARGIN}${bullet} ${theme.bold(verb)}${rest ? ` ${rest}` : ""}`;
|
|
228
|
+
if (kind === "shell") {
|
|
229
|
+
if (opened(width)) {
|
|
230
|
+
const prompt = theme.fg("bashMode", "$ ");
|
|
231
|
+
const first = width - visibleWidth(CODEX_MARGIN + "$ ");
|
|
232
|
+
const rest = Math.max(1, width - visibleWidth(CODEX_COMMAND_INDENT));
|
|
233
|
+
return segments.flatMap((line, index) => wrapSegments(line, index === 0 ? first : rest, rest)
|
|
234
|
+
.map((row, position) => index === 0 && position === 0 ? `${CODEX_MARGIN}${prompt}${paintSegments(row, theme)}` : `${CODEX_COMMAND_INDENT}${paintSegments(row, theme)}`));
|
|
235
|
+
}
|
|
236
|
+
const exitCode = state.codex?.exitCode;
|
|
237
|
+
const verb = phase === "error" && exitCode !== undefined ? `Failed (exit ${exitCode})` : VERBS.shell[phase];
|
|
238
|
+
// Painted as it is drawn, so a theme switched since the row was made colors it too.
|
|
239
|
+
return [clip(head(verb, paintSegments(segments[0] ?? [], theme) + (segments.length > 1 ? " …" : "")), width)];
|
|
240
|
+
}
|
|
241
|
+
if (kind === "custom") {
|
|
242
|
+
const verb = VERBS.custom[phase];
|
|
243
|
+
if (!expanded) return [clip(head(verb, theme.fg("accent", name)), width)];
|
|
244
|
+
const call = invocation(name, args);
|
|
245
|
+
if (invocationInline(verb, call, width)) return [head(verb, paintSegments(call, theme))];
|
|
246
|
+
return [head(verb, ""), ...prefixedRows([call], theme.fg("dim", CODEX_RESULT_PREFIX), CODEX_INDENT, width, theme)];
|
|
247
|
+
}
|
|
248
|
+
const path = codexPath(args);
|
|
249
|
+
if (name === "edit" || name === "write") {
|
|
250
|
+
const verbs = VERBS[name];
|
|
251
|
+
if (phase === "error") return [clip(`${CODEX_MARGIN}${theme.fg("error", theme.bold(`✘ ${verbs.error}${path ? ` ${path}` : ""}`))}`, width)];
|
|
252
|
+
if (phase === "running") return [clip(head(verbs.running, path), width)];
|
|
253
|
+
const changes = name === "write" ? { added: writeLineCount(args), removed: 0 } : state.codex?.changes ?? { added: 0, removed: 0 };
|
|
254
|
+
return [clip(`${CODEX_MARGIN}${theme.fg("dim", CODEX_BULLET)} ${theme.bold(verbs.success)} ${path} ${changeCount(theme, changes.added, changes.removed)}`, width)];
|
|
255
|
+
}
|
|
256
|
+
const { verb, target } = lookTitle(name, args, theme);
|
|
257
|
+
return [clip(head(verb, target), width)];
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
type ResultInput = {
|
|
262
|
+
name: string;
|
|
263
|
+
kind: CodexRowKind;
|
|
264
|
+
args: ToolArgs;
|
|
265
|
+
result: AgentToolResult<unknown>;
|
|
266
|
+
output: string;
|
|
267
|
+
theme: Theme;
|
|
268
|
+
state: RowState;
|
|
269
|
+
isError: boolean;
|
|
270
|
+
isPartial: boolean;
|
|
271
|
+
expanded: boolean;
|
|
272
|
+
toolCallId: string;
|
|
273
|
+
author?: (expanded: boolean) => Component | undefined;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* `✓ • 1.23s`, or `✗ (2) • 1.23s`: how an opened call ended, and how long it took.
|
|
278
|
+
* A row restored from the session was never timed here, so it shows only how it ended.
|
|
279
|
+
*/
|
|
280
|
+
function outcomeLine(theme: Theme, isError: boolean, exitCode: number | undefined, duration: string | undefined): string {
|
|
281
|
+
const mark = isError
|
|
282
|
+
? theme.bold(theme.fg("error", "✗")) + (exitCode !== undefined ? ` (${exitCode})` : "")
|
|
283
|
+
: theme.bold(theme.fg("success", "✓"));
|
|
284
|
+
return `${CODEX_MARGIN}${mark}${duration ? theme.fg("dim", ` • ${duration}`) : ""}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The last rows of output under `└`, each cut to the row, dim as Codex previews them. */
|
|
288
|
+
function previewRows(lines: readonly string[], theme: Theme, width: number): string[] {
|
|
289
|
+
return lines.map((line, index) => clip(theme.fg("dim", (index === 0 ? CODEX_RESULT_PREFIX : CODEX_INDENT) + line), width, theme.fg("dim", "…")));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* How many lines a collapsed preview leaves out: every line before the last three,
|
|
294
|
+
* and any of those three too wide to show whole.
|
|
295
|
+
*/
|
|
296
|
+
function hiddenLines(lines: readonly string[], width: number): number {
|
|
297
|
+
const shown = lines.slice(Math.max(0, lines.length - CODEX_PREVIEW_ROWS));
|
|
298
|
+
return Math.max(0, lines.length - CODEX_PREVIEW_ROWS)
|
|
299
|
+
+ shown.filter((line) => visibleWidth(line) + visibleWidth(CODEX_INDENT) > width).length;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function outputLines(text: string): string[] {
|
|
303
|
+
return normalizeLineEndings(stripTerminalSequences(text)).replace(/\n+$/u, "").split("\n").map((line) => line.replace(/\t/gu, CODEX_TAB));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** An opened call's output: whole, undimmed, wrapped from the margin. */
|
|
307
|
+
function fullOutput(text: string, theme: Theme, width: number): string[] {
|
|
308
|
+
if (isEmptyOutput(text)) return [];
|
|
309
|
+
return prefixedRows(outputLines(text).map((line) => line ? [{ text: line, color: "toolOutput" as const }] : []), CODEX_MARGIN, CODEX_MARGIN, width, theme);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function dimDisclosure(theme: Theme, width: number, expanded: boolean, hidden?: number): string {
|
|
313
|
+
return clip(theme.fg("dim", codexDisclosure(width, expanded, hidden)), width, theme.fg("dim", "…"));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A command, or a lone read, search, or listing: the tail of its output, or all of it opened. */
|
|
317
|
+
/** The verb a command's collapsed head reads with. */
|
|
318
|
+
function shellVerb(isPartial: boolean, isError: boolean, exitCode: number | undefined): string {
|
|
319
|
+
if (isPartial) return VERBS.shell.running;
|
|
320
|
+
if (!isError) return VERBS.shell.success;
|
|
321
|
+
return exitCode !== undefined ? `Failed (exit ${exitCode})` : VERBS.shell.error;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Whether a command's collapsed head hides part of it: a second line, or a first line too wide for the row. */
|
|
325
|
+
function headerCut(command: string, verb: string, width: number): boolean {
|
|
326
|
+
const lines = command.split("\n");
|
|
327
|
+
return lines.length > 1 || visibleWidth(`${CODEX_MARGIN}${CODEX_BULLET} ${verb} ${lines[0] ?? ""}`) > width;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** What a call's output and the text it shows come to: a command's exit code apart, a read's notice apart. */
|
|
331
|
+
function outputOf(input: ResultInput) {
|
|
332
|
+
const { name, kind, isError } = input;
|
|
333
|
+
const outcome = kind === "shell" && isError ? shellOutcome(input.output) : { output: input.output, exitCode: undefined };
|
|
334
|
+
const read = name === "read" && !isError ? splitReadFooter(outcome.output) : undefined;
|
|
335
|
+
return { exitCode: outcome.exitCode, text: read?.body ?? outcome.output, footer: read?.footer };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* What a collapsed command or lone read leaves out at a width: the lines it hides,
|
|
340
|
+
* "generic" when only its head is cut, and undefined when it shows everything, in
|
|
341
|
+
* which case Codex offers no control and does not open it.
|
|
342
|
+
*/
|
|
343
|
+
function collapsedDetails(input: ResultInput, width: number): number | "generic" | undefined {
|
|
344
|
+
const { exitCode, text, footer } = outputOf(input);
|
|
345
|
+
const hidden = isEmptyOutput(text) ? 0 : hiddenLines(outputLines(text), width);
|
|
346
|
+
if (hidden > 0) return hidden;
|
|
347
|
+
// A read cut short says so only once opened.
|
|
348
|
+
if (footer) return "generic";
|
|
349
|
+
const command = input.kind === "shell" ? commandOf(input.args) : "";
|
|
350
|
+
return input.kind === "shell" && headerCut(command, shellVerb(input.isPartial, input.isError, exitCode), width) ? "generic" : undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function outputResult(input: ResultInput, width: number): string[] {
|
|
354
|
+
const { theme, state, isError, isPartial } = input;
|
|
355
|
+
const { exitCode, text, footer } = outputOf(input);
|
|
356
|
+
const empty = isEmptyOutput(text);
|
|
357
|
+
const details = collapsedDetails(input, width);
|
|
358
|
+
if (opens(input, width)) {
|
|
359
|
+
// Codex has no file view: what a read returned shows as a command's output does, notice and all.
|
|
360
|
+
const rows = fullOutput(footer ? `${text}\n\n${footer}` : text, theme, width);
|
|
361
|
+
const ended = isPartial ? [] : [outcomeLine(theme, isError, exitCode, durationOf(state))];
|
|
362
|
+
return [...rows, ...ended, dimDisclosure(theme, width, true)];
|
|
363
|
+
}
|
|
364
|
+
const rows = empty ? (isPartial ? [] : previewRows(["(no output)"], theme, width))
|
|
365
|
+
: previewRows(tailLines(outputLines(text).join("\n")), theme, width);
|
|
366
|
+
if (details === undefined) return rows;
|
|
367
|
+
return [...rows, dimDisclosure(theme, width, false, details === "generic" ? undefined : details)];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Whether a command or lone read draws opened: always within an opened exploration, else only when it has more to show. */
|
|
371
|
+
function opens(input: ResultInput, width: number): boolean {
|
|
372
|
+
return input.expanded && (openExplorations.has(input.toolCallId) || collapsedDetails(input, width) !== undefined);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** A failed edit or write: why, under `└`, three rows of it until opened. */
|
|
376
|
+
function failureResult(input: ResultInput, width: number): string[] {
|
|
377
|
+
const { theme, expanded } = input;
|
|
378
|
+
const reason = input.output.trim() || "(error details unavailable)";
|
|
379
|
+
const rows = prefixedRows(textLines(reason, "dim"), theme.fg("dim", CODEX_RESULT_PREFIX), CODEX_INDENT, width, theme);
|
|
380
|
+
if (expanded) return [...rows, dimDisclosure(theme, width, true)];
|
|
381
|
+
return [...rows.slice(0, CODEX_PREVIEW_ROWS).map((row) => clip(row, width, theme.fg("dim", "…"))), dimDisclosure(theme, width, false)];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Changed lines only, as Codex previews a patch: context dropped, one `⋮` between hunks. */
|
|
385
|
+
function changesOnly(lines: readonly CodeDiffLine[]): CodeDiffLine[] {
|
|
386
|
+
const kept: CodeDiffLine[] = [];
|
|
387
|
+
for (const line of lines) {
|
|
388
|
+
if (line.kind === "context") continue;
|
|
389
|
+
if (line.kind === "separator" && (kept.length === 0 || kept.at(-1)!.kind === "separator")) continue;
|
|
390
|
+
kept.push(line);
|
|
391
|
+
}
|
|
392
|
+
while (kept.at(-1)?.kind === "separator") kept.pop();
|
|
393
|
+
return kept;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** An edit's diff or a write's new lines, the first changed rows until opened, every hunk once opened. */
|
|
397
|
+
function patchResult(input: ResultInput, width: number, lines: readonly CodeDiffLine[]): string[] {
|
|
398
|
+
const { theme, expanded, args } = input;
|
|
399
|
+
const shown = expanded ? lines : changesOnly(lines);
|
|
400
|
+
const rows = renderCodexDiff(shown, codexPath(args), theme, CODEX_INDENT, expanded ? undefined : CODEX_PREVIEW_ROWS)?.render(width) ?? [];
|
|
401
|
+
return [...rows, dimDisclosure(theme, width, expanded)];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** A custom tool's result as Codex shows an MCP call's: the last lines, then all of it, or its author's view. */
|
|
405
|
+
function customResult(input: ResultInput, width: number): string[] {
|
|
406
|
+
const { name, args, theme, isError, isPartial, expanded, author } = input;
|
|
407
|
+
const text = isEmptyOutput(input.output) ? "" : input.output.trimEnd();
|
|
408
|
+
if (isPartial) return text ? previewRows(tailLines(text), theme, width) : [];
|
|
409
|
+
if (expanded) {
|
|
410
|
+
const inline = invocationInline(VERBS.custom[isError ? "error" : "success"], invocation(name, args), width);
|
|
411
|
+
const first = inline ? theme.fg("dim", CODEX_RESULT_PREFIX) : CODEX_INDENT;
|
|
412
|
+
let rows: string[] = [];
|
|
413
|
+
let component: Component | undefined;
|
|
414
|
+
if (author && !isError) {
|
|
415
|
+
try {
|
|
416
|
+
component = author(true);
|
|
417
|
+
} catch {
|
|
418
|
+
// A failing third-party renderer degrades to the plain text result.
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (component) {
|
|
422
|
+
// The author's view hangs from "└" like any result, without the blank lines it may open with.
|
|
423
|
+
const lines = component.render(Math.max(1, width - visibleWidth(CODEX_INDENT)));
|
|
424
|
+
const start = lines.findIndex((line) => stripTerminalSequences(line).trim() !== "");
|
|
425
|
+
rows = (start < 0 ? [] : lines.slice(start)).map((line, index) => (index === 0 ? first : CODEX_INDENT) + line);
|
|
426
|
+
} else if (text || isError) rows = prefixedRows(textLines(outputLines(isError ? `Error: ${text}` : text).join("\n"), "dim"), first, CODEX_INDENT, width, theme);
|
|
427
|
+
return [...rows, dimDisclosure(theme, width, true)];
|
|
428
|
+
}
|
|
429
|
+
let lines = text ? tailLines(text) : [];
|
|
430
|
+
if (isError && outputLines(text).length <= CODEX_PREVIEW_ROWS) lines = lines.length ? [`Error: ${lines[0]}`, ...lines.slice(1)] : ["Error: "];
|
|
431
|
+
return [...previewRows(lines, theme, width), dimDisclosure(theme, width, false)];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Everything a Codex row draws beneath its head. */
|
|
435
|
+
export function renderCodexResult(input: ResultInput): Component {
|
|
436
|
+
const { name, kind, result, state, isError, isPartial, args } = input;
|
|
437
|
+
if (kind === "shell" && isError && !isPartial) state.codex = { ...state.codex, exitCode: shellOutcome(input.output).exitCode };
|
|
438
|
+
let draw: (width: number) => string[];
|
|
439
|
+
if (kind === "custom") {
|
|
440
|
+
draw = (width) => customResult(input, width);
|
|
441
|
+
} else if (name === "edit" || name === "write") {
|
|
442
|
+
if (isPartial) return new LiveLines(() => "", () => []);
|
|
443
|
+
if (isError) {
|
|
444
|
+
draw = (width) => failureResult(input, width);
|
|
445
|
+
} else if (name === "edit") {
|
|
446
|
+
state.codex = { ...state.codex, changes: countEditChanges(result) ?? { added: 0, removed: 0 } };
|
|
447
|
+
const lines = parseCodeDiff(getEditPatch(result), getEditDiff(result), CODEX_TAB);
|
|
448
|
+
draw = (width) => patchResult(input, width, lines);
|
|
449
|
+
} else {
|
|
450
|
+
const content = typeof args.content === "string" ? normalizeLineEndings(args.content).replace(/\n$/u, "") : "";
|
|
451
|
+
const lines = content ? content.split("\n").map((line, index): CodeDiffLine => ({ kind: "add", lineNumber: index + 1, content: line.replace(/\t/gu, CODEX_TAB), hunk: 0 })) : [];
|
|
452
|
+
draw = (width) => patchResult(input, width, lines);
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
draw = (width) => outputResult(input, width);
|
|
456
|
+
state.codex = { ...state.codex, opens: (width) => opens(input, width) };
|
|
457
|
+
}
|
|
458
|
+
// In an opened exploration the group's one "− Show less" closes it, so the call leaves out its own.
|
|
459
|
+
const grouped = () => openExplorations.has(input.toolCallId);
|
|
460
|
+
return new LiveLines(() => (grouped() ? "grouped" : ""), (width) => {
|
|
461
|
+
const lines = draw(width);
|
|
462
|
+
if (grouped() && lines.at(-1) === dimDisclosure(input.theme, width, true)) lines.pop();
|
|
463
|
+
return lines;
|
|
464
|
+
});
|
|
465
|
+
}
|