# AGENTS.md This document describes Lumina Terminal's architecture, design principles, and the rules any AI (or human) contributor must follow so the codebase stays high-cohesion / low-coupling and does not regress into duplication. > Read this **before** making changes. If a change would violate a rule below, > extract or refactor first rather than adding another copy. --- ## 1. Tech Stack | Layer | Technology | |-------|-----------| | Shell / backend | Rust + Tauri v2 | | PTY | `portable_pty` | | Frontend | React 19 + TypeScript (strict) | | Terminal renderer | xterm.js v6 (+ webgl, fit, web-links, image addons) | | UI components | HeroUI (`@heroui/react`) | | Styling | Tailwind CSS v4 | | Build | Vite 7, `pnpm` | | i18n | JSON files in `translations/` | The backend (`src-tauri/`) is intentionally thin: it spawns/kills PTYs, streams output via Tauri events, and exposes a few filesystem helpers. All UI logic, state, and derivation live in the frontend. `@xterm/xterm` stays on stable 6.0.0 plus a local backport patch (`patches/@xterm__xterm@6.0.0.patch`, declared in `pnpm-workspace.yaml`) that vendors two upstream IME fixes the WebKitGTK duplicate-input fix depends on (xterm.js #5439 + #5698). See `patches/README.md`; drop the patch when the next stable xterm release containing both ships. --- ## 2. Source Map ### Frontend (`src/`) ``` src/ ├── App.tsx # Root: composes chrome (TabBar/TitleBar/Term) + non-terminal key dispatch. │ # Sidebar visibility lives in useSidebarVisibility (explicit toggle → │ # one-shot CLI --sidebar → the showTabBar setting; setTabBarVisible │ # is the single write path); theme-mode translation in lib/themeMode.ts. │ # Tab lifecycle/state live in useTerminalManager; geometry in useWindowGeometry. │ # Non-first-screen pages (Settings/About/Welcome) are React.lazy so │ # Settings' subtree + the markdown renderer stay out of the startup chunk. ├── main.tsx # ReactDOM entry; wraps App in GlobalConfigProvider ├── constants.ts # Default config, default bindings, tab-id sentinels ├── types/ │ ├── config.ts # GlobalConfig, Binding, Actions, WithKeys, CommandIconRule + │ # Languages (the UI-language union lives here so GlobalConfig can │ # reference it without types/ reaching into hooks/i18n.tsx) │ ├── cli.ts # CliArgs — parsed launch flags (mirrors src-tauri/src/cli.rs CliArgs) │ └── terminal.ts # TerminalProfile (+ keepAfterExit: "exit"|"freeze"|"shell" — what │ # happens after startupCommand finishes) + ProfileLauncher (the │ # wrap-as-app section: title/workingDirectory/sidebar/icon; presence │ # enables it), TerminalRenderOptions, SSHConfig │ ├── lib/ # Pure, framework-agnostic logic (NO React) │ ├── platform.ts # isMacOS() / isLinux() │ ├── configFile.ts # Config-file IO domain: config.toml path + openConfigFile + │ │ # readConfigDocument (toml preferred; legacy config.json parsed │ │ # and migrated, then renamed config.json.bak) + writeConfigDocument │ ├── configFormat.ts # Pure config format layer: TOML parse + renderConfigToml (patches onto │ │ # the existing document — key order, layout and comments survive │ │ # settings rewrites; nullish pruning) + legacy JSON unwrap — zero │ │ # internal imports so node --test loads it directly │ ├── color.ts # isColorDark, foregroundFor, adjustColor, visibleRed │ ├── glass.ts # glassSurface / glassBorder / elevationShadow / windowOutline — │ │ # backdrop-filter material + Wayland/WebKitGTK opaque fallback │ │ # (single source for the glass look; windowOutline is the Linux │ │ # 1px window hairline for DEs without compositor shadows) │ ├── motion.ts # framer-motion variants/transitions presets (one spring curve for all chrome) │ ├── ssh.ts # formatSshAddress / formatSshEntry │ ├── term.ts # parseProfile, parseProfileTheme, parseProfilePadding │ ├── terminalApi.ts # invoke wrappers: writeToTerminal, resizeTerminal, ... startTerminal also │ │ # carries the spawn-time enableShellCompletions flag (hooks are baked into the │ │ # shell's init files — toggling affects new terminals only, like webgl) │ ├── mcpApi.ts # startMcpServer/stopMcpServer invoke wrappers (log-on-reject) — the │ │ # read-only MCP server domain API (sibling to terminalApi.ts) │ ├── proxyApi.ts # startProxySync/stopProxySync invoke wrappers (log-on-reject) — the │ │ # system-proxy watcher domain API (sibling to terminalApi.ts) │ ├── cliApi.ts # getCliArgs() wrapper — reads parsed launch flags (log-on-reject) │ ├── clipboardApi.ts # readClipboardText() — clipboard-plugin read wrapper (log-on-degrade); │ │ # the only clipboard READ path (navigator.clipboard.readText is │ │ # unusable in the Tauri webviews); writes stay on navigator.clipboard │ ├── openerApi.ts # openExternal — opener-plugin URL wrapper (log-on-reject); the one │ │ # way external links reach the system browser (plain target="_blank" │ │ # anchors are dead in the Tauri webview) │ ├── appIcon.ts # Command→tab-icon mapping: resolveAppFromCommand (wrapper-skipping) │ │ # + getAppIcon(line, userRules?) — user rules (config commandIcons, │ │ # plain basename or regex-vs-whole-line) run before the built-in │ │ # APP_COMMANDS table. Also the custom:" icon id helpers. Single │ │ # source of truth for which running command shows which app icon. │ ├── commandIconApi.ts # importCommandIcon/pruneCommandIcons/listCommandIcons invoke wrappers │ │ # (log-on-reject) + cached asset-protocol URL resolution for custom: │ │ # icon ids — the custom command-icon domain API (sibling to terminalApi.ts) │ ├── launcherApi.ts # Profile-launcher domain API (sibling to terminalApi.ts): │ │ # syncProfileLaunchers/getLauncherDir wrappers + launcherSpecsFromConfig │ │ # (config → spec derivation) + syncLaunchersFromConfig — the save/delete │ │ # hook ProfileSettings & SettingsPage call so launchers regenerate and │ │ # orphaned ones prune on every config change │ ├── launcherIcon.ts # resolveLauncherIcon(profile, config) — launcher icon payload: explicit │ │ # profile.launcher.icon override, else auto-derive via getAppIcon; │ │ # built-in SVGs fetch + (off-Linux) canvas-rasterize to PNG base64, │ │ # custom ids pass through by stored file name │ ├── fileManagerApi.ts # openInFileManager(path) — reveal-in-file-manager wrapper (log-on-reject); │ │ # the one frontend entry to backend open_in_file_manager │ ├── shellIcon.ts # getShellType(profile) → "bash"|"zsh"|"fish"|"nu"|"pwsh"|"ssh"|"default" │ ├── apiCore.ts # invokeLogged — THE one invoke-with-error-logging core every domain api │ │ # module (terminalApi/mcpApi/proxyApi/cliApi/fileManagerApi/ │ │ # commandIconApi/launcherApi) builds on; log-then-rethrow, optional │ │ # message/scope/fallback. Never hand-roll another catch-and-log invoke │ ├── bindings.ts # parseBindings, matchBinding, loadBindings, useKeyboardBindings, │ │ # exported actionSignature / keySignature. loadBindings takes an optional │ │ # keydown intercept (the completion popup owns Tab/arrows while open) and │ │ # dispatches │ │ # the `copy` action itself (needs the live selection: with one it │ │ # writes the clipboard, without it the key falls through to the │ │ # shell so bound-to-Ctrl+C copy keeps SIGINT) │ ├── edgeBackground.ts # sampleEdgeBackground (xterm buffer edge inspection) │ ├── tearoff.ts # Tab tear-off: label mint/store/consume + WebviewWindow spawn │ ├── session.ts # Terminal-session persistence: SavedTab/SavedSession types + │ │ # LazyStore("state/session.json") load/save/clear. Save-side re-spawn contract │ │ # (profile name + live cwd + optional scrollback); restore re-parses against │ │ # the current globalProfile. Pure logic — no React. │ ├── profileUsage.ts # Per-profile "last opened" recency map (empty-state sort) in a dedicated │ │ # LazyStore("state/profile-usage.json", NOT config.toml — runtime state, not │ │ # user config; mirrors session.ts): load/saveProfileLastOpened, log-on-fail. │ ├── stateStores.ts # migrateLegacyStateStores — one-time rename of the pre-folder LazyStore │ │ # files (session/profile-usage/terminal-metrics/tearoff) from the app-data │ │ # root into STATE_DIR ("state", constants.ts), awaited at the top of the │ │ # config load so it settles before the first store read. Idempotent; │ │ # tear-off-window races degrade to a debug log │ ├── tabDragOverlay.ts # mountTabDragOverlay (transparent full-window layer keeping dragover alive over canvas) │ ├── tabReorder.ts # Sidebar drag-reorder math: dropTargetFor (pointer Y → gap index) │ │ # + reorderByDrop (move item into gap; same ref when it's a no-op) │ ├── profileSync.ts # reResolveByName — hot-reload core: re-resolve live tab profile │ │ # snapshots by name against fresh config sources (deleted profiles │ │ # keep their snapshot; null when nothing changed). Generic + │ │ # dependency-free so node --test loads it directly │ ├── chunkedWriter.ts # ChunkedWriter — bounded-chunk feeder for term.write() (UTF-16-safe slicing) │ ├── terminalGeometry.ts # profileWindowSize — compute the OS window size for a profile's rows/cols. │ │ # Live path measures the cell via an off-screen dummy xterm + refreshes the │ │ # chrome-offset cache; returns null when the window is still hidden and the │ │ # caches are cold (WebKitGTK lays nothing out until the window is mapped — │ │ # the caller defers, see initialWindowSize.sizeMainWindowToProfile) │ ├── cellMetrics.ts # Cached xterm cell metrics + chrome offset (LazyStore state/terminal-metrics.json, │ │ # keyed by font family/size/weight/style/letterSpacing/lineHeight/dpr). │ │ # Warmed at config load (hooks/config.tsx) so startup window sizing can run │ │ # fully offline on unchanged fonts — no dummy-xterm measurement, and the │ │ # size lands BEFORE the window is shown │ ├── initialWindowSize.ts # Startup main-window sizing: the once-per-session lock (claim vs settle) that │ │ # also doubles as the show gate raced by hooks/config.tsx's window.show(), │ │ # plus sizeMainWindowToProfile — the shared sizer used by Term (terminal │ │ # mounts first) and useEmptyStateWindowSize (app starts with no terminal). │ │ # Warm caches → size while hidden, show once at final size; cold caches → │ │ # release the gate immediately (old show-then-resize behavior) and apply │ │ # the measured size once layout exists, warming the caches for next launch │ ├── imeCompositionGuard.ts # WebKitGTK/IBus normalization for xterm's unmatched keyCode-229 IME fallback │ │ # (config-gated: global imeDuplicateInputFix, default on — see GeneralSettings) │ ├── dragRegionDoubleClick.ts # isDragRegionDoubleClick — pure mousedown predicate: second click of a │ │ # double-click whose target itself carries data-tauri-drag-region (self-hit │ │ # semantics matching Tauri's drag script; interactive children never qualify). │ ├── currentCommand.ts # CurrentCommandParser — OSC 1337 shell-integration sequence parser feeding the │ │ # tab-subtitle command + per-command exit codes + the completion-suggest │ │ # payloads (fed by useCurrentCommand) │ ├── completions.ts # Terminal-suggest pure layer: CompletionCandidate + parseCompletionPayload │ │ # (OSC 1337;Completions RS/US framing: ctx US word RS insert US label US desc — │ │ # ctx = tokens before the word, the warm-cache key; survives the pty's ONLCR, │ │ # dropped by xterm inside OSC) + insertionBytes (DEL × code points of the word + insert; │ │ # the accept contract verified end-to-end in tests/completion_hooks.rs) + │ │ # filterCandidates (typing refinement) + shouldRetrigger (directory cascade) │ │ # + isPlainTypingKey (the as-you-type request trigger predicate) │ │ # + kind classification for the popup's row icons. Shell half: shell_integration.rs │ ├── completionCache.ts # Per-profile warm-index persistence (LazyStore state/completion-cache.json — │ │ # runtime state, not config): loadCompletionIndex + persistCompletionIndex │ │ # (read-merge-write per profile so concurrent tabs accumulate instead of │ │ # clobbering; pruned to 96 ctx × 8 words × display-cap candidates). Pure │ │ # prune/merge live in completions.ts (node-testable); useShellCompletions │ │ # debounces writes 3s + flushes on unmount. │ ├── ligatures.ts # Programming-ligature rendering from the font's real GSUB table: findFont + │ │ # parse (module-level font cache), enableLigatures installs a character │ │ # joiner; preloaded at startup by config.tsx when the global font enables it │ ├── updater.ts # Updater wrapper (pure, React-free) around @tauri-apps/plugin-updater │ │ # (check/download/install) — state machine consumed by useUpdater │ ├── updateAvailable.ts # Module-level store of the last update-check result, shared by the startup │ │ # check (useStartupUpdateCheck) and manual checks (sidebar banner / About) │ ├── releaseNotes.ts # fetchReleaseNotes — GitHub Releases API fetch for the About page's │ │ # "you're up to date" double-click changelog easter egg │ ├── techStack.ts # Parses README.md's "Technology Used" section into grouped items for the │ │ # About page's tech-stack modal (README is the single source of truth) │ ├── bindingsSettings.ts # bindings-editor pure logic: actionLabel, detectConflicts, toDraft, … │ ├── setupTermAddons.ts # setupTermAddons(term, profile, id) — assemble the standard xterm addon │ │ # stack (web links, Unicode 11, optional graphemes/WebGL, image, fit, │ │ # serialize, search) and return the handles Term keeps in refs │ ├── cliLaunch.ts # hasLaunchArgs + deriveCliLaunchProfile — pure Alacritty-style │ │ # launch-flag → initial-profile shaping (--profile/-e/--working-directory/ │ │ # --hold), consumed by useTerminalManager's seed effect │ ├── sessionRestore.ts # mapSavedSession — SavedSession → RestoredEntry[] mapping (terminal │ │ # tabs re-parsed against the current globalProfile, chrome tabs via │ │ # sentinel id, deleted profiles skipped). The map half of the seed │ │ # effect's session-restore branch │ ├── themeMode.ts # themeModeForces(themeMode, systemTheme) → {darkOverride, forceBg} — │ │ # the theme-mode setting translated for useEffectiveTheme + Term's │ │ # forceBg prop (extracted from App per §3.5: no inline theme derivation) │ └── FloatingFitAddon.ts # xterm fit addon subclass (centered sub-cell fit) │ ├── hooks/ # React hooks (start with `use`) │ ├── config.tsx # GlobalConfigProvider + useGlobalConfig — config.toml IO via │ │ # lib/configFile.ts + the config hot-reload watcher (dir watch + │ │ # debounce + own-write suppression via lastWrittenTextRef). Children │ │ # are gated on isLoading: the app tree (and its side-effect hooks — │ │ # update check, proxy/MCP watchers) mounts only once the REAL config │ │ # has loaded, so nothing acts on DEFAULT_CONFIG values │ ├── i18n.tsx # useI18n, languageNames (Languages type re-exported from types/config.ts) │ ├── useTauriListen.ts # useTauriListen(event, handler) + useTauriSubscription(subscribe|null, │ │ # handler, label) — the one Tauri event/subscription lifetime helper │ │ # (cancelled-guard unmount cleanup + latest-ref handlers). Replaces │ │ # the hand-rolled listen().then(cleanup) idiom everywhere │ ├── maximized.ts # useMaximized (window resize → isMaximized) │ ├── useAlwaysOnTop.ts # useAlwaysOnTop() → {pinned, toggle}: per-window always-on-top │ │ # (optimistic local state; no-op on Wayland, so the TitleBar │ │ # pin button disables itself there) │ ├── paddingOffset.ts # usePaddingOffset(isMaximized) → platform/maximize padding │ ├── surfaceColors.ts # useSurfaceColors(bg) → derived border/overlay/glass/accent colors │ ├── useGlass.ts # useGlass() → {supportsGlass, blurPx}: platform backdrop-filter capability │ │ # (disabled on Linux/WebKitGTK; module-cached like useShells) │ ├── useSettingsDraft.ts # useSettingsDraft(source, onCommit, deps) → {draft, isDirty, save, ...} │ │ # shared draft+dirty+save logic for all settings panels │ ├── useShells.ts # useShells() — cached find_shells backend call │ ├── useSshConfig.ts # useSshConfig() — cached parse_ssh_config backend call │ ├── useMcpServer.ts # useMcpServerLifecycle() — drives the MCP HTTP server from config.enableMcp │ │ # (called once at the app root, so the server follows the app lifecycle, │ │ # not the settings panel); useMcpEndpoint() reactively reads the running │ │ # server's URL+token (module-level singleton via useSyncExternalStore) │ ├── useProxySync.ts # useProxySync() — drives the system-proxy watcher from config.autoProxy │ │ # (default on). Same app-lifecycle pattern as useMcpServerLifecycle; │ │ # disabling stops the watcher and deletes the hooks' env-file so │ │ # running shells drop (only) the values Lumina injected │ ├── useCliArgs.ts # useCliArgs() — cached get_cli_args; Alacritty-style launch flags, │ │ # consumed by useTerminalManager's seed effect to shape the main │ │ # window's first tab (--profile/--command/--working-directory/--hold/--title) │ │ # and by App for the one-shot --sidebar show|hide visibility override │ │ # (local state, never persisted; first explicit toggle drops it) │ ├── useOutputMode.ts # useOutputMode(id) → {markInteractive}: debounced LowLatency toggle │ ├── useEffectiveTheme.ts # useEffectiveTheme(profile, currentId) → theme/bg/fg + HeroUI sync │ ├── useCurrentCommand.ts # useCurrentCommand({ptyId, onCommandChange, onCommandExit, onCompletions}) → │ │ # {feedOutput} — tracks what command runs in a terminal, merging │ │ # shell-integration OSC sequences (parsed from output, precise) with │ │ # the backend /proc fallback (subpressed once OSC proves active) │ ├── useShellCompletions.ts # useShellCompletions({ptyId, enabled}) → {state, offer, handleKey, select, │ │ # acceptCandidate, close} — the terminal-suggest popup state machine: single │ │ # candidate completes silently (directories cascade); while open, typing │ │ # extends the live word and narrows the set locally (v* ⊇ vi*, exact — see │ │ # filterCandidates), Backspace shrinks to the shell-reported base word, Tab │ │ # accepts + re-triggers (follow-up TAB byte re-offers against the new word), │ │ # Enter accepts and finishes (dirs cascade — shouldRetrigger); keys flow via │ │ # loadBindings' intercept; accept writes DEL×word + insert (terminalApi). │ │ # As-you-type mode (config shellCompletionsOnType, live): debounced │ │ # request TABs after typing pauses, gated by Term's at-prompt signal (never │ │ # injected into running commands) and the SPAWN-time hooks gate; explicit │ │ # dismissals cancel pending requests + drop in-flight offers. Requests are │ │ # in-band (the shell's line editor is single-threaded — a TAB mid-typing │ │ # delays echo), so they fire ONLY when the local filter dies (word boundary, │ │ # no match, cache miss) — live typing/backtracking is served synchronously │ │ # from the local filter + a bounded word→set cache; responses that lag the │ │ # typing merge into the live word instead of regressing the popup. A second, │ │ # ctx-keyed warm index re-opens the popup INSTANTLY for line contexts already │ │ # fetched this session (stored word ⊆ typed word ⇒ exact superset) — no sidecar │ │ # PTY, no bundled completion database; the shell stays the single source of │ │ # truth. Cache entries are freshness-stamped (FRESH_TTL_MS): a hit within the │ │ # TTL SKIPS the correction request entirely — measured, even a backgrounded │ │ # (`&`) job started from a fish key-binding stalls the line editor's echo │ │ # identically to a foreground one, so warm-path requests are pure loss — and a │ │ # word-boundary space NEVER requests (the empty-word gate would drop the │ │ # response anyway), and typing with the popup open never requests while the │ │ # local filter survives (an unconditional per-char schedule here stalled the │ │ # echo behind the shell's completion compute on every typing pause); │ │ # fetched sets are TRUSTED for 7 days (explicit TAB is the │ │ # refresh escape hatch) and persisted per profile (lib/completionCache.ts), │ │ # so coverage survives restarts and same-profile tabs. The instant-open's │ │ # anchor comes from Term's live getAnchor() (read at open time — a stale │ │ # shifted copy made the popup appear at the old position and jump). The │ │ # shadow line survives │ │ # acceptance (word = insert) and command execution (fresh line: ctx "") │ ├── useEdgeBackground.ts # useEdgeBackground(opts) → {containerBg} — polls the xterm buffer's │ │ # outer ring (a fullscreen TUI's bg), syncs the xterm layers + │ │ # padding fill, and reports the color up for chrome spread (active │ │ # tab only; honors forceBg/edgeCoverage/colorSpread) │ ├── useSidebarVisibility.ts # useSidebarVisibility(config, updateConfig, isMainWindow) → │ │ # {tabBarVisible, setTabBarVisible, toggleTabBar} — the sidebar │ │ # visibility chain: explicit toggle → one-shot --sidebar CLI flag │ │ # (main window only) → the persisted showTabBar setting │ ├── useProfileUsage.ts # useProfileUsage() → {lastOpened, record}: the empty-state recency map │ │ # from lib/profileUsage.ts — one-shot load + immediate-stamp record with │ │ # async persist. Consumed by useTerminalManager, which exposes the map │ │ # (profileLastOpened) for App → EmptyState's recency sort. │ ├── useTerminalManager.ts# useTerminalManager() — tab list/profiles/active id + create/close/reorder/ │ │ # tear-off + cross-window merge/hover listeners (extracted from App.tsx) │ │ # + hot re-resolution: config/global-profile/OS-theme changes re-resolve │ │ # every live tab's snapshot by name (lib/profileSync.ts); records per-profile │ │ # open recency via useProfileUsage (profile-usage.json, not config.toml). │ │ # Initial-tab seeding consumes lib/cliLaunch.ts (CLI flags) and │ │ # lib/sessionRestore.ts (saved-session mapping) │ ├── useTabDragController.ts # useTabDragController({tabIds, onReorder, onTearOff, …}) — the whole │ │ # sidebar drag domain extracted from TabBar.tsx: reorder preview + │ │ # drop commit, tear-off/merge dispatch on release outside the list, │ │ # and the foreign-drag sentinel overlay. Returns rowDragProps(id) + │ │ # sidebarDragProps; TabBar itself is pure rendering │ ├── useWindowGeometry.ts # useWindowGeometry(isMainWindow) — restore + persist window pos/size (Wayland-aware). │ │ # A restored SIZE also releases the initial-size show gate (Term/empty-state │ │ # sizers skip for it — see lib/initialWindowSize.ts) │ ├── useEmptyStateWindowSize.ts # useEmptyStateWindowSize(opts) — when the app starts with no terminal, size the │ │ # main window to the default profile via sizeMainWindowToProfile (same once-per- │ │ # session lock Term uses), so the empty state isn't stuck at the OS size. │ ├── useDragRegionDoubleClick.ts # useDragRegionDoubleClick() — double-click any drag region (title bar, sidebar │ │ # header, empty state) toggles maximize: a capture-phase mousedown listener suppresses │ │ # Tauri's built-in toggle (flaky on some platforms, tauri#11945/wry#622) then │ │ # maximizes/unmaximizes explicitly; resizable-guarded (welcome wizard locks it); │ │ # single-click dragging still goes through the built-in start_dragging. │ ├── useCommandPaletteActions.tsx # useCommandPaletteActions(opts) — build the palette action list (JSX) │ ├── useKeyRecorder.ts # useKeyRecorder(index, onRecord, onCancel) — global keydown capture for bindings editor │ ├── useSystemTheme.ts # useSystemTheme() → "light"|"dark"|null — OS theme preference (module-cached, │ │ # subscriber set; drives bare-profile palettes + themeMode "system") │ ├── useIsWayland.ts # useIsWayland() — cached is_wayland backend read (gates position restore, │ │ # glass capability; mirrors the useShells caching pattern) │ ├── useInstallSource.ts # useInstallSource() — cached install_source read (pacman/dpkg/rpm): the │ │ # update modal shows the package-manager command instead of self-update │ ├── useUpdater.ts # useUpdater() — the single updater state machine (check/download/install/ │ │ # progress/error) wrapping lib/updater.ts; owned by App so the sidebar │ │ # banner, update modal, and About page share one instance │ ├── useStartupUpdateCheck.ts # useStartupUpdateCheck(enabled) — the one-shot startup check (config- │ │ # gated; App mounts after config load so defaults never fire it) │ ├── useTearoffSession.ts # useTearoffSession() → {label, payload} | "no" | null (tab tear-off boot) │ └── useSessionPersistence.ts # useSessionPersistence(refs) — the app's only window close hook │ # (onCloseRequested): saves open tabs to session.json per sessionSaveMode, │ # drives the "ask" dialog, and one-shot-loads a saved session on mount for │ # useTerminalManager's seed effect to restore. │ ├── components/ │ ├── ui/ # Shared design primitives (the visual system — one of each thing) │ │ ├── IconButton.tsx # Unified chrome button (replaces 3 prior button systems). Motion-aware. │ │ ├── MaskedSurface.tsx # SVG-mask wrapper: clips children to a rounded rect (corners cut │ │ │ # away so the chrome beneath shows). Extensible to complex shapes. │ │ ├── SettingsShell.tsx # Settings page frame (scroll body + optional footer slot) │ │ ├── SettingRow.tsx # field / toggle / action / info row — kills the settings spacing drift │ │ ├── SectionTitle.tsx #