pi-zentui 0.10.0 → 0.11.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 +1 -1
- package/extensions/zentui/config.ts +208 -138
- 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 +10 -2
- package/extensions/zentui/index.ts +109 -48
- package/extensions/zentui/live-context.ts +75 -0
- package/extensions/zentui/prototype-patch-registry.ts +107 -0
- package/extensions/zentui/selector-border.ts +17 -46
- package/extensions/zentui/session-lifecycle.ts +60 -0
- package/extensions/zentui/settings-command.ts +8 -3
- package/extensions/zentui/user-message.ts +34 -70
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -275,7 +275,7 @@ Default config values — copy this and change any value you want:
|
|
|
275
275
|
|
|
276
276
|
- Style values can be Starship/terminal strings (`bold purple`, `fg:202`, `#89b` / `#89b4fa`, `bg:blue fg:bright-green`) or Pi theme tokens (`accent`, `borderMuted`, `thinkingHigh`). Short `#rgb` hex values expand to `#rrggbb`.
|
|
277
277
|
- `projectRefreshIntervalMs`: project status polling interval; `0` disables polling. Values `1..4999` clamp up to `5000` (minimum 5s); invalid/non-finite values fall back to `30000`.
|
|
278
|
-
- `contextStyle`: `text` (default), `gauge`, or `text+gauge` for the context segment.
|
|
278
|
+
- `contextStyle`: `text` (default), `gauge`, or `text+gauge` for the context segment. Context usage refreshes during assistant streaming; token and cost totals remain canonical and finalize at turn boundaries.
|
|
279
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.
|
|
280
280
|
- `contextThresholds`: `{ warning, error }` percentages (default `70` / `90`) that select contextNormal / contextWarning / contextError colors.
|
|
281
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`.
|
|
@@ -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,
|
|
@@ -623,14 +637,83 @@ function validFooterSegmentEntries(record: Record<string, unknown>): Partial<Foo
|
|
|
623
637
|
) as Partial<FooterSegmentsConfig>;
|
|
624
638
|
}
|
|
625
639
|
|
|
626
|
-
|
|
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;
|
|
627
653
|
try {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
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 };
|
|
671
|
+
}
|
|
672
|
+
}
|
|
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
|
+
);
|
|
633
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);
|
|
634
717
|
}
|
|
635
718
|
|
|
636
719
|
export function ensureConfigExists(): void {
|
|
@@ -724,129 +807,119 @@ export function saveColorSourcesPatch(
|
|
|
724
807
|
patch: Partial<ColorSourcesConfig>,
|
|
725
808
|
path = configPath,
|
|
726
809
|
): PolishedTuiConfig {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
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
|
+
});
|
|
737
819
|
}
|
|
738
820
|
|
|
739
821
|
export function saveUiFeaturesPatch(
|
|
740
822
|
patch: Partial<UiFeaturesConfig>,
|
|
741
823
|
path = configPath,
|
|
742
824
|
): PolishedTuiConfig {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
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
|
+
});
|
|
753
834
|
}
|
|
754
835
|
|
|
755
836
|
export function saveFooterSegmentsPatch(
|
|
756
837
|
patch: Partial<FooterSegmentsConfig>,
|
|
757
838
|
path = configPath,
|
|
758
839
|
): PolishedTuiConfig {
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
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
|
+
});
|
|
769
849
|
}
|
|
770
850
|
|
|
771
851
|
export function saveFooterFormatPatch(value: string, path = configPath): PolishedTuiConfig {
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
return mergeConfig(record);
|
|
852
|
+
return mutateConfig(path, (record) => {
|
|
853
|
+
record.footerFormat = typeof value === "string" ? value : "";
|
|
854
|
+
});
|
|
776
855
|
}
|
|
777
856
|
|
|
778
857
|
export function saveIconsModePatch(mode: IconMode, path = configPath): PolishedTuiConfig {
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
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
|
+
});
|
|
787
865
|
}
|
|
788
866
|
|
|
789
867
|
export function saveContextStylePatch(style: ContextStyle, path = configPath): PolishedTuiConfig {
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
return mergeConfig(record);
|
|
868
|
+
return mutateConfig(path, (record) => {
|
|
869
|
+
record.contextStyle = parseContextStyle(style);
|
|
870
|
+
});
|
|
794
871
|
}
|
|
795
872
|
|
|
796
873
|
export function saveSeparatorPatch(
|
|
797
874
|
separator: SeparatorStyle,
|
|
798
875
|
path = configPath,
|
|
799
876
|
): PolishedTuiConfig {
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
return mergeConfig(record);
|
|
877
|
+
return mutateConfig(path, (record) => {
|
|
878
|
+
record.separator = parseSeparatorStyle(separator);
|
|
879
|
+
});
|
|
804
880
|
}
|
|
805
881
|
|
|
806
882
|
export function saveContextThresholdsPatch(
|
|
807
883
|
thresholds: Partial<ContextThresholds>,
|
|
808
884
|
path = configPath,
|
|
809
885
|
): PolishedTuiConfig {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
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
|
+
});
|
|
820
895
|
}
|
|
821
896
|
|
|
822
897
|
export function savePathDisplayPatch(
|
|
823
898
|
patch: Partial<PathDisplayConfig>,
|
|
824
899
|
path = configPath,
|
|
825
900
|
): PolishedTuiConfig {
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
return mergeConfig(record);
|
|
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
|
+
});
|
|
835
909
|
}
|
|
836
910
|
|
|
837
911
|
export function saveGitBranchPatch(
|
|
838
912
|
patch: Partial<GitBranchConfig>,
|
|
839
913
|
path = configPath,
|
|
840
914
|
): PolishedTuiConfig {
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
return mergeConfig(record);
|
|
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
|
+
});
|
|
850
923
|
}
|
|
851
924
|
|
|
852
925
|
export function saveExtensionStatusPlacement(
|
|
@@ -854,27 +927,26 @@ export function saveExtensionStatusPlacement(
|
|
|
854
927
|
placement: ExtensionStatusPlacement,
|
|
855
928
|
path = configPath,
|
|
856
929
|
): PolishedTuiConfig {
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
+
};
|
|
870
949
|
});
|
|
871
|
-
|
|
872
|
-
record.extensionStatuses = {
|
|
873
|
-
...existingExtensionStatuses,
|
|
874
|
-
placements: existingPlacements,
|
|
875
|
-
};
|
|
876
|
-
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
877
|
-
return mergeConfig(record);
|
|
878
950
|
}
|
|
879
951
|
|
|
880
952
|
export function saveExtensionStatusColorMode(
|
|
@@ -882,43 +954,41 @@ export function saveExtensionStatusColorMode(
|
|
|
882
954
|
colorMode: ExtensionStatusColorMode,
|
|
883
955
|
path = configPath,
|
|
884
956
|
): PolishedTuiConfig {
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
+
};
|
|
898
976
|
});
|
|
899
|
-
|
|
900
|
-
record.extensionStatuses = {
|
|
901
|
-
...existingExtensionStatuses,
|
|
902
|
-
colorModes: existingColorModes,
|
|
903
|
-
};
|
|
904
|
-
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
905
|
-
return mergeConfig(record);
|
|
906
977
|
}
|
|
907
978
|
|
|
908
979
|
export function saveFixedEditorPatch(
|
|
909
980
|
patch: Partial<FixedEditorConfig>,
|
|
910
981
|
path = configPath,
|
|
911
982
|
): PolishedTuiConfig {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
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
|
+
});
|
|
924
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;
|