# Advanced (/docs/customize/advanced) ## Updates [#updates] | Key | Default | Description | | ------------- | ------- | ------------------------------- | | `auto_update` | `true` | Enable automatic update checks. | ## Startup [#startup] | Key | Default | Description | | --------------------------- | ------------------------------------- | --------------------------------------------------------------- | | `working_dir` | unset | Initial directory for new sessions. | | `working_dir_fallback` | `home` (macOS/Win), `process` (Linux) | Used when `working_dir` is unset. Alias: `default_working_dir`. | | `native_tab_persistence` | `false` | Restore native tabs and splits across restarts. | | `native_layout_autosave` | `false` | Auto-save into the currently loaded named layout. | | `native_buffer_persistence` | `false` | Replay saved buffer text when restoring layouts. | On launch, Termy starts shells for the selected restored workspace only. Inactive workspaces remain dormant until you first select them. ## Safety [#safety] | Key | Default | Description | | ----------------------------------- | ------- | -------------------------------------- | | `warn_on_quit` | `false` | Warn every time you try to quit. | | `warn_on_quit_with_running_process` | `true` | Warn when a tab has an active process. | ## Debug & UI [#debug--ui] | Key | Default | Description | | --------------------- | ------- | -------------------------------------------------------------- | | `show_debug_overlay` | `false` | Show FPS, CPU, and memory in the terminal corner. | | `simple_mode` | `false` | Open config file instead of Settings; disable command palette. | | `onboarding_complete` | `true` | First-run welcome flow; set `false` to see it again. | # Appearance (/docs/customize/appearance) ## Theme [#theme] | Key | Default | Description | | ----------------- | ------- | ---------------------------------------------- | | `theme` | `termy` | Current color scheme name. | | `chrome_contrast` | `false` | Increase contrast of non-terminal UI surfaces. | ## Font [#font] | Key | Default | Description | | ------------- | ----------------------------------------------------- | ----------------------------------- | | `font_family` | system monospace (`Menlo` / `Consolas` / `monospace`) | Font family used in terminal UI. | | `font_size` | `14` | Terminal font size in pixels. | | `line_height` | `1.40` | Line height multiplier (0.8 – 2.5). | ## Window [#window] | Key | Default | Description | | -------------------------- | ------- | ------------------------------------------------------- | | `background_opacity` | `1.0` | Window background opacity (0.0 – 1.0). | | `background_opacity_cells` | `false` | Apply window transparency to terminal cell backgrounds. | | `background_blur` | `false` | Enable blur for transparent backgrounds. | | `window_width` | `1280` | Startup window width in pixels. | | `window_height` | `820` | Startup window height in pixels. | Termy remembers the size, position, and maximized or fullscreen state of the last window you moved, resized, or closed, and reopens there on the next launch. `window_width` and `window_height` apply until a window size has been saved. To start from them again, delete `window-state.json` next to your config file. ## Padding [#padding] | Key | Default | Description | | ----------- | ------- | -------------------------------- | | `padding_x` | `12` | Left and right terminal padding. | | `padding_y` | `8` | Top and bottom terminal padding. | # Colors (/docs/customize/colors) Use the `[colors]` block to override the active theme. Values are 6-digit hex (`#RRGGBB`). For full themes, see [Themes](/docs/customize/themes). ```txt [colors] foreground = #e7ebf5 background = #0b1020 cursor = #a7e9a3 ``` ## Core [#core] | Key | Aliases | Description | | ------------ | ------- | -------------------- | | `foreground` | `fg` | Default text color. | | `background` | `bg` | Terminal background. | | `cursor` | | Cursor color. | ## ANSI 0–7 [#ansi-07] | Key | Aliases | Description | | --------- | -------- | ------------- | | `black` | `color0` | ANSI black. | | `red` | `color1` | ANSI red. | | `green` | `color2` | ANSI green. | | `yellow` | `color3` | ANSI yellow. | | `blue` | `color4` | ANSI blue. | | `magenta` | `color5` | ANSI magenta. | | `cyan` | `color6` | ANSI cyan. | | `white` | `color7` | ANSI white. | ## ANSI 8–15 (bright) [#ansi-815-bright] | Key | Aliases | Description | | ---------------- | -------------------------- | --------------- | | `bright_black` | `brightblack`, `color8` | Bright black. | | `bright_red` | `brightred`, `color9` | Bright red. | | `bright_green` | `brightgreen`, `color10` | Bright green. | | `bright_yellow` | `brightyellow`, `color11` | Bright yellow. | | `bright_blue` | `brightblue`, `color12` | Bright blue. | | `bright_magenta` | `brightmagenta`, `color13` | Bright magenta. | | `bright_cyan` | `brightcyan`, `color14` | Bright cyan. | | `bright_white` | `brightwhite`, `color15` | Bright white. | # Customize Termy (/docs/customize) Use Settings for normal changes. Edit `~/.config/termy/config.txt` directly when you want a portable, version-controlled setup. ## Common goals [#common-goals] | I want to… | Go to | | -------------------------- | ------------------------------------------------------------------ | | Change the font or cursor | [Appearance](/docs/customize/appearance) | | Use a different shell | [Terminal behavior](/docs/customize/terminal-behavior) | | Rebind a shortcut | [Keybindings](/docs/customize/keybindings) | | Find an exact setting name | [Configuration reference](/docs/reference/configuration-reference) | # Keybindings (/docs/customize/keybindings) In **Settings → Keyboard shortcuts**, **Switch tabs** is the main shortcut for cycling through tabs. It defaults to `Ctrl+Tab` and wraps from the last tab to the first. Click its shortcut box to record a replacement; press Escape to cancel or Backspace to clear it. Open **More tab shortcuts** to configure previous/next-tab and numbered-tab shortcuts individually. Existing custom bindings stay active when this group is collapsed and are preserved when you change **Switch tabs**. Repeated `keybind` lines in `~/.config/termy/config.txt`: ```txt keybind = clear keybind = secondary-p=toggle_command_palette keybind = secondary-t=new_tab keybind = secondary-w=unbind keybind = secondary-g=plugin:git-tools/status ``` * Lines apply in order; later lines win for the same trigger. * `clear` removes all defaults before later lines apply. * `unbind` removes a mapping. * `secondary` → `cmd` on macOS, `ctrl` elsewhere. * `plugin:/` runs a plugin command. Commands with inputs open their input form. * Termy refreshes plugins before a plugin shortcut runs, so changed manifests and source are picked up. Defaults: [Keybinding defaults](/docs/reference/keybinding-defaults). Actions: [Actions](/docs/reference/actions). # Tabs and Titlebar (/docs/customize/tabs-titlebar) ## Tab Titles [#tab-titles] | Key | Default | Description | | ----------------------------- | ----------------------------------- | --------------------------------------------- | | `tab_title_mode` | `smart` | `smart` \| `shell` \| `explicit` \| `static`. | | `tab_title_priority` | `manual, explicit, shell, fallback` | Exact source priority. | | `tab_title_fallback` | `Terminal` | Title when no source available. | | `tab_title_explicit_prefix` | `termy:tab:` | Prefix for explicit OSC title payloads. | | `tab_title_shell_integration` | `true` | Export `TERMY_*` env vars for shell hooks. | | `tab_title_prompt_format` | `{cwd}` | Template for prompt-derived titles. | | `tab_title_command_format` | `{command}` | Template for command-derived titles. | ## Tab Strip [#tab-strip] | Key | Default | Description | | --------------------------- | -------------------- | -------------------------------------------------- | | `tab_close_visibility` | `active_hover` | `active_hover` \| `hover` \| `always`. | | `tab_width_mode` | `active_grow_sticky` | `stable` \| `active_grow` \| `active_grow_sticky`. | | `tab_switch_modifier_hints` | `true` | Show secondary+1..9 badges on first nine tabs. | | `auto_hide_tabbar` | `true` | Hide tab bar when only one tab is open. | ## Title Bar [#title-bar] | Key | Default | Description | | ------------------------ | ------- | -------------------------------------------- | | `show_termy_in_titlebar` | `true` | Show or hide Termy branding in the titlebar. | # Terminal Behavior (/docs/customize/terminal-behavior) ## Shell [#shell] | Key | Default | Description | | --------------------------- | ---------------- | ---------------------------------------- | | `shell` | login shell | Executable used for new sessions. | | `term` | `xterm-256color` | `TERM` value exposed to child apps. | | `colorterm` | `truecolor` | `COLORTERM` value exposed to child apps. | | `shell_integration_enabled` | `true` | OSC 133 prompt and command tracking. | ## Cursor [#cursor] | Key | Default | Description | | -------------- | ------- | --------------------------------- | | `cursor_style` | `block` | `block` or `line`. | | `cursor_blink` | `true` | Enable blinking cursor animation. | ## Scrolling [#scrolling] | Key | Default | Description | | ------------------------- | ----------- | --------------------------------------------------- | | `mouse_scroll_multiplier` | `3` | Mouse wheel scroll speed multiplier. | | `scrollbar_visibility` | `on_scroll` | `off` \| `always` \| `on_scroll`. | | `scrollbar_style` | `neutral` | `neutral` \| `muted_theme` \| `theme`. | | `scrollback_history` | `2000` | Lines retained in scrollback (alias: `scrollback`). | | `inactive_tab_scrollback` | unset | Scrollback limit for inactive tabs. | ## Panes [#panes] | Key | Default | Description | | --------------------- | ---------------- | ------------------------------------------------------ | | `pane_focus_effect` | `soft_spotlight` | `off` \| `soft_spotlight` \| `cinematic` \| `minimal`. | | `pane_focus_strength` | `0.6` | Strength of active pane emphasis (0.0 – 2.0). | ## Clipboard [#clipboard] | Key | Default | Description | | ---------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `copy_on_select` | `false` | Auto-copy selected text. | | `copy_on_select_toast` | `true` | Show toast when text is copied on select. | | `clipboard_read` | `ask` | Whether programs may read the clipboard with OSC 52: `ask` prompts each time, `allow` answers silently, `deny` refuses. Writes are always allowed. | Termy supports the Kitty clipboard protocol (OSC 5522) out of the box. Terminal applications can list, read, and write multiple clipboard formats—including images and other non-text data—and can receive MIME-aware paste events. Reads that are not covered by a short-lived paste token or a remembered application grant require confirmation; format listing does not expose clipboard contents and does not prompt. The primary selection is supported on Linux. Native and tmux terminals use the same behavior. ## UI [#ui] Full-screen terminal apps that paint their own background, such as OpenCode, extend it into the padding around the grid when running in a single pane, so a different terminal theme does not leave a contrasting border. Apps that leave any edge cell on the terminal's default background, such as Claude Code and Codex, keep your theme's padding even while a diff or dialog fills most of the screen. Window controls and tabs remain available. | Key | Default | Description | | ------------------------------- | ------- | -------------------------------------------------------------- | | `command_palette_show_keybinds` | `true` | Show shortcut badges in command palette rows. | | `progress_indicator_enabled` | `true` | Show OSC 9;4 progress bars and OSC 7501 program status badges. | ## Tmux [#tmux] | Key | Default | Description | | ------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------ | | `tmux_enabled` | `false` | Enable tmux runtime integration. | | `tmux_persistence` | `true` | Reuse tmux tabs and panes across restarts. | | `tmux_exclusive` | `false` | Stay in tmux control mode; restart it instead of falling back to a classic terminal when control mode exits. | | `tmux_binary` | `tmux` | tmux executable path or binary name. | | `tmux_show_active_pane_border` | `false` | Show active tmux pane border highlight. | # Themes (/docs/customize/themes) Termy ships 800+ built-in themes from [iTerm2-Color-Schemes](https://github.com/mbadolato/iTerm2-Color-Schemes), plus its own `termy` and `termy-light`. Search and pick one in **Settings** or with **Switch Theme** in the command palette, or set it by id: ```txt theme = catppuccin-mocha ``` `termy -list-themes` prints every id. ## Custom themes [#custom-themes] Drop `.json` files in `~/.config/termy/themes/` (Windows: `%APPDATA%\termy\themes\`). The filename (without `.json`) is the theme id, and a file named like a built-in theme replaces it: ```txt theme = my-theme ``` Or pick one from **Switch Theme** in the command palette. ## Format [#format] ```json { "background": "#0b1020", "foreground": "#e7ebf5", "cursor": "#a7e9a3", "black": "#0b1020", "red": "#f1b8c5", "green": "#a7e9a3" } ``` All keys are 6-digit hex. See [Colors](/docs/customize/colors) for the full list. ## Import without switching theme [#import-without-switching-theme] **Import Colors** in the command palette writes values into `[colors]` while keeping your current `theme` — useful for one-off tweaks. Delete `[colors]` to fall back to the pure theme file. # Command Boundary (/docs/developer/command-boundary) ## Ownership [#ownership] `termy_core::command_core` owns: * Command IDs and config-facing command names * Command config-name parsing and normalization * Keybind defaults * Keybind directive parsing (`clear`, bind, unbind) * Deterministic keybind resolution order App/CLI adapters own: * UI labels, keywords, and command-palette presentation * Platform-specific visibility policy for palette entries * UI trigger canonicalization and validation (e.g. GPUI keystroke parsing) ## Dependency Rule [#dependency-rule] `termy_core::command_core` must remain a pure domain crate. It must **not** depend on: * `termy_core::config_core` * `gpui` * Other UI or presentation crates ## Integration Pattern [#integration-pattern] * Adapters convert parsed config keybind lines into `termy_core::command_core::KeybindLineRef`. * Adapters call `parse_keybind_directives_from_iter`; trigger canonicalization happens in `termy_core::command_core`. * Adapters call `resolve_keybinds` over `default_resolved_keybinds`. One canonical command/keybind engine; thin, readable adapter code. # Develop and embed (/docs/developer) Choose the path that matches what you are building. ## Pick the correct layer [#pick-the-correct-layer] | Change | Owning layer | | -------------------------------------------- | --------------------------------- | | Terminal parsing, PTY, frames, input, search | `termy_core` | | C, Swift, or Objective-C integration | `termy_core::ffi` | | Browser terminal and npm packages | `termy_wasm` and `packages/` | | `@termysh/pty` (Node.js PTYs) | `termy_core::pty` and `termy_pty` | | Shared tabs, panes, and terminal UI behavior | `termy::terminal_ui` | | GPUI desktop application | `termy` | # Integration guide for agents (/docs/developer/libtermy/agents) Use this page to establish the integration contract before generating code. `libtermy` is the product name; `termy_core` is the Rust crate and shared-library name. It is a terminal engine, not a ready-made native UI widget. ## Read the documentation as text [#read-the-documentation-as-text] The website exposes the same documentation to humans and agents: * [Documentation index](/llms.txt): page discovery. * [Full documentation](/llms-full.txt): all documentation in one response. * [libtermy overview](/docs/developer/libtermy.md): the section entry point. * [This agent guide](/docs/developer/libtermy/agents.md): the implementation contract. Append `.md` to any guide URL for its Markdown representation. For a focused integration, read the overview, your language quick start, rendering, events, and ownership reference; use the full dump only when broader context helps. ## Establish these decisions first [#establish-these-decisions-first] | Decision | What to record in the implementation | | ----------------- | ------------------------------------------------------------------- | | Revision | The exact Termy commit used for crate, header, binary, and bindings | | API | Rust or C ABI; rich cell text requirements may affect the choice | | Session ownership | Owned PTY or display-only external transport | | Rendering | UI toolkit, retained grid, coordinate scale, font source | | Scheduling | Terminal owner thread, output notification path, host timers | | Host policy | Clipboard access, URL schemes, paste behavior, shutdown behavior | Infer decisions from the existing host when possible. Do not add a new terminal parser, GUI framework, or session manager merely to make an example work. ## Integration contract [#integration-contract] 1. Use `termy_core` public exports or the checked-out `termy.h`. Do not invent constructors from an older example or import private engine types. 2. Start with a display-only parser smoke test. It makes build, ABI, and frame failures independent of shell startup. 3. Measure cells through core/config and create a nonzero grid. Keep pixel/point conversion consistent with drawing and hit testing. 4. Feed process output to `feed_output`; send input to `write` only for an owned PTY. Display-only input and protocol replies go to the external transport. 5. Establish a full retained frame. Full cells are row-major. Partial cells are concatenated in dirty-span order, with inclusive span bounds. 6. Choose one consuming damage API per render cycle. Do not drain damage before requesting the incremental frame that was supposed to contain it. 7. Update cursor and scroll metadata even when there is no cell damage. Resize, appearance changes, and lost renderer resources require appropriate redraws. 8. Encode keys and mouse events with the current terminal modes. Encoders do not send their results; empty results are valid. 9. Drain events and protocol requests, then continue or reschedule while `has_more` is true. A wakeup is coalesced work notification. 10. Serialize calls per C handle. Only wake wait/notify have a concurrency exception. Callback data is borrowed; read replies must be synchronous. 11. Free returned allocations with the matching libtermy free function. Free nested payloads through their batch, never independently. 12. On shutdown, stop new work, wake and join the waiter, cancel queued handle users, then free. Never rely on a finalizer racing with a waiter. ## Source map for a checkout [#source-map-for-a-checkout] Paths below are relative to the Termy repository. If documentation and source at your pinned revision differ, inspect the implementation and its tests before changing the host. | Source | What to verify | | -------------------------------------------------- | ------------------------------------------------------------- | | `crates/core/Cargo.toml` | Crate version, features, library artifact types | | `crates/core/include/termy.h` | C signatures, struct layouts, callbacks, concurrency contract | | `crates/core/src/lib.rs` | Public Rust exports | | `crates/core/src/runtime.rs` | Constructors, events, damage, visitors, terminal methods | | `crates/core/src/frame.rs` | Compact and rich frame fields | | `crates/core/src/ffi/mod.rs` | Numeric mappings, output ownership, C-to-Rust behavior | | `crates/core/src/config.rs` | Config loading, diagnostics, appearance resolution | | `crates/core/src/keyboard.rs`, `mouse_protocol.rs` | Input names and mode-aware encoding | | `crates/core/src/protocol/` | Reply routing and clipboard contracts | | `crates/core/src/glyph_geometry.rs` | Shared special-glyph plans | | `crates/core/tests/display_terminal.rs` | Deterministic parser/runtime examples | | `crates/core/tests/ffi_export_guard.rs` | Export/header guard checks | Internal runtime and engine files are useful for verification, not a public embedding dependency. Keep host code on the supported boundary. ## A task prompt you can reuse [#a-task-prompt-you-can-reuse] ```text Integrate libtermy into the existing host using the pinned Termy checkout. Read /docs/developer/libtermy/agents.md and the Rust or C quick start first. Use public termy_core exports or the matching termy.h; preserve the host UI stack. Implement a display-only smoke test, then the required session transport. Retain a full frame and patch partial cells in inclusive dirty-span order. Drain events and route protocol replies to the process that produced the output. Use current-mode input encoders and measured cell dimensions. Document allocation ownership, the terminal owner thread, and shutdown order. Verify parser output, incremental updates, resize, input, scrollback search, protocol replies, and teardown. Report which checks actually ran, which are platform-specific, and any unsupported rendering or clipboard behavior. ``` ## Validation and completion criteria [#validation-and-completion-criteria] For changes to core or its ABI in a Termy checkout, start with: ```sh cargo test -p termy_core cargo build --release -p termy_core --lib ``` For a host integration, compile and run the host against the newly built library; a successful core build does not prove the host's callbacks or renderer work. Use the [smoke-test matrix](/docs/developer/libtermy/troubleshooting#integration-smoke-tests). Document the revision, platforms tested, and limits such as compact-cell Unicode representation. Do not label an integration complete after only constructing a handle or receiving one wakeup. When updating these website pages, run `bun run types:check` and `bun run build` from `website/`. They are discovered by the documentation loader and included in search and the LLM routes. Check both the human page and its `.md` response when moving or adding a guide. # C ABI quick start (/docs/developer/libtermy/c) The C ABI lives in `termy_core`; the public header is [`crates/core/include/termy.h`](https://github.com/lassejlv/termy/blob/main/crates/core/include/termy.h). Use that header to generate bindings. Do not reconstruct its structs from prose: field order, `bool`, `size_t`, alignment, and pointer width are part of the ABI. ## Build the library [#build-the-library] From a Termy checkout at your chosen revision: ```sh cargo build --release -p termy_core --lib ``` With Cargo's default target directory, the native shared library is: | Platform | Output | | -------- | ------------------------------------ | | macOS | `target/release/libtermy_core.dylib` | | Linux | `target/release/libtermy_core.so` | | Windows | `target/release/termy_core.dll` | An explicit `--target` adds a target-triple directory. `CARGO_TARGET_DIR` changes the base directory. Match the library architecture to your host process. The crate builds `rlib`, `cdylib` and `staticlib` artifacts; the static library is `libtermy_core.a` (`termy_core.lib` on Windows). Linking it statically also needs the system libraries Rust's standard library uses; print them with `cargo rustc --release -p termy_core --lib --crate-type staticlib -- --print native-static-libs`. If your host owns the PTY and transport and needs only the parser, screens and encoders (for example a client mirroring a remote multiplexer), link the [engine-only C library](/docs/developer/libtermy/engine) instead. It has no PTY, config, keyring, font or network code. ## Run a deterministic example [#run-a-deterministic-example] Save this as `example.c` in the repository root. It parses known output without starting a shell and prints `hello from libtermy` from the returned cells. ```c title="example.c" #include "termy.h" #include int main(void) { int result = 1; TermyFfiTerminal *terminal = NULL; TermyFfiFrameUpdate update = {0}; TermyFfiSize size = termy_size_default(); size.cols = 80; size.rows = 24; TermyFfiStatus status = termy_display_terminal_new(size, &terminal); if (status != TERMY_FFI_OK) goto cleanup; const uint8_t output[] = "\x1b[32mhello from libtermy\x1b[0m\r\n"; status = termy_terminal_feed_output(terminal, output, sizeof(output) - 1); if (status != TERMY_FFI_OK) goto cleanup; status = termy_terminal_take_frame_update(terminal, true, &update); if (status != TERMY_FFI_OK) goto cleanup; if (update.damage_kind != 1 || update.cells_len != 80 * 24) goto cleanup; // This example prints ASCII only. A renderer must handle Unicode scalars. for (size_t col = 0; col < sizeof("hello from libtermy") - 1; ++col) { putchar((int)update.cells_ptr[col].codepoint); } putchar('\n'); result = 0; cleanup: if (status != TERMY_FFI_OK) fprintf(stderr, "libtermy status: %d\n", status); termy_frame_update_free(&update); termy_terminal_free(terminal); return result; } ``` Compile and run on macOS or Linux from the repository root: ```sh cc -std=c11 -Wall -Wextra example.c \ -I crates/core/include -L target/release -ltermy_core \ -Wl,-rpath,"$PWD/target/release" -o example ./example ``` The absolute runtime search path is for local development. For distribution, package the shared library with the application and configure the platform's runtime library search path. On Windows, use the import library produced by your selected toolchain and make the matching DLL available to the application. ## Create a PTY with configuration [#create-a-pty-with-configuration] For an interactive session, replace the display-only constructor with `termy_terminal_new_with_options`. The following helper returns a newly owned terminal or a status code; it does not build a GUI or run a render loop. ```c #include "termy.h" TermyFfiStatus create_terminal(TermyFfiTerminal **out_terminal) { TermyFfiConfig *config = NULL; TermyFfiRenderConfig render = {0}; TermyFfiStatus status = termy_config_load_default(&config); if (status != TERMY_FFI_OK) return status; status = termy_config_render_config_for_appearance(config, 1, &render); if (status == TERMY_FFI_OK) { TermyFfiSize size = termy_size_default(); size.cols = 100; size.rows = 30; size.cell_width = render.cell_width; size.cell_height = render.cell_height; TermyFfiTerminalOptions options = {0}; options.config = config; // Optional UTF-8 working directory, startup command, and env overrides. status = termy_terminal_new_with_options(size, &options, out_terminal); } termy_render_config_free(&render); termy_config_free(config); return status; } ``` Appearance `0` is light and `1` is dark. Use the real system appearance in your application. The constructor copies the launch data; the config and input buffers need only remain valid during the call. An environment entry consists of separate key/value pointers and byte lengths, not `KEY=value` strings. Write input with `termy_terminal_write`, drain events, and obtain frames after output arrives. Sending input and immediately taking a snapshot does not wait for a child process to answer. ## Display-only scheduling [#display-only-scheduling] `termy_display_terminal_new` creates a parser/grid with no PTY and **no C wake channel**. Schedule work from your transport's output callback: feed the bytes, drain protocol replies, then request an update. Do not build a display-only render loop around `termy_terminal_wait_for_wakeup`; it has no channel to wait on. For a PTY-backed handle, one dedicated waiter may call `termy_terminal_wait_for_wakeup`. Marshal its notification onto the thread that serially drives the handle. See [threading and shutdown](/docs/developer/libtermy/reference#threading-and-shutdown). ## Swift and other bindings [#swift-and-other-bindings] For macOS apps, prefer the [Swift SDK](/docs/developer/libtermy/swift): it wraps this ABI and includes AppKit and SwiftUI terminal views. To bind the C ABI yourself, expose the header through a bridging header or a Clang module. A minimal module map beside a vendored `termy.h` can contain: ```text title="module.modulemap" module CLibTermy { header "termy.h" link "termy_core" export * } ``` Configure your target's import/header and library search paths, then import `CLibTermy`. This imports the C ABI only. Wrap the opaque handle in one owner that serializes calls and performs the explicit shutdown sequence before releasing it. Across Swift, Go, Python, or C++ bindings: * Keep borrowed input bytes alive for the complete call. Lengths are byte counts, not Unicode character counts; buffers are not implicitly NUL-terminated. * Copy returned data into host-owned storage before calling its matching free function, or keep the owning batch alive until consumption finishes. * Preserve the complete returned pointer/length/capacity tuple until freeing it. * Keep callbacks and their user-data owners alive for the synchronous drain. * Use `termy_*_free`; never release Rust allocations with a host allocator. The [reference](/docs/developer/libtermy/reference) lists ownership pairs and status codes. A successful encoder may return an empty buffer; success does not always mean there are bytes to send. # Configuration and sizing (/docs/developer/libtermy/configuration) Configuration has two consumers: the terminal runtime needs shell, environment, scrollback, and query colors; the renderer needs fonts, colors, cursor settings, and dimensions. Loading configuration alone does not apply every setting to an existing terminal or to your UI. ## Load and inspect configuration [#load-and-inspect-configuration] | Source | Rust | C | | -------------------------- | ------------------------------- | ---------------------------- | | User's default config path | `load_config_from_default_path` | `termy_config_load_default` | | Explicit file | `load_config_from_path` | `termy_config_load_path` | | In-memory config text | `load_config_from_contents` | `termy_config_from_contents` | A missing default config uses defaults without creating a file. A missing explicit path is a load error. In-memory loading does not write settings. Rust's `LoadedTermyConfig` includes `app_config`, `runtime_config`, `diagnostics`, `path`, and `loaded_from_disk`. A successful load may still contain parse diagnostics. Show those to the user rather than silently treating fallback values as their intended configuration. In C, call `termy_config_diagnostics` and release the result with `termy_config_diagnostics_free`. | Diagnostic kind | Meaning | | --------------- | ------------------ | | `1` | Unknown section | | `2` | Unknown root key | | `3` | Unknown color key | | `4` | Invalid syntax | | `5` | Invalid value | | `6` | Duplicate root key | Use the [configuration reference](/docs/reference/configuration-reference) for accepted keys. A native embedder need not implement Termy's tabs, updater, or other app-level settings just because the config parser exposes them. ## Measure the terminal cells [#measure-the-terminal-cells] Rust hosts can call `measure_cell_from_config(&loaded.app_config)` or `measure_cell(font_family, font_size, line_height)`. C hosts obtain measured `cell_width` and `cell_height` in `TermyFfiRenderConfig` through `termy_config_render_config_for_appearance`. Use those dimensions for both the grid and rendering. Match the renderer's font and size to the resolved config; substituting a font without remeasuring can misalign text, mouse hit testing, and terminal size replies. Given available content dimensions after subtracting host chrome and padding: ```text cols = floor(content_width / cell_width) rows = floor(content_height / cell_height) ``` Use finite positive cell dimensions, clamp rows and columns to at least 1 and to the `uint16_t`/`u16` range, and avoid resizing while the view has no usable size. Keep cell measurements and content dimensions in one coordinate space. Apply logical-to-device scaling consistently instead of mixing points and pixels. ## Resolve appearance and colors [#resolve-appearance-and-colors] C hosts use `termy_config_render_config_for_appearance(config, appearance, ...)`: `0` selects light appearance and `1` selects dark. The simpler `termy_config_render_config` uses dark appearance. Release the returned font and theme data through `termy_render_config_free`, not by freeing its nested strings. The render config includes foreground, background, cursor color, font family, font size, line height, padding, opacity, cursor style/blink, and measured cells. Some additional fields describe optional host UI behaviors; they are not a renderer implementation. To change a live C terminal's query colors, call `termy_terminal_apply_config_colors_for_appearance`. Resolving a render config does not itself update the terminal. Refresh host drawing resources and request a full frame when the visual configuration changes. In Rust, use `runtime_config_from_app_config_with_theme` when constructing a runtime for a specific `SystemAppearance`. For an existing terminal, resolve colors with `resolve_theme_colors_from_app_config`, convert with `terminal_query_colors_from_resolved_theme`, and apply with `set_query_colors`. That updates query colors, not every runtime or application setting. ## Resize a live terminal [#resize-a-live-terminal] On a window resize or a font/scale change: 1. Resolve the current cell metrics and available content area. 2. Compute the new nonzero grid dimensions. 3. Call `Terminal::resize` (requires mutable access) or `termy_terminal_resize`. 4. In a display-only integration, also resize the external session through its transport; libtermy cannot resize a PTY it does not own. 5. Replace the retained cell buffer from a forced full frame, then repaint. Update image and glyph transforms along with text metrics. Do not patch a partial update into a buffer allocated for different dimensions. ## Choose where settings are saved [#choose-where-settings-are-saved] The `termy_settings_*` functions in the C header are conveniences for Termy's user configuration and can write to disk. They are separate from parsing an in-memory config. Use your own persistence layer if the embedder should have independent settings; do not call setters as a side effect of showing a preview. # Engine C library (/docs/developer/libtermy/engine) `libtermy_engine` is Termy's terminal engine as a standalone C library: the VT parser, screens, scrollback, protocol state and input encoders, and nothing else. It has no PTY, config files, threads, timers, keyring, fonts or network code. Your host feeds it program output and reads cells, state, protocol replies and encoded input bytes back. Use it when your application owns the process and transport itself, for example a client that mirrors panes from a multiplexer server, a session recorder, or a server that keeps the authoritative terminal for each pane. If you want libtermy to launch and own a shell, use the [C ABI quick start](/docs/developer/libtermy/c) instead. The header is [`crates/engine_ffi/include/termy_engine.h`](https://github.com/lassejlv/termy/blob/main/crates/engine_ffi/include/termy_engine.h) and is authoritative for every signature and struct. Check `termy_engine_abi_version() == TERMY_ENGINE_ABI_VERSION` at startup. Like the rest of libtermy, this library is in beta. Build the library and header from the same Termy revision on both sides of a connection, because a snapshot written by one version may be rejected by another. ## Build [#build] ```sh cargo build --release -p termy_engine_ffi --lib ``` This produces `target/release/libtermy_engine.a` and a shared library (the shared library is skipped on musl targets). To package a target as a tarball with the static library, the header, a Clang module map and the linker flags: ```sh scripts/package-libtermy-engine.sh x86_64-unknown-linux-musl aarch64-unknown-linux-musl scripts/package-libtermy-engine.sh aarch64-apple-darwin ``` Each target produces `dist/libtermy-engine-.tar.gz`. On macOS the script also assembles `dist/TermyEngine.xcframework`, which a Swift package can use as a `binaryTarget`. When linking the `.a` yourself, add the system libraries listed in the package's `lib/link-flags.txt`. ## Servers and replicas [#servers-and-replicas] Create the authoritative terminal with replies enabled, the default. Replies to queries such as DA, DSR, DECRQM and color reports come back from `termy_engine_feed` immediately and in byte order, so you can write them straight to the program: ```c TermyEngineOptions options = termy_engine_options_default(); options.cols = 120; options.rows = 40; TermyEngine *server = NULL; termy_engine_new(&options, &server); TermyEngineBytes replies = {0}; termy_engine_feed(server, pty_bytes, pty_len, &replies); write_to_pty(replies.ptr, replies.len); termy_engine_bytes_free(&replies); ``` A replica parses the same bytes but never answers, because the server already did. Start one from a snapshot of the server, then feed it every byte the server receives afterwards: ```c TermyEngineBytes snapshot = {0}; termy_engine_snapshot_encode(server, &snapshot); send_to_client(snapshot.ptr, snapshot.len); termy_engine_bytes_free(&snapshot); /* On the client: */ TermyEngine *replica = NULL; TermyEngineStatus status = termy_engine_snapshot_decode( snapshot_ptr, snapshot_len, TERMY_ENGINE_REPLIES_DISCARD, &replica); if (status == TERMY_ENGINE_UNSUPPORTED_SNAPSHOT_VERSION) { /* The snapshot is newer than this client reads; see "Versions" below. */ } termy_engine_feed(replica, output_ptr, output_len, NULL); ``` `termy_engine_set_reply_mode` switches an existing handle between the two modes. Synchronized output (`?2026`) buffers bytes until the program ends the update. The library has no timer: poll `termy_engine_synchronized_deadline` and call `termy_engine_tick` once the deadline passes, so an update that never ends still commits. ## Snapshots [#snapshots] A snapshot holds everything that decides how the terminal responds to later output: * both screens and all scrollback, with grapheme clusters, styles, OSC 8 hyperlinks, underline colors and wrap flags * the cursor with its style, visibility and pending wrap, and the saved cursors (DECSC) * scroll region, left/right margins, origin mode and tab stops * charsets and every DEC and ANSI mode, including mouse tracking and encoding, bracketed paste, focus reporting, alternate scroll, DECCKM, DECKPAM and each screen's kitty keyboard flag stack * OSC 4/10/11/12 color overrides, the title and title stack, the OSC 7 working directory and OSC 7501 program status * the parser's in-progress sequence (mid-escape, mid-string or mid-UTF-8) and a pending synchronized update * the scrollback and payload limits * kitty graphics: stored images and animations, placements, and an upload still arriving in chunks (version 2 and later) Decoding a snapshot and feeding it bytes B ends in the same state as feeding B to the original, including which scrollback rows are evicted. Snapshots do not include queued replies and events (drain them first) or render damage (a decoded engine starts with a full frame). The format starts with the magic `TRMYSNAP` and a version number, and all integers are little-endian, so snapshots move between Linux and macOS unchanged. `termy_engine_snapshot_peek_version` reads the version without decoding. Encoding 16 MiB of scrollback takes tens of milliseconds in a release build. Images are stored PNG-compressed. ### Versions [#versions] Every release decodes every version from `termy_engine_snapshot_min_version()` through `termy_engine_snapshot_version()`, and can encode any of them with `termy_engine_snapshot_encode_version`. When a server and its clients run different releases, the newer side adapts: a newer client decodes an older server's snapshot as is, and a newer server encodes for an older client after learning the client's `termy_engine_snapshot_version()` during the attach handshake. | Version | Adds | | ------- | --------------------------------------------------------------------------------- | | 1 | The first format | | 2 | Scrollback byte limit (and more than 20,000 rows), payload limits, kitty graphics | Encoding version 1 keeps the newest 20,000 history rows and leaves out kitty graphics; the client gets the default byte and payload limits. Releases may charge history rows differently, so identical eviction is only guaranteed between a server and replicas on the same release. ### Scrollback limit [#scrollback-limit] History keeps at most `scrollback_rows` rows and at most `scrollback_bytes` of row storage (16 MiB by default), whichever limit binds first. Set `scrollback_rows` to `TERMY_ENGINE_SCROLLBACK_UNLIMITED` to bound history by bytes alone. Each row is charged its slot, its cells and the grapheme, link and underline-color metadata it references, measured by value, so the charge depends only on the bytes fed and a server and its replicas evict the same rows. Calling `termy_engine_compact_history` changes how later rows are stored, so don't call it on a server or its replicas. Cells are stored dense (16 bytes each) or packed (4 bytes per character plus style runs). When history at its row limit would hold more dense rows than the byte limit or 16 MiB, output packs each line it scrolls into history into the storage of the row it evicts, keeping room for a full line. At 120 columns such a row costs about 650 bytes, so the default 16 MiB holds about 25,000 rows of output. `termy_engine_set_scrollback` and `termy_engine_set_scrollback_bytes` change the limits on a live handle. ### Payload limits [#payload-limits] OSC and DCS strings longer than `max_string_bytes` (64 KiB by default), and OSC 52 clipboard writes longer than `max_clipboard_bytes` (8 MiB by default), are dropped whole. The defaults are also the maxima: you can lower the limits but not raise them. `termy_engine_set_payload_limits` changes them on a live handle; a server and its replicas should use the same values, and snapshots carry them. Kitty graphics uploads have their own limit. ## Frames [#frames] `termy_engine_frame` returns the changes since the last frame. A frame is full on the first call, when you pass `force_full`, and whenever the size, active screen, viewport scroll position, palette or reverse video changed. Otherwise its spans list every changed cell, including rows that output scrolled. Frame pointers borrow buffers owned by the handle and stay valid until the next frame or line read on that handle, so steady-state rendering allocates nothing and nothing needs to be freed. Each `TermyEngineCell` carries: * the first scalar of its grapheme cluster, with the rest of the cluster in the frame's grapheme array * its width role: narrow, wide, spacer tail, or spacer head (a wide glyph that wrapped to the next row) * raw colors: `0` for the terminal default, `0x01000000 | n` for palette entry `n`, or `0x02RRGGBB` * every SGR attribute, the underline style and color, and a hyperlink index * `STYLED` and `BLANK` flags, so a renderer can skip plain blank cells The frame's cursor reports its shape, blinking, visibility, whether it is in the viewport, and whether it sits on the right half of a wide glyph. Resolve colors with `termy_engine_effective_colors`, after setting your theme with `termy_engine_set_default_colors`. `termy_engine_read_lines` reads any buffer lines, scrollback included, in the same cell format. ## State and queries [#state-and-queries] * `termy_engine_mode` answers like DECRQM for any mode: 0 not recognized, 1 set, 2 reset, 3 permanently set. * `termy_engine_get_state` returns size, cursor, mouse tracking and encoding, focus reporting, alternate scroll, bracketed paste, keypad and cursor-key modes, kitty keyboard flags and scrollback counts. * `termy_engine_title` and `termy_engine_working_directory` read the current title and OSC 7 directory. A replica restored from a snapshot has them without ever seeing the events. * `termy_engine_scrollbar` and `termy_engine_scroll_viewport` support scrollbars and scrolling to the top, the bottom, or a row. * `termy_engine_next_event` pops bell, title, directory, progress, clipboard and program-status events one at a time. ## Input encoders [#input-encoders] Encoders return the bytes to send to the program and never write anywhere: * `termy_engine_encode_key` handles legacy keys, DECCKM and DECKPAM, and every kitty keyboard flag. When the host reports which Option key is held, only the sides you configure act as Alt; the other side composes text. Pass the composed text in `text`: legacy encoding sends it as is for every key, Space included (macOS Option+Space sends U+00A0), unless Control or Command is held. * `termy_engine_encode_mouse` handles X10, normal, button-event and any-event tracking, the X10, UTF-8, SGR and SGR-pixel encodings, and buttons 1–11. SGR pixel reports (`?1016`) need `pixel_mouse_reports` in the options. * `termy_engine_encode_paste` turns line endings into CR, removes control characters that could run commands or end the bracket early, and adds the bracketed-paste markers when you pass `bracketed`. * `termy_engine_encode_focus` returns `CSI I` or `CSI O`. Send it only when the state reports focus reporting. ## Text and selection [#text-and-selection] `termy_engine_read_text` returns a range, or the whole buffer when the range is NULL, as plain text, VT text with SGR and OSC 8 preserved, or HTML. Options join soft-wrapped rows and trim trailing blanks. Your host keeps the selection state machine (click timing and drag extension). `termy_engine_select_word` and `termy_engine_select_line` return the word or logical line under a cell, across soft wraps, for double- and triple-click. To keep a selection attached to its text while output scrolls, anchor it with `scrolled_into_history` from the state. Resizing reflows rows without updating that count, so reset anchors on resize. ## Not included [#not-included] * kitty graphics in frames: the engine parses, stores and snapshots them, but the C API does not expose placements yet * platform keycodes: keys use Termy's key names # Events and protocol replies (/docs/developer/libtermy/events) Terminal output can change more than the cell grid. Event draining updates application chrome and services protocol requests from programs inside the terminal. A renderer that only takes frames can miss this work. ## Drain until the current work is handled [#drain-until-the-current-work-is-handled] Rust calls `terminal.drain_events(&mut reply_host)` and receives `(events, has_more)`. C calls `termy_terminal_drain_events` or the callback-enabled `termy_terminal_drain_events_with_clipboard` and receives a `TermyFfiEventBatch`. Process each batch, preserve `has_more`, free the batch, then repeat if needed. Under sustained output, use a host-side time budget and reschedule unfinished work instead of starving the UI. Do not wait for a new output notification when a batch already told you there is more work. A C batch owns every event's `payload`. Copy any title or other payload you need after `termy_event_batch_free`. Payloads have explicit byte lengths and are not NUL-terminated strings. ## Event reference [#event-reference] Use the named `TERMY_FFI_EVENT_*` constants from the header. Numeric values below help when implementing generated bindings or inspecting traces. | Kind | Event | Host action | | ---- | ----------------------- | --------------------------------------------------------------------------------------------- | | `1` | Wakeup | Schedule terminal work/redraw | | `2` | Title | Update the title from UTF-8 `payload` | | `3` | Reset title | Restore the host's fallback title | | `4` | Bell | Apply the user's bell/notification preference | | `5` | Exit | Mark the session exited and apply host close policy | | `6` | Clipboard store | Apply policy to the OSC 52 text in `payload` | | `7` | Shell prompt start | Update prompt lifecycle state | | `8` | Shell command start | Update command-input state | | `9` | Shell command executing | Mark command execution | | `10` | Shell command finished | Read the shell command's `exit_code` | | `11` | Progress | Read `progress_state` and `progress_value` | | `12` | Working directory | Update host metadata from UTF-8 `payload` | | `13` | Program status | Replace the previous snapshot with the JSON array in `payload` | | `14` | Input dropped | Tell the user that keys or a paste were discarded because the program's input backlog is full | Shell lifecycle events require the application/shell to emit the corresponding integration sequences; do not infer that every session will produce them. `Exit` is not an exact process-exit-code API. The command-finished event is a separate shell integration event, and its C representation uses `-1` when the shell did not supply an exit code. Progress states are `0` clear, `1` in progress, `2` error, `3` indeterminate, and `4` warning. For program-status records, see [program status](/docs/using-termy/program-status). Treat that event as a complete replacement snapshot rather than appending its records forever. ## Display-only protocol replies [#display-only-protocol-replies] Programs can query terminal capabilities, colors, cursor/size information, or clipboard state. PTY-backed terminals send generated replies to their owned child. A display-only terminal needs the host to forward reply bytes to the external process. A minimal Rust reply host can collect replies for the transport to send after the drain returns: ```rust use termy_core::{TerminalClipboardTarget, TerminalReplyHost}; #[derive(Default)] pub struct ReplyHost { pub pending_replies: Vec, } impl TerminalReplyHost for ReplyHost { fn load_clipboard(&mut self, _target: TerminalClipboardTarget) -> Option { None // No clipboard access in this example. } fn protocol_reply(&mut self, bytes: &[u8]) { self.pending_replies.extend_from_slice(bytes); } } ``` In C, provide the `protocol_reply_callback` to `termy_terminal_drain_events_with_clipboard`. Copy the callback's borrowed slice into a transport queue before returning. Do not feed reply bytes back into `termy_terminal_feed_output`; they are input for the external process. A NULL `protocol_reply_callback` drops the queued replies without changing any other state. To get replies without waiting for a drain, feed with `termy_terminal_feed_output_with_replies` (Rust: `feed_output_with_replies`). It returns every reply queued so far, in the order the engine produced them, so a status report precedes a following device-attributes reply. Release the bytes with `termy_buffer_free`. ### Replicas [#replicas] A display terminal that mirrors output another terminal already answered must not reply again. Call `termy_terminal_set_protocol_replies_enabled(terminal, false)` (Rust: `set_protocol_replies_enabled(false)`): queries are still parsed and change no state, and every reply is dropped. To start a replica from an existing terminal, copy its state with `termy_terminal_snapshot_encode` and create the replica with `termy_terminal_snapshot_decode`; feeding both the same bytes afterwards leaves them in the same state. See [snapshots](/docs/developer/libtermy/engine#snapshots) for what a snapshot holds. ## Kitty OSC 5522 clipboard [#kitty-osc-5522-clipboard] Kitty clipboard requests can read and write arbitrary MIME formats. The host owns clipboard access and permission policy. Rust implements the `TerminalReplyHost` clipboard methods. C uses the callback-enabled event drain. Missing read callbacks deny access; missing write callbacks report unsupported. C callback rules: 1. Requests, MIME lists, and write-content slices are borrowed only for the callback. Copy anything your host needs to keep. 2. A read callback must invoke the supplied reply callback before returning. libtermy copies the response slices during that invocation. 3. A write callback returns a `TermyFfiClipboardWriteResponse` directly. 4. Never retain the read reply function or `reply_user_data` for later use. 5. Never re-enter the same terminal handle from a clipboard or protocol-reply callback. Keep using the callback-enabled drain for subsequent batches. If your platform requires an asynchronous permission prompt, do not hold the borrowed callback open across it. Return an appropriate denied/busy result under your policy and obtain permission separately for future requests. Marshal drain work to the thread your platform clipboard API requires. Requests include location, MIME types/content, optional application name, and permission state. Result status values are success (`0`), denied (`1`), unsupported (`2`), busy (`3`), invalid data (`4`), and I/O error (`5`). These are clipboard result codes, distinct from `TermyFfiStatus` function return values. Before an ordinary paste, query `termy_terminal_kitty_clipboard_paste_events_enabled` and, if enabled, call `termy_terminal_send_kitty_clipboard_paste_event` with the available MIME types. Clipboard location is `1` clipboard or `2` primary selection. Rust display-only hosts can use `kitty_clipboard_paste_notification` to obtain notification bytes for their transport; do not assume an owned-PTY send helper delivers them. ## Wakeups and scheduling [#wakeups-and-scheduling] PTY wakeups are coalesced. One notification can represent several writes or several queued events; one write does not necessarily produce one notification. For C PTY handles, `termy_terminal_wait_for_wakeup` blocks up to `timeout_ms`; zero polls. It reports whether a signal was received through `out_woke`. Avoid calling a blocking wait on the UI thread. A dedicated waiter should post to the terminal's serialized owner and check a host-owned shutdown flag after waking. The C display-only constructor has no wake channel. Its transport should schedule drains and frames after feeding output. Rust provides `new_display_with_wakeup_notifier` if notification-based scheduling is needed. Before freeing a C handle with a waiter, stop new work, notify the waiter, join it, and only then free. See the [ownership contract](/docs/developer/libtermy/reference#threading-and-shutdown). # libtermy overview (/docs/developer/libtermy) `libtermy` is the embeddable terminal engine used by Termy. The Rust crate is `termy_core`; its C ABI is declared in `termy.h`. It provides terminal parsing, PTY sessions, scrollback, input encoding, events, search, and renderer-neutral frames. Your application provides the window, renderer, and platform integration. libtermy is in beta. Pin a Termy commit, build the library and bindings from that same revision, and validate upgrades. Neither Rust source compatibility nor C struct layouts are guaranteed between releases. ## Choose an API [#choose-an-api] | What you are building | Start with | Session owner | | -------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------ | | Native Rust application | [Rust quick start](/docs/developer/libtermy/rust) | libtermy can launch and own the PTY | | macOS app in Swift or SwiftUI | [Swift quick start](/docs/developer/libtermy/swift) | libtermy can launch and own the PTY | | C, C++, Objective-C, or another FFI host | [C ABI quick start](/docs/developer/libtermy/c) | libtermy can launch and own the PTY | | Remote terminal, replay viewer, or externally managed session | Display-only examples in either quick start | Your transport feeds output and receives input/replies | | Multiplexer client with replica terminals, or a static engine-only library | [Engine C library](/docs/developer/libtermy/engine) | Your host owns the PTY and transport; libtermy\_engine only parses and encodes | | Browser terminal or Node.js PTY | [Web terminal packages](/docs/developer/web) | See the package-specific guides | | Coding agent implementing an integration | [Agent integration guide](/docs/developer/libtermy/agents) | Choose explicitly before writing code | ## What is available in beta [#what-is-available-in-beta] | libtermy owns | Your host owns | | ------------------------------------------------------------------- | ------------------------------------------------------------------------ | | VT parsing, grid state, scrollback, terminal modes | Windows, tabs, panes, focus, and accessibility | | Native PTY startup, input writes, and resize | Session lifecycle and application shutdown policy | | Full frames, dirty spans, cursor and scroll metadata | Retained cell storage, text shaping, GPU resources, and drawing | | Keyboard and mouse protocol encoding | OS event translation, IME, shortcuts, selection, and paste policy | | Theme resolution and measured cell dimensions | Font availability, scale conversion, padding, and layout | | Shell, title, bell, progress, and clipboard requests | Application chrome and clipboard permission decisions | | Search, OSC 8 links, special-glyph geometry, Kitty image placements | Search navigation, URL opening, geometry painting, and image compositing | A terminal handle represents one terminal surface. Creating one does not create a visible widget. Owning a PTY and rendering its output are separate jobs. ## Rust quick start [#rust-quick-start] Begin with a deterministic [display-only Rust example](/docs/developer/libtermy/rust#parse-output-without-starting-a-shell), then switch to a PTY-backed terminal. The Rust guide includes configuration, launch parameters, and an event-draining example. ### Rich Rust rendering and the engine boundary [#rich-rust-rendering-and-the-engine-boundary] The compact frame API works across Rust and C. Rust also exposes complete cell text, underline styles, live palette revisions, and generation-checked reads. Use the [rendering guide](/docs/developer/libtermy/rendering#rich-rust-rendering) to choose a contract. The internal terminal engine is private; do not import backend implementation types into an embedder. ### Display-only terminals [#display-only-terminals] Feed process output into `feed_output`; send user input to the external session owner. `write` does nothing without an owned PTY. Protocol replies also need a route back to the external process. See [events and replies](/docs/developer/libtermy/events). ## C ABI quick start [#c-abi-quick-start] [Build the shared library and run a complete C example](/docs/developer/libtermy/c). The guide also covers PTY creation, linking, Swift bindings, and status handling. There is no separate prebuilt native SDK distribution documented here: build `termy_core` and vendor the matching header from the same checkout. ## Drive the render loop [#drive-the-render-loop] 1. Load configuration, measure cells, and create a terminal with a nonzero grid. 2. Request and retain one full frame. 3. Schedule terminal work when output, input, resize, or host timers require it. 4. Drain events and protocol replies; continue while the drain reports more work. 5. Take a frame update, patch the retained grid, and update cursor/scroll metadata. 6. Paint affected content, then return to idle. Read [rendering and damage](/docs/developer/libtermy/rendering) before implementing step 5. Full-frame cells are row-major; partial-frame cells are in dirty-span order. They are not interchangeable arrays. ### Shared special-glyph plans [#shared-special-glyph-plans] Use the shared glyph geometry for supported block, box-drawing, sextant, Braille, rounded-corner, and diagonal characters. The [geometry and graphics section](/docs/developer/libtermy/rendering#special-glyph-geometry) explains row context, sparse plans, and pixel snapping. ### Kitty clipboard callbacks from C [#kitty-clipboard-callbacks-from-c] Clipboard callbacks are synchronous and borrowed data expires when the callback returns. See [clipboard integration](/docs/developer/libtermy/events#kitty-osc-5522-clipboard) for permission handling and reply lifetimes. ### Batched special-glyph plans from C [#batched-special-glyph-plans-from-c] Patch the retained full grid first, then build a sparse plan with `termy_cells_build_glyph_render_plan`. Plan entries address that full grid. See [rendering](/docs/developer/libtermy/rendering#special-glyph-geometry). ## Input, search, and links [#input-search-and-links] [Input and navigation](/docs/developer/libtermy/input) covers mode-aware keyboard and mouse encoding, bracketed paste, viewport coordinates, scrollback search, and link lookup. Encoders return bytes; your host must send them. ## Ownership and threading [#ownership-and-threading] Serialize calls to each C terminal handle. Only the wake-channel wait and notify functions have a documented concurrency exception. Read the [ownership and API reference](/docs/developer/libtermy/reference) before building bindings; it lists allocation/free pairs, status codes, and safe shutdown order. ## Beta expectations [#beta-expectations] Keep your dependency revision, header, binary, and generated bindings together. Do not interpret a familiar function name as a promise of unchanged struct layout. The Rust 0.2 API removed raw-backend access including `Terminal::with_term` and `TerminalOptions::term_config`; use the public renderer-neutral types. ## Validate an integration [#validate-an-integration] The [troubleshooting guide](/docs/developer/libtermy/troubleshooting) provides a smoke-test matrix and checks for blank frames, stale redraws, bad coordinates, missing replies, and teardown crashes. Agents can read [this page as Markdown](/docs/developer/libtermy.md), the [documentation index](/llms.txt), or the [full documentation text](/llms-full.txt). Every guide in this section also has a `.md` URL. # Input, search, and navigation (/docs/developer/libtermy/input) Input flows from the host to the application running inside the terminal. Output flows from that application into libtermy. Keeping those directions separate is especially important for display-only sessions. ## Keyboard input [#keyboard-input] Use the current terminal keyboard mode when encoding a key. It can change while an application runs, so do not cache one mode forever or hardcode arrow-key escape sequences. ```rust use termy_core::{Terminal, TerminalKeyEventKind, TermyKeystroke, keystroke_to_input}; pub fn send_key(terminal: &Terminal, key: &TermyKeystroke, kind: TerminalKeyEventKind) { if let Some(bytes) = keystroke_to_input(key, kind, terminal.keyboard_mode(), true) { terminal.write(&bytes); } } ``` This helper is for an owned PTY. For display-only sessions, send the returned bytes to the external transport instead. Translate native events into `TermyKeystroke`: `key` is the logical key name, `key_char` is optional produced text, and `modifiers` describes control, alt, shift, platform, and function keys. Use names such as `enter`, `backspace`, `left`, and `right` supported by the [encoder](https://github.com/lassejlv/termy/blob/main/crates/core/src/keyboard.rs). In C, fill `TermyFfiKeystroke` and call `termy_terminal_encode_key` or `termy_terminal_encode_key_with_options`. `event_kind` is `1` press, `2` repeat, and `3` release. The encoder returns an owned `TermyFfiBytes`: write or forward it, then call `termy_buffer_free`. An empty result is valid, for example when a release event does not need reporting. Use the options variant for macOS Option-as-Alt behavior. Do not bypass the mode-aware encoder with an unconditional ESC prefix; enhanced keyboard modes can require a different representation. Handle application shortcuts and IME composition in the host so committed text is not sent twice. ## Paste [#paste] Query `bracketed_paste_mode()` or `termy_terminal_bracketed_paste_mode` at paste time. If enabled, wrap the approved paste payload in these delimiters: ```text ESC [ 2 0 0 ~ payload ESC [ 2 0 1 ~ ``` In byte notation, those are `\x1b[200~` and `\x1b[201~`. If disabled, send the payload without wrappers. Writing bytes does not automatically add these markers. Decide how to handle newlines and control characters at the host's paste-policy layer, and send one consistently ordered paste operation. If Kitty clipboard paste notifications are enabled, report the available MIME formats as described in [events and clipboard](/docs/developer/libtermy/events#kitty-osc-5522-clipboard). A paste notification does not replace the actual paste payload. ## Mouse input [#mouse-input] Convert pointer coordinates to zero-based viewport cell coordinates after subtracting padding and host chrome. Clamp or reject positions outside the grid. libtermy handles the terminal protocol's coordinate encoding. Rust uses `encode_mouse_report` with `terminal.mouse_mode()`, event kind, position, and modifiers. C uses `termy_terminal_encode_mouse`, which reads the current mode internally. | C `TermyFfiMouseInput.kind` | Meaning | | --------------------------- | ----------------- | | `1` | Press | | `2` | Release | | `3` | Drag | | `4` | Move | | `5`, `6` | Wheel up, down | | `7`, `8` | Wheel left, right | For press/release/drag, `button` is `1` left, `2` middle, or `3` right. An empty encoded result means nothing should be sent for that event/mode combination. The host decides whether a gesture should instead select text or scroll history. Mouse reporting and local scrollback navigation are separate actions. ## Scrollback and coordinate spaces [#scrollback-and-coordinate-spaces] `scroll_display(delta)` / `termy_terminal_scroll_display` moves the viewport: positive values move into older history, negative values move toward the live bottom. Use `scroll_to_bottom` to return to live output. Repaint when the method reports a change. | Coordinate | Meaning | | ---------------------------- | ---------------------------------------------------------------- | | Frame/dirty-span/mouse row | Zero-based row in the visible viewport | | `display_offset` | Distance in rows above the live bottom; zero is live output | | `history_size` | Number of retained history rows | | Terminal search result `row` | Zero-based row in history plus the live screen, oldest row first | | Rust buffer-line visitors | Signed engine-neutral line coordinates; see `line_bounds()` | For a coherent buffer state, the oldest visible search row is `history_size - display_offset`. Convert a search result to a viewport row with: ```text viewport_row = result.row - (history_size - display_offset) ``` Do that subtraction in a signed type or check bounds first; a result above the viewport must not underflow an unsigned index. Draw a highlight only when the result lies within the visible row range. Re-run searches after output, resize, or history trimming changes the buffer; result rows are not persistent IDs. ## Search [#search] `Terminal::search` and `termy_terminal_search` search history **and** the live screen. The default is case-insensitive literal matching. Use `search_with_options` / `termy_terminal_search_with_options` for case-sensitive or regular-expression queries. Results contain the buffer row, inclusive `start_col` and `end_col`, and line text. C result lines are owned by the search batch; release the entire result with `termy_search_batch_free`. Rust's `search_shared_with_options` shares line strings between matches on the same row, reducing duplication for large results. Empty queries and invalid regular expressions currently return no matches. If your UI needs to distinguish invalid syntax from zero results, validate the pattern separately. Searches operate on individual buffer rows; do not promise multiline matching across wrapped rows. The standalone Rust `search_frame` helpers only search the frame you pass in. That narrower behavior is different from searching a `Terminal`. ## Links and selection [#links-and-selection] `hyperlink_at(row, col)` / `termy_terminal_hyperlink_at` looks up OSC 8 hyperlinks at a **viewport** position. C reports whether a link was found separately from its status code; release a returned link with `termy_hyperlink_free`. Rust also exposes `link_at` for broader detected-link information. Opening a link is a host action. Validate terminal-provided URL schemes against your allowed schemes before passing a target to the OS. A terminal-provided working directory or link should not automatically execute a command. Selection gestures, highlight painting, copy behavior, and accessibility are host responsibilities. Preserve wide-cell and wrapping information when assembling selected text; slicing UTF-8 by terminal column is not valid. # Ownership and API reference (/docs/developer/libtermy/reference) The [C header](https://github.com/lassejlv/termy/blob/main/crates/core/include/termy.h) is authoritative for signatures and layouts. The [Rust exports](https://github.com/lassejlv/termy/blob/main/crates/core/src/lib.rs) identify the public Rust API. Generate local Rust documentation for your pinned checkout with `cargo doc -p termy_core --no-deps`. ## API map [#api-map] | Task | Rust | C | | ---------------------------------------- | --------------------------------------- | ------------------------------------------------------------------- | | Load user config | `load_config_from_default_path` | `termy_config_load_default` | | Parse config text | `load_config_from_contents` | `termy_config_from_contents` | | Launch PTY | `Terminal::new` and launch variants | `termy_terminal_new_with_options` | | Create parser without PTY | `Terminal::new_display` | `termy_display_terminal_new` | | Deliver process output | `feed_output` | `termy_terminal_feed_output` | | Deliver output, return replies in order | `feed_output_with_replies` | `termy_terminal_feed_output_with_replies` | | Mirror another terminal without replying | `set_protocol_replies_enabled(false)` | `termy_terminal_set_protocol_replies_enabled` | | Save full terminal state | `encode_snapshot` | `termy_terminal_snapshot_encode` | | Restore full terminal state | `restore_snapshot` | `termy_terminal_snapshot_decode`, `termy_terminal_snapshot_restore` | | Send input to owned PTY | `write` | `termy_terminal_write` | | Change dimensions | `resize` | `termy_terminal_resize` | | Read full compact frame | `snapshot` | `termy_terminal_snapshot` | | Consume compact update | `frame_update` | `termy_terminal_take_frame_update` | | Consume damage only | `take_damage_snapshot` | `termy_terminal_take_damage` | | Read rich text and palette | `render_read`, visitors | No equivalent rich-cell C API | | Encode keyboard event | `keystroke_to_input_with_options` | `termy_terminal_encode_key_with_options` | | Encode mouse event | `encode_mouse_report` | `termy_terminal_encode_mouse` | | Drain events and replies | `drain_events` with `TerminalReplyHost` | `termy_terminal_drain_events_with_clipboard` | | Search buffer | `search_with_options` | `termy_terminal_search_with_options` | | Find OSC 8 link | `hyperlink_at` | `termy_terminal_hyperlink_at` | | Scroll viewport | `scroll_display`, `scroll_to_bottom` | `termy_terminal_scroll_display`, `termy_terminal_scroll_to_bottom` | | Change history limit | `set_scrollback_history` | `termy_terminal_set_scrollback_history` | | Clear history | `clear_scrollback` | `termy_terminal_clear_scrollback` | | Plan special glyphs | `terminal_glyph_plan` | `termy_cells_build_glyph_render_plan` | | Read image placements | `kitty_graphics_snapshot` | `termy_terminal_kitty_graphics_placements` | C constructors without a config use runtime defaults; they do not implicitly load all of the user's application settings. Choose the config-aware constructor when you want that behavior. ## Allocation ownership [#allocation-ownership] Zero-initialize output structs and check the returned status before reading them. Free an existing successful result before reusing its output variable: the next call does not release the previous allocation for you. | Owned result | Matching release | Nested data owned by that result | | ------------------------------------------------------------ | --------------------------------- | ---------------------------------- | | `TermyFfiTerminal *` | `termy_terminal_free` | Terminal and wake channel | | `TermyFfiConfig *` | `termy_config_free` | Parsed configuration | | `TermyFfiFrame` | `termy_frame_free` | Cell array | | `TermyFfiFrameUpdate` | `termy_frame_update_free` | Cell and dirty-span arrays | | `TermyFfiDamage` | `termy_damage_free` | Dirty-span array | | `TermyFfiGlyphRenderPlan` | `termy_glyph_render_plan_free` | Entries, rectangles, strokes | | `TermyFfiEventBatch` | `termy_event_batch_free` | Events and each event's payload | | `TermyFfiSearchBatch` | `termy_search_batch_free` | Matches and each match's line | | `TermyFfiHyperlink` | `termy_hyperlink_free` | URI bytes | | `TermyFfiConfigDiagnosticBatch` | `termy_config_diagnostics_free` | Diagnostics and their messages | | `TermyFfiRenderConfig` | `termy_render_config_free` | Font-family and active-theme bytes | | `TermyFfiKittyGraphicsBatch` | `termy_kitty_graphics_batch_free` | Placements and their PNG buffers | | Standalone `TermyFfiBytes` (including snapshots and replies) | `termy_buffer_free` | Its byte buffer | Never free a nested byte field separately from its owning batch. Do not edit returned allocation pointers, lengths, or capacities; the free functions need them to reconstruct Rust-owned allocations. Copying an owning C struct does not create another owned allocation: freeing both copies double-frees the same data. `TermyFfiByteSlice` is borrowed and has no free function. Its lifetime comes from the call or callback that supplies it. Standalone `TermyFfiBytes` from an encoder or config string getter is owned; `TermyFfiBytes` inside a batch belongs to that batch. The containing API determines ownership, not just the field's type. ## Threading and shutdown [#threading-and-shutdown] A `TermyFfiTerminal` handle is not internally synchronized at the FFI boundary. Serialize every call taking that handle, including reads and encoding calls. Separate handles can be driven independently. The only documented exceptions are `termy_terminal_wait_for_wakeup` and `termy_terminal_notify_wakeup`, which access the wake channel rather than terminal state. A safe shutdown sequence for a host with a waiter is: 1. Stop scheduling new terminal work and set the host's shutdown flag. 2. Let any in-flight serialized terminal call finish. 3. Call `termy_terminal_notify_wakeup` so the waiter can observe shutdown. 4. Join the waiter; ensure queued callbacks/tasks cannot access the handle. 5. Call `termy_terminal_free` exactly once and clear the host pointer. Notify alone is insufficient: the waiter must exit rather than immediately waiting again. Never free while any call, including a blocking wait, is still using the handle. Clipboard callbacks cannot re-enter it even on the same thread. Rust types have their own synchronization and borrowing contracts; the C concurrency rule is not a substitute for reading the Rust method docs. In particular, locked visitors must not re-enter the same terminal. ## Status codes [#status-codes] | Status | Value | Interpretation | | ---------------------------------------- | ----- | ------------------------------------------------------------- | | `TERMY_FFI_OK` | `0` | Call succeeded; check result fields for empty/no-result cases | | `TERMY_FFI_NULL` | `1` | A required pointer was null | | `TERMY_FFI_INVALID_UTF8` | `2` | Text input was not valid UTF-8 | | `TERMY_FFI_SPAWN_FAILED` | `3` | Terminal child startup failed | | `TERMY_FFI_CONFIG_LOAD_FAILED` | `4` | Configuration could not be loaded | | `TERMY_FFI_UNKNOWN_KEY` | `5` | A key/value kind was unsupported by that API | | `TERMY_FFI_WRITE_FAILED` | `6` | An operation reporting write failure failed | | `TERMY_FFI_SERIALIZE_FAILED` | `7` | Serialization failed | | `TERMY_FFI_PANICKED` | `8` | A Rust panic was caught at the ABI boundary | | `TERMY_FFI_INVALID_ARGUMENT` | `9` | Arguments were rejected | | `TERMY_FFI_UNSUPPORTED_SNAPSHOT_VERSION` | `10` | A snapshot was written by an incompatible format version | | `TERMY_FFI_INVALID_SNAPSHOT` | `11` | The bytes are not a snapshot, or it is truncated or corrupt | Not every function returns every status. For example, `OK` from an encoder can mean no bytes were produced; `OK` from a search can mean no matches. Function success is not proof that the child has processed input or exited successfully. On failure, do not consume the output as a successful result. Report the API name and status, and use a controlled teardown/recreation policy after an unexpected panic. Panic catching does not make invalid pointers, mismatched headers, or concurrent handle access safe. ## Adjacent host APIs [#adjacent-host-apis] The header also exposes settings persistence, CLI installation, native config getters, and tmux control helpers. They are not required to embed one terminal. Settings and installation calls have filesystem side effects. A tmux control handle is a different type with its own lifetime: close it with `termy_tmux_control_close`, release notification batches with `termy_tmux_control_notifications_free`, and release standalone command output with `termy_buffer_free`. Keep session-manager features separate from the terminal frame loop. Use the matching header and implementation when adopting these additional APIs. # Rendering and damage (/docs/developer/libtermy/rendering) libtermy returns terminal state, not pixels. A renderer retains a grid, patches updates, shapes ordinary text, draws terminal geometry and images, and manages cursor/selection overlays. Start with correct full frames, then add incremental updates using the contracts below. ## Full frames and partial updates [#full-frames-and-partial-updates] `Terminal::snapshot()` / `termy_terminal_snapshot` returns a full compact frame. `frame_update(force_full)` / `termy_terminal_take_frame_update` returns cells plus damage. Force a full frame for initialization, dimension changes, or cache recovery. | Damage | Rust | C `damage_kind` | Cell layout | | -------------- | -------------------------- | --------------- | ----------------------------------------------- | | No cell damage | `Partial` with empty spans | `0` | No cells to patch | | Full | `Full` | `1` | `rows * cols` cells in row-major order | | Partial | `Partial(spans)` | `2` | Concatenated cells for each span, in span order | Each dirty span is `(row, left_col, right_col)` with **inclusive** columns. Its length is `right_col - left_col + 1`. A cell does not carry its own coordinates. For an 80-column grid, spans `(2, 3, 5)` and `(7, 10, 11)` correspond to five source cells. Copy the first three to retained indices 163–165, and the next two to 570–571. Do not index the five-cell source array by those absolute indices. ## Patch a retained Rust frame [#patch-a-retained-rust-frame] This complete helper assumes the first update is forced full. It also copies cursor and scroll metadata when no cells changed. ```rust use termy_core::{TerminalDamageSnapshot, TermyFrame, TermyFrameUpdate}; pub fn apply_update(retained: &mut Option, update: TermyFrameUpdate) { let TermyFrameUpdate { cols, rows, cells, cursor, display_offset, history_size, damage, } = update; match damage { TerminalDamageSnapshot::Full => { assert_eq!(cells.len(), usize::from(cols) * usize::from(rows)); *retained = Some(TermyFrame { cols, rows, cells, cursor, display_offset, history_size, }); } TerminalDamageSnapshot::Partial(spans) => { let frame = retained.as_mut().expect("request a full frame first"); assert_eq!((frame.cols, frame.rows), (cols, rows)); let mut source = 0; for span in spans { assert!(span.row < usize::from(rows)); assert!(span.left_col <= span.right_col); assert!(span.right_col < usize::from(cols)); let count = span.right_col - span.left_col + 1; let destination = span.row * usize::from(cols) + span.left_col; frame.cells[destination..destination + count] .copy_from_slice(&cells[source..source + count]); source += count; } assert_eq!(source, cells.len()); frame.cursor = cursor; frame.display_offset = display_offset; frame.history_size = history_size; } } } ``` In a GUI, recover from a lost cache or dimension mismatch by requesting a new full frame. The assertions above make integration mistakes visible during bring-up. C hosts use the same algorithm with `cells_ptr`, `spans_ptr`, and `damage_kind`, then call `termy_frame_update_free` after copying the data. Damage reads consume pending damage. Do not call `take_damage_snapshot` / `termy_terminal_take_damage` and then expect `frame_update(false)` to return that same damage. Choose one damage-consumption path per render cycle. Cursor movement, host selection, focus, blink, and animation can require a repaint independently of new text. Track old and new cursor rectangles and copy metadata even on an empty update. A host timer may need to repaint without a PTY wakeup. ## Interpret compact cells [#interpret-compact-cells] | Field | Rendering meaning | | ---------------------------------------------- | ----------------------------------------------------------------------- | | Rust `char` / C `codepoint` | One Unicode scalar, not a complete grapheme string | | `fg`, `bg` | Resolved RGBA cell colors | | `uses_terminal_default_bg` | Distinguishes default background from explicit cell backgrounds | | `render_text` | Whether to draw the cell's text; background painting is independent | | `wide_character_spacer` | Trailing space occupied by a wide character; do not draw a second glyph | | `bold`, `italic`, `underline`, `strikethrough` | Compact text styling flags | | `line_wrapped` | Wrapping information; preserve it when implementing selection/copy | The C cursor styles on this revision are `1` line/bar and `2` block. Check the matching header and implementation when supporting other revisions. Respect cursor visibility, including a hidden cursor while viewing history. The compact format cannot represent all combining text or underline variants. If those are requirements, use rich Rust reads or explicitly account for the C surface's limitations. Do not claim that `codepoint` contains a complete grapheme. ## Rich Rust rendering [#rich-rust-rendering] `Terminal::render_read(force_full)` returns a coherent `TerminalRenderRead` with viewport metadata, a live palette, rich cells, damage, scroll operations, and a generation. Its `cells` vector is a **full row-major viewport**, even when its damage is partial. This differs from `frame_update(false)`. `TerminalRenderCell` exposes complete cell text, indexed/default/RGB colors, underline color and style, dim/inverse/hidden state, and wide-cell flags. Resolve `TerminalRenderColor` using the read's palette and configured fallbacks. A live palette revision can change how cached cells look. For a damage-first renderer: 1. Call `take_render_damage_snapshot` to capture damage and its generation. 2. Account for ordered viewport scroll operations and palette revision changes. 3. Use `visit_viewport_ranges_at_generation` for the affected spans. 4. If it returns `false`, discard that attempt and take a fresh coherent read; a forced full read is a simple recovery path. `visit_viewport_cells` and `visit_viewport_ranges_at_generation` permit reentrant callbacks. The `_locked` variants and `visit_line_cells` hold the backend lock; their callbacks must not call back into the same terminal. Use the snapshot variants when callback reentrancy is required. ## Special-glyph geometry [#special-glyph-geometry] Use `terminal_glyph_plan` with `TerminalGlyphMetrics` and `TerminalGlyphNeighbors` to render supported block elements, box drawing, sextants, Braille, rounded corners, and diagonals. If no plan is returned, shape the character normally. `TerminalGlyphNeighbors::from_row` preserves the context-sensitive distinction between long Braille runs and short spinners. C hosts should: 1. Patch the retained full row-major cell buffer. 2. Pass that buffer, dimensions, measured metrics, and dirty spans to `termy_cells_build_glyph_render_plan`. 3. Draw entries using their absolute `cell_index` and ranges into the rectangle and stroke arrays. 4. Release the batch with `termy_glyph_render_plan_free`. Zero spans requests a **full glyph plan**, not an empty plan. Skip this call for an update with no cell damage unless a full rebuild is intended. Keep full row context available, and invalidate neighboring cached geometry when a change can affect a context-sensitive run; rebuilding the affected row is a simple policy. Primitive coordinates are normalized to a cell. Transform them into the host's coordinate system and snap in device pixels. Rectangle snap kinds are nearest (`1`) and outward (`2`); stroke width is a fraction of cell width. Do not font-shape and draw a geometry plan over the same glyph. ## Kitty graphics [#kitty-graphics] Images are separate from the compact cell array. In Rust, use `kitty_graphics_snapshot` for a revision and placements. In C, use `termy_terminal_kitty_graphics_revision` and `termy_terminal_kitty_graphics_placements`; release the batch with `termy_kitty_graphics_batch_free`. Placements include PNG bytes, image identity/generation, source crop, display size, viewport position, offsets, z-order, clipping, virtual-cell coordinates, and `next_frame_delay_ms`. Preserve signed viewport and column offsets when clipping; converting a negative value to an unsigned index can put an image far outside the viewport. Respect the placement transform and clipping fields instead of stretching every PNG over its occupied cells. An animated placement's nonzero `next_frame_delay_ms` requires a host timer to fetch refreshed placements and redraw. Zero means static. A terminal that is not receiving new text can still need an image repaint. The batch owns its PNG buffers; copy bytes needed by asynchronous decoders before freeing it. # Rust quick start (/docs/developer/libtermy/rust) The crate is `termy_core`, not `libtermy`. Use Rust stable with edition 2024 support and the repository's native build prerequisites. The default `native` feature includes the runtime used by these examples. Disabling default features is not a drop-in replacement for a native terminal integration. ## Add the dependency [#add-the-dependency] For a project next to your Termy checkout: ```toml title="Cargo.toml" [package] name = "libtermy-example" version = "0.1.0" edition = "2024" [dependencies] termy_core = { path = "../termy/crates/core" } ``` For a Git dependency, use `termy_core = { git = "https://github.com/lassejlv/termy", rev = "YOUR_TESTED_COMMIT" }` instead. Replace the placeholder with the full commit you tested and commit your lockfile. Examples on this website describe the current source; check the matching source when targeting an older revision. ## Parse output without starting a shell [#parse-output-without-starting-a-shell] This complete program feeds known bytes directly to the parser. It avoids shell startup timing and is a useful first check of your build and frame handling. ```rust title="src/main.rs" use termy_core::{ Terminal, TerminalDamageSnapshot, TerminalSize, load_config_from_contents, measure_cell_from_config, }; fn main() { let loaded = load_config_from_contents(""); let metrics = measure_cell_from_config(&loaded.app_config); let size = TerminalSize { cols: 80, rows: 24, cell_width: metrics.cell_width, cell_height: metrics.cell_height, }; let terminal = Terminal::new_display(size, Some(&loaded.runtime_config)); terminal.feed_output(b"\x1b[32mhello from libtermy\x1b[0m\r\n"); let frame = terminal.frame_update(true); assert!(matches!(frame.damage, TerminalDamageSnapshot::Full)); assert_eq!(frame.cells.len(), 80 * 24); let first_line: String = frame.cells[..80].iter().map(|cell| cell.char).collect(); println!("{}", first_line.trim_end()); } ``` Run it with `cargo run`. It prints `hello from libtermy`. The escape sequences change terminal state; they do not appear in the cell text. A display-only terminal has no child process. `feed_output` parses bytes coming **from** a process; `write` sends bytes **to** an owned PTY and is a no-op here. For a remote session, send encoded input through your transport and implement `TerminalReplyHost::protocol_reply` to forward terminal responses as well. ## Start a native PTY [#start-a-native-pty] Add `anyhow = "1"` and `flume = "0.11"` to the dependency table. This example opens a shell, sends a command, and looks for its output for up to three seconds. The command uses POSIX shell syntax, so adapt it for a Windows shell. ```rust title="src/main.rs" use std::time::{Duration, Instant}; use termy_core::{ Terminal, TerminalClipboardTarget, TerminalEvent, TerminalSize, load_config_from_default_path, measure_cell_from_config, }; fn main() -> anyhow::Result<()> { let loaded = load_config_from_default_path()?; let metrics = measure_cell_from_config(&loaded.app_config); let (wakeup_tx, wakeup_rx) = flume::bounded(1); let terminal = Terminal::new( TerminalSize { cols: 80, rows: 24, cell_width: metrics.cell_width, cell_height: metrics.cell_height, }, None, // optional working directory Some(wakeup_tx), None, // optional shell-integration title policy Some(&loaded.runtime_config), None, // optional startup command )?; // No clipboard access in this example. let mut reply_host = |_target: TerminalClipboardTarget| None::; let deadline = Instant::now() + Duration::from_secs(3); terminal.write(b"printf '\\154\\151\\142\\164\\145\\162\\155\\171\\055\\157\\153\\012'\r"); // Octal output avoids matching the shell's echo of the command itself. let mut exited = false; while Instant::now() < deadline { let _ = wakeup_rx.recv_timeout(Duration::from_millis(100)); loop { let (events, has_more) = terminal.drain_events(&mut reply_host); exited |= events.iter().any(|event| matches!(event, TerminalEvent::Exit)); if !has_more { break; } } if !terminal.search("libtermy-ok").is_empty() { println!("PTY output received"); return Ok(()); } if exited { anyhow::bail!("shell exited before producing the expected output"); } } anyhow::bail!("no expected output before the deadline") } ``` The timeout is an example failure bound, not a rendering cadence. A GUI should post wakeups to its event loop, drain work, request an incremental frame, and return to idle. A wakeup is a hint that work is available, not a promise that a particular command finished. ## Choose launch and wakeup controls [#choose-launch-and-wakeup-controls] | API | Use it when | | ----------------------------------------------- | ----------------------------------------------------------------------- | | `Terminal::new` | You want the configured shell and a `flume::Sender<()>` wakeup channel | | `Terminal::new_with_wakeup_notifier` | Your event loop can be scheduled by a `TerminalWakeupNotifier` callback | | `Terminal::new_with_launch_and_wakeup_notifier` | You need the typed `TerminalLaunch` contract | | `Terminal::new_display` | You own output delivery and redraw scheduling | | `Terminal::new_display_with_wakeup_notifier` | Your display-only host wants notifications when output is committed | Keep wakeup callbacks short: schedule work on the terminal-owning thread rather than performing rendering or nested terminal calls inside the notifier. ## Choose a rendering contract [#choose-a-rendering-contract] * `snapshot()` returns a complete compact row-major frame for simple readers. * `frame_update(true)` establishes a complete frame; subsequent `frame_update(false)` calls return damage-scoped cells. * `render_read` returns rich text and palette data for Rust renderers. * Generation-checked visitors support copying only damaged rich cells. Read [rendering and damage](/docs/developer/libtermy/rendering) for the exact layouts. Do not use internal `terminal_engine` or backend types as an integration shortcut. `Terminal::with_term`, `TerminalOptions::term_config`, and older raw conversion helpers are not part of the current Rust API. Continue with [configuration and sizing](/docs/developer/libtermy/configuration), then [events and protocol replies](/docs/developer/libtermy/events). # Swift Metal renderer (/docs/developer/libtermy/swift-rendering) `TerminalView` in the [Swift SDK](/docs/developer/libtermy/swift) renders with Metal. This page explains how a frame gets from libtermy to the screen, what is cached, and what each piece costs. The sources are in [`sdk/swift/Sources/TermyAppKit/Metal`](https://github.com/lassejlv/termy/tree/main/sdk/swift/Sources/TermyAppKit/Metal). ## From output to pixels [#from-output-to-pixels] ```text PTY output → libtermy parses it and records damage → TerminalSession.poll() takes a frame update (full, or dirty spans) → FrameStore applies it and reports a FrameChange → TerminalView invalidates the changed rows and schedules one Metal frame → MetalTerminalRenderer rebuilds those rows' quads, adds overlays, draws → CAMetalLayer presents the drawable ``` 1. **Damage.** libtermy reports which cells changed. `TerminalSession` applies the update to its retained `FrameStore`, which returns a `FrameChange`: full or partial damage, a resize, a cursor move, or a scroll. 2. **Invalidation.** `TerminalView` rebuilds libtermy's special-glyph plan for the damaged rows and tells the renderer which rows changed. A full update, resize, config change or display-scale change invalidates every row. 3. **Scheduling.** AppKit does not run its display cycle for a `CAMetalLayer`-backed view, so `TerminalView` schedules frames itself. Marking the view for display queues one render on the main run loop in common modes, so it also runs during live resize and mouse tracking. Many invalidations in one run-loop turn produce one frame. 4. **Drawing.** The renderer rebuilds quads only for invalidated rows, adds per-frame overlays, uploads one instance buffer and issues a handful of draw calls. ## One shader, instanced quads [#one-shader-instanced-quads] Everything on screen is a quad. Each one is a 64-byte instance: | Field | Meaning | | ------- | --------------------------------------------------- | | `rect` | Position and size in device pixels, top-left origin | | `uv` | Source rectangle in texels, for textured quads | | `color` | sRGB color with straight alpha | | `mode` | How the fragment shader colors the quad | The vertex shader expands each instance into a four-vertex triangle strip. The fragment shader has four modes: | Mode | Used for | Output | | ------------ | ----------------------------------------------------------------------- | --------------------------------- | | `solid` | Cell backgrounds, selection, cursor, underlines, block-element geometry | `color` | | `mask` | Text, stroked box-drawing geometry, IME text | `color` × texture coverage | | `colorGlyph` | Emoji and other color-font glyphs | Texture colors, unfiltered | | `image` | Kitty graphics | Texture colors, linearly filtered | All quads use premultiplied source-over blending into a `bgra8Unorm` drawable in sRGB, which matches how the CoreGraphics fallback composites. Consecutive quads that sample the same texture share a draw call. Without images or IME text, a whole frame is a single draw call; each visible image adds one, plus one for the atlas layers drawn after it. The shader is compiled from source with `makeLibrary(source:)` when the view is created, because command-line SwiftPM builds don't compile `.metal` files. ## Draw order [#draw-order] Each frame draws these layers, back to front: 1. Clear to the theme background at the configured opacity. 2. Cell backgrounds (cached per row). 3. Selection. 4. Kitty images with a negative z-index. 5. Foreground (cached per row): glyphs, underline and strikethrough, and special-glyph geometry. 6. Kitty images with a z-index of zero or more. 7. Cursor: a bar, a hollow block when the view is unfocused, or a filled block with the character under it redrawn in the background color. 8. IME marked text. Only layers 2 and 5 are cached. Everything else is rebuilt each frame, which is cheap because it is a few quads. A cursor blink or a selection drag rebuilds no rows. ## The glyph atlas [#the-glyph-atlas] Glyphs are rasterized on first use by CoreText into a 2048×2048 BGRA texture and packed in shelves. Each entry records its texel rectangle and its offset from the pen position on the baseline. * **Font resolution.** `TerminalFonts` resolves each cell's code point and style (regular, bold, italic, bold italic) to a glyph. When the configured font lacks a character, CoreText's fallback (`CTFontCreateForString`) supplies one, such as Apple Color Emoji or a CJK font. The CoreGraphics renderer uses the same resolver, so both backends pick the same glyphs. * **Monochrome glyphs** are rasterized in white and tinted in the shader, so a glyph is stored once no matter how many colors it appears in. * **Color glyphs** keep their own colors and are only faded by opacity. * **Device scale.** Glyphs are rasterized at the display's backing scale and placed on whole pixels, then sampled with nearest filtering. Each texel maps to one pixel, so text stays sharp. Moving the window to a display with a different scale rebuilds the atlas. * **Eviction.** When the atlas fills, the renderer starts a new texture and rebuilds every row once. Frames already in flight keep the old texture. ## Special glyphs [#special-glyphs] Box drawing, block elements, sextants, Braille, rounded corners and diagonals are drawn from libtermy's [glyph geometry](/docs/developer/libtermy/rendering#special-glyph-geometry) instead of font glyphs, so lines meet seamlessly across cells at any font size. * **Rectangles** become solid quads. Their edges are snapped to device pixels using each rectangle's snap rule: nearest, or outward so that adjacent parts overlap rather than leave a gap. * **Strokes** (rounded corners and diagonals) are rasterized once into the atlas for the current cell size and reused for every cell with the same geometry. ## Frame pacing and memory [#frame-pacing-and-memory] * Up to three frames are in flight. A ring of three instance buffers is reused and grows only when a frame needs more quads. * The layer presents in step with Core Animation (`presentsWithTransaction`). The main thread waits until the GPU has accepted the frame, not until it finishes, so live window resizes don't show stretched content. * Kitty image textures are cached by image ID and generation and released when their placements disappear. ## Performance [#performance] Measured on Apple silicon at 2× scale with a full 200×60 grid of colored text and a warm glyph atlas: | Renderer | Main-thread CPU per frame | GPU per frame | | ------------------------ | ------------------------- | ------------- | | CoreGraphics fallback | 5.55 ms | n/a | | Metal, every row rebuilt | 1.17 ms | 1.19 ms | | Metal, no rows changed | 0.10 ms | 0.80 ms | A 120 Hz display allows about 8.3 ms per frame. These numbers cover drawing only. They exclude parsing output in libtermy and applying frame updates, which dominate under heavy output. The first appearance of each glyph also pays a one-time CoreText rasterization. ## Choosing a backend [#choosing-a-backend] ```swift let view = try TerminalView() // Metal when available let cpu = try TerminalView(backend: .coreGraphics) // force the fallback print(view.renderingBackend) // .metal or .coreGraphics ``` `TerminalView` uses CoreGraphics when the system has no Metal device or the shader fails to compile. Snapshots made with `cacheDisplay(in:to:)` always go through `draw(_:)`, which uses the CoreGraphics renderer on either backend. ## Limitations [#limitations] * Glyphs are placed on whole pixels; there is no subpixel positioning. * A cell holds one Unicode scalar, so combining marks are not composed with their base character, and ligatures are not shaped. * `background_blur` is not applied; `background_opacity` is. ## Testing [#testing] `MetalRendererTests` renders views offscreen and checks pixels for text, cell backgrounds, box drawing, color emoji, Kitty images, the cursor, and correct redraws after partial updates. The tests skip themselves on machines without a Metal device. Run them with: ```sh just test-swift-sdk ``` # Swift quick start (/docs/developer/libtermy/swift) The Swift SDK lives in [`sdk/swift`](https://github.com/lassejlv/termy/tree/main/sdk/swift) and wraps the [C ABI](/docs/developer/libtermy/c). It compiles against the repository's `termy.h`, so build the SDK and libtermy from the same Termy revision. | Product | Use it for | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `Termy` | Terminals, sessions, frames, input encoding, events, config, search, Kitty graphics and clipboard, tmux control mode. No UI dependency. | | `TermyAppKit` | `TerminalView` (AppKit) and `TermyTerminal` (SwiftUI) with Metal rendering, IME, mouse reporting, selection, scrollback, links, and Kitty graphics. | Requires macOS 13+, Swift 6.2+, and a Rust toolchain. ## Build and test [#build-and-test] libtermy is built by Cargo. The SDK script builds it and stages `libtermy_core.dylib` in `sdk/swift/lib`: ```sh cd sdk/swift scripts/build-libtermy.sh swift test swift run TermyDemo ``` `just test-swift-sdk` runs the build and tests from the repository root. Set `TERMY_LIBRARY_PATH` to link a libtermy staged somewhere else. ## Add it to an app [#add-it-to-an-app] The package passes linker flags SwiftPM treats as unsafe, so depend on it by local path rather than by Git URL. SwiftPM names a path dependency after its directory, so the package is `swift`: ```swift title="Package.swift" .package(path: "../termy/sdk/swift"), // in your target's dependencies: .product(name: "TermyAppKit", package: "swift"), ``` Ship `libtermy_core.dylib` in `Contents/Frameworks`. The script sets its install name to `@rpath/libtermy_core.dylib`, so add `@executable_path/../Frameworks` to the app's runpath search paths. ## A SwiftUI terminal [#a-swiftui-terminal] `TerminalView()` loads the user's Termy config, resolves its theme for the current appearance, and starts their shell in a PTY. ```swift import SwiftUI import TermyAppKit @MainActor final class Model: ObservableObject { let view = try! TerminalView() @Published var title = "Terminal" } struct ContentView: View { @StateObject private var model = Model() var body: some View { TermyTerminal(view: model.view) .onTitleChange { model.title = $0 ?? "Terminal" } .navigationTitle(model.title) } } ``` Keep the view in a model. SwiftUI rebuilds `TermyTerminal` often; the `TerminalView` owns the shell and must outlive those rebuilds. ## Rendering [#rendering] `TerminalView` draws with Metal: instanced quads for cell backgrounds and decorations, and glyphs from a CoreText-rasterized texture atlas. Quads are cached per row and rebuilt only for rows libtermy reports as damaged. Special glyphs such as box drawing, blocks and Braille use libtermy's [glyph geometry](/docs/developer/libtermy/rendering#special-glyph-geometry), so they join seamlessly across cells. Without a Metal device, the view falls back to CoreGraphics. Pass `backend: .coreGraphics` to `TerminalView` to force it; `renderingBackend` reports the active one. [Swift Metal renderer](/docs/developer/libtermy/swift-rendering) explains the pipeline in detail: damage tracking, the shader, the glyph atlas, frame pacing, and performance. ## Headless sessions [#headless-sessions] `TerminalSession` drains events and keeps a retained `FrameStore` current on the main actor. A PTY session polls when the child writes output. ```swift import Termy @MainActor func run() async throws { let session = try TerminalSession( size: TerminalSize(cols: 120, rows: 40), options: TerminalOptions(startupCommand: "make test") ) defer { session.stop() } while !session.hasExited { try await Task.sleep(for: .milliseconds(20)) } session.poll() for row in 0.. A WebSocket connected to a shell gives full command execution to anyone who can reach it. The server below listens on `127.0.0.1` for local development. Before you expose a server like this, authenticate every connection, check the `Origin` header, and use TLS (`wss://`). ## The protocol [#the-protocol] Keep output and control messages apart so neither can be mistaken for the other: | Direction | Frame | Content | | ----------------- | ------ | ------------------------------------------------------ | | Server to browser | Binary | Raw PTY output | | Server to browser | Text | `{ "type": "exit", "exitCode": number }` | | Browser to server | Text | `{ "type": "input", "data": string }` | | Browser to server | Text | `{ "type": "resize", "cols": number, "rows": number }` | ## Server [#server] ```sh npm install ws @termysh/pty ``` ```js title="server.mjs" import { WebSocketServer } from 'ws' import pty from '@termysh/pty' // Local development only: anyone who can reach this port gets a shell. const wss = new WebSocketServer({ host: '127.0.0.1', port: 8787 }) wss.on('connection', (socket) => { const shell = pty.spawn(process.env.SHELL || 'bash', [], { name: 'xterm-256color', cols: 80, rows: 24, cwd: process.env.HOME, env: { ...process.env, COLORTERM: 'truecolor' }, }) // Output: binary frames, raw bytes. shell.onData((data) => socket.send(Buffer.from(data))) shell.onExit(({ exitCode }) => { socket.send(JSON.stringify({ type: 'exit', exitCode })) socket.close() }) // Input and control: text frames, JSON. socket.on('message', (raw, isBinary) => { if (isBinary) return const message = JSON.parse(raw.toString()) if (message.type === 'input') shell.write(message.data) if (message.type === 'resize') shell.resize(message.cols, message.rows) }) socket.on('close', () => shell.kill()) }) ``` `TERM=xterm-256color` matches what the Termy desktop app sets. `COLORTERM` tells programs that truecolor works. ## Browser [#browser] ```ts title="client.ts" import { Terminal } from '@termysh/web' const term = new Terminal({ theme: 'tokyo-night', fontSize: 13 }) term.open(document.getElementById('terminal')!) const socket = new WebSocket('ws://127.0.0.1:8787') socket.binaryType = 'arraybuffer' const send = (message: object) => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)) } socket.onopen = () => send({ type: 'resize', cols: term.cols, rows: term.rows }) socket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { term.write(new Uint8Array(event.data)) return } const message = JSON.parse(event.data) if (message.type === 'exit') { term.processExited() term.write(`\r\n[process exited with code ${message.exitCode}]\r\n`) } } term.onData((data) => send({ type: 'input', data })) term.onResize(({ cols, rows }) => send({ type: 'resize', cols, rows })) ``` What each part does: * `binaryType = 'arraybuffer'` makes output arrive as bytes. Writing bytes instead of decoded strings keeps multi-byte characters that are split across frames intact. * The resize on `onopen` gives the PTY the terminal's real size. `autoFit` may have resized the grid before the socket opened. * `onData` carries keys, pastes and protocol replies. Forward all of it. * `processExited()` tells the terminal the program is gone, so any [program status](/docs/using-termy/program-status) it reported as working or blocked expires instead of staying stuck. ## Clean up [#clean-up] When the view closes, close the socket and dispose of the terminal. The server kills the shell when the socket closes. ```ts socket.close() term.dispose() ``` ## Other transports [#other-transports] Only the transport changes; the terminal side stays the same: `write` what the program prints, send what `onData` (or `onBytes`) emits, and forward `onResize`. * **Server-sent events plus `fetch`:** stream output down, `POST` input up. * **WebRTC data channel:** useful for peer-to-peer terminals. * **A program in the page:** for an in-browser shell, call `term.write` with its output and handle `onData` yourself. The [live demo](/demo) works this way. * **Headless:** to drive a PTY without a browser, for tests or server-side snapshots, use [`@termysh/core`](/docs/developer/web/core). Forward `takeReplies()` to the PTY after every write. ## Binary mouse reports [#binary-mouse-reports] If a program enables legacy X10 mouse reporting without the UTF-8 or SGR extensions, some reports are not valid UTF-8. They arrive on `onBinary` instead of `onData`. Most programs use SGR mouse reporting and never trigger this. To forward every byte with no special cases, send `onBytes` as a binary frame and write it to the PTY as a `Buffer`. # Headless core (/docs/developer/web/core) `@termysh/core` is the engine without a renderer. It runs in browsers, web workers, Node and Bun, does no I/O, and has no DOM dependency. Use it to render with your own stack (WebGL, React, a canvas in a worker), to snapshot terminal output on a server, or to test programs that draw to a terminal. ```ts import { Slot, TermyCore, decodeColor, init } from '@termysh/core' await init() const term = new TermyCore({ cols: 80, rows: 24, scrollback: 1000 }) term.write('\x1b[1;32mready\x1b[0m\r\n$ ') term.lineText(0) // 'ready' term.cursor() // { row: 1, col: 2, visible: true, shape: 'block', blinking: false } ``` In Node or Bun, `init()` reads `termy.wasm` from disk. In a worker you can pass bytes or a compiled module to `initSync`. ## Rendering loop [#rendering-loop] ```ts const damage = term.takeDamage() // { full, scrolls, spans } const rows = term.readRows(0, term.rows) // flat cells for the viewport for (let row = 0; row < rows.rowCount; row++) { for (let col = 0; col < rows.cols; col++) { const text = rows.text(row, col) const at = rows.offset(row, col) const fg = decodeColor(rows.data[at + Slot.Foreground]) const style = rows.data[at + Slot.Style] // draw ... } } ``` Each cell is six `u32` slots: text, foreground, background, underline color, style bits and hyperlink. Colors keep their kind (`default`, `indexed` or `rgb`) so you resolve them against your own theme. Reads are copies, so they stay valid however the engine's memory changes. ## Input [#input] Encode input with the engine so it follows the modes the application enabled (application cursor keys, bracketed paste, mouse tracking, the kitty keyboard protocol), then send the bytes to your host: ```ts import { Modifier } from '@termysh/core' term.encodeKey('up', undefined, 0) // bytes of '\x1b[A', or '\x1bOA' in application cursor mode term.encodeKey('c', 'c', Modifier.Ctrl) // bytes of '\x03' term.encodeKey('enter', '\r', 0) // bytes of '\r' term.encodePaste(text) // bracketed when the program enabled it term.encodeMouse('press', 'left', col, row, 0) // undefined unless the program tracks the mouse term.encodeFocus(true) ``` Every encoder returns a `Uint8Array` (or `undefined` when the input produces no bytes). Send it to the host as is. `key` is a Termy key name such as `enter`, `up`, `f5` or `a`, and `text` is the character the key produced. Combine modifiers with `|`, for example `Modifier.Ctrl | Modifier.Shift`. Protocol replies (device attributes, cursor position reports, color queries) are collected after each write; forward them to the host too: ```ts term.write(output) const replies = term.takeReplies() if (replies.length) host.send(replies) for (const event of term.takeEvents()) { switch (event.type) { case 'title': setTitle(event.title); break case 'resetTitle': setTitle(''); break case 'bell': ring(); break case 'cwd': setCwd(event.cwd); break case 'progress': setProgress(event.state, event.value); break case 'programStatus': setStatus(event.records); break // OSC 7501 case 'clipboard': copy(event.data); break // OSC 52, data is base64 case 'shellIntegration': mark(event.payload); break // OSC 133 } } ``` When the host process exits, call `term.processExited()` so working or blocked [program status](/docs/using-termy/program-status) records expire. ## Reading text [#reading-text] ```ts term.lineText(0) // first line of the live screen term.lineText(term.rows - 1) // last line of the live screen term.lineText(-1) // newest scrollback line term.historySize // number of scrollback lines term.lineWrapped(0) // whether line 0 soft-wraps into line 1 ``` Line numbers here are relative to the live screen: `0` to `rows - 1` is the screen and negative numbers reach back into scrollback, down to `-historySize`. (`@termysh/web` uses absolute line numbers instead, starting from the oldest scrollback line.) ## Testing terminal output [#testing-terminal-output] Because the core runs in Node and Bun with no setup, you can assert on what a program draws: ```ts import { init, TermyCore } from '@termysh/core' import { expect, test } from 'vitest' await init() test('renders a progress line', () => { const term = new TermyCore({ cols: 40, rows: 5 }) term.write('Downloading...\r\x1b[2KDone\r\n') expect(term.lineText(0)).toBe('Done') term.dispose() }) ``` ## Images and glyphs [#images-and-glyphs] * `readGraphics(cellWidth, cellHeight)` returns visible kitty graphics placements with pixel clip and draw rectangles; `graphicsImage(index)` returns their RGBA or PNG data. * `glyphPlan(codePoint, neighbors, cellWidth, cellHeight, fontSize)` returns Termy's rectangles and strokes for box drawing, block elements, sextants and Braille, so they line up exactly across cells. * `builtinTheme(id)` and `builtinThemeIds()` expose the bundled themes. # Frameworks and bundlers (/docs/developer/web/frameworks) `@termysh/core` loads `termy.wasm` with `new URL('./termy.wasm', import.meta.url)`. Most bundlers see that pattern, copy the file into your build and rewrite the URL. Where one does not, either apply the setting below or [serve the file yourself](#serve-the-wasm-file). | Bundler | Setup | | ----------------------------------- | ------------------------------------------------------------------------- | | Vite 8 | None | | Vite 7 and earlier | Exclude `@termysh/core` from dependency pre-bundling | | Next.js (Turbopack, the default) | None | | Next.js with `--webpack`, webpack 5 | Ignore `node:fs/promises` in the browser build | | esbuild | Mark `node:fs/promises` external and copy `termy.wasm` next to the bundle | The `node:fs/promises` import only runs in Node and Bun, where `init()` reads `termy.wasm` from disk. Browser builds never execute it, but webpack and esbuild still try to resolve it. ## React [#react] ```tsx title="TerminalView.tsx" 'use client' // Next.js App Router only import { useEffect, useRef } from 'react' import { Terminal } from '@termysh/web' export function TerminalView({ url }: { url: string }) { const container = useRef(null) useEffect(() => { const term = new Terminal({ theme: 'tokyo-night', fontSize: 13 }) term.open(container.current!) const socket = new WebSocket(url) socket.binaryType = 'arraybuffer' socket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) term.write(new Uint8Array(event.data)) } const input = term.onData((data) => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'input', data })) }) return () => { input.dispose() socket.close() term.dispose() } }, [url]) return
} ``` * Create the terminal in `useEffect`, not during render. Creating and opening a terminal is a side effect, and `open` needs the DOM. * Return a cleanup that calls `dispose()`. React Strict Mode mounts effects twice in development, and the cleanup keeps that from leaving a second terminal behind. * Keep the `Terminal` in the effect or in a ref, not in state. Re-rendering must not create a new terminal. * Change options with `term.setOptions` instead of recreating the terminal. The full message protocol for this socket is in [Connect to a shell](/docs/developer/web/connect). ## Next.js [#nextjs] Use the React component above in a client component (`'use client'`). Importing `@termysh/web` during server rendering is safe; nothing touches the DOM until `open` runs in the effect. With Turbopack, the default bundler in Next.js 16, no configuration is needed. If you build with webpack (`next build --webpack`), the build fails with `UnhandledSchemeError: Reading from "node:fs/promises" is not handled by plugins`. Ignore that import in the browser build: ```js title="next.config.mjs" export default { webpack(config, { webpack, isServer }) { if (!isServer) { config.plugins.push(new webpack.IgnorePlugin({ resourceRegExp: /^node:fs\/promises$/ })) } return config }, } ``` ## Vue [#vue] ```vue title="TerminalView.vue" ``` Svelte, Solid and other frameworks follow the same pattern: create and `open` the terminal once the element is mounted, and `dispose` it on unmount. ## Vite [#vite] Vite 8 needs no configuration. In Vite 7 and earlier, production builds work, but the dev server pre-bundles dependencies into `node_modules/.vite/deps` and the wasm URL then points at a file that does not exist. The dev server answers with its HTML page, and loading fails with an error such as `WebAssembly.instantiate(): expected magic word 00 61 73 6d`. Exclude `@termysh/core` from pre-bundling: ```ts title="vite.config.ts" import { defineConfig } from 'vite' export default defineConfig({ optimizeDeps: { exclude: ['@termysh/core'] }, }) ``` ## webpack [#webpack] webpack 5 emits `termy.wasm` as an asset, but fails on the Node-only import. Ignore it: ```js title="webpack.config.js" const webpack = require('webpack') module.exports = { // ... plugins: [new webpack.IgnorePlugin({ resourceRegExp: /^node:fs\/promises$/ })], } ``` ## esbuild [#esbuild] esbuild does not copy assets referenced with `new URL(..., import.meta.url)`. Mark the Node-only import external and copy the wasm file next to your bundle: ```sh esbuild src/main.ts --bundle --format=esm --outdir=dist --external:node:fs/promises cp node_modules/@termysh/core/dist/termy.wasm dist/ ``` The bundle must be an ES module (`--format=esm`) so `import.meta.url` points at it. To put the file somewhere else, pass its URL with the `wasm` option. ## Serve the wasm file [#serve-the-wasm-file] To control where `termy.wasm` comes from (a CDN, a fixed path, a cache-busted name), copy `node_modules/@termysh/core/dist/termy.wasm` to your static assets and pass its URL: ```ts new Terminal({ wasm: '/assets/termy.wasm' }) ``` With Vite you can let the bundler emit it and give you the URL: ```ts import wasmUrl from '@termysh/core/termy.wasm?url' new Terminal({ wasm: wasmUrl }) ``` The `wasm` option, and `init`, also accept a `URL`, a `Response`, a promise of one, bytes, or a compiled `WebAssembly.Module`. The module loads once per page; later terminals reuse it. Serve the file with `Content-Type: application/wasm`. With another type it still loads, but more slowly, and logs a warning that `WebAssembly.instantiateStreaming` failed. ## Content Security Policy [#content-security-policy] If your site sends a Content Security Policy, allow WebAssembly to compile and allow the request for the file: ``` Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' ``` Add the origin that serves `termy.wasm` to `connect-src` if it is not your own. ## Workers, Node and Bun [#workers-node-and-bun] `@termysh/web` needs a DOM. For a terminal without one (in a web worker, in Node or Bun, or in tests), use [`@termysh/core`](/docs/developer/web/core). In Node and Bun, `await init()` finds `termy.wasm` on disk with no setup. To run the program on the server, use [`@termysh/pty`](/docs/developer/web/pty). # Web terminal (/docs/developer/web) The `@termysh` packages are new and pre-1.0. The API can change between releases, so pin a version and test upgrades. The `@termysh` packages bring Termy's terminal engine to the web. The parser, grid, scrollback, keyboard and mouse encoders, kitty protocols, box-drawing geometry and themes are the same Rust code the desktop app runs, compiled to WebAssembly. Use them to build a browser terminal or to replace xterm.js. ## Choose a package [#choose-a-package] | Package | What it is | Use it when | | ---------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------- | | `@termysh/web` | Browser terminal with renderer, input, selection, links and images | You are building something new | | `@termysh/xterm` | xterm.js-compatible API on top of `@termysh/web` | You are replacing `@xterm/xterm` | | `@termysh/core` | WebAssembly engine with a typed headless API | You render yourself, run in a worker or Node, or test terminal output | | `@termysh/pty` | Pseudo-terminals for Node.js and Bun, a drop-in replacement for `node-pty` | Your server runs the shell or program the terminal shows | ## Install [#install] ```sh npm install @termysh/web # or: bun add @termysh/web / pnpm add @termysh/web ``` `@termysh/web` depends on `@termysh/core`, which ships the `termy.wasm` module. There is no stylesheet to import. ## Quick start [#quick-start] ```html
``` ```ts import { Terminal } from '@termysh/web' const term = new Terminal({ theme: 'tokyo-night', fontSize: 13, cursorBlink: true }) term.open(document.getElementById('terminal')!) term.write('Hello from \x1b[1;32mTermy\x1b[0m\r\n') term.onData((data) => console.log('send to the host:', JSON.stringify(data))) ``` The terminal fills its parent element and, with `autoFit` (on by default), resizes its grid whenever the parent changes size. Give the parent a height. To run a real shell, connect `write` and `onData` to a PTY on a server. The [Connect to a shell](/docs/developer/web/connect) guide has a complete server and client. ## Essentials [#essentials] These points cover most integration bugs: 1. **The parent needs a size.** The terminal is `width: 100%; height: 100%` of the element you pass to `open`. A parent with no height renders nothing. 2. **The constructor is synchronous; the engine is not.** The WebAssembly module loads in the background. `write`, `resize`, `paste` and other calls made before it is ready are queued. Await `term.ready` (or use `await Terminal.create(options)`) before you read state such as `getLine`, `cursor` or `builtinThemeIds()`. 3. **Send everything from `onData` to the host.** It carries keystrokes, pastes and the terminal's protocol replies (cursor position reports, device attributes, color queries). Programs like `vim` wait for those replies. 4. **Send every `onResize` to the host.** Programs lay out for the PTY's size, not the terminal's. Forward the new `cols` and `rows` to the PTY. 5. **Call `dispose()` when you are done.** It removes the element, stops timers and frees the engine's memory. In React, do it in the effect cleanup. 6. **Do not parse escape sequences yourself.** Write raw bytes in and let the engine handle them. Use events such as `onTitleChange` and `onCwdChange` for the metadata programs send. ## Load the WebAssembly module [#load-the-webassembly-module] By default `termy.wasm` is resolved next to `@termysh/core` with `new URL('./termy.wasm', import.meta.url)`. Vite, Next.js and webpack emit the file automatically, with small caveats for some versions. See [Frameworks and bundlers](/docs/developer/web/frameworks). To serve the file yourself, copy `node_modules/@termysh/core/dist/termy.wasm` to your static assets and pass its URL: ```ts new Terminal({ wasm: '/assets/termy.wasm' }) // or, once at startup, before creating terminals: import { init } from '@termysh/web' await init('/assets/termy.wasm') ``` Serve it with the `application/wasm` content type for the fastest startup. ## API [#api] ```ts const term = new Terminal(options?) // or: await Terminal.create(options) term.ready // Promise, resolves when the engine is loaded term.open(parent) // mount; throws if already open term.write(data, callback?) // string or Uint8Array; callback runs once parsed term.writeln(data) term.paste(text) // honors bracketed paste term.input(text) // as if the user typed it (fires onData) term.resize(cols, rows) term.fit() // resize to the container now term.proposeDimensions() // { cols, rows } that would fit, without resizing term.setOptions({ fontSize: 15 }) // any option, at runtime term.setOption(key, value) / term.getOption(key) term.focus() / term.blur() / term.hasFocus term.clear() // clear scrollback, keep the prompt line term.reset() // full reset (RIS) term.processExited() // tell the terminal the host process ended term.scrollLines(n) / term.scrollPages(n) / term.scrollToTop() / term.scrollToBottom() / term.scrollToLine(line) term.select(col, line, length) / term.selectLines(start, end) / term.selectAll() / term.clearSelection() term.getSelection() / term.hasSelection() / term.getSelectionPosition() term.getLine(line) / term.isLineWrapped(line) term.cols / term.rows / term.cursor / term.historySize / term.viewportY / term.modes / term.title term.attachCustomKeyEventHandler((event) => true) // return false to stop the terminal handling a key term.element / term.textarea / term.cellSize term.core // the underlying TermyCore, for advanced use term.dispose() ``` Buffer lines are absolute: `0` is the oldest scrollback line and `historySize` is the first line of the live screen. The text on the cursor's line is `term.getLine(term.historySize + term.cursor!.row)` once `term.ready` has resolved. ### Events [#events] Every event is a function that takes a listener and returns a disposable: ```ts const subscription = term.onTitleChange((title) => (document.title = title)) subscription.dispose() ``` | Event | Payload | Fires when | | ------------------------------------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------- | | `onData` | `string` | Input for the host: keys, paste, protocol replies | | `onBinary` | `string` | Legacy X10 mouse reports that are not valid UTF-8, as a binary string | | `onBytes` | `Uint8Array` | Every byte sent to the host, whatever its encoding | | `onResize` | `{ cols, rows }` | The grid size changes | | `onTitleChange` | `string` | The application sets the title (OSC 0/2) | | `onBell` | | BEL | | `onCwdChange` | `string` | OSC 7 working directory | | `onProgress` | `{ state, value }` | OSC 9;4 progress | | `onProgramStatus` | `ProgramStatusRecord[]` | OSC 7501 program status changes. See [Program status](/docs/using-termy/program-status) | | `onShellIntegration` | `string` | OSC 133 prompt and command marks | | `onClipboard` | `{ selection, text }` | OSC 52 clipboard write | | `onKey` | `{ key, domEvent }` | A key produced input | | `onScroll` | `number` | The viewport moves | | `onRender` | `{ start, end }` | Rows were repainted | | `onSelectionChange`, `onCursorMove`, `onWriteParsed`, `onFocus`, `onBlur` | | As named | `onBytes` is the simplest choice when your transport carries bytes: it includes everything `onData` and `onBinary` send. ## Protocol support [#protocol-support] The web terminal inherits the desktop engine's protocol support: * xterm and VT control sequences, 256-color and truecolor * Styled and colored underlines (curly, dotted, dashed, double) * OSC 8 hyperlinks, plus plain URL detection * OSC 4/10/11/12 color changes and queries, OSC 7, OSC 52, OSC 133, OSC 9;4, OSC 7501 * Synchronized output (mode 2026), bracketed paste, focus events * Mouse tracking with default, UTF-8 and SGR encodings * The kitty keyboard protocol, including release events * The kitty graphics protocol: RGBA, RGB and PNG images, placements, z-index layering, animation and Unicode placeholders Not yet supported: sixel and iTerm2 inline images, kitty OSC 5522 clipboard, and xterm.js decorations, custom link providers and parser hooks. ## For AI coding agents [#for-ai-coding-agents] Every page in these docs is available as Markdown: add `.md` to the URL, for example `/docs/developer/web/connect.md`. [`/llms.txt`](/llms.txt) lists all pages and [`/llms-full.txt`](/llms-full.txt) contains the full text. The package READMEs on npm carry the same essentials, and the published type declarations document every option and method. # Web terminal options (/docs/developer/web/options) Pass options to the constructor, or change them later. Every option applies at runtime: font and padding changes re-measure the cell and re-fit the grid, and everything else repaints. ```ts const term = new Terminal({ fontSize: 14, cursorStyle: 'bar' }) term.setOptions({ fontSize: 16, theme: 'nord' }) term.setOption('cursorBlink', true) ``` ## Font [#font] | Option | Default | Description | | ---------------- | ----------------------------------------------- | -------------------------------------------- | | `fontFamily` | JetBrains Mono, SF Mono, Menlo, ... `monospace` | CSS font family | | `fontSize` | `14` | CSS pixels | | `fontWeight` | `'normal'` | Weight for regular text | | `fontWeightBold` | `'bold'` | Weight for bold text | | `lineHeight` | `1` | Multiplier of the font's natural line height | | `letterSpacing` | `0` | Extra CSS pixels between cells | The cell size is re-measured when web fonts finish loading and when the window moves to a display with a different pixel ratio. ## Colors [#colors] | Option | Default | Description | | ---------------------------- | --------- | -------------------------------------------------------------------------------------------------------- | | `theme` | `'termy'` | Bundled theme id, theme object, or `{ extends, ...overrides }`. See [Themes](/docs/developer/web/themes) | | `drawBoldTextInBrightColors` | `true` | Bold text in ANSI colors 0-7 uses colors 8-15 | | `minimumContrastRatio` | `1` | WCAG contrast ratio enforced for text, from `1` (off) to `21` | | `allowTransparency` | `false` | Let the page show through cells with the default background | ## Cursor [#cursor] | Option | Default | Description | | --------------------- | ----------- | ---------------------------------------------------------------------------------- | | `cursorStyle` | `'block'` | `'block'`, `'bar'` or `'underline'` | | `cursorBlink` | `false` | Blink while focused | | `cursorWidth` | `1` | Bar cursor width in CSS pixels | | `cursorInactiveStyle` | `'outline'` | Cursor while unfocused: `'outline'`, `'block'`, `'bar'`, `'underline'` or `'none'` | Applications can still change the cursor shape with `DECSCUSR`; `cursorStyle` is the shape it returns to after a reset. ## Layout [#layout] | Option | Default | Description | | ------------------ | ------------------------- | ------------------------------------------------------------------ | | `cols`, `rows` | `80`, `24` | Initial grid size; `autoFit` replaces it once the terminal is open | | `autoFit` | `true` | Follow the container's size | | `padding` | `0` | Inner padding in CSS pixels | | `scrollback` | `1000` | Lines kept in history | | `scrollbar` | `true` | Show the overlay scrollbar on hover | | `devicePixelRatio` | `window.devicePixelRatio` | Override the render scale | ## Rendering [#rendering] | Option | Default | Description | | -------------- | ------- | ------------------------------------------------------------------------------------------------------------ | | `customGlyphs` | `true` | Draw box drawing, block elements, sextants and Braille with Termy's pixel-exact geometry instead of the font | | `images` | `true` | Render kitty graphics protocol images | ## Scrolling [#scrolling] | Option | Default | Description | | ----------------------- | ------- | ---------------------------------------------------------------------- | | `scrollSensitivity` | `1` | Wheel multiplier | | `fastScrollSensitivity` | `5` | Wheel multiplier while `fastScrollModifier` is held | | `fastScrollModifier` | `'alt'` | `'alt'`, `'ctrl'`, `'shift'` or `'none'` | | `alternateScroll` | `true` | In full-screen apps without mouse tracking, the wheel sends arrow keys | | `scrollOnUserInput` | `true` | Jump to the bottom when the user types | ## Input [#input] | Option | Default | Description | | ------------------------------- | ------- | ---------------------------------------------------------------------- | | `macOptionIsMeta` | `false` | On macOS, Option acts as Alt instead of composing characters | | `macOptionClickForcesSelection` | `false` | On macOS, Option+click selects text even when the app tracks the mouse | | `rightClickSelectsWord` | `false` | Right-click selects the word under the pointer | | `altClickMovesCursor` | `true` | Alt+click on the prompt line moves the cursor there | | `disableStdin` | `false` | Ignore all user input | | `ignoreBracketedPasteMode` | `false` | Never wrap pastes in bracketed paste markers | | `convertEol` | `false` | Treat `\n` written to the terminal as `\r\n` | Hold Shift to select text while an application tracks the mouse. On macOS, Cmd+C and Cmd+V copy and paste; elsewhere use Ctrl+Shift+C and Ctrl+Shift+V. ## Selection and links [#selection-and-links] | Option | Default | Description | | --------------- | -------------------------- | ---------------------------------------------------------------------------------- | | `wordSeparator` | `` ()[]{}',"` `` and space | Characters that end a double-click word selection | | `copyOnSelect` | `false` | Copy to the clipboard when a selection finishes | | `linkHandler` | Opens links in a new tab | `{ activate, hover?, leave?, allowNonHttpProtocols? }`, or `null` to disable links | | `linkDetection` | `true` | Detect plain-text URLs in addition to OSC 8 hyperlinks | Only `http`, `https` and `mailto` links are activated unless the handler sets `allowNonHttpProtocols`. ## Integration [#integration] | Option | Default | Description | | --------------------- | ----------------------- | --------------------------------------------------------------------- | | `allowClipboardWrite` | `false` | Let OSC 52 write the system clipboard. `onClipboard` fires either way | | `bellStyle` | `'none'` | `'visual'` flashes the terminal on BEL | | `screenReaderMode` | `false` | Mirror output into an `aria-live` region | | `wasm` | next to `@termysh/core` | Where to load `termy.wasm` from | # PTY for Node.js (/docs/developer/web/pty) `@termysh/pty` is new and pre-1.0. Pin a version and test upgrades. `@termysh/pty` starts programs on a pseudo-terminal (PTY) from Node.js or Bun. It runs on the same native process layer the Termy app uses for its own sessions: `forkpty` on macOS and Linux, ConPTY on Windows. Its API matches [`node-pty`](https://github.com/microsoft/node-pty), so existing code keeps working. What it does differently: * **Prebuilt binaries.** Nothing compiles at install time, and there are no install scripts to approve. * **Backpressure.** Output waits for JavaScript instead of piling up in memory, and `pause()` stops reading, so a fast program blocks rather than flooding your server. * **Exit after output.** `onExit` fires once, after the last byte of output, with the exit code and terminating signal. ## Install [#install] ```sh npm install @termysh/pty ``` | Platform | Architectures | | -------------------- | ------------- | | macOS 11+ | arm64, x64 | | Linux, glibc 2.17+ | x64, arm64 | | Linux, musl (Alpine) | x64, arm64 | | Windows 10 1809+ | x64, arm64 | Node.js 18 or later, or Bun. npm installs the binary for your platform as an optional dependency named `@termysh/pty-`; installing with `--omit=optional` skips it and `spawn` then throws. ## Run a program [#run-a-program] ```ts import { spawn } from '@termysh/pty' const shell = spawn(process.env.SHELL ?? 'bash', [], { name: 'xterm-256color', cols: 80, rows: 24, cwd: process.env.HOME, env: { ...process.env, COLORTERM: 'truecolor' }, }) shell.onData((data) => process.stdout.write(data)) shell.onExit(({ exitCode, signal }) => console.log('exited', exitCode, signal)) shell.write('ls\r') shell.resize(120, 40) shell.kill() // SIGHUP, like closing a terminal window ``` `spawn` throws when the program can't start: it isn't found, isn't executable, or `cwd` doesn't exist. ## Migrate from node-pty [#migrate-from-node-pty] ```sh npm uninstall node-pty npm install @termysh/pty ``` ```diff -import * as pty from 'node-pty' +import * as pty from '@termysh/pty' ``` `spawn`, `fork`, `createTerminal`, the default export and the `IPty`, `IPtyForkOptions`, `IWindowsPtyForkOptions`, `IDisposable` and `IEvent` types match node-pty, including the EventEmitter-style `on('data')` and `on('exit')`. Differences: * `uid` and `gid` throw. Run the parent process as the target user instead. * `open()`, which creates a PTY pair without a process, is not provided. * Windows always uses ConPTY. winpty is not supported; `useConpty`, `useConptyDll` and `conptyInheritCursor` are accepted and ignored. * `write` after the program exits is dropped silently. Input the program has not read yet is queued, as with node-pty; past the 8 MiB native backlog it waits in JavaScript. ## Options [#options] | Option | Default | Description | | ------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------- | | `name` | `env.TERM`, then `xterm` | Exported as `TERM` on Unix | | `cols`, `rows` | `80`, `24` | Initial size, 1 to 65535 | | `cwd` | `process.cwd()` | Working directory, also exported as `PWD` on Unix | | `env` | `process.env` | The child's whole environment | | `encoding` | `'utf8'` | Decode output to strings; `null` emits `Buffer`s | | `handleFlowControl` | `false` | Treat writes of `flowControlPause` (`\x13`) and `flowControlResume` (`\x11`) as `pause()` and `resume()` | When `env` is left out, the child inherits `process.env` without `TMUX`, `TMUX_PANE`, `STY`, `WINDOW`, `WINDOWID`, `TERMCAP`, `COLUMNS` and `LINES`, which describe the parent's own terminal session. Pass `env` explicitly to control exactly what the child sees. ## The IPty object [#the-ipty-object] | Member | Description | | -------------------------------- | ------------------------------------------------------------------------------------------------ | | `pid` | Process id | | `cols`, `rows` | Current size | | `process` | The foreground program, such as `vim` while it runs in a shell. The spawned file name on Windows | | `onData(listener)` | Output; returns a disposable | | `onExit(listener)` | `{ exitCode, signal }`, once, after all output | | `write(data)` | Input, as a string (UTF-8) or `Buffer` | | `resize(cols, rows, pixelSize?)` | Resize. `pixelSize` is `{ width, height }` of the whole terminal, for programs that size images | | `kill(signal?)` | Send a signal, `SIGHUP` by default | | `pause()`, `resume()` | Stop and restart reading output | On Unix, a normal exit reports `{ exitCode: n, signal: 0 }` and a program ended by a signal reports `{ exitCode: 0, signal: n }`. On Windows, `kill()` closes the pseudoconsole, gives the program 250 ms to exit, then terminates it (exit code `1`). Passing a signal throws, and `onExit` reports only `exitCode`. ## Backpressure and pause [#backpressure-and-pause] Output reaches JavaScript through a small bounded queue. When your `onData` handlers fall behind, the native reader stops reading from the PTY; the kernel buffer then fills and the program blocks on its next write. Memory use stays flat no matter how fast the program prints. `pause()` stops reading entirely, which is useful when a slow client is connected, for example a Node stream: ```ts socket.on('drain', () => shell.resume()) shell.onData((data) => { if (!socket.write(data)) shell.pause() }) ``` ## Read the screen on the server [#read-the-screen-on-the-server] `@termysh/pty` delivers raw bytes. To know what is on the screen, for tests, for an AI agent driving an interactive program, or to snapshot a session, feed the output to [`@termysh/core`](/docs/developer/web/core): ```ts import { init, TermyCore } from '@termysh/core' import { spawn } from '@termysh/pty' await init() const screen = new TermyCore({ cols: 80, rows: 24 }) const app = spawn('htop', [], { name: 'xterm-256color', cols: 80, rows: 24, encoding: null }) app.onData((bytes) => { screen.write(bytes) // Programs wait for answers to their terminal queries; send them back. const replies = screen.takeReplies() if (replies.length) app.write(Buffer.from(replies)) }) // Later: screen.lineText(0) // first line of the screen ``` Answer queries in one place only. When a browser terminal is attached, it answers them, so don't also send `takeReplies()` from the server. ## Bundlers and Electron [#bundlers-and-electron] The addon is a native `.node` file that bundlers can't inline. Mark `@termysh/pty` as external (esbuild `--external:@termysh/pty`, webpack `externals`, Vite `ssr.external`). In Electron, unpack native files from the asar archive with `asarUnpack: ['**/*.node']`. To load a binary from a custom location, set the `TERMY_PTY_NATIVE` environment variable to its absolute path before importing the package. ## Troubleshooting [#troubleshooting] * **`@termysh/pty: no native binary for `**: the optional dependency wasn't installed. Reinstall without `--omit=optional` or `--no-optional`, and make sure the lockfile was created on a machine that records optional dependencies for all platforms (npm 7+ does). * **`spawn` throws `No such file or directory`**: the program isn't on the child's `PATH`, which comes from `env` when you pass it. Slim and Alpine images often have only `/bin/sh`, not `bash`. * **Windows: `spawn` throws that ConPTY is unavailable**: ConPTY needs Windows 10 version 1809 or later. Check with `isSupported()`. # Web terminal themes (/docs/developer/web/themes) The `theme` option accepts three forms. ```ts // A bundled Termy theme new Terminal({ theme: 'catppuccin-mocha' }) // A full theme object (the same shape as xterm.js ITheme) new Terminal({ theme: { background: '#0b0b0f', foreground: '#e6e6e6', cursor: '#7aa2f7' } }) // A bundled theme with overrides new Terminal({ theme: { extends: 'nord', cursor: '#ebcb8b', selectionBackground: '#88c0d055' } }) ``` Change it at any time with `term.setOptions({ theme: 'dracula' })`. ## Bundled themes [#bundled-themes] `builtinThemeIds()` lists the themes compiled into the engine. They are a small subset of the desktop app's built-in themes, under the same ids: `termy`, `termy-light`, `tokyo-night`, `catppuccin-mocha`, `dracula`, `gruvbox-dark`, `nord`, `solarized-dark`, `one-dark`, `monokai`, `material-dark`, `palenight`, `tomorrow-night`, `oceanic-next`. ```ts import { builtinTheme, builtinThemeIds } from '@termysh/web' await term.ready builtinThemeIds() // ['termy', 'termy-light', ...] builtinTheme('tokyo-night') // { foreground, background, cursor, ansi: [16 colors] } ``` Ids ignore case and separators, so `'Tokyo Night'` and `tokyo_night` work too. ## Theme keys [#theme-keys] Colors accept any CSS color: `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()` or a named color. | Key | Used for | | ------------------------------- | ----------------------------------------------------------------------- | | `foreground`, `background` | Default text and background | | `cursor`, `cursorAccent` | Cursor color and the text under a block cursor | | `selectionBackground` | Selection while focused; alpha blends with the cell | | `selectionInactiveBackground` | Selection while unfocused | | `selectionForeground` | Selected text (defaults to the cell's own color) | | `black` ... `white` | ANSI colors 0-7 | | `brightBlack` ... `brightWhite` | ANSI colors 8-15 | | `extendedAnsi` | Palette entries 16-255; missing entries use the standard 256-color cube | | `scrollbarThumb` | Overlay scrollbar | ## Colors set by applications [#colors-set-by-applications] Programs can still change colors at runtime with OSC 4 (palette), OSC 10 (foreground), OSC 11 (background) and OSC 12 (cursor). When a program queries colors, the terminal answers with the active theme, so tools that detect light or dark backgrounds pick the right colors. # Troubleshooting the web terminal (/docs/developer/web/troubleshooting) Find the symptom, then apply the fix. ## Nothing appears [#nothing-appears] * **The container has no height.** The terminal fills the element passed to `open` (`width: 100%; height: 100%`). Give that element a height, for example `height: 400px`, or `flex: 1` inside a sized flex container. * **`open` was never called**, or was called on an element that is not in the document. * **The wasm module failed to load.** Check the console and `term.ready`, which rejects with the error: ```ts term.ready.catch((error) => console.error('Termy failed to load', error)) ``` ## `expected magic word 00 61 73 6d` [#expected-magic-word-00-61-73-6d] The request for `termy.wasm` returned something other than the wasm file, usually an HTML page from a dev server or single-page-app fallback. Open the network tab and find the `termy.wasm` request: * In the Vite 7 (or earlier) dev server, the URL points into `node_modules/.vite/deps`. Exclude `@termysh/core` from pre-bundling; see [Vite](/docs/developer/web/frameworks#vite). * With esbuild or a custom setup, `termy.wasm` was not copied next to the bundle. Copy it, or pass its URL with the `wasm` option; see [Serve the wasm file](/docs/developer/web/frameworks#serve-the-wasm-file). ## `instantiateStreaming failed because your server does not serve Wasm with application/wasm MIME type` [#instantiatestreaming-failed-because-your-server-does-not-serve-wasm-with-applicationwasm-mime-type] This is a warning, not an error. The module still loads, more slowly. Configure your server to send `Content-Type: application/wasm` for `.wasm` files. ## `Refused to compile or instantiate WebAssembly module` [#refused-to-compile-or-instantiate-webassembly-module] Your Content Security Policy blocks WebAssembly. Add `'wasm-unsafe-eval'` to `script-src`; see [Content Security Policy](/docs/developer/web/frameworks#content-security-policy). ## Build fails on `node:fs/promises` [#build-fails-on-nodefspromises] webpack reports `UnhandledSchemeError: Reading from "node:fs/promises" is not handled by plugins` and esbuild reports `Could not resolve "node:fs/promises"`. The import only runs in Node and Bun. Ignore it in the browser build; see [Next.js](/docs/developer/web/frameworks#nextjs), [webpack](/docs/developer/web/frameworks#webpack) or [esbuild](/docs/developer/web/frameworks#esbuild). ## `@termysh/core: call await init() before creating a terminal` [#termyshcore-call-await-init-before-creating-a-terminal] Something used the engine before the wasm module loaded: * With `@termysh/core`, `await init()` before `new TermyCore()`, `builtinThemeIds()` or `builtinTheme()`. * With `@termysh/web`, `await term.ready` (or create the terminal with `await Terminal.create()`) before calling `builtinThemeIds()` or `builtinTheme()`. ## `@termysh/web: terminal is already open` [#termyshweb-terminal-is-already-open] `open` was called twice on the same terminal. In React this usually means the terminal was created outside the effect, or the effect has no cleanup. Create the terminal inside `useEffect` and call `term.dispose()` in the cleanup; see [React](/docs/developer/web/frameworks#react). ## Programs hang, or draw only after a key press [#programs-hang-or-draw-only-after-a-key-press] Programs such as `vim`, `htop` and shells with rich prompts send queries (cursor position, device attributes, background color) and wait for the answer. The terminal answers through `onData`. Send everything `onData` emits to the PTY, not only keystrokes. With `@termysh/core`, call `takeReplies()` after every `write` and send the bytes to the PTY. ## Output wraps at the wrong width [#output-wraps-at-the-wrong-width] The PTY still has its old size. Send every `onResize` to the server and call the PTY's resize, and send the current size once the connection opens. See [Connect to a shell](/docs/developer/web/connect#browser). ## Colors look washed out [#colors-look-washed-out] Programs decide whether to use 24-bit color from the environment. Spawn the PTY with `TERM=xterm-256color` and `COLORTERM=truecolor`. ## `posix_spawnp failed` from node-pty [#posix_spawnp-failed-from-node-pty] node-pty's `spawn-helper` binary lost its execute permission, which happens when the package manager skipped node-pty's install scripts. Allow them (npm prints the command to approve install scripts) and reinstall, or restore the permission: ```sh chmod +x node_modules/node-pty/prebuilds/*/spawn-helper ``` [`@termysh/pty`](/docs/developer/web/pty) has no helper binary or install scripts, so it does not have this problem. ## Copy and paste [#copy-and-paste] On macOS, Cmd+C and Cmd+V copy and paste. Elsewhere, use Ctrl+Shift+C and Ctrl+Shift+V; plain Ctrl+C goes to the program as an interrupt. To select text while a program tracks the mouse, hold Shift. ## Report a bug [#report-a-bug] If none of this fixes it, [open an issue](https://github.com/termylabs/termy/issues) with the package versions, browser, bundler, and a minimal reproduction. # Migrate from xterm.js (/docs/developer/web/xterm) `@termysh/xterm` implements the xterm.js `Terminal` API on top of `@termysh/web`. In most apps, migrating means changing imports. ```sh npm uninstall @xterm/xterm @xterm/addon-fit @xterm/addon-web-links @xterm/addon-search npm install @termysh/xterm ``` ```diff -import { Terminal } from '@xterm/xterm' -import { FitAddon } from '@xterm/addon-fit' -import { WebLinksAddon } from '@xterm/addon-web-links' -import '@xterm/xterm/css/xterm.css' +import { Terminal, FitAddon, WebLinksAddon } from '@termysh/xterm' const term = new Terminal({ cursorBlink: true, fontSize: 13 }) const fit = new FitAddon() term.loadAddon(fit) term.loadAddon(new WebLinksAddon()) term.open(container) fit.fit() term.onData((data) => socket.send(data)) ``` No stylesheet is needed. An unthemed terminal uses xterm.js's default colors, so it looks the same after the switch. ## What carries over [#what-carries-over] * `Terminal` options, including `theme` (`ITheme`), cursor, font, scrolling, input and link options. Options without a Termy equivalent (`logLevel`, `windowsMode`, `tabStopWidth`, `smoothScrollDuration`, ...) are accepted and ignored. * `term.options.fontSize = 16` and `term.options = { ... }` update live. * Methods: `open`, `write`, `writeln`, `paste`, `input`, `resize`, `focus`, `blur`, `clear`, `reset`, `scroll*`, `select*`, `getSelection`, `getSelectionPosition`, `attachCustomKeyEventHandler`, `attachCustomWheelEventHandler`, `registerMarker`, `loadAddon`, `dispose`. * Events: `onData`, `onBinary`, `onKey`, `onResize`, `onTitleChange`, `onBell`, `onScroll`, `onRender`, `onSelectionChange`, `onCursorMove`, `onLineFeed`, `onWriteParsed`. * `buffer.active` with `getLine(y).translateToString()`, `getCell(x)` and cell attribute and color getters; `modes`. ## Addons [#addons] | xterm.js addon | With @termysh/xterm | | ------------------------------------------- | ----------------------------------------------------------------------------- | | `@xterm/addon-fit` | `FitAddon` (same API) | | `@xterm/addon-web-links` | `WebLinksAddon` (same API) | | `@xterm/addon-search` | `SearchAddon`: `findNext`, `findPrevious`, `onDidChangeResults` | | `@xterm/addon-webgl`, `@xterm/addon-canvas` | `WebglAddon`, `CanvasAddon` no-ops; Termy has its own renderer | | `@xterm/addon-unicode11` | `Unicode11Addon` no-op; the engine measures widths | | `@xterm/addon-image` | Not needed for kitty graphics, which are built in. Sixel is not supported yet | Third-party addons that reach into xterm.js internals (`terminal._core`) will not work. ## Termy extras [#termy-extras] The adapter accepts a few extra options: | Option | Description | | ------------------------- | -------------------------------------------------------------------------- | | `termyTheme` | Start from a bundled Termy theme; `theme` then overrides individual colors | | `images` | Render kitty graphics (default `true`) | | `allowClipboardWrite` | Let OSC 52 write the clipboard | | `autoFit` | Follow the container without `FitAddon`. Off by default, as in xterm.js | | `padding`, `copyOnSelect` | As in `@termysh/web` | `term.termy` is the underlying `@termysh/web` terminal, and `term.ready` resolves once the engine is loaded. ## Differences [#differences] * Writes made before the WebAssembly module loads are queued; they render once it is ready. * Not supported yet: `registerDecoration`, custom `registerLinkProvider` providers and character joiners. They are accepted and do nothing. * There is no `term.parser`, so `parser.register*Handler` hooks are not available. Code that calls them throws; remove it, or use `term.termy.onTitleChange`, `onCwdChange`, `onProgress` and the other `@termysh/web` events for the common OSC sequences. * `buffer.normal` cannot read the main screen while a full-screen app is using the alternate screen. # Config file (/docs/getting-started/config-file) | Platform | Path | | ------------- | ---------------------------- | | macOS / Linux | `~/.config/termy/config.txt` | | Windows | `%APPDATA%\termy\config.txt` | Open **Settings** (`Cmd ,` / `Ctrl ,`) or edit the file directly. Each line is `key = value`. Comments start with `#`. ```txt theme = termy font_family = JetBrains Mono font_size = 14 shell = /opt/homebrew/bin/fish keybind = cmd-p=toggle_command_palette ``` Termy does not bundle custom fonts. A `font_family` value must name a family installed on your system; if it is missing or not fixed-pitch, Termy shows a warning and falls back to an installed monospace font (preferring Consolas on Windows, Menlo on macOS, or your Fontconfig monospace preference on Linux). On Linux, `font_family = monospace` follows that system preference and resolves it to a concrete font before rendering. On Windows, if an installed font is still not found, try reinstalling it for all users rather than only your account. ## Colors [#colors] Only `[colors]` uses a section header: ```txt [colors] foreground = #e7ebf5 background = #0b1020 ``` ## Tasks [#tasks] ```txt task.build.command = cargo build task.build.working_dir = crates/cli ``` ## Common tweaks [#common-tweaks] | Goal | Setting | | ------------------- | --------------------------- | | Transparent window | `background_opacity = 0.88` | | Blur | `background_blur = true` | | Always show tab bar | `auto_hide_tabbar = false` | Full key list: [Configuration reference](/docs/reference/configuration-reference). Keybind syntax: [Keybindings](/docs/customize/keybindings). # Getting started (/docs/getting-started) ## 1. Install the app [#1-install-the-app] Download the build for your platform, install it, and complete the first launch. [Install Termy →](/docs/getting-started/installation) macOS downloads from v0.2.75 onward are signed and notarized. If the app does not open, see [Troubleshooting](/docs/getting-started/troubleshooting). ## 2. Confirm the essentials [#2-confirm-the-essentials] On first launch, Termy uses your login shell and a system monospace font. Open Settings with `Cmd ,` on macOS or `Ctrl ,` on Linux and Windows. | Check | What to do next | | ------------------------------------- | ----------------------------------------------------------- | | Shell opens correctly | Continue to daily workflows | | Font or theme feels wrong | Open [Appearance](/docs/customize/appearance) | | Shell or working directory is wrong | Open [Terminal behavior](/docs/customize/terminal-behavior) | | A shortcut conflicts with another app | Open [Keybindings](/docs/customize/keybindings) | ## 3. Learn the command palette [#3-learn-the-command-palette] Press `Cmd P` or `Ctrl P`. The palette exposes Termy's actions without requiring you to memorize every shortcut first. [Use the command palette →](/docs/using-termy/command-palette) ## 4. Shape your workspace [#4-shape-your-workspace] Continue with the feature that matches how you work: # Install (/docs/getting-started/installation) The fastest path is the [Termy download page](/download). It detects your platform and links to the current GitHub release. ## macOS [#macos] 1. On the [download page](/download), choose **Apple silicon** for M-series Macs or **Intel** for older Macs. 2. Open the `.dmg` and drag Termy to `/Applications`. 3. Open Termy from Applications. macOS DMGs from v0.2.75 onward are signed and notarized. If Termy does not open, [see the troubleshooting guide](/docs/getting-started/troubleshooting). ## Linux [#linux] Download the AppImage or `.tar.gz` from [GitHub Releases](https://github.com/lassejlv/termy/releases). * **AppImage:** mark it executable, then run it. * **Archive:** unpack it and run the included Termy binary. Linux release launchers prefer X11/XWayland when it is available so the desktop window manager can provide the native title bar and window controls. To run the Wayland backend instead, launch Termy with `TERMY_LINUX_BACKEND=wayland`. Both terminal and Settings windows request system-managed decorations, including KDE's title bar, window controls, and resize borders. The window manager controls the titlebar theme independently of Termy's terminal color theme. ## Windows [#windows] 1. From [GitHub Releases](https://github.com/lassejlv/termy/releases), download the installer for your PC: * `Termy--windows-x64-Setup.exe` for Intel and AMD PCs. * `Termy--windows-arm64-Setup.exe` for ARM64 PCs such as Snapdragon laptops. 2. Run the installer. By default it installs Termy for your account only, in `%LOCALAPPDATA%\Programs\Termy`, without an administrator prompt. To install for every user under Program Files, choose **Install for all users** when Setup asks, or run it with `/ALLUSERS`. 3. Open Termy from the Start menu. A desktop shortcut is optional; tick it on the **Additional tasks** page. Termy checks for updates itself and installs them in the same place, for the same users, as the existing install. The Windows installer is not code-signed, so Windows SmartScreen may show **Windows protected your PC** with an unknown publisher. Choose **More info**, check that the file name is the one you downloaded from the Termy release, then choose **Run anyway**. To confirm the download, compare its SHA-256 hash (`Get-FileHash .\Termy--windows-x64-Setup.exe` in PowerShell) with `checksums.txt` on the same release. Termy will also be available through winget as `lassejlv.Termy` once the package is accepted into the winget repository: ```powershell winget install lassejlv.Termy ``` ## From source [#from-source] ```bash git clone https://github.com/lassejlv/termy cd termy cargo run --release -p termy ``` This route requires Rust and the platform dependencies used by the desktop app. Prefer a release binary unless you are developing Termy itself. ## First launch [#first-launch] | | | | --------------- | ------------------------------------------------------------ | | Theme | `termy` | | Shell | your login shell (`$SHELL`) | | Font | system monospace (Menlo on macOS, Consolas on Windows), 14px | | Settings | `Cmd ,` (macOS) · `Ctrl ,` (Linux / Windows) | | Command palette | `Cmd P` · `Ctrl P` | | Open a folder | file manager **Open new Termy tab here**, or `termy /path` | # Troubleshooting (/docs/getting-started/troubleshooting) ## macOS: app won't open [#macos-app-wont-open] macOS DMGs from v0.2.75 onward are signed and notarized. If macOS prevents Termy from opening, first check that you downloaded the current release and dragged `Termy.app` from the `.dmg` into **Applications**. ### Termy is still blocked [#termy-is-still-blocked] 1. Remove any older copy of Termy from Applications. 2. Download the newest `.dmg` from the [Termy download page](/download). 3. Drag Termy into Applications and try opening it again. 4. If macOS still blocks it, [report the exact message](https://github.com/lassejlv/termy/issues/new). Releases before v0.2.75 used unsigned macOS DMGs. Upgrade to a current release instead of removing the download's quarantine attribute. ## Report an issue [#report-an-issue] If the problem continues, [open a GitHub issue](https://github.com/lassejlv/termy/issues/new) with: * your operating system and architecture; * the Termy release you downloaded; * the exact error message; * what you expected to happen. Do not include tokens, shell history, or other secrets in screenshots or logs. # Termy documentation (/docs) New to Termy? Start with [Install Termy](/docs/getting-started/installation), then keep [Troubleshooting](/docs/getting-started/troubleshooting) nearby for install or launch issues. ## Start here [#start-here] These steps take you from download to a useful daily setup. ## Find an answer by goal [#find-an-answer-by-goal] ## Useful shortcuts [#useful-shortcuts] | Goal | macOS | Linux / Windows | | ---------------------- | ------- | --------------- | | Open Settings | `Cmd ,` | `Ctrl ,` | | Open command palette | `Cmd P` | `Ctrl P` | | Search terminal output | `Cmd F` | `Ctrl F` | Use the search button in the sidebar when you know a setting or feature name but not which section owns it. # Actions (/docs/reference/actions) ## Tabs [#tabs] `new_tab`, `close_tab`, `close_pane_or_tab`, `move_tab_left`, `move_tab_right`, `switch_tab_left`, `switch_tab_right`, `switch_to_tab_1` … `switch_to_tab_9`, `rename_tab`, `toggle_tab_bar_visibility`. ## Panes [#panes] `split_pane_vertical`, `split_pane_horizontal`, `close_pane`, `focus_pane_left`, `focus_pane_right`, `focus_pane_up`, `focus_pane_down`, `focus_pane_next`, `focus_pane_previous`, `resize_pane_left`, `resize_pane_right`, `resize_pane_up`, `resize_pane_down`, `toggle_pane_zoom`. ## Window [#window] `minimize_window`, `zoom_in`, `zoom_out`, `zoom_reset`, `quit`, `restart_app`, `app_info`. ## Search [#search] `open_search`, `close_search`, `search_next`, `search_previous`, `toggle_search_case_sensitive`, `toggle_search_regex`. ## Clipboard [#clipboard] `copy`, `paste`. ## Terminal [#terminal] `clear_screen` sends the same form-feed control character as `Ctrl+L` to the active pane, clearing the viewport while preserving scrollback in shells that use the conventional `Ctrl+L` behavior. On macOS, `Cmd+K` uses this action by default. On other platforms, bind it explicitly if desired: ```txt keybind = secondary-k=clear_screen ``` ## App [#app] `toggle_command_palette`, `open_settings`, `open_config`, `prettify_config`, `switch_theme`, `import_colors`, `check_for_updates`, `install_cli`. ## Workflows [#workflows] `manage_tmux_sessions`, `manage_saved_layouts`, `run_task`, `toggle_agent_sidebar`. # Configuration Reference (/docs/reference/configuration-reference) The canonical configuration reference is generated from the Rust config schema. ```bash just generate-config-doc ``` The generated source lives at [`docs/configuration.md`](https://github.com/lassejlv/termy/blob/main/docs/configuration.md). The website's [Customize](/docs/customize) pages group the same settings by task and explain common workflows. ## Common Sections [#common-sections] * [Appearance](/docs/customize/appearance) * [Terminal Behavior](/docs/customize/terminal-behavior) * [Tabs and Titlebar](/docs/customize/tabs-titlebar) * [Colors](/docs/customize/colors) * [Themes](/docs/customize/themes) * [Advanced](/docs/customize/advanced) ## Maintenance Rule [#maintenance-rule] Do not hand-copy new settings into reference tables without updating the generated config docs first. Generated docs are the source of truth for key names and defaults. # Reference (/docs/reference) Reference pages are for lookup. If you are deciding what to change, start in [Customize Termy](/docs/customize) instead. ## How the pieces connect [#how-the-pieces-connect] ```txt key press → keybinding → action → Termy behavior config key → parsed setting → runtime or appearance change ``` When creating a shortcut, find the behavior in [Actions](/docs/reference/actions), then map that action using [Keybindings](/docs/customize/keybindings). # Defaults (/docs/reference/keybinding-defaults) `secondary` is `cmd` on macOS and `ctrl` on Linux/Windows. Copy/paste differs per platform. ## macOS [#macos] ```txt secondary-q quit secondary-, open_settings secondary-p toggle_command_palette secondary-t new_tab secondary-n new_window secondary-w close_pane_or_tab secondary-1..9 switch_to_tab_1..9 secondary-d split_pane_vertical secondary-shift-d split_pane_horizontal secondary-o focus_pane_next secondary-alt- focus_pane_ secondary-alt-shift- resize_pane_ secondary-enter toggle_pane_zoom secondary-= / - zoom_in / zoom_out secondary-0 zoom_reset secondary-f open_search secondary-g search_next secondary-shift-g search_previous secondary-m minimize_window secondary-c copy secondary-v paste ``` ## Linux [#linux] Identical to macOS except clipboard uses `ctrl-shift-c` / `ctrl-shift-v`. No `new_window` or `minimize_window` binding. ## Windows [#windows] Identical to Linux. `secondary-c` and `secondary-v` map to copy/paste through the `secondary` modifier (`ctrl`). ## Notes [#notes] * `toggle_agent_sidebar` is currently unavailable on Windows. # Terminal protocols (/docs/reference/terminal-protocols) This page lists what Termy's terminal engine implements. The same engine runs the desktop app, libtermy, and the `@termysh` web packages, so programs see the same behavior everywhere. `TERM` defaults to `xterm-256color` (see the `term` setting), and XTGETTCAP always reports that name. Sequences that are not listed here are ignored without affecting the screen. ## Extensions [#extensions] | Protocol | Sequence | Notes | | ---------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Program status](/docs/using-termy/program-status) | `OSC 7501` | Revision 0.3. Reports `idle`, `working`, `blocked`, `done`, or `error`. `OSC 7501 ; ? ST` replies `ESC ] 7501 ; ? ESC \`. | | Progress | `OSC 9 ; 4 ; state ; value` | ConEmu/Windows Terminal progress. States `0` clear, `1` in progress, `2` error, `3` indeterminate, `4` warning. | | Kitty graphics | `APC G … ST` | Chunked RGB/RGBA/PNG uploads, zlib, file and shared-memory transfers, placements, deletion, Unicode placeholders, relative placements, and animation. | | Kitty keyboard | `CSI ? u`, `CSI > u`, `CSI < u`, `CSI = u` | All five progressive-enhancement flags. The primary and alternate screens keep separate flag stacks, up to 32 entries each. | | [Kitty clipboard](/docs/customize/terminal-behavior) | `OSC 5522`, mode `?5522` | Multi-format clipboard reads and writes, and paste events. | | Synchronized output | mode `?2026` | Output is held until the update ends, for up to 150 ms or 2 MiB. | | Hyperlinks | `OSC 8 ; params ; uri ST` | Supports the `id=` parameter. | | Clipboard | `OSC 52 ; targets ; data ST` | Writes are always allowed. Reads (`data` = `?`) follow the `clipboard_read` setting. | | Shell integration | `OSC 133 ; A/B/C/D` | Prompt start, command start, command executing, and command finished with an optional exit code. Always parsed; `shell_integration_enabled` only controls the environment Termy passes to shells. | | Working directory | `OSC 7 ; file://host/path ST`, `OSC 9 ; 9 ; path ST` | Percent-encoded `file://` URLs are decoded; a path with a literal `%` is kept as written. Other URL schemes are ignored. | | Window title | `OSC 0`, `OSC 2`, `CSI 22 t`, `CSI 23 t` | Title stack holds up to 64 entries. `OSC 1` (icon title) is ignored. | ## Colors [#colors] | Sequence | Notes | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | | `OSC 4 ; index ; spec` | Set or query (`?`) palette entries 0–255. | | `OSC 10`, `OSC 11`, `OSC 12` | Set or query foreground, background, and cursor colors. Without a cursor color, the cursor query reports the foreground. | | `OSC 104`, `OSC 110`, `OSC 111`, `OSC 112` | Reset palette entries, foreground, background, and cursor color. | Color specs accept `#rgb` through `#rrrrggggbbbb` and `rgb:r/g/b` with one to four hex digits per component. Replies use `rgb:rrrr/gggg/bbbb` and end with the query's terminator (BEL or ST). ## Text attributes (SGR) [#text-attributes-sgr] | Codes | Effect | | ---------------------------- | ------------------------------------------------------------------------------------------------ | | `0` | Reset (keeps DECSCA protection) | | `1`, `2`, `22` | Bold, dim, normal intensity | | `3`, `23` | Italic on/off | | `4`, `21`, `24` | Underline, double underline, no underline | | `4:0`–`4:5` | None, single, double, curly, dotted, dashed underline | | `5`, `6`, `25` | Blink on/off | | `7`, `27` | Inverse on/off | | `8`, `28` | Hidden on/off | | `9`, `29` | Strikethrough on/off | | `30`–`37`, `90`–`97`, `39` | Foreground: palette, bright palette, default | | `40`–`47`, `100`–`107`, `49` | Background: palette, bright palette, default | | `38`, `48`, `58` | Extended foreground, background, underline color: `5;n` or `2;r;g;b`, with `;` or `:` separators | | `59` | Default underline color | ## Modes [#modes] Set with `CSI ? n h`, reset with `CSI ? n l`. Every mode below can be queried with DECRQM (`CSI ? n $ p`). Except for `3`, `1048`, and `2027`, each can also be saved and restored with XTSAVE (`CSI ? n s`) and XTRESTORE (`CSI ? n r`). | Mode | Name | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `1` | Application cursor keys (DECCKM) | | `3` | DECCOLM: clears the screen and resets margins; does not resize | | `5` | Reverse video (DECSCNM): swaps the default foreground and background (explicitly colored cells keep their colors) | | `6` | Origin mode (DECOM) | | `7` | Autowrap (DECAWM) | | `69` | Left/right margin mode (DECLRMM): enables DECSLRM; resetting it resets the margins | | `9` | X10 mouse reporting | | `12` | Cursor blinking | | `25` | Cursor visible (DECTCEM) | | `47`, `1047`, `1049` | Alternate screen; `1049` also saves the cursor and clears on entry, including when `47`/`1047` already switched screens | | `1048` | Save/restore cursor; DECRQM reports whether a cursor is saved | | `1000`, `1002`, `1003` | Mouse click, drag, and motion tracking; resetting any of `9`, `1000`, `1002`, `1003` turns tracking off | | `1004` | Focus in/out events | | `1005`, `1006` | Mouse encoding: UTF-8, SGR. urxvt (`1015`) is not supported and reports `0` | | `1007` | Alternate scroll: the host may send wheel motion as cursor keys on the alternate screen when no mouse tracking is active | | `1016` | SGR pixel mouse encoding. Recognized only where the host reports pointer pixels: the [engine C library](/docs/developer/libtermy/engine) with `pixel_mouse_reports`. Elsewhere, including the Termy app, it is not supported and reports `0` | | `2004` | Bracketed paste | | `2026` | Synchronized output | | `2027` | Grapheme clustering: always on; DECRQM reports `3` (permanently set) | | `5522` | Kitty clipboard paste events | ANSI modes `4` (insert, IRM) and `20` (line feed/new line, LNM) use `CSI n h`/`CSI n l` and are reportable with `CSI n $ p`. ## Queries and replies [#queries-and-replies] | Query | Sequence | Reply | | ------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Primary device attributes | `CSI c`, `ESC Z` | `CSI ? 62 ; 22 c` | | Secondary device attributes | `CSI > c` | `CSI > 1 ; 1 ; 0 c` | | Terminal name and version (XTVERSION) | `CSI > q`, `CSI > 0 q` | `DCS > \| Termy version ST`, with the engine's version | | Device status | `CSI 5 n` | `CSI 0 n` | | Cursor position | `CSI 6 n`, `CSI ? 6 n` | `CSI row ; col R`, relative to the margins in origin mode | | Mode report | `CSI ? n $ p`, `CSI n $ p` | `CSI ? n ; status $ y` | | Kitty keyboard flags | `CSI ? u` | `CSI ? flags u` | | Text area size in pixels | `CSI 14 t` | `CSI 4 ; height ; width t` | | Cell size in pixels | `CSI 16 t` | `CSI 6 ; height ; width t` | | Text area size in cells | `CSI 18 t` | `CSI 8 ; rows ; cols t` | | DECRQSS | `DCS $ q Pt ST` | SGR (`m`), scroll region (`r`), left/right margins (`s`), cursor style (` q`), protection (`"q`), and conformance level (`"p`) | | XTGETTCAP | `DCS + q names ST` | `Co`/`colors` (`256`), `TN`/`name` (`xterm-256color`), `RGB` (`8`), and, in terminfo notation, `Smulx` (styled underlines), `Setulc` (underline color), and `Ms` (OSC 52 clipboard writes) | | Color | `OSC 4`, `OSC 10`–`12` with `?` | See [Colors](#colors) | | Program status | `OSC 7501 ; ?` | `OSC 7501 ; ? ST` | ## Cursor, editing, and screen control [#cursor-editing-and-screen-control] | Sequence | Function | | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `CSI A/B/C/D`, `CSI e`, `CSI a` | Cursor up, down, forward, back; VPR, HPR | | `CSI E/F` | Cursor next/previous line | | `CSI G`, `` CSI ` ``, `CSI d`, `CSI H`, `CSI f` | Column, row, and absolute positioning | | `CSI I`, `CSI Z`, `ESC H`, `CSI g` | Tab forward/back, set tab stop, clear tab stops (`0`, `3`) | | `CSI J`, `CSI K`, `CSI ? J`, `CSI ? K` | Erase display/line; selective erase respects DECSCA | | `CSI X`, `CSI @`, `CSI P` | Erase, insert, and delete characters | | `CSI L`, `CSI M`, `CSI S`, `CSI T` | Insert/delete lines, scroll up/down | | `CSI b` | Repeat the last printed character (REP) | | `CSI r` | Set the scroll region (DECSTBM) | | `CSI Pl ; Pr s` | Set the left/right margins (DECSLRM) while mode `69` is set. Text wraps and scrolls inside them, and IL/DL/ICH/DCH/SU/SD stay inside them; scrolling inside narrower margins doesn't add to scrollback | | `CSI s`, `CSI u`, `ESC 7`, `ESC 8` | Save/restore cursor and character sets; `CSI s` is DECSLRM while mode `69` is set | | `CSI Ps SP q` | Cursor style (DECSCUSR): block, underline, or bar, blinking or steady; `0` restores the configured default shape, steady | | `CSI Ps " q` | Character protection (DECSCA) | | `ESC D`, `ESC E`, `ESC M` | Index, next line, reverse index | | `ESC =`, `ESC >` | Application/normal keypad | | `ESC ( 0`, `ESC ) 0`, `ESC ( B`, `ESC ) B`, `SO`, `SI` | DEC special graphics in G0/G1; other designations select ASCII | | `ESC # 8` | Screen alignment test (DECALN): fills the screen with `E`, resets the margins, and homes the cursor | | `CSI ! p` | Soft reset (DECSTR); screen contents are kept, and mode `69` is reset | | `ESC c` | Full reset (RIS) | ## Not supported [#not-supported] Sixel and iTerm2 (`OSC 1337`) images, desktop notifications (`OSC 9` text, `OSC 777`, `OSC 99`), window manipulation beyond the `CSI t` reports above, and mouse pointer shapes (`OSC 22`). Use [Kitty graphics](#extensions) for images. # Command palette (/docs/using-termy/command-palette) Open with `secondary-p` (`Cmd P` / `Ctrl P`). Includes every [action](/docs/reference/actions), theme (preview on hover), setting, [task](/docs/using-termy/tasks-layouts), and tmux session when `tmux_enabled = true`. Hide shortcut badges: ```txt command_palette_show_keybinds = false ``` Disable the palette and open the raw config from Settings instead: ```txt simple_mode = true ``` # Using Termy (/docs/using-termy) Start with the command palette if you prefer discovering actions before learning shortcuts. Use tabs and panes for local organization; add tasks, layouts, or tmux when work needs to be repeatable or persistent. ## Choose the right workspace model [#choose-the-right-workspace-model] | Need | Start with | | ---------------------------------------- | ---------------------------------------------------- | | A few temporary shells | [Tabs](/docs/using-termy/tabs) | | Related commands visible together | [Splits and panes](/docs/using-termy/splits-panes) | | Repeat the same setup later | [Tasks and layouts](/docs/using-termy/tasks-layouts) | | Open a terminal on a saved remote host | [SSH hosts](/docs/using-termy/ssh-hosts) | | Keep work alive after closing the window | [Tmux sessions](/docs/using-termy/tmux-sessions) | # Built-in multiplexer (/docs/using-termy/multiplexer) Enable **Built-in Multiplexer** in **Settings → Terminal → Sessions**, then restart Termy. You can also set it in your config file: ```txt multiplexer_enabled = true ``` It is off by default. No tmux installation or session commands are needed. ## Quit and return [#quit-and-return] Quitting Termy or closing a window leaves its programs running in a background Termy process. Reopening Termy restores your windows, workspaces, tabs, split layouts, active panes, zoom, pins, and custom tab names. Each terminal reconnects to its original running process. Its scrollback, scroll position, cursor, colors, alternate screen, input modes, links, and images remain in the background host. Programs can continue producing output while the app is closed. Configured scrollback limits still apply. Closing a **tab**, **pane**, or **workspace** ends the sessions it contains. Exiting a shell also ends that shell normally. Closing the window with its window controls keeps its sessions available for reopening. Sessions last while the background host and your operating-system session are running. They do not survive logging out, rebooting, or terminating the host. This is live process persistence; named layouts remain reusable layouts that start new terminals. ## Change back to regular terminals [#change-back-to-regular-terminals] Turn **Built-in Multiplexer** off and restart Termy. Existing background sessions remain available if you enable it again; close their tabs or panes first if you want to end them. The built-in multiplexer takes precedence over `tmux_enabled`. To use [tmux sessions](/docs/using-termy/tmux-sessions), disable the built-in multiplexer and restart Termy. ## How it stays responsive [#how-it-stays-responsive] The host keeps the terminal engines and bounded scrollback in memory. The app receives the current viewport and fetches history or images as needed, so reopening does not replay terminal output. Clipboard requests use the attached app's existing policy; a detached terminal cannot read the system clipboard. # Commands and context (/docs/using-termy/plugins/commands-context) Commands are searchable actions that run inside the plugin's Bun Worker. They can collect native inputs, inspect a read-only Termy context, emit notifications, and return typed actions to the app. ```ts title="plugin.ts" export default definePlugin({ settings: { greeting: { type: "text", title: "Greeting", defaultValue: "Hello from Termy", }, token: { type: "secret", title: "API token", }, }, commands: [ { id: "greet", title: "Hello: Greet me", placements: ["commandPalette", "terminalContextMenu"], keywords: ["hello", "example"], icon: "info", inputs: [ { id: "style", type: "select", label: "Greeting style", options: [ { value: "short", label: "Short" }, { value: "friendly", label: "Friendly" }, ], }, { id: "confirmed", type: "confirm", label: "Show greeting?", defaultValue: true, }, ], async run({ inputs, context }) { if (inputs.confirmed !== true) return; const message = inputs.style === "friendly" ? `Welcome to Termy ${context.appVersion}` : context.settings.get("greeting") ?? "Hello from Termy"; context.toasts.success(message); }, }, ], } satisfies TermyPlugin); ``` ## Command fields and inputs [#command-fields-and-inputs] Commands require `id`, `title`, and `run`. They may also declare search `keywords`, `status`, `enabled`, `disabledReason`, `timeoutMs`, and one of Termy's built-in `icon` names. `placements` controls where a command is listed. It accepts `commandPalette`, `terminalContextMenu`, and `tabContextMenu`, and defaults to `["commandPalette"]`. Context-menu placements are currently available on Linux and Windows. Commands with inputs use the same palette input flow from every surface. Use `placements: []` for a keybinding-only command. Use `when` to make a command available only in matching context. Supported filters are `hasSelection`, `hasWorkingDirectory`, `runtimes`, and `platforms`. Termy checks the condition when listing the command and again immediately before running it. Inputs appear sequentially before the handler runs: * `text` returns a string and supports a placeholder, default, required flag, and maximum length. * `select` returns the value of one fixed option. * `pick` calls `loadOptions({ query, context })` as the user types and returns the selected option value. * `confirm` returns a boolean. Commands without inputs run immediately. Inputs are delivered as `inputs.` to `run`. Pick loaders may be async and return the same `{ value, label, keywords?, status? }` shape as select options. Termy debounces requests and ignores stale results. Keep loaders side-effect free; they cannot return actions. ## Read the active context [#read-the-active-context] Every handler receives a read-only snapshot with `platform`, `appVersion`, the resolved session launch `shell`, and the active `runtime` (`native` or `tmux`). It may also include `workingDirectory`, `activeCommand`, `selectedText`, `activeTab`, and `activePane`. `context.origin` contains stable `windowId`, `tabId`, and `paneId` values captured when the request starts. Use it to keep an async action aimed at its original terminal even if the user changes tabs. Active tab and pane snapshots include the same stable IDs. Tab and pane indexes are zero-based. Selected text is capped at 64 KiB on a UTF-8 boundary; `selectedTextTruncated` reports whether Termy shortened it. ```ts run({ context }) { if (!context.selectedText) { context.toasts.info("Select terminal text first"); return; } return { type: "clipboard.write", text: context.selectedText }; } ``` The context also exposes `context.toasts.info(message)`, `success(message)`, `warning(message)`, and `error(message)`. These emit notifications directly and do not require an SDK import or returned action. Long-running handlers should stop when `context.signal` aborts. They can update the cancellable loading toast with `context.progress.report({ message?, percentage? })`; percentages range from 0 to 100\. ## Plugin settings [#plugin-settings] Declare typed `toggle`, `text`, `select`, and `secret` settings beside `commands`. Termy renders them under **Settings → Plugins**, and handlers read the resolved values with `context.settings.get("settingName")`. Ordinary overrides live in the plugin data directory. Secrets are masked and stored through the operating-system credential store instead of `settings.json`. Changes apply to the next invocation without restarting the Worker. ## Return actions [#return-actions] A command, lifecycle handler, or native-view action handler can return one action, an action array, `{ actions: [...] }`, or nothing. Handlers may be async. | Type | Purpose | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `terminal.run` | Legacy action that opens a tab and runs a shell command. | | `terminal.sendText` | Send text, optionally followed by Enter, to `origin`, `active`, or an exact terminal target. | | `terminal.open` | Open a tab, right/down split, or window with an optional working directory and launch. | | `termy.command` | Invoke a built-in Termy command. | | `clipboard.write` | Copy text to the system clipboard. | | `url.open` | Open an `http` or `https` URL. | | `view.open` | Open a native view with optional JSON `params`. Add `target: "commandPalette"` to use the palette surface; the default is a modal. Requires `native-ui`. | | `view.replace` | Replace the current plugin view and params in place. | | `view.close` | Close the current plugin view. | | `toast` | Show an info, success, warning, or error notification. | Exact terminal targets are scoped to the Termy window that delivered the invocation. `focus: false` restores the previous pane after opening a tab or split; a newly created OS window necessarily becomes focused. `terminal.open.launch` accepts `{ type: "shell", command }` or `{ type: "program", program, args? }`. Prefer structured programs when exact argv boundaries matter. Structured program launches require the native runtime; tmux rejects them because preserving argv would otherwise require shell parsing. Never interpolate free-form text directly into `terminal.run` or a shell launch. Map selected values to fixed commands or quote values correctly for the target shell. ## Bind a plugin command [#bind-a-plugin-command] Use the manifest and command IDs in `~/.config/termy/config.txt`: ```txt keybind = secondary-g=plugin:git-tools/status ``` Commands with inputs open their input form. Termy refreshes the plugin catalog before invoking a shortcut, so saved plugin changes are picked up. Normal keybinding ordering applies: later lines win, `unbind` removes the shortcut, and a task keybinding takes priority on conflicts. See [Native UI](/docs/using-termy/plugins/native-ui) for `view.open` and [Lifecycle and storage](/docs/using-termy/plugins/lifecycle-storage) for events and persistence. # Getting started (/docs/using-termy/plugins/getting-started) Install [Bun](https://bun.sh/), then scaffold a plugin: ```sh termy plugin init my-plugin termy plugin dev ./my-plugin ``` `dev` installs or updates Termy's managed copy and watches the development folder. Open the command palette after saving to load the latest valid version. ## Plugin files [#plugin-files] A minimal plugin has a manifest and a TypeScript entrypoint: ```text my-plugin/plugin.json my-plugin/plugin.ts ``` `plugin.json` owns identity and API metadata: ```json title="plugin.json" { "$schema": "https://termy.sh/schemas/plugin.schema.json", "apiVersion": 1, "id": "hello", "name": "Hello", "version": "1.0.0", "capabilities": [] } ``` `version` is optional metadata shown in Settings. Set `main` to a relative `.ts` or `.tsx` path when the entrypoint is not `plugin.ts`; it must stay inside the plugin directory and cannot be a symlink. `$schema` gives compatible editors validation and autocomplete from Termy's public [plugin manifest schema](/schemas/plugin.schema.json). `termy plugin init` adds it automatically. `capabilities` declares the Termy host APIs a plugin uses. Supported values are `storage` for `context.storage` and `context.paths`, and `native-ui` for views and `view.open`. Omitting the field means no capabilities; unknown and duplicate values are rejected. Capabilities gate Termy APIs. They are not operating-system permissions: plugins are still trusted Bun code with normal file, network, and process access. The entrypoint exports a plugin definition without repeating manifest metadata: ```ts title="plugin.ts" export default definePlugin({ commands: [ { id: "greet", title: "Hello: Greet me", keywords: ["hello", "example"], icon: "info", run({ context }) { context.toasts.success(`Welcome to Termy ${context.appVersion}`); }, }, ], } satisfies TermyPlugin); ``` Termy manages `plugins/termy.d.ts`, which supplies the global `definePlugin`, `TermyPlugin`, `TermyPluginContext`, and JSX types. Do not import an SDK or run a build step. ## Install and manage plugins [#install-and-manage-plugins] Use **Settings → Plugins → Install from folder** to select a plugin directory. Termy validates it and copies it into the managed plugins directory. The same screen shows Bun status and lets you refresh, open the folder, enable, disable, or uninstall plugins. The CLI supports local folders and trusted GitHub sources: ```sh termy plugin add ./my-plugin termy plugin add https://github.com/example/termy-plugins --path my-plugin termy plugin status my-plugin termy plugin disable my-plugin termy plugin enable my-plugin termy plugin update my-plugin termy plugin uninstall my-plugin ``` `add` also accepts the `install` alias, while `remove` accepts `uninstall`. Local installs copy the source into Termy's managed directory and leave the development folder untouched. GitHub repository URLs, `/tree//` URLs, `--ref`, and `--path` are supported. Termy resolves refs to full commits and stores source metadata for predictable status and updates. A repository with multiple valid plugins requires `--path` instead of guessing. The installer downloads regular files as data. It does not clone the repository, run scripts, evaluate plugin code, or install dependencies while installing. The plugin becomes trusted Bun code when Termy loads it, so interactive CLI installs require confirmation and automation must pass `--yes`. ## Develop safely [#develop-safely] `termy plugin dev ./my-plugin` validates the source tree and atomically swaps each valid update into place. A validation or copy failure leaves the managed copy untouched; Bun load errors appear when the command palette refreshes. `Ctrl C` stops the watcher without uninstalling the plugin. Dev mode preserves enabled state and storage. It refuses to replace a GitHub-tracked installation with a local source, so uninstall that copy first when intentionally changing its origin. The managed plugin directory is `$XDG_CONFIG_HOME/termy/plugins` when `XDG_CONFIG_HOME` is set, `~/.config/termy/plugins` otherwise on macOS and Linux, and `%APPDATA%\termy\plugins` on Windows. Next, define richer [commands and context](/docs/using-termy/plugins/commands-context) or switch the entrypoint to `.tsx` for [native UI](/docs/using-termy/plugins/native-ui). # Plugins (/docs/using-termy/plugins) Plugins extend Termy with TypeScript running in Bun. A plugin can add command-palette commands, react to terminal lifecycle events, store local data, and open small native tools rendered by Termy. Plugins are trusted local code. They run with your user account's file, network, and process access, so read the source before installing one. ## What plugins can do [#what-plugins-can-do] | Feature | Use it for | | -------------------- | ------------------------------------------------------------------------------------------------ | | Commands | Add actions to the command palette, context menus, and keybindings. | | Context | Read the active terminal, selection, working directory, shell, tab, and pane. | | Native UI | Return allowlisted JSX that Termy validates and renders with GPUI. | | Lifecycle events | Respond when the terminal is ready, a tab changes, the directory changes, or a command finishes. | | Settings and storage | Expose typed settings and persist plugin-specific JSON or files. | Install [Bun](https://bun.sh/) before loading plugins. Then open **Settings → Plugins** to install, enable, disable, refresh, or uninstall them. Developers can start with [Getting started](/docs/using-termy/plugins/getting-started). # Lifecycle and storage (/docs/using-termy/plugins/lifecycle-storage) Plugins can explicitly subscribe to active-terminal lifecycle events beside `commands`. Use `commands: []` for an event-only plugin. ```ts title="plugin.ts" export default definePlugin({ commands: [], events: { "terminal.ready"({ context }) { context.toasts.info("Terminal ready"); }, "tab.activated"({ event, context }) { console.log(event.previousTabId, context.activeTab?.id); }, "pane.activated"({ event, context }) { console.log(event.previousPaneId, context.activePane?.id); }, "tab.created"({ event }) { console.log("created", event.tabId); }, "terminal.bell"() { console.log("bell"); }, "workingDirectory.changed"({ event }) { console.log(event.previousWorkingDirectory, event.workingDirectory); }, "command.finished"({ event }) { console.log(event.command, event.exitCode, event.durationMs); }, }, } satisfies TermyPlugin); ``` Handlers receive the same read-only [context](/docs/using-termy/plugins/commands-context#read-the-active-context) as commands. They can emit toasts or return the same actions. `terminal.ready` runs once after the plugin catalog is ready. Other subscriptions are `tab.activated`, `pane.activated`, `tab.created`, `tab.closed`, `terminal.bell`, `workingDirectory.changed`, `command.started`, and `command.finished`. Native shell integration provides command exit codes, measured durations, and bell events. Tmux completion is inferred and its bell state is not available, so unavailable fields/events are omitted instead of guessed. Events remain ordered within one plugin. Separate plugins may handle them concurrently under the normal execution timeout. ## Store JSON values [#store-json-values] Declare the storage capability in `plugin.json`: ```json title="plugin.json" { "$schema": "https://termy.sh/schemas/plugin.schema.json", "apiVersion": 1, "id": "todos", "name": "Todos", "capabilities": ["storage"] } ``` Then use the asynchronous storage API for small persistent values: ```ts const todos = await context.storage.get("todos") ?? []; await context.storage.set("todos", todos); await context.storage.delete("todos"); await context.storage.clear(); ``` Each plugin can store up to 512 JSON values totaling 1 MiB. Storage is plain local JSON; use a declared `secret` setting for credentials. ## Store larger files [#store-larger-files] `context.paths.dataDirectory` is for larger persistent files, while `context.paths.cacheDirectory` is for disposable data. Plugins may use Bun or Node-compatible file APIs to read and write these paths. Both locations are isolated per plugin, live outside the content-hashed source tree, and survive reloads and updates. Uninstalling a plugin removes its storage, data, and cache. Disabling it keeps them. Use [Native UI](/docs/using-termy/plugins/native-ui) to turn stored data into a small native tool, or review the [security model](/docs/using-termy/plugins/security-limits) before handling sensitive data. # Native UI (/docs/using-termy/plugins/native-ui) Plugins can open small native tools from `.tsx` entrypoints. JSX is compiled into a strictly validated Termy document and rendered with GPUI; it is not HTML or React, and plugins never receive native renderer access. Declare `native-ui` in `plugin.json`; add `storage` too when the view persists data. ## Open a view [#open-a-view] Point the manifest at a `.tsx` entrypoint: ```json title="plugin.json" { "$schema": "https://termy.sh/schemas/plugin.schema.json", "apiVersion": 1, "id": "todos", "name": "Todos", "main": "plugin.tsx", "capabilities": ["storage", "native-ui"] } ``` Add the Termy JSX pragmas, declare the view, and return `view.open` from a command: ```tsx title="plugin.tsx" /** @jsxRuntime classic */ /** @jsx TermyUI.createElement */ /** @jsxFrag TermyUI.Fragment */ export default definePlugin({ commands: [{ id: "open", title: "Todos: Open", run() { return { type: "view.open", view: "todos" }; }, }], views: { todos: { title: "Todos", async render({ params, context }) { const todos = await context.storage.get>("todos") ?? []; return ( Add {todos.slice(0, 24).map((todo) => ( {todo.title} ))} ); }, async onAction({ action, values, context }) { // Persist changes using action.id, action.payload, and the form values. // Termy rerenders the view after this handler finishes. }, }, }, } satisfies TermyPlugin); ``` The view key (`todos`) must match the `view` in the `view.open` action. The managed `termy.d.ts` file supplies `TermyUI`, JSX, view, and action types globally. `view.open` accepts a bounded JSON `params` object. Termy freezes it and passes the same values to `render` and `onAction`. Return `view.replace` to navigate to another declared view in place, or `view.close` to dismiss the current view. Views use a centered modal by default. Add `target: "commandPalette"` to render the same bounded native document inside the palette results area while keeping the palette search and footer: ```ts return { type: "view.open", view: "todos", target: "commandPalette" }; ``` ## Handle interactions [#handle-interactions] Interactive controls use named actions instead of JavaScript callbacks. Any view that renders an interactive control must define `onAction`; passive views may omit it. * `Button`, `Checkbox`, `Select`, `List`, and `ListItem` use named actions; `payload` carries optional item data. * `TextInput` and `TextArea` require an `id`; `submit` names an optional submit action. * Every control ID in one rendered document must be unique. Termy supplies Tab/Shift-Tab focus traversal, arrow-key selection for selects and lists, and Enter/Space activation for controls. A filtering list also accepts text while focused and filters its items locally. `onAction` receives `action.id`, `action.controlId`, optional `action.payload` and `action.value`, plus a `values` snapshot keyed by control ID. It can update storage, emit toasts, or return any normal [plugin action](/docs/using-termy/plugins/commands-context#return-actions). After it finishes, Termy calls `render` again and replaces the document. ```tsx type Todo = { id: string; title: string; done: boolean }; async onAction({ action, values, context }) { if (action.id === "add") { const title = String(values.title ?? "").trim(); if (!title) { context.toasts.info("Give the todo a title first"); return; } const todos = await context.storage.get("todos") ?? []; await context.storage.set("todos", [ ...todos, { id: crypto.randomUUID(), title, done: false }, ]); } } ``` ## Component allowlist [#component-allowlist] | Component | Supported props | | --------------- | ---------------------------------------------------------------------------------------------------- | | `Column`, `Row` | `gap`, `align`, and children | | `Text` | `variant`, `tone`, and text children | | `TextInput` | `id`, `label`, `placeholder`, `value`, `maxLength`, `submit`, `disabled` | | `TextArea` | TextInput props plus `rows` | | `Select` | `id`, `label`, `placeholder`, `value`, `options`, `action`, `disabled` | | `List` | `id`, `action`, `selectedId`, `searchPlaceholder`, `filtering`, `isLoading`, and `ListItem` children | | `ListItem` | `id`, `title`, `subtitle`, `keywords`, `status`, `payload`, `action`, `disabled` | | `EmptyState` | `title`, `description` | | `Progress` | `label`, `value` from 0 through 100 | | `Button` | `id`, `action`, `payload`, `variant`, `disabled`, and text children | | `Checkbox` | `id`, `action`, `payload`, `checked`, `disabled`, and text children | | `Divider` | No props | | `Spacer` | `size` | Gaps and spacer sizes use `none`, `small`, `medium`, or `large`. Alignment uses `start`, `center`, `end`, or `stretch`. Text variants are `heading`, `body`, `caption`, and `code`; tones are `default`, `muted`, `success`, and `danger`. Button variants are `secondary`, `primary`, and `danger`. Styling is semantic and theme-aware. Arbitrary CSS, GPUI properties, assets, callbacks, and colors are rejected. Read [Security and limits](/docs/using-termy/plugins/security-limits) for document and runtime boundaries. Paginate dynamic lists so large saved collections stay inside the node, child, and value-control limits. # Security and limits (/docs/using-termy/plugins/security-limits) Plugins are trusted Bun code, not sandboxed extensions. A handler can use async functions, `fetch`, `Bun.*`, Node-compatible standard-library APIs, files, subprocesses, network requests, and local relative TypeScript imports. Worker isolation protects Termy from many crashes and timeouts, but it does not protect your account from a malicious plugin. Read the source before installing. Manifest capabilities gate Termy-owned APIs, not operating-system access. A plugin needs `storage` for `context.storage` and `context.paths`, and `native-ui` for views and `view.open`, but those declarations do not restrict Bun, Node built-ins, subprocesses, files, or the network. ## Imports and source validation [#imports-and-source-validation] V1 supports local relative imports that remain inside the plugin directory, plus Bun and Node built-ins such as `bun` and `node:fs`. Package imports, out-of-root paths, and symlinks are rejected. Termy never installs packages automatically or runs plugin build hooks. GitHub installation downloads regular files without cloning the repository, running scripts, evaluating code, or installing dependencies. Code executes only when the plugin is loaded. ## Workers, timeouts, and processes [#workers-timeouts-and-processes] Termy uses one external Bun host and isolates each plugin in its own Worker. Lifecycle subscribers keep the host warm; eventless plugins restart it on demand after idle suspension. Commands, events, and native views use execution timeouts; a crash or timeout is contained to the failed Worker. Subprocesses started by plugin code can outlive that Worker, so the plugin must stop its own child processes when cancellation matters. If the shared host transport exits, Termy rebuilds it and reloads Workers on the next refresh without taking down the app. User cancellation aborts `context.signal`; long-running handlers should observe it and can report a bounded status message and percentage through `context.progress`. Persist durable state through `context.storage` or managed files. Module globals may reset when an eventless host sleeps. The default handler timeout is 10 seconds. A command or view may set `timeoutMs` from 100 to 30,000 milliseconds. ## Runtime limits [#runtime-limits] | Area | Limit | | ----------------- | --------------------------------------------------------------- | | Installed plugins | 32 | | Source tree | 4,096 files and 16 MiB total | | Definitions | 512 commands, 64 settings, and 32 views per plugin | | Command input | 16 inputs per command and 128 options per select or pick result | | Returned actions | 32 per invocation | | View params | 64 KiB JSON object | | Structured launch | 128 args and 4,096 characters per program/arg | Small persistent storage has separate limits documented under [Lifecycle and storage](/docs/using-termy/plugins/lifecycle-storage#store-json-values). ## Native UI boundaries [#native-ui-boundaries] JSX is data, not renderer access. Termy accepts only the documented [native components](/docs/using-termy/plugins/native-ui#component-allowlist), semantic props, and string or boolean control values. It rejects unknown nodes and props before GPUI renders anything. A plugin may declare at most 32 views. Each rendered document is limited to 256 nodes, 16 levels of nesting, 64 children per node, and 64 value-bearing controls. Text input values are limited to 4,096 characters. View params must be a JSON object and are limited to 64 KiB. ## Bundles and reloads [#bundles-and-reloads] Termy checks plugin content whenever the command palette opens. It fingerprints the manifest and source tree, then bundles each changed plugin once with `Bun.build({ target: "bun" })` into `plugins/.termy-cache/bundles//.mjs`. Local relative imports are included in both the content hash and bundle; unchanged plugins reuse their cached module. Saving a manifest or source file replaces the changed Worker and refreshes its commands without restarting Termy. Disabling a plugin keeps its managed files and storage but removes its commands on the next refresh. Uninstalling removes the managed copy, storage, and cache while leaving the original source folder untouched. Actions and native-view updates from requests already running when a plugin changes, is disabled, or is removed are rejected. Termy resolves Bun from `TERMY_BUN_PATH`, beside the Termy executable, `PATH`, Bun's default user install, Homebrew, and `/usr/local/bin`. Set `TERMY_BUN_PATH` to an absolute executable path when automatic resolution misses it. V1 supports command-palette commands, lifecycle events, plugin keybindings, and allowlisted native JSX views. It does not support arbitrary React, HTML, or GPUI, build hooks, package imports, or automatic package installation. # Program status (/docs/using-termy/program-status) Termy supports the [OSC 7501 Program Status Protocol](https://www.superlogical.com/rex/docs/build/program-status). Programs that emit these reports show a status badge in their tab: a dot while working, a question mark when waiting for you (such as approval, an answer, or sign-in), a checkmark when done, or an exclamation mark on failure. The **Progress Indicators** setting (`progress_indicator_enabled = true`) controls these badges. Blocked tasks take priority, followed by failures, completed tasks, and work in progress. Idle tasks have no badge. Messages, titles, and percentages are not drawn, and progress bars come only from OSC 9;4. These reports do not trigger system notifications or sounds. ## Report status from a script [#report-status-from-a-script] ```sh status() { printf '\033]7501;state=%s:msg=%s\033\\' "$1" "$(printf '%s' "$2" | base64 | tr -d '\n')" } status working "Running checks" # Run your command here, then report its result: status done "Checks passed" # Remove the status when it is no longer needed: status clear "" ``` A new shell prompt clears working and blocked statuses. Completed and failed statuses remain until replaced or cleared. Programs can use slash-separated `id` values for multiple tasks, such as `build/tests`; clearing a parent also clears its children. Full terminal reset clears every status. Status is retained when reattaching to Termy's native multiplexer. External multiplexers must forward OSC 7501 for reports and support detection to work. # Search (/docs/using-termy/search) | Action | Default | | ------------------------------ | ------------------- | | `open_search` | `secondary-f` | | `search_next` | `secondary-g` | | `search_previous` | `secondary-shift-g` | | `close_search` | bind manually | | `toggle_search_case_sensitive` | bind manually | | `toggle_search_regex` | bind manually | Deeper history: raise `scrollback_history` in [Terminal behavior](/docs/customize/terminal-behavior). # Splits and panes (/docs/using-termy/splits-panes) ## Split [#split] | Action | Default | | ----------------------- | ------------------- | | `split_pane_vertical` | `secondary-d` | | `split_pane_horizontal` | `secondary-shift-d` | | `close_pane` | bind manually | ## Focus and resize [#focus-and-resize] | Action | Default | | ------------------- | ----------------------------- | | `focus_pane_next` | `secondary-o` | | `focus_pane_` | `secondary-alt-` | | `resize_pane_` | `secondary-alt-shift-` | | `toggle_pane_zoom` | `secondary-enter` | ## Inactive panes [#inactive-panes] `pane_focus_effect`: `off` · `soft_spotlight` (default) · `cinematic` · `minimal` Intensity: `pane_focus_strength` (`0.0` – `2.0`) # SSH hosts (/docs/using-termy/ssh-hosts) Open **Settings → SSH** to add, edit, or delete a host. Each host keeps a display name, hostname, port, username, and either an SSH key path or password authentication. After saving a host, open the new-tab menu and choose it under **SSH Hosts**. Termy launches your system OpenSSH client in a normal terminal tab, so connection and authentication messages remain visible there. ## Credentials [#credentials] Leave the password or private-key passphrase blank to enter it interactively when OpenSSH asks. If you enter one in Settings, Termy stores it in the system Keychain and never in `ssh_hosts.json`, process arguments, logs, or the shell command line. Termy stores only an identity-file path, not a copy of your private key. ## Host verification [#host-verification] OpenSSH still manages host fingerprints and `known_hosts`. Termy does not disable strict host-key checks. Review the fingerprint before accepting a new host, and treat a changed-host-key warning as a security error. # Tabs (/docs/using-termy/tabs) | Action | Default | | -------------------------------------- | ----------------------------- | | `new_tab` | `secondary-t` | | `close_pane_or_tab` | `secondary-w` | | `switch_to_tab_1` … `switch_to_tab_9` | `secondary-1` … `secondary-9` | | `switch_tab_left` / `switch_tab_right` | bind manually | | `move_tab_left` / `move_tab_right` | bind manually | | `rename_tab` | bind manually | New tabs inherit the active pane's working directory, including while an app such as lazygit is running. Native tabs use the shell-reported directory or a fresh lookup of the shell process; tmux tabs query the selected pane directly. An explicitly requested directory takes precedence. Tab titles: [Tabs and titlebar](/docs/customize/tabs-titlebar). ## Open a tab from your file manager [#open-a-tab-from-your-file-manager] On macOS, go to **Settings → General → System integration → Default terminal** and click **Set as default**. This sets Termy as the system's terminal handler. Run the installed `Termy.app` to use this option. Apps with their own external terminal preference must be configured separately. Finder's **Open new Termy tab here** service remains available; opening a file in Termy opens its containing folder without running the file. After Termy is installed, file managers expose **Open new Termy tab here**: On macOS, launch Termy once after updating to refresh the Finder service. The action finds the installed app by its identifier, so it continues to work after the app bundle is replaced or moved. | Platform | Where it appears | | -------- | ---------------------------------------------------------------------------- | | Windows | Explorer folder, folder background, and drive context menus | | macOS | Finder Services, and Open With on a folder | | Linux | Nautilus/Caja Scripts, Nemo actions, KDE/Dolphin folder menus, and Open With | If Termy is already running, the action adds a tab in that folder. You can also request a tab from a shell: ```bash termy --new-tab --working-directory /path/to/project ``` ## Open another window [#open-another-window] On Linux, each launch of `termy` opens a separate window. This also applies to `termy /path/to/project` and `termy --working-directory /path/to/project`. Your window manager can tile and move each window independently. Set your Hyprland terminal command to `termy`; no wrapper is needed. Use `--new-window` to explicitly open a window on any platform: ```bash termy --new-window termy --new-window --working-directory /path/to/project ``` Closing a window or exiting its last shell leaves the other windows running. Additional windows start fresh. The first window owns the saved native session; extra windows do not overwrite it. With tmux enabled, extra windows use separate, temporary managed sessions. Explicitly attaching to a named tmux session still shares that session. On macOS and Windows, launches without either flag keep their existing behavior. ## Move tabs between windows [#move-tabs-between-windows] Drag a tab outside its window and release to open it in a new window. Drop it onto another Termy terminal window to move it there. The tab keeps its running shells, split panes, and scrollback. Moving the last tab closes the empty window. Press Escape before releasing to cancel a drag. If the tab bar is hidden for a single tab, turn off `auto_hide_tabbar` to make that tab available to drag. Windows must use the same terminal runtime. With tmux, they must also use the same server and different sessions. # Tasks and layouts (/docs/using-termy/tasks-layouts) Pattern: `task..` | Field | Required | Description | | ------------- | -------- | ------------------------------------------ | | `command` | yes | Shell command for a new tab | | `layout` | no | Only show when this saved layout is active | | `working_dir` | no | Working directory | ```txt task.build.command = cargo build task.build.working_dir = crates/cli task.dev_server.layout = dashboard task.dev_server.command = cargo run ``` Run from the palette (**Run Task**) or bind `run_task` to a key. # Tmux sessions (/docs/using-termy/tmux-sessions) For background sessions without installing tmux, use the [built-in multiplexer](/docs/using-termy/multiplexer). It takes precedence when `multiplexer_enabled = true`. Disable it and restart Termy before switching to tmux. Requires `tmux` ≥ 3.3 on `PATH` (macOS / Linux), or reachable through a command prefix (Windows / remote). ```txt tmux_enabled = true tmux_persistence = true tmux_exclusive = false tmux_binary = tmux tmux_command_prefix = none tmux_show_active_pane_border = false ``` | Key | Effect | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tmux_enabled` | New tabs use tmux instead of a raw PTY | | `tmux_persistence` | Sessions survive app restarts | | `tmux_exclusive` | Stay in tmux control mode; restart it instead of falling back to a classic terminal when control mode exits (for example after Ctrl+D closes the last pane) | | `tmux_binary` | Path or name of the tmux binary | | `tmux_command_prefix` | Command that tmux runs through, e.g. `wsl.exe -e` or `ssh myhost` | Open **Manage tmux Sessions** from the command palette, or bind `manage_tmux_sessions`. Termy creates one session per window (`termy-`). Splits map to real tmux panes. ## Windows (WSL) and remote hosts [#windows-wsl-and-remote-hosts] tmux cannot run natively on Windows, but Termy can drive a tmux that lives inside WSL or on a remote machine. Set `tmux_command_prefix` to the command that reaches it: ```txt # tmux inside WSL (default distro) tmux_enabled = true tmux_command_prefix = wsl.exe -e # tmux on a remote host over SSH (needs key-based auth) tmux_enabled = true tmux_command_prefix = ssh myhost ``` Termy then launches `wsl.exe -e tmux ...` (or `ssh myhost tmux ...`) for the control channel and all session management commands. The prefix is split on whitespace, so it cannot contain arguments with embedded spaces. On Windows, tmux mode stays off until a prefix is configured. New sessions start in the default directory of the WSL distro or remote shell — the host working directory is not forwarded. ## Troubleshooting [#troubleshooting] | Symptom | Fix | | ---------------------------------------- | ---------------------------------------------------------------------- | | Blank tabs | Set `tmux_binary` to an absolute path | | `protocol version mismatch` | Point `tmux_binary` at the tmux version you intend to use | | Sessions lost on quit | Set `tmux_persistence = true` | | Control mode exits to a classic terminal | Set `tmux_exclusive = true` to restart control mode instead | | Double active-pane border | Set `tmux_show_active_pane_border = false` | | tmux mode ignored on Windows | Set `tmux_command_prefix` (e.g. `wsl.exe -e`) | | `ssh` prefix hangs at startup | Use key-based auth; the control channel cannot answer password prompts |