Native macOS terminal that renders LaTeX formulas live as KaTeX overlays — positioned directly over the source characters between $...$, $$...$$, \(...\) and \[...\]. No OCR: a vendored SwiftTerm fork gives us the real cell grid, so formulas sit exactly on their source text.
The predecessor project LatexTerminalLive used ScreenCaptureKit + Vision OCR to read Ghostty's output. Worked, but OCR was unreliable (greek glyphs, fractions, subtle artifacts). This project is a full terminal emulator instead — we own the text stream, the grid model, and the render pipeline, so formula positions come from the cell grid directly. No OCR.
PTY (zsh) → SwiftTerm VT parser → Buffer grid
↓
OverlayController scans visible rows
↓
LaTeXDetector finds delimited formulas
↓
One FormulaLayer (single WKWebView + KaTeX)
renders every formula as a positioned <div>,
grid coords → pixel coords
- Terminal: vendored fork of SwiftTerm (MIT) at
SwiftTermLocal/. Fork adds a publicextraLineSpacingproperty onTerminalViewso we can introduce vertical gaps between rows without modifying glyph rendering. - Detection: per-row buffer text scan after every SwiftTerm
rangeChangedupdate. Inline segments ($..$,\(..\)) and single-line$$..$$/\[..\]are found line by line; multi-line display blocks ($$/\[…\]with each delimiter alone on its own line) are detected across rows byLaTeXDetector.findBlocks. Requiring the delimiters to stand alone keeps prose$$(or the shell PID$$) from forming false blocks. Soft-wrapped inline formulas that run across a line break are also detected: consecutiveisWrappedrows are reconstructed into one logical line (LaTeXDetector.findWrapped), scanned as a whole, and each hit projected back onto grid coordinates. - Rendering: a single
FormulaLayer(oneWKWebView) hosts all formulas — KaTeX is loaded offline once (CSS + JS + woff2 fonts bundled) and each formula is an absolutely-positioned<div>. Inline formulas get a tight 1-cell background box over the raw$..$text and scale (transform: scale()) to fit entirely within that single row so they never bleed into neighbouring lines. Display formulas render with truedisplayModeand are centred in both axes: single-line$$..$$is fit into its row, while a multi-line block spans its full source row range (the rows are already reserved by the source text) so the formula gets real vertical room. KaTeX parse errors are surfaced, not silently swallowed: the offending formula gets a red wavy underline and the hover/pin preview shows the raw source plus the actual KaTeX message (e.g. "Undefined control sequence: \fra"). - Hover preview: large formulas shrink to fit their row, so a hover "view mode" (
FormulaPreview) blows the formula back up at full size when the pointer rests over it. Hitboxes start as the source-text box and are tightened to the real rendered bounds reported back from the WebView; hover tracking is mouse-move only, so plain selection/scroll still pass through to the terminal. - Click to pin + copy: clicking a formula pins the preview and reveals three buttons — LaTeX copies the raw expression, Lesbar copies a readable Unicode-math form (e.g.
(-b ± √(b²-4ac))/(2a), viaLaTeXReadable) — including 2D matrices/caseswith real bracket glyphs, Unicode accents (x⃗,x̂) and Greek sub/superscripts (the 2D layout assumes a monospace, left-aligned paste target), and Bild copies the formula as a PNG image — a dark rounded "chip" composed from atakeSnapshotof the (already-painted) preview WebView at retina resolution viaFormulaImageRenderer. Clicking away,Esc, scrolling, or new output dismisses it. Two localNSEventmonitors drive pinning/dismissal;OverlayHost.hitTestlets clicks land inside the pinned panel (the buttons) while staying click-through everywhere else. - Overlay lifecycle: keyed by
(absoluteBufferRow, startCol, body)whereabsoluteBufferRow = viewportRow + buffer.yDisp. On rescan the desired state is sent to the layer as JSON and reconciled in JS (sync()): new keys create a<div>, missing keys are removed, surviving keys are only repositioned (no KaTeX re-render). Binding the key to the absolute scrollback row means scrolling repositions overlays instead of destroying and rebuilding them. Font-size and settings changes triggerclearAll()so KaTeX re-renders at the new size/colors. - Split-screen tiling:
⌘Tadds a pane, each with its own login shell and its ownOverlayController(independent formula overlays).TerminalSplitViewlays panes out by direct frame math (noNSSplitView), choosing the grid shape from the window's width and height: it picks the row count whose resulting cell aspect ratio is closest to a target (idealCellAspect ≈ 0.82), so a wide window stays single-row longer (up to ~3 across) and wraps into a balanced grid as it fills (4 → 2×2, then toward 3×3). Rows are equal height and each row divides its width independently (top-heavy masonry: e.g. 5 panes → 3 over 2). An 8px strut between cells is semi-transparent (alpha: 0.35) to let window vibrancy peek through.⌘Wcloses the focused pane,⌘1…9grows to N panes. Transitions when adding or removing panes are smoothly animated over 0.22s. - Premium Visuals & Window Design: The terminal window features a frameless blending design (
fullSizeContentView, transparent titlebar, hidden title, and window-wide dragging). It embeds a nativeNSVisualEffectViewbehind the terminal panes (.underWindowBackground,.behindWindowblend modes) for a premium translucent macOS feel. - Scroll-following overlays: scrolling is a rapid sequence of static states, and repositioning the out-of-process WebView per step flickers — while hiding the overlays makes formulas vanish mid-scroll. The terminal scrolls uniformly by
Δrows × cellHeight, so SwiftTerm'sscrolledevent drives a separate path (onScrolled→scheduleReposition) that translates the whole formula container as one block via CSStranslateYinside the WebView (GPU-composited, no per-div re-sync) — formulas stay glued to their text. Content-rescans are suppressed while scrolling (the per-steprangeChangedwould otherwise fight the translation); ~150 ms after the last event a single settlerescan()emits the new absolute positions and resets the translation in the same JS call (one WebView frame), so surviving formulas sit pixel-identical with no jump and no hide/reveal. The 30 msscheduleRescandebounce now only serves terminal output, resize, and settings changes.
Grab the latest LatexTerm.app from the Releases page, unzip, and drop it into /Applications.
The build is currently unsigned, so on first launch macOS Gatekeeper will block it. Right‑click the app → Open → Open, or clear the quarantine flag:
xattr -dr com.apple.quarantine /Applications/LatexTerm.appPrefer building it yourself? See Build from source.
- To run: macOS 14+
- To build: Xcode 26+ with the Metal Toolchain installed (
xcodebuild -downloadComponent MetalToolchain— SwiftTerm ships Metal shaders even when the CPU renderer is used)
Open in Xcode:
open LatexTerm.xcodeprojCmd+R to run. App Sandbox is intentionally disabled — the terminal needs free PTY/process spawn rights.
Or build from CLI:
xcodebuild -project LatexTerm.xcodeproj -scheme LatexTerm -configuration Release \
-derivedDataPath .build CODE_SIGNING_ALLOWED=NO build
open .build/Build/Products/Release/LatexTerm.appRun the tests (Cmd+U in Xcode, or from the CLI):
xcodebuild test -project LatexTerm.xcodeproj -scheme LatexTerm \
-destination 'platform=macOS' CODE_SIGNING_ALLOWED=NOLatexTermTests is a logic-only unit-test bundle (no test host) covering LaTeXDetector and LaTeXReadable.
| Shortcut | Action |
|---|---|
⌘T |
New terminal pane (auto-tiled into the grid) |
⌘W |
Close the focused pane (closes the window if it was the last) |
⌘1 … ⌘9 |
Grow the grid to N panes (grow-only — never closes panes) |
⌘+ / ⌘= |
Increase font size by 1pt (all panes) |
⌘- |
Decrease font size by 1pt (all panes) |
⌘0 |
Reset font size to 13pt (default, all panes) |
⌘L |
Toggle formula overlays on/off |
⌘⌥⌃A |
Toggle automatic adaptive accent color |
⌘⇧+ / ⌘⇧- |
Increase/decrease line spacing by 2px |
⌘⇧0 |
Reset line spacing to default (8px) |
⌥⌘+ / ⌥⌘- |
Increase/decrease formula render scale by 0.1× |
⌥⌘0 |
Reset formula scale to 1.0× |
Font size is persisted in UserDefaults under LatexTerm.fontSize (range 6–48pt) and restored on next launch. It is global: changing it in one pane updates all panes (broadcast via the LatexTerminalView.fontDidChange notification).
All formula settings (color, enabled, line spacing, scale) are also persisted and restored via FormulaSettings in UserDefaults.
echo in zsh interprets escapes like \f and breaks LaTeX in output. Use printf or a here-doc instead:
printf '%s\n' '$E=mc^2$ und $\int_0^\infty e^{-x^2}dx$'
cat <<'EOF'
Bruch: $\frac{n(n+1)(2n+1)}{6}$
Tief: $\frac{x+1}{\frac{a+b}{c-d}}$
EOFThe README shows a GIF of formulas rendering live as you type. To regenerate it:
- Launch LatexTerm and paste a few formulas (see Testing formulas).
- Record the window with macOS screen capture (
⇧⌘5) or Gifski /ffmpeg. - Save it as
docs/demo.gifand it will appear at the top of this README automatically.
LatexTerm.xcodeproj/ App project (SwiftUI lifecycle)
LatexTerm/
LatexTermApp.swift @main App definition + "Terminal" CommandMenu
TerminalContainer.swift NSViewRepresentable wrapping the split container
TerminalSplit.swift TerminalPane (shell + overlays per tile) +
TerminalSplitView (animated, vibrancy-integrated auto-tiling grid layout)
FormulaSettings.swift Settings singleton (UserDefaults + NotificationCenter)
Latex/
LatexTerminalView.swift LocalProcessTerminalView subclass: overlay host,
font/split/close/grid shortcuts, range-change forwarding
OverlayController.swift Per-rescan diff of detected formulas → JSON sync
LaTeXDetector.swift Delimiter-based formula extraction
LaTeXReadable.swift LaTeX → readable Unicode-math converter (copy "Lesbar")
FormulaImageRenderer.swift Composes a formula snapshot into a PNG chip (copy "Bild")
MathOverlayView.swift FormulaLayer: shared WKWebView + FormulaPreview (pin/copy)
katex/ Bundled KaTeX assets (CSS, JS, woff2)
Assets.xcassets/
Info.plist
SwiftTermLocal/ Vendored SwiftTerm fork (adds extraLineSpacing)
Sources/SwiftTerm/...
Package.swift Library-only manifest (no executables, no tests)
- Wrapped-inline detection — first iteration. Inline formulas that soft-wrap across a line break are detected (logical-line reconstruction via
isWrapped), rendered in whichever row-segment has the most room with the other segment masked. Two edge cases are out of scope: a formula whose opening delimiter is scrolled off above the viewport, and one that wraps off the bottom of the viewport (no closer visible). Multi-line display blocks ($$/\[…\]) are supported only in canonical form with each delimiter alone on its own line — a block whose delimiters share a line with other content is not detected. - Display mode
$$..$$/\[..\]is rendered with true KaTeXdisplayMode. Single-line display formulas are scaled into their row (so they stay one line tall); multi-line blocks span their source row range. - Greedy
$-pairing in prose. Inline$..$detection is brace-aware (an inner$inside{..}, e.g.$\text{cost: $5}$, no longer closes the formula early), but two bare shell-style$on one line (echo $PATH and $HOME) are still paired greedily into a false formula. A safe heuristic can't be found without breaking legitimate math ($(a+b)$,$n$th,$X$ … $Y$), so math correctness wins. - No theme sync after launch. Background color is captured per rescan into the layer config. Changing the terminal background at runtime updates formula backgrounds on the next rescan, but is not pushed live. Formula foreground color is user-controlled via the "Formeln" menu.
LatexTerm is released under the MIT License — Copyright (c) 2026 Mats Luca Dagott.
It bundles third-party software, each under its own license (see NOTICE):
- SwiftTerm — MIT, Copyright (c) 2019–2022 Miguel de Icaza and contributors. Vendored fork at
SwiftTermLocal/. Upstream: https://github.com/migueldeicaza/SwiftTerm - KaTeX v0.16.9 — MIT, Copyright (c) 2013–2020 Khan Academy and contributors. Bundled at
LatexTerm/katex/. The KaTeX fonts are under the SIL Open Font License 1.1.
