pi-zentui 0.26.1 → 0.27.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.
@@ -225,7 +225,8 @@ Reference only—not a starter file. Prefer the minimal overrides above. Optiona
225
225
  "contextThresholds": {
226
226
  "warning": 70,
227
227
  "error": 90
228
- }
228
+ },
229
+ "separator": "dash"
229
230
  }
230
231
  }
231
232
  },
@@ -246,6 +247,7 @@ Reference only—not a starter file. Prefer the minimal overrides above. Optiona
246
247
  },
247
248
  "workingLine": {
248
249
  "enabled": false,
250
+ "placement": "above",
249
251
  "turnSummary": true,
250
252
  "spinner": "star-bloom",
251
253
  "spinnerIntervalMs": 100,
@@ -422,6 +424,7 @@ Reference only—not a starter file. Prefer the minimal overrides above. Optiona
422
424
  - `components.thinkingSteps` independently owns opt-in **Thinking (Experimental)** display. It defaults to `{ "enabled": false, "mode": "tree" }`; canonical modes are `rail | tree | streaming`. The former persisted `streaming-experimental` value is accepted only as a migration alias and is normalized to `streaming` on save.
423
425
  - All three modes decorate Pi's private host renderer and are tested on exact Pi versions 0.80.5, 0.82.1, 0.83.0, 0.84.0, 0.84.4, and 0.85.1. 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 live disable require restart; live disable restores native thinking. Disabled mode changes only preconfigure.
424
426
  - `components.workingLine.enabled` is the sole Working-line ownership switch. Thinking (Experimental) never enables, configures, or owns the Working line and leaves the existing **Thinking time** option unchanged.
427
+ - `components.workingLine.placement` (`above | border`) selects the default floating row or the Minimalist/Opencode top border. Accent Rail supports only Above. Disabled, native, unavailable, or unsafe/narrow editors and Pi versions without working-row visibility control fall back to Above without changing saved choices. Legacy `input` normalizes to `above`; the prompt remains available for typing.
425
428
  - `components.selectorBorders` owns selector-border enablement, fixed `zentui` style, and color source. Disable it for native Pi behavior.
426
429
  - `components.footer` owns `native | starship | hidden` style selection, color source, model label, and Starship options. Hidden hides its main segments, retaining only allowed extension statuses; an empty status line occupies no rows.
427
430
  - Starship's package-version segment reads the project manifest and is distinct from the runtime segment, which reports the installed toolchain.
@@ -531,7 +534,7 @@ Set `ZENTUI_DEBUG=1` when launching Pi to log the workaround diagnostic without
531
534
 
532
535
  Set `components.editor.style` to `minimalist` or select it in `/zentui`. The rounded frame places viewport counts, Bash state, current/completed turn duration, and explicit session name at top left; cost, model, thinking, context, and optional latest-prompt cache hit rate at top right; viewport count plus Git at bottom left; and configured path at bottom right. Unnamed sessions add no placeholder.
533
536
 
534
- Path examples are `src` (`compact`), `zentui/src` (`project`), and `~/Projects/zentui/src` (`full`). Context can render as `11%`, `11%/372k`, or, with the gauge enabled and enough room, `[█░░░░] 11%/372k`. Enable `showCacheHit` to append values such as `Cache 98.2%`; it defaults to `false`, omits missing data, yields before context at narrow widths, and remains independent of Footer. The gauge shortens or disappears before the context text at narrow widths. Session name, timer, cost, cache hit rate, and Git can be hidden independently; model, thinking, and context remain structurally stable.
537
+ Path examples are `src` (`compact`), `zentui/src` (`project`), and `~/Projects/zentui/src` (`full`). Context can render as `11%`, `11%/372k`, or, with the gauge enabled and enough room, `[█░░░░] 11%/372k`. Enable `showCacheHit` to append values such as `Cache 98.2%`; it defaults to `false`, omits missing data, yields before context at narrow widths, and remains independent of Footer. The gauge shortens or disappears before the context text at narrow widths. Session name, timer, cost, cache hit rate, and Git can be hidden independently; model, thinking, and context remain structurally stable. Metadata items are joined with dashes by default (`separator: "dash"`) or dots (`separator: "dot"`).
535
538
 
536
539
  Autocomplete stays inside the frame when Pi output can be split safely. Unknown third-party layouts fail open. Footer visibility remains independently controlled by `components.footer.style`; Minimalist does not remove Pi's header.
537
540
 
@@ -54,10 +54,12 @@ export type WorkingLineSpinner =
54
54
  | "claude-inspired"
55
55
  | "pulse";
56
56
  export type WorkingLineTextAnimation = "classic" | "kitt" | "disabled";
57
+ export type WorkingLinePlacement = "above" | "border";
57
58
  export type ThinkingStepsMode = "rail" | "tree" | "streaming";
58
59
  export type ComponentStyleOwner = "editor" | "userMessages" | "selectorBorders" | "footer";
59
60
  export type MinimalistPathDisplayMode = "compact" | "project" | "full";
60
61
  export type MinimalistContextFormat = "percent" | "percent-total";
62
+ export type MinimalistEditorSeparator = "dash" | "dot";
61
63
  export type EditorBorderColorMode = "static" | "adaptive";
62
64
  export type CompletionMenuStyle = "native" | "palette";
63
65
  export type CompactFooterMaxLines = 1 | 2 | 3 | "unlimited";
@@ -142,6 +144,7 @@ export type MinimalistEditorStyleConfig = {
142
144
  showCacheHit: boolean;
143
145
  showGit: boolean;
144
146
  contextThresholds: ContextThresholds;
147
+ separator: MinimalistEditorSeparator;
145
148
  };
146
149
 
147
150
  /** Temporary name retained for existing settings consumers. */
@@ -258,6 +261,7 @@ export type WorkingLineComponentConfig = {
258
261
  colorSource: ColorSource;
259
262
  messages: WorkingLineMessagesConfig;
260
263
  segments: WorkingLineSegmentsConfig;
264
+ placement: WorkingLinePlacement;
261
265
  };
262
266
 
263
267
  export type WorkingLineComponentPatch = Partial<
@@ -498,6 +502,7 @@ const defaultMinimalistStyle: MinimalistEditorStyleConfig = {
498
502
  showCacheHit: false,
499
503
  showGit: true,
500
504
  contextThresholds: { warning: 70, error: 90 },
505
+ separator: "dash",
501
506
  };
502
507
 
503
508
  const defaultStarshipStyle: StarshipFooterStyleConfig = {
@@ -557,6 +562,7 @@ const defaultComponents: ComponentsConfig = {
557
562
  colorSource: "theme",
558
563
  messages: { custom: true, values: [...PI_WORKING_LINE_MESSAGES] },
559
564
  segments: { tool: true, elapsed: true, thought: true, tokens: true },
565
+ placement: "above",
560
566
  },
561
567
  selectorBorders: { enabled: true, style: "zentui", colorSource: "theme" },
562
568
  footer: {
@@ -1405,6 +1411,10 @@ function resolveComponents(config: ConfigRecord): ComponentsConfig {
1405
1411
  showCacheHit: parseBoolean(minimalist.showCacheHit, defaultMinimalistStyle.showCacheHit),
1406
1412
  showGit: parseBoolean(minimalist.showGit, defaultMinimalistStyle.showGit),
1407
1413
  contextThresholds: minimalistThresholds,
1414
+ separator:
1415
+ minimalist.separator === "dash" || minimalist.separator === "dot"
1416
+ ? minimalist.separator
1417
+ : defaultMinimalistStyle.separator,
1408
1418
  },
1409
1419
  },
1410
1420
  },
@@ -1466,6 +1476,10 @@ function resolveComponents(config: ConfigRecord): ComponentsConfig {
1466
1476
  workingLine.textAnimation === "disabled"
1467
1477
  ? workingLine.textAnimation
1468
1478
  : defaultComponents.workingLine.textAnimation,
1479
+ placement:
1480
+ workingLine.placement === "border"
1481
+ ? workingLine.placement
1482
+ : defaultComponents.workingLine.placement,
1469
1483
  colorSource: parseColorSource(
1470
1484
  workingLine.colorSource,
1471
1485
  defaultComponents.workingLine.colorSource,
@@ -1903,6 +1917,7 @@ function applyMinimalistStylePatch(
1903
1917
  if (patch.showCost !== undefined) style.showCost = patch.showCost;
1904
1918
  if (patch.showCacheHit !== undefined) style.showCacheHit = patch.showCacheHit;
1905
1919
  if (patch.showGit !== undefined) style.showGit = patch.showGit;
1920
+ if (patch.separator !== undefined) style.separator = patch.separator;
1906
1921
  if (patch.contextThresholds !== undefined) {
1907
1922
  style.contextThresholds = { ...style.contextThresholds, ...patch.contextThresholds };
1908
1923
  }
@@ -1970,6 +1985,7 @@ export function saveWorkingLineComponentPatch(
1970
1985
  if (patch.textIntervalMs !== undefined) component.textIntervalMs = patch.textIntervalMs;
1971
1986
  if (patch.textAnimation !== undefined) component.textAnimation = patch.textAnimation;
1972
1987
  if (patch.colorSource !== undefined) component.colorSource = patch.colorSource;
1988
+ if (patch.placement !== undefined) component.placement = patch.placement;
1973
1989
  if (patch.messages?.custom !== undefined) component.messages.custom = patch.messages.custom;
1974
1990
  if (patch.messages?.values !== undefined) {
1975
1991
  component.messages.values = normalizeWorkingLineMessages([...patch.messages.values]);
@@ -240,6 +240,13 @@ export default function (pi: ExtensionAPI) {
240
240
  let editorInstalled = false;
241
241
  let editorInstallMode: EditorInstallMode = "none";
242
242
  let installedEditorFactory: EditorFactory | undefined;
243
+ let activeEditor:
244
+ | {
245
+ editor: PolishedEditor | WrappedPolishedEditor;
246
+ factory: EditorFactory;
247
+ generation: number;
248
+ }
249
+ | undefined;
243
250
  let wrappedEditorFactory: EditorFactory | undefined;
244
251
  let stopSessionTimer: () => void = () => {};
245
252
  let stopMinimalistDurationUpdates: () => void = () => {};
@@ -330,7 +337,28 @@ export default function (pi: ExtensionAPI) {
330
337
  Date.now,
331
338
  () => interactionMetrics.currentThought(),
332
339
  () => invalidateWorkingLinePublisherState(),
340
+ undefined,
341
+ (ctx) => {
342
+ if (!activeTuiContext || ctx.ui !== activeTuiContext.ui) return false;
343
+ if (!ownsInstalledEditorFactory()) {
344
+ activeEditor = undefined;
345
+ return false;
346
+ }
347
+ return Boolean(
348
+ activeEditor &&
349
+ sessionLifecycle.isCurrent(activeEditor.generation) &&
350
+ activeEditor.factory === installedEditorFactory &&
351
+ effectiveEditorEnabled() &&
352
+ currentConfig.components.editor.style !== "accent-rail" &&
353
+ activeEditor.editor.canEmbedWorkingLineBorder(),
354
+ );
355
+ },
333
356
  );
357
+ workingLine.setRequestRender(() => {
358
+ if (sessionLifecycle.isCurrent()) {
359
+ requestEditorRender?.();
360
+ }
361
+ });
334
362
  let workingLineSessionReady = false;
335
363
  const workingLineExtensions = new WorkingLineExtensionSegments(
336
364
  pi.events,
@@ -567,6 +595,7 @@ export default function (pi: ExtensionAPI) {
567
595
 
568
596
  const refreshInteractiveState = (ctx: ExtensionContext, project = false) => {
569
597
  if (!sessionLifecycle.isCurrent() || !ctx.hasUI) return;
598
+ if (activeTuiContext?.ui === ctx.ui) activeTuiContext = ctx;
570
599
  if (editorInstalled && !ownsInstalledEditorFactory()) reconcileObservedEditorOwnership(ctx);
571
600
  syncFooterState(ctx);
572
601
  if (project && needsProjectRefresh()) scheduleProjectRefresh(ctx);
@@ -768,6 +797,8 @@ export default function (pi: ExtensionAPI) {
768
797
  };
769
798
 
770
799
  const clearEditorOwnership = () => {
800
+ activeEditor = undefined;
801
+ if (activeTuiContext && workingLineSessionReady) workingLine.reconcile(activeTuiContext);
771
802
  setMinimalistDecorationActive(false);
772
803
  requestEditorRender = undefined;
773
804
  wrappedEditorFactory = undefined;
@@ -779,6 +810,7 @@ export default function (pi: ExtensionAPI) {
779
810
 
780
811
  const trackZentuiEditorFactory = (factory: EditorFactory): boolean => {
781
812
  if (!isOwnedEditorFactory(factory)) return false;
813
+ if (activeEditor?.factory !== factory) activeEditor = undefined;
782
814
  const baseFactory = getZentuiEditorBaseFactory(factory);
783
815
  wrappedEditorFactory = baseFactory;
784
816
  installedEditorFactory = factory;
@@ -799,7 +831,11 @@ export default function (pi: ExtensionAPI) {
799
831
 
800
832
  const reconcileObservedEditorOwnership = (ctx: ExtensionContext) => {
801
833
  const observed = observeEditorFactory(ctx);
802
- if (!observed.known) return observed;
834
+ if (!observed.known) {
835
+ activeEditor = undefined;
836
+ if (workingLineSessionReady) workingLine.reconcile(ctx);
837
+ return observed;
838
+ }
803
839
  if (observed.factory && isOwnedEditorFactory(observed.factory)) {
804
840
  trackZentuiEditorFactory(observed.factory);
805
841
  } else {
@@ -820,39 +856,78 @@ export default function (pi: ExtensionAPI) {
820
856
  return editor;
821
857
  };
822
858
 
859
+ const isActiveEditor = (editor: PolishedEditor | WrappedPolishedEditor, generation: number) =>
860
+ sessionLifecycle.isCurrent(generation) && activeEditor?.editor === editor;
861
+
862
+ const editorBorderCapabilityChanged = (
863
+ editor: PolishedEditor | WrappedPolishedEditor,
864
+ generation: number,
865
+ ) => {
866
+ if (!isActiveEditor(editor, generation) || !activeTuiContext || !workingLineSessionReady)
867
+ return;
868
+ workingLine.reconcile(activeTuiContext);
869
+ if (workingLine.isAvailable()) requestEditorRender?.();
870
+ };
871
+
872
+ const editorWorkingLineFrame = (
873
+ editor: PolishedEditor | WrappedPolishedEditor,
874
+ generation: number,
875
+ ) => (isActiveEditor(editor, generation) ? workingLine.currentWorkingLineFrame() : undefined);
876
+
823
877
  const makeEditorFactory = (ctx: ExtensionContext): ZentuiEditorFactory => {
824
878
  const sessionTheme = ctx.ui.theme;
879
+ const generation = sessionLifecycle.currentGeneration();
825
880
  const factory = ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => {
826
- requestEditorRender = () => tui.requestRender();
827
- return markOwnedAccentRailEditor(
881
+ const editor: PolishedEditor = markOwnedAccentRailEditor(
828
882
  new PolishedEditor(
829
883
  tui,
830
884
  theme,
831
885
  keybindings,
832
886
  sessionTheme,
833
887
  getCurrentConfig,
834
- () => getEditorMeta(ctx),
888
+ () => getEditorMeta(activeTuiContext ?? ctx),
835
889
  getThinkingLevel,
836
- () => ({
837
- codexQuota: getEditorQuota(),
838
- cwd: ctx.cwd,
839
- projectRoot: minimalistProjectRoot,
840
- branch: state.branch,
841
- dirty: state.dirty,
842
- ahead: state.ahead,
843
- behind: state.behind,
844
- costLabel: state.costLabel,
845
- modelLabel: modelLabelFor(state, currentConfig.components.editor.modelLabel),
846
- thinkingLevel: getThinkingLevel(),
847
- ...getEditorContextMetadata(ctx),
848
- cacheHitRate: state.usageTotals.latestCacheHitRate,
849
- sessionName: ctx.sessionManager.getSessionName() ?? "",
850
- agentDurationMs: getAgentDurationMs(),
851
- agentActive: agentRunActive,
852
- }),
853
- setMinimalistDecorationActive,
890
+ () => {
891
+ const workingLineFrame = editorWorkingLineFrame(editor, generation);
892
+ if (currentConfig.components.editor.style !== "minimalist") {
893
+ return { cwd: "", workingLineFrame };
894
+ }
895
+ return {
896
+ codexQuota: getEditorQuota(),
897
+ cwd: (activeTuiContext ?? ctx).cwd,
898
+ projectRoot: minimalistProjectRoot,
899
+ branch: state.branch,
900
+ dirty: state.dirty,
901
+ ahead: state.ahead,
902
+ behind: state.behind,
903
+ costLabel: state.costLabel,
904
+ modelLabel: modelLabelFor(state, currentConfig.components.editor.modelLabel),
905
+ thinkingLevel: getThinkingLevel(),
906
+ ...getEditorContextMetadata(activeTuiContext ?? ctx),
907
+ cacheHitRate: state.usageTotals.latestCacheHitRate,
908
+ sessionName: (activeTuiContext ?? ctx).sessionManager.getSessionName() ?? "",
909
+ agentDurationMs: getAgentDurationMs(),
910
+ agentActive: agentRunActive,
911
+ workingLineFrame,
912
+ };
913
+ },
914
+ (active) => {
915
+ if (isActiveEditor(editor, generation)) setMinimalistDecorationActive(active);
916
+ },
917
+ () => editorBorderCapabilityChanged(editor, generation),
854
918
  ),
855
919
  );
920
+ const observed = observeEditorFactory(ctx);
921
+ if (
922
+ sessionLifecycle.isCurrent(generation) &&
923
+ activeTuiContext?.ui === ctx.ui &&
924
+ observed.known &&
925
+ observed.factory === factory
926
+ ) {
927
+ activeEditor = { editor, factory, generation };
928
+ requestEditorRender = () => tui.requestRender();
929
+ }
930
+ return editor;
856
931
  }) as ZentuiEditorFactory;
857
932
  factory[ZENTUI_EDITOR_FACTORY] = true;
858
933
  factory[ZENTUI_EDITOR_OWNER] = editorOwnerToken;
@@ -864,35 +939,56 @@ export default function (pi: ExtensionAPI) {
864
939
  baseFactory: EditorFactory,
865
940
  ): ZentuiEditorFactory => {
866
941
  const sessionTheme = ctx.ui.theme;
942
+ const generation = sessionLifecycle.currentGeneration();
867
943
  const factory = ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => {
868
- requestEditorRender = () => tui.requestRender();
869
- return markOwnedAccentRailEditor(
944
+ const editor: WrappedPolishedEditor = markOwnedAccentRailEditor(
870
945
  new WrappedPolishedEditor(
871
946
  baseFactory(tui, theme, keybindings),
872
947
  sessionTheme,
873
948
  getCurrentConfig,
874
- () => getEditorMeta(ctx),
949
+ () => getEditorMeta(activeTuiContext ?? ctx),
875
950
  getThinkingLevel,
876
- () => ({
877
- codexQuota: getEditorQuota(),
878
- cwd: ctx.cwd,
879
- projectRoot: minimalistProjectRoot,
880
- branch: state.branch,
881
- dirty: state.dirty,
882
- ahead: state.ahead,
883
- behind: state.behind,
884
- costLabel: state.costLabel,
885
- modelLabel: modelLabelFor(state, currentConfig.components.editor.modelLabel),
886
- thinkingLevel: getThinkingLevel(),
887
- ...getEditorContextMetadata(ctx),
888
- cacheHitRate: state.usageTotals.latestCacheHitRate,
889
- sessionName: ctx.sessionManager.getSessionName() ?? "",
890
- agentDurationMs: getAgentDurationMs(),
891
- agentActive: agentRunActive,
892
- }),
893
- setMinimalistDecorationActive,
951
+ () => {
952
+ const workingLineFrame = editorWorkingLineFrame(editor, generation);
953
+ if (currentConfig.components.editor.style !== "minimalist") {
954
+ return { cwd: "", workingLineFrame };
955
+ }
956
+ return {
957
+ codexQuota: getEditorQuota(),
958
+ cwd: (activeTuiContext ?? ctx).cwd,
959
+ projectRoot: minimalistProjectRoot,
960
+ branch: state.branch,
961
+ dirty: state.dirty,
962
+ ahead: state.ahead,
963
+ behind: state.behind,
964
+ costLabel: state.costLabel,
965
+ modelLabel: modelLabelFor(state, currentConfig.components.editor.modelLabel),
966
+ thinkingLevel: getThinkingLevel(),
967
+ ...getEditorContextMetadata(activeTuiContext ?? ctx),
968
+ cacheHitRate: state.usageTotals.latestCacheHitRate,
969
+ sessionName: (activeTuiContext ?? ctx).sessionManager.getSessionName() ?? "",
970
+ agentDurationMs: getAgentDurationMs(),
971
+ agentActive: agentRunActive,
972
+ workingLineFrame,
973
+ };
974
+ },
975
+ (active) => {
976
+ if (isActiveEditor(editor, generation)) setMinimalistDecorationActive(active);
977
+ },
978
+ () => editorBorderCapabilityChanged(editor, generation),
894
979
  ),
895
980
  );
981
+ const observed = observeEditorFactory(ctx);
982
+ if (
983
+ sessionLifecycle.isCurrent(generation) &&
984
+ activeTuiContext?.ui === ctx.ui &&
985
+ observed.known &&
986
+ observed.factory === factory
987
+ ) {
988
+ activeEditor = { editor, factory, generation };
989
+ requestEditorRender = () => tui.requestRender();
990
+ }
991
+ return editor;
896
992
  }) as ZentuiEditorFactory;
897
993
  factory[ZENTUI_EDITOR_FACTORY] = true;
898
994
  factory[ZENTUI_EDITOR_OWNER] = editorOwnerToken;
@@ -1141,6 +1237,8 @@ export default function (pi: ExtensionAPI) {
1141
1237
  ok: false,
1142
1238
  reason: "the editor could not be reconciled safely; reload Pi to apply this change",
1143
1239
  };
1240
+ } finally {
1241
+ if (workingLineSessionReady) workingLine.reconcile(ctx);
1144
1242
  }
1145
1243
  };
1146
1244
 
@@ -1231,6 +1329,7 @@ export default function (pi: ExtensionAPI) {
1231
1329
  const cleanupUi = (ctx?: ExtensionContext) => {
1232
1330
  if (!ctx || !sessionLifecycle.isCurrent()) return;
1233
1331
  sessionLifecycle.shutdown();
1332
+ activeEditor = undefined;
1234
1333
  extensionStatuses.dispose();
1235
1334
  codexQuota.stop();
1236
1335
  stopSessionTimer();
@@ -1290,6 +1389,7 @@ export default function (pi: ExtensionAPI) {
1290
1389
  usageTotals.invalidate();
1291
1390
  footerTelemetry.reset();
1292
1391
  const lifecycleGeneration = sessionLifecycle.start();
1392
+ activeEditor = undefined;
1293
1393
  // A new generation must not expose or route extension segments through the previous
1294
1394
  // session while asynchronous TUI startup is still pending.
1295
1395
  workingLineSessionReady = false;
@@ -1350,6 +1450,7 @@ export default function (pi: ExtensionAPI) {
1350
1450
  if (currentConfig.components.editor.style !== "minimalist") {
1351
1451
  setMinimalistDecorationActive(false);
1352
1452
  }
1453
+ workingLine.reconcile(ctx);
1353
1454
  reconcileUserMessages();
1354
1455
  reconcileFooter(ctx);
1355
1456
  reconcileProjectRefresh(ctx, effectiveFooterStyle() !== previousFooterStyle);
@@ -1404,6 +1505,7 @@ export default function (pi: ExtensionAPI) {
1404
1505
  if (patch.style !== undefined && patch.style !== "minimalist") {
1405
1506
  setMinimalistDecorationActive(false);
1406
1507
  }
1508
+ workingLine.reconcile(ctx);
1407
1509
  syncFooterUsage(ctx);
1408
1510
  if (patch.modelLabel !== undefined) syncFooterState(ctx);
1409
1511
  reconcileProjectRefresh(ctx);
@@ -58,6 +58,8 @@ export type MinimalistEditorMetadata = {
58
58
  sessionName?: string;
59
59
  agentDurationMs?: number;
60
60
  agentActive?: boolean;
61
+ /** Controller-gated border content; never resolve canonical placement in the renderer. */
62
+ workingLineFrame?: string;
61
63
  };
62
64
 
63
65
  export type MinimalistFrameOptions = {
@@ -182,6 +184,7 @@ function renderTopLeft(
182
184
  : safeThemeFg(uiTheme, "muted", duration),
183
185
  );
184
186
  }
187
+ if (metadata.workingLineFrame) parts.push(metadata.workingLineFrame);
185
188
  const sessionName = includeSessionName
186
189
  ? sanitizeEditorMetadataText(metadata.sessionName ?? "")
187
190
  : "";
@@ -209,8 +212,9 @@ function renderTopRight(
209
212
  ): string {
210
213
  const source = config.components.editor.colorSource;
211
214
  const parts: string[] = [];
215
+ const separator = config.components.editor.styles.minimalist.separator === "dot" ? " · " : " – ";
212
216
  const joinParts = (values: string[]) =>
213
- values.map((part, index) => (index > 0 ? `${renderBorder(" – ")}${part}` : part)).join("");
217
+ values.map((part, index) => (index > 0 ? `${renderBorder(separator)}${part}` : part)).join("");
214
218
  const cost = config.components.editor.styles.minimalist.showCost
215
219
  ? sanitizeEditorMetadataText(metadata.costLabel ?? "")
216
220
  : "";
@@ -498,6 +502,7 @@ export function renderMinimalistFrame({
498
502
  const topFallbacks = [
499
503
  joinStyled([topViewport, topOperational], separator),
500
504
  topOperational,
505
+ ...(metadata.workingLineFrame ? [metadata.workingLineFrame] : []),
501
506
  ].filter((value, index, values) => value !== topLeft && values.indexOf(value) === index);
502
507
  const top = renderLabeledBorder({
503
508
  width,
@@ -45,6 +45,7 @@ import {
45
45
  MAX_WORKING_LINE_INTERVAL_MS,
46
46
  MIN_WORKING_LINE_INTERVAL_MS,
47
47
  type MinimalistConfig,
48
+ type MinimalistEditorSeparator,
48
49
  type ModelLabelSource,
49
50
  type PathDisplayConfig,
50
51
  type PolishedCopyFriendlyEditorStyleConfig,
@@ -57,6 +58,7 @@ import {
57
58
  type UserMessageStyle,
58
59
  type UserMessagesComponentConfig,
59
60
  type WorkingLineComponentPatch,
61
+ type WorkingLinePlacement,
60
62
  type WorkingLineSpinner,
61
63
  type WorkingLineTextAnimation,
62
64
  } from "./config";
@@ -126,6 +128,7 @@ const completionMenuValues: CompletionMenuStyle[] = ["palette", "native"];
126
128
  const accentRailSurfaceValues = ["filled", "transparent"];
127
129
  const minimalistPathDisplayValues = ["compact", "project", "full"];
128
130
  const minimalistContextFormatValues = ["percent", "percent-total"];
131
+ const minimalistSeparatorValues: MinimalistEditorSeparator[] = ["dash", "dot"];
129
132
  const editorBorderColorModeValues: EditorBorderColorMode[] = ["static", "adaptive"];
130
133
  const compactFooterMaxLineValues = ["1", "2", "3", "unlimited"];
131
134
  const featureStateValues: FeatureState[] = ["enabled", "disabled"];
@@ -151,6 +154,11 @@ const speedValues = (presets: readonly { label: string }[]) => [
151
154
  ...presets.map(({ label }) => label),
152
155
  "Custom…",
153
156
  ];
157
+ const workingLinePlacementLabels: Record<WorkingLinePlacement, string> = {
158
+ above: "Above",
159
+ border: "Border",
160
+ };
161
+ const workingLinePlacementValues = Object.values(workingLinePlacementLabels);
154
162
  const workingLineTextAnimationValues: WorkingLineTextAnimation[] = ["classic", "kitt", "disabled"];
155
163
  const thinkingStepsModeLabels: Record<ThinkingStepsMode, string> = {
156
164
  rail: "Rail",
@@ -728,6 +736,13 @@ function buildMinimalistEditorStyleItems(config: PolishedTuiConfig): SettingItem
728
736
  currentValue: featureValue(minimalist.showGit),
729
737
  values: featureStateValues,
730
738
  },
739
+ {
740
+ id: "minimalistSeparator",
741
+ label: "Separator",
742
+ description: "Choose dash (–) or dot (·) between editor metadata items.",
743
+ currentValue: minimalist.separator,
744
+ values: minimalistSeparatorValues,
745
+ },
731
746
  ];
732
747
  }
733
748
 
@@ -796,6 +811,16 @@ function buildWorkingLineItems(config: PolishedTuiConfig): SettingItem[] {
796
811
  currentValue: featureValue(workingLine.enabled),
797
812
  values: featureStateValues,
798
813
  },
814
+ ...(config.components.editor.enabled && config.components.editor.style !== "accent-rail"
815
+ ? [
816
+ {
817
+ id: "workingLinePlacement",
818
+ label: "Placement",
819
+ currentValue: workingLinePlacementLabels[workingLine.placement],
820
+ values: workingLinePlacementValues,
821
+ },
822
+ ]
823
+ : []),
799
824
  {
800
825
  id: "workingLineTurnSummary",
801
826
  label: "Turn summary",
@@ -1745,6 +1770,14 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
1745
1770
  { contextFormat: newValue as MinimalistConfig["contextFormat"] },
1746
1771
  ctx,
1747
1772
  );
1773
+ else if (
1774
+ id === "minimalistSeparator" &&
1775
+ (newValue === "dash" || newValue === "dot")
1776
+ )
1777
+ deps.setMinimalist(
1778
+ { separator: newValue as MinimalistEditorSeparator },
1779
+ ctx,
1780
+ );
1748
1781
  else if (enabled !== undefined) {
1749
1782
  const key =
1750
1783
  id === "minimalistContextGauge"
@@ -1817,6 +1850,21 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
1817
1850
  notifyWorkingLineChange("Turn summary", newValue, result, false);
1818
1851
  return;
1819
1852
  }
1853
+ if (id === "workingLinePlacement") {
1854
+ const editor = deps.getConfig().components.editor;
1855
+ if (!editor.enabled || editor.style === "accent-rail") return;
1856
+ const placement = (
1857
+ Object.entries(workingLinePlacementLabels) as Array<
1858
+ [WorkingLinePlacement, string]
1859
+ >
1860
+ ).find(([, label]) => label === newValue)?.[0];
1861
+ if (placement) {
1862
+ const result = deps.setWorkingLineComponent({ placement }, ctx);
1863
+ settingsList.updateValue(id, newValue);
1864
+ notifyWorkingLineChange("Placement", newValue, result);
1865
+ return;
1866
+ }
1867
+ }
1820
1868
  const selectedWorkingLineSpinner =
1821
1869
  id === "workingLineSpinner" ? workingLineSpinnerId(newValue) : undefined;
1822
1870
  if (selectedWorkingLineSpinner) {
@@ -35,6 +35,9 @@ import {
35
35
  safeThemeFg,
36
36
  } from "./style";
37
37
 
38
+ // Reserve at least four label cells beside Minimalist metadata.
39
+ const MIN_WORKING_LINE_BORDER_WIDTH = 16;
40
+
38
41
  const LEGACY_SPLIT_POLISHED_FRAME = Symbol.for("pi-zentui.polished-frame");
39
42
 
40
43
  export type ViewportCounts = {
@@ -117,6 +120,7 @@ export type PolishedEditorFrameOptions = {
117
120
  modelMeta: EditorMeta;
118
121
  thinkingLevel?: string;
119
122
  rightStatus?: string;
123
+ workingLineFrame?: string;
120
124
  shellMode?: boolean;
121
125
  borderColor?: (text: string) => string;
122
126
  };
@@ -134,6 +138,8 @@ type PolishedFrameOptions = {
134
138
  shellMode?: boolean;
135
139
  ownedFrame?: PolishedFrameSplit;
136
140
  trustedBaseFrame?: boolean;
141
+ getWorkingLineFrame: () => string | undefined;
142
+ reportWorkingLineBorder: (active: boolean) => void;
137
143
  borderColor?: (text: string) => string;
138
144
  };
139
145
 
@@ -162,7 +168,8 @@ type MinimalistFrameAdapterOptions = {
162
168
  uiTheme: Theme;
163
169
  config: ZentuiConfig;
164
170
  inputText: string;
165
- metadata: MinimalistEditorMetadata;
171
+ getMetadata: () => MinimalistEditorMetadata;
172
+ reportWorkingLineBorder: (active: boolean) => void;
166
173
  ownedFrame?: PolishedFrameSplit;
167
174
  trustedBaseFrame?: boolean;
168
175
  borderColor?: (text: string) => string;
@@ -694,7 +701,8 @@ function renderMinimalistFrameFromBase({
694
701
  uiTheme,
695
702
  config,
696
703
  inputText,
697
- metadata,
704
+ getMetadata,
705
+ reportWorkingLineBorder,
698
706
  ownedFrame,
699
707
  trustedBaseFrame = false,
700
708
  borderColor,
@@ -733,6 +741,16 @@ function renderMinimalistFrameFromBase({
733
741
  below: parsedBottom?.count,
734
742
  };
735
743
  const editorLines = ownedFrame?.editorLines ?? editorFrame.slice(1, -1);
744
+ // Publish safe geometry before reading the controller-gated Working frame.
745
+ reportWorkingLineBorder(
746
+ width >= MIN_WORKING_LINE_BORDER_WIDTH &&
747
+ Boolean(
748
+ ownedFrame ||
749
+ (typeof autocompleteSource.isShowingAutocomplete === "function" &&
750
+ autocompleteCapture?.compatible),
751
+ ),
752
+ );
753
+ const metadata = getMetadata();
736
754
  const lines = renderMinimalistFrame({
737
755
  width,
738
756
  editorLines,
@@ -764,6 +782,8 @@ function renderPolishedFrame({
764
782
  shellMode = false,
765
783
  ownedFrame,
766
784
  trustedBaseFrame = false,
785
+ getWorkingLineFrame,
786
+ reportWorkingLineBorder,
767
787
  borderColor,
768
788
  }: PolishedFrameOptions): PolishedFrameResult {
769
789
  if (width <= 2) return { lines: clampRenderedLines(baseRendered, width), decorated: false };
@@ -804,6 +824,16 @@ function renderPolishedFrame({
804
824
  above: parsedTop?.count,
805
825
  below: parsedBottom?.count,
806
826
  };
827
+ // Publish safe geometry before reading the controller-gated Working frame.
828
+ reportWorkingLineBorder(
829
+ width >= MIN_WORKING_LINE_BORDER_WIDTH &&
830
+ Boolean(
831
+ ownedFrame ||
832
+ (typeof autocompleteSource.isShowingAutocomplete === "function" &&
833
+ autocompleteCapture?.compatible),
834
+ ),
835
+ );
836
+ const workingLineFrame = getWorkingLineFrame();
807
837
  const lines = renderPolishedEditorFrame({
808
838
  width,
809
839
  editorLines,
@@ -814,6 +844,7 @@ function renderPolishedFrame({
814
844
  modelMeta,
815
845
  thinkingLevel,
816
846
  rightStatus,
847
+ workingLineFrame,
817
848
  shellMode,
818
849
  borderColor,
819
850
  });
@@ -848,6 +879,7 @@ export function renderPolishedEditorFrame({
848
879
  modelMeta,
849
880
  thinkingLevel,
850
881
  rightStatus,
882
+ workingLineFrame,
851
883
  shellMode = false,
852
884
  borderColor,
853
885
  }: PolishedEditorFrameOptions): string[] {
@@ -920,13 +952,23 @@ export function renderPolishedEditorFrame({
920
952
  return renderStaticBorder(text);
921
953
  }
922
954
  };
923
- const top = renderBorder(
955
+ let top = renderBorder(
924
956
  renderEditorBorder(
925
957
  width,
926
958
  "above",
927
959
  config.components.editor.viewportIndicators ? viewport.above : undefined,
928
960
  ),
929
961
  );
962
+ if (workingLineFrame && width >= MIN_WORKING_LINE_BORDER_WIDTH) {
963
+ const label = truncateToWidth(workingLineFrame, width - 6, "…");
964
+ top = `${renderBorder("── ")}${label}${renderBorder(
965
+ ` ${renderEditorBorder(
966
+ width - visibleWidth(label) - 4,
967
+ "above",
968
+ config.components.editor.viewportIndicators ? viewport.above : undefined,
969
+ )}`,
970
+ )}`;
971
+ }
930
972
  const bottom = renderBorder(
931
973
  renderEditorBorder(
932
974
  width,
@@ -977,6 +1019,7 @@ export class PolishedEditor extends CustomEditor {
977
1019
  private readonly uiTheme: Theme;
978
1020
 
979
1021
  private readonly mouse = new EditorMouseForwarder();
1022
+ private workingLineBorderCapable = false;
980
1023
 
981
1024
  constructor(
982
1025
  tui: TUI,
@@ -988,6 +1031,7 @@ export class PolishedEditor extends CustomEditor {
988
1031
  getThinkingLevel: () => string | undefined,
989
1032
  getMinimalistMetadata: () => MinimalistEditorMetadata = () => ({ cwd: "" }),
990
1033
  onMinimalistDecorationChange: (active: boolean) => void = () => {},
1034
+ private readonly onWorkingLineBorderCapabilityChange: () => void = () => {},
991
1035
  ) {
992
1036
  super(tui, theme, keybindings, { paddingX: 0 });
993
1037
  this.borderColor = (text: string) => safeThemeFg(uiTheme, "border", text);
@@ -997,6 +1041,18 @@ export class PolishedEditor extends CustomEditor {
997
1041
  this.getThinkingLevel = getThinkingLevel;
998
1042
  this.getMinimalistMetadata = getMinimalistMetadata;
999
1043
  this.onMinimalistDecorationChange = onMinimalistDecorationChange;
1044
+ // Pi defines focused as an instance field; observe its public focus lifecycle
1045
+ // without shadowing the native cursor's value with a prototype accessor.
1046
+ let focused = this.focused;
1047
+ Object.defineProperty(this, "focused", {
1048
+ configurable: true,
1049
+ enumerable: true,
1050
+ get: () => focused,
1051
+ set: (value: boolean) => {
1052
+ focused = value;
1053
+ if (!value) this.reportWorkingLineBorder(false);
1054
+ },
1055
+ });
1000
1056
  // Resolve subclass prototype overrides before installing the instance adapter.
1001
1057
  // Their super call reaches the native prototype directly, so translate once.
1002
1058
  const handleMouse = (this as unknown as { handleMouse?: EditorMouseHandler }).handleMouse;
@@ -1018,20 +1074,42 @@ export class PolishedEditor extends CustomEditor {
1018
1074
  return this.mouse.baseRendered(width, super.render(width));
1019
1075
  }
1020
1076
 
1077
+ canEmbedWorkingLineBorder(): boolean {
1078
+ return this.focused && this.workingLineBorderCapable;
1079
+ }
1080
+
1081
+ private reportWorkingLineBorder = (active: boolean): void => {
1082
+ active = active && this.focused;
1083
+ if (this.workingLineBorderCapable === active) return;
1084
+ this.workingLineBorderCapable = active;
1085
+ this.onWorkingLineBorderCapabilityChange();
1086
+ };
1087
+
1088
+ private renderFallback(lines: string[], width: number): string[] {
1089
+ this.reportWorkingLineBorder(false);
1090
+ return clampRenderedLines(lines, width);
1091
+ }
1092
+
1021
1093
  render(width: number): string[] {
1022
- return this.mouse.rendered(this.renderDecorated(width));
1094
+ try {
1095
+ return this.mouse.rendered(this.renderDecorated(width));
1096
+ } catch (error) {
1097
+ this.reportWorkingLineBorder(false);
1098
+ throw error;
1099
+ }
1023
1100
  }
1024
1101
 
1025
1102
  private renderDecorated(width: number): string[] {
1026
1103
  const config = this.getConfig();
1027
1104
  if (!config.components.editor.enabled) {
1028
1105
  this.reportMinimalistDecoration(false);
1029
- return clampRenderedLines(this.renderBase(width), width);
1106
+ return this.renderFallback(this.renderBase(width), width);
1030
1107
  }
1031
1108
  if (config.components.editor.style === "accent-rail") {
1109
+ this.reportWorkingLineBorder(false);
1032
1110
  this.reportMinimalistDecoration(false);
1033
1111
  if (width < ACCENT_RAIL_CHROME_WIDTH + 1) {
1034
- return clampRenderedLines(this.renderBase(width), width);
1112
+ return this.renderFallback(this.renderBase(width), width);
1035
1113
  }
1036
1114
  let captured: { value: string[]; capture?: AutocompleteCapture };
1037
1115
  try {
@@ -1040,7 +1118,7 @@ export class PolishedEditor extends CustomEditor {
1040
1118
  () => this.renderBase(width - ACCENT_RAIL_CHROME_WIDTH),
1041
1119
  );
1042
1120
  } catch {
1043
- return clampRenderedLines(this.renderBase(width), width);
1121
+ return this.renderFallback(this.renderBase(width), width);
1044
1122
  }
1045
1123
  try {
1046
1124
  const result = renderAccentRailFrameFromBase({
@@ -1057,12 +1135,12 @@ export class PolishedEditor extends CustomEditor {
1057
1135
  } catch {
1058
1136
  // Decoration is optional; preserve the completed same-render rows below.
1059
1137
  }
1060
- return clampRenderedLines(captured.value, width);
1138
+ return this.renderFallback(captured.value, width);
1061
1139
  }
1062
1140
  if (config.components.editor.style === "minimalist") {
1063
1141
  if (width <= 4) {
1064
1142
  this.reportMinimalistDecoration(false);
1065
- return clampRenderedLines(this.renderBase(width), width);
1143
+ return this.renderFallback(this.renderBase(width), width);
1066
1144
  }
1067
1145
  let captured: { value: string[]; capture?: AutocompleteCapture };
1068
1146
  try {
@@ -1072,7 +1150,7 @@ export class PolishedEditor extends CustomEditor {
1072
1150
  );
1073
1151
  } catch {
1074
1152
  this.reportMinimalistDecoration(false);
1075
- return clampRenderedLines(this.renderBase(width), width);
1153
+ return this.renderFallback(this.renderBase(width), width);
1076
1154
  }
1077
1155
  try {
1078
1156
  const result = renderMinimalistFrameFromBase({
@@ -1083,20 +1161,22 @@ export class PolishedEditor extends CustomEditor {
1083
1161
  uiTheme: this.uiTheme,
1084
1162
  config,
1085
1163
  inputText: this.getText(),
1086
- metadata: this.getMinimalistMetadata(),
1164
+ getMetadata: this.getMinimalistMetadata,
1165
+ reportWorkingLineBorder: this.reportWorkingLineBorder,
1087
1166
  trustedBaseFrame: true,
1088
1167
  borderColor: this.borderColor,
1089
1168
  });
1090
1169
  this.reportMinimalistDecoration(result.decorated);
1170
+ if (!result.decorated) this.reportWorkingLineBorder(false);
1091
1171
  return result.lines;
1092
1172
  } catch {
1093
1173
  this.reportMinimalistDecoration(false);
1094
- return clampRenderedLines(captured.value, width);
1174
+ return this.renderFallback(captured.value, width);
1095
1175
  }
1096
1176
  }
1097
1177
  this.reportMinimalistDecoration(false);
1098
1178
  if (width <= 2) {
1099
- return clampRenderedLines(this.renderBase(width), width);
1179
+ return this.renderFallback(this.renderBase(width), width);
1100
1180
  }
1101
1181
 
1102
1182
  const shellMode = isShellModeInput(this.getText());
@@ -1108,7 +1188,7 @@ export class PolishedEditor extends CustomEditor {
1108
1188
  this.renderBase(innerWidth),
1109
1189
  );
1110
1190
  } catch {
1111
- return clampRenderedLines(this.renderBase(width), width);
1191
+ return this.renderFallback(this.renderBase(width), width);
1112
1192
  }
1113
1193
  try {
1114
1194
  const result = renderPolishedFrame({
@@ -1119,6 +1199,8 @@ export class PolishedEditor extends CustomEditor {
1119
1199
  uiTheme: this.uiTheme,
1120
1200
  config,
1121
1201
  modelMeta: this.getModelMeta(),
1202
+ getWorkingLineFrame: () => this.getMinimalistMetadata().workingLineFrame,
1203
+ reportWorkingLineBorder: this.reportWorkingLineBorder,
1122
1204
  thinkingLevel: this.getThinkingLevel(),
1123
1205
  shellMode,
1124
1206
  trustedBaseFrame: true,
@@ -1128,7 +1210,7 @@ export class PolishedEditor extends CustomEditor {
1128
1210
  } catch {
1129
1211
  // Decoration is optional; preserve the completed same-render rows below.
1130
1212
  }
1131
- return clampRenderedLines(captured.value, width);
1213
+ return this.renderFallback(captured.value, width);
1132
1214
  }
1133
1215
  }
1134
1216
 
@@ -1141,6 +1223,7 @@ export class WrappedPolishedEditor implements EditorComponent {
1141
1223
  declare readonly setAutocompleteMaxVisible?: (maxVisible: number) => void;
1142
1224
 
1143
1225
  private readonly mouse = new EditorMouseForwarder();
1226
+ private workingLineBorderCapable = false;
1144
1227
 
1145
1228
  constructor(
1146
1229
  private readonly base: WrappedEditor,
@@ -1150,6 +1233,7 @@ export class WrappedPolishedEditor implements EditorComponent {
1150
1233
  private readonly getThinkingLevel: () => string | undefined,
1151
1234
  private readonly getMinimalistMetadata: () => MinimalistEditorMetadata = () => ({ cwd: "" }),
1152
1235
  private readonly onMinimalistDecorationChange: (active: boolean) => void = () => {},
1236
+ private readonly onWorkingLineBorderCapabilityChange: () => void = () => {},
1153
1237
  ) {
1154
1238
  if (typeof base.handleMouse === "function") {
1155
1239
  this.handleMouse = (event) => {
@@ -1178,7 +1262,11 @@ export class WrappedPolishedEditor implements EditorComponent {
1178
1262
  return Boolean(this.base.focused);
1179
1263
  }
1180
1264
  set focused(value: boolean) {
1181
- this.base.focused = value;
1265
+ try {
1266
+ this.base.focused = value;
1267
+ } finally {
1268
+ if (!value) this.reportWorkingLineBorder(false);
1269
+ }
1182
1270
  }
1183
1271
 
1184
1272
  get borderColor(): ((str: string) => string) | undefined {
@@ -1259,20 +1347,42 @@ export class WrappedPolishedEditor implements EditorComponent {
1259
1347
  return this.mouse.baseRendered(width, this.base.render(width));
1260
1348
  }
1261
1349
 
1350
+ canEmbedWorkingLineBorder(): boolean {
1351
+ return this.focused && this.workingLineBorderCapable;
1352
+ }
1353
+
1354
+ private reportWorkingLineBorder = (active: boolean): void => {
1355
+ active = active && this.focused;
1356
+ if (this.workingLineBorderCapable === active) return;
1357
+ this.workingLineBorderCapable = active;
1358
+ this.onWorkingLineBorderCapabilityChange();
1359
+ };
1360
+
1361
+ private renderFallback(lines: string[], width: number): string[] {
1362
+ this.reportWorkingLineBorder(false);
1363
+ return clampRenderedLines(lines, width);
1364
+ }
1365
+
1262
1366
  render(width: number): string[] {
1263
- return this.mouse.rendered(this.renderDecorated(width));
1367
+ try {
1368
+ return this.mouse.rendered(this.renderDecorated(width));
1369
+ } catch (error) {
1370
+ this.reportWorkingLineBorder(false);
1371
+ throw error;
1372
+ }
1264
1373
  }
1265
1374
 
1266
1375
  private renderDecorated(width: number): string[] {
1267
1376
  const config = this.getConfig();
1268
1377
  if (!config.components.editor.enabled) {
1269
1378
  this.reportMinimalistDecoration(false);
1270
- return clampRenderedLines(this.renderBase(width), width);
1379
+ return this.renderFallback(this.renderBase(width), width);
1271
1380
  }
1272
1381
  if (config.components.editor.style === "accent-rail") {
1382
+ this.reportWorkingLineBorder(false);
1273
1383
  this.reportMinimalistDecoration(false);
1274
1384
  if (width < ACCENT_RAIL_CHROME_WIDTH + 1) {
1275
- return clampRenderedLines(this.renderBase(width), width);
1385
+ return this.renderFallback(this.renderBase(width), width);
1276
1386
  }
1277
1387
  let captured: { value: string[]; capture?: AutocompleteCapture };
1278
1388
  try {
@@ -1280,7 +1390,7 @@ export class WrappedPolishedEditor implements EditorComponent {
1280
1390
  this.renderBase(width - ACCENT_RAIL_CHROME_WIDTH),
1281
1391
  );
1282
1392
  } catch {
1283
- return clampRenderedLines(this.renderBase(width), width);
1393
+ return this.renderFallback(this.renderBase(width), width);
1284
1394
  }
1285
1395
  try {
1286
1396
  const provenance = inspectPolishedFrameProvenance(
@@ -1305,12 +1415,12 @@ export class WrappedPolishedEditor implements EditorComponent {
1305
1415
  } catch {
1306
1416
  // Decoration is optional; preserve the completed same-render rows below.
1307
1417
  }
1308
- return clampRenderedLines(captured.value, width);
1418
+ return this.renderFallback(captured.value, width);
1309
1419
  }
1310
1420
  if (config.components.editor.style === "minimalist") {
1311
1421
  if (width <= 4) {
1312
1422
  this.reportMinimalistDecoration(false);
1313
- return clampRenderedLines(this.renderBase(width), width);
1423
+ return this.renderFallback(this.renderBase(width), width);
1314
1424
  }
1315
1425
  let captured: { value: string[]; capture?: AutocompleteCapture };
1316
1426
  try {
@@ -1319,7 +1429,7 @@ export class WrappedPolishedEditor implements EditorComponent {
1319
1429
  );
1320
1430
  } catch {
1321
1431
  this.reportMinimalistDecoration(false);
1322
- return clampRenderedLines(this.renderBase(width), width);
1432
+ return this.renderFallback(this.renderBase(width), width);
1323
1433
  }
1324
1434
  try {
1325
1435
  const provenance = inspectPolishedFrameProvenance(
@@ -1337,7 +1447,8 @@ export class WrappedPolishedEditor implements EditorComponent {
1337
1447
  uiTheme: this.uiTheme,
1338
1448
  config,
1339
1449
  inputText: this.base.getText(),
1340
- metadata: this.getMinimalistMetadata(),
1450
+ getMetadata: this.getMinimalistMetadata,
1451
+ reportWorkingLineBorder: this.reportWorkingLineBorder,
1341
1452
  ownedFrame: provenance.ownedFrame,
1342
1453
  borderColor: this.borderColor,
1343
1454
  });
@@ -1350,10 +1461,10 @@ export class WrappedPolishedEditor implements EditorComponent {
1350
1461
  // Decoration is optional; preserve the completed same-render rows below.
1351
1462
  }
1352
1463
  this.reportMinimalistDecoration(false);
1353
- return clampRenderedLines(captured.value, width);
1464
+ return this.renderFallback(captured.value, width);
1354
1465
  }
1355
1466
  this.reportMinimalistDecoration(false);
1356
- if (width <= 2) return clampRenderedLines(this.renderBase(width), width);
1467
+ if (width <= 2) return this.renderFallback(this.renderBase(width), width);
1357
1468
 
1358
1469
  const shellMode = isShellModeInput(this.base.getText());
1359
1470
  const { railWidth } = getEditorChromeWidths(config, this.uiTheme, "\x1b[0m", shellMode);
@@ -1362,7 +1473,7 @@ export class WrappedPolishedEditor implements EditorComponent {
1362
1473
  try {
1363
1474
  captured = renderWithAutocompleteCapture(this.base, () => this.renderBase(innerWidth));
1364
1475
  } catch {
1365
- return clampRenderedLines(this.renderBase(width), width);
1476
+ return this.renderFallback(this.renderBase(width), width);
1366
1477
  }
1367
1478
  let result: PolishedFrameResult | undefined;
1368
1479
  try {
@@ -1381,6 +1492,8 @@ export class WrappedPolishedEditor implements EditorComponent {
1381
1492
  uiTheme: this.uiTheme,
1382
1493
  config,
1383
1494
  modelMeta: this.getModelMeta(),
1495
+ getWorkingLineFrame: () => this.getMinimalistMetadata().workingLineFrame,
1496
+ reportWorkingLineBorder: this.reportWorkingLineBorder,
1384
1497
  thinkingLevel: this.getThinkingLevel(),
1385
1498
  rightStatus: readVimStatus(this.base, this.uiTheme),
1386
1499
  shellMode,
@@ -1392,7 +1505,7 @@ export class WrappedPolishedEditor implements EditorComponent {
1392
1505
  // Decoration is optional; preserve the completed same-render rows below.
1393
1506
  }
1394
1507
  if (result?.decorated) return result.lines;
1395
- return clampRenderedLines(captured.value, width);
1508
+ return this.renderFallback(captured.value, width);
1396
1509
  }
1397
1510
 
1398
1511
  invalidate(): void {
@@ -59,6 +59,7 @@ type GraphemeCell = { text: string; start: number; width: number };
59
59
  type WorkingLineUi = {
60
60
  setWorkingMessage(message?: string): void;
61
61
  setWorkingIndicator(options?: { frames?: string[]; intervalMs?: number }): void;
62
+ setWorkingVisible?(visible: boolean): void;
62
63
  };
63
64
 
64
65
  type WorkingLineContext = {
@@ -1144,6 +1145,13 @@ export class WorkingLineController {
1144
1145
  private ownsIndicator = false;
1145
1146
  private ownsMessage = false;
1146
1147
  private agentActive = false;
1148
+ private animationTimer?: ReturnType<typeof setInterval>;
1149
+ private animationIntervalMs: number | undefined;
1150
+ private hiddenWorkingVisible = false;
1151
+ private restoreWorkingVisible: (() => void) | undefined;
1152
+ private installedUi: WorkingLineUi | undefined;
1153
+ private placementContext: WorkingLineContext | undefined;
1154
+ private requestRender: () => void = () => {};
1147
1155
  private selectedMessage: string | undefined;
1148
1156
  private frameKey: string | undefined;
1149
1157
  private installedPhase: InstalledAnimationPhase | undefined;
@@ -1172,9 +1180,11 @@ export class WorkingLineController {
1172
1180
  private readonly getThought: () => WorkingLineRuntimeSegments["thought"] = () => this.thought,
1173
1181
  private readonly onUnavailable: () => void = () => {},
1174
1182
  private readonly metricScheduler: WorkingLineMetricScheduler = defaultMetricScheduler,
1183
+ private readonly canEmbedBorder: (ctx: WorkingLineContext) => boolean = () => false,
1175
1184
  ) {}
1176
1185
 
1177
1186
  startSession(ctx: WorkingLineContext): WorkingLineReconcileResult {
1187
+ this.reset(ctx);
1178
1188
  this.clearRuntime();
1179
1189
  this.selectedMessage = undefined;
1180
1190
  this.installedPhase = undefined;
@@ -1190,6 +1200,7 @@ export class WorkingLineController {
1190
1200
  // stored indicator so the subsequently constructed Loader begins at intended frame zero.
1191
1201
  this.install(ctx, true, true);
1192
1202
  }
1203
+ this.reconcilePlacement(ctx);
1193
1204
  this.reconcileElapsedUpdates(ctx);
1194
1205
  }
1195
1206
 
@@ -1254,9 +1265,12 @@ export class WorkingLineController {
1254
1265
 
1255
1266
  finishAgent(ctx: WorkingLineContext): void {
1256
1267
  this.agentActive = false;
1268
+ this.stopAnimationTicks();
1269
+ this.releaseWorkingVisibility();
1257
1270
  this.activeTools.clear();
1258
1271
  this.deactivateElapsedUpdates();
1259
1272
  this.updateIndicator(ctx);
1273
+ this.requestRender();
1260
1274
  }
1261
1275
 
1262
1276
  flushMetrics(
@@ -1303,6 +1317,26 @@ export class WorkingLineController {
1303
1317
  return this.selectedMessage;
1304
1318
  }
1305
1319
 
1320
+ setRequestRender(requestRender: () => void): void {
1321
+ this.requestRender = requestRender;
1322
+ }
1323
+
1324
+ /** Only the currently capable, owned border renderer may consume this frame. */
1325
+ currentWorkingLineFrame(): string | undefined {
1326
+ if (!this.placementContext || !this.reconcilePlacement(this.placementContext)) return undefined;
1327
+ const frames = this.installedIndicatorOptions?.frames;
1328
+ if (!frames || frames.length === 0) {
1329
+ return this.selectedMessage;
1330
+ }
1331
+ if (!this.installedPhase || this.installedPhase.frameStates.length === 0) {
1332
+ return frames[0];
1333
+ }
1334
+ const interval = this.installedPhase.intervalMs > 0 ? this.installedPhase.intervalMs : 100;
1335
+ const elapsedMs = Math.max(0, this.now() - this.installedPhase.frameEpochMs);
1336
+ const frameIndex = Math.max(0, Math.floor(elapsedMs / interval)) % frames.length;
1337
+ return frames[frameIndex] ?? this.selectedMessage;
1338
+ }
1339
+
1306
1340
  /** Whether this controller currently claims both required public Working-row surfaces. */
1307
1341
  isAvailable(): boolean {
1308
1342
  return (
@@ -1321,12 +1355,14 @@ export class WorkingLineController {
1321
1355
  ): WorkingLineReconcileResult {
1322
1356
  this.cancelMetricIndicator();
1323
1357
  const ui = workingLineUi(ctx);
1358
+ if (this.installedUi && this.installedUi !== ui) this.reset(ctx);
1324
1359
  if (!ui) {
1325
- this.installed = false;
1326
- this.deactivateElapsedUpdates();
1360
+ this.reset(ctx);
1327
1361
  this.invalidateUnavailableExtensionSegments();
1328
1362
  return { applied: false, reason: "Working line requires a newer Pi TUI" };
1329
1363
  }
1364
+ this.installedUi = ui;
1365
+ this.placementContext = ctx;
1330
1366
  const rootConfig = this.getConfig();
1331
1367
  const config = rootConfig.components.workingLine;
1332
1368
  const nextMessage =
@@ -1339,6 +1375,7 @@ export class WorkingLineController {
1339
1375
  }
1340
1376
  this.applyIndicator(ui, rootConfig, nextMessage, forceIndicator, rebasePhase);
1341
1377
  this.selectedMessage = nextMessage;
1378
+ this.reconcilePlacement(ctx);
1342
1379
  this.reconcileElapsedUpdates(ctx);
1343
1380
  return { applied: true };
1344
1381
  } catch {
@@ -1471,12 +1508,22 @@ export class WorkingLineController {
1471
1508
  private updateIndicator(ctx: WorkingLineContext): boolean {
1472
1509
  this.cancelMetricIndicator();
1473
1510
  const rootConfig = this.getConfig();
1474
- if (!rootConfig.components.workingLine.enabled || !this.installed) return false;
1511
+ if (!rootConfig.components.workingLine.enabled) {
1512
+ this.reset(ctx);
1513
+ return false;
1514
+ }
1475
1515
  const ui = workingLineUi(ctx);
1476
- if (!ui) return false;
1516
+ if (!ui || this.installedUi !== ui) {
1517
+ this.reset(ctx);
1518
+ this.invalidateUnavailableExtensionSegments();
1519
+ return false;
1520
+ }
1521
+ if (!this.installed) return false;
1522
+ this.placementContext = ctx;
1477
1523
  const snapshot = this.installationSnapshot();
1478
1524
  try {
1479
1525
  this.applyIndicator(ui, rootConfig);
1526
+ this.reconcilePlacement(ctx);
1480
1527
  return !this.extensionSegmentsDirty;
1481
1528
  } catch {
1482
1529
  // A transient last-writer/public-API failure must not break the agent turn.
@@ -1527,7 +1574,86 @@ export class WorkingLineController {
1527
1574
  this.metricUpdateScheduled = false;
1528
1575
  this.metricUpdateHandle = undefined;
1529
1576
  }
1577
+ /** Placement is independent of the cached row contents and never changes user choices. */
1578
+ private reconcilePlacement(ctx: WorkingLineContext): boolean {
1579
+ let canEmbed = false;
1580
+ const ui = this.installedUi;
1581
+ try {
1582
+ canEmbed = Boolean(
1583
+ this.agentActive &&
1584
+ this.isAvailable() &&
1585
+ this.getConfig().components.workingLine.placement === "border" &&
1586
+ ui &&
1587
+ workingLineUi(ctx) === ui &&
1588
+ typeof ui.setWorkingVisible === "function" &&
1589
+ this.canEmbedBorder(ctx),
1590
+ );
1591
+ } catch {
1592
+ // Capability probes, including predecessor editor getters, must fail open.
1593
+ }
1594
+ if (!canEmbed || !ui) {
1595
+ this.stopAnimationTicks();
1596
+ this.releaseWorkingVisibility();
1597
+ return false;
1598
+ }
1599
+ if (!this.hiddenWorkingVisible) {
1600
+ // A failed restoration remains attached to its original UI, even after reset.
1601
+ this.releaseWorkingVisibility();
1602
+ if (this.restoreWorkingVisible) return false;
1603
+ try {
1604
+ const setVisible = ui.setWorkingVisible;
1605
+ if (typeof setVisible !== "function") return false;
1606
+ this.restoreWorkingVisible = () => setVisible.call(ui, true);
1607
+ setVisible.call(ui, false);
1608
+ this.hiddenWorkingVisible = true;
1609
+ } catch {
1610
+ // A throwing setter may already have hidden the row. Restore without claiming it.
1611
+ this.stopAnimationTicks();
1612
+ this.releaseWorkingVisibility();
1613
+ return false;
1614
+ }
1615
+ }
1616
+ this.startAnimationTicks();
1617
+ return true;
1618
+ }
1619
+
1620
+ private releaseWorkingVisibility(): void {
1621
+ this.hiddenWorkingVisible = false;
1622
+ if (!this.restoreWorkingVisible) return;
1623
+ try {
1624
+ this.restoreWorkingVisible();
1625
+ this.restoreWorkingVisible = undefined;
1626
+ } catch {
1627
+ // Keep the original setter for a later cleanup attempt, even without row ownership.
1628
+ }
1629
+ }
1630
+
1631
+ private startAnimationTicks(): void {
1632
+ const interval = this.installedPhase?.intervalMs;
1633
+ if (!interval || interval <= 0) {
1634
+ this.stopAnimationTicks();
1635
+ return;
1636
+ }
1637
+ if (this.animationTimer !== undefined && this.animationIntervalMs === interval) return;
1638
+ this.stopAnimationTicks();
1639
+ this.animationIntervalMs = interval;
1640
+ this.animationTimer = setInterval(() => {
1641
+ const ctx = this.placementContext;
1642
+ if (!ctx || !this.reconcilePlacement(ctx)) {
1643
+ this.stopAnimationTicks();
1644
+ return;
1645
+ }
1646
+ this.requestRender();
1647
+ }, interval);
1648
+ }
1530
1649
 
1650
+ private stopAnimationTicks(): void {
1651
+ if (this.animationTimer !== undefined) {
1652
+ clearInterval(this.animationTimer);
1653
+ this.animationTimer = undefined;
1654
+ }
1655
+ this.animationIntervalMs = undefined;
1656
+ }
1531
1657
  private reconcileElapsedUpdates(ctx: WorkingLineContext): void {
1532
1658
  this.elapsedUpdatesContext = ctx;
1533
1659
  const config = this.getConfig().components.workingLine;
@@ -1594,6 +1720,7 @@ export class WorkingLineController {
1594
1720
  try {
1595
1721
  ui.setWorkingIndicator(snapshot.indicatorOptions);
1596
1722
  if (snapshot.ownsMessage) ui.setWorkingMessage("");
1723
+ if (this.placementContext) this.reconcilePlacement(this.placementContext);
1597
1724
  } catch {
1598
1725
  // Recovery is deliberately direct rather than recursive. If either setter cannot
1599
1726
  // restore the last successful public state, release both unkeyed surfaces.
@@ -1611,7 +1738,10 @@ export class WorkingLineController {
1611
1738
  }
1612
1739
 
1613
1740
  private releaseAfterFailure(ui: WorkingLineUi): void {
1741
+ this.cancelMetricIndicator();
1614
1742
  this.deactivateElapsedUpdates();
1743
+ this.stopAnimationTicks();
1744
+ this.releaseWorkingVisibility();
1615
1745
  if (this.ownsIndicator) {
1616
1746
  try {
1617
1747
  ui.setWorkingIndicator();
@@ -1632,14 +1762,17 @@ export class WorkingLineController {
1632
1762
  this.frameKey = undefined;
1633
1763
  this.installedPhase = undefined;
1634
1764
  this.installedIndicatorOptions = undefined;
1765
+ this.installedUi = undefined;
1766
+ this.placementContext = undefined;
1635
1767
  this.invalidateUnavailableExtensionSegments();
1636
1768
  }
1637
1769
 
1638
1770
  private reset(ctx: WorkingLineContext): void {
1639
1771
  this.cancelMetricIndicator();
1640
1772
  this.deactivateElapsedUpdates();
1641
- if (!this.ownsIndicator && !this.ownsMessage) return;
1642
- const ui = workingLineUi(ctx);
1773
+ this.stopAnimationTicks();
1774
+ this.releaseWorkingVisibility();
1775
+ const ui = this.installedUi ?? workingLineUi(ctx);
1643
1776
  if (ui && this.ownsIndicator) {
1644
1777
  try {
1645
1778
  ui.setWorkingIndicator();
@@ -1660,6 +1793,8 @@ export class WorkingLineController {
1660
1793
  this.frameKey = undefined;
1661
1794
  this.installedPhase = undefined;
1662
1795
  this.installedIndicatorOptions = undefined;
1796
+ this.installedUi = undefined;
1797
+ this.placementContext = undefined;
1663
1798
  }
1664
1799
 
1665
1800
  private clearRuntime(): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.26.1",
3
+ "version": "0.27.0",
4
4
  "description": "Modular UI components for Pi — customizable editors, messages, progress displays, and statuslines.",
5
5
  "type": "module",
6
6
  "license": "MIT",