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.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Shell commands as both summary styles read them: split into words the way the
3
+ * shell would, and checked for anything that writes or runs something else. A row
4
+ * that folds into a "looked around" group hides its command, so a command that
5
+ * changes the disk must never pass for one that only reads it.
6
+ */
7
+
8
+ /** A redirection and where it points: `>`, `2>`, `&>`, `>&` with `1`, `<`. */
9
+ export type Redirect = { operator: string; target: string };
10
+
11
+ /** One command between connectors: its words, and the redirections written into it. */
12
+ export type ShellCommand = { words: string[]; redirects: Redirect[] };
13
+
14
+ export type ParsedShell = {
15
+ commands: ShellCommand[];
16
+ /** `$(…)`, backticks, `<(…)`, a subshell, or a background job: what runs there cannot be read off the words. */
17
+ opaque: boolean;
18
+ };
19
+
20
+ const CONNECTORS = ["&&", "||", "|&", "|", ";"];
21
+ const REDIRECTS = ["&>>", "&>", ">>", ">&", ">|", "<>", "<&", ">", "<"];
22
+
23
+ /**
24
+ * Split a command the way the shell does: quotes removed, a quoted `;` or newline
25
+ * kept as text, and connectors, newlines outside quotes, and redirections kept
26
+ * apart from words. Undefined when it cannot be split: an unterminated quote, or a
27
+ * here-document whose body would read as commands.
28
+ */
29
+ export function parseShell(source: string): ParsedShell | undefined {
30
+ const commands: ShellCommand[] = [{ words: [], redirects: [] }];
31
+ let opaque = false;
32
+ let word = "";
33
+ let started = false;
34
+ let redirect: string | undefined;
35
+ const current = () => commands[commands.length - 1]!;
36
+ const flush = () => {
37
+ if (!started) return;
38
+ if (redirect !== undefined) {
39
+ current().redirects.push({ operator: redirect, target: word });
40
+ redirect = undefined;
41
+ } else {
42
+ current().words.push(word);
43
+ }
44
+ word = "";
45
+ started = false;
46
+ };
47
+ const connect = () => {
48
+ flush();
49
+ if (redirect !== undefined) return false;
50
+ if (current().words.length > 0 || current().redirects.length > 0) commands.push({ words: [], redirects: [] });
51
+ return true;
52
+ };
53
+ for (let index = 0; index < source.length; index++) {
54
+ const character = source[index]!;
55
+ if (character === "'") {
56
+ const end = source.indexOf("'", index + 1);
57
+ if (end < 0) return undefined;
58
+ word += source.slice(index + 1, end);
59
+ started = true;
60
+ index = end;
61
+ continue;
62
+ }
63
+ if (character === "\"") {
64
+ let end = index + 1;
65
+ for (; end < source.length && source[end] !== "\""; end++) {
66
+ const inner = source[end]!;
67
+ if (inner === "\\" && end + 1 < source.length && "\"\\$`\n".includes(source[end + 1]!)) {
68
+ if (source[end + 1] !== "\n") word += source[end + 1];
69
+ end++;
70
+ } else {
71
+ if (inner === "`" || (inner === "$" && source[end + 1] === "(")) opaque = true;
72
+ word += inner;
73
+ }
74
+ }
75
+ if (end >= source.length) return undefined;
76
+ started = true;
77
+ index = end;
78
+ continue;
79
+ }
80
+ if (character === "\\") {
81
+ // A backslash before a newline joins the lines; before anything else it quotes it.
82
+ if (source[index + 1] !== "\n" && index + 1 < source.length) {
83
+ word += source[index + 1];
84
+ started = true;
85
+ }
86
+ index++;
87
+ continue;
88
+ }
89
+ if (character === " " || character === "\t") {
90
+ flush();
91
+ continue;
92
+ }
93
+ if (character === "\n") {
94
+ if (!connect()) return undefined;
95
+ continue;
96
+ }
97
+ if (character === "#" && !started) {
98
+ const end = source.indexOf("\n", index);
99
+ index = end < 0 ? source.length : end - 1;
100
+ continue;
101
+ }
102
+ if (character === "`" || (character === "$" && source[index + 1] === "(")) {
103
+ opaque = true;
104
+ word += character;
105
+ started = true;
106
+ continue;
107
+ }
108
+ const rest = source.slice(index);
109
+ if (rest.startsWith("<<")) return undefined;
110
+ if ((character === "<" || character === ">") && source[index + 1] === "(") {
111
+ opaque = true;
112
+ word += character;
113
+ started = true;
114
+ continue;
115
+ }
116
+ const redirection = REDIRECTS.find((operator) => rest.startsWith(operator));
117
+ if (redirection) {
118
+ // A word of digits right before it names the descriptor: `2>`.
119
+ const descriptor = started && /^\d+$/u.test(word) ? word : "";
120
+ if (descriptor) {
121
+ word = "";
122
+ started = false;
123
+ }
124
+ flush();
125
+ if (redirect !== undefined) return undefined;
126
+ redirect = descriptor + redirection;
127
+ index += redirection.length - 1;
128
+ continue;
129
+ }
130
+ const connector = CONNECTORS.find((operator) => rest.startsWith(operator));
131
+ if (connector) {
132
+ if (!connect()) return undefined;
133
+ index += connector.length - 1;
134
+ continue;
135
+ }
136
+ if (character === "&" || character === "(" || character === ")") {
137
+ opaque = true;
138
+ if (!connect()) return undefined;
139
+ continue;
140
+ }
141
+ word += character;
142
+ started = true;
143
+ }
144
+ flush();
145
+ if (redirect !== undefined) return undefined;
146
+ return { commands: commands.filter((command) => command.words.length > 0 || command.redirects.length > 0), opaque };
147
+ }
148
+
149
+ /** Whether a redirection only discards output or joins two streams, rather than reading or writing a file. */
150
+ export function isHarmlessRedirect({ operator, target }: Redirect): boolean {
151
+ if (/[>&]&$/u.test(operator) || operator.endsWith("<&")) return /^(?:\d+|-)$/u.test(target);
152
+ return operator.includes(">") && target === "/dev/null";
153
+ }
154
+
155
+ const FIND_ACTIONS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"]);
156
+
157
+ function shortFlag(arg: string, letter: string): boolean {
158
+ return /^-[^-]/u.test(arg) && arg.slice(1).includes(letter);
159
+ }
160
+
161
+ function operandCount(args: readonly string[]): number {
162
+ return args.filter((arg) => !arg.startsWith("-") || arg === "-").length;
163
+ }
164
+
165
+ /**
166
+ * sed scripts given with -e, or the first operand when there is none. A short option
167
+ * takes its value from the rest of its word or the next one: `-e'w out'`, `-ne 'w out'`.
168
+ */
169
+ function sedScripts(args: readonly string[]): string[] | undefined {
170
+ const scripts: string[] = [];
171
+ const operands: string[] = [];
172
+ for (let index = 0; index < args.length; index++) {
173
+ const arg = args[index]!;
174
+ if (arg === "--expression") scripts.push(args[++index] ?? "");
175
+ else if (arg.startsWith("--expression=")) scripts.push(arg.slice("--expression=".length));
176
+ // A script read from a file cannot be checked.
177
+ else if (arg === "--file" || arg.startsWith("--file=")) return undefined;
178
+ else if (/^-[^-]/u.test(arg)) {
179
+ // A cluster of single-letter options; e and f take what follows them.
180
+ for (let position = 1; position < arg.length; position++) {
181
+ const letter = arg[position];
182
+ if (letter === "f") return undefined;
183
+ if (letter !== "e") continue;
184
+ scripts.push(position + 1 < arg.length ? arg.slice(position + 1) : args[++index] ?? "");
185
+ break;
186
+ }
187
+ } else if (!arg.startsWith("-")) operands.push(arg);
188
+ }
189
+ if (scripts.length === 0 && operands[0] !== undefined) scripts.push(operands[0]);
190
+ return scripts;
191
+ }
192
+
193
+ /** Whether one sed command writes a file or runs one: `w`, `W`, `e`, or an `s` with the w or e flag. */
194
+ function sedCommandWrites(command: string): boolean {
195
+ // Past the address: line numbers, `$`, `/regex/` ranges, and `!`.
196
+ const body = command.replace(/^\s*(?:(?:\d+|\$|\/(?:\\.|[^/])*\/)\s*(?:[,~]\s*(?:\d+|\$|\/(?:\\.|[^/])*\/))?\s*!?\s*)?/u, "");
197
+ const verb = body[0];
198
+ if (verb === "w" || verb === "W" || verb === "e") return true;
199
+ if (verb !== "s" || body.length < 2) return false;
200
+ const delimiter = body[1]!;
201
+ let seen = 0;
202
+ let index = 2;
203
+ for (; index < body.length && seen < 2; index++) {
204
+ if (body[index] === "\\") index++;
205
+ else if (body[index] === delimiter) seen++;
206
+ }
207
+ return /^[^;}\s]*[we]/u.test(body.slice(index));
208
+ }
209
+
210
+ function sedWrites(args: readonly string[]): boolean {
211
+ // An i before any e or f in a cluster is the in-place flag; after one it belongs to the script.
212
+ const inPlace = (arg: string) => /^-[^-ef]*i/u.test(arg);
213
+ if (args.some((arg) => arg === "--in-place" || arg.startsWith("--in-place=") || inPlace(arg))) return true;
214
+ const scripts = sedScripts(args);
215
+ if (!scripts) return true;
216
+ return scripts.some((script) => script.split(/[;\n]/u).some(sedCommandWrites));
217
+ }
218
+
219
+ /** awk can print into files and pipes and run commands; a program it reads from a file cannot be checked. */
220
+ function awkWrites(args: readonly string[]): boolean {
221
+ let program: string | undefined;
222
+ for (let index = 0; index < args.length; index++) {
223
+ const arg = args[index]!;
224
+ if (arg === "-f" || arg.startsWith("--file")) return true;
225
+ if (arg === "-v" || arg === "-F") index++;
226
+ else if (!arg.startsWith("-")) {
227
+ program = arg;
228
+ break;
229
+ }
230
+ }
231
+ return program === undefined || /system\s*\(|[|>]/u.test(program);
232
+ }
233
+
234
+ /** An option given alone or with its value attached: `--pager`, `--pager=less`. */
235
+ function hasOption(args: readonly string[], ...names: string[]): boolean {
236
+ return args.some((arg) => names.some((name) => arg === name || arg.startsWith(`${name}=`)));
237
+ }
238
+
239
+ /**
240
+ * Whether a command can change files or run another program, though the program it
241
+ * names usually only reads: `find -delete`, `fd -x`, `rg --pre`, a pager it is told
242
+ * to run, `sort -o`, an in-place or writing `sed`, an `awk` that prints to a file,
243
+ * and `xargs` and `tee`, which exist to run and to write.
244
+ */
245
+ export function writesOrRuns(words: readonly string[]): boolean {
246
+ const [program, ...args] = words;
247
+ switch (program) {
248
+ case "git": {
249
+ // `git grep -O` opens its matches in a pager it runs; an option before the subcommand can set one.
250
+ const [sub, ...rest] = args;
251
+ if (sub !== "grep") return sub?.startsWith("-") === true;
252
+ return rest.some((arg) => /^-[^-]*O/u.test(arg)) || hasOption(rest, "--open-files-in-pager");
253
+ }
254
+ case "ag": case "ack": case "pt": case "bat": case "batcat":
255
+ return hasOption(args, "--pager");
256
+ case "xargs": case "tee":
257
+ return true;
258
+ case "find":
259
+ return args.some((arg) => FIND_ACTIONS.has(arg));
260
+ case "fd": case "fdfind":
261
+ return args.some((arg) => arg === "-x" || arg === "-X" || /^--exec(?:-batch)?(?:=|$)/u.test(arg));
262
+ case "rg":
263
+ return hasOption(args, "--pre", "--hostname-bin");
264
+ case "sort":
265
+ return args.some((arg) => arg.startsWith("--output") || arg.startsWith("--compress-program") || shortFlag(arg, "o"));
266
+ case "uniq":
267
+ return operandCount(args) >= 2;
268
+ case "tree":
269
+ return args.some((arg) => arg === "-o" || arg.startsWith("--output"));
270
+ case "sed":
271
+ return sedWrites(args);
272
+ case "awk": case "gawk": case "mawk": case "nawk":
273
+ return awkWrites(args);
274
+ default:
275
+ return false;
276
+ }
277
+ }
@@ -274,8 +274,11 @@ export class SilentModeController {
274
274
  pi.registerCommand("silent", {
275
275
  description: `(${SILENT_SHORTCUT}) Show only prompts and final answers`,
276
276
  getArgumentCompletions: (prefix) => {
277
+ // Pi applies a shown completion on Enter instead of running the command, so a value
278
+ // already typed in full is not offered again: Enter then runs it the first time.
279
+ const typed = prefix.trim().toLowerCase();
277
280
  const items = COMMAND_ARGUMENTS
278
- .filter((value) => value.startsWith(prefix.trim().toLowerCase()))
281
+ .filter((value) => value.startsWith(typed) && value !== typed)
279
282
  .map((value) => ({ value, label: value }));
280
283
  return items.length > 0 ? items : null;
281
284
  },
@@ -10,8 +10,8 @@ export type BuiltInDefinition = ToolDefinition<any, any, any>;
10
10
 
11
11
  export const DISPLAY_MODES = ["normal", "silent"] as const;
12
12
  export type DisplayMode = (typeof DISPLAY_MODES)[number];
13
- /** How tool rows are drawn: compactly, the way Claude Code draws them, or by Pi itself. */
14
- export const DISPLAY_STYLES = ["compact", "claude", "off"] as const;
13
+ /** How tool rows are drawn: compactly, the way Claude Code or Codex draws them, or by Pi itself. */
14
+ export const DISPLAY_STYLES = ["compact", "claude", "codex", "off"] as const;
15
15
  export type DisplayStyle = (typeof DISPLAY_STYLES)[number];
16
16
 
17
17
  export interface CustomToolsConfig {
@@ -51,6 +51,13 @@ export interface RowState {
51
51
  lastResultError?: boolean;
52
52
  lastResultConfigRevision?: object;
53
53
  lastResultPalette?: object;
54
+ /** What a Codex row's head reads from its result: a command's exit code, an edit's counts. */
55
+ codex?: {
56
+ exitCode?: number;
57
+ changes?: { added: number; removed: number };
58
+ /** Whether the row draws opened at a width: Codex opens only a call whose preview leaves something out. */
59
+ opens?: (width: number) => boolean;
60
+ };
54
61
  }
55
62
 
56
63
  type BaseRenderContext = Parameters<NonNullable<BuiltInDefinition["renderCall"]>>[2];
@@ -1,3 +1,4 @@
1
+ import { realpathSync } from "node:fs";
1
2
  import type {
2
3
  AgentToolResult,
3
4
  ExtensionAPI,
@@ -15,12 +16,14 @@ import {
15
16
  createPowerShellToolDefinition,
16
17
  createReadToolDefinition,
17
18
  createWriteToolDefinition,
19
+ ToolExecutionComponent,
18
20
  } from "@earendil-works/pi-coding-agent";
19
21
  import type { Component } from "@earendil-works/pi-tui";
20
22
  import { classifyCallStatus, formatDurationMs, normalizeLineEndings, type RowStatus } from "./compact-tools-core.ts";
21
23
  import { loadConfig } from "./compact-tools-config.ts";
22
24
  import { paletteOf } from "./compact-tools-color.ts";
23
- import { installToolRowPatch, setRowResolver, type RowRenderers, type ToolRow } from "./compact-tools-custom.ts";
25
+ import { registerCompactToolsCommand } from "./compact-tools-command.ts";
26
+ import { clearRowResolver, installToolRowPatch, setRowResolver, type RowRenderers, type ToolRow } from "./compact-tools-custom.ts";
24
27
  import {
25
28
  formatResultLineSummary,
26
29
  countEditChanges,
@@ -41,7 +44,7 @@ import {
41
44
  claudeRows,
42
45
  LiveCallContainer,
43
46
  limitComponentLines,
44
- prefixedLines,
47
+ prefixedSentence,
45
48
  prefixedText,
46
49
  railComponent,
47
50
  renderArguments,
@@ -50,14 +53,17 @@ import {
50
53
  renderOutput,
51
54
  renderToolCall,
52
55
  styleMultiline,
56
+ WithinWidth,
53
57
  } from "./compact-tools-layout.ts";
54
- import { CLAUDE_DIFF_CONTEXT_LINES, CLAUDE_OUTPUT_ROWS, CLAUDE_WRITE_ROWS, claudeFailure, claudeOutcome, claudeTitle } from "./compact-tools-claude.ts";
58
+ import { CLAUDE_DIFF_CONTEXT_LINES, CLAUDE_INDENT, CLAUDE_OUTPUT_ROWS, CLAUDE_RESULT_PREFIX, CLAUDE_TITLE_INDENT, CLAUDE_WRITE_ROWS, claudeFailure, claudeOutcome, claudeTitle } from "./compact-tools-claude.ts";
59
+ import { CODEX_BULLET } from "./compact-tools-codex.ts";
60
+ import { renderCodexCall, renderCodexResult } from "./compact-tools-codex-rows.ts";
55
61
  import { ToolGroupController } from "./compact-tools-grouping.ts";
56
62
  import { chromePainter, paintIndicator } from "./compact-tools-palette.ts";
57
63
  import { ProgressController } from "./compact-tools-progress.ts";
58
64
  import { showReleaseNotice } from "./compact-tools-release.ts";
59
65
  import { ToolRuntime } from "./compact-tools-runtime.ts";
60
- import { SilentModeController } from "./compact-tools-silent.ts";
66
+ import { findChat, isKind, SilentModeController } from "./compact-tools-silent.ts";
61
67
  import { ThinkingCycleController } from "./compact-tools-thinking.ts";
62
68
  import { ViewportKeeper } from "./compact-tools-viewport.ts";
63
69
  import {
@@ -71,13 +77,28 @@ import {
71
77
 
72
78
  /** Context lines kept around each change while an edit result is collapsed. */
73
79
  const PREVIEW_DIFF_CONTEXT_LINES = 1;
74
- /** Claude Code sets what a call returned under the "⎿" rather than beside a rail. */
75
- const CLAUDE_INDENT = " ";
76
80
 
77
81
  const runtime = new ToolRuntime();
78
82
  const RENDER_WIDGET_KEY = "compact-tools-render";
79
83
  const registeredTools = new Set<CompactToolName>();
80
84
  let registeredConfiguration: string | undefined;
85
+ /**
86
+ * Whether the last session found its directory trusted. A /reload rebuilds the
87
+ * transcript before session_start says so again, so the tools a trusted project
88
+ * adds are registered from the start rather than after its rows were made. Pi
89
+ * evaluates this module afresh on every load, so the answer is kept on globalThis.
90
+ */
91
+ const TRUST_KEY = Symbol.for("pi-compact-tools.trust");
92
+ type TrustSlot = typeof globalThis & { [TRUST_KEY]?: { cwd: string; trusted: boolean } };
93
+
94
+ /** A directory as the file system names it, so `/var` and `/private/var` are one place. */
95
+ function realDirectory(path: string): string {
96
+ try {
97
+ return realpathSync(path);
98
+ } catch {
99
+ return path;
100
+ }
101
+ }
81
102
 
82
103
  /** How a tool's row is drawn: a file tool's result is code, a shell's is output, a custom tool's is its author's. */
83
104
  type RowKind = "file" | "shell" | "custom";
@@ -91,6 +112,10 @@ function isClaude(): boolean {
91
112
  return runtime.config.style === "claude";
92
113
  }
93
114
 
115
+ function isCodex(): boolean {
116
+ return runtime.config.style === "codex";
117
+ }
118
+
94
119
  function pathArgument(args: ToolArgs): string {
95
120
  return typeof args.path === "string" ? args.path : typeof args.file_path === "string" ? args.file_path : "";
96
121
  }
@@ -108,13 +133,13 @@ function callStatus(ctx: RenderContext, state: RowState): RowStatus {
108
133
  * result, so a dot painted with the call would miss the result that arrived with
109
134
  * it; reading the row's state at draw time sees it. A settled dot never changes.
110
135
  */
111
- function liveIndicator(theme: Theme, ctx: RenderContext, state: RowState): () => string {
136
+ function liveIndicator(theme: Theme, ctx: RenderContext, state: RowState, glyph?: string): () => string {
112
137
  let settled: string | undefined;
113
138
  return () => {
114
139
  if (settled !== undefined) return settled;
115
140
  const status = callStatus(ctx, state);
116
141
  const indicator = paintIndicator(theme, status,
117
- runtime.syncIndicator(ctx.toolCallId, status === "running", () => ctx.invalidate()));
142
+ runtime.syncIndicator(ctx.toolCallId, status === "running", () => ctx.invalidate()), glyph);
118
143
  if (status === "success" || status === "error") settled = indicator;
119
144
  return indicator;
120
145
  };
@@ -192,19 +217,22 @@ function renderRowCall(name: string, args: ToolArgs, theme: Theme, ctx: RenderCo
192
217
  const state = runtime.syncRow(ctx);
193
218
  runtime.syncExpansion(state, ctx.expanded, name);
194
219
  const kind = rowKind(name);
195
- const indicator = liveIndicator(theme, ctx, state);
220
+ const indicator = liveIndicator(theme, ctx, state, isCodex() ? CODEX_BULLET : undefined);
196
221
  // Painted once now as well, so a row that starts running starts the animation clock.
197
222
  indicator();
223
+ // Codex's head reads the call's outcome, so it is worded each time it is drawn.
224
+ if (isCodex()) return renderCodexCall(name, kind, args, theme, state, () => callStatus(ctx, state), indicator, ctx.toolCallId);
198
225
  if (isClaude()) {
199
226
  // Claude Code's call line: `⦁ Bash(npm test)`, `⦁ Update(src/app.ts)`.
200
227
  const { label, argument } = claudeTitle(name, args, state.expanded === true);
201
228
  const title = ` ${theme.fg("toolTitle", theme.bold(label))}`
202
229
  + (argument ? styleMultiline(`(${argument})`, (line) => theme.fg("toolOutput", line)) : "");
203
- // The title wraps rather than being cut to the row; only a very long command is shortened.
204
- const container = new LiveCallContainer(indicator, (dot) => renderToolCall(dot + title, undefined, theme));
230
+ // The title wraps under the tool's name rather than being cut to the row; only a
231
+ // very long command is shortened.
232
+ const container = new LiveCallContainer(indicator, (dot) => renderToolCall(dot + title, undefined, theme, CLAUDE_TITLE_INDENT));
205
233
  // The title already lists plain arguments; only nested ones need the full view.
206
- if (kind === "custom" && state.expanded && hasNestedArguments(args)) container.addChild(renderArguments(args, theme));
207
- return container;
234
+ if (kind === "custom" && state.expanded && hasNestedArguments(args)) container.addChild(renderArguments(args, theme, CLAUDE_INDENT));
235
+ return new WithinWidth(container);
208
236
  }
209
237
  const title = ` ${theme.fg("toolTitle", theme.bold(name))}`;
210
238
  let details: string | undefined;
@@ -220,10 +248,13 @@ function renderRowCall(name: string, args: ToolArgs, theme: Theme, ctx: RenderCo
220
248
  const container = new LiveCallContainer(indicator, (dot) => renderToolCall(dot + title, details, theme));
221
249
  const extra = kind === "file" ? getArgumentDetails(name, args) : kind === "custom" && state.expanded ? args : {};
222
250
  if (Object.keys(extra).length > 0) container.addChild(renderArguments(extra, theme));
223
- return container;
251
+ return new WithinWidth(container);
224
252
  }
225
253
 
226
- /** A result body as code where it is code: numbered file text, or an edit's diff. */
254
+ /**
255
+ * A result body as code where it is code: numbered file text, or an edit's diff.
256
+ * `rail` is what each row starts with; the compact result rail unless given.
257
+ */
227
258
  function renderFileBody(
228
259
  name: string,
229
260
  args: ToolArgs,
@@ -232,20 +263,22 @@ function renderFileBody(
232
263
  expanded: boolean,
233
264
  theme: Theme,
234
265
  isError: boolean,
266
+ rail?: string,
235
267
  ): Component | undefined {
236
- if (isError) return renderOutput(output, theme, isError);
268
+ const fallback = () => renderOutput(output, theme, isError, rail);
269
+ if (isError) return fallback();
237
270
  const path = pathArgument(args);
238
271
  if (name === "edit" && getEditDiff(result)) {
239
272
  return renderCodeDiff(getEditPatch(result), getEditDiff(result), path, theme,
240
- expanded ? {} : { contextLines: PREVIEW_DIFF_CONTEXT_LINES }) ?? renderOutput(output, theme, isError);
273
+ expanded ? { rail } : { contextLines: PREVIEW_DIFF_CONTEXT_LINES, rail }) ?? fallback();
241
274
  }
242
275
  if (name === "read" && isReadTextResult(result)) {
243
276
  const { body, footer } = splitReadFooter(output);
244
277
  const startLine = typeof args.offset === "number" ? args.offset : 1;
245
- return renderCodeView(body, path, theme, { startLine, footer }) ?? renderOutput(output, theme, isError);
278
+ return renderCodeView(body, path, theme, { startLine, footer, rail }) ?? fallback();
246
279
  }
247
- if (name === "write") return renderCodeView(output, path, theme) ?? renderOutput(output, theme, isError);
248
- return renderOutput(output, theme, isError);
280
+ if (name === "write") return renderCodeView(output, path, theme, { rail }) ?? fallback();
281
+ return fallback();
249
282
  }
250
283
 
251
284
  function renderRowBody(
@@ -257,17 +290,18 @@ function renderRowBody(
257
290
  theme: Theme,
258
291
  isError: boolean,
259
292
  author: AuthorResultRenderer | undefined,
293
+ rail?: string,
260
294
  ): Component | undefined {
261
- if (rowKind(name) !== "custom") return renderFileBody(name, args, result, output, expanded, theme, isError);
295
+ if (rowKind(name) !== "custom") return renderFileBody(name, args, result, output, expanded, theme, isError, rail);
262
296
  if (author) {
263
297
  try {
264
298
  const component = author(expanded);
265
- if (component) return railComponent(component, theme);
299
+ if (component) return railComponent(component, theme, rail);
266
300
  } catch {
267
301
  // A failing third-party renderer degrades to the plain text result.
268
302
  }
269
303
  }
270
- return renderOutput(output, theme, isError);
304
+ return renderOutput(output, theme, isError, rail);
271
305
  }
272
306
 
273
307
  function claudeMoreLines(theme: Theme, hidden: number): string {
@@ -313,12 +347,12 @@ function claudeBody(
313
347
  // A command's output is already the result line itself.
314
348
  const kind = rowKind(name);
315
349
  if (!expanded || kind === "shell") return undefined;
316
- if (kind === "custom") return author ? renderRowBody(name, args, result, output, true, theme, false, author) : undefined;
317
- return renderFileBody(name, args, result, output, true, theme, false);
350
+ if (kind === "custom") return author ? renderRowBody(name, args, result, output, true, theme, false, author, CLAUDE_INDENT) : undefined;
351
+ return renderFileBody(name, args, result, output, true, theme, false, CLAUDE_INDENT);
318
352
  }
319
353
 
320
354
  /**
321
- * Claude Code's result block: a "└" line saying what happened, or the output
355
+ * Claude Code's result block: a "⎿" line saying what happened, or the output
322
356
  * itself for commands and custom tools, then any diff or code beneath it.
323
357
  */
324
358
  function renderClaudeResult(
@@ -335,7 +369,7 @@ function renderClaudeResult(
335
369
  const chrome = chromePainter(theme);
336
370
  const container = new CachedContainer();
337
371
  // One line under the call, wrapped rather than cut to the row.
338
- const line = (text: string) => prefixedLines(text, chrome(" └ "), CLAUDE_INDENT, false);
372
+ const line = (text: string) => prefixedSentence(text, chrome(CLAUDE_RESULT_PREFIX), CLAUDE_INDENT);
339
373
  if (options.isPartial) {
340
374
  container.addChild(line(chrome("Running…")));
341
375
  return container;
@@ -348,7 +382,7 @@ function renderClaudeResult(
348
382
  container.addChild(line(theme.fg("error", failure.headline)));
349
383
  container.addChild(claudeOutput(failure.detail, expanded, theme, true, "", CLAUDE_INDENT));
350
384
  } else if (expanded && output.trim()) {
351
- container.addChild(claudeOutput(`Error: ${output.trim()}`, true, theme, true, "", chrome(" └ ")));
385
+ container.addChild(claudeOutput(`Error: ${output.trim()}`, true, theme, true, "", chrome(CLAUDE_RESULT_PREFIX)));
352
386
  } else {
353
387
  container.addChild(line(theme.fg("error", failure.headline)));
354
388
  }
@@ -360,7 +394,7 @@ function renderClaudeResult(
360
394
  ? formatResultLineSummary(name, args, result, output) ?? "Done"
361
395
  : claudeOutcome(name, args, result, output, (value) => theme.bold(value));
362
396
  if (outcome !== undefined) container.addChild(line(theme.fg("toolOutput", outcome)));
363
- else container.addChild(claudeOutput(output, expanded, theme, false, rowKind(name) === "shell" ? "(No output)" : "(No content)", chrome(" └ ")));
397
+ else container.addChild(claudeOutput(output, expanded, theme, false, rowKind(name) === "shell" ? "(No output)" : "(No content)", chrome(CLAUDE_RESULT_PREFIX)));
364
398
  if (body) container.addChild(body);
365
399
  return container;
366
400
  }
@@ -386,8 +420,13 @@ function renderRowResult(
386
420
  const hasEditDiff = name === "edit" && getEditDiff(result).length > 0;
387
421
  runtime.setResultAvailable(state, name, hasEditDiff || output.length > 0 || author !== undefined);
388
422
  let component: Component;
389
- if (isClaude()) {
390
- component = renderClaudeResult(name, ctx.args, result, options, theme, ctx.isError, state.expanded === true, output, author);
423
+ if (isCodex()) {
424
+ component = renderCodexResult({
425
+ name, kind, args: ctx.args, result, output, theme, state, isError: ctx.isError,
426
+ isPartial: options.isPartial, expanded: state.expanded === true, toolCallId: ctx.toolCallId, author,
427
+ });
428
+ } else if (isClaude()) {
429
+ component = new WithinWidth(renderClaudeResult(name, ctx.args, result, options, theme, ctx.isError, state.expanded === true, output, author));
391
430
  } else {
392
431
  if (!options.isPartial && !state.resultLineSummaryComputed) {
393
432
  // A custom tool with no text has nothing to count.
@@ -404,7 +443,7 @@ function renderRowResult(
404
443
  const failureReason = ctx.isError && !state.expanded && !state.preview ? summarizeFailure(name, output) : undefined;
405
444
  container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, failureReason,
406
445
  name === "edit" ? countEditChanges(result) : undefined));
407
- component = container;
446
+ component = new WithinWidth(container);
408
447
  }
409
448
  rememberResult(state, result, options, ctx.isError, theme);
410
449
  return component;
@@ -495,6 +534,28 @@ function configure(pi: ExtensionAPI, cwd?: string, projectTrusted = false): void
495
534
  registeredConfiguration = signature;
496
535
  }
497
536
 
537
+ /**
538
+ * Apply a style saved by /compact-tools without reloading Pi. The compact, Claude, and
539
+ * Codex styles share every tool registration and read the style as they draw, so the
540
+ * configuration is swapped and each tool row on screen is drawn again, a running one
541
+ * included. Anything else that changed, or a switch to or from off, needs a reload.
542
+ */
543
+ function applyStyle(cwd: string, trusted: boolean): boolean {
544
+ const config = loadConfig(cwd, trusted);
545
+ const current = runtime.config;
546
+ if (config.style === "off" || current.style === "off") return false;
547
+ if (JSON.stringify({ ...config, style: current.style }) !== JSON.stringify(current)) return false;
548
+ runtime.configure(config);
549
+ registeredConfiguration = JSON.stringify([cwd, config]);
550
+ const chat = boundTui ? findChat(boundTui) : undefined;
551
+ for (const child of chat?.children ?? []) {
552
+ // Pi asks a row's renderers again whenever it redraws the row; this is that redraw.
553
+ if (isKind(child, ToolExecutionComponent)) (child as { updateDisplay?(): void }).updateDisplay?.();
554
+ }
555
+ boundTui?.requestRender();
556
+ return true;
557
+ }
558
+
498
559
  /** Note the calls an assistant message makes, as Pi reads them to make their rows. */
499
560
  function noteCalls(message: unknown): void {
500
561
  // Only a run's own updates announce calls; the check is cheap enough for every update.
@@ -515,15 +576,20 @@ function madeByTool(event: object): boolean {
515
576
  return typeof (event as { parentToolCallId?: unknown }).parentToolCallId === "string";
516
577
  }
517
578
 
579
+ /** Pi's TUI while one is bound: where a style switched in place finds the rows to draw again. */
580
+ let boundTui: { requestRender(): void } | undefined;
581
+
518
582
  /** Hand the animation Pi's frame request, reached through an invisible widget as the extension API allows. */
519
583
  function bindRenderer(ctx: ExtensionContext): void {
520
584
  ctx.ui.setWidget(RENDER_WIDGET_KEY, (tui) => {
585
+ boundTui = tui;
521
586
  runtime.bindRenderer(() => tui.requestRender());
522
587
  return { render: () => [], invalidate() {} };
523
588
  }, { placement: "belowEditor" });
524
589
  }
525
590
 
526
591
  function unbindRenderer(ctx: ExtensionContext | undefined): void {
592
+ boundTui = undefined;
527
593
  runtime.bindRenderer(undefined);
528
594
  ctx?.ui.setWidget(RENDER_WIDGET_KEY, undefined, { placement: "belowEditor" });
529
595
  }
@@ -537,6 +603,7 @@ export default function compactTools(pi: ExtensionAPI): void {
537
603
  registeredConfiguration = undefined;
538
604
  registeredTools.clear();
539
605
 
606
+ registerCompactToolsCommand(pi, () => runtime.config, applyStyle);
540
607
  const thinkingCycle = new ThinkingCycleController(pi);
541
608
  const progress = new ProgressController(pi);
542
609
  const viewport = new ViewportKeeper();
@@ -548,12 +615,16 @@ export default function compactTools(pi: ExtensionAPI): void {
548
615
  let renderContext: ExtensionContext | undefined;
549
616
  // Register once while the extension runtime is being built. In particular, this
550
617
  // makes the overrides available before Pi restores the active tool set on /reload.
551
- configure(pi, process.cwd());
618
+ const cwd = process.cwd();
619
+ const lastTrust = (globalThis as TrustSlot)[TRUST_KEY];
620
+ configure(pi, cwd, lastTrust?.cwd === realDirectory(cwd) && lastTrust.trusted);
552
621
  pi.on("session_start", (event, ctx) => {
553
622
  if (event.reason !== "reload") runtime.clearTimings();
554
623
  // Rows restored from the session are drawn next; while idle none of them is running.
555
624
  if (typeof ctx.isIdle === "function") runtime.setBusy(!ctx.isIdle());
556
- configure(pi, ctx.cwd, ctx.isProjectTrusted());
625
+ const trusted = ctx.isProjectTrusted();
626
+ (globalThis as TrustSlot)[TRUST_KEY] = { cwd: realDirectory(ctx.cwd), trusted };
627
+ configure(pi, ctx.cwd, trusted);
557
628
  unbindRenderer(renderContext);
558
629
  renderContext = undefined;
559
630
  if (ctx.mode === "tui" && runtime.config.style !== "off") {
@@ -601,5 +672,7 @@ export default function compactTools(pi: ExtensionAPI): void {
601
672
  unbindRenderer(renderContext);
602
673
  renderContext = undefined;
603
674
  runtime.reset(event.reason !== "reload");
675
+ // A reload that keeps the extension sets it again before Pi rebuilds the transcript.
676
+ clearRowResolver(resolveCustomRow);
604
677
  });
605
678
  }