1import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
2import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts";
3import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
4import * as Arr from "effect/Array";
5import * as Order from "effect/Order";
6
7export {
8 getTerminalLabel,
9 nextTerminalId,
10 resolveTerminalSessionLabel,
11} from "@t3tools/shared/terminalLabels";
12
13export interface TerminalMenuSession {
14 readonly terminalId: string;
15 readonly cwd: string | null;
16 readonly status: "starting" | "running" | "exited" | "error" | "closed";
17 readonly hasRunningSubprocess: boolean;
18 /** Server-authoritative title with the same fallback rules as web. */
19 readonly displayLabel: string;
20 readonly updatedAt: string | null;
21}
22
23const terminalMenuSessionOrder = Order.make<TerminalMenuSession>((left, right) => {
24 const comparison = left.terminalId.localeCompare(right.terminalId, undefined, { numeric: true });
25 if (comparison === 0) {
26 return 0;
27 }
28 return comparison < 0 ? -1 : 1;
29});
30
31export function basename(input: string | null): string | null {
32 if (!input) {
33 return null;
34 }
35
36 const normalized = input.replace(/\/+$/, "");
37 if (normalized.length === 0) {
38 return "/";
39 }
40
41 const segments = normalized.split("/");
42 return segments[segments.length - 1] ?? normalized;
43}
44
45export function getTerminalStatusLabel(input: {
46 readonly status: TerminalMenuSession["status"];
47 readonly hasRunningSubprocess?: boolean;
48}): string {
49 if (input.status === "running") {
50 return input.hasRunningSubprocess ? "Task running" : "Ready";
51 }
52 if (input.status === "starting") {
53 return "Starting";
54 }
55 if (input.status === "exited") {
56 return "Exited";
57 }
58 if (input.status === "error") {
59 return "Error";
60 }
61
62 return "Not started";
63}
64
65/**
66 * Picks an id for "open another shell". Counts the terminal screen already mounted
67 * (`activeRouteTerminalId`) as occupied so an empty session list on the primary route
68 * still advances to `term-2` instead of `replace`-navigating to the same `default` tab.
69 */
70export function nextOpenTerminalId(input: {
71 readonly listedTerminalIds: ReadonlyArray<string>;
72 readonly activeRouteTerminalId?: string | null;
73 readonly uniqueSuffix?: string;
74}): string {
75 const listed = input.listedTerminalIds.filter((id) => id.trim().length > 0);
76 const routeId = input.activeRouteTerminalId?.trim() ? input.activeRouteTerminalId : null;
77
78 if (!routeId || listed.includes(routeId)) {
79 return nextTerminalId(listed, input.uniqueSuffix);
80 }
81
82 return nextTerminalId([...listed, routeId], input.uniqueSuffix);
83}
84
85export function buildTerminalMenuSessions(input: {
86 readonly knownSessions: ReadonlyArray<KnownTerminalSession>;
87 readonly workspaceRoot: string | null;
88 readonly currentSession?: TerminalMenuSession | null;
89}): ReadonlyArray<TerminalMenuSession> {
90 const sessionsById = new Map<string, TerminalMenuSession>();
91
92 for (const session of input.knownSessions) {
93 if (
94 session.state.status !== "running" &&
95 session.state.status !== "starting" &&
96 session.target.terminalId !== input.currentSession?.terminalId
97 ) {
98 continue;
99 }
100
101 sessionsById.set(session.target.terminalId, {
102 terminalId: session.target.terminalId,
103 cwd: session.state.summary?.cwd ?? input.workspaceRoot,
104 status: session.state.status,
105 hasRunningSubprocess: session.state.hasRunningSubprocess,
106 displayLabel: resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
107 updatedAt: session.state.updatedAt,
108 });
109 }
110
111 if (input.currentSession && !sessionsById.has(input.currentSession.terminalId)) {
112 sessionsById.set(input.currentSession.terminalId, input.currentSession);
113 }
114
115 return Arr.sort(sessionsById.values(), terminalMenuSessionOrder);
116}
117
118/**
119 * Picks the session to show after a terminal exits: the nearest live session
120 * below the exited id (terminal n-1), falling back to the nearest one above.
121 * Returns null when no other live session remains and the terminal UI should
122 * be dismissed instead.
123 */
124export function previousLiveTerminalId(input: {
125 readonly sessions: ReadonlyArray<TerminalMenuSession>;
126 readonly exitedTerminalId: string;
127}): string | null {
128 const live = Arr.sort(
129 input.sessions.filter(
130 (session) =>
131 session.terminalId !== input.exitedTerminalId &&
132 (session.status === "running" || session.status === "starting"),
133 ),
134 terminalMenuSessionOrder,
135 );
136 if (live.length === 0) {
137 return null;
138 }
139
140 const below = live.filter(
141 (session) =>
142 session.terminalId.localeCompare(input.exitedTerminalId, undefined, { numeric: true }) < 0,
143 );
144 return (below[below.length - 1] ?? live[0])?.terminalId ?? null;
145}
146
147export function resolveProjectScriptTerminalId(input: {
148 readonly existingTerminalIds: ReadonlyArray<string>;
149 readonly hasRunningTerminal: boolean;
150 readonly uniqueSuffix?: string;
151}): string {
152 if (!input.hasRunningTerminal && input.uniqueSuffix === undefined) {
153 return DEFAULT_TERMINAL_ID;
154 }
155
156 return nextTerminalId(input.existingTerminalIds, input.uniqueSuffix);
157}
158
159export function projectScriptMenuIcon(icon: ProjectScript["icon"]) {
160 if (icon === "test") return "flask";
161 if (icon === "lint") return "checklist";
162 if (icon === "configure") return "wrench.and.screwdriver";
163 if (icon === "build") return "hammer";
164 if (icon === "debug") return "ladybug";
165 return "play";
166}