pi-zentui 0.9.0 → 0.10.1
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 +5 -1
- package/extensions/zentui/config.ts +238 -129
- package/extensions/zentui/fixed-editor/cluster.ts +7 -103
- package/extensions/zentui/fixed-editor/compositor.ts +197 -176
- package/extensions/zentui/fixed-editor/index.ts +50 -33
- package/extensions/zentui/fixed-editor/pi-compat.ts +296 -0
- package/extensions/zentui/fixed-editor/types.ts +0 -33
- package/extensions/zentui/footer.ts +11 -3
- package/extensions/zentui/format.ts +14 -0
- package/extensions/zentui/index.ts +71 -42
- package/extensions/zentui/prototype-patch-registry.ts +107 -0
- package/extensions/zentui/selector-border.ts +17 -46
- package/extensions/zentui/session-lifecycle.ts +53 -0
- package/extensions/zentui/settings-command.ts +51 -4
- package/extensions/zentui/user-message.ts +34 -70
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -167,6 +167,9 @@ Default config values — copy this and change any value you want:
|
|
|
167
167
|
"mode": "basename",
|
|
168
168
|
"depth": 0
|
|
169
169
|
},
|
|
170
|
+
"gitBranch": {
|
|
171
|
+
"maxLength": "full"
|
|
172
|
+
},
|
|
170
173
|
"icons": {
|
|
171
174
|
"mode": "auto",
|
|
172
175
|
"cwd": "",
|
|
@@ -276,11 +279,12 @@ Default config values — copy this and change any value you want:
|
|
|
276
279
|
- `separator`: controls the default footer layout and extension-status connectors: `pipe` (default, ` | `), `dot` (` · `), `chevron` (` › `), or `none` (one space). Cycle it from the `/zentui` **Layout** tab. This selects the separator glyph; `colors.separator` controls its color. Custom `footerFormat` literals and `$sep` keep their existing behavior.
|
|
277
280
|
- `contextThresholds`: `{ warning, error }` percentages (default `70` / `90`) that select contextNormal / contextWarning / contextError colors.
|
|
278
281
|
- `pathDisplay`: controls how the cwd/`$cwd` path is shown. `mode` is `basename` (default, last segment only) or `full` (path with home contracted to `~`). In `full` mode, `depth` keeps only the last N trailing directories (`0` = entire path after `~`, max `5`); when parents are dropped the path is prefixed with `…/` (Starship-style). The `/zentui` **Layout** tab cycles path mode and path depth (`0`–`5`; depth is ignored for basename). Example: `~/Projects/foo/bar` with `depth: 2` → `…/foo/bar`.
|
|
282
|
+
- `gitBranch.maxLength`: visible width of the built-in branch name and `$git_branch` / `$branch`. The default `full` preserves the complete name; any positive integer uses that width including the trailing `…`. `/zentui` **Layout** cycles `full`, `10`, `20`, `30`, `40`, and `50`; custom positive integers can be set in JSON.
|
|
279
283
|
- `icons`: every shown icon key is configurable; omit any key to use the Zentui default. `icons.mode` is `auto` | `nerd` | `ascii` (default `auto`, same glyphs as nerd). ASCII mode swaps in plain fallbacks for statusline icons and runtime symbols — useful without a Nerd Font. Custom per-icon strings always win over mode defaults. Custom `icons.os` always wins; when left at the mode default, Zentui maps the OS icon by platform. `rail` sets the vertical glyph drawn as the left rail of the active editor frame and previous user messages when `copyFriendly` is disabled (default `│`; any single Unicode vertical or block glyph). `editorPrompt` controls an optional copy-friendly editor prompt glyph; the default is `""` so copy-friendly mode stays rail-free.
|
|
280
284
|
- `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
|
|
281
285
|
- `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.
|
|
282
286
|
- `footerSegments`: show or hide individual built-in footer segments (`cwd`, `gitBranch`, `gitStatus`, `gitCounts`, `gitCommit`, `gitMetrics`, `runtime`, `packageVersion`, `sessionDuration`, `username`, `time`, `os`, `context`, `tokens`, `cost`). Toggle them from the `Built-in segments` tab in `/zentui`.
|
|
283
|
-
- `footerFormat`: optional Starship-style template string that fully controls the footer layout. When set, it overrides `footerSegments`. See [Footer Format Template](#footer-format-template) below. The `/zentui` **Layout** tab configures context style, separator, path display mode/depth, and icon mode; set or clear custom formats with `/zentui format`.
|
|
287
|
+
- `footerFormat`: optional Starship-style template string that fully controls the footer layout. When set, it overrides `footerSegments`. See [Footer Format Template](#footer-format-template) below. The `/zentui` **Layout** tab configures context style, separator, path display mode/depth, branch length, and icon mode; set or clear custom formats with `/zentui format`.
|
|
284
288
|
- `gitCommit`: Starship [`git_commit`](https://starship.rs/config/#git-commit)-style options for the `gitCommit` footer segment. `hashLength` (default `7`, clamped to `4`–`40`) controls the short-hash display length. `onlyDetached` (default `true`) shows the hash mainly on detached HEAD. `showTag` (default `true`) appends an exact-match tag (`git describe --tags --exact-match HEAD`). The tag probe piggybacks on the existing git refresh — it only runs when both the segment and `showTag` are on, and misses/failures degrade silently.
|
|
285
289
|
- `gitMetrics`: Starship [`git_metrics`](https://starship.rs/config/#git-metrics)-style options for the `gitMetrics` footer segment. Uses `git diff HEAD --numstat` (staged + unstaged combined — the Starship “total dirty” view) to show aggregate `+added −deleted` line counts. `onlyNonzero` (default `true`) omits each zero component independently and hides the segment entirely at `0/0`. `ignoreSubmodules` (default `false`) adds `--ignore-submodules=all`. The numstat diff piggybacks on the existing git refresh and uses a hard 2s timeout; a metrics-only failure degrades silently without discarding fresh branch/status data. On very large monorepos the diff may lag or be omitted on timeout.
|
|
286
290
|
- `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`. The `Extension segments` tab in `/zentui` lists only statuses that are currently active.
|
|
@@ -1,5 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
fchmodSync,
|
|
6
|
+
fsyncSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
statSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { basename, dirname, join } from "node:path";
|
|
3
17
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
18
|
import {
|
|
5
19
|
ICON_GLYPH_KEYS,
|
|
@@ -32,6 +46,12 @@ export type PathDisplayConfig = {
|
|
|
32
46
|
depth: number;
|
|
33
47
|
};
|
|
34
48
|
|
|
49
|
+
export type GitBranchMaxLength = "full" | number;
|
|
50
|
+
|
|
51
|
+
export type GitBranchConfig = {
|
|
52
|
+
maxLength: GitBranchMaxLength;
|
|
53
|
+
};
|
|
54
|
+
|
|
35
55
|
export type ColorSourcesConfig = {
|
|
36
56
|
starship: ColorSource;
|
|
37
57
|
editor: ColorSource;
|
|
@@ -108,6 +128,7 @@ export type PolishedTuiConfig = {
|
|
|
108
128
|
contextStyle: ContextStyle;
|
|
109
129
|
contextThresholds: ContextThresholds;
|
|
110
130
|
pathDisplay: PathDisplayConfig;
|
|
131
|
+
gitBranch: GitBranchConfig;
|
|
111
132
|
icons: ResolvedIcons;
|
|
112
133
|
colors: {
|
|
113
134
|
cwd: ColorSpec;
|
|
@@ -201,6 +222,7 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
201
222
|
contextStyle: "text",
|
|
202
223
|
contextThresholds: { warning: 70, error: 90 },
|
|
203
224
|
pathDisplay: { mode: "basename", depth: 0 },
|
|
225
|
+
gitBranch: { maxLength: "full" },
|
|
204
226
|
icons: {
|
|
205
227
|
mode: "auto",
|
|
206
228
|
...NERD_DEFAULT_ICONS,
|
|
@@ -342,6 +364,20 @@ function parsePathDisplay(value: unknown): PathDisplayConfig {
|
|
|
342
364
|
return { mode, depth };
|
|
343
365
|
}
|
|
344
366
|
|
|
367
|
+
function normalizeGitBranchMaxLength(value: unknown): GitBranchMaxLength {
|
|
368
|
+
if (value === "full") return value;
|
|
369
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
|
|
370
|
+
return defaultConfig.gitBranch.maxLength;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function parseGitBranchConfig(value: unknown): GitBranchConfig {
|
|
374
|
+
const defaults = defaultConfig.gitBranch;
|
|
375
|
+
if (!isRecord(value)) return { ...defaults };
|
|
376
|
+
return {
|
|
377
|
+
maxLength: normalizeGitBranchMaxLength(value.maxLength),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
345
381
|
function stringValue(record: Record<string, unknown>, key: string): string | undefined {
|
|
346
382
|
const value = record[key];
|
|
347
383
|
return typeof value === "string" ? value : undefined;
|
|
@@ -601,16 +637,85 @@ function validFooterSegmentEntries(record: Record<string, unknown>): Partial<Foo
|
|
|
601
637
|
) as Partial<FooterSegmentsConfig>;
|
|
602
638
|
}
|
|
603
639
|
|
|
604
|
-
|
|
640
|
+
type ConfigFileState =
|
|
641
|
+
| { kind: "missing"; record: ConfigRecord; writePath: string }
|
|
642
|
+
| { kind: "valid"; record: ConfigRecord; writePath: string; mode: number }
|
|
643
|
+
| { kind: "corrupt"; error: unknown };
|
|
644
|
+
|
|
645
|
+
function errorCode(error: unknown): string | undefined {
|
|
646
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
647
|
+
? String(error.code)
|
|
648
|
+
: undefined;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function readConfigFileState(path: string): ConfigFileState {
|
|
652
|
+
let writePath = path;
|
|
605
653
|
try {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
return
|
|
654
|
+
const pathStat = lstatSync(path);
|
|
655
|
+
if (pathStat.isSymbolicLink()) writePath = realpathSync(path);
|
|
656
|
+
const targetStat = statSync(writePath);
|
|
657
|
+
const parsed = JSON.parse(readFileSync(writePath, "utf8"));
|
|
658
|
+
return isRecord(parsed)
|
|
659
|
+
? { kind: "valid", record: parsed, writePath, mode: targetStat.mode & 0o7777 }
|
|
660
|
+
: { kind: "corrupt", error: new Error("top-level value must be a JSON object") };
|
|
661
|
+
} catch (error) {
|
|
662
|
+
if (errorCode(error) === "ENOENT") {
|
|
663
|
+
try {
|
|
664
|
+
lstatSync(path);
|
|
665
|
+
} catch (pathError) {
|
|
666
|
+
if (errorCode(pathError) === "ENOENT")
|
|
667
|
+
return { kind: "missing", record: {}, writePath: path };
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return { kind: "corrupt", error };
|
|
611
671
|
}
|
|
612
672
|
}
|
|
613
673
|
|
|
674
|
+
function writeConfigAtomically(path: string, record: ConfigRecord, mode?: number): void {
|
|
675
|
+
const tempPath = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
676
|
+
let file: number | undefined;
|
|
677
|
+
try {
|
|
678
|
+
file = openSync(tempPath, "wx", mode ?? 0o666);
|
|
679
|
+
if (mode !== undefined) fchmodSync(file, mode);
|
|
680
|
+
writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
681
|
+
fsyncSync(file);
|
|
682
|
+
closeSync(file);
|
|
683
|
+
file = undefined;
|
|
684
|
+
renameSync(tempPath, path);
|
|
685
|
+
} catch (error) {
|
|
686
|
+
if (file !== undefined) {
|
|
687
|
+
try {
|
|
688
|
+
closeSync(file);
|
|
689
|
+
} catch {}
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
unlinkSync(tempPath);
|
|
693
|
+
} catch (cleanupError) {
|
|
694
|
+
if (errorCode(cleanupError) !== "ENOENT") {
|
|
695
|
+
// Preserve the persistence failure; the best-effort cleanup error is secondary.
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
throw error;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function mutateConfig(path: string, mutate: (record: ConfigRecord) => void): PolishedTuiConfig {
|
|
703
|
+
const state = readConfigFileState(path);
|
|
704
|
+
if (state.kind === "corrupt") {
|
|
705
|
+
const detail = state.error instanceof Error ? ` (${state.error.message})` : "";
|
|
706
|
+
throw new Error(
|
|
707
|
+
`Refusing to save Zentui config because ${path} is corrupt or unreadable; fix or remove it first.${detail}`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
mutate(state.record);
|
|
711
|
+
writeConfigAtomically(
|
|
712
|
+
state.writePath,
|
|
713
|
+
state.record,
|
|
714
|
+
state.kind === "valid" ? state.mode : undefined,
|
|
715
|
+
);
|
|
716
|
+
return mergeConfig(state.record);
|
|
717
|
+
}
|
|
718
|
+
|
|
614
719
|
export function ensureConfigExists(): void {
|
|
615
720
|
// Intentionally left as a no-op. Zentui config is user-owned and
|
|
616
721
|
// compatibility-sensitive: runtime defaults come from `mergeConfig({})`, and
|
|
@@ -644,6 +749,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
|
|
|
644
749
|
const gitMetrics = isRecord(config.gitMetrics)
|
|
645
750
|
? normalizeGitMetricsConfig(config.gitMetrics as Record<string, unknown>)
|
|
646
751
|
: defaultConfig.gitMetrics;
|
|
752
|
+
const gitBranch = parseGitBranchConfig(config.gitBranch);
|
|
647
753
|
const fixedEditor = isRecord(config.fixedEditor)
|
|
648
754
|
? normalizeFixedEditorConfig(config.fixedEditor as Record<string, unknown>)
|
|
649
755
|
: defaultConfig.fixedEditor;
|
|
@@ -654,6 +760,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
|
|
|
654
760
|
contextStyle: parseContextStyle(config.contextStyle),
|
|
655
761
|
contextThresholds: parseContextThresholds(config.contextThresholds),
|
|
656
762
|
pathDisplay: parsePathDisplay(config.pathDisplay),
|
|
763
|
+
gitBranch,
|
|
657
764
|
icons: resolveConfiguredIcons(iconMode, iconOverrides),
|
|
658
765
|
colors: {
|
|
659
766
|
...defaultConfig.colors,
|
|
@@ -700,114 +807,119 @@ export function saveColorSourcesPatch(
|
|
|
700
807
|
patch: Partial<ColorSourcesConfig>,
|
|
701
808
|
path = configPath,
|
|
702
809
|
): PolishedTuiConfig {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
return mergeConfig(record);
|
|
810
|
+
return mutateConfig(path, (record) => {
|
|
811
|
+
const existing = isRecord(record.colorSources)
|
|
812
|
+
? { ...(record.colorSources as Record<string, unknown>) }
|
|
813
|
+
: {};
|
|
814
|
+
record.colorSources = {
|
|
815
|
+
...existing,
|
|
816
|
+
...validColorSourceEntries(patch),
|
|
817
|
+
};
|
|
818
|
+
});
|
|
713
819
|
}
|
|
714
820
|
|
|
715
821
|
export function saveUiFeaturesPatch(
|
|
716
822
|
patch: Partial<UiFeaturesConfig>,
|
|
717
823
|
path = configPath,
|
|
718
824
|
): PolishedTuiConfig {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
return mergeConfig(record);
|
|
825
|
+
return mutateConfig(path, (record) => {
|
|
826
|
+
const existing = isRecord(record.features)
|
|
827
|
+
? { ...(record.features as Record<string, unknown>) }
|
|
828
|
+
: {};
|
|
829
|
+
record.features = {
|
|
830
|
+
...existing,
|
|
831
|
+
...validUiFeatureEntries(patch),
|
|
832
|
+
};
|
|
833
|
+
});
|
|
729
834
|
}
|
|
730
835
|
|
|
731
836
|
export function saveFooterSegmentsPatch(
|
|
732
837
|
patch: Partial<FooterSegmentsConfig>,
|
|
733
838
|
path = configPath,
|
|
734
839
|
): PolishedTuiConfig {
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
return mergeConfig(record);
|
|
840
|
+
return mutateConfig(path, (record) => {
|
|
841
|
+
const existing = isRecord(record.footerSegments)
|
|
842
|
+
? { ...(record.footerSegments as Record<string, unknown>) }
|
|
843
|
+
: {};
|
|
844
|
+
record.footerSegments = {
|
|
845
|
+
...existing,
|
|
846
|
+
...validFooterSegmentEntries(patch),
|
|
847
|
+
};
|
|
848
|
+
});
|
|
745
849
|
}
|
|
746
850
|
|
|
747
851
|
export function saveFooterFormatPatch(value: string, path = configPath): PolishedTuiConfig {
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
return mergeConfig(record);
|
|
852
|
+
return mutateConfig(path, (record) => {
|
|
853
|
+
record.footerFormat = typeof value === "string" ? value : "";
|
|
854
|
+
});
|
|
752
855
|
}
|
|
753
856
|
|
|
754
857
|
export function saveIconsModePatch(mode: IconMode, path = configPath): PolishedTuiConfig {
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
return mergeConfig(record);
|
|
858
|
+
return mutateConfig(path, (record) => {
|
|
859
|
+
const existing = isRecord(record.icons) ? { ...(record.icons as Record<string, unknown>) } : {};
|
|
860
|
+
record.icons = {
|
|
861
|
+
...existing,
|
|
862
|
+
mode: normalizeIconMode(mode),
|
|
863
|
+
};
|
|
864
|
+
});
|
|
763
865
|
}
|
|
764
866
|
|
|
765
867
|
export function saveContextStylePatch(style: ContextStyle, path = configPath): PolishedTuiConfig {
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
return mergeConfig(record);
|
|
868
|
+
return mutateConfig(path, (record) => {
|
|
869
|
+
record.contextStyle = parseContextStyle(style);
|
|
870
|
+
});
|
|
770
871
|
}
|
|
771
872
|
|
|
772
873
|
export function saveSeparatorPatch(
|
|
773
874
|
separator: SeparatorStyle,
|
|
774
875
|
path = configPath,
|
|
775
876
|
): PolishedTuiConfig {
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
return mergeConfig(record);
|
|
877
|
+
return mutateConfig(path, (record) => {
|
|
878
|
+
record.separator = parseSeparatorStyle(separator);
|
|
879
|
+
});
|
|
780
880
|
}
|
|
781
881
|
|
|
782
882
|
export function saveContextThresholdsPatch(
|
|
783
883
|
thresholds: Partial<ContextThresholds>,
|
|
784
884
|
path = configPath,
|
|
785
885
|
): PolishedTuiConfig {
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
return mergeConfig(record);
|
|
886
|
+
return mutateConfig(path, (record) => {
|
|
887
|
+
const existing = isRecord(record.contextThresholds)
|
|
888
|
+
? { ...(record.contextThresholds as Record<string, unknown>) }
|
|
889
|
+
: {};
|
|
890
|
+
record.contextThresholds = {
|
|
891
|
+
...existing,
|
|
892
|
+
...thresholds,
|
|
893
|
+
};
|
|
894
|
+
});
|
|
796
895
|
}
|
|
797
896
|
|
|
798
897
|
export function savePathDisplayPatch(
|
|
799
898
|
patch: Partial<PathDisplayConfig>,
|
|
800
899
|
path = configPath,
|
|
801
900
|
): PolishedTuiConfig {
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
901
|
+
return mutateConfig(path, (record) => {
|
|
902
|
+
const existing = isRecord(record.pathDisplay)
|
|
903
|
+
? { ...(record.pathDisplay as Record<string, unknown>) }
|
|
904
|
+
: {};
|
|
905
|
+
if (patch.mode !== undefined) existing.mode = patch.mode;
|
|
906
|
+
if (patch.depth !== undefined) existing.depth = patch.depth;
|
|
907
|
+
record.pathDisplay = existing;
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export function saveGitBranchPatch(
|
|
912
|
+
patch: Partial<GitBranchConfig>,
|
|
913
|
+
path = configPath,
|
|
914
|
+
): PolishedTuiConfig {
|
|
915
|
+
return mutateConfig(path, (record) => {
|
|
916
|
+
const existing = isRecord(record.gitBranch)
|
|
917
|
+
? { ...(record.gitBranch as Record<string, unknown>) }
|
|
918
|
+
: {};
|
|
919
|
+
if (patch.maxLength !== undefined)
|
|
920
|
+
existing.maxLength = normalizeGitBranchMaxLength(patch.maxLength);
|
|
921
|
+
record.gitBranch = existing;
|
|
922
|
+
});
|
|
811
923
|
}
|
|
812
924
|
|
|
813
925
|
export function saveExtensionStatusPlacement(
|
|
@@ -815,27 +927,26 @@ export function saveExtensionStatusPlacement(
|
|
|
815
927
|
placement: ExtensionStatusPlacement,
|
|
816
928
|
path = configPath,
|
|
817
929
|
): PolishedTuiConfig {
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
930
|
+
return mutateConfig(path, (record) => {
|
|
931
|
+
const existingExtensionStatuses = isRecord(record.extensionStatuses)
|
|
932
|
+
? { ...(record.extensionStatuses as Record<string, unknown>) }
|
|
933
|
+
: {};
|
|
934
|
+
const existingPlacements = isRecord(existingExtensionStatuses.placements)
|
|
935
|
+
? { ...(existingExtensionStatuses.placements as Record<string, unknown>) }
|
|
936
|
+
: {};
|
|
937
|
+
|
|
938
|
+
Object.defineProperty(existingPlacements, key, {
|
|
939
|
+
value: placement,
|
|
940
|
+
enumerable: true,
|
|
941
|
+
configurable: true,
|
|
942
|
+
writable: true,
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
record.extensionStatuses = {
|
|
946
|
+
...existingExtensionStatuses,
|
|
947
|
+
placements: existingPlacements,
|
|
948
|
+
};
|
|
831
949
|
});
|
|
832
|
-
|
|
833
|
-
record.extensionStatuses = {
|
|
834
|
-
...existingExtensionStatuses,
|
|
835
|
-
placements: existingPlacements,
|
|
836
|
-
};
|
|
837
|
-
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
838
|
-
return mergeConfig(record);
|
|
839
950
|
}
|
|
840
951
|
|
|
841
952
|
export function saveExtensionStatusColorMode(
|
|
@@ -843,43 +954,41 @@ export function saveExtensionStatusColorMode(
|
|
|
843
954
|
colorMode: ExtensionStatusColorMode,
|
|
844
955
|
path = configPath,
|
|
845
956
|
): PolishedTuiConfig {
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
957
|
+
return mutateConfig(path, (record) => {
|
|
958
|
+
const existingExtensionStatuses = isRecord(record.extensionStatuses)
|
|
959
|
+
? { ...(record.extensionStatuses as Record<string, unknown>) }
|
|
960
|
+
: {};
|
|
961
|
+
const existingColorModes = isRecord(existingExtensionStatuses.colorModes)
|
|
962
|
+
? { ...(existingExtensionStatuses.colorModes as Record<string, unknown>) }
|
|
963
|
+
: {};
|
|
964
|
+
|
|
965
|
+
Object.defineProperty(existingColorModes, key, {
|
|
966
|
+
value: colorMode,
|
|
967
|
+
enumerable: true,
|
|
968
|
+
configurable: true,
|
|
969
|
+
writable: true,
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
record.extensionStatuses = {
|
|
973
|
+
...existingExtensionStatuses,
|
|
974
|
+
colorModes: existingColorModes,
|
|
975
|
+
};
|
|
859
976
|
});
|
|
860
|
-
|
|
861
|
-
record.extensionStatuses = {
|
|
862
|
-
...existingExtensionStatuses,
|
|
863
|
-
colorModes: existingColorModes,
|
|
864
|
-
};
|
|
865
|
-
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
866
|
-
return mergeConfig(record);
|
|
867
977
|
}
|
|
868
978
|
|
|
869
979
|
export function saveFixedEditorPatch(
|
|
870
980
|
patch: Partial<FixedEditorConfig>,
|
|
871
981
|
path = configPath,
|
|
872
982
|
): PolishedTuiConfig {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
return mergeConfig(record);
|
|
983
|
+
return mutateConfig(path, (record) => {
|
|
984
|
+
const existing = isRecord(record.fixedEditor)
|
|
985
|
+
? { ...(record.fixedEditor as Record<string, unknown>) }
|
|
986
|
+
: {};
|
|
987
|
+
record.fixedEditor = {
|
|
988
|
+
...existing,
|
|
989
|
+
...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
|
|
990
|
+
...(patch.mouseScroll !== undefined ? { mouseScroll: patch.mouseScroll } : {}),
|
|
991
|
+
...(patch.copyNotice !== undefined ? { copyNotice: patch.copyNotice } : {}),
|
|
992
|
+
};
|
|
993
|
+
});
|
|
885
994
|
}
|
|
@@ -1,118 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cluster
|
|
2
|
+
* Cluster rendering for the fixed editor.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* below-editor widget, and footer.
|
|
4
|
+
* Pi-specific cluster discovery and validation live in pi-compat.ts. This module
|
|
5
|
+
* only renders the already-verified pinned components.
|
|
7
6
|
*
|
|
8
7
|
* @internal
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import { CURSOR_MARKER, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
12
11
|
|
|
12
|
+
import type { PiFixedCluster, PiRenderableCapability } from "./pi-compat";
|
|
13
13
|
import type { ClusterRender } from "./types";
|
|
14
14
|
|
|
15
|
-
|
|
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
|
-
}
|
|
15
|
+
export type FixedCluster = PiFixedCluster;
|
|
110
16
|
|
|
111
|
-
|
|
112
|
-
function renderComponent(component: Renderable | null, width: number): string[] {
|
|
17
|
+
function renderComponent(component: PiRenderableCapability | null, width: number): string[] {
|
|
113
18
|
if (!component) return [];
|
|
114
|
-
const
|
|
115
|
-
const lines = renderFn.call(component, width);
|
|
19
|
+
const lines = component.render.call(component.target, width);
|
|
116
20
|
// Strip only trailing blank lines — internal blank lines (e.g. editor
|
|
117
21
|
// padding in copy-friendly mode) must be preserved.
|
|
118
22
|
let end = lines.length;
|