pi-zentui 0.7.0 → 0.8.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 +60 -1
- package/extensions/zentui/config.ts +49 -0
- package/extensions/zentui/fixed-editor/cluster.ts +194 -0
- package/extensions/zentui/fixed-editor/compositor.ts +584 -0
- package/extensions/zentui/fixed-editor/index.ts +206 -0
- package/extensions/zentui/fixed-editor/input.ts +85 -0
- package/extensions/zentui/fixed-editor/selection.ts +217 -0
- package/extensions/zentui/fixed-editor/terminal-modes.ts +75 -0
- package/extensions/zentui/fixed-editor/types.ts +70 -0
- package/extensions/zentui/index.ts +23 -0
- package/extensions/zentui/settings-command.ts +104 -6
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ Zentui brings two popular aesthetics to Pi:
|
|
|
35
35
|
- Configurable model, provider, and thinking-level indicator colors
|
|
36
36
|
- Prompt-box-style user messages matching the ZentUI input chrome
|
|
37
37
|
- Copy-friendly mode hides editor and previous-message rail glyphs so terminal selection copies less chrome
|
|
38
|
+
- **Fixed editor** (experimental, opt-in): Pin the editor and footer at the bottom of the terminal while the transcript scrolls above
|
|
38
39
|
|
|
39
40
|
### Git Status Icons
|
|
40
41
|
|
|
@@ -143,6 +144,9 @@ Useful slash-command shortcuts:
|
|
|
143
144
|
/zentui copy-friendly enable
|
|
144
145
|
/zentui copy-friendly disable
|
|
145
146
|
/zentui copy-friendly toggle
|
|
147
|
+
/zentui fixed-editor enable
|
|
148
|
+
/zentui fixed-editor disable
|
|
149
|
+
/zentui fixed-editor toggle
|
|
146
150
|
/zentui format "$cwd on branch $git_branch$git_status using $runtime $fill $context"
|
|
147
151
|
/zentui format clear
|
|
148
152
|
```
|
|
@@ -256,6 +260,11 @@ Default config values — copy this and change any value you want:
|
|
|
256
260
|
"defaultPlacement": "right",
|
|
257
261
|
"placements": {},
|
|
258
262
|
"colorModes": {}
|
|
263
|
+
},
|
|
264
|
+
"fixedEditor": {
|
|
265
|
+
"enabled": false,
|
|
266
|
+
"mouseScroll": true,
|
|
267
|
+
"copyNotice": true
|
|
259
268
|
}
|
|
260
269
|
}
|
|
261
270
|
```
|
|
@@ -342,9 +351,59 @@ Center the branch between directory and cost:
|
|
|
342
351
|
- Unknown `$variables` render empty.
|
|
343
352
|
- Set or clear at runtime: `/zentui format "<template>"` and `/zentui format clear`.
|
|
344
353
|
|
|
354
|
+
## Fixed editor (experimental, opt-in)
|
|
355
|
+
|
|
356
|
+
The fixed editor pins the Zentui editor and footer at the bottom of the terminal while the transcript scrolls above. This enables composing follow-up messages while referencing earlier conversation history.
|
|
357
|
+
|
|
358
|
+
### How to enable
|
|
359
|
+
|
|
360
|
+
```text
|
|
361
|
+
/zentui fixed-editor enable
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Or in `~/.pi/agent/zentui.json`:
|
|
365
|
+
|
|
366
|
+
```json
|
|
367
|
+
{
|
|
368
|
+
"fixedEditor": {
|
|
369
|
+
"enabled": true
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Keyboard controls
|
|
375
|
+
|
|
376
|
+
| Key | Action |
|
|
377
|
+
| --- | ------ |
|
|
378
|
+
| `PageUp` / `PageDown` | Scroll transcript one viewport up/down |
|
|
379
|
+
| `Ctrl+Shift+↑` / `Ctrl+Shift+↓` | Scroll transcript up/down (Kitty protocol variants supported) |
|
|
380
|
+
| `Enter` | Jump to bottom (and submit message) |
|
|
381
|
+
|
|
382
|
+
### Mouse scroll (default on)
|
|
383
|
+
|
|
384
|
+
Mouse wheel scrolling is enabled by default when the fixed editor is on. Disable it via `/zentui` Features or:
|
|
385
|
+
|
|
386
|
+
```json
|
|
387
|
+
{
|
|
388
|
+
"fixedEditor": {
|
|
389
|
+
"enabled": true,
|
|
390
|
+
"mouseScroll": true
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
**Warning**: Mouse scroll enables SGR mouse reporting, which disables native terminal text selection, URL click-through, and tmux/Herdr scrollback for the Pi session. Toggle off if you need those features.
|
|
396
|
+
|
|
397
|
+
### Conflicts and limitations
|
|
398
|
+
|
|
399
|
+
- **Incompatible with** `pi-powerline-footer`, `@tifan/pi-fixed-editor`, and `pi-sticky-input`. These packages patch the same Pi TUI internals; only one rendering owner can be active at a time.
|
|
400
|
+
- **Alternate screen**: Uses the terminal's alternate screen buffer. Native scrollback history is not accessible while the fixed editor is active.
|
|
401
|
+
- **Pi version fragility**: Patches internal TUI methods (`doRender`, `render`, `terminal.write`, `terminal.rows`) that may change across Pi versions. If the TUI layout is unsupported, Zentui falls back to normal rendering with a console warning.
|
|
402
|
+
- If your terminal is stuck after a crash, run `reset` or restart the terminal.
|
|
403
|
+
|
|
345
404
|
## Requirements
|
|
346
405
|
|
|
347
|
-
- [Pi](https://pi.dev) coding agent 0.
|
|
406
|
+
- [Pi](https://pi.dev) coding agent 0.80 or newer
|
|
348
407
|
- A [Nerd Font](https://www.nerdfonts.com/) for icons (or set `icons.mode` to `"ascii"`)
|
|
349
408
|
|
|
350
409
|
## Development
|
|
@@ -61,6 +61,12 @@ export type FooterSegmentsConfig = {
|
|
|
61
61
|
packageVersion: boolean;
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
export type FixedEditorConfig = {
|
|
65
|
+
enabled: boolean;
|
|
66
|
+
mouseScroll: boolean;
|
|
67
|
+
copyNotice: boolean;
|
|
68
|
+
};
|
|
69
|
+
|
|
64
70
|
export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
|
|
65
71
|
export type ExtensionStatusColorMode = "zentui" | "original";
|
|
66
72
|
|
|
@@ -139,6 +145,7 @@ export type PolishedTuiConfig = {
|
|
|
139
145
|
gitCommit: GitCommitConfig;
|
|
140
146
|
gitMetrics: GitMetricsConfig;
|
|
141
147
|
extensionStatuses: ExtensionStatusesConfig;
|
|
148
|
+
fixedEditor: FixedEditorConfig;
|
|
142
149
|
};
|
|
143
150
|
|
|
144
151
|
/**
|
|
@@ -257,6 +264,11 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
257
264
|
placements: {},
|
|
258
265
|
colorModes: {},
|
|
259
266
|
},
|
|
267
|
+
fixedEditor: {
|
|
268
|
+
enabled: false,
|
|
269
|
+
mouseScroll: true,
|
|
270
|
+
copyNotice: true,
|
|
271
|
+
},
|
|
260
272
|
};
|
|
261
273
|
|
|
262
274
|
type ConfigRecord = Record<string, unknown>;
|
|
@@ -508,6 +520,21 @@ function normalizeExtensionStatuses(record: Record<string, unknown>): ExtensionS
|
|
|
508
520
|
};
|
|
509
521
|
}
|
|
510
522
|
|
|
523
|
+
function normalizeFixedEditorConfig(record: Record<string, unknown>): FixedEditorConfig {
|
|
524
|
+
return {
|
|
525
|
+
enabled:
|
|
526
|
+
typeof record.enabled === "boolean" ? record.enabled : defaultConfig.fixedEditor.enabled,
|
|
527
|
+
mouseScroll:
|
|
528
|
+
typeof record.mouseScroll === "boolean"
|
|
529
|
+
? record.mouseScroll
|
|
530
|
+
: defaultConfig.fixedEditor.mouseScroll,
|
|
531
|
+
copyNotice:
|
|
532
|
+
typeof record.copyNotice === "boolean"
|
|
533
|
+
? record.copyNotice
|
|
534
|
+
: defaultConfig.fixedEditor.copyNotice,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
511
538
|
function isColorSourceKey(value: string): value is keyof ColorSourcesConfig {
|
|
512
539
|
return value === "starship" || value === "editor" || value === "userMessages";
|
|
513
540
|
}
|
|
@@ -606,6 +633,9 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
|
|
|
606
633
|
const gitMetrics = isRecord(config.gitMetrics)
|
|
607
634
|
? normalizeGitMetricsConfig(config.gitMetrics as Record<string, unknown>)
|
|
608
635
|
: defaultConfig.gitMetrics;
|
|
636
|
+
const fixedEditor = isRecord(config.fixedEditor)
|
|
637
|
+
? normalizeFixedEditorConfig(config.fixedEditor as Record<string, unknown>)
|
|
638
|
+
: defaultConfig.fixedEditor;
|
|
609
639
|
return {
|
|
610
640
|
projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
|
|
611
641
|
footerFormat: stringValue(config, "footerFormat") ?? "",
|
|
@@ -627,6 +657,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
|
|
|
627
657
|
placements: { ...extensionStatuses.placements },
|
|
628
658
|
colorModes: { ...extensionStatuses.colorModes },
|
|
629
659
|
},
|
|
660
|
+
fixedEditor,
|
|
630
661
|
};
|
|
631
662
|
}
|
|
632
663
|
|
|
@@ -812,3 +843,21 @@ export function saveExtensionStatusColorMode(
|
|
|
812
843
|
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
813
844
|
return mergeConfig(record);
|
|
814
845
|
}
|
|
846
|
+
|
|
847
|
+
export function saveFixedEditorPatch(
|
|
848
|
+
patch: Partial<FixedEditorConfig>,
|
|
849
|
+
path = configPath,
|
|
850
|
+
): PolishedTuiConfig {
|
|
851
|
+
const record = readConfigRecord(path);
|
|
852
|
+
const existing = isRecord(record.fixedEditor)
|
|
853
|
+
? { ...(record.fixedEditor as Record<string, unknown>) }
|
|
854
|
+
: {};
|
|
855
|
+
record.fixedEditor = {
|
|
856
|
+
...existing,
|
|
857
|
+
...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
|
|
858
|
+
...(patch.mouseScroll !== undefined ? { mouseScroll: patch.mouseScroll } : {}),
|
|
859
|
+
...(patch.copyNotice !== undefined ? { copyNotice: patch.copyNotice } : {}),
|
|
860
|
+
};
|
|
861
|
+
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
862
|
+
return mergeConfig(record);
|
|
863
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cluster discovery and rendering for the fixed editor.
|
|
3
|
+
*
|
|
4
|
+
* The "cluster" is the set of Pi TUI children around the editor that should be
|
|
5
|
+
* pinned at the bottom: status container, above-editor widget, editor,
|
|
6
|
+
* below-editor widget, and footer.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { CURSOR_MARKER, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
|
|
13
|
+
import type { ClusterRender } from "./types";
|
|
14
|
+
|
|
15
|
+
/** Minimal Component shape needed for rendering. */
|
|
16
|
+
type Renderable = {
|
|
17
|
+
render(width: number): string[];
|
|
18
|
+
/** Saved original render when the compositor has patched render → [] */
|
|
19
|
+
__zentuiOriginalRender?: (width: number) => string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Minimal Container shape for child scanning. */
|
|
23
|
+
type ContainerLike = Renderable & {
|
|
24
|
+
children: unknown[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Check if a value is a container-like object (has children + render). */
|
|
28
|
+
function isContainerLike(value: unknown): value is ContainerLike {
|
|
29
|
+
return (
|
|
30
|
+
typeof value === "object" &&
|
|
31
|
+
value !== null &&
|
|
32
|
+
Array.isArray(Reflect.get(value, "children")) &&
|
|
33
|
+
typeof Reflect.get(value, "render") === "function"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Check if a value looks like an editor component (duck-typed). */
|
|
38
|
+
function isEditorLike(value: unknown): boolean {
|
|
39
|
+
return (
|
|
40
|
+
typeof value === "object" &&
|
|
41
|
+
value !== null &&
|
|
42
|
+
typeof Reflect.get(value, "getText") === "function" &&
|
|
43
|
+
typeof Reflect.get(value, "setText") === "function" &&
|
|
44
|
+
typeof Reflect.get(value, "handleInput") === "function"
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Find the index in `children` of the container holding the editor.
|
|
50
|
+
* Prefers the focused component's parent; falls back to scanning for
|
|
51
|
+
* an editor-like grandchild.
|
|
52
|
+
*/
|
|
53
|
+
export function findEditorContainerIndex(
|
|
54
|
+
children: unknown[],
|
|
55
|
+
focusedComponent?: unknown,
|
|
56
|
+
): number | undefined {
|
|
57
|
+
// Try focused component first.
|
|
58
|
+
if (focusedComponent && typeof focusedComponent === "object") {
|
|
59
|
+
const idx = children.findIndex(
|
|
60
|
+
(c) => isContainerLike(c) && c.children.includes(focusedComponent),
|
|
61
|
+
);
|
|
62
|
+
if (idx !== -1) return idx;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Scan for a container with an editor-like child.
|
|
66
|
+
const idx = children.findIndex(
|
|
67
|
+
(c) => isContainerLike(c) && c.children.some((gc) => isEditorLike(gc)),
|
|
68
|
+
);
|
|
69
|
+
return idx === -1 ? undefined : idx;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The 5-component cluster pinned at the bottom. */
|
|
73
|
+
export type FixedCluster = {
|
|
74
|
+
status: Renderable | null;
|
|
75
|
+
aboveWidget: Renderable | null;
|
|
76
|
+
editor: Renderable;
|
|
77
|
+
belowWidget: Renderable | null;
|
|
78
|
+
footer: Renderable | null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Patch a cluster component's render to return [] (hide from transcript).
|
|
83
|
+
* Saves the original render for cluster painting.
|
|
84
|
+
*/
|
|
85
|
+
export function hideRenderable(component: Renderable | null): void {
|
|
86
|
+
if (!component || component.__zentuiOriginalRender) return;
|
|
87
|
+
component.__zentuiOriginalRender = component.render.bind(component);
|
|
88
|
+
component.render = () => [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Restore a cluster component's original render. */
|
|
92
|
+
export function restoreRenderable(component: Renderable | null): void {
|
|
93
|
+
if (!component?.__zentuiOriginalRender) return;
|
|
94
|
+
component.render = component.__zentuiOriginalRender;
|
|
95
|
+
delete component.__zentuiOriginalRender;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Build the cluster from children around the editor index. */
|
|
99
|
+
export function buildCluster(children: unknown[], editorIdx: number): FixedCluster | null {
|
|
100
|
+
const editor = children[editorIdx];
|
|
101
|
+
if (!editor || typeof (editor as Renderable).render !== "function") return null;
|
|
102
|
+
return {
|
|
103
|
+
status: (children[editorIdx - 2] as Renderable | undefined) ?? null,
|
|
104
|
+
aboveWidget: (children[editorIdx - 1] as Renderable | undefined) ?? null,
|
|
105
|
+
editor: editor as Renderable,
|
|
106
|
+
belowWidget: (children[editorIdx + 1] as Renderable | undefined) ?? null,
|
|
107
|
+
footer: (children[editorIdx + 2] as Renderable | undefined) ?? null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Render a component at `width`, using the saved original render if hidden. */
|
|
112
|
+
function renderComponent(component: Renderable | null, width: number): string[] {
|
|
113
|
+
if (!component) return [];
|
|
114
|
+
const renderFn = component.__zentuiOriginalRender ?? component.render;
|
|
115
|
+
const lines = renderFn.call(component, width);
|
|
116
|
+
// Strip only trailing blank lines — internal blank lines (e.g. editor
|
|
117
|
+
// padding in copy-friendly mode) must be preserved.
|
|
118
|
+
let end = lines.length;
|
|
119
|
+
while (end > 0 && visibleWidth(lines[end - 1]) === 0) end--;
|
|
120
|
+
return lines.slice(0, Math.max(end, 1));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Cap editor lines to `maxLines`, keeping the cursor row visible.
|
|
125
|
+
* If the cursor marker is found, the window centers on it; otherwise
|
|
126
|
+
* the last `maxLines` are kept.
|
|
127
|
+
*/
|
|
128
|
+
export function capEditorLines(lines: string[], maxLines: number): string[] {
|
|
129
|
+
if (maxLines <= 0) return [];
|
|
130
|
+
if (lines.length <= maxLines) return lines;
|
|
131
|
+
|
|
132
|
+
const cursorRow = lines.findIndex((line) => line.includes(CURSOR_MARKER));
|
|
133
|
+
if (cursorRow !== -1) {
|
|
134
|
+
const start = Math.max(0, Math.min(cursorRow - maxLines + 1, lines.length - maxLines));
|
|
135
|
+
return lines.slice(start, start + maxLines);
|
|
136
|
+
}
|
|
137
|
+
return lines.slice(lines.length - maxLines);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sanitizeLines(lines: string[], width: number): string[] {
|
|
141
|
+
return lines.map((line) =>
|
|
142
|
+
visibleWidth(line) > width ? truncateToWidth(line, width, "", true) : line,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Render the full cluster (status + widgets + editor + footer) and extract
|
|
148
|
+
* the cursor position from the CURSOR_MARKER.
|
|
149
|
+
*/
|
|
150
|
+
export function renderCluster(
|
|
151
|
+
cluster: FixedCluster,
|
|
152
|
+
width: number,
|
|
153
|
+
maxHeight: number,
|
|
154
|
+
): ClusterRender {
|
|
155
|
+
const w = Math.max(1, width);
|
|
156
|
+
const maxRows = Math.max(1, maxHeight - 1);
|
|
157
|
+
|
|
158
|
+
const statusLines = sanitizeLines(renderComponent(cluster.status, w), w);
|
|
159
|
+
const aboveLines = sanitizeLines(renderComponent(cluster.aboveWidget, w), w);
|
|
160
|
+
const editorSource = sanitizeLines(renderComponent(cluster.editor, w), w);
|
|
161
|
+
const belowLines = sanitizeLines(renderComponent(cluster.belowWidget, w), w);
|
|
162
|
+
const footerLines = sanitizeLines(renderComponent(cluster.footer, w), w);
|
|
163
|
+
|
|
164
|
+
const editorLines = capEditorLines(editorSource, maxRows);
|
|
165
|
+
let remaining = maxRows - editorLines.length;
|
|
166
|
+
|
|
167
|
+
const footer = footerLines.slice(-remaining);
|
|
168
|
+
remaining -= footer.length;
|
|
169
|
+
|
|
170
|
+
const below = belowLines.slice(-remaining);
|
|
171
|
+
remaining -= below.length;
|
|
172
|
+
|
|
173
|
+
const above = aboveLines.slice(-remaining);
|
|
174
|
+
remaining -= above.length;
|
|
175
|
+
|
|
176
|
+
const status = statusLines.slice(-remaining);
|
|
177
|
+
|
|
178
|
+
let allLines = [...status, ...above, ...editorLines, ...below, ...footer];
|
|
179
|
+
|
|
180
|
+
// Strip leading blank lines (e.g. empty status line above the editor border).
|
|
181
|
+
let start = 0;
|
|
182
|
+
while (start < allLines.length - 1 && visibleWidth(allLines[start]) === 0) start++;
|
|
183
|
+
allLines = allLines.slice(start);
|
|
184
|
+
|
|
185
|
+
let cursor: { row: number; col: number } | null = null;
|
|
186
|
+
const cleaned = allLines.map((line, row) => {
|
|
187
|
+
const markerIndex = line.indexOf(CURSOR_MARKER);
|
|
188
|
+
if (markerIndex === -1) return line;
|
|
189
|
+
cursor ??= { row, col: visibleWidth(line.slice(0, markerIndex)) };
|
|
190
|
+
return line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return { lines: cleaned, cursor };
|
|
194
|
+
}
|