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+tnew,cmd+wclose,cmd+1..9jump,ctrl+tabcycle. 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+candcmd+xboth copy the selection explicitly — a terminal has nothing to cut, socmd+xis 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
sshcopies 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: enablingControlMaster autoin~/.ssh/configlets QuikTerm reuse your own session's connection, making even the first paste prompt-free.
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.
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.netwith 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.
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 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.
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.
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/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.
Each tab can be split into panes, Ghostty-style:
cmd+dsplit right,cmd+shift+dsplit downcmd+opt+arrowsfocus pane in that direction;cmd+]/cmd+[next/previous panecmd+ctrl+arrowsmove the divider; drag it with the mouse;cmd+ctrl+=equalizecmd+wcloses 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.
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,
returnto run,escto close. cmd+nadds a command (name + command text),cmd+eedits the selected one,cmd+deleteremoves 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}orawk '{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+sfrom 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+erenames a saved tab,cmd+deleteremoves it. Stored in~/Library/Application Support/QuikTerm/saved-tabs.json.
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).
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.
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).
esccloses without changing anything. Hidden files aren't listed.
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.
MIT — see LICENSE. QuikTerm embeds Ghostty (MIT) and uses TOMLKit (MIT, itself powered by the MIT-licensed toml++); see THIRD-PARTY-LICENSES.md.
- 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
xcloses a pane; everything else is read-and-run. - Every path QuikTerm owns is rooted at
$QUIKTERM_HOMEwhen 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$HOMEdoes 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 yourquiktermCLI talks to. Keep the value short and absolute: the control socket path must fit insun_path, which leaves about 53 bytes for$QUIKTERM_HOMEitself (/tmp/qt-e2eis fine; a baremktemp -dis 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.