/** * Single Zustand store for terminal UI state keyed by scoped thread identity. * * Terminal transition helpers are intentionally private to keep the public * API constrained to store actions/selectors. */ import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime";import { type ScopedThreadRef, type TerminalEvent } from "@t3tools/contracts";import { create } from "zustand";import { createJSONStorage, persist } from "zustand/middleware";import { resolveStorage } from "./lib/storage";import { terminalRunningSubprocessFromEvent } from "./terminalActivity";import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup,} from "./types"; interface ThreadTerminalState { terminalOpen: boolean; terminalHeight: number; terminalIds: string[]; runningTerminalIds: string[]; activeTerminalId: string; terminalGroups: ThreadTerminalGroup[]; activeTerminalGroupId: string;} export interface ThreadTerminalLaunchContext { cwd: string; worktreePath: string | null;} export interface TerminalEventEntry { id: number; event: TerminalEvent;} const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray<TerminalEventEntry> = [];const MAX_TERMINAL_EVENT_BUFFER = 200; interface PersistedTerminalStateStoreState { terminalStateByThreadKey?: Record<string, ThreadTerminalState>;} export function migratePersistedTerminalStateStoreState( persistedState: unknown, version: number,): PersistedTerminalStateStoreState { if (version === 1 && persistedState && typeof persistedState === "object") { const candidate = persistedState as PersistedTerminalStateStoreState; const nextTerminalStateByThreadKey = Object.fromEntries( Object.entries(candidate.terminalStateByThreadKey ?? {}).filter(([threadKey]) => parseScopedThreadKey(threadKey), ), ); return { terminalStateByThreadKey: nextTerminalStateByThreadKey }; } return { terminalStateByThreadKey: {} };} function createTerminalStateStorage() { return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);} function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];} function normalizeRunningTerminalIds( runningTerminalIds: string[], terminalIds: string[],): string[] { if (runningTerminalIds.length === 0) return []; const validTerminalIdSet = new Set(terminalIds); return [...new Set(runningTerminalIds)] .map((id) => id.trim()) .filter((id) => id.length > 0 && validTerminalIdSet.has(id));} 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 [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];} function normalizeTerminalGroups( terminalGroups: ThreadTerminalGroup[], terminalIds: string[],): ThreadTerminalGroup[] { 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] ?? DEFAULT_THREAD_TERMINAL_ID); nextGroups.push({ id: assignUniqueGroupId(baseGroupId, usedGroupIds), terminalIds: groupTerminalIds, }); } for (const terminalId of terminalIds) { if (assignedTerminalIds.has(terminalId)) continue; nextGroups.push({ id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), terminalIds: [terminalId], }); } if (nextGroups.length === 0) { return [ { id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), terminalIds: [DEFAULT_THREAD_TERMINAL_ID], }, ]; } 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 (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; } return true;} function threadTerminalStateEqual(left: ThreadTerminalState, right: ThreadTerminalState): boolean { return ( left.terminalOpen === right.terminalOpen && left.terminalHeight === right.terminalHeight && left.activeTerminalId === right.activeTerminalId && left.activeTerminalGroupId === right.activeTerminalGroupId && arraysEqual(left.terminalIds, right.terminalIds) && arraysEqual(left.runningTerminalIds, right.runningTerminalIds) && terminalGroupsEqual(left.terminalGroups, right.terminalGroups) );} const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({ terminalOpen: false, terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], runningTerminalIds: [], activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, terminalGroups: [ { id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), terminalIds: [DEFAULT_THREAD_TERMINAL_ID], }, ], activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),}); function createDefaultThreadTerminalState(): ThreadTerminalState { return { ...DEFAULT_THREAD_TERMINAL_STATE, terminalIds: [...DEFAULT_THREAD_TERMINAL_STATE.terminalIds], runningTerminalIds: [...DEFAULT_THREAD_TERMINAL_STATE.runningTerminalIds], terminalGroups: copyTerminalGroups(DEFAULT_THREAD_TERMINAL_STATE.terminalGroups), };} function getDefaultThreadTerminalState(): ThreadTerminalState { return DEFAULT_THREAD_TERMINAL_STATE;} function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTerminalState { const terminalIds = normalizeTerminalIds(state.terminalIds); const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; const runningTerminalIds = normalizeRunningTerminalIds(state.runningTerminalIds, nextTerminalIds); const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) ? state.activeTerminalId : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); 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: ThreadTerminalState = { terminalOpen: state.terminalOpen, terminalHeight: Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 ? state.terminalHeight : DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: nextTerminalIds, runningTerminalIds, activeTerminalId, terminalGroups, activeTerminalGroupId: activeGroupIdFromState ?? activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), }; return threadTerminalStateEqual(state, normalized) ? state : normalized;} function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean { const normalized = normalizeThreadTerminalState(state); return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE);} function isValidTerminalId(terminalId: string): boolean { return terminalId.trim().length > 0;} function terminalThreadKey(threadRef: ScopedThreadRef): string { return scopedThreadKey(threadRef);} function terminalEventBufferKey(threadRef: ScopedThreadRef, terminalId: string): string { return `${terminalThreadKey(threadRef)}\u0000${terminalId}`;} function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { return groups.map((group) => ({ id: group.id, terminalIds: [...group.terminalIds], }));} function appendTerminalEventEntry( terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>, nextTerminalEventId: number, threadRef: ScopedThreadRef, event: TerminalEvent,) { const key = terminalEventBufferKey(threadRef, event.terminalId); const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; const nextEntry: TerminalEventEntry = { id: nextTerminalEventId, event, }; const nextEntries = currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER ? [...currentEntries.slice(1), nextEntry] : [...currentEntries, nextEntry]; return { terminalEventEntriesByKey: { ...terminalEventEntriesByKey, [key]: nextEntries, }, nextTerminalEventId: nextTerminalEventId + 1, };} function launchContextFromStartEvent( event: Extract<TerminalEvent, { type: "started" | "restarted" }>,): ThreadTerminalLaunchContext { return { cwd: event.snapshot.cwd, worktreePath: event.snapshot.worktreePath, };} function upsertTerminalIntoGroups( state: ThreadTerminalState, terminalId: string, mode: "split" | "new",): ThreadTerminalState { const normalized = normalizeThreadTerminalState(state); 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 (mode === "new") { const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); return normalizeThreadTerminalState({ ...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; } if ( isNewTerminal && !destinationGroup.terminalIds.includes(terminalId) && destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP ) { return normalized; } if (!destinationGroup.terminalIds.includes(terminalId)) { const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); if (anchorIndex >= 0) { destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId); } else { destinationGroup.terminalIds.push(terminalId); } } return normalizeThreadTerminalState({ ...normalized, terminalOpen: true, terminalIds, activeTerminalId: terminalId, terminalGroups, activeTerminalGroupId: destinationGroup.id, });} function setThreadTerminalOpen(state: ThreadTerminalState, open: boolean): ThreadTerminalState { const normalized = normalizeThreadTerminalState(state); if (normalized.terminalOpen === open) return normalized; return { ...normalized, terminalOpen: open };} function setThreadTerminalHeight(state: ThreadTerminalState, height: number): ThreadTerminalState { const normalized = normalizeThreadTerminalState(state); if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) { return normalized; } return { ...normalized, terminalHeight: height };} function splitThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { return upsertTerminalIntoGroups(state, terminalId, "split");} function newThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { return upsertTerminalIntoGroups(state, terminalId, "new");} function setThreadActiveTerminal( state: ThreadTerminalState, terminalId: string,): ThreadTerminalState { const normalized = normalizeThreadTerminalState(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: ThreadTerminalState, terminalId: string): ThreadTerminalState { const normalized = normalizeThreadTerminalState(state); if (!normalized.terminalIds.includes(terminalId)) { return normalized; } const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId); if (remainingTerminalIds.length === 0) { return createDefaultThreadTerminalState(); } const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); const nextActiveTerminalId = normalized.activeTerminalId === terminalId ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? remainingTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID) : normalized.activeTerminalId; const terminalGroups = normalized.terminalGroups .map((group) => ({ ...group, terminalIds: group.terminalIds.filter((id) => id !== terminalId), })) .filter((group) => group.terminalIds.length > 0); const nextActiveTerminalGroupId = terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? terminalGroups[0]?.id ?? fallbackGroupId(nextActiveTerminalId); return normalizeThreadTerminalState({ terminalOpen: normalized.terminalOpen, terminalHeight: normalized.terminalHeight, terminalIds: remainingTerminalIds, runningTerminalIds: normalized.runningTerminalIds.filter((id) => id !== terminalId), activeTerminalId: nextActiveTerminalId, terminalGroups, activeTerminalGroupId: nextActiveTerminalGroupId, });} function setThreadTerminalActivity( state: ThreadTerminalState, terminalId: string, hasRunningSubprocess: boolean,): ThreadTerminalState { const normalized = normalizeThreadTerminalState(state); if (!normalized.terminalIds.includes(terminalId)) { return normalized; } const alreadyRunning = normalized.runningTerminalIds.includes(terminalId); if (hasRunningSubprocess === alreadyRunning) {Lines 1–500 of 847. Load more
/** * Single Zustand store for terminal UI state keyed by scoped thread identity. * * Terminal transition helpers are intentionally private to keep the public * API constrained to store actions/selectors. */ import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; import { type ScopedThreadRef, type TerminalEvent } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; import { terminalRunningSubprocessFromEvent } from "./terminalActivity"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "./types"; interface ThreadTerminalState { terminalOpen: boolean; terminalHeight: number; terminalIds: string[]; runningTerminalIds: string[]; activeTerminalId: string; terminalGroups: ThreadTerminalGroup[]; activeTerminalGroupId: string; } export interface ThreadTerminalLaunchContext { cwd: string; worktreePath: string | null; } export interface TerminalEventEntry { id: number; event: TerminalEvent; } const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray<TerminalEventEntry> = []; const MAX_TERMINAL_EVENT_BUFFER = 200; interface PersistedTerminalStateStoreState { terminalStateByThreadKey?: Record<string, ThreadTerminalState>; } export function migratePersistedTerminalStateStoreState( persistedState: unknown, version: number, ): PersistedTerminalStateStoreState { if (version === 1 && persistedState && typeof persistedState === "object") { const candidate = persistedState as PersistedTerminalStateStoreState; const nextTerminalStateByThreadKey = Object.fromEntries( Object.entries(candidate.terminalStateByThreadKey ?? {}).filter(([threadKey]) => parseScopedThreadKey(threadKey), ), ); return { terminalStateByThreadKey: nextTerminalStateByThreadKey }; } return { terminalStateByThreadKey: {} }; } function createTerminalStateStorage() { return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); } function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; } function normalizeRunningTerminalIds( runningTerminalIds: string[], terminalIds: string[], ): string[] { if (runningTerminalIds.length === 0) return []; const validTerminalIdSet = new Set(terminalIds); return [...new Set(runningTerminalIds)] .map((id) => id.trim()) .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); } 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 [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; } function normalizeTerminalGroups( terminalGroups: ThreadTerminalGroup[], terminalIds: string[], ): ThreadTerminalGroup[] { 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] ?? DEFAULT_THREAD_TERMINAL_ID); nextGroups.push({ id: assignUniqueGroupId(baseGroupId, usedGroupIds), terminalIds: groupTerminalIds, }); } for (const terminalId of terminalIds) { if (assignedTerminalIds.has(terminalId)) continue; nextGroups.push({ id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), terminalIds: [terminalId], }); } if (nextGroups.length === 0) { return [ { id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), terminalIds: [DEFAULT_THREAD_TERMINAL_ID], }, ]; } 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 (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; } return true; } function threadTerminalStateEqual(left: ThreadTerminalState, right: ThreadTerminalState): boolean { return ( left.terminalOpen === right.terminalOpen && left.terminalHeight === right.terminalHeight && left.activeTerminalId === right.activeTerminalId && left.activeTerminalGroupId === right.activeTerminalGroupId && arraysEqual(left.terminalIds, right.terminalIds) && arraysEqual(left.runningTerminalIds, right.runningTerminalIds) && terminalGroupsEqual(left.terminalGroups, right.terminalGroups) ); } const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({ terminalOpen: false, terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], runningTerminalIds: [], activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, terminalGroups: [ { id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), terminalIds: [DEFAULT_THREAD_TERMINAL_ID], }, ], activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), }); function createDefaultThreadTerminalState(): ThreadTerminalState { return { ...DEFAULT_THREAD_TERMINAL_STATE, terminalIds: [...DEFAULT_THREAD_TERMINAL_STATE.terminalIds], runningTerminalIds: [...DEFAULT_THREAD_TERMINAL_STATE.runningTerminalIds], terminalGroups: copyTerminalGroups(DEFAULT_THREAD_TERMINAL_STATE.terminalGroups), }; } function getDefaultThreadTerminalState(): ThreadTerminalState { return DEFAULT_THREAD_TERMINAL_STATE; } function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTerminalState { const terminalIds = normalizeTerminalIds(state.terminalIds); const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; const runningTerminalIds = normalizeRunningTerminalIds(state.runningTerminalIds, nextTerminalIds); const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) ? state.activeTerminalId : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); 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: ThreadTerminalState = { terminalOpen: state.terminalOpen, terminalHeight: Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 ? state.terminalHeight : DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: nextTerminalIds, runningTerminalIds, activeTerminalId, terminalGroups, activeTerminalGroupId: activeGroupIdFromState ?? activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), }; return threadTerminalStateEqual(state, normalized) ? state : normalized; } function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean { const normalized = normalizeThreadTerminalState(state); return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE); } function isValidTerminalId(terminalId: string): boolean { return terminalId.trim().length > 0; } function terminalThreadKey(threadRef: ScopedThreadRef): string { return scopedThreadKey(threadRef); } function terminalEventBufferKey(threadRef: ScopedThreadRef, terminalId: string): string { return `${terminalThreadKey(threadRef)}\u0000${terminalId}`; } function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { return groups.map((group) => ({ id: group.id, terminalIds: [...group.terminalIds], })); } function appendTerminalEventEntry( terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>, nextTerminalEventId: number, threadRef: ScopedThreadRef, event: TerminalEvent, ) { const key = terminalEventBufferKey(threadRef, event.terminalId); const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; const nextEntry: TerminalEventEntry = { id: nextTerminalEventId, event, }; const nextEntries = currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER ? [...currentEntries.slice(1), nextEntry] : [...currentEntries, nextEntry]; return { terminalEventEntriesByKey: { ...terminalEventEntriesByKey, [key]: nextEntries, }, nextTerminalEventId: nextTerminalEventId + 1, }; } function launchContextFromStartEvent( event: Extract<TerminalEvent, { type: "started" | "restarted" }>, ): ThreadTerminalLaunchContext { return { cwd: event.snapshot.cwd, worktreePath: event.snapshot.worktreePath, }; } function upsertTerminalIntoGroups( state: ThreadTerminalState, terminalId: string, mode: "split" | "new", ): ThreadTerminalState { const normalized = normalizeThreadTerminalState(state); 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 (mode === "new") { const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); return normalizeThreadTerminalState({ ...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; } if ( isNewTerminal && !destinationGroup.terminalIds.includes(terminalId) && destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP ) { return normalized; } if (!destinationGroup.terminalIds.includes(terminalId)) { const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); if (anchorIndex >= 0) { destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId); } else { destinationGroup.terminalIds.push(terminalId); } } return normalizeThreadTerminalState({ ...normalized, terminalOpen: true, terminalIds, activeTerminalId: terminalId, terminalGroups, activeTerminalGroupId: destinationGroup.id, }); } function setThreadTerminalOpen(state: ThreadTerminalState, open: boolean): ThreadTerminalState { const normalized = normalizeThreadTerminalState(state); if (normalized.terminalOpen === open) return normalized; return { ...normalized, terminalOpen: open }; } function setThreadTerminalHeight(state: ThreadTerminalState, height: number): ThreadTerminalState { const normalized = normalizeThreadTerminalState(state); if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) { return normalized; } return { ...normalized, terminalHeight: height }; } function splitThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { return upsertTerminalIntoGroups(state, terminalId, "split"); } function newThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { return upsertTerminalIntoGroups(state, terminalId, "new"); } function setThreadActiveTerminal( state: ThreadTerminalState, terminalId: string, ): ThreadTerminalState { const normalized = normalizeThreadTerminalState(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: ThreadTerminalState, terminalId: string): ThreadTerminalState { const normalized = normalizeThreadTerminalState(state); if (!normalized.terminalIds.includes(terminalId)) { return normalized; } const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId); if (remainingTerminalIds.length === 0) { return createDefaultThreadTerminalState(); } const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); const nextActiveTerminalId = normalized.activeTerminalId === terminalId ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? remainingTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID) : normalized.activeTerminalId; const terminalGroups = normalized.terminalGroups .map((group) => ({ ...group, terminalIds: group.terminalIds.filter((id) => id !== terminalId), })) .filter((group) => group.terminalIds.length > 0); const nextActiveTerminalGroupId = terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? terminalGroups[0]?.id ?? fallbackGroupId(nextActiveTerminalId); return normalizeThreadTerminalState({ terminalOpen: normalized.terminalOpen, terminalHeight: normalized.terminalHeight, terminalIds: remainingTerminalIds, runningTerminalIds: normalized.runningTerminalIds.filter((id) => id !== terminalId), activeTerminalId: nextActiveTerminalId, terminalGroups, activeTerminalGroupId: nextActiveTerminalGroupId, }); } function setThreadTerminalActivity( state: ThreadTerminalState, terminalId: string, hasRunningSubprocess: boolean, ): ThreadTerminalState { const normalized = normalizeThreadTerminalState(state); if (!normalized.terminalIds.includes(terminalId)) { return normalized; } const alreadyRunning = normalized.runningTerminalIds.includes(terminalId); if (hasRunningSubprocess === alreadyRunning) {