@oh-my-pi/pi-tui 18.9.1 → 18.10.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.
@@ -96,12 +96,37 @@ interface TranscriptEntry {
96
96
  * state for emitted-row slicing but never emits another mid-stream row.
97
97
  */
98
98
  stableFrozen: boolean;
99
+ /**
100
+ * Head rows of a settled block already in native history while the rest
101
+ * stays live: pressure commits only the rows that left the screen, so no
102
+ * committed row is ever still visible above the viewport.
103
+ */
104
+ split: RowSplit | undefined;
105
+ }
106
+
107
+ /**
108
+ * A committed physical row prefix of one block. `rows` counts trimmed render
109
+ * rows at `width` (any emitted stable prefix included) and `hash` fingerprints
110
+ * them; `text` is their letters and digits in order. A settled block can still
111
+ * change (a tool pane expanding) or rewrap at another width without a replay,
112
+ * so every render re-locates the seam: the committed rows when they render
113
+ * unchanged, else by continuing `text` — per-row decoration (quote bars,
114
+ * borders, rules) is punctuation it ignores.
115
+ */
116
+ interface RowSplit {
117
+ readonly rows: number;
118
+ readonly width: number;
119
+ readonly hash: number | bigint;
120
+ readonly text: string;
121
+ /** Seam of the last render it was located in; renders are memoized per frame. */
122
+ located?: { readonly rendered: readonly string[]; readonly count: number };
99
123
  }
100
124
 
101
125
  type RetirementPolicy = "pressure" | "flush";
102
126
  type Offered =
103
127
  | { batch: HistoryBatch; kind: "append"; entry: number; emittedEnd: number }
104
- | { batch: HistoryBatch; kind: "commit"; end: number }
128
+ /** Whole blocks `[frontier, end)`, then optionally the head rows of block `end`. */
129
+ | { batch: HistoryBatch; kind: "commit"; end: number; split?: RowSplit }
105
130
  | { batch: HistoryBatch; kind: "replay" };
106
131
 
107
132
  /** Rows a progressive-append retirement offers, and the stable count they bring the head to. */
@@ -140,6 +165,24 @@ function isPlainBlank(line: string): boolean {
140
165
  return !/\S/.test(line);
141
166
  }
142
167
 
168
+ /** Letters and digits of `rows` in reading order: the content rewrapping preserves. */
169
+ function contentOf(rows: readonly string[]): string {
170
+ let text = "";
171
+ for (const row of rows) text += Bun.stripANSI(row).replace(/[^\p{L}\p{N}]/gu, "");
172
+ return text;
173
+ }
174
+
175
+ /**
176
+ * Split committing the first `rows` rows of `scrollback` at `width`. The
177
+ * fingerprint covers the scrollback bytes history receives, which can differ
178
+ * from the measured render at the same row count (a SIXEL image encodes
179
+ * synchronously instead of returning its reserved rows).
180
+ */
181
+ function rowSplit(scrollback: readonly string[], rows: number, width: number): RowSplit {
182
+ const committed = scrollback.slice(0, rows);
183
+ return { rows, width, hash: Bun.hash(committed.join("\n")), text: contentOf(committed) };
184
+ }
185
+
143
186
  /** Whether `prefix` matches `rows` byte-for-byte from the top. */
144
187
  export function isRowPrefix(prefix: readonly string[], rows: readonly string[]): boolean {
145
188
  if (prefix.length > rows.length) return false;
@@ -226,12 +269,33 @@ export class TranscriptContainer extends Container {
226
269
  stableRowCountByWidth: new Map(),
227
270
  emitted: 0,
228
271
  stableFrozen: false,
272
+ split: undefined,
229
273
  });
230
- // Callers may splice a just-added block into place (insert after an
231
- // anchor); re-check the whole list once instead of trusting positions.
274
+ // `children` is public and may be edited externally; re-check the whole
275
+ // list once instead of trusting positions. Insert-after-anchor callers
276
+ // use insertChildAt, which keeps both arrays in lockstep.
232
277
  this.#entriesUnverified = true;
233
278
  }
234
279
 
280
+ /**
281
+ * `#entries` mirrors `children`; move both together or the next `#syncEntries`
282
+ * pays a full O(n) rebuild.
283
+ */
284
+ insertChildAt(component: Component, index: number): void {
285
+ this.addChild(component);
286
+ const from = this.children.length - 1;
287
+ if (index >= from) {
288
+ this.#entriesUnverified = false;
289
+ return;
290
+ }
291
+ const [moved] = this.children.splice(from, 1);
292
+ if (moved) this.children.splice(index, 0, moved);
293
+ const [entry] = this.#entries.splice(this.#entries.length - 1, 1);
294
+ if (entry) this.#entries.splice(index, 0, entry);
295
+ // Both arrays moved in lockstep, so the live-tail check applies again.
296
+ this.#entriesUnverified = false;
297
+ }
298
+
235
299
  /** Removes `component` if {@link canRemoveBlock} allows it; otherwise does nothing. */
236
300
  override removeChild(component: Component): void {
237
301
  const index = this.#removableIndex(component);
@@ -280,13 +344,16 @@ export class TranscriptContainer extends Container {
280
344
  */
281
345
  resetStableEmission(): void {
282
346
  this.#syncEntries();
283
- if (this.#offered?.kind === "append") this.#offered = undefined;
347
+ if (this.#offered?.kind === "append" || (this.#offered?.kind === "commit" && this.#offered.split)) {
348
+ this.#offered = undefined;
349
+ }
284
350
  for (const entry of this.#entries) {
285
351
  entry.emitted = 0;
286
352
  entry.stableRows = EMPTY_STABLE_ROWS;
287
353
  entry.renderedStableByWidth = new Map();
288
354
  entry.stableRowCountByWidth = new Map();
289
355
  entry.stableFrozen = false;
356
+ entry.split = undefined;
290
357
  if (entry.mode === "appendOnly") {
291
358
  (entry.component as Component & AppendOnlyTranscriptBlock).resetTranscriptStableRows?.();
292
359
  }
@@ -309,8 +376,8 @@ export class TranscriptContainer extends Container {
309
376
  const index = this.#entries.findLastIndex(entry => entry.component === component);
310
377
  if (index < 0) return -1;
311
378
  const entry = this.#entries[index]!;
312
- if (entry.state === "committed" || entry.emitted > 0) return -1;
313
- if (this.#offered?.kind === "commit" && index < this.#offered.end) return -1;
379
+ if (entry.state === "committed" || entry.emitted > 0 || entry.split !== undefined) return -1;
380
+ if (this.#offered?.kind === "commit" && index < this.#offered.end + (this.#offered.split ? 1 : 0)) return -1;
314
381
  if (this.#offered?.kind === "append" && index === this.#offered.entry) return -1;
315
382
  return index;
316
383
  }
@@ -414,17 +481,21 @@ export class TranscriptContainer extends Container {
414
481
 
415
482
  /**
416
483
  * One block's blank-trimmed render at its full-height allocation. Inside an
417
- * open frame the first measurement of each block is replayed to later ones.
484
+ * open frame the first measurement of each block is replayed to later ones,
485
+ * leaving whatever allocation the viewport has since rendered it at.
418
486
  */
419
487
  #measuredRows(entry: TranscriptEntry, width: number): readonly string[] {
420
- this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
421
- if (this.#openFrame === undefined) return this.#renderEntry(entry, width);
488
+ if (this.#openFrame === undefined) {
489
+ this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
490
+ return this.#renderEntry(entry, width);
491
+ }
422
492
  if (this.#frameRowsWidth !== width) {
423
493
  this.#frameRows.clear();
424
494
  this.#frameRowsWidth = width;
425
495
  }
426
496
  let rows = this.#frameRows.get(entry);
427
497
  if (rows === undefined) {
498
+ this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
428
499
  rows = this.#renderEntry(entry, width);
429
500
  this.#frameRows.set(entry, rows);
430
501
  }
@@ -626,6 +697,20 @@ export class TranscriptContainer extends Container {
626
697
  rows = after.slice(before.length);
627
698
  } else if (offered.kind === "commit") {
628
699
  rows = this.#renderRange(this.#frontier, offered.end, width, true).rows;
700
+ const head = offered.split ? this.#entries[offered.end] : undefined;
701
+ if (head !== undefined && offered.split !== undefined) {
702
+ // The offer's fingerprint covers bytes of a discarded frame history
703
+ // never received (an image-budget retry demotes graphics to text),
704
+ // so the split keeps its cut and re-fingerprints the rows it now
705
+ // delivers. Only another width relocates the cut.
706
+ const start = this.#emittedRowCount(head, width);
707
+ const scrollback = renderForScrollback(() => this.#renderEntry(head, width));
708
+ const cut =
709
+ width === offered.split.width ? offered.split.rows : this.#splitRowCount(head, offered.split, width);
710
+ const end = Math.min(cut, scrollback.length);
711
+ offered.split = rowSplit(scrollback, end, width);
712
+ rows = [...rows, ...scrollback.slice(start, end)];
713
+ }
629
714
  } else {
630
715
  rows = this.#renderReplay(width);
631
716
  }
@@ -654,6 +739,7 @@ export class TranscriptContainer extends Container {
654
739
  head?.mode === "appendOnly" &&
655
740
  !head.stableFrozen &&
656
741
  head.state !== "committed" &&
742
+ head.split === undefined &&
657
743
  head.emitted < head.stableRows.length
658
744
  ? head
659
745
  : undefined;
@@ -719,9 +805,26 @@ export class TranscriptContainer extends Container {
719
805
  limit = Math.max(limit, this.#frontier + (this.#liveCount() - (MAX_LIVE_BLOCKS - 1)));
720
806
  }
721
807
  if (policy === "flush") limit = this.#entries.length;
808
+ // The block straddling the top edge commits only the rows that left the
809
+ // screen; the rest stays live, so a later shrink of the chrome or the
810
+ // tail refills the screen from live rows instead of opening a band no
811
+ // frame can fill (rows still on screen cannot return from scrollback).
812
+ let split: { index: number; rows: readonly string[]; split: RowSplit } | undefined;
813
+ if (policy === "pressure" && keep > 0 && limit === this.#frontier + keep) {
814
+ const boundary = live[keep - 1]!;
815
+ const visible = room - tailRows - (tailRows > 0 ? 1 : 0);
816
+ if (boundary.entry.state === "settled" && visible > 0) {
817
+ const offer = this.#splitOffer(boundary.entry, width, visible);
818
+ if (offer !== undefined) {
819
+ split = { index: boundary.index, ...offer };
820
+ limit = boundary.index;
821
+ }
822
+ }
823
+ }
722
824
  let end = this.#frontier;
723
825
  while (end < limit && end < this.#entries.length && this.#entries[end]!.state === "settled") end++;
724
- if (end === this.#frontier) {
826
+ if (split !== undefined && end !== split.index) split = undefined;
827
+ if (end === this.#frontier && split === undefined) {
725
828
  if (policy === "pressure") this.#notePinnedFrontier();
726
829
  return undefined;
727
830
  }
@@ -741,15 +844,35 @@ export class TranscriptContainer extends Container {
741
844
  } finally {
742
845
  popLoopPhase();
743
846
  }
847
+ if (split !== undefined && retirement.end !== split.index) split = undefined;
744
848
  const batch: HistoryBatch = {
745
849
  id: this.#nextBatchId++,
746
- rows: retirement.rows,
850
+ rows: split === undefined ? retirement.rows : [...retirement.rows, ...split.rows],
747
851
  kind: "append",
748
852
  };
749
- this.#offered = { batch, end: retirement.end, kind: "commit" };
853
+ this.#offered = { batch, end: retirement.end, kind: "commit", split: split?.split };
750
854
  return batch;
751
855
  }
752
856
 
857
+ /**
858
+ * The head rows of settled `entry` beyond its committed prefix that leave
859
+ * `visible` rows live, or undefined when nothing would remain committed
860
+ * past that prefix or the scrollback render would not line up row for row.
861
+ */
862
+ #splitOffer(
863
+ entry: TranscriptEntry,
864
+ width: number,
865
+ visible: number,
866
+ ): { rows: readonly string[]; split: RowSplit } | undefined {
867
+ const measured = this.#measuredRows(entry, width);
868
+ const start = this.#emittedRowCount(entry, width);
869
+ const total = measured.length - visible;
870
+ if (total <= start) return undefined;
871
+ const scrollback = renderForScrollback(() => this.#renderEntry(entry, width));
872
+ if (scrollback.length !== measured.length) return undefined;
873
+ return { rows: scrollback.slice(start, total), split: rowSplit(scrollback, total, width) };
874
+ }
875
+
753
876
  /** Acknowledges exactly the most recently offered append, commit, or replay transaction. */
754
877
  acknowledgeFinalizedBatch(id: number): void {
755
878
  const offered = this.#offered;
@@ -766,6 +889,8 @@ export class TranscriptContainer extends Container {
766
889
  this.#retireEntry(this.#entries[index]!);
767
890
  }
768
891
  this.#frontier = offered.end;
892
+ const head = offered.split ? this.#entries[offered.end] : undefined;
893
+ if (head !== undefined) head.split = offered.split;
769
894
  } else {
770
895
  this.#releaseCommittedRenderCaches();
771
896
  }
@@ -908,14 +1033,32 @@ export class TranscriptContainer extends Container {
908
1033
  }
909
1034
 
910
1035
  /**
911
- * Length-only variant of `#renderStablePrefix`: answers the projected
912
- * emitted row count without re-rendering the prefix. The container only
913
- * needs the length for slicing; the render call it replaced existed
914
- * purely to read `.length` off the result.
1036
+ * Rows of `entry` at `width` already in native history or in the
1037
+ * unacknowledged offer: the row split when one is pending or committed,
1038
+ * else its emitted stable prefix.
915
1039
  */
916
1040
  #projectedEmittedRowCount(entry: TranscriptEntry, index: number, width: number): number {
917
1041
  const offered = this.#offered;
918
- const count = offered?.kind === "append" && offered.entry === index ? offered.emittedEnd : entry.emitted;
1042
+ if (offered?.kind === "commit" && offered.split !== undefined && offered.end === index) {
1043
+ return this.#splitRowCount(entry, offered.split, width);
1044
+ }
1045
+ if (offered?.kind === "append" && offered.entry === index && entry.split === undefined) {
1046
+ return this.#stablePrefixRowCount(entry, offered.emittedEnd, width);
1047
+ }
1048
+ return this.#emittedRowCount(entry, width);
1049
+ }
1050
+
1051
+ /** Rows of `entry` at `width` already acknowledged into native history. */
1052
+ #emittedRowCount(entry: TranscriptEntry, width: number): number {
1053
+ if (entry.split !== undefined) return this.#splitRowCount(entry, entry.split, width);
1054
+ return this.#stablePrefixRowCount(entry, entry.emitted, width);
1055
+ }
1056
+
1057
+ /**
1058
+ * Length-only variant of `#renderStablePrefix`: answers the emitted row
1059
+ * count without re-rendering the prefix when this width already recorded it.
1060
+ */
1061
+ #stablePrefixRowCount(entry: TranscriptEntry, count: number, width: number): number {
919
1062
  if (count === 0) return 0;
920
1063
  const perCount = entry.stableRowCountByWidth.get(width);
921
1064
  const memo = perCount?.get(Math.min(count, entry.stableRows.length));
@@ -923,6 +1066,39 @@ export class TranscriptContainer extends Container {
923
1066
  return this.#renderStablePrefix(entry, count, width).length;
924
1067
  }
925
1068
 
1069
+ /**
1070
+ * Rows of `entry`'s current render at `width` that `split` committed: all
1071
+ * of them while they render unchanged, else the longest prefix whose
1072
+ * content continues the committed text exactly. A row the seam falls
1073
+ * inside, a row the block changed, a row with no content (a rule or other
1074
+ * punctuation the block revealed), or one whose decoration reads as
1075
+ * content stays live: it can repeat committed text but never drop any.
1076
+ */
1077
+ #splitRowCount(entry: TranscriptEntry, split: RowSplit, width: number): number {
1078
+ const rendered = this.#measuredRows(entry, width);
1079
+ if (split.located?.rendered === rendered) return split.located.count;
1080
+ let count = 0;
1081
+ if (
1082
+ width === split.width &&
1083
+ rendered.length >= split.rows &&
1084
+ Bun.hash(rendered.slice(0, split.rows).join("\n")) === split.hash
1085
+ ) {
1086
+ count = split.rows;
1087
+ } else {
1088
+ let consumed = 0;
1089
+ while (count < rendered.length && consumed < split.text.length) {
1090
+ const row = contentOf([rendered[count]!]);
1091
+ // An empty-content row matches at any offset, so the walk cannot
1092
+ // tell it apart from a revealed row history never received.
1093
+ if (row.length === 0 || !split.text.startsWith(row, consumed)) break;
1094
+ consumed += row.length;
1095
+ count++;
1096
+ }
1097
+ }
1098
+ split.located = { rendered, count };
1099
+ return count;
1100
+ }
1101
+
926
1102
  /**
927
1103
  * Size a progressive-append batch from the stable renders `#renderEntry`
928
1104
  * recorded at this width, without rendering any prefix. Each recorded count
@@ -930,7 +1106,8 @@ export class TranscriptContainer extends Container {
930
1106
  * `renderedStableByWidth` holds the newest, so each count's rows are a
931
1107
  * prefix of it and the batch is one slice. Undefined when a count the walk
932
1108
  * needs was never recorded here (published between renders, or before a
933
- * resize); the rendered walk then decides.
1109
+ * resize); the rendered walk then decides. Like the rendered walk, it never
1110
+ * passes `overflow`: a prefix reaching onto the screen waits.
934
1111
  */
935
1112
  #measuredAppendBatch(entry: TranscriptEntry, width: number, overflow: number): AppendBatch | undefined {
936
1113
  const counts = entry.stableRowCountByWidth.get(width);
@@ -941,7 +1118,7 @@ export class TranscriptContainer extends Container {
941
1118
  if (start === undefined) return undefined;
942
1119
  let emittedEnd = entry.emitted;
943
1120
  let end = start;
944
- while (emittedEnd < target && end - start < overflow) {
1121
+ while (emittedEnd < target) {
945
1122
  const next = counts.get(emittedEnd + 1);
946
1123
  if (next === undefined) return undefined;
947
1124
  if (next <= start) {
@@ -950,6 +1127,7 @@ export class TranscriptContainer extends Container {
950
1127
  }
951
1128
  break;
952
1129
  }
1130
+ if (next - start > overflow) break;
953
1131
  end = next;
954
1132
  emittedEnd += 1;
955
1133
  }
@@ -958,15 +1136,18 @@ export class TranscriptContainer extends Container {
958
1136
 
959
1137
  /**
960
1138
  * Size a progressive-append batch by rendering each further stable prefix:
961
- * extend the emitted prefix until its new rows cover `overflow`, stopping
962
- * at the first prefix that adds no row or stops extending the emitted one
963
- * (freezing the block when that is the very next prefix).
1139
+ * extend the emitted prefix while its new rows stay within `overflow` — the
1140
+ * rows that left the screen — stopping at the first prefix that would reach
1141
+ * onto it, adds no row, or stops extending the emitted one (freezing the
1142
+ * block when that is the very next prefix). A prefix still partly on screen
1143
+ * stays live: committing it would strand those rows above the live tail,
1144
+ * where a later shrink of the chrome below opens a band no frame can fill.
964
1145
  */
965
1146
  #renderedAppendBatch(entry: TranscriptEntry, width: number, overflow: number): AppendBatch {
966
1147
  const before = this.#renderStablePrefix(entry, entry.emitted, width);
967
1148
  let emittedEnd = entry.emitted;
968
1149
  let after = before;
969
- while (emittedEnd < entry.stableRows.length && after.length - before.length < overflow) {
1150
+ while (emittedEnd < entry.stableRows.length) {
970
1151
  const next = this.#renderStablePrefix(entry, emittedEnd + 1, width);
971
1152
  if (!isRowPrefix(before, next) || next.length === before.length) {
972
1153
  if (emittedEnd === entry.emitted) {
@@ -974,6 +1155,7 @@ export class TranscriptContainer extends Container {
974
1155
  }
975
1156
  break;
976
1157
  }
1158
+ if (next.length - before.length > overflow) break;
977
1159
  after = next;
978
1160
  emittedEnd += 1;
979
1161
  }
@@ -1035,7 +1217,7 @@ export class TranscriptContainer extends Container {
1035
1217
  const rendered = renderForScrollback(() =>
1036
1218
  index === start ? this.#renderEntry(entry, width) : trimBlankEdges(entry.component.render(width)),
1037
1219
  );
1038
- const emittedRows = index === start ? this.#renderStablePrefix(entry, entry.emitted, width).length : 0;
1220
+ const emittedRows = index === start ? this.#emittedRowCount(entry, width) : 0;
1039
1221
  const block = rendered.slice(emittedRows);
1040
1222
  reached = index + 1;
1041
1223
  if (block.length > 0) {
@@ -1051,7 +1233,11 @@ export class TranscriptContainer extends Container {
1051
1233
  #renderReplay(width: number): readonly string[] {
1052
1234
  const rows = Array.from(this.#renderRange(0, this.#frontier, width, true).rows);
1053
1235
  const head = this.#entries[this.#frontier];
1054
- if (head?.mode === "appendOnly" && head.emitted > 0) {
1236
+ if (head?.split !== undefined) {
1237
+ this.#setAllocation(head.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
1238
+ const committed = this.#splitRowCount(head, head.split, width);
1239
+ rows.push(...renderForScrollback(() => this.#renderEntry(head, width)).slice(0, committed));
1240
+ } else if (head?.mode === "appendOnly" && head.emitted > 0) {
1055
1241
  this.#setAllocation(head.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
1056
1242
  this.#renderEntry(head, width);
1057
1243
  rows.push(...this.#renderStablePrefix(head, head.emitted, width));
@@ -1062,10 +1248,15 @@ export class TranscriptContainer extends Container {
1062
1248
  #completeFullyEmittedHeads(width: number): void {
1063
1249
  while (this.#frontier < this.#entries.length) {
1064
1250
  const entry = this.#entries[this.#frontier]!;
1065
- if (entry.mode !== "appendOnly" || entry.state !== "settled") return;
1066
- const rendered = this.#measuredRows(entry, width);
1067
- if (entry.emitted !== entry.stableRows.length) return;
1068
- if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
1251
+ if (entry.state !== "settled") return;
1252
+ if (entry.split !== undefined) {
1253
+ if (this.#splitRowCount(entry, entry.split, width) < this.#measuredRows(entry, width).length) return;
1254
+ } else {
1255
+ if (entry.mode !== "appendOnly") return;
1256
+ const rendered = this.#measuredRows(entry, width);
1257
+ if (entry.emitted !== entry.stableRows.length) return;
1258
+ if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
1259
+ }
1069
1260
  this.#retireEntry(entry);
1070
1261
  this.#frontier++;
1071
1262
  }
@@ -1074,6 +1265,7 @@ export class TranscriptContainer extends Container {
1074
1265
  #retireEntry(entry: TranscriptEntry): void {
1075
1266
  entry.state = "committed";
1076
1267
  entry.emitted = 0;
1268
+ entry.split = undefined;
1077
1269
  entry.stableRows = EMPTY_STABLE_ROWS;
1078
1270
  entry.renderedStableByWidth = new Map();
1079
1271
  entry.stableRowCountByWidth = new Map();
@@ -1104,7 +1296,8 @@ export class TranscriptContainer extends Container {
1104
1296
 
1105
1297
  #startReplay(): void {
1106
1298
  const head = this.#entries[this.#frontier];
1107
- this.#replayPending = this.#frontier > 0 || (head?.mode === "appendOnly" && head.emitted > 0);
1299
+ this.#replayPending =
1300
+ this.#frontier > 0 || head?.split !== undefined || (head?.mode === "appendOnly" && head.emitted > 0);
1108
1301
  this.#replayRequested = false;
1109
1302
  }
1110
1303
 
@@ -1275,6 +1468,7 @@ export class TranscriptContainer extends Container {
1275
1468
  stableRowCountByWidth: new Map(),
1276
1469
  emitted: 0,
1277
1470
  stableFrozen: false,
1471
+ split: undefined,
1278
1472
  },
1279
1473
  );
1280
1474
  this.#frontier = this.#entries.findIndex(entry => entry.state !== "committed");
@@ -1,5 +1,4 @@
1
- import { existsSync, unlinkSync } from "node:fs";
2
- import { createServer, type Server, type Socket } from "node:net";
1
+ import { type IpcConnection, IpcServer } from "@oh-my-pi/pi-natives";
3
2
  import { type Component, isFocusable, type OverlayOptions, type TUI } from "./tui";
4
3
  import { plainText } from "./native/spans";
5
4
 
@@ -209,72 +208,60 @@ function mouseSequence(x: number, y: number, action: string): string {
209
208
  export class TuiDebugServer {
210
209
  readonly #tui: TUI;
211
210
  readonly #path: string;
212
- #server: Server | undefined;
213
- #sockets = new Set<Socket>();
211
+ #server: IpcServer | undefined;
212
+ #listening: Promise<boolean> | undefined;
213
+ #stopped = false;
214
+ #connections = new Set<IpcConnection>();
214
215
 
215
216
  constructor(tui: TUI, path: string) {
216
217
  this.#tui = tui;
217
218
  this.#path = path;
218
219
  }
219
220
 
221
+ /** Resolves true once the socket accepts connections, false if listen failed or the server stopped first. */
222
+ get ready(): Promise<boolean> {
223
+ return this.#listening ?? Promise.resolve(false);
224
+ }
225
+
220
226
  start(): void {
221
- if (this.#server !== undefined) return;
222
- try {
223
- if (existsSync(this.#path)) unlinkSync(this.#path);
224
- } catch {
225
- // The listen error below is isolated from the host application.
226
- }
227
- const server = createServer(socket => this.#accept(socket));
228
- this.#server = server;
229
- server.on("error", error => {
230
- void error;
231
- });
232
- try {
233
- server.listen(this.#path);
234
- } catch {
235
- this.#server = undefined;
236
- server.close();
237
- }
227
+ if (this.#listening !== undefined) return;
228
+ this.#stopped = false;
229
+ this.#listening = IpcServer.listen(this.#path, { framing: "lines" }, connection => this.#accept(connection)).then(
230
+ server => {
231
+ if (this.#stopped) {
232
+ void server.close().catch(() => {});
233
+ return false;
234
+ }
235
+ this.#server = server;
236
+ return true;
237
+ },
238
+ () => false, // A live peer or unusable path disables the debug socket; the host application is unaffected.
239
+ );
238
240
  }
239
241
 
240
242
  stop(): void {
243
+ this.#stopped = true;
241
244
  const server = this.#server;
242
245
  this.#server = undefined;
243
- for (const socket of this.#sockets) socket.destroy();
244
- this.#sockets.clear();
245
- try {
246
- server?.close();
247
- } catch {
248
- // A failed or already-closed listener still needs its stale path removed.
249
- }
250
- try {
251
- if (existsSync(this.#path)) unlinkSync(this.#path);
252
- } catch {
253
- // Teardown must not affect the host application.
254
- }
246
+ this.#listening = undefined;
247
+ for (const connection of this.#connections) connection.destroy();
248
+ this.#connections.clear();
249
+ void server?.close().catch(() => {});
255
250
  }
256
251
 
257
- #accept(socket: Socket): void {
258
- this.#sockets.add(socket);
259
- let buffer = "";
260
- socket.setEncoding("utf8");
261
- socket.on("data", chunk => {
262
- buffer += chunk;
263
- let newline = buffer.indexOf("\n");
264
- while (newline !== -1) {
265
- const line = buffer.slice(0, newline).replace(/\r$/, "");
266
- buffer = buffer.slice(newline + 1);
267
- if (line.length > 0) this.#handleLine(socket, line);
268
- newline = buffer.indexOf("\n");
269
- }
270
- });
271
- socket.on("error", error => {
272
- void error;
273
- });
274
- socket.on("close", () => this.#sockets.delete(socket));
252
+ #accept(connection: IpcConnection): void {
253
+ if (this.#stopped) {
254
+ connection.destroy();
255
+ return;
256
+ }
257
+ this.#connections.add(connection);
258
+ connection.start(
259
+ line => void this.#handleLine(connection, line),
260
+ () => this.#connections.delete(connection),
261
+ );
275
262
  }
276
263
 
277
- #handleLine(socket: Socket, line: string): void {
264
+ async #handleLine(connection: IpcConnection, line: string): Promise<void> {
278
265
  let request: DebugRequest;
279
266
  try {
280
267
  const parsed: unknown = JSON.parse(line);
@@ -282,36 +269,31 @@ export class TuiDebugServer {
282
269
  throw new Error("request must be an object");
283
270
  request = parsed as DebugRequest;
284
271
  } catch (error) {
285
- this.#write(socket, { ok: false, error: `malformed JSON: ${errorMessage(error)}` });
272
+ this.#write(connection, { ok: false, error: `malformed JSON: ${errorMessage(error)}` });
286
273
  return;
287
274
  }
275
+ let response: DebugResponse;
288
276
  try {
289
- const response = this.#dispatch(request);
290
- if (request.op === "quit" && response.ok) {
291
- this.#write(socket, response, () => {
292
- this.#tui.stop();
293
- process.exit(0);
294
- });
295
- return;
296
- }
297
- this.#write(socket, response);
277
+ response = this.#dispatch(request);
298
278
  } catch (error) {
299
- this.#write(socket, { ok: false, error: errorMessage(error) });
279
+ response = { ok: false, error: errorMessage(error) };
280
+ }
281
+ this.#write(connection, response);
282
+ if (request.op === "quit" && response.ok) {
283
+ await connection.drained();
284
+ this.#tui.stop();
285
+ process.exit(0);
300
286
  }
301
287
  }
302
288
 
303
- #write(socket: Socket, response: DebugResponse, flushed?: () => void): void {
289
+ #write(connection: IpcConnection, response: DebugResponse): void {
304
290
  let line: string;
305
291
  try {
306
292
  line = JSON.stringify(response);
307
293
  } catch (error) {
308
294
  line = JSON.stringify({ ok: false, error: errorMessage(error) });
309
295
  }
310
- try {
311
- socket.write(`${line}\n`, flushed);
312
- } catch {
313
- // Broken clients cannot affect the TUI.
314
- }
296
+ connection.send(line);
315
297
  }
316
298
 
317
299
  #dispatch(request: DebugRequest): DebugResponse {
@@ -135,6 +135,7 @@ function hotkeyGroups(bindings: HotkeysMarkdownBindings): HotkeyGroup[] {
135
135
  { keys: [act("app.history.search")], action: "Search prompt history" },
136
136
  { keys: [act("app.tools.expand")], action: "Toggle tool output expansion" },
137
137
  { keys: [act("app.tools.toggleVisibility")], action: "Toggle tool activity visibility" },
138
+ { keys: [act("app.ask.hide")], action: "Hide/show a pending ask question" },
138
139
  { keys: [act("app.thinking.toggle")], action: "Toggle thinking block visibility" },
139
140
  { keys: [act("app.editor.external")], action: "Edit message in external editor" },
140
141
  { keys: [act("app.retry")], action: "Retry last failed assistant turn" },