A terminal for people who work with agents. Built on Ghostty's VT core and Zed's GPUI, it is the house terminal of the k4 bar — though it does not need it: with no bar in front, the bridge stays quiet and it stands on its own.
git clone --recursive https://github.com/k4ditano/k4term && cd k4term
./scripts/bootstrap-zig.sh # unless you already have Zig 0.14.1
./instalarLinux and Hyprland. Builds in release, installs both binaries plus the icon and
the desktop entry into your home — no sudo, nothing that collides with distro
packages. Zig is what builds the vendored Ghostty core, and ./instalar stops
with a clear message rather than a wall of errors if it cannot find it.
./instalar --enlace symlinks to target/release instead, for development,
and ./instalar --quitar undoes all of it, shell integration included.
Every window opens a socket and puts its number in the environment. An agent running inside — Claude Code, Codex, a script of your own — can read what the last command did, raise a real modal to ask you something, and fold away the four hundred lines it just printed.
Plus the kitty keyboard protocol (so Helix and Neovim get ctrl+shift+letter),
images over the grid, desktop notifications when something finishes or is
waiting for you, and a session you can move between a window and the bar's
island without losing it.
The kitty graphics protocol: kitten icat, matplotlib's terminal backend, the
console image viewers. PNG included — the decoder comes out of the Ghostty
submodule this repo already vendors.
The window has it on; the island session does not, on purpose. It ships its screen to the bar as JSON and a PNG does not fit in there, so it answers "no images" and apps fall back to text. Saying yes and then drawing nothing leaves the application with no plan B.
An agent that needs permission today prints a line and hopes you see it among four hundred of output. This puts a real modal in front of everything, and the answer comes back as the exit code — so it works from a script with no parsing at all:
k4term preguntar "¿Sigo?" && hacerloThe whole surface, through a socket at $XDG_RUNTIME_DIR/k4term/:
| Command | What it does |
|---|---|
k4term bloque |
The last command: what it was, how it ended, how long it took, where it ran |
k4term diario |
Every command of this session |
k4term preguntar TEXT [options…] |
A modal; the answer is the exit code |
k4term avisar TITLE [body] |
A desktop notification |
k4term marcar TITLE |
A titled separator in the scrollback |
k4term recoger |
Folds away what it just printed |
Everything comes out as JSON, which is what an agent can read without inventing
a parser. k4term bloque | jq -r .texto is the one that saves you the most: you
stop copying and pasting two hundred lines of a failure into a prompt.
No agent does this on its own. It is a door you have opened; something has
to be told to use it — a line in your CLAUDE.md, a Claude Code hook, or your
own scripts.
Two more things for working alongside one: it tells you when something is
waiting for you — quiet for a while, the last thing written looking like a
question, and you looking elsewhere — and every session gets the names of
your servers in its environment, so an agent can offer ssh casa instead of
asking you for an IP.
ctrl+shift+C / ctrl+shift+V |
Copy / paste |
ctrl+shift+A |
Select the whole screen |
ctrl+shift+F |
Find in the scrollback |
ctrl+shift+↑ / ↓ |
Jump from prompt to prompt |
ctrl+shift+E |
Copy the last command's output |
ctrl+shift+O |
Fold away everything before the current command |
ctrl+shift+P |
Saved commands |
ctrl+shift+S |
Servers · ctrl+G on one opens the agents' door |
ctrl+shift+T |
Another terminal, where the shell is right now |
ctrl+shift+I |
Send this session back to the bar's island |
ctrl+shift+Q |
Quiet mode: dim everything before the last command |
ctrl+shift+N / M |
Send the block / the session to a note |
ctrl+, |
Settings |
ctrl+± / ctrl+0 |
Font size |
All nineteen can be rebound in the config, and only what you change goes in the
file: atajo.pegar = ctrl-v, or no to give the key back to the shell.
The mouse does what it should: double click takes the word (paths and URLs whole), triple takes the line, what you select goes to the primary selection and the middle button pastes it, right click opens a menu, and dropping a file types its path.
ctrl+, opens them. They are written to ~/.config/k4term/k4term.conf line by
line, leaving your comments and any keys not offered there untouched — that
file stays the source of truth and can still be edited by hand. The window
watches it, so a change shows up immediately in every open window.
tema = gruvbox # k4, gruvbox, nord, tokyonight, solarized, solarized-claro
color1 = #ff0000 # …or one at a time, color0 through color15
fondo = #17181a
tinta = #d6d8db
fuente = MesloLGS Nerd Font Mono
tamaño = 13
cursor = barra | bloque | subrayado # if the program asks, the program wins
parpadeo = no
historial = 10000 # lines, applies to the next window
ligaduras = no # for Fira Code and friends
campana = si
respaldos = Noto Color Emoji, Symbols Nerd FontThe bar publishes a theme and k4term follows it; a colour written into your own
file wins over it, which is the order you expect and the only way to have
colours at all when there is no bar in front. tema = barra is not one of the
built-ins, so it sets nothing and the bar's theme stands again.
apps/k4term: the window.apps/k4term-isla: the same session minus the window. It serves the grid as JSON lines on stdout and takes keys on stdin, which is how the bar embeds a real terminal inside its island. The bar starts it on its own, so it has to be onPATH.
Shell integration goes in automatically, into every shell it has a script for
whose rc file exists — not just $SHELL, because having your login shell in
one and your terminal in another is normal, and doing only one leaves it half
done. It is one marked block, it backs up what it replaces to *.k4.bak, it is
idempotent, and --quitar takes it out and leaves the file byte-identical.
Without it k4term still works, but nothing can know which command is running or how long it has been going: that is the shell talking OSC, and it is what the blocks are built on.
With the k4 bar installed you also get SUPER + Shift + T (the session in the
island) and SUPER + Alt + T (pop it out into a window, in whatever directory
it was left).
The icon is drawn, not shipped as a blob: python3 assets/icono.py renders
every size from assets/icono.py itself.
The interface is written in Spanish and translated from there, with the same file format the k4 bar uses — a flat JSON of original to translation, where an empty value means "leave this one in Spanish". A half-finished translation does not break anything; it just leaves the rest untranslated, which is what makes twenty strings worth sending.
English ships translated. Russian ships as an empty file, ready for whoever wants it. The catalogues are built into the binary, like the shell integration, so a freshly compiled k4term already speaks English with nothing to install.
It picks the language from LANG, or from idioma = en in your config if you
would rather say it explicitly. Three places are looked at, in this order:
~/.config/k4term/traducciones/<code>.json— yours, which wins. For fixing one string without recompiling.- The one built in here.
~/.config/quickshell/k4/traducciones/<code>.json— the bar's, as a fallback. Same format, 1383 strings: whatever is missing here and present there comes out translated for free.
What is not translated is what the terminal did not write. An agent asking
you something through k4term preguntar gets its question shown exactly as it
sent it — only the frame around it changes language.
A long command that finishes while you are looking somewhere else raises a
desktop bubble, and so does the bell. Programs can ask for one themselves with
OSC 9 or OSC 777. With the bar in front none of this happens: it already
shows the pill and the dot, and telling you twice is worse than not telling you.
ctrl+shift+S opens the server list, read from ~/.ssh/config (plus k4's own
extras in ~/.config/k4term/hosts.json: favourites, tags, tint, tunnels).
A server may also carry a password, for machines that ask for one instead of using a key. Two things are worth knowing before you use it:
- It is stored in
~/.config/k4term/claves.jsonwith600permissions in clear text — the same deal as an SSH key without a passphrase: anyone who has your user account has it. It never goes into~/.ssh/confignor intohosts.json, because those get opened, shown and copied around without a second thought. If a working Secret Service ever shows up on the machine,servidores::ruta_clavesis the single place to change. - It is delivered by the terminal itself: k4term watches the PTY and types it
when the other side asks. No
sshpass, no extra binary — and, for the same reason, it only works inside k4term (the window or the island session), not in someone else's terminal. - «When the other side asks» is narrow on purpose, because the cost of getting
it wrong is typing your password somewhere else. Three things have to hold:
the prompt must be the last thing written — a prompt leaves the stream
parked there waiting, so
grep password:or a log line is not one; the connection must still be running — the shell integration says when thesshcommand ended, and that disarms the wait, so asudoafterwards never gets it; and it is armed for at most half a minute, and fires once.
Saved hosts get StrictHostKeyChecking accept-new in their block, so the
first-time fingerprint question does not interrupt the connection. A key that
changes still stops it, which is the case that matters.
Anything running inside k4term — an AI agent, a script, a build — already has
your shell, so it can run ssh on its own. What it cannot do is type a
password: its commands go through pipes, not through the PTY the terminal
watches. Handing it your password would hand it everything.
So it gets something else. ctrl+G on a server opens the agents' door:
- a dedicated key,
~/.ssh/k4-agentes, created on the spot if it is not there and sent withssh-copy-id— you see the command run, because it asks for your password and that is not done behind your back; - a dedicated alias,
<server>-agentes, pointing at the same machine but withIdentitiesOnly yes, so that key and only that key gets offered. On the server side you can then restrict it (restrict,command=…) inauthorized_keysand know that whoever came in through that door came as the agent, not as you; - revoking is
ctrl+Gagain: the alias goes, and the terminal runs the command that deletes that key's line over there. The key carries a mark (k4-agentes@<your machine>) precisely so the line can be found.
Servers with the door open show a ⚙ in the list. The -agentes aliases do
not show up as entries of their own: they are a permission on a server, not
another place to go.
Every session also gets the names of your servers in its environment —
K4_SERVIDORES, and K4_SERVIDORES_AGENTES for the ones with the door open.
Names only: no hosts, no users, no secrets. It is enough for an agent to know
that casa exists and offer ssh casa instead of asking you for the IP. The
list is read when the session starts, so a server added afterwards shows up in
the next one.
Common terminal behaviour modern TUIs need: DSR replies, OSC title tracking and OSC 52 clipboard, OSC 10/11 colour queries, OSC 4/104 palette updates, OSC 9 and 777 notifications, SGR mouse modes, IME composition, DEC Special Graphics and box drawing.
And the two that took real work:
- Kitty keyboard protocol (
CSI ? u,CSI > u,CSI < u,CSI = u) — what Helix, Neovim and Zellij ask for so they can tellctrl+shift+letterapart fromctrl+letter, which legacy encoding turns into the same byte. Four of the five flags are served: report event types is not, because only key presses reach the terminal here, and saying yes would leave the app waiting for releases that never come. The query answers what is actually on. - Kitty graphics protocol — see above.
apps/k4term: the windowapps/k4term-isla: the same session with no window, for the bar's islandcrates/ghostty_vt_sys: Zig build + C ABI for the Ghostty VT corecrates/ghostty_vt: safe Rust wrapper over the C ABIcrates/gpui_ghostty_terminal: GPUITerminalView+ input/selection/rendering gluecrates/k4term_puente: everything k4term knows about the house — the bar's theme, the shell's OSC markers, saved servers and passwords, settings, saved commands, notifications, and the agent channel. Kept apart so that a k4term with no bar in front simply stays quiet.examples/:vt_dump,basic_terminal,pty_terminal,split_pty_terminal
This is not a from-scratch stack. The embedding work underneath — the Zig
build and C ABI over Ghostty's terminal core, the safe Rust wrapper, and the
GPUI TerminalView with its input, selection and rendering glue — comes from
Xuanwo's gpui-ghostty, Apache-2.0,
whose copyright notice this repository keeps in LICENSE. k4term is what got
built on top: the two applications, the k4 bridge, and the additions to the
embedding layer that they needed.
The stack it inherits is minimal, pinned and testable:
- VT parsing/state: Ghostty's terminal core (vendored as a submodule)
- Rendering/UI: GPUI (from Zed), with a custom renderer (no Ghostty renderer reuse)
- Ghostty is vendored at
vendor/ghosttyand pinned to tagv1.2.3. - Zig is pinned to
0.14.1(required to build the vendored Ghostty core). - GPUI is consumed from Zed; the revision is whatever
Cargo.lockholds. It is deliberately not repeated here — this line used to name a commit that had not been the one in the lockfile for a long while, and a pin written in two places is a pin that lies in one of them. wuffs(Ghostty's image decoder) comes fromvendor/ghostty/pkg/wuffs, out of the submodule this repository already vendors. It is what makes the graphics protocol able to read a PNG.
- Initialize submodules:
git submodule update --init --recursive- Install Zig (pinned) into
.context/zig/zig:
./scripts/bootstrap-zig.sh- Build and test:
cargo testNotes:
crates/ghostty_vt_sysrequireszig. Ifzigis not inPATH, it will use.context/zig/zig.- You can also set
ZIG=/path/to/zigto override discovery.
VT dump:
printf '\033[31mred\033[0m\n' | cargo run -p vt_dumpGPUI demos:
cargo run -p basic_terminal
cargo run -p pty_terminal
cargo run -p split_pty_terminalCrate root re-exports the stable entry points:
TerminalConfig,TerminalSessiondefault_terminal_font,terminal_font_featuresFigura,Rgbview::{TerminalView, TerminalInput, Appearance, Bloque, Atajo, …actions}
Embed-friendly options:
- Disable window title updates (useful when embedding into a host app that owns titles):
use gpui_ghostty_terminal::TerminalConfig;
let config = TerminalConfig {
update_window_title: false,
// Images and scrollback are the host's call. Zero on `imagenes`
// turns the graphics protocol off entirely, which is the honest
// answer for a host that cannot draw one.
imagenes: 0,
..TerminalConfig::default()
};Everything the terminal cannot decide by itself — where the servers live, what
counts as a password prompt, what to do with a note — is registered by the host
through registrar_*. Register nothing and those doors simply do not exist:
no button is drawn and no key does anything.
This project is licensed under the Apache License, Version 2.0. See LICENSE.
Spanish UI with translation files in traducciones/ — English
included, Russian waiting for a translator.
This repository vendors Ghostty as a git submodule under vendor/ghostty; third-party code remains under its respective licenses.







