pi-zen 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.
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/extensions/zen.ts +200 -0
- package/package.json +62 -0
- package/src/backgroundless-theme.ts +158 -0
- package/src/builtin-render.ts +69 -0
- package/src/call-group.ts +190 -0
- package/src/compact-tools.ts +565 -0
- package/src/display-path.ts +23 -0
- package/src/edit-diff.ts +169 -0
- package/src/editor-rail.ts +68 -0
- package/src/markdown-compaction.ts +40 -0
- package/src/silent-header.ts +15 -0
- package/src/thinking-tail.ts +51 -0
- package/src/tool-output.ts +184 -0
- package/src/tool-row.ts +124 -0
- package/src/working-indicator.ts +29 -0
- package/src/zen-editor.ts +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roshvan
|
|
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
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# pi-zen
|
|
2
|
+
|
|
3
|
+
I built `pi-zen` because I wanted a minimal interface that made it easier to stay present in an agent session. I did not want to keep scrolling through long stretches of context to find the few details I needed; I wanted a clean way to follow along, ask questions, and steer and ride the loop.
|
|
4
|
+
|
|
5
|
+
For real knowledge work, I like being an active participant. I want to understand what is happening between the AI and me so we can be good partners in making decisions. `pi-zen` gives me that space: clear responses, visible progress, and the useful parts of the session in a calm, visually appealing interface.
|
|
6
|
+
|
|
7
|
+
`pi-zen` is a presentation extension for the [Pi](https://pi.dev) terminal interface. It removes visual clutter while preserving conversations, tool activity, reasoning, and session data. It adds compact tool summaries and diffs, a minimal editor rail, a quieter startup, and a calmer working indicator without changing tool execution or model prompts.
|
|
8
|
+
|
|
9
|
+
## Showcase
|
|
10
|
+
|
|
11
|
+
### Light mode
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
### Dark mode
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
### Coding cat demo
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
Install the extension from npm:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pi install npm:pi-zen
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
You can also install the latest version directly from GitHub with `pi install git:github.com/Roshvan/pi-zen`. Start Pi as usual, then run `/zen` to toggle Zen mode.
|
|
32
|
+
|
|
33
|
+
## Development
|
|
34
|
+
|
|
35
|
+
You will need Node.js 22.19 or newer and pnpm. Clone the repository, install the dependencies, and start Pi with the local extension:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
git clone https://github.com/Roshvan/pi-zen.git
|
|
39
|
+
cd pi-zen
|
|
40
|
+
pnpm install
|
|
41
|
+
pnpm dev
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Before submitting a change, run `pnpm check` and `pnpm pack:check`.
|
|
45
|
+
|
|
46
|
+
## Issues and contributions
|
|
47
|
+
|
|
48
|
+
Issues and pull requests are welcome. If you have an idea, find a bug, or want to improve something, feel free to open an issue or create a pull request. I am happy to look it over.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
type ExtensionCommandContext,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
SettingsManager,
|
|
6
|
+
type Theme,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
BACKGROUNDLESS_THEME_NAME,
|
|
11
|
+
snapshotTheme,
|
|
12
|
+
withoutContentBackgrounds,
|
|
13
|
+
} from "../src/backgroundless-theme.ts";
|
|
14
|
+
import { CallGrouper } from "../src/call-group.ts";
|
|
15
|
+
import { type BuiltinToolOptions, registerCompactTools } from "../src/compact-tools.ts";
|
|
16
|
+
import { squeezeBlankLines } from "../src/markdown-compaction.ts";
|
|
17
|
+
import { installBlankHeader } from "../src/silent-header.ts";
|
|
18
|
+
import { thinkingTail, thinkingTailLineBudget } from "../src/thinking-tail.ts";
|
|
19
|
+
import { installQuietIndicator, restoreDefaultIndicator } from "../src/working-indicator.ts";
|
|
20
|
+
import { ZenEditor } from "../src/zen-editor.ts";
|
|
21
|
+
|
|
22
|
+
/** What Zen is doing in the current session. */
|
|
23
|
+
type ZenState =
|
|
24
|
+
| { readonly kind: "off" }
|
|
25
|
+
| {
|
|
26
|
+
readonly kind: "on";
|
|
27
|
+
readonly previousEditor: ReturnType<ExtensionContext["ui"]["getEditorComponent"]>;
|
|
28
|
+
/** The active theme before Zen removed its content backgrounds. */
|
|
29
|
+
readonly previousTheme: Theme;
|
|
30
|
+
/** What `quietStartup` was set to before Zen claimed it. */
|
|
31
|
+
readonly previousQuietStartup: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function settingsFor(ctx: ExtensionContext): SettingsManager {
|
|
35
|
+
return SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toolOptions(ctx: ExtensionContext): BuiltinToolOptions {
|
|
39
|
+
const settings = settingsFor(ctx);
|
|
40
|
+
return {
|
|
41
|
+
autoResizeImages: settings.getImageAutoResize(),
|
|
42
|
+
shellCommandPrefix: settings.getShellCommandPrefix(),
|
|
43
|
+
shellPath: settings.getShellPath(),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Zen: a quieter Pi TUI.
|
|
49
|
+
*
|
|
50
|
+
* The extension owns the presentation layer only — chrome, editor frame, working
|
|
51
|
+
* indicator, and how tool calls read. Nothing here changes what Pi executes,
|
|
52
|
+
* what the model sees, or what a session records.
|
|
53
|
+
*
|
|
54
|
+
* There is one switch. `/zen on` is every part of it and `/zen off` is none of
|
|
55
|
+
* it, because a quiet interface you have to configure is not one.
|
|
56
|
+
*
|
|
57
|
+
* @param pi - The extension API.
|
|
58
|
+
*/
|
|
59
|
+
export default function zen(pi: ExtensionAPI): void {
|
|
60
|
+
let state: ZenState = { kind: "off" };
|
|
61
|
+
let toolsRegistered = false;
|
|
62
|
+
let deferredReloadInstall: ReturnType<typeof setTimeout> | undefined;
|
|
63
|
+
let deferredThemeProjection: ReturnType<typeof setTimeout> | undefined;
|
|
64
|
+
let activeContext: ExtensionContext | undefined;
|
|
65
|
+
const grouper = new CallGrouper();
|
|
66
|
+
|
|
67
|
+
// Installing is idempotent on purpose: another extension can take the header
|
|
68
|
+
// or the editor at any time, so `/zen on` has to be able to claim them back.
|
|
69
|
+
const install = (ctx: ExtensionContext) => {
|
|
70
|
+
if (ctx.mode !== "tui") return;
|
|
71
|
+
|
|
72
|
+
const previousEditor = state.kind === "on" ? state.previousEditor : ctx.ui.getEditorComponent();
|
|
73
|
+
let previousTheme = state.kind === "on" ? state.previousTheme : snapshotTheme(ctx.ui.theme);
|
|
74
|
+
if (ctx.ui.theme.name !== BACKGROUNDLESS_THEME_NAME) {
|
|
75
|
+
previousTheme = snapshotTheme(ctx.ui.theme);
|
|
76
|
+
ctx.ui.setTheme(withoutContentBackgrounds(ctx.ui.theme));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Pi reads quietStartup before extensions load, so this is the one thing Zen
|
|
80
|
+
// cannot do for the session it is asked in — it takes effect next launch.
|
|
81
|
+
const settings = settingsFor(ctx);
|
|
82
|
+
const previousQuietStartup = state.kind === "on" ? state.previousQuietStartup : settings.getQuietStartup();
|
|
83
|
+
if (!settings.getQuietStartup()) settings.setQuietStartup(true);
|
|
84
|
+
|
|
85
|
+
installBlankHeader(ctx);
|
|
86
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => new ZenEditor(tui, theme, keybindings));
|
|
87
|
+
installQuietIndicator(ctx.ui);
|
|
88
|
+
state = { kind: "on", previousEditor, previousTheme, previousQuietStartup };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Pi has no public theme-change event for extensions. Markdown transformers
|
|
92
|
+
// do rerun when the transcript redraws, so they can notice a newly selected
|
|
93
|
+
// theme and project it on the next task without touching content.
|
|
94
|
+
const projectChangedThemeSoon = () => {
|
|
95
|
+
const ctx = activeContext;
|
|
96
|
+
if (ctx === undefined || state.kind === "off" || ctx.ui.theme.name === BACKGROUNDLESS_THEME_NAME) return;
|
|
97
|
+
if (deferredThemeProjection !== undefined) return;
|
|
98
|
+
|
|
99
|
+
deferredThemeProjection = setTimeout(() => {
|
|
100
|
+
deferredThemeProjection = undefined;
|
|
101
|
+
if (state.kind === "off" || activeContext !== ctx || ctx.ui.theme.name === BACKGROUNDLESS_THEME_NAME) return;
|
|
102
|
+
|
|
103
|
+
const previousTheme = snapshotTheme(ctx.ui.theme);
|
|
104
|
+
ctx.ui.setTheme(withoutContentBackgrounds(ctx.ui.theme));
|
|
105
|
+
installQuietIndicator(ctx.ui);
|
|
106
|
+
state = { ...state, previousTheme };
|
|
107
|
+
}, 0);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// `settings` is only given back when the user asks for Zen off. Shutting down is
|
|
111
|
+
// not a change of mind, and reverting quietStartup there would undo it every
|
|
112
|
+
// time pi closed.
|
|
113
|
+
const restore = (ctx: ExtensionContext, give: "chrome" | "chrome and settings") => {
|
|
114
|
+
if (state.kind === "off") return;
|
|
115
|
+
if (give === "chrome and settings") {
|
|
116
|
+
const settings = settingsFor(ctx);
|
|
117
|
+
if (settings.getQuietStartup() !== state.previousQuietStartup) {
|
|
118
|
+
settings.setQuietStartup(state.previousQuietStartup);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (ctx.ui.theme.name === BACKGROUNDLESS_THEME_NAME) ctx.ui.setTheme(state.previousTheme);
|
|
122
|
+
ctx.ui.setHeader(undefined);
|
|
123
|
+
ctx.ui.setEditorComponent(state.previousEditor);
|
|
124
|
+
restoreDefaultIndicator(ctx.ui);
|
|
125
|
+
state = { kind: "off" };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
pi.on("session_start", (event, ctx) => {
|
|
129
|
+
if (ctx.mode !== "tui") return;
|
|
130
|
+
activeContext = ctx;
|
|
131
|
+
|
|
132
|
+
// Tool overrides are registered once per process: re-registering the same
|
|
133
|
+
// names on a later session_start would stack duplicate definitions.
|
|
134
|
+
if (!toolsRegistered) {
|
|
135
|
+
registerCompactTools(pi, ctx.cwd, toolOptions(ctx), grouper, () => state.kind === "on");
|
|
136
|
+
toolsRegistered = true;
|
|
137
|
+
}
|
|
138
|
+
install(ctx);
|
|
139
|
+
|
|
140
|
+
// During /reload, Pi reapplies the saved theme after session_start. Reclaim
|
|
141
|
+
// it on the next task so newly submitted messages stay backgroundless too.
|
|
142
|
+
if (event.reason === "reload") {
|
|
143
|
+
deferredReloadInstall = setTimeout(() => {
|
|
144
|
+
deferredReloadInstall = undefined;
|
|
145
|
+
if (state.kind === "on") install(ctx);
|
|
146
|
+
}, 0);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
pi.on("turn_end", () => {
|
|
151
|
+
// The next turn's reads belong to their own line, not to this turn's.
|
|
152
|
+
grouper.close();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
156
|
+
if (deferredReloadInstall !== undefined) {
|
|
157
|
+
clearTimeout(deferredReloadInstall);
|
|
158
|
+
deferredReloadInstall = undefined;
|
|
159
|
+
}
|
|
160
|
+
if (deferredThemeProjection !== undefined) {
|
|
161
|
+
clearTimeout(deferredThemeProjection);
|
|
162
|
+
deferredThemeProjection = undefined;
|
|
163
|
+
}
|
|
164
|
+
activeContext = undefined;
|
|
165
|
+
restore(ctx, "chrome");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Display-only: the session and the model keep the original markdown.
|
|
169
|
+
pi.registerMarkdownTransformer((markdown, context) => {
|
|
170
|
+
if (state.kind === "off") return markdown;
|
|
171
|
+
projectChangedThemeSoon();
|
|
172
|
+
if (context.messageType === "assistant-thinking" && context.isStreaming) {
|
|
173
|
+
return thinkingTail(squeezeBlankLines(markdown), thinkingTailLineBudget(process.stdout.rows));
|
|
174
|
+
}
|
|
175
|
+
return squeezeBlankLines(markdown);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
pi.registerCommand("zen", {
|
|
179
|
+
description: "Quiet the TUI: on or off",
|
|
180
|
+
getArgumentCompletions: (prefix) =>
|
|
181
|
+
["on", "off"].filter((option) => option.startsWith(prefix)).map((option) => ({ value: option, label: option })),
|
|
182
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
183
|
+
const request = args.trim().toLowerCase();
|
|
184
|
+
const wanted = request === "" ? state.kind === "off" : request !== "off";
|
|
185
|
+
|
|
186
|
+
if (wanted) {
|
|
187
|
+
const wasQuiet = settingsFor(ctx).getQuietStartup();
|
|
188
|
+
install(ctx);
|
|
189
|
+
ctx.ui.notify(wasQuiet ? "Zen on" : "Zen on · quiet startup from the next launch", "info");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (deferredThemeProjection !== undefined) {
|
|
193
|
+
clearTimeout(deferredThemeProjection);
|
|
194
|
+
deferredThemeProjection = undefined;
|
|
195
|
+
}
|
|
196
|
+
restore(ctx, "chrome and settings");
|
|
197
|
+
ctx.ui.notify("Zen off · tool frames return after Zen is disabled and Pi reloads", "info");
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-zen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A quiet Pi TUI on one switch: backgroundless messages, a rail editor, compact tool calls, and restrained reasoning",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@11.21.0",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Roshvan/pi-zen.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Roshvan/pi-zen/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/Roshvan/pi-zen#readme",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"registry": "https://registry.npmjs.org/"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"pi-package",
|
|
22
|
+
"pi-extension",
|
|
23
|
+
"pi",
|
|
24
|
+
"coding-agent",
|
|
25
|
+
"tui",
|
|
26
|
+
"minimal",
|
|
27
|
+
"zen"
|
|
28
|
+
],
|
|
29
|
+
"files": [
|
|
30
|
+
"extensions",
|
|
31
|
+
"src",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"check": "tsc --noEmit && oxlint",
|
|
37
|
+
"dev": "pi -e ./extensions/zen.ts",
|
|
38
|
+
"lint": "oxlint",
|
|
39
|
+
"pack:check": "pnpm pack --dry-run",
|
|
40
|
+
"prepublishOnly": "pnpm check && pnpm pack:check"
|
|
41
|
+
},
|
|
42
|
+
"pi": {
|
|
43
|
+
"extensions": [
|
|
44
|
+
"./extensions/zen.ts"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
49
|
+
"@earendil-works/pi-tui": "*"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@earendil-works/pi-coding-agent": "0.84.3",
|
|
53
|
+
"@earendil-works/pi-tui": "0.84.3",
|
|
54
|
+
"@oxlint/plugins": "^1.79.0",
|
|
55
|
+
"@types/node": "^24.10.0",
|
|
56
|
+
"oxlint": "^1.79.0",
|
|
57
|
+
"typescript": "7.0.2"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=22.19.0"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Name used only while Zen's backgroundless projection is active. */
|
|
4
|
+
export const BACKGROUNDLESS_THEME_NAME = "pi-zen:backgroundless";
|
|
5
|
+
|
|
6
|
+
const THEME_COLORS = [
|
|
7
|
+
"accent",
|
|
8
|
+
"border",
|
|
9
|
+
"borderAccent",
|
|
10
|
+
"borderMuted",
|
|
11
|
+
"success",
|
|
12
|
+
"error",
|
|
13
|
+
"warning",
|
|
14
|
+
"muted",
|
|
15
|
+
"dim",
|
|
16
|
+
"text",
|
|
17
|
+
"thinkingText",
|
|
18
|
+
"searchMatchText",
|
|
19
|
+
"userMessageText",
|
|
20
|
+
"customMessageText",
|
|
21
|
+
"customMessageLabel",
|
|
22
|
+
"toolTitle",
|
|
23
|
+
"toolOutput",
|
|
24
|
+
"mdHeading",
|
|
25
|
+
"mdLink",
|
|
26
|
+
"mdLinkUrl",
|
|
27
|
+
"mdCode",
|
|
28
|
+
"mdCodeBlock",
|
|
29
|
+
"mdCodeBlockBorder",
|
|
30
|
+
"mdQuote",
|
|
31
|
+
"mdQuoteBorder",
|
|
32
|
+
"mdHr",
|
|
33
|
+
"mdListBullet",
|
|
34
|
+
"toolDiffAdded",
|
|
35
|
+
"toolDiffRemoved",
|
|
36
|
+
"toolDiffContext",
|
|
37
|
+
"syntaxComment",
|
|
38
|
+
"syntaxKeyword",
|
|
39
|
+
"syntaxFunction",
|
|
40
|
+
"syntaxVariable",
|
|
41
|
+
"syntaxString",
|
|
42
|
+
"syntaxNumber",
|
|
43
|
+
"syntaxType",
|
|
44
|
+
"syntaxOperator",
|
|
45
|
+
"syntaxPunctuation",
|
|
46
|
+
"thinkingOff",
|
|
47
|
+
"thinkingMinimal",
|
|
48
|
+
"thinkingLow",
|
|
49
|
+
"thinkingMedium",
|
|
50
|
+
"thinkingHigh",
|
|
51
|
+
"thinkingXhigh",
|
|
52
|
+
"thinkingMax",
|
|
53
|
+
"bashMode",
|
|
54
|
+
] as const satisfies ReadonlyArray<ThemeColor>;
|
|
55
|
+
|
|
56
|
+
type ThemeBackground = Parameters<Theme["bg"]>[0];
|
|
57
|
+
|
|
58
|
+
const THEME_BACKGROUNDS = [
|
|
59
|
+
"selectedBg",
|
|
60
|
+
"scrollbarThumb",
|
|
61
|
+
"searchMatchBg",
|
|
62
|
+
"userMessageBg",
|
|
63
|
+
"customMessageBg",
|
|
64
|
+
"toolPendingBg",
|
|
65
|
+
"toolSuccessBg",
|
|
66
|
+
"toolErrorBg",
|
|
67
|
+
] as const satisfies ReadonlyArray<ThemeBackground>;
|
|
68
|
+
|
|
69
|
+
const CONTENT_BACKGROUNDS: ReadonlySet<ThemeBackground> = new Set([
|
|
70
|
+
"userMessageBg",
|
|
71
|
+
"customMessageBg",
|
|
72
|
+
"toolPendingBg",
|
|
73
|
+
"toolSuccessBg",
|
|
74
|
+
"toolErrorBg",
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/** Foregrounds designed for a colored surface need a terminal-safe counterpart once that surface is gone. */
|
|
78
|
+
function backgroundlessForeground(color: ThemeColor): ThemeColor {
|
|
79
|
+
if (color === "userMessageText" || color === "customMessageText" || color === "toolOutput") return "text";
|
|
80
|
+
return color;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type ForegroundInput = ConstructorParameters<typeof Theme>[0];
|
|
84
|
+
type BackgroundInput = ConstructorParameters<typeof Theme>[1];
|
|
85
|
+
|
|
86
|
+
type ThemeSnapshotOptions = {
|
|
87
|
+
readonly name: string | undefined;
|
|
88
|
+
readonly suppressContentBackgrounds: boolean;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function emptyForegrounds(): ForegroundInput {
|
|
92
|
+
// SAFETY: THEME_COLORS is checked against ThemeColor and contains every required key exactly once.
|
|
93
|
+
return Object.fromEntries(THEME_COLORS.map((color) => [color, ""])) as ForegroundInput;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function emptyBackgrounds(): BackgroundInput {
|
|
97
|
+
// SAFETY: THEME_BACKGROUNDS is checked against ThemeBackground and contains every required key exactly once.
|
|
98
|
+
return Object.fromEntries(THEME_BACKGROUNDS.map((color) => [color, ""])) as BackgroundInput;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function makeThemeSnapshot(source: Theme, options: ThemeSnapshotOptions): Theme {
|
|
102
|
+
const foregroundAnsi = new Map<ThemeColor, string>();
|
|
103
|
+
const backgroundAnsi = new Map<ThemeBackground, string>();
|
|
104
|
+
for (const color of THEME_COLORS) foregroundAnsi.set(color, source.getFgAnsi(color));
|
|
105
|
+
for (const color of THEME_BACKGROUNDS) backgroundAnsi.set(color, source.getBgAnsi(color));
|
|
106
|
+
|
|
107
|
+
// SAFETY: Pi created source, so its constructor is the runtime's Theme constructor. Using that exact constructor
|
|
108
|
+
// keeps instanceof checks valid when this source package has a different development copy of Pi installed.
|
|
109
|
+
const RuntimeTheme = source.constructor as typeof Theme;
|
|
110
|
+
const snapshot = new RuntimeTheme(
|
|
111
|
+
emptyForegrounds(),
|
|
112
|
+
emptyBackgrounds(),
|
|
113
|
+
source.getColorMode(),
|
|
114
|
+
options.name === undefined ? {} : { name: options.name },
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
snapshot.getFgAnsi = (color: ThemeColor): string => {
|
|
118
|
+
const projectedColor = options.suppressContentBackgrounds ? backgroundlessForeground(color) : color;
|
|
119
|
+
const ansi = foregroundAnsi.get(projectedColor);
|
|
120
|
+
if (ansi === undefined) throw new Error(`Unknown theme color: ${color}`);
|
|
121
|
+
return ansi;
|
|
122
|
+
};
|
|
123
|
+
snapshot.getBgAnsi = (color: ThemeBackground): string => {
|
|
124
|
+
if (options.suppressContentBackgrounds && CONTENT_BACKGROUNDS.has(color)) return "\x1b[49m";
|
|
125
|
+
const ansi = backgroundAnsi.get(color);
|
|
126
|
+
if (ansi === undefined) throw new Error(`Unknown theme background: ${color}`);
|
|
127
|
+
return ansi;
|
|
128
|
+
};
|
|
129
|
+
snapshot.fg = (color: ThemeColor, text: string): string => `${snapshot.getFgAnsi(color)}${text}\x1b[39m`;
|
|
130
|
+
snapshot.bg = (color: ThemeBackground, text: string): string => {
|
|
131
|
+
if (options.suppressContentBackgrounds && CONTENT_BACKGROUNDS.has(color)) return text;
|
|
132
|
+
return `${snapshot.getBgAnsi(color)}${text}\x1b[49m`;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return snapshot;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Capture the active theme so Zen can restore it without depending on mutable global theme state.
|
|
140
|
+
*
|
|
141
|
+
* @param source - Pi's currently active theme.
|
|
142
|
+
* @returns An in-memory copy of the active theme.
|
|
143
|
+
*/
|
|
144
|
+
export function snapshotTheme(source: Theme): Theme {
|
|
145
|
+
return makeThemeSnapshot(source, { name: source.name, suppressContentBackgrounds: false });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Keep the active theme while removing message and tool backgrounds.
|
|
150
|
+
* Surface-specific body text falls back to the theme's terminal-safe base text;
|
|
151
|
+
* selection, search, and scrollbar backgrounds remain as affordances.
|
|
152
|
+
*
|
|
153
|
+
* @param source - Pi's currently active theme.
|
|
154
|
+
* @returns A backgroundless projection of the active theme.
|
|
155
|
+
*/
|
|
156
|
+
export function withoutContentBackgrounds(source: Theme): Theme {
|
|
157
|
+
return makeThemeSnapshot(source, { name: BACKGROUNDLESS_THEME_NAME, suppressContentBackgrounds: true });
|
|
158
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The render context Pi hands to a tool's render slots.
|
|
6
|
+
*
|
|
7
|
+
* Pi does not export `ToolRenderContext`, and each built-in tool keeps a
|
|
8
|
+
* private render state on it, so an expanded row forwards the context object it
|
|
9
|
+
* was given rather than rebuilding one.
|
|
10
|
+
*/
|
|
11
|
+
// oxlint-disable-next-line no-explicit-any -- SAFETY: the forwarded value is always the context object Pi just passed in, with only the cached component replaced. Naming its type would mean restating Pi's private per-tool render state.
|
|
12
|
+
export type ForwardedRenderContext = any;
|
|
13
|
+
|
|
14
|
+
/** A built-in `renderCall` slot. */
|
|
15
|
+
export type BuiltinCallSlot<TArgs> = (args: TArgs, theme: Theme, context: ForwardedRenderContext) => Component;
|
|
16
|
+
|
|
17
|
+
/** A built-in `renderResult` slot. */
|
|
18
|
+
export type BuiltinResultSlot<TDetails> = (
|
|
19
|
+
result: AgentToolResult<TDetails>,
|
|
20
|
+
options: ToolRenderResultOptions,
|
|
21
|
+
theme: Theme,
|
|
22
|
+
context: ForwardedRenderContext,
|
|
23
|
+
) => Component;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Render a built-in tool's call slot, reusing the component it returned last time.
|
|
27
|
+
*
|
|
28
|
+
* @template TArgs - The tool's argument type.
|
|
29
|
+
* @param slot - The built-in `renderCall`, when the tool has one.
|
|
30
|
+
* @param args - Arguments for this tool call.
|
|
31
|
+
* @param theme - The active theme.
|
|
32
|
+
* @param context - The context Pi passed to our own renderer.
|
|
33
|
+
* @param lastComponent - The component this slot returned on the previous render.
|
|
34
|
+
* @returns The built-in component, or undefined when the tool has no call slot.
|
|
35
|
+
*/
|
|
36
|
+
export function forwardCall<TArgs>(
|
|
37
|
+
slot: BuiltinCallSlot<TArgs> | undefined,
|
|
38
|
+
args: TArgs,
|
|
39
|
+
theme: Theme,
|
|
40
|
+
context: ForwardedRenderContext,
|
|
41
|
+
lastComponent: Component | undefined,
|
|
42
|
+
): Component | undefined {
|
|
43
|
+
if (slot === undefined) return undefined;
|
|
44
|
+
return slot(args, theme, { ...context, lastComponent });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render a built-in tool's result slot, reusing the component it returned last time.
|
|
49
|
+
*
|
|
50
|
+
* @template TDetails - The tool's result detail type.
|
|
51
|
+
* @param slot - The built-in `renderResult`, when the tool has one.
|
|
52
|
+
* @param result - The settled tool result.
|
|
53
|
+
* @param options - Pi's render options for this result.
|
|
54
|
+
* @param theme - The active theme.
|
|
55
|
+
* @param context - The context Pi passed to our own renderer.
|
|
56
|
+
* @param lastComponent - The component this slot returned on the previous render.
|
|
57
|
+
* @returns The built-in component, or undefined when the tool has no result slot.
|
|
58
|
+
*/
|
|
59
|
+
export function forwardResult<TDetails>(
|
|
60
|
+
slot: BuiltinResultSlot<TDetails> | undefined,
|
|
61
|
+
result: AgentToolResult<TDetails>,
|
|
62
|
+
options: ToolRenderResultOptions,
|
|
63
|
+
theme: Theme,
|
|
64
|
+
context: ForwardedRenderContext,
|
|
65
|
+
lastComponent: Component | undefined,
|
|
66
|
+
): Component | undefined {
|
|
67
|
+
if (slot === undefined) return undefined;
|
|
68
|
+
return slot(result, options, theme, { ...context, lastComponent });
|
|
69
|
+
}
|