pi-compact-tools 0.13.2 → 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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # pi-compact-tools
2
2
 
3
- Compact, expandable tool rows for Pi in its own compact style or Claude Code's, with a silent mode and a GitHub Dark theme. Works with custom tools from other packages.
3
+ Tool rows for Pi in three styles: **Claude**, **Codex**, and **Compact**. Pick the look you know from Claude Code or Codex CLI, or Pi's own compact rows, and switch any time with `/compact-tools`. Includes a silent mode and a GitHub Dark theme, and works with custom tools from other packages.
4
4
 
5
- ![The compact style, then the Claude style, each switched to silent mode mid-run and back](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/overview.gif)
5
+ ![One conversation drawn in the Compact style, switched to the Claude style, then the Codex style, then silent mode](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/overview.gif)
6
6
 
7
7
  ## Install
8
8
 
@@ -10,46 +10,67 @@ Compact, expandable tool rows for Pi in its own compact style or Claude Code's,
10
10
  pi install npm:pi-compact-tools
11
11
  ```
12
12
 
13
- Restart Pi or run `/reload`. For the bundled theme, set `"theme": "github-dark-pro"` in `~/.pi/agent/settings.json`.
13
+ Restart Pi or run `/reload`, then run `/compact-tools` to choose a style. To try it for one session without installing: `pi -e npm:pi-compact-tools`.
14
14
 
15
- ## Usage
15
+ Requires Node.js 22.19+ and Pi 0.85.1+. It changes only how tool calls are drawn, not what they run or what the model sees.
16
16
 
17
- Pick a style. Silent mode works on top of either.
17
+ ## Styles
18
18
 
19
- | | Setting | Shows | How |
20
- |---|---|---|---|
21
- | **Compact** | Style | every tool call as its own compact, expandable row | `"style": "compact"` (default) |
22
- | **Claude** | Style | reads and searches folded into one line, like Claude Code | `"style": "claude"` |
23
- | **Silent** | Mode | only your prompts and the answers | `Ctrl+'` or `/silent` |
19
+ | Style | Looks like | Set with |
20
+ |---|---|---|
21
+ | **Claude** | Claude Code: reads and searches fold into one line, `⎿` results | `/compact-tools claude` |
22
+ | **Codex** | Codex CLI: `• Explored` groups, `• Ran` with the last output lines | `/compact-tools codex` |
23
+ | **Compact** | one expandable row per call, with its status and time (default) | `/compact-tools compact` |
24
24
 
25
- Set `"style"` in the config below, then `/reload`. `"style": "off"` restores Pi's own rows.
25
+ Silent mode (`Ctrl+'` or `/silent`) works on top of any style. `/compact-tools off` restores Pi's own rows.
26
26
 
27
- ### Style: Compact
27
+ ### Claude
28
+
29
+ ![Claude style: reads and searches folded into one line, then Bash and Update rows](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/claude-style.gif)
30
+
31
+ - Reads, searches, and listings fold into one line: `Searched for 2 patterns, read 3 files`.
32
+ - `Bash`, `Update`, `Write`, and custom tools keep their own rows with `⎿` results and short previews.
33
+ - Matched against Claude Code 2.1.288 in a live terminal.
34
+
35
+ ### Codex
36
+
37
+ ![Codex style: an Explored group, Ran rows with their last output lines, and an Edited diff](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/codex-style.gif)
38
+
39
+ - Reads, searches, and listings fold into `• Explored`: `Read a.ts, b.ts`, `Search TODO in src`.
40
+ - Commands read `Ran` or `Failed (exit 2)` with their last three output lines; opened, `$ cmd`, all its output, and `✓ • 1.23s`.
41
+ - Edits preview their changed lines; custom tools read `• Called name`.
42
+ - Matched against Codex CLI 0.160.0 in a live terminal.
43
+
44
+ ### Compact
28
45
 
29
46
  ![Compact style: searches, reads, a test run, and an edit with its diff, each call with its status](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/compact-workflow.gif)
30
47
 
31
- - Each call: what it ran, then its status, duration, and line count.
48
+ - One row per call: what it ran, its status, duration, and line count.
32
49
  - Edits show a highlighted diff with `(+3 -1)`; reads and writes show numbered code.
33
50
 
34
- ### Style: Claude
51
+ A command that writes or runs something, such as `tee`, `xargs`, or `find -delete`, always keeps its own row; only calls that just look around are folded into a group.
35
52
 
36
- ![Claude style: reads and searches folded into one line, then Bash and Update rows](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/claude-style.gif)
53
+ ### Silent mode
37
54
 
38
- - Reads, searches, and listings fold into one line: `Searched for 2 patterns, read 3 files`.
39
- - `Bash`, `Update`, `Write`, and custom tools keep their own rows with short previews.
55
+ ![Silent mode switched on mid-run: the rows fold into a moving light, the answer stays, and every row comes back when it is off](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/silent-mode.gif)
40
56
 
41
- ### Mode: Silent
57
+ Only your prompts and the answers. Toggle with `Ctrl+'` or `/silent`; `"mode": "silent"` starts sessions with it on.
42
58
 
43
- ![Silent mode switched on mid-run: the rows fold into a moving light, the answer stays, and every row comes back when it is off](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/silent-mode.gif)
59
+ ## Commands
44
60
 
45
- Shows only your prompts and the answers, in either style.
61
+ ```text
62
+ /compact-tools # choose a style and where to save it
63
+ /compact-tools codex # switch style, at once, even while tools run
64
+ /compact-tools claude project # save for this trusted project
65
+ /compact-tools off # restore Pi's own rows (reloads Pi)
66
+ /compact-tools status # effective style, mode, and config paths
67
+ ```
46
68
 
47
- - Toggle with `Ctrl+'` or `/silent`. Tool rows and thinking fold into a moving light while the agent works.
48
- - Turn it off and every row comes back. `"mode": "silent"` starts sessions with it on.
69
+ Without a scope, the style is saved where it is decided: in the trusted project if its settings choose a style, otherwise globally. Switching between Claude, Codex, and Compact redraws every row at once, even while tools run. Turning rows `off` or back on reloads Pi, so it waits until the response finishes. Your other settings are kept.
49
70
 
50
71
  ## Configuration
51
72
 
52
- Optional, in `~/.pi/agent/compact-tools.json` or `<project>/.pi/compact-tools.json`:
73
+ Optional, in `~/.pi/agent/compact-tools.json` or `<project>/.pi/compact-tools.json` (trusted projects only; they override global settings field by field). Add `"$schema": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/schemas/compact-tools.schema.json"` for editor validation.
53
74
 
54
75
  ```json
55
76
  {
@@ -62,8 +83,9 @@ Optional, in `~/.pi/agent/compact-tools.json` or `<project>/.pi/compact-tools.js
62
83
  }
63
84
  ```
64
85
 
65
- - `tools`: built-ins to compact. Also `grep`, `find`, `ls`, `powershell`.
66
- - `auto_compact`: `true` starts a row collapsed; `false` shows up to `previewLines` rows. Custom tool names work too.
86
+ - `style`: `claude`, `codex`, `compact`, or `off`.
87
+ - `tools`: built-ins drawn in the style. Also `grep`, `find`, `ls`, `powershell`.
88
+ - `auto_compact`: `true` starts a Compact row collapsed; `false` shows up to `previewLines` rows. Custom tool names work too.
67
89
  - `custom_tools`: rows for tools from other packages. `exclude` keeps a tool's own look.
68
90
 
69
91
  ## Controls
@@ -83,7 +105,7 @@ Development dependencies are pinned to Pi 1.0.0; the peer minimum remains Pi 0.8
83
105
 
84
106
  For a local before/after performance comparison, run `npx tsx tests/benchmark.ts <clean-baseline-directory>` with the same host dependencies in both directories. Measurements are informational, not timing-sensitive test assertions.
85
107
 
86
- Renders are pinned in `tests/golden/render.json`. After an intended change to the output, record it with `UPDATE_GOLDEN=1 npm test` and review the diff. Before publishing, add the version's notes to `release-notes.json` (`[]` for none).
108
+ Renders are pinned in `tests/golden/`. After an intended change to the output, record it with `UPDATE_GOLDEN=1 npm test` and review the diff. The Claude and Codex styles are checked against references captured from the real tools in `tests/fixtures/`. The README's GIFs are recorded with `scripts/record-demos.sh` (needs `vhs` and `ffmpeg`). Before publishing, add the version's notes to `release-notes.json` (`[]` for none).
87
109
 
88
110
  ## License
89
111
 
@@ -4,10 +4,10 @@ import { normalizeLineEndings } from "./compact-tools-core.ts";
4
4
  import {
5
5
  countEditChanges,
6
6
  formatResultLineSummary,
7
- shellSegments,
8
7
  summarizeCustomArguments,
9
8
  summarizeFailure,
10
9
  } from "./compact-tools-invocation.ts";
10
+ import { isHarmlessRedirect, parseShell, writesOrRuns } from "./compact-tools-shell.ts";
11
11
  import type { ToolArgs } from "./compact-tools-types.ts";
12
12
 
13
13
  /**
@@ -20,6 +20,18 @@ export const CLAUDE_WRITE_ROWS = 10;
20
20
  /** Lines of context Claude Code keeps around each change in an edit's diff. */
21
21
  export const CLAUDE_DIFF_CONTEXT_LINES = 3;
22
22
 
23
+ /**
24
+ * Claude Code 2.1.288's chrome: `⏺ Bash(…)`, then ` ⎿ ` with the "⎿" under the
25
+ * tool's name and the result two columns after it. Pi draws every row one column
26
+ * in, with its own smaller "⦁" dot, so each prefix keeps that geometry one column
27
+ * further right.
28
+ */
29
+ export const CLAUDE_RESULT_PREFIX = " ⎿ ";
30
+ /** Lines after the first under "⎿", and everything drawn beneath the result. */
31
+ export const CLAUDE_INDENT = " ";
32
+ /** Where a wrapped call line continues: under the tool's name. */
33
+ export const CLAUDE_TITLE_INDENT = " ";
34
+
23
35
  /** Claude Code's names for the tools Pi shares with it. */
24
36
  const LABELS: Readonly<Record<string, string>> = {
25
37
  read: "Read",
@@ -118,38 +130,20 @@ const NEUTRAL_COMMANDS = new Set(["echo", "printf", "true", "false", ":"]);
118
130
 
119
131
  export type LookKind = "search" | "read" | "list";
120
132
 
121
- /** Split one shell step at its unquoted pipes. */
122
- function pipeStages(segment: string): string[] {
123
- const stages: string[] = [];
124
- let current = "";
125
- let quote: string | undefined;
126
- for (let index = 0; index < segment.length; index++) {
127
- const character = segment[index]!;
128
- if (quote) {
129
- if (character === quote) quote = undefined;
130
- } else if (character === "'" || character === '"' || character === "`") {
131
- quote = character;
132
- } else if (character === "|" && segment[index + 1] !== "|") {
133
- stages.push(current);
134
- current = "";
135
- continue;
136
- }
137
- current += character;
138
- }
139
- stages.push(current);
140
- return stages.map((stage) => stage.trim()).filter(Boolean);
141
- }
142
-
143
133
  /**
144
134
  * How a shell command looks around, if that is all it does: `rg x | head` searches,
145
- * `cat a.ts` reads, `ls src` lists. Anything that runs something else is undefined.
135
+ * `cat a.ts` reads, `ls src` lists. Anything that runs something else, writes a
136
+ * file, or redirects anywhere but /dev/null is undefined, so a group never hides it.
146
137
  */
147
138
  export function classifyShellCommand(command: string): LookKind | undefined {
139
+ const parsed = parseShell(normalizeLineEndings(command));
140
+ if (!parsed || parsed.opaque) return undefined;
148
141
  let search = false;
149
142
  let read = false;
150
143
  let list = false;
151
- for (const stage of shellSegments(normalizeLineEndings(command)).flatMap(pipeStages)) {
152
- const program = stage.split(/\s+/u)[0];
144
+ for (const { words, redirects } of parsed.commands) {
145
+ if (!redirects.every(isHarmlessRedirect) || writesOrRuns(words)) return undefined;
146
+ const program = words[0];
153
147
  if (!program || NEUTRAL_COMMANDS.has(program)) continue;
154
148
  if (SEARCH_COMMANDS.has(program)) search = true;
155
149
  else if (READ_COMMANDS.has(program)) read = true;
@@ -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
+ }