Skip to content

Repository files navigation

QuikTerm

A fast quake-style slide-in terminal for macOS, built on libghostty.

  • Global hotkey (default ctrl+`) slides a terminal in from a configurable screen edge (top/bottom/left/right).
  • Full screen width on top/bottom, full height on left/right; thickness is drag-resizable and remembered.
  • Simple tabs, placed opposite the slide-in edge. cmd+t new, cmd+w close, cmd+1..9 jump, ctrl+tab cycle. Option-click a tab to close it.
  • Each tab's working directory is restored across relaunches.
  • Auto-hides on focus loss; pin button keeps it open.
  • Selecting text copies it (Ghostty's copy-on-select); cmd+c and cmd+x both copy the selection explicitly — a terminal has nothing to cut, so cmd+x is a copy alias.
  • Fonts/themes come from your regular Ghostty config.
  • cmd+h (or the menu bar's "QuikTerm Help") shows every shortcut and feature in an in-panel overlay.
  • Remote paste over ssh — pasting an image or dropping a file into a pane whose foreground process is ssh copies it to the remote host (scp, with QuikTerm-managed connection sharing) and types the remote path, so remote CLIs like Claude Code can read it. Password prompts appear as a native dialog; key/agent auth is silent. Tip: enabling ControlMaster auto in ~/.ssh/config lets QuikTerm reuse your own session's connection, making even the first paste prompt-free.

Remote access

QuikTerm can serve its tabs to a browser — attach from your phone or another machine, type, scroll, create/close tabs, all live-synced with the Mac's own panel.

~/.config/quik-term/config.toml:

[remote]
enabled = false     # true: serve the browser client + WebSocket API
bind = "127.0.0.1"  # interface to listen on
port = 7677         # TCP port (one port serves both HTTP and /ws)
resize_policy = "hidden_only"  # who owns the terminal size
leader_key = "ctrl+b"          # browser client's prefix key
hide_panel_on_attach = true    # slide the panel away when a client attaches
serve_https_port = 8443        # re-claim this tailscale serve port at launch

Toggling enabled and using "Reload Config" takes effect immediately, no relaunch needed.

resize_policy decides what happens when a remote client with a different window size attaches to a pane:

  • hidden_only (default) — the remote sets the size only while the panel is hidden. Showing the panel restores the desktop's own size immediately, so attaching from a phone never reflows what you are reading at your desk.
  • local_wins — the desktop always sets the size; the remote client letterboxes itself.
  • remote_wins — the most recent remote client always sets the size. This is what QuikTerm used to do unconditionally.

Why hidden_only is the default. A pty has exactly one size, so any two clients of different shapes must argue about it, and the loser's output is reflowed — full-screen programs (vim, tmux, a pager, anything drawing a box) repaint at the new width and lose their layout. The question is only whose view gets sacrificed, and the answer follows from who is looking: leaving an iPad attached on the desk is normal, so remote_wins meant a tablet sitting on the far side of the desk permanently dictated the width of the terminal being read on the Mac, reflowing it the moment it connected. hidden_only gives the size to whoever is actually watching — the desktop while its panel is up, the browser while it is not. The remote's requested size is remembered rather than discarded, so hiding the panel hands the grid straight back with no reconnect. The switch happens at the start of the slide-in and at the end of the slide-out, so the desk terminal is already correct before the panel finishes appearing.

leader_key is the browser client's prefix key — see Keyboard shortcuts.

Transport security is your responsibility, not this server's — it speaks plain HTTP/WS with a bearer token, no TLS of its own.

Recommended: tailscale serve

Keep the server on loopback and let Tailscale terminate TLS in front of it:

# config.toml
[remote]
enabled = true
bind = "127.0.0.1"
port = 7677

tailscale serve --bg --https=443 http://127.0.0.1:7677

This is the recommended setup for three concrete reasons:

  • A real certificate. You get https://<machine>.<tailnet>.ts.net with a genuine Let's Encrypt certificate, so no browser warnings and no certificate to install on every phone.
  • Works beyond the LAN. Any device on your tailnet reaches it from anywhere — coffee shop, phone on cellular — without exposing a port to the internet. Nothing but loopback is ever listening locally.
  • Enables the clipboard. navigator.clipboard (and other browser APIs) only exist in a secure context. Over plain HTTP to a LAN IP, they are simply absent, so copy-on-select in the browser terminal silently does nothing. Over HTTPS it works.

Then open https://<machine>.<tailnet>.ts.net/#<token>, taking the token from ~/.config/quik-term/remote-token.

Alternative: plain LAN

Binding directly to a LAN or tailnet address also works:

[remote]
enabled = true
bind = "192.168.1.20"   # or a 100.x.y.z tailnet IP
port = 7677

Caveat: this is plaintext. Everything — keystrokes, terminal output, and the bearer token in the WebSocket handshake — crosses the network unencrypted, readable by anything else on that network. A tailnet address is a reasonable middle ground (WireGuard encrypts the hop even though the server speaks HTTP), but it is still not a secure context, so the browser clipboard stays unavailable. A LAN IP has neither property; use it only on a network you trust, and prefer tailscale serve.

Don't bind to 0.0.0.0 or a public interface — the auth token is the only thing standing between anyone who can reach the port and your shells.

The menu bar's "Copy Remote URL" item (shown whenever remote.enabled is true) copies a http://bind:port/#token link — paste it straight into a browser. The token rides in the URL fragment, so it's never sent in a request line, log, or Referer header.

The browser UI

The browser client is a full mirror of the panel, not a single terminal:

  • Tab strip — one button per tab (not per pane), labelled with the tab's name and accented with its colour, plus a badge showing the pane count on split tabs. Internal pane ids never appear in the UI.
  • Splits — a split tab renders as real side-by-side (or stacked) terminals in the browser, at the same ratios the desktop is showing, and every visible pane streams live at once: a build log next to a shell updates in both. Dividers drag to re-balance the browser's view only — a remote drag never restructures the desktop layout.
  • Narrow screens — below ~700px the split collapses to one full-width pane plus a pane switcher under the tab strip, because a terminal under ~40 columns is unusable. Tap a pane's name to bring it forward.
  • Command palette — your saved commands, saved tabs, "Save Tab As…", and Reload Config. Destructive app actions (Quit, Hide Panel, Edit Config) are deliberately not exposed remotely.
  • File browser — walk the Mac's filesystem; Return on a folder cds the focused pane, Return on a file opens it in the Mac's default app (honouring [open_with]). File contents never cross the network.
  • Help overlay — the binding table below, generated from the same source as the shortcut handler so the two can't drift.

Typing, scrolling, and the "+"/"×" tab controls stay in sync with the desktop app. An on-screen accessory row on iOS supplies Esc/Ctrl/arrow keys a software keyboard lacks, plus buttons for the palette, files and help — those chords can't be typed on a phone.

Who owns a pane's real grid is set by resize_policy (above); the default gives it to the desktop while the panel is up and to the browser while it is hidden.

The tab strip follows the panel's edge, so the browser reads like the desktop rather than like a different app: it sits on the side opposite the edge the panel slides from. edge = "left" or "right" gives a vertical strip (on the right and left respectively) with rotated labels, matching the desktop's vertical tab bar; edge = "top" or "bottom" keeps a horizontal one. Below ~700px a vertical strip falls back to horizontal-on-top, where there is no width to spare. A tab with more than one pane also draws a per-pane header — a colour dot and the pane's title (its name, else the cwd's basename) — with the focused pane tinted differently, again mirroring the desktop.

Keyboard shortcuts (browser)

The desktop's cmd+… chords belong to the browser, so the web client uses a tmux-style leader key: press the leader, then one more key. The leader is leader_key in [remote], default ctrl+b.

This replaced a table of ctrl+shift+… chords, which turned out to be a bad neighbour: ctrl+shift+… combinations are real terminal input, so the client was stealing keystrokes from the very shell it was meant to be forwarding to. A leader is a single key to give up instead of eight, and you get that one back by pressing it twice.

Action Web Desktop equivalent
New tab leader then t cmd+t
Close pane/tab leader then x cmd+w
Select tab 1..9 leader then 1..9 cmd+1..9
Next tab leader then } ctrl+tab
Previous tab leader then { ctrl+shift+tab
Next pane leader then ]
Previous pane leader then [
Split right leader then d cmd+d
Split down leader then shift+d cmd+shift+d
Command palette leader then p cmd+shift+p
File browser leader then f cmd+shift+f
Help overlay leader then ? cmd+h

The keys mirror the desktop's own mnemonics. c (new tab), n (next tab), o (next pane) and , (palette) still work as unadvertised aliases from an earlier tmux-flavoured scheme; p now means palette, not previous tab.

The leader is the only keystroke the client ever intercepts on its own. With nothing pending, every key — including ctrl+shift+… — goes straight to the terminal. After the leader, an unbound key is passed through untouched and cancels the leader, so a mistyped chord costs nothing. Pressing the leader twice sends one literal leader byte to the shell, which is how you drive a tmux running inside a pane. A pending leader expires after two seconds.

Esc closes whichever overlay is open and returns focus to the pane you were typing in.

Build

Requirements: macOS 14+, Xcode (recent), zig 0.15.x.

git submodule update --init
make ghostty     # builds GhosttyKit.xcframework (slow, once)
make app         # builds build/QuikTerm.app
make run

Config

~/.config/quik-term/config.toml (all keys optional):

edge = "top"                # top | bottom | left | right
hotkey = "ctrl+`"           # e.g. "cmd+shift+t", "f12"
screen = "main"             # main | mouse
animation_duration = 0.18
always_on_top = true        # false: normal window level — apps opened
                            # from the terminal appear above it instead
                            # of the panel hiding to make room
show_in_dock = false        # true: Dock icon + cmd+tab entry; clicking
                            # the icon or switching to the app shows
                            # the terminal
confirm_quit = true         # false: cmd+q quits without asking
open_links_in_browser_tab = false
                            # true: cmd+clicked http(s) links open in a
                            # QuikTerm browser tab instead of the default
                            # browser (an [open_with] entry for the
                            # scheme still wins; other schemes are
                            # unaffected)

The panel only hides via the hotkey (or the menu bar toggle) — losing focus never hides it. The menu bar icon has quick toggles (edge, reload) plus "Edit Config…" and "Edit Ghostty Config…", which open the respective file in your default editor (creating it first if needed — use Reload Config after saving). Picking an edge from the menu rewrites the config file (comments are not preserved). Right-click a tab to give it a color; the color persists across restarts.

Splits

Each tab can be split into panes, Ghostty-style:

  • cmd+d split right, cmd+shift+d split down
  • cmd+opt+arrows focus pane in that direction; cmd+] / cmd+[ next/previous pane
  • cmd+ctrl+arrows move the divider; drag it with the mouse; cmd+ctrl+= equalize
  • cmd+w closes the active pane (closes the tab when it's the last one)

When a tab is split, each pane gets a small title strip across its top. Right-click the strip to rename the pane or tag it with a color; both persist across restarts. Without a custom name the strip shows the pane's working directory. A single, unsplit pane shows no strip.

Splits (orientation, sizes, and each pane's directory) are restored across restarts. If a pane had a foreground app running when QuikTerm quit (vim, htop, ssh…), the same command is re-run in the restored pane; idle shells are left alone. Font zoom (cmd+= / cmd+- / cmd+0 to reset) is remembered per pane and restored too.

Command palette

cmd+shift+p opens a palette grouped into three sections: Your Commands (ones you create — typed into the focused pane's shell), Saved Tabs, and App Commands (built into QuikTerm, mirroring the menu bar). For your commands:

  • Type to filter, arrows to select, return to run, esc to close.
  • cmd+n adds a command (name + command text), cmd+e edits the selected one, cmd+delete removes it.
  • Commands can be templates: {branch} in the command text prompts for a value before running, {branch:main} pre-fills the prompt with a default. A placeholder name is a single word (letters, digits, _, -), so shell syntax like ${HOME} or awk '{print $1}' passes through untouched. Example: git checkout {branch:main}.
  • Commands are stored in ~/Library/Application Support/QuikTerm/commands.json, which can also be edited by hand.

Everything in the menu bar is also available as palette entries: Hide Terminal, the four slide-in edges (current one checkmarked), Edit Config, Edit Ghostty Config, Reload Config, Launch at Login, and Quit.

The palette also manages saved tabs:

  • "Save Tab As…" (or cmd+shift+s from anywhere) snapshots the current tab under a name: all splits, each pane's directory, whatever is running in each pane, and zoom. The tab shows that name instead of its directory from then on (also across restarts).
  • Saved tabs appear as palette entries — pick one to reopen the whole layout in a new tab, with the recorded commands relaunched (if a tab with that name is already open, it's selected instead). cmd+e renames a saved tab, cmd+delete removes it. Stored in ~/Library/Application Support/QuikTerm/saved-tabs.json.

Control CLI (for humans and AI agents)

The app listens on a local control socket (~/Library/Application Support/QuikTerm/control.sock). This is on by default and needs no configuration; the socket is owner-only (0600), so only your user account can talk to it.

Setup (one time): menu bar icon → "Install CLI…" symlinks the bundled quikterm binary into /usr/local/bin. If that folder isn't writable, the dialog shows the command to run yourself:

sudo ln -sf /Applications/QuikTerm.app/Contents/MacOS/QuikTermCLI \
  /usr/local/bin/quikterm

The symlink points into the app bundle, so it keeps working across app updates — no reinstall needed.

quikterm list                       # tabs and panes with stable ids
quikterm new-tab --title build      # open + name a tab
quikterm split %1 right --name logs # split a pane
quikterm run logs "make test" --wait # type a command, wait, get the screen
quikterm read build/logs --history 200 # dump a pane's screen (+ scrollback)
quikterm name %2 "api server"       # rename a pane
quikterm panel show                 # show/hide/toggle/status the panel
quikterm browser https://grafana.local # open a browser tab there
quikterm browser --url              # print a browser tab's address

panel prints the resulting state (shown / hidden); panel status reports it without moving anything. It is the same code path as the hotkey, so it drives resize_policy too — which is how the remote resize behaviour is tested end to end.

browser <url> takes http/https URLs only, reuses the active tab when it is already a browser and opens a new one otherwise — the same rule bookmarks follow, so the CLI and the book menu behave alike. This machine's own remote URL is refused with an error and nothing opens (loading it here would attach a remote client to this very app). browser --url [target] prints a browser tab's current address — the active tab by default, or a @tab/%pane id or name; a target that isn't a browser tab answers not_found.

Add --json for machine-readable output. run refuses panes whose foreground process isn't an idle shell (pane_busy), so agents can't type into vim by mistake. Exit codes: 0 ok, 1 error, 2 wait timeout.

Targets can be names, not just ids (case-insensitive): for pane commands (run, read, name, split) a bare name matches pane names, for title it matches tab titles, and for focus/close it matches both with the pane winning a tie. tab/pane qualifies by tab when names collide. An ambiguous name fails listing each match with its id — structured as data.candidates under --json, so an agent can retry by id in one step. For run and name, a bare-name target needs both positionals: quikterm run logs runs the command logs in the focused pane, quikterm run logs "make test" targets the pane named logs.

Using it from an AI agent (Claude Code, etc.): once the symlink is installed, agents can drive your terminal by shelling out to quikterm — no per-session setup. A line like this in your project's CLAUDE.md is enough for the agent to discover the rest:

Use the `quikterm` CLI to open, name, run commands in, and read
QuikTerm panes (`quikterm help` lists commands; add --json for
structured output).

Links

cmd+click opens detected URLs (and OSC 8 hyperlinks, e.g. from ls --hyperlink=auto) in their default app, with focus handed over. Holding cmd underlines the link under the pointer.

This works inside full-screen TUIs too — vim, tmux, Claude Code. Such programs turn on mouse reporting and would normally take every click, which upstream ghostty only lets you override by also holding shift. QuikTerm reserves cmd for the terminal instead, the way Terminal.app and iTerm2 do, so one gesture behaves the same at a shell prompt and inside a TUI. A cmd-clicked link is swallowed by the terminal and never reaches the program underneath. This is a local patch to the vendored ghostty (patches/), applied by scripts/build-ghosttykit.sh.

Set open_links_in_browser_tab = true in the config to keep http/ https links inside QuikTerm instead: each clicked link opens its own browser tab. An [open_with] entry for the scheme outranks the setting (mapping https to Firefox by hand means it), and every other scheme — mailto:, ssh:, file paths — is unaffected. A link to QuikTerm's own remote URL still goes to the default browser, where opening your own remote client actually works.

Plain file paths work too — no URL scheme needed: cmd+click any word that resolves to an existing file or directory (bare names from ls resolve against the pane's directory; ./relative, /absolute, and ~/paths all work, and compiler refs like main.swift:12:5 open the file). Directories open in Finder. Paths containing spaces aren't detected.

To override which app opens what, map URL schemes or file extensions in the config:

[open_with]
https = "Firefox"
md = "Typora"                       # file:// links ending in .md
toml = "com.microsoft.VSCode"       # bundle ids work too

Values may be an app name (looked up in the Applications folders), a bundle identifier, or a full path to the .app. The Edit Config menu items honor a matching extension override as well.

File browser

cmd+shift+f opens a Finder-style list rooted at the focused pane's directory (small icons, alternating rows, disclosure triangles):

  • Arrows move, / expand and collapse folders inline, typing jumps to a name, cmd+↑ re-roots at the parent directory.
  • return (or double-click) on a folder cds the terminal there and closes the browser; on a file it opens it in its default app — or the app mapped in [open_with].
  • The cd is typed into the shell, so it only fires at an idle prompt (a beep means the pane's foreground app has the keyboard).
  • esc closes without changing anything. Hidden files aren't listed.

AI CLI notifications

QuikTerm watches every pane for a running AI CLI (claude, codex, gemini, aider, …). When one stops working — task finished or waiting for your input — you get a macOS notification (click it to jump to that tab) and the tab shows an orange dot until you focus the pane again. A terminal bell or a desktop-notification escape (OSC 9) from the pane triggers the same immediately. Nothing fires while you're already looking at the pane.

Config keys:

ai_notify = true            # set false to disable the macOS notifications
ai_clis = ["claude", "codex", "gemini", "aider", "goose", "opencode",
           "amp", "crush", "droid", "cursor-agent", "copilot"]

The quiet detection is CPU-based (the AI process going idle for a few seconds), so it needs no configuration in the AI tool itself.

License

MIT — see LICENSE. QuikTerm embeds Ghostty (MIT) and uses TOMLKit (MIT, itself powered by the MIT-licensed toml++); see THIRD-PARTY-LICENSES.md.

Known v1 limitations

  • IME/preedit composition (CJK input, dead keys) is implemented but has had little real-world testing — report issues.
  • No scrollback restore.
  • Session state: ~/Library/Application Support/QuikTerm/session.json.
  • The browser cannot create or close splits, reorder tabs, edit tab colours, edit commands/saved tabs, or transfer files. Existing splits render and the leader's x closes a pane; everything else is read-and-run.
  • Every path QuikTerm owns is rooted at $QUIKTERM_HOME when that is set (config, session store, remote token, control socket, ssh support dir). It exists so a test or verification instance can run beside your real one without touching it. Redirecting only $HOME does not work — the app resolves its home through the directory service — and is worse than doing nothing, because such an instance reads your real session, re-runs your saved commands, and rebinds the control socket your quikterm CLI talks to. Keep the value short and absolute: the control socket path must fit in sun_path, which leaves about 53 bytes for $QUIKTERM_HOME itself (/tmp/qt-e2e is fine; a bare mktemp -d is not). An unusable value is refused at startup with the budget in the message rather than degrading into an app whose CLI silently cannot connect.

Releases

Packages

Contributors

Languages