Unified · Split
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { memo, useCallback } from "react"; | |
| 1 | +import { memo, useCallback, useEffect } from "react"; | |
| 2 | 2 | import { |
| 3 | 3 | Pressable, |
| 4 | 4 | ScrollView, |
| @@ -17,6 +17,7 @@ import { · 12 unmodified lines | ||
| 17 | 17 | getPierreTerminalTheme, |
| 18 | 18 | type TerminalTheme, |
| 19 | 19 | } from "./terminalTheme"; |
| 20 | +import { terminalDebugLog } from "./terminalDebugLog"; | |
| 20 | 21 | |
| 21 | 22 | interface TerminalInputEvent { |
| 22 | 23 | readonly data: string; |
| @@ -168,6 +169,16 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 145 unmodified lines | ||
| 168 | 169 | const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); |
| 169 | 170 | const { onInput, onResize } = props; |
| 170 | 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 | 182 | const handleNativeInput = useCallback( |
| 172 | 183 | (event: NativeSyntheticEvent<TerminalInputEvent>) => { |
| 173 | 184 | onInput(event.nativeEvent.data); |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | } from "../../state/use-terminal-session"; |
| 13 | 13 | import { TerminalSurface } from "./NativeTerminalSurface"; |
| 14 | 14 | import { hasNativeTerminalSurface } from "./nativeTerminalModule"; |
| 15 | +import { terminalDebugLog } from "./terminalDebugLog"; | |
| 15 | 16 | |
| 16 | 17 | interface ThreadTerminalPanelProps { |
| 17 | 18 | readonly environmentId: EnvironmentId; |
| @@ -51,9 +52,19 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( · 33 unmodified lines | ||
| 51 | 52 | |
| 52 | 53 | const client = getEnvironmentClient(props.environmentId); |
| 53 | 54 | if (!client) { |
| 55 | + terminalDebugLog("panel:attach-skip", { | |
| 56 | + reason: "no-environment-client", | |
| 57 | + environmentId: props.environmentId, | |
| 58 | + }); | |
| 54 | 59 | return; |
| 55 | 60 | } |
| 56 | 61 | |
| 62 | + terminalDebugLog("panel:attach", { | |
| 63 | + environmentId: props.environmentId, | |
| 64 | + threadId: props.threadId, | |
| 65 | + terminalId, | |
| 66 | + }); | |
| 67 | + | |
| 57 | 68 | return attachTerminalSession({ |
| 58 | 69 | environmentId: props.environmentId, |
| 59 | 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 | 8 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 3 | 9 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | 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 | 14 | import { EmptyState } from "../../components/EmptyState"; |
| 9 | 15 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 10 | −import { buildThreadTerminalRoutePath } from "../../lib/routes"; | |
| 16 | +import { buildThreadTerminalNavigation } from "../../lib/routes"; | |
| 11 | 17 | import { |
| 12 | 18 | getEnvironmentClient, |
| 13 | 19 | useRemoteEnvironmentState, |
| @@ -23,6 +29,7 @@ import { useSelectedThreadDetail } from "../../state/use-thread-detail"; · 9 unmodified lines | ||
| 23 | 29 | import { TerminalSurface } from "./NativeTerminalSurface"; |
| 24 | 30 | import { getPierreTerminalTheme } from "./terminalTheme"; |
| 25 | 31 | import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; |
| 32 | +import { terminalDebugLog } from "./terminalDebugLog"; | |
| 26 | 33 | import { |
| 27 | 34 | getTerminalBufferReplayKey, |
| 28 | 35 | getTerminalSurfaceReplayBuffer, |
| @@ -37,9 +44,10 @@ import { · 8 unmodified lines | ||
| 37 | 44 | import { |
| 38 | 45 | basename, |
| 39 | 46 | buildTerminalMenuSessions, |
| 40 | − getTerminalLabel, | |
| 41 | 47 | getTerminalStatusLabel, |
| 42 | − nextTerminalId, | |
| 48 | + nextOpenTerminalId, | |
| 49 | + resolveTerminalSessionLabel, | |
| 50 | + type TerminalMenuSession, | |
| 43 | 51 | } from "./terminalMenu"; |
| 44 | 52 | import { |
| 45 | 53 | DEFAULT_TERMINAL_FONT_SIZE, |
| @@ -71,13 +79,6 @@ type TerminalToolbarAction = · 25 unmodified lines | ||
| 71 | 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 | 82 | function getTerminalStatusTone(input: { |
| 82 | 83 | readonly status: TerminalMenuSession["status"]; |
| 83 | 84 | readonly hasRunningSubprocess?: boolean; |
| @@ -174,6 +175,22 @@ function withAlpha(hexColor: string, alpha: string): string { · 90 unmodified lines | ||
| 174 | 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 | 194 | export function ThreadTerminalRouteScreen() { |
| 178 | 195 | const router = useRouter(); |
| 179 | 196 | const keyboard = useAnimatedKeyboard(); |
| @@ -217,12 +234,15 @@ export function ThreadTerminalRouteScreen() { · 37 unmodified lines | ||
| 217 | 234 | const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); |
| 218 | 235 | const hasOpenedRef = useRef(false); |
| 219 | 236 | const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 237 | + const attachStreamLogCountRef = useRef(0); | |
| 238 | + const firstNonEmptyBufferLoggedRef = useRef(false); | |
| 220 | 239 | const lastBufferReplayKeyRef = useRef<string | null>(null); |
| 221 | 240 | const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null); |
| 222 | 241 | const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState( |
| 223 | 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 | 246 | const [pendingModifierState, setPendingModifierState] = useState<{ |
| 227 | 247 | readonly terminalId: string; |
| 228 | 248 | readonly value: PendingModifier | null; |
| @@ -252,22 +272,79 @@ export function ThreadTerminalRouteScreen() { · 23 unmodified lines | ||
| 252 | 272 | readyReplayKey: readyBufferReplayKey, |
| 253 | 273 | }); |
| 254 | 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 | 325 | const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null; |
| 256 | 326 | const hostPlatform = useMemo( |
| 257 | 327 | () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), |
| 258 | 328 | [selectedEnvironmentConnection?.environmentLabel], |
| 259 | 329 | ); |
| 260 | 330 | const runningSession = useMemo( |
| 261 | − () => | |
| 262 | − knownSessions.find( | |
| 263 | − (session) => session.state.status === "running" || session.state.status === "starting", | |
| 264 | − ) ?? null, | |
| 331 | + () => pickRunningTerminalSessionForBootstrap(knownSessions), | |
| 265 | 332 | [knownSessions], |
| 266 | 333 | ); |
| 267 | 334 | const activeKnownSession = useMemo( |
| 268 | 335 | () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, |
| 269 | 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 | 348 | const terminalStatusTone = useMemo( |
| 272 | 349 | () => |
| 273 | 350 | getTerminalStatusTone({ |
| @@ -339,6 +416,7 @@ export function ThreadTerminalRouteScreen() { · 65 unmodified lines | ||
| 339 | 416 | cwd: cwd ?? null, |
| 340 | 417 | status: terminal.status, |
| 341 | 418 | hasRunningSubprocess: terminal.hasRunningSubprocess, |
| 419 | + displayLabel: resolveTerminalSessionLabel(terminalId, terminal.summary), | |
| 342 | 420 | updatedAt: terminal.updatedAt, |
| 343 | 421 | }, |
| 344 | 422 | }), |
| @@ -347,19 +425,47 @@ export function ThreadTerminalRouteScreen() { · 2 unmodified lines | ||
| 347 | 425 | knownSessions, |
| 348 | 426 | selectedThreadProject?.workspaceRoot, |
| 349 | 427 | terminal.hasRunningSubprocess, |
| 428 | + terminal.summary, | |
| 350 | 429 | terminal.status, |
| 351 | 430 | terminal.updatedAt, |
| 352 | 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 | 457 | const attachTerminal = useCallback(() => { |
| 357 | 458 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { |
| 459 | + terminalDebugLog("attach:abort", { reason: "no-thread-or-workspace" }); | |
| 358 | 460 | return null; |
| 359 | 461 | } |
| 360 | 462 | |
| 361 | 463 | const client = getEnvironmentClient(selectedThread.environmentId); |
| 362 | 464 | if (!client) { |
| 465 | + terminalDebugLog("attach:abort", { | |
| 466 | + reason: "no-environment-client", | |
| 467 | + environmentId: selectedThread.environmentId, | |
| 468 | + }); | |
| 363 | 469 | return null; |
| 364 | 470 | } |
| 365 | 471 | |
| @@ -378,13 +484,22 @@ export function ThreadTerminalRouteScreen() { · 12 unmodified lines | ||
| 378 | 484 | worktreePath: pendingLaunch.worktreePath, |
| 379 | 485 | } |
| 380 | 486 | : resolveTerminalOpenLocation({ |
| 381 | − terminalLocation: terminal.summary, | |
| 382 | − activeSessionLocation: activeKnownSession?.state.summary ?? null, | |
| 487 | + terminalLocation: terminalAttachLaunchHintsRef.current.terminalSummary, | |
| 488 | + activeSessionLocation: terminalAttachLaunchHintsRef.current.activeKnownSummary, | |
| 383 | 489 | workspaceRoot: selectedThreadProject.workspaceRoot, |
| 384 | 490 | threadShellWorktreePath: selectedThread.worktreePath ?? null, |
| 385 | 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 | 503 | return attachTerminalSession({ |
| 389 | 504 | environmentId: selectedThread.environmentId, |
| 390 | 505 | client, |
| @@ -397,6 +512,7 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines | ||
| 397 | 512 | rows: lastGridSize.rows, |
| 398 | 513 | env: pendingLaunch?.env, |
| 399 | 514 | }, |
| 515 | + onEvent: logAttachStreamEvent, | |
| 400 | 516 | onSnapshot: () => { |
| 401 | 517 | if (!pendingLaunch?.initialInput || initialInputSent) { |
| 402 | 518 | return; |
| @@ -411,6 +527,9 @@ export function ThreadTerminalRouteScreen() { · 8 unmodified lines | ||
| 411 | 527 | }, |
| 412 | 528 | }); |
| 413 | 529 | } catch (error) { |
| 530 | + terminalDebugLog("attach:error", { | |
| 531 | + message: error instanceof Error ? error.message : String(error), | |
| 532 | + }); | |
| 414 | 533 | if (pendingLaunch) { |
| 415 | 534 | stagePendingTerminalLaunch({ |
| 416 | 535 | target: pendingLaunchTarget, |
| @@ -423,16 +542,34 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines | ||
| 423 | 542 | }, [ |
| 424 | 543 | lastGridSize.cols, |
| 425 | 544 | lastGridSize.rows, |
| 426 | − activeKnownSession?.state.summary, | |
| 545 | + logAttachStreamEvent, | |
| 427 | 546 | selectedThreadDetail?.worktreePath, |
| 428 | 547 | selectedThread, |
| 429 | 548 | selectedThreadProject?.workspaceRoot, |
| 430 | − terminal.summary, | |
| 431 | 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 | 569 | useEffect(() => { |
| 435 | 570 | hasOpenedRef.current = false; |
| 571 | + attachStreamLogCountRef.current = 0; | |
| 572 | + firstNonEmptyBufferLoggedRef.current = false; | |
| 436 | 573 | }, [terminalKey]); |
| 437 | 574 | |
| 438 | 575 | const clearBufferReplayTimer = useCallback(() => { |
| @@ -445,9 +582,14 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines | ||
| 445 | 582 | const scheduleBufferReplayReady = useCallback(() => { |
| 446 | 583 | clearBufferReplayTimer(); |
| 447 | 584 | const replayKey = bufferReplayKey; |
| 585 | + terminalDebugLog("replay:schedule-ready", { | |
| 586 | + replayKey, | |
| 587 | + delayMs: TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, | |
| 588 | + }); | |
| 448 | 589 | bufferReplayTimerRef.current = setTimeout(() => { |
| 449 | 590 | bufferReplayTimerRef.current = null; |
| 450 | 591 | setReadyBufferReplayKey(replayKey); |
| 592 | + terminalDebugLog("replay:ready", { replayKey }); | |
| 451 | 593 | }, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS); |
| 452 | 594 | }, [bufferReplayKey, clearBufferReplayTimer]); |
| 453 | 595 | |
| @@ -482,27 +624,9 @@ export function ThreadTerminalRouteScreen() { · 28 unmodified lines | ||
| 482 | 624 | rows: DEFAULT_TERMINAL_ROWS, |
| 483 | 625 | }, |
| 484 | 626 | ); |
| 485 | − setHasMeasuredSurface( | |
| 486 | − getCachedTerminalGridSize({ | |
| 487 | − environmentId: routeEnvironmentId, | |
| 488 | − threadId: routeThreadId, | |
| 489 | − terminalId, | |
| 490 | − }) !== null, | |
| 491 | − ); | |
| 627 | + setHasMeasuredSurface(true); | |
| 492 | 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 | 630 | useEffect(() => { |
| 507 | 631 | let cancelled = false; |
| 508 | 632 | |
| @@ -539,50 +663,79 @@ export function ThreadTerminalRouteScreen() { · 30 unmodified lines | ||
| 539 | 663 | }); |
| 540 | 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 | 670 | useEffect(() => { |
| 543 | 671 | if (!hasResolvedFontPreference || !hasMeasuredSurface) { |
| 544 | 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 | 680 | const bootstrapAction = resolveTerminalRouteBootstrap({ |
| 548 | − hasThread: selectedThread !== null, | |
| 549 | − hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), | |
| 681 | + hasThread: thread !== null, | |
| 682 | + hasWorkspaceRoot: Boolean(project?.workspaceRoot), | |
| 550 | 683 | hasOpened: hasOpenedRef.current, |
| 551 | 684 | requestedTerminalId, |
| 552 | 685 | currentTerminalId: terminalId, |
| 553 | − runningTerminalId: runningSession?.target.terminalId ?? null, | |
| 554 | − currentTerminalStatus: terminal.status, | |
| 555 | − hasCurrentTerminalHydration: terminal.summary !== null || terminal.buffer.length > 0, | |
| 686 | + runningTerminalId: running?.target.terminalId ?? null, | |
| 687 | + currentTerminalStatus: termSnap.status, | |
| 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 | 702 | return; |
| 559 | 703 | } |
| 560 | 704 | |
| 561 | 705 | if (bootstrapAction.kind === "redirect") { |
| 562 | − router.replace(buildThreadTerminalRoutePath(selectedThread, bootstrapAction.terminalId)); | |
| 706 | + router.replace(buildThreadTerminalNavigation(thread, bootstrapAction.terminalId)); | |
| 563 | 707 | return; |
| 564 | 708 | } |
| 565 | 709 | |
| 566 | 710 | hasOpenedRef.current = true; |
| 567 | 711 | try { |
| 568 | − return attachTerminal() ?? undefined; | |
| 569 | − } catch { | |
| 712 | + const detach = attachTerminalRef.current(); | |
| 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 | 724 | hasOpenedRef.current = false; |
| 725 | + terminalDebugLog("bootstrap:attach-threw", { | |
| 726 | + message: error instanceof Error ? error.message : String(error), | |
| 727 | + }); | |
| 571 | 728 | return; |
| 572 | 729 | } |
| 573 | 730 | }, [ |
| 574 | − attachTerminal, | |
| 731 | + hasMeasuredSurface, | |
| 732 | + hasResolvedFontPreference, | |
| 575 | 733 | requestedTerminalId, |
| 576 | 734 | router, |
| 577 | − runningSession, | |
| 578 | − selectedThread, | |
| 735 | + selectedThread?.environmentId, | |
| 736 | + selectedThread?.id, | |
| 579 | 737 | selectedThreadProject?.workspaceRoot, |
| 580 | − terminal.buffer.length, | |
| 581 | − terminal.summary, | |
| 582 | − terminal.status, | |
| 583 | 738 | terminalId, |
| 584 | − hasMeasuredSurface, | |
| 585 | − hasResolvedFontPreference, | |
| 586 | 739 | ]); |
| 587 | 740 | |
| 588 | 741 | const writeInput = useCallback( |
| @@ -626,6 +779,11 @@ export function ThreadTerminalRouteScreen() { · 37 unmodified lines | ||
| 626 | 779 | |
| 627 | 780 | const handleResize = useCallback( |
| 628 | 781 | (size: { readonly cols: number; readonly rows: number }) => { |
| 782 | + terminalDebugLog("native:onResize", { | |
| 783 | + cols: size.cols, | |
| 784 | + rows: size.rows, | |
| 785 | + terminalKey, | |
| 786 | + }); | |
| 629 | 787 | setHasMeasuredSurface(true); |
| 630 | 788 | if (readyBufferReplayKey !== bufferReplayKey) { |
| 631 | 789 | scheduleBufferReplayReady(); |
| @@ -672,6 +830,7 @@ export function ThreadTerminalRouteScreen() { · 40 unmodified lines | ||
| 672 | 830 | scheduleBufferReplayReady, |
| 673 | 831 | selectedThread, |
| 674 | 832 | terminalId, |
| 833 | + terminalKey, | |
| 675 | 834 | ], |
| 676 | 835 | ); |
| 677 | 836 | |
| @@ -681,7 +840,7 @@ export function ThreadTerminalRouteScreen() { · 3 unmodified lines | ||
| 681 | 840 | return; |
| 682 | 841 | } |
| 683 | 842 | |
| 684 | − router.replace(buildThreadTerminalRoutePath(selectedThread, nextTerminalId)); | |
| 843 | + router.replace(buildThreadTerminalNavigation(selectedThread, nextTerminalId)); | |
| 685 | 844 | }, |
| 686 | 845 | [router, selectedThread, terminalId], |
| 687 | 846 | ); |
| @@ -692,12 +851,15 @@ export function ThreadTerminalRouteScreen() { · 4 unmodified lines | ||
| 692 | 851 | } |
| 693 | 852 | |
| 694 | 853 | router.replace( |
| 695 | − buildThreadTerminalRoutePath( | |
| 854 | + buildThreadTerminalNavigation( | |
| 696 | 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 | 864 | const adjustFontSize = useCallback((delta: number) => { |
| 703 | 865 | setTimeout(() => { |
| @@ -856,7 +1018,7 @@ export function ThreadTerminalRouteScreen() { · 152 unmodified lines | ||
| 856 | 1018 | .filter(Boolean) |
| 857 | 1019 | .join(" · ")} |
| 858 | 1020 | > |
| 859 | − <Stack.Toolbar.Label>{getTerminalLabel(session.terminalId)}</Stack.Toolbar.Label> | |
| 1021 | + <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label> | |
| 860 | 1022 | </Stack.Toolbar.MenuAction> |
| 861 | 1023 | ))} |
| 862 | 1024 | <Stack.Toolbar.MenuAction |
| @@ -12,7 +12,7 @@ describe("terminalBufferReplay", () => { · 11 unmodified lines | ||
| 12 | 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 | 16 | const replayKey = getTerminalBufferReplayKey({ |
| 17 | 17 | terminalKey: "env-1:thread-1:default", |
| 18 | 18 | fontSize: 10, |
| @@ -24,7 +24,7 @@ describe("terminalBufferReplay", () => { · 5 unmodified lines | ||
| 24 | 24 | replayKey, |
| 25 | 25 | readyReplayKey: null, |
| 26 | 26 | }), |
| 27 | − ).toBe(""); | |
| 27 | + ).toBe("fastfetch output"); | |
| 28 | 28 | expect( |
| 29 | 29 | getTerminalSurfaceReplayBuffer({ |
| 30 | 30 | buffer: "fastfetch output", |
| @@ -1,3 +1,5 @@ | ||
| 1 | +import { terminalDebugLog } from "./terminalDebugLog"; | |
| 2 | + | |
| 1 | 3 | export const TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS = 180; |
| 2 | 4 | |
| 3 | 5 | export function getTerminalBufferReplayKey(input: { |
| @@ -12,5 +14,16 @@ export function getTerminalSurfaceReplayBuffer(input: { · 8 unmodified lines | ||
| 12 | 14 | readonly replayKey: string; |
| 13 | 15 | readonly readyReplayKey: string | null; |
| 14 | 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 | 3 | import type { KnownTerminalSession } from "@t3tools/client-runtime"; |
| 4 | 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 | 15 | function makeKnownSession(input: { |
| 9 | 16 | readonly terminalId: string; |
| @@ -29,6 +36,7 @@ function makeKnownSession(input: { · 19 unmodified lines | ||
| 29 | 36 | exitCode: null, |
| 30 | 37 | exitSignal: null, |
| 31 | 38 | hasRunningSubprocess: false, |
| 39 | + label: getTerminalLabel(input.terminalId), | |
| 32 | 40 | updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", |
| 33 | 41 | } |
| 34 | 42 | : null, |
| @@ -43,7 +51,7 @@ function makeKnownSession(input: { · 8 unmodified lines | ||
| 43 | 51 | } |
| 44 | 52 | |
| 45 | 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 | 55 | expect( |
| 48 | 56 | buildTerminalMenuSessions({ |
| 49 | 57 | knownSessions: [ |
| @@ -63,18 +71,12 @@ describe("buildTerminalMenuSessions", () => { · 13 unmodified lines | ||
| 63 | 71 | workspaceRoot: "/workspace/root", |
| 64 | 72 | }), |
| 65 | 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 | 75 | terminalId: "term-3", |
| 75 | 76 | cwd: "/workspace/feature", |
| 76 | 77 | status: "running", |
| 77 | 78 | hasRunningSubprocess: false, |
| 79 | + displayLabel: "Terminal 3", | |
| 78 | 80 | updatedAt: "2026-04-15T20:05:00.000Z", |
| 79 | 81 | }, |
| 80 | 82 | ]); |
| @@ -90,28 +92,58 @@ describe("buildTerminalMenuSessions", () => { · 9 unmodified lines | ||
| 90 | 92 | cwd: "/workspace/exited", |
| 91 | 93 | status: "exited", |
| 92 | 94 | hasRunningSubprocess: false, |
| 95 | + displayLabel: "Terminal 4", | |
| 93 | 96 | updatedAt: "2026-04-15T20:07:00.000Z", |
| 94 | 97 | }, |
| 95 | 98 | }), |
| 96 | 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 | 101 | terminalId: "term-4", |
| 106 | 102 | cwd: "/workspace/exited", |
| 107 | 103 | status: "exited", |
| 108 | 104 | hasRunningSubprocess: false, |
| 105 | + displayLabel: "Terminal 4", | |
| 109 | 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 | 147 | describe("resolveProjectScriptTerminalId", () => { |
| 116 | 148 | it("reuses the default shell when no terminal is running", () => { |
| 117 | 149 | expect( |
| @@ -1,11 +1,20 @@ | ||
| 1 | 1 | import type { KnownTerminalSession } from "@t3tools/client-runtime"; |
| 2 | 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 | 11 | export interface TerminalMenuSession { |
| 5 | 12 | readonly terminalId: string; |
| 6 | 13 | readonly cwd: string | null; |
| 7 | 14 | readonly status: "starting" | "running" | "exited" | "error" | "closed"; |
| 8 | 15 | readonly hasRunningSubprocess: boolean; |
| 16 | + /** Server-authoritative title with the same fallback rules as web. */ | |
| 17 | + readonly displayLabel: string; | |
| 9 | 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 | 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 | 35 | export function getTerminalStatusLabel(input: { |
| 40 | 36 | readonly status: TerminalMenuSession["status"]; |
| 41 | 37 | readonly hasRunningSubprocess?: boolean; |
| @@ -56,14 +52,23 @@ export function getTerminalStatusLabel(input: { · 14 unmodified lines | ||
| 56 | 52 | return "Not started"; |
| 57 | 53 | } |
| 58 | 54 | |
| 59 | −export function nextTerminalId(existingTerminalIds: ReadonlyArray<string>): string { | |
| 60 | − const usedIds = new Set(existingTerminalIds); | |
| 61 | − let nextIndex = 2; | |
| 62 | − while (usedIds.has(`term-${nextIndex}`)) { | |
| 63 | − nextIndex += 1; | |
| 55 | +/** | |
| 56 | + * Picks an id for "open another shell". Counts the terminal screen already mounted | |
| 57 | + * (`activeRouteTerminalId`) as occupied so an empty session list on the primary route | |
| 58 | + * still advances to `term-2` instead of `replace`-navigating to the same `default` tab. | |
| 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 | 74 | export function buildTerminalMenuSessions(input: { |
| @@ -73,14 +78,6 @@ export function buildTerminalMenuSessions(input: { · 3 unmodified lines | ||
| 73 | 78 | }): ReadonlyArray<TerminalMenuSession> { |
| 74 | 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 | 81 | for (const session of input.knownSessions) { |
| 85 | 82 | if ( |
| 86 | 83 | session.state.status !== "running" && |
| @@ -95,6 +92,7 @@ export function buildTerminalMenuSessions(input: { · 8 unmodified lines | ||
| 95 | 92 | cwd: session.state.summary?.cwd ?? input.workspaceRoot, |
| 96 | 93 | status: session.state.status, |
| 97 | 94 | hasRunningSubprocess: session.state.hasRunningSubprocess, |
| 95 | + displayLabel: resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), | |
| 98 | 96 | updatedAt: session.state.updatedAt, |
| 99 | 97 | }); |
| 100 | 98 | } |
| @@ -103,18 +101,9 @@ export function buildTerminalMenuSessions(input: { · 2 unmodified lines | ||
| 103 | 101 | sessionsById.set(input.currentSession.terminalId, input.currentSession); |
| 104 | 102 | } |
| 105 | 103 | |
| 106 | − return Array.from(sessionsById.values()).sort((left, right) => { | |
| 107 | − if (left.terminalId === DEFAULT_TERMINAL_ID) return -1; | |
| 108 | − if (right.terminalId === DEFAULT_TERMINAL_ID) return 1; | |
| 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 | − }); | |
| 104 | + return Array.from(sessionsById.values()).sort((left, right) => | |
| 105 | + left.terminalId.localeCompare(right.terminalId, undefined, { numeric: true }), | |
| 106 | + ); | |
| 118 | 107 | } |
| 119 | 108 | |
| 120 | 109 | export function resolveProjectScriptTerminalId(input: { |
| @@ -6,6 +6,7 @@ export function resolveTerminalRouteBootstrap(input: { · 5 unmodified lines | ||
| 6 | 6 | readonly currentTerminalId: string; |
| 7 | 7 | readonly runningTerminalId: string | null; |
| 8 | 8 | readonly currentTerminalStatus: "starting" | "running" | "exited" | "error" | "closed"; |
| 9 | + /** True once the attach stream has populated scrollback (`buffer` non-empty), not merely metadata. */ | |
| 9 | 10 | readonly hasCurrentTerminalHydration: boolean; |
| 10 | 11 | }): |
| 11 | 12 | | { readonly kind: "idle" } |
| @@ -11,7 +11,6 @@ import { Alert, Linking } from "react-native"; · 10 unmodified lines | ||
| 11 | 11 | import { buildThreadReviewRoutePath } from "../../lib/routes"; |
| 12 | 12 | import { |
| 13 | 13 | basename, |
| 14 | − getTerminalLabel, | |
| 15 | 14 | getTerminalStatusLabel, |
| 16 | 15 | projectScriptMenuIcon, |
| 17 | 16 | projectScriptMenuLabel, |
| @@ -183,19 +182,16 @@ export function ThreadGitControls(props: { · 165 unmodified lines | ||
| 183 | 182 | <Stack.Toolbar placement="right"> |
| 184 | 183 | <Stack.Toolbar.Menu icon="terminal" disabled={!props.canOpenTerminal} separateBackground> |
| 185 | 184 | {props.projectScripts.length > 0 ? ( |
| 186 | − <Stack.Toolbar.Menu icon="play" inline title="Project scripts"> | |
| 187 | − <Stack.Toolbar.Label>Project scripts</Stack.Toolbar.Label> | |
| 188 | − {props.projectScripts.map((script) => ( | |
| 189 | − <Stack.Toolbar.MenuAction | |
| 190 | − key={script.id} | |
| 191 | − icon={projectScriptMenuIcon(script.icon)} | |
| 192 | − onPress={() => void props.onRunProjectScript(script)} | |
| 193 | − subtitle={script.command} | |
| 194 | − > | |
| 195 | − <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label> | |
| 196 | − </Stack.Toolbar.MenuAction> | |
| 197 | − ))} | |
| 198 | − </Stack.Toolbar.Menu> | |
| 185 | + props.projectScripts.map((script) => ( | |
| 186 | + <Stack.Toolbar.MenuAction | |
| 187 | + key={script.id} | |
| 188 | + icon={projectScriptMenuIcon(script.icon)} | |
| 189 | + onPress={() => void props.onRunProjectScript(script)} | |
| 190 | + subtitle={script.command} | |
| 191 | + > | |
| 192 | + <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label> | |
| 193 | + </Stack.Toolbar.MenuAction> | |
| 194 | + )) | |
| 199 | 195 | ) : ( |
| 200 | 196 | <Stack.Toolbar.MenuAction |
| 201 | 197 | icon="play" |
| @@ -206,34 +202,31 @@ export function ThreadGitControls(props: { · 4 unmodified lines | ||
| 206 | 202 | <Stack.Toolbar.Label>No project scripts</Stack.Toolbar.Label> |
| 207 | 203 | </Stack.Toolbar.MenuAction> |
| 208 | 204 | )} |
| 209 | − <Stack.Toolbar.Menu icon="terminal" inline title="Terminals"> | |
| 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 | − ))} | |
| 205 | + {props.terminalSessions.map((session) => ( | |
| 229 | 206 | <Stack.Toolbar.MenuAction |
| 230 | − icon="plus" | |
| 231 | − onPress={props.onOpenNewTerminal} | |
| 232 | − subtitle="Start another shell for this thread" | |
| 207 | + key={session.terminalId} | |
| 208 | + icon="terminal" | |
| 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 | 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 | 230 | </Stack.Toolbar.Menu> |
| 238 | 231 | <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up"> |
| 239 | 232 | <Stack.Toolbar.MenuAction |
| @@ -12,7 +12,7 @@ import { dismissGitActionResult, useGitActionProgress } from "../../state/use-gi · 11 unmodified lines | ||
| 12 | 12 | |
| 13 | 13 | import { EmptyState } from "../../components/EmptyState"; |
| 14 | 14 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 15 | −import { buildThreadRoutePath, buildThreadTerminalRoutePath } from "../../lib/routes"; | |
| 15 | +import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes"; | |
| 16 | 16 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 17 | 17 | import { connectionTone } from "../connection/connectionTone"; |
| 18 | 18 | |
| @@ -27,13 +27,14 @@ import { useThreadSelection } from "../../state/use-thread-selection"; · 8 unmodified lines | ||
| 27 | 27 | import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; |
| 28 | 28 | import { |
| 29 | 29 | buildTerminalMenuSessions, |
| 30 | − nextTerminalId, | |
| 30 | + nextOpenTerminalId, | |
| 31 | 31 | resolveProjectScriptTerminalId, |
| 32 | 32 | } from "../terminal/terminalMenu"; |
| 33 | 33 | import { |
| 34 | 34 | resolvePreferredThreadWorktreePath, |
| 35 | 35 | stagePendingTerminalLaunch, |
| 36 | 36 | } from "../terminal/terminalLaunchContext"; |
| 37 | +import { terminalDebugLog } from "../terminal/terminalDebugLog"; | |
| 37 | 38 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; |
| 38 | 39 | import { ThreadGitControls } from "./ThreadGitControls"; |
| 39 | 40 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; |
| @@ -145,27 +146,52 @@ export function ThreadRouteScreen() { · 105 unmodified lines | ||
| 145 | 146 | |
| 146 | 147 | const handleOpenTerminal = useCallback( |
| 147 | 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 | 155 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { |
| 149 | 156 | return; |
| 150 | 157 | } |
| 151 | 158 | |
| 152 | − void router.push(buildThreadTerminalRoutePath(selectedThread, nextTerminalId)); | |
| 159 | + void router.push(buildThreadTerminalNavigation(selectedThread, nextTerminalId)); | |
| 153 | 160 | }, |
| 154 | 161 | [router, selectedThread, selectedThreadProject?.workspaceRoot], |
| 155 | 162 | ); |
| 156 | 163 | |
| 157 | 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 | 171 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { |
| 159 | 172 | return; |
| 160 | 173 | } |
| 161 | 174 | |
| 162 | − const nextId = nextTerminalId(terminalMenuSessions.map((session) => session.terminalId)); | |
| 163 | − void router.push(buildThreadTerminalRoutePath(selectedThread, nextId)); | |
| 175 | + const nextId = nextOpenTerminalId({ | |
| 176 | + listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | |
| 177 | + }); | |
| 178 | + void router.push(buildThreadTerminalNavigation(selectedThread, nextId)); | |
| 164 | 179 | }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]); |
| 165 | 180 | |
| 166 | 181 | const handleRunProjectScript = useCallback( |
| 167 | 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 | 190 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { |
| 191 | + terminalDebugLog("project-script:abort", { | |
| 192 | + scriptId: script.id, | |
| 193 | + reason: "no-thread-or-workspace", | |
| 194 | + }); | |
| 169 | 195 | return; |
| 170 | 196 | } |
| 171 | 197 | |
| @@ -200,8 +226,14 @@ export function ThreadRouteScreen() { · 28 unmodified lines | ||
| 200 | 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 | 239 | router, |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import type { Router } from "expo-router"; | |
| 1 | +import type { Href, Router } from "expo-router"; | |
| 2 | 2 | |
| 3 | 3 | import type { SelectedThreadRef } from "../state/remote-runtime-types"; |
| 4 | 4 | import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; |
| @@ -41,6 +41,33 @@ export function buildThreadTerminalRoutePath( · 36 unmodified lines | ||
| 41 | 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 | 71 | export function dismissRoute(router: Router) { |
| 45 | 72 | if (router.canGoBack()) { |
| 46 | 73 | router.back(); |
| @@ -18,6 +18,7 @@ import * as Option from "effect/Option"; · 17 unmodified lines | ||
| 18 | 18 | import { pipe } from "effect/Function"; |
| 19 | 19 | import { Atom } from "effect/unstable/reactivity"; |
| 20 | 20 | import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection"; |
| 21 | +import { terminalDebugLog } from "../features/terminal/terminalDebugLog"; | |
| 21 | 22 | import { clearSavedConnection, loadSavedConnections, saveConnection } from "../lib/storage"; |
| 22 | 23 | import { appAtomRegistry } from "./atom-registry"; |
| 23 | 24 | import { type ConnectedEnvironmentSummary, type EnvironmentSession } from "./remote-runtime-types"; |
| @@ -268,6 +269,9 @@ export async function connectSavedEnvironment( · 244 unmodified lines | ||
| 268 | 269 | client, |
| 269 | 270 | }), |
| 270 | 271 | ); |
| 272 | + terminalDebugLog("registry:terminal-metadata-subscribed", { | |
| 273 | + environmentId: connection.environmentId, | |
| 274 | + }); | |
| 271 | 275 | notifyEnvironmentConnectionListeners(); |
| 272 | 276 | |
| 273 | 277 | try { |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | import type { |
| 14 | 14 | EnvironmentId, |
| 15 | 15 | TerminalAttachInput, |
| 16 | + TerminalAttachStreamEvent, | |
| 16 | 17 | TerminalMetadataStreamEvent, |
| 17 | 18 | TerminalSessionSnapshot, |
| 18 | 19 | } from "@t3tools/contracts"; |
| @@ -43,12 +44,14 @@ export function attachTerminalSession(input: { · 24 unmodified lines | ||
| 43 | 44 | readonly client: Parameters<typeof terminalSessionManager.attach>[0]["client"]; |
| 44 | 45 | readonly terminal: TerminalAttachInput; |
| 45 | 46 | readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; |
| 47 | + readonly onEvent?: (event: TerminalAttachStreamEvent) => void; | |
| 46 | 48 | }) { |
| 47 | 49 | return terminalSessionManager.attach({ |
| 48 | 50 | environmentId: input.environmentId, |
| 49 | 51 | client: input.client, |
| 50 | 52 | terminal: input.terminal, |
| 51 | 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 | 91 | history: "", |
| 92 | 92 | exitCode: null, |
| 93 | 93 | exitSignal: null, |
| 94 | + label: "setup-setup", | |
| 94 | 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 | 3929 | history: "", |
| 3930 | 3930 | exitCode: null, |
| 3931 | 3931 | exitSignal: null, |
| 3932 | + label: "Primary", | |
| 3932 | 3933 | updatedAt: new Date().toISOString(), |
| 3933 | 3934 | }; |
| 3934 | 3935 | |
| @@ -198,7 +198,10 @@ interface CreateManagerOptions { · 197 unmodified lines | ||
| 198 | 198 | shellResolver?: () => string; |
| 199 | 199 | platform?: NodeJS.Platform; |
| 200 | 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 | 205 | subprocessPollIntervalMs?: number; |
| 203 | 206 | processKillGraceMs?: number; |
| 204 | 207 | maxRetainedInactiveSessions?: number; |
| @@ -234,8 +237,8 @@ const createManager = ( · 29 unmodified lines | ||
| 234 | 237 | ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), |
| 235 | 238 | ...(options.platform !== undefined ? { platform: options.platform } : {}), |
| 236 | 239 | ...(options.env !== undefined ? { env: options.env } : {}), |
| 237 | − ...(options.subprocessChecker !== undefined | |
| 238 | − ? { subprocessChecker: options.subprocessChecker } | |
| 240 | + ...(options.subprocessInspector !== undefined | |
| 241 | + ? { subprocessInspector: options.subprocessInspector } | |
| 239 | 242 | : {}), |
| 240 | 243 | ...(options.subprocessPollIntervalMs !== undefined |
| 241 | 244 | ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } |
| @@ -275,7 +278,7 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 33 unmodified lines | ||
| 275 | 278 | const third = yield* manager.open(openInput()); |
| 276 | 279 | |
| 277 | 280 | assert.equal(first.threadId, "thread-1"); |
| 278 | − assert.equal(first.terminalId, "default"); | |
| 281 | + assert.equal(first.terminalId, DEFAULT_TERMINAL_ID); | |
| 279 | 282 | assert.equal(second.threadId, "thread-1"); |
| 280 | 283 | assert.equal(third.threadId, "thread-1"); |
| 281 | 284 | expect(ptyAdapter.spawnInputs).toHaveLength(1); |
| @@ -445,7 +448,7 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 163 unmodified lines | ||
| 445 | 448 | (event) => |
| 446 | 449 | event.type === "cleared" && |
| 447 | 450 | event.threadId === "thread-1" && |
| 448 | − event.terminalId === "default", | |
| 451 | + event.terminalId === DEFAULT_TERMINAL_ID, | |
| 449 | 452 | ), |
| 450 | 453 | ).toBe(true); |
| 451 | 454 | }), |
| @@ -617,27 +620,40 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 165 unmodified lines | ||
| 617 | 620 | |
| 618 | 621 | it.effect("emits subprocess activity events when child-process state changes", () => |
| 619 | 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 | 627 | const { manager, getEvents } = yield* createManager(5, { |
| 622 | − subprocessChecker: () => Effect.succeed(hasRunningSubprocess), | |
| 628 | + subprocessInspector: () => Effect.succeed(inspect), | |
| 623 | 629 | subprocessPollIntervalMs: 20, |
| 624 | 630 | }); |
| 625 | 631 | |
| 626 | 632 | yield* manager.open(openInput()); |
| 627 | 633 | expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); |
| 628 | 634 | |
| 629 | − hasRunningSubprocess = true; | |
| 635 | + inspect = { hasRunningSubprocess: true, childCommand: "vim" }; | |
| 630 | 636 | yield* waitFor( |
| 631 | 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 | 645 | "1200 millis", |
| 635 | 646 | ); |
| 636 | 647 | |
| 637 | − hasRunningSubprocess = false; | |
| 648 | + inspect = { hasRunningSubprocess: false, childCommand: null }; | |
| 638 | 649 | yield* waitFor( |
| 639 | 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 | 658 | "1200 millis", |
| 643 | 659 | ); |
| @@ -648,9 +664,9 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 4 unmodified lines | ||
| 648 | 664 | Effect.gen(function* () { |
| 649 | 665 | let checks = 0; |
| 650 | 666 | const { manager } = yield* createManager(5, { |
| 651 | − subprocessChecker: () => { | |
| 667 | + subprocessInspector: () => { | |
| 652 | 668 | checks += 1; |
| 653 | − return Effect.succeed(false); | |
| 669 | + return Effect.succeed({ hasRunningSubprocess: false, childCommand: null }); | |
| 654 | 670 | }, |
| 655 | 671 | subprocessPollIntervalMs: 20, |
| 656 | 672 | }); |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | type TerminalSummary, |
| 13 | 13 | } from "@t3tools/contracts"; |
| 14 | 14 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; |
| 15 | +import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | |
| 15 | 16 | import { |
| 16 | 17 | Effect, |
| 17 | 18 | Encoding, |
| @@ -58,6 +59,7 @@ const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; · 40 unmodified lines | ||
| 58 | 59 | const DEFAULT_OPEN_COLS = 120; |
| 59 | 60 | const DEFAULT_OPEN_ROWS = 30; |
| 60 | 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 | 64 | class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()( |
| 63 | 65 | "TerminalSubprocessCheckError", |
| @@ -78,8 +80,15 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess · 14 unmodified lines | ||
| 78 | 80 | }, |
| 79 | 81 | ) {} |
| 80 | 82 | |
| 81 | −interface TerminalSubprocessChecker { | |
| 82 | − (terminalPid: number): Effect.Effect<boolean, TerminalSubprocessCheckError>; | |
| 83 | +interface TerminalSubprocessInspectResult { | |
| 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 | 94 | interface ShellCandidate { |
| @@ -119,6 +128,8 @@ interface TerminalSessionState { · 33 unmodified lines | ||
| 119 | 128 | unsubscribeData: (() => void) | null; |
| 120 | 129 | unsubscribeExit: (() => void) | null; |
| 121 | 130 | hasRunningSubprocess: boolean; |
| 131 | + /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */ | |
| 132 | + childCommandLabel: string | null; | |
| 122 | 133 | runtimeEnv: Record<string, string> | null; |
| 123 | 134 | } |
| 124 | 135 | |
| @@ -154,6 +165,38 @@ interface TerminalManagerState { · 29 unmodified lines | ||
| 154 | 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 | 200 | function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { |
| 158 | 201 | return { |
| 159 | 202 | threadId: session.threadId, |
| @@ -165,6 +208,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { · 5 unmodified lines | ||
| 165 | 208 | history: session.history, |
| 166 | 209 | exitCode: session.exitCode, |
| 167 | 210 | exitSignal: session.exitSignal, |
| 211 | + label: terminalWireLabel(session), | |
| 168 | 212 | updatedAt: session.updatedAt, |
| 169 | 213 | sequence: session.eventSequence, |
| 170 | 214 | }; |
| @@ -181,6 +225,7 @@ function summary(session: TerminalSessionState): TerminalSummary { · 10 unmodified lines | ||
| 181 | 225 | exitCode: session.exitCode, |
| 182 | 226 | exitSignal: session.exitSignal, |
| 183 | 227 | hasRunningSubprocess: session.hasRunningSubprocess, |
| 228 | + label: terminalWireLabel(session), | |
| 184 | 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 | 485 | terminalPid: number, |
| 431 | −): Effect.Effect<boolean, TerminalSubprocessCheckError> { | |
| 486 | + platform: NodeJS.Platform, | |
| 487 | +): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError> { | |
| 432 | 488 | const command = [ |
| 433 | − `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, | |
| 434 | − "if ($children) { exit 0 }", | |
| 435 | − "exit 1", | |
| 489 | + `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`, | |
| 490 | + "if ($null -eq $c) { exit 1 }", | |
| 491 | + "Write-Output $c.Name", | |
| 492 | + "exit 0", | |
| 436 | 493 | ].join("; "); |
| 437 | 494 | return Effect.tryPromise({ |
| 438 | 495 | try: () => |
| @@ -444,17 +501,33 @@ function checkWindowsSubprocessActivity( · 5 unmodified lines | ||
| 444 | 501 | }), |
| 445 | 502 | catch: (cause) => |
| 446 | 503 | new TerminalSubprocessCheckError({ |
| 447 | − message: "Failed to check Windows terminal subprocess activity.", | |
| 504 | + message: "Failed to inspect Windows terminal subprocesses.", | |
| 448 | 505 | cause, |
| 449 | 506 | terminalPid, |
| 450 | 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 | 528 | terminalPid: number, |
| 457 | −): Effect.fn.Return<boolean, TerminalSubprocessCheckError> { | |
| 529 | + platform: NodeJS.Platform, | |
| 530 | +): Effect.fn.Return<TerminalSubprocessInspectResult, TerminalSubprocessCheckError> { | |
| 458 | 531 | const runPgrep = Effect.tryPromise({ |
| 459 | 532 | try: () => |
| 460 | 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 | 567 | const pgrepResult = yield* Effect.exit(runPgrep); |
| 493 | 568 | if (pgrepResult._tag === "Success") { |
| 494 | 569 | if (pgrepResult.value.code === 0) { |
| 495 | − return pgrepResult.value.stdout.trim().length > 0; | |
| 496 | − } | |
| 497 | − if (pgrepResult.value.code === 1) { | |
| 498 | − return false; | |
| 570 | + childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); | |
| 571 | + } else if (pgrepResult.value.code === 1) { | |
| 572 | + return { hasRunningSubprocess: false, childCommand: null }; | |
| 499 | 573 | } |
| 500 | 574 | } |
| 501 | 575 | |
| 502 | − const psResult = yield* Effect.exit(runPs); | |
| 503 | − if (psResult._tag === "Failure" || psResult.value.code !== 0) { | |
| 504 | − return false; | |
| 576 | + if (childPid === null) { | |
| 577 | + const psResult = yield* Effect.exit(runPs); | |
| 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)) { | |
| 508 | − const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | |
| 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 | − } | |
| 593 | + if (childPid === null) { | |
| 594 | + return { hasRunningSubprocess: false, childCommand: null }; | |
| 515 | 595 | } |
| 516 | − return false; | |
| 517 | −}); | |
| 518 | 596 | |
| 519 | −const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* ( | |
| 520 | − terminalPid: number, | |
| 521 | −): Effect.fn.Return<boolean, TerminalSubprocessCheckError> { | |
| 522 | − if (!Number.isInteger(terminalPid) || terminalPid <= 0) { | |
| 523 | − return false; | |
| 597 | + const runComm = Effect.tryPromise({ | |
| 598 | + try: () => | |
| 599 | + runProcess("ps", ["-p", String(childPid), "-o", "comm="], { | |
| 600 | + timeoutMs: 1_000, | |
| 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") { | |
| 526 | − return yield* checkWindowsSubprocessActivity(terminalPid); | |
| 613 | + | |
| 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 | 653 | function capHistory(history: string, maxLines: number): string { |
| 532 | 654 | if (history.length === 0) return history; |
| 533 | 655 | const hasTrailingNewline = history.endsWith("\n"); |
| @@ -775,7 +897,7 @@ interface TerminalManagerOptions { · 241 unmodified lines | ||
| 775 | 897 | shellResolver?: () => string; |
| 776 | 898 | platform?: NodeJS.Platform; |
| 777 | 899 | env?: NodeJS.ProcessEnv; |
| 778 | − subprocessChecker?: TerminalSubprocessChecker; | |
| 900 | + subprocessInspector?: TerminalSubprocessInspector; | |
| 779 | 901 | subprocessPollIntervalMs?: number; |
| 780 | 902 | processKillGraceMs?: number; |
| 781 | 903 | maxRetainedInactiveSessions?: number; |
| @@ -801,7 +923,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 19 unmodified lines | ||
| 801 | 923 | const platform = options.platform ?? process.platform; |
| 802 | 924 | const baseEnv = options.env ?? process.env; |
| 803 | 925 | const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); |
| 804 | − const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; | |
| 926 | + const subprocessInspector = | |
| 927 | + options.subprocessInspector ?? defaultSubprocessInspectorForPlatform(platform); | |
| 805 | 928 | const subprocessPollIntervalMs = |
| 806 | 929 | options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; |
| 807 | 930 | const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; |
| @@ -1290,6 +1413,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 482 unmodified lines | ||
| 1290 | 1413 | session.process = null; |
| 1291 | 1414 | session.pid = null; |
| 1292 | 1415 | session.hasRunningSubprocess = false; |
| 1416 | + session.childCommandLabel = null; | |
| 1293 | 1417 | session.status = "exited"; |
| 1294 | 1418 | session.pendingHistoryControlSequence = ""; |
| 1295 | 1419 | session.pendingProcessEvents = []; |
| @@ -1358,6 +1482,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 62 unmodified lines | ||
| 1358 | 1482 | session.process = null; |
| 1359 | 1483 | session.pid = null; |
| 1360 | 1484 | session.hasRunningSubprocess = false; |
| 1485 | + session.childCommandLabel = null; | |
| 1361 | 1486 | session.status = "exited"; |
| 1362 | 1487 | session.pendingHistoryControlSequence = ""; |
| 1363 | 1488 | session.pendingProcessEvents = []; |
| @@ -1451,6 +1576,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 87 unmodified lines | ||
| 1451 | 1576 | session.exitCode = null; |
| 1452 | 1577 | session.exitSignal = null; |
| 1453 | 1578 | session.hasRunningSubprocess = false; |
| 1579 | + session.childCommandLabel = null; | |
| 1454 | 1580 | session.pendingProcessEvents = []; |
| 1455 | 1581 | session.pendingProcessEventIndex = 0; |
| 1456 | 1582 | session.processEventDrainRunning = false; |
| @@ -1528,6 +1654,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 71 unmodified lines | ||
| 1528 | 1654 | session.unsubscribeData = null; |
| 1529 | 1655 | session.unsubscribeExit = null; |
| 1530 | 1656 | session.hasRunningSubprocess = false; |
| 1657 | + session.childCommandLabel = null; | |
| 1531 | 1658 | session.pendingProcessEvents = []; |
| 1532 | 1659 | session.pendingProcessEventIndex = 0; |
| 1533 | 1660 | session.processEventDrainRunning = false; |
| @@ -1608,7 +1735,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 74 unmodified lines | ||
| 1608 | 1735 | session: TerminalSessionState & { pid: number }, |
| 1609 | 1736 | ) { |
| 1610 | 1737 | const terminalPid = session.pid; |
| 1611 | − const hasRunningSubprocess = yield* subprocessChecker(terminalPid).pipe( | |
| 1738 | + const inspectResult = yield* subprocessInspector(terminalPid).pipe( | |
| 1612 | 1739 | Effect.map(Option.some), |
| 1613 | 1740 | Effect.catch((reason) => |
| 1614 | 1741 | Effect.logWarning("failed to check terminal subprocess activity", { |
| @@ -1616,14 +1743,17 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 1 unmodified line | ||
| 1616 | 1743 | terminalId: session.terminalId, |
| 1617 | 1744 | terminalPid, |
| 1618 | 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 | 1751 | return; |
| 1625 | 1752 | } |
| 1626 | 1753 | |
| 1754 | + const next = inspectResult.value; | |
| 1755 | + const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; | |
| 1756 | + | |
| 1627 | 1757 | const event = yield* modifyManagerState((state) => { |
| 1628 | 1758 | const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr( |
| 1629 | 1759 | state.sessions.get(toSessionKey(session.threadId, session.terminalId)), |
| @@ -1632,12 +1762,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 2 unmodified lines | ||
| 1632 | 1762 | Option.isNone(liveSession) || |
| 1633 | 1763 | liveSession.value.status !== "running" || |
| 1634 | 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 | 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 | 1773 | const eventStamp = advanceEventSequence(liveSession.value); |
| 1642 | 1774 | |
| 1643 | 1775 | return [ |
| @@ -1646,7 +1778,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 2 unmodified lines | ||
| 1646 | 1778 | threadId: liveSession.value.threadId, |
| 1647 | 1779 | terminalId: liveSession.value.terminalId, |
| 1648 | 1780 | sequence: eventStamp.sequence, |
| 1649 | − hasRunningSubprocess: hasRunningSubprocess.value, | |
| 1781 | + hasRunningSubprocess: next.hasRunningSubprocess, | |
| 1782 | + label: terminalWireLabel(liveSession.value), | |
| 1650 | 1783 | }), |
| 1651 | 1784 | state, |
| 1652 | 1785 | ] as const; |
| @@ -1711,7 +1844,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 58 unmodified lines | ||
| 1711 | 1844 | ); |
| 1712 | 1845 | |
| 1713 | 1846 | const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { |
| 1714 | − const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 1847 | + const terminalId = input.terminalId; | |
| 1715 | 1848 | yield* assertValidCwd(input.cwd); |
| 1716 | 1849 | |
| 1717 | 1850 | const sessionKey = toSessionKey(input.threadId, terminalId); |
| @@ -1743,6 +1876,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 25 unmodified lines | ||
| 1743 | 1876 | unsubscribeData: null, |
| 1744 | 1877 | unsubscribeExit: null, |
| 1745 | 1878 | hasRunningSubprocess: false, |
| 1879 | + childCommandLabel: null, | |
| 1746 | 1880 | runtimeEnv: normalizedRuntimeEnv(input.env), |
| 1747 | 1881 | }; |
| 1748 | 1882 | |
| @@ -1839,7 +1973,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 90 unmodified lines | ||
| 1839 | 1973 | withThreadLock( |
| 1840 | 1974 | input.threadId, |
| 1841 | 1975 | Effect.gen(function* () { |
| 1842 | − const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 1976 | + const terminalId = input.terminalId; | |
| 1843 | 1977 | const existing = yield* getSession(input.threadId, terminalId); |
| 1844 | 1978 | |
| 1845 | 1979 | if (Option.isNone(existing)) { |
| @@ -1918,10 +2052,12 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 72 unmodified lines | ||
| 1918 | 2052 | let unsubscribe: (() => void) | null = null; |
| 1919 | 2053 | |
| 1920 | 2054 | return Effect.gen(function* () { |
| 1921 | − const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 1922 | 2055 | const bufferedEvents: TerminalEvent[] = []; |
| 1923 | 2056 | let deliverLive = false; |
| 1924 | 2057 | |
| 2058 | + const initialSnapshot = yield* openOrAttachForStream(input); | |
| 2059 | + const terminalId = initialSnapshot.terminalId; | |
| 2060 | + | |
| 1925 | 2061 | unsubscribe = yield* subscribe((event) => { |
| 1926 | 2062 | if (event.threadId !== input.threadId || event.terminalId !== terminalId) { |
| 1927 | 2063 | return Effect.void; |
| @@ -1936,7 +2072,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 8 unmodified lines | ||
| 1936 | 2072 | return attachEvent ? listener(attachEvent) : Effect.void; |
| 1937 | 2073 | }); |
| 1938 | 2074 | |
| 1939 | − const initialSnapshot = yield* openOrAttachForStream(input); | |
| 1940 | 2075 | yield* listener({ |
| 1941 | 2076 | type: "snapshot", |
| 1942 | 2077 | snapshot: initialSnapshot, |
| @@ -2044,7 +2179,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 101 unmodified lines | ||
| 2044 | 2179 | }; |
| 2045 | 2180 | |
| 2046 | 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 | 2183 | const session = yield* requireSession(input.threadId, terminalId); |
| 2049 | 2184 | const process = session.process; |
| 2050 | 2185 | if (!process || session.status !== "running") { |
| @@ -2058,7 +2193,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 7 unmodified lines | ||
| 2058 | 2193 | }); |
| 2059 | 2194 | |
| 2060 | 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 | 2197 | const session = yield* requireSession(input.threadId, terminalId); |
| 2063 | 2198 | const process = session.process; |
| 2064 | 2199 | if (!process || session.status !== "running") { |
| @@ -2077,7 +2212,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 12 unmodified lines | ||
| 2077 | 2212 | withThreadLock( |
| 2078 | 2213 | input.threadId, |
| 2079 | 2214 | Effect.gen(function* () { |
| 2080 | − const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 2215 | + const terminalId = input.terminalId; | |
| 2081 | 2216 | const session = yield* requireSession(input.threadId, terminalId); |
| 2082 | 2217 | session.history = ""; |
| 2083 | 2218 | session.pendingHistoryControlSequence = ""; |
| @@ -2100,7 +2235,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 16 unmodified lines | ||
| 2100 | 2235 | input.threadId, |
| 2101 | 2236 | Effect.gen(function* () { |
| 2102 | 2237 | yield* increment(terminalRestartsTotal, { scope: "thread" }); |
| 2103 | − const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 2238 | + const terminalId = input.terminalId; | |
| 2104 | 2239 | yield* assertValidCwd(input.cwd); |
| 2105 | 2240 | |
| 2106 | 2241 | const sessionKey = toSessionKey(input.threadId, terminalId); |
| @@ -2131,6 +2266,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 24 unmodified lines | ||
| 2131 | 2266 | unsubscribeData: null, |
| 2132 | 2267 | unsubscribeExit: null, |
| 2133 | 2268 | hasRunningSubprocess: false, |
| 2269 | + childCommandLabel: null, | |
| 2134 | 2270 | runtimeEnv: normalizedRuntimeEnv(input.env), |
| 2135 | 2271 | }; |
| 2136 | 2272 | const createdSession = session; |
| @@ -993,6 +993,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { · 992 unmodified lines | ||
| 993 | 993 | history: "", |
| 994 | 994 | exitCode: null, |
| 995 | 995 | exitSignal: null, |
| 996 | + label: "Terminal 1", | |
| 996 | 997 | updatedAt: NOW_ISO, |
| 997 | 998 | }; |
| 998 | 999 | } |
| @@ -1767,6 +1768,26 @@ describe("ChatView timeline estimator parity (full app)", () => { · 768 unmodified lines | ||
| 1767 | 1768 | const mounted = await mountChatView({ |
| 1768 | 1769 | viewport: DEFAULT_VIEWPORT, |
| 1769 | 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 | 1793 | try { |
| @@ -3550,6 +3571,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1777 unmodified lines | ||
| 3550 | 3571 | exitCode: null, |
| 3551 | 3572 | exitSignal: null, |
| 3552 | 3573 | hasRunningSubprocess: true, |
| 3574 | + label: "Terminal 1", | |
| 3553 | 3575 | updatedAt: isoAt(1_200), |
| 3554 | 3576 | }, |
| 3555 | 3577 | }, |
| @@ -29,6 +29,7 @@ import { · 28 unmodified lines | ||
| 29 | 29 | import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model"; |
| 30 | 30 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 31 | 31 | import { truncate } from "@t3tools/shared/String"; |
| 32 | +import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; | |
| 32 | 33 | import { Debouncer } from "@tanstack/react-pacer"; |
| 33 | 34 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 34 | 35 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| @@ -100,7 +101,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" · 65 unmodified lines | ||
| 100 | 101 | import PlanSidebar from "./PlanSidebar"; |
| 101 | 102 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 102 | 103 | import { ChevronDownIcon } from "lucide-react"; |
| 103 | −import { cn, randomUUID } from "~/lib/utils"; | |
| 104 | +import { cn } from "~/lib/utils"; | |
| 104 | 105 | import { toastManager } from "./ui/toast"; |
| 105 | 106 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| 106 | 107 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| @@ -133,7 +134,7 @@ import { · 26 unmodified lines | ||
| 133 | 134 | type TerminalContextSelection, |
| 134 | 135 | } from "../lib/terminalContext"; |
| 135 | 136 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 136 | −import { useThreadRunningTerminalIds } from "../terminalSessionState"; | |
| 137 | +import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; | |
| 137 | 138 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; |
| 138 | 139 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; |
| 139 | 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 | 451 | interface PersistentThreadTerminalDrawerProps { |
| 412 | 452 | threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; |
| 413 | 453 | threadId: ThreadId; |
| @@ -444,11 +484,42 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 30 unmodified lines | ||
| 444 | 484 | const terminalUiState = useTerminalUiStateStore((state) => |
| 445 | 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 | 505 | const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); |
| 448 | 506 | const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); |
| 449 | 507 | const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); |
| 450 | 508 | const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); |
| 451 | 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 | 523 | const [localFocusRequestId, setLocalFocusRequestId] = useState(0); |
| 453 | 524 | const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| 454 | 525 | const effectiveWorktreePath = useMemo(() => { |
| @@ -494,14 +565,68 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 39 unmodified lines | ||
| 494 | 565 | ); |
| 495 | 566 | |
| 496 | 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 | 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 | 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 | 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 | 631 | const activateTerminal = useCallback( |
| 507 | 632 | (terminalId: string) => { |
| @@ -537,13 +662,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 29 unmodified lines | ||
| 537 | 662 | storeCloseTerminal(threadRef, terminalId); |
| 538 | 663 | bumpFocusRequestId(); |
| 539 | 664 | }, |
| 540 | − [ | |
| 541 | − bumpFocusRequestId, | |
| 542 | − storeCloseTerminal, | |
| 543 | − terminalUiState.terminalIds.length, | |
| 544 | − threadId, | |
| 545 | − threadRef, | |
| 546 | − ], | |
| 665 | + [bumpFocusRequestId, storeCloseTerminal, terminalUiState.terminalIds, threadId, threadRef], | |
| 547 | 666 | ); |
| 548 | 667 | |
| 549 | 668 | const handleAddTerminalContext = useCallback( |
| @@ -570,6 +689,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 20 unmodified lines | ||
| 570 | 689 | runtimeEnv={runtimeEnv} |
| 571 | 690 | visible={visible} |
| 572 | 691 | height={terminalUiState.terminalHeight} |
| 692 | + // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable. | |
| 573 | 693 | terminalIds={terminalUiState.terminalIds} |
| 574 | 694 | activeTerminalId={terminalUiState.activeTerminalId} |
| 575 | 695 | terminalGroups={terminalUiState.terminalGroups} |
| @@ -585,6 +705,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 9 unmodified lines | ||
| 585 | 705 | onCloseTerminal={closeTerminal} |
| 586 | 706 | onHeightChange={setTerminalHeight} |
| 587 | 707 | onAddTerminalContext={handleAddTerminalContext} |
| 708 | + terminalLabelsById={terminalLabelsById} | |
| 588 | 709 | /> |
| 589 | 710 | </div> |
| 590 | 711 | ); |
| @@ -797,11 +918,52 @@ export default function ChatView(props: ChatViewProps) { · 206 unmodified lines | ||
| 797 | 918 | environmentId: activeThread?.environmentId ?? null, |
| 798 | 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 | 938 | const activeThreadRef = useMemo( |
| 801 | 939 | () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), |
| 802 | 940 | [activeThread], |
| 803 | 941 | ); |
| 804 | 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 | 967 | const existingOpenTerminalThreadKeys = useMemo(() => { |
| 806 | 968 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); |
| 807 | 969 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); |
| @@ -1581,22 +1743,93 @@ export default function ChatView(props: ChatViewProps) { · 773 unmodified lines | ||
| 1581 | 1743 | setTerminalOpen(!terminalUiState.terminalOpen); |
| 1582 | 1744 | }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); |
| 1583 | 1745 | const splitTerminal = useCallback(() => { |
| 1584 | − if (!activeThreadRef || hasReachedSplitLimit) return; | |
| 1585 | − const terminalId = `terminal-${randomUUID()}`; | |
| 1746 | + if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { | |
| 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 | 1758 | storeSplitTerminal(activeThreadRef, terminalId); |
| 1587 | 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 | 1787 | const createNewTerminal = useCallback(() => { |
| 1590 | − if (!activeThreadRef) return; | |
| 1591 | − const terminalId = `terminal-${randomUUID()}`; | |
| 1788 | + if (!activeThreadRef || !activeThreadId || !activeProject) { | |
| 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 | 1800 | storeNewTerminal(activeThreadRef, terminalId); |
| 1593 | 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 | 1828 | const closeTerminal = useCallback( |
| 1596 | 1829 | (terminalId: string) => { |
| 1597 | 1830 | const api = readEnvironmentApi(environmentId); |
| 1598 | − if (!activeThreadId || !api) return; | |
| 1599 | − const isFinalTerminal = terminalUiState.terminalIds.length <= 1; | |
| 1831 | + if (!activeThreadId || !api || !activeThreadRef) return; | |
| 1832 | + const isFinalTerminal = activeServerOrderedTerminalIds.length <= 1; | |
| 1600 | 1833 | const fallbackExitWrite = () => |
| 1601 | 1834 | api.terminal |
| 1602 | 1835 | .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) |
| @@ -1617,17 +1850,15 @@ export default function ChatView(props: ChatViewProps) { · 14 unmodified lines | ||
| 1617 | 1850 | } else { |
| 1618 | 1851 | void fallbackExitWrite(); |
| 1619 | 1852 | } |
| 1620 | − if (activeThreadRef) { | |
| 1621 | − storeCloseTerminal(activeThreadRef, terminalId); | |
| 1622 | − } | |
| 1853 | + storeCloseTerminal(activeThreadRef, terminalId); | |
| 1623 | 1854 | setTerminalFocusRequestId((value) => value + 1); |
| 1624 | 1855 | }, |
| 1625 | 1856 | [ |
| 1626 | 1857 | activeThreadId, |
| 1627 | 1858 | activeThreadRef, |
| 1859 | + activeServerOrderedTerminalIds, | |
| 1628 | 1860 | environmentId, |
| 1629 | 1861 | storeCloseTerminal, |
| 1630 | − terminalUiState.terminalIds.length, | |
| 1631 | 1862 | ], |
| 1632 | 1863 | ); |
| 1633 | 1864 | const runProjectScript = useCallback( |
| @@ -1652,14 +1883,11 @@ export default function ChatView(props: ChatViewProps) { · 18 unmodified lines | ||
| 1652 | 1883 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; |
| 1653 | 1884 | const baseTerminalId = |
| 1654 | 1885 | terminalUiState.activeTerminalId || |
| 1655 | − terminalUiState.terminalIds[0] || | |
| 1886 | + activeServerOrderedTerminalIds[0] || | |
| 1656 | 1887 | DEFAULT_THREAD_TERMINAL_ID; |
| 1657 | 1888 | const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); |
| 1658 | 1889 | const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; |
| 1659 | 1890 | const shouldCreateNewTerminal = wantsNewTerminal; |
| 1660 | − const targetTerminalId = shouldCreateNewTerminal | |
| 1661 | − ? `terminal-${randomUUID()}` | |
| 1662 | − : baseTerminalId; | |
| 1663 | 1891 | const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; |
| 1664 | 1892 | |
| 1665 | 1893 | setTerminalUiLaunchContext({ |
| @@ -1671,11 +1899,6 @@ export default function ChatView(props: ChatViewProps) { · 5 unmodified lines | ||
| 1671 | 1899 | if (!activeThreadRef) { |
| 1672 | 1900 | return; |
| 1673 | 1901 | } |
| 1674 | − if (shouldCreateNewTerminal) { | |
| 1675 | − storeNewTerminal(activeThreadRef, targetTerminalId); | |
| 1676 | − } else { | |
| 1677 | − storeSetActiveTerminal(activeThreadRef, targetTerminalId); | |
| 1678 | − } | |
| 1679 | 1902 | setTerminalFocusRequestId((value) => value + 1); |
| 1680 | 1903 | |
| 1681 | 1904 | const runtimeEnv = projectScriptRuntimeEnv({ |
| @@ -1685,6 +1908,9 @@ export default function ChatView(props: ChatViewProps) { · 3 unmodified lines | ||
| 1685 | 1908 | worktreePath: targetWorktreePath, |
| 1686 | 1909 | ...(options?.env ? { extraEnv: options.env } : {}), |
| 1687 | 1910 | }); |
| 1911 | + const targetTerminalId = shouldCreateNewTerminal | |
| 1912 | + ? nextTerminalId(activeServerOrderedTerminalIds) | |
| 1913 | + : baseTerminalId; | |
| 1688 | 1914 | const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal |
| 1689 | 1915 | ? { |
| 1690 | 1916 | threadId: activeThreadId, |
| @@ -1703,6 +1929,12 @@ export default function ChatView(props: ChatViewProps) { · 12 unmodified lines | ||
| 1703 | 1929 | env: runtimeEnv, |
| 1704 | 1930 | }; |
| 1705 | 1931 | |
| 1932 | + if (shouldCreateNewTerminal) { | |
| 1933 | + storeNewTerminal(activeThreadRef, targetTerminalId); | |
| 1934 | + } else { | |
| 1935 | + storeSetActiveTerminal(activeThreadRef, targetTerminalId); | |
| 1936 | + } | |
| 1937 | + | |
| 1706 | 1938 | try { |
| 1707 | 1939 | await api.terminal.open(openTerminalInput); |
| 1708 | 1940 | await api.terminal.write({ |
| @@ -1729,9 +1961,9 @@ export default function ChatView(props: ChatViewProps) { · 20 unmodified lines | ||
| 1729 | 1961 | storeSetActiveTerminal, |
| 1730 | 1962 | setLastInvokedScriptByProjectId, |
| 1731 | 1963 | environmentId, |
| 1964 | + activeServerOrderedTerminalIds, | |
| 1732 | 1965 | runningTerminalIds, |
| 1733 | 1966 | terminalUiState.activeTerminalId, |
| 1734 | − terminalUiState.terminalIds, | |
| 1735 | 1967 | ], |
| 1736 | 1968 | ); |
| 1737 | 1969 | |
| @@ -128,7 +128,7 @@ function createEnvironmentApi() { · 127 unmodified lines | ||
| 128 | 128 | terminal: { |
| 129 | 129 | open: vi.fn(async () => ({ |
| 130 | 130 | threadId: THREAD_ID, |
| 131 | − terminalId: "default", | |
| 131 | + terminalId: "term-1", | |
| 132 | 132 | cwd: "/repo/project", |
| 133 | 133 | worktreePath: null, |
| 134 | 134 | status: "running" as const, |
| @@ -136,6 +136,7 @@ function createEnvironmentApi() { · 1 unmodified line | ||
| 136 | 136 | history: "", |
| 137 | 137 | exitCode: null, |
| 138 | 138 | exitSignal: null, |
| 139 | + label: "Terminal 1", | |
| 139 | 140 | updatedAt: "2026-04-07T00:00:00.000Z", |
| 140 | 141 | })), |
| 141 | 142 | write: vi.fn(async () => undefined), |
| @@ -168,7 +169,7 @@ async function mountTerminalViewport(props: { · 26 unmodified lines | ||
| 168 | 169 | <TerminalViewport |
| 169 | 170 | threadRef={props.threadRef} |
| 170 | 171 | threadId={THREAD_ID} |
| 171 | − terminalId="default" | |
| 172 | + terminalId="term-1" | |
| 172 | 173 | terminalLabel="Terminal" |
| 173 | 174 | cwd="/repo/project" |
| 174 | 175 | onSessionExited={() => undefined} |
| @@ -188,7 +189,7 @@ async function mountTerminalViewport(props: { · 13 unmodified lines | ||
| 188 | 189 | <TerminalViewport |
| 189 | 190 | threadRef={nextProps.threadRef} |
| 190 | 191 | threadId={THREAD_ID} |
| 191 | − terminalId="default" | |
| 192 | + terminalId="term-1" | |
| 192 | 193 | terminalLabel="Terminal" |
| 193 | 194 | cwd="/repo/project" |
| 194 | 195 | onSessionExited={() => undefined} |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | type TerminalSessionSnapshot, |
| 8 | 8 | type ThreadId, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | +import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | |
| 10 | 11 | import { Terminal, type ITheme } from "@xterm/xterm"; |
| 11 | 12 | import { |
| 12 | 13 | type PointerEvent as ReactPointerEvent, |
| @@ -41,7 +42,6 @@ import { · 28 unmodified lines | ||
| 41 | 42 | } from "../keybindings"; |
| 42 | 43 | import { |
| 43 | 44 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 44 | − DEFAULT_THREAD_TERMINAL_ID, | |
| 45 | 45 | MAX_TERMINALS_PER_GROUP, |
| 46 | 46 | type ThreadTerminalGroup, |
| 47 | 47 | } from "../types"; |
| @@ -763,6 +763,8 @@ interface ThreadTerminalDrawerProps { · 715 unmodified lines | ||
| 763 | 763 | onHeightChange: (height: number) => void; |
| 764 | 764 | onAddTerminalContext: (selection: TerminalContextSelection) => void; |
| 765 | 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 | 770 | interface TerminalActionButtonProps { |
| @@ -817,6 +819,7 @@ export default function ThreadTerminalDrawer({ · 48 unmodified lines | ||
| 817 | 819 | onHeightChange, |
| 818 | 820 | onAddTerminalContext, |
| 819 | 821 | keybindings, |
| 822 | + terminalLabelsById, | |
| 820 | 823 | }: ThreadTerminalDrawerProps) { |
| 821 | 824 | const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); |
| 822 | 825 | const [resizeEpoch, setResizeEpoch] = useState(0); |
| @@ -831,15 +834,20 @@ export default function ThreadTerminalDrawer({ · 8 unmodified lines | ||
| 831 | 834 | const didResizeDuringDragRef = useRef(false); |
| 832 | 835 | |
| 833 | 836 | const normalizedTerminalIds = useMemo(() => { |
| 834 | − const cleaned = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | |
| 835 | − return cleaned.length > 0 ? cleaned : [DEFAULT_THREAD_TERMINAL_ID]; | |
| 837 | + return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | |
| 836 | 838 | }, [terminalIds]); |
| 837 | 839 | |
| 838 | − const resolvedActiveTerminalId = normalizedTerminalIds.includes(activeTerminalId) | |
| 839 | − ? activeTerminalId | |
| 840 | − : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | |
| 840 | + const resolvedActiveTerminalId = | |
| 841 | + normalizedTerminalIds.length === 0 | |
| 842 | + ? "" | |
| 843 | + : normalizedTerminalIds.includes(activeTerminalId) | |
| 844 | + ? activeTerminalId | |
| 845 | + : (normalizedTerminalIds[0] ?? ""); | |
| 841 | 846 | |
| 842 | 847 | const resolvedTerminalGroups = useMemo(() => { |
| 848 | + if (normalizedTerminalIds.length === 0) { | |
| 849 | + return []; | |
| 850 | + } | |
| 843 | 851 | const validTerminalIdSet = new Set(normalizedTerminalIds); |
| 844 | 852 | const assignedTerminalIds = new Set<string>(); |
| 845 | 853 | const usedGroupIds = new Set<string>(); |
| @@ -876,7 +884,7 @@ export default function ThreadTerminalDrawer({ · 30 unmodified lines | ||
| 876 | 884 | const baseGroupId = |
| 877 | 885 | terminalGroup.id.trim().length > 0 |
| 878 | 886 | ? terminalGroup.id.trim() |
| 879 | − : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`; | |
| 887 | + : `group-${nextTerminalIds[0] ?? normalizedTerminalIds[0] ?? ""}`; | |
| 880 | 888 | nextGroups.push({ |
| 881 | 889 | id: assignUniqueGroupId(baseGroupId), |
| 882 | 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) { | |
| 895 | − return nextGroups; | |
| 896 | − } | |
| 902 | + const terminalOrderIndex = new Map( | |
| 903 | + normalizedTerminalIds.map((id, index) => [id, index] as const), | |
| 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 [ | |
| 899 | − { | |
| 900 | − id: `group-${resolvedActiveTerminalId}`, | |
| 901 | − terminalIds: [resolvedActiveTerminalId], | |
| 902 | − }, | |
| 903 | − ]; | |
| 904 | − }, [normalizedTerminalIds, resolvedActiveTerminalId, terminalGroups]); | |
| 911 | + return nextGroups; | |
| 912 | + }, [normalizedTerminalIds, terminalGroups]); | |
| 905 | 913 | |
| 906 | 914 | const resolvedActiveGroupIndex = useMemo(() => { |
| 907 | 915 | const indexById = resolvedTerminalGroups.findIndex( |
| @@ -914,22 +922,22 @@ export default function ThreadTerminalDrawer({ · 6 unmodified lines | ||
| 914 | 922 | return indexByTerminal >= 0 ? indexByTerminal : 0; |
| 915 | 923 | }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]); |
| 916 | 924 | |
| 917 | − const visibleTerminalIds = resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? [ | |
| 918 | − resolvedActiveTerminalId, | |
| 919 | − ]; | |
| 925 | + const visibleTerminalIds = | |
| 926 | + resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? | |
| 927 | + (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); | |
| 920 | 928 | const hasTerminalSidebar = normalizedTerminalIds.length > 1; |
| 921 | 929 | const isSplitView = visibleTerminalIds.length > 1; |
| 922 | 930 | const showGroupHeaders = |
| 923 | 931 | resolvedTerminalGroups.length > 1 || |
| 924 | 932 | resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); |
| 925 | 933 | const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; |
| 926 | − const terminalLabelById = useMemo( | |
| 927 | − () => | |
| 928 | − new Map( | |
| 929 | − normalizedTerminalIds.map((terminalId, index) => [terminalId, `Terminal ${index + 1}`]), | |
| 930 | − ), | |
| 931 | − [normalizedTerminalIds], | |
| 932 | − ); | |
| 934 | + const terminalLabelById = useMemo(() => { | |
| 935 | + const next = new Map<string, string>(); | |
| 936 | + for (const terminalId of normalizedTerminalIds) { | |
| 937 | + next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId)); | |
| 938 | + } | |
| 939 | + return next; | |
| 940 | + }, [normalizedTerminalIds, terminalLabelsById]); | |
| 933 | 941 | const splitTerminalActionLabel = hasReachedSplitLimit |
| 934 | 942 | ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` |
| 935 | 943 | : splitShortcutLabel |
| @@ -1051,6 +1059,33 @@ export default function ThreadTerminalDrawer({ · 115 unmodified lines | ||
| 1051 | 1059 | }; |
| 1052 | 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 | 1089 | return ( |
| 1055 | 1090 | <aside |
| 1056 | 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 | 1252 | onClick={() => onActiveTerminalChange(groupActiveTerminalId)} |
| 1218 | 1253 | > |
| 1219 | − {terminalGroup.terminalIds.length > 1 | |
| 1220 | − ? `Split ${groupIndex + 1}` | |
| 1221 | − : `Terminal ${groupIndex + 1}`} | |
| 1254 | + Group {groupIndex + 1} | |
| 1222 | 1255 | </button> |
| 1223 | 1256 | )} |
| 1224 | 1257 | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { QueryClient } from "@tanstack/react-query"; |
| 2 | +import type { WsRpcClient } from "@t3tools/client-runtime"; | |
| 2 | 3 | import { |
| 3 | 4 | EnvironmentId, |
| 4 | 5 | ProjectId, |
| @@ -67,6 +68,70 @@ vi.mock("../../rpc/wsRpcClient", () => ({ · 62 unmodified lines | ||
| 67 | 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 | 135 | vi.mock("../../rpc/wsTransport", () => ({ |
| 71 | 136 | WsTransport: MockWsTransport, |
| 72 | 137 | })); |
| @@ -11,7 +11,13 @@ class MockHTMLElement { · 10 unmodified lines | ||
| 11 | 11 | }; |
| 12 | 12 | |
| 13 | 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 | 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 | 3 | if (!(activeElement instanceof HTMLElement)) return false; |
| 4 | 4 | if (!activeElement.isConnected) return false; |
| 5 | 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 | 341 | exitCode: null, |
| 342 | 342 | exitSignal: null, |
| 343 | 343 | hasRunningSubprocess: true, |
| 344 | + label: "terminal-1", | |
| 344 | 345 | updatedAt: "2026-02-24T00:00:00.000Z", |
| 345 | 346 | }, |
| 346 | 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 | 24 | const terminalUiState = selectThreadTerminalUiState( |
| 25 | 25 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, |
| 26 | 26 | THREAD_REF, |
| @@ -28,10 +28,10 @@ describe("terminalUiStateStore actions", () => { · 1 unmodified line | ||
| 28 | 28 | expect(terminalUiState).toEqual({ |
| 29 | 29 | terminalOpen: false, |
| 30 | 30 | terminalHeight: 280, |
| 31 | − terminalIds: ["default"], | |
| 32 | − activeTerminalId: "default", | |
| 33 | − terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | |
| 34 | − activeTerminalGroupId: "group-default", | |
| 31 | + terminalIds: [], | |
| 32 | + activeTerminalId: "", | |
| 33 | + terminalGroups: [], | |
| 34 | + activeTerminalGroupId: "", | |
| 35 | 35 | }); |
| 36 | 36 | }); |
| 37 | 37 | |
| @@ -45,10 +45,10 @@ describe("terminalUiStateStore actions", () => { · 7 unmodified lines | ||
| 45 | 45 | THREAD_REF, |
| 46 | 46 | ); |
| 47 | 47 | expect(terminalUiState.terminalOpen).toBe(true); |
| 48 | − expect(terminalUiState.terminalIds).toEqual(["default", "terminal-2"]); | |
| 48 | + expect(terminalUiState.terminalIds).toEqual(["terminal-2"]); | |
| 49 | 49 | expect(terminalUiState.activeTerminalId).toBe("terminal-2"); |
| 50 | 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 | 58 | store.splitTerminal(THREAD_REF, "terminal-3"); |
| 59 | 59 | store.splitTerminal(THREAD_REF, "terminal-4"); |
| 60 | 60 | store.splitTerminal(THREAD_REF, "terminal-5"); |
| 61 | + store.splitTerminal(THREAD_REF, "terminal-6"); | |
| 61 | 62 | |
| 62 | 63 | const terminalUiState = selectThreadTerminalUiState( |
| 63 | 64 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, |
| 64 | 65 | THREAD_REF, |
| 65 | 66 | ); |
| 66 | 67 | expect(terminalUiState.terminalIds).toEqual([ |
| 67 | − "default", | |
| 68 | 68 | "terminal-2", |
| 69 | 69 | "terminal-3", |
| 70 | 70 | "terminal-4", |
| 71 | + "terminal-5", | |
| 71 | 72 | ]); |
| 72 | 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 | 85 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, |
| 82 | 86 | THREAD_REF, |
| 83 | 87 | ); |
| 84 | − expect(terminalUiState.terminalIds).toEqual(["default", "terminal-2"]); | |
| 88 | + expect(terminalUiState.terminalIds).toEqual(["terminal-2"]); | |
| 85 | 89 | expect(terminalUiState.activeTerminalId).toBe("terminal-2"); |
| 86 | 90 | expect(terminalUiState.activeTerminalGroupId).toBe("group-terminal-2"); |
| 87 | 91 | expect(terminalUiState.terminalGroups).toEqual([ |
| 88 | − { id: "group-default", terminalIds: ["default"] }, | |
| 89 | 92 | { id: "group-terminal-2", terminalIds: ["terminal-2"] }, |
| 90 | 93 | ]); |
| 91 | 94 | }); |
| @@ -99,10 +102,9 @@ describe("terminalUiStateStore actions", () => { · 7 unmodified lines | ||
| 99 | 102 | THREAD_REF, |
| 100 | 103 | ); |
| 101 | 104 | expect(terminalUiState.terminalOpen).toBe(true); |
| 102 | − expect(terminalUiState.terminalIds).toEqual(["default", "setup-setup"]); | |
| 105 | + expect(terminalUiState.terminalIds).toEqual(["setup-setup"]); | |
| 103 | 106 | expect(terminalUiState.activeTerminalId).toBe("setup-setup"); |
| 104 | 107 | expect(terminalUiState.terminalGroups).toEqual([ |
| 105 | − { id: "group-default", terminalIds: ["default"] }, | |
| 106 | 108 | { id: "group-setup-setup", terminalIds: ["setup-setup"] }, |
| 107 | 109 | ]); |
| 108 | 110 | }); |
| @@ -123,28 +125,28 @@ describe("terminalUiStateStore actions", () => { · 14 unmodified lines | ||
| 123 | 125 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, |
| 124 | 126 | OTHER_THREAD_REF, |
| 125 | 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 | 132 | const migrated = migratePersistedTerminalUiStateStoreState( |
| 131 | 133 | { |
| 132 | 134 | terminalStateByThreadKey: { |
| 133 | 135 | [scopedThreadKey(THREAD_REF)]: { |
| 134 | 136 | terminalOpen: true, |
| 135 | 137 | terminalHeight: 320, |
| 136 | − terminalIds: ["default"], | |
| 137 | − activeTerminalId: "default", | |
| 138 | − terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | |
| 139 | − activeTerminalGroupId: "group-default", | |
| 138 | + terminalIds: ["term-1"], | |
| 139 | + activeTerminalId: "term-1", | |
| 140 | + terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }], | |
| 141 | + activeTerminalGroupId: "group-term-1", | |
| 140 | 142 | }, |
| 141 | 143 | "legacy-thread-id": { |
| 142 | 144 | terminalOpen: true, |
| 143 | 145 | terminalHeight: 320, |
| 144 | − terminalIds: ["default"], | |
| 145 | − activeTerminalId: "default", | |
| 146 | − terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | |
| 147 | − activeTerminalGroupId: "group-default", | |
| 146 | + terminalIds: ["term-1"], | |
| 147 | + activeTerminalId: "term-1", | |
| 148 | + terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }], | |
| 149 | + activeTerminalGroupId: "group-term-1", | |
| 148 | 150 | }, |
| 149 | 151 | }, |
| 150 | 152 | }, |
| @@ -156,10 +158,10 @@ describe("terminalUiStateStore actions", () => { · 5 unmodified lines | ||
| 156 | 158 | [scopedThreadKey(THREAD_REF)]: { |
| 157 | 159 | terminalOpen: true, |
| 158 | 160 | terminalHeight: 320, |
| 159 | − terminalIds: ["default"], | |
| 160 | − activeTerminalId: "default", | |
| 161 | − terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | |
| 162 | − activeTerminalGroupId: "group-default", | |
| 161 | + terminalIds: ["term-1"], | |
| 162 | + activeTerminalId: "term-1", | |
| 163 | + terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }], | |
| 164 | + activeTerminalGroupId: "group-term-1", | |
| 163 | 165 | }, |
| 164 | 166 | }, |
| 165 | 167 | }); |
| @@ -167,7 +169,8 @@ describe("terminalUiStateStore actions", () => { · 1 unmodified line | ||
| 167 | 169 | |
| 168 | 170 | it("resets to default and clears persisted entry when closing the last terminal", () => { |
| 169 | 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 | 175 | expect( |
| 173 | 176 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey[scopedThreadKey(THREAD_REF)], |
| @@ -177,7 +180,7 @@ describe("terminalUiStateStore actions", () => { · 3 unmodified lines | ||
| 177 | 180 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, |
| 178 | 181 | THREAD_REF, |
| 179 | 182 | ).terminalIds, |
| 180 | − ).toEqual(["default"]); | |
| 183 | + ).toEqual([]); | |
| 181 | 184 | }); |
| 182 | 185 | |
| 183 | 186 | it("keeps a valid active terminal after closing an active split terminal", () => { |
| @@ -191,9 +194,26 @@ describe("terminalUiStateStore actions", () => { · 7 unmodified lines | ||
| 191 | 194 | THREAD_REF, |
| 192 | 195 | ); |
| 193 | 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 | 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 | 12 | import { resolveStorage } from "./lib/storage"; |
| 13 | 13 | import { |
| 14 | 14 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 15 | − DEFAULT_THREAD_TERMINAL_ID, | |
| 16 | 15 | MAX_TERMINALS_PER_GROUP, |
| 17 | 16 | type ThreadTerminalGroup, |
| 18 | 17 | } from "./types"; |
| @@ -36,20 +35,22 @@ interface PersistedTerminalUiStateStoreState { · 17 unmodified lines | ||
| 36 | 35 | |
| 37 | 36 | export function migratePersistedTerminalUiStateStoreState( |
| 38 | 37 | persistedState: unknown, |
| 39 | − version: number, | |
| 38 | + _version: number, | |
| 40 | 39 | ): PersistedTerminalUiStateStoreState { |
| 41 | − if (version <= 2 && persistedState && typeof persistedState === "object") { | |
| 42 | − const candidate = persistedState as PersistedTerminalUiStateStoreState; | |
| 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 }; | |
| 40 | + if (!persistedState || typeof persistedState !== "object") { | |
| 41 | + return { terminalUiStateByThreadKey: {} }; | |
| 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 | 56 | function createTerminalUiStateStorage() { |
| @@ -57,8 +58,7 @@ function createTerminalUiStateStorage() { · 1 unmodified line | ||
| 57 | 58 | } |
| 58 | 59 | |
| 59 | 60 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 60 | − const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | |
| 61 | − return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; | |
| 61 | + return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | |
| 62 | 62 | } |
| 63 | 63 | |
| 64 | 64 | function fallbackGroupId(terminalId: string): string { |
| @@ -91,6 +91,10 @@ function normalizeTerminalGroups( · 26 unmodified lines | ||
| 91 | 91 | terminalGroups: ThreadTerminalGroup[], |
| 92 | 92 | terminalIds: string[], |
| 93 | 93 | ): ThreadTerminalGroup[] { |
| 94 | + if (terminalIds.length === 0) { | |
| 95 | + return []; | |
| 96 | + } | |
| 97 | + | |
| 94 | 98 | const validTerminalIdSet = new Set(terminalIds); |
| 95 | 99 | const assignedTerminalIds = new Set<string>(); |
| 96 | 100 | const nextGroups: ThreadTerminalGroup[] = []; |
| @@ -109,7 +113,7 @@ function normalizeTerminalGroups( · 12 unmodified lines | ||
| 109 | 113 | const baseGroupId = |
| 110 | 114 | group.id.trim().length > 0 |
| 111 | 115 | ? group.id.trim() |
| 112 | − : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | |
| 116 | + : fallbackGroupId(groupTerminalIds[0] ?? terminalIds[0] ?? ""); | |
| 113 | 117 | nextGroups.push({ |
| 114 | 118 | id: assignUniqueGroupId(baseGroupId, usedGroupIds), |
| 115 | 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 | 131 | return nextGroups; |
| 137 | 132 | } |
| 138 | 133 | |
| @@ -173,15 +168,10 @@ function threadTerminalUiStateEqual( · 34 unmodified lines | ||
| 173 | 168 | const DEFAULT_THREAD_TERMINAL_UI_STATE: ThreadTerminalUiState = Object.freeze({ |
| 174 | 169 | terminalOpen: false, |
| 175 | 170 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 176 | − terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 177 | − activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 178 | − terminalGroups: [ | |
| 179 | − { | |
| 180 | − id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 181 | − terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 182 | − }, | |
| 183 | − ], | |
| 184 | − activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 171 | + terminalIds: [], | |
| 172 | + activeTerminalId: "", | |
| 173 | + terminalGroups: [], | |
| 174 | + activeTerminalGroupId: "", | |
| 185 | 175 | }); |
| 186 | 176 | |
| 187 | 177 | function createDefaultThreadTerminalUiState(): ThreadTerminalUiState { |
| @@ -197,11 +187,10 @@ function getDefaultThreadTerminalUiState(): ThreadTerminalUiState { · 9 unmodified lines | ||
| 197 | 187 | } |
| 198 | 188 | |
| 199 | 189 | function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTerminalUiState { |
| 200 | − const terminalIds = normalizeTerminalIds(state.terminalIds); | |
| 201 | − const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; | |
| 190 | + const nextTerminalIds = normalizeTerminalIds(state.terminalIds); | |
| 202 | 191 | const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) |
| 203 | 192 | ? state.activeTerminalId |
| 204 | − : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | |
| 193 | + : (nextTerminalIds[0] ?? ""); | |
| 205 | 194 | const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); |
| 206 | 195 | const activeGroupIdFromState = terminalGroups.some( |
| 207 | 196 | (group) => group.id === state.activeTerminalGroupId, |
| @@ -221,10 +210,7 @@ function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTer · 13 unmodified lines | ||
| 221 | 210 | activeTerminalId, |
| 222 | 211 | terminalGroups, |
| 223 | 212 | activeTerminalGroupId: |
| 224 | − activeGroupIdFromState ?? | |
| 225 | − activeGroupIdFromTerminal ?? | |
| 226 | − terminalGroups[0]?.id ?? | |
| 227 | − fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 213 | + activeGroupIdFromState ?? activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "", | |
| 228 | 214 | }; |
| 229 | 215 | return threadTerminalUiStateEqual(state, normalized) ? state : normalized; |
| 230 | 216 | } |
| @@ -255,6 +241,7 @@ function upsertTerminalIntoGroups( · 24 unmodified lines | ||
| 255 | 241 | mode: "split" | "new", |
| 256 | 242 | ): ThreadTerminalUiState { |
| 257 | 243 | const normalized = normalizeThreadTerminalUiState(state); |
| 244 | + const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode; | |
| 258 | 245 | if (!isValidTerminalId(terminalId)) { |
| 259 | 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 | 266 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); |
| 280 | 267 | const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); |
| 281 | 268 | terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); |
| @@ -411,7 +398,7 @@ function closeThreadTerminal( · 129 unmodified lines | ||
| 411 | 398 | normalized.activeTerminalId === terminalId |
| 412 | 399 | ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? |
| 413 | 400 | remainingTerminalIds[0] ?? |
| 414 | − DEFAULT_THREAD_TERMINAL_ID) | |
| 401 | + "") | |
| 415 | 402 | : normalized.activeTerminalId; |
| 416 | 403 | |
| 417 | 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 | 452 | export function selectThreadTerminalUiState( |
| 440 | 453 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>, |
| 441 | 454 | threadRef: ScopedThreadRef | null | undefined, |
| @@ -491,6 +504,7 @@ interface TerminalUiStateStoreState { · 49 unmodified lines | ||
| 491 | 504 | ) => void; |
| 492 | 505 | setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 493 | 506 | closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 507 | + reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void; | |
| 494 | 508 | clearTerminalUiState: (threadRef: ScopedThreadRef) => void; |
| 495 | 509 | removeTerminalUiState: (threadRef: ScopedThreadRef) => void; |
| 496 | 510 | removeOrphanedTerminalUiStates: (activeThreadKeys: Set<string>) => void; |
| @@ -553,6 +567,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( · 56 unmodified lines | ||
| 553 | 567 | updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), |
| 554 | 568 | closeTerminal: (threadRef, terminalId) => |
| 555 | 569 | updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)), |
| 570 | + reconcileTerminalIds: (threadRef, nextIds) => | |
| 571 | + updateTerminal(threadRef, (state) => reconcileThreadTerminalSessionIds(state, nextIds)), | |
| 556 | 572 | clearTerminalUiState: (threadRef) => |
| 557 | 573 | set((state) => { |
| 558 | 574 | const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( |
| @@ -600,7 +616,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( · 41 unmodified lines | ||
| 600 | 616 | }, |
| 601 | 617 | { |
| 602 | 618 | name: TERMINAL_UI_STATE_STORAGE_KEY, |
| 603 | − version: 3, | |
| 619 | + version: 4, | |
| 604 | 620 | storage: createJSONStorage(createTerminalUiStateStorage), |
| 605 | 621 | migrate: migratePersistedTerminalUiStateStoreState, |
| 606 | 622 | partialize: (state) => ({ |
| @@ -22,7 +22,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; · 21 unmodified lines | ||
| 22 | 22 | |
| 23 | 23 | export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default"; |
| 24 | 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 | 26 | export const MAX_TERMINALS_PER_GROUP = 4; |
| 27 | 27 | export type ProjectScript = ContractProjectScript; |
| 28 | 28 | |
| @@ -28,7 +28,7 @@ function resetAtomRegistry() { · 27 unmodified lines | ||
| 28 | 28 | const TARGET = { |
| 29 | 29 | environmentId: EnvironmentId.make("env-local"), |
| 30 | 30 | threadId: ThreadId.make("thread-1"), |
| 31 | − terminalId: "default", | |
| 31 | + terminalId: "term-1", | |
| 32 | 32 | } as const; |
| 33 | 33 | |
| 34 | 34 | const BASE_SNAPSHOT: TerminalSessionSnapshot = { |
| @@ -41,6 +41,7 @@ const BASE_SNAPSHOT: TerminalSessionSnapshot = { · 6 unmodified lines | ||
| 41 | 41 | history: "hello", |
| 42 | 42 | exitCode: null, |
| 43 | 43 | exitSignal: null, |
| 44 | + label: "Terminal 1", | |
| 44 | 45 | updatedAt: "2026-04-01T00:00:00.000Z", |
| 45 | 46 | }; |
| 46 | 47 | |
| @@ -161,7 +162,7 @@ describe("createTerminalSessionManager", () => { · 114 unmodified lines | ||
| 161 | 162 | const otherTarget = { |
| 162 | 163 | environmentId: EnvironmentId.make("env-remote"), |
| 163 | 164 | threadId: ThreadId.make("thread-1"), |
| 164 | − terminalId: "default", | |
| 165 | + terminalId: "term-1", | |
| 165 | 166 | } as const; |
| 166 | 167 | |
| 167 | 168 | for (const target of [TARGET, otherTarget]) { |
| @@ -181,7 +182,7 @@ describe("createTerminalSessionManager", () => { · 13 unmodified lines | ||
| 181 | 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 | 186 | const manager = createTerminalSessionManager({ |
| 186 | 187 | getRegistry: () => atomRegistry, |
| 187 | 188 | }); |
| @@ -190,6 +191,19 @@ describe("createTerminalSessionManager", () => { · 2 unmodified lines | ||
| 190 | 191 | { |
| 191 | 192 | type: "snapshot", |
| 192 | 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 | 208 | threadId: TARGET.threadId, |
| 195 | 209 | terminalId: TARGET.terminalId, |
| @@ -201,6 +215,7 @@ describe("createTerminalSessionManager", () => { · 5 unmodified lines | ||
| 201 | 215 | exitSignal: null, |
| 202 | 216 | updatedAt: "2026-04-01T00:00:00.000Z", |
| 203 | 217 | hasRunningSubprocess: false, |
| 218 | + label: "Terminal 1", | |
| 204 | 219 | }, |
| 205 | 220 | { |
| 206 | 221 | threadId: TARGET.threadId, |
| @@ -213,6 +228,7 @@ describe("createTerminalSessionManager", () => { · 6 unmodified lines | ||
| 213 | 228 | exitSignal: null, |
| 214 | 229 | updatedAt: "2026-04-01T00:00:02.000Z", |
| 215 | 230 | hasRunningSubprocess: false, |
| 231 | + label: "Terminal 2", | |
| 216 | 232 | }, |
| 217 | 233 | ], |
| 218 | 234 | }, |
| @@ -225,7 +241,7 @@ describe("createTerminalSessionManager", () => { · 6 unmodified lines | ||
| 225 | 241 | threadId: TARGET.threadId, |
| 226 | 242 | }) |
| 227 | 243 | .map((session) => session.target.terminalId), |
| 228 | − ).toEqual(["term-2", "default"]); | |
| 244 | + ).toEqual(["term-1", "term-2", "term-10"]); | |
| 229 | 245 | }); |
| 230 | 246 | |
| 231 | 247 | it("drops known sessions when an environment is invalidated", () => { |
| @@ -271,6 +287,7 @@ describe("createTerminalSessionManager", () => { · 39 unmodified lines | ||
| 271 | 287 | exitSignal: null, |
| 272 | 288 | updatedAt: BASE_SNAPSHOT.updatedAt, |
| 273 | 289 | hasRunningSubprocess: false, |
| 290 | + label: "Terminal 1", | |
| 274 | 291 | }, |
| 275 | 292 | }, |
| 276 | 293 | ]); |
| @@ -384,6 +401,7 @@ describe("createTerminalSessionManager", () => { · 107 unmodified lines | ||
| 384 | 401 | snapshot: { |
| 385 | 402 | ...BASE_SNAPSHOT, |
| 386 | 403 | terminalId: "term-2", |
| 404 | + label: "Terminal 2", | |
| 387 | 405 | updatedAt: "2026-04-01T00:00:02.000Z", |
| 388 | 406 | }, |
| 389 | 407 | }, |
| @@ -405,6 +423,7 @@ describe("createTerminalSessionManager", () => { · 15 unmodified lines | ||
| 405 | 423 | exitSignal: null, |
| 406 | 424 | updatedAt: "2026-04-01T00:00:05.000Z", |
| 407 | 425 | hasRunningSubprocess: true, |
| 426 | + label: "Terminal 2", | |
| 408 | 427 | }, |
| 409 | 428 | ], |
| 410 | 429 | }, |
| @@ -452,6 +471,7 @@ describe("createTerminalSessionManager", () => { · 41 unmodified lines | ||
| 452 | 471 | exitSignal: null, |
| 453 | 472 | updatedAt: BASE_SNAPSHOT.updatedAt, |
| 454 | 473 | hasRunningSubprocess: false, |
| 474 | + label: "Terminal 1", | |
| 455 | 475 | }, |
| 456 | 476 | }, |
| 457 | 477 | ]); |
| @@ -470,6 +490,7 @@ describe("createTerminalSessionManager", () => { · 12 unmodified lines | ||
| 470 | 490 | exitSignal: null, |
| 471 | 491 | updatedAt: "2026-04-01T00:00:05.000Z", |
| 472 | 492 | hasRunningSubprocess: true, |
| 493 | + label: "Terminal 1", | |
| 473 | 494 | }, |
| 474 | 495 | }, |
| 475 | 496 | ]); |
| @@ -504,6 +525,7 @@ describe("createTerminalSessionManager", () => { · 28 unmodified lines | ||
| 504 | 525 | exitSignal: null, |
| 505 | 526 | updatedAt: BASE_SNAPSHOT.updatedAt, |
| 506 | 527 | hasRunningSubprocess: true, |
| 528 | + label: "Terminal 1", | |
| 507 | 529 | }, |
| 508 | 530 | }, |
| 509 | 531 | ]); |
| @@ -5,7 +5,7 @@ import type { · 4 unmodified lines | ||
| 5 | 5 | TerminalSummary, |
| 6 | 6 | EnvironmentId, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | −import { DEFAULT_TERMINAL_ID, ThreadId, type TerminalAttachInput } from "@t3tools/contracts"; | |
| 8 | +import { ThreadId, type TerminalAttachInput } from "@t3tools/contracts"; | |
| 9 | 9 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; |
| 10 | 10 | |
| 11 | 11 | export interface TerminalSessionState { |
| @@ -77,7 +77,7 @@ export interface TerminalMetadataClient { · 65 unmodified lines | ||
| 77 | 77 | export interface TerminalAttachClient { |
| 78 | 78 | readonly terminal: { |
| 79 | 79 | readonly attach: ( |
| 80 | − input: TerminalAttachInput & { readonly terminalId: string }, | |
| 80 | + input: TerminalAttachInput, | |
| 81 | 81 | listener: (event: TerminalAttachStreamEvent) => void, |
| 82 | 82 | options?: { readonly onResubscribe?: () => void }, |
| 83 | 83 | ) => () => void; |
| @@ -268,14 +268,9 @@ function listKnownSessionsFromMetadata( · 184 unmodified lines | ||
| 268 | 268 | target, |
| 269 | 269 | state: combineSessionState(summary, getBuffer(target)), |
| 270 | 270 | })) |
| 271 | − .sort((left, right) => { | |
| 272 | − const leftUpdatedAt = left.state.updatedAt ? Date.parse(left.state.updatedAt) : 0; | |
| 273 | − const rightUpdatedAt = right.state.updatedAt ? Date.parse(right.state.updatedAt) : 0; | |
| 274 | − if (leftUpdatedAt !== rightUpdatedAt) { | |
| 275 | − return rightUpdatedAt - leftUpdatedAt; | |
| 276 | − } | |
| 277 | − return left.target.terminalId.localeCompare(right.target.terminalId); | |
| 278 | − }); | |
| 271 | + .sort((left, right) => | |
| 272 | + left.target.terminalId.localeCompare(right.target.terminalId, undefined, { numeric: true }), | |
| 273 | + ); | |
| 279 | 274 | } |
| 280 | 275 | |
| 281 | 276 | export const terminalSessionStateAtom = Atom.family((target: KnownTerminalSessionTarget) => |
| @@ -569,12 +564,8 @@ export function createTerminalSessionManager(config: TerminalSessionManagerConfi · 287 unmodified lines | ||
| 569 | 564 | readonly onEvent?: (event: TerminalAttachStreamEvent) => void; |
| 570 | 565 | readonly options?: { readonly onResubscribe?: () => void }; |
| 571 | 566 | }): () => void { |
| 572 | − const terminal = { | |
| 573 | − ...input.terminal, | |
| 574 | − terminalId: input.terminal.terminalId ?? DEFAULT_TERMINAL_ID, | |
| 575 | − }; | |
| 576 | 567 | return input.client.terminal.attach( |
| 577 | − terminal, | |
| 568 | + input.terminal, | |
| 578 | 569 | (event) => { |
| 579 | 570 | applyAttachEvent({ environmentId: input.environmentId }, event); |
| 580 | 571 | input.onEvent?.(event); |
| @@ -31,6 +31,7 @@ describe("TerminalOpenInput", () => { · 30 unmodified lines | ||
| 31 | 31 | expect( |
| 32 | 32 | decodes(TerminalOpenInput, { |
| 33 | 33 | threadId: "thread-1", |
| 34 | + terminalId: DEFAULT_TERMINAL_ID, | |
| 34 | 35 | cwd: "/tmp/project", |
| 35 | 36 | cols: 120, |
| 36 | 37 | rows: 40, |
| @@ -42,6 +43,7 @@ describe("TerminalOpenInput", () => { · 5 unmodified lines | ||
| 42 | 43 | expect( |
| 43 | 44 | decodes(TerminalOpenInput, { |
| 44 | 45 | threadId: "thread-1", |
| 46 | + terminalId: DEFAULT_TERMINAL_ID, | |
| 45 | 47 | cwd: "/tmp/project", |
| 46 | 48 | cols: 10, |
| 47 | 49 | rows: 2, |
| @@ -49,19 +51,21 @@ describe("TerminalOpenInput", () => { · 1 unmodified line | ||
| 49 | 51 | ).toBe(false); |
| 50 | 52 | }); |
| 51 | 53 | |
| 52 | − it("defaults terminalId when missing", () => { | |
| 53 | − const parsed = decodeSync(TerminalOpenInput, { | |
| 54 | − threadId: "thread-1", | |
| 55 | − cwd: "/tmp/project", | |
| 56 | − cols: 100, | |
| 57 | − rows: 24, | |
| 58 | − }); | |
| 59 | − expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); | |
| 54 | + it("requires terminalId — the client must always pick an id", () => { | |
| 55 | + expect( | |
| 56 | + decodes(TerminalOpenInput, { | |
| 57 | + threadId: "thread-1", | |
| 58 | + cwd: "/tmp/project", | |
| 59 | + cols: 100, | |
| 60 | + rows: 24, | |
| 61 | + }), | |
| 62 | + ).toBe(false); | |
| 60 | 63 | }); |
| 61 | 64 | |
| 62 | 65 | it("accepts optional env overrides", () => { |
| 63 | 66 | const parsed = decodeSync(TerminalOpenInput, { |
| 64 | 67 | threadId: "thread-1", |
| 68 | + terminalId: DEFAULT_TERMINAL_ID, | |
| 65 | 69 | cwd: "/tmp/project", |
| 66 | 70 | worktreePath: "/tmp/project/.t3/worktrees/feature-a", |
| 67 | 71 | cols: 100, |
| @@ -98,6 +102,7 @@ describe("TerminalWriteInput", () => { · 30 unmodified lines | ||
| 98 | 102 | expect( |
| 99 | 103 | decodes(TerminalWriteInput, { |
| 100 | 104 | threadId: "thread-1", |
| 105 | + terminalId: DEFAULT_TERMINAL_ID, | |
| 101 | 106 | data: "echo hello\n", |
| 102 | 107 | }), |
| 103 | 108 | ).toBe(true); |
| @@ -107,10 +112,20 @@ describe("TerminalWriteInput", () => { · 3 unmodified lines | ||
| 107 | 112 | expect( |
| 108 | 113 | decodes(TerminalWriteInput, { |
| 109 | 114 | threadId: "thread-1", |
| 115 | + terminalId: DEFAULT_TERMINAL_ID, | |
| 110 | 116 | data: "", |
| 111 | 117 | }), |
| 112 | 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 | 131 | describe("TerminalThreadInput", () => { |
| @@ -125,17 +140,33 @@ describe("TerminalResizeInput", () => { · 8 unmodified lines | ||
| 125 | 140 | expect( |
| 126 | 141 | decodes(TerminalResizeInput, { |
| 127 | 142 | threadId: "thread-1", |
| 143 | + terminalId: DEFAULT_TERMINAL_ID, | |
| 128 | 144 | cols: 80, |
| 129 | 145 | rows: 24, |
| 130 | 146 | }), |
| 131 | 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 | 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 | 167 | const parsed = decodeSync(TerminalClearInput, { |
| 138 | 168 | threadId: "thread-1", |
| 169 | + terminalId: DEFAULT_TERMINAL_ID, | |
| 139 | 170 | }); |
| 140 | 171 | expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); |
| 141 | 172 | }); |
| @@ -165,6 +196,7 @@ describe("TerminalSessionSnapshot", () => { · 23 unmodified lines | ||
| 165 | 196 | history: "hello\n", |
| 166 | 197 | exitCode: null, |
| 167 | 198 | exitSignal: null, |
| 199 | + label: "Primary", | |
| 168 | 200 | updatedAt: new Date().toISOString(), |
| 169 | 201 | }), |
| 170 | 202 | ).toBe(true); |
| @@ -212,6 +244,7 @@ describe("TerminalEvent", () => { · 41 unmodified lines | ||
| 212 | 244 | threadId: "thread-1", |
| 213 | 245 | terminalId: DEFAULT_TERMINAL_ID, |
| 214 | 246 | hasRunningSubprocess: true, |
| 247 | + label: "vim", | |
| 215 | 248 | }), |
| 216 | 249 | ).toBe(true); |
| 217 | 250 | }); |
| @@ -232,6 +265,7 @@ describe("TerminalEvent", () => { · 14 unmodified lines | ||
| 232 | 265 | history: "", |
| 233 | 266 | exitCode: null, |
| 234 | 267 | exitSignal: null, |
| 268 | + label: "Primary", | |
| 235 | 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 | 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 | 11 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 7 | 12 | const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check( |
| @@ -19,18 +24,15 @@ const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSc · 11 unmodified lines | ||
| 19 | 24 | Schema.isMaxProperties(128), |
| 20 | 25 | ); |
| 21 | 26 | |
| 22 | −const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe( | |
| 23 | − Schema.withDecodingDefault(Effect.succeed(DEFAULT_TERMINAL_ID)), | |
| 24 | −); | |
| 25 | − | |
| 26 | 27 | export const TerminalThreadInput = Schema.Struct({ |
| 27 | 28 | threadId: TrimmedNonEmptyStringSchema, |
| 28 | 29 | }); |
| 29 | 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 | 33 | const TerminalSessionInput = Schema.Struct({ |
| 32 | 34 | ...TerminalThreadInput.fields, |
| 33 | − terminalId: TerminalIdWithDefaultSchema, | |
| 35 | + terminalId: TerminalIdSchema, | |
| 34 | 36 | }); |
| 35 | 37 | export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>; |
| 36 | 38 | |
| @@ -100,6 +102,8 @@ export const TerminalSessionSnapshot = Schema.Struct({ · 63 unmodified lines | ||
| 100 | 102 | history: Schema.String, |
| 101 | 103 | exitCode: Schema.NullOr(Schema.Int), |
| 102 | 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 | 107 | updatedAt: Schema.String, |
| 104 | 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 | 119 | exitCode: Schema.NullOr(Schema.Int), |
| 116 | 120 | exitSignal: Schema.NullOr(Schema.Int), |
| 117 | 121 | hasRunningSubprocess: Schema.Boolean, |
| 122 | + /** Server-computed display title (idle shell vs subprocess command). */ | |
| 123 | + label: Schema.String.check(Schema.isMaxLength(128)), | |
| 118 | 124 | updatedAt: Schema.String, |
| 119 | 125 | }); |
| 120 | 126 | export type TerminalSummary = typeof TerminalSummary.Type; |
| @@ -193,6 +199,7 @@ const TerminalActivityEvent = Schema.Struct({ · 72 unmodified lines | ||
| 193 | 199 | ...TerminalEventBaseSchema.fields, |
| 194 | 200 | type: Schema.Literal("activity"), |
| 195 | 201 | hasRunningSubprocess: Schema.Boolean, |
| 202 | + label: Schema.String.check(Schema.isMaxLength(128)), | |
| 196 | 203 | }); |
| 197 | 204 | |
| 198 | 205 | export const TerminalEvent = Schema.Union([ |
| @@ -83,6 +83,10 @@ · 82 unmodified lines | ||
| 83 | 83 | "./composerTrigger": { |
| 84 | 84 | "types": "./src/composerTrigger.ts", |
| 85 | 85 | "import": "./src/composerTrigger.ts" |
| 86 | + }, | |
| 87 | + "./terminalLabels": { | |
| 88 | + "types": "./src/terminalLabels.ts", | |
| 89 | + "import": "./src/terminalLabels.ts" | |
| 86 | 90 | } |
| 87 | 91 | }, |
| 88 | 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 | +} | |