1import type {
2 EnvironmentId,
3 TerminalAttachStreamEvent,
4 TerminalMetadataStreamEvent,
5 TerminalSessionSnapshot,
6 TerminalSummary,
7 ThreadId,
8} from "@t3tools/contracts";
9import { DEFAULT_MAX_TERMINAL_BUFFER_BYTES } from "@t3tools/contracts";
10import {
11 appendOutput,
12 EMPTY_TERMINAL_OUTPUT_STATE,
13 resetOutput,
14 type TerminalOutputState,
15} from "./terminalOutput.ts";
16
17export {
18 INITIAL_TERMINAL_OUTPUT_CURSOR,
19 readTerminalOutputUpdate,
20 terminalOutputText,
21 type TerminalOutputCursor,
22 type TerminalOutputState,
23 type TerminalOutputUpdate,
24} from "./terminalOutput.ts";
25export { DEFAULT_MAX_TERMINAL_BUFFER_BYTES } from "@t3tools/contracts";
26
27/** The PTY grid every renderer of a shared terminal draws. */
28export interface TerminalGrid {
29 readonly cols: number;
30 readonly rows: number;
31}
32
33export interface TerminalSessionState {
34 readonly summary: TerminalSummary | null;
35 readonly output: TerminalOutputState;
36 /** Null when the server predates shared terminal geometry. */
37 readonly grid: TerminalGrid | null;
38 readonly status: TerminalSessionSnapshot["status"] | "closed";
39 readonly error: string | null;
40 readonly hasRunningSubprocess: boolean;
41 readonly updatedAt: string | null;
42 readonly version: number;
43 readonly lifecycleVersion: number;
44}
45
46export interface TerminalBufferState {
47 readonly output: TerminalOutputState;
48 readonly grid: TerminalGrid | null;
49 readonly status: TerminalSessionSnapshot["status"] | "closed";
50 readonly error: string | null;
51 readonly updatedAt: string | null;
52 readonly version: number;
53 readonly lifecycleVersion: number;
54}
55
56export interface KnownTerminalSessionTarget {
57 readonly environmentId: EnvironmentId;
58 readonly threadId: ThreadId;
59 readonly terminalId: string;
60}
61
62export interface KnownTerminalSession {
63 readonly target: KnownTerminalSessionTarget;
64 readonly state: TerminalSessionState;
65}
66
67export function selectRunningSubprocessTerminalIds(
68 sessions: ReadonlyArray<KnownTerminalSession>,
69): ReadonlyArray<string> {
70 return sessions
71 .filter((session) => session.state.hasRunningSubprocess)
72 .map((session) => session.target.terminalId);
73}
74
75export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({
76 output: EMPTY_TERMINAL_OUTPUT_STATE,
77 grid: null,
78 status: "closed",
79 error: null,
80 updatedAt: null,
81 version: 0,
82 lifecycleVersion: 0,
83});
84
85export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({
86 summary: null,
87 output: EMPTY_TERMINAL_OUTPUT_STATE,
88 grid: null,
89 status: "closed",
90 error: null,
91 hasRunningSubprocess: false,
92 updatedAt: null,
93 version: 0,
94 lifecycleVersion: 0,
95});
96
97let terminalAttachGeneration = 0;
98
99/** A reinstalled attach stream must not reuse an old renderer's output cursor. */
100export function nextTerminalAttachSeedState(): TerminalBufferState {
101 return {
102 ...EMPTY_TERMINAL_BUFFER_STATE,
103 output: {
104 ...EMPTY_TERMINAL_OUTPUT_STATE,
105 generation: ++terminalAttachGeneration,
106 },
107 };
108}
109
110function terminalBufferStateFromSnapshot(
111 snapshot: TerminalSessionSnapshot,
112 maxBufferBytes: number,
113 current: TerminalBufferState = EMPTY_TERMINAL_BUFFER_STATE,
114): TerminalBufferState {
115 return {
116 output: resetOutput(current.output, snapshot.history, maxBufferBytes),
117 grid: nextGrid(current.grid, snapshot),
118 status: snapshot.status,
119 error: null,
120 updatedAt: snapshot.updatedAt,
121 version: current.version + 1,
122 lifecycleVersion: current.lifecycleVersion,
123 };
124}
125
126function nextGrid(
127 current: TerminalGrid | null,
128 source: { readonly cols?: number | undefined; readonly rows?: number | undefined },
129): TerminalGrid | null {
130 if (source.cols === undefined || source.rows === undefined) return current;
131 return current?.cols === source.cols && current.rows === source.rows
132 ? current
133 : { cols: source.cols, rows: source.rows };
134}
135
136function latestTimestamp(left: string | null, right: string | null): string | null {
137 if (left === null) return right;
138 if (right === null) return left;
139 return Date.parse(left) >= Date.parse(right) ? left : right;
140}
141
142export function combineTerminalSessionState(
143 summary: TerminalSummary | null,
144 buffer: TerminalBufferState,
145): TerminalSessionState {
146 return {
147 summary,
148 output: buffer.output,
149 grid: buffer.grid,
150 status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status),
151 error: buffer.error,
152 hasRunningSubprocess: summary?.hasRunningSubprocess ?? false,
153 updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt),
154 version: buffer.version,
155 lifecycleVersion: buffer.lifecycleVersion,
156 };
157}
158
159export function applyTerminalAttachStreamEvent(
160 current: TerminalBufferState,
161 event: TerminalAttachStreamEvent,
162 maxBufferBytes = DEFAULT_MAX_TERMINAL_BUFFER_BYTES,
163): TerminalBufferState {
164 switch (event.type) {
165 case "snapshot": {
166 if (current.version === 0) {
167 return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current);
168 }
169 // A later snapshot is a new server attachment, as after a socket reconnect. It starts a
170 // new generation because viewers replay their measured grid once per generation.
171 const reattached = {
172 ...current,
173 output: { ...current.output, generation: ++terminalAttachGeneration },
174 };
175 return {
176 ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, reattached),
177 lifecycleVersion: current.lifecycleVersion + 1,
178 };
179 }
180 case "restarted":
181 return {
182 ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current),
183 lifecycleVersion: current.lifecycleVersion + 1,
184 };
185 case "output":
186 return {
187 ...current,
188 output: appendOutput(current.output, event.data, maxBufferBytes),
189 status: current.status === "closed" ? "running" : current.status,
190 error: null,
191 version: current.version + 1,
192 };
193 case "cleared":
194 return {
195 ...current,
196 output: resetOutput(current.output, "", maxBufferBytes),
197 error: null,
198 version: current.version + 1,
199 };
200 case "exited":
201 return {
202 ...current,
203 status: "exited",
204 error: null,
205 version: current.version + 1,
206 };
207 case "closed":
208 return {
209 ...current,
210 status: "closed",
211 error: null,
212 version: current.version + 1,
213 };
214 case "error":
215 return {
216 ...current,
217 status: "error",
218 error: event.message,
219 version: current.version + 1,
220 };
221 case "activity": {
222 const grid = nextGrid(current.grid, event);
223 return grid === current.grid ? current : { ...current, grid };
224 }
225 }
226}
227
228export function applyTerminalMetadataStreamEvent(
229 current: ReadonlyArray<TerminalSummary>,
230 event: TerminalMetadataStreamEvent,
231): ReadonlyArray<TerminalSummary> {
232 if (event.type === "snapshot") {
233 return event.terminals;
234 }
235 if (event.type === "remove") {
236 return current.filter(
237 (terminal) =>
238 terminal.threadId !== event.threadId || terminal.terminalId !== event.terminalId,
239 );
240 }
241 const next = current.filter(
242 (terminal) =>
243 terminal.threadId !== event.terminal.threadId ||
244 terminal.terminalId !== event.terminal.terminalId,
245 );
246 return [...next, event.terminal];
247}