@oh-my-pi/pi-tui 18.4.8 → 18.4.9
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 +18 -0
- package/dist/types/apps/debug/log-viewer.d.ts +17 -0
- package/dist/types/apps/debug/raw-sse.d.ts +14 -0
- package/dist/types/native/backend.d.ts +8 -0
- package/dist/types/native/encode.d.ts +5 -1
- package/dist/types/native/node.d.ts +14 -1
- package/dist/types/native/reconcile.d.ts +5 -0
- package/dist/types/overlays/extensions/extension-dashboard.d.ts +31 -0
- package/dist/types/overlays/extensions/extension-list.d.ts +37 -0
- package/dist/types/overlays/extensions/inspector-panel.d.ts +8 -0
- package/dist/types/overlays/jobs-panel.d.ts +35 -9
- package/dist/types/prompt/word-completion.d.ts +12 -0
- package/dist/types/terminal.d.ts +7 -0
- package/dist/types/tools/bash-interactive.d.ts +15 -0
- package/dist/types/tools/output-meta.d.ts +8 -2
- package/dist/types/tools/streaming-output.d.ts +15 -10
- package/dist/types/tui.d.ts +5 -0
- package/dist/types/windows-input-mode.d.ts +26 -0
- package/package.json +9 -9
- package/src/apps/debug/log-viewer.ts +236 -2
- package/src/apps/debug/protocol-probe.ts +10 -1
- package/src/apps/debug/raw-sse.ts +210 -28
- package/src/native/apply.ts +2 -1
- package/src/native/backend.ts +22 -0
- package/src/native/encode.ts +7 -2
- package/src/native/node.ts +15 -1
- package/src/native/reconcile.ts +28 -1
- package/src/overlays/extensions/extension-dashboard.ts +275 -23
- package/src/overlays/extensions/extension-list.ts +274 -14
- package/src/overlays/extensions/inspector-panel.ts +368 -0
- package/src/overlays/hook-selector.ts +1 -1
- package/src/overlays/jobs-panel.ts +222 -49
- package/src/prompt/composer-cache.ts +26 -3
- package/src/prompt/word-completion.ts +31 -13
- package/src/terminal.ts +37 -1
- package/src/tools/bash-interactive.ts +85 -16
- package/src/tools/output-meta.ts +13 -5
- package/src/tools/streaming-output.ts +48 -23
- package/src/tui.ts +52 -14
- package/src/windows-input-mode.ts +206 -0
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type * as XtermModule from "@oh-my-pi/pi-utils/vterm";
|
|
2
2
|
import type { Terminal as XtermTerminalType } from "@oh-my-pi/pi-utils/vterm";
|
|
3
|
+
import type { TspSpan } from "@oh-my-pi/pi-wire";
|
|
3
4
|
import type { Component } from "../tui";
|
|
5
|
+
import { ansi, col, row, span, text } from "../native/describe";
|
|
6
|
+
import { Memo } from "../native/memo";
|
|
7
|
+
import type { DescribeContext, NativeNode, NativeUiEvent } from "../native/node";
|
|
8
|
+
import { actionBar, actionButton } from "../native/overlay";
|
|
4
9
|
import { extractPrintableText, matchesKey, parseKey, parseKittySequence } from "../keys";
|
|
5
10
|
import { padding, truncateToWidth, visibleWidth } from "../utils";
|
|
6
11
|
import type { Theme } from "../theme/theme";
|
|
@@ -15,6 +20,10 @@ export interface BashInteractiveTerminalBackend {
|
|
|
15
20
|
|
|
16
21
|
// The capture sink owns final output; this caps only the live display backlog.
|
|
17
22
|
const MAX_LIVE_WRITE_QUEUE_CHUNKS = 512;
|
|
23
|
+
// Cells the native full sheet's insets take from the pane width.
|
|
24
|
+
const NATIVE_SHEET_INSET_COLS = 8;
|
|
25
|
+
// Action id of the native Force kill button (mirrors Esc).
|
|
26
|
+
const KILL_ACTION = "kill";
|
|
18
27
|
|
|
19
28
|
function normalizeInputForPty(data: string, applicationCursorKeysMode: boolean): string {
|
|
20
29
|
const kitty = parseKittySequence(data);
|
|
@@ -78,6 +87,8 @@ function normalizeInputForPty(data: string, applicationCursorKeysMode: boolean):
|
|
|
78
87
|
}
|
|
79
88
|
/** Interactive terminal overlay driven by an external PTY controller. */
|
|
80
89
|
export class BashInteractiveOverlayComponent implements Component {
|
|
90
|
+
/** Native sheet: a full-pane glass overlay titled Console. */
|
|
91
|
+
readonly nativeOverlay = { role: "omp.overlay.console", size: "full", head: "Console" } as const;
|
|
81
92
|
#terminal: XtermTerminalType;
|
|
82
93
|
#state: "running" | "complete" | "timed_out" | "killed" = "running";
|
|
83
94
|
#exitCode: number | undefined;
|
|
@@ -94,6 +105,9 @@ export class BashInteractiveOverlayComponent implements Component {
|
|
|
94
105
|
#writeOffset = 0;
|
|
95
106
|
#flushResolvers: Array<() => void> = [];
|
|
96
107
|
#writing = false;
|
|
108
|
+
/** Bumped whenever the virtual terminal processed a chunk, so `describe` knows the screen changed. */
|
|
109
|
+
#screenVersion = 0;
|
|
110
|
+
readonly #native = new Memo();
|
|
97
111
|
|
|
98
112
|
constructor(
|
|
99
113
|
command: string,
|
|
@@ -160,6 +174,7 @@ export class BashInteractiveOverlayComponent implements Component {
|
|
|
160
174
|
const data = this.#writeQueue[this.#writeOffset]!;
|
|
161
175
|
this.#terminal.write(data, () => {
|
|
162
176
|
this.#writing = false;
|
|
177
|
+
this.#screenVersion += 1;
|
|
163
178
|
this.#writeOffset += 1;
|
|
164
179
|
if (this.#writeOffset >= this.#writeQueue.length) {
|
|
165
180
|
this.#writeQueue = [];
|
|
@@ -206,7 +221,7 @@ export class BashInteractiveOverlayComponent implements Component {
|
|
|
206
221
|
|
|
207
222
|
handleInput(data: string): void {
|
|
208
223
|
if (this.#state === "running" && (matchesKey(data, "escape") || matchesKey(data, "esc"))) {
|
|
209
|
-
this.#
|
|
224
|
+
this.#forceKill();
|
|
210
225
|
return;
|
|
211
226
|
}
|
|
212
227
|
if (this.#state !== "running") {
|
|
@@ -218,6 +233,73 @@ export class BashInteractiveOverlayComponent implements Component {
|
|
|
218
233
|
}
|
|
219
234
|
this.#onInput(normalizedInput);
|
|
220
235
|
}
|
|
236
|
+
|
|
237
|
+
/** The Force kill button runs the Esc path. */
|
|
238
|
+
handleNativeEvent(event: NativeUiEvent): void {
|
|
239
|
+
if (event.type === "action" && event.act === KILL_ACTION) this.#forceKill();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#forceKill(): void {
|
|
243
|
+
if (this.#state === "running") this.#onDismiss();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#syncPtySize(cols: number, rows: number): void {
|
|
247
|
+
if (cols === this.#lastCols && rows === this.#lastRows) return;
|
|
248
|
+
this.#lastCols = cols;
|
|
249
|
+
this.#lastRows = rows;
|
|
250
|
+
try {
|
|
251
|
+
this.#backend.resize(cols, rows);
|
|
252
|
+
} catch {
|
|
253
|
+
// Session may have ended
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#maxContentRows(): number {
|
|
258
|
+
const maxOverlayRows = Math.max(5, Math.floor(this.#getTerminalRows() * 0.8));
|
|
259
|
+
return Math.max(1, maxOverlayRows - 4);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#stateSpan(): TspSpan {
|
|
263
|
+
if (this.#state === "running") return span("running", "warning");
|
|
264
|
+
if (this.#state === "timed_out") return span("timed out", "warning");
|
|
265
|
+
if (this.#state === "killed") return span("killed", "warning");
|
|
266
|
+
if (this.#exitCode === 0) return span("exit 0", "success");
|
|
267
|
+
if (this.#exitCode === undefined) return span("exited", "warning");
|
|
268
|
+
return span(`exit ${this.#exitCode}`, "error");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Native view: the command with its state badge, the PTY screen as an
|
|
273
|
+
* `ansi` node (SGR kept, fixed to the PTY grid), and a footer with the
|
|
274
|
+
* Force kill action while running.
|
|
275
|
+
*/
|
|
276
|
+
describe(cx: DescribeContext): NativeNode {
|
|
277
|
+
const cols = Math.max(20, cx.cols - NATIVE_SHEET_INSET_COLS);
|
|
278
|
+
const rows = this.#maxContentRows();
|
|
279
|
+
this.#syncPtySize(cols, rows);
|
|
280
|
+
this.#terminal.resize(cols, rows);
|
|
281
|
+
const deps = [this.#screenVersion, cols, rows, this.#state, this.#exitCode];
|
|
282
|
+
return this.#native.get(deps, () => {
|
|
283
|
+
const viewportY = this.#terminal.buffer.active.viewportY;
|
|
284
|
+
const screen = readTerminalRows(this.#terminal, viewportY, rows).map(line => styleTerminalRow(line, ""));
|
|
285
|
+
const head = row(
|
|
286
|
+
[
|
|
287
|
+
text([span(replaceTabs(this.#command), "muted")], { wrap: "none", truncate: "end" }),
|
|
288
|
+
text([span("[", "dim"), this.#stateSpan(), span("]", "dim")]),
|
|
289
|
+
],
|
|
290
|
+
{ gap: "sm", align: "baseline", justify: "between" },
|
|
291
|
+
);
|
|
292
|
+
const footer =
|
|
293
|
+
this.#state === "running"
|
|
294
|
+
? actionBar([
|
|
295
|
+
text([span("Input forwarded to PTY", "dim")]),
|
|
296
|
+
null,
|
|
297
|
+
actionButton("Force kill", KILL_ACTION, { keys: "escape", tone: "error" }),
|
|
298
|
+
])
|
|
299
|
+
: actionBar([text([span("Session finished", "dim")])]);
|
|
300
|
+
return col([head, ansi(screen.join("\n"), { cols }), footer], { gap: "sm" });
|
|
301
|
+
});
|
|
302
|
+
}
|
|
221
303
|
#stateText(): string {
|
|
222
304
|
if (this.#state === "running") return this.#uiTheme.fg("warning", "running");
|
|
223
305
|
if (this.#state === "timed_out") return this.#uiTheme.fg("warning", "timed out");
|
|
@@ -237,21 +319,8 @@ export class BashInteractiveOverlayComponent implements Component {
|
|
|
237
319
|
render(width: number): readonly string[] {
|
|
238
320
|
const safeWidth = Math.max(20, width);
|
|
239
321
|
const innerWidth = Math.max(1, safeWidth - 2);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
const maxContentRows = Math.max(1, maxOverlayRows - chromeRows);
|
|
243
|
-
// Propagate terminal resize to PTY session
|
|
244
|
-
const currentCols = innerWidth;
|
|
245
|
-
const currentRows = maxContentRows;
|
|
246
|
-
if (currentCols !== this.#lastCols || currentRows !== this.#lastRows) {
|
|
247
|
-
this.#lastCols = currentCols;
|
|
248
|
-
this.#lastRows = currentRows;
|
|
249
|
-
try {
|
|
250
|
-
this.#backend.resize(currentCols, currentRows);
|
|
251
|
-
} catch {
|
|
252
|
-
// Session may have ended
|
|
253
|
-
}
|
|
254
|
-
}
|
|
322
|
+
const maxContentRows = this.#maxContentRows();
|
|
323
|
+
this.#syncPtySize(innerWidth, maxContentRows);
|
|
255
324
|
const statusIcon =
|
|
256
325
|
this.#state === "running"
|
|
257
326
|
? formatStatusIcon("running", this.#uiTheme)
|
package/src/tools/output-meta.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface TruncationMeta {
|
|
|
25
25
|
elidedLines?: number;
|
|
26
26
|
/** Artifact ID if full output was saved */
|
|
27
27
|
artifactId?: string;
|
|
28
|
+
/** Bytes the artifact cap dropped from the saved file's middle; the artifact is then a head/tail sample. */
|
|
29
|
+
artifactElidedBytes?: number;
|
|
28
30
|
/** Next offset for pagination (head truncation only) */
|
|
29
31
|
nextOffset?: number;
|
|
30
32
|
/**
|
|
@@ -62,7 +64,7 @@ export interface LimitsMeta {
|
|
|
62
64
|
resultLimit?: { reached: number; suggestion?: number };
|
|
63
65
|
headLimit?: { reached: number; suggestion: number };
|
|
64
66
|
/** `unit` may be absent in sessions persisted before it was recorded. */
|
|
65
|
-
columnTruncated?: { maxColumn: number; unit?: "bytes" | "chars"; artifactId?: string };
|
|
67
|
+
columnTruncated?: { maxColumn: number; unit?: "bytes" | "chars"; artifactId?: string; artifactElidedBytes?: number };
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
/**
|
|
@@ -130,8 +132,14 @@ export function formatGroupedDiagnosticMessages(messages: string[]): string {
|
|
|
130
132
|
return lines.join("\n");
|
|
131
133
|
}
|
|
132
134
|
|
|
133
|
-
/**
|
|
134
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Format a recoverable output artifact link. An artifact the size cap cut
|
|
137
|
+
* (`artifactElidedBytes > 0`) is labeled as the head/tail sample it holds.
|
|
138
|
+
*/
|
|
139
|
+
export function formatFullOutputReference(artifactId: string, artifactElidedBytes?: number): string {
|
|
140
|
+
if (artifactElidedBytes !== undefined && artifactElidedBytes > 0) {
|
|
141
|
+
return `Read artifact://${artifactId} for a head/tail sample of the output; ${formatBytes(artifactElidedBytes)} from its middle was not saved`;
|
|
142
|
+
}
|
|
135
143
|
return `Read artifact://${artifactId} for full output`;
|
|
136
144
|
}
|
|
137
145
|
|
|
@@ -206,7 +214,7 @@ export function formatTruncationMetaNotice(truncation: TruncationMeta, source?:
|
|
|
206
214
|
? undefined
|
|
207
215
|
: source?.type === "report"
|
|
208
216
|
? `Read artifact://${truncation.artifactId} for full report (${source.value})`
|
|
209
|
-
: formatFullOutputReference(truncation.artifactId);
|
|
217
|
+
: formatFullOutputReference(truncation.artifactId, truncation.artifactElidedBytes);
|
|
210
218
|
|
|
211
219
|
if (truncation.direction === "middle") {
|
|
212
220
|
const head = truncation.headRange;
|
|
@@ -322,7 +330,7 @@ export function formatOutputNotice(meta: OutputMeta | undefined): string {
|
|
|
322
330
|
// stops matching the persisted "… 768 chars" text.
|
|
323
331
|
let columnNotice = `Some lines truncated to ${c.maxColumn} ${c.unit ?? "chars"}`;
|
|
324
332
|
if (c.artifactId != null) {
|
|
325
|
-
columnNotice += `. ${formatFullOutputReference(c.artifactId)}`;
|
|
333
|
+
columnNotice += `. ${formatFullOutputReference(c.artifactId, c.artifactElidedBytes)}`;
|
|
326
334
|
}
|
|
327
335
|
parts.push(columnNotice);
|
|
328
336
|
}
|
|
@@ -14,14 +14,15 @@ export const DEFAULT_MAX_BYTES = 50 * 1024;
|
|
|
14
14
|
export const DEFAULT_MAX_COLUMN = 512;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Default artifact-on-disk cap for {@link OutputSink}
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* complete raw stream instead of a capped head/tail sample.
|
|
17
|
+
* Default artifact-on-disk cap for {@link OutputSink}: 16 MiB, split into a
|
|
18
|
+
* {@link ARTIFACT_DEFAULT_HEAD_BYTES} head and a rolling tail. `0` means
|
|
19
|
+
* unbounded (the complete raw stream is preserved).
|
|
21
20
|
*/
|
|
22
|
-
export const ARTIFACT_DEFAULT_MAX_BYTES =
|
|
21
|
+
export const ARTIFACT_DEFAULT_MAX_BYTES = 16 * 1024 * 1024; // 16 MiB
|
|
23
22
|
/** Default head budget; the remainder becomes the rolling tail window. */
|
|
24
23
|
export const ARTIFACT_DEFAULT_HEAD_BYTES = 3 * 1024 * 1024; // 3 MiB
|
|
24
|
+
/** Upper bound on how far the rolling artifact tail may outgrow its budget between trims. */
|
|
25
|
+
const ARTIFACT_TAIL_MAX_SLACK_BYTES = 4 * 1024 * 1024;
|
|
25
26
|
|
|
26
27
|
const NL = "\n";
|
|
27
28
|
const CR = "\r";
|
|
@@ -54,6 +55,11 @@ export interface OutputSummary {
|
|
|
54
55
|
columnMax?: number;
|
|
55
56
|
/** Artifact ID for internal URL access (artifact://<id>) when truncated */
|
|
56
57
|
artifactId?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Bytes the artifact cap dropped from the middle of the saved file. When
|
|
60
|
+
* set, `artifact://<id>` holds a head/tail sample, not the full output.
|
|
61
|
+
*/
|
|
62
|
+
artifactElidedBytes?: number;
|
|
57
63
|
/** Full raw output was not completely saved; artifactId is unavailable. */
|
|
58
64
|
artifactError?: OutputArtifactError;
|
|
59
65
|
}
|
|
@@ -86,17 +92,18 @@ export interface OutputSinkOptions {
|
|
|
86
92
|
/** Minimum ms between onChunk calls. 0 = every chunk (default). */
|
|
87
93
|
chunkThrottleMs?: number;
|
|
88
94
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
95
|
+
* Cap on bytes written to the artifact-on-disk file. When the cap is hit,
|
|
96
|
+
* the head window is preserved verbatim and subsequent output feeds a
|
|
97
|
+
* rolling tail window; on close, the sink writes a single
|
|
92
98
|
* `[ARTIFACT TRUNCATED: …]` notice between them. Default
|
|
93
|
-
* {@link ARTIFACT_DEFAULT_MAX_BYTES} (unbounded
|
|
99
|
+
* {@link ARTIFACT_DEFAULT_MAX_BYTES} (16 MiB); `0` = unbounded.
|
|
94
100
|
*/
|
|
95
101
|
artifactMaxBytes?: number;
|
|
96
102
|
/**
|
|
97
103
|
* Bytes reserved for the head window of the capped artifact file. The
|
|
98
104
|
* tail window receives `artifactMaxBytes - artifactHeadBytes`. Default
|
|
99
|
-
* {@link ARTIFACT_DEFAULT_HEAD_BYTES}
|
|
105
|
+
* {@link ARTIFACT_DEFAULT_HEAD_BYTES}, but at most half of
|
|
106
|
+
* `artifactMaxBytes`; clamped to `[0, artifactMaxBytes]`.
|
|
100
107
|
*/
|
|
101
108
|
artifactHeadBytes?: number;
|
|
102
109
|
}
|
|
@@ -912,20 +919,24 @@ export class OutputSink {
|
|
|
912
919
|
readonly #chunkThrottleMs: number;
|
|
913
920
|
readonly #maxColumns: number;
|
|
914
921
|
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
// `#artifactTailBudget`
|
|
919
|
-
//
|
|
920
|
-
//
|
|
922
|
+
// Artifact-on-disk cap. When `#artifactMaxBytes > 0` the file sink owns a
|
|
923
|
+
// head budget + a rolling tail buffer; once the head is closed, subsequent
|
|
924
|
+
// chunks are diverted into `#artifactTailRing` (trimmed back to
|
|
925
|
+
// `#artifactTailBudget` whenever it outgrows the budget by
|
|
926
|
+
// `#artifactTailSlack`). On `dump()` the tail is flushed back to the sink
|
|
927
|
+
// behind a `[ARTIFACT TRUNCATED: …]` notice. `0` disables the cap.
|
|
921
928
|
readonly #artifactMaxBytes: number;
|
|
922
929
|
readonly #artifactHeadBudget: number;
|
|
923
930
|
readonly #artifactTailBudget: number;
|
|
931
|
+
/** Overshoot tolerated before trimming, so trimming costs amortized O(1) per byte instead of O(budget) per chunk. */
|
|
932
|
+
readonly #artifactTailSlack: number;
|
|
924
933
|
#artifactHeadBytesWritten = 0;
|
|
925
934
|
#artifactHeadClosed = false;
|
|
926
935
|
#artifactTailRing = "";
|
|
927
936
|
#artifactTailRingBytes = 0;
|
|
928
937
|
#artifactTailIncomingBytes = 0;
|
|
938
|
+
/** Bytes the cap dropped from the artifact middle, recorded when the tail is flushed. */
|
|
939
|
+
#artifactElidedBytes = 0;
|
|
929
940
|
constructor(options?: OutputSinkOptions) {
|
|
930
941
|
const {
|
|
931
942
|
artifactPath,
|
|
@@ -936,7 +947,7 @@ export class OutputSink {
|
|
|
936
947
|
onChunk,
|
|
937
948
|
chunkThrottleMs = 0,
|
|
938
949
|
artifactMaxBytes = ARTIFACT_DEFAULT_MAX_BYTES,
|
|
939
|
-
artifactHeadBytes
|
|
950
|
+
artifactHeadBytes,
|
|
940
951
|
} = options ?? {};
|
|
941
952
|
this.#artifactPath = artifactPath;
|
|
942
953
|
this.#artifactId = artifactId;
|
|
@@ -946,8 +957,13 @@ export class OutputSink {
|
|
|
946
957
|
this.#onChunk = onChunk;
|
|
947
958
|
this.#chunkThrottleMs = chunkThrottleMs;
|
|
948
959
|
this.#artifactMaxBytes = Math.max(0, artifactMaxBytes);
|
|
949
|
-
|
|
960
|
+
// The default head yields to the rolling tail on small caps: it takes at
|
|
961
|
+
// most half, so the most recent output is always kept.
|
|
962
|
+
const headBytesRequested =
|
|
963
|
+
artifactHeadBytes ?? Math.min(ARTIFACT_DEFAULT_HEAD_BYTES, Math.floor(this.#artifactMaxBytes / 2));
|
|
964
|
+
this.#artifactHeadBudget = Math.max(0, Math.min(headBytesRequested, this.#artifactMaxBytes));
|
|
950
965
|
this.#artifactTailBudget = Math.max(0, this.#artifactMaxBytes - this.#artifactHeadBudget);
|
|
966
|
+
this.#artifactTailSlack = Math.min(this.#artifactTailBudget, ARTIFACT_TAIL_MAX_SLACK_BYTES);
|
|
951
967
|
}
|
|
952
968
|
|
|
953
969
|
/**
|
|
@@ -1251,11 +1267,14 @@ export class OutputSink {
|
|
|
1251
1267
|
}
|
|
1252
1268
|
this.#artifactTailRing += chunk;
|
|
1253
1269
|
this.#artifactTailRingBytes += chunkBytes;
|
|
1254
|
-
if (this.#artifactTailRingBytes > budget)
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1270
|
+
if (this.#artifactTailRingBytes > budget + this.#artifactTailSlack) this.#trimArtifactTail();
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
#trimArtifactTail(): void {
|
|
1274
|
+
if (this.#artifactTailRingBytes <= this.#artifactTailBudget) return;
|
|
1275
|
+
const { text, bytes } = truncateTailBytes(this.#artifactTailRing, this.#artifactTailBudget);
|
|
1276
|
+
this.#artifactTailRing = text;
|
|
1277
|
+
this.#artifactTailRingBytes = bytes;
|
|
1259
1278
|
}
|
|
1260
1279
|
|
|
1261
1280
|
#recordArtifactError(operation: OutputArtifactError): void {
|
|
@@ -1413,11 +1432,13 @@ export class OutputSink {
|
|
|
1413
1432
|
#flushArtifactTailIfCapped(): void {
|
|
1414
1433
|
if (!this.#file || this.#artifactError) return;
|
|
1415
1434
|
if (this.#artifactMaxBytes === 0) return;
|
|
1435
|
+
this.#trimArtifactTail();
|
|
1416
1436
|
const tailBytes = this.#artifactTailRingBytes;
|
|
1417
1437
|
const droppedBytes = Math.max(0, this.#artifactTailIncomingBytes - tailBytes);
|
|
1418
1438
|
if (tailBytes === 0 && droppedBytes === 0) return;
|
|
1419
1439
|
|
|
1420
1440
|
if (droppedBytes > 0) {
|
|
1441
|
+
this.#artifactElidedBytes = droppedBytes;
|
|
1421
1442
|
const headWritten = this.#artifactHeadBytesWritten;
|
|
1422
1443
|
const totalCapped = headWritten + this.#artifactTailIncomingBytes;
|
|
1423
1444
|
const headSep = headWritten > 0 ? "\n" : "";
|
|
@@ -1505,6 +1526,10 @@ export class OutputSink {
|
|
|
1505
1526
|
columnTruncatedLines: this.#columnTruncatedLines > 0 ? this.#columnTruncatedLines : undefined,
|
|
1506
1527
|
columnMax: this.#columnTruncatedLines > 0 ? this.#maxColumns : undefined,
|
|
1507
1528
|
artifactId: this.#artifactError ? undefined : this.#file?.artifactId,
|
|
1529
|
+
artifactElidedBytes:
|
|
1530
|
+
this.#artifactError || !this.#file?.artifactId || this.#artifactElidedBytes === 0
|
|
1531
|
+
? undefined
|
|
1532
|
+
: this.#artifactElidedBytes,
|
|
1508
1533
|
artifactError: this.#artifactError,
|
|
1509
1534
|
};
|
|
1510
1535
|
}
|
package/src/tui.ts
CHANGED
|
@@ -1002,6 +1002,11 @@ export class TUI extends Container {
|
|
|
1002
1002
|
options?: OverlayOptions;
|
|
1003
1003
|
preFocus: Component | null;
|
|
1004
1004
|
hidden: boolean;
|
|
1005
|
+
/**
|
|
1006
|
+
* A sheet the user clicked away from (see {@link TUI.#focusFromPointer}):
|
|
1007
|
+
* it stays up but no longer holds the keys, until focus moves back into it.
|
|
1008
|
+
*/
|
|
1009
|
+
released: boolean;
|
|
1005
1010
|
}[] = [];
|
|
1006
1011
|
|
|
1007
1012
|
constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: TUIOptions) {
|
|
@@ -1120,12 +1125,14 @@ export class TUI extends Container {
|
|
|
1120
1125
|
}
|
|
1121
1126
|
|
|
1122
1127
|
setFocus(component: Component | null): void {
|
|
1123
|
-
const
|
|
1124
|
-
if (
|
|
1128
|
+
const holder = this.#getKeyHolderOverlay();
|
|
1129
|
+
if (holder && !isOverlayFocusTarget(holder.component, component)) {
|
|
1125
1130
|
const currentFocus = this.#focusedComponent;
|
|
1126
|
-
component = isOverlayFocusTarget(
|
|
1127
|
-
|
|
1128
|
-
|
|
1131
|
+
component = isOverlayFocusTarget(holder.component, currentFocus) ? currentFocus : holder.component;
|
|
1132
|
+
}
|
|
1133
|
+
// Focus moving back into a released sheet makes it hold the keys again.
|
|
1134
|
+
for (const entry of this.overlayStack) {
|
|
1135
|
+
if (entry.released && isOverlayFocusTarget(entry.component, component)) entry.released = false;
|
|
1129
1136
|
}
|
|
1130
1137
|
|
|
1131
1138
|
const previousFocusedComponent = this.#focusedComponent;
|
|
@@ -1176,7 +1183,7 @@ export class TUI extends Container {
|
|
|
1176
1183
|
*/
|
|
1177
1184
|
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
|
|
1178
1185
|
component.setIgnoreTight?.(true);
|
|
1179
|
-
const entry = { component, options, preFocus: this.#focusedComponent, hidden: false };
|
|
1186
|
+
const entry = { component, options, preFocus: this.#focusedComponent, hidden: false, released: false };
|
|
1180
1187
|
this.overlayStack.push(entry);
|
|
1181
1188
|
// Only focus if overlay is actually visible
|
|
1182
1189
|
if (this.#isOverlayVisible(entry)) {
|
|
@@ -1194,8 +1201,7 @@ export class TUI extends Container {
|
|
|
1194
1201
|
this.overlayStack.splice(index, 1);
|
|
1195
1202
|
// Restore focus if this overlay or one of its owned targets had focus
|
|
1196
1203
|
if (isOverlayFocusTarget(component, this.#focusedComponent)) {
|
|
1197
|
-
|
|
1198
|
-
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
1204
|
+
this.setFocus(this.#getKeyHolderOverlay()?.component ?? entry.preFocus);
|
|
1199
1205
|
}
|
|
1200
1206
|
if (this.overlayStack.length === 0) {
|
|
1201
1207
|
this.terminal.hideCursor();
|
|
@@ -1209,10 +1215,9 @@ export class TUI extends Container {
|
|
|
1209
1215
|
entry.hidden = hidden;
|
|
1210
1216
|
// Update focus when hiding/showing
|
|
1211
1217
|
if (hidden) {
|
|
1212
|
-
// If this overlay or one of its owned targets had focus, move focus to next
|
|
1218
|
+
// If this overlay or one of its owned targets had focus, move focus to the next one holding keys or preFocus
|
|
1213
1219
|
if (isOverlayFocusTarget(component, this.#focusedComponent)) {
|
|
1214
|
-
|
|
1215
|
-
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
1220
|
+
this.setFocus(this.#getKeyHolderOverlay()?.component ?? entry.preFocus);
|
|
1216
1221
|
}
|
|
1217
1222
|
} else {
|
|
1218
1223
|
// Restore focus to this overlay when showing (if it's actually visible)
|
|
@@ -1230,9 +1235,8 @@ export class TUI extends Container {
|
|
|
1230
1235
|
hideOverlay(): void {
|
|
1231
1236
|
const overlay = this.overlayStack.pop();
|
|
1232
1237
|
if (!overlay) return;
|
|
1233
|
-
// Find topmost visible overlay, or fall back to preFocus
|
|
1234
|
-
|
|
1235
|
-
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
1238
|
+
// Find the topmost visible overlay holding keys, or fall back to preFocus
|
|
1239
|
+
this.setFocus(this.#getKeyHolderOverlay()?.component ?? overlay.preFocus);
|
|
1236
1240
|
if (this.overlayStack.length === 0) {
|
|
1237
1241
|
this.terminal.hideCursor();
|
|
1238
1242
|
this.#recordHardwareCursorHidden();
|
|
@@ -1312,6 +1316,39 @@ export class TUI extends Container {
|
|
|
1312
1316
|
return undefined;
|
|
1313
1317
|
}
|
|
1314
1318
|
|
|
1319
|
+
/** The topmost visible overlay that holds the keys: not a sheet the user clicked away from. */
|
|
1320
|
+
#getKeyHolderOverlay(): (typeof this.overlayStack)[number] | undefined {
|
|
1321
|
+
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
|
1322
|
+
const entry = this.overlayStack[i];
|
|
1323
|
+
if (!entry.released && this.#isOverlayVisible(entry)) return entry;
|
|
1324
|
+
}
|
|
1325
|
+
return undefined;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* Moves keyboard focus where the user clicked (the terminal's `focus` event,
|
|
1330
|
+
* see {@link NativeHost.focusFromPointer}). A click inside an overlay gives it
|
|
1331
|
+
* the keys unless they are already inside it. A click on `field` under the
|
|
1332
|
+
* overlays focuses it only when every visible overlay is a `sheet` (a panel
|
|
1333
|
+
* beside the content, like `/settings` docked in Tern): those stay up but
|
|
1334
|
+
* stop holding the keys until focus moves back into one of them.
|
|
1335
|
+
*/
|
|
1336
|
+
#focusFromPointer(
|
|
1337
|
+
owners: readonly Component[],
|
|
1338
|
+
field: Component | null,
|
|
1339
|
+
sheet: (overlay: Component) => boolean,
|
|
1340
|
+
): void {
|
|
1341
|
+
const visible = this.overlayStack.filter(entry => this.#isOverlayVisible(entry));
|
|
1342
|
+
const overlay = visible.findLast(entry => owners.some(owner => isOverlayFocusTarget(entry.component, owner)));
|
|
1343
|
+
if (overlay) {
|
|
1344
|
+
if (!isOverlayFocusTarget(overlay.component, this.#focusedComponent)) this.setFocus(overlay.component);
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
if (!field || field === this.#focusedComponent || !visible.every(entry => sheet(entry.component))) return;
|
|
1348
|
+
for (const entry of visible) entry.released = true;
|
|
1349
|
+
this.setFocus(field);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1315
1352
|
override invalidate(): void {
|
|
1316
1353
|
super.invalidate();
|
|
1317
1354
|
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
|
|
@@ -1616,6 +1653,7 @@ export class TUI extends Container {
|
|
|
1616
1653
|
return visible;
|
|
1617
1654
|
},
|
|
1618
1655
|
focused: () => this.#focusedComponent,
|
|
1656
|
+
focusFromPointer: (owners, field, sheet) => this.#focusFromPointer(owners, field, sheet),
|
|
1619
1657
|
requestRender: () => this.requestRender(),
|
|
1620
1658
|
appearanceChanged: () => {
|
|
1621
1659
|
this.terminal.refreshAppearance?.();
|