TERAX.md
Terax loads TERAX.md from the workspace root as agent memory (similar to AGENTS.md / CLAUDE.md). This file is also the project's living architecture doc β read it before making changes.
Project
Terax β open-source AI-native terminal emulator. Tauri 2 + Rust (portable-pty) backend, React 19 + TypeScript + xterm.js (webgl) client, BYOK AI via Vercel AI SDK v6.
- Bundle id:
app.crynta.terax - Package manager: pnpm
- Platforms: macOS, Linux, Windows
- Frontend type-check:
pnpm exec tsc --noEmit - Rust checks:
cd src-tauri && cargo check && cargo clippy
Architecture
Two-process model
Rust (src-tauri/) owns all OS access. The webview never touches the FS, processes, or shells directly β everything goes through invoke() calls to commands registered in src-tauri/src/lib.rs:
pty::pty_*β long-lived interactive PTY sessions (xterm β portable-pty), managed byPtyState(RwLock<HashMap<id, Session>>). Output streams via a TauriChannel<PtyEvent>.fs::tree::*,fs::file::*,fs::mutate::*β file explorer + editor IO.fs::search::*,fs::grep::*β fuzzy file finder + content search (powered byignore+grep-*crates).shell::shell_run_commandβ one-shot subshell exec used by AI tools. Distinct from PTY sessions; not the user's interactive terminal. On Windows it shells out via PowerShell (-NoProfile -Command); on Unix via$SHELL -lc. Shared helperbuild_oneshot_command.shell::shell_session_*β persistent agent shell with state across calls.shell::shell_bg_*β long-running background processes (dev servers etc.) with bounded ring-buffer log capture.secrets::secrets_*β OS keychain via thekeyringcrate. Service constantterax-ai. Linux uses a file-based fallback gated behind#[cfg(target_os = "linux")].open_settings_windowβ separate webview window for Settings.
PTY shell integration
PTY shells are bootstrapped via injected init scripts in src-tauri/src/modules/pty/scripts/:
- Unix (
zshenv.zsh,zprofile.zsh,zlogin.zsh,zshrc.zsh,bashrc.bash) β installed viaZDOTDIR(zsh) or--rcfile(bash). Emit OSC 7 (cwd) and OSC 133 A/B/C/D (prompt boundaries + exit code) so the host can track cwd and detect command boundaries without re-parsing the prompt. - Windows (
profile.ps1) β passed viapwsh -NoLogo -NoExit -ExecutionPolicy Bypass -File <path>. Wraps the user's existingpromptfunction (after their$PROFILEruns) to emit OSC 7 + OSC 133 A/B/D. Shell priority:pwsh.exe(PS 7+) βpowershell.exe(PS 5.1) βcmd.exe(no integration). cwd is normalized to backslashes before being passed to ConPTY (CreateProcessWmisbehaves with forward-slash cwd).
pty/shell_init.rs is split into #[cfg(unix)] / #[cfg(windows)] modules β keep new platform-specific code in the right cfg arm.
ConPTY on Windows requires SPAWN_LOCK (Mutex) around openpty + spawn_command in session.rs. Concurrent spawns leave one of the resulting PTYs with a stalled output pipe. Don't remove the lock without verifying first-tab stability under fast tab spam.
Each ConPTY child is also assigned to a per-session Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE (pty/job.rs). When the Job HANDLE drops β clean shutdown, panic, or even SIGKILL'd Terax process β the kernel kills every descendant of the shell (e.g. npm run dev spawned from inside pwsh). Without this Windows orphans the entire process subtree because TerminateProcess only kills the immediate child. macOS/Linux rely on Drop for Session β killer.kill(); on dev-Ctrl-C of cargo run destructors don't fire and orphans are possible there too β acceptable for now since dev only.
AiComposerProvider is mounted unconditionally at the App.tsx root: a conditional wrapper would change the parent element type when keys load, remounting the entire tree (and re-spawning every PTY) the moment getAllKeys() resolves. Production happened to dodge this because keychain reads can land in the same paint frame; dev didn't. Keep the unconditional wrap.
Frontend (src/)
Single-window React app. Path alias @/* β src/*. Tabs are tagged-union ({ kind: "terminal" | "editor" | "preview" | "ai-diff", β¦ }) and not unmounted on switch β they're hidden via invisible pointer-events-none so PTYs and dev servers keep streaming in the background.
App.tsx wires modules together β keep it a coordinator. New features go inside the appropriate modules/<area>/.
Module layout (src/modules/)
Each module is self-contained, exports a thin barrel via index.ts, and owns its hooks under lib/.
- terminal/ β
TerminalStackkeeps one mounted xterm per tab viauseTerminalSession+pty-bridge.osc-handlers.tsparses OSC 7 (with Windows drive-letter normalization:/C:/Users/fooβC:/Users/foo) and OSC 133 markers. Themes inthemes.ts. - editor/ β CodeMirror 6 stack (
EditorStackmirrorsTerminalStack).extensions.tsconfigures language modes; supports vim mode and prebuilt themes (Tokyo Night, Nord, GitHub, Atom One, Aura, Copilot, Xcode). - explorer/ β file tree with Material/Catppuccin icons (
iconResolver.ts), fuzzy search, keyboard nav, inline rename, context actions. Backslash-awarebasename. - preview/ β auto-detected dev-server preview tab (status-bar pill suggests opening when a localhost URL is detected).
- tabs/ β
useTabsis the source of truth for tab list + active id.useWorkspaceCwdderives explorer root + inherited cwd for new tabs from active tab.basenamesplits on both/and\. - header/ β top bar + inline search (
SearchInlineadapts to terminal vs editor viaSearchTarget).WindowControlsrendered whenUSE_CUSTOM_WINDOW_CONTROLSis true (Linux + Windows; macOS uses native traffic lights). - statusbar/ β bottom bar,
CwdBreadcrumb(handles Unix paths, Windows drive letters, and home~segments viapathUtils.segmentsFromCwd), AI tools indicator. - shortcuts/ β keymap registry (
shortcuts.ts) +useGlobalShortcuts. Handlers live inApp.tsxand are passed in by id (tab.new,ai.toggle, β¦).metaKey || ctrlKeyfor cross-platform Cmd/Ctrl. - settings/ β settings store (
store.tsviatauri-plugin-store), preferences hook, settings window opener. - shell-integration/ β frontend bridge for OSC events and shell session lifecycle.
- theme/ β
next-themesprovider. - updater/ β auto-updater UI built on
tauri-plugin-updater. - ai/ β see below.
AI subsystem (src/modules/ai/)
BYOK. Multi-provider via @ai-sdk/*: OpenAI, Anthropic, Google, Groq, xAI, Cerebras, OpenAI-compatible (LM Studio for local/offline). Provider list in config.ts (PROVIDERS); model registry includes DEFAULT_MODEL_ID + DEFAULT_AUTOCOMPLETE_MODEL.
- Key storage: OS keychain via
keyring(Rust). Frontend reads/writes throughsecrets_*commands. ServiceKEYRING_SERVICE = "terax-ai". Never persist keys to disk, settings store, orlocalStorage. - Agent (
lib/agent.ts):Experimental_AgentwithstopWhen: stepCountIs(MAX_AGENT_STEPS)and the system prompt fromconfig.ts. Provider branching happens here β keep theAgent/DirectChatTransportshape; the rest of the system depends on AI SDK v6 chat semantics. - Sub-agents (
agents/registry.ts,agents/runSubagent.ts): named sub-agents with their own system prompts and tool subsets, invoked by the main agent viarun_subagenttool. - Sessions (
lib/sessions.ts+store/chatStore.ts): conversations are organized into named sessions, persisted viatauri-plugin-storeatterax-ai-sessions.json(list +activeId+ per-sessionmessages:<id>keys).chatStore.tskeeps a module-scopedMap<sessionId, Chat<UIMessage>>;getOrCreateChat(apiKey, sessionId)lazily constructs aChat, seeded with messages from a hydration map populated byhydrateSessions()(called once fromApp.tsx).AgentRunBridgemirrors active-session messages to disk on every change and auto-derives titles from the first user message. Switching the API key wipes the chat map; sessions persist. - Composer (
lib/composer.tsx): React context providing shared input state (text, attachments, voice) for both the dockedAiInputBarand any other surface. Attachments include image, text-file, andselectionkinds β selections come fromuseChatStore.attachSelection(text, source)(drained into chips, not pasted into the textarea) and are wrapped as<selection source="terminal|editor">β¦</selection>blocks at submit. Composer derivesisBusyfromagentMeta.statusso it can mount safely before sessions hydrate. - Voice input: streamed transcription pipeline. Toggled from the composer.
- Live context bridge:
App.tsxcallssetLive({ getCwd, getTerminalContext, β¦ })so tools can read the currently active terminal's cwd + last 300 lines of buffer. Lazy by design β don't pre-snapshot. - Tools (
tools/tools.ts):read_file,list_directory,fs_search,fs_grepauto-execute.write_file,create_directory,rename,delete,run_command,shell_session_run,shell_bg_spawnsetneedsApproval: trueand the AI SDK pauses for an in-UI confirmation card. Auto-send after approval useslastAssistantMessageIsCompleteWithApprovalResponses.lib/security.tsis a deny-list refusing obvious secret paths (.env*,.ssh/, credentials, keychain dirs) β apply on both read and write paths and don't bypass it. - Edit diffs: AI-proposed edits open in a side-by-side diff tab (
ai-difftab kind); user accepts/rejects per hunk before the write tool actually runs. - Skills / snippets: reusable prompt fragments + tool-bundles surfaced in the composer.
UI conventions
- shadcn/ui is configured (
components.json, styleradix-luma, basemist, icon lib hugeicons). Primitives insrc/components/ui/β don't hand-edit; re-runpnpm dlx shadcn addto upgrade. - AI Elements (Vercel) live in
src/components/ai-elements/from the@ai-elementsregistry incomponents.json. Same rule: regenerate, don't hand-patch β composition wrappers belong inmodules/ai/components/. - Tailwind v4 β no
tailwind.config.*, config is insrc/App.cssvia@theme. Usecn()from@/lib/utils. - Animation:
motion(Framer Motion successor). Resizable layout:react-resizable-panels. - Path imports: always
@/β¦, never relative across modules. - Cross-platform paths: anywhere a path may originate from OSC 7, the explorer, or the OS, normalize separators with
.split(/[\\/]/)rather than.split("/"). - Canonical path form on the frontend is forward-slash.
homeDir()returns backslashes on Windows; convert at the boundary (App.tsx setHome). OSC 7 already arrives as forward-slash. Equal canonical strings keepuseFileTreefrom wiping its tree and flashing the explorer whentab.cwdfirst arrives.
Window styling
- macOS:
titleBarStyle: Overlay+hiddenTitle: trueintauri.conf.json(native traffic lights via overlay). - Linux:
decorations: false+transparent: truefromtauri.linux.conf.json; re-asserted post-realize for GNOME/Mutter CSD. - Windows: same as Linux via
tauri.windows.conf.json. React renders customWindowControls.
Tauri capabilities
src-tauri/capabilities/default.json is the allowlist for plugin APIs available to the webview. New plugins (dialog, autostart, updater, window-state, store, opener, os, log are wired in lib.rs) typically need:
Cargo.tomldependency.plugin(...)call inlib.rsrun()- capability entry in
default.json
Cross-platform conventions
- HOME / cache dirs: use the
dirscrate (dirs::home_dir(),dirs::cache_dir()), never raw$HOME/%USERPROFILE%. - Shell init scripts: gate Unix-only logic behind
#[cfg(unix)]; Windows arm inpty::shell_init::windows. - Terminal input: send
\r(CR) for Enter, not\n(LF) β PowerShell on Windows requires CR.
Bundle config
bundle.targets: "all"plus per-platform sections intauri.conf.json:- macOS:
minimumSystemVersion: 10.15. - Linux: deb depends
libwebkit2gtk-4.1-0,libgtk-3-0; rpmwebkit2gtk4.1,gtk3; AppImage bundles its media framework. - Windows: NSIS installer in
currentUsermode (no admin required), WebView2 viaembedBootstrapper(offline install).
- macOS:
- Auto-updater configured with a public minisign key; release artifacts at
https://github.com/crynta/terax-ai/releases/latest/download/latest.json.
Known gotchas
- React 19 strict mode double-mounts
useEffectin dev β terminals spawn twice on first render. The first PTY is cleaned up almost immediately. TheSPAWN_LOCKmutex serializes this; don't be alarmed bypty opened id=1followed bypty closed id=1in dev logs. - Windows PowerShell process lifecycle:
killer.kill()fromportable-ptyonly kills the immediate child. Descendants (e.g.npm run devstarted inside pwsh) survive unless something else takes them down. The Job Object inpty/job.rshandles this for the Terax-process-death case; an explicitpty_closefrom JS also kills only the immediate child + relies on the Job to take the rest. Don't disable the Job without a replacement. - Tab
cwdstorage: comes from OSC 7 with forward slashes (afterparseOsc7strips/C:βC:). Anything that consumestab.cwdand passes it to a Rust fs command on Windows must normalize separators or accept both forms βapply_commoninpty::shell_inithandles this for PTY spawn; other call sites must do their own.