pi-zentui 0.29.0 → 0.29.2
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/docs/configuration.md +1 -1
- package/extensions/zentui/index.ts +96 -43
- package/extensions/zentui/layered-editor.ts +55 -0
- package/extensions/zentui/thinking-experimental.ts +18 -2
- package/extensions/zentui/user-message.ts +10 -3
- package/extensions/zentui/working-line.ts +29 -36
- package/package.json +1 -1
package/docs/configuration.md
CHANGED
|
@@ -776,7 +776,7 @@ Rail, Tree, and Streaming share one private `AssistantMessageComponent` wrapper.
|
|
|
776
776
|
|
|
777
777
|
Rail parses each native contiguous thinking run and shows every label in that run. Tree shows the latest five in each run; neither aggregates across intervening text or tool blocks. Both follow Pi's thinking visibility. Labels come from headings, top-level list items, and blank-line-separated prose. Complete strict 7-bit CSI SGR styling is stripped before parsing; every other terminal control, unsafe or unstructured content, malformed or over-limit input, unterminated fences/math, and unsupported structure leave that complete run native. Fenced code, Mermaid, display math, and indented nested content remain opaque bodies.
|
|
778
778
|
|
|
779
|
-
Each selected SGR-free label is rendered as Markdown by a fresh Pi `Markdown` with the host child's exact theme, default `thinkingText`/italic style, and transform options. Native emphasis, code, links, HTML, LaTeX, and custom theme/transform callbacks therefore remain authoritative. Host horizontal padding is applied externally. Each label is exactly one terminal row; Pi TUI's ANSI/OSC/grapheme-aware width utilities crop the first rendered row and reserve one cell for `…` only when required. Empty, image, non-text, impossible-width, or throwing output restores the whole native run. Connectors are separate from Markdown and call the current `theme.fg("accent", connector)`
|
|
779
|
+
Each selected SGR-free label is rendered as Markdown by a fresh Pi `Markdown` with the host child's exact theme, default `thinkingText`/italic style, and transform options. Native emphasis, code, links, HTML, LaTeX, and custom theme/transform callbacks therefore remain authoritative. Host horizontal padding is applied externally. Each label is exactly one terminal row; Pi TUI's ANSI/OSC/grapheme-aware width utilities crop the first rendered row and reserve one cell for `…` only when required. Empty, image, non-text, impossible-width, or throwing output restores the whole native run. Connectors are separate from Markdown and call the current `theme.fg("accent", connector)` when building a layout, so custom themes directly control their appearance. Successful layouts are reused while width and connector-theme identity are unchanged; a width or theme identity change or invalidation refreshes them. Same-object theme mutations require invalidation. Visible forms are:
|
|
780
780
|
|
|
781
781
|
```text
|
|
782
782
|
│ Thinking ┆ Thinking
|
|
@@ -81,6 +81,7 @@ import {
|
|
|
81
81
|
renderTurnSummaryEntry,
|
|
82
82
|
TURN_SUMMARY_ENTRY_TYPE,
|
|
83
83
|
} from "./interaction-summary";
|
|
84
|
+
import { LayeredEditorConsumer } from "./layered-editor";
|
|
84
85
|
import { LiveContextController } from "./live-context";
|
|
85
86
|
import { readPackageVersionResult } from "./package-version";
|
|
86
87
|
import { getComponentPreset } from "./presets";
|
|
@@ -277,7 +278,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
277
278
|
let stopSessionTimer: () => void = () => {};
|
|
278
279
|
let stopMinimalistDurationUpdates: () => void = () => {};
|
|
279
280
|
let minimalistDurationUpdatesActive = false;
|
|
280
|
-
let
|
|
281
|
+
let minimalistDecorationEditor: PolishedEditor | WrappedPolishedEditor | undefined;
|
|
281
282
|
let customVariableSessionReady = false;
|
|
282
283
|
let sessionTimerRequirements = "";
|
|
283
284
|
let lastDurationLabel = "";
|
|
@@ -324,14 +325,41 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
324
325
|
}
|
|
325
326
|
};
|
|
326
327
|
|
|
328
|
+
const layeredEditor = new LayeredEditorConsumer<
|
|
329
|
+
EditorFactory,
|
|
330
|
+
PolishedEditor | WrappedPolishedEditor
|
|
331
|
+
>((generation) => sessionLifecycle.isCurrent(generation));
|
|
332
|
+
const currentEditorFactory = (): EditorFactory | undefined => {
|
|
333
|
+
try {
|
|
334
|
+
return activeTuiContext?.ui.getEditorComponent();
|
|
335
|
+
} catch {
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
// Layered visibility is read-only recognition; installation ownership above stays exclusive.
|
|
340
|
+
// Data, demand, and timer consumers read the visible editor through these two helpers; anything
|
|
341
|
+
// that installs, restores, or mutates host UI must keep using ownsInstalledEditorFactory().
|
|
342
|
+
// Ownership counts before Pi constructs its editor; layered visibility requires the recorded
|
|
343
|
+
// instance to remain reachable through the current factory.
|
|
344
|
+
const layeredVisibleEditor = () => layeredEditor.editorVisibleThrough(currentEditorFactory());
|
|
345
|
+
const editorConsumerActive = () =>
|
|
346
|
+
ownsInstalledEditorFactory() || layeredVisibleEditor() !== undefined;
|
|
347
|
+
const visibleEditor = () =>
|
|
348
|
+
ownsInstalledEditorFactory() ? activeEditor?.editor : layeredVisibleEditor();
|
|
349
|
+
const requestEditorRepaint = () => {
|
|
350
|
+
requestEditorRender?.();
|
|
351
|
+
// The layered record intentionally outlives ownership clearing, so disabling the editor is
|
|
352
|
+
// gated here; a live wrapper keeps repainting while the editor remains enabled.
|
|
353
|
+
if (effectiveEditorEnabled()) layeredEditor.requestRender(currentEditorFactory());
|
|
354
|
+
};
|
|
355
|
+
|
|
327
356
|
const customVariables = new CustomVariables(
|
|
328
357
|
pi.events,
|
|
329
358
|
(key) => {
|
|
330
359
|
if (!customVariableSessionReady || !sessionLifecycle.isCurrent()) return false;
|
|
331
360
|
return (
|
|
332
361
|
(effectiveEditorEnabled() &&
|
|
333
|
-
|
|
334
|
-
activeEditor?.editor.isMetadataDecorated() === true &&
|
|
362
|
+
visibleEditor()?.isMetadataDecorated() === true &&
|
|
335
363
|
editorDemandsCustomVariable(currentConfig, key)) ||
|
|
336
364
|
(installedFooterKind === "starship" &&
|
|
337
365
|
ownsInstalledFooter() &&
|
|
@@ -340,7 +368,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
340
368
|
},
|
|
341
369
|
() => {
|
|
342
370
|
if (!customVariableSessionReady || !sessionLifecycle.isCurrent()) return;
|
|
343
|
-
|
|
371
|
+
requestEditorRepaint();
|
|
344
372
|
requestFooterRender?.();
|
|
345
373
|
},
|
|
346
374
|
);
|
|
@@ -349,7 +377,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
349
377
|
customVariables.reconcile();
|
|
350
378
|
codexQuota.reconcile();
|
|
351
379
|
requestFooterRender?.();
|
|
352
|
-
|
|
380
|
+
requestEditorRepaint();
|
|
353
381
|
};
|
|
354
382
|
const thinkingExperimental = new ThinkingExperimentalController(
|
|
355
383
|
() => currentConfig.components.thinkingSteps,
|
|
@@ -391,7 +419,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
391
419
|
if (!host.skinOnly)
|
|
392
420
|
workingLine.setRequestRender(() => {
|
|
393
421
|
if (sessionLifecycle.isCurrent()) {
|
|
394
|
-
|
|
422
|
+
requestEditorRepaint();
|
|
395
423
|
}
|
|
396
424
|
});
|
|
397
425
|
let workingLineSessionReady = false;
|
|
@@ -468,7 +496,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
468
496
|
return usageTotals.resolve(
|
|
469
497
|
ctx,
|
|
470
498
|
(effectiveEditorEnabled() &&
|
|
471
|
-
|
|
499
|
+
editorConsumerActive() &&
|
|
472
500
|
["tokens", "input_tokens", "output_tokens", "cost", "cache_hit"].some((name) =>
|
|
473
501
|
editorMetadataReferences(currentConfig).has(name),
|
|
474
502
|
)) ||
|
|
@@ -504,9 +532,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
504
532
|
)
|
|
505
533
|
return undefined;
|
|
506
534
|
const editorDemand =
|
|
507
|
-
effectiveEditorEnabled() &&
|
|
508
|
-
ownsInstalledEditorFactory() &&
|
|
509
|
-
editorWantsCodexQuota(currentConfig);
|
|
535
|
+
effectiveEditorEnabled() && editorConsumerActive() && editorWantsCodexQuota(currentConfig);
|
|
510
536
|
const footerDemand =
|
|
511
537
|
effectiveFooterStyle() === "starship" &&
|
|
512
538
|
currentConfig.components.footer.codexQuota &&
|
|
@@ -516,7 +542,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
516
542
|
() => {
|
|
517
543
|
if (!sessionLifecycle.isCurrent()) return;
|
|
518
544
|
requestFooterRender?.();
|
|
519
|
-
|
|
545
|
+
requestEditorRepaint();
|
|
520
546
|
},
|
|
521
547
|
);
|
|
522
548
|
const getEditorQuota = () =>
|
|
@@ -598,7 +624,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
598
624
|
currentConfig,
|
|
599
625
|
installedFooterReferences(),
|
|
600
626
|
effectiveEditorEnabled() &&
|
|
601
|
-
|
|
627
|
+
editorConsumerActive() &&
|
|
602
628
|
currentConfig.components.editor.style === "minimalist",
|
|
603
629
|
);
|
|
604
630
|
|
|
@@ -647,6 +673,8 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
647
673
|
if (!sessionLifecycle.isCurrent() || !ctx.hasUI) return;
|
|
648
674
|
if (activeTuiContext?.ui === ctx.ui) activeTuiContext = ctx;
|
|
649
675
|
if (editorInstalled && !ownsInstalledEditorFactory()) reconcileObservedEditorOwnership(ctx);
|
|
676
|
+
// A layered editor can stop being visible without any ownership transition to observe.
|
|
677
|
+
if (projectRefreshActive && !needsProjectRefresh()) stopProjectRefresh();
|
|
650
678
|
syncFooterState(ctx);
|
|
651
679
|
if (project && needsProjectRefresh()) scheduleProjectRefresh(ctx);
|
|
652
680
|
refresh();
|
|
@@ -698,9 +726,8 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
698
726
|
sessionLifecycle.isCurrent() &&
|
|
699
727
|
agentRunActive &&
|
|
700
728
|
agentDurationClock.isActive() &&
|
|
701
|
-
minimalistDecorationActive &&
|
|
729
|
+
minimalistDecorationActive() &&
|
|
702
730
|
effectiveEditorEnabled() &&
|
|
703
|
-
ownsInstalledEditorFactory() &&
|
|
704
731
|
currentConfig.components.editor.style === "minimalist" &&
|
|
705
732
|
editorMetadataReferences(currentConfig).has("turn_duration");
|
|
706
733
|
if (!needed) {
|
|
@@ -721,13 +748,25 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
721
748
|
});
|
|
722
749
|
};
|
|
723
750
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
751
|
+
// Decoration is bound to the editor instance that reported it, so it stays valid while that
|
|
752
|
+
// editor is visible (owned or layered) and cannot leak to a replacement editor.
|
|
753
|
+
const minimalistDecorationActive = () =>
|
|
754
|
+
sessionLifecycle.isCurrent() &&
|
|
755
|
+
minimalistDecorationEditor !== undefined &&
|
|
756
|
+
visibleEditor() === minimalistDecorationEditor;
|
|
757
|
+
const reconcileMinimalistDecoration = () => {
|
|
728
758
|
customVariables.reconcile();
|
|
729
759
|
reconcileAgentTimer();
|
|
730
760
|
};
|
|
761
|
+
const setMinimalistDecorationActive = (
|
|
762
|
+
active: boolean,
|
|
763
|
+
editor?: PolishedEditor | WrappedPolishedEditor,
|
|
764
|
+
) => {
|
|
765
|
+
const next = active ? editor : undefined;
|
|
766
|
+
if (minimalistDecorationEditor === next) return;
|
|
767
|
+
minimalistDecorationEditor = next;
|
|
768
|
+
reconcileMinimalistDecoration();
|
|
769
|
+
};
|
|
731
770
|
|
|
732
771
|
const startAgentTurn = (interactionStarted: boolean) => {
|
|
733
772
|
agentRunActive = true;
|
|
@@ -852,12 +891,12 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
852
891
|
if (activeTuiContext && workingLineSessionReady) {
|
|
853
892
|
if (!host.skinOnly) workingLine.reconcile(activeTuiContext);
|
|
854
893
|
}
|
|
855
|
-
setMinimalistDecorationActive(false);
|
|
856
894
|
requestEditorRender = undefined;
|
|
857
895
|
wrappedEditorFactory = undefined;
|
|
858
896
|
installedEditorFactory = undefined;
|
|
859
897
|
editorInstallMode = "none";
|
|
860
898
|
editorInstalled = false;
|
|
899
|
+
reconcileMinimalistDecoration();
|
|
861
900
|
codexQuota.reconcile();
|
|
862
901
|
};
|
|
863
902
|
|
|
@@ -900,8 +939,12 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
900
939
|
return observed;
|
|
901
940
|
};
|
|
902
941
|
|
|
942
|
+
// Exclusive: gates mutation of host UI such as the Working row.
|
|
903
943
|
const isActiveEditor = (editor: PolishedEditor | WrappedPolishedEditor, generation: number) =>
|
|
904
944
|
sessionLifecycle.isCurrent(generation) && activeEditor?.editor === editor;
|
|
945
|
+
// Read-only: also true for an editor still visible beneath a foreign wrapper.
|
|
946
|
+
const isVisibleEditor = (editor: PolishedEditor | WrappedPolishedEditor, generation: number) =>
|
|
947
|
+
sessionLifecycle.isCurrent(generation) && visibleEditor() === editor;
|
|
905
948
|
|
|
906
949
|
const editorBorderCapabilityChanged = (
|
|
907
950
|
editor: PolishedEditor | WrappedPolishedEditor,
|
|
@@ -910,14 +953,14 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
910
953
|
if (!isActiveEditor(editor, generation) || !activeTuiContext || !workingLineSessionReady)
|
|
911
954
|
return;
|
|
912
955
|
if (!host.skinOnly) workingLine.reconcile(activeTuiContext);
|
|
913
|
-
if (workingLine.isAvailable())
|
|
956
|
+
if (workingLine.isAvailable()) requestEditorRepaint();
|
|
914
957
|
};
|
|
915
958
|
|
|
916
959
|
const editorMetadataDecorationChanged = (
|
|
917
960
|
editor: PolishedEditor | WrappedPolishedEditor,
|
|
918
961
|
generation: number,
|
|
919
962
|
) => {
|
|
920
|
-
if (
|
|
963
|
+
if (isVisibleEditor(editor, generation)) customVariables.reconcile();
|
|
921
964
|
};
|
|
922
965
|
|
|
923
966
|
const editorWorkingLineFrame = (
|
|
@@ -928,6 +971,31 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
928
971
|
? workingLine.currentWorkingLineFrame()
|
|
929
972
|
: undefined;
|
|
930
973
|
|
|
974
|
+
/** Records a freshly constructed editor as owned (exclusive) or visible beneath a foreign wrapper. */
|
|
975
|
+
const registerConstructedEditor = (
|
|
976
|
+
ctx: ExtensionContext,
|
|
977
|
+
factory: ZentuiEditorFactory,
|
|
978
|
+
generation: number,
|
|
979
|
+
editor: PolishedEditor | WrappedPolishedEditor,
|
|
980
|
+
tui: TUI,
|
|
981
|
+
) => {
|
|
982
|
+
const observed = observeEditorFactory(ctx);
|
|
983
|
+
if (
|
|
984
|
+
!sessionLifecycle.isCurrent(generation) ||
|
|
985
|
+
activeTuiContext?.ui !== ctx.ui ||
|
|
986
|
+
!observed.known
|
|
987
|
+
) {
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (observed.factory === factory) {
|
|
991
|
+
activeEditor = { editor, factory, generation };
|
|
992
|
+
requestEditorRender = () => tui.requestRender();
|
|
993
|
+
} else if (observed.factory && !isZentuiEditorFactory(observed.factory)) {
|
|
994
|
+
// A foreign wrapper built this editor beneath its own outer factory.
|
|
995
|
+
layeredEditor.attach(observed.factory, generation, editor, () => tui.requestRender());
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
|
|
931
999
|
const makeEditorFactory = (
|
|
932
1000
|
ctx: ExtensionContext,
|
|
933
1001
|
baseFactory: EditorFactory | undefined,
|
|
@@ -977,21 +1045,12 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
977
1045
|
};
|
|
978
1046
|
},
|
|
979
1047
|
(active) => {
|
|
980
|
-
if (
|
|
1048
|
+
if (isVisibleEditor(editor, generation)) setMinimalistDecorationActive(active, editor);
|
|
981
1049
|
},
|
|
982
1050
|
() => editorBorderCapabilityChanged(editor, generation),
|
|
983
1051
|
() => editorMetadataDecorationChanged(editor, generation),
|
|
984
1052
|
);
|
|
985
|
-
|
|
986
|
-
if (
|
|
987
|
-
sessionLifecycle.isCurrent(generation) &&
|
|
988
|
-
activeTuiContext?.ui === ctx.ui &&
|
|
989
|
-
observed.known &&
|
|
990
|
-
observed.factory === factory
|
|
991
|
-
) {
|
|
992
|
-
activeEditor = { editor, factory, generation };
|
|
993
|
-
requestEditorRender = () => tui.requestRender();
|
|
994
|
-
}
|
|
1053
|
+
registerConstructedEditor(ctx, factory, generation, editor, tui);
|
|
995
1054
|
return editor;
|
|
996
1055
|
}) as ZentuiEditorFactory;
|
|
997
1056
|
factory[ZENTUI_EDITOR_FACTORY] = true;
|
|
@@ -1048,21 +1107,12 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
1048
1107
|
};
|
|
1049
1108
|
},
|
|
1050
1109
|
(active) => {
|
|
1051
|
-
if (
|
|
1110
|
+
if (isVisibleEditor(editor, generation)) setMinimalistDecorationActive(active, editor);
|
|
1052
1111
|
},
|
|
1053
1112
|
() => editorBorderCapabilityChanged(editor, generation),
|
|
1054
1113
|
() => editorMetadataDecorationChanged(editor, generation),
|
|
1055
1114
|
);
|
|
1056
|
-
|
|
1057
|
-
if (
|
|
1058
|
-
sessionLifecycle.isCurrent(generation) &&
|
|
1059
|
-
activeTuiContext?.ui === ctx.ui &&
|
|
1060
|
-
observed.known &&
|
|
1061
|
-
observed.factory === factory
|
|
1062
|
-
) {
|
|
1063
|
-
activeEditor = { editor, factory, generation };
|
|
1064
|
-
requestEditorRender = () => tui.requestRender();
|
|
1065
|
-
}
|
|
1115
|
+
registerConstructedEditor(ctx, factory, generation, editor, tui);
|
|
1066
1116
|
return editor;
|
|
1067
1117
|
}) as ZentuiEditorFactory;
|
|
1068
1118
|
factory[ZENTUI_EDITOR_FACTORY] = true;
|
|
@@ -1415,6 +1465,8 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
1415
1465
|
if (!ctx || !sessionLifecycle.isCurrent()) return;
|
|
1416
1466
|
sessionLifecycle.shutdown();
|
|
1417
1467
|
activeEditor = undefined;
|
|
1468
|
+
minimalistDecorationEditor = undefined;
|
|
1469
|
+
layeredEditor.detach();
|
|
1418
1470
|
extensionStatuses.dispose();
|
|
1419
1471
|
codexQuota.stop();
|
|
1420
1472
|
stopSessionTimer();
|
|
@@ -1474,6 +1526,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
|
|
|
1474
1526
|
customVariables.clear();
|
|
1475
1527
|
const lifecycleGeneration = sessionLifecycle.start();
|
|
1476
1528
|
activeEditor = undefined;
|
|
1529
|
+
minimalistDecorationEditor = undefined;
|
|
1477
1530
|
// A new generation must not expose or route extension segments through the previous
|
|
1478
1531
|
// session while TUI startup is in progress.
|
|
1479
1532
|
workingLineSessionReady = false;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records that a Zentui editor instance is still visible beneath a foreign predecessor-preserving
|
|
3
|
+
* wrapper (`setEditorComponent((tui, theme, kb) => predecessor(tui, theme, kb))`).
|
|
4
|
+
*
|
|
5
|
+
* This is deliberately NOT installation ownership: Zentui does not own the outer factory, must not
|
|
6
|
+
* replace or restore it, and keeps this record across ownership clearing. It only answers
|
|
7
|
+
* "may a surface Zentui rendered still be on screen?" for read-only consumers such as usage demand
|
|
8
|
+
* and redraw requests. The record is valid only while the outer factory observed at construction is
|
|
9
|
+
* still the current editor factory, so any replacement of the chain invalidates it.
|
|
10
|
+
*
|
|
11
|
+
* Visibility is only evidence that a wrapper constructed the editor, not that its rows reach the
|
|
12
|
+
* screen, so surfaces that mutate host UI (for example hiding the native Working row) must keep
|
|
13
|
+
* requiring exclusive installation ownership instead of consulting this record.
|
|
14
|
+
*/
|
|
15
|
+
export class LayeredEditorConsumer<Factory, Editor = unknown> {
|
|
16
|
+
private record?: {
|
|
17
|
+
outerFactory: Factory;
|
|
18
|
+
generation: number;
|
|
19
|
+
editor: Editor;
|
|
20
|
+
requestRender: () => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
constructor(private readonly isCurrentGeneration: (generation: number) => boolean) {}
|
|
24
|
+
|
|
25
|
+
attach(
|
|
26
|
+
outerFactory: Factory,
|
|
27
|
+
generation: number,
|
|
28
|
+
editor: Editor,
|
|
29
|
+
requestRender: () => void,
|
|
30
|
+
): void {
|
|
31
|
+
this.record = { outerFactory, generation, editor, requestRender };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
detach(): void {
|
|
35
|
+
this.record = undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
editorVisibleThrough(currentFactory: Factory | undefined): Editor | undefined {
|
|
39
|
+
const record = this.record;
|
|
40
|
+
if (!record) return undefined;
|
|
41
|
+
if (!this.isCurrentGeneration(record.generation) || record.outerFactory !== currentFactory) {
|
|
42
|
+
this.record = undefined;
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return record.editor;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
isVisibleThrough(currentFactory: Factory | undefined): boolean {
|
|
49
|
+
return this.editorVisibleThrough(currentFactory) !== undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
requestRender(currentFactory: Factory | undefined): void {
|
|
53
|
+
if (this.isVisibleThrough(currentFactory)) this.record?.requestRender();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -218,6 +218,8 @@ function croppedMarkdownRow(markdown: Markdown, source: string, width: number):
|
|
|
218
218
|
/** A width-bounded private replacement for one native contiguous thinking run. */
|
|
219
219
|
export class ThinkingStepsRows implements Component {
|
|
220
220
|
private readonly title: Markdown;
|
|
221
|
+
// Content and presentation are constructor-fixed; retain only the last successful layout.
|
|
222
|
+
private renderedCache: { width: number; theme: AccentTheme; rows: string[] } | undefined;
|
|
221
223
|
private readonly labels: Array<{ source: string; markdown: Markdown }>;
|
|
222
224
|
|
|
223
225
|
constructor(
|
|
@@ -246,12 +248,23 @@ export class ThinkingStepsRows implements Component {
|
|
|
246
248
|
}
|
|
247
249
|
|
|
248
250
|
private renderNative(width: number): string[] {
|
|
251
|
+
this.renderedCache = undefined;
|
|
249
252
|
this.onPresentation?.(false);
|
|
250
253
|
return this.native.render(width);
|
|
251
254
|
}
|
|
252
255
|
|
|
253
256
|
render(width: number): string[] {
|
|
254
257
|
try {
|
|
258
|
+
let theme: AccentTheme | undefined;
|
|
259
|
+
const cached = this.renderedCache;
|
|
260
|
+
if (cached?.width === width) {
|
|
261
|
+
// The host theme getter is a read-only lookup, including on cache hits.
|
|
262
|
+
theme = this.getTheme();
|
|
263
|
+
if (cached.theme === theme) {
|
|
264
|
+
this.onPresentation?.(true);
|
|
265
|
+
return [...cached.rows];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
255
268
|
const outer = this.shape.paddingX;
|
|
256
269
|
const innerWidth = Math.floor(width) - outer * 2;
|
|
257
270
|
const titleConnector = this.mode === "rail" ? "│ " : "┆ ";
|
|
@@ -276,7 +289,7 @@ export class ThinkingStepsRows implements Component {
|
|
|
276
289
|
if (!label) return this.renderNative(width);
|
|
277
290
|
renderedLabels.push({ connector, label });
|
|
278
291
|
}
|
|
279
|
-
|
|
292
|
+
theme ??= this.getTheme();
|
|
280
293
|
const left = " ".repeat(outer);
|
|
281
294
|
const right = " ".repeat(outer);
|
|
282
295
|
const rows = [
|
|
@@ -286,14 +299,17 @@ export class ThinkingStepsRows implements Component {
|
|
|
286
299
|
),
|
|
287
300
|
];
|
|
288
301
|
if (!rows.every((row) => visibleWidth(row) <= width)) return this.renderNative(width);
|
|
302
|
+
this.renderedCache = { width, theme, rows };
|
|
289
303
|
this.onPresentation?.(true);
|
|
290
|
-
|
|
304
|
+
// Containers and decorators may mutate their returned array.
|
|
305
|
+
return [...rows];
|
|
291
306
|
} catch {
|
|
292
307
|
return this.renderNative(width);
|
|
293
308
|
}
|
|
294
309
|
}
|
|
295
310
|
|
|
296
311
|
invalidate(): void {
|
|
312
|
+
this.renderedCache = undefined;
|
|
297
313
|
this.onInvalidate?.();
|
|
298
314
|
this.native.invalidate();
|
|
299
315
|
this.title.invalidate();
|
|
@@ -54,7 +54,7 @@ function isNativeMarkdown(renderer: unknown): boolean {
|
|
|
54
54
|
|
|
55
55
|
const sanitizedMarkdownCache = new WeakMap<
|
|
56
56
|
object,
|
|
57
|
-
{ lines: unknown; text: string; options: unknown }
|
|
57
|
+
{ lines: unknown; sourceText: string; text: string; options: unknown }
|
|
58
58
|
>();
|
|
59
59
|
|
|
60
60
|
// Framing eligibility is deliberately narrower than the source trust boundary.
|
|
@@ -74,7 +74,14 @@ function withSanitizedMarkdownSources<T>(instance: unknown, render: (adapted: bo
|
|
|
74
74
|
if (!isRecord(options.value)) return;
|
|
75
75
|
const transform = options.value.transform;
|
|
76
76
|
if (transform !== undefined && typeof transform !== "function") return;
|
|
77
|
-
const
|
|
77
|
+
const cached = sanitizedMarkdownCache.get(value);
|
|
78
|
+
// Source sanitization depends only on the immutable raw string, not on
|
|
79
|
+
// width, theme, options or native output invalidation. Reuse just that
|
|
80
|
+
// result; the guarded native render and its cache checks still run.
|
|
81
|
+
const source =
|
|
82
|
+
cached && cached.sourceText === text.value
|
|
83
|
+
? cached.text
|
|
84
|
+
: sanitizeUserMessageSourceText(text.value);
|
|
78
85
|
const safeOptions = {
|
|
79
86
|
...options.value,
|
|
80
87
|
...(transform
|
|
@@ -86,7 +93,6 @@ function withSanitizedMarkdownSources<T>(instance: unknown, render: (adapted: bo
|
|
|
86
93
|
}
|
|
87
94
|
: {}),
|
|
88
95
|
};
|
|
89
|
-
const cached = sanitizedMarkdownCache.get(value);
|
|
90
96
|
restore.push(() => {
|
|
91
97
|
Object.defineProperty(value, "text", text);
|
|
92
98
|
Object.defineProperty(value, "options", options);
|
|
@@ -95,6 +101,7 @@ function withSanitizedMarkdownSources<T>(instance: unknown, render: (adapted: bo
|
|
|
95
101
|
// them normally, and cleanup needs no retained component references.
|
|
96
102
|
sanitizedMarkdownCache.set(value, {
|
|
97
103
|
lines: value.cachedText === source ? value.cachedLines : undefined,
|
|
104
|
+
sourceText: text.value,
|
|
98
105
|
text: source,
|
|
99
106
|
options: options.value,
|
|
100
107
|
});
|
|
@@ -434,20 +434,26 @@ export function normalizeWorkingLineStyleSpec(value: ColorSpec | undefined): Col
|
|
|
434
434
|
return tokens.join(" ");
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
-
function
|
|
437
|
+
function workingLineTierRenderer(
|
|
438
438
|
theme: ThemeLike,
|
|
439
439
|
config: WorkingLineComponentConfig,
|
|
440
440
|
colors: PolishedTuiColors,
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
441
|
+
): (tier: Tier, text: string) => string {
|
|
442
|
+
// Palette validation is invariant within this build, but must not survive a
|
|
443
|
+
// metric/config/theme rebuild. Resolve lazily so unused tiers remain untouched.
|
|
444
|
+
const tierStyles = new Map<Tier, ColorSpec | undefined>();
|
|
445
|
+
return (tier, text) => {
|
|
446
|
+
if (!tierStyles.has(tier)) {
|
|
447
|
+
tierStyles.set(tier, normalizeWorkingLineStyleSpec(workingLineColor(config, colors, tier)));
|
|
448
|
+
}
|
|
449
|
+
return renderStyleForSourceOrFallback(
|
|
450
|
+
theme,
|
|
451
|
+
config.colorSource,
|
|
452
|
+
tierStyles.get(tier),
|
|
453
|
+
WORKING_LINE_FALLBACKS[tier],
|
|
454
|
+
text,
|
|
455
|
+
);
|
|
456
|
+
};
|
|
451
457
|
}
|
|
452
458
|
|
|
453
459
|
/** Resolve the fixed, nonanimated high-tier style used by persisted Turn summaries. */
|
|
@@ -484,9 +490,7 @@ export function snapshotWorkingLineHighStyle(
|
|
|
484
490
|
}
|
|
485
491
|
|
|
486
492
|
function renderAnimatedText(
|
|
487
|
-
|
|
488
|
-
config: WorkingLineComponentConfig,
|
|
489
|
-
colors: PolishedTuiColors,
|
|
493
|
+
render: (tier: Tier, text: string) => string,
|
|
490
494
|
cells: GraphemeCell[],
|
|
491
495
|
width: number,
|
|
492
496
|
tick: number,
|
|
@@ -504,7 +508,7 @@ function renderAnimatedText(
|
|
|
504
508
|
if (previous?.tier === tier) previous.text += cell.text;
|
|
505
509
|
else runs.push({ tier, text: cell.text });
|
|
506
510
|
}
|
|
507
|
-
return runs.map((run) =>
|
|
511
|
+
return runs.map((run) => render(run.tier, run.text)).join("");
|
|
508
512
|
}
|
|
509
513
|
|
|
510
514
|
export type WorkingLineRuntimeSegments = {
|
|
@@ -813,8 +817,7 @@ function spinnerRowCells(
|
|
|
813
817
|
function renderWorkingLineSchedule(
|
|
814
818
|
definition: ScheduleDefinition,
|
|
815
819
|
config: WorkingLineComponentConfig,
|
|
816
|
-
|
|
817
|
-
theme: ThemeLike,
|
|
820
|
+
render: (tier: Tier, text: string) => string,
|
|
818
821
|
rowCells: GraphemeCell[],
|
|
819
822
|
spinnerCells: Map<string, GraphemeCell[]> | undefined,
|
|
820
823
|
width: number,
|
|
@@ -834,15 +837,7 @@ function renderWorkingLineSchedule(
|
|
|
834
837
|
const renderTextForTick = (tick: number): string => {
|
|
835
838
|
let cachedText = textRenderCache.get(tick);
|
|
836
839
|
if (cachedText === undefined) {
|
|
837
|
-
cachedText = renderAnimatedText(
|
|
838
|
-
theme,
|
|
839
|
-
config,
|
|
840
|
-
colors,
|
|
841
|
-
rowCells,
|
|
842
|
-
width,
|
|
843
|
-
tick,
|
|
844
|
-
config.textAnimation,
|
|
845
|
-
);
|
|
840
|
+
cachedText = renderAnimatedText(render, rowCells, width, tick, config.textAnimation);
|
|
846
841
|
textRenderCache.set(tick, cachedText);
|
|
847
842
|
}
|
|
848
843
|
return cachedText;
|
|
@@ -856,7 +851,7 @@ function renderWorkingLineSchedule(
|
|
|
856
851
|
let cachedSpinner = spinnerRenderCache.get(index);
|
|
857
852
|
if (cachedSpinner === undefined) {
|
|
858
853
|
const glyph = spinner.frames[index] ?? spinner.frames[0];
|
|
859
|
-
cachedSpinner =
|
|
854
|
+
cachedSpinner = render("high", glyph);
|
|
860
855
|
spinnerRenderCache.set(index, cachedSpinner);
|
|
861
856
|
}
|
|
862
857
|
return cachedSpinner;
|
|
@@ -874,9 +869,7 @@ function renderWorkingLineSchedule(
|
|
|
874
869
|
spinner.frames[state.spinnerTick % spinner.frames.length] ?? spinner.frames[0];
|
|
875
870
|
const frame = config.animateSpinnerColor
|
|
876
871
|
? `${renderAnimatedText(
|
|
877
|
-
|
|
878
|
-
config,
|
|
879
|
-
colors,
|
|
872
|
+
render,
|
|
880
873
|
spinnerCells?.get(spinnerGlyph) ?? rowCells,
|
|
881
874
|
width,
|
|
882
875
|
state.textTick,
|
|
@@ -934,6 +927,7 @@ function buildPreparedWorkingLineFrames(
|
|
|
934
927
|
const textOrigin = config.animateSpinnerColor ? 0 : spinnerWidth + 1;
|
|
935
928
|
const textCycle = textPeriod(config.textAnimation, animatedTextWidth);
|
|
936
929
|
const textPhase = normalizedPhaseTick(textStartTick, textCycle);
|
|
930
|
+
const render = workingLineTierRenderer(theme, config, colors);
|
|
937
931
|
|
|
938
932
|
if (config.textAnimation === "disabled") {
|
|
939
933
|
const frameStates = Array.from({ length: spinner.frames.length }, (_, index) => ({
|
|
@@ -943,7 +937,7 @@ function buildPreparedWorkingLineFrames(
|
|
|
943
937
|
let codeUnits = 0;
|
|
944
938
|
const frames = frameStates.map((state) => {
|
|
945
939
|
const glyph = spinner.frames[state.spinnerTick % spinner.frames.length] ?? spinner.frames[0];
|
|
946
|
-
const frame = `${
|
|
940
|
+
const frame = `${render("mid", `${glyph} ${composed.row}`)}${SGR_RESET}`;
|
|
947
941
|
codeUnits += frame.length;
|
|
948
942
|
if (codeUnits > MAX_WORKING_LINE_FRAME_CODE_UNITS)
|
|
949
943
|
throw new Error("Working-line animation exceeds its memory cap");
|
|
@@ -985,8 +979,7 @@ function buildPreparedWorkingLineFrames(
|
|
|
985
979
|
const rendered = renderWorkingLineSchedule(
|
|
986
980
|
exact,
|
|
987
981
|
config,
|
|
988
|
-
|
|
989
|
-
theme,
|
|
982
|
+
render,
|
|
990
983
|
rowCells,
|
|
991
984
|
spinnerCells,
|
|
992
985
|
animatedTextWidth,
|
|
@@ -1029,8 +1022,7 @@ function buildPreparedWorkingLineFrames(
|
|
|
1029
1022
|
const rendered = renderWorkingLineSchedule(
|
|
1030
1023
|
fallback,
|
|
1031
1024
|
config,
|
|
1032
|
-
|
|
1033
|
-
theme,
|
|
1025
|
+
render,
|
|
1034
1026
|
rowCells,
|
|
1035
1027
|
spinnerCells,
|
|
1036
1028
|
animatedTextWidth,
|
|
@@ -1064,6 +1056,7 @@ export function buildWorkingLineSpinnerFrames(
|
|
|
1064
1056
|
): { frames: string[]; frameStates: WorkingLineFrameState[]; intervalMs: number } {
|
|
1065
1057
|
const spinner = WORKING_LINE_SPINNERS[config.spinner];
|
|
1066
1058
|
workingLineSpinnerWidth(config.spinner);
|
|
1059
|
+
const render = workingLineTierRenderer(theme, config, colors);
|
|
1067
1060
|
const phase = Number.isFinite(startTick) ? Math.max(0, Math.floor(startTick)) : 0;
|
|
1068
1061
|
const frameStates = Array.from({ length: spinner.frames.length }, (_, index) => ({
|
|
1069
1062
|
spinnerTick: phase + index,
|
|
@@ -1071,7 +1064,7 @@ export function buildWorkingLineSpinnerFrames(
|
|
|
1071
1064
|
}));
|
|
1072
1065
|
const frames = frameStates.map((state) => {
|
|
1073
1066
|
const glyph = spinner.frames[state.spinnerTick % spinner.frames.length] ?? spinner.frames[0];
|
|
1074
|
-
return `${
|
|
1067
|
+
return `${render("high", glyph)}${SGR_RESET}`;
|
|
1075
1068
|
});
|
|
1076
1069
|
return { frames, frameStates, intervalMs: config.spinnerIntervalMs };
|
|
1077
1070
|
}
|
package/package.json
CHANGED