pi-zentui 0.1.8 → 0.1.10

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
  | ---------------- | ------------------------------------------------------------ |
@@ -121,13 +121,15 @@ pi install git:github.com/lmilojevicc/pi-zentui
121
121
 
122
122
  ## Config
123
123
 
124
- On first run, Zentui creates a config file at:
124
+ Zentui uses built-in defaults when no config file exists. User settings live at:
125
125
 
126
126
  ```
127
127
  ~/.pi/agent/zentui.json
128
128
  ```
129
129
 
130
- ### Default config
130
+ Zentui treats this file as user-owned and compatibility-sensitive: invalid known values fall back to runtime defaults, unknown keys are ignored at runtime, and `/zentui` patches only the settings it changes instead of rewriting the whole file.
131
+
132
+ ### Runtime defaults
131
133
 
132
134
  ```json
133
135
  {
@@ -148,15 +150,21 @@ On first run, Zentui creates a config file at:
148
150
  "typechanged": "T"
149
151
  },
150
152
  "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"
153
+ "cwd": "bold cyan",
154
+ "gitBranch": "bold purple",
155
+ "gitStatus": "bold red",
156
+ "contextNormal": "dimmed",
157
+ "contextWarning": "bold yellow",
158
+ "contextError": "bold red",
159
+ "tokens": "dimmed",
160
+ "cost": "bold green",
161
+ "separator": "dimmed",
162
+ "runtimePrefix": ""
163
+ },
164
+ "colorSources": {
165
+ "starship": "theme",
166
+ "editor": "theme",
167
+ "userMessages": "theme"
160
168
  }
161
169
  }
162
170
  ```
@@ -165,12 +173,21 @@ On first run, Zentui creates a config file at:
165
173
 
166
174
  ### Color values
167
175
 
168
- Colors can be:
176
+ Use `/zentui` inside Pi to switch color sources between Pi theme colors and terminal colors:
177
+
178
+ - `starship` — footer/runtime/git/context/cost colors
179
+ - `editor + previous messages` — input editor and previous user-message rails/borders
180
+
181
+ 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.
182
+
183
+ Color values can use terminal-palette style strings, hex colors, or Pi theme color tokens:
169
184
 
170
- - Pi theme token names (e.g., `accent`, `error`, `syntaxKeyword`)
171
- - Hex colors (e.g., `#89b4fa`)
185
+ - Named terminal colors: `bold purple`, `yellow`, `bright-cyan`
186
+ - 256-color / hex: `bold 149`, `fg:202`, `#89b4fa`
187
+ - Backgrounds and modifiers: `bg:blue fg:bright-green`, `underline bg:#bf5700`
188
+ - Pi theme tokens: `accent`, `borderMuted`, `syntaxKeyword`
172
189
 
173
- This means Zentui works with any Pi theme — it uses your theme's colors by default.
190
+ Use the `colors` config key for these values.
174
191
 
175
192
  ## Requirements
176
193
 
@@ -1,8 +1,16 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { isSupportedColorSpec } from "./style";
4
5
 
5
6
  export type ColorSpec = string;
7
+ export type ColorSource = "theme" | "terminal";
8
+
9
+ export type ColorSourcesConfig = {
10
+ starship: ColorSource;
11
+ editor: ColorSource;
12
+ userMessages: ColorSource;
13
+ };
6
14
 
7
15
  const DEFAULT_PROJECT_REFRESH_INTERVAL_MS = 30_000;
8
16
  const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
@@ -25,8 +33,8 @@ export type PolishedTuiConfig = {
25
33
  typechanged: string;
26
34
  };
27
35
  colors: {
28
- cwdText: ColorSpec;
29
- git: ColorSpec;
36
+ cwd: ColorSpec;
37
+ gitBranch: ColorSpec;
30
38
  gitStatus: ColorSpec;
31
39
  contextNormal: ColorSpec;
32
40
  contextWarning: ColorSpec;
@@ -34,7 +42,9 @@ export type PolishedTuiConfig = {
34
42
  tokens: ColorSpec;
35
43
  cost: ColorSpec;
36
44
  separator: ColorSpec;
45
+ runtimePrefix: ColorSpec;
37
46
  };
47
+ colorSources: ColorSourcesConfig;
38
48
  };
39
49
 
40
50
  export const configPath = join(getAgentDir(), "zentui.json");
@@ -43,7 +53,7 @@ export const defaultConfig: PolishedTuiConfig = {
43
53
  projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
44
54
  icons: {
45
55
  cwd: "󰝰",
46
- git: "",
56
+ git: "",
47
57
  ahead: "↑",
48
58
  behind: "↓",
49
59
  diverged: "⇕",
@@ -57,18 +67,40 @@ export const defaultConfig: PolishedTuiConfig = {
57
67
  typechanged: "T",
58
68
  },
59
69
  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",
70
+ cwd: "bold cyan",
71
+ gitBranch: "bold purple",
72
+ gitStatus: "bold red",
73
+ contextNormal: "dimmed",
74
+ contextWarning: "bold yellow",
75
+ contextError: "bold red",
76
+ tokens: "dimmed",
77
+ cost: "bold green",
78
+ separator: "dimmed",
79
+ runtimePrefix: "",
80
+ },
81
+ colorSources: {
82
+ starship: "theme",
83
+ editor: "theme",
84
+ userMessages: "theme",
69
85
  },
70
86
  };
71
87
 
88
+ const iconKeys = [
89
+ "cwd",
90
+ "git",
91
+ "ahead",
92
+ "behind",
93
+ "diverged",
94
+ "conflicted",
95
+ "untracked",
96
+ "stashed",
97
+ "modified",
98
+ "staged",
99
+ "renamed",
100
+ "deleted",
101
+ "typechanged",
102
+ ] as const satisfies readonly (keyof PolishedTuiConfig["icons"])[];
103
+
72
104
  type ConfigRecord = Record<string, unknown>;
73
105
 
74
106
  function isRecord(value: unknown): value is ConfigRecord {
@@ -87,22 +119,107 @@ function parseProjectRefreshIntervalMs(value: unknown): number {
87
119
  : defaultConfig.projectRefreshIntervalMs;
88
120
  }
89
121
 
90
- export function ensureConfigExists(): void {
122
+ function stringValue(record: Record<string, unknown>, key: string): string | undefined {
123
+ const value = record[key];
124
+ return typeof value === "string" ? value : undefined;
125
+ }
126
+
127
+ function colorValue(record: Record<string, unknown>, key: string): string | undefined {
128
+ const value = stringValue(record, key);
129
+ return value !== undefined && isSupportedColorSpec(value) ? value : undefined;
130
+ }
131
+
132
+ function colorSourceValue(
133
+ record: Record<string, unknown>,
134
+ key: keyof ColorSourcesConfig,
135
+ ): ColorSource {
136
+ const value = record[key];
137
+ return value === "terminal" || value === "theme" ? value : defaultConfig.colorSources[key];
138
+ }
139
+
140
+ function definedColors(
141
+ colors: Partial<Record<keyof PolishedTuiConfig["colors"], string | undefined>>,
142
+ ): Partial<PolishedTuiConfig["colors"]> {
143
+ return Object.fromEntries(
144
+ Object.entries(colors).filter(
145
+ (entry): entry is [keyof PolishedTuiConfig["colors"], string] => typeof entry[1] === "string",
146
+ ),
147
+ ) as Partial<PolishedTuiConfig["colors"]>;
148
+ }
149
+
150
+ function normalizeIcons(record: Record<string, unknown>): Partial<PolishedTuiConfig["icons"]> {
151
+ return Object.fromEntries(
152
+ iconKeys.flatMap((key) => {
153
+ const value = stringValue(record, key);
154
+ return value === undefined ? [] : [[key, value]];
155
+ }),
156
+ ) as Partial<PolishedTuiConfig["icons"]>;
157
+ }
158
+
159
+ function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiConfig["colors"]> {
160
+ return definedColors({
161
+ cwd: colorValue(record, "cwd") ?? colorValue(record, "cwdText"),
162
+ gitBranch: colorValue(record, "gitBranch") ?? colorValue(record, "git"),
163
+ gitStatus: colorValue(record, "gitStatus"),
164
+ contextNormal: colorValue(record, "contextNormal"),
165
+ contextWarning: colorValue(record, "contextWarning"),
166
+ contextError: colorValue(record, "contextError"),
167
+ tokens: colorValue(record, "tokens"),
168
+ cost: colorValue(record, "cost"),
169
+ separator: colorValue(record, "separator"),
170
+ runtimePrefix: colorValue(record, "runtimePrefix"),
171
+ });
172
+ }
173
+
174
+ function normalizeColorSources(record: Record<string, unknown>): ColorSourcesConfig {
175
+ return {
176
+ starship: colorSourceValue(record, "starship"),
177
+ editor: colorSourceValue(record, "editor"),
178
+ userMessages: colorSourceValue(record, "userMessages"),
179
+ };
180
+ }
181
+
182
+ function isColorSourceKey(value: string): value is keyof ColorSourcesConfig {
183
+ return value === "starship" || value === "editor" || value === "userMessages";
184
+ }
185
+
186
+ function validColorSourceEntries(record: Record<string, unknown>): Partial<ColorSourcesConfig> {
187
+ return Object.fromEntries(
188
+ Object.entries(record).filter((entry): entry is [keyof ColorSourcesConfig, ColorSource] => {
189
+ const [key, value] = entry;
190
+ return isColorSourceKey(key) && (value === "theme" || value === "terminal");
191
+ }),
192
+ ) as Partial<ColorSourcesConfig>;
193
+ }
194
+
195
+ function readConfigRecord(path = configPath): ConfigRecord {
91
196
  try {
92
- if (!existsSync(configPath)) {
93
- writeFileSync(configPath, `${JSON.stringify(defaultConfig, null, 2)}\n`, "utf8");
94
- }
197
+ if (!existsSync(path)) return {};
198
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
199
+ return isRecord(parsed) ? parsed : {};
95
200
  } catch {
96
- // Ignore config bootstrap failures; extension will fall back to defaults.
201
+ return {};
97
202
  }
98
203
  }
99
204
 
205
+ export function ensureConfigExists(): void {
206
+ // Intentionally left as a no-op. Zentui config is user-owned and
207
+ // compatibility-sensitive: runtime defaults come from `mergeConfig({})`, and
208
+ // the extension should not persist opinionated defaults unless the user
209
+ // explicitly changes a setting.
210
+ }
211
+
100
212
  export function mergeConfig(parsed: unknown): PolishedTuiConfig {
101
213
  const config = isRecord(parsed) ? parsed : {};
102
- const icons = isRecord(config.icons) ? (config.icons as Partial<PolishedTuiConfig["icons"]>) : {};
214
+ const icons = isRecord(config.icons)
215
+ ? normalizeIcons(config.icons as Record<string, unknown>)
216
+ : {};
103
217
  const colors = isRecord(config.colors)
104
- ? (config.colors as Partial<PolishedTuiConfig["colors"]>)
218
+ ? normalizeColors(config.colors as Record<string, unknown>)
105
219
  : {};
220
+ const colorSources = isRecord(config.colorSources)
221
+ ? normalizeColorSources(config.colorSources as Record<string, unknown>)
222
+ : defaultConfig.colorSources;
106
223
  return {
107
224
  projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
108
225
  icons: {
@@ -113,14 +230,31 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
113
230
  ...defaultConfig.colors,
114
231
  ...colors,
115
232
  },
233
+ colorSources: { ...colorSources },
116
234
  };
117
235
  }
118
236
 
119
237
  export function loadConfig(): PolishedTuiConfig {
120
238
  try {
121
- if (!existsSync(configPath)) return defaultConfig;
239
+ if (!existsSync(configPath)) return mergeConfig({});
122
240
  return mergeConfig(JSON.parse(readFileSync(configPath, "utf8")));
123
241
  } catch {
124
- return defaultConfig;
242
+ return mergeConfig({});
125
243
  }
126
244
  }
245
+
246
+ export function saveColorSourcesPatch(
247
+ patch: Partial<ColorSourcesConfig>,
248
+ path = configPath,
249
+ ): PolishedTuiConfig {
250
+ const record = readConfigRecord(path);
251
+ const existing = isRecord(record.colorSources)
252
+ ? { ...(record.colorSources as Record<string, unknown>) }
253
+ : {};
254
+ record.colorSources = {
255
+ ...existing,
256
+ ...validColorSourceEntries(patch),
257
+ };
258
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
259
+ return mergeConfig(record);
260
+ }
@@ -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,9 +49,11 @@ 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);
51
- const gitIcon = gitColor(config.icons.git);
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);
56
+ const gitIcon = config.icons.git ? gitColor(config.icons.git) : "";
52
57
  const allStatus = [
53
58
  state.conflicted > 0 ? config.icons.conflicted : "",
54
59
  state.stashed ? config.icons.stashed : "",
@@ -70,15 +75,22 @@ 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)].filter(Boolean), statusBlock]
79
+ .filter(Boolean)
80
+ .join(" ")
74
81
  : "";
75
- const runtimeLabel = formatRuntimeSegment(theme, state.runtime, "text");
82
+ const runtimeLabel = formatRuntimeSegment(
83
+ theme,
84
+ state.runtime,
85
+ config.colors.runtimePrefix,
86
+ colorSource,
87
+ );
76
88
 
77
89
  const left = [cwdLabel, branchLabel, runtimeLabel].filter(Boolean).join(" ");
78
90
  const right = [
79
- colorize(theme, contextColor, state.contextLabel),
80
- colorize(theme, config.colors.tokens, state.tokenLabel),
81
- colorize(theme, config.colors.cost, state.costLabel),
91
+ renderStyleForSource(theme, colorSource, contextColor, state.contextLabel),
92
+ renderStyleForSource(theme, colorSource, config.colors.tokens, state.tokenLabel),
93
+ renderStyleForSource(theme, colorSource, config.colors.cost, state.costLabel),
82
94
  ].join(separator);
83
95
 
84
96
  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, safeThemeFg } 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),
74
- ctx.ui.theme.fg("text", state.providerLabel),
75
- ].join(ctx.ui.theme.fg("borderMuted", " ")),
76
- () => pi.getThinkingLevel(),
86
+ renderAccentLine(
87
+ ctx.ui.theme,
88
+ getCurrentConfig().colorSources.editor,
89
+ state.modelLabel,
90
+ ),
91
+ safeThemeFg(ctx.ui.theme, "text", state.providerLabel),
92
+ ].join(safeThemeFg(ctx.ui.theme, "borderMuted", " ")),
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, safeThemeFg } 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 = safeThemeFg(theme, "accent", theme.bold("Zentui settings"));
101
+ const hint = safeThemeFg(theme, "muted", "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,18 @@
1
- import type { ColorSpec } from "./config";
1
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import type { ColorSource, ColorSpec } from "./config";
2
3
 
3
4
  type ThemeLike = {
4
5
  fg(color: string, text: string): string;
6
+ bold?: (text: string) => string;
7
+ italic?: (text: string) => string;
8
+ underline?: (text: string) => string;
5
9
  };
6
10
 
7
11
  export type { ThemeLike };
8
12
 
13
+ export const EDITOR_ACCENT_STYLE = "blue";
14
+ export const EDITOR_BORDER_STYLE = "bright-black";
15
+
9
16
  function isHexColor(value: string): boolean {
10
17
  return /^#(?:[0-9a-fA-F]{6})$/.test(value);
11
18
  }
@@ -45,33 +52,166 @@ const terminalStyleModifiers = new Map([
45
52
  ["underline", 4],
46
53
  ]);
47
54
 
48
- function terminalColorToAnsi(color: string): string | undefined {
55
+ const themeColorNameMap = new Map([
56
+ ["red", "error"],
57
+ ["bright-red", "error"],
58
+ ["green", "success"],
59
+ ["bright-green", "success"],
60
+ ["yellow", "warning"],
61
+ ["bright-yellow", "warning"],
62
+ ["blue", "syntaxFunction"],
63
+ ["bright-blue", "syntaxFunction"],
64
+ ["cyan", "syntaxFunction"],
65
+ ["bright-cyan", "syntaxFunction"],
66
+ ["purple", "syntaxKeyword"],
67
+ ["bright-purple", "syntaxKeyword"],
68
+ ["black", "muted"],
69
+ ["bright-black", "muted"],
70
+ ["white", "text"],
71
+ ["bright-white", "text"],
72
+ ]);
73
+
74
+ const themeStyleModifiers = new Set(["bold", "italic", "underline"]);
75
+
76
+ const themeColorTokens = new Set<ThemeColor>([
77
+ "accent",
78
+ "border",
79
+ "borderAccent",
80
+ "borderMuted",
81
+ "success",
82
+ "error",
83
+ "warning",
84
+ "muted",
85
+ "dim",
86
+ "text",
87
+ "thinkingText",
88
+ "userMessageText",
89
+ "customMessageText",
90
+ "customMessageLabel",
91
+ "toolTitle",
92
+ "toolOutput",
93
+ "mdHeading",
94
+ "mdLink",
95
+ "mdLinkUrl",
96
+ "mdCode",
97
+ "mdCodeBlock",
98
+ "mdCodeBlockBorder",
99
+ "mdQuote",
100
+ "mdQuoteBorder",
101
+ "mdHr",
102
+ "mdListBullet",
103
+ "toolDiffAdded",
104
+ "toolDiffRemoved",
105
+ "toolDiffContext",
106
+ "syntaxComment",
107
+ "syntaxKeyword",
108
+ "syntaxFunction",
109
+ "syntaxVariable",
110
+ "syntaxString",
111
+ "syntaxNumber",
112
+ "syntaxType",
113
+ "syntaxOperator",
114
+ "syntaxPunctuation",
115
+ "thinkingOff",
116
+ "thinkingMinimal",
117
+ "thinkingLow",
118
+ "thinkingMedium",
119
+ "thinkingHigh",
120
+ "thinkingXhigh",
121
+ "bashMode",
122
+ ]);
123
+
124
+ function terminalColorToAnsi(color: string, isBackground = false): string | undefined {
49
125
  const normalized = color.toLowerCase();
50
126
  const colorCode = terminalColorCodes.get(normalized);
51
- if (colorCode !== undefined) return `${colorCode}`;
127
+ if (colorCode !== undefined) return `${isBackground ? colorCode + 10 : colorCode}`;
52
128
 
53
129
  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}`;
130
+ return `${isBackground ? 48 : 38};5;${normalized}`;
55
131
  }
56
132
 
57
- if (isHexColor(normalized)) return hexToAnsi(normalized).slice(2, -1);
133
+ if (isHexColor(normalized)) return hexToAnsi(normalized, isBackground).slice(2, -1);
58
134
  return undefined;
59
135
  }
60
136
 
137
+ function isExplicitTerminalColorToken(token: string): boolean {
138
+ const normalized = token.toLowerCase();
139
+ if (normalized.startsWith("fg:") || normalized.startsWith("bg:")) return true;
140
+ if (isHexColor(normalized)) return true;
141
+ return /^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(normalized);
142
+ }
143
+
144
+ function isSupportedStyleToken(token: string): boolean {
145
+ const normalized = token.toLowerCase();
146
+ if (terminalStyleModifiers.has(normalized)) return true;
147
+ if (terminalColorToAnsi(normalized) !== undefined) return true;
148
+
149
+ const isForeground = normalized.startsWith("fg:");
150
+ const isBackground = normalized.startsWith("bg:");
151
+ if (isForeground || isBackground) {
152
+ return terminalColorToAnsi(normalized.slice(3), isBackground) !== undefined;
153
+ }
154
+
155
+ return themeColorTokens.has(token as ThemeColor);
156
+ }
157
+
158
+ export function isSupportedColorSpec(style: ColorSpec): boolean {
159
+ const trimmed = style.trim();
160
+ if (trimmed === "") return true;
161
+ return trimmed.split(/\s+/).every(isSupportedStyleToken);
162
+ }
163
+
164
+ function applyThemeModifiers(theme: ThemeLike, styleTokens: string[], text: string): string {
165
+ let rendered = text;
166
+ for (const token of styleTokens) {
167
+ const normalized = token.toLowerCase();
168
+ if (normalized === "bold") rendered = theme.bold?.(rendered) ?? rendered;
169
+ if (normalized === "italic") rendered = theme.italic?.(rendered) ?? rendered;
170
+ if (normalized === "underline") rendered = theme.underline?.(rendered) ?? rendered;
171
+ }
172
+ return rendered;
173
+ }
174
+
175
+ export function safeThemeFg(theme: ThemeLike, color: string, text: string): string {
176
+ try {
177
+ return theme.fg(color, text);
178
+ } catch {
179
+ return text;
180
+ }
181
+ }
182
+
183
+ function mapThemeColor(styleTokens: string[]): string | undefined {
184
+ let fallback: string | undefined;
185
+ for (const token of styleTokens) {
186
+ const normalized = token.toLowerCase();
187
+ if (themeStyleModifiers.has(normalized)) continue;
188
+ if (normalized === "dim" || normalized === "dimmed") {
189
+ fallback = "muted";
190
+ continue;
191
+ }
192
+
193
+ const mapped = themeColorNameMap.get(normalized);
194
+ if (mapped) return mapped;
195
+ return token;
196
+ }
197
+ return fallback;
198
+ }
199
+
61
200
  /**
62
201
  * 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.
202
+ * Non-hex values are passed directly to `theme.fg()`; invalid tokens fall back
203
+ * to unstyled text so a config typo does not break rendering.
65
204
  */
66
205
  export function colorize(theme: ThemeLike, color: ColorSpec, text: string): string {
67
206
  if (isHexColor(color)) {
68
207
  return `${hexToAnsi(color)}${text}\x1b[39m`;
69
208
  }
70
- return theme.fg(color, text);
209
+ return safeThemeFg(theme, color, text);
71
210
  }
72
211
 
73
212
  /**
74
- * Render text with Starship-style terminal styling strings (e.g. "bold red", "fg:202").
213
+ * Render text with Starship-style terminal styling strings (e.g. "bold red", "fg:202",
214
+ * "bg:blue", "underline bg:#bf5700").
75
215
  */
76
216
  export function renderTerminalStyle(style: string, text: string): string {
77
217
  const codes: string[] = [];
@@ -85,10 +225,66 @@ export function renderTerminalStyle(style: string, text: string): string {
85
225
  continue;
86
226
  }
87
227
 
88
- const foreground = normalized.startsWith("fg:") ? normalized.slice(3) : normalized;
89
- const color = terminalColorToAnsi(foreground);
228
+ const isForeground = normalized.startsWith("fg:");
229
+ const isBackground = normalized.startsWith("bg:");
230
+ const colorName = isForeground || isBackground ? normalized.slice(3) : normalized;
231
+ const color = terminalColorToAnsi(colorName, isBackground);
90
232
  if (color) codes.push(color);
91
233
  }
92
234
 
93
235
  return codes.length ? `\x1b[${codes.join(";")}m${text}\x1b[0m` : text;
94
236
  }
237
+
238
+ /**
239
+ * Apply Starship-style terminal styling first, falling back to Pi theme tokens for
240
+ * legacy config values such as "accent" or "syntaxKeyword".
241
+ */
242
+ export function renderStyle(theme: ThemeLike, style: ColorSpec, text: string): string {
243
+ if (style.trim() === "") return text;
244
+ const styled = renderTerminalStyle(style, text);
245
+ return styled === text ? colorize(theme, style, text) : styled;
246
+ }
247
+
248
+ export function renderThemeStyle(theme: ThemeLike, style: ColorSpec, text: string): string {
249
+ const trimmed = style.trim();
250
+ if (trimmed === "") return text;
251
+
252
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
253
+ if (tokens.some(isExplicitTerminalColorToken)) return renderTerminalStyle(style, text);
254
+
255
+ const color = mapThemeColor(tokens) ?? "text";
256
+ return safeThemeFg(theme, color, applyThemeModifiers(theme, tokens, text));
257
+ }
258
+
259
+ export function renderStyleForSource(
260
+ theme: ThemeLike,
261
+ source: ColorSource,
262
+ style: ColorSpec,
263
+ text: string,
264
+ ): string {
265
+ return source === "terminal"
266
+ ? renderStyle(theme, style, text)
267
+ : renderThemeStyle(theme, style, text);
268
+ }
269
+
270
+ export function renderEditorAccent(text: string): string {
271
+ return renderTerminalStyle(EDITOR_ACCENT_STYLE, text);
272
+ }
273
+
274
+ export function renderEditorBorder(text: string): string {
275
+ return renderTerminalStyle(EDITOR_BORDER_STYLE, text);
276
+ }
277
+
278
+ export function renderAccentLine(theme: ThemeLike, source: ColorSource, text: string): string {
279
+ return source === "terminal" ? renderEditorAccent(text) : safeThemeFg(theme, "accent", text);
280
+ }
281
+
282
+ export function renderChromeBorder(
283
+ theme: ThemeLike,
284
+ source: ColorSource,
285
+ terminalFallbackStyle: ColorSpec,
286
+ text: string,
287
+ ): string {
288
+ if (source === "terminal") return renderTerminalStyle(terminalFallbackStyle, text);
289
+ return safeThemeFg(theme, "borderMuted", text);
290
+ }
@@ -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, safeThemeFg } 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
- this.borderColor = (text: string) => uiTheme.fg("border", text);
39
+ this.borderColor = (text: string) => safeThemeFg(uiTheme, "border", text);
36
40
  this.uiTheme = uiTheme;
41
+ this.getConfig = getConfig;
37
42
  this.getModelMeta = getModelMeta;
38
43
  this.getThinkingLevel = getThinkingLevel;
39
44
  }
@@ -83,13 +88,24 @@ export class PolishedEditor extends CustomEditor {
83
88
  const metaParts = [this.getModelMeta()];
84
89
  const thinkingLevel = this.getThinkingLevel();
85
90
  if (thinkingLevel && thinkingLevel !== "off") {
86
- metaParts.push(this.uiTheme.fg("muted", thinkingLevel));
91
+ metaParts.push(safeThemeFg(this.uiTheme, "muted", thinkingLevel));
87
92
  }
88
- const meta = metaParts.filter(Boolean).join(this.uiTheme.fg("border", " "));
93
+ const meta = metaParts.filter(Boolean).join(safeThemeFg(this.uiTheme, "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
  };
@@ -45,7 +52,12 @@ function findMarkdownText(value: unknown): string | undefined {
45
52
  }
46
53
 
47
54
  function themeFg(theme: Theme | undefined, color: ThemeColor, text: string): string {
48
- return theme ? theme.fg(color, text) : text;
55
+ if (!theme) return text;
56
+ try {
57
+ return theme.fg(color, text);
58
+ } catch {
59
+ return text;
60
+ }
49
61
  }
50
62
 
51
63
  function makeMarkdownTheme(theme: Theme | undefined): MarkdownTheme {
@@ -73,9 +85,14 @@ function fillLine(content: string, width: number): string {
73
85
  return `${truncated}${pad}`;
74
86
  }
75
87
 
76
- function renderPromptBoxLine(line: string, width: number, theme: Theme | undefined): string {
88
+ function renderPromptBoxLine(
89
+ line: string,
90
+ width: number,
91
+ theme: Theme | undefined,
92
+ config: PolishedTuiConfig,
93
+ ): string {
77
94
  if (width <= 0) return "";
78
- const rail = `${themeFg(theme, "accent", "│")} `;
95
+ const rail = `${theme ? renderAccentLine(theme, config.colorSources.userMessages, "") : "│"} `;
79
96
  const contentWidth = Math.max(0, width - visibleWidth(rail));
80
97
  return truncateToWidth(`${rail}${fillLine(line, contentWidth)}`, width, "");
81
98
  }
@@ -84,42 +101,70 @@ function renderZentuiUserMessage(
84
101
  instance: PatchableUserMessagePrototype,
85
102
  width: number,
86
103
  theme: Theme | undefined,
104
+ config: PolishedTuiConfig,
87
105
  ): string[] | undefined {
88
106
  const text = findMarkdownText(instance);
89
107
  if (text === undefined) return undefined;
90
108
  if (width <= 0) return [""];
91
109
 
92
- const railWidth = visibleWidth(`${themeFg(theme, "accent", "│")} `);
110
+ const railWidth = visibleWidth(
111
+ `${theme ? renderAccentLine(theme, config.colorSources.userMessages, "│") : "│"} `,
112
+ );
93
113
  const contentWidth = Math.max(1, width - railWidth);
94
114
  const renderer = new Markdown(text, 0, 0, makeMarkdownTheme(theme), {
95
115
  color: (content) => themeFg(theme, "userMessageText", content),
96
116
  });
97
117
  const body = renderer.render(contentWidth);
98
118
  const contentLines = body.length > 0 ? body : [""];
99
- const border = themeFg(theme, "border", "─".repeat(width));
119
+ const border = theme
120
+ ? renderChromeBorder(
121
+ theme,
122
+ config.colorSources.userMessages,
123
+ EDITOR_BORDER_STYLE,
124
+ "─".repeat(width),
125
+ )
126
+ : "─".repeat(width);
100
127
 
101
128
  return [
102
129
  truncateToWidth(border, width, ""),
103
- renderPromptBoxLine("", width, theme),
104
- ...contentLines.map((line) => renderPromptBoxLine(line, width, theme)),
105
- renderPromptBoxLine("", width, theme),
130
+ renderPromptBoxLine("", width, theme, config),
131
+ ...contentLines.map((line) => renderPromptBoxLine(line, width, theme, config)),
132
+ renderPromptBoxLine("", width, theme, config),
106
133
  truncateToWidth(border, width, ""),
107
134
  ];
108
135
  }
109
136
 
110
- export function installUserMessageStyle(getTheme: () => Theme | undefined): void {
137
+ export function installUserMessageStyle(
138
+ getTheme: () => Theme | undefined,
139
+ getConfig: () => PolishedTuiConfig,
140
+ ): Cleanup {
111
141
  const prototype = UserMessageComponent.prototype as unknown as PatchableUserMessagePrototype;
112
142
  prototype.__zentuiUserMessageGetTheme = getTheme;
113
-
114
- if (prototype.__zentuiUserMessagePatched) return;
143
+ prototype.__zentuiUserMessageGetConfig = getConfig;
144
+ prototype.__zentuiUserMessageActive = true;
145
+
146
+ if (
147
+ prototype.__zentuiUserMessagePatched &&
148
+ prototype.render === prototype.__zentuiUserMessageWrapper
149
+ ) {
150
+ return () => {
151
+ prototype.__zentuiUserMessageActive = false;
152
+ };
153
+ }
115
154
 
116
155
  prototype.__zentuiUserMessageOriginalRender = prototype.render;
117
- prototype.render = function renderWithZentuiUserMessage(width: number): string[] {
156
+ const wrapper = function renderWithZentuiUserMessage(this: unknown, width: number): string[] {
118
157
  const original = prototype.__zentuiUserMessageOriginalRender ?? prototype.render;
158
+ if (!prototype.__zentuiUserMessageActive) return original.call(this, width);
159
+
160
+ const config = prototype.__zentuiUserMessageGetConfig?.();
161
+ if (!config) return original.call(this, width);
162
+
119
163
  const lines = renderZentuiUserMessage(
120
164
  this as PatchableUserMessagePrototype,
121
165
  width,
122
166
  prototype.__zentuiUserMessageGetTheme?.(),
167
+ config,
123
168
  );
124
169
 
125
170
  if (!lines) return original.call(this, width);
@@ -129,5 +174,11 @@ export function installUserMessageStyle(getTheme: () => Theme | undefined): void
129
174
  lines[lines.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + lines[lines.length - 1];
130
175
  return lines;
131
176
  };
177
+ prototype.__zentuiUserMessageWrapper = wrapper;
178
+ prototype.render = wrapper;
132
179
  prototype.__zentuiUserMessagePatched = true;
180
+
181
+ return () => {
182
+ prototype.__zentuiUserMessageActive = false;
183
+ };
133
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",