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 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. Both can be changed from `/zentui` or direct slash-command arguments.
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 && !isZentuiEditorFactory(currentFactory)) return false;
167
+ if (currentFactory && isZentuiEditorFactory(currentFactory)) {
168
+ editorInstalled = true;
169
+ return true;
170
+ }
145
171
 
146
172
  installPrototypePatches();
147
- ctx.ui.setEditorComponent(makeEditorFactory(ctx));
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(undefined);
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
- if (!editorInstalled) result.editorBlocked = !installEditor(ctx);
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.setEditorComponent(undefined);
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
- : undefined;
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
- activeStatusCount: number,
168
- thirdPartyStatusesSubmenu: SettingItem["submenu"],
186
+ activeStatuses: ReadonlyMap<string, string>,
169
187
  ): SettingItem[] {
170
- return [
171
- ...(Object.keys(colorSettingLabels) as ColorSettingId[]).map((key) => ({
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
- id: "thirdPartyStatuses",
180
- label: "Third-party statuses",
181
- description:
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
- const makeThirdPartyStatusesSubmenu: SettingItem["submenu"] = (_currentValue, close) => {
243
- const activeStatuses = Array.from(deps.getActiveExtensionStatuses().entries()).sort(
244
- ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0),
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
- (key, newValue) => {
293
- if (!isExtensionStatusPlacement(newValue)) return;
294
-
325
+ (id, newValue) => {
295
326
  try {
296
- deps.setExtensionStatusPlacement(key, newValue);
297
- statusSettingsList.updateValue(key, newValue);
298
- deps.requestRender();
299
- ctx.ui.notify(`Third-party status ${key}: ${newValue}`, "info");
300
- tui.requestRender();
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
- () => close(undefined),
371
+ () => done(undefined),
307
372
  );
308
- return statusSettingsList;
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(header, width, ""),
371
- truncateToWidth(hint, width, ""),
372
- "",
373
- ...settingsList.render(width),
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
  };
@@ -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 innerWidth = width - 2;
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
- const editorInternals = this as unknown as AutocompleteEditorInternals;
87
- const isShowingAutocomplete =
88
- typeof editorInternals.isShowingAutocomplete === "function"
89
- ? Boolean(editorInternals.isShowingAutocomplete())
90
- : false;
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
- if (rendered.length < 2) {
93
- return clampRenderedLines(super.render(width), width);
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
- const { autocompleteList } = editorInternals;
97
- const autocompleteCount =
98
- isShowingAutocomplete && typeof autocompleteList?.render === "function"
99
- ? autocompleteList.render(innerWidth).length
100
- : 0;
101
- const editorFrame =
102
- autocompleteCount > 0 && autocompleteCount < rendered.length
103
- ? rendered.slice(0, -autocompleteCount)
104
- : rendered;
105
- const autocompleteLines =
106
- autocompleteCount > 0 && autocompleteCount < rendered.length
107
- ? rendered.slice(-autocompleteCount)
108
- : [];
109
-
110
- if (editorFrame.length < 2) {
111
- return clampRenderedLines(rendered, width);
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 colorSource = config.colorSources.editor;
116
- const editorLines = editorFrame.slice(1, -1);
117
- const { modelLabel, providerLabel } = this.getModelMeta();
118
- const model = renderStyleForSourceOrFallback(
119
- this.uiTheme,
120
- colorSource,
121
- config.colors.editorModel,
122
- EDITOR_ACCENT_FALLBACK,
123
- modelLabel,
124
- );
125
- const provider = renderStyleForSourceOrFallback(
126
- this.uiTheme,
127
- colorSource,
128
- config.colors.editorProvider,
129
- "text",
130
- providerLabel,
131
- );
132
- const modelMeta = [model, provider]
133
- .filter(Boolean)
134
- .join(safeThemeFg(this.uiTheme, "borderMuted", " "));
135
- const metaParts = [modelMeta];
136
- const thinkingLevel = this.getThinkingLevel();
137
- if (thinkingLevel && thinkingLevel !== "off") {
138
- metaParts.push(
139
- renderStyleForSourceOrFallback(
140
- this.uiTheme,
141
- colorSource,
142
- this.editorThinkingStyle(config, thinkingLevel),
143
- "muted",
144
- thinkingLevel,
145
- ),
146
- );
147
- }
148
- const meta = metaParts.filter(Boolean).join(safeThemeFg(this.uiTheme, "border", " "));
149
-
150
- const rail = `${renderStyleForSourceOrFallback(
151
- this.uiTheme,
152
- colorSource,
153
- config.colors.editorAccent,
154
- EDITOR_ACCENT_FALLBACK,
155
- "│",
156
- )}${this.reset} `;
157
- const top = renderStyleForSourceOrFallback(
158
- this.uiTheme,
159
- colorSource,
160
- config.colors.editorBorder,
161
- EDITOR_BORDER_FALLBACK,
162
- "─".repeat(width),
163
- );
164
- const bottom = renderStyleForSourceOrFallback(
165
- this.uiTheme,
166
- colorSource,
167
- config.colors.editorBorder,
168
- EDITOR_BORDER_FALLBACK,
169
- "─".repeat(width),
170
- );
171
- const lines = ["", ...editorLines, "", meta];
172
- const renderedLines = [
173
- top,
174
- ...lines.map((line) => `${rail}${this.fillLine(line, innerWidth)}`),
175
- bottom,
176
- ...autocompleteLines,
177
- ];
178
-
179
- return clampRenderedLines(renderedLines, width);
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 renderPromptBoxLine(
93
- line: string,
94
- width: number,
95
- theme: Theme | undefined,
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
- return truncateToWidth(`${rail}${fillLine(line, contentWidth)}`, width, "");
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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.1.14",
3
+ "version": "0.2.0",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",