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