@oh-my-pi/pi-tui 16.3.5 → 16.3.7

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.
@@ -65,6 +65,7 @@ export class ImageBudget {
65
65
  #requestRender: () => void;
66
66
  #nextId = nextImageIdSeed();
67
67
  #keyToId = new Map<string, number>();
68
+ #idToKey = new Map<number, string>();
68
69
  /** Display-order image ids observed during the in-flight pass. */
69
70
  #passIds: number[] = [];
70
71
  /**
@@ -132,6 +133,7 @@ export class ImageBudget {
132
133
  const id = this.#nextId;
133
134
  this.#nextId = (this.#nextId + 1) & 0xffffff || 1;
134
135
  this.#keyToId.set(key, id);
136
+ this.#idToKey.set(id, key);
135
137
  return id;
136
138
  }
137
139
  const id = this.#nextId;
@@ -163,11 +165,15 @@ export class ImageBudget {
163
165
  */
164
166
  observe(imageId: number): boolean {
165
167
  if (this.#stablePass) {
166
- return this.#cap > 0 && this.#suppressedIds.has(imageId);
168
+ const suppressed = this.#cap > 0 && this.#suppressedIds.has(imageId);
169
+ if (suppressed) this.#forgetKeyForId(imageId);
170
+ return suppressed;
167
171
  }
168
172
  const index = this.#passIds.length;
169
173
  this.#passIds.push(imageId);
170
- return this.#cap > 0 && index < this.#planned;
174
+ const suppressed = this.#cap > 0 && index < this.#planned;
175
+ if (suppressed) this.#forgetKeyForId(imageId);
176
+ return suppressed;
171
177
  }
172
178
 
173
179
  /**
@@ -185,6 +191,7 @@ export class ImageBudget {
185
191
  this.#purgeIds.push(id);
186
192
  // d=I frees the data too, so the image must re-transmit if it returns.
187
193
  this.#transmitted.delete(id);
194
+ this.#forgetKeyForId(id);
188
195
  }
189
196
  this.#onTerminal = this.#planned;
190
197
  this.#applyingReset = false;
@@ -214,6 +221,8 @@ export class ImageBudget {
214
221
  this.#transmitted.clear();
215
222
  this.#purgeIds = [];
216
223
  this.#pendingTransmits = [];
224
+ this.#keyToId.clear();
225
+ this.#idToKey.clear();
217
226
  return ids;
218
227
  }
219
228
 
@@ -274,6 +283,13 @@ export class ImageBudget {
274
283
  this.#pendingTransmits = [];
275
284
  }
276
285
 
286
+ #forgetKeyForId(id: number): void {
287
+ const key = this.#idToKey.get(id);
288
+ if (key === undefined) return;
289
+ this.#idToKey.delete(id);
290
+ if (this.#keyToId.get(key) === id) this.#keyToId.delete(key);
291
+ }
292
+
277
293
  #reconcile(total: number): void {
278
294
  const desired = this.#cap > 0 ? Math.max(0, total - this.#cap) : 0;
279
295
  if (desired === this.#planned) {
@@ -817,6 +817,21 @@ export class Markdown implements Component {
817
817
  #streamPrefixText?: string;
818
818
  #streamPrefixTokens?: Token[];
819
819
  #streamPrefixLineCache?: StreamPrefixLineCache;
820
+ // Rows of the most recent render() that are settled — top padding plus the
821
+ // rendered frozen token prefix — exposed via getLastRenderSettledRows()
822
+ // for native-scrollback commit gating.
823
+ #lastRenderSettledRows = 0;
824
+ // Frozen-prefix text backing the last non-zero settled exposure. Settled
825
+ // rows are declared final downstream, so a render whose frozen text no
826
+ // longer extends this prefix (a rewind / wholesale rewrite) resets the
827
+ // exposure to 0 and re-earns it — the exposure is hard-monotone within a
828
+ // text lineage.
829
+ #settledExposedText?: string;
830
+ // True while #renderStreamingContentLines renders the frozen token range:
831
+ // frozen code blocks highlight even in transient mode so their bytes match
832
+ // the finalized render (they render once into the prefix line cache, so
833
+ // the FFI cost is amortized); the volatile tail stays unhighlighted.
834
+ #renderingFrozenPrefix = false;
820
835
 
821
836
  #ignoreTight = false;
822
837
 
@@ -857,6 +872,7 @@ export class Markdown implements Component {
857
872
  this.#streamPrefixText = undefined;
858
873
  this.#streamPrefixTokens = undefined;
859
874
  this.#streamPrefixLineCache = undefined;
875
+ this.#settledExposedText = undefined;
860
876
  }
861
877
  this.invalidate();
862
878
  return true;
@@ -878,6 +894,19 @@ export class Markdown implements Component {
878
894
  this.invalidate();
879
895
  }
880
896
 
897
+ /**
898
+ * Rows at the top of the most recent render() (top padding + rendered
899
+ * frozen-token prefix) whose bytes are settled: byte-stable at this
900
+ * width/theme for as long as the text keeps growing append-only. Hosts
901
+ * feed this to transcript commit gating (see the coding agent's
902
+ * `FinalizableBlock.getTranscriptBlockSettledRows`). 0 outside streaming
903
+ * (`transientRenderCache`) mode, after a text rewind (re-earned on the new
904
+ * lineage), and on cache-served non-streaming renders.
905
+ */
906
+ getLastRenderSettledRows(): number {
907
+ return this.#lastRenderSettledRows;
908
+ }
909
+
881
910
  // Lex `text` into block tokens, reusing the frozen stable prefix when the text
882
911
  // only grew (the streaming path). Falls back to a full lex whenever the prefix
883
912
  // is no longer a prefix (non-append edit), the text carries reference-link
@@ -965,6 +994,10 @@ export class Markdown implements Component {
965
994
  return this.#cachedLines;
966
995
  }
967
996
 
997
+ // Recomputed below by the streaming path; every other path (cache-served,
998
+ // empty text, non-streaming full render) exposes no settled rows.
999
+ this.#lastRenderSettledRows = 0;
1000
+
968
1001
  // Calculate available width for content (subtract horizontal padding)
969
1002
  const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
970
1003
  const contentWidth = Math.max(1, width - paddingX * 2);
@@ -1076,9 +1109,16 @@ export class Markdown implements Component {
1076
1109
  }
1077
1110
 
1078
1111
  if (renderedUntil < frozenTokenCount) {
1079
- contentLines.push(
1080
- ...this.#renderContentLines(tokens, renderedUntil, frozenTokenCount, contentWidth, signature),
1081
- );
1112
+ // Frozen tokens render with full fidelity (syntax highlighting on)
1113
+ // so these cached rows byte-match the finalized render.
1114
+ this.#renderingFrozenPrefix = true;
1115
+ try {
1116
+ contentLines.push(
1117
+ ...this.#renderContentLines(tokens, renderedUntil, frozenTokenCount, contentWidth, signature),
1118
+ );
1119
+ } finally {
1120
+ this.#renderingFrozenPrefix = false;
1121
+ }
1082
1122
  renderedUntil = frozenTokenCount;
1083
1123
  }
1084
1124
 
@@ -1089,6 +1129,19 @@ export class Markdown implements Component {
1089
1129
  lines: contentLines.slice(),
1090
1130
  };
1091
1131
 
1132
+ // Settled exposure (hard-monotone): these rows are declared final to
1133
+ // the host, so expose them only while the frozen text still extends
1134
+ // the previously exposed prefix; a rewind resets to 0 and re-earns on
1135
+ // the rewritten lineage.
1136
+ if (contentLines.length > 0) {
1137
+ if (this.#settledExposedText === undefined || frozenText.startsWith(this.#settledExposedText)) {
1138
+ this.#settledExposedText = frozenText;
1139
+ this.#lastRenderSettledRows = signature.paddingY + contentLines.length;
1140
+ } else {
1141
+ this.#settledExposedText = undefined;
1142
+ }
1143
+ }
1144
+
1092
1145
  if (renderedUntil < tokens.length) {
1093
1146
  contentLines.push(...this.#renderContentLines(tokens, renderedUntil, tokens.length, contentWidth, signature));
1094
1147
  }
@@ -1361,7 +1414,7 @@ export class Markdown implements Component {
1361
1414
 
1362
1415
  const codeIndent = padding(this.#codeBlockIndent);
1363
1416
  lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
1364
- if (this.#theme.highlightCode && !this.transientRenderCache) {
1417
+ if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
1365
1418
  const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
1366
1419
  for (const hlLine of highlightedLines) {
1367
1420
  lines.push(`${codeIndent}${hlLine}`);
@@ -1751,7 +1804,7 @@ export class Markdown implements Component {
1751
1804
  // Code block in list item
1752
1805
  const codeIndent = padding(this.#codeBlockIndent);
1753
1806
  lines.push({ text: this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`), nested: false });
1754
- if (this.#theme.highlightCode && !this.transientRenderCache) {
1807
+ if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
1755
1808
  const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
1756
1809
  for (const hlLine of highlightedLines) {
1757
1810
  lines.push({ text: `${codeIndent}${hlLine}`, nested: false });
@@ -122,6 +122,13 @@ export class TerminalInfo {
122
122
  process.stdout.write(`${wrapTmuxPassthrough(formatted)}\x07`);
123
123
  return;
124
124
  }
125
+ // Zellij drops OSC 9/99 and has no DCS passthrough envelope, but raises its
126
+ // `[!]` bell flag on a bare BEL — the same backgrounded-pane signal tmux
127
+ // users get. So follow the (Zellij-swallowed) OSC with a plain BEL.
128
+ if (this.notifyProtocol !== NotifyProtocol.Bell && isInsideZellij()) {
129
+ process.stdout.write(`${formatted}\x07`);
130
+ return;
131
+ }
125
132
  process.stdout.write(formatted);
126
133
  // VTE-family terminals (Ptyxis, GNOME Terminal, Tilix, …) plus Alacritty
127
134
  // and bare xterm-on-Wayland have no in-band escape that surfaces an
@@ -156,6 +163,15 @@ export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): b
156
163
  return term.startsWith("tmux") || term.startsWith("screen");
157
164
  }
158
165
 
166
+ /**
167
+ * Whether the agent process is running inside a Zellij session. Read fresh on
168
+ * each call (like {@link isInsideTmux}) so a session attached/detached mid-run
169
+ * is observed and tests can toggle `Bun.env.ZELLIJ` per case.
170
+ */
171
+ export function isInsideZellij(env: NodeJS.ProcessEnv = Bun.env): boolean {
172
+ return Boolean(env.ZELLIJ);
173
+ }
174
+
159
175
  /**
160
176
  * Wrap a control-sequence payload in tmux's DCS passthrough envelope. Each
161
177
  * ESC byte inside `payload` is doubled per tmux's escape rules. tmux strips