pi-zentui 0.1.14 → 0.2.0
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 +18 -6
- package/extensions/zentui/config.ts +9 -1
- package/extensions/zentui/index.ts +67 -6
- package/extensions/zentui/settings-command.ts +155 -129
- package/extensions/zentui/ui.ts +416 -115
- package/extensions/zentui/user-message.ts +19 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ A Starship-inspired statusline and Opencode-style TUI for [Pi](https://pi.dev).
|
|
|
11
11
|
Zentui brings two popular aesthetics to Pi:
|
|
12
12
|
|
|
13
13
|
- **[Starship](https://starship.rs/) footer** — shows your current directory, git branch, git status indicators, and runtime/version detection in a compact, icon-rich format
|
|
14
|
-
- **[Opencode](https://github.com/opencode-ai/opencode) editor** — clean bordered input box with accent rail and model/provider display inside the editor frame
|
|
14
|
+
- **[Opencode](https://github.com/opencode-ai/opencode) editor** — clean bordered input box with accent rail, copy-friendly mode, and model/provider display inside the editor frame
|
|
15
15
|
|
|
16
16
|
## Features
|
|
17
17
|
|
|
@@ -31,6 +31,7 @@ Zentui brings two popular aesthetics to Pi:
|
|
|
31
31
|
- Model name and provider displayed inside the editor frame
|
|
32
32
|
- Configurable model, provider, and thinking-level indicator colors
|
|
33
33
|
- Prompt-box-style user messages matching the ZentUI input chrome
|
|
34
|
+
- Copy-friendly mode hides editor and previous-message rail glyphs so terminal selection copies less chrome
|
|
34
35
|
|
|
35
36
|
### Git Status Icons
|
|
36
37
|
|
|
@@ -125,6 +126,8 @@ pi install git:github.com/lmilojevicc/pi-zentui
|
|
|
125
126
|
|
|
126
127
|
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, UI feature toggles, and active third-party status placements.
|
|
127
128
|
|
|
129
|
+
The interactive `/zentui` menu is split into three sections. Use `Tab` to switch between `Coloring`, `Features`, and `Status line`.
|
|
130
|
+
|
|
128
131
|
Useful slash-command shortcuts:
|
|
129
132
|
|
|
130
133
|
```text
|
|
@@ -134,6 +137,9 @@ Useful slash-command shortcuts:
|
|
|
134
137
|
/zentui statusline disable
|
|
135
138
|
/zentui editor toggle
|
|
136
139
|
/zentui statusline toggle
|
|
140
|
+
/zentui copy-friendly enable
|
|
141
|
+
/zentui copy-friendly disable
|
|
142
|
+
/zentui copy-friendly toggle
|
|
137
143
|
```
|
|
138
144
|
|
|
139
145
|
Default config values — copy this and change any value you want:
|
|
@@ -155,7 +161,8 @@ Default config values — copy this and change any value you want:
|
|
|
155
161
|
"renamed": "»",
|
|
156
162
|
"deleted": "✘",
|
|
157
163
|
"typechanged": "T",
|
|
158
|
-
"cacheHit": ""
|
|
164
|
+
"cacheHit": "",
|
|
165
|
+
"editorPrompt": ""
|
|
159
166
|
},
|
|
160
167
|
"colors": {
|
|
161
168
|
"cwd": "bold cyan",
|
|
@@ -170,6 +177,7 @@ Default config values — copy this and change any value you want:
|
|
|
170
177
|
"separator": "bright-black",
|
|
171
178
|
"runtimePrefix": "",
|
|
172
179
|
"editorAccent": "accent",
|
|
180
|
+
"editorPrompt": "accent",
|
|
173
181
|
"editorBorder": "borderMuted",
|
|
174
182
|
"editorModel": "accent",
|
|
175
183
|
"editorProvider": "text",
|
|
@@ -187,7 +195,8 @@ Default config values — copy this and change any value you want:
|
|
|
187
195
|
},
|
|
188
196
|
"features": {
|
|
189
197
|
"editor": true,
|
|
190
|
-
"statusLine": true
|
|
198
|
+
"statusLine": true,
|
|
199
|
+
"copyFriendly": false
|
|
191
200
|
},
|
|
192
201
|
"extensionStatuses": {
|
|
193
202
|
"defaultPlacement": "right",
|
|
@@ -198,15 +207,18 @@ Default config values — copy this and change any value you want:
|
|
|
198
207
|
|
|
199
208
|
- Style values can be Starship/terminal strings (`bold purple`, `fg:202`, `#89b4fa`, `bg:blue fg:bright-green`) or Pi theme tokens (`accent`, `borderMuted`, `thinkingHigh`).
|
|
200
209
|
- `projectRefreshIntervalMs`: project status polling interval; `0` disables polling.
|
|
201
|
-
- `icons`: every shown icon key is configurable; omit any key to use the Zentui default.
|
|
210
|
+
- `icons`: every shown icon key is configurable; omit any key to use the Zentui default. `editorPrompt` controls an optional copy-friendly editor prompt glyph; the default is `""` so copy-friendly mode stays rail-free.
|
|
202
211
|
- `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
|
|
203
|
-
- `features`: `editor` enables Zentui's custom editor, selector borders, and previous-message chrome. `statusLine` enables Zentui's custom footer/status line.
|
|
212
|
+
- `features`: `editor` enables Zentui's custom editor, selector borders, and previous-message chrome. `statusLine` enables Zentui's custom footer/status line. `copyFriendly` hides editor and previous-message rail glyphs so native terminal selection copies less chrome. All three can be changed from `/zentui` or direct slash-command arguments.
|
|
204
213
|
- `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.
|
|
205
214
|
- 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`.
|
|
206
|
-
- `editorAccent` styles the active editor rail and previous user-message rail.
|
|
215
|
+
- `editorAccent` styles the active editor rail and previous user-message rail when `features.copyFriendly` is disabled.
|
|
216
|
+
- `editorPrompt` styles the copy-friendly editor prompt glyph. Omit it to use `editorAccent`, then the default accent fallback.
|
|
207
217
|
- `editorBorder` styles the active editor and previous user-message top/bottom border color only; the border glyph stays `─`.
|
|
208
218
|
- `editorModel`, `editorProvider`, and `editorThinking*` style the editor metadata. `editorThinking` applies to every non-`off` thinking level unless a level-specific key is set.
|
|
209
219
|
|
|
220
|
+
Tip: when using copy-friendly mode, setting Pi's `editorPaddingX` to `1` in `~/.pi/agent/settings.json` keeps a small left gutter without copying a rail glyph.
|
|
221
|
+
|
|
210
222
|
## Requirements
|
|
211
223
|
|
|
212
224
|
- [Pi](https://pi.dev) coding agent 0.74 or newer
|
|
@@ -15,6 +15,7 @@ export type ColorSourcesConfig = {
|
|
|
15
15
|
export type UiFeaturesConfig = {
|
|
16
16
|
editor: boolean;
|
|
17
17
|
statusLine: boolean;
|
|
18
|
+
copyFriendly: boolean;
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
|
|
@@ -44,6 +45,7 @@ export type PolishedTuiConfig = {
|
|
|
44
45
|
deleted: string;
|
|
45
46
|
typechanged: string;
|
|
46
47
|
cacheHit: string;
|
|
48
|
+
editorPrompt: string;
|
|
47
49
|
};
|
|
48
50
|
colors: {
|
|
49
51
|
cwd: ColorSpec;
|
|
@@ -58,6 +60,7 @@ export type PolishedTuiConfig = {
|
|
|
58
60
|
runtimePrefix: ColorSpec;
|
|
59
61
|
extensionStatus: ColorSpec;
|
|
60
62
|
editorAccent?: ColorSpec;
|
|
63
|
+
editorPrompt?: ColorSpec;
|
|
61
64
|
editorBorder?: ColorSpec;
|
|
62
65
|
editorModel?: ColorSpec;
|
|
63
66
|
editorProvider?: ColorSpec;
|
|
@@ -92,6 +95,7 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
92
95
|
deleted: "✘",
|
|
93
96
|
typechanged: "T",
|
|
94
97
|
cacheHit: "",
|
|
98
|
+
editorPrompt: "",
|
|
95
99
|
},
|
|
96
100
|
colors: {
|
|
97
101
|
cwd: "bold cyan",
|
|
@@ -114,6 +118,7 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
114
118
|
features: {
|
|
115
119
|
editor: true,
|
|
116
120
|
statusLine: true,
|
|
121
|
+
copyFriendly: false,
|
|
117
122
|
},
|
|
118
123
|
extensionStatuses: {
|
|
119
124
|
defaultPlacement: "right",
|
|
@@ -136,6 +141,7 @@ const iconKeys = [
|
|
|
136
141
|
"deleted",
|
|
137
142
|
"typechanged",
|
|
138
143
|
"cacheHit",
|
|
144
|
+
"editorPrompt",
|
|
139
145
|
] as const satisfies readonly (keyof PolishedTuiConfig["icons"])[];
|
|
140
146
|
|
|
141
147
|
type ConfigRecord = Record<string, unknown>;
|
|
@@ -212,6 +218,7 @@ function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiCo
|
|
|
212
218
|
runtimePrefix: colorValue(record, "runtimePrefix"),
|
|
213
219
|
extensionStatus: colorValue(record, "extensionStatus"),
|
|
214
220
|
editorAccent: colorValue(record, "editorAccent"),
|
|
221
|
+
editorPrompt: colorValue(record, "editorPrompt"),
|
|
215
222
|
editorBorder: colorValue(record, "editorBorder"),
|
|
216
223
|
editorModel: colorValue(record, "editorModel"),
|
|
217
224
|
editorProvider: colorValue(record, "editorProvider"),
|
|
@@ -236,6 +243,7 @@ function normalizeUiFeatures(record: Record<string, unknown>): UiFeaturesConfig
|
|
|
236
243
|
return {
|
|
237
244
|
editor: booleanValue(record, "editor"),
|
|
238
245
|
statusLine: booleanValue(record, "statusLine"),
|
|
246
|
+
copyFriendly: booleanValue(record, "copyFriendly"),
|
|
239
247
|
};
|
|
240
248
|
}
|
|
241
249
|
|
|
@@ -267,7 +275,7 @@ function isColorSourceKey(value: string): value is keyof ColorSourcesConfig {
|
|
|
267
275
|
}
|
|
268
276
|
|
|
269
277
|
function isUiFeatureKey(value: string): value is keyof UiFeaturesConfig {
|
|
270
|
-
return value === "editor" || value === "statusLine";
|
|
278
|
+
return value === "editor" || value === "statusLine" || value === "copyFriendly";
|
|
271
279
|
}
|
|
272
280
|
|
|
273
281
|
function validColorSourceEntries(record: Record<string, unknown>): Partial<ColorSourcesConfig> {
|
|
@@ -23,7 +23,7 @@ import { readRuntimeInfo } from "./runtime";
|
|
|
23
23
|
import { installSelectorBorderStyle } from "./selector-border";
|
|
24
24
|
import { registerZentuiSettingsCommand } from "./settings-command";
|
|
25
25
|
import { type FooterState, createInitialState, syncState } from "./state";
|
|
26
|
-
import { PolishedEditor } from "./ui";
|
|
26
|
+
import { PolishedEditor, WrappedPolishedEditor } from "./ui";
|
|
27
27
|
import { installUserMessageStyle } from "./user-message";
|
|
28
28
|
|
|
29
29
|
const ZENTUI_EDITOR_FACTORY = Symbol.for("pi-zentui.editor-factory");
|
|
@@ -38,6 +38,8 @@ type ApplyUiResult = {
|
|
|
38
38
|
editorBlocked: boolean;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
type EditorInstallMode = "none" | "standalone" | "wrapper";
|
|
42
|
+
|
|
41
43
|
function isZentuiEditorFactory(factory: EditorFactory | undefined): boolean {
|
|
42
44
|
return Boolean((factory as ZentuiEditorFactory | undefined)?.[ZENTUI_EDITOR_FACTORY]);
|
|
43
45
|
}
|
|
@@ -53,6 +55,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
53
55
|
let cleanupPrototypePatches: () => void = () => {};
|
|
54
56
|
let footerInstalled = false;
|
|
55
57
|
let editorInstalled = false;
|
|
58
|
+
let editorInstallMode: EditorInstallMode = "none";
|
|
59
|
+
let wrappedEditorFactory: EditorFactory | undefined;
|
|
56
60
|
let prototypePatchesInstalled = false;
|
|
57
61
|
let projectRefreshInFlight = false;
|
|
58
62
|
let projectRefreshPending = false;
|
|
@@ -139,12 +143,42 @@ export default function (pi: ExtensionAPI) {
|
|
|
139
143
|
return factory;
|
|
140
144
|
};
|
|
141
145
|
|
|
146
|
+
const makeWrappedEditorFactory = (
|
|
147
|
+
ctx: ExtensionContext,
|
|
148
|
+
baseFactory: EditorFactory,
|
|
149
|
+
): ZentuiEditorFactory => {
|
|
150
|
+
const factory = ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) =>
|
|
151
|
+
new WrappedPolishedEditor(
|
|
152
|
+
baseFactory(tui, theme, keybindings),
|
|
153
|
+
ctx.ui.theme,
|
|
154
|
+
getCurrentConfig,
|
|
155
|
+
() => ({
|
|
156
|
+
modelLabel: state.modelLabel,
|
|
157
|
+
providerLabel: state.providerLabel,
|
|
158
|
+
}),
|
|
159
|
+
getThinkingLevel,
|
|
160
|
+
)) as ZentuiEditorFactory;
|
|
161
|
+
factory[ZENTUI_EDITOR_FACTORY] = true;
|
|
162
|
+
return factory;
|
|
163
|
+
};
|
|
164
|
+
|
|
142
165
|
const installEditor = (ctx: ExtensionContext): boolean => {
|
|
143
166
|
const currentFactory = ctx.ui.getEditorComponent();
|
|
144
|
-
if (currentFactory &&
|
|
167
|
+
if (currentFactory && isZentuiEditorFactory(currentFactory)) {
|
|
168
|
+
editorInstalled = true;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
145
171
|
|
|
146
172
|
installPrototypePatches();
|
|
147
|
-
|
|
173
|
+
if (currentFactory) {
|
|
174
|
+
wrappedEditorFactory = currentFactory;
|
|
175
|
+
ctx.ui.setEditorComponent(makeWrappedEditorFactory(ctx, currentFactory));
|
|
176
|
+
editorInstallMode = "wrapper";
|
|
177
|
+
} else {
|
|
178
|
+
wrappedEditorFactory = undefined;
|
|
179
|
+
ctx.ui.setEditorComponent(makeEditorFactory(ctx));
|
|
180
|
+
editorInstallMode = "standalone";
|
|
181
|
+
}
|
|
148
182
|
editorInstalled = true;
|
|
149
183
|
return true;
|
|
150
184
|
};
|
|
@@ -154,7 +188,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
154
188
|
if (currentFactory && !isZentuiEditorFactory(currentFactory)) return false;
|
|
155
189
|
|
|
156
190
|
uninstallPrototypePatches();
|
|
157
|
-
ctx.ui.setEditorComponent(
|
|
191
|
+
ctx.ui.setEditorComponent(
|
|
192
|
+
editorInstallMode === "wrapper" && wrappedEditorFactory ? wrappedEditorFactory : undefined,
|
|
193
|
+
);
|
|
194
|
+
wrappedEditorFactory = undefined;
|
|
195
|
+
editorInstallMode = "none";
|
|
158
196
|
editorInstalled = false;
|
|
159
197
|
return true;
|
|
160
198
|
};
|
|
@@ -192,7 +230,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
192
230
|
if (!ctx.hasUI) return result;
|
|
193
231
|
activeTheme = ctx.ui.theme;
|
|
194
232
|
if (currentConfig.features.editor) {
|
|
195
|
-
|
|
233
|
+
const currentFactory = ctx.ui.getEditorComponent();
|
|
234
|
+
const editorMissingOrReplaced = !editorInstalled || !isZentuiEditorFactory(currentFactory);
|
|
235
|
+
if (editorMissingOrReplaced) result.editorBlocked = !installEditor(ctx);
|
|
196
236
|
} else if (editorInstalled || prototypePatchesInstalled) {
|
|
197
237
|
result.editorBlocked = !uninstallEditor(ctx);
|
|
198
238
|
}
|
|
@@ -219,6 +259,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
219
259
|
refresh();
|
|
220
260
|
};
|
|
221
261
|
|
|
262
|
+
const scheduleEditorReconciliation = (ctx: ExtensionContext) => {
|
|
263
|
+
setTimeout(() => {
|
|
264
|
+
if (!ctx.hasUI || !currentConfig.features.editor) return;
|
|
265
|
+
const currentFactory = ctx.ui.getEditorComponent();
|
|
266
|
+
if (currentFactory && !isZentuiEditorFactory(currentFactory)) {
|
|
267
|
+
applyConfiguredUi(ctx);
|
|
268
|
+
refresh();
|
|
269
|
+
}
|
|
270
|
+
}, 0);
|
|
271
|
+
};
|
|
272
|
+
|
|
222
273
|
const cleanupUi = (ctx?: ExtensionContext) => {
|
|
223
274
|
uninstallPrototypePatches();
|
|
224
275
|
stopProjectRefresh();
|
|
@@ -226,8 +277,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
226
277
|
getActiveExtensionStatuses = () => new Map();
|
|
227
278
|
if (ctx?.hasUI) {
|
|
228
279
|
ctx.ui.setFooter(undefined);
|
|
229
|
-
ctx.ui.
|
|
280
|
+
const currentFactory = ctx.ui.getEditorComponent();
|
|
281
|
+
if (!currentFactory || isZentuiEditorFactory(currentFactory)) {
|
|
282
|
+
ctx.ui.setEditorComponent(
|
|
283
|
+
editorInstallMode === "wrapper" && wrappedEditorFactory
|
|
284
|
+
? wrappedEditorFactory
|
|
285
|
+
: undefined,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
230
288
|
}
|
|
289
|
+
wrappedEditorFactory = undefined;
|
|
290
|
+
editorInstallMode = "none";
|
|
231
291
|
footerInstalled = false;
|
|
232
292
|
editorInstalled = false;
|
|
233
293
|
activeTheme = undefined;
|
|
@@ -242,6 +302,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
242
302
|
|
|
243
303
|
pi.on("session_start", async (_event, ctx) => {
|
|
244
304
|
installUi(ctx);
|
|
305
|
+
scheduleEditorReconciliation(ctx);
|
|
245
306
|
});
|
|
246
307
|
|
|
247
308
|
registerZentuiSettingsCommand(pi, {
|
|
@@ -29,9 +29,11 @@ const extensionStatusPlacementValues: ExtensionStatusPlacement[] = [
|
|
|
29
29
|
type FeatureState = "enabled" | "disabled";
|
|
30
30
|
|
|
31
31
|
const featureStateValues: FeatureState[] = ["enabled", "disabled"];
|
|
32
|
+
const settingsSections = ["coloring", "features", "statusLine"] as const;
|
|
32
33
|
|
|
33
34
|
type ColorSettingId = "starship" | "editorMessages";
|
|
34
35
|
type FeatureSettingId = keyof UiFeaturesConfig;
|
|
36
|
+
type SettingsSection = (typeof settingsSections)[number];
|
|
35
37
|
|
|
36
38
|
type SettingsCommandDeps = {
|
|
37
39
|
getConfig: () => PolishedTuiConfig;
|
|
@@ -61,12 +63,15 @@ const colorSettingDescriptions: Record<ColorSettingId, string> = {
|
|
|
61
63
|
const featureSettingLabels: Record<FeatureSettingId, string> = {
|
|
62
64
|
editor: "Editor",
|
|
63
65
|
statusLine: "Status line",
|
|
66
|
+
copyFriendly: "Copy-friendly mode",
|
|
64
67
|
};
|
|
65
68
|
|
|
66
69
|
const featureSettingDescriptions: Record<FeatureSettingId, string> = {
|
|
67
70
|
editor:
|
|
68
71
|
"Enable or disable Zentui's custom editor, selector borders, and previous-message chrome.",
|
|
69
72
|
statusLine: "Enable or disable Zentui's custom footer/status line.",
|
|
73
|
+
copyFriendly:
|
|
74
|
+
"Hide editor and previous-message rail glyphs for cleaner native terminal selection.",
|
|
70
75
|
};
|
|
71
76
|
|
|
72
77
|
const directCommandSuggestions = [
|
|
@@ -76,8 +81,19 @@ const directCommandSuggestions = [
|
|
|
76
81
|
"statusline enable",
|
|
77
82
|
"statusline disable",
|
|
78
83
|
"statusline toggle",
|
|
84
|
+
"copy-friendly enable",
|
|
85
|
+
"copy-friendly disable",
|
|
86
|
+
"copy-friendly toggle",
|
|
79
87
|
];
|
|
80
88
|
|
|
89
|
+
const sectionLabels: Record<SettingsSection, string> = {
|
|
90
|
+
coloring: "Coloring",
|
|
91
|
+
features: "Features",
|
|
92
|
+
statusLine: "Status line",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const thirdPartyStatusSettingPrefix = "thirdPartyStatus:";
|
|
96
|
+
|
|
81
97
|
function isColorSource(value: string): value is ColorSource {
|
|
82
98
|
return value === "theme" || value === "terminal";
|
|
83
99
|
}
|
|
@@ -87,7 +103,7 @@ function isColorSettingId(value: string): value is ColorSettingId {
|
|
|
87
103
|
}
|
|
88
104
|
|
|
89
105
|
function isFeatureSettingId(value: string): value is FeatureSettingId {
|
|
90
|
-
return value === "editor" || value === "statusLine";
|
|
106
|
+
return value === "editor" || value === "statusLine" || value === "copyFriendly";
|
|
91
107
|
}
|
|
92
108
|
|
|
93
109
|
function isFeatureState(value: string): value is FeatureState {
|
|
@@ -113,7 +129,7 @@ function featurePatch(id: FeatureSettingId, value: FeatureState): Partial<UiFeat
|
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
function usageText(): string {
|
|
116
|
-
return "Usage: /zentui [editor|statusline] [enable|disable|toggle]";
|
|
132
|
+
return "Usage: /zentui [editor|statusline|copy-friendly] [enable|disable|toggle]";
|
|
117
133
|
}
|
|
118
134
|
|
|
119
135
|
function featureNotification(
|
|
@@ -138,7 +154,9 @@ function parseDirectFeatureCommand(
|
|
|
138
154
|
? "editor"
|
|
139
155
|
: hasWord("footer") || hasWord("statusline") || hasWord("status")
|
|
140
156
|
? "statusLine"
|
|
141
|
-
:
|
|
157
|
+
: hasWord("copyfriendly") || hasWord("copy")
|
|
158
|
+
? "copyFriendly"
|
|
159
|
+
: undefined;
|
|
142
160
|
const action = hasWord("toggle")
|
|
143
161
|
? "toggle"
|
|
144
162
|
: hasWord("enable") || hasWord("enabled") || hasWord("on")
|
|
@@ -163,34 +181,92 @@ function argumentCompletions(prefix: string): AutocompleteItem[] | null {
|
|
|
163
181
|
}
|
|
164
182
|
|
|
165
183
|
function buildItems(
|
|
184
|
+
section: SettingsSection,
|
|
166
185
|
config: PolishedTuiConfig,
|
|
167
|
-
|
|
168
|
-
thirdPartyStatusesSubmenu: SettingItem["submenu"],
|
|
186
|
+
activeStatuses: ReadonlyMap<string, string>,
|
|
169
187
|
): SettingItem[] {
|
|
170
|
-
|
|
171
|
-
|
|
188
|
+
if (section === "coloring") {
|
|
189
|
+
return (Object.keys(colorSettingLabels) as ColorSettingId[]).map((key) => ({
|
|
172
190
|
id: key,
|
|
173
191
|
label: colorSettingLabels[key],
|
|
174
192
|
description: colorSettingDescriptions[key],
|
|
175
193
|
currentValue: key === "starship" ? config.colorSources.starship : editorMessageValue(config),
|
|
176
194
|
values: colorSourceValues,
|
|
177
|
-
}))
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
"Configure active ctx.ui.setStatus() footer statuses. Only currently active keys are listed.",
|
|
183
|
-
currentValue: `${activeStatusCount} active`,
|
|
184
|
-
submenu: thirdPartyStatusesSubmenu,
|
|
185
|
-
},
|
|
186
|
-
...(Object.keys(featureSettingLabels) as FeatureSettingId[]).map((key) => ({
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (section === "features") {
|
|
199
|
+
return (Object.keys(featureSettingLabels) as FeatureSettingId[]).map((key) => ({
|
|
187
200
|
id: key,
|
|
188
201
|
label: featureSettingLabels[key],
|
|
189
202
|
description: featureSettingDescriptions[key],
|
|
190
203
|
currentValue: featureValue(config.features[key]),
|
|
191
204
|
values: featureStateValues,
|
|
192
|
-
}))
|
|
193
|
-
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const statuses = Array.from(activeStatuses.entries()).sort(([a], [b]) =>
|
|
209
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
210
|
+
);
|
|
211
|
+
if (statuses.length === 0) {
|
|
212
|
+
return [
|
|
213
|
+
{
|
|
214
|
+
id: "noThirdPartyStatuses",
|
|
215
|
+
label: "No active statuses",
|
|
216
|
+
description:
|
|
217
|
+
"This section only lists statuses currently published through ctx.ui.setStatus().",
|
|
218
|
+
currentValue: "—",
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return statuses.map(([key, value]) => {
|
|
224
|
+
const sanitizedText = sanitizeExtensionStatusText(value);
|
|
225
|
+
return {
|
|
226
|
+
id: `${thirdPartyStatusSettingPrefix}${key}`,
|
|
227
|
+
label: key,
|
|
228
|
+
description: sanitizedText ? `Current status: ${sanitizedText}` : undefined,
|
|
229
|
+
currentValue: getExtensionStatusPlacement(config, key),
|
|
230
|
+
values: extensionStatusPlacementValues,
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function thirdPartyStatusKeyFromSettingId(id: string): string | undefined {
|
|
236
|
+
return id.startsWith(thirdPartyStatusSettingPrefix)
|
|
237
|
+
? id.slice(thirdPartyStatusSettingPrefix.length)
|
|
238
|
+
: undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function nextSection(section: SettingsSection): SettingsSection {
|
|
242
|
+
const currentIndex = settingsSections.indexOf(section);
|
|
243
|
+
return settingsSections[(currentIndex + 1) % settingsSections.length] ?? "coloring";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function formatSectionTabs(
|
|
247
|
+
activeSection: SettingsSection,
|
|
248
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
249
|
+
): string {
|
|
250
|
+
const rendered = settingsSections.map((section) => {
|
|
251
|
+
const label = sectionLabels[section];
|
|
252
|
+
return section === activeSection ? theme.bold(label) : safeThemeFg(theme, "muted", label);
|
|
253
|
+
});
|
|
254
|
+
return ` ${rendered.join(safeThemeFg(theme, "muted", " / "))}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function withSectionFooter(lines: string[], theme: ExtensionContext["ui"]["theme"]): string[] {
|
|
258
|
+
const next = [...lines];
|
|
259
|
+
for (let index = next.length - 1; index >= 0; index -= 1) {
|
|
260
|
+
if (next[index]?.includes("Enter/Space")) {
|
|
261
|
+
next[index] = safeThemeFg(
|
|
262
|
+
theme,
|
|
263
|
+
"muted",
|
|
264
|
+
" Enter/Space to change · Tab to switch sections · Esc to close",
|
|
265
|
+
);
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return next;
|
|
194
270
|
}
|
|
195
271
|
|
|
196
272
|
export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCommandDeps): void {
|
|
@@ -233,126 +309,73 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
|
|
|
233
309
|
|
|
234
310
|
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
235
311
|
const settingsListTheme = deps.settingsListTheme ?? getSettingsListTheme();
|
|
312
|
+
let activeSection: SettingsSection = "coloring";
|
|
236
313
|
const applyFeatureChange = (id: FeatureSettingId, newValue: FeatureState) => {
|
|
237
314
|
const result = deps.setUiFeatures(featurePatch(id, newValue), ctx);
|
|
238
315
|
deps.requestRender();
|
|
239
316
|
ctx.ui.notify(featureNotification(id, newValue, result), "info");
|
|
240
317
|
tui.requestRender();
|
|
241
318
|
};
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (activeStatuses.length === 0) {
|
|
248
|
-
return {
|
|
249
|
-
render(width: number) {
|
|
250
|
-
return [
|
|
251
|
-
truncateToWidth(safeThemeFg(theme, "accent", "Third-party statuses"), width, ""),
|
|
252
|
-
"",
|
|
253
|
-
truncateToWidth(
|
|
254
|
-
safeThemeFg(theme, "muted", "No third-party statuses are active."),
|
|
255
|
-
width,
|
|
256
|
-
"",
|
|
257
|
-
),
|
|
258
|
-
truncateToWidth(
|
|
259
|
-
safeThemeFg(
|
|
260
|
-
theme,
|
|
261
|
-
"muted",
|
|
262
|
-
"This menu only lists statuses currently published through ctx.ui.setStatus().",
|
|
263
|
-
),
|
|
264
|
-
width,
|
|
265
|
-
"",
|
|
266
|
-
),
|
|
267
|
-
"",
|
|
268
|
-
truncateToWidth(safeThemeFg(theme, "muted", "Esc to go back"), width, ""),
|
|
269
|
-
];
|
|
270
|
-
},
|
|
271
|
-
invalidate() {},
|
|
272
|
-
handleInput(data: string) {
|
|
273
|
-
if (data === "\x1b" || data === "\u0003") close(undefined);
|
|
274
|
-
},
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const statusItems: SettingItem[] = activeStatuses.map(([key, value]) => {
|
|
279
|
-
const sanitizedText = sanitizeExtensionStatusText(value);
|
|
280
|
-
return {
|
|
281
|
-
id: key,
|
|
282
|
-
label: key,
|
|
283
|
-
description: sanitizedText ? `Current status: ${sanitizedText}` : undefined,
|
|
284
|
-
currentValue: getExtensionStatusPlacement(deps.getConfig(), key),
|
|
285
|
-
values: extensionStatusPlacementValues,
|
|
286
|
-
};
|
|
287
|
-
});
|
|
288
|
-
const statusSettingsList = new SettingsList(
|
|
289
|
-
statusItems,
|
|
319
|
+
let settingsList: SettingsList;
|
|
320
|
+
const makeSettingsList = () =>
|
|
321
|
+
new SettingsList(
|
|
322
|
+
buildItems(activeSection, deps.getConfig(), deps.getActiveExtensionStatuses()),
|
|
290
323
|
8,
|
|
291
324
|
settingsListTheme,
|
|
292
|
-
(
|
|
293
|
-
if (!isExtensionStatusPlacement(newValue)) return;
|
|
294
|
-
|
|
325
|
+
(id, newValue) => {
|
|
295
326
|
try {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
327
|
+
if (isColorSettingId(id) && isColorSource(newValue)) {
|
|
328
|
+
deps.setColorSources(patchForSetting(id, newValue));
|
|
329
|
+
settingsList.updateValue(id, newValue);
|
|
330
|
+
deps.requestRender();
|
|
331
|
+
ctx.ui.notify(`${colorSettingLabels[id]}: ${newValue}`, "info");
|
|
332
|
+
tui.requestRender();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (isFeatureSettingId(id) && isFeatureState(newValue)) {
|
|
337
|
+
settingsList.updateValue(id, newValue);
|
|
338
|
+
if (id === "editor") {
|
|
339
|
+
done(undefined);
|
|
340
|
+
// Changing the editor component while ctx.ui.custom() is active clears the
|
|
341
|
+
// custom component without resolving it, leaving Pi's input loop stuck.
|
|
342
|
+
// Close the settings UI first, then apply the editor swap on the next tick.
|
|
343
|
+
setTimeout(() => {
|
|
344
|
+
try {
|
|
345
|
+
applyFeatureChange(id, newValue);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
348
|
+
ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
|
|
349
|
+
}
|
|
350
|
+
}, 0);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
applyFeatureChange(id, newValue);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const thirdPartyStatusKey = thirdPartyStatusKeyFromSettingId(id);
|
|
359
|
+
if (thirdPartyStatusKey && isExtensionStatusPlacement(newValue)) {
|
|
360
|
+
deps.setExtensionStatusPlacement(thirdPartyStatusKey, newValue);
|
|
361
|
+
settingsList.updateValue(id, newValue);
|
|
362
|
+
deps.requestRender();
|
|
363
|
+
ctx.ui.notify(`Third-party status ${thirdPartyStatusKey}: ${newValue}`, "info");
|
|
364
|
+
tui.requestRender();
|
|
365
|
+
}
|
|
301
366
|
} catch (error) {
|
|
302
367
|
const message = error instanceof Error ? error.message : String(error);
|
|
303
368
|
ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
|
|
304
369
|
}
|
|
305
370
|
},
|
|
306
|
-
() =>
|
|
371
|
+
() => done(undefined),
|
|
307
372
|
);
|
|
308
|
-
|
|
373
|
+
settingsList = makeSettingsList();
|
|
374
|
+
const switchSection = () => {
|
|
375
|
+
activeSection = nextSection(activeSection);
|
|
376
|
+
settingsList = makeSettingsList();
|
|
377
|
+
tui.requestRender();
|
|
309
378
|
};
|
|
310
|
-
const settingsList = new SettingsList(
|
|
311
|
-
buildItems(
|
|
312
|
-
deps.getConfig(),
|
|
313
|
-
deps.getActiveExtensionStatuses().size,
|
|
314
|
-
makeThirdPartyStatusesSubmenu,
|
|
315
|
-
),
|
|
316
|
-
5,
|
|
317
|
-
settingsListTheme,
|
|
318
|
-
(id, newValue) => {
|
|
319
|
-
try {
|
|
320
|
-
if (isColorSettingId(id) && isColorSource(newValue)) {
|
|
321
|
-
deps.setColorSources(patchForSetting(id, newValue));
|
|
322
|
-
settingsList.updateValue(id, newValue);
|
|
323
|
-
deps.requestRender();
|
|
324
|
-
ctx.ui.notify(`${colorSettingLabels[id]}: ${newValue}`, "info");
|
|
325
|
-
tui.requestRender();
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
if (isFeatureSettingId(id) && isFeatureState(newValue)) {
|
|
330
|
-
settingsList.updateValue(id, newValue);
|
|
331
|
-
if (id === "editor") {
|
|
332
|
-
done(undefined);
|
|
333
|
-
// Changing the editor component while ctx.ui.custom() is active clears the
|
|
334
|
-
// custom component without resolving it, leaving Pi's input loop stuck.
|
|
335
|
-
// Close the settings UI first, then apply the editor swap on the next tick.
|
|
336
|
-
setTimeout(() => {
|
|
337
|
-
try {
|
|
338
|
-
applyFeatureChange(id, newValue);
|
|
339
|
-
} catch (error) {
|
|
340
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
341
|
-
ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
|
|
342
|
-
}
|
|
343
|
-
}, 0);
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
applyFeatureChange(id, newValue);
|
|
348
|
-
}
|
|
349
|
-
} catch (error) {
|
|
350
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
351
|
-
ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
|
|
352
|
-
}
|
|
353
|
-
},
|
|
354
|
-
() => done(undefined),
|
|
355
|
-
);
|
|
356
379
|
|
|
357
380
|
return {
|
|
358
381
|
render(width: number) {
|
|
@@ -363,14 +386,13 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
|
|
|
363
386
|
EDITOR_BORDER_STYLE,
|
|
364
387
|
"─".repeat(Math.max(0, width)),
|
|
365
388
|
);
|
|
366
|
-
const header = safeThemeFg(theme, "accent", theme.bold("Zentui settings"));
|
|
367
|
-
const hint = safeThemeFg(theme, "muted", "Enter/Space cycles values · Esc closes");
|
|
368
389
|
return [
|
|
369
390
|
truncateToWidth(border, width, ""),
|
|
370
|
-
truncateToWidth(
|
|
371
|
-
truncateToWidth(
|
|
372
|
-
|
|
373
|
-
|
|
391
|
+
truncateToWidth(formatSectionTabs(activeSection, theme), width, ""),
|
|
392
|
+
truncateToWidth(border, width, ""),
|
|
393
|
+
...withSectionFooter(settingsList.render(width), theme).map((line) =>
|
|
394
|
+
truncateToWidth(line, width, ""),
|
|
395
|
+
),
|
|
374
396
|
truncateToWidth(border, width, ""),
|
|
375
397
|
];
|
|
376
398
|
},
|
|
@@ -378,6 +400,10 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
|
|
|
378
400
|
settingsList.invalidate();
|
|
379
401
|
},
|
|
380
402
|
handleInput(data: string) {
|
|
403
|
+
if (data === "\t") {
|
|
404
|
+
switchSection();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
381
407
|
settingsList.handleInput(data);
|
|
382
408
|
},
|
|
383
409
|
};
|
package/extensions/zentui/ui.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { CustomEditor, type KeybindingsManager, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
|
+
type AutocompleteProvider,
|
|
3
4
|
type Component,
|
|
5
|
+
type EditorComponent,
|
|
4
6
|
type EditorTheme,
|
|
5
7
|
type TUI,
|
|
6
8
|
truncateToWidth,
|
|
@@ -19,22 +21,260 @@ type AutocompleteEditorInternals = {
|
|
|
19
21
|
isShowingAutocomplete?: () => boolean;
|
|
20
22
|
};
|
|
21
23
|
|
|
24
|
+
type WrappedEditor = EditorComponent &
|
|
25
|
+
AutocompleteEditorInternals & {
|
|
26
|
+
focused?: boolean;
|
|
27
|
+
onEscape?: () => void;
|
|
28
|
+
onCtrlD?: () => void;
|
|
29
|
+
onPasteImage?: () => void;
|
|
30
|
+
onExtensionShortcut?: (data: string) => boolean;
|
|
31
|
+
actionHandlers?: Map<unknown, () => void>;
|
|
32
|
+
wantsKeyRelease?: boolean;
|
|
33
|
+
disableSubmit?: boolean;
|
|
34
|
+
getLines?: () => string[];
|
|
35
|
+
getCursor?: () => unknown;
|
|
36
|
+
getMode?: () => unknown;
|
|
37
|
+
getPaddingX?: () => number;
|
|
38
|
+
getAutocompleteMaxVisible?: () => number;
|
|
39
|
+
addToHistory?: (text: string) => void;
|
|
40
|
+
getExpandedText?: () => string;
|
|
41
|
+
insertTextAtCursor?: (text: string) => void;
|
|
42
|
+
setAutocompleteProvider?: (provider: AutocompleteProvider) => void;
|
|
43
|
+
setPaddingX?: (padding: number) => void;
|
|
44
|
+
setAutocompleteMaxVisible?: (maxVisible: number) => void;
|
|
45
|
+
};
|
|
46
|
+
|
|
22
47
|
type EditorMeta = {
|
|
23
48
|
modelLabel: string;
|
|
24
49
|
providerLabel: string;
|
|
25
50
|
};
|
|
26
51
|
|
|
52
|
+
type PolishedFrameOptions = {
|
|
53
|
+
width: number;
|
|
54
|
+
baseRendered: string[];
|
|
55
|
+
autocompleteSource: AutocompleteEditorInternals;
|
|
56
|
+
uiTheme: Theme;
|
|
57
|
+
config: PolishedTuiConfig;
|
|
58
|
+
modelMeta: EditorMeta;
|
|
59
|
+
thinkingLevel: string | undefined;
|
|
60
|
+
rightStatus?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
27
63
|
function clampRenderedLines(lines: string[], width: number): string[] {
|
|
28
64
|
const maxWidth = Math.max(0, width);
|
|
29
65
|
return lines.map((line) => truncateToWidth(line, maxWidth, ""));
|
|
30
66
|
}
|
|
31
67
|
|
|
68
|
+
function fillLine(content: string, width: number): string {
|
|
69
|
+
const truncated = truncateToWidth(content, Math.max(0, width), "");
|
|
70
|
+
const pad = " ".repeat(Math.max(0, width - visibleWidth(truncated)));
|
|
71
|
+
return `${truncated}${pad}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function editorThinkingStyle(config: PolishedTuiConfig, level: string): string | undefined {
|
|
75
|
+
switch (level.toLowerCase()) {
|
|
76
|
+
case "minimal":
|
|
77
|
+
return config.colors.editorThinkingMinimal ?? config.colors.editorThinking;
|
|
78
|
+
case "low":
|
|
79
|
+
return config.colors.editorThinkingLow ?? config.colors.editorThinking;
|
|
80
|
+
case "medium":
|
|
81
|
+
return config.colors.editorThinkingMedium ?? config.colors.editorThinking;
|
|
82
|
+
case "high":
|
|
83
|
+
return config.colors.editorThinkingHigh ?? config.colors.editorThinking;
|
|
84
|
+
case "xhigh":
|
|
85
|
+
return config.colors.editorThinkingXhigh ?? config.colors.editorThinking;
|
|
86
|
+
default:
|
|
87
|
+
return config.colors.editorThinking;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function copyFriendlyPrompt(config: PolishedTuiConfig, uiTheme: Theme, reset: string): string {
|
|
92
|
+
const promptIcon = config.icons.editorPrompt;
|
|
93
|
+
return promptIcon
|
|
94
|
+
? `${renderStyleForSourceOrFallback(
|
|
95
|
+
uiTheme,
|
|
96
|
+
config.colorSources.editor,
|
|
97
|
+
config.colors.editorPrompt ?? config.colors.editorAccent,
|
|
98
|
+
EDITOR_ACCENT_FALLBACK,
|
|
99
|
+
promptIcon,
|
|
100
|
+
)}${reset} `
|
|
101
|
+
: "";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getEditorChromeWidths(config: PolishedTuiConfig, uiTheme: Theme, reset: string) {
|
|
105
|
+
const prompt = copyFriendlyPrompt(config, uiTheme, reset);
|
|
106
|
+
const rail = config.features.copyFriendly
|
|
107
|
+
? ""
|
|
108
|
+
: `${renderStyleForSourceOrFallback(
|
|
109
|
+
uiTheme,
|
|
110
|
+
config.colorSources.editor,
|
|
111
|
+
config.colors.editorAccent,
|
|
112
|
+
EDITOR_ACCENT_FALLBACK,
|
|
113
|
+
"│",
|
|
114
|
+
)}${reset} `;
|
|
115
|
+
return {
|
|
116
|
+
prompt,
|
|
117
|
+
promptWidth: visibleWidth(prompt),
|
|
118
|
+
rail,
|
|
119
|
+
railWidth: config.features.copyFriendly ? visibleWidth(prompt) : visibleWidth(rail),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function composeMetadataLine(left: string, right: string | undefined, width: number): string {
|
|
124
|
+
if (!right) return left;
|
|
125
|
+
const maxWidth = Math.max(0, width);
|
|
126
|
+
const rightWidth = visibleWidth(right);
|
|
127
|
+
if (rightWidth >= maxWidth) return truncateToWidth(right, maxWidth, "");
|
|
128
|
+
|
|
129
|
+
const leftWidth = Math.max(0, maxWidth - rightWidth - 1);
|
|
130
|
+
const leftText = truncateToWidth(left, leftWidth, "");
|
|
131
|
+
const gap = " ".repeat(Math.max(1, maxWidth - visibleWidth(leftText) - rightWidth));
|
|
132
|
+
return `${leftText}${gap}${right}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function vimModeColor(mode: string): string {
|
|
136
|
+
switch (mode.toLowerCase()) {
|
|
137
|
+
case "insert":
|
|
138
|
+
return "success";
|
|
139
|
+
case "normal":
|
|
140
|
+
return "accent";
|
|
141
|
+
case "ex":
|
|
142
|
+
return "warning";
|
|
143
|
+
case "replace":
|
|
144
|
+
return "error";
|
|
145
|
+
case "visual":
|
|
146
|
+
return "syntaxKeyword";
|
|
147
|
+
default:
|
|
148
|
+
return "muted";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readVimStatus(editor: WrappedEditor, uiTheme: Theme): string | undefined {
|
|
153
|
+
const mode = editor.getMode?.();
|
|
154
|
+
if (typeof mode !== "string") return undefined;
|
|
155
|
+
const normalized = mode.trim();
|
|
156
|
+
if (!normalized) return undefined;
|
|
157
|
+
const label = `${normalized.toUpperCase()} `;
|
|
158
|
+
return safeThemeFg(uiTheme, vimModeColor(normalized), label);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function renderPolishedFrame({
|
|
162
|
+
width,
|
|
163
|
+
baseRendered,
|
|
164
|
+
autocompleteSource,
|
|
165
|
+
uiTheme,
|
|
166
|
+
config,
|
|
167
|
+
modelMeta,
|
|
168
|
+
thinkingLevel,
|
|
169
|
+
rightStatus,
|
|
170
|
+
}: PolishedFrameOptions): string[] {
|
|
171
|
+
if (width <= 2) return clampRenderedLines(baseRendered, width);
|
|
172
|
+
|
|
173
|
+
const reset = "\x1b[0m";
|
|
174
|
+
const colorSource = config.colorSources.editor;
|
|
175
|
+
const { prompt, promptWidth, rail, railWidth } = getEditorChromeWidths(config, uiTheme, reset);
|
|
176
|
+
const innerWidth = Math.max(0, width - railWidth);
|
|
177
|
+
const copyFriendlyContinuation = " ".repeat(promptWidth);
|
|
178
|
+
const isShowingAutocomplete =
|
|
179
|
+
typeof autocompleteSource.isShowingAutocomplete === "function"
|
|
180
|
+
? Boolean(autocompleteSource.isShowingAutocomplete())
|
|
181
|
+
: false;
|
|
182
|
+
|
|
183
|
+
if (baseRendered.length < 2) return clampRenderedLines(baseRendered, width);
|
|
184
|
+
|
|
185
|
+
const { autocompleteList } = autocompleteSource;
|
|
186
|
+
const autocompleteCount =
|
|
187
|
+
isShowingAutocomplete && typeof autocompleteList?.render === "function"
|
|
188
|
+
? autocompleteList.render(innerWidth).length
|
|
189
|
+
: 0;
|
|
190
|
+
const editorFrame =
|
|
191
|
+
autocompleteCount > 0 && autocompleteCount < baseRendered.length
|
|
192
|
+
? baseRendered.slice(0, -autocompleteCount)
|
|
193
|
+
: baseRendered;
|
|
194
|
+
const autocompleteLines =
|
|
195
|
+
autocompleteCount > 0 && autocompleteCount < baseRendered.length
|
|
196
|
+
? baseRendered.slice(-autocompleteCount)
|
|
197
|
+
: [];
|
|
198
|
+
|
|
199
|
+
if (editorFrame.length < 2) return clampRenderedLines(baseRendered, width);
|
|
200
|
+
|
|
201
|
+
const editorLines = editorFrame.slice(1, -1);
|
|
202
|
+
const model = renderStyleForSourceOrFallback(
|
|
203
|
+
uiTheme,
|
|
204
|
+
colorSource,
|
|
205
|
+
config.colors.editorModel,
|
|
206
|
+
EDITOR_ACCENT_FALLBACK,
|
|
207
|
+
modelMeta.modelLabel,
|
|
208
|
+
);
|
|
209
|
+
const provider = renderStyleForSourceOrFallback(
|
|
210
|
+
uiTheme,
|
|
211
|
+
colorSource,
|
|
212
|
+
config.colors.editorProvider,
|
|
213
|
+
"text",
|
|
214
|
+
modelMeta.providerLabel,
|
|
215
|
+
);
|
|
216
|
+
const renderedModelMeta = [model, provider]
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
.join(safeThemeFg(uiTheme, "borderMuted", " "));
|
|
219
|
+
const metaParts = [renderedModelMeta];
|
|
220
|
+
if (thinkingLevel && thinkingLevel !== "off") {
|
|
221
|
+
metaParts.push(
|
|
222
|
+
renderStyleForSourceOrFallback(
|
|
223
|
+
uiTheme,
|
|
224
|
+
colorSource,
|
|
225
|
+
editorThinkingStyle(config, thinkingLevel),
|
|
226
|
+
"muted",
|
|
227
|
+
thinkingLevel,
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
const meta = metaParts.filter(Boolean).join(safeThemeFg(uiTheme, "border", " "));
|
|
232
|
+
const copyFriendlyMeta = composeMetadataLine(meta, rightStatus, Math.max(0, width - 1));
|
|
233
|
+
const railedMeta = composeMetadataLine(meta, rightStatus, innerWidth);
|
|
234
|
+
|
|
235
|
+
const top = renderStyleForSourceOrFallback(
|
|
236
|
+
uiTheme,
|
|
237
|
+
colorSource,
|
|
238
|
+
config.colors.editorBorder,
|
|
239
|
+
EDITOR_BORDER_FALLBACK,
|
|
240
|
+
"─".repeat(width),
|
|
241
|
+
);
|
|
242
|
+
const bottom = renderStyleForSourceOrFallback(
|
|
243
|
+
uiTheme,
|
|
244
|
+
colorSource,
|
|
245
|
+
config.colors.editorBorder,
|
|
246
|
+
EDITOR_BORDER_FALLBACK,
|
|
247
|
+
"─".repeat(width),
|
|
248
|
+
);
|
|
249
|
+
const lines = ["", ...editorLines, "", railedMeta];
|
|
250
|
+
const renderedLines = config.features.copyFriendly
|
|
251
|
+
? [
|
|
252
|
+
top,
|
|
253
|
+
"",
|
|
254
|
+
...editorLines.map(
|
|
255
|
+
(line, index) =>
|
|
256
|
+
`${index === 0 ? prompt : copyFriendlyContinuation}${fillLine(line, innerWidth)}`,
|
|
257
|
+
),
|
|
258
|
+
"",
|
|
259
|
+
` ${truncateToWidth(copyFriendlyMeta, Math.max(0, width - 1), "")}`,
|
|
260
|
+
bottom,
|
|
261
|
+
...autocompleteLines,
|
|
262
|
+
]
|
|
263
|
+
: [
|
|
264
|
+
top,
|
|
265
|
+
...lines.map((line) => `${rail}${fillLine(line, innerWidth)}`),
|
|
266
|
+
bottom,
|
|
267
|
+
...autocompleteLines,
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
return clampRenderedLines(renderedLines, width);
|
|
271
|
+
}
|
|
272
|
+
|
|
32
273
|
export class PolishedEditor extends CustomEditor {
|
|
33
274
|
private readonly getModelMeta: () => EditorMeta;
|
|
34
275
|
private readonly getThinkingLevel: () => string | undefined;
|
|
35
276
|
private readonly getConfig: () => PolishedTuiConfig;
|
|
36
277
|
private readonly uiTheme: Theme;
|
|
37
|
-
private readonly reset = "\x1b[0m";
|
|
38
278
|
|
|
39
279
|
constructor(
|
|
40
280
|
tui: TUI,
|
|
@@ -53,129 +293,190 @@ export class PolishedEditor extends CustomEditor {
|
|
|
53
293
|
this.getThinkingLevel = getThinkingLevel;
|
|
54
294
|
}
|
|
55
295
|
|
|
56
|
-
private fillLine(content: string, width: number): string {
|
|
57
|
-
const truncated = truncateToWidth(content, Math.max(0, width), "");
|
|
58
|
-
const pad = " ".repeat(Math.max(0, width - visibleWidth(truncated)));
|
|
59
|
-
return `${truncated}${pad}`;
|
|
60
|
-
}
|
|
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
|
-
|
|
79
296
|
render(width: number): string[] {
|
|
80
297
|
if (width <= 2) {
|
|
81
298
|
return clampRenderedLines(super.render(width), width);
|
|
82
299
|
}
|
|
83
300
|
|
|
84
|
-
const
|
|
301
|
+
const config = this.getConfig();
|
|
302
|
+
const { railWidth } = getEditorChromeWidths(config, this.uiTheme, "\x1b[0m");
|
|
303
|
+
const innerWidth = Math.max(0, width - railWidth);
|
|
85
304
|
const rendered = super.render(innerWidth);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
305
|
+
return renderPolishedFrame({
|
|
306
|
+
width,
|
|
307
|
+
baseRendered: rendered,
|
|
308
|
+
autocompleteSource: this as unknown as AutocompleteEditorInternals,
|
|
309
|
+
uiTheme: this.uiTheme,
|
|
310
|
+
config,
|
|
311
|
+
modelMeta: this.getModelMeta(),
|
|
312
|
+
thinkingLevel: this.getThinkingLevel(),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
91
316
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
317
|
+
export class WrappedPolishedEditor implements EditorComponent {
|
|
318
|
+
constructor(
|
|
319
|
+
private readonly base: WrappedEditor,
|
|
320
|
+
private readonly uiTheme: Theme,
|
|
321
|
+
private readonly getConfig: () => PolishedTuiConfig,
|
|
322
|
+
private readonly getModelMeta: () => EditorMeta,
|
|
323
|
+
private readonly getThinkingLevel: () => string | undefined,
|
|
324
|
+
) {}
|
|
95
325
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
326
|
+
get focused(): boolean {
|
|
327
|
+
return Boolean(this.base.focused);
|
|
328
|
+
}
|
|
329
|
+
set focused(value: boolean) {
|
|
330
|
+
this.base.focused = value;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
get borderColor(): ((str: string) => string) | undefined {
|
|
334
|
+
return this.base.borderColor;
|
|
335
|
+
}
|
|
336
|
+
set borderColor(value: ((str: string) => string) | undefined) {
|
|
337
|
+
this.base.borderColor = value;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
get onSubmit(): ((text: string) => void) | undefined {
|
|
341
|
+
return this.base.onSubmit;
|
|
342
|
+
}
|
|
343
|
+
set onSubmit(value: ((text: string) => void) | undefined) {
|
|
344
|
+
this.base.onSubmit = value;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
get onChange(): ((text: string) => void) | undefined {
|
|
348
|
+
return this.base.onChange;
|
|
349
|
+
}
|
|
350
|
+
set onChange(value: ((text: string) => void) | undefined) {
|
|
351
|
+
this.base.onChange = value;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
get onEscape(): (() => void) | undefined {
|
|
355
|
+
return this.base.onEscape;
|
|
356
|
+
}
|
|
357
|
+
set onEscape(value: (() => void) | undefined) {
|
|
358
|
+
this.base.onEscape = value;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
get onCtrlD(): (() => void) | undefined {
|
|
362
|
+
return this.base.onCtrlD;
|
|
363
|
+
}
|
|
364
|
+
set onCtrlD(value: (() => void) | undefined) {
|
|
365
|
+
this.base.onCtrlD = value;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
get onPasteImage(): (() => void) | undefined {
|
|
369
|
+
return this.base.onPasteImage;
|
|
370
|
+
}
|
|
371
|
+
set onPasteImage(value: (() => void) | undefined) {
|
|
372
|
+
this.base.onPasteImage = value;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
get onExtensionShortcut(): ((data: string) => boolean) | undefined {
|
|
376
|
+
return this.base.onExtensionShortcut;
|
|
377
|
+
}
|
|
378
|
+
set onExtensionShortcut(value: ((data: string) => boolean) | undefined) {
|
|
379
|
+
this.base.onExtensionShortcut = value;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
get actionHandlers(): Map<unknown, () => void> | undefined {
|
|
383
|
+
return this.base.actionHandlers;
|
|
384
|
+
}
|
|
385
|
+
set actionHandlers(value: Map<unknown, () => void> | undefined) {
|
|
386
|
+
this.base.actionHandlers = value;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
get wantsKeyRelease(): boolean | undefined {
|
|
390
|
+
return this.base.wantsKeyRelease;
|
|
391
|
+
}
|
|
392
|
+
set wantsKeyRelease(value: boolean | undefined) {
|
|
393
|
+
this.base.wantsKeyRelease = value;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
get disableSubmit(): boolean | undefined {
|
|
397
|
+
return this.base.disableSubmit;
|
|
398
|
+
}
|
|
399
|
+
set disableSubmit(value: boolean | undefined) {
|
|
400
|
+
this.base.disableSubmit = value;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
render(width: number): string[] {
|
|
404
|
+
if (width <= 2) return clampRenderedLines(this.base.render(width), width);
|
|
113
405
|
|
|
114
406
|
const config = this.getConfig();
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
this.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
407
|
+
const { railWidth } = getEditorChromeWidths(config, this.uiTheme, "\x1b[0m");
|
|
408
|
+
const innerWidth = Math.max(0, width - railWidth);
|
|
409
|
+
const rendered = this.base.render(innerWidth);
|
|
410
|
+
const vimStatus = readVimStatus(this.base, this.uiTheme);
|
|
411
|
+
return renderPolishedFrame({
|
|
412
|
+
width,
|
|
413
|
+
baseRendered: rendered,
|
|
414
|
+
autocompleteSource: this.base,
|
|
415
|
+
uiTheme: this.uiTheme,
|
|
416
|
+
config,
|
|
417
|
+
modelMeta: this.getModelMeta(),
|
|
418
|
+
thinkingLevel: this.getThinkingLevel(),
|
|
419
|
+
rightStatus: vimStatus,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
invalidate(): void {
|
|
424
|
+
this.base.invalidate?.();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
handleInput(data: string): void {
|
|
428
|
+
this.base.handleInput(data);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
getText(): string {
|
|
432
|
+
return this.base.getText();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
setText(text: string): void {
|
|
436
|
+
this.base.setText(text);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
addToHistory(text: string): void {
|
|
440
|
+
this.base.addToHistory?.(text);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
insertTextAtCursor(text: string): void {
|
|
444
|
+
this.base.insertTextAtCursor?.(text);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
getExpandedText(): string {
|
|
448
|
+
return this.base.getExpandedText?.() ?? this.base.getText();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
452
|
+
this.base.setAutocompleteProvider?.(provider);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
setPaddingX(padding: number): void {
|
|
456
|
+
this.base.setPaddingX?.(padding);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
setAutocompleteMaxVisible(maxVisible: number): void {
|
|
460
|
+
this.base.setAutocompleteMaxVisible?.(maxVisible);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
getLines(): string[] {
|
|
464
|
+
return this.base.getLines?.() ?? this.base.getText().split("\n");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
getCursor(): unknown {
|
|
468
|
+
return this.base.getCursor?.();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
getMode(): unknown {
|
|
472
|
+
return this.base.getMode?.();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
getPaddingX(): number | undefined {
|
|
476
|
+
return this.base.getPaddingX?.();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
getAutocompleteMaxVisible(): number | undefined {
|
|
480
|
+
return this.base.getAutocompleteMaxVisible?.();
|
|
180
481
|
}
|
|
181
482
|
}
|
|
@@ -89,14 +89,10 @@ function fillLine(content: string, width: number): string {
|
|
|
89
89
|
return `${truncated}${pad}`;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
config: PolishedTuiConfig,
|
|
97
|
-
): string {
|
|
98
|
-
if (width <= 0) return "";
|
|
99
|
-
const rail = `${
|
|
92
|
+
function renderPromptBoxRail(theme: Theme | undefined, config: PolishedTuiConfig): string {
|
|
93
|
+
if (config.features.copyFriendly) return "";
|
|
94
|
+
|
|
95
|
+
return `${
|
|
100
96
|
theme
|
|
101
97
|
? renderStyleForSourceOrFallback(
|
|
102
98
|
theme,
|
|
@@ -107,8 +103,21 @@ function renderPromptBoxLine(
|
|
|
107
103
|
)
|
|
108
104
|
: "│"
|
|
109
105
|
} `;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function renderPromptBoxLine(
|
|
109
|
+
line: string,
|
|
110
|
+
width: number,
|
|
111
|
+
theme: Theme | undefined,
|
|
112
|
+
config: PolishedTuiConfig,
|
|
113
|
+
): string {
|
|
114
|
+
if (width <= 0) return "";
|
|
115
|
+
const rail = renderPromptBoxRail(theme, config);
|
|
110
116
|
const contentWidth = Math.max(0, width - visibleWidth(rail));
|
|
111
|
-
|
|
117
|
+
const content = config.features.copyFriendly
|
|
118
|
+
? truncateToWidth(line, contentWidth, "")
|
|
119
|
+
: fillLine(line, contentWidth);
|
|
120
|
+
return truncateToWidth(`${rail}${content}`, width, "");
|
|
112
121
|
}
|
|
113
122
|
|
|
114
123
|
function renderZentuiUserMessage(
|
|
@@ -121,19 +130,7 @@ function renderZentuiUserMessage(
|
|
|
121
130
|
if (text === undefined) return undefined;
|
|
122
131
|
if (width <= 0) return [""];
|
|
123
132
|
|
|
124
|
-
const railWidth = visibleWidth(
|
|
125
|
-
`${
|
|
126
|
-
theme
|
|
127
|
-
? renderStyleForSourceOrFallback(
|
|
128
|
-
theme,
|
|
129
|
-
config.colorSources.userMessages,
|
|
130
|
-
config.colors.editorAccent,
|
|
131
|
-
EDITOR_ACCENT_FALLBACK,
|
|
132
|
-
"│",
|
|
133
|
-
)
|
|
134
|
-
: "│"
|
|
135
|
-
} `,
|
|
136
|
-
);
|
|
133
|
+
const railWidth = visibleWidth(renderPromptBoxRail(theme, config));
|
|
137
134
|
const contentWidth = Math.max(1, width - railWidth);
|
|
138
135
|
const renderer = new Markdown(text, 0, 0, makeMarkdownTheme(theme), {
|
|
139
136
|
color: (content) => themeFg(theme, "userMessageText", content),
|