Skip to content
Source
Sign inSign up

terminalSession.ts

258 lines7.7 KB

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;
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;
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;
55​
56export interface KnownTerminalSessionTarget {
57 readonly environmentId: EnvironmentId;
58 readonly threadId: ThreadId;
59 readonly terminalId: string;
61​
62export interface KnownTerminalSession {
63 readonly target: KnownTerminalSessionTarget;
64 readonly state: TerminalSessionState;
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);
74​
75/** What a terminal tab shows: a process running, the last command failed, or an idle shell. */
76export type TerminalActivityStatus = "running" | "failed" | "idle";
77​
78export function resolveTerminalActivityStatus(
79 state: Pick<TerminalSessionState, "summary" | "hasRunningSubprocess">,
80): TerminalActivityStatus {
81 if (state.hasRunningSubprocess) return "running";
82 const exitCode = state.summary?.lastCommandExitCode;
83 return exitCode !== undefined && exitCode !== 0 ? "failed" : "idle";
85​
86export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({
87 output: EMPTY_TERMINAL_OUTPUT_STATE,
88 grid: null,
89 status: "closed",
90 error: null,
91 updatedAt: null,
92 version: 0,
93 lifecycleVersion: 0,
94});
95​
96export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({
97 summary: null,
98 output: EMPTY_TERMINAL_OUTPUT_STATE,
99 grid: null,
100 status: "closed",
101 error: null,
102 hasRunningSubprocess: false,
103 updatedAt: null,
104 version: 0,
105 lifecycleVersion: 0,
106});
107​
108let terminalAttachGeneration = 0;
109​
110/** A reinstalled attach stream must not reuse an old renderer's output cursor. */
111export function nextTerminalAttachSeedState(): TerminalBufferState {
112 return {
113 ...EMPTY_TERMINAL_BUFFER_STATE,
114 output: {
115 ...EMPTY_TERMINAL_OUTPUT_STATE,
116 generation: ++terminalAttachGeneration,
117 },
118 };
120​
121function terminalBufferStateFromSnapshot(
122 snapshot: TerminalSessionSnapshot,
123 maxBufferBytes: number,
124 current: TerminalBufferState = EMPTY_TERMINAL_BUFFER_STATE,
125): TerminalBufferState {
126 return {
127 output: resetOutput(current.output, snapshot.history, maxBufferBytes),
128 grid: nextGrid(current.grid, snapshot),
129 status: snapshot.status,
130 error: null,
131 updatedAt: snapshot.updatedAt,
132 version: current.version + 1,
133 lifecycleVersion: current.lifecycleVersion,
134 };
136​
137function nextGrid(
138 current: TerminalGrid | null,
139 source: { readonly cols?: number | undefined; readonly rows?: number | undefined },
140): TerminalGrid | null {
141 if (source.cols === undefined || source.rows === undefined) return current;
142 return current?.cols === source.cols && current.rows === source.rows
143 ? current
144 : { cols: source.cols, rows: source.rows };
146​
147function latestTimestamp(left: string | null, right: string | null): string | null {
148 if (left === null) return right;
149 if (right === null) return left;
150 return Date.parse(left) >= Date.parse(right) ? left : right;
152​
153export function combineTerminalSessionState(
154 summary: TerminalSummary | null,
155 buffer: TerminalBufferState,
156): TerminalSessionState {
157 return {
158 summary,
159 output: buffer.output,
160 grid: buffer.grid,
161 status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status),
162 error: buffer.error,
163 hasRunningSubprocess: summary?.hasRunningSubprocess ?? false,
164 updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt),
165 version: buffer.version,
166 lifecycleVersion: buffer.lifecycleVersion,
167 };
169​
170export function applyTerminalAttachStreamEvent(
171 current: TerminalBufferState,
172 event: TerminalAttachStreamEvent,
173 maxBufferBytes = DEFAULT_MAX_TERMINAL_BUFFER_BYTES,
174): TerminalBufferState {
175 switch (event.type) {
176 case "snapshot": {
177 if (current.version === 0) {
178 return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current);
179 }
180 // A later snapshot is a new server attachment, as after a socket reconnect. It starts a
181 // new generation because viewers replay their measured grid once per generation.
182 const reattached = {
183 ...current,
184 output: { ...current.output, generation: ++terminalAttachGeneration },
185 };
186 return {
187 ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, reattached),
188 lifecycleVersion: current.lifecycleVersion + 1,
189 };
190 }
191 case "restarted":
192 return {
193 ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current),
194 lifecycleVersion: current.lifecycleVersion + 1,
195 };
196 case "output":
197 return {
198 ...current,
199 output: appendOutput(current.output, event.data, maxBufferBytes),
200 status: current.status === "closed" ? "running" : current.status,
201 error: null,
202 version: current.version + 1,
203 };
204 case "cleared":
205 return {
206 ...current,
207 output: resetOutput(current.output, "", maxBufferBytes),
208 error: null,
209 version: current.version + 1,
210 };
211 case "exited":
212 return {
213 ...current,
214 status: "exited",
215 error: null,
216 version: current.version + 1,
217 };
218 case "closed":
219 return {
220 ...current,
221 status: "closed",
222 error: null,
223 version: current.version + 1,
224 };
225 case "error":
226 return {
227 ...current,
228 status: "error",
229 error: event.message,
230 version: current.version + 1,
231 };
232 case "activity": {
233 const grid = nextGrid(current.grid, event);
234 return grid === current.grid ? current : { ...current, grid };
235 }
236 }
238​
239export function applyTerminalMetadataStreamEvent(
240 current: ReadonlyArray<TerminalSummary>,
241 event: TerminalMetadataStreamEvent,
242): ReadonlyArray<TerminalSummary> {
243 if (event.type === "snapshot") {
244 return event.terminals;
245 }
246 if (event.type === "remove") {
247 return current.filter(
248 (terminal) =>
249 terminal.threadId !== event.threadId || terminal.terminalId !== event.terminalId,
250 );
251 }
252 const next = current.filter(
253 (terminal) =>
254 terminal.threadId !== event.terminal.threadId ||
255 terminal.terminalId !== event.terminal.terminalId,
256 );
257 return [...next, event.terminal];