pi-zentui 0.1.2 → 0.1.4
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 +20 -12
- package/extensions/zentui/config.ts +43 -11
- package/extensions/zentui/index.ts +16 -30
- package/extensions/zentui/project-refresh.ts +13 -0
- package/extensions/zentui/ui.ts +65 -33
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -84,9 +84,10 @@ On first run, Zentui creates a config file at:
|
|
|
84
84
|
|
|
85
85
|
```json
|
|
86
86
|
{
|
|
87
|
+
"projectRefreshIntervalMs": 30000,
|
|
87
88
|
"icons": {
|
|
88
89
|
"cwd": "",
|
|
89
|
-
"git": "",
|
|
90
|
+
"git": "",
|
|
90
91
|
"ahead": "↑",
|
|
91
92
|
"behind": "↓",
|
|
92
93
|
"diverged": "⇕",
|
|
@@ -113,6 +114,8 @@ On first run, Zentui creates a config file at:
|
|
|
113
114
|
}
|
|
114
115
|
```
|
|
115
116
|
|
|
117
|
+
`projectRefreshIntervalMs` controls how often Zentui refreshes project status (git/runtime) while Pi is idle. Set it to `0` to disable polling; invalid values or values below 5000 ms fall back to `30000`.
|
|
118
|
+
|
|
116
119
|
### Color values
|
|
117
120
|
|
|
118
121
|
Colors can be:
|
|
@@ -129,23 +132,28 @@ This means Zentui works with any Pi theme — it uses your theme's colors by def
|
|
|
129
132
|
|
|
130
133
|
## Development
|
|
131
134
|
|
|
132
|
-
|
|
135
|
+
```bash
|
|
136
|
+
npm install
|
|
137
|
+
npm run verify
|
|
138
|
+
npm run fmt
|
|
139
|
+
npm run pack:check
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Test in Pi
|
|
143
|
+
|
|
144
|
+
The project keeps Pi core packages as peer dependencies for runtime and dev dependencies for
|
|
145
|
+
typechecking. To avoid accidentally running the local `node_modules/.bin/pi` shim, the dev scripts use
|
|
146
|
+
the globally installed Pi binary by default:
|
|
133
147
|
|
|
134
148
|
```bash
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
mise run verify
|
|
138
|
-
mise run fmt
|
|
139
|
-
mise run ci
|
|
149
|
+
npm run pi:dev
|
|
150
|
+
npm run pi:install-local
|
|
140
151
|
```
|
|
141
152
|
|
|
142
|
-
|
|
153
|
+
Override the binary if your Pi install is somewhere else:
|
|
143
154
|
|
|
144
155
|
```bash
|
|
145
|
-
npm
|
|
146
|
-
npm run verify
|
|
147
|
-
npm run fmt
|
|
148
|
-
npm run pack:check
|
|
156
|
+
PI_BIN=/path/to/pi npm run pi:dev
|
|
149
157
|
```
|
|
150
158
|
|
|
151
159
|
## Credits
|
|
@@ -4,7 +4,11 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
|
4
4
|
|
|
5
5
|
export type ColorSpec = string;
|
|
6
6
|
|
|
7
|
+
const DEFAULT_PROJECT_REFRESH_INTERVAL_MS = 30_000;
|
|
8
|
+
const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
|
|
9
|
+
|
|
7
10
|
export type PolishedTuiConfig = {
|
|
11
|
+
projectRefreshIntervalMs: number;
|
|
8
12
|
icons: {
|
|
9
13
|
cwd: string;
|
|
10
14
|
git: string;
|
|
@@ -84,6 +88,7 @@ const themeColorTokens = new Set([
|
|
|
84
88
|
]);
|
|
85
89
|
|
|
86
90
|
export const defaultConfig: PolishedTuiConfig = {
|
|
91
|
+
projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
|
|
87
92
|
icons: {
|
|
88
93
|
cwd: "",
|
|
89
94
|
git: "",
|
|
@@ -128,6 +133,24 @@ type ThemeLike = {
|
|
|
128
133
|
fg(color: string, text: string): string;
|
|
129
134
|
};
|
|
130
135
|
|
|
136
|
+
type ConfigRecord = Record<string, unknown>;
|
|
137
|
+
|
|
138
|
+
function isRecord(value: unknown): value is ConfigRecord {
|
|
139
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseProjectRefreshIntervalMs(value: unknown): number {
|
|
143
|
+
if (value === 0) return 0;
|
|
144
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
145
|
+
return defaultConfig.projectRefreshIntervalMs;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const interval = Math.round(value);
|
|
149
|
+
return interval >= MIN_PROJECT_REFRESH_INTERVAL_MS
|
|
150
|
+
? interval
|
|
151
|
+
: defaultConfig.projectRefreshIntervalMs;
|
|
152
|
+
}
|
|
153
|
+
|
|
131
154
|
export function colorize(theme: ThemeLike, color: ColorSpec, text: string): string {
|
|
132
155
|
if (themeColorTokens.has(color)) {
|
|
133
156
|
return theme.fg(color, text);
|
|
@@ -148,20 +171,29 @@ export function ensureConfigExists(): void {
|
|
|
148
171
|
}
|
|
149
172
|
}
|
|
150
173
|
|
|
174
|
+
export function mergeConfig(parsed: unknown): PolishedTuiConfig {
|
|
175
|
+
const config = isRecord(parsed) ? parsed : {};
|
|
176
|
+
const icons = isRecord(config.icons) ? (config.icons as Partial<PolishedTuiConfig["icons"]>) : {};
|
|
177
|
+
const colors = isRecord(config.colors)
|
|
178
|
+
? (config.colors as Partial<PolishedTuiConfig["colors"]>)
|
|
179
|
+
: {};
|
|
180
|
+
return {
|
|
181
|
+
projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
|
|
182
|
+
icons: {
|
|
183
|
+
...defaultConfig.icons,
|
|
184
|
+
...icons,
|
|
185
|
+
},
|
|
186
|
+
colors: {
|
|
187
|
+
...defaultConfig.colors,
|
|
188
|
+
...colors,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
151
193
|
export function loadConfig(): PolishedTuiConfig {
|
|
152
194
|
try {
|
|
153
195
|
if (!existsSync(configPath)) return defaultConfig;
|
|
154
|
-
|
|
155
|
-
return {
|
|
156
|
-
icons: {
|
|
157
|
-
...defaultConfig.icons,
|
|
158
|
-
...(parsed.icons ?? {}),
|
|
159
|
-
},
|
|
160
|
-
colors: {
|
|
161
|
-
...defaultConfig.colors,
|
|
162
|
-
...(parsed.colors ?? {}),
|
|
163
|
-
},
|
|
164
|
-
};
|
|
196
|
+
return mergeConfig(JSON.parse(readFileSync(configPath, "utf8")));
|
|
165
197
|
} catch {
|
|
166
198
|
return defaultConfig;
|
|
167
199
|
}
|
|
@@ -8,11 +8,11 @@ import type {
|
|
|
8
8
|
import { type EditorTheme, type TUI, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
9
9
|
import { type PolishedTuiConfig, colorize, ensureConfigExists, loadConfig } from "./config";
|
|
10
10
|
import { type GitStatusSummary, emptyGitStatus, readGitStatus } from "./git";
|
|
11
|
+
import { type StopProjectRefreshInterval, startProjectRefreshInterval } from "./project-refresh";
|
|
11
12
|
import { type RuntimeInfo, readRuntimeInfo } from "./runtime";
|
|
12
|
-
import { PolishedEditor, patchUserMessageComponent } from "./ui";
|
|
13
|
+
import { PolishedEditor, patchUserMessageComponent, restoreUserMessageComponent } from "./ui";
|
|
13
14
|
|
|
14
15
|
type FooterState = GitStatusSummary & {
|
|
15
|
-
busy: boolean;
|
|
16
16
|
modelLabel: string;
|
|
17
17
|
providerLabel: string;
|
|
18
18
|
contextLabel: string;
|
|
@@ -128,7 +128,6 @@ function formatCwdLabel(cwd: string, cwdIcon: string): string {
|
|
|
128
128
|
|
|
129
129
|
export default function (pi: ExtensionAPI) {
|
|
130
130
|
const state: FooterState = {
|
|
131
|
-
busy: false,
|
|
132
131
|
modelLabel: "no-model",
|
|
133
132
|
providerLabel: "Unknown",
|
|
134
133
|
contextLabel: "--",
|
|
@@ -140,6 +139,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
140
139
|
|
|
141
140
|
let currentConfig: PolishedTuiConfig = loadConfig();
|
|
142
141
|
let requestFooterRender: (() => void) | undefined;
|
|
142
|
+
let stopProjectRefreshInterval: StopProjectRefreshInterval = () => {};
|
|
143
143
|
let projectRefreshInFlight = false;
|
|
144
144
|
let projectRefreshPending = false;
|
|
145
145
|
|
|
@@ -267,15 +267,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
267
267
|
const installEditor = (ctx: ExtensionContext) => {
|
|
268
268
|
syncState(ctx);
|
|
269
269
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
type AutocompleteEditorInternals = {
|
|
274
|
-
autocompleteProvider?: unknown;
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
const editorFactory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => {
|
|
278
|
-
const editor = new PolishedEditor(
|
|
270
|
+
const editorFactory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) =>
|
|
271
|
+
new PolishedEditor(
|
|
279
272
|
tui,
|
|
280
273
|
theme,
|
|
281
274
|
keybindings,
|
|
@@ -287,22 +280,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
287
280
|
].join(ctx.ui.theme.fg("borderMuted", " ")),
|
|
288
281
|
() => pi.getThinkingLevel(),
|
|
289
282
|
);
|
|
290
|
-
currentEditor = editor;
|
|
291
|
-
|
|
292
|
-
const originalHandleInput = editor.handleInput.bind(editor);
|
|
293
|
-
editor.handleInput = (data: string) => {
|
|
294
|
-
const editorInternals = editor as unknown as AutocompleteEditorInternals;
|
|
295
|
-
if (!autocompleteFixed && !editorInternals.autocompleteProvider) {
|
|
296
|
-
autocompleteFixed = true;
|
|
297
|
-
ctx.ui.setEditorComponent(editorFactory);
|
|
298
|
-
currentEditor?.handleInput(data);
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
originalHandleInput(data);
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
return editor;
|
|
305
|
-
};
|
|
306
283
|
|
|
307
284
|
ctx.ui.setEditorComponent(editorFactory);
|
|
308
285
|
};
|
|
@@ -313,6 +290,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
313
290
|
patchUserMessageComponent(ctx.ui.theme);
|
|
314
291
|
installFooter(ctx);
|
|
315
292
|
installEditor(ctx);
|
|
293
|
+
stopProjectRefreshInterval();
|
|
294
|
+
stopProjectRefreshInterval = startProjectRefreshInterval(
|
|
295
|
+
currentConfig.projectRefreshIntervalMs,
|
|
296
|
+
() => scheduleProjectRefresh(ctx),
|
|
297
|
+
);
|
|
316
298
|
scheduleProjectRefresh(ctx);
|
|
317
299
|
refresh();
|
|
318
300
|
};
|
|
@@ -321,14 +303,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
321
303
|
installUi(ctx);
|
|
322
304
|
});
|
|
323
305
|
|
|
306
|
+
pi.on("session_shutdown", async () => {
|
|
307
|
+
stopProjectRefreshInterval();
|
|
308
|
+
stopProjectRefreshInterval = () => {};
|
|
309
|
+
restoreUserMessageComponent();
|
|
310
|
+
});
|
|
311
|
+
|
|
324
312
|
pi.on("agent_start", async (_event, ctx) => {
|
|
325
|
-
state.busy = true;
|
|
326
313
|
syncState(ctx);
|
|
327
314
|
refresh();
|
|
328
315
|
});
|
|
329
316
|
|
|
330
317
|
pi.on("agent_end", async (_event, ctx) => {
|
|
331
|
-
state.busy = false;
|
|
332
318
|
syncState(ctx);
|
|
333
319
|
scheduleProjectRefresh(ctx);
|
|
334
320
|
refresh();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type StopProjectRefreshInterval = () => void;
|
|
2
|
+
|
|
3
|
+
export function startProjectRefreshInterval(
|
|
4
|
+
intervalMs: number,
|
|
5
|
+
refresh: () => void,
|
|
6
|
+
): StopProjectRefreshInterval {
|
|
7
|
+
if (intervalMs <= 0) return () => {};
|
|
8
|
+
|
|
9
|
+
const timer = setInterval(refresh, intervalMs);
|
|
10
|
+
timer.unref?.();
|
|
11
|
+
|
|
12
|
+
return () => clearInterval(timer);
|
|
13
|
+
}
|
package/extensions/zentui/ui.ts
CHANGED
|
@@ -16,13 +16,22 @@ import {
|
|
|
16
16
|
const OSC133_ZONE_START = "\x1b]133;A\x07";
|
|
17
17
|
const OSC133_ZONE_END = "\x1b]133;B\x07";
|
|
18
18
|
const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
|
|
19
|
-
const
|
|
19
|
+
const userMessagePatchKey = "__zentuiUserMessagePatch";
|
|
20
20
|
|
|
21
21
|
type AutocompleteEditorInternals = {
|
|
22
22
|
autocompleteList?: Pick<Component, "render">;
|
|
23
23
|
isShowingAutocomplete?: () => boolean;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
type UserMessageRender = (this: UserMessageComponent, width: number) => string[];
|
|
27
|
+
type UserMessagePatchState = {
|
|
28
|
+
originalRender: UserMessageRender;
|
|
29
|
+
patchedRender: UserMessageRender;
|
|
30
|
+
};
|
|
31
|
+
type PatchableUserMessagePrototype = typeof UserMessageComponent.prototype & {
|
|
32
|
+
[userMessagePatchKey]?: UserMessagePatchState;
|
|
33
|
+
};
|
|
34
|
+
|
|
26
35
|
let currentUiTheme: Theme | undefined;
|
|
27
36
|
|
|
28
37
|
const TRUECOLOR_BACKGROUND_ANSI = /\x1b\[48;2;\d+;\d+;\d+m/g;
|
|
@@ -36,51 +45,74 @@ function stripBackgroundAnsi(text: string): string {
|
|
|
36
45
|
.replace(SIMPLE_BACKGROUND_ANSI, "");
|
|
37
46
|
}
|
|
38
47
|
|
|
39
|
-
function fillStyledLine(
|
|
40
|
-
content: string,
|
|
41
|
-
width: number,
|
|
42
|
-
background?: (text: string) => string,
|
|
43
|
-
): string {
|
|
48
|
+
function fillStyledLine(content: string, width: number): string {
|
|
44
49
|
const truncated = truncateToWidth(stripBackgroundAnsi(content), width, "");
|
|
45
50
|
const padWidth = Math.max(0, width - visibleWidth(truncated));
|
|
46
|
-
const pad =
|
|
47
|
-
padWidth > 0 ? (background ? background(" ".repeat(padWidth)) : " ".repeat(padWidth)) : "";
|
|
51
|
+
const pad = padWidth > 0 ? " ".repeat(padWidth) : "";
|
|
48
52
|
return `${truncated}${pad}`;
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
function userMessagePrototype(): PatchableUserMessagePrototype {
|
|
56
|
+
return UserMessageComponent.prototype as PatchableUserMessagePrototype;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderPatchedUserMessage(this: UserMessageComponent, width: number): string[] {
|
|
60
|
+
const originalRender = userMessagePrototype()[userMessagePatchKey]?.originalRender;
|
|
61
|
+
if (!currentUiTheme || !originalRender) {
|
|
62
|
+
return originalRender
|
|
63
|
+
? originalRender.call(this, width)
|
|
64
|
+
: (Container.prototype.render.call(this, width) as string[]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const railWidth = 2;
|
|
68
|
+
const innerWidth = Math.max(1, width - railWidth);
|
|
69
|
+
const baseLines = Container.prototype.render.call(this, innerWidth) as string[];
|
|
70
|
+
if (baseLines.length === 0) return baseLines;
|
|
71
|
+
|
|
72
|
+
const hasLeadingSpacer = baseLines.length > 1 && visibleWidth(baseLines[0] ?? "") === 0;
|
|
73
|
+
const leadingLines = hasLeadingSpacer ? [baseLines[0] ?? ""] : [];
|
|
74
|
+
const contentLines = hasLeadingSpacer ? baseLines.slice(1) : baseLines;
|
|
75
|
+
const rail = `${currentUiTheme.fg("accent", "│")}\x1b[0m `;
|
|
76
|
+
const border = currentUiTheme.fg("border", "─".repeat(width));
|
|
77
|
+
const styledLines = contentLines.map((line) => `${rail}${fillStyledLine(line, innerWidth)}`);
|
|
78
|
+
|
|
79
|
+
if (styledLines.length === 0) {
|
|
80
|
+
return leadingLines;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const framedLines = [border, ...styledLines, border];
|
|
84
|
+
framedLines[0] = OSC133_ZONE_START + framedLines[0];
|
|
85
|
+
framedLines[framedLines.length - 1] =
|
|
86
|
+
framedLines[framedLines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
|
|
87
|
+
return [...leadingLines, ...framedLines];
|
|
88
|
+
}
|
|
89
|
+
|
|
51
90
|
export function patchUserMessageComponent(uiTheme: Theme): void {
|
|
52
91
|
currentUiTheme = uiTheme;
|
|
53
92
|
|
|
54
|
-
const prototype =
|
|
55
|
-
|
|
93
|
+
const prototype = userMessagePrototype();
|
|
94
|
+
const patchState = prototype[userMessagePatchKey] ?? {
|
|
95
|
+
originalRender: prototype.render,
|
|
96
|
+
patchedRender: renderPatchedUserMessage,
|
|
56
97
|
};
|
|
57
|
-
|
|
58
|
-
if (!currentUiTheme) {
|
|
59
|
-
return originalUserMessageRender.call(this, width);
|
|
60
|
-
}
|
|
98
|
+
patchState.patchedRender = renderPatchedUserMessage;
|
|
61
99
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
100
|
+
Object.defineProperty(prototype, userMessagePatchKey, {
|
|
101
|
+
value: patchState,
|
|
102
|
+
configurable: true,
|
|
103
|
+
});
|
|
104
|
+
prototype.render = renderPatchedUserMessage;
|
|
105
|
+
}
|
|
66
106
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const contentLines = hasLeadingSpacer ? baseLines.slice(1) : baseLines;
|
|
70
|
-
const rail = `${currentUiTheme.fg("accent", "│")}\x1b[0m `;
|
|
71
|
-
const border = currentUiTheme.fg("border", "─".repeat(width));
|
|
72
|
-
const styledLines = contentLines.map((line) => `${rail}${fillStyledLine(line, innerWidth)}`);
|
|
107
|
+
export function restoreUserMessageComponent(): void {
|
|
108
|
+
currentUiTheme = undefined;
|
|
73
109
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
110
|
+
const prototype = userMessagePrototype();
|
|
111
|
+
const patchState = prototype[userMessagePatchKey];
|
|
112
|
+
if (!patchState || prototype.render !== patchState.patchedRender) return;
|
|
77
113
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
framedLines[framedLines.length - 1] =
|
|
81
|
-
framedLines[framedLines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
|
|
82
|
-
return [...leadingLines, ...framedLines];
|
|
83
|
-
};
|
|
114
|
+
prototype.render = patchState.originalRender;
|
|
115
|
+
delete prototype[userMessagePatchKey];
|
|
84
116
|
}
|
|
85
117
|
|
|
86
118
|
export class PolishedEditor extends CustomEditor {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-zentui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
"lint": "biome check .",
|
|
19
19
|
"typecheck": "tsc --noEmit",
|
|
20
20
|
"test": "vitest run",
|
|
21
|
+
"pi:dev": "${PI_BIN:-/opt/homebrew/bin/pi} --no-extensions -e ./extensions/zentui/index.ts",
|
|
22
|
+
"pi:install-local": "${PI_BIN:-/opt/homebrew/bin/pi} install ./ -l",
|
|
21
23
|
"fmt": "biome format --write .",
|
|
22
24
|
"fmt:check": "biome format --check .",
|
|
23
25
|
"verify": "npm run lint && npm run typecheck && npm run test",
|
|
@@ -38,9 +40,9 @@
|
|
|
38
40
|
},
|
|
39
41
|
"devDependencies": {
|
|
40
42
|
"@biomejs/biome": "^1.9.4",
|
|
41
|
-
"@mariozechner/pi-ai": "^0.
|
|
42
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
43
|
-
"@mariozechner/pi-tui": "^0.
|
|
43
|
+
"@mariozechner/pi-ai": "^0.73.0",
|
|
44
|
+
"@mariozechner/pi-coding-agent": "^0.73.0",
|
|
45
|
+
"@mariozechner/pi-tui": "^0.73.0",
|
|
44
46
|
"@types/node": "^25.5.2",
|
|
45
47
|
"typescript": "^6.0.2",
|
|
46
48
|
"vitest": "^3.2.4"
|