# 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 #

heading + optional subtitle (consistent mb) │ │ ├── SaveFooter.tsx # Save (disabled-when-clean) + unsaved hint + trailing action slot │ │ └── ExternalLink.tsx # that opens via lib/openerApi.ts — every plain external │ │ # link goes through this (motion anchors call openExternal) │ ├── Term.tsx # Single xterm instance: PTY lifecycle, addon assembly (via │ │ # lib/setupTermAddons.ts), the hot-apply effect (render-option │ │ # changes mutate the live term.options + re-fit + ligature joiner │ │ # re-register; cols/rows and webgl are deliberately NOT hot-applied), │ │ # + delegates current-command tracking to useCurrentCommand and │ │ # edge-background sampling to useEdgeBackground │ ├── SearchBar.tsx # In-terminal search overlay (Ctrl+F): drives the headless │ │ # @xterm/addon-search via a glass top slide-down bar (case / │ │ # whole-word / regex toggles + result counter). Mounted in Term. │ ├── CompletionPopup.tsx # The terminal-suggest floating list: glass surface + kind icons (folder/file/ │ │ # command/option via lib/completions.ts), selected-row highlight, scroll + │ │ # into-view, flip below/above the cursor anchor Term computes. Never takes │ │ # focus — keys keep flowing through xterm's chain; hover selects, click │ │ # accepts. Rows are memoized with delegated events (candidate objects keep │ │ # identity through filtering, so typing re-renders only the selection flips) │ │ # and the list is capped (MAX_LIST in useShellCompletions) — unbounded rows │ │ # reconciled per keystroke stalled the main thread ahead of the echo's paint. │ ├── TabBar.tsx # Sidebar tab list — pure rendering. The whole drag domain (one │ │ # HTML5 drag serving reorder-inside / tear-off-outside, plus the │ │ # foreign-drag sentinel) lives in hooks/useTabDragController.ts │ ├── TitleBar.tsx # Drag region + window controls (per-platform) │ ├── CommandPalette.tsx # Ctrl+Shift+P modal │ ├── SessionSaveDialog.tsx # "Ask every time" close confirmation (Save / Don't Save + remember │ │ # this choice). Driven by useSessionPersistence; glass Modal. │ ├── ShellIcon.tsx # Per-shell tab icon (bash/zsh/fish/nu/pwsh/ssh/default) │ ├── AppIcon.tsx # Per-command tab icon: branded app logos from assets/app-icons/ │ │ # (registry loaded via import.meta.glob) or custom: imported images │ │ # (asset-protocol URL, resolved+cached via lib/commandIconApi.ts). │ │ # Rendered when the running command maps to an app (lib/appIcon.ts); │ │ # takes precedence over ShellIcon. │ ├── ThemePreview.tsx # 8-color ANSI swatch with tooltip │ ├── UpdateModal.tsx # Update flow modal: version + release notes + download progress + install; │ │ # driven by App's single useUpdater instance + installSource │ ├── TechStackModal.tsx # About page's tech-stack modal (items from lib/techStack.ts) │ ├── Markdown.tsx # Shared react-markdown + remark-gfm renderer (About page release notes) │ ├── EmptyState.tsx # Profile quick-launch list shown in the main area when the last tab is │ │ # closed while "keep window on last tab closed" is on (ids empty). │ │ # Centered icon + heading + clickable profile rows (shell icon via │ │ # getShellType/ShellIcon); the default profile shows its new-tab │ │ # shortcut hint (findBinding/bindingToShortcut). Whole surface is a │ │ # window drag region (data-tauri-drag-region). │ └── settings/ │ ├── GeneralSettings.tsx │ ├── GlobalProfileSettings.tsx │ ├── ProfileSettings.tsx # Per-profile form + the "wrap as app" launcher section (toggle, │ │ # title/working-directory/sidebar/icon rows via IconPicker, reveal │ │ # button via lib/fileManagerApi.ts). Saving chains │ │ # syncLaunchersFromConfig so launchers regenerate on commit. │ ├── RenderSettings.tsx # Shared render-option form (rows/cols/font/theme/webgl) │ ├── BindingsSettings.tsx │ ├── CommandIconSettings.tsx # User command→icon rules editor (config commandIcons): match │ │ # input + regex toggle (live validation) + icon picker (built-ins │ │ # + every stored imported SVG/PNG via list_command_icons) + live │ │ # test preview. Saving prunes unreferenced icon files (the only │ │ # cleanup moment) and re-lists. │ ├── IconPicker.tsx # Shared icon picker grid (built-ins + custom: ids + import button, │ │ # optional leading "auto" choice) — used by CommandIconSettings │ │ # rows and the profile launcher section │ ├── DeveloperSettings.tsx │ ├── AddProfileModal.tsx │ ├── ShellSelector.tsx # Shared shell picker (dropdown + custom path + browse) │ └── SshFields.tsx # Shared SSH Host/Port/User/IdentityFile form │ └── pages/ ├── WelcomePage.tsx # First-run wizard (3 steps) ├── SettingsPage.tsx # Settings shell with inner sidebar └── AboutPage.tsx ``` ### Backend (`src-tauri/src/`) ``` src-tauri/src/ ├── main.rs # entry, calls lib::run() ├── lib.rs # Tauri builder: plugins, state, invoke_handler registration; parse_cli() at top │ # of run() (handles --help/--version + exits before the window spawns) ├── cli.rs # clap-based launch-flag parsing (Alacritty-style): CliArgs (-e/--command, │ # --working-directory, -T/--title, --hold, --profile, --sidebar show|hide), │ # CliState, parse_cli │ # (filters macOS -psn_*), get_cli_args command — args are surfaced to the │ # frontend, which decides how they shape the initial tab. `-e` gets a │ # pre-clap argv split (split_command_region, tested via try_parse_cli): │ # tokens after it are the command EXCEPT Lumina's own window-shaping │ # flags, which still parse as flags (`-e nvim -T nvim` titles the window); │ # `--` switches to verbatim capture (escape hatch for `-e -- ssh -T h`) ├── state.rs # TerminalState (HashMap of PTY pairs + writers + force_low_latency flags │ # + swappable output_channel for tab tear-off reattach) ├── terminal.rs # start/reattach/kill/write/resize_terminal, set_output_mode commands. │ # start_terminal is a thin orchestrator over: build_shell_command (+ pure │ # shell_family / startup_command_argv / ssh_remote_command helpers — the │ # keepAfterExit per-shell-family argv, tested in tests/terminal.rs; │ # startup-command tabs also get the current proxy env set straight onto │ # the PTY via proxy::spawn_proxy_env — a -c command runs before the │ # first prompt, where the proxy hooks fire), the │ # extracted spawn_reader_thread (streams output over the entry's swappable │ # Channel with streaming-UTF-8 decoding + two-mode burst coalescing) │ # and spawn_watcher_thread (exit poll, foreground-command tracking, cleanup, │ # term-exit emit). reattach_terminal atomically swaps the channel for tab │ # tear-off ├── command_tracker.rs # CommandInfo type + foreground_command() /proc + ps + privileged-name logic ├── command_icons.rs # User-imported command icon storage: import_command_icon (validate ext/size, │ # copy into /command-icons with a content-hash name), │ # list_command_icons (picker source — every stored icon, so a rule can be │ # switched away and back), prune_command_icons (drop files no saved rule │ # references — the ONLY cleanup moment); pure helpers (sanitize_stem, ext_of, …) │ # parameterized by dir (tests/command_icons.rs) ├── shell_integration/ # bash/zsh/fish OSC-1337 injection (precmd/preexec hooks for exit codes │ # and command text) + TAB-completion interception for zsh/fish │ # (completion_hook_zsh: compadd recording shim, PREFIX filter, RS/US OSC payload; │ # completion_hook_fish: complete -C engine + fish_prompt-event rebinding so bundled │ # autopair can't steal TAB; both gated by the spawn-time enableShellCompletions flag) │ # + the per-shell proxy-sync hooks whose env-file │ # (proxy.env, same dir) is written by proxy.rs; hook sources are │ # generated per launch with the env-file path baked in (real-shell │ # lifecycle tests in tests/shell_hooks.rs). Layout: mod.rs holds the │ # Rust logic (apply_interactive, hook builders, render_proxy); the shell │ # snippets are real per-shell files embedded at compile time via │ # include_str! — bash/ (init.sh, proxy.sh), zsh/ (zshrc, zshenv, zprofile, │ # zlogin, proxy, complete .zsh), fish/ (preexec, precmd, proxy, complete │ # .fish). Proxy templates carry {env_path}/{proxy_keys} tokens filled by │ # render_proxy (plain string replace, NOT format! — the files keep valid │ # shell brace syntax with no escaping) ├── proxy.rs # System-proxy auto injection: ProxySnapshot + per-source parsers │ # (gsettings list-recursively / KDE kioslaverc / scutil --proxy / │ # reg query — pure & unit-tested) + the polling watcher thread and │ # start/stop_proxy_sync commands. Publishes the shell hooks' env-file │ # (KEY=value lines, absence = unset) atomically on change only; PAC │ # modes are reported off (env vars cannot express them). Also the │ # spawn-time source for startup-command tabs (spawn_proxy_env + │ # parse_proxy_env, PROXY_ENV_KEYS-only filter) — a -c command runs │ # before the hooks' first prompt. Parsers are │ # tested in tests/proxy.rs ├── mcp.rs # Read-only MCP (Model Context Protocol) server: rmcp tool handlers │ # (list_tabs/get_active_tab/get_tab/get_foreground_command/get_recent_output/ │ # get_terminal_cwd) reusing TerminalState + command_tracker + the per-tab │ # recent_output ring buffer; Streamable HTTP endpoint on 127.0.0.1 via axum, │ # config-driven start/stop (start_mcp_server/stop_mcp_server). Read-only by │ # design — no PTY-write tool. URL token in /state/mcp-token │ # (STATE_DIR mirrors src/constants.ts; legacy-root migration + loader │ # extracted as load_or_create_token_in, tested in tests/mcp.rs). ├── ssh.rs # SshConfig/SshHostEntry types + parse_ssh_config (~/.ssh/config) → │ # parse_ssh_config_content (pure content parser, tested in tests/ssh_config.rs) ├── shells.rs # find_shells — PATH + known-dir shell discovery (Win MSYS2/Git, Unix homebrew); │ # PATH scan extracted as scan_path_for (tested in tests/shells.rs) ├── system.rs # is_wayland, is_debug, get_commit_hash, get_log_dir, open_devtools ├── install_source.rs # install_source — pacman/dpkg/rpm package-ownership detection; │ # stdout parsers extracted pure (tested in tests/install_source.rs) ├── file_manager.rs # open_in_file_manager — xdg-open / open -R / explorer (per-OS) ├── launchers/ # Profile "wrap as app" generation, split by concern: │ │ # mod.rs — spec/dirs types, icon materialization + content-hash cache, │ │ # generate/sync/prune orchestration, and the Tauri command layer │ │ # (sync_profile_launchers regenerates every launcher from the frontend's │ │ # full spec list and prunes orphaned files, proving ownership before any │ │ # delete: lumina- prefix / bundle-id-in-plist / dedicated Start-Menu │ │ # subdir; exe resolution prefers $APPIMAGE on Linux — AppImage │ │ # current_exe is a temp mount). desktop.rs / bundle.rs / shortcut.rs — │ │ # the per-platform content builders (.desktop, exec script + Info.plist, │ │ # PowerShell WScript.Shell — no COM crates). icons.rs — hand-rolled │ │ # PNG→icns/ico byte wrapping (no image deps). Re-exported through mod.rs │ # so tests/lib.rs keep the `launchers::` paths. Tested in tests/launchers.rs ├── fonts.rs # find_font — CSS font-family → font file bytes for ligature parsing; │ # first_concrete_family pure (tested in tests/fonts.rs) └── utils.rs # path_exist, read_file (frontend-facing fs helpers) + the shared fs idioms # previously hand-rolled per module: content_hash_hex (icon storage naming), # write_atomic (tmp+rename; proxy env-file, icons, .desktop entries) and # prune_files_not_in (command-icons + launcher-icon caches) tests/ # Backend integration tests (mandatory for backend work — see §3.7). │ # Each file targets one src/ module against the lib crate │ # (lumina_terminal_lib); run with │ # `cargo test --manifest-path src-tauri/Cargo.toml`. ├── proxy.rs # per-source proxy parsers + env-file render + env-file parse (spawn injection) + real-gsettings e2e (self-skipping) ├── shell_hooks.rs # real bash/zsh/fish lifecycle of the generated proxy-sync hooks (self-skipping) ├── completion_hooks.rs # real-shell e2e of the completion interception: PTY-driven zsh (compinit HOME) │ # + fish (TERM=dumb — fish 4.x blocks on terminal queries a bare PTY never answers); │ # asserts the OSC payload (word + candidates + descriptions) and round-trips the │ # frontend insertion contract (DEL×word + insert) against the live zle ├── cli.rs # launch-flag parsing + the macOS -psn_* argv filter + the `-e` │ # command-region split (flags-after-command, `--` escape hatch) ├── ssh_config.rs # ~/.ssh/config content parsing: wildcards, keyword case, invalid port ├── shells.rs # scan_path_for over controlled temp dirs: hits, dedup, separators ├── state.rs # RecentOutput 64 KiB UTF-8-safe tail + capped exit/command stores ├── mcp.rs # strip_ansi: CSI/OSC/DCS removal, control chars, torn escapes ├── terminal.rs # flush_utf8_pass (split multi-byte chars, malformed safety net) │ # + process_cwd against this process's own /proc entry │ # + shell_family / startup_command_argv (keepAfterExit pwsh -NoExit vs │ # fish/nu/POSIX exec families) / ssh_remote_command pure helpers ├── command_tracker.rs # basename / privileged-name classification + real /proc & ps │ # argv resolution against a live child (Unix-only file) ├── command_icons.rs # import (ext/size validation, hash-named dedup) + prune over temp dirs; │ # sanitize_stem / ext_of pure helpers ├── install_source.rs # pacman/dpkg/rpm stdout sample shapes (hit and miss) ├── launchers.rs # Exec escaping, plist/script/ps1 content, png_width, icns/ico byte wrapping (via the │ # launchers/ submodule re-exports), │ # sanitize helpers + sync over temp dirs for all three formats (generate, │ # idempotent re-run, prune, macOS bundle-ownership protection, custom-icon │ # resolution + traversal rejection) ├── fonts.rs # CSS font-family → first concrete family extraction ├── utils.rs # path_exist / read_file over real temp files + content_hash_hex (shape, │ # stability), write_atomic (overwrite/parents/no-tmp-leftover) and │ # prune_files_not_in (keep-vs-drop, subdir-safe, missing-dir no-op) └── file_manager.rs # nonexistent-path guard (rejected before any OS spawn) ``` --- ## 3. Design Principles ### 3.1 Layering — one direction of dependency ``` types ← lib ← hooks ← components/pages ← App ``` - **`types/`** depends on nothing internal. - **`lib/`** holds pure logic: no React, no JSX, no `useState`. The single exception is `lib/bindings.ts`, which exports `useKeyboardBindings` for convenience — do not add more React into `lib/`. - **`hooks/`** may import `lib/` and `types/`, never `components/`. - **`components/`** may import `hooks/`, `lib/`, `types/`. - **`App.tsx`** wires everything; it may import from all layers. Never invert an arrow. If a `lib/` function needs React, it belongs in `hooks/`. ### 3.2 Single Source of Truth (no duplication) Before writing any new logic, check whether it already exists. Common categories that tend to duplicate: - **Platform checks** → use `lib/platform.ts` (`isMacOS`, `isLinux`). Do not call `@tauri-apps/plugin-os` directly in components. - **Color math** → use `lib/color.ts`. Do not re-implement luminance / contrast. - **Glass material / backdrop-filter** → use `lib/glass.ts` (`glassSurface`, `glassBorder`, `elevationShadow`) gated by `hooks/useGlass.ts`. Never write `backdrop-filter` inline in a component — the Wayland/WebKitGTK fallback lives in `glassSurface`, so bypassing it breaks Linux. Call `glassSurface` directly in the chrome container and spread the result onto its `style`. - **Motion presets** → use `lib/motion.ts` (shared framer-motion variants). Do not invent per-component spring curves; reuse `springSoft`, `fadeSlideUp`, `whileHoverTap`, etc., so all chrome animates with one rhythm. - **Chrome buttons** → use `components/ui/IconButton.tsx`. Do not hand-roll `