@xzzpig/pi-tidy 0.0.0-stage → 0.1.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +1104 -0
  2. package/FORK-CHANGELOG.md +31 -0
  3. package/LICENSE +21 -0
  4. package/README.md +416 -2
  5. package/fork/defaults.ts +77 -0
  6. package/fork/index.ts +2 -0
  7. package/fork/install.ts +58 -0
  8. package/fork/thinking-row.ts +221 -0
  9. package/package.json +68 -3
  10. package/src/clean-copy.ts +313 -0
  11. package/src/clipboard.ts +29 -0
  12. package/src/config.ts +292 -0
  13. package/src/draw/frame-draw.ts +307 -0
  14. package/src/draw/icons.ts +2014 -0
  15. package/src/draw/paint.ts +57 -0
  16. package/src/frame.ts +20 -0
  17. package/src/hover.ts +185 -0
  18. package/src/index.ts +322 -0
  19. package/src/live.ts +91 -0
  20. package/src/messages/bash-mode-frame.ts +112 -0
  21. package/src/messages/error-lines.ts +331 -0
  22. package/src/messages/extension-notice.ts +204 -0
  23. package/src/messages/hex-swatches.ts +245 -0
  24. package/src/messages/message-frame.ts +515 -0
  25. package/src/messages/user-message.ts +207 -0
  26. package/src/theme-access.ts +26 -0
  27. package/src/tools/background-jobs.ts +617 -0
  28. package/src/tools/bash-highlight.ts +330 -0
  29. package/src/tools/codemode-body.ts +602 -0
  30. package/src/tools/result-preview.ts +56 -0
  31. package/src/tools/split-diff.ts +606 -0
  32. package/src/tools/tool-frame.ts +1019 -0
  33. package/src/tools/tool-group-view.ts +1160 -0
  34. package/src/tools/tool-groups.ts +706 -0
  35. package/src/tools/tool-lines.ts +621 -0
  36. package/src/tools/write-preview.ts +81 -0
  37. package/src/turns/code-blocks.ts +273 -0
  38. package/src/turns/turn-lifecycle.ts +43 -0
  39. package/src/turns/turn-state.ts +257 -0
  40. package/src/turns/turn-view.ts +504 -0
@@ -0,0 +1,31 @@
1
+ # Fork Changelog
2
+
3
+ Changes specific to `@xzzpig/pi-tidy` in this repository. Upstream history remains in
4
+ [`CHANGELOG.md`](./CHANGELOG.md).
5
+
6
+ ## 0.1.0 — Initial fork
7
+
8
+ Forked from Andy8647/pi-tidy v0.9.6.
9
+
10
+ - Add omission-only fork runtime defaults in `fork/defaults.ts`; upstream `src/config.ts` and its
11
+ `DEFAULTS` remain unchanged.
12
+ - Add fork-only `frameTools` (default `false`), preserving shared hover tracking for code-block
13
+ copy controls while leaving tool-frame ownership to `pi-tool-display`.
14
+ - Enable `frameMessages` so the selected `userMessageStyle: "frame"` renders. The upstream shares
15
+ this switch with compaction, branch, skill and custom message boxes, so those frames are enabled
16
+ by default too. Keep turn folding, tool grouping, split diffs, bash highlighting, background jobs,
17
+ per-tool colors, and clean-copy disabled by default; users can opt into them with `tidy.*`.
18
+ - Keep user-message style at `frame`; retain the upstream defaults for error deduplication, hex
19
+ swatches, final-reply framing, and code-block sections.
20
+ - Preserve `pi-thinking-collapse`'s completed thinking row outside pi-tidy's final-reply frame, with
21
+ Pi's native click-to-expand/re-collapse behavior intact.
22
+ - Do not modify `pi-tool-display` defaults. When both packages are installed, users who want pi-tidy
23
+ to own user-message rendering must set `enableNativeUserMessageBox` to `false` in
24
+ `~/.pi/extensions/pi-tool-display/config.json`.
25
+ - Do not restore the tool-frame path-copy chip; it lives on a frame that this fork disables by
26
+ default.
27
+ - Guard the group-boundary renderer against nested installs: only the outermost wrapper restyles a
28
+ component and inner wrappers pass through. `pi-group-collapse` installs its render projection
29
+ between pi-tidy's load-time and `before_agent_start` boundary installs; without the guard both
30
+ wrappers remap the same rows and a retry error draws its `error ×2` frame twice.
31
+
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andy8647
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,417 @@
1
- # Temporary Holding Version
1
+ # pi-tidy
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <p align="center">
4
+ <img src="https://raw.githubusercontent.com/Andy8647/pi-tidy/main/assets/cover.png" alt="pi-tidy — a reply's tool calls fold into one line" width="840">
5
+ </p>
6
+
7
+ Tool folding for the [pi](https://github.com/earendil-works/pi) coding agent: a reply's tool calls
8
+ fold into one line you can click open, while edits, failures and background jobs stay in view — and
9
+ every box gets a rounded transparent frame.
10
+
11
+ **▶ [Watch the 40-second demo](https://cdn.jsdelivr.net/gh/Andy8647/pi-tidy@v0.4.3/assets/demo.mp4)**
12
+
13
+ ## Features
14
+
15
+ - **Rounded borders** — ╭╮╰╯ box drawing instead of pi's ┌┐└┘, on a transparent background: no fill, so terminal transparency works
16
+ - **Every tool box** — built-in tools, MCP calls (pi-mcp-adapter), subagents and any other extension's tools get the same frame, a kind icon, and the tool's own border color on success (pending stays grey, errors red)
17
+ - **Turn folding** — a whole run folds to `▸ took 1m20s · LLM 58s · tools 20s · 12 calls · read×6 · bash×4`, and opens to the process it replaced. Steering, retries and continuations stay in the run
18
+ - **Grouped tool calls** — the calls between two pieces of assistant text fold to `▸ 5 calls · bash×3 · 1 failed · 1.2s`: click for a row per call under a `│` gutter, click a row for its output underneath, and each row says what the call produced (`12 lines`, `3 matches`, `exit 1`). A failure is counted once in the summary; only a *running* call keeps a row. Edits and writes never fold — their diff is the point
19
+ - **Every message box** — compaction, branch, skill and extension custom messages get the same frame, each kind its own border color. A message an extension renders itself keeps its rows and loses its fill; pi-subagents' notices are drawn from the message, so the border says `worker needs attention` or `wiki-verifier failed` in that run's own color, one line of the text underneath, a click for the rest
20
+ - **One row per failure** — pi prints its own errors (a failed request, an extension that threw) once per retry attempt, provider body and all; tidy keeps the first row, frames it, and counts the rest as `×N`. Warnings and notes are left alone
21
+ - **User messages that fit** — tidy's rounded frame, as wide as the text, with the user icon and time in its top border; a one-word prompt gets a small box. Or the rail (a rule above and below, an accent `│` in front, the same icon and time in the top rule), or pi's own look
22
+ - **Your own `!` commands** — framed in the user color with the `!` you typed, so a `!` run never passes for the agent's bash box
23
+ - **Thinking, folded in** — a step that only thinks leaves a `thinking` row inside its group (a brain, the word, one line of what the model thought, sliding over the newest words while it streams); the summary counts the runs (`🧠 5 thoughts`), and a click opens that step's thinking full length, in a framed box with `󰧑 thinking` in its border. pi's `hideThinkingBlock` is ignored on purpose: the row is the same either way, and the click is the one way to read it
24
+ - **Background jobs** — a call that starts a pi-processes job shows the job's live state on its row (`◐ process ticker running 12s · READY matched`, then `✓ … exited 0 · 20s`); its `[process]` notifications are folded into that row, the matched log line they carried waits on a row of its own under it (open the group to read it), and a click opens the command, working directory and last 20 output lines without expanding the call
25
+ - **codemode reads as a call list** — opened, the calls a script ran are tidy rows with a name and a duration column; the script and the output each fold to one row
26
+ - **Side-by-side edit diffs** — `old │ new` with line numbers on both sides, a bar and a tint per changed row, the changed word emphasised; below `splitDiffMinWidth` it falls back to pi's unified diff
27
+ - **One-line boxes** — a collapsed box keeps `bashResultLines` rows of output under a `(+N more lines, ctrl+o to expand)` footer, so a long call no longer owns the screen
28
+ - **Nerd Font icons** — a kind icon per box, and the target file's nvim-web-devicons glyph in front of its path (`write /tmp/x.ts` renders ` write 󰛦 /tmp/x.ts`)
29
+ - **Hex colors you can see** — a message that names a color (`#7f849c`, `#f00`, `rgb(127, 132, 156)`, `hsl(240, 10%, 15%)`) gets a `●` painted in that color in front of it, so a palette in prose reads at a glance. Fenced blocks, inline code and thinking keep their text
30
+ - **Bash syntax highlighting** — token-level colors for commands, flags, strings, variables and operators. The script a shell's `-c` takes is highlighted as the code it is, so `bash -c 'set -e` plus five more lines no longer arrives as one flat string colour. Words the grammar cannot name (arguments, paths, globs) take `toolOutput`, so a path-heavy row is not a wall of the terminal's default foreground
31
+ - **Copies without the chrome** — text you select in pi's fullscreen mode goes to the clipboard without frames, corners or the rail around a box. A tool box's border is the exception: the path it names is what you dragged for, so it copies on its own
32
+ - **One-click paths** — a `write` or `edit` box puts a copy chip next to the path in its border (`╭─ 󰛦 src/app.ts 󰆒 ────╮`): click it for the path, no drag, and the chip reads a tick for a moment
33
+ - **Click to fold** — clicks land on pi's own expand toggle, for every tool including `renderShell: "self"` ones; only left clicks are taken, so selecting text still works
34
+ - **Scroll-safe caching** — an unchanged box costs one string comparison per frame
35
+
36
+ ## Turn folding
37
+
38
+ On by default since 0.8.0. To go back, merge these keys into the `tidy` object in
39
+ `~/.pi/agent/settings.json` and run `/reload`:
40
+
41
+ ```json
42
+ { "tidy": { "collapseTurns": "off", "finalReplyStyle": "off" } }
43
+ ```
44
+
45
+ The keys are independent: `collapseTurns: "live"` folds while the run is still arriving, and
46
+ `finalReplyStyle` works without turn folding.
47
+
48
+ A run folds to one line, where it started:
49
+
50
+ ```text
51
+ ▸ took 1m20s · LLM 58s · tools 20s · 12 calls · read×6 · bash×4 · codemode×2
52
+ ```
53
+
54
+ Click it to open the process, click again to fold. The fold hides thinking and `alwaysShowTools`
55
+ boxes, never your own inputs, and reopens with manual expansion, `groupOpen` and output budgets
56
+ intact. `/tidy turn` toggles the latest run, `/tidy turn N` the one at that position on the branch;
57
+ `ctrl+o` still expands tool outputs and their turns. User inputs, pi's error rows and a job
58
+ notification with no row to absorb it stay visible. A reply with no
59
+ hidden process gets no summary. A folded run whose jobs matched log lines says
60
+ so (`· 2 matched`), because the rows carrying those lines are inside the group.
61
+
62
+ `took` is wall time. `LLM` is the main agent's model requests, including response wait. `tools` is
63
+ the union of top-level tool intervals (codemode and MCP waits included, parallel and nested calls
64
+ not counted twice). Retries and overlap mean the parts need not sum to the total; missing historical
65
+ timings show `—`.
66
+
67
+ Opened, the process hangs on a rail under the summary's marker, and the rail runs into the final
68
+ reply's border. A box from `alwaysShowTools` joins the rail of a group that is open right above or
69
+ below it. A run that fails with no text keeps the failure in view when it folds.
70
+
71
+ `finalReplyStyle` draws only a settled final text reply, keeping pi's Markdown and mouse targets —
72
+ an intermediate tool-calling message is never promoted. The border names the model that answered
73
+ (from the message, not the current selection) with its provider glyph, clipped to `…` when it does
74
+ not fit, and `icons: false` drops the glyph.
75
+
76
+ When `pi-thinking-collapse` is installed, this fork keeps Pi's completed `Thinking… (click to expand)`
77
+ row above the final reply frame. Pi's native click still expands and re-collapses the original thought.
78
+
79
+ ## What it looks like
80
+
81
+ A reply's tool calls, folded:
82
+
83
+ <img src="https://raw.githubusercontent.com/Andy8647/pi-tidy/main/assets/folded.png" alt="Five tool calls folded into one summary line" width="840">
84
+
85
+ A background process (pi-processes), tracked on the row that started it:
86
+
87
+ <img src="https://raw.githubusercontent.com/Andy8647/pi-tidy/main/assets/process.png" alt="A background process row with its live state" width="560">
88
+
89
+ Click `▸ 5 calls` and it becomes a `▾` header with one row per call, each step's thinking above it:
90
+
91
+ ```
92
+ ▾ 5 calls · bash×3 read×2 · 4 thoughts · 1 failed · 2.4s
93
+ │ 🧠 thinking 测试挂了,先确认是哪一个断言炸的,再看是不是我们这边的问题
94
+ │ ✓ bash git status --short 4 lines · 0.1s
95
+ │ 🧠 thinking 看一眼改了什么
96
+ │ ✓ read src/app.ts 120 lines · 0.0s
97
+ │ ✗ bash pnpm test exit 1 · 1.8s
98
+ │ 🧠 thinking …
99
+ ```
100
+
101
+ ## xzzpig fork behavior
102
+
103
+ This package is published as `@xzzpig/pi-tidy`, forked from Andy8647's v0.9.6. The upstream
104
+ `loadConfig()` defaults remain unchanged; at extension startup the fork applies these defaults only
105
+ when the corresponding key is omitted:
106
+
107
+ | Setting | Fork default | Notes |
108
+ |---------|--------------|-------|
109
+ | `frameTools` | `false` | Fork-only switch. `pi-tool-display` owns tool frames; set `true` only when you want pi-tidy's frames instead. |
110
+ | `frameMessages` | `true` | Enabled with user-message `frame` because the upstream uses this switch for both; compaction, branch, skill and custom message boxes are therefore framed too. |
111
+ | `collapseTurns` | `"off"` | Group/turn folding remains owned by the other installed extensions. |
112
+ | `groupTools` | `false` | Tool grouping is opt-in. |
113
+ | `splitDiff` | `false` | Uses the host's unified diff by default. |
114
+ | `highlightBash` | `false` | Bash syntax highlighting is opt-in. |
115
+ | `backgroundJobs` | `false` | Job rows are opt-in. |
116
+ | `toolColors` | `{}` | Per-tool border colors are off with tool frames. |
117
+ | `cleanCopy` | `false` | Set `tidy.cleanCopy` to `true` to opt into copying without pi-tidy's frames and rails. |
118
+ | `userMessageStyle` | `"frame"` | pi-tidy owns user-message styling; `rail`, `rules`, and `off` remain available. |
119
+ | `dedupeErrors`, `hexSwatches`, `finalReplyStyle`, `codeBlocks` | upstream defaults | These pi-tidy capabilities remain enabled by default. |
120
+
121
+ To let pi-tidy exclusively style user messages when `pi-tool-display` is installed, set
122
+ `enableNativeUserMessageBox` to `false` in `~/.pi/extensions/pi-tool-display/config.json`:
123
+
124
+ ```json
125
+ { "enableNativeUserMessageBox": false }
126
+ ```
127
+
128
+ This is a user-side prerequisite; this fork does not alter pi-tool-display's upstream default.
129
+ The fork also leaves the upstream tool-frame implementation intact: its `frameTools` switch skips
130
+ that patch while retaining shared hover tracking for code-block copy controls. The path-copy chip
131
+ that appears on a tool-frame border is consequently not available when `frameTools` is off.
132
+
133
+ ## Installation
134
+
135
+ ```bash
136
+ pi install npm:@xzzpig/pi-tidy
137
+ ```
138
+
139
+ Requires pi ≥ 1.1.0 (component mouse events, tool durations); on an older pi it
140
+ says so at session start. Bash highlighting, the bash output budget and the custom
141
+ write preview also need `pi.registerToolRenderer()` (pi 1.3.0). Without that API they are skipped and Tidy
142
+ says so once, rather than falling back to a tool override that would stop another
143
+ extension from loading.
144
+
145
+ Or straight from this repository:
146
+
147
+ ```bash
148
+ pi install ./packages/pi-tidy
149
+ ```
150
+
151
+ Restart pi to activate.
152
+
153
+ ### Coming from pi-toolbox
154
+
155
+ pi-tidy is `@andy8647/pi-toolbox` renamed (from 0.4.2). Swap the package, and
156
+ remove the old one first so the two aren't loaded side by side:
157
+
158
+ ```bash
159
+ pi remove npm:@andy8647/pi-toolbox
160
+ pi install npm:pi-tidy
161
+ ```
162
+
163
+ Your `"toolbox"` settings keep working; rename the key to `"tidy"` when convenient.
164
+
165
+ The old package re-registered `bash` and `write` as its own tools so it could draw
166
+ the highlighted command row and the write preview. pi treats a second registration
167
+ under a built-in name as a hard failure, so Tidy could not be loaded alongside an
168
+ extension that sandboxes or gates those tools, in either load order. Since 0.4.5
169
+ both previews travel as `pi.registerToolRenderer()` resolvers instead, which is why
170
+ there is nothing left to conflict with (see [How it works](#how-it-works)).
171
+
172
+ ## Releasing
173
+
174
+ `.github/workflows/publish.yml` publishes to npm on a published GitHub Release,
175
+ using npm's Trusted Publisher (OIDC) — no `NPM_TOKEN` secret. Bump `version` in
176
+ `package.json`, tag `vX.Y.Z`, and publish the release; the workflow fails fast
177
+ if the tag and the manifest version disagree.
178
+
179
+ ## Configuration
180
+
181
+ Add keys to the `tidy` object in `~/.pi/agent/settings.json`, then run `/reload`. A key you leave
182
+ out keeps its default:
183
+
184
+ ```json
185
+ { "tidy": { "collapseTurns": "off", "userMessageStyle": "rail", "bashResultLines": 3 } }
186
+ ```
187
+
188
+ Colors are theme color names, so they follow the active theme. `thinkingColor`, `userMessageColors`
189
+ and `messageBorderColors.assistant` also take `#rrggbb`.
190
+
191
+ ### General
192
+
193
+ | Key | Default | What it does | When it applies |
194
+ |-----|---------|--------------|-----------------|
195
+ | `enabled` | `true` | Turns the whole extension on or off | Always |
196
+ | `icons` | `true` | Nerd Font icons on boxes, rows and message labels, and the copy chips (a glyph instead of the word `copy`) | Always. Off, a user message loses its label, so its time goes too |
197
+
198
+ ### User messages
199
+
200
+ | Key | Default | What it does | When it applies |
201
+ |-----|---------|--------------|-----------------|
202
+ | `userMessageStyle` | `"frame"` | `frame`: a rounded frame as wide as the text, with the user icon and time in its top border. `rail`: a full-width rule above and below, an accent `│` in front of every row, and the icon and time in the top rule. `rules`: the same without the `│`, so a terminal's own selection copies the text without any verticals. `off`: pi's own look | `frame` needs `frameMessages`; without it you get pi's own look. `rail` and `rules` do not need it |
203
+ | `userMessageAlign` | `"left"` | `"left"` or `"right"`. Long messages use the whole width unless `userMessageMaxWidth` caps it | `frame` moves the message and its label. `rail` and `rules` move only the label, so they need `icons`. `off` ignores it |
204
+ | `userMessageMaxWidth` | `"auto"` | The widest a framed message's text gets before it wraps, so the frame hugs the narrower column: `"auto"` is the whole width, `0.7` is a fraction of it, `88` is a column count. Anything else falls back to `"auto"` | `userMessageStyle: "frame"`. A label wider than the cap still widens the box to fit itself |
205
+ | `userMessageTime` | `true` | Shows when you sent a message (`14:06`, with the date when it is not from today). The final reply's border keeps its own time | Needs `icons`, and any `userMessageStyle` but `off` |
206
+ | `userMessagePaddingY` | `1` | Rows between each rule and the text, `0` to `3` | `userMessageStyle` `rail` or `rules` |
207
+ | `userMessageColors` | `{ "rail": "accent", "rule": "borderMuted" }` | Colors of the `│` and the icon (`rail`), and the rules (`rule`) | `userMessageStyle` `rail` or `rules`. Under `rail`, `rail` also colors your `!` commands' frames |
208
+
209
+ ### Final reply and turn folding
210
+
211
+ | Key | Default | What it does | When it applies |
212
+ |-----|---------|--------------|-----------------|
213
+ | `collapseTurns` | `"afterRun"` | Folds a whole run's process to one line. `afterRun` waits until the run settles, `live` folds while it runs and keeps the latest assistant text visible, `off` never folds | Always, with grouping on or off |
214
+ | `finalReplyStyle` | `"frame"` | The final assistant reply, with the provider glyph, model name and time in its top edge. `frame`: a full-width rounded frame. `rail`: a full-width rule above and below and a `│` in front of every row. `rules`: only the rules, so a terminal's own selection copies the text without any verticals. `off`: pi's own look. `frameFinalReply` from before 0.9 still works: `true` is `frame`, `false` is `off` | Once the run settles, and only on a final text reply. Independent of `collapseTurns` and `frameMessages` |
215
+ | `messageTimeFormat` | `"auto"` | The time in a message's top edge. `auto` is the clock, with the date when the message is not from today. Anything else is a pattern of `YYYY`, `M`, `MM`, `D`, `DD`, `H`, `HH`, `mm`, `ss` and literal text, such as `"M月D日 HH:mm:ss"`. Those letters are always read as tokens, so a word like `Mon` will not survive | Wherever a time is shown: a final reply drawn by `finalReplyStyle`, and a user message under `userMessageTime` |
216
+ | `messageHeaderStyle` | `"inline"` | How a message's top edge sets out who and when. `inline`: `╭─ model 13:09 ───╮`. `split`: `╭── model ── 13:09 ───╮` | Where a label is drawn: a final reply drawn by `finalReplyStyle`, and a user message with `icons`. With no time to separate, or on an edge too narrow for it, `split` looks like `inline` |
217
+
218
+ See [Turn folding](#turn-folding) for what a fold keeps and how to open it.
219
+
220
+ ### Other message boxes
221
+
222
+ | Key | Default | What it does | When it applies |
223
+ |-----|---------|--------------|-----------------|
224
+ | `frameMessages` | `true` | Frames compaction, branch, skill and custom messages, and your own `!` / `!!` commands. Also allows `userMessageStyle: "frame"` | A custom message drawn by another extension's own renderer keeps its rows inside the frame and loses its fill (see below). Does not affect the rail, the final reply or tool boxes |
225
+ | `messageBorderColors` | `{ "assistant": "accent", "user": "toolTitle", "compaction": "customMessageLabel", "branch": "mdCode", "skill": "accent", "custom": "warning" }` | Border color per message kind, merged over the defaults | `assistant` colors the final reply's frame and an opened run's rail and `▾` marker, and needs `finalReplyStyle` or `collapseTurns`. The rest need `frameMessages`. `user` colors a `frame` message and your `!` commands; under `rail`, those commands use `userMessageColors.rail` |
226
+
227
+ ### Tool folding
228
+
229
+ | Key | Default | What it does | When it applies |
230
+ |-----|---------|--------------|-----------------|
231
+ | `groupTools` | `true` | Folds the tool calls between two assistant text blocks (or a user message) into one summary line. Click it for one row per call, click a row for its output, click the `▾` header to fold. `false` gives every call its own box (a `codemode` box still reads as its call list) | Always |
232
+ | `groupOpen` | `false` | Starts each new group open (`▾` header, one row per call). Only the level a group is born with: clicking the header or `ctrl+o`'s collapse-all stays folded, and nothing is saved | Needs `groupTools` |
233
+ | `toolRowLines` | `1` | Rows a call in an open group may take. With `2`, a call that does not fit goes on to a second row. Calls that fit keep one | Needs `groupTools` |
234
+ | `alwaysShowTools` | `["edit", "write"]` | Tools that never fold: they keep their own box and end the group before them. `[]` folds edits and writes like everything else | Needs `groupTools`; with it off every tool has its own box anyway. A folded turn still hides these boxes |
235
+
236
+ ### Tool output
237
+
238
+ | Key | Default | What it does | When it applies |
239
+ |-----|---------|--------------|-----------------|
240
+ | `highlightBash` | `true` | Syntax-highlights bash commands, including the script a shell's `-c` takes | Needs pi 1.3 |
241
+ | `bashResultLines` | `1` | Visual lines of output in a collapsed bash box. pi's own number is 5, with no setting. `0` hides the output until you expand | Needs pi 1.3. A failed command keeps pi's preview. `ctrl+o` shows everything |
242
+ | `writeLines` | `10` | Visual lines of a written file in a collapsed box. `0` keeps just the header | Needs pi 1.3. `ctrl+o` shows everything |
243
+ | `splitDiff` | `true` | Draws an edit's diff as two panes (`old │ new`) with line numbers, a bar and a tinted row per changed line. Also decides whether a reply's `diff` code block gets panes. `false` keeps pi's unified diff | `edit` boxes at least `splitDiffMinWidth` wide, and `diff` code blocks on a row that wide; narrower ones get the unified form. An `edit` another extension registers (aft's hashline mode, for one) gets the panes too, whenever its result carries the `diff` |
244
+ | `splitDiffMinWidth` | `72` | Box width below which the panes give way to pi's unified diff | Needs `splitDiff` |
245
+ | `diffLines` | `20` | Diff rows in a collapsed edit box, starting a third of the budget above the first change, with `… (N more lines, M total, ctrl+o to expand)` under them. `0` keeps just the summary row | Only when the panes are drawn. pi's unified diff has no limit, so with `splitDiff: false` or in a narrower box a long diff is shown whole |
246
+ | `codeBlocks` | `true` | Draws a reply's fenced blocks as a section: the two fence rows become rules, the opening one labelled with its language and a copy chip (`├─ fish 󰆒 ────┤`), and they join the reply's frame or rail at the edges. The code rows are pi's own, apart from the chip: it lights up and the pointer turns into a hand while it is under it (needs a terminal without tmux, where pi tracks the pointer), and shows a tick for a moment after a click puts the block's text on the clipboard. A `diff` block goes side by side instead, under the same switch and width as an edit's diff, and languages highlight.js has no grammar for (`fish`, `.env`, TOML) get the closest one it does. `false` leaves pi's own fences | Assistant replies that tidy draws (needs `collapseTurns` or `finalReplyStyle`). A pi whose Markdown has no `renderToken` keeps its own code blocks, and tidy says so once at session start. With `icons: false` the chip is the word `copy` instead |
247
+
248
+ ### Colors
249
+
250
+ | Key | Default | What it does | When it applies |
251
+ |-----|---------|--------------|-----------------|
252
+ | `toolColors` | `{ "bash": "bashMode", "read": "toolTitle", "edit": "syntaxVariable", "write": "syntaxVariable", "grep": "syntaxOperator", "find": "syntaxOperator", "ls": "syntaxOperator", "mcp": "customMessageLabel" }` | Border color per tool, on success. Pending stays grey, errors red, unlisted tools take `accent`. The map **replaces** the defaults, so `{}` turns per-tool colors off | Tool boxes only, not the rows in a group |
253
+ | `thinkingColor` | `"warning"` | Color of the brain, the `N thoughts` count and an expanded thinking box | Always, with grouping on or off |
254
+ | `hexSwatches` | `true` | Puts a `●` in front of every color a message names, painted with that color (`● #7f849c`). Hex (3 to 8 digits, alpha dropped), `rgb()` / `rgba()` and `hsl()` / `hsla()` (spaces or commas) all count; named CSS colors do not, since `red` is an ordinary word. Fenced blocks, inline code and thinking are left alone | Every message whose text tidy draws. `false` leaves the markdown exactly as pi rendered it |
255
+
256
+ Tool boxes keep green, red and grey for a call's state, so the default message colors avoid them:
257
+ mauve for compactions, peach for branch summaries, sky for skills and yellow for extension notices.
258
+
259
+ ### Background jobs
260
+
261
+ | Key | Default | What it does | When it applies |
262
+ |-----|---------|--------------|-----------------|
263
+ | `backgroundJobs` | `true` | A call that started a job shows the job's live state on its row and in the summary. A running job keeps its row while folded; a finished one folds away. The spinner turns once a second for a job's first 5 minutes, then stands still | Needs pi-processes and `groupTools`: the state lives on a group's row, so a tool in `alwaysShowTools` has none to show it on |
264
+ | `hideJobNotifications` | `true` | Stops drawing a job's notifications (`[process] "ticker" matched stdout: READY`) once its row shows them. What the row cannot hold — the matched log line — lands on a row under the job inside its group, and a folded turn counts them (`· 2 matched`). The model still receives every message | Needs `backgroundJobs` and `groupTools`. A notification whose job has no row yet (another session, before a `/reload` re-renders it, pi-processes not loaded) keeps its own block, so nothing is lost |
265
+
266
+ ### Other
267
+
268
+ | Key | Default | What it does | When it applies |
269
+ |-----|---------|--------------|-----------------|
270
+ | `dedupeErrors` | `true` | pi prints its own error rows (a failed request, an extension that threw) once per retry attempt. Folds the repeats into the first as `×N` and frames that row (`╭─ error ×3 ─╮`). pi's "Retry failed after N attempts" replaces it when pi gives up | Error rows only. Warnings, notes and an abort stay as pi drew them |
271
+ | `cleanCopy` | `true` | Takes tidy's frames, corners, rail glyphs and rules out of what you copy, on select or with the copy key. A top border's label stays when it names a file — a `write` or `edit` box's path is what the drag was for | pi's fullscreen selection only. A terminal's own selection (shift or option and drag) never reaches tidy. A `rules` user message with `icons: false` has no label to tell its rules from a model's, so they stay |
272
+
273
+ ## How it works
274
+
275
+ pi builds one `ToolExecutionComponent` per tool call, no matter which extension
276
+ registered the tool. pi-tidy patches that component's `render` to frame the whole
277
+ box — which is why MCP and subagent boxes are framed too. Message boxes (compaction, branch
278
+ summary, skill invocation, extension custom message) never go through that
279
+ component; their own prototypes get the same treatment. Their `[compaction]` /
280
+ `[branch]` / `[skill]` / `[type]` row is a title, so it sits in the top border
281
+ next to the kind icon and the spacer under it is gone; a collapsed skill box
282
+ keeps its label inline, where it shares the row with the skill's name.
283
+
284
+ A custom message an extension renders itself is framed around its rows: the
285
+ rows are the extension's, so nothing here knows where a label belongs in them,
286
+ but the frame is the transcript's — its background fill is stripped, its blank
287
+ edges are trimmed, its kind goes in the top border, and a click maps back
288
+ through the border to the component that drew it. pi-subagents' notices go one
289
+ step further and are drawn from the message instead of the component at all:
290
+ the border carries the subagent's name and what happened to it
291
+ (`╭─ wiki-verifier failed ─╮`, red for a failure, yellow for a stall), the
292
+ message's own text is the body with the title line it repeats taken out, one row
293
+ of it shows until a click, and the type's own `(ctrl+o to expand)` is gone. The
294
+ label comes from `details` — `runs[].agent` and `runs[].status` for a completion
295
+ notice, `event.agent` and `event.type` for a control notice, and the same fields
296
+ on the eleven other types wherever they exist, so a notice that names nobody is
297
+ still drawn, with the glyph alone. Session *entries* (pi-subagents' `reply sent
298
+ to …`) are not covered: they render through `CustomEntryComponent`, which pi does
299
+ not export, and the entry renderer registry answers whichever extension
300
+ registered first.
301
+
302
+ `!` bash-mode runs are framed as the user's
303
+ rather than as a tool's: the agent's bash box changes color with its state,
304
+ while the user's keeps the user messages' color (`messageBorderColors.user`, or
305
+ `userMessageColors.rail` under the rail) and lets pi's own rows (`Running...`,
306
+ `(exit 1)`) say how it went. Both user message styles render pi's own Markdown
307
+ child narrower and swap only its chrome, so markdown
308
+ transformers registered by other extensions (pi-ide-context's selection
309
+ references, for one) keep working. With pi-starline 0.9 and later, tidy is the
310
+ only extension that styles user messages; earlier pi-starline versions draw
311
+ their own over it.
312
+
313
+ Tidy registers **no tools at all**. Everything it changes about `bash` and `write`
314
+ (the highlighted command row, the collapsed output budgets, the write preview)
315
+ travels as a `pi.registerToolRenderer()` resolver, so the tool that is actually
316
+ installed keeps its execution, schema, shell and result renderer, and `next()`
317
+ hands Tidy whatever is downstream — pi's built-in renderers when an extension
318
+ defines none. Registering a tool under a built-in name is a hard failure in pi
319
+ (`Tool "write" conflicts with …`), which is why an extension that sandboxes
320
+ `bash`/`write` used to be mutually exclusive with Tidy no matter which loaded
321
+ first. It no longer is (issue #7, PR #8 and the bash follow-up).
322
+
323
+ An unchanged box returns its cached line array outright: pi re-renders the
324
+ whole transcript on every frame, including every mouse packet while scrolling.
325
+ Measured against unpatched pi at 300 boxes / 2400 lines: +0.03 ms per full
326
+ render.
327
+
328
+ pi's own error rows — `Error: 429: …`, an extension that threw — have no
329
+ patchable prototype: they are `ThemedText`, which pi does not export. That seam
330
+ is the chat container instead. After `InteractiveMode.init`, its `addChild` is
331
+ wrapped: a row painted in the error colour that repeats the previous one folds
332
+ into it as `×N`, and the survivor gets the frame. If a future pi renames `init`,
333
+ the hook stays uninstalled and those rows render exactly as pi draws them — the
334
+ behaviour before 0.7.2, not a crash.
335
+
336
+ ### Grouping
337
+
338
+ Groups are derived at render time from transcript order — nothing is
339
+ persisted, so a reloaded session folds the same way. A boundary is a user
340
+ message or an assistant message with visible text; pi gives every model step its
341
+ own assistant message, and steps that only call a tool don't split a group.
342
+ `ctrl+o` and pi's own box clicks both arrive through `setExpanded`, which is how
343
+ the group levels stay in step with pi's per-box expand flag.
344
+
345
+ ### Background jobs
346
+
347
+ pi has no notion of a job that outlives its tool call, so `src/tools/background-jobs.ts`
348
+ has a small provider interface. pi-processes is the one provider: its
349
+ `processes:*` events on pi's shared extension bus keep a row live, its persisted
350
+ `ad-process:notification` messages rebuild a reloaded session, and a process it
351
+ no longer knows reads `not running`. A log match's line (the part its row cannot
352
+ hold) is kept per job and drawn as a row under it inside the group; the message
353
+ that carried it is not drawn. If that protocol changes, rows fall back to
354
+ plain tool rows.
355
+
356
+ ### Mouse
357
+
358
+ Clicks come from pi's own fullscreen mode (`tuiMode: "fullscreen"`, pi ≥ 0.86
359
+ component mouse events); pi-tidy never turns on mouse reporting itself. Only left
360
+ clicks on its own rows are taken — presses and drags pass through, so text
361
+ selection works as usual. Without fullscreen there are no clicks: `ctrl+o` still
362
+ toggles every box.
363
+
364
+ A selection in the transcript copies without tidy's chrome (`cleanCopy`). A frame is
365
+ recognised by its shape: rounded corners of equal width in one column, with a vertical
366
+ on both sides of every row. A table's square corners and a blockquote's `│` stay. So
367
+ does the rail beside an open run's rows, which a selection cannot tell from a quote,
368
+ and a rounded box a model drew in an unframed message is taken for a frame. A top
369
+ border's label survives when it names a file: dragging the path out of a tool box's
370
+ border copies the path, and `ctrl+x` on that selection has something to hand over.
371
+
372
+ A `write` or `edit` box's path also has a chip in the border, one click away from the
373
+ clipboard, and the same on a reply's code block (see `codeBlocks`).
374
+
375
+ A row you can click lights up under the pointer, and the pointer turns into a hand
376
+ while it is on one (OSC 22: Ghostty, kitty, foot and WezTerm draw it; other
377
+ terminals ignore it). Under tmux, zellij or screen pi only reports motion with a button
378
+ held, so there is no hover there.
379
+
380
+ The transcript's scrollbar is pi's, and with `fullscreenScrollbar: "auto"` (the
381
+ default) pi paints it over the rightmost column — the one these frames put their
382
+ border in, so it covers the border while it is on screen. Set `"always"` and pi
383
+ reserves that column instead, drawing the frames one cell narrower; `"hidden"`
384
+ works too if you never want it. Nothing to configure on this side either way.
385
+
386
+ ## Render benchmark
387
+
388
+ PRs include a **Render benchmark** check. Its Job Summary compares the PR's base and head
389
+ commits on the same runner using the same fixture and installed dependencies. It reports
390
+ median and P95 milliseconds per frame at 100, 500 and 1,500 components: folded, runs folded,
391
+ and with all tool outputs expanded. At 500 and 1,500 it also measures opened runs on the rail,
392
+ with two-row calls and with large groups. Raw samples are attached as an artifact. Timing
393
+ changes do not fail CI.
394
+
395
+ The fixture uses real pi components with synthetic user messages, thinking, tool groups,
396
+ failures, edit diffs and codemode calls. It never executes tools or contacts a model.
397
+ Measurements cover warm transcript rendering, not terminal drawing or end-to-end scroll latency.
398
+ Identical code can differ a lot between processes, so each revision runs in four workers and the
399
+ table shows their range: a change whose ranges overlap is not evidence of anything. For a local
400
+ comparison, run `pnpm bench /path/to/base-checkout`
401
+ from the PR checkout. Both checkouts must resolve the PR checkout's installed dependencies.
402
+
403
+ ## Compatible themes
404
+
405
+ For the best transparent experience, set tool background colors to `""` in your theme:
406
+
407
+ ```json
408
+ {
409
+ "toolPendingBg": "",
410
+ "toolSuccessBg": "",
411
+ "toolErrorBg": ""
412
+ }
413
+ ```
414
+
415
+ ## License
416
+
417
+ MIT
@@ -0,0 +1,77 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { loadConfig, type TidyConfig } from "../src/config.ts";
6
+
7
+ export interface ForkTidyConfig extends TidyConfig {
8
+ /** Draw pi-tidy's own tool frames. The fork leaves them to pi-tool-display by default. */
9
+ frameTools: boolean;
10
+ }
11
+
12
+ export type ForkSettingsFile = {
13
+ tidy?: RecordLike;
14
+ toolbox?: RecordLike;
15
+ };
16
+
17
+ type RecordLike = Record<string, unknown>;
18
+
19
+ function asRecord(value: unknown): RecordLike | undefined {
20
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as RecordLike) : undefined;
21
+ }
22
+
23
+ function readSettings(): ForkSettingsFile | undefined {
24
+ try {
25
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
26
+ const parsed: unknown = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8"));
27
+ const root = asRecord(parsed);
28
+ return root ? { tidy: asRecord(root.tidy), toolbox: asRecord(root.toolbox) } : undefined;
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ function tidyOptions(settings: ForkSettingsFile | undefined): RecordLike {
35
+ return settings?.tidy ?? settings?.toolbox ?? {};
36
+ }
37
+
38
+ /**
39
+ * Apply fork defaults without mutating the upstream config or overriding an
40
+ * explicitly configured value. Upstream loadConfig() has already resolved and
41
+ * validated the user's values; the raw settings are only used to distinguish
42
+ * an omitted key from an upstream default.
43
+ */
44
+ export function applyForkDefaults(
45
+ config: TidyConfig,
46
+ settings: ForkSettingsFile | undefined = readSettings(),
47
+ ): ForkTidyConfig {
48
+ const userOptions = tidyOptions(settings);
49
+ const configuredOr = <K extends keyof TidyConfig>(key: K, fallback: TidyConfig[K]): TidyConfig[K] =>
50
+ Object.hasOwn(userOptions, key) ? config[key] : fallback;
51
+
52
+ return {
53
+ ...config,
54
+ frameTools: typeof userOptions.frameTools === "boolean" ? userOptions.frameTools : false,
55
+ highlightBash: configuredOr("highlightBash", false),
56
+ frameMessages: configuredOr("frameMessages", true),
57
+ userMessageStyle: configuredOr("userMessageStyle", "frame"),
58
+ collapseTurns: configuredOr("collapseTurns", "off"),
59
+ finalReplyStyle:
60
+ Object.hasOwn(userOptions, "finalReplyStyle") || Object.hasOwn(userOptions, "frameFinalReply")
61
+ ? config.finalReplyStyle
62
+ : "frame",
63
+ groupTools: configuredOr("groupTools", false),
64
+ backgroundJobs: configuredOr("backgroundJobs", false),
65
+ dedupeErrors: configuredOr("dedupeErrors", true),
66
+ cleanCopy: configuredOr("cleanCopy", false),
67
+ splitDiff: configuredOr("splitDiff", false),
68
+ codeBlocks: configuredOr("codeBlocks", true),
69
+ hexSwatches: configuredOr("hexSwatches", true),
70
+ toolColors: configuredOr("toolColors", {}),
71
+ };
72
+ }
73
+
74
+ /** Load the upstream configuration and apply the fork's omission-only defaults. */
75
+ export function loadForkConfig(): ForkTidyConfig {
76
+ return applyForkDefaults(loadConfig());
77
+ }
package/fork/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { applyForkDefaults, loadForkConfig, type ForkSettingsFile, type ForkTidyConfig } from "./defaults.ts";
2
+ export { installForkToolFrames, type ForkToolFrameInstallers } from "./install.ts";
@@ -0,0 +1,58 @@
1
+ import { InteractiveMode, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { patchToolBoxFrames, type ToolFrameOptions } from "../src/frame.ts";
3
+ import { installHoverTracking } from "../src/hover.ts";
4
+ import type { ForkTidyConfig } from "./defaults.ts";
5
+ import { installForkThinkingRows } from "./thinking-row.ts";
6
+
7
+ export interface ForkToolFrameInstallers {
8
+ patchToolBoxFrames(options: ToolFrameOptions): void;
9
+ installHoverTracking(): void;
10
+ }
11
+
12
+ const upstreamInstallers: ForkToolFrameInstallers = { patchToolBoxFrames, installHoverTracking };
13
+ let errorHookWarningSent = false;
14
+
15
+ function warnIfErrorHookIsUnavailable(pi: ExtensionAPI, config: ForkTidyConfig): void {
16
+ if (!config.dedupeErrors) return;
17
+ const init = (InteractiveMode.prototype as { init?: unknown }).init;
18
+ if (typeof init === "function") return;
19
+
20
+ pi.on("session_start", (_event, ctx) => {
21
+ if (errorHookWarningSent) return;
22
+ errorHookWarningSent = true;
23
+ ctx.ui.notify("pi-tidy: this pi has no InteractiveMode.init hook; chat-row retry errors keep the host's original rendering.", "warning");
24
+ });
25
+ }
26
+
27
+ function toolFrameOptions(config: ForkTidyConfig): ToolFrameOptions {
28
+ return {
29
+ icons: config.icons,
30
+ toolColors: config.toolColors,
31
+ groupTools: config.groupTools,
32
+ groupOpen: config.groupOpen,
33
+ alwaysShowTools: config.alwaysShowTools,
34
+ backgroundJobs: config.backgroundJobs,
35
+ hideJobNotifications: config.hideJobNotifications,
36
+ splitDiff: config.splitDiff,
37
+ splitDiffMinWidth: config.splitDiffMinWidth,
38
+ diffLines: config.diffLines,
39
+ thinkingColor: config.thinkingColor,
40
+ highlightBash: config.highlightBash,
41
+ toolRowLines: config.toolRowLines,
42
+ };
43
+ }
44
+
45
+ /** Install tool frames only when opted in, but keep the shared hover hook for code-block chips. */
46
+ export function installForkToolFrames(
47
+ config: ForkTidyConfig,
48
+ pi: ExtensionAPI,
49
+ installers: ForkToolFrameInstallers = upstreamInstallers,
50
+ ): void {
51
+ warnIfErrorHookIsUnavailable(pi, config);
52
+ if (config.enabled && config.finalReplyStyle !== "off") {
53
+ // index.ts registers pi-tidy's own boundary installer first, so this runs after it.
54
+ pi.on("before_agent_start", () => installForkThinkingRows());
55
+ }
56
+ if (config.frameTools) installers.patchToolBoxFrames(toolFrameOptions(config));
57
+ else installers.installHoverTracking();
58
+ }