pi-zentui 0.1.10 → 0.1.12

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
@@ -22,12 +22,14 @@ Zentui brings two popular aesthetics to Pi:
22
22
  - `[!?↑]` — git status indicators (modified, untracked, ahead/behind, stashed, etc.)
23
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
+ - Third-party Pi extension statuses from `ctx.ui.setStatus()` can be shown on the left,
26
+ middle, or right side, or hidden per status key from `/zentui`
25
27
 
26
28
  ### Editor (Opencode-inspired)
27
29
 
28
- - Bordered input box with theme accent rail and thinking-level border color
30
+ - Bordered input box with configurable accent rail and border colors
29
31
  - Model name and provider displayed inside the editor frame
30
- - Thinking level indicator when enabled
32
+ - Configurable model, provider, and thinking-level indicator colors
31
33
  - Prompt-box-style user messages matching the ZentUI input chrome
32
34
 
33
35
  ### Git Status Icons
@@ -49,65 +51,65 @@ Zentui brings two popular aesthetics to Pi:
49
51
 
50
52
  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
53
 
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` |
54
+ | Runtime/language | Detection examples |
55
+ | ---------------- | ------------------------------------------------------------- |
56
+ | Buf | `buf.yaml`, `buf.gen.yaml`, `buf.work.yaml` |
57
+ | Bun | `bun.lock`, `bun.lockb` |
58
+ | C | `.c`, `.h` files |
59
+ | C++ | `.cpp`, `.cc`, `.cxx`, `.hpp` files |
60
+ | CMake | `CMakeLists.txt`, `CMakeCache.txt` |
61
+ | COBOL | `.cbl`, `.cob` files |
62
+ | Conda | `CONDA_DEFAULT_ENV` environment |
63
+ | Crystal | `.cr` files, `shard.yml` |
64
+ | Dart | `.dart` files, `pubspec.yaml`, `.dart_tool/` |
65
+ | Deno | `deno.json`, `deno.jsonc`, `deno.lock` |
66
+ | .NET | `.csproj`, `.fsproj`, `global.json`, `Directory.Build.*` |
67
+ | Elixir | `mix.exs` |
68
+ | Elm | `.elm` files, `elm.json`, `elm-stuff/` |
69
+ | Erlang | `rebar.config`, `erlang.mk` |
70
+ | Fennel | `.fnl` files |
71
+ | Fortran | `.f`, `.f90`, `.f95`, `.f03`, `.f08`, `.f18`, `fpm.toml` |
72
+ | Gleam | `.gleam` files, `gleam.toml` |
73
+ | Go | `go.mod` |
74
+ | Gradle | `build.gradle`, `build.gradle.kts`, `gradle/` |
75
+ | Guix shell | `GUIX_ENVIRONMENT` environment |
76
+ | Haskell | `.hs`, `.cabal`, `stack.yaml`, `cabal.project` |
77
+ | Haxe | `.hx`, `.hxml`, `haxelib.json`, `.haxerc` |
78
+ | Helm | `helmfile.yaml`, `Chart.yaml` |
79
+ | Java | `.java-version` |
80
+ | Julia | `.jl` files, `Project.toml`, `Manifest.toml` |
81
+ | Kotlin | `.kt`, `.kts` files |
82
+ | Lua | `.lua` files, `stylua.toml`, `.luarc.json`, `lua/` dir |
83
+ | Maven | `pom.xml` |
84
+ | Meson | `MESON_DEVENV=1` and `MESON_PROJECT_NAME` environment |
85
+ | Mojo | `.mojo` files |
86
+ | Nim | `.nim`, `.nims`, `.nimble`, `nim.cfg` |
87
+ | Nix shell | `IN_NIX_SHELL=pure` or `IN_NIX_SHELL=impure` environment |
88
+ | Node.js | `package.json`, `.nvmrc`, `.node-version` |
89
+ | OCaml | `.opam`, `.ml`, `.mli`, `dune`, `_opam/`, `esy.lock/` |
90
+ | Odin | `.odin` files |
91
+ | OPA/Rego | `.rego` files |
92
+ | Perl | `.pl`, `.pm`, `Makefile.PL`, `cpanfile`, `META.*` |
93
+ | PHP | `composer.json` |
92
94
  | 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` |
95
+ | Pulumi | `Pulumi.yaml`, `Pulumi.yml` |
96
+ | PureScript | `.purs` files, `spago.dhall`, `spago.yaml`, `spago.lock` |
97
+ | Python | `pyproject.toml`, `requirements.txt`, `setup.py`, `Pipfile` |
98
+ | R | `.R`, `.Rmd`, `.Rproj`, `DESCRIPTION`, `.Rproj.user/` |
99
+ | Raku | `.raku`, `.rakumod`, `.p6`, `.pm6`, `META6.json` |
100
+ | Red | `.red`, `.reds` files |
101
+ | Ruby | `Gemfile`, `.ruby-version` |
100
102
  | 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` |
103
+ | Scala | `.scala`, `.sbt`, `build.sbt`, `.metals/` |
104
+ | Solidity | `.sol` files |
105
+ | Spack | `SPACK_ENV` environment |
106
+ | Swift | `.swift` files, `Package.swift` |
107
+ | Terraform | `.tf`, `.tfplan`, `.tfstate`, `.terraform/` |
108
+ | Typst | `.typ` files, `template.typ` |
109
+ | Vagrant | `Vagrantfile` |
110
+ | V | `.v` files, `v.mod`, `vpkg.json` |
111
+ | Xmake | `xmake.lua` |
112
+ | Zig | `.zig` files, `build.zig` |
111
113
 
112
114
  ## Install
113
115
 
@@ -121,73 +123,72 @@ pi install git:github.com/lmilojevicc/pi-zentui
121
123
 
122
124
  ## Config
123
125
 
124
- Zentui uses built-in defaults when no config file exists. User settings live at:
126
+ 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` can patch color-source settings plus active third-party status placements.
125
127
 
126
- ```
127
- ~/.pi/agent/zentui.json
128
- ```
129
-
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
128
+ Default config values — copy this and change any value you want:
133
129
 
134
130
  ```json
135
131
  {
136
- "projectRefreshIntervalMs": 30000,
137
- "icons": {
138
- "cwd": "󰝰",
139
- "git": "",
140
- "ahead": "↑",
141
- "behind": "↓",
142
- "diverged": "⇕",
143
- "conflicted": "=",
144
- "untracked": "?",
145
- "stashed": "$",
146
- "modified": "!",
147
- "staged": "+",
148
- "renamed": "»",
149
- "deleted": "✘",
150
- "typechanged": "T"
151
- },
152
- "colors": {
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"
168
- }
132
+ "projectRefreshIntervalMs": 30000,
133
+ "icons": {
134
+ "cwd": "󰝰",
135
+ "git": "",
136
+ "ahead": "↑",
137
+ "behind": "↓",
138
+ "diverged": "⇕",
139
+ "conflicted": "=",
140
+ "untracked": "?",
141
+ "stashed": "$",
142
+ "modified": "!",
143
+ "staged": "+",
144
+ "renamed": "»",
145
+ "deleted": "✘",
146
+ "typechanged": "T"
147
+ },
148
+ "colors": {
149
+ "cwd": "bold cyan",
150
+ "gitBranch": "bold purple",
151
+ "gitStatus": "bold red",
152
+ "contextNormal": "bright-black",
153
+ "contextWarning": "bold yellow",
154
+ "contextError": "bold red",
155
+ "tokens": "bright-black",
156
+ "cost": "bold green",
157
+ "extensionStatus": "bright-black",
158
+ "separator": "bright-black",
159
+ "runtimePrefix": "",
160
+ "editorAccent": "accent",
161
+ "editorBorder": "borderMuted",
162
+ "editorModel": "accent",
163
+ "editorProvider": "text",
164
+ "editorThinking": "muted",
165
+ "editorThinkingMinimal": "thinkingMinimal",
166
+ "editorThinkingLow": "thinkingLow",
167
+ "editorThinkingMedium": "thinkingMedium",
168
+ "editorThinkingHigh": "thinkingHigh",
169
+ "editorThinkingXhigh": "thinkingXhigh"
170
+ },
171
+ "colorSources": {
172
+ "starship": "theme",
173
+ "editor": "theme",
174
+ "userMessages": "theme"
175
+ },
176
+ "extensionStatuses": {
177
+ "defaultPlacement": "right",
178
+ "placements": {}
179
+ }
169
180
  }
170
181
  ```
171
182
 
172
- `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`.
173
-
174
- ### Color values
175
-
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:
184
-
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`
189
-
190
- Use the `colors` config key for these values.
183
+ - Style values can be Starship/terminal strings (`bold purple`, `fg:202`, `#89b4fa`, `bg:blue fg:bright-green`) or Pi theme tokens (`accent`, `borderMuted`, `thinkingHigh`).
184
+ - `projectRefreshIntervalMs`: project status polling interval; `0` disables polling.
185
+ - `icons`: every shown icon key is configurable; omit any key to use the Zentui default.
186
+ - `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
187
+ - `extensionStatuses`: controls third-party statuses published by other Pi extensions through `ctx.ui.setStatus()`. `defaultPlacement` and each `placements` value can be `off`, `left`, `middle`, or `right`. `/zentui` lists only statuses that are currently active.
188
+ - 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`.
189
+ - `editorAccent` styles the active editor rail and previous user-message rail.
190
+ - `editorBorder` styles the active editor and previous user-message top/bottom border color only; the border glyph stays `─`.
191
+ - `editorModel`, `editorProvider`, and `editorThinking*` style the editor metadata. `editorThinking` applies to every non-`off` thinking level unless a level-specific key is set.
191
192
 
192
193
  ## Requirements
193
194
 
@@ -12,6 +12,13 @@ export type ColorSourcesConfig = {
12
12
  userMessages: ColorSource;
13
13
  };
14
14
 
15
+ export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
16
+
17
+ export type ExtensionStatusesConfig = {
18
+ defaultPlacement: ExtensionStatusPlacement;
19
+ placements: Record<string, ExtensionStatusPlacement>;
20
+ };
21
+
15
22
  const DEFAULT_PROJECT_REFRESH_INTERVAL_MS = 30_000;
16
23
  const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
17
24
 
@@ -43,8 +50,20 @@ export type PolishedTuiConfig = {
43
50
  cost: ColorSpec;
44
51
  separator: ColorSpec;
45
52
  runtimePrefix: ColorSpec;
53
+ extensionStatus: ColorSpec;
54
+ editorAccent?: ColorSpec;
55
+ editorBorder?: ColorSpec;
56
+ editorModel?: ColorSpec;
57
+ editorProvider?: ColorSpec;
58
+ editorThinking?: ColorSpec;
59
+ editorThinkingMinimal?: ColorSpec;
60
+ editorThinkingLow?: ColorSpec;
61
+ editorThinkingMedium?: ColorSpec;
62
+ editorThinkingHigh?: ColorSpec;
63
+ editorThinkingXhigh?: ColorSpec;
46
64
  };
47
65
  colorSources: ColorSourcesConfig;
66
+ extensionStatuses: ExtensionStatusesConfig;
48
67
  };
49
68
 
50
69
  export const configPath = join(getAgentDir(), "zentui.json");
@@ -70,19 +89,24 @@ export const defaultConfig: PolishedTuiConfig = {
70
89
  cwd: "bold cyan",
71
90
  gitBranch: "bold purple",
72
91
  gitStatus: "bold red",
73
- contextNormal: "dimmed",
92
+ contextNormal: "bright-black",
74
93
  contextWarning: "bold yellow",
75
94
  contextError: "bold red",
76
- tokens: "dimmed",
95
+ tokens: "bright-black",
77
96
  cost: "bold green",
78
- separator: "dimmed",
97
+ separator: "bright-black",
79
98
  runtimePrefix: "",
99
+ extensionStatus: "bright-black",
80
100
  },
81
101
  colorSources: {
82
102
  starship: "theme",
83
103
  editor: "theme",
84
104
  userMessages: "theme",
85
105
  },
106
+ extensionStatuses: {
107
+ defaultPlacement: "right",
108
+ placements: {},
109
+ },
86
110
  };
87
111
 
88
112
  const iconKeys = [
@@ -168,6 +192,17 @@ function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiCo
168
192
  cost: colorValue(record, "cost"),
169
193
  separator: colorValue(record, "separator"),
170
194
  runtimePrefix: colorValue(record, "runtimePrefix"),
195
+ extensionStatus: colorValue(record, "extensionStatus"),
196
+ editorAccent: colorValue(record, "editorAccent"),
197
+ editorBorder: colorValue(record, "editorBorder"),
198
+ editorModel: colorValue(record, "editorModel"),
199
+ editorProvider: colorValue(record, "editorProvider"),
200
+ editorThinking: colorValue(record, "editorThinking"),
201
+ editorThinkingMinimal: colorValue(record, "editorThinkingMinimal"),
202
+ editorThinkingLow: colorValue(record, "editorThinkingLow"),
203
+ editorThinkingMedium: colorValue(record, "editorThinkingMedium"),
204
+ editorThinkingHigh: colorValue(record, "editorThinkingHigh"),
205
+ editorThinkingXhigh: colorValue(record, "editorThinkingXhigh"),
171
206
  });
172
207
  }
173
208
 
@@ -179,6 +214,29 @@ function normalizeColorSources(record: Record<string, unknown>): ColorSourcesCon
179
214
  };
180
215
  }
181
216
 
217
+ export function isExtensionStatusPlacement(value: unknown): value is ExtensionStatusPlacement {
218
+ return value === "off" || value === "left" || value === "middle" || value === "right";
219
+ }
220
+
221
+ function normalizeExtensionStatuses(record: Record<string, unknown>): ExtensionStatusesConfig {
222
+ const defaultPlacement = isExtensionStatusPlacement(record.defaultPlacement)
223
+ ? record.defaultPlacement
224
+ : defaultConfig.extensionStatuses.defaultPlacement;
225
+ const placements = isRecord(record.placements)
226
+ ? Object.fromEntries(
227
+ Object.entries(record.placements).filter(
228
+ (entry): entry is [string, ExtensionStatusPlacement] =>
229
+ isExtensionStatusPlacement(entry[1]),
230
+ ),
231
+ )
232
+ : {};
233
+
234
+ return {
235
+ defaultPlacement,
236
+ placements,
237
+ };
238
+ }
239
+
182
240
  function isColorSourceKey(value: string): value is keyof ColorSourcesConfig {
183
241
  return value === "starship" || value === "editor" || value === "userMessages";
184
242
  }
@@ -220,6 +278,9 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
220
278
  const colorSources = isRecord(config.colorSources)
221
279
  ? normalizeColorSources(config.colorSources as Record<string, unknown>)
222
280
  : defaultConfig.colorSources;
281
+ const extensionStatuses = isRecord(config.extensionStatuses)
282
+ ? normalizeExtensionStatuses(config.extensionStatuses as Record<string, unknown>)
283
+ : defaultConfig.extensionStatuses;
223
284
  return {
224
285
  projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
225
286
  icons: {
@@ -231,9 +292,20 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
231
292
  ...colors,
232
293
  },
233
294
  colorSources: { ...colorSources },
295
+ extensionStatuses: {
296
+ defaultPlacement: extensionStatuses.defaultPlacement,
297
+ placements: { ...extensionStatuses.placements },
298
+ },
234
299
  };
235
300
  }
236
301
 
302
+ export function getExtensionStatusPlacement(
303
+ config: PolishedTuiConfig,
304
+ key: string,
305
+ ): ExtensionStatusPlacement {
306
+ return config.extensionStatuses.placements[key] ?? config.extensionStatuses.defaultPlacement;
307
+ }
308
+
237
309
  export function loadConfig(): PolishedTuiConfig {
238
310
  try {
239
311
  if (!existsSync(configPath)) return mergeConfig({});
@@ -258,3 +330,31 @@ export function saveColorSourcesPatch(
258
330
  writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
259
331
  return mergeConfig(record);
260
332
  }
333
+
334
+ export function saveExtensionStatusPlacement(
335
+ key: string,
336
+ placement: ExtensionStatusPlacement,
337
+ path = configPath,
338
+ ): PolishedTuiConfig {
339
+ const record = readConfigRecord(path);
340
+ const existingExtensionStatuses = isRecord(record.extensionStatuses)
341
+ ? { ...(record.extensionStatuses as Record<string, unknown>) }
342
+ : {};
343
+ const existingPlacements = isRecord(existingExtensionStatuses.placements)
344
+ ? { ...(existingExtensionStatuses.placements as Record<string, unknown>) }
345
+ : {};
346
+
347
+ Object.defineProperty(existingPlacements, key, {
348
+ value: placement,
349
+ enumerable: true,
350
+ configurable: true,
351
+ writable: true,
352
+ });
353
+
354
+ record.extensionStatuses = {
355
+ ...existingExtensionStatuses,
356
+ placements: existingPlacements,
357
+ };
358
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
359
+ return mergeConfig(record);
360
+ }
@@ -0,0 +1,53 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import type { ExtensionStatusPlacement, PolishedTuiConfig } from "./config";
3
+ import { getExtensionStatusPlacement } from "./config";
4
+
5
+ export type ExtensionStatusSegment = {
6
+ key: string;
7
+ text: string;
8
+ placement: ExtensionStatusPlacement;
9
+ };
10
+
11
+ export type ExtensionStatusSegmentsByPlacement = {
12
+ left: ExtensionStatusSegment[];
13
+ middle: ExtensionStatusSegment[];
14
+ right: ExtensionStatusSegment[];
15
+ };
16
+
17
+ function compareKeys(a: ExtensionStatusSegment, b: ExtensionStatusSegment): number {
18
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
19
+ }
20
+
21
+ export function sanitizeExtensionStatusText(value: string): string {
22
+ return stripVTControlCharacters(value)
23
+ .replace(/[\r\n\t\f\v]+/g, " ")
24
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, "")
25
+ .replace(/\s+/g, " ")
26
+ .trim();
27
+ }
28
+
29
+ export function collectExtensionStatusSegments(
30
+ statuses: ReadonlyMap<string, string>,
31
+ config: PolishedTuiConfig,
32
+ ): ExtensionStatusSegmentsByPlacement {
33
+ const segments: ExtensionStatusSegmentsByPlacement = {
34
+ left: [],
35
+ middle: [],
36
+ right: [],
37
+ };
38
+
39
+ for (const [key, value] of statuses.entries()) {
40
+ const placement = getExtensionStatusPlacement(config, key);
41
+ if (placement === "off") continue;
42
+
43
+ const text = sanitizeExtensionStatusText(value);
44
+ if (!text) continue;
45
+
46
+ segments[placement].push({ key, text, placement });
47
+ }
48
+
49
+ segments.left.sort(compareKeys);
50
+ segments.middle.sort(compareKeys);
51
+ segments.right.sort(compareKeys);
52
+ return segments;
53
+ }
@@ -1,10 +1,129 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import type { PolishedTuiConfig } from "./config";
4
+ import { collectExtensionStatusSegments } from "./extension-status";
4
5
  import { formatCwdLabel, formatRuntimeSegment } from "./format";
5
6
  import type { FooterState } from "./state";
6
7
  import { renderStyleForSource } from "./style";
7
8
 
9
+ function joinStatusTexts(statusTexts: string[], separator: string): string {
10
+ return statusTexts.filter(Boolean).join(separator);
11
+ }
12
+
13
+ function fitStatusTexts(statusTexts: string[], maxWidth: number, separator: string): string {
14
+ if (maxWidth <= 0) return "";
15
+
16
+ const fitted: string[] = [];
17
+ for (const text of statusTexts) {
18
+ const candidate = joinStatusTexts([...fitted, text], separator);
19
+ if (visibleWidth(candidate) <= maxWidth) {
20
+ fitted.push(text);
21
+ continue;
22
+ }
23
+
24
+ if (fitted.length === 0) {
25
+ return maxWidth > 1 ? truncateToWidth(text, maxWidth, "…") : "";
26
+ }
27
+ break;
28
+ }
29
+
30
+ return joinStatusTexts(fitted, separator);
31
+ }
32
+
33
+ function appendStatusArea(base: string, statusText: string, separator: string): string {
34
+ if (!base) return statusText;
35
+ if (!statusText) return base;
36
+ return `${base}${separator}${statusText}`;
37
+ }
38
+
39
+ function prependStatusArea(base: string, statusText: string, separator: string): string {
40
+ if (!base) return statusText;
41
+ if (!statusText) return base;
42
+ return `${statusText}${separator}${base}`;
43
+ }
44
+
45
+ function composeBuiltInFooterContent(left: string, right: string, innerWidth: number): string {
46
+ const leftWidth = visibleWidth(left);
47
+ const rightWidth = visibleWidth(right);
48
+ return leftWidth >= innerWidth
49
+ ? truncateToWidth(left, innerWidth, "")
50
+ : leftWidth + 1 + rightWidth <= innerWidth
51
+ ? `${left}${" ".repeat(innerWidth - leftWidth - rightWidth)}${right}`
52
+ : truncateToWidth(left, innerWidth, "");
53
+ }
54
+
55
+ function composeFooterContent(
56
+ builtInLeft: string,
57
+ builtInRight: string,
58
+ extensionLeft: string[],
59
+ extensionMiddle: string[],
60
+ extensionRight: string[],
61
+ separator: string,
62
+ innerWidth: number,
63
+ ): string {
64
+ const builtInLeftWidth = visibleWidth(builtInLeft);
65
+ const builtInRightWidth = visibleWidth(builtInRight);
66
+ const minimumGap = builtInLeft && builtInRight ? 1 : 0;
67
+
68
+ if (builtInLeftWidth + minimumGap + builtInRightWidth > innerWidth) {
69
+ return composeBuiltInFooterContent(builtInLeft, builtInRight, innerWidth);
70
+ }
71
+
72
+ const available = Math.max(0, innerWidth - builtInLeftWidth - builtInRightWidth - minimumGap);
73
+ let remaining = available;
74
+ const leftConnectorWidth = builtInLeft && extensionLeft.length > 0 ? visibleWidth(separator) : 0;
75
+ const rightConnectorWidth =
76
+ builtInRight && extensionRight.length > 0 ? visibleWidth(separator) : 0;
77
+ let leftStatus = "";
78
+ let rightStatus = "";
79
+
80
+ if (extensionLeft.length > 0 && extensionRight.length > 0) {
81
+ const leftBudget = Math.max(0, Math.floor(available / 2) - leftConnectorWidth);
82
+ leftStatus = fitStatusTexts(extensionLeft, leftBudget, separator);
83
+ remaining -= leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0;
84
+
85
+ const rightBudget = Math.max(0, remaining - rightConnectorWidth);
86
+ rightStatus = fitStatusTexts(extensionRight, rightBudget, separator);
87
+ remaining -= rightStatus ? rightConnectorWidth + visibleWidth(rightStatus) : 0;
88
+
89
+ const expandedLeftBudget = Math.max(0, remaining + visibleWidth(leftStatus));
90
+ const expandedLeftStatus = fitStatusTexts(extensionLeft, expandedLeftBudget, separator);
91
+ if (visibleWidth(expandedLeftStatus) > visibleWidth(leftStatus)) {
92
+ remaining += leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0;
93
+ leftStatus = expandedLeftStatus;
94
+ remaining -= leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0;
95
+ }
96
+ } else if (extensionLeft.length > 0) {
97
+ leftStatus = fitStatusTexts(
98
+ extensionLeft,
99
+ Math.max(0, available - leftConnectorWidth),
100
+ separator,
101
+ );
102
+ remaining -= leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0;
103
+ } else if (extensionRight.length > 0) {
104
+ rightStatus = fitStatusTexts(
105
+ extensionRight,
106
+ Math.max(0, available - rightConnectorWidth),
107
+ separator,
108
+ );
109
+ remaining -= rightStatus ? rightConnectorWidth + visibleWidth(rightStatus) : 0;
110
+ }
111
+
112
+ const left = appendStatusArea(builtInLeft, leftStatus, separator);
113
+ const right = prependStatusArea(builtInRight, rightStatus, separator);
114
+ const gapWidth = Math.max(0, innerWidth - visibleWidth(left) - visibleWidth(right));
115
+ const middle = fitStatusTexts(extensionMiddle, gapWidth, separator);
116
+ const middleWidth = visibleWidth(middle);
117
+
118
+ if (!middle || middleWidth <= 0) {
119
+ return `${left}${" ".repeat(gapWidth)}${right}`;
120
+ }
121
+
122
+ const leftPadding = Math.floor((gapWidth - middleWidth) / 2);
123
+ const rightPadding = gapWidth - middleWidth - leftPadding;
124
+ return `${left}${" ".repeat(leftPadding)}${middle}${" ".repeat(rightPadding)}${right}`;
125
+ }
126
+
8
127
  export function installFooter(
9
128
  ctx: ExtensionContext,
10
129
  state: FooterState,
@@ -12,10 +131,12 @@ export function installFooter(
12
131
  hooks: {
13
132
  setRequestRender: (fn: (() => void) | undefined) => void;
14
133
  scheduleProjectRefresh: (ctx: ExtensionContext) => void;
134
+ setExtensionStatusesGetter?: (fn: (() => ReadonlyMap<string, string>) | undefined) => void;
15
135
  },
16
136
  ): void {
17
137
  ctx.ui.setFooter((tui, theme, footerData) => {
18
138
  hooks.setRequestRender(() => tui.requestRender());
139
+ hooks.setExtensionStatusesGetter?.(() => footerData.getExtensionStatuses());
19
140
  const unsubscribeBranch = footerData.onBranchChange(() => {
20
141
  hooks.scheduleProjectRefresh(ctx);
21
142
  tui.requestRender();
@@ -25,6 +146,7 @@ export function installFooter(
25
146
  dispose: () => {
26
147
  unsubscribeBranch();
27
148
  hooks.setRequestRender(undefined);
149
+ hooks.setExtensionStatusesGetter?.(undefined);
28
150
  },
29
151
  invalidate() {},
30
152
  render(width: number): string[] {
@@ -92,15 +214,21 @@ export function installFooter(
92
214
  renderStyleForSource(theme, colorSource, config.colors.tokens, state.tokenLabel),
93
215
  renderStyleForSource(theme, colorSource, config.colors.cost, state.costLabel),
94
216
  ].join(separator);
95
-
96
- const leftWidth = visibleWidth(left);
97
- const rightWidth = visibleWidth(right);
98
- const content =
99
- leftWidth >= innerWidth
100
- ? truncateToWidth(left, innerWidth, "")
101
- : leftWidth + 1 + rightWidth <= innerWidth
102
- ? `${left}${" ".repeat(innerWidth - leftWidth - rightWidth)}${right}`
103
- : truncateToWidth(left, innerWidth, "");
217
+ const extensionStatuses = collectExtensionStatusSegments(
218
+ footerData.getExtensionStatuses(),
219
+ config,
220
+ );
221
+ const renderExtensionStatus = (text: string) =>
222
+ renderStyleForSource(theme, colorSource, config.colors.extensionStatus, text);
223
+ const content = composeFooterContent(
224
+ left,
225
+ right,
226
+ extensionStatuses.left.map((segment) => renderExtensionStatus(segment.text)),
227
+ extensionStatuses.middle.map((segment) => renderExtensionStatus(segment.text)),
228
+ extensionStatuses.right.map((segment) => renderExtensionStatus(segment.text)),
229
+ separator,
230
+ innerWidth,
231
+ );
104
232
  const framed = width > 2 ? ` ${truncateToWidth(content, width - 2, "")} ` : content;
105
233
  return [truncateToWidth(framed, width, "")];
106
234
  },
@@ -7,10 +7,12 @@ import type {
7
7
  import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
8
8
  import {
9
9
  type ColorSourcesConfig,
10
+ type ExtensionStatusPlacement,
10
11
  type PolishedTuiConfig,
11
12
  ensureConfigExists,
12
13
  loadConfig,
13
14
  saveColorSourcesPatch,
15
+ saveExtensionStatusPlacement,
14
16
  } from "./config";
15
17
  import { installFooter } from "./footer";
16
18
  import { emptyGitStatus, readGitStatus } from "./git";
@@ -19,7 +21,6 @@ import { readRuntimeInfo } from "./runtime";
19
21
  import { installSelectorBorderStyle } from "./selector-border";
20
22
  import { registerZentuiSettingsCommand } from "./settings-command";
21
23
  import { type FooterState, createInitialState, syncState } from "./state";
22
- import { renderAccentLine, safeThemeFg } from "./style";
23
24
  import { PolishedEditor } from "./ui";
24
25
  import { installUserMessageStyle } from "./user-message";
25
26
 
@@ -29,6 +30,7 @@ export default function (pi: ExtensionAPI) {
29
30
  let currentConfig: PolishedTuiConfig = loadConfig();
30
31
  let activeTheme: Theme | undefined;
31
32
  let requestFooterRender: (() => void) | undefined;
33
+ let getActiveExtensionStatuses: () => ReadonlyMap<string, string> = () => new Map();
32
34
  let stopRefreshInterval: StopProjectRefreshInterval = () => {};
33
35
  let cleanupPrototypePatches: () => void = () => {};
34
36
  let projectRefreshInFlight = false;
@@ -81,15 +83,10 @@ export default function (pi: ExtensionAPI) {
81
83
  keybindings,
82
84
  ctx.ui.theme,
83
85
  getCurrentConfig,
84
- () =>
85
- [
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", " ")),
86
+ () => ({
87
+ modelLabel: state.modelLabel,
88
+ providerLabel: state.providerLabel,
89
+ }),
93
90
  getThinkingLevel,
94
91
  ),
95
92
  );
@@ -115,6 +112,9 @@ export default function (pi: ExtensionAPI) {
115
112
  requestFooterRender = fn;
116
113
  },
117
114
  scheduleProjectRefresh,
115
+ setExtensionStatusesGetter(fn) {
116
+ getActiveExtensionStatuses = fn ?? (() => new Map());
117
+ },
118
118
  });
119
119
  installEditor(ctx);
120
120
  stopRefreshInterval = startProjectRefreshInterval(currentConfig.projectRefreshIntervalMs, () =>
@@ -132,6 +132,7 @@ export default function (pi: ExtensionAPI) {
132
132
  projectRefreshInFlight = false;
133
133
  projectRefreshPending = false;
134
134
  requestFooterRender = undefined;
135
+ getActiveExtensionStatuses = () => new Map();
135
136
  if (ctx?.hasUI) {
136
137
  ctx.ui.setFooter(undefined);
137
138
  ctx.ui.setEditorComponent(undefined);
@@ -148,6 +149,12 @@ export default function (pi: ExtensionAPI) {
148
149
  setColorSources(patch: Partial<ColorSourcesConfig>) {
149
150
  currentConfig = saveColorSourcesPatch(patch);
150
151
  },
152
+ getActiveExtensionStatuses() {
153
+ return getActiveExtensionStatuses();
154
+ },
155
+ setExtensionStatusPlacement(key: string, placement: ExtensionStatusPlacement) {
156
+ currentConfig = saveExtensionStatusPlacement(key, placement);
157
+ },
151
158
  requestRender() {
152
159
  refresh();
153
160
  },
@@ -6,16 +6,32 @@ import {
6
6
  type SettingsListTheme,
7
7
  truncateToWidth,
8
8
  } from "@earendil-works/pi-tui";
9
- import type { ColorSource, ColorSourcesConfig, PolishedTuiConfig } from "./config";
9
+ import {
10
+ type ColorSource,
11
+ type ColorSourcesConfig,
12
+ type ExtensionStatusPlacement,
13
+ type PolishedTuiConfig,
14
+ getExtensionStatusPlacement,
15
+ isExtensionStatusPlacement,
16
+ } from "./config";
17
+ import { sanitizeExtensionStatusText } from "./extension-status";
10
18
  import { EDITOR_BORDER_STYLE, renderChromeBorder, safeThemeFg } from "./style";
11
19
 
12
20
  const colorSourceValues: ColorSource[] = ["theme", "terminal"];
21
+ const extensionStatusPlacementValues: ExtensionStatusPlacement[] = [
22
+ "off",
23
+ "left",
24
+ "middle",
25
+ "right",
26
+ ];
13
27
 
14
28
  type SettingId = "starship" | "editorMessages";
15
29
 
16
30
  type SettingsCommandDeps = {
17
31
  getConfig: () => PolishedTuiConfig;
18
32
  setColorSources: (patch: Partial<ColorSourcesConfig>) => void;
33
+ getActiveExtensionStatuses: () => ReadonlyMap<string, string>;
34
+ setExtensionStatusPlacement: (key: string, placement: ExtensionStatusPlacement) => void;
19
35
  requestRender: () => void;
20
36
  settingsListTheme?: SettingsListTheme;
21
37
  };
@@ -50,27 +66,114 @@ function patchForSetting(id: SettingId, value: ColorSource): Partial<ColorSource
50
66
  return id === "starship" ? { starship: value } : { editor: value, userMessages: value };
51
67
  }
52
68
 
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
- }));
69
+ function buildItems(
70
+ config: PolishedTuiConfig,
71
+ activeStatusCount: number,
72
+ thirdPartyStatusesSubmenu: SettingItem["submenu"],
73
+ ): SettingItem[] {
74
+ return [
75
+ ...(Object.keys(settingLabels) as SettingId[]).map((key) => ({
76
+ id: key,
77
+ label: settingLabels[key],
78
+ description: settingDescriptions[key],
79
+ currentValue: key === "starship" ? config.colorSources.starship : editorMessageValue(config),
80
+ values: colorSourceValues,
81
+ })),
82
+ {
83
+ id: "thirdPartyStatuses",
84
+ label: "Third-party statuses",
85
+ description:
86
+ "Configure active ctx.ui.setStatus() footer statuses. Only currently active keys are listed.",
87
+ currentValue: `${activeStatusCount} active`,
88
+ submenu: thirdPartyStatusesSubmenu,
89
+ },
90
+ ];
61
91
  }
62
92
 
63
93
  export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCommandDeps): void {
64
94
  pi.registerCommand("zentui", {
65
- description: "Configure Zentui colors",
95
+ description: "Configure Zentui",
66
96
  handler: async (_args, ctx) => {
67
97
  if (!ctx.hasUI) return;
68
98
 
69
99
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
100
+ const settingsListTheme = deps.settingsListTheme ?? getSettingsListTheme();
101
+ const makeThirdPartyStatusesSubmenu: SettingItem["submenu"] = (_currentValue, close) => {
102
+ const activeStatuses = Array.from(deps.getActiveExtensionStatuses().entries()).sort(
103
+ ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0),
104
+ );
105
+
106
+ if (activeStatuses.length === 0) {
107
+ return {
108
+ render(width: number) {
109
+ return [
110
+ truncateToWidth(safeThemeFg(theme, "accent", "Third-party statuses"), width, ""),
111
+ "",
112
+ truncateToWidth(
113
+ safeThemeFg(theme, "muted", "No third-party statuses are active."),
114
+ width,
115
+ "",
116
+ ),
117
+ truncateToWidth(
118
+ safeThemeFg(
119
+ theme,
120
+ "muted",
121
+ "This menu only lists statuses currently published through ctx.ui.setStatus().",
122
+ ),
123
+ width,
124
+ "",
125
+ ),
126
+ "",
127
+ truncateToWidth(safeThemeFg(theme, "muted", "Esc to go back"), width, ""),
128
+ ];
129
+ },
130
+ invalidate() {},
131
+ handleInput(data: string) {
132
+ if (data === "\x1b" || data === "\u0003") close(undefined);
133
+ },
134
+ };
135
+ }
136
+
137
+ const statusItems: SettingItem[] = activeStatuses.map(([key, value]) => {
138
+ const sanitizedText = sanitizeExtensionStatusText(value);
139
+ return {
140
+ id: key,
141
+ label: key,
142
+ description: sanitizedText ? `Current status: ${sanitizedText}` : undefined,
143
+ currentValue: getExtensionStatusPlacement(deps.getConfig(), key),
144
+ values: extensionStatusPlacementValues,
145
+ };
146
+ });
147
+ const statusSettingsList = new SettingsList(
148
+ statusItems,
149
+ 8,
150
+ settingsListTheme,
151
+ (key, newValue) => {
152
+ if (!isExtensionStatusPlacement(newValue)) return;
153
+
154
+ try {
155
+ deps.setExtensionStatusPlacement(key, newValue);
156
+ statusSettingsList.updateValue(key, newValue);
157
+ deps.requestRender();
158
+ ctx.ui.notify(`Third-party status ${key}: ${newValue}`, "info");
159
+ tui.requestRender();
160
+ } catch (error) {
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
163
+ }
164
+ },
165
+ () => close(undefined),
166
+ );
167
+ return statusSettingsList;
168
+ };
70
169
  const settingsList = new SettingsList(
71
- buildItems(deps.getConfig()),
170
+ buildItems(
171
+ deps.getConfig(),
172
+ deps.getActiveExtensionStatuses().size,
173
+ makeThirdPartyStatusesSubmenu,
174
+ ),
72
175
  5,
73
- deps.settingsListTheme ?? getSettingsListTheme(),
176
+ settingsListTheme,
74
177
  (id, newValue) => {
75
178
  if (!isSettingId(id) || !isColorSource(newValue)) return;
76
179
 
@@ -13,6 +13,21 @@ export type { ThemeLike };
13
13
  export const EDITOR_ACCENT_STYLE = "blue";
14
14
  export const EDITOR_BORDER_STYLE = "bright-black";
15
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
+
16
31
  function isHexColor(value: string): boolean {
17
32
  return /^#(?:[0-9a-fA-F]{6})$/.test(value);
18
33
  }
@@ -267,6 +282,17 @@ export function renderStyleForSource(
267
282
  : renderThemeStyle(theme, style, text);
268
283
  }
269
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
+
270
296
  export function renderEditorAccent(text: string): string {
271
297
  return renderTerminalStyle(EDITOR_ACCENT_STYLE, text);
272
298
  }
@@ -276,7 +302,7 @@ export function renderEditorBorder(text: string): string {
276
302
  }
277
303
 
278
304
  export function renderAccentLine(theme: ThemeLike, source: ColorSource, text: string): string {
279
- return source === "terminal" ? renderEditorAccent(text) : safeThemeFg(theme, "accent", text);
305
+ return renderStyleForSourceOrFallback(theme, source, undefined, EDITOR_ACCENT_FALLBACK, text);
280
306
  }
281
307
 
282
308
  export function renderChromeBorder(
@@ -285,6 +311,11 @@ export function renderChromeBorder(
285
311
  terminalFallbackStyle: ColorSpec,
286
312
  text: string,
287
313
  ): string {
288
- if (source === "terminal") return renderTerminalStyle(terminalFallbackStyle, text);
289
- return safeThemeFg(theme, "borderMuted", text);
314
+ return renderStyleForSourceOrFallback(
315
+ theme,
316
+ source,
317
+ undefined,
318
+ { theme: "borderMuted", terminal: terminalFallbackStyle },
319
+ text,
320
+ );
290
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, safeThemeFg } 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,7 +42,7 @@ 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 });
@@ -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(safeThemeFg(this.uiTheme, "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
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";
@@ -92,7 +96,17 @@ function renderPromptBoxLine(
92
96
  config: PolishedTuiConfig,
93
97
  ): string {
94
98
  if (width <= 0) return "";
95
- 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
+ } `;
96
110
  const contentWidth = Math.max(0, width - visibleWidth(rail));
97
111
  return truncateToWidth(`${rail}${fillLine(line, contentWidth)}`, width, "");
98
112
  }
@@ -108,7 +122,17 @@ function renderZentuiUserMessage(
108
122
  if (width <= 0) return [""];
109
123
 
110
124
  const railWidth = visibleWidth(
111
- `${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
+ } `,
112
136
  );
113
137
  const contentWidth = Math.max(1, width - railWidth);
114
138
  const renderer = new Markdown(text, 0, 0, makeMarkdownTheme(theme), {
@@ -117,10 +141,11 @@ function renderZentuiUserMessage(
117
141
  const body = renderer.render(contentWidth);
118
142
  const contentLines = body.length > 0 ? body : [""];
119
143
  const border = theme
120
- ? renderChromeBorder(
144
+ ? renderStyleForSourceOrFallback(
121
145
  theme,
122
146
  config.colorSources.userMessages,
123
- EDITOR_BORDER_STYLE,
147
+ config.colors.editorBorder,
148
+ EDITOR_BORDER_FALLBACK,
124
149
  "─".repeat(width),
125
150
  )
126
151
  : "─".repeat(width);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",