| # 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 by `PtyState` (`RwLock<HashMap<id, Session>>`). Output streams via a Tauri `Channel<PtyEvent>`. |
| - `fs::tree::*`, `fs::file::*`, `fs::mutate::*` β file explorer + editor IO. |
| - `fs::search::*`, `fs::grep::*` β fuzzy file finder + content search (powered by `ignore` + `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 helper `build_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 the `keyring` crate. Service constant `terax-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 via `ZDOTDIR` (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 via `pwsh -NoLogo -NoExit -ExecutionPolicy Bypass -File <path>`. Wraps the user's existing `prompt` function (after their `$PROFILE` runs) 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 (`CreateProcessW` misbehaves 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/** β `TerminalStack` keeps one mounted xterm per tab via `useTerminalSession` + `pty-bridge`. `osc-handlers.ts` parses OSC 7 (with Windows drive-letter normalization: `/C:/Users/foo` β `C:/Users/foo`) and OSC 133 markers. Themes in `themes.ts`. |
| - **editor/** β CodeMirror 6 stack (`EditorStack` mirrors `TerminalStack`). `extensions.ts` configures 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-aware `basename`. |
| - **preview/** β auto-detected dev-server preview tab (status-bar pill suggests opening when a localhost URL is detected). |
| - **tabs/** β `useTabs` is the source of truth for tab list + active id. `useWorkspaceCwd` derives explorer root + inherited cwd for new tabs from active tab. `basename` splits on both `/` and `\`. |
| - **header/** β top bar + inline search (`SearchInline` adapts to terminal vs editor via `SearchTarget`). `WindowControls` rendered when `USE_CUSTOM_WINDOW_CONTROLS` is true (Linux + Windows; macOS uses native traffic lights). |
| - **statusbar/** β bottom bar, `CwdBreadcrumb` (handles Unix paths, Windows drive letters, and home `~` segments via `pathUtils.segmentsFromCwd`), AI tools indicator. |
| - **shortcuts/** β keymap registry (`shortcuts.ts`) + `useGlobalShortcuts`. Handlers live in `App.tsx` and are passed in by id (`tab.new`, `ai.toggle`, β¦). `metaKey || ctrlKey` for cross-platform Cmd/Ctrl. |
| - **settings/** β settings store (`store.ts` via `tauri-plugin-store`), preferences hook, settings window opener. |
| - **shell-integration/** β frontend bridge for OSC events and shell session lifecycle. |
| - **theme/** β `next-themes` provider. |
| - **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 through `secrets_*` commands. Service `KEYRING_SERVICE = "terax-ai"`. Never persist keys to disk, settings store, or `localStorage`. |
| - **Agent** (`lib/agent.ts`): `Experimental_Agent` with `stopWhen: stepCountIs(MAX_AGENT_STEPS)` and the system prompt from `config.ts`. Provider branching happens here β keep the `Agent` / `DirectChatTransport` shape; 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 via `run_subagent` tool. |
| - **Sessions** (`lib/sessions.ts` + `store/chatStore.ts`): conversations are organized into named sessions, persisted via `tauri-plugin-store` at `terax-ai-sessions.json` (list + `activeId` + per-session `messages:<id>` keys). `chatStore.ts` keeps a module-scoped `Map<sessionId, Chat<UIMessage>>`; `getOrCreateChat(apiKey, sessionId)` lazily constructs a `Chat`, seeded with messages from a hydration map populated by `hydrateSessions()` (called once from `App.tsx`). `AgentRunBridge` mirrors 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 docked `AiInputBar` and any other surface. Attachments include image, text-file, and `selection` kinds β selections come from `useChatStore.attachSelection(text, source)` (drained into chips, not pasted into the textarea) and are wrapped as `<selection source="terminal|editor">β¦</selection>` blocks at submit. Composer derives `isBusy` from `agentMeta.status` so it can mount safely before sessions hydrate. |
| - **Voice input**: streamed transcription pipeline. Toggled from the composer. |
| - **Live context bridge**: `App.tsx` calls `setLive({ 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_grep` auto-execute. `write_file`, `create_directory`, `rename`, `delete`, `run_command`, `shell_session_run`, `shell_bg_spawn` set `needsApproval: true` and the AI SDK pauses for an in-UI confirmation card. Auto-send after approval uses `lastAssistantMessageIsCompleteWithApprovalResponses`. `lib/security.ts` is 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-diff` tab 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`, style `radix-luma`, base `mist`, icon lib **hugeicons**). Primitives in `src/components/ui/` β don't hand-edit; re-run `pnpm dlx shadcn add` to upgrade. |
| - **AI Elements** (Vercel) live in `src/components/ai-elements/` from the `@ai-elements` registry in `components.json`. Same rule: regenerate, don't hand-patch β composition wrappers belong in `modules/ai/components/`. |
| - **Tailwind v4** β no `tailwind.config.*`, config is in `src/App.css` via `@theme`. Use `cn()` 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 keep `useFileTree` from wiping its tree and flashing the explorer when `tab.cwd` first arrives. |
|
|
| ### Window styling |
|
|
| - macOS: `titleBarStyle: Overlay` + `hiddenTitle: true` in `tauri.conf.json` (native traffic lights via overlay). |
| - Linux: `decorations: false` + `transparent: true` from `tauri.linux.conf.json`; re-asserted post-realize for GNOME/Mutter CSD. |
| - Windows: same as Linux via `tauri.windows.conf.json`. React renders custom `WindowControls`. |
|
|
| ### 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: |
| 1. `Cargo.toml` dependency |
| 2. `.plugin(...)` call in `lib.rs` `run()` |
| 3. capability entry in `default.json` |
|
|
| ### Cross-platform conventions |
|
|
| - HOME / cache dirs: use the `dirs` crate (`dirs::home_dir()`, `dirs::cache_dir()`), never raw `$HOME` / `%USERPROFILE%`. |
| - Shell init scripts: gate Unix-only logic behind `#[cfg(unix)]`; Windows arm in `pty::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 in `tauri.conf.json`: |
| - **macOS**: `minimumSystemVersion: 10.15`. |
| - **Linux**: deb depends `libwebkit2gtk-4.1-0`, `libgtk-3-0`; rpm `webkit2gtk4.1`, `gtk3`; AppImage bundles its media framework. |
| - **Windows**: NSIS installer in `currentUser` mode (no admin required), WebView2 via `embedBootstrapper` (offline install). |
| - 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 `useEffect` in dev β terminals spawn twice on first render. The first PTY is cleaned up almost immediately. The `SPAWN_LOCK` mutex serializes this; don't be alarmed by `pty opened id=1` followed by `pty closed id=1` in dev logs. |
| - **Windows PowerShell process lifecycle**: `killer.kill()` from `portable-pty` only kills the immediate child. Descendants (e.g. `npm run dev` started inside pwsh) survive unless something else takes them down. The Job Object in `pty/job.rs` handles this for the Terax-process-death case; an explicit `pty_close` from JS also kills only the immediate child + relies on the Job to take the rest. Don't disable the Job without a replacement. |
| - **Tab `cwd` storage**: comes from OSC 7 with forward slashes (after `parseOsc7` strips `/C:` β `C:`). Anything that consumes `tab.cwd` and passes it to a Rust fs command on Windows must normalize separators or accept both forms β `apply_common` in `pty::shell_init` handles this for PTY spawn; other call sites must do their own. |
|
|