pi-zentui 0.1.9 → 0.1.11

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
@@ -25,9 +25,9 @@ Zentui brings two popular aesthetics to Pi:
25
25
 
26
26
  ### Editor (Opencode-inspired)
27
27
 
28
- - Bordered input box with theme accent rail and thinking-level border color
28
+ - Bordered input box with configurable accent rail and border colors
29
29
  - Model name and provider displayed inside the editor frame
30
- - Thinking level indicator when enabled
30
+ - Configurable model, provider, and thinking-level indicator colors
31
31
  - Prompt-box-style user messages matching the ZentUI input chrome
32
32
 
33
33
  ### Git Status Icons
@@ -49,65 +49,65 @@ Zentui brings two popular aesthetics to Pi:
49
49
 
50
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
- | Runtime/language | Detection examples |
53
- | ---------------- | ------------------------------------------------------------ |
54
- | Buf | `buf.yaml`, `buf.gen.yaml`, `buf.work.yaml` |
55
- | Bun | `bun.lock`, `bun.lockb` |
56
- | C | `.c`, `.h` files |
57
- | C++ | `.cpp`, `.cc`, `.cxx`, `.hpp` files |
58
- | CMake | `CMakeLists.txt`, `CMakeCache.txt` |
59
- | COBOL | `.cbl`, `.cob` files |
60
- | Conda | `CONDA_DEFAULT_ENV` environment |
61
- | Crystal | `.cr` files, `shard.yml` |
62
- | Dart | `.dart` files, `pubspec.yaml`, `.dart_tool/` |
63
- | Deno | `deno.json`, `deno.jsonc`, `deno.lock` |
64
- | .NET | `.csproj`, `.fsproj`, `global.json`, `Directory.Build.*` |
65
- | Elixir | `mix.exs` |
66
- | Elm | `.elm` files, `elm.json`, `elm-stuff/` |
67
- | Erlang | `rebar.config`, `erlang.mk` |
68
- | Fennel | `.fnl` files |
69
- | Fortran | `.f`, `.f90`, `.f95`, `.f03`, `.f08`, `.f18`, `fpm.toml` |
70
- | Gleam | `.gleam` files, `gleam.toml` |
71
- | Go | `go.mod` |
72
- | Gradle | `build.gradle`, `build.gradle.kts`, `gradle/` |
73
- | Guix shell | `GUIX_ENVIRONMENT` environment |
74
- | Haskell | `.hs`, `.cabal`, `stack.yaml`, `cabal.project` |
75
- | Haxe | `.hx`, `.hxml`, `haxelib.json`, `.haxerc` |
76
- | Helm | `helmfile.yaml`, `Chart.yaml` |
77
- | Java | `.java-version` |
78
- | Julia | `.jl` files, `Project.toml`, `Manifest.toml` |
79
- | Kotlin | `.kt`, `.kts` files |
80
- | Lua | `.lua` files, `stylua.toml`, `.luarc.json`, `lua/` dir |
81
- | Maven | `pom.xml` |
82
- | Meson | `MESON_DEVENV=1` and `MESON_PROJECT_NAME` environment |
83
- | Mojo | `.mojo` files |
84
- | Nim | `.nim`, `.nims`, `.nimble`, `nim.cfg` |
85
- | Nix shell | `IN_NIX_SHELL=pure` or `IN_NIX_SHELL=impure` environment |
86
- | Node.js | `package.json`, `.nvmrc`, `.node-version` |
87
- | OCaml | `.opam`, `.ml`, `.mli`, `dune`, `_opam/`, `esy.lock/` |
88
- | Odin | `.odin` files |
89
- | OPA/Rego | `.rego` files |
90
- | Perl | `.pl`, `.pm`, `Makefile.PL`, `cpanfile`, `META.*` |
91
- | PHP | `composer.json` |
52
+ | Runtime/language | Detection examples |
53
+ | ---------------- | ------------------------------------------------------------- |
54
+ | Buf | `buf.yaml`, `buf.gen.yaml`, `buf.work.yaml` |
55
+ | Bun | `bun.lock`, `bun.lockb` |
56
+ | C | `.c`, `.h` files |
57
+ | C++ | `.cpp`, `.cc`, `.cxx`, `.hpp` files |
58
+ | CMake | `CMakeLists.txt`, `CMakeCache.txt` |
59
+ | COBOL | `.cbl`, `.cob` files |
60
+ | Conda | `CONDA_DEFAULT_ENV` environment |
61
+ | Crystal | `.cr` files, `shard.yml` |
62
+ | Dart | `.dart` files, `pubspec.yaml`, `.dart_tool/` |
63
+ | Deno | `deno.json`, `deno.jsonc`, `deno.lock` |
64
+ | .NET | `.csproj`, `.fsproj`, `global.json`, `Directory.Build.*` |
65
+ | Elixir | `mix.exs` |
66
+ | Elm | `.elm` files, `elm.json`, `elm-stuff/` |
67
+ | Erlang | `rebar.config`, `erlang.mk` |
68
+ | Fennel | `.fnl` files |
69
+ | Fortran | `.f`, `.f90`, `.f95`, `.f03`, `.f08`, `.f18`, `fpm.toml` |
70
+ | Gleam | `.gleam` files, `gleam.toml` |
71
+ | Go | `go.mod` |
72
+ | Gradle | `build.gradle`, `build.gradle.kts`, `gradle/` |
73
+ | Guix shell | `GUIX_ENVIRONMENT` environment |
74
+ | Haskell | `.hs`, `.cabal`, `stack.yaml`, `cabal.project` |
75
+ | Haxe | `.hx`, `.hxml`, `haxelib.json`, `.haxerc` |
76
+ | Helm | `helmfile.yaml`, `Chart.yaml` |
77
+ | Java | `.java-version` |
78
+ | Julia | `.jl` files, `Project.toml`, `Manifest.toml` |
79
+ | Kotlin | `.kt`, `.kts` files |
80
+ | Lua | `.lua` files, `stylua.toml`, `.luarc.json`, `lua/` dir |
81
+ | Maven | `pom.xml` |
82
+ | Meson | `MESON_DEVENV=1` and `MESON_PROJECT_NAME` environment |
83
+ | Mojo | `.mojo` files |
84
+ | Nim | `.nim`, `.nims`, `.nimble`, `nim.cfg` |
85
+ | Nix shell | `IN_NIX_SHELL=pure` or `IN_NIX_SHELL=impure` environment |
86
+ | Node.js | `package.json`, `.nvmrc`, `.node-version` |
87
+ | OCaml | `.opam`, `.ml`, `.mli`, `dune`, `_opam/`, `esy.lock/` |
88
+ | Odin | `.odin` files |
89
+ | OPA/Rego | `.rego` files |
90
+ | Perl | `.pl`, `.pm`, `Makefile.PL`, `cpanfile`, `META.*` |
91
+ | PHP | `composer.json` |
92
92
  | Pixi | `pixi.toml`, `pixi.lock`, `PIXI_ENVIRONMENT_NAME` environment |
93
- | Pulumi | `Pulumi.yaml`, `Pulumi.yml` |
94
- | PureScript | `.purs` files, `spago.dhall`, `spago.yaml`, `spago.lock` |
95
- | Python | `pyproject.toml`, `requirements.txt`, `setup.py`, `Pipfile` |
96
- | R | `.R`, `.Rmd`, `.Rproj`, `DESCRIPTION`, `.Rproj.user/` |
97
- | Raku | `.raku`, `.rakumod`, `.p6`, `.pm6`, `META6.json` |
98
- | Red | `.red`, `.reds` files |
99
- | Ruby | `Gemfile`, `.ruby-version` |
93
+ | Pulumi | `Pulumi.yaml`, `Pulumi.yml` |
94
+ | PureScript | `.purs` files, `spago.dhall`, `spago.yaml`, `spago.lock` |
95
+ | Python | `pyproject.toml`, `requirements.txt`, `setup.py`, `Pipfile` |
96
+ | R | `.R`, `.Rmd`, `.Rproj`, `DESCRIPTION`, `.Rproj.user/` |
97
+ | Raku | `.raku`, `.rakumod`, `.p6`, `.pm6`, `META6.json` |
98
+ | Red | `.red`, `.reds` files |
99
+ | Ruby | `Gemfile`, `.ruby-version` |
100
100
  | Rust | `Cargo.toml` |
101
- | Scala | `.scala`, `.sbt`, `build.sbt`, `.metals/` |
102
- | Solidity | `.sol` files |
103
- | Spack | `SPACK_ENV` environment |
104
- | Swift | `.swift` files, `Package.swift` |
105
- | Terraform | `.tf`, `.tfplan`, `.tfstate`, `.terraform/` |
106
- | Typst | `.typ` files, `template.typ` |
107
- | Vagrant | `Vagrantfile` |
108
- | V | `.v` files, `v.mod`, `vpkg.json` |
109
- | Xmake | `xmake.lua` |
110
- | Zig | `.zig` files, `build.zig` |
101
+ | Scala | `.scala`, `.sbt`, `build.sbt`, `.metals/` |
102
+ | Solidity | `.sol` files |
103
+ | Spack | `SPACK_ENV` environment |
104
+ | Swift | `.swift` files, `Package.swift` |
105
+ | Terraform | `.tf`, `.tfplan`, `.tfstate`, `.terraform/` |
106
+ | Typst | `.typ` files, `template.typ` |
107
+ | Vagrant | `Vagrantfile` |
108
+ | V | `.v` files, `v.mod`, `vpkg.json` |
109
+ | Xmake | `xmake.lua` |
110
+ | Zig | `.zig` files, `build.zig` |
111
111
 
112
112
  ## Install
113
113
 
@@ -121,71 +121,66 @@ 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
+ User config lives at `~/.pi/agent/zentui.json`. The file is optional: missing or invalid known values fall back to Zentui defaults, unknown keys are ignored at runtime, and `/zentui` currently patches only color-source settings.
125
125
 
126
- ```
127
- ~/.pi/agent/zentui.json
128
- ```
129
-
130
- ### Default config
126
+ Default config values — copy this and change any value you want:
131
127
 
132
128
  ```json
133
129
  {
134
- "projectRefreshIntervalMs": 30000,
135
- "icons": {
136
- "cwd": "󰝰",
137
- "git": "",
138
- "ahead": "↑",
139
- "behind": "↓",
140
- "diverged": "⇕",
141
- "conflicted": "=",
142
- "untracked": "?",
143
- "stashed": "$",
144
- "modified": "!",
145
- "staged": "+",
146
- "renamed": "»",
147
- "deleted": "✘",
148
- "typechanged": "T"
149
- },
150
- "colors": {
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"
166
- }
130
+ "projectRefreshIntervalMs": 30000,
131
+ "icons": {
132
+ "cwd": "󰝰",
133
+ "git": "",
134
+ "ahead": "↑",
135
+ "behind": "↓",
136
+ "diverged": "⇕",
137
+ "conflicted": "=",
138
+ "untracked": "?",
139
+ "stashed": "$",
140
+ "modified": "!",
141
+ "staged": "+",
142
+ "renamed": "»",
143
+ "deleted": "✘",
144
+ "typechanged": "T"
145
+ },
146
+ "colors": {
147
+ "cwd": "bold cyan",
148
+ "gitBranch": "bold purple",
149
+ "gitStatus": "bold red",
150
+ "contextNormal": "bright-black",
151
+ "contextWarning": "bold yellow",
152
+ "contextError": "bold red",
153
+ "tokens": "bright-black",
154
+ "cost": "bold green",
155
+ "separator": "bright-black",
156
+ "runtimePrefix": "",
157
+ "editorAccent": "accent",
158
+ "editorBorder": "borderMuted",
159
+ "editorModel": "accent",
160
+ "editorProvider": "text",
161
+ "editorThinking": "muted",
162
+ "editorThinkingMinimal": "thinkingMinimal",
163
+ "editorThinkingLow": "thinkingLow",
164
+ "editorThinkingMedium": "thinkingMedium",
165
+ "editorThinkingHigh": "thinkingHigh",
166
+ "editorThinkingXhigh": "thinkingXhigh"
167
+ },
168
+ "colorSources": {
169
+ "starship": "theme",
170
+ "editor": "theme",
171
+ "userMessages": "theme"
172
+ }
167
173
  }
168
174
  ```
169
175
 
170
- `projectRefreshIntervalMs` controls how often Zentui refreshes project status (git/runtime) while Pi is idle. Set it to `0` to disable polling; invalid values or values below 5000 ms fall back to `30000`.
171
-
172
- ### Color values
173
-
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:
182
-
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`
187
-
188
- Use the `colors` config key for these values.
176
+ - Style values can be Starship/terminal strings (`bold purple`, `fg:202`, `#89b4fa`, `bg:blue fg:bright-green`) or Pi theme tokens (`accent`, `borderMuted`, `thinkingHigh`).
177
+ - `projectRefreshIntervalMs`: project status polling interval; `0` disables polling.
178
+ - `icons`: every shown icon key is configurable; omit any key to use the Zentui default.
179
+ - `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
180
+ - The shown `editor*` values match the default `theme` source. Omit those keys to keep Zentui's source-aware defaults when switching between `theme` and `terminal`.
181
+ - `editorAccent` styles the active editor rail and previous user-message rail.
182
+ - `editorBorder` styles the active editor and previous user-message top/bottom border color only; the border glyph stays `─`.
183
+ - `editorModel`, `editorProvider`, and `editorThinking*` style the editor metadata. `editorThinking` applies to every non-`off` thinking level unless a level-specific key is set.
189
184
 
190
185
  ## Requirements
191
186
 
@@ -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";
@@ -42,6 +43,16 @@ export type PolishedTuiConfig = {
42
43
  cost: ColorSpec;
43
44
  separator: ColorSpec;
44
45
  runtimePrefix: ColorSpec;
46
+ editorAccent?: ColorSpec;
47
+ editorBorder?: ColorSpec;
48
+ editorModel?: ColorSpec;
49
+ editorProvider?: ColorSpec;
50
+ editorThinking?: ColorSpec;
51
+ editorThinkingMinimal?: ColorSpec;
52
+ editorThinkingLow?: ColorSpec;
53
+ editorThinkingMedium?: ColorSpec;
54
+ editorThinkingHigh?: ColorSpec;
55
+ editorThinkingXhigh?: ColorSpec;
45
56
  };
46
57
  colorSources: ColorSourcesConfig;
47
58
  };
@@ -69,12 +80,12 @@ export const defaultConfig: PolishedTuiConfig = {
69
80
  cwd: "bold cyan",
70
81
  gitBranch: "bold purple",
71
82
  gitStatus: "bold red",
72
- contextNormal: "dimmed",
83
+ contextNormal: "bright-black",
73
84
  contextWarning: "bold yellow",
74
85
  contextError: "bold red",
75
- tokens: "dimmed",
86
+ tokens: "bright-black",
76
87
  cost: "bold green",
77
- separator: "dimmed",
88
+ separator: "bright-black",
78
89
  runtimePrefix: "",
79
90
  },
80
91
  colorSources: {
@@ -84,6 +95,22 @@ export const defaultConfig: PolishedTuiConfig = {
84
95
  },
85
96
  };
86
97
 
98
+ const iconKeys = [
99
+ "cwd",
100
+ "git",
101
+ "ahead",
102
+ "behind",
103
+ "diverged",
104
+ "conflicted",
105
+ "untracked",
106
+ "stashed",
107
+ "modified",
108
+ "staged",
109
+ "renamed",
110
+ "deleted",
111
+ "typechanged",
112
+ ] as const satisfies readonly (keyof PolishedTuiConfig["icons"])[];
113
+
87
114
  type ConfigRecord = Record<string, unknown>;
88
115
 
89
116
  function isRecord(value: unknown): value is ConfigRecord {
@@ -107,6 +134,11 @@ function stringValue(record: Record<string, unknown>, key: string): string | und
107
134
  return typeof value === "string" ? value : undefined;
108
135
  }
109
136
 
137
+ function colorValue(record: Record<string, unknown>, key: string): string | undefined {
138
+ const value = stringValue(record, key);
139
+ return value !== undefined && isSupportedColorSpec(value) ? value : undefined;
140
+ }
141
+
110
142
  function colorSourceValue(
111
143
  record: Record<string, unknown>,
112
144
  key: keyof ColorSourcesConfig,
@@ -125,18 +157,37 @@ function definedColors(
125
157
  ) as Partial<PolishedTuiConfig["colors"]>;
126
158
  }
127
159
 
160
+ function normalizeIcons(record: Record<string, unknown>): Partial<PolishedTuiConfig["icons"]> {
161
+ return Object.fromEntries(
162
+ iconKeys.flatMap((key) => {
163
+ const value = stringValue(record, key);
164
+ return value === undefined ? [] : [[key, value]];
165
+ }),
166
+ ) as Partial<PolishedTuiConfig["icons"]>;
167
+ }
168
+
128
169
  function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiConfig["colors"]> {
129
170
  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"),
171
+ cwd: colorValue(record, "cwd") ?? colorValue(record, "cwdText"),
172
+ gitBranch: colorValue(record, "gitBranch") ?? colorValue(record, "git"),
173
+ gitStatus: colorValue(record, "gitStatus"),
174
+ contextNormal: colorValue(record, "contextNormal"),
175
+ contextWarning: colorValue(record, "contextWarning"),
176
+ contextError: colorValue(record, "contextError"),
177
+ tokens: colorValue(record, "tokens"),
178
+ cost: colorValue(record, "cost"),
179
+ separator: colorValue(record, "separator"),
180
+ runtimePrefix: colorValue(record, "runtimePrefix"),
181
+ editorAccent: colorValue(record, "editorAccent"),
182
+ editorBorder: colorValue(record, "editorBorder"),
183
+ editorModel: colorValue(record, "editorModel"),
184
+ editorProvider: colorValue(record, "editorProvider"),
185
+ editorThinking: colorValue(record, "editorThinking"),
186
+ editorThinkingMinimal: colorValue(record, "editorThinkingMinimal"),
187
+ editorThinkingLow: colorValue(record, "editorThinkingLow"),
188
+ editorThinkingMedium: colorValue(record, "editorThinkingMedium"),
189
+ editorThinkingHigh: colorValue(record, "editorThinkingHigh"),
190
+ editorThinkingXhigh: colorValue(record, "editorThinkingXhigh"),
140
191
  });
141
192
  }
142
193
 
@@ -172,18 +223,17 @@ function readConfigRecord(path = configPath): ConfigRecord {
172
223
  }
173
224
 
174
225
  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
- }
226
+ // Intentionally left as a no-op. Zentui config is user-owned and
227
+ // compatibility-sensitive: runtime defaults come from `mergeConfig({})`, and
228
+ // the extension should not persist opinionated defaults unless the user
229
+ // explicitly changes a setting.
182
230
  }
183
231
 
184
232
  export function mergeConfig(parsed: unknown): PolishedTuiConfig {
185
233
  const config = isRecord(parsed) ? parsed : {};
186
- const icons = isRecord(config.icons) ? (config.icons as Partial<PolishedTuiConfig["icons"]>) : {};
234
+ const icons = isRecord(config.icons)
235
+ ? normalizeIcons(config.icons as Record<string, unknown>)
236
+ : {};
187
237
  const colors = isRecord(config.colors)
188
238
  ? normalizeColors(config.colors as Record<string, unknown>)
189
239
  : {};
@@ -200,16 +250,16 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
200
250
  ...defaultConfig.colors,
201
251
  ...colors,
202
252
  },
203
- colorSources,
253
+ colorSources: { ...colorSources },
204
254
  };
205
255
  }
206
256
 
207
257
  export function loadConfig(): PolishedTuiConfig {
208
258
  try {
209
- if (!existsSync(configPath)) return defaultConfig;
259
+ if (!existsSync(configPath)) return mergeConfig({});
210
260
  return mergeConfig(JSON.parse(readFileSync(configPath, "utf8")));
211
261
  } catch {
212
- return defaultConfig;
262
+ return mergeConfig({});
213
263
  }
214
264
  }
215
265
 
@@ -219,10 +269,9 @@ export function saveColorSourcesPatch(
219
269
  ): PolishedTuiConfig {
220
270
  const record = readConfigRecord(path);
221
271
  const existing = isRecord(record.colorSources)
222
- ? validColorSourceEntries(record.colorSources as Record<string, unknown>)
272
+ ? { ...(record.colorSources as Record<string, unknown>) }
223
273
  : {};
224
274
  record.colorSources = {
225
- ...defaultConfig.colorSources,
226
275
  ...existing,
227
276
  ...validColorSourceEntries(patch),
228
277
  };
@@ -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,6 @@ 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";
23
22
  import { PolishedEditor } from "./ui";
24
23
  import { installUserMessageStyle } from "./user-message";
25
24
 
@@ -81,15 +80,10 @@ export default function (pi: ExtensionAPI) {
81
80
  keybindings,
82
81
  ctx.ui.theme,
83
82
  getCurrentConfig,
84
- () =>
85
- [
86
- renderAccentLine(
87
- ctx.ui.theme,
88
- getCurrentConfig().colorSources.editor,
89
- state.modelLabel,
90
- ),
91
- ctx.ui.theme.fg("text", state.providerLabel),
92
- ].join(ctx.ui.theme.fg("borderMuted", " ")),
83
+ () => ({
84
+ modelLabel: state.modelLabel,
85
+ providerLabel: state.providerLabel,
86
+ }),
93
87
  getThinkingLevel,
94
88
  ),
95
89
  );
@@ -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 = {
@@ -12,6 +13,21 @@ export type { ThemeLike };
12
13
  export const EDITOR_ACCENT_STYLE = "blue";
13
14
  export const EDITOR_BORDER_STYLE = "bright-black";
14
15
 
16
+ export type SourceStyleFallback = {
17
+ theme: ColorSpec;
18
+ terminal: ColorSpec;
19
+ };
20
+
21
+ export const EDITOR_ACCENT_FALLBACK: SourceStyleFallback = {
22
+ theme: "accent",
23
+ terminal: EDITOR_ACCENT_STYLE,
24
+ };
25
+
26
+ export const EDITOR_BORDER_FALLBACK: SourceStyleFallback = {
27
+ theme: "borderMuted",
28
+ terminal: EDITOR_BORDER_STYLE,
29
+ };
30
+
15
31
  function isHexColor(value: string): boolean {
16
32
  return /^#(?:[0-9a-fA-F]{6})$/.test(value);
17
33
  }
@@ -72,6 +88,54 @@ const themeColorNameMap = new Map([
72
88
 
73
89
  const themeStyleModifiers = new Set(["bold", "italic", "underline"]);
74
90
 
91
+ const themeColorTokens = new Set<ThemeColor>([
92
+ "accent",
93
+ "border",
94
+ "borderAccent",
95
+ "borderMuted",
96
+ "success",
97
+ "error",
98
+ "warning",
99
+ "muted",
100
+ "dim",
101
+ "text",
102
+ "thinkingText",
103
+ "userMessageText",
104
+ "customMessageText",
105
+ "customMessageLabel",
106
+ "toolTitle",
107
+ "toolOutput",
108
+ "mdHeading",
109
+ "mdLink",
110
+ "mdLinkUrl",
111
+ "mdCode",
112
+ "mdCodeBlock",
113
+ "mdCodeBlockBorder",
114
+ "mdQuote",
115
+ "mdQuoteBorder",
116
+ "mdHr",
117
+ "mdListBullet",
118
+ "toolDiffAdded",
119
+ "toolDiffRemoved",
120
+ "toolDiffContext",
121
+ "syntaxComment",
122
+ "syntaxKeyword",
123
+ "syntaxFunction",
124
+ "syntaxVariable",
125
+ "syntaxString",
126
+ "syntaxNumber",
127
+ "syntaxType",
128
+ "syntaxOperator",
129
+ "syntaxPunctuation",
130
+ "thinkingOff",
131
+ "thinkingMinimal",
132
+ "thinkingLow",
133
+ "thinkingMedium",
134
+ "thinkingHigh",
135
+ "thinkingXhigh",
136
+ "bashMode",
137
+ ]);
138
+
75
139
  function terminalColorToAnsi(color: string, isBackground = false): string | undefined {
76
140
  const normalized = color.toLowerCase();
77
141
  const colorCode = terminalColorCodes.get(normalized);
@@ -92,6 +156,26 @@ function isExplicitTerminalColorToken(token: string): boolean {
92
156
  return /^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(normalized);
93
157
  }
94
158
 
159
+ function isSupportedStyleToken(token: string): boolean {
160
+ const normalized = token.toLowerCase();
161
+ if (terminalStyleModifiers.has(normalized)) return true;
162
+ if (terminalColorToAnsi(normalized) !== undefined) return true;
163
+
164
+ const isForeground = normalized.startsWith("fg:");
165
+ const isBackground = normalized.startsWith("bg:");
166
+ if (isForeground || isBackground) {
167
+ return terminalColorToAnsi(normalized.slice(3), isBackground) !== undefined;
168
+ }
169
+
170
+ return themeColorTokens.has(token as ThemeColor);
171
+ }
172
+
173
+ export function isSupportedColorSpec(style: ColorSpec): boolean {
174
+ const trimmed = style.trim();
175
+ if (trimmed === "") return true;
176
+ return trimmed.split(/\s+/).every(isSupportedStyleToken);
177
+ }
178
+
95
179
  function applyThemeModifiers(theme: ThemeLike, styleTokens: string[], text: string): string {
96
180
  let rendered = text;
97
181
  for (const token of styleTokens) {
@@ -103,7 +187,7 @@ function applyThemeModifiers(theme: ThemeLike, styleTokens: string[], text: stri
103
187
  return rendered;
104
188
  }
105
189
 
106
- function themeFg(theme: ThemeLike, color: string, text: string): string {
190
+ export function safeThemeFg(theme: ThemeLike, color: string, text: string): string {
107
191
  try {
108
192
  return theme.fg(color, text);
109
193
  } catch {
@@ -137,7 +221,7 @@ export function colorize(theme: ThemeLike, color: ColorSpec, text: string): stri
137
221
  if (isHexColor(color)) {
138
222
  return `${hexToAnsi(color)}${text}\x1b[39m`;
139
223
  }
140
- return themeFg(theme, color, text);
224
+ return safeThemeFg(theme, color, text);
141
225
  }
142
226
 
143
227
  /**
@@ -184,7 +268,7 @@ export function renderThemeStyle(theme: ThemeLike, style: ColorSpec, text: strin
184
268
  if (tokens.some(isExplicitTerminalColorToken)) return renderTerminalStyle(style, text);
185
269
 
186
270
  const color = mapThemeColor(tokens) ?? "text";
187
- return themeFg(theme, color, applyThemeModifiers(theme, tokens, text));
271
+ return safeThemeFg(theme, color, applyThemeModifiers(theme, tokens, text));
188
272
  }
189
273
 
190
274
  export function renderStyleForSource(
@@ -198,6 +282,17 @@ export function renderStyleForSource(
198
282
  : renderThemeStyle(theme, style, text);
199
283
  }
200
284
 
285
+ export function renderStyleForSourceOrFallback(
286
+ theme: ThemeLike,
287
+ source: ColorSource,
288
+ style: ColorSpec | undefined,
289
+ fallback: ColorSpec | SourceStyleFallback,
290
+ text: string,
291
+ ): string {
292
+ const fallbackStyle = typeof fallback === "string" ? fallback : fallback[source];
293
+ return renderStyleForSource(theme, source, style ?? fallbackStyle, text);
294
+ }
295
+
201
296
  export function renderEditorAccent(text: string): string {
202
297
  return renderTerminalStyle(EDITOR_ACCENT_STYLE, text);
203
298
  }
@@ -207,7 +302,7 @@ export function renderEditorBorder(text: string): string {
207
302
  }
208
303
 
209
304
  export function renderAccentLine(theme: ThemeLike, source: ColorSource, text: string): string {
210
- return source === "terminal" ? renderEditorAccent(text) : themeFg(theme, "accent", text);
305
+ return renderStyleForSourceOrFallback(theme, source, undefined, EDITOR_ACCENT_FALLBACK, text);
211
306
  }
212
307
 
213
308
  export function renderChromeBorder(
@@ -216,6 +311,11 @@ export function renderChromeBorder(
216
311
  terminalFallbackStyle: ColorSpec,
217
312
  text: string,
218
313
  ): string {
219
- if (source === "terminal") return renderTerminalStyle(terminalFallbackStyle, text);
220
- return themeFg(theme, "borderMuted", text);
314
+ return renderStyleForSourceOrFallback(
315
+ theme,
316
+ source,
317
+ undefined,
318
+ { theme: "borderMuted", terminal: terminalFallbackStyle },
319
+ text,
320
+ );
221
321
  }
@@ -7,20 +7,30 @@ 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 {
11
+ EDITOR_ACCENT_FALLBACK,
12
+ EDITOR_BORDER_FALLBACK,
13
+ renderStyleForSourceOrFallback,
14
+ safeThemeFg,
15
+ } from "./style";
11
16
 
12
17
  type AutocompleteEditorInternals = {
13
18
  autocompleteList?: Pick<Component, "render">;
14
19
  isShowingAutocomplete?: () => boolean;
15
20
  };
16
21
 
22
+ type EditorMeta = {
23
+ modelLabel: string;
24
+ providerLabel: string;
25
+ };
26
+
17
27
  function clampRenderedLines(lines: string[], width: number): string[] {
18
28
  const maxWidth = Math.max(0, width);
19
29
  return lines.map((line) => truncateToWidth(line, maxWidth, ""));
20
30
  }
21
31
 
22
32
  export class PolishedEditor extends CustomEditor {
23
- private readonly getModelMeta: () => string;
33
+ private readonly getModelMeta: () => EditorMeta;
24
34
  private readonly getThinkingLevel: () => string | undefined;
25
35
  private readonly getConfig: () => PolishedTuiConfig;
26
36
  private readonly uiTheme: Theme;
@@ -32,11 +42,11 @@ export class PolishedEditor extends CustomEditor {
32
42
  keybindings: KeybindingsManager,
33
43
  uiTheme: Theme,
34
44
  getConfig: () => PolishedTuiConfig,
35
- getModelMeta: () => string,
45
+ getModelMeta: () => EditorMeta,
36
46
  getThinkingLevel: () => string | undefined,
37
47
  ) {
38
48
  super(tui, theme, keybindings, { paddingX: 0 });
39
- this.borderColor = (text: string) => uiTheme.fg("border", text);
49
+ this.borderColor = (text: string) => safeThemeFg(uiTheme, "border", text);
40
50
  this.uiTheme = uiTheme;
41
51
  this.getConfig = getConfig;
42
52
  this.getModelMeta = getModelMeta;
@@ -49,6 +59,23 @@ export class PolishedEditor extends CustomEditor {
49
59
  return `${truncated}${pad}`;
50
60
  }
51
61
 
62
+ private editorThinkingStyle(config: PolishedTuiConfig, level: string): string | undefined {
63
+ switch (level.toLowerCase()) {
64
+ case "minimal":
65
+ return config.colors.editorThinkingMinimal ?? config.colors.editorThinking;
66
+ case "low":
67
+ return config.colors.editorThinkingLow ?? config.colors.editorThinking;
68
+ case "medium":
69
+ return config.colors.editorThinkingMedium ?? config.colors.editorThinking;
70
+ case "high":
71
+ return config.colors.editorThinkingHigh ?? config.colors.editorThinking;
72
+ case "xhigh":
73
+ return config.colors.editorThinkingXhigh ?? config.colors.editorThinking;
74
+ default:
75
+ return config.colors.editorThinking;
76
+ }
77
+ }
78
+
52
79
  render(width: number): string[] {
53
80
  if (width <= 2) {
54
81
  return clampRenderedLines(super.render(width), width);
@@ -84,26 +111,61 @@ export class PolishedEditor extends CustomEditor {
84
111
  return clampRenderedLines(rendered, width);
85
112
  }
86
113
 
114
+ const config = this.getConfig();
115
+ const colorSource = config.colorSources.editor;
87
116
  const editorLines = editorFrame.slice(1, -1);
88
- const metaParts = [this.getModelMeta()];
117
+ const { modelLabel, providerLabel } = this.getModelMeta();
118
+ const model = renderStyleForSourceOrFallback(
119
+ this.uiTheme,
120
+ colorSource,
121
+ config.colors.editorModel,
122
+ EDITOR_ACCENT_FALLBACK,
123
+ modelLabel,
124
+ );
125
+ const provider = renderStyleForSourceOrFallback(
126
+ this.uiTheme,
127
+ colorSource,
128
+ config.colors.editorProvider,
129
+ "text",
130
+ providerLabel,
131
+ );
132
+ const modelMeta = [model, provider]
133
+ .filter(Boolean)
134
+ .join(safeThemeFg(this.uiTheme, "borderMuted", " "));
135
+ const metaParts = [modelMeta];
89
136
  const thinkingLevel = this.getThinkingLevel();
90
137
  if (thinkingLevel && thinkingLevel !== "off") {
91
- metaParts.push(this.uiTheme.fg("muted", thinkingLevel));
138
+ metaParts.push(
139
+ renderStyleForSourceOrFallback(
140
+ this.uiTheme,
141
+ colorSource,
142
+ this.editorThinkingStyle(config, thinkingLevel),
143
+ "muted",
144
+ thinkingLevel,
145
+ ),
146
+ );
92
147
  }
93
- const meta = metaParts.filter(Boolean).join(this.uiTheme.fg("border", " "));
148
+ const meta = metaParts.filter(Boolean).join(safeThemeFg(this.uiTheme, "border", " "));
94
149
 
95
- const colorSource = this.getConfig().colorSources.editor;
96
- const rail = `${renderAccentLine(this.uiTheme, colorSource, "│")}${this.reset} `;
97
- const top = renderChromeBorder(
150
+ const rail = `${renderStyleForSourceOrFallback(
151
+ this.uiTheme,
152
+ colorSource,
153
+ config.colors.editorAccent,
154
+ EDITOR_ACCENT_FALLBACK,
155
+ "│",
156
+ )}${this.reset} `;
157
+ const top = renderStyleForSourceOrFallback(
98
158
  this.uiTheme,
99
159
  colorSource,
100
- EDITOR_BORDER_STYLE,
160
+ config.colors.editorBorder,
161
+ EDITOR_BORDER_FALLBACK,
101
162
  "─".repeat(width),
102
163
  );
103
- const bottom = renderChromeBorder(
164
+ const bottom = renderStyleForSourceOrFallback(
104
165
  this.uiTheme,
105
166
  colorSource,
106
- EDITOR_BORDER_STYLE,
167
+ config.colors.editorBorder,
168
+ EDITOR_BORDER_FALLBACK,
107
169
  "─".repeat(width),
108
170
  );
109
171
  const lines = ["", ...editorLines, "", meta];
@@ -6,7 +6,11 @@ import {
6
6
  visibleWidth,
7
7
  } from "@earendil-works/pi-tui";
8
8
  import type { PolishedTuiConfig } from "./config";
9
- import { EDITOR_BORDER_STYLE, renderAccentLine, renderChromeBorder } from "./style";
9
+ import {
10
+ EDITOR_ACCENT_FALLBACK,
11
+ EDITOR_BORDER_FALLBACK,
12
+ renderStyleForSourceOrFallback,
13
+ } from "./style";
10
14
 
11
15
  const OSC133_ZONE_START = "\x1b]133;A\x07";
12
16
  const OSC133_ZONE_END = "\x1b]133;B\x07";
@@ -52,7 +56,12 @@ function findMarkdownText(value: unknown): string | undefined {
52
56
  }
53
57
 
54
58
  function themeFg(theme: Theme | undefined, color: ThemeColor, text: string): string {
55
- return theme ? theme.fg(color, text) : text;
59
+ if (!theme) return text;
60
+ try {
61
+ return theme.fg(color, text);
62
+ } catch {
63
+ return text;
64
+ }
56
65
  }
57
66
 
58
67
  function makeMarkdownTheme(theme: Theme | undefined): MarkdownTheme {
@@ -87,7 +96,17 @@ function renderPromptBoxLine(
87
96
  config: PolishedTuiConfig,
88
97
  ): string {
89
98
  if (width <= 0) return "";
90
- const rail = `${theme ? renderAccentLine(theme, config.colorSources.userMessages, "│") : "│"} `;
99
+ const rail = `${
100
+ theme
101
+ ? renderStyleForSourceOrFallback(
102
+ theme,
103
+ config.colorSources.userMessages,
104
+ config.colors.editorAccent,
105
+ EDITOR_ACCENT_FALLBACK,
106
+ "│",
107
+ )
108
+ : "│"
109
+ } `;
91
110
  const contentWidth = Math.max(0, width - visibleWidth(rail));
92
111
  return truncateToWidth(`${rail}${fillLine(line, contentWidth)}`, width, "");
93
112
  }
@@ -103,7 +122,17 @@ function renderZentuiUserMessage(
103
122
  if (width <= 0) return [""];
104
123
 
105
124
  const railWidth = visibleWidth(
106
- `${theme ? renderAccentLine(theme, config.colorSources.userMessages, "│") : "│"} `,
125
+ `${
126
+ theme
127
+ ? renderStyleForSourceOrFallback(
128
+ theme,
129
+ config.colorSources.userMessages,
130
+ config.colors.editorAccent,
131
+ EDITOR_ACCENT_FALLBACK,
132
+ "│",
133
+ )
134
+ : "│"
135
+ } `,
107
136
  );
108
137
  const contentWidth = Math.max(1, width - railWidth);
109
138
  const renderer = new Markdown(text, 0, 0, makeMarkdownTheme(theme), {
@@ -112,10 +141,11 @@ function renderZentuiUserMessage(
112
141
  const body = renderer.render(contentWidth);
113
142
  const contentLines = body.length > 0 ? body : [""];
114
143
  const border = theme
115
- ? renderChromeBorder(
144
+ ? renderStyleForSourceOrFallback(
116
145
  theme,
117
146
  config.colorSources.userMessages,
118
- EDITOR_BORDER_STYLE,
147
+ config.colors.editorBorder,
148
+ EDITOR_BORDER_FALLBACK,
119
149
  "─".repeat(width),
120
150
  )
121
151
  : "─".repeat(width);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",