Unified · Split
| @@ -1,4 +1,4 @@ | |||
| 1 | import { memo, useCallback } from "react"; | 1 | import { memo, useCallback, useEffect } from "react"; |
| 2 | import { | 2 | import { |
| 3 | Pressable, | 3 | Pressable, |
| 4 | ScrollView, | 4 | ScrollView, |
| @@ -17,6 +17,7 @@ import { · 12 unmodified lines | |||
| 17 | getPierreTerminalTheme, | 17 | getPierreTerminalTheme, |
| 18 | type TerminalTheme, | 18 | type TerminalTheme, |
| 19 | } from "./terminalTheme"; | 19 | } from "./terminalTheme"; |
| 20 | import { terminalDebugLog } from "./terminalDebugLog"; | ||
| 20 | 21 | ||
| 21 | interface TerminalInputEvent { | 22 | interface TerminalInputEvent { |
| 22 | readonly data: string; | 23 | readonly data: string; |
| @@ -168,6 +169,16 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 145 unmodified lines | |||
| 168 | const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); | 169 | const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); |
| 169 | const { onInput, onResize } = props; | 170 | const { onInput, onResize } = props; |
| 170 | const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView(); | 171 | const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView(); |
| 172 | const hasNativeSurface = Boolean(NativeTerminalSurfaceView); | ||
| 173 | |||
| 174 | useEffect(() => { | ||
| 175 | terminalDebugLog("native:surface", { | ||
| 176 | terminalKey: props.terminalKey, | ||
| 177 | native: hasNativeSurface, | ||
| 178 | bufferLen: props.buffer.length, | ||
| 179 | isRunning: props.isRunning, | ||
| 180 | }); | ||
| 181 | }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]); | ||
| 171 | const handleNativeInput = useCallback( | 182 | const handleNativeInput = useCallback( |
| 172 | (event: NativeSyntheticEvent<TerminalInputEvent>) => { | 183 | (event: NativeSyntheticEvent<TerminalInputEvent>) => { |
| 173 | onInput(event.nativeEvent.data); | 184 | onInput(event.nativeEvent.data); |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | |||
| 12 | } from "../../state/use-terminal-session"; | 12 | } from "../../state/use-terminal-session"; |
| 13 | import { TerminalSurface } from "./NativeTerminalSurface"; | 13 | import { TerminalSurface } from "./NativeTerminalSurface"; |
| 14 | import { hasNativeTerminalSurface } from "./nativeTerminalModule"; | 14 | import { hasNativeTerminalSurface } from "./nativeTerminalModule"; |
| 15 | import { terminalDebugLog } from "./terminalDebugLog"; | ||
| 15 | 16 | ||
| 16 | interface ThreadTerminalPanelProps { | 17 | interface ThreadTerminalPanelProps { |
| 17 | readonly environmentId: EnvironmentId; | 18 | readonly environmentId: EnvironmentId; |
| @@ -51,9 +52,19 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( · 33 unmodified lines | |||
| 51 | 52 | ||
| 52 | const client = getEnvironmentClient(props.environmentId); | 53 | const client = getEnvironmentClient(props.environmentId); |
| 53 | if (!client) { | 54 | if (!client) { |
| 55 | terminalDebugLog("panel:attach-skip", { | ||
| 56 | reason: "no-environment-client", | ||
| 57 | environmentId: props.environmentId, | ||
| 58 | }); | ||
| 54 | return; | 59 | return; |
| 55 | } | 60 | } |
| 56 | 61 | ||
| 62 | terminalDebugLog("panel:attach", { | ||
| 63 | environmentId: props.environmentId, | ||
| 64 | threadId: props.threadId, | ||
| 65 | terminalId, | ||
| 66 | }); | ||
| 67 | |||
| 57 | return attachTerminalSession({ | 68 | return attachTerminalSession({ |
| 58 | environmentId: props.environmentId, | 69 | environmentId: props.environmentId, |
| 59 | client, | 70 | client, |
| @@ -1,4 +1,10 @@ | |||
| 1 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; | 1 | import { |
| 2 | DEFAULT_TERMINAL_ID, | ||
| 3 | EnvironmentId, | ||
| 4 | type TerminalAttachStreamEvent, | ||
| 5 | ThreadId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import type { KnownTerminalSession } from "@t3tools/client-runtime"; | ||
| 2 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | 8 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 9 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native"; | 10 | import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native"; |
| @@ -7,7 +13,7 @@ import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-re · 2 unmodified lines | |||
| 7 | 13 | ||
| 8 | import { EmptyState } from "../../components/EmptyState"; | 14 | import { EmptyState } from "../../components/EmptyState"; |
| 9 | import { LoadingScreen } from "../../components/LoadingScreen"; | 15 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 10 | import { buildThreadTerminalRoutePath } from "../../lib/routes"; | 16 | import { buildThreadTerminalNavigation } from "../../lib/routes"; |
| 11 | import { | 17 | import { |
| 12 | getEnvironmentClient, | 18 | getEnvironmentClient, |
| 13 | useRemoteEnvironmentState, | 19 | useRemoteEnvironmentState, |
| @@ -23,6 +29,7 @@ import { useSelectedThreadDetail } from "../../state/use-thread-detail"; · 9 unmodified lines | |||
| 23 | import { TerminalSurface } from "./NativeTerminalSurface"; | 29 | import { TerminalSurface } from "./NativeTerminalSurface"; |
| 24 | import { getPierreTerminalTheme } from "./terminalTheme"; | 30 | import { getPierreTerminalTheme } from "./terminalTheme"; |
| 25 | import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; | 31 | import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; |
| 32 | import { terminalDebugLog } from "./terminalDebugLog"; | ||
| 26 | import { | 33 | import { |
| 27 | getTerminalBufferReplayKey, | 34 | getTerminalBufferReplayKey, |
| 28 | getTerminalSurfaceReplayBuffer, | 35 | getTerminalSurfaceReplayBuffer, |
| @@ -37,9 +44,10 @@ import { · 8 unmodified lines | |||
| 37 | import { | 44 | import { |
| 38 | basename, | 45 | basename, |
| 39 | buildTerminalMenuSessions, | 46 | buildTerminalMenuSessions, |
| 40 | getTerminalLabel, | ||
| 41 | getTerminalStatusLabel, | 47 | getTerminalStatusLabel, |
| 42 | nextTerminalId, | 48 | nextOpenTerminalId, |
| 49 | resolveTerminalSessionLabel, | ||
| 50 | type TerminalMenuSession, | ||
| 43 | } from "./terminalMenu"; | 51 | } from "./terminalMenu"; |
| 44 | import { | 52 | import { |
| 45 | DEFAULT_TERMINAL_FONT_SIZE, | 53 | DEFAULT_TERMINAL_FONT_SIZE, |
| @@ -71,13 +79,6 @@ type TerminalToolbarAction = · 25 unmodified lines | |||
| 71 | readonly modifier: PendingModifier; | 79 | readonly modifier: PendingModifier; |
| 72 | }; | 80 | }; |
| 73 | 81 | ||
| 74 | interface TerminalMenuSession { | ||
| 75 | readonly terminalId: string; | ||
| 76 | readonly cwd: string | null; | ||
| 77 | readonly status: "starting" | "running" | "exited" | "error" | "closed"; | ||
| 78 | readonly updatedAt: string | null; | ||
| 79 | } | ||
| 80 | |||
| 81 | function getTerminalStatusTone(input: { | 82 | function getTerminalStatusTone(input: { |
| 82 | readonly status: TerminalMenuSession["status"]; | 83 | readonly status: TerminalMenuSession["status"]; |
| 83 | readonly hasRunningSubprocess?: boolean; | 84 | readonly hasRunningSubprocess?: boolean; |
| @@ -174,6 +175,22 @@ function withAlpha(hexColor: string, alpha: string): string { · 90 unmodified lines | |||
| 174 | return /^#[0-9a-f]{6}$/i.test(hexColor) ? `${hexColor}${alpha}` : hexColor; | 175 | return /^#[0-9a-f]{6}$/i.test(hexColor) ? `${hexColor}${alpha}` : hexColor; |
| 175 | } | 176 | } |
| 176 | 177 | ||
| 178 | function pickRunningTerminalSessionForBootstrap( | ||
| 179 | sessions: ReadonlyArray<KnownTerminalSession>, | ||
| 180 | ): KnownTerminalSession | null { | ||
| 181 | const running = sessions.filter( | ||
| 182 | (session) => session.state.status === "running" || session.state.status === "starting", | ||
| 183 | ); | ||
| 184 | if (running.length === 0) { | ||
| 185 | return null; | ||
| 186 | } | ||
| 187 | return ( | ||
| 188 | running.find((session) => session.target.terminalId === DEFAULT_TERMINAL_ID) ?? | ||
| 189 | running[0] ?? | ||
| 190 | null | ||
| 191 | ); | ||
| 192 | } | ||
| 193 | |||
| 177 | export function ThreadTerminalRouteScreen() { | 194 | export function ThreadTerminalRouteScreen() { |
| 178 | const router = useRouter(); | 195 | const router = useRouter(); |
| 179 | const keyboard = useAnimatedKeyboard(); | 196 | const keyboard = useAnimatedKeyboard(); |
| @@ -217,12 +234,15 @@ export function ThreadTerminalRouteScreen() { · 37 unmodified lines | |||
| 217 | const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); | 234 | const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); |
| 218 | const hasOpenedRef = useRef(false); | 235 | const hasOpenedRef = useRef(false); |
| 219 | const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 236 | const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 237 | const attachStreamLogCountRef = useRef(0); | ||
| 238 | const firstNonEmptyBufferLoggedRef = useRef(false); | ||
| 220 | const lastBufferReplayKeyRef = useRef<string | null>(null); | 239 | const lastBufferReplayKeyRef = useRef<string | null>(null); |
| 221 | const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null); | 240 | const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null); |
| 222 | const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState( | 241 | const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState( |
| 223 | cachedFontSize !== null, | 242 | cachedFontSize !== null, |
| 224 | ); | 243 | ); |
| 225 | const [hasMeasuredSurface, setHasMeasuredSurface] = useState(cachedRouteGridSize !== null); | 244 | /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */ |
| 245 | const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true); | ||
| 226 | const [pendingModifierState, setPendingModifierState] = useState<{ | 246 | const [pendingModifierState, setPendingModifierState] = useState<{ |
| 227 | readonly terminalId: string; | 247 | readonly terminalId: string; |
| 228 | readonly value: PendingModifier | null; | 248 | readonly value: PendingModifier | null; |
| @@ -252,22 +272,79 @@ export function ThreadTerminalRouteScreen() { · 23 unmodified lines | |||
| 252 | readyReplayKey: readyBufferReplayKey, | 272 | readyReplayKey: readyBufferReplayKey, |
| 253 | }); | 273 | }); |
| 254 | const isRunning = terminal.status === "running" || terminal.status === "starting"; | 274 | const isRunning = terminal.status === "running" || terminal.status === "starting"; |
| 275 | |||
| 276 | useEffect(() => { | ||
| 277 | terminalDebugLog("surface:props", { | ||
| 278 | terminalKey, | ||
| 279 | atomBufferLen: terminal.buffer.length, | ||
| 280 | surfaceBufferLen: terminalSurfaceBuffer.length, | ||
| 281 | replayKey: bufferReplayKey, | ||
| 282 | readyReplayKey: readyBufferReplayKey, | ||
| 283 | status: terminal.status, | ||
| 284 | version: terminal.version, | ||
| 285 | }); | ||
| 286 | }, [ | ||
| 287 | bufferReplayKey, | ||
| 288 | readyBufferReplayKey, | ||
| 289 | terminal.buffer.length, | ||
| 290 | terminal.status, | ||
| 291 | terminal.version, | ||
| 292 | terminalKey, | ||
| 293 | terminalSurfaceBuffer.length, | ||
| 294 | ]); | ||
| 295 | |||
| 296 | useEffect(() => { | ||
| 297 | terminalDebugLog("session:status", { | ||
| 298 | terminalKey, | ||
| 299 | status: terminal.status, | ||
| 300 | error: terminal.error, | ||
| 301 | summary: terminal.summary?.cwd ?? null, | ||
| 302 | bufferLen: terminal.buffer.length, | ||
| 303 | version: terminal.version, | ||
| 304 | }); | ||
| 305 | }, [ | ||
| 306 | terminal.buffer.length, | ||
| 307 | terminal.error, | ||
| 308 | terminal.status, | ||
| 309 | terminal.summary?.cwd, | ||
| 310 | terminal.version, | ||
| 311 | terminalKey, | ||
| 312 | ]); | ||
| 313 | |||
| 314 | useEffect(() => { | ||
| 315 | if (terminal.buffer.length === 0 || firstNonEmptyBufferLoggedRef.current) { | ||
| 316 | return; | ||
| 317 | } | ||
| 318 | firstNonEmptyBufferLoggedRef.current = true; | ||
| 319 | terminalDebugLog("session:first-nonempty-buffer", { | ||
| 320 | terminalKey, | ||
| 321 | length: terminal.buffer.length, | ||
| 322 | preview: terminal.buffer.slice(0, 160), | ||
| 323 | }); | ||
| 324 | }, [terminal.buffer, terminal.buffer.length, terminalKey]); | ||
| 255 | const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null; | 325 | const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null; |
| 256 | const hostPlatform = useMemo( | 326 | const hostPlatform = useMemo( |
| 257 | () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), | 327 | () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), |
| 258 | [selectedEnvironmentConnection?.environmentLabel], | 328 | [selectedEnvironmentConnection?.environmentLabel], |
| 259 | ); | 329 | ); |
| 260 | const runningSession = useMemo( | 330 | const runningSession = useMemo( |
| 261 | () => | 331 | () => pickRunningTerminalSessionForBootstrap(knownSessions), |
| 262 | knownSessions.find( | ||
| 263 | (session) => session.state.status === "running" || session.state.status === "starting", | ||
| 264 | ) ?? null, | ||
| 265 | [knownSessions], | 332 | [knownSessions], |
| 266 | ); | 333 | ); |
| 267 | const activeKnownSession = useMemo( | 334 | const activeKnownSession = useMemo( |
| 268 | () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, | 335 | () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, |
| 269 | [knownSessions, terminalId], | 336 | [knownSessions, terminalId], |
| 270 | ); | 337 | ); |
| 338 | |||
| 339 | const terminalAttachLaunchHintsRef = useRef({ | ||
| 340 | terminalSummary: terminal.summary, | ||
| 341 | activeKnownSummary: activeKnownSession?.state.summary ?? null, | ||
| 342 | }); | ||
| 343 | terminalAttachLaunchHintsRef.current = { | ||
| 344 | terminalSummary: terminal.summary, | ||
| 345 | activeKnownSummary: activeKnownSession?.state.summary ?? null, | ||
| 346 | }; | ||
| 347 | |||
| 271 | const terminalStatusTone = useMemo( | 348 | const terminalStatusTone = useMemo( |
| 272 | () => | 349 | () => |
| 273 | getTerminalStatusTone({ | 350 | getTerminalStatusTone({ |
| @@ -339,6 +416,7 @@ export function ThreadTerminalRouteScreen() { · 65 unmodified lines | |||
| 339 | cwd: cwd ?? null, | 416 | cwd: cwd ?? null, |
| 340 | status: terminal.status, | 417 | status: terminal.status, |
| 341 | hasRunningSubprocess: terminal.hasRunningSubprocess, | 418 | hasRunningSubprocess: terminal.hasRunningSubprocess, |
| 419 | displayLabel: resolveTerminalSessionLabel(terminalId, terminal.summary), | ||
| 342 | updatedAt: terminal.updatedAt, | 420 | updatedAt: terminal.updatedAt, |
| 343 | }, | 421 | }, |
| 344 | }), | 422 | }), |
| @@ -347,19 +425,47 @@ export function ThreadTerminalRouteScreen() { · 2 unmodified lines | |||
| 347 | knownSessions, | 425 | knownSessions, |
| 348 | selectedThreadProject?.workspaceRoot, | 426 | selectedThreadProject?.workspaceRoot, |
| 349 | terminal.hasRunningSubprocess, | 427 | terminal.hasRunningSubprocess, |
| 428 | terminal.summary, | ||
| 350 | terminal.status, | 429 | terminal.status, |
| 351 | terminal.updatedAt, | 430 | terminal.updatedAt, |
| 352 | terminalId, | 431 | terminalId, |
| 353 | ], | 432 | ], |
| 354 | ); | 433 | ); |
| 355 | 434 | ||
| 435 | const logAttachStreamEvent = useCallback((event: TerminalAttachStreamEvent) => { | ||
| 436 | const n = ++attachStreamLogCountRef.current; | ||
| 437 | if (event.type === "output" && n > 32 && n % 64 !== 0) { | ||
| 438 | return; | ||
| 439 | } | ||
| 440 | if (event.type === "snapshot") { | ||
| 441 | terminalDebugLog("attach:stream", { | ||
| 442 | n, | ||
| 443 | type: event.type, | ||
| 444 | status: event.snapshot.status, | ||
| 445 | historyLen: event.snapshot.history.length, | ||
| 446 | cwd: event.snapshot.cwd, | ||
| 447 | }); | ||
| 448 | return; | ||
| 449 | } | ||
| 450 | if (event.type === "output") { | ||
| 451 | terminalDebugLog("attach:stream", { n, type: event.type, dataLen: event.data.length }); | ||
| 452 | return; | ||
| 453 | } | ||
| 454 | terminalDebugLog("attach:stream", { n, type: event.type }); | ||
| 455 | }, []); | ||
| 456 | |||
| 356 | const attachTerminal = useCallback(() => { | 457 | const attachTerminal = useCallback(() => { |
| 357 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { | 458 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { |
| 459 | terminalDebugLog("attach:abort", { reason: "no-thread-or-workspace" }); | ||
| 358 | return null; | 460 | return null; |
| 359 | } | 461 | } |
| 360 | 462 | ||
| 361 | const client = getEnvironmentClient(selectedThread.environmentId); | 463 | const client = getEnvironmentClient(selectedThread.environmentId); |
| 362 | if (!client) { | 464 | if (!client) { |
| 465 | terminalDebugLog("attach:abort", { | ||
| 466 | reason: "no-environment-client", | ||
| 467 | environmentId: selectedThread.environmentId, | ||
| 468 | }); | ||
| 363 | return null; | 469 | return null; |
| 364 | } | 470 | } |
| 365 | 471 | ||
| @@ -378,13 +484,22 @@ export function ThreadTerminalRouteScreen() { · 12 unmodified lines | |||
| 378 | worktreePath: pendingLaunch.worktreePath, | 484 | worktreePath: pendingLaunch.worktreePath, |
| 379 | } | 485 | } |
| 380 | : resolveTerminalOpenLocation({ | 486 | : resolveTerminalOpenLocation({ |
| 381 | terminalLocation: terminal.summary, | 487 | terminalLocation: terminalAttachLaunchHintsRef.current.terminalSummary, |
| 382 | activeSessionLocation: activeKnownSession?.state.summary ?? null, | 488 | activeSessionLocation: terminalAttachLaunchHintsRef.current.activeKnownSummary, |
| 383 | workspaceRoot: selectedThreadProject.workspaceRoot, | 489 | workspaceRoot: selectedThreadProject.workspaceRoot, |
| 384 | threadShellWorktreePath: selectedThread.worktreePath ?? null, | 490 | threadShellWorktreePath: selectedThread.worktreePath ?? null, |
| 385 | threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, | 491 | threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, |
| 386 | }); | 492 | }); |
| 387 | 493 | ||
| 494 | terminalDebugLog("attach:start", { | ||
| 495 | terminalId, | ||
| 496 | threadId: selectedThread.id, | ||
| 497 | cols: lastGridSize.cols, | ||
| 498 | rows: lastGridSize.rows, | ||
| 499 | cwd: launchLocation.cwd, | ||
| 500 | worktreePath: launchLocation.worktreePath, | ||
| 501 | }); | ||
| 502 | |||
| 388 | return attachTerminalSession({ | 503 | return attachTerminalSession({ |
| 389 | environmentId: selectedThread.environmentId, | 504 | environmentId: selectedThread.environmentId, |
| 390 | client, | 505 | client, |
| @@ -397,6 +512,7 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines | |||
| 397 | rows: lastGridSize.rows, | 512 | rows: lastGridSize.rows, |
| 398 | env: pendingLaunch?.env, | 513 | env: pendingLaunch?.env, |
| 399 | }, | 514 | }, |
| 515 | onEvent: logAttachStreamEvent, | ||
| 400 | onSnapshot: () => { | 516 | onSnapshot: () => { |
| 401 | if (!pendingLaunch?.initialInput || initialInputSent) { | 517 | if (!pendingLaunch?.initialInput || initialInputSent) { |
| 402 | return; | 518 | return; |
| @@ -411,6 +527,9 @@ export function ThreadTerminalRouteScreen() { · 8 unmodified lines | |||
| 411 | }, | 527 | }, |
| 412 | }); | 528 | }); |
| 413 | } catch (error) { | 529 | } catch (error) { |
| 530 | terminalDebugLog("attach:error", { | ||
| 531 | message: error instanceof Error ? error.message : String(error), | ||
| 532 | }); | ||
| 414 | if (pendingLaunch) { | 533 | if (pendingLaunch) { |
| 415 | stagePendingTerminalLaunch({ | 534 | stagePendingTerminalLaunch({ |
| 416 | target: pendingLaunchTarget, | 535 | target: pendingLaunchTarget, |
| @@ -423,16 +542,34 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines | |||
| 423 | }, [ | 542 | }, [ |
| 424 | lastGridSize.cols, | 543 | lastGridSize.cols, |
| 425 | lastGridSize.rows, | 544 | lastGridSize.rows, |
| 426 | activeKnownSession?.state.summary, | 545 | logAttachStreamEvent, |
| 427 | selectedThreadDetail?.worktreePath, | 546 | selectedThreadDetail?.worktreePath, |
| 428 | selectedThread, | 547 | selectedThread, |
| 429 | selectedThreadProject?.workspaceRoot, | 548 | selectedThreadProject?.workspaceRoot, |
| 430 | terminal.summary, | ||
| 431 | terminalId, | 549 | terminalId, |
| 432 | ]); | 550 | ]); |
| 433 | 551 | ||
| 552 | const attachTerminalRef = useRef(attachTerminal); | ||
| 553 | attachTerminalRef.current = attachTerminal; | ||
| 554 | const selectedThreadRef = useRef(selectedThread); | ||
| 555 | selectedThreadRef.current = selectedThread; | ||
| 556 | const selectedThreadProjectBootstrapRef = useRef(selectedThreadProject); | ||
| 557 | selectedThreadProjectBootstrapRef.current = selectedThreadProject; | ||
| 558 | const runningSessionRef = useRef(runningSession); | ||
| 559 | runningSessionRef.current = runningSession; | ||
| 560 | const terminalBootstrapRef = useRef({ | ||
| 561 | status: terminal.status, | ||
| 562 | bufferLen: terminal.buffer.length, | ||
| 563 | }); | ||
| 564 | terminalBootstrapRef.current = { | ||
| 565 | status: terminal.status, | ||
| 566 | bufferLen: terminal.buffer.length, | ||
| 567 | }; | ||
| 568 | |||
| 434 | useEffect(() => { | 569 | useEffect(() => { |
| 435 | hasOpenedRef.current = false; | 570 | hasOpenedRef.current = false; |
| 571 | attachStreamLogCountRef.current = 0; | ||
| 572 | firstNonEmptyBufferLoggedRef.current = false; | ||
| 436 | }, [terminalKey]); | 573 | }, [terminalKey]); |
| 437 | 574 | ||
| 438 | const clearBufferReplayTimer = useCallback(() => { | 575 | const clearBufferReplayTimer = useCallback(() => { |
| @@ -445,9 +582,14 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines | |||
| 445 | const scheduleBufferReplayReady = useCallback(() => { | 582 | const scheduleBufferReplayReady = useCallback(() => { |
| 446 | clearBufferReplayTimer(); | 583 | clearBufferReplayTimer(); |
| 447 | const replayKey = bufferReplayKey; | 584 | const replayKey = bufferReplayKey; |
| 585 | terminalDebugLog("replay:schedule-ready", { | ||
| 586 | replayKey, | ||
| 587 | delayMs: TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, | ||
| 588 | }); | ||
| 448 | bufferReplayTimerRef.current = setTimeout(() => { | 589 | bufferReplayTimerRef.current = setTimeout(() => { |
| 449 | bufferReplayTimerRef.current = null; | 590 | bufferReplayTimerRef.current = null; |
| 450 | setReadyBufferReplayKey(replayKey); | 591 | setReadyBufferReplayKey(replayKey); |
| 592 | terminalDebugLog("replay:ready", { replayKey }); | ||
| 451 | }, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS); | 593 | }, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS); |
| 452 | }, [bufferReplayKey, clearBufferReplayTimer]); | 594 | }, [bufferReplayKey, clearBufferReplayTimer]); |
| 453 | 595 | ||
| @@ -482,27 +624,9 @@ export function ThreadTerminalRouteScreen() { · 28 unmodified lines | |||
| 482 | rows: DEFAULT_TERMINAL_ROWS, | 624 | rows: DEFAULT_TERMINAL_ROWS, |
| 483 | }, | 625 | }, |
| 484 | ); | 626 | ); |
| 485 | setHasMeasuredSurface( | 627 | setHasMeasuredSurface(true); |
| 486 | getCachedTerminalGridSize({ | ||
| 487 | environmentId: routeEnvironmentId, | ||
| 488 | threadId: routeThreadId, | ||
| 489 | terminalId, | ||
| 490 | }) !== null, | ||
| 491 | ); | ||
| 492 | }, [routeEnvironmentId, routeThreadId, terminalId]); | 628 | }, [routeEnvironmentId, routeThreadId, terminalId]); |
| 493 | 629 | ||
| 494 | useEffect(() => { | ||
| 495 | setHasMeasuredSurface( | ||
| 496 | routeEnvironmentId !== null && | ||
| 497 | routeThreadId !== null && | ||
| 498 | getCachedTerminalGridSize({ | ||
| 499 | environmentId: routeEnvironmentId, | ||
| 500 | threadId: routeThreadId, | ||
| 501 | terminalId, | ||
| 502 | }) !== null, | ||
| 503 | ); | ||
| 504 | }, [fontSize, routeEnvironmentId, routeThreadId, terminalId]); | ||
| 505 | |||
| 506 | useEffect(() => { | 630 | useEffect(() => { |
| 507 | let cancelled = false; | 631 | let cancelled = false; |
| 508 | 632 | ||
| @@ -539,50 +663,79 @@ export function ThreadTerminalRouteScreen() { · 30 unmodified lines | |||
| 539 | }); | 663 | }); |
| 540 | }, [fontSize, hasResolvedFontPreference]); | 664 | }, [fontSize, hasResolvedFontPreference]); |
| 541 | 665 | ||
| 666 | // Subscribes `terminal.attach` once per route+terminal until thread/env/attach args change. | ||
| 667 | // Use refs for `attachTerminal` / `selectedThread` / `runningSession`: their identities change when | ||
| 668 | // unrelated store updates (e.g. terminal buffer) re-render the parent, which was firing cleanup | ||
| 669 | // → detach immediately after the first snapshot. | ||
| 542 | useEffect(() => { | 670 | useEffect(() => { |
| 543 | if (!hasResolvedFontPreference || !hasMeasuredSurface) { | 671 | if (!hasResolvedFontPreference || !hasMeasuredSurface) { |
| 544 | return; | 672 | return; |
| 545 | } | 673 | } |
| 546 | 674 | ||
| 675 | const thread = selectedThreadRef.current; | ||
| 676 | const project = selectedThreadProjectBootstrapRef.current; | ||
| 677 | const running = runningSessionRef.current; | ||
| 678 | const termSnap = terminalBootstrapRef.current; | ||
| 679 | |||
| 547 | const bootstrapAction = resolveTerminalRouteBootstrap({ | 680 | const bootstrapAction = resolveTerminalRouteBootstrap({ |
| 548 | hasThread: selectedThread !== null, | 681 | hasThread: thread !== null, |
| 549 | hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), | 682 | hasWorkspaceRoot: Boolean(project?.workspaceRoot), |
| 550 | hasOpened: hasOpenedRef.current, | 683 | hasOpened: hasOpenedRef.current, |
| 551 | requestedTerminalId, | 684 | requestedTerminalId, |
| 552 | currentTerminalId: terminalId, | 685 | currentTerminalId: terminalId, |
| 553 | runningTerminalId: runningSession?.target.terminalId ?? null, | 686 | runningTerminalId: running?.target.terminalId ?? null, |
| 554 | currentTerminalStatus: terminal.status, | 687 | currentTerminalStatus: termSnap.status, |
| 555 | hasCurrentTerminalHydration: terminal.summary !== null || terminal.buffer.length > 0, | 688 | // Metadata summary (cwd/status) is not scrollback. Only `terminal.attach` fills `buffer`; |
| 689 | // treating summary as "hydrated" skipped attach while status was running → empty surface. | ||
| 690 | hasCurrentTerminalHydration: termSnap.bufferLen > 0, | ||
| 556 | }); | 691 | }); |
| 557 | if (bootstrapAction.kind === "idle" || !selectedThread) { | 692 | if (bootstrapAction.kind !== "idle") { |
| 693 | terminalDebugLog("bootstrap:action", { | ||
| 694 | kind: bootstrapAction.kind, | ||
| 695 | hasOpenedBefore: hasOpenedRef.current, | ||
| 696 | hasHydration: termSnap.bufferLen > 0, | ||
| 697 | terminalStatus: termSnap.status, | ||
| 698 | bufLen: termSnap.bufferLen, | ||
| 699 | }); | ||
| 700 | } | ||
| 701 | if (bootstrapAction.kind === "idle" || !thread) { | ||
| 558 | return; | 702 | return; |
| 559 | } | 703 | } |
| 560 | 704 | ||
| 561 | if (bootstrapAction.kind === "redirect") { | 705 | if (bootstrapAction.kind === "redirect") { |
| 562 | router.replace(buildThreadTerminalRoutePath(selectedThread, bootstrapAction.terminalId)); | 706 | router.replace(buildThreadTerminalNavigation(thread, bootstrapAction.terminalId)); |
| 563 | return; | 707 | return; |
| 564 | } | 708 | } |
| 565 | 709 | ||
| 566 | hasOpenedRef.current = true; | 710 | hasOpenedRef.current = true; |
| 567 | try { | 711 | try { |
| 568 | return attachTerminal() ?? undefined; | 712 | const detach = attachTerminalRef.current(); |
| 569 | } catch { | 713 | terminalDebugLog("bootstrap:subscribe", { hasDetach: Boolean(detach) }); |
| 714 | if (!detach) { | ||
| 715 | hasOpenedRef.current = false; | ||
| 716 | return; | ||
| 717 | } | ||
| 718 | return () => { | ||
| 719 | detach(); | ||
| 720 | hasOpenedRef.current = false; | ||
| 721 | terminalDebugLog("bootstrap:unsubscribe"); | ||
| 722 | }; | ||
| 723 | } catch (error) { | ||
| 570 | hasOpenedRef.current = false; | 724 | hasOpenedRef.current = false; |
| 725 | terminalDebugLog("bootstrap:attach-threw", { | ||
| 726 | message: error instanceof Error ? error.message : String(error), | ||
| 727 | }); | ||
| 571 | return; | 728 | return; |
| 572 | } | 729 | } |
| 573 | }, [ | 730 | }, [ |
| 574 | attachTerminal, | 731 | hasMeasuredSurface, |
| 732 | hasResolvedFontPreference, | ||
| 575 | requestedTerminalId, | 733 | requestedTerminalId, |
| 576 | router, | 734 | router, |
| 577 | runningSession, | 735 | selectedThread?.environmentId, |
| 578 | selectedThread, | 736 | selectedThread?.id, |
| 579 | selectedThreadProject?.workspaceRoot, | 737 | selectedThreadProject?.workspaceRoot, |
| 580 | terminal.buffer.length, | ||
| 581 | terminal.summary, | ||
| 582 | terminal.status, | ||
| 583 | terminalId, | 738 | terminalId, |
| 584 | hasMeasuredSurface, | ||
| 585 | hasResolvedFontPreference, | ||
| 586 | ]); | 739 | ]); |
| 587 | 740 | ||
| 588 | const writeInput = useCallback( | 741 | const writeInput = useCallback( |
| @@ -626,6 +779,11 @@ export function ThreadTerminalRouteScreen() { · 37 unmodified lines | |||
| 626 | 779 | ||
| 627 | const handleResize = useCallback( | 780 | const handleResize = useCallback( |
| 628 | (size: { readonly cols: number; readonly rows: number }) => { | 781 | (size: { readonly cols: number; readonly rows: number }) => { |
| 782 | terminalDebugLog("native:onResize", { | ||
| 783 | cols: size.cols, | ||
| 784 | rows: size.rows, | ||
| 785 | terminalKey, | ||
| 786 | }); | ||
| 629 | setHasMeasuredSurface(true); | 787 | setHasMeasuredSurface(true); |
| 630 | if (readyBufferReplayKey !== bufferReplayKey) { | 788 | if (readyBufferReplayKey !== bufferReplayKey) { |
| 631 | scheduleBufferReplayReady(); | 789 | scheduleBufferReplayReady(); |
| @@ -672,6 +830,7 @@ export function ThreadTerminalRouteScreen() { · 40 unmodified lines | |||
| 672 | scheduleBufferReplayReady, | 830 | scheduleBufferReplayReady, |
| 673 | selectedThread, | 831 | selectedThread, |
| 674 | terminalId, | 832 | terminalId, |
| 833 | terminalKey, | ||
| 675 | ], | 834 | ], |
| 676 | ); | 835 | ); |
| 677 | 836 | ||
| @@ -681,7 +840,7 @@ export function ThreadTerminalRouteScreen() { · 3 unmodified lines | |||
| 681 | return; | 840 | return; |
| 682 | } | 841 | } |
| 683 | 842 | ||
| 684 | router.replace(buildThreadTerminalRoutePath(selectedThread, nextTerminalId)); | 843 | router.replace(buildThreadTerminalNavigation(selectedThread, nextTerminalId)); |
| 685 | }, | 844 | }, |
| 686 | [router, selectedThread, terminalId], | 845 | [router, selectedThread, terminalId], |
| 687 | ); | 846 | ); |
| @@ -692,12 +851,15 @@ export function ThreadTerminalRouteScreen() { · 4 unmodified lines | |||
| 692 | } | 851 | } |
| 693 | 852 | ||
| 694 | router.replace( | 853 | router.replace( |
| 695 | buildThreadTerminalRoutePath( | 854 | buildThreadTerminalNavigation( |
| 696 | selectedThread, | 855 | selectedThread, |
| 697 | nextTerminalId(terminalMenuSessions.map((session) => session.terminalId)), | 856 | nextOpenTerminalId({ |
| 857 | listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | ||
| 858 | activeRouteTerminalId: terminalId, | ||
| 859 | }), | ||
| 698 | ), | 860 | ), |
| 699 | ); | 861 | ); |
| 700 | }, [router, selectedThread, terminalMenuSessions]); | 862 | }, [router, selectedThread, terminalId, terminalMenuSessions]); |
| 701 | 863 | ||
| 702 | const adjustFontSize = useCallback((delta: number) => { | 864 | const adjustFontSize = useCallback((delta: number) => { |
| 703 | setTimeout(() => { | 865 | setTimeout(() => { |
| @@ -856,7 +1018,7 @@ export function ThreadTerminalRouteScreen() { · 152 unmodified lines | |||
| 856 | .filter(Boolean) | 1018 | .filter(Boolean) |
| 857 | .join(" · ")} | 1019 | .join(" · ")} |
| 858 | > | 1020 | > |
| 859 | <Stack.Toolbar.Label>{getTerminalLabel(session.terminalId)}</Stack.Toolbar.Label> | 1021 | <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label> |
| 860 | </Stack.Toolbar.MenuAction> | 1022 | </Stack.Toolbar.MenuAction> |
| 861 | ))} | 1023 | ))} |
| 862 | <Stack.Toolbar.MenuAction | 1024 | <Stack.Toolbar.MenuAction |
| @@ -12,7 +12,7 @@ describe("terminalBufferReplay", () => { · 11 unmodified lines | |||
| 12 | ).toBe("env-1:thread-1:default:10"); | 12 | ).toBe("env-1:thread-1:default:10"); |
| 13 | }); | 13 | }); |
| 14 | 14 | ||
| 15 | it("holds back terminal history until the measured surface is ready", () => { | 15 | it("shows terminal history while replay key is unset (initial mount / after key change)", () => { |
| 16 | const replayKey = getTerminalBufferReplayKey({ | 16 | const replayKey = getTerminalBufferReplayKey({ |
| 17 | terminalKey: "env-1:thread-1:default", | 17 | terminalKey: "env-1:thread-1:default", |
| 18 | fontSize: 10, | 18 | fontSize: 10, |
| @@ -24,7 +24,7 @@ describe("terminalBufferReplay", () => { · 5 unmodified lines | |||
| 24 | replayKey, | 24 | replayKey, |
| 25 | readyReplayKey: null, | 25 | readyReplayKey: null, |
| 26 | }), | 26 | }), |
| 27 | ).toBe(""); | 27 | ).toBe("fastfetch output"); |
| 28 | expect( | 28 | expect( |
| 29 | getTerminalSurfaceReplayBuffer({ | 29 | getTerminalSurfaceReplayBuffer({ |
| 30 | buffer: "fastfetch output", | 30 | buffer: "fastfetch output", |
| @@ -1,3 +1,5 @@ | |||
| 1 | import { terminalDebugLog } from "./terminalDebugLog"; | ||
| 2 | |||
| 1 | export const TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS = 180; | 3 | export const TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS = 180; |
| 2 | 4 | ||
| 3 | export function getTerminalBufferReplayKey(input: { | 5 | export function getTerminalBufferReplayKey(input: { |
| @@ -12,5 +14,16 @@ export function getTerminalSurfaceReplayBuffer(input: { · 8 unmodified lines | |||
| 12 | readonly replayKey: string; | 14 | readonly replayKey: string; |
| 13 | readonly readyReplayKey: string | null; | 15 | readonly readyReplayKey: string | null; |
| 14 | }): string { | 16 | }): string { |
| 15 | return input.readyReplayKey === input.replayKey ? input.buffer : ""; | 17 | // Pass live buffer whenever ready key is unset or matches. Only return "" when ready key is |
| 18 | // stale vs current replay key (e.g. mid font-size transition). | ||
| 19 | if (input.readyReplayKey !== null && input.readyReplayKey !== input.replayKey) { | ||
| 20 | terminalDebugLog("replay:stale-key-hiding-buffer", { | ||
| 21 | replayKey: input.replayKey, | ||
| 22 | readyReplayKey: input.readyReplayKey, | ||
| 23 | bufferLen: input.buffer.length, | ||
| 24 | }); | ||
| 25 | return ""; | ||
| 26 | } | ||
| 27 | |||
| 28 | return input.buffer; | ||
| 16 | } | 29 | } |
| @@ -0,0 +1,24 @@ | |||
| 1 | /** | ||
| 2 | * Debug logging for the mobile terminal pipeline. Prefix: `[t3-terminal]`. | ||
| 3 | * | ||
| 4 | * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__ = true` in a JS | ||
| 5 | * debugger / Metro console to trace release/TestFlight builds. | ||
| 6 | */ | ||
| 7 | export function isTerminalDebugEnabled(): boolean { | ||
| 8 | return ( | ||
| 9 | (typeof __DEV__ !== "undefined" && __DEV__) || | ||
| 10 | (typeof globalThis !== "undefined" && | ||
| 11 | (globalThis as { __T3_TERMINAL_DEBUG__?: boolean }).__T3_TERMINAL_DEBUG__ === true) | ||
| 12 | ); | ||
| 13 | } | ||
| 14 | |||
| 15 | export function terminalDebugLog(message: string, data?: Record<string, unknown>): void { | ||
| 16 | if (!isTerminalDebugEnabled()) { | ||
| 17 | return; | ||
| 18 | } | ||
| 19 | if (data !== undefined) { | ||
| 20 | console.log(`[t3-terminal] ${message}`, data); | ||
| 21 | } else { | ||
| 22 | console.log(`[t3-terminal] ${message}`); | ||
| 23 | } | ||
| 24 | } | ||
| @@ -3,7 +3,14 @@ import { describe, expect, it } from "vitest"; · 2 unmodified lines | |||
| 3 | import type { KnownTerminalSession } from "@t3tools/client-runtime"; | 3 | import type { KnownTerminalSession } from "@t3tools/client-runtime"; |
| 4 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; | 4 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 5 | 5 | ||
| 6 | import { buildTerminalMenuSessions, resolveProjectScriptTerminalId } from "./terminalMenu"; | 6 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 7 | |||
| 8 | import { | ||
| 9 | buildTerminalMenuSessions, | ||
| 10 | nextOpenTerminalId, | ||
| 11 | nextTerminalId, | ||
| 12 | resolveProjectScriptTerminalId, | ||
| 13 | } from "./terminalMenu"; | ||
| 7 | 14 | ||
| 8 | function makeKnownSession(input: { | 15 | function makeKnownSession(input: { |
| 9 | readonly terminalId: string; | 16 | readonly terminalId: string; |
| @@ -29,6 +36,7 @@ function makeKnownSession(input: { · 19 unmodified lines | |||
| 29 | exitCode: null, | 36 | exitCode: null, |
| 30 | exitSignal: null, | 37 | exitSignal: null, |
| 31 | hasRunningSubprocess: false, | 38 | hasRunningSubprocess: false, |
| 39 | label: getTerminalLabel(input.terminalId), | ||
| 32 | updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", | 40 | updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", |
| 33 | } | 41 | } |
| 34 | : null, | 42 | : null, |
| @@ -43,7 +51,7 @@ function makeKnownSession(input: { · 8 unmodified lines | |||
| 43 | } | 51 | } |
| 44 | 52 | ||
| 45 | describe("buildTerminalMenuSessions", () => { | 53 | describe("buildTerminalMenuSessions", () => { |
| 46 | it("keeps the default shell and only includes running sessions by default", () => { | 54 | it("only lists server-known sessions that are running or starting (plus current)", () => { |
| 47 | expect( | 55 | expect( |
| 48 | buildTerminalMenuSessions({ | 56 | buildTerminalMenuSessions({ |
| 49 | knownSessions: [ | 57 | knownSessions: [ |
| @@ -63,18 +71,12 @@ describe("buildTerminalMenuSessions", () => { · 13 unmodified lines | |||
| 63 | workspaceRoot: "/workspace/root", | 71 | workspaceRoot: "/workspace/root", |
| 64 | }), | 72 | }), |
| 65 | ).toEqual([ | 73 | ).toEqual([ |
| 66 | { | ||
| 67 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 68 | cwd: "/workspace/root", | ||
| 69 | status: "closed", | ||
| 70 | hasRunningSubprocess: false, | ||
| 71 | updatedAt: null, | ||
| 72 | }, | ||
| 73 | { | 74 | { |
| 74 | terminalId: "term-3", | 75 | terminalId: "term-3", |
| 75 | cwd: "/workspace/feature", | 76 | cwd: "/workspace/feature", |
| 76 | status: "running", | 77 | status: "running", |
| 77 | hasRunningSubprocess: false, | 78 | hasRunningSubprocess: false, |
| 79 | displayLabel: "Terminal 3", | ||
| 78 | updatedAt: "2026-04-15T20:05:00.000Z", | 80 | updatedAt: "2026-04-15T20:05:00.000Z", |
| 79 | }, | 81 | }, |
| 80 | ]); | 82 | ]); |
| @@ -90,28 +92,58 @@ describe("buildTerminalMenuSessions", () => { · 9 unmodified lines | |||
| 90 | cwd: "/workspace/exited", | 92 | cwd: "/workspace/exited", |
| 91 | status: "exited", | 93 | status: "exited", |
| 92 | hasRunningSubprocess: false, | 94 | hasRunningSubprocess: false, |
| 95 | displayLabel: "Terminal 4", | ||
| 93 | updatedAt: "2026-04-15T20:07:00.000Z", | 96 | updatedAt: "2026-04-15T20:07:00.000Z", |
| 94 | }, | 97 | }, |
| 95 | }), | 98 | }), |
| 96 | ).toEqual([ | 99 | ).toEqual([ |
| 97 | { | ||
| 98 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 99 | cwd: "/workspace/root", | ||
| 100 | status: "closed", | ||
| 101 | hasRunningSubprocess: false, | ||
| 102 | updatedAt: null, | ||
| 103 | }, | ||
| 104 | { | 100 | { |
| 105 | terminalId: "term-4", | 101 | terminalId: "term-4", |
| 106 | cwd: "/workspace/exited", | 102 | cwd: "/workspace/exited", |
| 107 | status: "exited", | 103 | status: "exited", |
| 108 | hasRunningSubprocess: false, | 104 | hasRunningSubprocess: false, |
| 105 | displayLabel: "Terminal 4", | ||
| 109 | updatedAt: "2026-04-15T20:07:00.000Z", | 106 | updatedAt: "2026-04-15T20:07:00.000Z", |
| 110 | }, | 107 | }, |
| 111 | ]); | 108 | ]); |
| 112 | }); | 109 | }); |
| 113 | }); | 110 | }); |
| 114 | 111 | ||
| 112 | describe("nextTerminalId", () => { | ||
| 113 | it("uses the primary id when no terminals are listed yet", () => { | ||
| 114 | expect(nextTerminalId([])).toBe(DEFAULT_TERMINAL_ID); | ||
| 115 | }); | ||
| 116 | |||
| 117 | it("allocates term-2 when only the primary shell exists", () => { | ||
| 118 | expect(nextTerminalId([DEFAULT_TERMINAL_ID])).toBe("term-2"); | ||
| 119 | }); | ||
| 120 | }); | ||
| 121 | |||
| 122 | describe("nextOpenTerminalId", () => { | ||
| 123 | it("matches nextTerminalId when not on a terminal route", () => { | ||
| 124 | expect(nextOpenTerminalId({ listedTerminalIds: [] })).toBe(DEFAULT_TERMINAL_ID); | ||
| 125 | expect(nextOpenTerminalId({ listedTerminalIds: [DEFAULT_TERMINAL_ID] })).toBe("term-2"); | ||
| 126 | }); | ||
| 127 | |||
| 128 | it("avoids the mounted primary tab when the session list is still empty", () => { | ||
| 129 | expect( | ||
| 130 | nextOpenTerminalId({ | ||
| 131 | listedTerminalIds: [], | ||
| 132 | activeRouteTerminalId: DEFAULT_TERMINAL_ID, | ||
| 133 | }), | ||
| 134 | ).toBe("term-2"); | ||
| 135 | }); | ||
| 136 | |||
| 137 | it("does not double-count when the route id is already listed", () => { | ||
| 138 | expect( | ||
| 139 | nextOpenTerminalId({ | ||
| 140 | listedTerminalIds: [DEFAULT_TERMINAL_ID], | ||
| 141 | activeRouteTerminalId: DEFAULT_TERMINAL_ID, | ||
| 142 | }), | ||
| 143 | ).toBe("term-2"); | ||
| 144 | }); | ||
| 145 | }); | ||
| 146 | |||
| 115 | describe("resolveProjectScriptTerminalId", () => { | 147 | describe("resolveProjectScriptTerminalId", () => { |
| 116 | it("reuses the default shell when no terminal is running", () => { | 148 | it("reuses the default shell when no terminal is running", () => { |
| 117 | expect( | 149 | expect( |
| @@ -1,11 +1,20 @@ | |||
| 1 | import type { KnownTerminalSession } from "@t3tools/client-runtime"; | 1 | import type { KnownTerminalSession } from "@t3tools/client-runtime"; |
| 2 | import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts"; | 2 | import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts"; |
| 3 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; | ||
| 4 | |||
| 5 | export { | ||
| 6 | getTerminalLabel, | ||
| 7 | nextTerminalId, | ||
| 8 | resolveTerminalSessionLabel, | ||
| 9 | } from "@t3tools/shared/terminalLabels"; | ||
| 3 | 10 | ||
| 4 | export interface TerminalMenuSession { | 11 | export interface TerminalMenuSession { |
| 5 | readonly terminalId: string; | 12 | readonly terminalId: string; |
| 6 | readonly cwd: string | null; | 13 | readonly cwd: string | null; |
| 7 | readonly status: "starting" | "running" | "exited" | "error" | "closed"; | 14 | readonly status: "starting" | "running" | "exited" | "error" | "closed"; |
| 8 | readonly hasRunningSubprocess: boolean; | 15 | readonly hasRunningSubprocess: boolean; |
| 16 | /** Server-authoritative title with the same fallback rules as web. */ | ||
| 17 | readonly displayLabel: string; | ||
| 9 | readonly updatedAt: string | null; | 18 | readonly updatedAt: string | null; |
| 10 | } | 19 | } |
| 11 | 20 | ||
| @@ -23,19 +32,6 @@ export function basename(input: string | null): string | null { · 11 unmodified lines | |||
| 23 | return segments[segments.length - 1] ?? normalized; | 32 | return segments[segments.length - 1] ?? normalized; |
| 24 | } | 33 | } |
| 25 | 34 | ||
| 26 | export function getTerminalLabel(terminalId: string): string { | ||
| 27 | if (terminalId === DEFAULT_TERMINAL_ID) { | ||
| 28 | return "Main shell"; | ||
| 29 | } | ||
| 30 | |||
| 31 | const numericSuffix = /^term(?:inal)?-(\d+)$/i.exec(terminalId)?.[1]; | ||
| 32 | if (numericSuffix) { | ||
| 33 | return `Terminal ${numericSuffix}`; | ||
| 34 | } | ||
| 35 | |||
| 36 | return terminalId; | ||
| 37 | } | ||
| 38 | |||
| 39 | export function getTerminalStatusLabel(input: { | 35 | export function getTerminalStatusLabel(input: { |
| 40 | readonly status: TerminalMenuSession["status"]; | 36 | readonly status: TerminalMenuSession["status"]; |
| 41 | readonly hasRunningSubprocess?: boolean; | 37 | readonly hasRunningSubprocess?: boolean; |
| @@ -56,14 +52,23 @@ export function getTerminalStatusLabel(input: { · 14 unmodified lines | |||
| 56 | return "Not started"; | 52 | return "Not started"; |
| 57 | } | 53 | } |
| 58 | 54 | ||
| 59 | export function nextTerminalId(existingTerminalIds: ReadonlyArray<string>): string { | 55 | /** |
| 60 | const usedIds = new Set(existingTerminalIds); | 56 | * Picks an id for "open another shell". Counts the terminal screen already mounted |
| 61 | let nextIndex = 2; | 57 | * (`activeRouteTerminalId`) as occupied so an empty session list on the primary route |
| 62 | while (usedIds.has(`term-${nextIndex}`)) { | 58 | * still advances to `term-2` instead of `replace`-navigating to the same `default` tab. |
| 63 | nextIndex += 1; | 59 | */ |
| 60 | export function nextOpenTerminalId(input: { | ||
| 61 | readonly listedTerminalIds: ReadonlyArray<string>; | ||
| 62 | readonly activeRouteTerminalId?: string | null; | ||
| 63 | }): string { | ||
| 64 | const listed = input.listedTerminalIds.filter((id) => id.trim().length > 0); | ||
| 65 | const routeId = input.activeRouteTerminalId?.trim() ? input.activeRouteTerminalId : null; | ||
| 66 | |||
| 67 | if (!routeId || listed.includes(routeId)) { | ||
| 68 | return nextTerminalId(listed); | ||
| 64 | } | 69 | } |
| 65 | 70 | ||
| 66 | return `term-${nextIndex}`; | 71 | return nextTerminalId([...listed, routeId]); |
| 67 | } | 72 | } |
| 68 | 73 | ||
| 69 | export function buildTerminalMenuSessions(input: { | 74 | export function buildTerminalMenuSessions(input: { |
| @@ -73,14 +78,6 @@ export function buildTerminalMenuSessions(input: { · 3 unmodified lines | |||
| 73 | }): ReadonlyArray<TerminalMenuSession> { | 78 | }): ReadonlyArray<TerminalMenuSession> { |
| 74 | const sessionsById = new Map<string, TerminalMenuSession>(); | 79 | const sessionsById = new Map<string, TerminalMenuSession>(); |
| 75 | 80 | ||
| 76 | sessionsById.set(DEFAULT_TERMINAL_ID, { | ||
| 77 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 78 | cwd: input.workspaceRoot, | ||
| 79 | status: "closed", | ||
| 80 | hasRunningSubprocess: false, | ||
| 81 | updatedAt: null, | ||
| 82 | }); | ||
| 83 | |||
| 84 | for (const session of input.knownSessions) { | 81 | for (const session of input.knownSessions) { |
| 85 | if ( | 82 | if ( |
| 86 | session.state.status !== "running" && | 83 | session.state.status !== "running" && |
| @@ -95,6 +92,7 @@ export function buildTerminalMenuSessions(input: { · 8 unmodified lines | |||
| 95 | cwd: session.state.summary?.cwd ?? input.workspaceRoot, | 92 | cwd: session.state.summary?.cwd ?? input.workspaceRoot, |
| 96 | status: session.state.status, | 93 | status: session.state.status, |
| 97 | hasRunningSubprocess: session.state.hasRunningSubprocess, | 94 | hasRunningSubprocess: session.state.hasRunningSubprocess, |
| 95 | displayLabel: resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), | ||
| 98 | updatedAt: session.state.updatedAt, | 96 | updatedAt: session.state.updatedAt, |
| 99 | }); | 97 | }); |
| 100 | } | 98 | } |
| @@ -103,18 +101,9 @@ export function buildTerminalMenuSessions(input: { · 2 unmodified lines | |||
| 103 | sessionsById.set(input.currentSession.terminalId, input.currentSession); | 101 | sessionsById.set(input.currentSession.terminalId, input.currentSession); |
| 104 | } | 102 | } |
| 105 | 103 | ||
| 106 | return Array.from(sessionsById.values()).sort((left, right) => { | 104 | return Array.from(sessionsById.values()).sort((left, right) => |
| 107 | if (left.terminalId === DEFAULT_TERMINAL_ID) return -1; | 105 | left.terminalId.localeCompare(right.terminalId, undefined, { numeric: true }), |
| 108 | if (right.terminalId === DEFAULT_TERMINAL_ID) return 1; | 106 | ); |
| 109 | |||
| 110 | const leftUpdatedAt = left.updatedAt ? Date.parse(left.updatedAt) : 0; | ||
| 111 | const rightUpdatedAt = right.updatedAt ? Date.parse(right.updatedAt) : 0; | ||
| 112 | if (leftUpdatedAt !== rightUpdatedAt) { | ||
| 113 | return rightUpdatedAt - leftUpdatedAt; | ||
| 114 | } | ||
| 115 | |||
| 116 | return left.terminalId.localeCompare(right.terminalId); | ||
| 117 | }); | ||
| 118 | } | 107 | } |
| 119 | 108 | ||
| 120 | export function resolveProjectScriptTerminalId(input: { | 109 | export function resolveProjectScriptTerminalId(input: { |
| @@ -6,6 +6,7 @@ export function resolveTerminalRouteBootstrap(input: { · 5 unmodified lines | |||
| 6 | readonly currentTerminalId: string; | 6 | readonly currentTerminalId: string; |
| 7 | readonly runningTerminalId: string | null; | 7 | readonly runningTerminalId: string | null; |
| 8 | readonly currentTerminalStatus: "starting" | "running" | "exited" | "error" | "closed"; | 8 | readonly currentTerminalStatus: "starting" | "running" | "exited" | "error" | "closed"; |
| 9 | /** True once the attach stream has populated scrollback (`buffer` non-empty), not merely metadata. */ | ||
| 9 | readonly hasCurrentTerminalHydration: boolean; | 10 | readonly hasCurrentTerminalHydration: boolean; |
| 10 | }): | 11 | }): |
| 11 | | { readonly kind: "idle" } | 12 | | { readonly kind: "idle" } |
| @@ -11,7 +11,6 @@ import { Alert, Linking } from "react-native"; · 10 unmodified lines | |||
| 11 | import { buildThreadReviewRoutePath } from "../../lib/routes"; | 11 | import { buildThreadReviewRoutePath } from "../../lib/routes"; |
| 12 | import { | 12 | import { |
| 13 | basename, | 13 | basename, |
| 14 | getTerminalLabel, | ||
| 15 | getTerminalStatusLabel, | 14 | getTerminalStatusLabel, |
| 16 | projectScriptMenuIcon, | 15 | projectScriptMenuIcon, |
| 17 | projectScriptMenuLabel, | 16 | projectScriptMenuLabel, |
| @@ -183,19 +182,16 @@ export function ThreadGitControls(props: { · 165 unmodified lines | |||
| 183 | <Stack.Toolbar placement="right"> | 182 | <Stack.Toolbar placement="right"> |
| 184 | <Stack.Toolbar.Menu icon="terminal" disabled={!props.canOpenTerminal} separateBackground> | 183 | <Stack.Toolbar.Menu icon="terminal" disabled={!props.canOpenTerminal} separateBackground> |
| 185 | {props.projectScripts.length > 0 ? ( | 184 | {props.projectScripts.length > 0 ? ( |
| 186 | <Stack.Toolbar.Menu icon="play" inline title="Project scripts"> | 185 | props.projectScripts.map((script) => ( |
| 187 | <Stack.Toolbar.Label>Project scripts</Stack.Toolbar.Label> | 186 | <Stack.Toolbar.MenuAction |
| 188 | {props.projectScripts.map((script) => ( | 187 | key={script.id} |
| 189 | <Stack.Toolbar.MenuAction | 188 | icon={projectScriptMenuIcon(script.icon)} |
| 190 | key={script.id} | 189 | onPress={() => void props.onRunProjectScript(script)} |
| 191 | icon={projectScriptMenuIcon(script.icon)} | 190 | subtitle={script.command} |
| 192 | onPress={() => void props.onRunProjectScript(script)} | 191 | > |
| 193 | subtitle={script.command} | 192 | <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label> |
| 194 | > | 193 | </Stack.Toolbar.MenuAction> |
| 195 | <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label> | 194 | )) |
| 196 | </Stack.Toolbar.MenuAction> | ||
| 197 | ))} | ||
| 198 | </Stack.Toolbar.Menu> | ||
| 199 | ) : ( | 195 | ) : ( |
| 200 | <Stack.Toolbar.MenuAction | 196 | <Stack.Toolbar.MenuAction |
| 201 | icon="play" | 197 | icon="play" |
| @@ -206,34 +202,31 @@ export function ThreadGitControls(props: { · 4 unmodified lines | |||
| 206 | <Stack.Toolbar.Label>No project scripts</Stack.Toolbar.Label> | 202 | <Stack.Toolbar.Label>No project scripts</Stack.Toolbar.Label> |
| 207 | </Stack.Toolbar.MenuAction> | 203 | </Stack.Toolbar.MenuAction> |
| 208 | )} | 204 | )} |
| 209 | <Stack.Toolbar.Menu icon="terminal" inline title="Terminals"> | 205 | {props.terminalSessions.map((session) => ( |
| 210 | <Stack.Toolbar.Label>Terminals</Stack.Toolbar.Label> | ||
| 211 | {props.terminalSessions.map((session) => ( | ||
| 212 | <Stack.Toolbar.MenuAction | ||
| 213 | key={session.terminalId} | ||
| 214 | icon="terminal" | ||
| 215 | onPress={() => props.onOpenTerminal(session.terminalId)} | ||
| 216 | subtitle={[ | ||
| 217 | getTerminalStatusLabel({ | ||
| 218 | status: session.status, | ||
| 219 | hasRunningSubprocess: session.hasRunningSubprocess, | ||
| 220 | }), | ||
| 221 | basename(session.cwd), | ||
| 222 | ] | ||
| 223 | .filter(Boolean) | ||
| 224 | .join(" · ")} | ||
| 225 | > | ||
| 226 | <Stack.Toolbar.Label>{getTerminalLabel(session.terminalId)}</Stack.Toolbar.Label> | ||
| 227 | </Stack.Toolbar.MenuAction> | ||
| 228 | ))} | ||
| 229 | <Stack.Toolbar.MenuAction | 206 | <Stack.Toolbar.MenuAction |
| 230 | icon="plus" | 207 | key={session.terminalId} |
| 231 | onPress={props.onOpenNewTerminal} | 208 | icon="terminal" |
| 232 | subtitle="Start another shell for this thread" | 209 | onPress={() => props.onOpenTerminal(session.terminalId)} |
| 210 | subtitle={[ | ||
| 211 | getTerminalStatusLabel({ | ||
| 212 | status: session.status, | ||
| 213 | hasRunningSubprocess: session.hasRunningSubprocess, | ||
| 214 | }), | ||
| 215 | basename(session.cwd), | ||
| 216 | ] | ||
| 217 | .filter(Boolean) | ||
| 218 | .join(" · ")} | ||
| 233 | > | 219 | > |
| 234 | <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label> | 220 | <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label> |
| 235 | </Stack.Toolbar.MenuAction> | 221 | </Stack.Toolbar.MenuAction> |
| 236 | </Stack.Toolbar.Menu> | 222 | ))} |
| 223 | <Stack.Toolbar.MenuAction | ||
| 224 | icon="plus" | ||
| 225 | onPress={props.onOpenNewTerminal} | ||
| 226 | subtitle="Start another shell for this thread" | ||
| 227 | > | ||
| 228 | <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label> | ||
| 229 | </Stack.Toolbar.MenuAction> | ||
| 237 | </Stack.Toolbar.Menu> | 230 | </Stack.Toolbar.Menu> |
| 238 | <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up"> | 231 | <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up"> |
| 239 | <Stack.Toolbar.MenuAction | 232 | <Stack.Toolbar.MenuAction |
| @@ -12,7 +12,7 @@ import { dismissGitActionResult, useGitActionProgress } from "../../state/use-gi · 11 unmodified lines | |||
| 12 | 12 | ||
| 13 | import { EmptyState } from "../../components/EmptyState"; | 13 | import { EmptyState } from "../../components/EmptyState"; |
| 14 | import { LoadingScreen } from "../../components/LoadingScreen"; | 14 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 15 | import { buildThreadRoutePath, buildThreadTerminalRoutePath } from "../../lib/routes"; | 15 | import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes"; |
| 16 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 16 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 17 | import { connectionTone } from "../connection/connectionTone"; | 17 | import { connectionTone } from "../connection/connectionTone"; |
| 18 | 18 | ||
| @@ -27,13 +27,14 @@ import { useThreadSelection } from "../../state/use-thread-selection"; · 8 unmodified lines | |||
| 27 | import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; | 27 | import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; |
| 28 | import { | 28 | import { |
| 29 | buildTerminalMenuSessions, | 29 | buildTerminalMenuSessions, |
| 30 | nextTerminalId, | 30 | nextOpenTerminalId, |
| 31 | resolveProjectScriptTerminalId, | 31 | resolveProjectScriptTerminalId, |
| 32 | } from "../terminal/terminalMenu"; | 32 | } from "../terminal/terminalMenu"; |
| 33 | import { | 33 | import { |
| 34 | resolvePreferredThreadWorktreePath, | 34 | resolvePreferredThreadWorktreePath, |
| 35 | stagePendingTerminalLaunch, | 35 | stagePendingTerminalLaunch, |
| 36 | } from "../terminal/terminalLaunchContext"; | 36 | } from "../terminal/terminalLaunchContext"; |
| 37 | import { terminalDebugLog } from "../terminal/terminalDebugLog"; | ||
| 37 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; | 38 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; |
| 38 | import { ThreadGitControls } from "./ThreadGitControls"; | 39 | import { ThreadGitControls } from "./ThreadGitControls"; |
| 39 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; | 40 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; |
| @@ -145,27 +146,52 @@ export function ThreadRouteScreen() { · 105 unmodified lines | |||
| 145 | 146 | ||
| 146 | const handleOpenTerminal = useCallback( | 147 | const handleOpenTerminal = useCallback( |
| 147 | (nextTerminalId?: string | null) => { | 148 | (nextTerminalId?: string | null) => { |
| 149 | terminalDebugLog("terminal-menu:open-existing", { | ||
| 150 | terminalId: nextTerminalId ?? null, | ||
| 151 | hasThread: Boolean(selectedThread), | ||
| 152 | hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), | ||
| 153 | }); | ||
| 154 | |||
| 148 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { | 155 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { |
| 149 | return; | 156 | return; |
| 150 | } | 157 | } |
| 151 | 158 | ||
| 152 | void router.push(buildThreadTerminalRoutePath(selectedThread, nextTerminalId)); | 159 | void router.push(buildThreadTerminalNavigation(selectedThread, nextTerminalId)); |
| 153 | }, | 160 | }, |
| 154 | [router, selectedThread, selectedThreadProject?.workspaceRoot], | 161 | [router, selectedThread, selectedThreadProject?.workspaceRoot], |
| 155 | ); | 162 | ); |
| 156 | 163 | ||
| 157 | const handleOpenNewTerminal = useCallback(() => { | 164 | const handleOpenNewTerminal = useCallback(() => { |
| 165 | terminalDebugLog("terminal-menu:open-new", { | ||
| 166 | hasThread: Boolean(selectedThread), | ||
| 167 | hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), | ||
| 168 | listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | ||
| 169 | }); | ||
| 170 | |||
| 158 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { | 171 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { |
| 159 | return; | 172 | return; |
| 160 | } | 173 | } |
| 161 | 174 | ||
| 162 | const nextId = nextTerminalId(terminalMenuSessions.map((session) => session.terminalId)); | 175 | const nextId = nextOpenTerminalId({ |
| 163 | void router.push(buildThreadTerminalRoutePath(selectedThread, nextId)); | 176 | listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), |
| 177 | }); | ||
| 178 | void router.push(buildThreadTerminalNavigation(selectedThread, nextId)); | ||
| 164 | }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]); | 179 | }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]); |
| 165 | 180 | ||
| 166 | const handleRunProjectScript = useCallback( | 181 | const handleRunProjectScript = useCallback( |
| 167 | async (script: ProjectScript) => { | 182 | async (script: ProjectScript) => { |
| 183 | terminalDebugLog("project-script:press", { | ||
| 184 | scriptId: script.id, | ||
| 185 | command: script.command, | ||
| 186 | hasThread: Boolean(selectedThread), | ||
| 187 | hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), | ||
| 188 | }); | ||
| 189 | |||
| 168 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { | 190 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { |
| 191 | terminalDebugLog("project-script:abort", { | ||
| 192 | scriptId: script.id, | ||
| 193 | reason: "no-thread-or-workspace", | ||
| 194 | }); | ||
| 169 | return; | 195 | return; |
| 170 | } | 196 | } |
| 171 | 197 | ||
| @@ -200,8 +226,14 @@ export function ThreadRouteScreen() { · 28 unmodified lines | |||
| 200 | initialInput: `${script.command}\r`, | 226 | initialInput: `${script.command}\r`, |
| 201 | }, | 227 | }, |
| 202 | }); | 228 | }); |
| 229 | terminalDebugLog("project-script:staged", { | ||
| 230 | scriptId: script.id, | ||
| 231 | terminalId: targetTerminalId, | ||
| 232 | cwd, | ||
| 233 | worktreePath: preferredWorktreePath, | ||
| 234 | }); | ||
| 203 | 235 | ||
| 204 | void router.push(buildThreadTerminalRoutePath(selectedThread, targetTerminalId)); | 236 | void router.push(buildThreadTerminalNavigation(selectedThread, targetTerminalId)); |
| 205 | }, | 237 | }, |
| 206 | [ | 238 | [ |
| 207 | router, | 239 | router, |
| @@ -1,4 +1,4 @@ | |||
| 1 | import type { Router } from "expo-router"; | 1 | import type { Href, Router } from "expo-router"; |
| 2 | 2 | ||
| 3 | import type { SelectedThreadRef } from "../state/remote-runtime-types"; | 3 | import type { SelectedThreadRef } from "../state/remote-runtime-types"; |
| 4 | import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; | 4 | import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; |
| @@ -41,6 +41,33 @@ export function buildThreadTerminalRoutePath( · 36 unmodified lines | |||
| 41 | return `${basePath}?terminalId=${encodeURIComponent(terminalId)}`; | 41 | return `${basePath}?terminalId=${encodeURIComponent(terminalId)}`; |
| 42 | } | 42 | } |
| 43 | 43 | ||
| 44 | /** | ||
| 45 | * Prefer this over {@link buildThreadTerminalRoutePath} with `router.push(string)` — Expo Router | ||
| 46 | * often does not merge query strings into `useLocalSearchParams`, which breaks terminal bootstrap | ||
| 47 | * (`requestedTerminalId` stays null while the UI assumes `default`). | ||
| 48 | */ | ||
| 49 | export function buildThreadTerminalNavigation( | ||
| 50 | input: ThreadRouteInput | PlainThreadRouteInput, | ||
| 51 | terminalId?: string | null, | ||
| 52 | ): Href { | ||
| 53 | const environmentId = String(input.environmentId); | ||
| 54 | const threadId = String("threadId" in input ? input.threadId : input.id); | ||
| 55 | |||
| 56 | const params: { environmentId: string; threadId: string; terminalId?: string } = { | ||
| 57 | environmentId, | ||
| 58 | threadId, | ||
| 59 | }; | ||
| 60 | |||
| 61 | if (terminalId != null && terminalId !== "") { | ||
| 62 | params.terminalId = terminalId; | ||
| 63 | } | ||
| 64 | |||
| 65 | return { | ||
| 66 | pathname: "/threads/[environmentId]/[threadId]/terminal", | ||
| 67 | params, | ||
| 68 | }; | ||
| 69 | } | ||
| 70 | |||
| 44 | export function dismissRoute(router: Router) { | 71 | export function dismissRoute(router: Router) { |
| 45 | if (router.canGoBack()) { | 72 | if (router.canGoBack()) { |
| 46 | router.back(); | 73 | router.back(); |
| @@ -18,6 +18,7 @@ import * as Option from "effect/Option"; · 17 unmodified lines | |||
| 18 | import { pipe } from "effect/Function"; | 18 | import { pipe } from "effect/Function"; |
| 19 | import { Atom } from "effect/unstable/reactivity"; | 19 | import { Atom } from "effect/unstable/reactivity"; |
| 20 | import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection"; | 20 | import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection"; |
| 21 | import { terminalDebugLog } from "../features/terminal/terminalDebugLog"; | ||
| 21 | import { clearSavedConnection, loadSavedConnections, saveConnection } from "../lib/storage"; | 22 | import { clearSavedConnection, loadSavedConnections, saveConnection } from "../lib/storage"; |
| 22 | import { appAtomRegistry } from "./atom-registry"; | 23 | import { appAtomRegistry } from "./atom-registry"; |
| 23 | import { type ConnectedEnvironmentSummary, type EnvironmentSession } from "./remote-runtime-types"; | 24 | import { type ConnectedEnvironmentSummary, type EnvironmentSession } from "./remote-runtime-types"; |
| @@ -268,6 +269,9 @@ export async function connectSavedEnvironment( · 244 unmodified lines | |||
| 268 | client, | 269 | client, |
| 269 | }), | 270 | }), |
| 270 | ); | 271 | ); |
| 272 | terminalDebugLog("registry:terminal-metadata-subscribed", { | ||
| 273 | environmentId: connection.environmentId, | ||
| 274 | }); | ||
| 271 | notifyEnvironmentConnectionListeners(); | 275 | notifyEnvironmentConnectionListeners(); |
| 272 | 276 | ||
| 273 | try { | 277 | try { |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | |||
| 13 | import type { | 13 | import type { |
| 14 | EnvironmentId, | 14 | EnvironmentId, |
| 15 | TerminalAttachInput, | 15 | TerminalAttachInput, |
| 16 | TerminalAttachStreamEvent, | ||
| 16 | TerminalMetadataStreamEvent, | 17 | TerminalMetadataStreamEvent, |
| 17 | TerminalSessionSnapshot, | 18 | TerminalSessionSnapshot, |
| 18 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| @@ -43,12 +44,14 @@ export function attachTerminalSession(input: { · 24 unmodified lines | |||
| 43 | readonly client: Parameters<typeof terminalSessionManager.attach>[0]["client"]; | 44 | readonly client: Parameters<typeof terminalSessionManager.attach>[0]["client"]; |
| 44 | readonly terminal: TerminalAttachInput; | 45 | readonly terminal: TerminalAttachInput; |
| 45 | readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; | 46 | readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; |
| 47 | readonly onEvent?: (event: TerminalAttachStreamEvent) => void; | ||
| 46 | }) { | 48 | }) { |
| 47 | return terminalSessionManager.attach({ | 49 | return terminalSessionManager.attach({ |
| 48 | environmentId: input.environmentId, | 50 | environmentId: input.environmentId, |
| 49 | client: input.client, | 51 | client: input.client, |
| 50 | terminal: input.terminal, | 52 | terminal: input.terminal, |
| 51 | ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}), | 53 | ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}), |
| 54 | ...(input.onEvent ? { onEvent: input.onEvent } : {}), | ||
| 52 | }); | 55 | }); |
| 53 | } | 56 | } |
| 54 | 57 | ||
| @@ -91,6 +91,7 @@ describe("ProjectSetupScriptRunner", () => { · 90 unmodified lines | |||
| 91 | history: "", | 91 | history: "", |
| 92 | exitCode: null, | 92 | exitCode: null, |
| 93 | exitSignal: null, | 93 | exitSignal: null, |
| 94 | label: "setup-setup", | ||
| 94 | updatedAt: "2026-01-01T00:00:00.000Z", | 95 | updatedAt: "2026-01-01T00:00:00.000Z", |
| 95 | }), | 96 | }), |
| 96 | ); | 97 | ); |
| @@ -3929,6 +3929,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 3928 unmodified lines | |||
| 3929 | history: "", | 3929 | history: "", |
| 3930 | exitCode: null, | 3930 | exitCode: null, |
| 3931 | exitSignal: null, | 3931 | exitSignal: null, |
| 3932 | label: "Primary", | ||
| 3932 | updatedAt: new Date().toISOString(), | 3933 | updatedAt: new Date().toISOString(), |
| 3933 | }; | 3934 | }; |
| 3934 | 3935 | ||
| @@ -198,7 +198,10 @@ interface CreateManagerOptions { · 197 unmodified lines | |||
| 198 | shellResolver?: () => string; | 198 | shellResolver?: () => string; |
| 199 | platform?: NodeJS.Platform; | 199 | platform?: NodeJS.Platform; |
| 200 | env?: NodeJS.ProcessEnv; | 200 | env?: NodeJS.ProcessEnv; |
| 201 | subprocessChecker?: (terminalPid: number) => Effect.Effect<boolean>; | 201 | subprocessInspector?: (terminalPid: number) => Effect.Effect<{ |
| 202 | readonly hasRunningSubprocess: boolean; | ||
| 203 | readonly childCommand: string | null; | ||
| 204 | }>; | ||
| 202 | subprocessPollIntervalMs?: number; | 205 | subprocessPollIntervalMs?: number; |
| 203 | processKillGraceMs?: number; | 206 | processKillGraceMs?: number; |
| 204 | maxRetainedInactiveSessions?: number; | 207 | maxRetainedInactiveSessions?: number; |
| @@ -234,8 +237,8 @@ const createManager = ( · 29 unmodified lines | |||
| 234 | ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), | 237 | ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), |
| 235 | ...(options.platform !== undefined ? { platform: options.platform } : {}), | 238 | ...(options.platform !== undefined ? { platform: options.platform } : {}), |
| 236 | ...(options.env !== undefined ? { env: options.env } : {}), | 239 | ...(options.env !== undefined ? { env: options.env } : {}), |
| 237 | ...(options.subprocessChecker !== undefined | 240 | ...(options.subprocessInspector !== undefined |
| 238 | ? { subprocessChecker: options.subprocessChecker } | 241 | ? { subprocessInspector: options.subprocessInspector } |
| 239 | : {}), | 242 | : {}), |
| 240 | ...(options.subprocessPollIntervalMs !== undefined | 243 | ...(options.subprocessPollIntervalMs !== undefined |
| 241 | ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } | 244 | ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } |
| @@ -275,7 +278,7 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 33 unmodified lines | |||
| 275 | const third = yield* manager.open(openInput()); | 278 | const third = yield* manager.open(openInput()); |
| 276 | 279 | ||
| 277 | assert.equal(first.threadId, "thread-1"); | 280 | assert.equal(first.threadId, "thread-1"); |
| 278 | assert.equal(first.terminalId, "default"); | 281 | assert.equal(first.terminalId, DEFAULT_TERMINAL_ID); |
| 279 | assert.equal(second.threadId, "thread-1"); | 282 | assert.equal(second.threadId, "thread-1"); |
| 280 | assert.equal(third.threadId, "thread-1"); | 283 | assert.equal(third.threadId, "thread-1"); |
| 281 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | 284 | expect(ptyAdapter.spawnInputs).toHaveLength(1); |
| @@ -445,7 +448,7 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 163 unmodified lines | |||
| 445 | (event) => | 448 | (event) => |
| 446 | event.type === "cleared" && | 449 | event.type === "cleared" && |
| 447 | event.threadId === "thread-1" && | 450 | event.threadId === "thread-1" && |
| 448 | event.terminalId === "default", | 451 | event.terminalId === DEFAULT_TERMINAL_ID, |
| 449 | ), | 452 | ), |
| 450 | ).toBe(true); | 453 | ).toBe(true); |
| 451 | }), | 454 | }), |
| @@ -617,27 +620,40 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 165 unmodified lines | |||
| 617 | 620 | ||
| 618 | it.effect("emits subprocess activity events when child-process state changes", () => | 621 | it.effect("emits subprocess activity events when child-process state changes", () => |
| 619 | Effect.gen(function* () { | 622 | Effect.gen(function* () { |
| 620 | let hasRunningSubprocess = false; | 623 | let inspect: { |
| 624 | readonly hasRunningSubprocess: boolean; | ||
| 625 | readonly childCommand: string | null; | ||
| 626 | } = { hasRunningSubprocess: false, childCommand: null }; | ||
| 621 | const { manager, getEvents } = yield* createManager(5, { | 627 | const { manager, getEvents } = yield* createManager(5, { |
| 622 | subprocessChecker: () => Effect.succeed(hasRunningSubprocess), | 628 | subprocessInspector: () => Effect.succeed(inspect), |
| 623 | subprocessPollIntervalMs: 20, | 629 | subprocessPollIntervalMs: 20, |
| 624 | }); | 630 | }); |
| 625 | 631 | ||
| 626 | yield* manager.open(openInput()); | 632 | yield* manager.open(openInput()); |
| 627 | expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); | 633 | expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); |
| 628 | 634 | ||
| 629 | hasRunningSubprocess = true; | 635 | inspect = { hasRunningSubprocess: true, childCommand: "vim" }; |
| 630 | yield* waitFor( | 636 | yield* waitFor( |
| 631 | Effect.map(getEvents, (events) => | 637 | Effect.map(getEvents, (events) => |
| 632 | events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true), | 638 | events.some( |
| 639 | (event) => | ||
| 640 | event.type === "activity" && | ||
| 641 | event.hasRunningSubprocess === true && | ||
| 642 | event.label === "vim", | ||
| 643 | ), | ||
| 633 | ), | 644 | ), |
| 634 | "1200 millis", | 645 | "1200 millis", |
| 635 | ); | 646 | ); |
| 636 | 647 | ||
| 637 | hasRunningSubprocess = false; | 648 | inspect = { hasRunningSubprocess: false, childCommand: null }; |
| 638 | yield* waitFor( | 649 | yield* waitFor( |
| 639 | Effect.map(getEvents, (events) => | 650 | Effect.map(getEvents, (events) => |
| 640 | events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false), | 651 | events.some( |
| 652 | (event) => | ||
| 653 | event.type === "activity" && | ||
| 654 | event.hasRunningSubprocess === false && | ||
| 655 | event.label === "Terminal 1", | ||
| 656 | ), | ||
| 641 | ), | 657 | ), |
| 642 | "1200 millis", | 658 | "1200 millis", |
| 643 | ); | 659 | ); |
| @@ -648,9 +664,9 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 4 unmodified lines | |||
| 648 | Effect.gen(function* () { | 664 | Effect.gen(function* () { |
| 649 | let checks = 0; | 665 | let checks = 0; |
| 650 | const { manager } = yield* createManager(5, { | 666 | const { manager } = yield* createManager(5, { |
| 651 | subprocessChecker: () => { | 667 | subprocessInspector: () => { |
| 652 | checks += 1; | 668 | checks += 1; |
| 653 | return Effect.succeed(false); | 669 | return Effect.succeed({ hasRunningSubprocess: false, childCommand: null }); |
| 654 | }, | 670 | }, |
| 655 | subprocessPollIntervalMs: 20, | 671 | subprocessPollIntervalMs: 20, |
| 656 | }); | 672 | }); |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | |||
| 12 | type TerminalSummary, | 12 | type TerminalSummary, |
| 13 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 14 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; | 14 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; |
| 15 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | ||
| 15 | import { | 16 | import { |
| 16 | Effect, | 17 | Effect, |
| 17 | Encoding, | 18 | Encoding, |
| @@ -58,6 +59,7 @@ const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; · 40 unmodified lines | |||
| 58 | const DEFAULT_OPEN_COLS = 120; | 59 | const DEFAULT_OPEN_COLS = 120; |
| 59 | const DEFAULT_OPEN_ROWS = 30; | 60 | const DEFAULT_OPEN_ROWS = 30; |
| 60 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); | 61 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); |
| 62 | const MAX_TERMINAL_LABEL_LENGTH = 128; | ||
| 61 | 63 | ||
| 62 | class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()( | 64 | class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()( |
| 63 | "TerminalSubprocessCheckError", | 65 | "TerminalSubprocessCheckError", |
| @@ -78,8 +80,15 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess · 14 unmodified lines | |||
| 78 | }, | 80 | }, |
| 79 | ) {} | 81 | ) {} |
| 80 | 82 | ||
| 81 | interface TerminalSubprocessChecker { | 83 | interface TerminalSubprocessInspectResult { |
| 82 | (terminalPid: number): Effect.Effect<boolean, TerminalSubprocessCheckError>; | 84 | readonly hasRunningSubprocess: boolean; |
| 85 | readonly childCommand: string | null; | ||
| 86 | } | ||
| 87 | |||
| 88 | interface TerminalSubprocessInspector { | ||
| 89 | ( | ||
| 90 | terminalPid: number, | ||
| 91 | ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>; | ||
| 83 | } | 92 | } |
| 84 | 93 | ||
| 85 | interface ShellCandidate { | 94 | interface ShellCandidate { |
| @@ -119,6 +128,8 @@ interface TerminalSessionState { · 33 unmodified lines | |||
| 119 | unsubscribeData: (() => void) | null; | 128 | unsubscribeData: (() => void) | null; |
| 120 | unsubscribeExit: (() => void) | null; | 129 | unsubscribeExit: (() => void) | null; |
| 121 | hasRunningSubprocess: boolean; | 130 | hasRunningSubprocess: boolean; |
| 131 | /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */ | ||
| 132 | childCommandLabel: string | null; | ||
| 122 | runtimeEnv: Record<string, string> | null; | 133 | runtimeEnv: Record<string, string> | null; |
| 123 | } | 134 | } |
| 124 | 135 | ||
| @@ -154,6 +165,38 @@ interface TerminalManagerState { · 29 unmodified lines | |||
| 154 | killFibers: Map<PtyProcess, Fiber.Fiber<void, never>>; | 165 | killFibers: Map<PtyProcess, Fiber.Fiber<void, never>>; |
| 155 | } | 166 | } |
| 156 | 167 | ||
| 168 | function truncateTerminalWireLabel(value: string): string { | ||
| 169 | if (value.length <= MAX_TERMINAL_LABEL_LENGTH) return value; | ||
| 170 | return value.slice(0, MAX_TERMINAL_LABEL_LENGTH); | ||
| 171 | } | ||
| 172 | |||
| 173 | function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): string | null { | ||
| 174 | let trimmed = raw.trim(); | ||
| 175 | if (trimmed.length === 0) return null; | ||
| 176 | if ( | ||
| 177 | (trimmed.startsWith("[") && trimmed.endsWith("]")) || | ||
| 178 | (trimmed.startsWith("(") && trimmed.endsWith(")")) | ||
| 179 | ) { | ||
| 180 | trimmed = trimmed.slice(1, -1).trim(); | ||
| 181 | } | ||
| 182 | const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim(); | ||
| 183 | if (firstToken.length === 0) return null; | ||
| 184 | const base = platform === "win32" ? path.win32.basename(firstToken) : path.basename(firstToken); | ||
| 185 | const withoutExe = | ||
| 186 | platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base; | ||
| 187 | return withoutExe.length > 0 ? withoutExe : null; | ||
| 188 | } | ||
| 189 | |||
| 190 | function terminalWireLabel(session: TerminalSessionState): string { | ||
| 191 | if (session.hasRunningSubprocess && session.childCommandLabel) { | ||
| 192 | const trimmed = session.childCommandLabel.trim(); | ||
| 193 | if (trimmed.length > 0) { | ||
| 194 | return truncateTerminalWireLabel(trimmed); | ||
| 195 | } | ||
| 196 | } | ||
| 197 | return truncateTerminalWireLabel(getTerminalLabel(session.terminalId)); | ||
| 198 | } | ||
| 199 | |||
| 157 | function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { | 200 | function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { |
| 158 | return { | 201 | return { |
| 159 | threadId: session.threadId, | 202 | threadId: session.threadId, |
| @@ -165,6 +208,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { · 5 unmodified lines | |||
| 165 | history: session.history, | 208 | history: session.history, |
| 166 | exitCode: session.exitCode, | 209 | exitCode: session.exitCode, |
| 167 | exitSignal: session.exitSignal, | 210 | exitSignal: session.exitSignal, |
| 211 | label: terminalWireLabel(session), | ||
| 168 | updatedAt: session.updatedAt, | 212 | updatedAt: session.updatedAt, |
| 169 | sequence: session.eventSequence, | 213 | sequence: session.eventSequence, |
| 170 | }; | 214 | }; |
| @@ -181,6 +225,7 @@ function summary(session: TerminalSessionState): TerminalSummary { · 10 unmodified lines | |||
| 181 | exitCode: session.exitCode, | 225 | exitCode: session.exitCode, |
| 182 | exitSignal: session.exitSignal, | 226 | exitSignal: session.exitSignal, |
| 183 | hasRunningSubprocess: session.hasRunningSubprocess, | 227 | hasRunningSubprocess: session.hasRunningSubprocess, |
| 228 | label: terminalWireLabel(session), | ||
| 184 | updatedAt: session.updatedAt, | 229 | updatedAt: session.updatedAt, |
| 185 | }; | 230 | }; |
| 186 | } | 231 | } |
| @@ -426,13 +471,25 @@ function isRetryableShellSpawnError(error: PtySpawnError): boolean { · 239 unmodified lines | |||
| 426 | ); | 471 | ); |
| 427 | } | 472 | } |
| 428 | 473 | ||
| 429 | function checkWindowsSubprocessActivity( | 474 | function parseFirstChildPidFromPgrep(stdout: string): number | null { |
| 475 | for (const line of stdout.split(/\r?\n/g)) { | ||
| 476 | const n = Number.parseInt(line.trim(), 10); | ||
| 477 | if (Number.isInteger(n) && n > 0) { | ||
| 478 | return n; | ||
| 479 | } | ||
| 480 | } | ||
| 481 | return null; | ||
| 482 | } | ||
| 483 | |||
| 484 | function windowsInspectSubprocess( | ||
| 430 | terminalPid: number, | 485 | terminalPid: number, |
| 431 | ): Effect.Effect<boolean, TerminalSubprocessCheckError> { | 486 | platform: NodeJS.Platform, |
| 487 | ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError> { | ||
| 432 | const command = [ | 488 | const command = [ |
| 433 | `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, | 489 | `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`, |
| 434 | "if ($children) { exit 0 }", | 490 | "if ($null -eq $c) { exit 1 }", |
| 435 | "exit 1", | 491 | "Write-Output $c.Name", |
| 492 | "exit 0", | ||
| 436 | ].join("; "); | 493 | ].join("; "); |
| 437 | return Effect.tryPromise({ | 494 | return Effect.tryPromise({ |
| 438 | try: () => | 495 | try: () => |
| @@ -444,17 +501,33 @@ function checkWindowsSubprocessActivity( · 5 unmodified lines | |||
| 444 | }), | 501 | }), |
| 445 | catch: (cause) => | 502 | catch: (cause) => |
| 446 | new TerminalSubprocessCheckError({ | 503 | new TerminalSubprocessCheckError({ |
| 447 | message: "Failed to check Windows terminal subprocess activity.", | 504 | message: "Failed to inspect Windows terminal subprocesses.", |
| 448 | cause, | 505 | cause, |
| 449 | terminalPid, | 506 | terminalPid, |
| 450 | command: "powershell", | 507 | command: "powershell", |
| 451 | }), | 508 | }), |
| 452 | }).pipe(Effect.map((result) => result.code === 0)); | 509 | }).pipe( |
| 510 | Effect.map((result) => { | ||
| 511 | if (result.code !== 0) { | ||
| 512 | return { hasRunningSubprocess: false, childCommand: null } as const; | ||
| 513 | } | ||
| 514 | const name = result.stdout.trim().split(/\r?\n/)[0]?.trim() ?? ""; | ||
| 515 | if (name.length === 0) { | ||
| 516 | return { hasRunningSubprocess: true, childCommand: null } as const; | ||
| 517 | } | ||
| 518 | const normalized = normalizeChildCommandName(name, platform); | ||
| 519 | return { | ||
| 520 | hasRunningSubprocess: true, | ||
| 521 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, | ||
| 522 | } as const; | ||
| 523 | }), | ||
| 524 | ); | ||
| 453 | } | 525 | } |
| 454 | 526 | ||
| 455 | const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* ( | 527 | const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* ( |
| 456 | terminalPid: number, | 528 | terminalPid: number, |
| 457 | ): Effect.fn.Return<boolean, TerminalSubprocessCheckError> { | 529 | platform: NodeJS.Platform, |
| 530 | ): Effect.fn.Return<TerminalSubprocessInspectResult, TerminalSubprocessCheckError> { | ||
| 458 | const runPgrep = Effect.tryPromise({ | 531 | const runPgrep = Effect.tryPromise({ |
| 459 | try: () => | 532 | try: () => |
| 460 | runProcess("pgrep", ["-P", String(terminalPid)], { | 533 | runProcess("pgrep", ["-P", String(terminalPid)], { |
| @@ -489,45 +562,94 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct · 28 unmodified lines | |||
| 489 | }), | 562 | }), |
| 490 | }); | 563 | }); |
| 491 | 564 | ||
| 565 | let childPid: number | null = null; | ||
| 566 | |||
| 492 | const pgrepResult = yield* Effect.exit(runPgrep); | 567 | const pgrepResult = yield* Effect.exit(runPgrep); |
| 493 | if (pgrepResult._tag === "Success") { | 568 | if (pgrepResult._tag === "Success") { |
| 494 | if (pgrepResult.value.code === 0) { | 569 | if (pgrepResult.value.code === 0) { |
| 495 | return pgrepResult.value.stdout.trim().length > 0; | 570 | childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); |
| 496 | } | 571 | } else if (pgrepResult.value.code === 1) { |
| 497 | if (pgrepResult.value.code === 1) { | 572 | return { hasRunningSubprocess: false, childCommand: null }; |
| 498 | return false; | ||
| 499 | } | 573 | } |
| 500 | } | 574 | } |
| 501 | 575 | ||
| 502 | const psResult = yield* Effect.exit(runPs); | 576 | if (childPid === null) { |
| 503 | if (psResult._tag === "Failure" || psResult.value.code !== 0) { | 577 | const psResult = yield* Effect.exit(runPs); |
| 504 | return false; | 578 | if (psResult._tag === "Failure" || psResult.value.code !== 0) { |
| 579 | return { hasRunningSubprocess: false, childCommand: null }; | ||
| 580 | } | ||
| 581 | for (const line of psResult.value.stdout.split(/\r?\n/g)) { | ||
| 582 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | ||
| 583 | const pid = Number(pidRaw); | ||
| 584 | const ppid = Number(ppidRaw); | ||
| 585 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; | ||
| 586 | if (ppid === terminalPid) { | ||
| 587 | childPid = pid; | ||
| 588 | break; | ||
| 589 | } | ||
| 590 | } | ||
| 505 | } | 591 | } |
| 506 | 592 | ||
| 507 | for (const line of psResult.value.stdout.split(/\r?\n/g)) { | 593 | if (childPid === null) { |
| 508 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | 594 | return { hasRunningSubprocess: false, childCommand: null }; |
| 509 | const pid = Number(pidRaw); | ||
| 510 | const ppid = Number(ppidRaw); | ||
| 511 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; | ||
| 512 | if (ppid === terminalPid) { | ||
| 513 | return true; | ||
| 514 | } | ||
| 515 | } | 595 | } |
| 516 | return false; | ||
| 517 | }); | ||
| 518 | 596 | ||
| 519 | const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* ( | 597 | const runComm = Effect.tryPromise({ |
| 520 | terminalPid: number, | 598 | try: () => |
| 521 | ): Effect.fn.Return<boolean, TerminalSubprocessCheckError> { | 599 | runProcess("ps", ["-p", String(childPid), "-o", "comm="], { |
| 522 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { | 600 | timeoutMs: 1_000, |
| 523 | return false; | 601 | allowNonZeroExit: true, |
| 602 | maxBufferBytes: 8_192, | ||
| 603 | outputMode: "truncate", | ||
| 604 | }), | ||
| 605 | catch: () => null, | ||
| 606 | }); | ||
| 607 | |||
| 608 | const commResult = yield* Effect.exit(runComm); | ||
| 609 | let rawComm: string | null = null; | ||
| 610 | if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) { | ||
| 611 | rawComm = commResult.value.stdout.trim(); | ||
| 524 | } | 612 | } |
| 525 | if (process.platform === "win32") { | 613 | |
| 526 | return yield* checkWindowsSubprocessActivity(terminalPid); | 614 | if (!rawComm || rawComm.length === 0) { |
| 615 | const runArgs = Effect.tryPromise({ | ||
| 616 | try: () => | ||
| 617 | runProcess("ps", ["-p", String(childPid), "-o", "args="], { | ||
| 618 | timeoutMs: 1_000, | ||
| 619 | allowNonZeroExit: true, | ||
| 620 | maxBufferBytes: 16_384, | ||
| 621 | outputMode: "truncate", | ||
| 622 | }), | ||
| 623 | catch: () => null, | ||
| 624 | }); | ||
| 625 | const argsResult = yield* Effect.exit(runArgs); | ||
| 626 | if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) { | ||
| 627 | const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? ""; | ||
| 628 | rawComm = first.length > 0 ? first : null; | ||
| 629 | } | ||
| 527 | } | 630 | } |
| 528 | return yield* checkPosixSubprocessActivity(terminalPid); | 631 | |
| 632 | const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; | ||
| 633 | return { | ||
| 634 | hasRunningSubprocess: true, | ||
| 635 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, | ||
| 636 | }; | ||
| 529 | }); | 637 | }); |
| 530 | 638 | ||
| 639 | function defaultSubprocessInspectorForPlatform( | ||
| 640 | platform: NodeJS.Platform, | ||
| 641 | ): TerminalSubprocessInspector { | ||
| 642 | return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { | ||
| 643 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { | ||
| 644 | return { hasRunningSubprocess: false, childCommand: null }; | ||
| 645 | } | ||
| 646 | if (platform === "win32") { | ||
| 647 | return yield* windowsInspectSubprocess(terminalPid, platform); | ||
| 648 | } | ||
| 649 | return yield* posixInspectSubprocess(terminalPid, platform); | ||
| 650 | }); | ||
| 651 | } | ||
| 652 | |||
| 531 | function capHistory(history: string, maxLines: number): string { | 653 | function capHistory(history: string, maxLines: number): string { |
| 532 | if (history.length === 0) return history; | 654 | if (history.length === 0) return history; |
| 533 | const hasTrailingNewline = history.endsWith("\n"); | 655 | const hasTrailingNewline = history.endsWith("\n"); |
| @@ -775,7 +897,7 @@ interface TerminalManagerOptions { · 241 unmodified lines | |||
| 775 | shellResolver?: () => string; | 897 | shellResolver?: () => string; |
| 776 | platform?: NodeJS.Platform; | 898 | platform?: NodeJS.Platform; |
| 777 | env?: NodeJS.ProcessEnv; | 899 | env?: NodeJS.ProcessEnv; |
| 778 | subprocessChecker?: TerminalSubprocessChecker; | 900 | subprocessInspector?: TerminalSubprocessInspector; |
| 779 | subprocessPollIntervalMs?: number; | 901 | subprocessPollIntervalMs?: number; |
| 780 | processKillGraceMs?: number; | 902 | processKillGraceMs?: number; |
| 781 | maxRetainedInactiveSessions?: number; | 903 | maxRetainedInactiveSessions?: number; |
| @@ -801,7 +923,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 19 unmodified lines | |||
| 801 | const platform = options.platform ?? process.platform; | 923 | const platform = options.platform ?? process.platform; |
| 802 | const baseEnv = options.env ?? process.env; | 924 | const baseEnv = options.env ?? process.env; |
| 803 | const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); | 925 | const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); |
| 804 | const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; | 926 | const subprocessInspector = |
| 927 | options.subprocessInspector ?? defaultSubprocessInspectorForPlatform(platform); | ||
| 805 | const subprocessPollIntervalMs = | 928 | const subprocessPollIntervalMs = |
| 806 | options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; | 929 | options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; |
| 807 | const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; | 930 | const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; |
| @@ -1290,6 +1413,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 482 unmodified lines | |||
| 1290 | session.process = null; | 1413 | session.process = null; |
| 1291 | session.pid = null; | 1414 | session.pid = null; |
| 1292 | session.hasRunningSubprocess = false; | 1415 | session.hasRunningSubprocess = false; |
| 1416 | session.childCommandLabel = null; | ||
| 1293 | session.status = "exited"; | 1417 | session.status = "exited"; |
| 1294 | session.pendingHistoryControlSequence = ""; | 1418 | session.pendingHistoryControlSequence = ""; |
| 1295 | session.pendingProcessEvents = []; | 1419 | session.pendingProcessEvents = []; |
| @@ -1358,6 +1482,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 62 unmodified lines | |||
| 1358 | session.process = null; | 1482 | session.process = null; |
| 1359 | session.pid = null; | 1483 | session.pid = null; |
| 1360 | session.hasRunningSubprocess = false; | 1484 | session.hasRunningSubprocess = false; |
| 1485 | session.childCommandLabel = null; | ||
| 1361 | session.status = "exited"; | 1486 | session.status = "exited"; |
| 1362 | session.pendingHistoryControlSequence = ""; | 1487 | session.pendingHistoryControlSequence = ""; |
| 1363 | session.pendingProcessEvents = []; | 1488 | session.pendingProcessEvents = []; |
| @@ -1451,6 +1576,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 87 unmodified lines | |||
| 1451 | session.exitCode = null; | 1576 | session.exitCode = null; |
| 1452 | session.exitSignal = null; | 1577 | session.exitSignal = null; |
| 1453 | session.hasRunningSubprocess = false; | 1578 | session.hasRunningSubprocess = false; |
| 1579 | session.childCommandLabel = null; | ||
| 1454 | session.pendingProcessEvents = []; | 1580 | session.pendingProcessEvents = []; |
| 1455 | session.pendingProcessEventIndex = 0; | 1581 | session.pendingProcessEventIndex = 0; |
| 1456 | session.processEventDrainRunning = false; | 1582 | session.processEventDrainRunning = false; |
| @@ -1528,6 +1654,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 71 unmodified lines | |||
| 1528 | session.unsubscribeData = null; | 1654 | session.unsubscribeData = null; |
| 1529 | session.unsubscribeExit = null; | 1655 | session.unsubscribeExit = null; |
| 1530 | session.hasRunningSubprocess = false; | 1656 | session.hasRunningSubprocess = false; |
| 1657 | session.childCommandLabel = null; | ||
| 1531 | session.pendingProcessEvents = []; | 1658 | session.pendingProcessEvents = []; |
| 1532 | session.pendingProcessEventIndex = 0; | 1659 | session.pendingProcessEventIndex = 0; |
| 1533 | session.processEventDrainRunning = false; | 1660 | session.processEventDrainRunning = false; |
| @@ -1608,7 +1735,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 74 unmodified lines | |||
| 1608 | session: TerminalSessionState & { pid: number }, | 1735 | session: TerminalSessionState & { pid: number }, |
| 1609 | ) { | 1736 | ) { |
| 1610 | const terminalPid = session.pid; | 1737 | const terminalPid = session.pid; |
| 1611 | const hasRunningSubprocess = yield* subprocessChecker(terminalPid).pipe( | 1738 | const inspectResult = yield* subprocessInspector(terminalPid).pipe( |
| 1612 | Effect.map(Option.some), | 1739 | Effect.map(Option.some), |
| 1613 | Effect.catch((reason) => | 1740 | Effect.catch((reason) => |
| 1614 | Effect.logWarning("failed to check terminal subprocess activity", { | 1741 | Effect.logWarning("failed to check terminal subprocess activity", { |
| @@ -1616,14 +1743,17 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 1 unmodified line | |||
| 1616 | terminalId: session.terminalId, | 1743 | terminalId: session.terminalId, |
| 1617 | terminalPid, | 1744 | terminalPid, |
| 1618 | reason, | 1745 | reason, |
| 1619 | }).pipe(Effect.as(Option.none<boolean>())), | 1746 | }).pipe(Effect.as(Option.none<TerminalSubprocessInspectResult>())), |
| 1620 | ), | 1747 | ), |
| 1621 | ); | 1748 | ); |
| 1622 | 1749 | ||
| 1623 | if (Option.isNone(hasRunningSubprocess)) { | 1750 | if (Option.isNone(inspectResult)) { |
| 1624 | return; | 1751 | return; |
| 1625 | } | 1752 | } |
| 1626 | 1753 | ||
| 1754 | const next = inspectResult.value; | ||
| 1755 | const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; | ||
| 1756 | |||
| 1627 | const event = yield* modifyManagerState((state) => { | 1757 | const event = yield* modifyManagerState((state) => { |
| 1628 | const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr( | 1758 | const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr( |
| 1629 | state.sessions.get(toSessionKey(session.threadId, session.terminalId)), | 1759 | state.sessions.get(toSessionKey(session.threadId, session.terminalId)), |
| @@ -1632,12 +1762,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 2 unmodified lines | |||
| 1632 | Option.isNone(liveSession) || | 1762 | Option.isNone(liveSession) || |
| 1633 | liveSession.value.status !== "running" || | 1763 | liveSession.value.status !== "running" || |
| 1634 | liveSession.value.pid !== terminalPid || | 1764 | liveSession.value.pid !== terminalPid || |
| 1635 | liveSession.value.hasRunningSubprocess === hasRunningSubprocess.value | 1765 | (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess && |
| 1766 | liveSession.value.childCommandLabel === nextChildLabel) | ||
| 1636 | ) { | 1767 | ) { |
| 1637 | return [Option.none(), state] as const; | 1768 | return [Option.none(), state] as const; |
| 1638 | } | 1769 | } |
| 1639 | 1770 | ||
| 1640 | liveSession.value.hasRunningSubprocess = hasRunningSubprocess.value; | 1771 | liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess; |
| 1772 | liveSession.value.childCommandLabel = nextChildLabel; | ||
| 1641 | const eventStamp = advanceEventSequence(liveSession.value); | 1773 | const eventStamp = advanceEventSequence(liveSession.value); |
| 1642 | 1774 | ||
| 1643 | return [ | 1775 | return [ |
| @@ -1646,7 +1778,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 2 unmodified lines | |||
| 1646 | threadId: liveSession.value.threadId, | 1778 | threadId: liveSession.value.threadId, |
| 1647 | terminalId: liveSession.value.terminalId, | 1779 | terminalId: liveSession.value.terminalId, |
| 1648 | sequence: eventStamp.sequence, | 1780 | sequence: eventStamp.sequence, |
| 1649 | hasRunningSubprocess: hasRunningSubprocess.value, | 1781 | hasRunningSubprocess: next.hasRunningSubprocess, |
| 1782 | label: terminalWireLabel(liveSession.value), | ||
| 1650 | }), | 1783 | }), |
| 1651 | state, | 1784 | state, |
| 1652 | ] as const; | 1785 | ] as const; |
| @@ -1711,7 +1844,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 58 unmodified lines | |||
| 1711 | ); | 1844 | ); |
| 1712 | 1845 | ||
| 1713 | const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { | 1846 | const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { |
| 1714 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 1847 | const terminalId = input.terminalId; |
| 1715 | yield* assertValidCwd(input.cwd); | 1848 | yield* assertValidCwd(input.cwd); |
| 1716 | 1849 | ||
| 1717 | const sessionKey = toSessionKey(input.threadId, terminalId); | 1850 | const sessionKey = toSessionKey(input.threadId, terminalId); |
| @@ -1743,6 +1876,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 25 unmodified lines | |||
| 1743 | unsubscribeData: null, | 1876 | unsubscribeData: null, |
| 1744 | unsubscribeExit: null, | 1877 | unsubscribeExit: null, |
| 1745 | hasRunningSubprocess: false, | 1878 | hasRunningSubprocess: false, |
| 1879 | childCommandLabel: null, | ||
| 1746 | runtimeEnv: normalizedRuntimeEnv(input.env), | 1880 | runtimeEnv: normalizedRuntimeEnv(input.env), |
| 1747 | }; | 1881 | }; |
| 1748 | 1882 | ||
| @@ -1839,7 +1973,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 90 unmodified lines | |||
| 1839 | withThreadLock( | 1973 | withThreadLock( |
| 1840 | input.threadId, | 1974 | input.threadId, |
| 1841 | Effect.gen(function* () { | 1975 | Effect.gen(function* () { |
| 1842 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 1976 | const terminalId = input.terminalId; |
| 1843 | const existing = yield* getSession(input.threadId, terminalId); | 1977 | const existing = yield* getSession(input.threadId, terminalId); |
| 1844 | 1978 | ||
| 1845 | if (Option.isNone(existing)) { | 1979 | if (Option.isNone(existing)) { |
| @@ -1918,10 +2052,12 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 72 unmodified lines | |||
| 1918 | let unsubscribe: (() => void) | null = null; | 2052 | let unsubscribe: (() => void) | null = null; |
| 1919 | 2053 | ||
| 1920 | return Effect.gen(function* () { | 2054 | return Effect.gen(function* () { |
| 1921 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | ||
| 1922 | const bufferedEvents: TerminalEvent[] = []; | 2055 | const bufferedEvents: TerminalEvent[] = []; |
| 1923 | let deliverLive = false; | 2056 | let deliverLive = false; |
| 1924 | 2057 | ||
| 2058 | const initialSnapshot = yield* openOrAttachForStream(input); | ||
| 2059 | const terminalId = initialSnapshot.terminalId; | ||
| 2060 | |||
| 1925 | unsubscribe = yield* subscribe((event) => { | 2061 | unsubscribe = yield* subscribe((event) => { |
| 1926 | if (event.threadId !== input.threadId || event.terminalId !== terminalId) { | 2062 | if (event.threadId !== input.threadId || event.terminalId !== terminalId) { |
| 1927 | return Effect.void; | 2063 | return Effect.void; |
| @@ -1936,7 +2072,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 8 unmodified lines | |||
| 1936 | return attachEvent ? listener(attachEvent) : Effect.void; | 2072 | return attachEvent ? listener(attachEvent) : Effect.void; |
| 1937 | }); | 2073 | }); |
| 1938 | 2074 | ||
| 1939 | const initialSnapshot = yield* openOrAttachForStream(input); | ||
| 1940 | yield* listener({ | 2075 | yield* listener({ |
| 1941 | type: "snapshot", | 2076 | type: "snapshot", |
| 1942 | snapshot: initialSnapshot, | 2077 | snapshot: initialSnapshot, |
| @@ -2044,7 +2179,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 101 unmodified lines | |||
| 2044 | }; | 2179 | }; |
| 2045 | 2180 | ||
| 2046 | const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) { | 2181 | const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) { |
| 2047 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 2182 | const terminalId = input.terminalId; |
| 2048 | const session = yield* requireSession(input.threadId, terminalId); | 2183 | const session = yield* requireSession(input.threadId, terminalId); |
| 2049 | const process = session.process; | 2184 | const process = session.process; |
| 2050 | if (!process || session.status !== "running") { | 2185 | if (!process || session.status !== "running") { |
| @@ -2058,7 +2193,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 7 unmodified lines | |||
| 2058 | }); | 2193 | }); |
| 2059 | 2194 | ||
| 2060 | const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) { | 2195 | const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) { |
| 2061 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 2196 | const terminalId = input.terminalId; |
| 2062 | const session = yield* requireSession(input.threadId, terminalId); | 2197 | const session = yield* requireSession(input.threadId, terminalId); |
| 2063 | const process = session.process; | 2198 | const process = session.process; |
| 2064 | if (!process || session.status !== "running") { | 2199 | if (!process || session.status !== "running") { |
| @@ -2077,7 +2212,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 12 unmodified lines | |||
| 2077 | withThreadLock( | 2212 | withThreadLock( |
| 2078 | input.threadId, | 2213 | input.threadId, |
| 2079 | Effect.gen(function* () { | 2214 | Effect.gen(function* () { |
| 2080 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 2215 | const terminalId = input.terminalId; |
| 2081 | const session = yield* requireSession(input.threadId, terminalId); | 2216 | const session = yield* requireSession(input.threadId, terminalId); |
| 2082 | session.history = ""; | 2217 | session.history = ""; |
| 2083 | session.pendingHistoryControlSequence = ""; | 2218 | session.pendingHistoryControlSequence = ""; |
| @@ -2100,7 +2235,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 16 unmodified lines | |||
| 2100 | input.threadId, | 2235 | input.threadId, |
| 2101 | Effect.gen(function* () { | 2236 | Effect.gen(function* () { |
| 2102 | yield* increment(terminalRestartsTotal, { scope: "thread" }); | 2237 | yield* increment(terminalRestartsTotal, { scope: "thread" }); |
| 2103 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 2238 | const terminalId = input.terminalId; |
| 2104 | yield* assertValidCwd(input.cwd); | 2239 | yield* assertValidCwd(input.cwd); |
| 2105 | 2240 | ||
| 2106 | const sessionKey = toSessionKey(input.threadId, terminalId); | 2241 | const sessionKey = toSessionKey(input.threadId, terminalId); |
| @@ -2131,6 +2266,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 24 unmodified lines | |||
| 2131 | unsubscribeData: null, | 2266 | unsubscribeData: null, |
| 2132 | unsubscribeExit: null, | 2267 | unsubscribeExit: null, |
| 2133 | hasRunningSubprocess: false, | 2268 | hasRunningSubprocess: false, |
| 2269 | childCommandLabel: null, | ||
| 2134 | runtimeEnv: normalizedRuntimeEnv(input.env), | 2270 | runtimeEnv: normalizedRuntimeEnv(input.env), |
| 2135 | }; | 2271 | }; |
| 2136 | const createdSession = session; | 2272 | const createdSession = session; |
| @@ -993,6 +993,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { · 992 unmodified lines | |||
| 993 | history: "", | 993 | history: "", |
| 994 | exitCode: null, | 994 | exitCode: null, |
| 995 | exitSignal: null, | 995 | exitSignal: null, |
| 996 | label: "Terminal 1", | ||
| 996 | updatedAt: NOW_ISO, | 997 | updatedAt: NOW_ISO, |
| 997 | }; | 998 | }; |
| 998 | } | 999 | } |
| @@ -1767,6 +1768,26 @@ describe("ChatView timeline estimator parity (full app)", () => { · 768 unmodified lines | |||
| 1767 | const mounted = await mountChatView({ | 1768 | const mounted = await mountChatView({ |
| 1768 | viewport: DEFAULT_VIEWPORT, | 1769 | viewport: DEFAULT_VIEWPORT, |
| 1769 | snapshot, | 1770 | snapshot, |
| 1771 | configureFixture: (nextFixture) => { | ||
| 1772 | nextFixture.terminalMetadataEvents = [ | ||
| 1773 | { | ||
| 1774 | type: "upsert", | ||
| 1775 | terminal: { | ||
| 1776 | threadId: THREAD_ID, | ||
| 1777 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 1778 | cwd: "/repo/project", | ||
| 1779 | worktreePath: null, | ||
| 1780 | status: "running", | ||
| 1781 | pid: 123, | ||
| 1782 | exitCode: null, | ||
| 1783 | exitSignal: null, | ||
| 1784 | hasRunningSubprocess: false, | ||
| 1785 | label: "Terminal 1", | ||
| 1786 | updatedAt: isoAt(0), | ||
| 1787 | }, | ||
| 1788 | }, | ||
| 1789 | ]; | ||
| 1790 | }, | ||
| 1770 | }); | 1791 | }); |
| 1771 | 1792 | ||
| 1772 | try { | 1793 | try { |
| @@ -3550,6 +3571,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1777 unmodified lines | |||
| 3550 | exitCode: null, | 3571 | exitCode: null, |
| 3551 | exitSignal: null, | 3572 | exitSignal: null, |
| 3552 | hasRunningSubprocess: true, | 3573 | hasRunningSubprocess: true, |
| 3574 | label: "Terminal 1", | ||
| 3553 | updatedAt: isoAt(1_200), | 3575 | updatedAt: isoAt(1_200), |
| 3554 | }, | 3576 | }, |
| 3555 | }, | 3577 | }, |
| @@ -29,6 +29,7 @@ import { · 28 unmodified lines | |||
| 29 | import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model"; | 29 | import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model"; |
| 30 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 30 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 31 | import { truncate } from "@t3tools/shared/String"; | 31 | import { truncate } from "@t3tools/shared/String"; |
| 32 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; | ||
| 32 | import { Debouncer } from "@tanstack/react-pacer"; | 33 | import { Debouncer } from "@tanstack/react-pacer"; |
| 33 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 34 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 34 | import { useNavigate, useSearch } from "@tanstack/react-router"; | 35 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| @@ -100,7 +101,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" · 65 unmodified lines | |||
| 100 | import PlanSidebar from "./PlanSidebar"; | 101 | import PlanSidebar from "./PlanSidebar"; |
| 101 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 102 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 102 | import { ChevronDownIcon } from "lucide-react"; | 103 | import { ChevronDownIcon } from "lucide-react"; |
| 103 | import { cn, randomUUID } from "~/lib/utils"; | 104 | import { cn } from "~/lib/utils"; |
| 104 | import { toastManager } from "./ui/toast"; | 105 | import { toastManager } from "./ui/toast"; |
| 105 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; | 106 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| 106 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; | 107 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| @@ -133,7 +134,7 @@ import { · 26 unmodified lines | |||
| 133 | type TerminalContextSelection, | 134 | type TerminalContextSelection, |
| 134 | } from "../lib/terminalContext"; | 135 | } from "../lib/terminalContext"; |
| 135 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | 136 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 136 | import { useThreadRunningTerminalIds } from "../terminalSessionState"; | 137 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; |
| 137 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; | 138 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; |
| 138 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; | 139 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; |
| 139 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; | 140 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| @@ -408,6 +409,45 @@ function useLocalDispatchState(input: { · 268 unmodified lines | |||
| 408 | }; | 409 | }; |
| 409 | } | 410 | } |
| 410 | 411 | ||
| 412 | /** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ | ||
| 413 | function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { | ||
| 414 | if (left.length !== right.length) { | ||
| 415 | return false; | ||
| 416 | } | ||
| 417 | if (left.length === 0) { | ||
| 418 | return true; | ||
| 419 | } | ||
| 420 | const sortedLeft = [...left].sort((a, b) => a.localeCompare(b)); | ||
| 421 | const sortedRight = [...right].sort((a, b) => a.localeCompare(b)); | ||
| 422 | for (let index = 0; index < sortedLeft.length; index += 1) { | ||
| 423 | if (sortedLeft[index] !== sortedRight[index]) { | ||
| 424 | return false; | ||
| 425 | } | ||
| 426 | } | ||
| 427 | return true; | ||
| 428 | } | ||
| 429 | |||
| 430 | /** | ||
| 431 | * Server knows about fewer sessions than the client, but every server id still exists locally. | ||
| 432 | * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id | ||
| 433 | * and later re-add it as a separate group (no split layout). | ||
| 434 | */ | ||
| 435 | function serverTerminalIdsStrictSubsetOfClient( | ||
| 436 | serverIds: readonly string[], | ||
| 437 | clientIds: readonly string[], | ||
| 438 | ): boolean { | ||
| 439 | if (serverIds.length >= clientIds.length || clientIds.length === 0) { | ||
| 440 | return false; | ||
| 441 | } | ||
| 442 | const clientSet = new Set(clientIds); | ||
| 443 | for (const id of serverIds) { | ||
| 444 | if (!clientSet.has(id)) { | ||
| 445 | return false; | ||
| 446 | } | ||
| 447 | } | ||
| 448 | return true; | ||
| 449 | } | ||
| 450 | |||
| 411 | interface PersistentThreadTerminalDrawerProps { | 451 | interface PersistentThreadTerminalDrawerProps { |
| 412 | threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; | 452 | threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; |
| 413 | threadId: ThreadId; | 453 | threadId: ThreadId; |
| @@ -444,11 +484,42 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 30 unmodified lines | |||
| 444 | const terminalUiState = useTerminalUiStateStore((state) => | 484 | const terminalUiState = useTerminalUiStateStore((state) => |
| 445 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), | 485 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), |
| 446 | ); | 486 | ); |
| 487 | const knownTerminalSessions = useKnownTerminalSessions({ | ||
| 488 | environmentId: threadRef.environmentId, | ||
| 489 | threadId, | ||
| 490 | }); | ||
| 491 | const terminalLabelsById = useMemo(() => { | ||
| 492 | const next = new Map<string, string>(); | ||
| 493 | for (const session of knownTerminalSessions) { | ||
| 494 | next.set( | ||
| 495 | session.target.terminalId, | ||
| 496 | resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), | ||
| 497 | ); | ||
| 498 | } | ||
| 499 | return next; | ||
| 500 | }, [knownTerminalSessions]); | ||
| 501 | const serverOrderedTerminalIds = useMemo( | ||
| 502 | () => knownTerminalSessions.map((session) => session.target.terminalId), | ||
| 503 | [knownTerminalSessions], | ||
| 504 | ); | ||
| 447 | const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); | 505 | const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); |
| 448 | const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); | 506 | const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); |
| 449 | const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); | 507 | const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); |
| 450 | const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); | 508 | const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); |
| 451 | const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); | 509 | const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); |
| 510 | const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); | ||
| 511 | |||
| 512 | useEffect(() => { | ||
| 513 | if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { | ||
| 514 | return; | ||
| 515 | } | ||
| 516 | if ( | ||
| 517 | serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds) | ||
| 518 | ) { | ||
| 519 | return; | ||
| 520 | } | ||
| 521 | reconcileTerminalIds(threadRef, serverOrderedTerminalIds); | ||
| 522 | }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); | ||
| 452 | const [localFocusRequestId, setLocalFocusRequestId] = useState(0); | 523 | const [localFocusRequestId, setLocalFocusRequestId] = useState(0); |
| 453 | const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; | 524 | const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| 454 | const effectiveWorktreePath = useMemo(() => { | 525 | const effectiveWorktreePath = useMemo(() => { |
| @@ -494,14 +565,68 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 39 unmodified lines | |||
| 494 | ); | 565 | ); |
| 495 | 566 | ||
| 496 | const splitTerminal = useCallback(() => { | 567 | const splitTerminal = useCallback(() => { |
| 497 | storeSplitTerminal(threadRef, `terminal-${randomUUID()}`); | 568 | const api = readEnvironmentApi(threadRef.environmentId); |
| 569 | if (!api || !cwd) { | ||
| 570 | return; | ||
| 571 | } | ||
| 572 | const terminalId = nextTerminalId(serverOrderedTerminalIds); | ||
| 573 | storeSplitTerminal(threadRef, terminalId); | ||
| 498 | bumpFocusRequestId(); | 574 | bumpFocusRequestId(); |
| 499 | }, [bumpFocusRequestId, storeSplitTerminal, threadRef]); | 575 | void (async () => { |
| 576 | try { | ||
| 577 | await api.terminal.open({ | ||
| 578 | threadId, | ||
| 579 | terminalId, | ||
| 580 | cwd, | ||
| 581 | ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), | ||
| 582 | env: runtimeEnv, | ||
| 583 | }); | ||
| 584 | } catch { | ||
| 585 | // Opening failed; the tab is already in the store — user can retry or close it. | ||
| 586 | } | ||
| 587 | })(); | ||
| 588 | }, [ | ||
| 589 | bumpFocusRequestId, | ||
| 590 | cwd, | ||
| 591 | effectiveWorktreePath, | ||
| 592 | runtimeEnv, | ||
| 593 | serverOrderedTerminalIds, | ||
| 594 | storeSplitTerminal, | ||
| 595 | threadId, | ||
| 596 | threadRef, | ||
| 597 | ]); | ||
| 500 | 598 | ||
| 501 | const createNewTerminal = useCallback(() => { | 599 | const createNewTerminal = useCallback(() => { |
| 502 | storeNewTerminal(threadRef, `terminal-${randomUUID()}`); | 600 | const api = readEnvironmentApi(threadRef.environmentId); |
| 601 | if (!api || !cwd) { | ||
| 602 | return; | ||
| 603 | } | ||
| 604 | const terminalId = nextTerminalId(serverOrderedTerminalIds); | ||
| 605 | storeNewTerminal(threadRef, terminalId); | ||
| 503 | bumpFocusRequestId(); | 606 | bumpFocusRequestId(); |
| 504 | }, [bumpFocusRequestId, storeNewTerminal, threadRef]); | 607 | void (async () => { |
| 608 | try { | ||
| 609 | await api.terminal.open({ | ||
| 610 | threadId, | ||
| 611 | terminalId, | ||
| 612 | cwd, | ||
| 613 | ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), | ||
| 614 | env: runtimeEnv, | ||
| 615 | }); | ||
| 616 | } catch { | ||
| 617 | // Opening failed; the tab is already in the store — user can retry or close it. | ||
| 618 | } | ||
| 619 | })(); | ||
| 620 | }, [ | ||
| 621 | bumpFocusRequestId, | ||
| 622 | cwd, | ||
| 623 | effectiveWorktreePath, | ||
| 624 | runtimeEnv, | ||
| 625 | serverOrderedTerminalIds, | ||
| 626 | storeNewTerminal, | ||
| 627 | threadId, | ||
| 628 | threadRef, | ||
| 629 | ]); | ||
| 505 | 630 | ||
| 506 | const activateTerminal = useCallback( | 631 | const activateTerminal = useCallback( |
| 507 | (terminalId: string) => { | 632 | (terminalId: string) => { |
| @@ -537,13 +662,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 29 unmodified lines | |||
| 537 | storeCloseTerminal(threadRef, terminalId); | 662 | storeCloseTerminal(threadRef, terminalId); |
| 538 | bumpFocusRequestId(); | 663 | bumpFocusRequestId(); |
| 539 | }, | 664 | }, |
| 540 | [ | 665 | [bumpFocusRequestId, storeCloseTerminal, terminalUiState.terminalIds, threadId, threadRef], |
| 541 | bumpFocusRequestId, | ||
| 542 | storeCloseTerminal, | ||
| 543 | terminalUiState.terminalIds.length, | ||
| 544 | threadId, | ||
| 545 | threadRef, | ||
| 546 | ], | ||
| 547 | ); | 666 | ); |
| 548 | 667 | ||
| 549 | const handleAddTerminalContext = useCallback( | 668 | const handleAddTerminalContext = useCallback( |
| @@ -570,6 +689,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 20 unmodified lines | |||
| 570 | runtimeEnv={runtimeEnv} | 689 | runtimeEnv={runtimeEnv} |
| 571 | visible={visible} | 690 | visible={visible} |
| 572 | height={terminalUiState.terminalHeight} | 691 | height={terminalUiState.terminalHeight} |
| 692 | // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable. | ||
| 573 | terminalIds={terminalUiState.terminalIds} | 693 | terminalIds={terminalUiState.terminalIds} |
| 574 | activeTerminalId={terminalUiState.activeTerminalId} | 694 | activeTerminalId={terminalUiState.activeTerminalId} |
| 575 | terminalGroups={terminalUiState.terminalGroups} | 695 | terminalGroups={terminalUiState.terminalGroups} |
| @@ -585,6 +705,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 9 unmodified lines | |||
| 585 | onCloseTerminal={closeTerminal} | 705 | onCloseTerminal={closeTerminal} |
| 586 | onHeightChange={setTerminalHeight} | 706 | onHeightChange={setTerminalHeight} |
| 587 | onAddTerminalContext={handleAddTerminalContext} | 707 | onAddTerminalContext={handleAddTerminalContext} |
| 708 | terminalLabelsById={terminalLabelsById} | ||
| 588 | /> | 709 | /> |
| 589 | </div> | 710 | </div> |
| 590 | ); | 711 | ); |
| @@ -797,11 +918,52 @@ export default function ChatView(props: ChatViewProps) { · 206 unmodified lines | |||
| 797 | environmentId: activeThread?.environmentId ?? null, | 918 | environmentId: activeThread?.environmentId ?? null, |
| 798 | threadId: activeThreadId, | 919 | threadId: activeThreadId, |
| 799 | }); | 920 | }); |
| 921 | const activeThreadKnownSessionsRaw = useKnownTerminalSessions({ | ||
| 922 | environmentId: activeThread?.environmentId ?? null, | ||
| 923 | threadId: activeThreadId, | ||
| 924 | }); | ||
| 925 | const activeThreadKnownSessions = useMemo(() => { | ||
| 926 | if (activeThreadId === null) { | ||
| 927 | return []; | ||
| 928 | } | ||
| 929 | return activeThreadKnownSessionsRaw.filter( | ||
| 930 | (session) => session.target.threadId === activeThreadId, | ||
| 931 | ); | ||
| 932 | }, [activeThreadId, activeThreadKnownSessionsRaw]); | ||
| 933 | const activeServerOrderedTerminalIds = useMemo( | ||
| 934 | () => activeThreadKnownSessions.map((session) => session.target.terminalId), | ||
| 935 | [activeThreadKnownSessions], | ||
| 936 | ); | ||
| 937 | const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); | ||
| 800 | const activeThreadRef = useMemo( | 938 | const activeThreadRef = useMemo( |
| 801 | () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), | 939 | () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), |
| 802 | [activeThread], | 940 | [activeThread], |
| 803 | ); | 941 | ); |
| 804 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; | 942 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 943 | |||
| 944 | useEffect(() => { | ||
| 945 | if (!activeThreadRef) { | ||
| 946 | return; | ||
| 947 | } | ||
| 948 | if (terminalIdListsEqual(activeServerOrderedTerminalIds, terminalUiState.terminalIds)) { | ||
| 949 | return; | ||
| 950 | } | ||
| 951 | if ( | ||
| 952 | serverTerminalIdsStrictSubsetOfClient( | ||
| 953 | activeServerOrderedTerminalIds, | ||
| 954 | terminalUiState.terminalIds, | ||
| 955 | ) | ||
| 956 | ) { | ||
| 957 | return; | ||
| 958 | } | ||
| 959 | reconcileTerminalIds(activeThreadRef, activeServerOrderedTerminalIds); | ||
| 960 | }, [ | ||
| 961 | activeThreadRef, | ||
| 962 | activeServerOrderedTerminalIds, | ||
| 963 | reconcileTerminalIds, | ||
| 964 | terminalUiState.terminalIds, | ||
| 965 | ]); | ||
| 966 | |||
| 805 | const existingOpenTerminalThreadKeys = useMemo(() => { | 967 | const existingOpenTerminalThreadKeys = useMemo(() => { |
| 806 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); | 968 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); |
| 807 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); | 969 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); |
| @@ -1581,22 +1743,93 @@ export default function ChatView(props: ChatViewProps) { · 773 unmodified lines | |||
| 1581 | setTerminalOpen(!terminalUiState.terminalOpen); | 1743 | setTerminalOpen(!terminalUiState.terminalOpen); |
| 1582 | }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); | 1744 | }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); |
| 1583 | const splitTerminal = useCallback(() => { | 1745 | const splitTerminal = useCallback(() => { |
| 1584 | if (!activeThreadRef || hasReachedSplitLimit) return; | 1746 | if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { |
| 1585 | const terminalId = `terminal-${randomUUID()}`; | 1747 | return; |
| 1748 | } | ||
| 1749 | const cwdForOpen = gitCwd ?? activeProject.cwd; | ||
| 1750 | if (!cwdForOpen) { | ||
| 1751 | return; | ||
| 1752 | } | ||
| 1753 | const api = readEnvironmentApi(environmentId); | ||
| 1754 | if (!api) { | ||
| 1755 | return; | ||
| 1756 | } | ||
| 1757 | const terminalId = nextTerminalId(activeServerOrderedTerminalIds); | ||
| 1586 | storeSplitTerminal(activeThreadRef, terminalId); | 1758 | storeSplitTerminal(activeThreadRef, terminalId); |
| 1587 | setTerminalFocusRequestId((value) => value + 1); | 1759 | setTerminalFocusRequestId((value) => value + 1); |
| 1588 | }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]); | 1760 | void (async () => { |
| 1761 | try { | ||
| 1762 | await api.terminal.open({ | ||
| 1763 | threadId: activeThreadId, | ||
| 1764 | terminalId, | ||
| 1765 | cwd: cwdForOpen, | ||
| 1766 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | ||
| 1767 | env: projectScriptRuntimeEnv({ | ||
| 1768 | project: { cwd: activeProject.cwd }, | ||
| 1769 | worktreePath: activeThreadWorktreePath, | ||
| 1770 | }), | ||
| 1771 | }); | ||
| 1772 | } catch { | ||
| 1773 | // Opening failed; the tab is already in the store — user can retry or close it. | ||
| 1774 | } | ||
| 1775 | })(); | ||
| 1776 | }, [ | ||
| 1777 | activeProject, | ||
| 1778 | activeServerOrderedTerminalIds, | ||
| 1779 | activeThreadId, | ||
| 1780 | activeThreadRef, | ||
| 1781 | activeThreadWorktreePath, | ||
| 1782 | environmentId, | ||
| 1783 | gitCwd, | ||
| 1784 | hasReachedSplitLimit, | ||
| 1785 | storeSplitTerminal, | ||
| 1786 | ]); | ||
| 1589 | const createNewTerminal = useCallback(() => { | 1787 | const createNewTerminal = useCallback(() => { |
| 1590 | if (!activeThreadRef) return; | 1788 | if (!activeThreadRef || !activeThreadId || !activeProject) { |
| 1591 | const terminalId = `terminal-${randomUUID()}`; | 1789 | return; |
| 1790 | } | ||
| 1791 | const cwdForOpen = gitCwd ?? activeProject.cwd; | ||
| 1792 | if (!cwdForOpen) { | ||
| 1793 | return; | ||
| 1794 | } | ||
| 1795 | const api = readEnvironmentApi(environmentId); | ||
| 1796 | if (!api) { | ||
| 1797 | return; | ||
| 1798 | } | ||
| 1799 | const terminalId = nextTerminalId(activeServerOrderedTerminalIds); | ||
| 1592 | storeNewTerminal(activeThreadRef, terminalId); | 1800 | storeNewTerminal(activeThreadRef, terminalId); |
| 1593 | setTerminalFocusRequestId((value) => value + 1); | 1801 | setTerminalFocusRequestId((value) => value + 1); |
| 1594 | }, [activeThreadRef, storeNewTerminal]); | 1802 | void (async () => { |
| 1803 | try { | ||
| 1804 | await api.terminal.open({ | ||
| 1805 | threadId: activeThreadId, | ||
| 1806 | terminalId, | ||
| 1807 | cwd: cwdForOpen, | ||
| 1808 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | ||
| 1809 | env: projectScriptRuntimeEnv({ | ||
| 1810 | project: { cwd: activeProject.cwd }, | ||
| 1811 | worktreePath: activeThreadWorktreePath, | ||
| 1812 | }), | ||
| 1813 | }); | ||
| 1814 | } catch { | ||
| 1815 | // Opening failed; the tab is already in the store — user can retry or close it. | ||
| 1816 | } | ||
| 1817 | })(); | ||
| 1818 | }, [ | ||
| 1819 | activeProject, | ||
| 1820 | activeServerOrderedTerminalIds, | ||
| 1821 | activeThreadId, | ||
| 1822 | activeThreadRef, | ||
| 1823 | activeThreadWorktreePath, | ||
| 1824 | environmentId, | ||
| 1825 | gitCwd, | ||
| 1826 | storeNewTerminal, | ||
| 1827 | ]); | ||
| 1595 | const closeTerminal = useCallback( | 1828 | const closeTerminal = useCallback( |
| 1596 | (terminalId: string) => { | 1829 | (terminalId: string) => { |
| 1597 | const api = readEnvironmentApi(environmentId); | 1830 | const api = readEnvironmentApi(environmentId); |
| 1598 | if (!activeThreadId || !api) return; | 1831 | if (!activeThreadId || !api || !activeThreadRef) return; |
| 1599 | const isFinalTerminal = terminalUiState.terminalIds.length <= 1; | 1832 | const isFinalTerminal = activeServerOrderedTerminalIds.length <= 1; |
| 1600 | const fallbackExitWrite = () => | 1833 | const fallbackExitWrite = () => |
| 1601 | api.terminal | 1834 | api.terminal |
| 1602 | .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) | 1835 | .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) |
| @@ -1617,17 +1850,15 @@ export default function ChatView(props: ChatViewProps) { · 14 unmodified lines | |||
| 1617 | } else { | 1850 | } else { |
| 1618 | void fallbackExitWrite(); | 1851 | void fallbackExitWrite(); |
| 1619 | } | 1852 | } |
| 1620 | if (activeThreadRef) { | 1853 | storeCloseTerminal(activeThreadRef, terminalId); |
| 1621 | storeCloseTerminal(activeThreadRef, terminalId); | ||
| 1622 | } | ||
| 1623 | setTerminalFocusRequestId((value) => value + 1); | 1854 | setTerminalFocusRequestId((value) => value + 1); |
| 1624 | }, | 1855 | }, |
| 1625 | [ | 1856 | [ |
| 1626 | activeThreadId, | 1857 | activeThreadId, |
| 1627 | activeThreadRef, | 1858 | activeThreadRef, |
| 1859 | activeServerOrderedTerminalIds, | ||
| 1628 | environmentId, | 1860 | environmentId, |
| 1629 | storeCloseTerminal, | 1861 | storeCloseTerminal, |
| 1630 | terminalUiState.terminalIds.length, | ||
| 1631 | ], | 1862 | ], |
| 1632 | ); | 1863 | ); |
| 1633 | const runProjectScript = useCallback( | 1864 | const runProjectScript = useCallback( |
| @@ -1652,14 +1883,11 @@ export default function ChatView(props: ChatViewProps) { · 18 unmodified lines | |||
| 1652 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; | 1883 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; |
| 1653 | const baseTerminalId = | 1884 | const baseTerminalId = |
| 1654 | terminalUiState.activeTerminalId || | 1885 | terminalUiState.activeTerminalId || |
| 1655 | terminalUiState.terminalIds[0] || | 1886 | activeServerOrderedTerminalIds[0] || |
| 1656 | DEFAULT_THREAD_TERMINAL_ID; | 1887 | DEFAULT_THREAD_TERMINAL_ID; |
| 1657 | const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); | 1888 | const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); |
| 1658 | const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; | 1889 | const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; |
| 1659 | const shouldCreateNewTerminal = wantsNewTerminal; | 1890 | const shouldCreateNewTerminal = wantsNewTerminal; |
| 1660 | const targetTerminalId = shouldCreateNewTerminal | ||
| 1661 | ? `terminal-${randomUUID()}` | ||
| 1662 | : baseTerminalId; | ||
| 1663 | const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; | 1891 | const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; |
| 1664 | 1892 | ||
| 1665 | setTerminalUiLaunchContext({ | 1893 | setTerminalUiLaunchContext({ |
| @@ -1671,11 +1899,6 @@ export default function ChatView(props: ChatViewProps) { · 5 unmodified lines | |||
| 1671 | if (!activeThreadRef) { | 1899 | if (!activeThreadRef) { |
| 1672 | return; | 1900 | return; |
| 1673 | } | 1901 | } |
| 1674 | if (shouldCreateNewTerminal) { | ||
| 1675 | storeNewTerminal(activeThreadRef, targetTerminalId); | ||
| 1676 | } else { | ||
| 1677 | storeSetActiveTerminal(activeThreadRef, targetTerminalId); | ||
| 1678 | } | ||
| 1679 | setTerminalFocusRequestId((value) => value + 1); | 1902 | setTerminalFocusRequestId((value) => value + 1); |
| 1680 | 1903 | ||
| 1681 | const runtimeEnv = projectScriptRuntimeEnv({ | 1904 | const runtimeEnv = projectScriptRuntimeEnv({ |
| @@ -1685,6 +1908,9 @@ export default function ChatView(props: ChatViewProps) { · 3 unmodified lines | |||
| 1685 | worktreePath: targetWorktreePath, | 1908 | worktreePath: targetWorktreePath, |
| 1686 | ...(options?.env ? { extraEnv: options.env } : {}), | 1909 | ...(options?.env ? { extraEnv: options.env } : {}), |
| 1687 | }); | 1910 | }); |
| 1911 | const targetTerminalId = shouldCreateNewTerminal | ||
| 1912 | ? nextTerminalId(activeServerOrderedTerminalIds) | ||
| 1913 | : baseTerminalId; | ||
| 1688 | const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal | 1914 | const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal |
| 1689 | ? { | 1915 | ? { |
| 1690 | threadId: activeThreadId, | 1916 | threadId: activeThreadId, |
| @@ -1703,6 +1929,12 @@ export default function ChatView(props: ChatViewProps) { · 12 unmodified lines | |||
| 1703 | env: runtimeEnv, | 1929 | env: runtimeEnv, |
| 1704 | }; | 1930 | }; |
| 1705 | 1931 | ||
| 1932 | if (shouldCreateNewTerminal) { | ||
| 1933 | storeNewTerminal(activeThreadRef, targetTerminalId); | ||
| 1934 | } else { | ||
| 1935 | storeSetActiveTerminal(activeThreadRef, targetTerminalId); | ||
| 1936 | } | ||
| 1937 | |||
| 1706 | try { | 1938 | try { |
| 1707 | await api.terminal.open(openTerminalInput); | 1939 | await api.terminal.open(openTerminalInput); |
| 1708 | await api.terminal.write({ | 1940 | await api.terminal.write({ |
| @@ -1729,9 +1961,9 @@ export default function ChatView(props: ChatViewProps) { · 20 unmodified lines | |||
| 1729 | storeSetActiveTerminal, | 1961 | storeSetActiveTerminal, |
| 1730 | setLastInvokedScriptByProjectId, | 1962 | setLastInvokedScriptByProjectId, |
| 1731 | environmentId, | 1963 | environmentId, |
| 1964 | activeServerOrderedTerminalIds, | ||
| 1732 | runningTerminalIds, | 1965 | runningTerminalIds, |
| 1733 | terminalUiState.activeTerminalId, | 1966 | terminalUiState.activeTerminalId, |
| 1734 | terminalUiState.terminalIds, | ||
| 1735 | ], | 1967 | ], |
| 1736 | ); | 1968 | ); |
| 1737 | 1969 | ||
| @@ -128,7 +128,7 @@ function createEnvironmentApi() { · 127 unmodified lines | |||
| 128 | terminal: { | 128 | terminal: { |
| 129 | open: vi.fn(async () => ({ | 129 | open: vi.fn(async () => ({ |
| 130 | threadId: THREAD_ID, | 130 | threadId: THREAD_ID, |
| 131 | terminalId: "default", | 131 | terminalId: "term-1", |
| 132 | cwd: "/repo/project", | 132 | cwd: "/repo/project", |
| 133 | worktreePath: null, | 133 | worktreePath: null, |
| 134 | status: "running" as const, | 134 | status: "running" as const, |
| @@ -136,6 +136,7 @@ function createEnvironmentApi() { · 1 unmodified line | |||
| 136 | history: "", | 136 | history: "", |
| 137 | exitCode: null, | 137 | exitCode: null, |
| 138 | exitSignal: null, | 138 | exitSignal: null, |
| 139 | label: "Terminal 1", | ||
| 139 | updatedAt: "2026-04-07T00:00:00.000Z", | 140 | updatedAt: "2026-04-07T00:00:00.000Z", |
| 140 | })), | 141 | })), |
| 141 | write: vi.fn(async () => undefined), | 142 | write: vi.fn(async () => undefined), |
| @@ -168,7 +169,7 @@ async function mountTerminalViewport(props: { · 26 unmodified lines | |||
| 168 | <TerminalViewport | 169 | <TerminalViewport |
| 169 | threadRef={props.threadRef} | 170 | threadRef={props.threadRef} |
| 170 | threadId={THREAD_ID} | 171 | threadId={THREAD_ID} |
| 171 | terminalId="default" | 172 | terminalId="term-1" |
| 172 | terminalLabel="Terminal" | 173 | terminalLabel="Terminal" |
| 173 | cwd="/repo/project" | 174 | cwd="/repo/project" |
| 174 | onSessionExited={() => undefined} | 175 | onSessionExited={() => undefined} |
| @@ -188,7 +189,7 @@ async function mountTerminalViewport(props: { · 13 unmodified lines | |||
| 188 | <TerminalViewport | 189 | <TerminalViewport |
| 189 | threadRef={nextProps.threadRef} | 190 | threadRef={nextProps.threadRef} |
| 190 | threadId={THREAD_ID} | 191 | threadId={THREAD_ID} |
| 191 | terminalId="default" | 192 | terminalId="term-1" |
| 192 | terminalLabel="Terminal" | 193 | terminalLabel="Terminal" |
| 193 | cwd="/repo/project" | 194 | cwd="/repo/project" |
| 194 | onSessionExited={() => undefined} | 195 | onSessionExited={() => undefined} |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | type TerminalSessionSnapshot, | 7 | type TerminalSessionSnapshot, |
| 8 | type ThreadId, | 8 | type ThreadId, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | ||
| 10 | import { Terminal, type ITheme } from "@xterm/xterm"; | 11 | import { Terminal, type ITheme } from "@xterm/xterm"; |
| 11 | import { | 12 | import { |
| 12 | type PointerEvent as ReactPointerEvent, | 13 | type PointerEvent as ReactPointerEvent, |
| @@ -41,7 +42,6 @@ import { · 28 unmodified lines | |||
| 41 | } from "../keybindings"; | 42 | } from "../keybindings"; |
| 42 | import { | 43 | import { |
| 43 | DEFAULT_THREAD_TERMINAL_HEIGHT, | 44 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 44 | DEFAULT_THREAD_TERMINAL_ID, | ||
| 45 | MAX_TERMINALS_PER_GROUP, | 45 | MAX_TERMINALS_PER_GROUP, |
| 46 | type ThreadTerminalGroup, | 46 | type ThreadTerminalGroup, |
| 47 | } from "../types"; | 47 | } from "../types"; |
| @@ -763,6 +763,8 @@ interface ThreadTerminalDrawerProps { · 715 unmodified lines | |||
| 763 | onHeightChange: (height: number) => void; | 763 | onHeightChange: (height: number) => void; |
| 764 | onAddTerminalContext: (selection: TerminalContextSelection) => void; | 764 | onAddTerminalContext: (selection: TerminalContextSelection) => void; |
| 765 | keybindings: ResolvedKeybindingsConfig; | 765 | keybindings: ResolvedKeybindingsConfig; |
| 766 | /** Prefer server-provided tab titles when present (e.g. active subprocess name). */ | ||
| 767 | terminalLabelsById?: ReadonlyMap<string, string>; | ||
| 766 | } | 768 | } |
| 767 | 769 | ||
| 768 | interface TerminalActionButtonProps { | 770 | interface TerminalActionButtonProps { |
| @@ -817,6 +819,7 @@ export default function ThreadTerminalDrawer({ · 48 unmodified lines | |||
| 817 | onHeightChange, | 819 | onHeightChange, |
| 818 | onAddTerminalContext, | 820 | onAddTerminalContext, |
| 819 | keybindings, | 821 | keybindings, |
| 822 | terminalLabelsById, | ||
| 820 | }: ThreadTerminalDrawerProps) { | 823 | }: ThreadTerminalDrawerProps) { |
| 821 | const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); | 824 | const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); |
| 822 | const [resizeEpoch, setResizeEpoch] = useState(0); | 825 | const [resizeEpoch, setResizeEpoch] = useState(0); |
| @@ -831,15 +834,20 @@ export default function ThreadTerminalDrawer({ · 8 unmodified lines | |||
| 831 | const didResizeDuringDragRef = useRef(false); | 834 | const didResizeDuringDragRef = useRef(false); |
| 832 | 835 | ||
| 833 | const normalizedTerminalIds = useMemo(() => { | 836 | const normalizedTerminalIds = useMemo(() => { |
| 834 | const cleaned = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | 837 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; |
| 835 | return cleaned.length > 0 ? cleaned : [DEFAULT_THREAD_TERMINAL_ID]; | ||
| 836 | }, [terminalIds]); | 838 | }, [terminalIds]); |
| 837 | 839 | ||
| 838 | const resolvedActiveTerminalId = normalizedTerminalIds.includes(activeTerminalId) | 840 | const resolvedActiveTerminalId = |
| 839 | ? activeTerminalId | 841 | normalizedTerminalIds.length === 0 |
| 840 | : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | 842 | ? "" |
| 843 | : normalizedTerminalIds.includes(activeTerminalId) | ||
| 844 | ? activeTerminalId | ||
| 845 | : (normalizedTerminalIds[0] ?? ""); | ||
| 841 | 846 | ||
| 842 | const resolvedTerminalGroups = useMemo(() => { | 847 | const resolvedTerminalGroups = useMemo(() => { |
| 848 | if (normalizedTerminalIds.length === 0) { | ||
| 849 | return []; | ||
| 850 | } | ||
| 843 | const validTerminalIdSet = new Set(normalizedTerminalIds); | 851 | const validTerminalIdSet = new Set(normalizedTerminalIds); |
| 844 | const assignedTerminalIds = new Set<string>(); | 852 | const assignedTerminalIds = new Set<string>(); |
| 845 | const usedGroupIds = new Set<string>(); | 853 | const usedGroupIds = new Set<string>(); |
| @@ -876,7 +884,7 @@ export default function ThreadTerminalDrawer({ · 30 unmodified lines | |||
| 876 | const baseGroupId = | 884 | const baseGroupId = |
| 877 | terminalGroup.id.trim().length > 0 | 885 | terminalGroup.id.trim().length > 0 |
| 878 | ? terminalGroup.id.trim() | 886 | ? terminalGroup.id.trim() |
| 879 | : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`; | 887 | : `group-${nextTerminalIds[0] ?? normalizedTerminalIds[0] ?? ""}`; |
| 880 | nextGroups.push({ | 888 | nextGroups.push({ |
| 881 | id: assignUniqueGroupId(baseGroupId), | 889 | id: assignUniqueGroupId(baseGroupId), |
| 882 | terminalIds: nextTerminalIds, | 890 | terminalIds: nextTerminalIds, |
| @@ -891,17 +899,17 @@ export default function ThreadTerminalDrawer({ · 8 unmodified lines | |||
| 891 | }); | 899 | }); |
| 892 | } | 900 | } |
| 893 | 901 | ||
| 894 | if (nextGroups.length > 0) { | 902 | const terminalOrderIndex = new Map( |
| 895 | return nextGroups; | 903 | normalizedTerminalIds.map((id, index) => [id, index] as const), |
| 896 | } | 904 | ); |
| 905 | nextGroups.sort((left, right) => { | ||
| 906 | const rank = (ids: readonly string[]) => | ||
| 907 | Math.min(...ids.map((id) => terminalOrderIndex.get(id) ?? Number.POSITIVE_INFINITY)); | ||
| 908 | return rank(left.terminalIds) - rank(right.terminalIds); | ||
| 909 | }); | ||
| 897 | 910 | ||
| 898 | return [ | 911 | return nextGroups; |
| 899 | { | 912 | }, [normalizedTerminalIds, terminalGroups]); |
| 900 | id: `group-${resolvedActiveTerminalId}`, | ||
| 901 | terminalIds: [resolvedActiveTerminalId], | ||
| 902 | }, | ||
| 903 | ]; | ||
| 904 | }, [normalizedTerminalIds, resolvedActiveTerminalId, terminalGroups]); | ||
| 905 | 913 | ||
| 906 | const resolvedActiveGroupIndex = useMemo(() => { | 914 | const resolvedActiveGroupIndex = useMemo(() => { |
| 907 | const indexById = resolvedTerminalGroups.findIndex( | 915 | const indexById = resolvedTerminalGroups.findIndex( |
| @@ -914,22 +922,22 @@ export default function ThreadTerminalDrawer({ · 6 unmodified lines | |||
| 914 | return indexByTerminal >= 0 ? indexByTerminal : 0; | 922 | return indexByTerminal >= 0 ? indexByTerminal : 0; |
| 915 | }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]); | 923 | }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]); |
| 916 | 924 | ||
| 917 | const visibleTerminalIds = resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? [ | 925 | const visibleTerminalIds = |
| 918 | resolvedActiveTerminalId, | 926 | resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? |
| 919 | ]; | 927 | (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); |
| 920 | const hasTerminalSidebar = normalizedTerminalIds.length > 1; | 928 | const hasTerminalSidebar = normalizedTerminalIds.length > 1; |
| 921 | const isSplitView = visibleTerminalIds.length > 1; | 929 | const isSplitView = visibleTerminalIds.length > 1; |
| 922 | const showGroupHeaders = | 930 | const showGroupHeaders = |
| 923 | resolvedTerminalGroups.length > 1 || | 931 | resolvedTerminalGroups.length > 1 || |
| 924 | resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); | 932 | resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); |
| 925 | const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; | 933 | const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; |
| 926 | const terminalLabelById = useMemo( | 934 | const terminalLabelById = useMemo(() => { |
| 927 | () => | 935 | const next = new Map<string, string>(); |
| 928 | new Map( | 936 | for (const terminalId of normalizedTerminalIds) { |
| 929 | normalizedTerminalIds.map((terminalId, index) => [terminalId, `Terminal ${index + 1}`]), | 937 | next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId)); |
| 930 | ), | 938 | } |
| 931 | [normalizedTerminalIds], | 939 | return next; |
| 932 | ); | 940 | }, [normalizedTerminalIds, terminalLabelsById]); |
| 933 | const splitTerminalActionLabel = hasReachedSplitLimit | 941 | const splitTerminalActionLabel = hasReachedSplitLimit |
| 934 | ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` | 942 | ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` |
| 935 | : splitShortcutLabel | 943 | : splitShortcutLabel |
| @@ -1051,6 +1059,33 @@ export default function ThreadTerminalDrawer({ · 115 unmodified lines | |||
| 1051 | }; | 1059 | }; |
| 1052 | }, [syncHeight]); | 1060 | }, [syncHeight]); |
| 1053 | 1061 | ||
| 1062 | if (normalizedTerminalIds.length === 0) { | ||
| 1063 | return ( | ||
| 1064 | <aside | ||
| 1065 | className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background" | ||
| 1066 | style={{ height: `${drawerHeight}px` }} | ||
| 1067 | > | ||
| 1068 | <div | ||
| 1069 | className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize" | ||
| 1070 | onPointerDown={handleResizePointerDown} | ||
| 1071 | onPointerMove={handleResizePointerMove} | ||
| 1072 | onPointerUp={handleResizePointerEnd} | ||
| 1073 | onPointerCancel={handleResizePointerEnd} | ||
| 1074 | /> | ||
| 1075 | <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground"> | ||
| 1076 | <p>No terminal sessions for this thread yet.</p> | ||
| 1077 | <button | ||
| 1078 | type="button" | ||
| 1079 | className="rounded-md border border-border/80 bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent" | ||
| 1080 | onClick={onNewTerminalAction} | ||
| 1081 | > | ||
| 1082 | {newTerminalActionLabel} | ||
| 1083 | </button> | ||
| 1084 | </div> | ||
| 1085 | </aside> | ||
| 1086 | ); | ||
| 1087 | } | ||
| 1088 | |||
| 1054 | return ( | 1089 | return ( |
| 1055 | <aside | 1090 | <aside |
| 1056 | className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background" | 1091 | className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background" |
| @@ -1216,9 +1251,7 @@ export default function ThreadTerminalDrawer({ · 159 unmodified lines | |||
| 1216 | }`} | 1251 | }`} |
| 1217 | onClick={() => onActiveTerminalChange(groupActiveTerminalId)} | 1252 | onClick={() => onActiveTerminalChange(groupActiveTerminalId)} |
| 1218 | > | 1253 | > |
| 1219 | {terminalGroup.terminalIds.length > 1 | 1254 | Group {groupIndex + 1} |
| 1220 | ? `Split ${groupIndex + 1}` | ||
| 1221 | : `Terminal ${groupIndex + 1}`} | ||
| 1222 | </button> | 1255 | </button> |
| 1223 | )} | 1256 | )} |
| 1224 | 1257 | ||
| @@ -1,4 +1,5 @@ | |||
| 1 | import { QueryClient } from "@tanstack/react-query"; | 1 | import { QueryClient } from "@tanstack/react-query"; |
| 2 | import type { WsRpcClient } from "@t3tools/client-runtime"; | ||
| 2 | import { | 3 | import { |
| 3 | EnvironmentId, | 4 | EnvironmentId, |
| 4 | ProjectId, | 5 | ProjectId, |
| @@ -67,6 +68,70 @@ vi.mock("../../rpc/wsRpcClient", () => ({ · 62 unmodified lines | |||
| 67 | createWsRpcClient: mockCreateWsRpcClient, | 68 | createWsRpcClient: mockCreateWsRpcClient, |
| 68 | })); | 69 | })); |
| 69 | 70 | ||
| 71 | vi.mock("@t3tools/client-runtime", async (importOriginal) => { | ||
| 72 | const actual = await importOriginal<typeof import("@t3tools/client-runtime")>(); | ||
| 73 | const stubWsClient: WsRpcClient = { | ||
| 74 | dispose: async () => undefined, | ||
| 75 | reconnect: async () => undefined, | ||
| 76 | orchestration: { | ||
| 77 | dispatchCommand: vi.fn(), | ||
| 78 | getTurnDiff: vi.fn(), | ||
| 79 | getFullThreadDiff: vi.fn(), | ||
| 80 | subscribeShell: vi.fn(() => () => undefined), | ||
| 81 | subscribeThread: mockSubscribeThread, | ||
| 82 | }, | ||
| 83 | terminal: { | ||
| 84 | open: vi.fn(), | ||
| 85 | attach: vi.fn(() => () => undefined), | ||
| 86 | write: vi.fn(), | ||
| 87 | resize: vi.fn(), | ||
| 88 | clear: vi.fn(), | ||
| 89 | restart: vi.fn(), | ||
| 90 | close: vi.fn(), | ||
| 91 | onMetadata: vi.fn(() => () => undefined), | ||
| 92 | }, | ||
| 93 | projects: { | ||
| 94 | searchEntries: vi.fn(), | ||
| 95 | writeFile: vi.fn(), | ||
| 96 | }, | ||
| 97 | filesystem: { | ||
| 98 | browse: vi.fn(), | ||
| 99 | }, | ||
| 100 | shell: { | ||
| 101 | openInEditor: vi.fn(), | ||
| 102 | }, | ||
| 103 | git: { | ||
| 104 | pull: vi.fn(), | ||
| 105 | refreshStatus: vi.fn(), | ||
| 106 | onStatus: vi.fn(() => () => undefined), | ||
| 107 | runStackedAction: vi.fn(), | ||
| 108 | listBranches: vi.fn(), | ||
| 109 | createWorktree: vi.fn(), | ||
| 110 | removeWorktree: vi.fn(), | ||
| 111 | createBranch: vi.fn(), | ||
| 112 | checkout: vi.fn(), | ||
| 113 | init: vi.fn(), | ||
| 114 | resolvePullRequest: vi.fn(), | ||
| 115 | preparePullRequestThread: vi.fn(), | ||
| 116 | getReviewDiffs: vi.fn(), | ||
| 117 | }, | ||
| 118 | server: { | ||
| 119 | getConfig: vi.fn(), | ||
| 120 | refreshProviders: vi.fn(), | ||
| 121 | upsertKeybinding: vi.fn(), | ||
| 122 | getSettings: vi.fn(), | ||
| 123 | updateSettings: vi.fn(), | ||
| 124 | subscribeConfig: vi.fn(() => () => undefined), | ||
| 125 | subscribeLifecycle: vi.fn(() => () => undefined), | ||
| 126 | subscribeAuthAccess: vi.fn(() => () => undefined), | ||
| 127 | }, | ||
| 128 | }; | ||
| 129 | return { | ||
| 130 | ...actual, | ||
| 131 | createWsRpcClient: vi.fn(() => stubWsClient), | ||
| 132 | }; | ||
| 133 | }); | ||
| 134 | |||
| 70 | vi.mock("../../rpc/wsTransport", () => ({ | 135 | vi.mock("../../rpc/wsTransport", () => ({ |
| 71 | WsTransport: MockWsTransport, | 136 | WsTransport: MockWsTransport, |
| 72 | })); | 137 | })); |
| @@ -11,7 +11,13 @@ class MockHTMLElement { · 10 unmodified lines | |||
| 11 | }; | 11 | }; |
| 12 | 12 | ||
| 13 | closest(selector: string): MockHTMLElement | null { | 13 | closest(selector: string): MockHTMLElement | null { |
| 14 | return selector === ".thread-terminal-drawer .xterm" && this.isConnected ? this : null; | 14 | if (!this.isConnected) { |
| 15 | return null; | ||
| 16 | } | ||
| 17 | if (selector === ".thread-terminal-drawer .xterm" || selector === ".thread-terminal-drawer") { | ||
| 18 | return this; | ||
| 19 | } | ||
| 20 | return null; | ||
| 15 | } | 21 | } |
| 16 | } | 22 | } |
| 17 | 23 | ||
| @@ -53,4 +59,15 @@ describe("isTerminalFocused", () => { · 35 unmodified lines | |||
| 53 | 59 | ||
| 54 | expect(isTerminalFocused()).toBe(true); | 60 | expect(isTerminalFocused()).toBe(true); |
| 55 | }); | 61 | }); |
| 62 | |||
| 63 | it("returns true for focus inside the terminal drawer (e.g. sidebar)", () => { | ||
| 64 | const sidebarButton = new MockHTMLElement(); | ||
| 65 | sidebarButton.className = "terminal-sidebar-button"; | ||
| 66 | sidebarButton.isConnected = true; | ||
| 67 | |||
| 68 | globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; | ||
| 69 | globalThis.document = { activeElement: sidebarButton } as Document; | ||
| 70 | |||
| 71 | expect(isTerminalFocused()).toBe(true); | ||
| 72 | }); | ||
| 56 | }); | 73 | }); |
| @@ -3,5 +3,7 @@ export function isTerminalFocused(): boolean { · 2 unmodified lines | |||
| 3 | if (!(activeElement instanceof HTMLElement)) return false; | 3 | if (!(activeElement instanceof HTMLElement)) return false; |
| 4 | if (!activeElement.isConnected) return false; | 4 | if (!activeElement.isConnected) return false; |
| 5 | if (activeElement.classList.contains("xterm-helper-textarea")) return true; | 5 | if (activeElement.classList.contains("xterm-helper-textarea")) return true; |
| 6 | return activeElement.closest(".thread-terminal-drawer .xterm") !== null; | 6 | if (activeElement.closest(".thread-terminal-drawer .xterm") !== null) return true; |
| 7 | // Sidebar / toolbar / resize affordances: still "terminal UI" for split vs diff.toggle (⌘D). | ||
| 8 | return activeElement.closest(".thread-terminal-drawer") !== null; | ||
| 7 | } | 9 | } |
| @@ -341,6 +341,7 @@ describe("wsApi", () => { · 340 unmodified lines | |||
| 341 | exitCode: null, | 341 | exitCode: null, |
| 342 | exitSignal: null, | 342 | exitSignal: null, |
| 343 | hasRunningSubprocess: true, | 343 | hasRunningSubprocess: true, |
| 344 | label: "terminal-1", | ||
| 344 | updatedAt: "2026-02-24T00:00:00.000Z", | 345 | updatedAt: "2026-02-24T00:00:00.000Z", |
| 345 | }, | 346 | }, |
| 346 | } satisfies TerminalMetadataStreamEvent; | 347 | } satisfies TerminalMetadataStreamEvent; |
| @@ -20,7 +20,7 @@ describe("terminalUiStateStore actions", () => { · 19 unmodified lines | |||
| 20 | }); | 20 | }); |
| 21 | }); | 21 | }); |
| 22 | 22 | ||
| 23 | it("returns a closed default terminal UI state for unknown threads", () => { | 23 | it("returns an empty default terminal UI state for unknown threads", () => { |
| 24 | const terminalUiState = selectThreadTerminalUiState( | 24 | const terminalUiState = selectThreadTerminalUiState( |
| 25 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | 25 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, |
| 26 | THREAD_REF, | 26 | THREAD_REF, |
| @@ -28,10 +28,10 @@ describe("terminalUiStateStore actions", () => { · 1 unmodified line | |||
| 28 | expect(terminalUiState).toEqual({ | 28 | expect(terminalUiState).toEqual({ |
| 29 | terminalOpen: false, | 29 | terminalOpen: false, |
| 30 | terminalHeight: 280, | 30 | terminalHeight: 280, |
| 31 | terminalIds: ["default"], | 31 | terminalIds: [], |
| 32 | activeTerminalId: "default", | 32 | activeTerminalId: "", |
| 33 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | 33 | terminalGroups: [], |
| 34 | activeTerminalGroupId: "group-default", | 34 | activeTerminalGroupId: "", |
| 35 | }); | 35 | }); |
| 36 | }); | 36 | }); |
| 37 | 37 | ||
| @@ -45,10 +45,10 @@ describe("terminalUiStateStore actions", () => { · 7 unmodified lines | |||
| 45 | THREAD_REF, | 45 | THREAD_REF, |
| 46 | ); | 46 | ); |
| 47 | expect(terminalUiState.terminalOpen).toBe(true); | 47 | expect(terminalUiState.terminalOpen).toBe(true); |
| 48 | expect(terminalUiState.terminalIds).toEqual(["default", "terminal-2"]); | 48 | expect(terminalUiState.terminalIds).toEqual(["terminal-2"]); |
| 49 | expect(terminalUiState.activeTerminalId).toBe("terminal-2"); | 49 | expect(terminalUiState.activeTerminalId).toBe("terminal-2"); |
| 50 | expect(terminalUiState.terminalGroups).toEqual([ | 50 | expect(terminalUiState.terminalGroups).toEqual([ |
| 51 | { id: "group-default", terminalIds: ["default", "terminal-2"] }, | 51 | { id: "group-terminal-2", terminalIds: ["terminal-2"] }, |
| 52 | ]); | 52 | ]); |
| 53 | }); | 53 | }); |
| 54 | 54 | ||
| @@ -58,19 +58,23 @@ describe("terminalUiStateStore actions", () => { · 3 unmodified lines | |||
| 58 | store.splitTerminal(THREAD_REF, "terminal-3"); | 58 | store.splitTerminal(THREAD_REF, "terminal-3"); |
| 59 | store.splitTerminal(THREAD_REF, "terminal-4"); | 59 | store.splitTerminal(THREAD_REF, "terminal-4"); |
| 60 | store.splitTerminal(THREAD_REF, "terminal-5"); | 60 | store.splitTerminal(THREAD_REF, "terminal-5"); |
| 61 | store.splitTerminal(THREAD_REF, "terminal-6"); | ||
| 61 | 62 | ||
| 62 | const terminalUiState = selectThreadTerminalUiState( | 63 | const terminalUiState = selectThreadTerminalUiState( |
| 63 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | 64 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, |
| 64 | THREAD_REF, | 65 | THREAD_REF, |
| 65 | ); | 66 | ); |
| 66 | expect(terminalUiState.terminalIds).toEqual([ | 67 | expect(terminalUiState.terminalIds).toEqual([ |
| 67 | "default", | ||
| 68 | "terminal-2", | 68 | "terminal-2", |
| 69 | "terminal-3", | 69 | "terminal-3", |
| 70 | "terminal-4", | 70 | "terminal-4", |
| 71 | "terminal-5", | ||
| 71 | ]); | 72 | ]); |
| 72 | expect(terminalUiState.terminalGroups).toEqual([ | 73 | expect(terminalUiState.terminalGroups).toEqual([ |
| 73 | { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] }, | 74 | { |
| 75 | id: "group-terminal-2", | ||
| 76 | terminalIds: ["terminal-2", "terminal-3", "terminal-4", "terminal-5"], | ||
| 77 | }, | ||
| 74 | ]); | 78 | ]); |
| 75 | }); | 79 | }); |
| 76 | 80 | ||
| @@ -81,11 +85,10 @@ describe("terminalUiStateStore actions", () => { · 4 unmodified lines | |||
| 81 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | 85 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, |
| 82 | THREAD_REF, | 86 | THREAD_REF, |
| 83 | ); | 87 | ); |
| 84 | expect(terminalUiState.terminalIds).toEqual(["default", "terminal-2"]); | 88 | expect(terminalUiState.terminalIds).toEqual(["terminal-2"]); |
| 85 | expect(terminalUiState.activeTerminalId).toBe("terminal-2"); | 89 | expect(terminalUiState.activeTerminalId).toBe("terminal-2"); |
| 86 | expect(terminalUiState.activeTerminalGroupId).toBe("group-terminal-2"); | 90 | expect(terminalUiState.activeTerminalGroupId).toBe("group-terminal-2"); |
| 87 | expect(terminalUiState.terminalGroups).toEqual([ | 91 | expect(terminalUiState.terminalGroups).toEqual([ |
| 88 | { id: "group-default", terminalIds: ["default"] }, | ||
| 89 | { id: "group-terminal-2", terminalIds: ["terminal-2"] }, | 92 | { id: "group-terminal-2", terminalIds: ["terminal-2"] }, |
| 90 | ]); | 93 | ]); |
| 91 | }); | 94 | }); |
| @@ -99,10 +102,9 @@ describe("terminalUiStateStore actions", () => { · 7 unmodified lines | |||
| 99 | THREAD_REF, | 102 | THREAD_REF, |
| 100 | ); | 103 | ); |
| 101 | expect(terminalUiState.terminalOpen).toBe(true); | 104 | expect(terminalUiState.terminalOpen).toBe(true); |
| 102 | expect(terminalUiState.terminalIds).toEqual(["default", "setup-setup"]); | 105 | expect(terminalUiState.terminalIds).toEqual(["setup-setup"]); |
| 103 | expect(terminalUiState.activeTerminalId).toBe("setup-setup"); | 106 | expect(terminalUiState.activeTerminalId).toBe("setup-setup"); |
| 104 | expect(terminalUiState.terminalGroups).toEqual([ | 107 | expect(terminalUiState.terminalGroups).toEqual([ |
| 105 | { id: "group-default", terminalIds: ["default"] }, | ||
| 106 | { id: "group-setup-setup", terminalIds: ["setup-setup"] }, | 108 | { id: "group-setup-setup", terminalIds: ["setup-setup"] }, |
| 107 | ]); | 109 | ]); |
| 108 | }); | 110 | }); |
| @@ -123,28 +125,28 @@ describe("terminalUiStateStore actions", () => { · 14 unmodified lines | |||
| 123 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | 125 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, |
| 124 | OTHER_THREAD_REF, | 126 | OTHER_THREAD_REF, |
| 125 | ).terminalIds, | 127 | ).terminalIds, |
| 126 | ).toEqual(["default", "env-b-terminal"]); | 128 | ).toEqual(["env-b-terminal"]); |
| 127 | }); | 129 | }); |
| 128 | 130 | ||
| 129 | it("migrates legacy persisted terminal UI state using the stored version", () => { | 131 | it("drops persisted entries whose thread keys are not valid scoped keys", () => { |
| 130 | const migrated = migratePersistedTerminalUiStateStoreState( | 132 | const migrated = migratePersistedTerminalUiStateStoreState( |
| 131 | { | 133 | { |
| 132 | terminalStateByThreadKey: { | 134 | terminalStateByThreadKey: { |
| 133 | [scopedThreadKey(THREAD_REF)]: { | 135 | [scopedThreadKey(THREAD_REF)]: { |
| 134 | terminalOpen: true, | 136 | terminalOpen: true, |
| 135 | terminalHeight: 320, | 137 | terminalHeight: 320, |
| 136 | terminalIds: ["default"], | 138 | terminalIds: ["term-1"], |
| 137 | activeTerminalId: "default", | 139 | activeTerminalId: "term-1", |
| 138 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | 140 | terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }], |
| 139 | activeTerminalGroupId: "group-default", | 141 | activeTerminalGroupId: "group-term-1", |
| 140 | }, | 142 | }, |
| 141 | "legacy-thread-id": { | 143 | "legacy-thread-id": { |
| 142 | terminalOpen: true, | 144 | terminalOpen: true, |
| 143 | terminalHeight: 320, | 145 | terminalHeight: 320, |
| 144 | terminalIds: ["default"], | 146 | terminalIds: ["term-1"], |
| 145 | activeTerminalId: "default", | 147 | activeTerminalId: "term-1", |
| 146 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | 148 | terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }], |
| 147 | activeTerminalGroupId: "group-default", | 149 | activeTerminalGroupId: "group-term-1", |
| 148 | }, | 150 | }, |
| 149 | }, | 151 | }, |
| 150 | }, | 152 | }, |
| @@ -156,10 +158,10 @@ describe("terminalUiStateStore actions", () => { · 5 unmodified lines | |||
| 156 | [scopedThreadKey(THREAD_REF)]: { | 158 | [scopedThreadKey(THREAD_REF)]: { |
| 157 | terminalOpen: true, | 159 | terminalOpen: true, |
| 158 | terminalHeight: 320, | 160 | terminalHeight: 320, |
| 159 | terminalIds: ["default"], | 161 | terminalIds: ["term-1"], |
| 160 | activeTerminalId: "default", | 162 | activeTerminalId: "term-1", |
| 161 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | 163 | terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }], |
| 162 | activeTerminalGroupId: "group-default", | 164 | activeTerminalGroupId: "group-term-1", |
| 163 | }, | 165 | }, |
| 164 | }, | 166 | }, |
| 165 | }); | 167 | }); |
| @@ -167,7 +169,8 @@ describe("terminalUiStateStore actions", () => { · 1 unmodified line | |||
| 167 | 169 | ||
| 168 | it("resets to default and clears persisted entry when closing the last terminal", () => { | 170 | it("resets to default and clears persisted entry when closing the last terminal", () => { |
| 169 | const store = useTerminalUiStateStore.getState(); | 171 | const store = useTerminalUiStateStore.getState(); |
| 170 | store.closeTerminal(THREAD_REF, "default"); | 172 | store.newTerminal(THREAD_REF, "terminal-only"); |
| 173 | store.closeTerminal(THREAD_REF, "terminal-only"); | ||
| 171 | 174 | ||
| 172 | expect( | 175 | expect( |
| 173 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey[scopedThreadKey(THREAD_REF)], | 176 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey[scopedThreadKey(THREAD_REF)], |
| @@ -177,7 +180,7 @@ describe("terminalUiStateStore actions", () => { · 3 unmodified lines | |||
| 177 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | 180 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, |
| 178 | THREAD_REF, | 181 | THREAD_REF, |
| 179 | ).terminalIds, | 182 | ).terminalIds, |
| 180 | ).toEqual(["default"]); | 183 | ).toEqual([]); |
| 181 | }); | 184 | }); |
| 182 | 185 | ||
| 183 | it("keeps a valid active terminal after closing an active split terminal", () => { | 186 | it("keeps a valid active terminal after closing an active split terminal", () => { |
| @@ -191,9 +194,26 @@ describe("terminalUiStateStore actions", () => { · 7 unmodified lines | |||
| 191 | THREAD_REF, | 194 | THREAD_REF, |
| 192 | ); | 195 | ); |
| 193 | expect(terminalUiState.activeTerminalId).toBe("terminal-2"); | 196 | expect(terminalUiState.activeTerminalId).toBe("terminal-2"); |
| 194 | expect(terminalUiState.terminalIds).toEqual(["default", "terminal-2"]); | 197 | expect(terminalUiState.terminalIds).toEqual(["terminal-2"]); |
| 198 | expect(terminalUiState.terminalGroups).toEqual([ | ||
| 199 | { id: "group-terminal-2", terminalIds: ["terminal-2"] }, | ||
| 200 | ]); | ||
| 201 | }); | ||
| 202 | |||
| 203 | it("reconciles terminal ids from an external ordered list", () => { | ||
| 204 | const store = useTerminalUiStateStore.getState(); | ||
| 205 | store.setTerminalOpen(THREAD_REF, true); | ||
| 206 | store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]); | ||
| 207 | |||
| 208 | const terminalUiState = selectThreadTerminalUiState( | ||
| 209 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 210 | THREAD_REF, | ||
| 211 | ); | ||
| 212 | expect(terminalUiState.terminalIds).toEqual(["term-a", "term-b"]); | ||
| 213 | expect(terminalUiState.activeTerminalId).toBe("term-a"); | ||
| 195 | expect(terminalUiState.terminalGroups).toEqual([ | 214 | expect(terminalUiState.terminalGroups).toEqual([ |
| 196 | { id: "group-default", terminalIds: ["default", "terminal-2"] }, | 215 | { id: "group-term-a", terminalIds: ["term-a"] }, |
| 216 | { id: "group-term-b", terminalIds: ["term-b"] }, | ||
| 197 | ]); | 217 | ]); |
| 198 | }); | 218 | }); |
| 199 | 219 | ||
| @@ -12,7 +12,6 @@ import { createJSONStorage, persist } from "zustand/middleware"; · 11 unmodified lines | |||
| 12 | import { resolveStorage } from "./lib/storage"; | 12 | import { resolveStorage } from "./lib/storage"; |
| 13 | import { | 13 | import { |
| 14 | DEFAULT_THREAD_TERMINAL_HEIGHT, | 14 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 15 | DEFAULT_THREAD_TERMINAL_ID, | ||
| 16 | MAX_TERMINALS_PER_GROUP, | 15 | MAX_TERMINALS_PER_GROUP, |
| 17 | type ThreadTerminalGroup, | 16 | type ThreadTerminalGroup, |
| 18 | } from "./types"; | 17 | } from "./types"; |
| @@ -36,20 +35,22 @@ interface PersistedTerminalUiStateStoreState { · 17 unmodified lines | |||
| 36 | 35 | ||
| 37 | export function migratePersistedTerminalUiStateStoreState( | 36 | export function migratePersistedTerminalUiStateStoreState( |
| 38 | persistedState: unknown, | 37 | persistedState: unknown, |
| 39 | version: number, | 38 | _version: number, |
| 40 | ): PersistedTerminalUiStateStoreState { | 39 | ): PersistedTerminalUiStateStoreState { |
| 41 | if (version <= 2 && persistedState && typeof persistedState === "object") { | 40 | if (!persistedState || typeof persistedState !== "object") { |
| 42 | const candidate = persistedState as PersistedTerminalUiStateStoreState; | 41 | return { terminalUiStateByThreadKey: {} }; |
| 43 | const persistedUiStateByThreadKey = | ||
| 44 | candidate.terminalUiStateByThreadKey ?? candidate.terminalStateByThreadKey ?? {}; | ||
| 45 | const nextTerminalUiStateByThreadKey = Object.fromEntries( | ||
| 46 | Object.entries(persistedUiStateByThreadKey).filter(([threadKey]) => | ||
| 47 | parseScopedThreadKey(threadKey), | ||
| 48 | ), | ||
| 49 | ); | ||
| 50 | return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey }; | ||
| 51 | } | 42 | } |
| 52 | return { terminalUiStateByThreadKey: {} }; | 43 | |
| 44 | const candidate = persistedState as PersistedTerminalUiStateStoreState; | ||
| 45 | const persistedUiStateByThreadKey = | ||
| 46 | candidate.terminalUiStateByThreadKey ?? candidate.terminalStateByThreadKey ?? {}; | ||
| 47 | const terminalUiStateByThreadKey = Object.fromEntries( | ||
| 48 | Object.entries(persistedUiStateByThreadKey).filter(([threadKey]) => | ||
| 49 | parseScopedThreadKey(threadKey), | ||
| 50 | ), | ||
| 51 | ); | ||
| 52 | |||
| 53 | return { terminalUiStateByThreadKey }; | ||
| 53 | } | 54 | } |
| 54 | 55 | ||
| 55 | function createTerminalUiStateStorage() { | 56 | function createTerminalUiStateStorage() { |
| @@ -57,8 +58,7 @@ function createTerminalUiStateStorage() { · 1 unmodified line | |||
| 57 | } | 58 | } |
| 58 | 59 | ||
| 59 | function normalizeTerminalIds(terminalIds: string[]): string[] { | 60 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 60 | const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | 61 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; |
| 61 | return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; | ||
| 62 | } | 62 | } |
| 63 | 63 | ||
| 64 | function fallbackGroupId(terminalId: string): string { | 64 | function fallbackGroupId(terminalId: string): string { |
| @@ -91,6 +91,10 @@ function normalizeTerminalGroups( · 26 unmodified lines | |||
| 91 | terminalGroups: ThreadTerminalGroup[], | 91 | terminalGroups: ThreadTerminalGroup[], |
| 92 | terminalIds: string[], | 92 | terminalIds: string[], |
| 93 | ): ThreadTerminalGroup[] { | 93 | ): ThreadTerminalGroup[] { |
| 94 | if (terminalIds.length === 0) { | ||
| 95 | return []; | ||
| 96 | } | ||
| 97 | |||
| 94 | const validTerminalIdSet = new Set(terminalIds); | 98 | const validTerminalIdSet = new Set(terminalIds); |
| 95 | const assignedTerminalIds = new Set<string>(); | 99 | const assignedTerminalIds = new Set<string>(); |
| 96 | const nextGroups: ThreadTerminalGroup[] = []; | 100 | const nextGroups: ThreadTerminalGroup[] = []; |
| @@ -109,7 +113,7 @@ function normalizeTerminalGroups( · 12 unmodified lines | |||
| 109 | const baseGroupId = | 113 | const baseGroupId = |
| 110 | group.id.trim().length > 0 | 114 | group.id.trim().length > 0 |
| 111 | ? group.id.trim() | 115 | ? group.id.trim() |
| 112 | : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | 116 | : fallbackGroupId(groupTerminalIds[0] ?? terminalIds[0] ?? ""); |
| 113 | nextGroups.push({ | 117 | nextGroups.push({ |
| 114 | id: assignUniqueGroupId(baseGroupId, usedGroupIds), | 118 | id: assignUniqueGroupId(baseGroupId, usedGroupIds), |
| 115 | terminalIds: groupTerminalIds, | 119 | terminalIds: groupTerminalIds, |
| @@ -124,15 +128,6 @@ function normalizeTerminalGroups( · 8 unmodified lines | |||
| 124 | }); | 128 | }); |
| 125 | } | 129 | } |
| 126 | 130 | ||
| 127 | if (nextGroups.length === 0) { | ||
| 128 | return [ | ||
| 129 | { | ||
| 130 | id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | ||
| 131 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 132 | }, | ||
| 133 | ]; | ||
| 134 | } | ||
| 135 | |||
| 136 | return nextGroups; | 131 | return nextGroups; |
| 137 | } | 132 | } |
| 138 | 133 | ||
| @@ -173,15 +168,10 @@ function threadTerminalUiStateEqual( · 34 unmodified lines | |||
| 173 | const DEFAULT_THREAD_TERMINAL_UI_STATE: ThreadTerminalUiState = Object.freeze({ | 168 | const DEFAULT_THREAD_TERMINAL_UI_STATE: ThreadTerminalUiState = Object.freeze({ |
| 174 | terminalOpen: false, | 169 | terminalOpen: false, |
| 175 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | 170 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 176 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | 171 | terminalIds: [], |
| 177 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | 172 | activeTerminalId: "", |
| 178 | terminalGroups: [ | 173 | terminalGroups: [], |
| 179 | { | 174 | activeTerminalGroupId: "", |
| 180 | id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | ||
| 181 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 182 | }, | ||
| 183 | ], | ||
| 184 | activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | ||
| 185 | }); | 175 | }); |
| 186 | 176 | ||
| 187 | function createDefaultThreadTerminalUiState(): ThreadTerminalUiState { | 177 | function createDefaultThreadTerminalUiState(): ThreadTerminalUiState { |
| @@ -197,11 +187,10 @@ function getDefaultThreadTerminalUiState(): ThreadTerminalUiState { · 9 unmodified lines | |||
| 197 | } | 187 | } |
| 198 | 188 | ||
| 199 | function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTerminalUiState { | 189 | function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTerminalUiState { |
| 200 | const terminalIds = normalizeTerminalIds(state.terminalIds); | 190 | const nextTerminalIds = normalizeTerminalIds(state.terminalIds); |
| 201 | const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; | ||
| 202 | const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) | 191 | const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) |
| 203 | ? state.activeTerminalId | 192 | ? state.activeTerminalId |
| 204 | : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | 193 | : (nextTerminalIds[0] ?? ""); |
| 205 | const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); | 194 | const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); |
| 206 | const activeGroupIdFromState = terminalGroups.some( | 195 | const activeGroupIdFromState = terminalGroups.some( |
| 207 | (group) => group.id === state.activeTerminalGroupId, | 196 | (group) => group.id === state.activeTerminalGroupId, |
| @@ -221,10 +210,7 @@ function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTer · 13 unmodified lines | |||
| 221 | activeTerminalId, | 210 | activeTerminalId, |
| 222 | terminalGroups, | 211 | terminalGroups, |
| 223 | activeTerminalGroupId: | 212 | activeTerminalGroupId: |
| 224 | activeGroupIdFromState ?? | 213 | activeGroupIdFromState ?? activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "", |
| 225 | activeGroupIdFromTerminal ?? | ||
| 226 | terminalGroups[0]?.id ?? | ||
| 227 | fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | ||
| 228 | }; | 214 | }; |
| 229 | return threadTerminalUiStateEqual(state, normalized) ? state : normalized; | 215 | return threadTerminalUiStateEqual(state, normalized) ? state : normalized; |
| 230 | } | 216 | } |
| @@ -255,6 +241,7 @@ function upsertTerminalIntoGroups( · 24 unmodified lines | |||
| 255 | mode: "split" | "new", | 241 | mode: "split" | "new", |
| 256 | ): ThreadTerminalUiState { | 242 | ): ThreadTerminalUiState { |
| 257 | const normalized = normalizeThreadTerminalUiState(state); | 243 | const normalized = normalizeThreadTerminalUiState(state); |
| 244 | const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode; | ||
| 258 | if (!isValidTerminalId(terminalId)) { | 245 | if (!isValidTerminalId(terminalId)) { |
| 259 | return normalized; | 246 | return normalized; |
| 260 | } | 247 | } |
| @@ -275,7 +262,7 @@ function upsertTerminalIntoGroups( · 14 unmodified lines | |||
| 275 | } | 262 | } |
| 276 | } | 263 | } |
| 277 | 264 | ||
| 278 | if (mode === "new") { | 265 | if (effectiveMode === "new") { |
| 279 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); | 266 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); |
| 280 | const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); | 267 | const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); |
| 281 | terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); | 268 | terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); |
| @@ -411,7 +398,7 @@ function closeThreadTerminal( · 129 unmodified lines | |||
| 411 | normalized.activeTerminalId === terminalId | 398 | normalized.activeTerminalId === terminalId |
| 412 | ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? | 399 | ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? |
| 413 | remainingTerminalIds[0] ?? | 400 | remainingTerminalIds[0] ?? |
| 414 | DEFAULT_THREAD_TERMINAL_ID) | 401 | "") |
| 415 | : normalized.activeTerminalId; | 402 | : normalized.activeTerminalId; |
| 416 | 403 | ||
| 417 | const terminalGroups = normalized.terminalGroups | 404 | const terminalGroups = normalized.terminalGroups |
| @@ -436,6 +423,32 @@ function closeThreadTerminal( · 18 unmodified lines | |||
| 436 | }); | 423 | }); |
| 437 | } | 424 | } |
| 438 | 425 | ||
| 426 | function reconcileThreadTerminalSessionIds( | ||
| 427 | state: ThreadTerminalUiState, | ||
| 428 | nextIds: string[], | ||
| 429 | ): ThreadTerminalUiState { | ||
| 430 | const normalized = normalizeThreadTerminalUiState(state); | ||
| 431 | if (arraysEqual(normalized.terminalIds, nextIds)) { | ||
| 432 | return normalized; | ||
| 433 | } | ||
| 434 | |||
| 435 | const nextActiveTerminalId = nextIds.includes(normalized.activeTerminalId) | ||
| 436 | ? normalized.activeTerminalId | ||
| 437 | : (nextIds[0] ?? ""); | ||
| 438 | |||
| 439 | const terminalGroups = normalizeTerminalGroups(normalized.terminalGroups, nextIds); | ||
| 440 | const activeGroupIdFromTerminal = | ||
| 441 | terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? null; | ||
| 442 | |||
| 443 | return normalizeThreadTerminalUiState({ | ||
| 444 | ...normalized, | ||
| 445 | terminalIds: nextIds, | ||
| 446 | activeTerminalId: nextActiveTerminalId, | ||
| 447 | terminalGroups, | ||
| 448 | activeTerminalGroupId: activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "", | ||
| 449 | }); | ||
| 450 | } | ||
| 451 | |||
| 439 | export function selectThreadTerminalUiState( | 452 | export function selectThreadTerminalUiState( |
| 440 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>, | 453 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>, |
| 441 | threadRef: ScopedThreadRef | null | undefined, | 454 | threadRef: ScopedThreadRef | null | undefined, |
| @@ -491,6 +504,7 @@ interface TerminalUiStateStoreState { · 49 unmodified lines | |||
| 491 | ) => void; | 504 | ) => void; |
| 492 | setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | 505 | setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 493 | closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | 506 | closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 507 | reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void; | ||
| 494 | clearTerminalUiState: (threadRef: ScopedThreadRef) => void; | 508 | clearTerminalUiState: (threadRef: ScopedThreadRef) => void; |
| 495 | removeTerminalUiState: (threadRef: ScopedThreadRef) => void; | 509 | removeTerminalUiState: (threadRef: ScopedThreadRef) => void; |
| 496 | removeOrphanedTerminalUiStates: (activeThreadKeys: Set<string>) => void; | 510 | removeOrphanedTerminalUiStates: (activeThreadKeys: Set<string>) => void; |
| @@ -553,6 +567,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( · 56 unmodified lines | |||
| 553 | updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), | 567 | updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), |
| 554 | closeTerminal: (threadRef, terminalId) => | 568 | closeTerminal: (threadRef, terminalId) => |
| 555 | updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)), | 569 | updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)), |
| 570 | reconcileTerminalIds: (threadRef, nextIds) => | ||
| 571 | updateTerminal(threadRef, (state) => reconcileThreadTerminalSessionIds(state, nextIds)), | ||
| 556 | clearTerminalUiState: (threadRef) => | 572 | clearTerminalUiState: (threadRef) => |
| 557 | set((state) => { | 573 | set((state) => { |
| 558 | const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( | 574 | const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( |
| @@ -600,7 +616,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( · 41 unmodified lines | |||
| 600 | }, | 616 | }, |
| 601 | { | 617 | { |
| 602 | name: TERMINAL_UI_STATE_STORAGE_KEY, | 618 | name: TERMINAL_UI_STATE_STORAGE_KEY, |
| 603 | version: 3, | 619 | version: 4, |
| 604 | storage: createJSONStorage(createTerminalUiStateStorage), | 620 | storage: createJSONStorage(createTerminalUiStateStorage), |
| 605 | migrate: migratePersistedTerminalUiStateStoreState, | 621 | migrate: migratePersistedTerminalUiStateStoreState, |
| 606 | partialize: (state) => ({ | 622 | partialize: (state) => ({ |
| @@ -22,7 +22,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; · 21 unmodified lines | |||
| 22 | 22 | ||
| 23 | export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default"; | 23 | export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default"; |
| 24 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; | 24 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; |
| 25 | export const DEFAULT_THREAD_TERMINAL_ID = "default"; | 25 | export const DEFAULT_THREAD_TERMINAL_ID = "term-1"; |
| 26 | export const MAX_TERMINALS_PER_GROUP = 4; | 26 | export const MAX_TERMINALS_PER_GROUP = 4; |
| 27 | export type ProjectScript = ContractProjectScript; | 27 | export type ProjectScript = ContractProjectScript; |
| 28 | 28 | ||
| @@ -28,7 +28,7 @@ function resetAtomRegistry() { · 27 unmodified lines | |||
| 28 | const TARGET = { | 28 | const TARGET = { |
| 29 | environmentId: EnvironmentId.make("env-local"), | 29 | environmentId: EnvironmentId.make("env-local"), |
| 30 | threadId: ThreadId.make("thread-1"), | 30 | threadId: ThreadId.make("thread-1"), |
| 31 | terminalId: "default", | 31 | terminalId: "term-1", |
| 32 | } as const; | 32 | } as const; |
| 33 | 33 | ||
| 34 | const BASE_SNAPSHOT: TerminalSessionSnapshot = { | 34 | const BASE_SNAPSHOT: TerminalSessionSnapshot = { |
| @@ -41,6 +41,7 @@ const BASE_SNAPSHOT: TerminalSessionSnapshot = { · 6 unmodified lines | |||
| 41 | history: "hello", | 41 | history: "hello", |
| 42 | exitCode: null, | 42 | exitCode: null, |
| 43 | exitSignal: null, | 43 | exitSignal: null, |
| 44 | label: "Terminal 1", | ||
| 44 | updatedAt: "2026-04-01T00:00:00.000Z", | 45 | updatedAt: "2026-04-01T00:00:00.000Z", |
| 45 | }; | 46 | }; |
| 46 | 47 | ||
| @@ -161,7 +162,7 @@ describe("createTerminalSessionManager", () => { · 114 unmodified lines | |||
| 161 | const otherTarget = { | 162 | const otherTarget = { |
| 162 | environmentId: EnvironmentId.make("env-remote"), | 163 | environmentId: EnvironmentId.make("env-remote"), |
| 163 | threadId: ThreadId.make("thread-1"), | 164 | threadId: ThreadId.make("thread-1"), |
| 164 | terminalId: "default", | 165 | terminalId: "term-1", |
| 165 | } as const; | 166 | } as const; |
| 166 | 167 | ||
| 167 | for (const target of [TARGET, otherTarget]) { | 168 | for (const target of [TARGET, otherTarget]) { |
| @@ -181,7 +182,7 @@ describe("createTerminalSessionManager", () => { · 13 unmodified lines | |||
| 181 | expect(manager.getSnapshot(otherTarget).buffer).toBe("env-remote"); | 182 | expect(manager.getSnapshot(otherTarget).buffer).toBe("env-remote"); |
| 182 | }); | 183 | }); |
| 183 | 184 | ||
| 184 | it("lists known sessions for a thread ordered by recency", () => { | 185 | it("lists known sessions for a thread ordered by terminal id (numeric-aware)", () => { |
| 185 | const manager = createTerminalSessionManager({ | 186 | const manager = createTerminalSessionManager({ |
| 186 | getRegistry: () => atomRegistry, | 187 | getRegistry: () => atomRegistry, |
| 187 | }); | 188 | }); |
| @@ -190,6 +191,19 @@ describe("createTerminalSessionManager", () => { · 2 unmodified lines | |||
| 190 | { | 191 | { |
| 191 | type: "snapshot", | 192 | type: "snapshot", |
| 192 | terminals: [ | 193 | terminals: [ |
| 194 | { | ||
| 195 | threadId: TARGET.threadId, | ||
| 196 | terminalId: "term-10", | ||
| 197 | cwd: "/repo", | ||
| 198 | worktreePath: null, | ||
| 199 | status: "running", | ||
| 200 | pid: 125, | ||
| 201 | exitCode: null, | ||
| 202 | exitSignal: null, | ||
| 203 | updatedAt: "2026-04-01T00:00:05.000Z", | ||
| 204 | hasRunningSubprocess: false, | ||
| 205 | label: "Terminal 10", | ||
| 206 | }, | ||
| 193 | { | 207 | { |
| 194 | threadId: TARGET.threadId, | 208 | threadId: TARGET.threadId, |
| 195 | terminalId: TARGET.terminalId, | 209 | terminalId: TARGET.terminalId, |
| @@ -201,6 +215,7 @@ describe("createTerminalSessionManager", () => { · 5 unmodified lines | |||
| 201 | exitSignal: null, | 215 | exitSignal: null, |
| 202 | updatedAt: "2026-04-01T00:00:00.000Z", | 216 | updatedAt: "2026-04-01T00:00:00.000Z", |
| 203 | hasRunningSubprocess: false, | 217 | hasRunningSubprocess: false, |
| 218 | label: "Terminal 1", | ||
| 204 | }, | 219 | }, |
| 205 | { | 220 | { |
| 206 | threadId: TARGET.threadId, | 221 | threadId: TARGET.threadId, |
| @@ -213,6 +228,7 @@ describe("createTerminalSessionManager", () => { · 6 unmodified lines | |||
| 213 | exitSignal: null, | 228 | exitSignal: null, |
| 214 | updatedAt: "2026-04-01T00:00:02.000Z", | 229 | updatedAt: "2026-04-01T00:00:02.000Z", |
| 215 | hasRunningSubprocess: false, | 230 | hasRunningSubprocess: false, |
| 231 | label: "Terminal 2", | ||
| 216 | }, | 232 | }, |
| 217 | ], | 233 | ], |
| 218 | }, | 234 | }, |
| @@ -225,7 +241,7 @@ describe("createTerminalSessionManager", () => { · 6 unmodified lines | |||
| 225 | threadId: TARGET.threadId, | 241 | threadId: TARGET.threadId, |
| 226 | }) | 242 | }) |
| 227 | .map((session) => session.target.terminalId), | 243 | .map((session) => session.target.terminalId), |
| 228 | ).toEqual(["term-2", "default"]); | 244 | ).toEqual(["term-1", "term-2", "term-10"]); |
| 229 | }); | 245 | }); |
| 230 | 246 | ||
| 231 | it("drops known sessions when an environment is invalidated", () => { | 247 | it("drops known sessions when an environment is invalidated", () => { |
| @@ -271,6 +287,7 @@ describe("createTerminalSessionManager", () => { · 39 unmodified lines | |||
| 271 | exitSignal: null, | 287 | exitSignal: null, |
| 272 | updatedAt: BASE_SNAPSHOT.updatedAt, | 288 | updatedAt: BASE_SNAPSHOT.updatedAt, |
| 273 | hasRunningSubprocess: false, | 289 | hasRunningSubprocess: false, |
| 290 | label: "Terminal 1", | ||
| 274 | }, | 291 | }, |
| 275 | }, | 292 | }, |
| 276 | ]); | 293 | ]); |
| @@ -384,6 +401,7 @@ describe("createTerminalSessionManager", () => { · 107 unmodified lines | |||
| 384 | snapshot: { | 401 | snapshot: { |
| 385 | ...BASE_SNAPSHOT, | 402 | ...BASE_SNAPSHOT, |
| 386 | terminalId: "term-2", | 403 | terminalId: "term-2", |
| 404 | label: "Terminal 2", | ||
| 387 | updatedAt: "2026-04-01T00:00:02.000Z", | 405 | updatedAt: "2026-04-01T00:00:02.000Z", |
| 388 | }, | 406 | }, |
| 389 | }, | 407 | }, |
| @@ -405,6 +423,7 @@ describe("createTerminalSessionManager", () => { · 15 unmodified lines | |||
| 405 | exitSignal: null, | 423 | exitSignal: null, |
| 406 | updatedAt: "2026-04-01T00:00:05.000Z", | 424 | updatedAt: "2026-04-01T00:00:05.000Z", |
| 407 | hasRunningSubprocess: true, | 425 | hasRunningSubprocess: true, |
| 426 | label: "Terminal 2", | ||
| 408 | }, | 427 | }, |
| 409 | ], | 428 | ], |
| 410 | }, | 429 | }, |
| @@ -452,6 +471,7 @@ describe("createTerminalSessionManager", () => { · 41 unmodified lines | |||
| 452 | exitSignal: null, | 471 | exitSignal: null, |
| 453 | updatedAt: BASE_SNAPSHOT.updatedAt, | 472 | updatedAt: BASE_SNAPSHOT.updatedAt, |
| 454 | hasRunningSubprocess: false, | 473 | hasRunningSubprocess: false, |
| 474 | label: "Terminal 1", | ||
| 455 | }, | 475 | }, |
| 456 | }, | 476 | }, |
| 457 | ]); | 477 | ]); |
| @@ -470,6 +490,7 @@ describe("createTerminalSessionManager", () => { · 12 unmodified lines | |||
| 470 | exitSignal: null, | 490 | exitSignal: null, |
| 471 | updatedAt: "2026-04-01T00:00:05.000Z", | 491 | updatedAt: "2026-04-01T00:00:05.000Z", |
| 472 | hasRunningSubprocess: true, | 492 | hasRunningSubprocess: true, |
| 493 | label: "Terminal 1", | ||
| 473 | }, | 494 | }, |
| 474 | }, | 495 | }, |
| 475 | ]); | 496 | ]); |
| @@ -504,6 +525,7 @@ describe("createTerminalSessionManager", () => { · 28 unmodified lines | |||
| 504 | exitSignal: null, | 525 | exitSignal: null, |
| 505 | updatedAt: BASE_SNAPSHOT.updatedAt, | 526 | updatedAt: BASE_SNAPSHOT.updatedAt, |
| 506 | hasRunningSubprocess: true, | 527 | hasRunningSubprocess: true, |
| 528 | label: "Terminal 1", | ||
| 507 | }, | 529 | }, |
| 508 | }, | 530 | }, |
| 509 | ]); | 531 | ]); |
| @@ -5,7 +5,7 @@ import type { · 4 unmodified lines | |||
| 5 | TerminalSummary, | 5 | TerminalSummary, |
| 6 | EnvironmentId, | 6 | EnvironmentId, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { DEFAULT_TERMINAL_ID, ThreadId, type TerminalAttachInput } from "@t3tools/contracts"; | 8 | import { ThreadId, type TerminalAttachInput } from "@t3tools/contracts"; |
| 9 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | 9 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; |
| 10 | 10 | ||
| 11 | export interface TerminalSessionState { | 11 | export interface TerminalSessionState { |
| @@ -77,7 +77,7 @@ export interface TerminalMetadataClient { · 65 unmodified lines | |||
| 77 | export interface TerminalAttachClient { | 77 | export interface TerminalAttachClient { |
| 78 | readonly terminal: { | 78 | readonly terminal: { |
| 79 | readonly attach: ( | 79 | readonly attach: ( |
| 80 | input: TerminalAttachInput & { readonly terminalId: string }, | 80 | input: TerminalAttachInput, |
| 81 | listener: (event: TerminalAttachStreamEvent) => void, | 81 | listener: (event: TerminalAttachStreamEvent) => void, |
| 82 | options?: { readonly onResubscribe?: () => void }, | 82 | options?: { readonly onResubscribe?: () => void }, |
| 83 | ) => () => void; | 83 | ) => () => void; |
| @@ -268,14 +268,9 @@ function listKnownSessionsFromMetadata( · 184 unmodified lines | |||
| 268 | target, | 268 | target, |
| 269 | state: combineSessionState(summary, getBuffer(target)), | 269 | state: combineSessionState(summary, getBuffer(target)), |
| 270 | })) | 270 | })) |
| 271 | .sort((left, right) => { | 271 | .sort((left, right) => |
| 272 | const leftUpdatedAt = left.state.updatedAt ? Date.parse(left.state.updatedAt) : 0; | 272 | left.target.terminalId.localeCompare(right.target.terminalId, undefined, { numeric: true }), |
| 273 | const rightUpdatedAt = right.state.updatedAt ? Date.parse(right.state.updatedAt) : 0; | 273 | ); |
| 274 | if (leftUpdatedAt !== rightUpdatedAt) { | ||
| 275 | return rightUpdatedAt - leftUpdatedAt; | ||
| 276 | } | ||
| 277 | return left.target.terminalId.localeCompare(right.target.terminalId); | ||
| 278 | }); | ||
| 279 | } | 274 | } |
| 280 | 275 | ||
| 281 | export const terminalSessionStateAtom = Atom.family((target: KnownTerminalSessionTarget) => | 276 | export const terminalSessionStateAtom = Atom.family((target: KnownTerminalSessionTarget) => |
| @@ -569,12 +564,8 @@ export function createTerminalSessionManager(config: TerminalSessionManagerConfi · 287 unmodified lines | |||
| 569 | readonly onEvent?: (event: TerminalAttachStreamEvent) => void; | 564 | readonly onEvent?: (event: TerminalAttachStreamEvent) => void; |
| 570 | readonly options?: { readonly onResubscribe?: () => void }; | 565 | readonly options?: { readonly onResubscribe?: () => void }; |
| 571 | }): () => void { | 566 | }): () => void { |
| 572 | const terminal = { | ||
| 573 | ...input.terminal, | ||
| 574 | terminalId: input.terminal.terminalId ?? DEFAULT_TERMINAL_ID, | ||
| 575 | }; | ||
| 576 | return input.client.terminal.attach( | 567 | return input.client.terminal.attach( |
| 577 | terminal, | 568 | input.terminal, |
| 578 | (event) => { | 569 | (event) => { |
| 579 | applyAttachEvent({ environmentId: input.environmentId }, event); | 570 | applyAttachEvent({ environmentId: input.environmentId }, event); |
| 580 | input.onEvent?.(event); | 571 | input.onEvent?.(event); |
| @@ -31,6 +31,7 @@ describe("TerminalOpenInput", () => { · 30 unmodified lines | |||
| 31 | expect( | 31 | expect( |
| 32 | decodes(TerminalOpenInput, { | 32 | decodes(TerminalOpenInput, { |
| 33 | threadId: "thread-1", | 33 | threadId: "thread-1", |
| 34 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 34 | cwd: "/tmp/project", | 35 | cwd: "/tmp/project", |
| 35 | cols: 120, | 36 | cols: 120, |
| 36 | rows: 40, | 37 | rows: 40, |
| @@ -42,6 +43,7 @@ describe("TerminalOpenInput", () => { · 5 unmodified lines | |||
| 42 | expect( | 43 | expect( |
| 43 | decodes(TerminalOpenInput, { | 44 | decodes(TerminalOpenInput, { |
| 44 | threadId: "thread-1", | 45 | threadId: "thread-1", |
| 46 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 45 | cwd: "/tmp/project", | 47 | cwd: "/tmp/project", |
| 46 | cols: 10, | 48 | cols: 10, |
| 47 | rows: 2, | 49 | rows: 2, |
| @@ -49,19 +51,21 @@ describe("TerminalOpenInput", () => { · 1 unmodified line | |||
| 49 | ).toBe(false); | 51 | ).toBe(false); |
| 50 | }); | 52 | }); |
| 51 | 53 | ||
| 52 | it("defaults terminalId when missing", () => { | 54 | it("requires terminalId — the client must always pick an id", () => { |
| 53 | const parsed = decodeSync(TerminalOpenInput, { | 55 | expect( |
| 54 | threadId: "thread-1", | 56 | decodes(TerminalOpenInput, { |
| 55 | cwd: "/tmp/project", | 57 | threadId: "thread-1", |
| 56 | cols: 100, | 58 | cwd: "/tmp/project", |
| 57 | rows: 24, | 59 | cols: 100, |
| 58 | }); | 60 | rows: 24, |
| 59 | expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); | 61 | }), |
| 62 | ).toBe(false); | ||
| 60 | }); | 63 | }); |
| 61 | 64 | ||
| 62 | it("accepts optional env overrides", () => { | 65 | it("accepts optional env overrides", () => { |
| 63 | const parsed = decodeSync(TerminalOpenInput, { | 66 | const parsed = decodeSync(TerminalOpenInput, { |
| 64 | threadId: "thread-1", | 67 | threadId: "thread-1", |
| 68 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 65 | cwd: "/tmp/project", | 69 | cwd: "/tmp/project", |
| 66 | worktreePath: "/tmp/project/.t3/worktrees/feature-a", | 70 | worktreePath: "/tmp/project/.t3/worktrees/feature-a", |
| 67 | cols: 100, | 71 | cols: 100, |
| @@ -98,6 +102,7 @@ describe("TerminalWriteInput", () => { · 30 unmodified lines | |||
| 98 | expect( | 102 | expect( |
| 99 | decodes(TerminalWriteInput, { | 103 | decodes(TerminalWriteInput, { |
| 100 | threadId: "thread-1", | 104 | threadId: "thread-1", |
| 105 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 101 | data: "echo hello\n", | 106 | data: "echo hello\n", |
| 102 | }), | 107 | }), |
| 103 | ).toBe(true); | 108 | ).toBe(true); |
| @@ -107,10 +112,20 @@ describe("TerminalWriteInput", () => { · 3 unmodified lines | |||
| 107 | expect( | 112 | expect( |
| 108 | decodes(TerminalWriteInput, { | 113 | decodes(TerminalWriteInput, { |
| 109 | threadId: "thread-1", | 114 | threadId: "thread-1", |
| 115 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 110 | data: "", | 116 | data: "", |
| 111 | }), | 117 | }), |
| 112 | ).toBe(false); | 118 | ).toBe(false); |
| 113 | }); | 119 | }); |
| 120 | |||
| 121 | it("rejects missing terminalId", () => { | ||
| 122 | expect( | ||
| 123 | decodes(TerminalWriteInput, { | ||
| 124 | threadId: "thread-1", | ||
| 125 | data: "echo hello\n", | ||
| 126 | }), | ||
| 127 | ).toBe(false); | ||
| 128 | }); | ||
| 114 | }); | 129 | }); |
| 115 | 130 | ||
| 116 | describe("TerminalThreadInput", () => { | 131 | describe("TerminalThreadInput", () => { |
| @@ -125,17 +140,33 @@ describe("TerminalResizeInput", () => { · 8 unmodified lines | |||
| 125 | expect( | 140 | expect( |
| 126 | decodes(TerminalResizeInput, { | 141 | decodes(TerminalResizeInput, { |
| 127 | threadId: "thread-1", | 142 | threadId: "thread-1", |
| 143 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 128 | cols: 80, | 144 | cols: 80, |
| 129 | rows: 24, | 145 | rows: 24, |
| 130 | }), | 146 | }), |
| 131 | ).toBe(true); | 147 | ).toBe(true); |
| 132 | }); | 148 | }); |
| 149 | |||
| 150 | it("rejects missing terminalId", () => { | ||
| 151 | expect( | ||
| 152 | decodes(TerminalResizeInput, { | ||
| 153 | threadId: "thread-1", | ||
| 154 | cols: 80, | ||
| 155 | rows: 24, | ||
| 156 | }), | ||
| 157 | ).toBe(false); | ||
| 158 | }); | ||
| 133 | }); | 159 | }); |
| 134 | 160 | ||
| 135 | describe("TerminalClearInput", () => { | 161 | describe("TerminalClearInput", () => { |
| 136 | it("defaults terminal id", () => { | 162 | it("requires terminalId", () => { |
| 163 | expect(decodes(TerminalClearInput, { threadId: "thread-1" })).toBe(false); | ||
| 164 | }); | ||
| 165 | |||
| 166 | it("accepts an explicit terminalId", () => { | ||
| 137 | const parsed = decodeSync(TerminalClearInput, { | 167 | const parsed = decodeSync(TerminalClearInput, { |
| 138 | threadId: "thread-1", | 168 | threadId: "thread-1", |
| 169 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 139 | }); | 170 | }); |
| 140 | expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); | 171 | expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); |
| 141 | }); | 172 | }); |
| @@ -165,6 +196,7 @@ describe("TerminalSessionSnapshot", () => { · 23 unmodified lines | |||
| 165 | history: "hello\n", | 196 | history: "hello\n", |
| 166 | exitCode: null, | 197 | exitCode: null, |
| 167 | exitSignal: null, | 198 | exitSignal: null, |
| 199 | label: "Primary", | ||
| 168 | updatedAt: new Date().toISOString(), | 200 | updatedAt: new Date().toISOString(), |
| 169 | }), | 201 | }), |
| 170 | ).toBe(true); | 202 | ).toBe(true); |
| @@ -212,6 +244,7 @@ describe("TerminalEvent", () => { · 41 unmodified lines | |||
| 212 | threadId: "thread-1", | 244 | threadId: "thread-1", |
| 213 | terminalId: DEFAULT_TERMINAL_ID, | 245 | terminalId: DEFAULT_TERMINAL_ID, |
| 214 | hasRunningSubprocess: true, | 246 | hasRunningSubprocess: true, |
| 247 | label: "vim", | ||
| 215 | }), | 248 | }), |
| 216 | ).toBe(true); | 249 | ).toBe(true); |
| 217 | }); | 250 | }); |
| @@ -232,6 +265,7 @@ describe("TerminalEvent", () => { · 14 unmodified lines | |||
| 232 | history: "", | 265 | history: "", |
| 233 | exitCode: null, | 266 | exitCode: null, |
| 234 | exitSignal: null, | 267 | exitSignal: null, |
| 268 | label: "Primary", | ||
| 235 | updatedAt: new Date().toISOString(), | 269 | updatedAt: new Date().toISOString(), |
| 236 | }, | 270 | }, |
| 237 | }), | 271 | }), |
| @@ -1,7 +1,12 @@ | |||
| 1 | import { Effect, Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | 3 | ||
| 4 | export const DEFAULT_TERMINAL_ID = "default"; | 4 | /** |
| 5 | * Client-side id for the first shell opened on a thread. Ids are uniformly | ||
| 6 | * `term-N`; there's no "default" intrinsic. Kept as a named constant so callers | ||
| 7 | * that want "the primary shell" don't hardcode `"term-1"`. | ||
| 8 | */ | ||
| 9 | export const DEFAULT_TERMINAL_ID = "term-1"; | ||
| 5 | 10 | ||
| 6 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; | 11 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 7 | const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check( | 12 | const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check( |
| @@ -19,18 +24,15 @@ const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSc · 11 unmodified lines | |||
| 19 | Schema.isMaxProperties(128), | 24 | Schema.isMaxProperties(128), |
| 20 | ); | 25 | ); |
| 21 | 26 | ||
| 22 | const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe( | ||
| 23 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_TERMINAL_ID)), | ||
| 24 | ); | ||
| 25 | |||
| 26 | export const TerminalThreadInput = Schema.Struct({ | 27 | export const TerminalThreadInput = Schema.Struct({ |
| 27 | threadId: TrimmedNonEmptyStringSchema, | 28 | threadId: TrimmedNonEmptyStringSchema, |
| 28 | }); | 29 | }); |
| 29 | export type TerminalThreadInput = typeof TerminalThreadInput.Type; | 30 | export type TerminalThreadInput = typeof TerminalThreadInput.Type; |
| 30 | 31 | ||
| 32 | /** Terminal ids are ALWAYS chosen by the client and sent explicitly — no server-side allocation. */ | ||
| 31 | const TerminalSessionInput = Schema.Struct({ | 33 | const TerminalSessionInput = Schema.Struct({ |
| 32 | ...TerminalThreadInput.fields, | 34 | ...TerminalThreadInput.fields, |
| 33 | terminalId: TerminalIdWithDefaultSchema, | 35 | terminalId: TerminalIdSchema, |
| 34 | }); | 36 | }); |
| 35 | export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>; | 37 | export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>; |
| 36 | 38 | ||
| @@ -100,6 +102,8 @@ export const TerminalSessionSnapshot = Schema.Struct({ · 63 unmodified lines | |||
| 100 | history: Schema.String, | 102 | history: Schema.String, |
| 101 | exitCode: Schema.NullOr(Schema.Int), | 103 | exitCode: Schema.NullOr(Schema.Int), |
| 102 | exitSignal: Schema.NullOr(Schema.Int), | 104 | exitSignal: Schema.NullOr(Schema.Int), |
| 105 | /** Server-computed display title (idle shell vs subprocess command). */ | ||
| 106 | label: Schema.String.check(Schema.isMaxLength(128)), | ||
| 103 | updatedAt: Schema.String, | 107 | updatedAt: Schema.String, |
| 104 | sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), | 108 | sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), |
| 105 | }); | 109 | }); |
| @@ -115,6 +119,8 @@ export const TerminalSummary = Schema.Struct({ · 9 unmodified lines | |||
| 115 | exitCode: Schema.NullOr(Schema.Int), | 119 | exitCode: Schema.NullOr(Schema.Int), |
| 116 | exitSignal: Schema.NullOr(Schema.Int), | 120 | exitSignal: Schema.NullOr(Schema.Int), |
| 117 | hasRunningSubprocess: Schema.Boolean, | 121 | hasRunningSubprocess: Schema.Boolean, |
| 122 | /** Server-computed display title (idle shell vs subprocess command). */ | ||
| 123 | label: Schema.String.check(Schema.isMaxLength(128)), | ||
| 118 | updatedAt: Schema.String, | 124 | updatedAt: Schema.String, |
| 119 | }); | 125 | }); |
| 120 | export type TerminalSummary = typeof TerminalSummary.Type; | 126 | export type TerminalSummary = typeof TerminalSummary.Type; |
| @@ -193,6 +199,7 @@ const TerminalActivityEvent = Schema.Struct({ · 72 unmodified lines | |||
| 193 | ...TerminalEventBaseSchema.fields, | 199 | ...TerminalEventBaseSchema.fields, |
| 194 | type: Schema.Literal("activity"), | 200 | type: Schema.Literal("activity"), |
| 195 | hasRunningSubprocess: Schema.Boolean, | 201 | hasRunningSubprocess: Schema.Boolean, |
| 202 | label: Schema.String.check(Schema.isMaxLength(128)), | ||
| 196 | }); | 203 | }); |
| 197 | 204 | ||
| 198 | export const TerminalEvent = Schema.Union([ | 205 | export const TerminalEvent = Schema.Union([ |
| @@ -83,6 +83,10 @@ · 82 unmodified lines | |||
| 83 | "./composerTrigger": { | 83 | "./composerTrigger": { |
| 84 | "types": "./src/composerTrigger.ts", | 84 | "types": "./src/composerTrigger.ts", |
| 85 | "import": "./src/composerTrigger.ts" | 85 | "import": "./src/composerTrigger.ts" |
| 86 | }, | ||
| 87 | "./terminalLabels": { | ||
| 88 | "types": "./src/terminalLabels.ts", | ||
| 89 | "import": "./src/terminalLabels.ts" | ||
| 86 | } | 90 | } |
| 87 | }, | 91 | }, |
| 88 | "scripts": { | 92 | "scripts": { |
| @@ -0,0 +1,54 @@ | |||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import type { TerminalSummary } from "@t3tools/contracts"; | ||
| 4 | import { DEFAULT_TERMINAL_ID } from "@t3tools/contracts"; | ||
| 5 | |||
| 6 | import { getTerminalLabel, nextTerminalId, resolveTerminalSessionLabel } from "./terminalLabels.ts"; | ||
| 7 | |||
| 8 | describe("getTerminalLabel", () => { | ||
| 9 | it("uses the numeric suffix for term-* ids", () => { | ||
| 10 | expect(getTerminalLabel(DEFAULT_TERMINAL_ID)).toBe("Terminal 1"); | ||
| 11 | expect(getTerminalLabel("term-2")).toBe("Terminal 2"); | ||
| 12 | expect(getTerminalLabel("term-12")).toBe("Terminal 12"); | ||
| 13 | expect(getTerminalLabel("terminal-3")).toBe("Terminal 3"); | ||
| 14 | }); | ||
| 15 | |||
| 16 | it("falls back to the raw id for unknown shapes", () => { | ||
| 17 | expect(getTerminalLabel("custom-session")).toBe("custom-session"); | ||
| 18 | }); | ||
| 19 | }); | ||
| 20 | |||
| 21 | describe("resolveTerminalSessionLabel", () => { | ||
| 22 | it("prefers a non-empty summary label", () => { | ||
| 23 | const summary = { label: " bun " } as Pick<TerminalSummary, "label">; | ||
| 24 | expect(resolveTerminalSessionLabel("term-1", summary)).toBe("bun"); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("falls back to getTerminalLabel when summary is missing or blank", () => { | ||
| 28 | expect(resolveTerminalSessionLabel(DEFAULT_TERMINAL_ID, { label: " " })).toBe("Terminal 1"); | ||
| 29 | expect(resolveTerminalSessionLabel(DEFAULT_TERMINAL_ID, null)).toBe("Terminal 1"); | ||
| 30 | expect(resolveTerminalSessionLabel("term-2", undefined)).toBe("Terminal 2"); | ||
| 31 | }); | ||
| 32 | }); | ||
| 33 | |||
| 34 | describe("nextTerminalId", () => { | ||
| 35 | it("allocates term-1 when no terminals are listed yet", () => { | ||
| 36 | expect(nextTerminalId([])).toBe(DEFAULT_TERMINAL_ID); | ||
| 37 | expect(nextTerminalId([])).toBe("term-1"); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("allocates term-2 when only term-1 exists", () => { | ||
| 41 | expect(nextTerminalId([DEFAULT_TERMINAL_ID])).toBe("term-2"); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("skips over taken term-N slots", () => { | ||
| 45 | expect(nextTerminalId([DEFAULT_TERMINAL_ID, "term-2", "term-3"])).toBe("term-4"); | ||
| 46 | expect(nextTerminalId([DEFAULT_TERMINAL_ID, "term-3"])).toBe("term-2"); | ||
| 47 | expect(nextTerminalId(["term-2", "term-3"])).toBe("term-1"); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("ignores blank/whitespace-only ids", () => { | ||
| 51 | expect(nextTerminalId(["", " ", DEFAULT_TERMINAL_ID])).toBe("term-2"); | ||
| 52 | expect(nextTerminalId(["", " "])).toBe("term-1"); | ||
| 53 | }); | ||
| 54 | }); | ||
| @@ -0,0 +1,40 @@ | |||
| 1 | import type { TerminalSummary } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | /** Human-readable label for a terminal tab; matches mobile and web sidebars. */ | ||
| 4 | export function getTerminalLabel(terminalId: string): string { | ||
| 5 | const numericSuffix = /^term(?:inal)?-(\d+)$/i.exec(terminalId)?.[1]; | ||
| 6 | if (numericSuffix) { | ||
| 7 | return `Terminal ${numericSuffix}`; | ||
| 8 | } | ||
| 9 | |||
| 10 | return terminalId; | ||
| 11 | } | ||
| 12 | |||
| 13 | /** Prefer server summary label when present; otherwise fall back to `getTerminalLabel`. */ | ||
| 14 | export function resolveTerminalSessionLabel( | ||
| 15 | terminalId: string, | ||
| 16 | summary: Pick<TerminalSummary, "label"> | null | undefined, | ||
| 17 | ): string { | ||
| 18 | const trimmed = summary?.label?.trim(); | ||
| 19 | if (trimmed && trimmed.length > 0) { | ||
| 20 | return trimmed; | ||
| 21 | } | ||
| 22 | return getTerminalLabel(terminalId); | ||
| 23 | } | ||
| 24 | |||
| 25 | /** | ||
| 26 | * Client-side terminal id allocator. Ids are ALWAYS chosen by the client and sent explicitly | ||
| 27 | * on every `terminal.open` / `terminal.attach` call — the server never allocates. | ||
| 28 | * | ||
| 29 | * Returns the lowest unused `term-N` id (starting at `term-1`), skipping any ids already in | ||
| 30 | * `existingTerminalIds`. | ||
| 31 | */ | ||
| 32 | export function nextTerminalId(existingTerminalIds: ReadonlyArray<string>): string { | ||
| 33 | const usedIds = new Set(existingTerminalIds.filter((id) => id.trim().length > 0)); | ||
| 34 | let nextIndex = 1; | ||
| 35 | while (usedIds.has(`term-${nextIndex}`)) { | ||
| 36 | nextIndex += 1; | ||
| 37 | } | ||
| 38 | |||
| 39 | return `term-${nextIndex}`; | ||
| 40 | } | ||