diff --git a/AGENTS.md b/AGENTS.md index 0658336..d00c180 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,107 +2,150 @@ ## Project -`term` — a Mac-native terminal emulator in Rust. Runs zsh inside a PTY, parses VT/ANSI escape codes, and renders to a GPU-backed framebuffer using the Catppuccin Mocha color theme. +`term` — a Mac-native terminal emulator in Rust. Runs zsh inside a PTY, parses VT/ANSI escape codes, and renders to a GPU-accelerated Metal surface (wgpu) using the Catppuccin Mocha color theme. + +Four binaries are built by `cargo build`: +- `term` — the terminal emulator +- `tcat` — syntax-highlighted file viewer +- `tdiff` — syntax-highlighted diff pager +- `tjson` — streaming JSON prettifier (filter or PTY mode) ## Build ```sh cargo build # debug build cargo build --release # release build (use for performance work) +cargo test # run all tests ``` Dependencies are fetched automatically by cargo. No other setup required. ## Testing -There is no test suite yet. After any change: +After any change: 1. `cargo build` must succeed with zero errors and zero warnings. -2. `cargo run` (or `cargo run --release`) must open a window and display a working zsh prompt. -3. Manually verify: type commands, run `vim`, run `htop`, check colors with `echo -e "\e[31mred\e[0m"`. +2. `cargo test` must pass. +3. `cargo run --release` must open a window and display a working zsh prompt. +4. Manually verify: type commands, run `vim`, run `htop`, check colors with `echo -e "\e[31mred\e[0m"`. + +Unit tests live inline in each source file. Run a specific module with e.g. `cargo test --lib terminal`. ## Code map ``` src/ - config.rs Color constants (Catppuccin Mocha), ansi_256_color(), FONT_SIZE_PT - terminal.rs VT state machine (vte::Perform impl), Cell/Attrs types, Terminal wrapper - renderer.rs fontdue glyph cache, frame rendering to &mut [u32], narrow cursor bar - main.rs winit event loop, softbuffer surface, PTY spawn, keyboard routing, - cursor blink (about_to_wait), ZDOTDIR shell injection - bin/tcat.rs standalone syntax-highlighting cat (syntect, base16-ocean.dark theme) + config.rs Color constants (Catppuccin Mocha), ansi_256_color(), FONT_SIZE_PT + terminal.rs VT state machine (vte::Perform impl), Cell/Attrs types, TerminalState, + Terminal wrapper, scrollback (VecDeque), alternate screen buffer + renderer.rs wgpu pipelines (rect + glyph), 1024×1024 glyph atlas, block-char rendering, + tab bar, URL underlines, selection highlight, cursor bar + main.rs winit event loop, wgpu surface init, PTY spawn per tab, keyboard routing, + tabs (Vec), clipboard (pbcopy/pbpaste/OSC52), URL detection, + ghost text (zsh_history), ZDOTDIR shell injection, mouse handling + bin/tcat.rs standalone syntax-highlighting cat (syntect, base16-ocean.dark) + bin/tdiff.rs unified diff processor; used as GIT_PAGER + bin/tjson.rs JSON prettifier; filter mode (stdin) and PTY mode (spawns command) +assets/ + JetBrainsMono-Regular.ttf bundled font (loaded via include_bytes!) ``` ## Key types | Type | File | Purpose | |------|------|---------| -| `Color` | config.rs | RGB triple; `to_u32()` and `blend()` helpers | +| `Color` | config.rs | RGB triple; theme constants | | `Attrs` | terminal.rs | Per-cell style: fg/bg colors + bold/italic/underline/inverse | | `Cell` | terminal.rs | Single terminal cell: `char` + `Attrs` | -| `TerminalState` | terminal.rs | Grid (`Vec>`), cursor, scroll region, VT performer | +| `TerminalState` | terminal.rs | Grid, scrollback, cursor, scroll region, alt screen, VT performer | | `Terminal` | terminal.rs | Wraps `vte::Parser` + `TerminalState`; exposes `process(&[u8])` | -| `Renderer` | renderer.rs | fontdue font, glyph cache, `render()` blit | -| `App` | main.rs | winit `ApplicationHandler`, owns PTY master + writer + Terminal + Renderer | -| `AppEvent` | main.rs | `PtyData(Vec)` / `PtyExit` sent across threads via `EventLoopProxy` | +| `Renderer` | renderer.rs | wgpu device/queue, two render pipelines, glyph atlas, `render()` | +| `Tab` | main.rs | PTY pair + `Terminal` instance; one per tab | +| `App` | main.rs | winit `ApplicationHandler`; owns Vec, Renderer, selection, ghost text | +| `AppEvent` | main.rs | `PtyData { tab_id, data }` / `PtyExit { tab_id }` sent via `EventLoopProxy` | ## Threading model - **Main thread**: winit event loop, rendering, keyboard input, PTY writes. -- **Reader thread**: blocking `Read` on PTY master; sends `AppEvent::PtyData` via `EventLoopProxy`. Never touches the terminal grid directly. +- **Reader thread** (one per tab): blocking `Read` on PTY master; sends `AppEvent::PtyData` via `EventLoopProxy`. Never touches the terminal grid directly. Do not add shared mutable state across threads. Route all PTY output through `AppEvent`. +## Renderer architecture + +Two wgpu pipelines, both using instanced drawing: + +**Rect pipeline** (`RectInst { pos, sz, color }`) +- Used for: cell backgrounds, selection highlight, cursor bar, block characters, URL underlines, tab bar backgrounds. + +**Glyph pipeline** (`GlyphInst { pos, sz, uv_pos, uv_sz, fg }`) +- Glyph bitmaps are rasterized by fontdue and packed into a 1024×1024 R8 atlas texture. +- Cache key is `char` only (bold shares the same entry). +- Atlas evicts and restarts when full (rare; terminals use a small glyph set). + +Block/Braille characters (U+2580–U+259F and U+2800–U+28FF) are rendered as fill rects, not atlas lookups. + +Each `render()` call fills `Vec` and `Vec`, uploads them to GPU vertex buffers (grown on demand), and issues two draw calls. + ## Adding escape sequences Implement in `TerminalState`'s `Perform` methods in `src/terminal.rs`: - Printable characters → `fn print` - C0 controls (BS, LF, CR, TAB) → `fn execute` -- CSI sequences → `fn csi_dispatch` — dispatch on `(intermediates.first().copied().unwrap_or(0), action)` -- OSC sequences → `fn osc_dispatch` +- CSI sequences → `fn csi_dispatch` — dispatch on `(intermediates.first().copied().unwrap_or(0), action as u8)` +- OSC sequences → `fn osc_dispatch` — params are `&[&[u8]]` - ESC sequences → `fn esc_dispatch` -To send a response back to the PTY (e.g. cursor position report), push to `self.pending_responses`; `main.rs` drains and writes them after each `process()` call. +To send a response back to the PTY (e.g. cursor position report, OSC 52 reply), push to `self.pending_responses`; `main.rs` drains and writes them after each `process()` call. -## Changing the color theme +## Tabs -All colors live in `src/config.rs`: -- `DEFAULT_FG` / `DEFAULT_BG` — base foreground/background -- `CURSOR_COLOR` — cursor block color -- `ANSI_COLORS: [Color; 16]` — standard 16-color palette -- `ansi_256_color(index)` — 256-color + grayscale ramp +`App` owns `Vec` and `active_tab: usize`. Each `Tab` has its own PTY reader thread. Opening a tab spawns a new zsh process with the same `setup_shell_env` call. Closing a tab drops the PTY writer (sending EOF to zsh) and removes the entry from the vec. -## Changing font size or font path +## Scrollback -- Size: `FONT_SIZE_PT` in `src/config.rs` (points; scaled by DPI in `Renderer::new`). -- Font: `Renderer::load_font()` in `src/renderer.rs` tries a list of paths in order. +`TerminalState.scrollback: VecDeque>`, capped at `SCROLLBACK_MAX = 10_000`. Lines are pushed there when they scroll off the top of the live grid (normal screen only; alternate screen is excluded). `viewport_offset` (0 = live) shifts `visual_cell()` lookups into scrollback. The renderer and selection code both use `visual_cell()` so they automatically reflect the scrolled view. -## Style rules +## Shell environment -- No `unwrap()` in hot paths (the render loop). Prefer `if let` / `match`. -- Keep `TerminalState` free of I/O. All PTY writes go through `pending_responses` or `App::pty_write`. -- The glyph cache (`HashMap<(char, bool), Glyph>`) is append-only. Do not add eviction without profiling first. -- Avoid allocations in `Renderer::render`. The per-frame path should only touch the existing cache and write into the provided `&mut [u32]`. +`setup_shell_env(&mut CommandBuilder)` in `main.rs`: +1. Creates a temp `ZDOTDIR` (`/tmp/term_zsh_{pid}/`) with `.zshenv` and `.zshrc`. +2. `.zshrc` sources the user's real config, then defines `cat` → `tcat`, `json` → `tjson "$@"`, sets `GIT_PAGER=tdiff`, installs ZLE + chpwd + precmd/preexec hooks. +3. Sets `TERM=xterm-256color`, `COLORTERM=truecolor`, `TERM_PROGRAM=ghostty`. +4. Sets `LANG=en_US.UTF-8` and `LC_ALL=en_US.UTF-8` if not already in the environment. This is required when `term` is launched from Finder/launchd (no inherited locale); without it, macOS defaults to Mac Roman and zsh re-encodes UTF-8 bytes as Mac Roman–decoded Unicode, producing garbled multi-byte characters. -## Cursor blink +## `tjson` PTY mode -- `App` fields: `cursor_visible: bool`, `last_blink: Instant` -- `about_to_wait` toggles `cursor_visible` every 530 ms and calls `window.request_redraw()`; sets `ControlFlow::WaitUntil(last_blink + 530ms)` so the event loop wakes at the right time -- `reset_blink()` (called on keypress and PTY data) forces the cursor visible and resets the timer — cursor stays solid while active -- `renderer.render(..., cursor_visible)` receives the current state; when `false`, the cursor bar is simply not drawn +`tjson` (and the `json` shell alias) accepts an optional command as arguments: + +```sh +json pnpm dev # PTY mode: spawns pnpm dev in a PTY +pnpm dev | json # filter mode: reads from stdin +``` + +PTY mode is necessary for commands like Next.js dev servers that detect whether stdout is a terminal and suppress their formatted startup output if it isn't. `portable_pty` is reused here (same dependency as the main terminal). + +`run_pty` inherits `COLUMNS`/`LINES` from the environment for PTY sizing (defaults to 220×50). The child inherits the full shell environment, including `LANG`. + +## Changing the color theme + +All colors live in `src/config.rs`: +- `DEFAULT_FG` / `DEFAULT_BG` — base foreground/background +- `CURSOR_COLOR` — cursor bar color +- `ANSI_COLORS: [Color; 16]` — standard 16-color palette +- `ansi_256_color(index)` — 256-color + grayscale ramp -## Syntax highlighting (`tcat`) +## Changing font size -- `src/bin/tcat.rs` is a separate binary compiled by the same `cargo build` -- `setup_shell_env(&mut cmd)` in `main()` creates a temp ZDOTDIR and writes `.zshenv` + `.zshrc` that source the user's real config then define `function cat()` pointing at `tcat` -- The `tcat` path is `std::env::current_exe().parent().join("tcat")` — works for both debug and release builds since both binaries land in the same directory -- To change the highlight theme, edit the `ts.themes["base16-ocean.dark"]` line in `src/bin/tcat.rs`; available defaults: `base16-ocean.dark`, `base16-ocean.light`, `base16-eighties.dark`, `base16-mocha.dark`, `Solarized (dark)`, `InspiredGitHub` -- `as_24_bit_terminal_escaped(&ranges, false)` — the `false` suppresses background color escapes so the terminal's own background shows through +Edit `FONT_SIZE_PT` in `src/config.rs`. The renderer scales by the window's DPI factor. ## Common pitfalls -- **Scroll region**: `scroll_up`/`scroll_down` in `TerminalState` operate on the live `grid` vec using index arithmetic. After `remove(scroll_top)`, insert at `scroll_bottom` (not `scroll_bottom - 1`) — the removal already shifted indices. -- **Glyph baseline**: `gy0 = py + baseline - (ymin + height)`. `ymin` in fontdue is the bottom of the bounding box relative to baseline (positive = above baseline). Getting this wrong causes glyphs to float or clip. +- **Scroll region**: `scroll_up`/`scroll_down` operate on the live `grid` vec using index arithmetic. After `remove(scroll_top)`, insert at `scroll_bottom` (not `scroll_bottom - 1`) — the removal already shifted indices. +- **Glyph baseline**: `gy0 = py + baseline - (ymin + height)`. `ymin` in fontdue is the bottom of the bounding box relative to baseline. Getting this wrong causes glyphs to float or clip. - **DPI**: `Renderer` stores physical-pixel sizes. Window resize events from winit give physical pixels. `LogicalSize` is only used for the initial window creation. -- **PTY slave lifetime**: drop `pair.slave` after `spawn_command` — keeping it open can cause the reader thread to never see EOF when zsh exits. +- **PTY slave lifetime**: drop `pair.slave` after `spawn_command` — keeping it open prevents the reader thread from ever seeing EOF when the shell exits. +- **Alternate screen**: `alt_grid` and `alt_saved_cursor` are separate from the normal grid. Operations on the live grid (scrollback push, viewport snap) must check `alt_screen` and skip when true. +- **UTF-8 in tjson**: `BufReader::lines()` requires valid UTF-8. If the PTY output contains raw C1 bytes (0x80–0x9F) that aren't part of a multi-byte sequence, `lines()` will error and the loop will break. Be aware of this if expanding `run_pty`. +- **GPU buffer growth**: `rect_buf` and `glyph_buf` are grown on demand by reallocating. The capacity is tracked in `rect_buf_cap` / `glyph_buf_cap`. Don't assume a fixed size. diff --git a/CLAUDE.md b/CLAUDE.md index b77c8df..97d603e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # term -Mac-native terminal emulator written in Rust. Runs zsh, renders via a CPU framebuffer backed by Metal (softbuffer), rasterizes glyphs with fontdue. Catppuccin Mocha color theme. +Mac-native terminal emulator written in Rust. Runs zsh, renders via a GPU-accelerated Metal pipeline (wgpu), rasterizes glyphs with fontdue. Catppuccin Mocha color theme. ## Build & run @@ -17,9 +17,9 @@ No external tools, no scripts. The standard cargo workflow is the whole story. | File | Responsibility | |------|---------------| | `src/config.rs` | Color theme (Catppuccin Mocha), `ansi_256_color`, font size constant | -| `src/terminal.rs` | VT/ANSI state machine (`vte::Perform`), terminal grid, `Terminal` wrapper | -| `src/renderer.rs` | fontdue glyph cache, per-frame blit to `u32` framebuffer | -| `src/main.rs` | winit 0.30 event loop, softbuffer surface, PTY lifecycle, keyboard input | +| `src/terminal.rs` | VT/ANSI state machine (`vte::Perform`), terminal grid, scrollback, `Terminal` wrapper | +| `src/renderer.rs` | wgpu pipelines, glyph atlas, per-frame GPU render | +| `src/main.rs` | winit 0.30 event loop, wgpu surface, PTY lifecycle, tabs, keyboard input, clipboard, URL detection, ghost text | ### Data flow @@ -29,7 +29,7 @@ zsh (PTY slave) └─ EventLoopProxy::send_event(AppEvent::PtyData) └─ terminal.process(bytes) ← vte parser └─ window.request_redraw() - └─ renderer.render() → softbuffer + └─ renderer.render() → wgpu surface ``` Keyboard input goes the other direction: `winit KeyboardInput → handle_key → pty_writer`. @@ -38,64 +38,152 @@ Keyboard input goes the other direction: `winit KeyboardInput → handle_key → `TerminalState` owns the grid (`Vec>`) and implements `vte::Perform`. Supported: - SGR colors: standard ANSI 8/16, 256-color (`38;5;n`), true-color (`38;2;r;g;b`) -- Cursor movement: CUP, CUU/D/F/B, CHA, VPA, home/end +- Cursor movement: CUP, CUU/D/F/B, CHA, VPA, home/end, cursor save/restore (ESC 7/8 and CSI s/u) - Erase: ED (0/1/2/3), EL (0/1/2), ECH - Scroll region: DECSTBM (`r`), scroll up/down (SU/SD, IL/DL) -- Insert/delete chars: ICH (`@`), DCH (`P`) -- Cursor save/restore: ESC 7/8 and CSI s/u +- Insert/delete: ICH (`@`), DCH (`P`), IL (`L`), DL (`M`) +- Device attributes: `ESC[c` → `\x1b[?1;2c` - Device status report: `ESC[6n` → cursor position reply - OSC 0/2: window title -- Private modes (`?h`/`?l`): accepted but mostly no-op +- OSC 7: working directory (`file://hostname/path`) → used for tab titles +- OSC 52: clipboard read/write (base64) +- OSC 9001: ZLE buffer + cursor position (shell integration) +- Private modes: `?47h`/`?1047h`/`?1049h` (alternate screen), `?2004h` (bracketed paste) + +### Scrollback + +`TerminalState.scrollback` is a `VecDeque>` capped at `SCROLLBACK_MAX = 10_000` lines. Lines pushed off the top of the live grid are appended there. Alternate-screen content is not captured. `viewport_offset` (0 = live view) controls what `visual_cell()` returns for rendering and selection. + +### Alternate screen buffer + +`?1049h` saves the cursor and switches to `alt_grid`. `?1049l` restores. `?47h`/`?1047h` switch without cursor save. vim, htop, etc. work via this mechanism. ### Rendering -`Renderer::render` is called every frame: -1. Fill framebuffer with `DEFAULT_BG` -2. For each visible cell: fill cell background, blit cached glyph with alpha compositing -3. Cursor drawn as a solid block (CURSOR_COLOR bg, DEFAULT_BG fg) at the cursor cell +`Renderer` maintains two wgpu render pipelines: +- **Rect pipeline** — fills solid color rectangles (backgrounds, block chars, cursor, selection, URL underlines) +- **Glyph pipeline** — draws text from a 1024×1024 R8 atlas texture using instanced quads + +Each frame: collect `RectInst` and `GlyphInst` vecs from the terminal grid, upload to GPU buffers, issue two draw calls. Block/Braille characters (U+2580–U+259F and Braille range) are decomposed into fill rects rather than looked up in the font. + +Glyph cache key is `char` only (bold uses the same rasterization). Cache is never evicted unless the atlas overflows, at which point it is fully cleared and rebuilt. + +### Cursor + +A narrow 2-physical-pixel vertical bar drawn on top of the current cell after the full cell pass. Color: `CURSOR_COLOR`. Blinks at ~530 ms on/off via `about_to_wait` + `ControlFlow::WaitUntil`. Blink resets (cursor always shown) on keypress or PTY output. + +## Syntax highlighting (`tcat`, `tdiff`, `tjson`) + +### `tcat` + +`src/bin/tcat.rs` reads a file, applies 24-bit true-color ANSI syntax highlighting via `syntect` (theme: `base16-ocean.dark`), and prints to stdout with a header and line numbers. + +```sh +tcat file.rs # whole file +tcat file.rs:40-70 # line range +tcat file.rs:42 # single line +``` + +### `tdiff` + +`src/bin/tdiff.rs` reads a unified diff from stdin, applies syntax highlighting to the code content, and renders added/removed lines with green/red background tints (Catppuccin Mocha palette). Used as `GIT_PAGER` so `git diff`, `git show`, `git log -p`, etc. automatically render with syntax colors. + +### `tjson` + +`src/bin/tjson.rs` is a streaming JSON prettifier with two modes: -Glyph cache key is `(char, bold)`. Cache is never evicted (terminals use a small glyph set). +**Filter mode** (stdin pipe): +```sh +some-cmd | json +``` -## Syntax highlighting (`tcat` and `tdiff`) +**PTY mode** (recommended for servers): +```sh +json pnpm dev +json node server.js +``` -`src/bin/tcat.rs` reads a file, applies 24-bit true-color ANSI syntax highlighting via `syntect` (theme: `base16-ocean.dark`), and prints to stdout. +PTY mode spawns the command inside a pseudo-terminal so the child process sees a real terminal on stdout (enabling its full formatted output), then filters the combined output line by line. Lines starting with `{` or `[` that parse as valid JSON are pretty-printed with syntax highlighting; all other lines pass through unchanged. -`src/bin/tdiff.rs` reads a unified diff from stdin, applies syntax highlighting to the code content, and renders added/removed lines with green/red background tints (Catppuccin Mocha palette). It is used as `GIT_PAGER` so `git diff`, `git show`, `git log -p`, etc. automatically render with syntax colors. +### Shell init On startup, `term` writes a ZDOTDIR-based zsh init that: -1. Sources the user's real `~/.zshenv` and `~/.zshrc` +1. Sources the user's real `~/.zshenv`, `~/.zprofile`, and `~/.zshrc` 2. Defines `function cat()` that calls `tcat` for single-file invocations -3. Sets `GIT_PAGER=tdiff` and `GIT_COLOR_UI=never` so git hands raw diff to `tdiff` +3. Defines `function json()` that calls `tjson "$@"` (passes all args) +4. Sets `GIT_PAGER=tdiff` and `GIT_COLOR_UI=never` so git hands raw diff to `tdiff` +5. Installs ZLE hooks (`add-zle-hook-widget`) for live buffer reporting (OSC 9001) +6. Installs `chpwd`, `precmd`, and `preexec` hooks for dynamic tab titles (OSC 0) and working directory (OSC 7) +7. Sets `LANG=en_US.UTF-8` / `LC_ALL=en_US.UTF-8` if not already set (prevents Mac Roman re-encoding of UTF-8 when `term` is launched without a locale from Finder/launchd) -Both `tcat` and `tdiff` live next to the `term` binary in the build output dir. `cargo build` builds all three. +All four binaries (`term`, `tcat`, `tdiff`, `tjson`) live next to each other in the build output directory. `cargo build` builds all four. -## Cursor +## Tabs -A narrow 2-physical-pixel vertical bar drawn on top of the current cell after the full cell pass. Color: `CURSOR_COLOR`. Blinks at ~530 ms on/off via `about_to_wait` + `ControlFlow::WaitUntil`. Blink resets (cursor always shown) on keypress or PTY output. +Multiple tabs are supported. Each tab owns its own `Terminal` and PTY pair. + +| Key | Action | +|-----|--------| +| Cmd+T | New tab | +| Cmd+W | Close tab | +| Cmd+[ / Cmd+] | Previous / next tab | +| Cmd+1…9 | Jump to tab N | + +Tab titles update dynamically: CWD at the prompt, command name while a command is running (via OSC 0 from ZLE hooks). + +Tabs can be reordered by left-click-drag on the tab bar. + +## Clipboard + +- **Copy** (Cmd+C): extracts selected text from the terminal grid, strips `tcat` line-number gutter characters, and pipes to `pbcopy`. +- **Paste** (Cmd+V): checks clipboard for a PNG image first (saves to a temp file and writes the path to the PTY); falls back to text via `pbpaste`. Wraps in `\x1b[200~`/`\x1b[201~` when bracketed paste mode is active. +- **OSC 52**: applications can query or set the clipboard via base64-encoded escape sequences. + +## URL detection + +URLs (`http://` and `https://`) in the visible terminal rows are detected each frame. When the Cmd key is held: +- Detected URLs are underlined. +- The cursor changes to a pointer over a URL. +- Cmd+click launches the URL with `open`. + +Trailing punctuation (`.,:;)`) is stripped from detected URLs. + +## Ghost text (inline history) + +When typing at an empty-or-non-empty prompt with the cursor at the end, `term` scans `~/.zsh_history` for the most recent command with the current buffer as a prefix and renders the remainder in dim gray as ghost text. Accept with Cmd+Right or →; any other key clears it. ## Crate versions (pinned in Cargo.lock) - `winit 0.30` — windowing, `ApplicationHandler` API -- `softbuffer 0.4` — Metal-backed CPU framebuffer on macOS +- `wgpu 0.20` — GPU render pipeline (Metal backend on macOS) +- `pollster 0.3` — block-on for wgpu async init +- `bytemuck 1` — safe casting for GPU instance data - `vte 0.13` — VT/ANSI parser - `portable-pty 0.8` — PTY open + zsh spawn - `fontdue 0.8` — pure-Rust glyph rasterizer -- `syntect 5` — syntax highlighting for `tcat` (features: `default-syntaxes`, `default-themes`, `parsing`, `regex-fancy`) +- `syntect 5` — syntax highlighting (features: `default-syntaxes`, `default-themes`, `parsing`, `regex-fancy`) +- `serde_json 1` — JSON parsing for `tjson` +- `objc2 0.5` — macOS clipboard (PNG detection) ## Font -Loads the first available path at startup: -1. `/System/Library/Fonts/Menlo.ttc` (default macOS) -2. `/System/Library/Fonts/Monaco.ttf` -3. `/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf` +JetBrains Mono Regular is bundled in `assets/JetBrainsMono-Regular.ttf` and loaded at startup via `include_bytes!`. No system font is required. To change the font size, edit `FONT_SIZE_PT` in `src/config.rs`. + +## Testing + +```sh +cargo test # all tests +cargo test --lib # terminal + renderer unit tests only +``` -Panics at startup if none found. To change the font size, edit `FONT_SIZE_PT` in `src/config.rs`. +Tests live inline in `src/terminal.rs` (VT sequences, scrollback, SGR), `src/bin/tcat.rs` (header/range rendering), `src/bin/tdiff.rs` (diff parsing), and `src/bin/tjson.rs` (JSON detection, passthrough, ANSI output). ## Known gaps / future work -- No scrollback view (lines scroll off and are gone) -- No clipboard (Cmd+V is a no-op) -- No mouse support +- No mouse reporting protocols (programs can't receive click/drag events) +- No sixel or kitty image protocols +- No search in scrollback +- No split panes - No ligatures or double-width characters -- No alternate screen buffer (vim/htop work via scroll region but no true alt screen) -- Bold uses same font (no separate bold face loaded) +- No custom keybinding or theme config files +- Bold uses the same font face (no separate bold variant loaded) diff --git a/README.md b/README.md index c54e7f5..f0502a9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ term icon -A personal Mac terminal emulator built for terminal-based AI work. Written in Rust, GPU-accelerated via Metal, with just enough features to get the job done and nothing more. +A personal Mac terminal emulator built for terminal-based AI work. Written in Rust, GPU-accelerated via Metal (wgpu), with just enough features to get the job done and nothing more. ## Build @@ -11,15 +11,84 @@ cargo build --release cargo run --release ``` -Builds three binaries: `term`, `tcat`, and `tdiff`. No external tools or scripts needed. +Builds four binaries: `term`, `tcat`, `tdiff`, `tjson`. No external tools or scripts needed. ## Features -- **GPU-accelerated rendering** — Metal-backed framebuffer via softbuffer +- **GPU-accelerated rendering** — wgpu/Metal pipeline with batched instancing and a 1024×1024 glyph atlas +- **JetBrains Mono** — bundled font, no system font dependency - **Catppuccin Mocha** color theme throughout - **True-color support** — ANSI 8/16, 256-color, and 24-bit RGB -- **Syntax-highlighted `cat`** — `cat` is aliased to `tcat`, which highlights files using `syntect` -- **Syntax-highlighted diffs** — `tdiff` is set as `GIT_PAGER`, so `git diff`, `git show`, and `git log -p` all render with color -- **Blinking cursor** — narrow vertical bar, blinks at ~530 ms, resets on input +- **Multiple tabs** — Cmd+T/W to open/close, Cmd+[/] or Cmd+1–9 to navigate, drag to reorder +- **Scrollback** — 10,000-line buffer; scroll with mouse wheel, Cmd+Up/Down, Cmd+Home/End +- **Clipboard** — Cmd+C copies selection (text), Cmd+V pastes (text or image path); OSC 52 supported +- **URL detection** — hold Cmd to underline URLs; Cmd+click opens in browser +- **Inline history** — ghost-text completion from `~/.zsh_history`; accept with Cmd+Right or → +- **Alternate screen buffer** — vim, htop, etc. work correctly with `?1049h` +- **Block characters** — 40+ Unicode block/Braille chars rendered as precise fill rectangles +- **Syntax-highlighted `cat`** — `cat` is aliased to `tcat`, which highlights files via `syntect` +- **Syntax-highlighted diffs** — `tdiff` is set as `GIT_PAGER`, so `git diff`, `git show`, and `git log -p` all render with color and line-level highlights +- **JSON prettifier** — `json pnpm dev` (or any command) runs it in a PTY so it sees a real terminal, then pretty-prints any JSON log lines while passing everything else through +- **Shell integration** — ZLE hooks report the input buffer and cursor position live; `chpwd` reports the working directory for dynamic tab titles +- **Blinking cursor** — narrow 2px vertical bar, blinks at ~530 ms, resets on input - **zsh** with your real `~/.zshrc` and `~/.zshenv` sourced automatically -- **Standard VT/ANSI sequences** — cursor movement, erase, scroll regions, insert/delete chars, cursor save/restore + +## Keyboard shortcuts + +| Shortcut | Action | +|----------|--------| +| Cmd+T | New tab | +| Cmd+W | Close tab | +| Cmd+[ / Cmd+] | Previous / next tab | +| Cmd+1…9 | Jump to tab N | +| Cmd+C | Copy selection | +| Cmd+V | Paste | +| Cmd+Up / Cmd+Down | Scroll one page | +| Cmd+Home / Cmd+End | Scroll to top / bottom | +| Cmd+Left / Cmd+Right | Move to start / end of line | +| Cmd+Backspace | Kill line backward | +| Alt+Left / Alt+Right | Previous / next word | +| Alt+Backspace / Alt+Delete | Kill word backward / forward | +| Cmd+click | Open URL under cursor | + +## Utility tools + +### `tcat` — syntax-highlighted file viewer + +```sh +tcat src/main.rs # whole file +tcat src/main.rs:40-70 # lines 40–70 +tcat src/main.rs:42 # single line +``` + +Shows a header with file name, language, and directory. The `cat` alias in `term`'s shell uses `tcat` automatically for single-file invocations. + +### `tdiff` — syntax-highlighted diff + +```sh +git diff # uses tdiff automatically via GIT_PAGER +git show HEAD +git log -p +``` + +Highlights added/removed lines with green/red tints and syntax-colors the code content. + +### `tjson` — streaming JSON prettifier + +```sh +json pnpm dev # PTY mode: pnpm dev sees a real terminal +json node server.js # any command that emits JSON log lines +some-cmd | json # filter mode: reads stdin +``` + +Lines that parse as JSON objects or arrays are pretty-printed with syntax color. All other output passes through unchanged. PTY mode is preferred for servers (like Next.js) that suppress their startup output when stdout is not a terminal. + +## Known gaps + +- No mouse reporting protocols (programs can't receive click/drag events) +- No sixel or kitty image protocols +- No search in scrollback +- No split panes +- No custom keybinding config +- No ligatures or double-width characters +- Bold uses the same font face (no separate bold variant loaded) diff --git a/src/bin/features-list.md b/src/bin/features-list.md index 8b9274e..1dba6fb 100644 --- a/src/bin/features-list.md +++ b/src/bin/features-list.md @@ -1 +1,57 @@ -- nice drag ui \ No newline at end of file +# Features + +## Terminal emulator (`term`) + +- GPU-accelerated Metal rendering via wgpu (rect + glyph instanced pipelines) +- JetBrains Mono bundled font; no system font dependency +- Catppuccin Mocha color theme +- True-color ANSI (8/16, 256-color, 24-bit RGB) +- 10,000-line scrollback buffer (mouse wheel, Cmd+Up/Down/Home/End) +- Multiple tabs (Cmd+T/W, Cmd+[/], Cmd+1–9, drag to reorder) +- Dynamic tab titles (CWD at prompt, command name while running) +- Clipboard: Cmd+C copy (text), Cmd+V paste (text or image path), OSC 52 +- URL detection with Cmd+underline and Cmd+click to open +- Inline history ghost text from ~/.zsh_history (accept with Cmd+Right) +- Alternate screen buffer (?1049h) — vim, htop, etc. +- Block/Braille character rendering (U+2580–U+259F, U+2800–U+28FF) as fill rects +- Bracketed paste mode (?2004h) +- Shell integration: ZLE buffer/cursor via OSC 9001, working directory via OSC 7 +- Mouse: scroll wheel, click-drag text selection, auto-scroll during drag +- Blinking 2px cursor bar (~530 ms) +- LANG/LC_ALL=en_US.UTF-8 injected at startup (prevents Mac Roman garbling) + +## VT/ANSI sequences + +- SGR: bold, italic, underline, inverse, all color modes +- Cursor: CUP, CUU/D/F/B, CHA, VPA, home/end, save/restore (ESC 7/8 and CSI s/u) +- Erase: ED (0/1/2/3), EL (0/1/2), ECH +- Scroll region: DECSTBM, SU, SD +- Insert/delete: ICH, DCH, IL, DL +- Device attributes and status report +- OSC 0/2 (title), OSC 7 (cwd), OSC 52 (clipboard), OSC 9001 (shell integration) +- Private modes: ?47, ?1047, ?1049 (alt screen), ?2004 (bracketed paste) + +## `tcat` — syntax-highlighted file viewer + +- Syntax highlighting via syntect (base16-ocean.dark theme) +- Line numbers in gutter +- File info header (name, language, directory) +- Line range support: `tcat file.rs:40-70`, `tcat file.rs:42` +- Automatic via `cat` alias for single-file invocations + +## `tdiff` — syntax-highlighted diff pager + +- Processes unified diff format (stdin) +- Syntax highlights added/removed line content +- Green/red background tints per line type +- Colored hunk headers (@@) in Catppuccin Mauve +- Set as GIT_PAGER automatically (git diff, git show, git log -p) + +## `tjson` — streaming JSON prettifier + +- Filter mode: `cmd | json` — reads stdin line by line +- PTY mode: `json cmd [args]` — spawns command in PTY (child sees real terminal) +- JSON lines (starting with `{` or `[`) are pretty-printed with syntax highlighting +- Non-JSON lines pass through unchanged +- Exit code propagated in PTY mode +- nice drag ui diff --git a/src/bin/tjson.rs b/src/bin/tjson.rs index c25b693..4d03b83 100644 --- a/src/bin/tjson.rs +++ b/src/bin/tjson.rs @@ -1,20 +1,28 @@ //! tjson — streaming JSON prettifier with syntax highlighting. //! -//! Reads stdin line by line. Lines that parse as JSON objects or arrays are -//! pretty-printed with 24-bit ANSI colour (syntect, base16-ocean.dark theme). -//! All other lines pass through unchanged. +//! Two modes: +//! some-cmd | tjson — filter mode: reads stdin line by line +//! tjson pnpm dev [args…] — PTY mode: runs the command in a PTY so it +//! sees a real terminal on stdout/stderr, then +//! filters its combined output the same way. +//! +//! Lines that parse as JSON objects or arrays are pretty-printed with 24-bit +//! ANSI colour (syntect, base16-ocean.dark theme). All other lines pass +//! through unchanged. //! //! Usage: //! pnpm dev | tjson -//! some-command | tjson +//! json pnpm dev # via the shell alias (preferred — preserves TTY) + +use std::io::{self, BufRead, BufReader, Write}; -use std::io::{self, BufRead, Write}; +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use syntect::easy::HighlightLines; use syntect::highlighting::{FontStyle, Style, ThemeSet}; use syntect::parsing::SyntaxSet; use syntect::util::LinesWithEndings; -// ── ANSI helpers (mirrors tcat) ─────────────────────────────────────────────── +// ── ANSI helpers ────────────────────────────────────────────────────────────── fn fg(out: &mut impl Write, r: u8, g: u8, b: u8) -> io::Result<()> { write!(out, "\x1b[38;2;{r};{g};{b}m") @@ -56,28 +64,44 @@ fn print_highlighted( Ok(()) } -// ── Entry point ─────────────────────────────────────────────────────────────── +// ── Core line processor (shared by both modes) ──────────────────────────────── -fn main() { - let ps = SyntaxSet::load_defaults_newlines(); - let ts = ThemeSet::load_defaults(); +fn process_line( + line: &str, + out: &mut impl Write, + ps: &SyntaxSet, + syntax: &syntect::parsing::SyntaxReference, + theme: &syntect::highlighting::Theme, + drain: &mut bool, +) { + let trimmed = line.trim(); - let syntax = ps - .find_syntax_by_extension("json") - .unwrap_or_else(|| ps.find_syntax_plain_text()); + if trimmed.starts_with('{') || trimmed.starts_with('[') { + if let Ok(val) = serde_json::from_str::(trimmed) { + if let Ok(pretty) = serde_json::to_string_pretty(&val) { + match print_highlighted(out, &pretty, ps, syntax, theme) { + Ok(()) => return, + Err(_) => { *drain = true; return; } + } + } + } + } - let theme = ["base16-ocean.dark", "Solarized (dark)"] - .iter() - .find_map(|n| ts.themes.get(*n)) - .or_else(|| ts.themes.values().next()) - .expect("syntect has no themes"); + // Non-JSON or failed parse: pass through unchanged. + if writeln!(out, "{line}").is_err() { + *drain = true; + } +} +// ── Filter mode: read from stdin ────────────────────────────────────────────── + +fn run_filter( + ps: &SyntaxSet, + syntax: &syntect::parsing::SyntaxReference, + theme: &syntect::highlighting::Theme, +) { let stdout = io::stdout(); let mut out = io::BufWriter::new(stdout.lock()); - - // When stdout breaks we switch to drain mode: keep reading stdin until EOF - // so the upstream process never sees a broken pipe (EPIPE). Node.js in - // particular crashes with an uncaughtException on unhandled EPIPE. let mut drain = false; for line in io::stdin().lock().lines() { @@ -85,30 +109,89 @@ fn main() { Ok(l) => l, Err(_) => break, }; - if drain { continue; } + process_line(&line, &mut out, ps, syntax, theme, &mut drain); + } + let _ = out.flush(); +} - let trimmed = line.trim(); +// ── PTY mode: run a command so it sees a real terminal ──────────────────────── - // Only attempt to parse lines that look like JSON objects or arrays. - if trimmed.starts_with('{') || trimmed.starts_with('[') { - if let Ok(val) = serde_json::from_str::(trimmed) { - if let Ok(pretty) = serde_json::to_string_pretty(&val) { - match print_highlighted(&mut out, &pretty, &ps, syntax, theme) { - Ok(()) => continue, - Err(_) => { drain = true; continue; } - } - } - } - } +fn run_pty( + args: &[String], + ps: &SyntaxSet, + syntax: &syntect::parsing::SyntaxReference, + theme: &syntect::highlighting::Theme, +) { + // Inherit terminal dimensions if available. + let cols: u16 = std::env::var("COLUMNS").ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(220); + let rows: u16 = std::env::var("LINES").ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(50); - // Non-JSON or failed parse: pass through unchanged. - if writeln!(out, "{line}").is_err() { - drain = true; - } + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) + .expect("openpty failed"); + + let mut cmd = CommandBuilder::new(&args[0]); + for arg in &args[1..] { + cmd.arg(arg); + } + + let mut child = pair.slave.spawn_command(cmd).expect("spawn failed"); + drop(pair.slave); // child owns the slave end + + // PTY master gives us combined stdout+stderr from the child. + let reader = pair.master.try_clone_reader().expect("clone reader"); + + let stdout = io::stdout(); + let mut out = io::BufWriter::new(stdout.lock()); + let mut drain = false; + + for line in BufReader::new(reader).lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + if drain { continue; } + // PTY line endings are \r\n; BufRead::lines strips \n but leaves \r. + let line = line.strip_suffix('\r').unwrap_or(&line).to_owned(); + process_line(&line, &mut out, ps, syntax, theme, &mut drain); } let _ = out.flush(); + + let exit_code = match child.wait() { + Ok(status) => if status.success() { 0 } else { 1 }, + Err(_) => 1, + }; + std::process::exit(exit_code); +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + + let ps = SyntaxSet::load_defaults_newlines(); + let ts = ThemeSet::load_defaults(); + let syntax = ps + .find_syntax_by_extension("json") + .unwrap_or_else(|| ps.find_syntax_plain_text()); + let theme = ["base16-ocean.dark", "Solarized (dark)"] + .iter() + .find_map(|n| ts.themes.get(*n)) + .or_else(|| ts.themes.values().next()) + .expect("syntect has no themes"); + + if args.is_empty() { + run_filter(&ps, syntax, theme); + } else { + run_pty(&args, &ps, syntax, theme); + } } #[cfg(test)] @@ -153,15 +236,12 @@ mod tests { #[test] fn test_pretty_printed_multiline() { let out = highlighted(r#"{"a":1,"b":2}"#); - // Pretty-printing should produce at least 3 lines: opening brace, fields, closing brace let lines: Vec<&str> = out.lines().collect(); assert!(lines.len() >= 3, "expected multi-line pretty output, got: {out:?}"); } - // parse_check: non-JSON lines should not be accidentally parsed #[test] fn test_non_json_passthrough() { - // Simulate the passthrough branch directly. let line = "Next.js 16.2.0 (Turbopack)"; let trimmed = line.trim(); let would_parse = (trimmed.starts_with('{') || trimmed.starts_with('[')) diff --git a/src/main.rs b/src/main.rs index e1957bb..1b1bd7b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1500,7 +1500,7 @@ fn setup_shell_env(cmd: &mut CommandBuilder) { }; let json_fn = match &tjson { Some(p) => format!( - "_TJSON='{}'\nfunction json() {{ \"$_TJSON\"; }}\n", + "_TJSON='{}'\nfunction json() {{ \"$_TJSON\" \"$@\"; }}\n", p.display() ), None => String::new(), @@ -1542,6 +1542,14 @@ zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' cmd.env("ZDOTDIR", &zdotdir); cmd.env("TERM_PROGRAM", "ghostty"); + // Ensure UTF-8 locale so multi-byte characters aren't re-encoded via Mac Roman. + // Only set if not already present — respect the user's explicit locale choice. + if std::env::var("LANG").is_err() { + cmd.env("LANG", "en_US.UTF-8"); + } + if std::env::var("LC_ALL").is_err() { + cmd.env("LC_ALL", "en_US.UTF-8"); + } } // ── Entry point ───────────────────────────────────────────────────────────────