pi-zentui 0.1.8 → 0.1.9

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
@@ -20,12 +20,12 @@ Zentui brings two popular aesthetics to Pi:
20
20
  - `󰝰 dirname` — current directory with icon
21
21
  - `on branch` — git branch with icon
22
22
  - `[!?↑]` — git status indicators (modified, untracked, ahead/behind, stashed, etc.)
23
- - `via v5.5.0` — runtime detection with version and Starship terminal styles for Nerd Font runtime/language modules
23
+ - `via v5.5.0` — runtime detection with version and Starship-style Nerd Font runtime/language modules
24
24
  - Right side shows context usage, token counts, and cost
25
25
 
26
26
  ### Editor (Opencode-inspired)
27
27
 
28
- - Bordered input box with accent-colored left rail
28
+ - Bordered input box with theme accent rail and thinking-level border color
29
29
  - Model name and provider displayed inside the editor frame
30
30
  - Thinking level indicator when enabled
31
31
  - Prompt-box-style user messages matching the ZentUI input chrome
@@ -47,7 +47,7 @@ Zentui brings two popular aesthetics to Pi:
47
47
 
48
48
  ### Runtime Detection
49
49
 
50
- Detects Starship Nerd Font runtime/language modules, uses the Starship Nerd Font symbols, and styles each runtime with Starship's terminal style strings (for example, Node.js uses `bold green`, so your terminal colorscheme supplies the actual green):
50
+ Detects Starship Nerd Font runtime/language modules, uses the Starship Nerd Font symbols, and keeps Starship-style defaults such as `bold green` for Node.js. By default Zentui maps those styles through your active Pi theme; switch the Starship/footer color source to `terminal` in `/zentui` if you want your terminal colorscheme to supply the exact ANSI colors.
51
51
 
52
52
  | Runtime/language | Detection examples |
53
53
  | ---------------- | ------------------------------------------------------------ |
@@ -148,15 +148,21 @@ On first run, Zentui creates a config file at:
148
148
  "typechanged": "T"
149
149
  },
150
150
  "colors": {
151
- "cwdText": "syntaxOperator",
152
- "git": "syntaxKeyword",
153
- "gitStatus": "error",
154
- "contextNormal": "muted",
155
- "contextWarning": "warning",
156
- "contextError": "error",
157
- "tokens": "muted",
158
- "cost": "success",
159
- "separator": "borderMuted"
151
+ "cwd": "bold cyan",
152
+ "gitBranch": "bold purple",
153
+ "gitStatus": "bold red",
154
+ "contextNormal": "dimmed",
155
+ "contextWarning": "bold yellow",
156
+ "contextError": "bold red",
157
+ "tokens": "dimmed",
158
+ "cost": "bold green",
159
+ "separator": "dimmed",
160
+ "runtimePrefix": ""
161
+ },
162
+ "colorSources": {
163
+ "starship": "theme",
164
+ "editor": "theme",
165
+ "userMessages": "theme"
160
166
  }
161
167
  }
162
168
  ```
@@ -165,12 +171,21 @@ On first run, Zentui creates a config file at:
165
171
 
166
172
  ### Color values
167
173
 
168
- Colors can be:
174
+ Use `/zentui` inside Pi to switch color sources between Pi theme colors and terminal colors:
175
+
176
+ - `starship` — footer/runtime/git/context/cost colors
177
+ - `editor + previous messages` — input editor and previous user-message rails/borders
178
+
179
+ Both settings default to `theme`. The config still stores editor and previous user-message sources separately as `editor` and `userMessages`, but `/zentui` changes them together so the prompt chrome stays consistent.
180
+
181
+ Color values can use terminal-palette style strings, hex colors, or Pi theme color tokens:
169
182
 
170
- - Pi theme token names (e.g., `accent`, `error`, `syntaxKeyword`)
171
- - Hex colors (e.g., `#89b4fa`)
183
+ - Named terminal colors: `bold purple`, `yellow`, `bright-cyan`
184
+ - 256-color / hex: `bold 149`, `fg:202`, `#89b4fa`
185
+ - Backgrounds and modifiers: `bg:blue fg:bright-green`, `underline bg:#bf5700`
186
+ - Pi theme tokens: `accent`, `borderMuted`, `syntaxKeyword`
172
187
 
173
- This means Zentui works with any Pi theme — it uses your theme's colors by default.
188
+ Use the `colors` config key for these values.
174
189
 
175
190
  ## Requirements
176
191
 
@@ -3,6 +3,13 @@ import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
 
5
5
  export type ColorSpec = string;
6
+ export type ColorSource = "theme" | "terminal";
7
+
8
+ export type ColorSourcesConfig = {
9
+ starship: ColorSource;
10
+ editor: ColorSource;
11
+ userMessages: ColorSource;
12
+ };
6
13
 
7
14
  const DEFAULT_PROJECT_REFRESH_INTERVAL_MS = 30_000;
8
15
  const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
@@ -25,8 +32,8 @@ export type PolishedTuiConfig = {
25
32
  typechanged: string;
26
33
  };
27
34
  colors: {
28
- cwdText: ColorSpec;
29
- git: ColorSpec;
35
+ cwd: ColorSpec;
36
+ gitBranch: ColorSpec;
30
37
  gitStatus: ColorSpec;
31
38
  contextNormal: ColorSpec;
32
39
  contextWarning: ColorSpec;
@@ -34,7 +41,9 @@ export type PolishedTuiConfig = {
34
41
  tokens: ColorSpec;
35
42
  cost: ColorSpec;
36
43
  separator: ColorSpec;
44
+ runtimePrefix: ColorSpec;
37
45
  };
46
+ colorSources: ColorSourcesConfig;
38
47
  };
39
48
 
40
49
  export const configPath = join(getAgentDir(), "zentui.json");
@@ -43,7 +52,7 @@ export const defaultConfig: PolishedTuiConfig = {
43
52
  projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
44
53
  icons: {
45
54
  cwd: "󰝰",
46
- git: "",
55
+ git: "",
47
56
  ahead: "↑",
48
57
  behind: "↓",
49
58
  diverged: "⇕",
@@ -57,15 +66,21 @@ export const defaultConfig: PolishedTuiConfig = {
57
66
  typechanged: "T",
58
67
  },
59
68
  colors: {
60
- cwdText: "syntaxOperator",
61
- git: "syntaxKeyword",
62
- gitStatus: "error",
63
- contextNormal: "muted",
64
- contextWarning: "warning",
65
- contextError: "error",
66
- tokens: "muted",
67
- cost: "success",
68
- separator: "borderMuted",
69
+ cwd: "bold cyan",
70
+ gitBranch: "bold purple",
71
+ gitStatus: "bold red",
72
+ contextNormal: "dimmed",
73
+ contextWarning: "bold yellow",
74
+ contextError: "bold red",
75
+ tokens: "dimmed",
76
+ cost: "bold green",
77
+ separator: "dimmed",
78
+ runtimePrefix: "",
79
+ },
80
+ colorSources: {
81
+ starship: "theme",
82
+ editor: "theme",
83
+ userMessages: "theme",
69
84
  },
70
85
  };
71
86
 
@@ -87,6 +102,75 @@ function parseProjectRefreshIntervalMs(value: unknown): number {
87
102
  : defaultConfig.projectRefreshIntervalMs;
88
103
  }
89
104
 
105
+ function stringValue(record: Record<string, unknown>, key: string): string | undefined {
106
+ const value = record[key];
107
+ return typeof value === "string" ? value : undefined;
108
+ }
109
+
110
+ function colorSourceValue(
111
+ record: Record<string, unknown>,
112
+ key: keyof ColorSourcesConfig,
113
+ ): ColorSource {
114
+ const value = record[key];
115
+ return value === "terminal" || value === "theme" ? value : defaultConfig.colorSources[key];
116
+ }
117
+
118
+ function definedColors(
119
+ colors: Partial<Record<keyof PolishedTuiConfig["colors"], string | undefined>>,
120
+ ): Partial<PolishedTuiConfig["colors"]> {
121
+ return Object.fromEntries(
122
+ Object.entries(colors).filter(
123
+ (entry): entry is [keyof PolishedTuiConfig["colors"], string] => typeof entry[1] === "string",
124
+ ),
125
+ ) as Partial<PolishedTuiConfig["colors"]>;
126
+ }
127
+
128
+ function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiConfig["colors"]> {
129
+ return definedColors({
130
+ cwd: stringValue(record, "cwd") ?? stringValue(record, "cwdText"),
131
+ gitBranch: stringValue(record, "gitBranch") ?? stringValue(record, "git"),
132
+ gitStatus: stringValue(record, "gitStatus"),
133
+ contextNormal: stringValue(record, "contextNormal"),
134
+ contextWarning: stringValue(record, "contextWarning"),
135
+ contextError: stringValue(record, "contextError"),
136
+ tokens: stringValue(record, "tokens"),
137
+ cost: stringValue(record, "cost"),
138
+ separator: stringValue(record, "separator"),
139
+ runtimePrefix: stringValue(record, "runtimePrefix"),
140
+ });
141
+ }
142
+
143
+ function normalizeColorSources(record: Record<string, unknown>): ColorSourcesConfig {
144
+ return {
145
+ starship: colorSourceValue(record, "starship"),
146
+ editor: colorSourceValue(record, "editor"),
147
+ userMessages: colorSourceValue(record, "userMessages"),
148
+ };
149
+ }
150
+
151
+ function isColorSourceKey(value: string): value is keyof ColorSourcesConfig {
152
+ return value === "starship" || value === "editor" || value === "userMessages";
153
+ }
154
+
155
+ function validColorSourceEntries(record: Record<string, unknown>): Partial<ColorSourcesConfig> {
156
+ return Object.fromEntries(
157
+ Object.entries(record).filter((entry): entry is [keyof ColorSourcesConfig, ColorSource] => {
158
+ const [key, value] = entry;
159
+ return isColorSourceKey(key) && (value === "theme" || value === "terminal");
160
+ }),
161
+ ) as Partial<ColorSourcesConfig>;
162
+ }
163
+
164
+ function readConfigRecord(path = configPath): ConfigRecord {
165
+ try {
166
+ if (!existsSync(path)) return {};
167
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
168
+ return isRecord(parsed) ? parsed : {};
169
+ } catch {
170
+ return {};
171
+ }
172
+ }
173
+
90
174
  export function ensureConfigExists(): void {
91
175
  try {
92
176
  if (!existsSync(configPath)) {
@@ -101,8 +185,11 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
101
185
  const config = isRecord(parsed) ? parsed : {};
102
186
  const icons = isRecord(config.icons) ? (config.icons as Partial<PolishedTuiConfig["icons"]>) : {};
103
187
  const colors = isRecord(config.colors)
104
- ? (config.colors as Partial<PolishedTuiConfig["colors"]>)
188
+ ? normalizeColors(config.colors as Record<string, unknown>)
105
189
  : {};
190
+ const colorSources = isRecord(config.colorSources)
191
+ ? normalizeColorSources(config.colorSources as Record<string, unknown>)
192
+ : defaultConfig.colorSources;
106
193
  return {
107
194
  projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
108
195
  icons: {
@@ -113,6 +200,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
113
200
  ...defaultConfig.colors,
114
201
  ...colors,
115
202
  },
203
+ colorSources,
116
204
  };
117
205
  }
118
206
 
@@ -124,3 +212,20 @@ export function loadConfig(): PolishedTuiConfig {
124
212
  return defaultConfig;
125
213
  }
126
214
  }
215
+
216
+ export function saveColorSourcesPatch(
217
+ patch: Partial<ColorSourcesConfig>,
218
+ path = configPath,
219
+ ): PolishedTuiConfig {
220
+ const record = readConfigRecord(path);
221
+ const existing = isRecord(record.colorSources)
222
+ ? validColorSourceEntries(record.colorSources as Record<string, unknown>)
223
+ : {};
224
+ record.colorSources = {
225
+ ...defaultConfig.colorSources,
226
+ ...existing,
227
+ ...validColorSourceEntries(patch),
228
+ };
229
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
230
+ return mergeConfig(record);
231
+ }
@@ -3,12 +3,12 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import type { PolishedTuiConfig } from "./config";
4
4
  import { formatCwdLabel, formatRuntimeSegment } from "./format";
5
5
  import type { FooterState } from "./state";
6
- import { colorize } from "./style";
6
+ import { renderStyleForSource } from "./style";
7
7
 
8
8
  export function installFooter(
9
9
  ctx: ExtensionContext,
10
10
  state: FooterState,
11
- config: PolishedTuiConfig,
11
+ getConfig: () => PolishedTuiConfig,
12
12
  hooks: {
13
13
  setRequestRender: (fn: (() => void) | undefined) => void;
14
14
  scheduleProjectRefresh: (ctx: ExtensionContext) => void;
@@ -20,7 +20,6 @@ export function installFooter(
20
20
  hooks.scheduleProjectRefresh(ctx);
21
21
  tui.requestRender();
22
22
  });
23
- const separator = colorize(theme, config.colors.separator, " | ");
24
23
 
25
24
  return {
26
25
  dispose: () => {
@@ -30,10 +29,14 @@ export function installFooter(
30
29
  invalidate() {},
31
30
  render(width: number): string[] {
32
31
  if (width <= 0) return [""];
32
+ const config = getConfig();
33
+ const colorSource = config.colorSources.starship;
34
+ const separator = renderStyleForSource(theme, colorSource, config.colors.separator, " | ");
33
35
  const innerWidth = Math.max(1, width - 2);
34
- const cwdLabel = colorize(
36
+ const cwdLabel = renderStyleForSource(
35
37
  theme,
36
- config.colors.cwdText,
38
+ colorSource,
39
+ config.colors.cwd,
37
40
  formatCwdLabel(ctx.cwd, config.icons.cwd),
38
41
  );
39
42
  const branch = state.branch;
@@ -46,8 +49,10 @@ export function installFooter(
46
49
  ? config.colors.contextWarning
47
50
  : config.colors.contextNormal
48
51
  : config.colors.contextNormal;
49
- const gitColor = (text: string) => colorize(theme, config.colors.git, text);
50
- const gitStatusColor = (text: string) => colorize(theme, config.colors.gitStatus, text);
52
+ const gitColor = (text: string) =>
53
+ renderStyleForSource(theme, colorSource, config.colors.gitBranch, text);
54
+ const gitStatusColor = (text: string) =>
55
+ renderStyleForSource(theme, colorSource, config.colors.gitStatus, text);
51
56
  const gitIcon = gitColor(config.icons.git);
52
57
  const allStatus = [
53
58
  state.conflicted > 0 ? config.icons.conflicted : "",
@@ -70,15 +75,20 @@ export function installFooter(
70
75
  const statusBlock =
71
76
  allStatus || aheadBehind ? gitStatusColor(`[${allStatus}${aheadBehind}]`) : "";
72
77
  const branchLabel = branch
73
- ? `${colorize(theme, "text", "on")} ${gitIcon} ${gitColor(branch)}${statusBlock ? ` ${statusBlock}` : ""}`
78
+ ? `on ${gitIcon} ${gitColor(branch)}${statusBlock ? ` ${statusBlock}` : ""}`
74
79
  : "";
75
- const runtimeLabel = formatRuntimeSegment(theme, state.runtime, "text");
80
+ const runtimeLabel = formatRuntimeSegment(
81
+ theme,
82
+ state.runtime,
83
+ config.colors.runtimePrefix,
84
+ colorSource,
85
+ );
76
86
 
77
87
  const left = [cwdLabel, branchLabel, runtimeLabel].filter(Boolean).join(" ");
78
88
  const right = [
79
- colorize(theme, contextColor, state.contextLabel),
80
- colorize(theme, config.colors.tokens, state.tokenLabel),
81
- colorize(theme, config.colors.cost, state.costLabel),
89
+ renderStyleForSource(theme, colorSource, contextColor, state.contextLabel),
90
+ renderStyleForSource(theme, colorSource, config.colors.tokens, state.tokenLabel),
91
+ renderStyleForSource(theme, colorSource, config.colors.cost, state.costLabel),
82
92
  ].join(separator);
83
93
 
84
94
  const leftWidth = visibleWidth(left);
@@ -1,7 +1,8 @@
1
1
  import type { AssistantMessage } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
3
+ import type { ColorSource, ColorSpec } from "./config";
3
4
  import type { RuntimeInfo } from "./runtime";
4
- import { type ThemeLike, colorize, renderTerminalStyle } from "./style";
5
+ import { renderStyleForSource } from "./style";
5
6
 
6
7
  export type UsageTotals = {
7
8
  input: number;
@@ -70,11 +71,12 @@ export function buildContextLabel(ctx: ExtensionContext): string {
70
71
  export function formatRuntimeSegment(
71
72
  theme: Pick<Theme, "fg">,
72
73
  runtime: RuntimeInfo | undefined,
73
- mutedColor: string,
74
+ prefixStyle: ColorSpec,
75
+ colorSource: ColorSource,
74
76
  ): string {
75
77
  if (!runtime) return "";
76
78
  const label = runtime.version ? `${runtime.symbol} ${runtime.version}` : runtime.symbol;
77
- return `${colorize(theme, mutedColor, "via")} ${renderTerminalStyle(runtime.style, label)}`;
79
+ return `${renderStyleForSource(theme, colorSource, prefixStyle, "via")} ${renderStyleForSource(theme, colorSource, runtime.style, label)}`;
78
80
  }
79
81
 
80
82
  export function formatCwdLabel(cwd: string, cwdIcon: string): string {
@@ -5,12 +5,21 @@ import type {
5
5
  Theme,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
8
- import { type PolishedTuiConfig, ensureConfigExists, loadConfig } from "./config";
8
+ import {
9
+ type ColorSourcesConfig,
10
+ type PolishedTuiConfig,
11
+ ensureConfigExists,
12
+ loadConfig,
13
+ saveColorSourcesPatch,
14
+ } from "./config";
9
15
  import { installFooter } from "./footer";
10
16
  import { emptyGitStatus, readGitStatus } from "./git";
11
17
  import { type StopProjectRefreshInterval, startProjectRefreshInterval } from "./project-refresh";
12
18
  import { readRuntimeInfo } from "./runtime";
19
+ import { installSelectorBorderStyle } from "./selector-border";
20
+ import { registerZentuiSettingsCommand } from "./settings-command";
13
21
  import { type FooterState, createInitialState, syncState } from "./state";
22
+ import { renderAccentLine } from "./style";
14
23
  import { PolishedEditor } from "./ui";
15
24
  import { installUserMessageStyle } from "./user-message";
16
25
 
@@ -21,11 +30,14 @@ export default function (pi: ExtensionAPI) {
21
30
  let activeTheme: Theme | undefined;
22
31
  let requestFooterRender: (() => void) | undefined;
23
32
  let stopRefreshInterval: StopProjectRefreshInterval = () => {};
33
+ let cleanupPrototypePatches: () => void = () => {};
24
34
  let projectRefreshInFlight = false;
25
35
  let projectRefreshPending = false;
26
36
 
27
37
  const refresh = () => requestFooterRender?.();
28
38
  const getActiveTheme = () => activeTheme;
39
+ const getCurrentConfig = () => currentConfig;
40
+ const getThinkingLevel = () => pi.getThinkingLevel();
29
41
 
30
42
  const refreshProjectState = async (ctx: ExtensionContext) => {
31
43
  const [gitStatus, runtime] = await Promise.all([
@@ -68,12 +80,17 @@ export default function (pi: ExtensionAPI) {
68
80
  theme,
69
81
  keybindings,
70
82
  ctx.ui.theme,
83
+ getCurrentConfig,
71
84
  () =>
72
85
  [
73
- ctx.ui.theme.fg("accent", state.modelLabel),
86
+ renderAccentLine(
87
+ ctx.ui.theme,
88
+ getCurrentConfig().colorSources.editor,
89
+ state.modelLabel,
90
+ ),
74
91
  ctx.ui.theme.fg("text", state.providerLabel),
75
92
  ].join(ctx.ui.theme.fg("borderMuted", " ")),
76
- () => pi.getThinkingLevel(),
93
+ getThinkingLevel,
77
94
  ),
78
95
  );
79
96
  };
@@ -81,18 +98,25 @@ export default function (pi: ExtensionAPI) {
81
98
  const installUi = (ctx: ExtensionContext) => {
82
99
  if (!ctx.hasUI) return;
83
100
  activeTheme = ctx.ui.theme;
84
- installUserMessageStyle(getActiveTheme);
101
+ cleanupPrototypePatches();
102
+ const cleanupSelectorBorderStyle = installSelectorBorderStyle(getActiveTheme, getCurrentConfig);
103
+ const cleanupUserMessageStyle = installUserMessageStyle(getActiveTheme, getCurrentConfig);
104
+ cleanupPrototypePatches = () => {
105
+ cleanupSelectorBorderStyle();
106
+ cleanupUserMessageStyle();
107
+ };
85
108
  ensureConfigExists();
86
109
  currentConfig = loadConfig();
87
110
  syncState(state, ctx);
88
- installFooter(ctx, state, currentConfig, {
111
+ stopRefreshInterval();
112
+ stopRefreshInterval = () => {};
113
+ installFooter(ctx, state, getCurrentConfig, {
89
114
  setRequestRender: (fn) => {
90
115
  requestFooterRender = fn;
91
116
  },
92
117
  scheduleProjectRefresh,
93
118
  });
94
119
  installEditor(ctx);
95
- stopRefreshInterval();
96
120
  stopRefreshInterval = startProjectRefreshInterval(currentConfig.projectRefreshIntervalMs, () =>
97
121
  scheduleProjectRefresh(ctx),
98
122
  );
@@ -101,6 +125,8 @@ export default function (pi: ExtensionAPI) {
101
125
  };
102
126
 
103
127
  const cleanupUi = (ctx?: ExtensionContext) => {
128
+ cleanupPrototypePatches();
129
+ cleanupPrototypePatches = () => {};
104
130
  stopRefreshInterval();
105
131
  stopRefreshInterval = () => {};
106
132
  projectRefreshInFlight = false;
@@ -117,6 +143,16 @@ export default function (pi: ExtensionAPI) {
117
143
  installUi(ctx);
118
144
  });
119
145
 
146
+ registerZentuiSettingsCommand(pi, {
147
+ getConfig: getCurrentConfig,
148
+ setColorSources(patch: Partial<ColorSourcesConfig>) {
149
+ currentConfig = saveColorSourcesPatch(patch);
150
+ },
151
+ requestRender() {
152
+ refresh();
153
+ },
154
+ });
155
+
120
156
  pi.on("session_shutdown", async (_event, ctx) => {
121
157
  cleanupUi(ctx);
122
158
  });
@@ -133,6 +169,10 @@ export default function (pi: ExtensionAPI) {
133
169
  refreshInteractiveState(ctx);
134
170
  });
135
171
 
172
+ pi.on("thinking_level_select", async (_event, ctx) => {
173
+ refreshInteractiveState(ctx);
174
+ });
175
+
136
176
  pi.on("message_end", async (_event, ctx) => {
137
177
  refreshInteractiveState(ctx, true);
138
178
  });
@@ -0,0 +1,106 @@
1
+ import {
2
+ ModelSelectorComponent,
3
+ SettingsSelectorComponent,
4
+ type Theme,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type { PolishedTuiConfig } from "./config";
7
+ import { EDITOR_BORDER_STYLE, renderChromeBorder, renderEditorBorder } from "./style";
8
+
9
+ type RenderFn = (width: number) => string[];
10
+
11
+ type PatchableSelectorPrototype = {
12
+ render: RenderFn;
13
+ __zentuiSelectorBorderOriginalRender?: RenderFn;
14
+ __zentuiSelectorBorderPatched?: boolean;
15
+ __zentuiSelectorBorderWrapper?: RenderFn;
16
+ __zentuiSelectorBorderActive?: boolean;
17
+ __zentuiSelectorBorderGetTheme?: () => Theme | undefined;
18
+ __zentuiSelectorBorderGetConfig?: () => PolishedTuiConfig;
19
+ };
20
+
21
+ type Cleanup = () => void;
22
+
23
+ function stripAnsi(text: string): string {
24
+ return text.replaceAll(/\x1b\[[0-9;]*m/g, "");
25
+ }
26
+
27
+ function isHorizontalBorderLine(line: string): boolean {
28
+ return /^─+$/.test(stripAnsi(line));
29
+ }
30
+
31
+ function renderBorderLine(
32
+ width: number,
33
+ theme: Theme | undefined,
34
+ config: PolishedTuiConfig | undefined,
35
+ ): string {
36
+ const text = "─".repeat(Math.max(1, width));
37
+ if (theme && config) {
38
+ return renderChromeBorder(theme, config.colorSources.editor, EDITOR_BORDER_STYLE, text);
39
+ }
40
+ return renderEditorBorder(text);
41
+ }
42
+
43
+ export function patchSelectorBorderStyle(
44
+ prototype: PatchableSelectorPrototype,
45
+ getTheme?: () => Theme | undefined,
46
+ getConfig?: () => PolishedTuiConfig,
47
+ ): Cleanup {
48
+ prototype.__zentuiSelectorBorderGetTheme = getTheme;
49
+ prototype.__zentuiSelectorBorderGetConfig = getConfig;
50
+ prototype.__zentuiSelectorBorderActive = true;
51
+
52
+ if (
53
+ prototype.__zentuiSelectorBorderPatched &&
54
+ prototype.render === prototype.__zentuiSelectorBorderWrapper
55
+ ) {
56
+ return () => {
57
+ prototype.__zentuiSelectorBorderActive = false;
58
+ };
59
+ }
60
+
61
+ prototype.__zentuiSelectorBorderOriginalRender = prototype.render;
62
+ const wrapper = function renderWithZentuiSelectorBorders(this: unknown, width: number): string[] {
63
+ const original = prototype.__zentuiSelectorBorderOriginalRender ?? prototype.render;
64
+ if (!prototype.__zentuiSelectorBorderActive) return original.call(this, width);
65
+
66
+ const lines = original.call(this, width);
67
+ if (lines.length === 0 || width <= 0) return lines;
68
+
69
+ return lines.map((line, index) => {
70
+ if (index !== 0 && index !== lines.length - 1) return line;
71
+ if (!isHorizontalBorderLine(line)) return line;
72
+ return renderBorderLine(
73
+ width,
74
+ prototype.__zentuiSelectorBorderGetTheme?.(),
75
+ prototype.__zentuiSelectorBorderGetConfig?.(),
76
+ );
77
+ });
78
+ };
79
+ prototype.__zentuiSelectorBorderWrapper = wrapper;
80
+ prototype.render = wrapper;
81
+ prototype.__zentuiSelectorBorderPatched = true;
82
+
83
+ return () => {
84
+ prototype.__zentuiSelectorBorderActive = false;
85
+ };
86
+ }
87
+
88
+ export function installSelectorBorderStyle(
89
+ getTheme?: () => Theme | undefined,
90
+ getConfig?: () => PolishedTuiConfig,
91
+ ): Cleanup {
92
+ const cleanupModel = patchSelectorBorderStyle(
93
+ ModelSelectorComponent.prototype as unknown as PatchableSelectorPrototype,
94
+ getTheme,
95
+ getConfig,
96
+ );
97
+ const cleanupSettings = patchSelectorBorderStyle(
98
+ SettingsSelectorComponent.prototype as unknown as PatchableSelectorPrototype,
99
+ getTheme,
100
+ getConfig,
101
+ );
102
+ return () => {
103
+ cleanupModel();
104
+ cleanupSettings();
105
+ };
106
+ }
@@ -0,0 +1,121 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ type SettingItem,
5
+ SettingsList,
6
+ type SettingsListTheme,
7
+ truncateToWidth,
8
+ } from "@earendil-works/pi-tui";
9
+ import type { ColorSource, ColorSourcesConfig, PolishedTuiConfig } from "./config";
10
+ import { EDITOR_BORDER_STYLE, renderChromeBorder } from "./style";
11
+
12
+ const colorSourceValues: ColorSource[] = ["theme", "terminal"];
13
+
14
+ type SettingId = "starship" | "editorMessages";
15
+
16
+ type SettingsCommandDeps = {
17
+ getConfig: () => PolishedTuiConfig;
18
+ setColorSources: (patch: Partial<ColorSourcesConfig>) => void;
19
+ requestRender: () => void;
20
+ settingsListTheme?: SettingsListTheme;
21
+ };
22
+
23
+ const settingLabels: Record<SettingId, string> = {
24
+ starship: "Starship/footer colors",
25
+ editorMessages: "Editor + previous messages",
26
+ };
27
+
28
+ const settingDescriptions: Record<SettingId, string> = {
29
+ starship:
30
+ "Choose whether footer runtime/git/context colors use Pi theme tokens or terminal palette styles.",
31
+ editorMessages:
32
+ "Choose whether editor and previous user-message borders/rails use Pi theme colors or terminal palette styles.",
33
+ };
34
+
35
+ function isColorSource(value: string): value is ColorSource {
36
+ return value === "theme" || value === "terminal";
37
+ }
38
+
39
+ function isSettingId(value: string): value is SettingId {
40
+ return value === "starship" || value === "editorMessages";
41
+ }
42
+
43
+ function editorMessageValue(config: PolishedTuiConfig): ColorSource | "mixed" {
44
+ return config.colorSources.editor === config.colorSources.userMessages
45
+ ? config.colorSources.editor
46
+ : "mixed";
47
+ }
48
+
49
+ function patchForSetting(id: SettingId, value: ColorSource): Partial<ColorSourcesConfig> {
50
+ return id === "starship" ? { starship: value } : { editor: value, userMessages: value };
51
+ }
52
+
53
+ function buildItems(config: PolishedTuiConfig): SettingItem[] {
54
+ return (Object.keys(settingLabels) as SettingId[]).map((key) => ({
55
+ id: key,
56
+ label: settingLabels[key],
57
+ description: settingDescriptions[key],
58
+ currentValue: key === "starship" ? config.colorSources.starship : editorMessageValue(config),
59
+ values: colorSourceValues,
60
+ }));
61
+ }
62
+
63
+ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCommandDeps): void {
64
+ pi.registerCommand("zentui", {
65
+ description: "Configure Zentui colors",
66
+ handler: async (_args, ctx) => {
67
+ if (!ctx.hasUI) return;
68
+
69
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
70
+ const settingsList = new SettingsList(
71
+ buildItems(deps.getConfig()),
72
+ 5,
73
+ deps.settingsListTheme ?? getSettingsListTheme(),
74
+ (id, newValue) => {
75
+ if (!isSettingId(id) || !isColorSource(newValue)) return;
76
+
77
+ try {
78
+ deps.setColorSources(patchForSetting(id, newValue));
79
+ settingsList.updateValue(id, newValue);
80
+ deps.requestRender();
81
+ ctx.ui.notify(`${settingLabels[id]}: ${newValue}`, "info");
82
+ tui.requestRender();
83
+ } catch (error) {
84
+ const message = error instanceof Error ? error.message : String(error);
85
+ ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
86
+ }
87
+ },
88
+ () => done(undefined),
89
+ );
90
+
91
+ return {
92
+ render(width: number) {
93
+ const colorSource = deps.getConfig().colorSources.editor;
94
+ const border = renderChromeBorder(
95
+ theme,
96
+ colorSource,
97
+ EDITOR_BORDER_STYLE,
98
+ "─".repeat(Math.max(0, width)),
99
+ );
100
+ const header = theme.fg("accent", theme.bold("Zentui settings"));
101
+ const hint = theme.fg("dim", "Enter/Space cycles values · Esc closes");
102
+ return [
103
+ truncateToWidth(border, width, ""),
104
+ truncateToWidth(header, width, ""),
105
+ truncateToWidth(hint, width, ""),
106
+ "",
107
+ ...settingsList.render(width),
108
+ truncateToWidth(border, width, ""),
109
+ ];
110
+ },
111
+ invalidate() {
112
+ settingsList.invalidate();
113
+ },
114
+ handleInput(data: string) {
115
+ settingsList.handleInput(data);
116
+ },
117
+ };
118
+ });
119
+ },
120
+ });
121
+ }
@@ -1,11 +1,17 @@
1
- import type { ColorSpec } from "./config";
1
+ import type { ColorSource, ColorSpec } from "./config";
2
2
 
3
3
  type ThemeLike = {
4
4
  fg(color: string, text: string): string;
5
+ bold?: (text: string) => string;
6
+ italic?: (text: string) => string;
7
+ underline?: (text: string) => string;
5
8
  };
6
9
 
7
10
  export type { ThemeLike };
8
11
 
12
+ export const EDITOR_ACCENT_STYLE = "blue";
13
+ export const EDITOR_BORDER_STYLE = "bright-black";
14
+
9
15
  function isHexColor(value: string): boolean {
10
16
  return /^#(?:[0-9a-fA-F]{6})$/.test(value);
11
17
  }
@@ -45,33 +51,98 @@ const terminalStyleModifiers = new Map([
45
51
  ["underline", 4],
46
52
  ]);
47
53
 
48
- function terminalColorToAnsi(color: string): string | undefined {
54
+ const themeColorNameMap = new Map([
55
+ ["red", "error"],
56
+ ["bright-red", "error"],
57
+ ["green", "success"],
58
+ ["bright-green", "success"],
59
+ ["yellow", "warning"],
60
+ ["bright-yellow", "warning"],
61
+ ["blue", "syntaxFunction"],
62
+ ["bright-blue", "syntaxFunction"],
63
+ ["cyan", "syntaxFunction"],
64
+ ["bright-cyan", "syntaxFunction"],
65
+ ["purple", "syntaxKeyword"],
66
+ ["bright-purple", "syntaxKeyword"],
67
+ ["black", "muted"],
68
+ ["bright-black", "muted"],
69
+ ["white", "text"],
70
+ ["bright-white", "text"],
71
+ ]);
72
+
73
+ const themeStyleModifiers = new Set(["bold", "italic", "underline"]);
74
+
75
+ function terminalColorToAnsi(color: string, isBackground = false): string | undefined {
49
76
  const normalized = color.toLowerCase();
50
77
  const colorCode = terminalColorCodes.get(normalized);
51
- if (colorCode !== undefined) return `${colorCode}`;
78
+ if (colorCode !== undefined) return `${isBackground ? colorCode + 10 : colorCode}`;
52
79
 
53
80
  if (/^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(normalized)) {
54
- return `38;5;${normalized}`;
81
+ return `${isBackground ? 48 : 38};5;${normalized}`;
55
82
  }
56
83
 
57
- if (isHexColor(normalized)) return hexToAnsi(normalized).slice(2, -1);
84
+ if (isHexColor(normalized)) return hexToAnsi(normalized, isBackground).slice(2, -1);
58
85
  return undefined;
59
86
  }
60
87
 
88
+ function isExplicitTerminalColorToken(token: string): boolean {
89
+ const normalized = token.toLowerCase();
90
+ if (normalized.startsWith("fg:") || normalized.startsWith("bg:")) return true;
91
+ if (isHexColor(normalized)) return true;
92
+ return /^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(normalized);
93
+ }
94
+
95
+ function applyThemeModifiers(theme: ThemeLike, styleTokens: string[], text: string): string {
96
+ let rendered = text;
97
+ for (const token of styleTokens) {
98
+ const normalized = token.toLowerCase();
99
+ if (normalized === "bold") rendered = theme.bold?.(rendered) ?? rendered;
100
+ if (normalized === "italic") rendered = theme.italic?.(rendered) ?? rendered;
101
+ if (normalized === "underline") rendered = theme.underline?.(rendered) ?? rendered;
102
+ }
103
+ return rendered;
104
+ }
105
+
106
+ function themeFg(theme: ThemeLike, color: string, text: string): string {
107
+ try {
108
+ return theme.fg(color, text);
109
+ } catch {
110
+ return text;
111
+ }
112
+ }
113
+
114
+ function mapThemeColor(styleTokens: string[]): string | undefined {
115
+ let fallback: string | undefined;
116
+ for (const token of styleTokens) {
117
+ const normalized = token.toLowerCase();
118
+ if (themeStyleModifiers.has(normalized)) continue;
119
+ if (normalized === "dim" || normalized === "dimmed") {
120
+ fallback = "muted";
121
+ continue;
122
+ }
123
+
124
+ const mapped = themeColorNameMap.get(normalized);
125
+ if (mapped) return mapped;
126
+ return token;
127
+ }
128
+ return fallback;
129
+ }
130
+
61
131
  /**
62
132
  * Colorize text using a theme color token or hex color.
63
- * Non-hex values are passed directly to `theme.fg()` if the token
64
- * is valid it renders styled, otherwise the theme handles fallback.
133
+ * Non-hex values are passed directly to `theme.fg()`; invalid tokens fall back
134
+ * to unstyled text so a config typo does not break rendering.
65
135
  */
66
136
  export function colorize(theme: ThemeLike, color: ColorSpec, text: string): string {
67
137
  if (isHexColor(color)) {
68
138
  return `${hexToAnsi(color)}${text}\x1b[39m`;
69
139
  }
70
- return theme.fg(color, text);
140
+ return themeFg(theme, color, text);
71
141
  }
72
142
 
73
143
  /**
74
- * Render text with Starship-style terminal styling strings (e.g. "bold red", "fg:202").
144
+ * Render text with Starship-style terminal styling strings (e.g. "bold red", "fg:202",
145
+ * "bg:blue", "underline bg:#bf5700").
75
146
  */
76
147
  export function renderTerminalStyle(style: string, text: string): string {
77
148
  const codes: string[] = [];
@@ -85,10 +156,66 @@ export function renderTerminalStyle(style: string, text: string): string {
85
156
  continue;
86
157
  }
87
158
 
88
- const foreground = normalized.startsWith("fg:") ? normalized.slice(3) : normalized;
89
- const color = terminalColorToAnsi(foreground);
159
+ const isForeground = normalized.startsWith("fg:");
160
+ const isBackground = normalized.startsWith("bg:");
161
+ const colorName = isForeground || isBackground ? normalized.slice(3) : normalized;
162
+ const color = terminalColorToAnsi(colorName, isBackground);
90
163
  if (color) codes.push(color);
91
164
  }
92
165
 
93
166
  return codes.length ? `\x1b[${codes.join(";")}m${text}\x1b[0m` : text;
94
167
  }
168
+
169
+ /**
170
+ * Apply Starship-style terminal styling first, falling back to Pi theme tokens for
171
+ * legacy config values such as "accent" or "syntaxKeyword".
172
+ */
173
+ export function renderStyle(theme: ThemeLike, style: ColorSpec, text: string): string {
174
+ if (style.trim() === "") return text;
175
+ const styled = renderTerminalStyle(style, text);
176
+ return styled === text ? colorize(theme, style, text) : styled;
177
+ }
178
+
179
+ export function renderThemeStyle(theme: ThemeLike, style: ColorSpec, text: string): string {
180
+ const trimmed = style.trim();
181
+ if (trimmed === "") return text;
182
+
183
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
184
+ if (tokens.some(isExplicitTerminalColorToken)) return renderTerminalStyle(style, text);
185
+
186
+ const color = mapThemeColor(tokens) ?? "text";
187
+ return themeFg(theme, color, applyThemeModifiers(theme, tokens, text));
188
+ }
189
+
190
+ export function renderStyleForSource(
191
+ theme: ThemeLike,
192
+ source: ColorSource,
193
+ style: ColorSpec,
194
+ text: string,
195
+ ): string {
196
+ return source === "terminal"
197
+ ? renderStyle(theme, style, text)
198
+ : renderThemeStyle(theme, style, text);
199
+ }
200
+
201
+ export function renderEditorAccent(text: string): string {
202
+ return renderTerminalStyle(EDITOR_ACCENT_STYLE, text);
203
+ }
204
+
205
+ export function renderEditorBorder(text: string): string {
206
+ return renderTerminalStyle(EDITOR_BORDER_STYLE, text);
207
+ }
208
+
209
+ export function renderAccentLine(theme: ThemeLike, source: ColorSource, text: string): string {
210
+ return source === "terminal" ? renderEditorAccent(text) : themeFg(theme, "accent", text);
211
+ }
212
+
213
+ export function renderChromeBorder(
214
+ theme: ThemeLike,
215
+ source: ColorSource,
216
+ terminalFallbackStyle: ColorSpec,
217
+ text: string,
218
+ ): string {
219
+ if (source === "terminal") return renderTerminalStyle(terminalFallbackStyle, text);
220
+ return themeFg(theme, "borderMuted", text);
221
+ }
@@ -6,6 +6,8 @@ import {
6
6
  truncateToWidth,
7
7
  visibleWidth,
8
8
  } from "@earendil-works/pi-tui";
9
+ import type { PolishedTuiConfig } from "./config";
10
+ import { EDITOR_BORDER_STYLE, renderAccentLine, renderChromeBorder } from "./style";
9
11
 
10
12
  type AutocompleteEditorInternals = {
11
13
  autocompleteList?: Pick<Component, "render">;
@@ -20,6 +22,7 @@ function clampRenderedLines(lines: string[], width: number): string[] {
20
22
  export class PolishedEditor extends CustomEditor {
21
23
  private readonly getModelMeta: () => string;
22
24
  private readonly getThinkingLevel: () => string | undefined;
25
+ private readonly getConfig: () => PolishedTuiConfig;
23
26
  private readonly uiTheme: Theme;
24
27
  private readonly reset = "\x1b[0m";
25
28
 
@@ -28,12 +31,14 @@ export class PolishedEditor extends CustomEditor {
28
31
  theme: EditorTheme,
29
32
  keybindings: KeybindingsManager,
30
33
  uiTheme: Theme,
34
+ getConfig: () => PolishedTuiConfig,
31
35
  getModelMeta: () => string,
32
36
  getThinkingLevel: () => string | undefined,
33
37
  ) {
34
38
  super(tui, theme, keybindings, { paddingX: 0 });
35
39
  this.borderColor = (text: string) => uiTheme.fg("border", text);
36
40
  this.uiTheme = uiTheme;
41
+ this.getConfig = getConfig;
37
42
  this.getModelMeta = getModelMeta;
38
43
  this.getThinkingLevel = getThinkingLevel;
39
44
  }
@@ -87,9 +92,20 @@ export class PolishedEditor extends CustomEditor {
87
92
  }
88
93
  const meta = metaParts.filter(Boolean).join(this.uiTheme.fg("border", " "));
89
94
 
90
- const rail = `${this.uiTheme.fg("accent", "│")}${this.reset} `;
91
- const top = this.uiTheme.fg("border", "".repeat(width));
92
- const bottom = this.uiTheme.fg("border", "─".repeat(width));
95
+ const colorSource = this.getConfig().colorSources.editor;
96
+ const rail = `${renderAccentLine(this.uiTheme, colorSource, "")}${this.reset} `;
97
+ const top = renderChromeBorder(
98
+ this.uiTheme,
99
+ colorSource,
100
+ EDITOR_BORDER_STYLE,
101
+ "─".repeat(width),
102
+ );
103
+ const bottom = renderChromeBorder(
104
+ this.uiTheme,
105
+ colorSource,
106
+ EDITOR_BORDER_STYLE,
107
+ "─".repeat(width),
108
+ );
93
109
  const lines = ["", ...editorLines, "", meta];
94
110
  const renderedLines = [
95
111
  top,
@@ -5,6 +5,8 @@ import {
5
5
  truncateToWidth,
6
6
  visibleWidth,
7
7
  } from "@earendil-works/pi-tui";
8
+ import type { PolishedTuiConfig } from "./config";
9
+ import { EDITOR_BORDER_STYLE, renderAccentLine, renderChromeBorder } from "./style";
8
10
 
9
11
  const OSC133_ZONE_START = "\x1b]133;A\x07";
10
12
  const OSC133_ZONE_END = "\x1b]133;B\x07";
@@ -17,9 +19,14 @@ type PatchableUserMessagePrototype = {
17
19
  children?: unknown[];
18
20
  __zentuiUserMessageOriginalRender?: RenderFn;
19
21
  __zentuiUserMessagePatched?: boolean;
22
+ __zentuiUserMessageWrapper?: RenderFn;
23
+ __zentuiUserMessageActive?: boolean;
20
24
  __zentuiUserMessageGetTheme?: () => Theme | undefined;
25
+ __zentuiUserMessageGetConfig?: () => PolishedTuiConfig;
21
26
  };
22
27
 
28
+ type Cleanup = () => void;
29
+
23
30
  type MarkdownLike = {
24
31
  text?: unknown;
25
32
  };
@@ -73,9 +80,14 @@ function fillLine(content: string, width: number): string {
73
80
  return `${truncated}${pad}`;
74
81
  }
75
82
 
76
- function renderPromptBoxLine(line: string, width: number, theme: Theme | undefined): string {
83
+ function renderPromptBoxLine(
84
+ line: string,
85
+ width: number,
86
+ theme: Theme | undefined,
87
+ config: PolishedTuiConfig,
88
+ ): string {
77
89
  if (width <= 0) return "";
78
- const rail = `${themeFg(theme, "accent", "│")} `;
90
+ const rail = `${theme ? renderAccentLine(theme, config.colorSources.userMessages, "") : "│"} `;
79
91
  const contentWidth = Math.max(0, width - visibleWidth(rail));
80
92
  return truncateToWidth(`${rail}${fillLine(line, contentWidth)}`, width, "");
81
93
  }
@@ -84,42 +96,70 @@ function renderZentuiUserMessage(
84
96
  instance: PatchableUserMessagePrototype,
85
97
  width: number,
86
98
  theme: Theme | undefined,
99
+ config: PolishedTuiConfig,
87
100
  ): string[] | undefined {
88
101
  const text = findMarkdownText(instance);
89
102
  if (text === undefined) return undefined;
90
103
  if (width <= 0) return [""];
91
104
 
92
- const railWidth = visibleWidth(`${themeFg(theme, "accent", "│")} `);
105
+ const railWidth = visibleWidth(
106
+ `${theme ? renderAccentLine(theme, config.colorSources.userMessages, "│") : "│"} `,
107
+ );
93
108
  const contentWidth = Math.max(1, width - railWidth);
94
109
  const renderer = new Markdown(text, 0, 0, makeMarkdownTheme(theme), {
95
110
  color: (content) => themeFg(theme, "userMessageText", content),
96
111
  });
97
112
  const body = renderer.render(contentWidth);
98
113
  const contentLines = body.length > 0 ? body : [""];
99
- const border = themeFg(theme, "border", "─".repeat(width));
114
+ const border = theme
115
+ ? renderChromeBorder(
116
+ theme,
117
+ config.colorSources.userMessages,
118
+ EDITOR_BORDER_STYLE,
119
+ "─".repeat(width),
120
+ )
121
+ : "─".repeat(width);
100
122
 
101
123
  return [
102
124
  truncateToWidth(border, width, ""),
103
- renderPromptBoxLine("", width, theme),
104
- ...contentLines.map((line) => renderPromptBoxLine(line, width, theme)),
105
- renderPromptBoxLine("", width, theme),
125
+ renderPromptBoxLine("", width, theme, config),
126
+ ...contentLines.map((line) => renderPromptBoxLine(line, width, theme, config)),
127
+ renderPromptBoxLine("", width, theme, config),
106
128
  truncateToWidth(border, width, ""),
107
129
  ];
108
130
  }
109
131
 
110
- export function installUserMessageStyle(getTheme: () => Theme | undefined): void {
132
+ export function installUserMessageStyle(
133
+ getTheme: () => Theme | undefined,
134
+ getConfig: () => PolishedTuiConfig,
135
+ ): Cleanup {
111
136
  const prototype = UserMessageComponent.prototype as unknown as PatchableUserMessagePrototype;
112
137
  prototype.__zentuiUserMessageGetTheme = getTheme;
113
-
114
- if (prototype.__zentuiUserMessagePatched) return;
138
+ prototype.__zentuiUserMessageGetConfig = getConfig;
139
+ prototype.__zentuiUserMessageActive = true;
140
+
141
+ if (
142
+ prototype.__zentuiUserMessagePatched &&
143
+ prototype.render === prototype.__zentuiUserMessageWrapper
144
+ ) {
145
+ return () => {
146
+ prototype.__zentuiUserMessageActive = false;
147
+ };
148
+ }
115
149
 
116
150
  prototype.__zentuiUserMessageOriginalRender = prototype.render;
117
- prototype.render = function renderWithZentuiUserMessage(width: number): string[] {
151
+ const wrapper = function renderWithZentuiUserMessage(this: unknown, width: number): string[] {
118
152
  const original = prototype.__zentuiUserMessageOriginalRender ?? prototype.render;
153
+ if (!prototype.__zentuiUserMessageActive) return original.call(this, width);
154
+
155
+ const config = prototype.__zentuiUserMessageGetConfig?.();
156
+ if (!config) return original.call(this, width);
157
+
119
158
  const lines = renderZentuiUserMessage(
120
159
  this as PatchableUserMessagePrototype,
121
160
  width,
122
161
  prototype.__zentuiUserMessageGetTheme?.(),
162
+ config,
123
163
  );
124
164
 
125
165
  if (!lines) return original.call(this, width);
@@ -129,5 +169,11 @@ export function installUserMessageStyle(getTheme: () => Theme | undefined): void
129
169
  lines[lines.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + lines[lines.length - 1];
130
170
  return lines;
131
171
  };
172
+ prototype.__zentuiUserMessageWrapper = wrapper;
173
+ prototype.render = wrapper;
132
174
  prototype.__zentuiUserMessagePatched = true;
175
+
176
+ return () => {
177
+ prototype.__zentuiUserMessageActive = false;
178
+ };
133
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",