pi-zentui 0.1.9 → 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
@@ -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
  {
@@ -1,6 +1,7 @@
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;
6
7
  export type ColorSource = "theme" | "terminal";
@@ -84,6 +85,22 @@ export const defaultConfig: PolishedTuiConfig = {
84
85
  },
85
86
  };
86
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
+
87
104
  type ConfigRecord = Record<string, unknown>;
88
105
 
89
106
  function isRecord(value: unknown): value is ConfigRecord {
@@ -107,6 +124,11 @@ function stringValue(record: Record<string, unknown>, key: string): string | und
107
124
  return typeof value === "string" ? value : undefined;
108
125
  }
109
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
+
110
132
  function colorSourceValue(
111
133
  record: Record<string, unknown>,
112
134
  key: keyof ColorSourcesConfig,
@@ -125,18 +147,27 @@ function definedColors(
125
147
  ) as Partial<PolishedTuiConfig["colors"]>;
126
148
  }
127
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
+
128
159
  function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiConfig["colors"]> {
129
160
  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"),
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"),
140
171
  });
141
172
  }
142
173
 
@@ -172,18 +203,17 @@ function readConfigRecord(path = configPath): ConfigRecord {
172
203
  }
173
204
 
174
205
  export function ensureConfigExists(): void {
175
- try {
176
- if (!existsSync(configPath)) {
177
- writeFileSync(configPath, `${JSON.stringify(defaultConfig, null, 2)}\n`, "utf8");
178
- }
179
- } catch {
180
- // Ignore config bootstrap failures; extension will fall back to defaults.
181
- }
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.
182
210
  }
183
211
 
184
212
  export function mergeConfig(parsed: unknown): PolishedTuiConfig {
185
213
  const config = isRecord(parsed) ? parsed : {};
186
- 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
+ : {};
187
217
  const colors = isRecord(config.colors)
188
218
  ? normalizeColors(config.colors as Record<string, unknown>)
189
219
  : {};
@@ -200,16 +230,16 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
200
230
  ...defaultConfig.colors,
201
231
  ...colors,
202
232
  },
203
- colorSources,
233
+ colorSources: { ...colorSources },
204
234
  };
205
235
  }
206
236
 
207
237
  export function loadConfig(): PolishedTuiConfig {
208
238
  try {
209
- if (!existsSync(configPath)) return defaultConfig;
239
+ if (!existsSync(configPath)) return mergeConfig({});
210
240
  return mergeConfig(JSON.parse(readFileSync(configPath, "utf8")));
211
241
  } catch {
212
- return defaultConfig;
242
+ return mergeConfig({});
213
243
  }
214
244
  }
215
245
 
@@ -219,10 +249,9 @@ export function saveColorSourcesPatch(
219
249
  ): PolishedTuiConfig {
220
250
  const record = readConfigRecord(path);
221
251
  const existing = isRecord(record.colorSources)
222
- ? validColorSourceEntries(record.colorSources as Record<string, unknown>)
252
+ ? { ...(record.colorSources as Record<string, unknown>) }
223
253
  : {};
224
254
  record.colorSources = {
225
- ...defaultConfig.colorSources,
226
255
  ...existing,
227
256
  ...validColorSourceEntries(patch),
228
257
  };
@@ -53,7 +53,7 @@ export function installFooter(
53
53
  renderStyleForSource(theme, colorSource, config.colors.gitBranch, text);
54
54
  const gitStatusColor = (text: string) =>
55
55
  renderStyleForSource(theme, colorSource, config.colors.gitStatus, text);
56
- const gitIcon = gitColor(config.icons.git);
56
+ const gitIcon = config.icons.git ? gitColor(config.icons.git) : "";
57
57
  const allStatus = [
58
58
  state.conflicted > 0 ? config.icons.conflicted : "",
59
59
  state.stashed ? config.icons.stashed : "",
@@ -75,7 +75,9 @@ export function installFooter(
75
75
  const statusBlock =
76
76
  allStatus || aheadBehind ? gitStatusColor(`[${allStatus}${aheadBehind}]`) : "";
77
77
  const branchLabel = branch
78
- ? `on ${gitIcon} ${gitColor(branch)}${statusBlock ? ` ${statusBlock}` : ""}`
78
+ ? [...["on", gitIcon, gitColor(branch)].filter(Boolean), statusBlock]
79
+ .filter(Boolean)
80
+ .join(" ")
79
81
  : "";
80
82
  const runtimeLabel = formatRuntimeSegment(
81
83
  theme,
@@ -19,7 +19,7 @@ import { readRuntimeInfo } from "./runtime";
19
19
  import { installSelectorBorderStyle } from "./selector-border";
20
20
  import { registerZentuiSettingsCommand } from "./settings-command";
21
21
  import { type FooterState, createInitialState, syncState } from "./state";
22
- import { renderAccentLine } from "./style";
22
+ import { renderAccentLine, safeThemeFg } from "./style";
23
23
  import { PolishedEditor } from "./ui";
24
24
  import { installUserMessageStyle } from "./user-message";
25
25
 
@@ -88,8 +88,8 @@ export default function (pi: ExtensionAPI) {
88
88
  getCurrentConfig().colorSources.editor,
89
89
  state.modelLabel,
90
90
  ),
91
- ctx.ui.theme.fg("text", state.providerLabel),
92
- ].join(ctx.ui.theme.fg("borderMuted", " ")),
91
+ safeThemeFg(ctx.ui.theme, "text", state.providerLabel),
92
+ ].join(safeThemeFg(ctx.ui.theme, "borderMuted", " ")),
93
93
  getThinkingLevel,
94
94
  ),
95
95
  );
@@ -7,7 +7,7 @@ import {
7
7
  truncateToWidth,
8
8
  } from "@earendil-works/pi-tui";
9
9
  import type { ColorSource, ColorSourcesConfig, PolishedTuiConfig } from "./config";
10
- import { EDITOR_BORDER_STYLE, renderChromeBorder } from "./style";
10
+ import { EDITOR_BORDER_STYLE, renderChromeBorder, safeThemeFg } from "./style";
11
11
 
12
12
  const colorSourceValues: ColorSource[] = ["theme", "terminal"];
13
13
 
@@ -97,8 +97,8 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
97
97
  EDITOR_BORDER_STYLE,
98
98
  "─".repeat(Math.max(0, width)),
99
99
  );
100
- const header = theme.fg("accent", theme.bold("Zentui settings"));
101
- const hint = theme.fg("dim", "Enter/Space cycles values · Esc closes");
100
+ const header = safeThemeFg(theme, "accent", theme.bold("Zentui settings"));
101
+ const hint = safeThemeFg(theme, "muted", "Enter/Space cycles values · Esc closes");
102
102
  return [
103
103
  truncateToWidth(border, width, ""),
104
104
  truncateToWidth(header, width, ""),
@@ -1,3 +1,4 @@
1
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
1
2
  import type { ColorSource, ColorSpec } from "./config";
2
3
 
3
4
  type ThemeLike = {
@@ -72,6 +73,54 @@ const themeColorNameMap = new Map([
72
73
 
73
74
  const themeStyleModifiers = new Set(["bold", "italic", "underline"]);
74
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
+
75
124
  function terminalColorToAnsi(color: string, isBackground = false): string | undefined {
76
125
  const normalized = color.toLowerCase();
77
126
  const colorCode = terminalColorCodes.get(normalized);
@@ -92,6 +141,26 @@ function isExplicitTerminalColorToken(token: string): boolean {
92
141
  return /^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(normalized);
93
142
  }
94
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
+
95
164
  function applyThemeModifiers(theme: ThemeLike, styleTokens: string[], text: string): string {
96
165
  let rendered = text;
97
166
  for (const token of styleTokens) {
@@ -103,7 +172,7 @@ function applyThemeModifiers(theme: ThemeLike, styleTokens: string[], text: stri
103
172
  return rendered;
104
173
  }
105
174
 
106
- function themeFg(theme: ThemeLike, color: string, text: string): string {
175
+ export function safeThemeFg(theme: ThemeLike, color: string, text: string): string {
107
176
  try {
108
177
  return theme.fg(color, text);
109
178
  } catch {
@@ -137,7 +206,7 @@ export function colorize(theme: ThemeLike, color: ColorSpec, text: string): stri
137
206
  if (isHexColor(color)) {
138
207
  return `${hexToAnsi(color)}${text}\x1b[39m`;
139
208
  }
140
- return themeFg(theme, color, text);
209
+ return safeThemeFg(theme, color, text);
141
210
  }
142
211
 
143
212
  /**
@@ -184,7 +253,7 @@ export function renderThemeStyle(theme: ThemeLike, style: ColorSpec, text: strin
184
253
  if (tokens.some(isExplicitTerminalColorToken)) return renderTerminalStyle(style, text);
185
254
 
186
255
  const color = mapThemeColor(tokens) ?? "text";
187
- return themeFg(theme, color, applyThemeModifiers(theme, tokens, text));
256
+ return safeThemeFg(theme, color, applyThemeModifiers(theme, tokens, text));
188
257
  }
189
258
 
190
259
  export function renderStyleForSource(
@@ -207,7 +276,7 @@ export function renderEditorBorder(text: string): string {
207
276
  }
208
277
 
209
278
  export function renderAccentLine(theme: ThemeLike, source: ColorSource, text: string): string {
210
- return source === "terminal" ? renderEditorAccent(text) : themeFg(theme, "accent", text);
279
+ return source === "terminal" ? renderEditorAccent(text) : safeThemeFg(theme, "accent", text);
211
280
  }
212
281
 
213
282
  export function renderChromeBorder(
@@ -217,5 +286,5 @@ export function renderChromeBorder(
217
286
  text: string,
218
287
  ): string {
219
288
  if (source === "terminal") return renderTerminalStyle(terminalFallbackStyle, text);
220
- return themeFg(theme, "borderMuted", text);
289
+ return safeThemeFg(theme, "borderMuted", text);
221
290
  }
@@ -7,7 +7,7 @@ import {
7
7
  visibleWidth,
8
8
  } from "@earendil-works/pi-tui";
9
9
  import type { PolishedTuiConfig } from "./config";
10
- import { EDITOR_BORDER_STYLE, renderAccentLine, renderChromeBorder } from "./style";
10
+ import { EDITOR_BORDER_STYLE, renderAccentLine, renderChromeBorder, safeThemeFg } from "./style";
11
11
 
12
12
  type AutocompleteEditorInternals = {
13
13
  autocompleteList?: Pick<Component, "render">;
@@ -36,7 +36,7 @@ export class PolishedEditor extends CustomEditor {
36
36
  getThinkingLevel: () => string | undefined,
37
37
  ) {
38
38
  super(tui, theme, keybindings, { paddingX: 0 });
39
- this.borderColor = (text: string) => uiTheme.fg("border", text);
39
+ this.borderColor = (text: string) => safeThemeFg(uiTheme, "border", text);
40
40
  this.uiTheme = uiTheme;
41
41
  this.getConfig = getConfig;
42
42
  this.getModelMeta = getModelMeta;
@@ -88,9 +88,9 @@ export class PolishedEditor extends CustomEditor {
88
88
  const metaParts = [this.getModelMeta()];
89
89
  const thinkingLevel = this.getThinkingLevel();
90
90
  if (thinkingLevel && thinkingLevel !== "off") {
91
- metaParts.push(this.uiTheme.fg("muted", thinkingLevel));
91
+ metaParts.push(safeThemeFg(this.uiTheme, "muted", thinkingLevel));
92
92
  }
93
- const meta = metaParts.filter(Boolean).join(this.uiTheme.fg("border", " "));
93
+ const meta = metaParts.filter(Boolean).join(safeThemeFg(this.uiTheme, "border", " "));
94
94
 
95
95
  const colorSource = this.getConfig().colorSources.editor;
96
96
  const rail = `${renderAccentLine(this.uiTheme, colorSource, "│")}${this.reset} `;
@@ -52,7 +52,12 @@ function findMarkdownText(value: unknown): string | undefined {
52
52
  }
53
53
 
54
54
  function themeFg(theme: Theme | undefined, color: ThemeColor, text: string): string {
55
- 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
+ }
56
61
  }
57
62
 
58
63
  function makeMarkdownTheme(theme: Theme | undefined): MarkdownTheme {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.1.9",
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",