@alchemy.run/sigil 0.0.0-alpha.4 → 0.0.0-alpha.5

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.
Files changed (137) hide show
  1. package/README.md +21 -9
  2. package/THIRD_PARTY_NOTICES.md +39 -1
  3. package/dist/Text-D5HUf3Fj.d.ts +452 -0
  4. package/dist/ansi.d.ts +122 -131
  5. package/dist/ansi.js +86 -6
  6. package/dist/capabilities.d.ts +5 -0
  7. package/dist/capabilities.js +3 -0
  8. package/dist/cell-_ZVhbfl0.js +44 -0
  9. package/dist/color-CkbalRqK.js +2 -0
  10. package/dist/color-policy-BMzMwV7Q.d.ts +22 -0
  11. package/dist/color-policy-SVj1pYTA.js +560 -0
  12. package/dist/color-profile-DHhQHY55.js +36 -0
  13. package/dist/color-profile-u0Nhe9Nv.d.ts +97 -0
  14. package/dist/color.d.ts +21 -0
  15. package/dist/color.js +3 -0
  16. package/dist/cursor-position-D2LAkRG0.d.ts +7 -0
  17. package/dist/detect-Bh4yGP6w.d.ts +186 -0
  18. package/dist/detect-BuTXtY6e.js +373 -0
  19. package/dist/env-YVw64yZS.js +9 -0
  20. package/dist/escapes-CB_6CWOE.d.ts +72 -0
  21. package/dist/geometry-BxXOzJgo.d.ts +11 -0
  22. package/dist/index-Bmc2tRPk.d.ts +21 -0
  23. package/dist/index.d.ts +143 -498
  24. package/dist/index.js +1016 -2242
  25. package/dist/osc-CCH7xDoS.js +71 -0
  26. package/dist/osc-Cn0fw77g.d.ts +23 -0
  27. package/dist/paint-C19minOS.d.ts +81 -0
  28. package/dist/query-vaIeGOkH.d.ts +152 -0
  29. package/dist/router.d.ts +392 -0
  30. package/dist/router.js +709 -0
  31. package/dist/sample-Cqw1bjUL.js +445 -0
  32. package/dist/screen-BOh__-65.js +324 -0
  33. package/dist/screen-BReKIheE.d.ts +40 -0
  34. package/dist/screen.d.ts +5 -0
  35. package/dist/screen.js +5 -0
  36. package/dist/semantic-text-style-DIMzC7xt.js +91 -0
  37. package/dist/serialize-BTkAZgw1.js +79 -0
  38. package/dist/session-BJmzX2NJ.js +664 -0
  39. package/dist/sgr-BhwaWAJB.js +246 -0
  40. package/dist/store-CgrG9K4y.d.ts +72 -0
  41. package/dist/string-width-CijQwpIk.js +69 -0
  42. package/dist/strip-BvU4toXG.js +6 -0
  43. package/dist/terminal.d.ts +118 -0
  44. package/dist/terminal.js +2 -0
  45. package/dist/tokenize-AjqbvtiT.js +1242 -0
  46. package/dist/tokenize-Dx1y_l5H.d.ts +57 -0
  47. package/dist/truncate-D31fhU6i.js +562 -0
  48. package/dist/use-focus-C2sciZOo.js +1335 -0
  49. package/package.json +41 -9
  50. package/src/ansi/chalk.ts +5 -3
  51. package/src/ansi/escapes.ts +14 -0
  52. package/src/ansi/graphemes.ts +8 -0
  53. package/src/ansi/hyperlink.ts +44 -0
  54. package/src/ansi/index.ts +3 -1
  55. package/src/ansi/osc.ts +77 -0
  56. package/src/ansi/tokenize.ts +3 -4
  57. package/src/capabilities/color-policy.ts +34 -0
  58. package/src/capabilities/detect.ts +594 -0
  59. package/src/capabilities/index.ts +37 -0
  60. package/src/capabilities/query.ts +657 -0
  61. package/src/capabilities/store.ts +379 -0
  62. package/src/color/index.ts +3 -0
  63. package/src/color/paint.ts +169 -0
  64. package/src/color/palette.ts +48 -0
  65. package/src/color/sample.ts +323 -0
  66. package/src/color.ts +1 -0
  67. package/src/components/AnsiText.tsx +42 -0
  68. package/src/components/App.tsx +98 -10
  69. package/src/components/BackgroundContext.ts +2 -3
  70. package/src/components/Box.tsx +0 -8
  71. package/src/components/CursorContext.ts +1 -1
  72. package/src/components/Hyperlink.tsx +56 -0
  73. package/src/components/TerminalOscContext.ts +25 -0
  74. package/src/components/Text.tsx +21 -44
  75. package/src/components/Transform.tsx +1 -1
  76. package/src/dom.ts +5 -2
  77. package/src/global.d.ts +3 -0
  78. package/src/hooks/use-capabilities.ts +73 -0
  79. package/src/hooks/use-cursor.ts +2 -2
  80. package/src/hooks/use-terminal-osc.ts +59 -0
  81. package/src/index.ts +45 -1
  82. package/src/ink.tsx +213 -153
  83. package/src/{render-node-to-output.ts → paint-tree.ts} +71 -48
  84. package/src/reconciler.ts +24 -3
  85. package/src/render-background.ts +36 -15
  86. package/src/render-border.ts +94 -61
  87. package/src/render-frame.ts +83 -0
  88. package/src/render-to-string.ts +21 -6
  89. package/src/render.ts +15 -6
  90. package/src/router/components.tsx +343 -0
  91. package/src/router/context.ts +41 -0
  92. package/src/router/history.ts +194 -0
  93. package/src/router/hooks.tsx +391 -0
  94. package/src/router/index.ts +34 -0
  95. package/src/router/matcher.ts +571 -0
  96. package/src/screen/ansi.ts +184 -0
  97. package/src/screen/canvas.ts +137 -0
  98. package/src/screen/cell.ts +138 -0
  99. package/src/screen/color-profile.ts +47 -0
  100. package/src/screen/geometry.ts +9 -0
  101. package/src/screen/index.ts +6 -0
  102. package/src/screen/screen.ts +272 -0
  103. package/src/screen/serialize.ts +129 -0
  104. package/src/screen.ts +1 -0
  105. package/src/semantic-text-style.ts +118 -0
  106. package/src/squash-text-nodes.ts +2 -5
  107. package/src/structured-text.ts +321 -0
  108. package/src/styles.ts +12 -13
  109. package/src/terminal/index.ts +2 -0
  110. package/src/terminal/inline-presenter.ts +120 -0
  111. package/src/terminal/input.ts +86 -0
  112. package/src/terminal/render-scheduler.ts +37 -0
  113. package/src/terminal/screen-presenter.ts +188 -0
  114. package/src/terminal/session.ts +407 -0
  115. package/src/terminal.ts +1 -0
  116. package/src/testing/browser.ts +588 -0
  117. package/src/testing/emulators.ts +205 -0
  118. package/src/testing/explorer-app/index.html +12 -0
  119. package/src/testing/explorer-app/main.ts +381 -0
  120. package/src/testing/explorer-app/style.css +194 -0
  121. package/src/testing/explorer-app/tsconfig.json +15 -0
  122. package/src/testing/explorer-app/vite-env.d.ts +1 -0
  123. package/src/testing/index.ts +26 -0
  124. package/src/testing/keys.ts +56 -0
  125. package/src/testing/live.ts +85 -0
  126. package/src/testing/matchers.ts +70 -0
  127. package/src/testing/public.ts +94 -0
  128. package/src/testing/terminal.ts +349 -0
  129. package/src/testing/vitest.ts +157 -0
  130. package/src/transform-adapter.ts +14 -0
  131. package/dist/sgr-CMfEpjSk.d.ts +0 -91
  132. package/dist/truncate-Cr6xVFMa.js +0 -2330
  133. package/src/ansi/supports-color.ts +0 -207
  134. package/src/colorize.ts +0 -60
  135. package/src/log-update.ts +0 -370
  136. package/src/output.ts +0 -308
  137. package/src/renderer.ts +0 -73
@@ -0,0 +1,188 @@
1
+ import { cliCursor } from "#/ansi/cursor.ts";
2
+ import { ansiEscapes } from "#/ansi/escapes.ts";
3
+ import {
4
+ buildCursorOnlySequence,
5
+ buildCursorSuffix,
6
+ buildReturnToBottomPrefix,
7
+ cursorPositionChanged,
8
+ type CursorPosition,
9
+ } from "#/cursor-position.ts";
10
+ import { cellsEqual } from "#/screen/cell.ts";
11
+ import type { ColorProfile } from "#/screen/color-profile.ts";
12
+ import type { Screen } from "#/screen/screen.ts";
13
+ import { serializeLine, serializeScreen } from "#/screen/serialize.ts";
14
+
15
+ type Write = (data: string) => boolean;
16
+
17
+ /** Presents structured frames in an inline terminal region. */
18
+ export class ScreenPresenter {
19
+ #screen: Screen | undefined;
20
+ #lineCount = 0;
21
+ #cursor: CursorPosition | undefined;
22
+ #cursorWasShown = false;
23
+ #fullscreen = false;
24
+ #hidden = false;
25
+ readonly #write: Write;
26
+ readonly #stream: NodeJS.WritableStream;
27
+
28
+ constructor(write: Write, stream: NodeJS.WritableStream) {
29
+ this.#write = write;
30
+ this.#stream = stream;
31
+ }
32
+
33
+ present(
34
+ screen: Screen,
35
+ options: {
36
+ readonly colorProfile: ColorProfile;
37
+ readonly cursor?: CursorPosition;
38
+ readonly fullscreen?: boolean;
39
+ readonly forceRewrite?: boolean;
40
+ },
41
+ ): boolean {
42
+ if (!this.#hidden) {
43
+ cliCursor.hide(this.#stream);
44
+ this.#hidden = true;
45
+ }
46
+
47
+ const previousScreen = this.#screen;
48
+ const sameScreen = previousScreen !== undefined && screensEqual(previousScreen, screen);
49
+ const cursorChanged = cursorPositionChanged(options.cursor, this.#cursor);
50
+ const fullscreen = options.fullscreen ?? false;
51
+ const presentationModeChanged = this.#fullscreen !== fullscreen;
52
+ if (!options.forceRewrite && sameScreen && !cursorChanged && !presentationModeChanged) {
53
+ return false;
54
+ }
55
+
56
+ if (!options.forceRewrite && sameScreen && !presentationModeChanged) {
57
+ this.#write(
58
+ buildCursorOnlySequence({
59
+ cursorWasShown: this.#cursorWasShown,
60
+ previousLineCount: this.#lineCount,
61
+ previousCursorPosition: this.#cursor,
62
+ cursorPosition: options.cursor,
63
+ }),
64
+ );
65
+ this.#remember(screen, options.cursor, options.fullscreen ?? false);
66
+ return true;
67
+ }
68
+
69
+ const canRewriteSuffix =
70
+ !options.forceRewrite &&
71
+ previousScreen !== undefined &&
72
+ previousScreen.width === screen.width &&
73
+ this.#fullscreen === fullscreen;
74
+ const firstChangedRow = canRewriteSuffix
75
+ ? findFirstChangedRow(previousScreen, screen)
76
+ : undefined;
77
+ const serializeOptions = {
78
+ colorProfile: options.colorProfile,
79
+ styles: options.colorProfile !== "none",
80
+ } as const;
81
+
82
+ if (firstChangedRow !== undefined) {
83
+ const eraseCount = this.#lineCount - firstChangedRow;
84
+ const body = screen
85
+ .toRows()
86
+ .slice(firstChangedRow)
87
+ .map((line) => serializeLine(line, serializeOptions))
88
+ .join("\n");
89
+ const output = fullscreen || body === "" ? body : `${body}\n`;
90
+ this.#write(
91
+ buildReturnToBottomPrefix(this.#cursorWasShown, this.#lineCount, this.#cursor) +
92
+ ansiEscapes.eraseLines(eraseCount) +
93
+ output +
94
+ buildCursorSuffix(screen.height - (fullscreen ? 1 : 0), options.cursor),
95
+ );
96
+ } else {
97
+ const body = serializeScreen(screen, serializeOptions);
98
+ const output = fullscreen ? body : `${body}\n`;
99
+ this.#write(
100
+ buildReturnToBottomPrefix(this.#cursorWasShown, this.#lineCount, this.#cursor) +
101
+ ansiEscapes.eraseLines(this.#lineCount) +
102
+ output +
103
+ buildCursorSuffix(screen.height - (fullscreen ? 1 : 0), options.cursor),
104
+ );
105
+ }
106
+
107
+ this.#remember(screen, options.cursor, fullscreen);
108
+ return true;
109
+ }
110
+
111
+ willPresent(
112
+ screen: Screen,
113
+ cursor: CursorPosition | undefined,
114
+ fullscreen = false,
115
+ forceRewrite = false,
116
+ ): boolean {
117
+ return (
118
+ forceRewrite ||
119
+ this.#screen === undefined ||
120
+ !screensEqual(this.#screen, screen) ||
121
+ cursorPositionChanged(cursor, this.#cursor) ||
122
+ fullscreen !== this.#fullscreen
123
+ );
124
+ }
125
+
126
+ clear(): void {
127
+ this.#write(
128
+ buildReturnToBottomPrefix(this.#cursorWasShown, this.#lineCount, this.#cursor) +
129
+ ansiEscapes.eraseLines(this.#lineCount),
130
+ );
131
+ this.#lineCount = 0;
132
+ this.#cursor = undefined;
133
+ this.#cursorWasShown = false;
134
+ this.#fullscreen = false;
135
+ }
136
+
137
+ reset(): void {
138
+ this.#screen = undefined;
139
+ this.#lineCount = 0;
140
+ this.#cursor = undefined;
141
+ this.#cursorWasShown = false;
142
+ }
143
+
144
+ done(): void {
145
+ this.reset();
146
+ if (this.#hidden) {
147
+ cliCursor.show(this.#stream);
148
+ this.#hidden = false;
149
+ }
150
+ }
151
+
152
+ get current(): Screen | undefined {
153
+ return this.#screen;
154
+ }
155
+
156
+ #remember(screen: Screen, cursor: CursorPosition | undefined, fullscreen: boolean): void {
157
+ this.#screen = screen;
158
+ this.#lineCount = screen.height + (fullscreen ? 0 : 1);
159
+ this.#cursor = cursor ? { ...cursor } : undefined;
160
+ this.#cursorWasShown = cursor !== undefined;
161
+ this.#fullscreen = fullscreen;
162
+ }
163
+ }
164
+
165
+ function findFirstChangedRow(previous: Screen, next: Screen): number | undefined {
166
+ const height = Math.max(previous.height, next.height);
167
+ for (let y = 0; y < height; y++) {
168
+ if (!rowsEqual(previous, next, y)) return y;
169
+ }
170
+ return undefined;
171
+ }
172
+
173
+ function rowsEqual(left: Screen, right: Screen, y: number): boolean {
174
+ for (let x = 0; x < left.width; x++) {
175
+ if (!cellsEqual(left.cellAt(x, y), right.cellAt(x, y))) return false;
176
+ }
177
+ return true;
178
+ }
179
+
180
+ function screensEqual(left: Screen, right: Screen): boolean {
181
+ if (left.width !== right.width || left.height !== right.height) return false;
182
+ for (let y = 0; y < left.height; y++) {
183
+ for (let x = 0; x < left.width; x++) {
184
+ if (!cellsEqual(left.cellAt(x, y), right.cellAt(x, y))) return false;
185
+ }
186
+ }
187
+ return true;
188
+ }
@@ -0,0 +1,407 @@
1
+ import { ansiEscapes, type CursorShape } from "#/ansi/escapes.ts";
2
+ import {
3
+ notify,
4
+ setClipboard,
5
+ setPointerShape,
6
+ setTerminalProgress,
7
+ setWindowTitle,
8
+ setWorkingDirectory,
9
+ tmuxPassthrough,
10
+ type ClipboardSelection,
11
+ type TerminalProgressState,
12
+ } from "#/ansi/osc.ts";
13
+ import { colorState, type ColorPolicy } from "#/capabilities/color-policy.ts";
14
+ import { getCapabilities, type CapabilitiesStore } from "#/capabilities/store.ts";
15
+ import type { CursorPosition } from "#/cursor-position.ts";
16
+ import type { ColorProfile } from "#/screen/color-profile.ts";
17
+ import type { Screen } from "#/screen/screen.ts";
18
+ import { serializeScreen } from "#/screen/serialize.ts";
19
+ import type { OutputStream } from "#/stream.ts";
20
+ import { TerminalInput } from "#/terminal/input.ts";
21
+ import { ScreenPresenter } from "#/terminal/screen-presenter.ts";
22
+
23
+ export type TerminalSessionOptions = {
24
+ readonly stdin: NodeJS.ReadableStream;
25
+ readonly stdout: OutputStream;
26
+ readonly stderr: OutputStream;
27
+ readonly colorPolicy?: ColorPolicy;
28
+ readonly onCapabilitiesChange?: () => void;
29
+ };
30
+
31
+ export type TerminalMode = {
32
+ readonly id: string;
33
+ readonly enable: string;
34
+ readonly disable: string;
35
+ };
36
+
37
+ export type TerminalCursor = {
38
+ readonly position?: CursorPosition;
39
+ readonly visible: boolean;
40
+ readonly shape: CursorShape;
41
+ readonly blinking: boolean;
42
+ readonly color?: string;
43
+ };
44
+
45
+ type ActiveMode = TerminalMode & { count: number };
46
+ const imperativeProgressOwner = Symbol("imperative-progress");
47
+ const imperativeTitleOwner = Symbol("imperative-title");
48
+
49
+ /** Instance-scoped terminal state and lifecycle ownership. */
50
+ export class TerminalSession {
51
+ readonly stdin: NodeJS.ReadableStream;
52
+ readonly stdout: OutputStream;
53
+ readonly stderr: OutputStream;
54
+ readonly capabilities: CapabilitiesStore;
55
+ readonly input: TerminalInput;
56
+ readonly #presenter: ScreenPresenter;
57
+
58
+ cursor: TerminalCursor = {
59
+ visible: true,
60
+ shape: "block",
61
+ blinking: true,
62
+ };
63
+ suspended = false;
64
+ alternateScreen = false;
65
+ inlineScreen = true;
66
+
67
+ #policy: ColorPolicy;
68
+ #profile: ColorProfile;
69
+ #modes = new Map<string, ActiveMode>();
70
+ #pendingWrites = new Set<Promise<void>>();
71
+ #unsubscribe: () => void;
72
+ #cleaned = false;
73
+ #progressActive = false;
74
+ #progressSequence: string | undefined;
75
+ #progressHeartbeat: ReturnType<typeof setInterval> | undefined;
76
+ #progressPublishers = new Map<symbol, { state: TerminalProgressState; value?: number }>();
77
+ #titlePublishers = new Map<symbol, string>();
78
+ #titleActive = false;
79
+
80
+ constructor(options: TerminalSessionOptions) {
81
+ this.stdin = options.stdin;
82
+ this.stdout = options.stdout;
83
+ this.stderr = options.stderr;
84
+ this.capabilities = getCapabilities(this.stdin, this.stdout);
85
+ this.input = new TerminalInput(this.capabilities);
86
+ this.#presenter = new ScreenPresenter((data) => this.write(data), this.stdout);
87
+ this.#policy = options.colorPolicy ?? "auto";
88
+ this.#profile = colorState(this.capabilities.current, this.#policy).effective;
89
+ this.#unsubscribe = this.capabilities.subscribe(
90
+ (capabilities) => {
91
+ const profile = colorState(capabilities, this.#policy).effective;
92
+ if (profile !== this.#profile) {
93
+ this.#profile = profile;
94
+ this.#presenter.reset();
95
+ }
96
+ options.onCapabilitiesChange?.();
97
+ },
98
+ { resizes: false },
99
+ );
100
+ }
101
+
102
+ get colorProfile(): ColorProfile {
103
+ return this.#profile;
104
+ }
105
+
106
+ /** Encodes a structured frame at the terminal boundary. */
107
+ encode(screen: Screen): string {
108
+ return serializeScreen(screen, {
109
+ colorProfile: this.colorProfile,
110
+ styles: this.colorProfile !== "none",
111
+ });
112
+ }
113
+
114
+ present(
115
+ screen: Screen,
116
+ options: {
117
+ readonly fullscreen?: boolean;
118
+ readonly forceRewrite?: boolean;
119
+ } = {},
120
+ ): boolean {
121
+ return this.#presenter.present(screen, {
122
+ colorProfile: this.colorProfile,
123
+ cursor: this.cursor.position,
124
+ ...options,
125
+ });
126
+ }
127
+
128
+ willPresent(
129
+ screen: Screen,
130
+ options: {
131
+ readonly fullscreen?: boolean;
132
+ readonly forceRewrite?: boolean;
133
+ } = {},
134
+ ): boolean {
135
+ return this.#presenter.willPresent(
136
+ screen,
137
+ this.cursor.position,
138
+ options.fullscreen,
139
+ options.forceRewrite,
140
+ );
141
+ }
142
+
143
+ clearFrame(): void {
144
+ this.#presenter.clear();
145
+ }
146
+
147
+ resetFrame(): void {
148
+ this.#presenter.reset();
149
+ }
150
+
151
+ finishFrame(): void {
152
+ this.#presenter.done();
153
+ }
154
+
155
+ setColorPolicy(policy: ColorPolicy): void {
156
+ this.#policy = policy;
157
+ const profile = colorState(this.capabilities.current, policy).effective;
158
+ if (profile !== this.#profile) {
159
+ this.#profile = profile;
160
+ this.#presenter.reset();
161
+ }
162
+ }
163
+
164
+ /** Writes through the session and tracks callback-based backpressure completion. */
165
+ write(data: string): boolean {
166
+ let settle!: () => void;
167
+ const completion = new Promise<void>((resolve) => {
168
+ settle = resolve;
169
+ });
170
+ this.#pendingWrites.add(completion);
171
+ try {
172
+ return this.stdout.write(data, () => {
173
+ this.#pendingWrites.delete(completion);
174
+ settle();
175
+ });
176
+ } catch (error) {
177
+ this.#pendingWrites.delete(completion);
178
+ settle();
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ async flush(): Promise<void> {
184
+ await Promise.all(this.#pendingWrites);
185
+ }
186
+
187
+ /** Copy text through OSC 52, including tmux passthrough when required. */
188
+ copyToClipboard(text: string, selection: ClipboardSelection = "clipboard"): boolean {
189
+ return this.write(this.#wrapOsc(setClipboard(text, selection)));
190
+ }
191
+
192
+ setTitle(title: string): boolean {
193
+ return this.publishTitle(imperativeTitleOwner, title);
194
+ }
195
+
196
+ publishTitle(owner: symbol, title?: string): boolean {
197
+ this.#titlePublishers.delete(owner);
198
+ if (title !== undefined) this.#titlePublishers.set(owner, title);
199
+ const current = [...this.#titlePublishers.values()].at(-1);
200
+ if (current !== undefined) {
201
+ this.#titleActive = true;
202
+ return this.write(this.#wrapOsc(setWindowTitle(current)));
203
+ }
204
+ if (!this.#titleActive) return true;
205
+ this.#titleActive = false;
206
+ return this.write(this.#wrapOsc(setWindowTitle("")));
207
+ }
208
+
209
+ setWorkingDirectory(directory: URL | string): boolean {
210
+ return this.write(this.#wrapOsc(setWorkingDirectory(directory)));
211
+ }
212
+
213
+ notify(title: string): boolean {
214
+ return this.write(this.#wrapOsc(notify(title)));
215
+ }
216
+
217
+ setPointerShape(shape: string): boolean {
218
+ return this.write(this.#wrapOsc(setPointerShape(shape)));
219
+ }
220
+
221
+ publishProgress(owner: symbol, state: TerminalProgressState, value?: number): boolean {
222
+ this.#progressPublishers.delete(owner);
223
+ if (state !== "inactive") this.#progressPublishers.set(owner, { state, value });
224
+ return this.#applyPublishedProgress();
225
+ }
226
+
227
+ #applyPublishedProgress(): boolean {
228
+ const current = [...this.#progressPublishers.values()].at(-1);
229
+ return current === undefined
230
+ ? this.#writeProgress("inactive")
231
+ : this.#writeProgress(current.state, current.value);
232
+ }
233
+
234
+ /**
235
+ * Update terminal-native progress. Active progress is reset during cleanup
236
+ * so a completed or failed process cannot leave a stale terminal indicator.
237
+ */
238
+ setProgress(state: TerminalProgressState, value?: number): boolean {
239
+ this.#progressPublishers.delete(imperativeProgressOwner);
240
+ if (state !== "inactive") {
241
+ this.#progressPublishers.set(imperativeProgressOwner, { state, value });
242
+ }
243
+ return this.#applyPublishedProgress();
244
+ }
245
+
246
+ #writeProgress(state: TerminalProgressState, value?: number): boolean {
247
+ this.#progressActive = state !== "inactive";
248
+ const sequence = this.#wrapOsc(setTerminalProgress(state, value));
249
+ if (!this.#progressActive) {
250
+ this.#stopProgressHeartbeat();
251
+ } else {
252
+ this.#progressSequence = sequence;
253
+ if (this.#progressHeartbeat === undefined) {
254
+ this.#progressHeartbeat = setInterval(() => {
255
+ if (this.#progressSequence !== undefined) this.write(this.#progressSequence);
256
+ }, 1000);
257
+ this.#progressHeartbeat.unref?.();
258
+ }
259
+ }
260
+ return this.write(sequence);
261
+ }
262
+
263
+ #stopProgressHeartbeat(): void {
264
+ if (this.#progressHeartbeat !== undefined) {
265
+ clearInterval(this.#progressHeartbeat);
266
+ this.#progressHeartbeat = undefined;
267
+ }
268
+ this.#progressSequence = undefined;
269
+ }
270
+
271
+ enableMode(mode: TerminalMode): void;
272
+ enableMode(enable: string, disable: string): void;
273
+ enableMode(modeOrEnable: TerminalMode | string, disable?: string): void {
274
+ const mode =
275
+ typeof modeOrEnable === "string"
276
+ ? {
277
+ id: disable ?? modeOrEnable,
278
+ enable: modeOrEnable,
279
+ disable: disable ?? "",
280
+ }
281
+ : modeOrEnable;
282
+ const active = this.#modes.get(mode.id);
283
+ if (active) {
284
+ active.count++;
285
+ return;
286
+ }
287
+ this.write(mode.enable);
288
+ this.#modes.set(mode.id, { ...mode, count: 1 });
289
+ }
290
+
291
+ disableMode(idOrDisable: string): void {
292
+ const entry =
293
+ this.#modes.get(idOrDisable) ??
294
+ [...this.#modes.values()].find((mode) => mode.disable === idOrDisable);
295
+ if (!entry) return;
296
+ if (--entry.count > 0) return;
297
+ this.#modes.delete(entry.id);
298
+ this.write(entry.disable);
299
+ }
300
+
301
+ setCursor(position: CursorPosition | undefined): void {
302
+ this.cursor = { ...this.cursor, position, visible: position !== undefined };
303
+ }
304
+
305
+ setCursorAppearance(options: {
306
+ readonly visible?: boolean;
307
+ readonly shape?: CursorShape;
308
+ readonly blinking?: boolean;
309
+ readonly color?: string | null;
310
+ }): void {
311
+ const next = {
312
+ ...this.cursor,
313
+ ...(options.visible === undefined ? {} : { visible: options.visible }),
314
+ ...(options.shape === undefined ? {} : { shape: options.shape }),
315
+ ...(options.blinking === undefined ? {} : { blinking: options.blinking }),
316
+ ...(options.color === undefined ? {} : { color: options.color ?? undefined }),
317
+ };
318
+ let output = "";
319
+ if (next.shape !== this.cursor.shape || next.blinking !== this.cursor.blinking)
320
+ output += ansiEscapes.cursorShape(next.shape, next.blinking);
321
+ if (options.color !== undefined)
322
+ output +=
323
+ options.color === null
324
+ ? ansiEscapes.resetCursorColor
325
+ : ansiEscapes.cursorColor(options.color);
326
+ if (next.visible !== this.cursor.visible)
327
+ output += next.visible ? ansiEscapes.cursorShow : ansiEscapes.cursorHide;
328
+ this.cursor = next;
329
+ if (output) this.write(output);
330
+ }
331
+
332
+ setAlternateScreen(enabled: boolean, options: { hideCursor?: boolean } = {}): void {
333
+ if (enabled === this.alternateScreen) return;
334
+ this.alternateScreen = enabled;
335
+ this.inlineScreen = !enabled;
336
+ if (enabled) {
337
+ this.enableMode({
338
+ id: "alternate-screen",
339
+ enable: ansiEscapes.enterAlternativeScreen,
340
+ disable: ansiEscapes.exitAlternativeScreen,
341
+ });
342
+ if (options.hideCursor) this.setCursorAppearance({ visible: false });
343
+ } else {
344
+ this.disableMode("alternate-screen");
345
+ }
346
+ this.#presenter.reset();
347
+ }
348
+
349
+ beginSuspension(): void {
350
+ if (this.suspended) throw new Error("The terminal session is already suspended");
351
+ this.suspended = true;
352
+ }
353
+
354
+ resume(): void {
355
+ if (!this.suspended) return;
356
+ this.suspended = false;
357
+ this.#presenter.reset();
358
+ }
359
+
360
+ cleanup(): void {
361
+ if (this.#cleaned) return;
362
+ this.#cleaned = true;
363
+ this.#unsubscribe();
364
+ this.#stopProgressHeartbeat();
365
+ this.#progressPublishers.clear();
366
+ if (this.#progressActive) {
367
+ try {
368
+ this.stdout.write(this.#wrapOsc(setTerminalProgress("inactive")));
369
+ } catch {}
370
+ this.#progressActive = false;
371
+ }
372
+ this.#titlePublishers.clear();
373
+ if (this.#titleActive) {
374
+ try {
375
+ this.stdout.write(this.#wrapOsc(setWindowTitle("")));
376
+ } catch {}
377
+ this.#titleActive = false;
378
+ }
379
+ for (const mode of [...this.#modes.values()].reverse()) {
380
+ try {
381
+ this.stdout.write(mode.disable);
382
+ } catch {}
383
+ }
384
+ if (this.cursor.color !== undefined) {
385
+ try {
386
+ this.stdout.write(ansiEscapes.resetCursorColor);
387
+ } catch {}
388
+ }
389
+ if (!this.cursor.visible) {
390
+ try {
391
+ this.stdout.write(ansiEscapes.cursorShow);
392
+ } catch {}
393
+ }
394
+ this.#modes.clear();
395
+ this.alternateScreen = false;
396
+ this.inlineScreen = true;
397
+ this.suspended = false;
398
+ this.cursor = { visible: true, shape: "block", blinking: true };
399
+ this.#presenter.done();
400
+ }
401
+
402
+ #wrapOsc(sequence: string): string {
403
+ return this.capabilities.current.terminal.multiplexer === "tmux"
404
+ ? tmuxPassthrough(sequence)
405
+ : sequence;
406
+ }
407
+ }
@@ -0,0 +1 @@
1
+ export * from "#/terminal/index.ts";