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 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
- If you use [mise](https://mise.jdx.dev/):
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
- mise install
136
- mise run setup
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
- Without mise:
153
+ Override the binary if your Pi install is somewhere else:
143
154
 
144
155
  ```bash
145
- npm install
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
- const parsed = JSON.parse(readFileSync(configPath, "utf8")) as Partial<PolishedTuiConfig>;
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
- let currentEditor: PolishedEditor | undefined;
271
- let autocompleteFixed = false;
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
+ }
@@ -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 originalUserMessageRender = UserMessageComponent.prototype.render;
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 = UserMessageComponent.prototype as {
55
- render(width: number): string[];
93
+ const prototype = userMessagePrototype();
94
+ const patchState = prototype[userMessagePatchKey] ?? {
95
+ originalRender: prototype.render,
96
+ patchedRender: renderPatchedUserMessage,
56
97
  };
57
- prototype.render = function (this: UserMessageComponent, width: number): string[] {
58
- if (!currentUiTheme) {
59
- return originalUserMessageRender.call(this, width);
60
- }
98
+ patchState.patchedRender = renderPatchedUserMessage;
61
99
 
62
- const railWidth = 2;
63
- const innerWidth = Math.max(1, width - railWidth);
64
- const baseLines = Container.prototype.render.call(this, innerWidth) as string[];
65
- if (baseLines.length === 0) return baseLines;
100
+ Object.defineProperty(prototype, userMessagePatchKey, {
101
+ value: patchState,
102
+ configurable: true,
103
+ });
104
+ prototype.render = renderPatchedUserMessage;
105
+ }
66
106
 
67
- const hasLeadingSpacer = baseLines.length > 1 && visibleWidth(baseLines[0] ?? "") === 0;
68
- const leadingLines = hasLeadingSpacer ? [baseLines[0] ?? ""] : [];
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
- if (styledLines.length === 0) {
75
- return leadingLines;
76
- }
110
+ const prototype = userMessagePrototype();
111
+ const patchState = prototype[userMessagePatchKey];
112
+ if (!patchState || prototype.render !== patchState.patchedRender) return;
77
113
 
78
- const framedLines = [border, ...styledLines, border];
79
- framedLines[0] = OSC133_ZONE_START + framedLines[0];
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.2",
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.65.2",
42
- "@mariozechner/pi-coding-agent": "^0.65.2",
43
- "@mariozechner/pi-tui": "^0.65.2",
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"