/** * Single Zustand store for terminal UI state keyed by scoped thread identity. * * Terminal UI transition helpers are intentionally private to keep the public * API constrained to store actions/selectors. */ import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment";import { type ScopedThreadRef } from "@t3tools/contracts";import { create } from "zustand";import { createJSONStorage, persist } from "zustand/middleware";import { resolveStorage } from "./lib/storage";import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup,} from "./types"; interface ThreadTerminalUiState { terminalOpen: boolean; terminalHeight: number; terminalIds: string[]; activeTerminalId: string; terminalGroups: ThreadTerminalGroup[]; activeTerminalGroupId: string;} // Keep the old storage key so existing drawer layout preferences migrate.const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; interface PersistedTerminalUiStateStoreState { terminalUiStateByThreadKey?: Record<string, ThreadTerminalUiState>; terminalStateByThreadKey?: Record<string, ThreadTerminalUiState>;} export function migratePersistedTerminalUiStateStoreState( persistedState: unknown, _version: number,): PersistedTerminalUiStateStoreState { if (!persistedState || typeof persistedState !== "object") { return { terminalUiStateByThreadKey: {} }; } const candidate = persistedState as PersistedTerminalUiStateStoreState; const persistedUiStateByThreadKey = candidate.terminalUiStateByThreadKey ?? candidate.terminalStateByThreadKey ?? {}; const terminalUiStateByThreadKey = Object.fromEntries( Object.entries(persistedUiStateByThreadKey).filter(([threadKey]) => parseScopedThreadKey(threadKey), ), ); return { terminalUiStateByThreadKey };} function createTerminalUiStateStorage() { return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);} function normalizeTerminalIds(terminalIds: string[]): string[] { const normalizedIds: string[] = []; const seen = new Set<string>(); for (const id of terminalIds) { const trimmedId = id.trim(); if (trimmedId.length === 0 || seen.has(trimmedId)) continue; seen.add(trimmedId); normalizedIds.push(trimmedId); } return normalizedIds;} function fallbackGroupId(terminalId: string): string { return `group-${terminalId}`;} function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string { let candidate = baseId; let index = 2; while (usedGroupIds.has(candidate)) { candidate = `${baseId}-${index}`; index += 1; } usedGroupIds.add(candidate); return candidate;} function findGroupIndexByTerminalId( terminalGroups: ThreadTerminalGroup[], terminalId: string,): number { return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));} function normalizeTerminalGroupIds(terminalIds: string[]): string[] { return normalizeTerminalIds(terminalIds);} function normalizeTerminalGroups( terminalGroups: ThreadTerminalGroup[], terminalIds: string[],): ThreadTerminalGroup[] { if (terminalIds.length === 0) { return []; } const validTerminalIdSet = new Set(terminalIds); const assignedTerminalIds = new Set<string>(); const nextGroups: ThreadTerminalGroup[] = []; const usedGroupIds = new Set<string>(); for (const group of terminalGroups) { const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { if (!validTerminalIdSet.has(terminalId)) return false; if (assignedTerminalIds.has(terminalId)) return false; return true; }); if (groupTerminalIds.length === 0) continue; for (const terminalId of groupTerminalIds) { assignedTerminalIds.add(terminalId); } const baseGroupId = group.id.trim().length > 0 ? group.id.trim() : fallbackGroupId(groupTerminalIds[0] ?? terminalIds[0] ?? ""); nextGroups.push({ id: assignUniqueGroupId(baseGroupId, usedGroupIds), terminalIds: groupTerminalIds, ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), }); } for (const terminalId of terminalIds) { if (assignedTerminalIds.has(terminalId)) continue; nextGroups.push({ id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), terminalIds: [terminalId], }); } return nextGroups;} function arraysEqual(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; for (let index = 0; index < a.length; index += 1) { if (a[index] !== b[index]) return false; } return true;} function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean { if (left.length !== right.length) return false; for (let index = 0; index < left.length; index += 1) { const leftGroup = left[index]; const rightGroup = right[index]; if (!leftGroup || !rightGroup) return false; if (leftGroup.id !== rightGroup.id) return false; if ( (leftGroup.splitDirection ?? "horizontal") !== (rightGroup.splitDirection ?? "horizontal") ) { return false; } if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; } return true;} function threadTerminalUiStateEqual( left: ThreadTerminalUiState, right: ThreadTerminalUiState,): boolean { return ( left.terminalOpen === right.terminalOpen && left.terminalHeight === right.terminalHeight && left.activeTerminalId === right.activeTerminalId && left.activeTerminalGroupId === right.activeTerminalGroupId && arraysEqual(left.terminalIds, right.terminalIds) && terminalGroupsEqual(left.terminalGroups, right.terminalGroups) );} const DEFAULT_THREAD_TERMINAL_UI_STATE: ThreadTerminalUiState = Object.freeze({ terminalOpen: false, terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: [], activeTerminalId: "", terminalGroups: [], activeTerminalGroupId: "",}); function createDefaultThreadTerminalUiState(): ThreadTerminalUiState { return { ...DEFAULT_THREAD_TERMINAL_UI_STATE, terminalIds: [...DEFAULT_THREAD_TERMINAL_UI_STATE.terminalIds], terminalGroups: copyTerminalGroups(DEFAULT_THREAD_TERMINAL_UI_STATE.terminalGroups), };} function getDefaultThreadTerminalUiState(): ThreadTerminalUiState { return DEFAULT_THREAD_TERMINAL_UI_STATE;} function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTerminalUiState { const nextTerminalIds = normalizeTerminalIds(state.terminalIds); const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) ? state.activeTerminalId : (nextTerminalIds[0] ?? ""); const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); const activeGroupIdFromState = terminalGroups.some( (group) => group.id === state.activeTerminalGroupId, ) ? state.activeTerminalGroupId : null; const activeGroupIdFromTerminal = terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; const normalized: ThreadTerminalUiState = { terminalOpen: state.terminalOpen, terminalHeight: Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 ? state.terminalHeight : DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: nextTerminalIds, activeTerminalId, terminalGroups, activeTerminalGroupId: activeGroupIdFromState ?? activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "", }; return threadTerminalUiStateEqual(state, normalized) ? state : normalized;} function isDefaultThreadTerminalUiState(state: ThreadTerminalUiState): boolean { const normalized = normalizeThreadTerminalUiState(state); return threadTerminalUiStateEqual(normalized, DEFAULT_THREAD_TERMINAL_UI_STATE);} function isValidTerminalId(terminalId: string): boolean { return terminalId.trim().length > 0;} function terminalThreadKey(threadRef: ScopedThreadRef): string { return scopedThreadKey(threadRef);} function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { return groups.map((group) => ({ id: group.id, terminalIds: [...group.terminalIds], ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), }));} function upsertTerminalIntoGroups( state: ThreadTerminalUiState, terminalId: string, mode: "split" | "new", splitDirection: "horizontal" | "vertical" = "horizontal",): ThreadTerminalUiState { const normalized = normalizeThreadTerminalUiState(state); const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode; if (!isValidTerminalId(terminalId)) { return normalized; } const isNewTerminal = !normalized.terminalIds.includes(terminalId); const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds; const terminalGroups = copyTerminalGroups(normalized.terminalGroups); const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId); if (existingGroupIndex >= 0) { terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[ existingGroupIndex ]!.terminalIds.filter((id) => id !== terminalId); if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { terminalGroups.splice(existingGroupIndex, 1); } } if (effectiveMode === "new") { const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); return normalizeThreadTerminalUiState({ ...normalized, terminalOpen: true, terminalIds, activeTerminalId: terminalId, terminalGroups, activeTerminalGroupId: nextGroupId, }); } let activeGroupIndex = terminalGroups.findIndex( (group) => group.id === normalized.activeTerminalGroupId, ); if (activeGroupIndex < 0) { activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId); } if (activeGroupIndex < 0) { const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); const nextGroupId = assignUniqueGroupId( fallbackGroupId(normalized.activeTerminalId), usedGroupIds, ); terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); activeGroupIndex = terminalGroups.length - 1; } const destinationGroup = terminalGroups[activeGroupIndex]; if (!destinationGroup) { return normalized; } const destinationTerminalIdSet = new Set(destinationGroup.terminalIds); if ( isNewTerminal && !destinationTerminalIdSet.has(terminalId) && destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP ) { return normalized; } if (!destinationTerminalIdSet.has(terminalId)) { const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); if (anchorIndex >= 0) { destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId); } else { destinationGroup.terminalIds.push(terminalId); } } if (splitDirection === "vertical") { destinationGroup.splitDirection = "vertical"; } else { delete destinationGroup.splitDirection; } return normalizeThreadTerminalUiState({ ...normalized, terminalOpen: true, terminalIds, activeTerminalId: terminalId, terminalGroups, activeTerminalGroupId: destinationGroup.id, });} function setThreadTerminalOpen(state: ThreadTerminalUiState, open: boolean): ThreadTerminalUiState { const normalized = normalizeThreadTerminalUiState(state); if (open && normalized.terminalIds.length === 0) { return upsertTerminalIntoGroups(normalized, DEFAULT_THREAD_TERMINAL_ID, "new"); } if (normalized.terminalOpen === open) return normalized; return { ...normalized, terminalOpen: open };} function setThreadTerminalHeight( state: ThreadTerminalUiState, height: number,): ThreadTerminalUiState { const normalized = normalizeThreadTerminalUiState(state); if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) { return normalized; } return { ...normalized, terminalHeight: height };} function splitThreadTerminal( state: ThreadTerminalUiState, terminalId: string, direction: "horizontal" | "vertical" = "horizontal",): ThreadTerminalUiState { return upsertTerminalIntoGroups(state, terminalId, "split", direction);} function newThreadTerminal( state: ThreadTerminalUiState, terminalId: string,): ThreadTerminalUiState { return upsertTerminalIntoGroups(state, terminalId, "new");} function setThreadActiveTerminal( state: ThreadTerminalUiState, terminalId: string,): ThreadTerminalUiState { const normalized = normalizeThreadTerminalUiState(state); if (!normalized.terminalIds.includes(terminalId)) { return normalized; } const activeTerminalGroupId = normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ?? normalized.activeTerminalGroupId; if ( normalized.activeTerminalId === terminalId && normalized.activeTerminalGroupId === activeTerminalGroupId ) { return normalized; } return { ...normalized, activeTerminalId: terminalId, activeTerminalGroupId, };} function closeThreadTerminal( state: ThreadTerminalUiState, terminalId: string,): ThreadTerminalUiState { const normalized = normalizeThreadTerminalUiState(state); if (!normalized.terminalIds.includes(terminalId)) { return normalized; } const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId); if (remainingTerminalIds.length === 0) { return createDefaultThreadTerminalUiState(); } const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); const nextActiveTerminalId = normalized.activeTerminalId === terminalId ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? remainingTerminalIds[0] ?? "") : normalized.activeTerminalId; const terminalGroups: ThreadTerminalGroup[] = []; for (const group of normalized.terminalGroups) { const terminalIds = group.terminalIds.filter((id) => id !== terminalId); if (terminalIds.length > 0) { terminalGroups.push({ ...group, terminalIds }); } } const nextActiveTerminalGroupId = terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? terminalGroups[0]?.id ?? fallbackGroupId(nextActiveTerminalId); return normalizeThreadTerminalUiState({ terminalOpen: normalized.terminalOpen, terminalHeight: normalized.terminalHeight, terminalIds: remainingTerminalIds, activeTerminalId: nextActiveTerminalId, terminalGroups, activeTerminalGroupId: nextActiveTerminalGroupId, });} function reconcileThreadTerminalSessionIds( state: ThreadTerminalUiState, nextIds: string[],): ThreadTerminalUiState { const normalized = normalizeThreadTerminalUiState(state); if (arraysEqual(normalized.terminalIds, nextIds)) { return normalized; } const nextActiveTerminalId = nextIds.includes(normalized.activeTerminalId) ? normalized.activeTerminalId : (nextIds[0] ?? ""); const terminalGroups = normalizeTerminalGroups(normalized.terminalGroups, nextIds); const activeGroupIdFromTerminal = terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? null; return normalizeThreadTerminalUiState({ ...normalized, terminalIds: nextIds, activeTerminalId: nextActiveTerminalId, terminalGroups, activeTerminalGroupId: activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "", });} export function selectThreadTerminalUiState( terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>, threadRef: ScopedThreadRef | null | undefined,): ThreadTerminalUiState { if (!threadRef || threadRef.threadId.length === 0) { return getDefaultThreadTerminalUiState(); } return ( terminalUiStateByThreadKey[terminalThreadKey(threadRef)] ?? getDefaultThreadTerminalUiState() );} function updateTerminalUiStateByThreadKey( terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>, threadRef: ScopedThreadRef, updater: (state: ThreadTerminalUiState) => ThreadTerminalUiState,): Record<string, ThreadTerminalUiState> { if (threadRef.threadId.length === 0) { return terminalUiStateByThreadKey; } Lines 1–500 of 781. Load more
/** * Single Zustand store for terminal UI state keyed by scoped thread identity. * * Terminal UI transition helpers are intentionally private to keep the public * API constrained to store actions/selectors. */ import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { type ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "./types"; interface ThreadTerminalUiState { terminalOpen: boolean; terminalHeight: number; terminalIds: string[]; activeTerminalId: string; terminalGroups: ThreadTerminalGroup[]; activeTerminalGroupId: string; } // Keep the old storage key so existing drawer layout preferences migrate. const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; interface PersistedTerminalUiStateStoreState { terminalUiStateByThreadKey?: Record<string, ThreadTerminalUiState>; terminalStateByThreadKey?: Record<string, ThreadTerminalUiState>; } export function migratePersistedTerminalUiStateStoreState( persistedState: unknown, _version: number, ): PersistedTerminalUiStateStoreState { if (!persistedState || typeof persistedState !== "object") { return { terminalUiStateByThreadKey: {} }; } const candidate = persistedState as PersistedTerminalUiStateStoreState; const persistedUiStateByThreadKey = candidate.terminalUiStateByThreadKey ?? candidate.terminalStateByThreadKey ?? {}; const terminalUiStateByThreadKey = Object.fromEntries( Object.entries(persistedUiStateByThreadKey).filter(([threadKey]) => parseScopedThreadKey(threadKey), ), ); return { terminalUiStateByThreadKey }; } function createTerminalUiStateStorage() { return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); } function normalizeTerminalIds(terminalIds: string[]): string[] { const normalizedIds: string[] = []; const seen = new Set<string>(); for (const id of terminalIds) { const trimmedId = id.trim(); if (trimmedId.length === 0 || seen.has(trimmedId)) continue; seen.add(trimmedId); normalizedIds.push(trimmedId); } return normalizedIds; } function fallbackGroupId(terminalId: string): string { return `group-${terminalId}`; } function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string { let candidate = baseId; let index = 2; while (usedGroupIds.has(candidate)) { candidate = `${baseId}-${index}`; index += 1; } usedGroupIds.add(candidate); return candidate; } function findGroupIndexByTerminalId( terminalGroups: ThreadTerminalGroup[], terminalId: string, ): number { return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); } function normalizeTerminalGroupIds(terminalIds: string[]): string[] { return normalizeTerminalIds(terminalIds); } function normalizeTerminalGroups( terminalGroups: ThreadTerminalGroup[], terminalIds: string[], ): ThreadTerminalGroup[] { if (terminalIds.length === 0) { return []; } const validTerminalIdSet = new Set(terminalIds); const assignedTerminalIds = new Set<string>(); const nextGroups: ThreadTerminalGroup[] = []; const usedGroupIds = new Set<string>(); for (const group of terminalGroups) { const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { if (!validTerminalIdSet.has(terminalId)) return false; if (assignedTerminalIds.has(terminalId)) return false; return true; }); if (groupTerminalIds.length === 0) continue; for (const terminalId of groupTerminalIds) { assignedTerminalIds.add(terminalId); } const baseGroupId = group.id.trim().length > 0 ? group.id.trim() : fallbackGroupId(groupTerminalIds[0] ?? terminalIds[0] ?? ""); nextGroups.push({ id: assignUniqueGroupId(baseGroupId, usedGroupIds), terminalIds: groupTerminalIds, ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), }); } for (const terminalId of terminalIds) { if (assignedTerminalIds.has(terminalId)) continue; nextGroups.push({ id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), terminalIds: [terminalId], }); } return nextGroups; } function arraysEqual(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; for (let index = 0; index < a.length; index += 1) { if (a[index] !== b[index]) return false; } return true; } function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean { if (left.length !== right.length) return false; for (let index = 0; index < left.length; index += 1) { const leftGroup = left[index]; const rightGroup = right[index]; if (!leftGroup || !rightGroup) return false; if (leftGroup.id !== rightGroup.id) return false; if ( (leftGroup.splitDirection ?? "horizontal") !== (rightGroup.splitDirection ?? "horizontal") ) { return false; } if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; } return true; } function threadTerminalUiStateEqual( left: ThreadTerminalUiState, right: ThreadTerminalUiState, ): boolean { return ( left.terminalOpen === right.terminalOpen && left.terminalHeight === right.terminalHeight && left.activeTerminalId === right.activeTerminalId && left.activeTerminalGroupId === right.activeTerminalGroupId && arraysEqual(left.terminalIds, right.terminalIds) && terminalGroupsEqual(left.terminalGroups, right.terminalGroups) ); } const DEFAULT_THREAD_TERMINAL_UI_STATE: ThreadTerminalUiState = Object.freeze({ terminalOpen: false, terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: [], activeTerminalId: "", terminalGroups: [], activeTerminalGroupId: "", }); function createDefaultThreadTerminalUiState(): ThreadTerminalUiState { return { ...DEFAULT_THREAD_TERMINAL_UI_STATE, terminalIds: [...DEFAULT_THREAD_TERMINAL_UI_STATE.terminalIds], terminalGroups: copyTerminalGroups(DEFAULT_THREAD_TERMINAL_UI_STATE.terminalGroups), }; } function getDefaultThreadTerminalUiState(): ThreadTerminalUiState { return DEFAULT_THREAD_TERMINAL_UI_STATE; } function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTerminalUiState { const nextTerminalIds = normalizeTerminalIds(state.terminalIds); const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) ? state.activeTerminalId : (nextTerminalIds[0] ?? ""); const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); const activeGroupIdFromState = terminalGroups.some( (group) => group.id === state.activeTerminalGroupId, ) ? state.activeTerminalGroupId : null; const activeGroupIdFromTerminal = terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; const normalized: ThreadTerminalUiState = { terminalOpen: state.terminalOpen, terminalHeight: Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 ? state.terminalHeight : DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: nextTerminalIds, activeTerminalId, terminalGroups, activeTerminalGroupId: activeGroupIdFromState ?? activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "", }; return threadTerminalUiStateEqual(state, normalized) ? state : normalized; } function isDefaultThreadTerminalUiState(state: ThreadTerminalUiState): boolean { const normalized = normalizeThreadTerminalUiState(state); return threadTerminalUiStateEqual(normalized, DEFAULT_THREAD_TERMINAL_UI_STATE); } function isValidTerminalId(terminalId: string): boolean { return terminalId.trim().length > 0; } function terminalThreadKey(threadRef: ScopedThreadRef): string { return scopedThreadKey(threadRef); } function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { return groups.map((group) => ({ id: group.id, terminalIds: [...group.terminalIds], ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), })); } function upsertTerminalIntoGroups( state: ThreadTerminalUiState, terminalId: string, mode: "split" | "new", splitDirection: "horizontal" | "vertical" = "horizontal", ): ThreadTerminalUiState { const normalized = normalizeThreadTerminalUiState(state); const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode; if (!isValidTerminalId(terminalId)) { return normalized; } const isNewTerminal = !normalized.terminalIds.includes(terminalId); const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds; const terminalGroups = copyTerminalGroups(normalized.terminalGroups); const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId); if (existingGroupIndex >= 0) { terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[ existingGroupIndex ]!.terminalIds.filter((id) => id !== terminalId); if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { terminalGroups.splice(existingGroupIndex, 1); } } if (effectiveMode === "new") { const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); return normalizeThreadTerminalUiState({ ...normalized, terminalOpen: true, terminalIds, activeTerminalId: terminalId, terminalGroups, activeTerminalGroupId: nextGroupId, }); } let activeGroupIndex = terminalGroups.findIndex( (group) => group.id === normalized.activeTerminalGroupId, ); if (activeGroupIndex < 0) { activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId); } if (activeGroupIndex < 0) { const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); const nextGroupId = assignUniqueGroupId( fallbackGroupId(normalized.activeTerminalId), usedGroupIds, ); terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); activeGroupIndex = terminalGroups.length - 1; } const destinationGroup = terminalGroups[activeGroupIndex]; if (!destinationGroup) { return normalized; } const destinationTerminalIdSet = new Set(destinationGroup.terminalIds); if ( isNewTerminal && !destinationTerminalIdSet.has(terminalId) && destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP ) { return normalized; } if (!destinationTerminalIdSet.has(terminalId)) { const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); if (anchorIndex >= 0) { destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId); } else { destinationGroup.terminalIds.push(terminalId); } } if (splitDirection === "vertical") { destinationGroup.splitDirection = "vertical"; } else { delete destinationGroup.splitDirection; } return normalizeThreadTerminalUiState({ ...normalized, terminalOpen: true, terminalIds, activeTerminalId: terminalId, terminalGroups, activeTerminalGroupId: destinationGroup.id, }); } function setThreadTerminalOpen(state: ThreadTerminalUiState, open: boolean): ThreadTerminalUiState { const normalized = normalizeThreadTerminalUiState(state); if (open && normalized.terminalIds.length === 0) { return upsertTerminalIntoGroups(normalized, DEFAULT_THREAD_TERMINAL_ID, "new"); } if (normalized.terminalOpen === open) return normalized; return { ...normalized, terminalOpen: open }; } function setThreadTerminalHeight( state: ThreadTerminalUiState, height: number, ): ThreadTerminalUiState { const normalized = normalizeThreadTerminalUiState(state); if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) { return normalized; } return { ...normalized, terminalHeight: height }; } function splitThreadTerminal( state: ThreadTerminalUiState, terminalId: string, direction: "horizontal" | "vertical" = "horizontal", ): ThreadTerminalUiState { return upsertTerminalIntoGroups(state, terminalId, "split", direction); } function newThreadTerminal( state: ThreadTerminalUiState, terminalId: string, ): ThreadTerminalUiState { return upsertTerminalIntoGroups(state, terminalId, "new"); } function setThreadActiveTerminal( state: ThreadTerminalUiState, terminalId: string, ): ThreadTerminalUiState { const normalized = normalizeThreadTerminalUiState(state); if (!normalized.terminalIds.includes(terminalId)) { return normalized; } const activeTerminalGroupId = normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ?? normalized.activeTerminalGroupId; if ( normalized.activeTerminalId === terminalId && normalized.activeTerminalGroupId === activeTerminalGroupId ) { return normalized; } return { ...normalized, activeTerminalId: terminalId, activeTerminalGroupId, }; } function closeThreadTerminal( state: ThreadTerminalUiState, terminalId: string, ): ThreadTerminalUiState { const normalized = normalizeThreadTerminalUiState(state); if (!normalized.terminalIds.includes(terminalId)) { return normalized; } const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId); if (remainingTerminalIds.length === 0) { return createDefaultThreadTerminalUiState(); } const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); const nextActiveTerminalId = normalized.activeTerminalId === terminalId ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? remainingTerminalIds[0] ?? "") : normalized.activeTerminalId; const terminalGroups: ThreadTerminalGroup[] = []; for (const group of normalized.terminalGroups) { const terminalIds = group.terminalIds.filter((id) => id !== terminalId); if (terminalIds.length > 0) { terminalGroups.push({ ...group, terminalIds }); } } const nextActiveTerminalGroupId = terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? terminalGroups[0]?.id ?? fallbackGroupId(nextActiveTerminalId); return normalizeThreadTerminalUiState({ terminalOpen: normalized.terminalOpen, terminalHeight: normalized.terminalHeight, terminalIds: remainingTerminalIds, activeTerminalId: nextActiveTerminalId, terminalGroups, activeTerminalGroupId: nextActiveTerminalGroupId, }); } function reconcileThreadTerminalSessionIds( state: ThreadTerminalUiState, nextIds: string[], ): ThreadTerminalUiState { const normalized = normalizeThreadTerminalUiState(state); if (arraysEqual(normalized.terminalIds, nextIds)) { return normalized; } const nextActiveTerminalId = nextIds.includes(normalized.activeTerminalId) ? normalized.activeTerminalId : (nextIds[0] ?? ""); const terminalGroups = normalizeTerminalGroups(normalized.terminalGroups, nextIds); const activeGroupIdFromTerminal = terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? null; return normalizeThreadTerminalUiState({ ...normalized, terminalIds: nextIds, activeTerminalId: nextActiveTerminalId, terminalGroups, activeTerminalGroupId: activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "", }); } export function selectThreadTerminalUiState( terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>, threadRef: ScopedThreadRef | null | undefined, ): ThreadTerminalUiState { if (!threadRef || threadRef.threadId.length === 0) { return getDefaultThreadTerminalUiState(); } return ( terminalUiStateByThreadKey[terminalThreadKey(threadRef)] ?? getDefaultThreadTerminalUiState() ); } function updateTerminalUiStateByThreadKey( terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>, threadRef: ScopedThreadRef, updater: (state: ThreadTerminalUiState) => ThreadTerminalUiState, ): Record<string, ThreadTerminalUiState> { if (threadRef.threadId.length === 0) { return terminalUiStateByThreadKey; }