@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.
- package/CHANGELOG.md +39 -0
- package/dist/types/app-keybindings.d.ts +5 -0
- package/dist/types/chrome/transcript-container.d.ts +5 -0
- package/dist/types/debug-server.d.ts +2 -0
- package/dist/types/overlays/ask-dialog.d.ts +2 -2
- package/dist/types/prompt/composer.d.ts +1 -20
- package/dist/types/tmux.d.ts +10 -0
- package/dist/types/tui.d.ts +10 -3
- package/package.json +9 -9
- package/src/app-keybindings.ts +5 -0
- package/src/chrome/transcript-container.ts +224 -30
- package/src/debug-server.ts +51 -69
- package/src/hotkeys-markdown.ts +1 -0
- package/src/overlays/ask-dialog.ts +111 -31
- package/src/prompt/composer.ts +35 -72
- package/src/terminal.ts +39 -0
- package/src/tmux.ts +43 -0
- package/src/tui.ts +27 -4
|
@@ -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
|
-
|
|
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
|
-
//
|
|
231
|
-
//
|
|
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"
|
|
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.#
|
|
421
|
-
|
|
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
|
|
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
|
-
*
|
|
912
|
-
*
|
|
913
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
962
|
-
*
|
|
963
|
-
*
|
|
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
|
|
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.#
|
|
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?.
|
|
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.
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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 =
|
|
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");
|
package/src/debug-server.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
213
|
-
#
|
|
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.#
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
244
|
-
this.#
|
|
245
|
-
|
|
246
|
-
|
|
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(
|
|
258
|
-
this.#
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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(
|
|
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(
|
|
272
|
+
this.#write(connection, { ok: false, error: `malformed JSON: ${errorMessage(error)}` });
|
|
286
273
|
return;
|
|
287
274
|
}
|
|
275
|
+
let response: DebugResponse;
|
|
288
276
|
try {
|
|
289
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
package/src/hotkeys-markdown.ts
CHANGED
|
@@ -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" },
|