Skip to content

Repository files navigation

k4term

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.

Follow @k4ditano on X License: Apache-2.0 Built on Ghostty + GPUI

k4term running: a git log with coloured hashes and a cargo test run, with the block rules down the left margin

git clone --recursive https://github.com/k4ditano/k4term && cd k4term
./scripts/bootstrap-zig.sh      # unless you already have Zig 0.14.1
./instalar

Linux 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.

The terminal your agent can talk to

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.

How that works ↓


What you get

Two command blocks, one green and one red, with their exit status at the end of the prompt line The same two blocks with the first one folded into a single summary line
Blocks that know how they ended✓ 3 s, ✗ 1 · 2 s, right where you are already looking. Fold one away and four hundred lines become one. Everything below moves up.
The server picker listing three saved hosts The saved commands picker with four entries
Your servers, out of ~/.ssh/config — with passwords it types for you and a separate SSH door for agents. Saved commands — what Warp calls workflows. It writes them at the prompt; running them is your call.

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.

Images, drawn over the grid

The k4term icon rendered inline in the terminal

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.


Agents

A modal in the middle of the terminal reading 'Te preguntan desde dentro de esta terminal — ¿Borro target/ y reconstruyo desde cero?' with three numbered options

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?" && hacerlo

The 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.

Shortcuts

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.

Settings

The settings panel with ten rows: font size, glass, cursor trail, quiet mode, colours, cursor shape, blinking, ligatures, bell and scrollback

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 Font

The 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.

The two binaries

  • 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 on PATH.

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.

Language

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:

  1. ~/.config/k4term/traducciones/<code>.json — yours, which wins. For fixing one string without recompiling.
  2. The one built in here.
  3. ~/.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.

When something wants your attention

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.

Saved servers and passwords

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.json with 600 permissions 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/config nor into hosts.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_claves is 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 the ssh command ended, and that disarms the wait, so a sudo afterwards 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.

The agents' SSH door

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 with ssh-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 with IdentitiesOnly yes, so that key and only that key gets offered. On the server side you can then restrict it (restrict, command=…) in authorized_keys and know that whoever came in through that door came as the agent, not as you;
  • revoking is ctrl+G again: 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.

Compatibility Notes

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 tell ctrl+shift+letter apart from ctrl+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.

Workspace Layout

  • apps/k4term: the window
  • apps/k4term-isla: the same session with no window, for the bar's island
  • crates/ghostty_vt_sys: Zig build + C ABI for the Ghostty VT core
  • crates/ghostty_vt: safe Rust wrapper over the C ABI
  • crates/gpui_ghostty_terminal: GPUI TerminalView + input/selection/rendering glue
  • crates/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

Credit where it is due

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)

Version Pinning

  • Ghostty is vendored at vendor/ghostty and pinned to tag v1.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.lock holds. 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 from vendor/ghostty/pkg/wuffs, out of the submodule this repository already vendors. It is what makes the graphics protocol able to read a PNG.

Build Prerequisites

  1. Initialize submodules:
git submodule update --init --recursive
  1. Install Zig (pinned) into .context/zig/zig:
./scripts/bootstrap-zig.sh
  1. Build and test:
cargo test

Notes:

  • crates/ghostty_vt_sys requires zig. If zig is not in PATH, it will use .context/zig/zig.
  • You can also set ZIG=/path/to/zig to override discovery.

Running Examples

VT dump:

printf '\033[31mred\033[0m\n' | cargo run -p vt_dump

GPUI demos:

cargo run -p basic_terminal
cargo run -p pty_terminal
cargo run -p split_pty_terminal

Public API (gpui_ghostty_terminal)

Crate root re-exports the stable entry points:

  • TerminalConfig, TerminalSession
  • default_terminal_font, terminal_font_features
  • Figura, Rgb
  • view::{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.

License

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.

About

Terminal emulator built on Ghostty's VT core and Zed's GPUI — foldable command blocks, kitty graphics and keyboard protocols, and a channel the AI agents running inside it can talk to.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages