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