pi-zentui 0.22.0 → 0.22.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/README.md CHANGED
@@ -89,7 +89,7 @@ pi install git:github.com/lmilojevicc/pi-zentui
89
89
 
90
90
  ## Configure
91
91
 
92
- Run `/zentui` inside Pi to configure Appearance, Editor, User messages, Thinking (Experimental), Working line, Footer, Segments, Git, and Extensions. Use `Tab` and `Shift+Tab` to switch sections. Most changes apply live; Thinking (Experimental) changes are saved and require restarting Pi. Configuration is saved to:
92
+ Run `/zentui` inside Pi to configure Appearance, Editor, User messages, Thinking (Experimental), Working line, Footer, Segments, Git, and Extensions. Use `Tab` and `Shift+Tab` to switch sections. Most changes apply live. Active Streaming can switch live to Rail or Tree, and Rail and Tree can switch live between each other. Entering Streaming from a structural mode, first enable, and re-enable after a live disable require restarting Pi. Configuration is saved to:
93
93
 
94
94
  ```text
95
95
  ~/.pi/agent/zentui.json
@@ -144,13 +144,13 @@ Useful shortcuts:
144
144
  /zentui format clear
145
145
  ```
146
146
 
147
- **Thinking (Experimental)** uses one restart-gated private `AssistantMessageComponent` renderer for Rail, Tree, and Streaming, tested against exact Pi versions 0.80.5, 0.83.0, 0.84.0, and 0.84.4. It is disabled by default and may break after Pi updates. Every enable, disable, or mode change is saved but does not alter the active startup snapshot; restart Pi to apply it. Zentui installs an enabled startup snapshot before transcript restoration. Missing constructors, incompatible child layouts, parser limits, theme/render/width errors, or displaced patch ownership fail open to complete native thinking rather than switching modes.
147
+ **Thinking (Experimental)** uses one private `AssistantMessageComponent` renderer for Rail, Tree, and Streaming, tested against exact Pi versions 0.80.5, 0.82.1, 0.83.0, 0.84.0, and 0.84.4. It is disabled by default and may break after Pi updates. Zentui installs an enabled startup mode before transcript restoration. A healthy installed controller lets active Streaming switch live to Rail or Tree, and lets Rail and Tree switch live between each other, without reinstalling its patch. Entering Streaming from Rail or Tree saves the choice but keeps the active structural mode until restart. Disabling live restores native thinking and releases Streaming resources. First enable and re-enable after a live disable are also restart-gated. Mode changes while disabled only preconfigure the next enable. Startup failures, missing constructors, incompatible private child layouts, parser limits, theme/render/width errors, and displaced patch ownership fail open to complete native thinking. If cleanup throws while leaving Streaming, disabling still restores native thinking and a structural selection still becomes active; the successful change warns that Streaming is unavailable for the rest of the session.
148
148
 
149
- Rail shows every parsed label in each native contiguous thinking run (`│ Label`, with only the open final phase shown as `│ • Label`). Tree independently shows the latest five labels in each run (`├─ · Label`, settled `└─ · Label`, open `└─ • Label`); it never aggregates across intervening text or tool blocks. Labels are rendered by fresh host-shaped Pi Markdown instances before cropping, so emphasis, code, links, HTML, LaTeX, custom transforms, and native `thinkingText` styling remain host-controlled. Every label occupies one terminal row: ANSI/OSC/grapheme-aware cropping adds `…` only when needed. Native horizontal padding stays external. Connectors are styled directly with the current theme's `accent` callback on every render, so custom themes control them independently. Hidden native thinking remains hidden and keeps Pi's native hidden label.
149
+ Rail shows every parsed label in each native contiguous thinking run (`│ Label`, with only the open final phase shown as `│ • Label`). Tree independently shows the latest five labels in each run (`├─ · Label`, settled `└─ · Label`, open `└─ • Label`); it never aggregates across intervening text or tool blocks. Rail and Tree follow Pi's thinking visibility, and unsafe or unstructured content remains native. Labels are rendered by fresh host-shaped Pi Markdown instances before cropping, so emphasis, code, links, HTML, LaTeX, custom transforms, and native `thinkingText` styling remain host-controlled. Every label occupies one terminal row: ANSI/OSC/grapheme-aware cropping adds `…` only when needed. Native horizontal padding stays external. Connectors are styled directly with the current theme's `accent` callback on every render, so custom themes control them independently. Hidden native thinking remains hidden and keeps Pi's native hidden label.
150
150
 
151
- Streaming retains Pi's host-rendered final five rows under `Thinking 7.1s`, folds completed reasoning under `Thought` or current-session `Thought for 12.3s`, and owns the configured thinking-toggle binding (Ctrl+T by default) only for an active Streaming startup. Restored completions cannot recover a duration because Pi does not persist the thinking-end timestamp. Expand/refold and lifecycle tracking are bounded to 256 retained assistant components; evicted entries are first restored natively. All modes restore/dispose on shutdown. Thinking (Experimental) never writes the Working line and does not change its existing **Thinking time** option, working text, Footer, Editor, statuses, or model behavior.
151
+ Streaming retains Pi's host-rendered final five rows under `Thinking 7.1s`, folds completed reasoning under `Thought` or current-session `Thought for 12.3s`, and owns the configured thinking-toggle binding (Ctrl+T by default) only when started in Streaming. Its input listener and timer are acquired only for an enabled Streaming session start; startup acquisition failure uses native thinking and marks Streaming unavailable. Restored completions cannot recover a duration because Pi does not persist the thinking-end timestamp. Expand/refold and lifecycle tracking are bounded to 256 retained assistant components; evicted entries are first restored natively. All modes restore/dispose on shutdown. Thinking (Experimental) never writes the Working line and does not change its existing **Thinking time** option, working text, Footer, Editor, statuses, or model behavior.
152
152
 
153
- Pi 0.84 also provides a native fullscreen TUI with a sticky editor and Footer. Zentui does not enable it automatically; select fullscreen from Pi's `/settings`, set `"tuiMode": "fullscreen"` in Pi settings, or launch Pi with `--tui-mode fullscreen`.
153
+ Pi 0.84 also provides a native fullscreen TUI with a sticky editor and Footer. Pi 0.84.4 is covered by a fullscreen live-transition PTY smoke in addition to the standard matrix. Zentui does not enable fullscreen automatically; select it from Pi's `/settings`, set `"tuiMode": "fullscreen"` in Pi settings, or launch Pi with `--tui-mode fullscreen`.
154
154
 
155
155
  ## Requirements
156
156
 
@@ -192,8 +192,10 @@ See [CONTRIBUTING.md](https://github.com/lmilojevicc/pi-zentui/blob/main/CONTRIB
192
192
  - [Opencode](https://github.com/anomalyco/opencode) — inspiration for the Opencode editor treatment
193
193
  - [Oh My Pi (`omp`)](https://github.com/can1357/oh-my-pi) by [Can Bölük](https://github.com/can1357) — visual inspiration for the filled, single-left-rail Accent Rail editor
194
194
  - [Pi Custom Input](https://github.com/VinhLe1410/pi-custom-input) by [Vinh Le](https://github.com/VinhLe1410) — visual inspiration for Minimalist's framed, border-embedded session, model, context, Git, and path metadata
195
+ - [Pi Thinking Steps](https://github.com/crustyhacker/pi-thinking-steps) by Marc Mironescu / FluxGear — structural-step parsing and the Rail/Tree visual language; adapted in Thinking (Experimental) under the MIT License
196
+ - [Pi Thinking Fold](https://github.com/99percentpeople/pi-extensions/tree/master/extensions/thinking-fold) by [Zach Yuen](https://github.com/99percentpeople) — native rendered-row folding, timing, expand/refold behavior, and fail-open compatibility patterns; adapted in Thinking (Experimental) under the MIT License
195
197
 
196
- Zentui's implementations are independent; these credits acknowledge product and visual inspiration rather than source-code reuse.
198
+ Most Zentui implementations are independent; these credits acknowledge product and visual inspiration. Thinking (Experimental) also adapts MIT-licensed implementation work from Pi Thinking Steps and Pi Thinking Fold. Their complete copyright and permission notices are retained in the packaged [`thinking-experimental.ts`](./extensions/zentui/thinking-experimental.ts) source.
197
199
 
198
200
  ## License
199
201
 
@@ -63,6 +63,7 @@ import {
63
63
  SETTINGS_PREVIEW_MAX_WIDTH,
64
64
  } from "./settings-previews";
65
65
  import { EDITOR_BORDER_STYLE, renderChromeBorder, safeThemeFg } from "./style";
66
+ import { formatThinkingStatus, thinkingStatusLabels } from "./thinking-status";
66
67
  import {
67
68
  buildWorkingLinePreviewFrames,
68
69
  normalizeWorkingLineMessages,
@@ -140,7 +141,11 @@ const thinkingStepsModeLabels: Record<ThinkingStepsMode, string> = {
140
141
  tree: "Tree",
141
142
  streaming: "Streaming",
142
143
  };
143
- const thinkingStepsModeValues = Object.values(thinkingStepsModeLabels);
144
+ const thinkingStepsModeValues = (startupMode: ThinkingStepsMode): string[] => {
145
+ if (startupMode === "streaming") return ["Streaming", "Tree", "Rail"];
146
+ if (startupMode === "rail") return ["Rail", "Tree", "Streaming"];
147
+ return ["Tree", "Rail", "Streaming"];
148
+ };
144
149
 
145
150
  const settingsSections = [
146
151
  "appearance",
@@ -176,6 +181,9 @@ type SettingsOutcome =
176
181
 
177
182
  type ThinkingControllerState = Readonly<{
178
183
  available: boolean;
184
+ rendererAvailable?: boolean;
185
+ streamingAvailable?: boolean;
186
+ streamingPoisoned?: boolean;
179
187
  active: boolean;
180
188
  activeMode?: ThinkingStepsMode;
181
189
  startup: Readonly<ThinkingStepsComponentConfig>;
@@ -665,20 +673,8 @@ function buildThinkingStepsItems(
665
673
  ): SettingItem[] {
666
674
  const thinkingSteps = config.components.thinkingSteps;
667
675
  const controller = experimentalThinkingCapability(capability);
668
- const startupLabel = controller.startup.enabled
669
- ? `Active startup: ${thinkingStepsModeLabels[controller.startup.mode]}`
670
- : "Active startup: native thinking";
671
- const savedLabel = thinkingSteps.enabled
672
- ? `Saved: ${thinkingStepsModeLabels[thinkingSteps.mode]}`
673
- : "Saved: disabled";
674
- const status = !controller.available
675
- ? `${controller.reason ?? "Private renderer unavailable"}; using native thinking.`
676
- : controller.restartRequired
677
- ? `${savedLabel}; ${startupLabel}. Restart Pi to apply.`
678
- : controller.active
679
- ? `${startupLabel}.`
680
- : `${savedLabel}; using native thinking.`;
681
- const enabledDescription = `${status} Private renderer; every enable, disable, or mode change requires restart and may break after Pi updates.`;
676
+ const status = formatThinkingStatus(thinkingStatusLabels(thinkingSteps, controller));
677
+ const enabledDescription = `${status} Live switching supports Streaming → Rail/Tree and Rail ↔ Tree. Entering Streaming, first enable, and re-enable after live disable require restart. Private renderer may break after Pi updates.`;
682
678
  return [
683
679
  {
684
680
  id: "thinkingStepsEnabled",
@@ -690,9 +686,9 @@ function buildThinkingStepsItems(
690
686
  {
691
687
  id: "thinkingStepsMode",
692
688
  label: "Mode",
693
- description: `${status} Rail shows every parsed label; Tree shows the latest five per contiguous run; Streaming folds to the latest five host-rendered rows and owns the configured thinking toggle only when active. Incompatibility uses native thinking.`,
689
+ description: `${status} Rail shows every parsed label; Tree shows the latest five per contiguous run; both follow Pi thinking visibility, and unsafe or unstructured content remains native. Streaming folds to the latest five host-rendered rows and owns the configured thinking toggle only when active. Incompatibility uses native thinking.`,
694
690
  currentValue: thinkingStepsModeLabels[thinkingSteps.mode],
695
- values: thinkingStepsModeValues,
691
+ values: thinkingStepsModeValues(controller.startup.mode),
696
692
  },
697
693
  ];
698
694
  }
@@ -1285,9 +1281,10 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
1285
1281
  };
1286
1282
  const notifyChange = (label: string, value: string, result?: ApplyResult) => {
1287
1283
  deps.requestRender();
1284
+ const detail = result?.reason?.trim();
1288
1285
  ctx.ui.notify(
1289
- `${label}: ${value}${result && !result.applied ? ` (${result.reason ?? "reload Pi to apply this change"})` : ""}`,
1290
- "info",
1286
+ `${label}: ${value}${detail ? ` (${detail})` : result && !result.applied ? " (reload Pi to apply this change)" : ""}`,
1287
+ result && (!result.applied || detail) ? "warning" : "info",
1291
1288
  );
1292
1289
  startPreview();
1293
1290
  tui.requestRender();
@@ -6,6 +6,7 @@ import { sanitizeEditorMetadataText } from "./editor-metadata-format";
6
6
  import { renderMinimalistFrame } from "./minimalist-editor";
7
7
  import { safeThemeFg } from "./style";
8
8
  import { createThinkingStepsRows } from "./thinking-experimental";
9
+ import { formatThinkingStatus, thinkingStatusLabels } from "./thinking-status";
9
10
  import { parseThinkingSteps } from "./thinking-steps";
10
11
  import { renderPolishedEditorFrame } from "./ui";
11
12
  import { makeMarkdownTheme, renderUserMessageStyle } from "./user-message-styles";
@@ -25,6 +26,9 @@ export const THINKING_STEPS_PREVIEW_MARKDOWN = [
25
26
  ].join("\n");
26
27
  type ThinkingPreviewState = Readonly<{
27
28
  available: boolean;
29
+ rendererAvailable?: boolean;
30
+ streamingAvailable?: boolean;
31
+ streamingPoisoned?: boolean;
28
32
  active: boolean;
29
33
  activeMode?: "rail" | "tree" | "streaming";
30
34
  startup: Readonly<{ enabled: boolean; mode: "rail" | "tree" | "streaming" }>;
@@ -37,6 +41,23 @@ type ThinkingPreviewCapability =
37
41
  | Readonly<{ available: boolean }>
38
42
  | Readonly<{ readonly state: ThinkingPreviewState }>;
39
43
 
44
+ function wrapPreviewText(text: string, width: number): string[] {
45
+ if (width <= 0) return [];
46
+ const rows: string[] = [];
47
+ for (const fact of text.split(" · ")) {
48
+ const candidate = rows.length > 0 ? `${rows.at(-1)} · ${fact}` : fact;
49
+ if (visibleWidth(candidate) <= width) {
50
+ if (rows.length > 0) rows[rows.length - 1] = candidate;
51
+ else rows.push(candidate);
52
+ continue;
53
+ }
54
+ if (rows.length === 0 || rows.at(-1) !== fact) rows.push(fact);
55
+ }
56
+ return rows.flatMap((row) =>
57
+ visibleWidth(row) <= width ? [row] : [truncateToWidth(row, width, "")],
58
+ );
59
+ }
60
+
40
61
  function boundedRows(rows: string[], width: number): string[] {
41
62
  const safeWidth = Math.max(0, Math.min(SETTINGS_PREVIEW_MAX_WIDTH, width));
42
63
  if (safeWidth <= 0) return [];
@@ -176,20 +197,17 @@ export function renderThinkingStepsSettingsPreview(
176
197
  startup: { enabled: false, mode: "tree" },
177
198
  restartRequired: false,
178
199
  };
179
- const saved = thinkingSteps.enabled ? `saved ${thinkingSteps.mode}` : "saved disabled";
180
- const active = state.active && state.activeMode ? `active ${state.activeMode}` : "native active";
181
- const status = !state.available
182
- ? `${state.reason ?? "private renderer unavailable"} · native thinking`
183
- : state.restartRequired
184
- ? `${saved} · ${active} · restart required`
185
- : `${saved} · ${active}`;
200
+ const status = formatThinkingStatus(thinkingStatusLabels(thinkingSteps, state));
201
+ const statusRows = wrapPreviewText(status, previewWidth).map((row) =>
202
+ safeThemeFg(theme, "muted", row),
203
+ );
186
204
  if (thinkingSteps.mode === "streaming") {
187
205
  return boundedRows(
188
206
  [
207
+ ...statusRows,
189
208
  safeThemeFg(theme, "thinkingText", "Thinking 7.1s (configured thinking toggle to expand)"),
190
209
  safeThemeFg(theme, "thinkingText", "Inspect the host-rendered reasoning tail."),
191
210
  safeThemeFg(theme, "thinkingText", "Preserve native Markdown and wrapping."),
192
- safeThemeFg(theme, "muted", status),
193
211
  ],
194
212
  previewWidth,
195
213
  );
@@ -223,6 +241,5 @@ export function renderThinkingStepsSettingsPreview(
223
241
  () => theme,
224
242
  ).render(previewWidth)
225
243
  : native.render(previewWidth);
226
- rows.push(safeThemeFg(theme, "muted", status));
227
- return boundedRows(rows, previewWidth);
244
+ return boundedRows([...statusRows, ...rows], previewWidth);
228
245
  }
@@ -17,6 +17,7 @@ import {
17
17
  isPrototypePatchCurrent,
18
18
  type PrototypePatchRegistration,
19
19
  } from "./prototype-patch-registry";
20
+ import { formatThinkingStatus, thinkingStatusLabels } from "./thinking-status";
20
21
  import { parseThinkingSteps, type ThinkingStep } from "./thinking-steps";
21
22
 
22
23
  /*
@@ -114,7 +115,11 @@ export type ThinkingExperimentalDiagnostics = Readonly<{
114
115
  }>;
115
116
 
116
117
  export type ThinkingExperimentalState = Readonly<{
118
+ /** Backward-compatible alias for whole renderer availability. */
117
119
  available: boolean;
120
+ rendererAvailable: boolean;
121
+ streamingAvailable: boolean;
122
+ streamingPoisoned: boolean;
118
123
  active: boolean;
119
124
  activeMode?: ThinkingStepsMode;
120
125
  startup: Readonly<ThinkingStepsComponentConfig>;
@@ -123,6 +128,11 @@ export type ThinkingExperimentalState = Readonly<{
123
128
  reason?: string;
124
129
  }>;
125
130
 
131
+ export type ThinkingExperimentalApplyResult = Readonly<{
132
+ applied: boolean;
133
+ reason?: string;
134
+ }>;
135
+
126
136
  function messageTimestamp(message: AssistantMessage): number | undefined {
127
137
  return Number.isFinite(message.timestamp) ? message.timestamp : undefined;
128
138
  }
@@ -747,6 +757,11 @@ export class ThinkingExperimentalController {
747
757
  private displaced = false;
748
758
  private restartRequired = false;
749
759
  private unavailableReason: string | undefined;
760
+ private streamingUnavailableReason: string | undefined;
761
+ private streamingListenerPoisoned = false;
762
+ private disposed = false;
763
+ private resourceToken = 0;
764
+ private timerToken = 0;
750
765
  private patchRegistration: PrototypePatchRegistration | undefined;
751
766
  private context: ExtensionContext | undefined;
752
767
  private stopInput: (() => void) | undefined;
@@ -789,12 +804,15 @@ export class ThinkingExperimentalController {
789
804
 
790
805
  get state(): ThinkingExperimentalState {
791
806
  this.checkDisplacement();
807
+ const rendererAvailable = !this.unavailableReason && !this.displaced;
792
808
  const reason = this.displaced
793
- ? "Private renderer patch ownership was displaced; restart required"
794
- : (this.unavailableReason ??
795
- (this.restartRequired ? "Restart Pi to apply saved Thinking changes." : undefined));
809
+ ? "Private renderer patch ownership was displaced"
810
+ : (this.unavailableReason ?? this.streamingUnavailableReason);
796
811
  return Object.freeze({
797
- available: !this.unavailableReason && !this.displaced,
812
+ available: rendererAvailable,
813
+ rendererAvailable,
814
+ streamingAvailable: !this.streamingUnavailableReason,
815
+ streamingPoisoned: this.streamingListenerPoisoned,
798
816
  active: this.active && !this.displaced,
799
817
  ...(this.activeMode ? { activeMode: this.activeMode } : {}),
800
818
  startup: Object.freeze({ ...this.startup }),
@@ -804,6 +822,13 @@ export class ThinkingExperimentalController {
804
822
  });
805
823
  }
806
824
 
825
+ private failureResult(): ThinkingExperimentalApplyResult {
826
+ return {
827
+ applied: false,
828
+ reason: formatThinkingStatus(thinkingStatusLabels(this.getConfig(), this.state)),
829
+ };
830
+ }
831
+
807
832
  get diagnostics(): ThinkingExperimentalDiagnostics {
808
833
  return Object.freeze({
809
834
  trackedComponents: this.trackedEntries().length,
@@ -812,56 +837,106 @@ export class ThinkingExperimentalController {
812
837
  });
813
838
  }
814
839
 
815
- /** Activates the private renderer only during session startup, before transcript restoration. */
816
- startSession(ctx: ExtensionContext): { applied: boolean; reason?: string } {
840
+ /** Activates the private renderer during session startup, before transcript restoration. */
841
+ startSession(ctx: ExtensionContext): ThinkingExperimentalApplyResult {
842
+ this.disposed = false;
843
+ this.resourceToken += 1;
844
+ this.streamingUnavailableReason = undefined;
845
+ this.streamingListenerPoisoned = false;
817
846
  if (ctx.mode === "tui" && ctx.hasUI) this.context = ctx;
818
847
  this.startup = { ...this.getConfig() };
819
848
  this.restartRequired = false;
820
849
  this.activeMode = undefined;
821
850
  if (!this.startup.enabled) return { applied: true };
822
851
  if (!this.context) {
823
- return { applied: false, reason: "Thinking (Experimental) requires a TUI session" };
852
+ this.unavailableReason = "Thinking (Experimental) requires a TUI session";
853
+ this.restartRequired = true;
854
+ return this.failureResult();
855
+ }
856
+ if (this.unavailableReason) {
857
+ this.restartRequired = true;
858
+ return this.failureResult();
824
859
  }
825
- if (this.unavailableReason) return { applied: false, reason: this.unavailableReason };
826
860
  if (this.startup.mode === "streaming" && !this.resolveToggleInput()) {
827
- return {
828
- applied: false,
829
- reason: this.unavailableReason ?? "Experimental thinking toggle is unavailable",
830
- };
861
+ this.restartRequired = true;
862
+ return this.failureResult();
831
863
  }
832
864
  if (!this.install()) {
833
- return {
834
- applied: false,
835
- reason:
836
- this.unavailableReason ?? "Experimental renderer unavailable; using native thinking",
837
- };
865
+ this.unavailableReason ??= "Experimental renderer unavailable; using native thinking";
866
+ this.restartRequired = true;
867
+ return this.failureResult();
838
868
  }
839
869
  if (this.checkDisplacement()) {
840
870
  this.restartRequired = true;
841
- return {
842
- applied: false,
843
- reason: "Experimental renderer was displaced; restart required, using native thinking",
844
- };
871
+ return this.failureResult();
872
+ }
873
+ if (this.startup.mode === "streaming") {
874
+ const acquired = this.acquireStreamingResources();
875
+ if (!acquired.applied) {
876
+ this.restartRequired = true;
877
+ return this.failureResult();
878
+ }
845
879
  }
846
880
  this.active = true;
847
881
  this.activeMode = this.startup.mode;
848
- if (this.activeMode === "streaming") this.installInputListener();
849
- if (!this.active) {
850
- return {
851
- applied: false,
852
- reason: this.unavailableReason ?? "Experimental input handling is unavailable",
853
- };
854
- }
855
882
  return { applied: true };
856
883
  }
857
884
 
858
- /** Persists live settings while retaining the immutable startup renderer snapshot. */
859
- reconcile(): { applied: boolean; reason?: string } {
885
+ /** Applies healthy installed transitions while preserving persistence as the authority. */
886
+ reconcile(): ThinkingExperimentalApplyResult {
860
887
  const desired = this.getConfig();
861
- this.restartRequired =
862
- desired.enabled !== this.startup.enabled || desired.mode !== this.startup.mode;
863
- if (!this.restartRequired) return { applied: true };
864
- return { applied: false, reason: "Restart Pi to apply saved Thinking changes." };
888
+ if (!desired.enabled) {
889
+ this.restartRequired = false;
890
+ return this.active ? this.deactivate() : { applied: true };
891
+ }
892
+ if (this.active && this.activeMode === desired.mode) {
893
+ this.restartRequired = false;
894
+ return { applied: true };
895
+ }
896
+ if (
897
+ !this.active ||
898
+ !this.installed ||
899
+ this.displaced ||
900
+ this.unavailableReason ||
901
+ this.checkDisplacement()
902
+ ) {
903
+ this.restartRequired = true;
904
+ return this.failureResult();
905
+ }
906
+ const previousMode = this.activeMode;
907
+ if (!previousMode) {
908
+ this.restartRequired = true;
909
+ return this.failureResult();
910
+ }
911
+ if (desired.mode === "streaming") {
912
+ // Streaming owns terminal input and a timer. Never acquire either from a
913
+ // settings callback: entering Streaming is an explicit restart boundary.
914
+ this.restartRequired = true;
915
+ return this.failureResult();
916
+ }
917
+ const streamingCleanupSucceeded =
918
+ previousMode !== "streaming" || this.releaseStreamingResources();
919
+ this.expanded = false;
920
+ this.activeMode = desired.mode;
921
+ this.restartRequired = false;
922
+ this.rerenderTracked();
923
+ const result = this.liveTransitionResult(desired.mode);
924
+ if (!result.applied || streamingCleanupSucceeded) return result;
925
+ return {
926
+ applied: true,
927
+ reason: formatThinkingStatus(thinkingStatusLabels(this.getConfig(), this.state)),
928
+ };
929
+ }
930
+
931
+ private liveTransitionResult(mode: ThinkingStepsMode): ThinkingExperimentalApplyResult {
932
+ if (this.active && this.activeMode === mode && !this.displaced && !this.unavailableReason) {
933
+ return { applied: true };
934
+ }
935
+ this.restartRequired = true;
936
+ if (!this.unavailableReason && !this.streamingUnavailableReason) {
937
+ this.unavailableReason = "Experimental renderer failed during the live transition";
938
+ }
939
+ return this.failureResult();
865
940
  }
866
941
 
867
942
  private install(): boolean {
@@ -952,7 +1027,25 @@ export class ThinkingExperimentalController {
952
1027
  this.failShape("Pi's private assistant renderer shape is incompatible");
953
1028
  return nativeResult;
954
1029
  }
955
- this.track(component, message, args, predecessor, incomplete, nativeHidden);
1030
+ const trackedState = this.track(
1031
+ component,
1032
+ message,
1033
+ args,
1034
+ predecessor,
1035
+ incomplete,
1036
+ nativeHidden,
1037
+ );
1038
+ // Tracking can synchronously acquire the first Streaming timer. If that
1039
+ // transaction fails, failActiveStreaming has already restored native UI.
1040
+ if (
1041
+ !this.active ||
1042
+ this.activeMode !== mode ||
1043
+ this.displaced ||
1044
+ this.unavailableReason ||
1045
+ this.states.get(component) !== trackedState ||
1046
+ (mode === "streaming" && (this.streamingListenerPoisoned || !this.stopInput))
1047
+ )
1048
+ return nativeResult;
956
1049
  if (mode === "streaming" && this.expanded) return nativeResult;
957
1050
  if (
958
1051
  !replaceThinkingChildren(
@@ -1000,15 +1093,16 @@ export class ThinkingExperimentalController {
1000
1093
  predecessor: (this: unknown, ...args: unknown[]) => unknown,
1001
1094
  incomplete: boolean,
1002
1095
  nativeHidden: HiddenState,
1003
- ): void {
1004
- this.states.set(component, {
1096
+ ): TrackedState {
1097
+ const trackedState: TrackedState = {
1005
1098
  message,
1006
1099
  args: [...args],
1007
1100
  predecessor,
1008
1101
  incomplete,
1009
1102
  nativeHidden,
1010
1103
  folded: false,
1011
- });
1104
+ };
1105
+ this.states.set(component, trackedState);
1012
1106
  let reference = this.references.get(component);
1013
1107
  if (!reference) {
1014
1108
  reference = new WeakRef(component);
@@ -1034,6 +1128,7 @@ export class ThinkingExperimentalController {
1034
1128
  }
1035
1129
  this.pruneTimings();
1036
1130
  this.reconcileTimer();
1131
+ return trackedState;
1037
1132
  }
1038
1133
 
1039
1134
  private headerFor(message: AssistantMessage, incomplete: boolean, hidden: boolean): string {
@@ -1055,6 +1150,7 @@ export class ThinkingExperimentalController {
1055
1150
  }
1056
1151
 
1057
1152
  private resolveToggleInput(): boolean {
1153
+ if (this.streamingListenerPoisoned) return false;
1058
1154
  if (this.toggleInput) return true;
1059
1155
  const action = "app.thinking.toggle";
1060
1156
  if (!this.getHostKeybindings) {
@@ -1131,22 +1227,68 @@ export class ThinkingExperimentalController {
1131
1227
  }
1132
1228
 
1133
1229
  private failToggleCapability(reason: string): false {
1134
- this.unavailableReason = reason;
1230
+ this.poisonStreamingListener(reason);
1231
+ return false;
1232
+ }
1233
+
1234
+ private poisonStreamingListener(
1235
+ reason = "Pi's terminal input listener cleanup is unavailable",
1236
+ ): void {
1237
+ this.streamingListenerPoisoned = true;
1238
+ this.streamingUnavailableReason = reason;
1239
+ this.toggleInput = undefined;
1240
+ }
1241
+
1242
+ private cleanupInput(stopInput: (() => void) | undefined): boolean {
1243
+ if (!stopInput) return true;
1244
+ try {
1245
+ stopInput();
1246
+ return true;
1247
+ } catch {
1248
+ this.poisonStreamingListener();
1249
+ return false;
1250
+ }
1251
+ }
1252
+
1253
+ private failActiveStreaming(reason: string): void {
1254
+ // Invalidate first so a retained or currently executing callback can never act again.
1255
+ this.resourceToken += 1;
1256
+ this.releaseStreamingResources();
1257
+ this.poisonStreamingListener(reason);
1135
1258
  this.active = false;
1136
- this.stopTimer();
1137
- this.stopInput?.();
1138
- this.stopInput = undefined;
1139
- this.rerenderTracked(true);
1259
+ this.activeMode = undefined;
1260
+ this.restartRequired = true;
1261
+ this.rerenderTracked(true, true);
1140
1262
  this.clearTrackedSnapshots();
1141
- this.toggleInput = undefined;
1142
- return false;
1263
+ this.clearLiveState();
1143
1264
  }
1144
1265
 
1145
- private installInputListener(): void {
1146
- if (this.stopInput || !this.context || !this.toggleInput) return;
1266
+ private acquireStreamingResources(): { applied: boolean; reason?: string } {
1267
+ if (this.stopInput || this.interval) {
1268
+ return { applied: false, reason: "Experimental input resources are already owned" };
1269
+ }
1270
+ if (this.streamingListenerPoisoned) {
1271
+ return {
1272
+ applied: false,
1273
+ reason:
1274
+ this.streamingUnavailableReason ?? "Pi's terminal input listener cleanup is unavailable",
1275
+ };
1276
+ }
1277
+ if (!this.context || !this.toggleInput) {
1278
+ return { applied: false, reason: "Experimental input handling is unavailable" };
1279
+ }
1280
+ const token = ++this.resourceToken;
1281
+ let stopInput: unknown;
1147
1282
  try {
1148
- const stopInput = this.context.ui.onTerminalInput((data) => {
1149
- if (!this.active || this.checkDisplacement()) return;
1283
+ stopInput = this.context.ui.onTerminalInput((data) => {
1284
+ if (
1285
+ this.disposed ||
1286
+ token !== this.resourceToken ||
1287
+ !this.active ||
1288
+ this.activeMode !== "streaming" ||
1289
+ this.checkDisplacement()
1290
+ )
1291
+ return;
1150
1292
  const input = this.toggleInput;
1151
1293
  if (!input) return;
1152
1294
  let matches = false;
@@ -1159,7 +1301,7 @@ export class ThinkingExperimentalController {
1159
1301
  matches = parsed !== undefined && input.keys.includes(parsed);
1160
1302
  }
1161
1303
  } catch {
1162
- this.failToggleCapability("Pi's thinking-toggle matcher failed at runtime");
1304
+ this.failActiveStreaming("Pi's thinking-toggle matcher failed at runtime");
1163
1305
  return;
1164
1306
  }
1165
1307
  if (!matches) return;
@@ -1169,14 +1311,64 @@ export class ThinkingExperimentalController {
1169
1311
  }
1170
1312
  return { consume: true };
1171
1313
  });
1172
- if (typeof stopInput !== "function") {
1173
- this.failToggleCapability("Pi's terminal input listener cleanup is unavailable");
1174
- return;
1175
- }
1176
- this.stopInput = stopInput;
1177
1314
  } catch {
1178
- this.failToggleCapability("Pi's terminal input listener is unavailable");
1315
+ // Registration may retain the callback before throwing. Invalidate it and never
1316
+ // attempt another registration in this session because ownership is unknowable.
1317
+ this.resourceToken += 1;
1318
+ this.poisonStreamingListener("Pi's terminal input listener registration is unavailable");
1319
+ return { applied: false, reason: this.streamingUnavailableReason };
1320
+ }
1321
+ if (typeof stopInput !== "function") {
1322
+ // The host may have retained the callback without giving us a release mechanism.
1323
+ // Poison Streaming for this session and leave that callback permanently inert.
1324
+ this.resourceToken += 1;
1325
+ this.poisonStreamingListener();
1326
+ return { applied: false, reason: this.streamingUnavailableReason };
1179
1327
  }
1328
+ const releaseInput = stopInput as () => void;
1329
+ let interval: ReturnType<typeof setInterval> | undefined;
1330
+ try {
1331
+ if (this.activeEntries().length > 0) interval = this.createStreamingTimer(token);
1332
+ } catch {
1333
+ // Registration succeeded but the acquisition transaction did not. Invalidate the
1334
+ // candidate callback before cleanup and poison Streaming even when cleanup succeeds.
1335
+ this.resourceToken += 1;
1336
+ this.cleanupInput(releaseInput);
1337
+ this.poisonStreamingListener("Pi's thinking timer is unavailable");
1338
+ return { applied: false, reason: this.streamingUnavailableReason };
1339
+ }
1340
+ this.stopInput = releaseInput;
1341
+ this.interval = interval;
1342
+ return { applied: true };
1343
+ }
1344
+
1345
+ private createStreamingTimer(resourceToken = this.resourceToken): ReturnType<typeof setInterval> {
1346
+ const timerToken = ++this.timerToken;
1347
+ return setInterval(() => {
1348
+ // Timer/resource release invalidates callbacks before attempting host cleanup.
1349
+ // A host that throws from clearInterval may retain this callback, so stale
1350
+ // callbacks must be wholly inert while a later release retries the same handle.
1351
+ if (
1352
+ this.disposed ||
1353
+ resourceToken !== this.resourceToken ||
1354
+ timerToken !== this.timerToken ||
1355
+ !this.active ||
1356
+ this.activeMode !== "streaming"
1357
+ )
1358
+ return;
1359
+ if (this.checkDisplacement()) return;
1360
+ this.rerenderActive();
1361
+ this.reconcileTimer();
1362
+ }, 1000);
1363
+ }
1364
+
1365
+ private releaseStreamingResources(): boolean {
1366
+ this.resourceToken += 1;
1367
+ const timerCleanupSucceeded = this.stopTimer();
1368
+ const stopInput = this.stopInput;
1369
+ this.stopInput = undefined;
1370
+ const inputCleanupSucceeded = this.cleanupInput(stopInput);
1371
+ return timerCleanupSucceeded && inputCleanupSucceeded;
1180
1372
  }
1181
1373
 
1182
1374
  private trackedEntries(): Array<[object, TrackedState]> {
@@ -1263,13 +1455,20 @@ export class ThinkingExperimentalController {
1263
1455
  }
1264
1456
  }
1265
1457
 
1266
- private restoreNative(entries: Array<[object, TrackedState]>): number {
1458
+ private restoreNative(
1459
+ entries: Array<[object, TrackedState]>,
1460
+ preserveVisibleUnfolded = false,
1461
+ ): number {
1267
1462
  let processed = 0;
1268
1463
  for (const [component, state] of entries) {
1269
1464
  processed += 1;
1270
1465
  const instance = component as PatchableAssistant;
1271
1466
  try {
1272
- renderWithHiddenState(instance, state.predecessor, state.args, state.nativeHidden);
1467
+ // A visible native predecessor has already produced the authoritative children
1468
+ // when first-message timer acquisition fails inside track(). Preserve their exact
1469
+ // identity rather than invoking the predecessor a second time.
1470
+ if (!preserveVisibleUnfolded || state.folded || state.nativeHidden.value === true)
1471
+ renderWithHiddenState(instance, state.predecessor, state.args, state.nativeHidden);
1273
1472
  } catch {
1274
1473
  // A stale transcript component must never break restoration of later components.
1275
1474
  } finally {
@@ -1328,7 +1527,7 @@ export class ThinkingExperimentalController {
1328
1527
  state.folded = true;
1329
1528
  }
1330
1529
  } catch {
1331
- this.dropComponent(component);
1530
+ this.failShape("Pi's private assistant renderer failed during a live rerender");
1332
1531
  }
1333
1532
  if (this.shapeFailureDuringRerender) break;
1334
1533
  }
@@ -1345,9 +1544,11 @@ export class ThinkingExperimentalController {
1345
1544
  return processed;
1346
1545
  }
1347
1546
 
1348
- private rerenderTracked(nativeOnly = false): void {
1547
+ private rerenderTracked(nativeOnly = false, preserveVisibleUnfolded = false): void {
1349
1548
  const entries = this.trackedEntries();
1350
- const processed = this.rerenderEntries(entries, nativeOnly);
1549
+ const processed = nativeOnly
1550
+ ? this.restoreNative(entries, preserveVisibleUnfolded)
1551
+ : this.rerenderEntries(entries);
1351
1552
  if (processed > 0) this.requestHostRender();
1352
1553
  }
1353
1554
 
@@ -1362,7 +1563,7 @@ export class ThinkingExperimentalController {
1362
1563
  }
1363
1564
 
1364
1565
  private reconcileTimer(): void {
1365
- if (!this.active || this.activeMode !== "streaming") {
1566
+ if (!this.active || this.activeMode !== "streaming" || !this.stopInput) {
1366
1567
  this.stopTimer();
1367
1568
  return;
1368
1569
  }
@@ -1370,21 +1571,29 @@ export class ThinkingExperimentalController {
1370
1571
  this.stopTimer();
1371
1572
  return;
1372
1573
  }
1373
- if (!this.interval) {
1374
- this.interval = setInterval(() => {
1375
- if (!this.active || this.checkDisplacement()) {
1376
- this.stopTimer();
1377
- return;
1378
- }
1379
- this.rerenderActive();
1380
- this.reconcileTimer();
1381
- }, 1000);
1574
+ if (!this.interval && !this.streamingListenerPoisoned) {
1575
+ try {
1576
+ this.interval = this.createStreamingTimer();
1577
+ } catch {
1578
+ this.failActiveStreaming("Pi's thinking timer is unavailable");
1579
+ }
1382
1580
  }
1383
1581
  }
1384
1582
 
1385
- private stopTimer(): void {
1386
- if (this.interval) clearInterval(this.interval);
1387
- this.interval = undefined;
1583
+ private stopTimer(): boolean {
1584
+ const interval = this.interval;
1585
+ if (!interval) return true;
1586
+ this.timerToken += 1;
1587
+ try {
1588
+ clearInterval(interval);
1589
+ this.interval = undefined;
1590
+ return true;
1591
+ } catch {
1592
+ // Keep the exact handle so shutdown or another release can retry it. Streaming
1593
+ // remains poisoned, preventing any replacement timer/listener acquisition.
1594
+ this.poisonStreamingListener("Pi's thinking timer cleanup is unavailable");
1595
+ return false;
1596
+ }
1388
1597
  }
1389
1598
 
1390
1599
  private checkDisplacement(): boolean {
@@ -1405,9 +1614,7 @@ export class ThinkingExperimentalController {
1405
1614
  // Restore every component synchronously before relinquishing input/timer ownership.
1406
1615
  this.rerenderTracked(true);
1407
1616
  this.clearTrackedSnapshots();
1408
- this.stopTimer();
1409
- this.stopInput?.();
1410
- this.stopInput = undefined;
1617
+ this.releaseStreamingResources();
1411
1618
  }
1412
1619
  }
1413
1620
  return this.displaced;
@@ -1416,9 +1623,9 @@ export class ThinkingExperimentalController {
1416
1623
  private failShape(reason: string): void {
1417
1624
  this.unavailableReason = reason;
1418
1625
  this.active = false;
1419
- this.stopTimer();
1420
- this.stopInput?.();
1421
- this.stopInput = undefined;
1626
+ this.activeMode = undefined;
1627
+ this.restartRequired = true;
1628
+ this.releaseStreamingResources();
1422
1629
  if (this.rerendering) {
1423
1630
  this.shapeFailureDuringRerender = reason;
1424
1631
  return;
@@ -1440,19 +1647,22 @@ export class ThinkingExperimentalController {
1440
1647
  this.currentMessage = undefined;
1441
1648
  }
1442
1649
 
1443
- private deactivate(): void {
1650
+ private deactivate(): ThinkingExperimentalApplyResult {
1444
1651
  this.active = false;
1445
1652
  this.activeMode = undefined;
1446
1653
  this.expanded = false;
1447
- this.stopTimer();
1448
- this.stopInput?.();
1449
- this.stopInput = undefined;
1654
+ const streamingCleanupSucceeded = this.releaseStreamingResources();
1450
1655
  this.rerenderTracked(true);
1451
1656
  // Cached host components stay native until Pi next rebuilds/updates them. Dropping
1452
1657
  // snapshots is intentional: reactivation must never replay pre-disable content.
1453
1658
  this.clearTrackedSnapshots();
1454
1659
  this.clearLiveState();
1455
1660
  this.toggleInput = undefined;
1661
+ if (streamingCleanupSucceeded) return { applied: true };
1662
+ return {
1663
+ applied: true,
1664
+ reason: formatThinkingStatus(thinkingStatusLabels(this.getConfig(), this.state)),
1665
+ };
1456
1666
  }
1457
1667
 
1458
1668
  beginMessage(event: unknown): void {
@@ -1544,6 +1754,7 @@ export class ThinkingExperimentalController {
1544
1754
  }
1545
1755
 
1546
1756
  shutdown(): void {
1757
+ this.disposed = true;
1547
1758
  this.deactivate();
1548
1759
  this.startup = { enabled: false, mode: "tree" };
1549
1760
  this.restartRequired = false;
@@ -0,0 +1,69 @@
1
+ import type { ThinkingStepsComponentConfig, ThinkingStepsMode } from "./config";
2
+
3
+ export type ThinkingStatusState = Readonly<{
4
+ active: boolean;
5
+ activeMode?: ThinkingStepsMode;
6
+ restartRequired: boolean;
7
+ /** Whole private renderer capability, independent of mode-specific resources. */
8
+ rendererAvailable?: boolean;
9
+ /** Streaming input/timer capability for this session. */
10
+ streamingAvailable?: boolean;
11
+ /** Irreversible session-local acquisition/callback poison. */
12
+ streamingPoisoned?: boolean;
13
+ /** Legacy whole-renderer capability accepted by settings adapters. */
14
+ available?: boolean;
15
+ reason?: string;
16
+ }>;
17
+
18
+ export type ThinkingStatusLabels = Readonly<{
19
+ saved: ThinkingStepsMode | "disabled";
20
+ active: ThinkingStepsMode | "native";
21
+ rendererUnavailable: boolean;
22
+ streamingUnavailable: boolean;
23
+ streamingPoisoned: boolean;
24
+ restartRequired: boolean;
25
+ reason?: string;
26
+ }>;
27
+
28
+ function cleanReason(value: string | undefined): string | undefined {
29
+ const reason = value
30
+ ?.replace(/(?:[;,·]\s*)?restart required\b[.!]?/gi, "")
31
+ .replace(/^restart pi to apply(?: saved thinking changes)?[.!]?$/i, "")
32
+ .replace(/^[\s;,.·]+|[\s;,.·]+$/g, "")
33
+ .trim();
34
+ if (!reason || /^(?:renderer|streaming) unavailable$/i.test(reason)) return undefined;
35
+ return reason;
36
+ }
37
+
38
+ /** Builds independent status facts once so every consumer reports the same state. */
39
+ export function thinkingStatusLabels(
40
+ config: ThinkingStepsComponentConfig,
41
+ state: ThinkingStatusState,
42
+ ): ThinkingStatusLabels {
43
+ const rendererAvailable = state.rendererAvailable ?? state.available ?? true;
44
+ const reason = cleanReason(state.reason);
45
+ return {
46
+ saved: config.enabled ? config.mode : "disabled",
47
+ active: state.active && state.activeMode ? state.activeMode : "native",
48
+ rendererUnavailable: !rendererAvailable,
49
+ streamingUnavailable: state.streamingAvailable === false || state.streamingPoisoned === true,
50
+ streamingPoisoned: state.streamingPoisoned === true,
51
+ restartRequired: state.restartRequired,
52
+ ...(reason ? { reason } : {}),
53
+ };
54
+ }
55
+
56
+ const modeLabel = (mode: ThinkingStepsMode | "disabled" | "native"): string =>
57
+ `${mode.charAt(0).toUpperCase()}${mode.slice(1)}`;
58
+
59
+ /** Formats the canonical complete status line used by settings, previews, and apply failures. */
60
+ export function formatThinkingStatus(facts: ThinkingStatusLabels): string {
61
+ return [
62
+ `Saved: ${modeLabel(facts.saved)}`,
63
+ `Active: ${modeLabel(facts.active)}`,
64
+ ...(facts.rendererUnavailable ? ["Renderer unavailable"] : []),
65
+ ...(facts.streamingUnavailable ? ["Streaming unavailable"] : []),
66
+ ...(facts.restartRequired ? ["restart required"] : []),
67
+ ...(facts.reason ? [facts.reason] : []),
68
+ ].join(" · ");
69
+ }
@@ -602,7 +602,7 @@ function renderAccentRailFrameFromBase({
602
602
  above: parsedTop?.count,
603
603
  below: parsedBottom?.count,
604
604
  };
605
- const lines = renderAccentRailEditorFrame({
605
+ const renderedLines = renderAccentRailEditorFrame({
606
606
  width,
607
607
  editorLines,
608
608
  autocompleteLines,
@@ -610,6 +610,7 @@ function renderAccentRailFrameFromBase({
610
610
  uiTheme,
611
611
  config,
612
612
  });
613
+ const lines = renderedLines.length === 1 ? ["", ...renderedLines, ""] : renderedLines;
613
614
  POLISHED_FRAME_SPLITS.set(lines, {
614
615
  rows: Object.freeze([...lines]),
615
616
  split: { editorLines, trailingLines: autocompleteLines, viewport },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",