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 +48 -26
- 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 +115 -19
- package/extensions/compact-tools-palette.ts +6 -7
- package/extensions/compact-tools-release.ts +30 -7
- package/extensions/compact-tools-shell.ts +277 -0
- package/extensions/compact-tools-silent.ts +4 -1
- package/extensions/compact-tools-types.ts +9 -2
- package/extensions/compact-tools.ts +107 -34
- package/package.json +4 -2
- package/release-notes.json +14 -0
- package/schemas/compact-tools.schema.json +3 -3
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# pi-compact-tools
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-

|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
17
|
+
## Styles
|
|
18
18
|
|
|
19
|
-
| |
|
|
20
|
-
|
|
21
|
-
| **
|
|
22
|
-
| **
|
|
23
|
-
| **
|
|
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
|
-
|
|
25
|
+
Silent mode (`Ctrl+'` or `/silent`) works on top of any style. `/compact-tools off` restores Pi's own rows.
|
|
26
26
|
|
|
27
|
-
###
|
|
27
|
+
### Claude
|
|
28
|
+
|
|
29
|
+

|
|
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
|
+

|
|
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
|

|
|
30
47
|
|
|
31
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
+
### Silent mode
|
|
37
54
|
|
|
38
|
-
|
|
39
|
-
- `Bash`, `Update`, `Write`, and custom tools keep their own rows with short previews.
|
|
55
|
+

|
|
40
56
|
|
|
41
|
-
|
|
57
|
+
Only your prompts and the answers. Toggle with `Ctrl+'` or `/silent`; `"mode": "silent"` starts sessions with it on.
|
|
42
58
|
|
|
43
|
-
|
|
59
|
+
## Commands
|
|
44
60
|
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
- `
|
|
66
|
-
- `
|
|
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
|
|
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
|
|
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
|
|
152
|
-
|
|
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
|
+
}
|