# Development (/docs/haus/rooms/development)



**Development** is the room the command line lives in, painted from the same
[palette](/docs/haus/rooms/appearance) as everything else.

|          |                                                 |                                                                                                              |
| -------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Terminal | [Ghostty](https://ghostty.org)                  | title bar hidden, kitty graphics, one window per pane                                                        |
| Sessions | [zmx](https://github.com/neurosnap/zmx)         | every window's shell survives the window                                                                     |
| Shell    | zsh                                             | no framework, fzf-tab, sensible history, git aliases                                                         |
| Prompt   | [starship](https://starship.rs)                 |                                                                                                              |
| Files    | [yazi](https://yazi-rs.github.io)               | markdown, code and image previews                                                                            |
| Editor   | [Zed](https://zed.dev)                          | the default `$EDITOR`, themed; or VS Code, Cursor, helix, neovim, vim, nano ([below](#choosing-your-editor)) |
| Jumper   | [zoxide](https://github.com/ajeetdsouza/zoxide) | `cd` learns your habits; `cdi` opens a picker                                                                |

Plus `bat` over `cat`, `lsd` over `ls`, `lazygit` (`lg`), `delta` for git diffs,
and `fzf`, `fd`, `jq`, `gh`, `glow`, `fastfetch`, with `nix-index` and `comma`
so you can run anything else in nixpkgs without installing it.

## Enable it [#enable-it]

```nix
haus.developer.enable = true;
haus.developer.languages = [ "node" ];
```

The terminal stack is the floor and is always there. `haus.developer.*` brings
the *tools*, each with a sub-switch of its own: `haus.developer.toolbelt.enable`,
`haus.developer.git.enable`, an empty `haus.developer.languages`. Turning the
room off takes all three and leaves the floor standing.

## A window is a pane [#a-window-is-a-pane]

There is no multiplexer. A "pane" is a real Ghostty window, tiled by the
[Windows](/docs/haus/rooms/windows) room, and every window's shell runs in its
own [zmx](https://github.com/neurosnap/zmx) session, so `⌘W` **detaches** a
window rather than ending it. Images render for real, which a second emulator in
the middle of the pipe never managed, and there is one layout model rather than
two nested ones.

Ghostty's own `⌘T`, `⌘N`, `⌘Y` and `⌘F` are **unbound** on purpose, so they mean
here what they mean in every other app. There are also **no tabs and no
splits**, and that is what freed two of them: a tab or a split would nest a
second layout model inside one tile, when the tiler already tiles. `⌘D` and
`⌘⇧D` do nothing at all rather than splitting the surface, an easy chord to hit
coming from iTerm. Two keys stayed with Ghostty. `⌘C` is copy, and a `⇧`-drag
makes a selection over whatever TUI has taken the mouse for `⌘C` to take. `⌘⇧R`
is Ghostty's because it has to be: it works when nothing else in the window is
listening.

| Keys          | What happens                                                                                                                                   |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `⌘N` / `⌘⇧N`  | New shell **window**, in **this page's repo**. The plain one hops to the repo's **main checkout** from inside an agent worktree; `⇧` stays put |
| `⌘T`          | New **neutral** terminal: your home directory, no repo, and it takes you to the base workspace. `⌘⇧T` does nothing                             |
| `⌘W`          | Close the window, **detaching** its session. The shell keeps running                                                                           |
| `⌃D` / `exit` | End the shell, its **session** and the window with it, first press                                                                             |
| `⌘F` / `⌘⇧F`  | [Find](#finding-things) in this pane / across every pane                                                                                       |
| `⌘G`          | [Your review queue](#your-review-queue), if you turned it on                                                                                   |
| `⌘⏎`          | Spawn a coding agent in its own worktree, on **this page's repo**                                                                              |
| `⌘Y` / `⌘⇧Y`  | [yazi peek](#yazi-peek), with and without the worktree hop                                                                                     |
| `⌘L`          | Links picker: every URL in the pane's transcript or scrollback                                                                                 |
| `⌃⇥` / `⌃⇧⇥`  | Walk the lane pages by recency, like `⌘⇥` over apps                                                                                            |
| `⌘⇧R`         | [Un-break a cursed terminal](#when-the-terminal-goes-cursed)                                                                                   |

The ⇪ and ⌥ chords that tile these windows are [Keys](/docs/haus/rooms/keys).
`⌘N`, `⌘⏎` and `⌃⇥` are taken **only while Ghostty is frontmost**, so `⌘⏎` still
sends in Slack, `⌘N` still opens a window in your browser and `⌃⇥` still walks
that app's tabs. They live at the window layer because Ghostty has no keybind
action that runs a command, and `⌃⇥` needs that twice over: macOS eats the chord
for focus navigation before any app sees it. There is **no reload chord**, since
Ghostty applies a config change within a second and a `haus rebuild` lands on
the windows you already have, live agent conversations uninterrupted.

<Callout title="Opening a link">
  `⌘`-click opens the link under the pointer and `⌘`-hover previews it, no shift,
  even inside a program that has taken the mouse (an agent, `lazygit`, `vim`):
  Ghostty treats a `⌘`-click as a link click and never forwards it, so a bare
  click still belongs to whatever is running. `⇧` is the modifier Ghostty always
  keeps for itself, which is what lets a `⇧`-drag select over a mouse grab. On a
  link click it buys nothing.
</Callout>

### When the terminal goes cursed [#when-the-terminal-goes-cursed]

A program that dies mid-draw leaves garbage on screen, nothing echoed as you
type, and the mouse spraying escape codes into your prompt.

**`reset`** is the one you type, and here it is a shell *function*: macOS's
`reset` links to `tset(1)`, which still carries a one-second sleep added in 1979
so printer terminals could settle. This one repairs both halves of the damage,
the kernel's idea of your terminal and the terminal's own display state, in
about **8 milliseconds**, and throws away whatever you mashed at the dead
terminal instead of running it. Give it arguments and the real `tset` takes
over, sleep and all.

**`⌘⇧R`** is for when you can't type at all. It is Ghostty's own reset and needs
nothing from the shell, so it works on a shell stuck in raw mode with no echo;
typing `reset` into the readable screen it hands back finishes the repair.

### Your windows come back [#your-windows-come-back]

`⌘W` parks a window and `⌘Q` parks all of them: the shells keep running, the
builds keep building, the agents keep thinking, with nothing looking at them.
Start Ghostty again and **the first window puts the desk back**, one per parked
session, each rejoined to the session it belongs to, lanes landing on their own
repo pages. The same processes, not a snapshot replayed. Only the *first*
window, though, and never `⌘N`, which is always a new shell in the directory you
asked for. The palette's **Restore Terminal Windows** pulls the rest back later,
as do the [bar](/docs/haus/rooms/bar)'s agents pill, `⌘F`'s `⏎` and the
**Lanes** picker, one at a time.

So the pair worth having in the fingers is `⌃D` versus `⌘W`: **end** a shell you
are finished with, **close** one you want back. If you never end anything, every
start is a crowd. Set `haus.terminal.restoreWindows = false` if you would rather
the first window were just a window.

### The page owns the repo [#the-page-owns-the-repo]

Each repo's lanes tile on their own page, `T/<repo>`, so pounce work and haus
work don't pile onto one screen. Standing on one decides which repo the spawn
chords act on: `⌘⏎` opens its lane on **that** repo and `⌘N` its shell in it,
whatever the window under your cursor happens to be looking at.

It is a correction, not a takeover. The window still picks the exact directory
whenever that directory already belongs to the page's repo, so `⌘N` in a
subdirectory opens there and `⌘⏎` inside a worktree spawns a sibling lane. Only
a directory belonging to a *different* repo, or to none, is overridden, with
that repo's main checkout. Off a page the focused window decides, as before, and
`⌘⇧N` opts out everywhere, since it already means "don't move me". One caveat: a
`⌘N` window carries no name saying which page is its own, so the next re-sort
(<kbd>⇪</kbd> then `` ` ``) sends it home to `T&#x60;, while the lanes stay put.
**`⌘T` is the way out**, the one chord left meaning "a terminal about nothing".

The [launcher](/docs/haus/rooms/launcher#rows-that-are-listed-on-some-pages-only)
carries both chords as palette rows and adds two pickers of its own. **Lanes**
is fuzzy over every lane that has a window: `↵` focuses it or wakes a parked
one, and `/` plus a word searches what the agents are *saying* live. A lane
spawned seconds ago is listed at once, but what the picker knows *about* one,
its branch, its commit subject, whether the work landed, lags by up to fifteen
minutes until `⌘↵` forces a live read. **Pages** covers the pages themselves,
`↵` going to one and `⌥↵` throwing the focused window onto it; the bar's `page`
pill opens the same list, in move mode on a ⇧ or right-click, and typing a name
no page has yet makes it. That row is listed only while some page is open, so on
a Mac with none you open the first with `⌘⏎` and **Pages** comes back with it.

A lane's terminal is a window like any other, so `⌘W` detaches it and the agent
keeps thinking; the [AI room](/docs/haus/rooms/ai) has the rest of that
lifecycle. [Windows](/docs/haus/rooms/windows) is not required: with it off,
lanes open as ordinary macOS windows and the build warns once.

## Finding things [#finding-things]

`⌘F` opens a search overlay for the focused window, `⌘⇧F` the same overlay
across every window at once, and `Esc` puts you back. Results appear as you type
with the lines around each hit beside them. `⏎` jumps to the window a hit came
from, `^y` copies the line, and `^s` flips between the two scopes without losing
your query, resizing the overlay as it goes: one window's search covers that
window, every-window covers the whole desktop.

**In an agent window it searches the conversation, not the terminal.** Claude
Code and Opencode render in the alt-screen, which has no scrollback at all, so
searching the grid would only find what is on screen this second; the stored
conversation has all of it, collapsed tool output included. Codex falls back to
scrollback, carrying no conversation id to join to. Every other window gets the
whole of its scrollback.

## Your review queue [#your-review-queue]

`⌘G` opens [gh-dash](https://github.com/dlvhdr/gh-dash) in a near-fullscreen
floating window, themed like everything else and gone again on `q`. It's off
unless you ask:

```nix
haus.terminal.ghDash.enable = true;
haus.git.org = "your-org";     # or your own account
```

The first line gets you the tabs that are about *you*: issues you opened, ones
assigned to you, unread notifications, threads you're in. The second is what a
PR tab needs, since a PR tab is a GitHub search and a search needs a scope. Set
it and four more appear: **open**, **green**, **red** and **shipped**, where a
branch still building shows in neither green nor red. Leave `haus.git.org`
empty, right if you read several owners at once, and those four aren't
written.

## yazi peek [#yazi-peek]

`⌘Y` opens a floating file browser with live previews, covering exactly the
window you pressed it in and rooted at that window's directory, so it reads as
that terminal turning into a file browser. `Enter` on a file pages it
fullscreen; `Enter` on a directory opens a new window there, which makes peek
double as a directory chooser; `q` or `Esc` closes it and leaves your layout
untouched. Markdown renders through `glow`, code through `bat`, images through
`chafa`, and `Y` copies a file's *contents*, not its path. Summon it from a wide
window when you're browsing rather than glancing: yazi wants a parent column,
the tree and a preview side by side.

Every floating terminal haus summons wears a thin outline following the window's
corner curve, and stays above the tiled windows behind it whatever you click
next. Unpinned it would sink behind the first window you click, with Mission
Control the only way back:

```nix
haus.terminal.floatBorder = "accent";  # the default; also "grey", any accent name, or "off"
haus.terminal.floatOnTop = true;       # the default
```

Pinning raises the window's *level*, which only the app owning a window can do,
so haus pins the terminals it summons and nothing else. A floating FaceTime
window is a macOS limit rather than a missing setting.

### The grants a summoned popup needs [#the-grants-a-summoned-popup-needs]

Three grants sit behind one popup, and macOS books every one against whichever
app *summoned* it rather than against haus: Pounce for the chords and the
palette's own windows, SketchyBar for the bar's agent peek. **Automation** for
Ghostty is the pin, and declining it leaves those popups opening in front and
then sinking like ordinary windows. A second Automation grant, for System
Events, is *where* a popup lands, over the window you summoned it from; the
[launcher page](/docs/haus/rooms/launcher) covers that one. The bar's peek needs
a third, **Accessibility** for SketchyBar, because macOS gates driving System
Events on the calling app as well: Pounce holds it already for its key handling,
and until SketchyBar has its own the peek pins but opens wherever Ghostty last
left a window.

Each summon asks for one of the two Automation grants, and the popup that raised
a dialog is never pinned itself, so expect two dialogs and a third popup before
everything is pinned and placed. `haus permissions` has all three cards, under
the agents pill, if you dismissed them.

<Callout type="warn" title="A machine that hasn't updated in a while">
  Pounce releases before 2026.09.06 carry no Apple Events entitlement, so macOS
  never asks for Ghostty under Pounce and the Automation pane never lists it.
  `haus permissions` says so on such a machine, and `haus update` is the fix.
</Callout>

## Git aliases [#git-aliases]

Plain zsh, no framework, a finite set of shortcuts. The names follow the
Oh-My-Zsh vocabulary where alias sets agree and skip the ambiguous ones (`gl`,
`gr`, `gs`) where they don't. `g` itself is `git`.

|                   |                                                             |
| ----------------- | ----------------------------------------------------------- |
| Add               | `ga` `gaa` `gapa`                                           |
| Branch / checkout | `gb` `gba` `gbd` `gbD` `gbm` `gco` `gcb` `gcl` `gsw` `gswc` |
| Restore           | `grs` `grss` `grst`                                         |
| Commit            | `gc` `gca` `gcam` `gcmsg` `gcn` `gcp` `gcpa` `gcpc`         |
| Diff / log        | `gd` `gds` `gdw` `glo` `glog` `gloga`                       |
| Fetch / merge     | `gf` `gfa` `gfo` `gm` `gma` `gmc` `gmff`                    |
| Pull / push       | `gpl` `gpr` `gp` `gpf` `gpsup`                              |
| Rebase            | `grb` `grba` `grbc` `grbi` `grbs` `grt` `grv`               |
| Status / stash    | `gst` `gss` `gsb` `gsta` `gstl` `gstp` `gsts`               |
| Tag / worktree    | `gt` `gwt` `gwta` `gwtl` `gwtr`                             |

`grs` and `grst` are the file half of what `gco` used to do: `grs <path>` throws
away an unstaged change, `grst <path>` unstages one. Add, replace or remove one
per host:

```nix
haus.git.shellAliases = {
  gsync = "git pull --rebase --autostash";   # add
  gst = "git status --short --branch";       # replace
  gco = null;                                # remove
};
```

## Choosing your editor [#choosing-your-editor]

`haus.terminal.editorName` takes **zed** (the default), **vscode**, **cursor**,
**helix**, **neovim**, **vim** or **nano**: one choice that installs the editor,
sets your `$EDITOR` and points every "open in an editor" action at it. The three
apps arrive as casks, so `haus.homebrew.adopt` (on by default) claims a copy
already on your Mac rather than installing a second one; the rest come from
nixpkgs.

```nix
haus.terminal.editorName = "neovim";              # installed, and $EDITOR
haus.terminal.hijackFileAssociations = true;      # off by default: make it the
                                                  # opener for .json, .md, .nix, …
```

Zed comes set up: the Nebelung theme for your flavor and accent, following the
accent on every rebuild with no re-pick in Settings, the terminal's mono font,
and the Nix, TOML, Swift, HTML, Dockerfile and Make extensions, which Zed
installs the first time it opens with a network. It is also pointed at
[nixd](https://github.com/nix-community/nixd), the Nix language server every
haus Mac gets, so hovering a `haus.*` option in your host file tells you what
it does. Your own settings survive, since haus merges its few keys into
`settings.json` at activation and leaves the rest of that file alone. helix is
the other editor haus writes settings for, and it gets the same nixd wiring in
a `languages.toml` haus owns outright; the other five arrive in their own
colours, with nixd on PATH for you to point them at.

"Open in an editor" fits the editor. A terminal editor gets a new terminal
window cwd'd at the project; a GUI editor is handed the project folder and the
file, so the palette's **Nix Config** and the bar's nix pill land in Zed with
your config open as a workspace. To point haus at an editor it does **not**
install, set `haus.terminal.editor` instead: a shell command, host file only,
and it wins over whatever `editorName` chose.

```nix
haus.terminal.editor = "subl -w";                 # points at it; installs nothing
```

## GitHub, without the polling [#github-without-the-polling]

The bar's octocat pill, the PR column in the agent statusline and the lanes
popup all *ask* GitHub, every few seconds, forever, because asking is all you
can do when GitHub has no way to reach you. `haus.github` gives it one: a
Cloudflare tunnel points a hostname you own at a receiver on this Mac, GitHub
posts each event to it, and the three surfaces drop to a slow backstop
(`haus.github.backstop`) and refresh when something happens. The octocat pill,
whose interval was five minutes, now moves inside one.

```nix
haus.github = {
  enable = true;
  hooks = [ { scope = "org:your-org"; } ];
  tunnel = {
    enable = true;
    id = "the-uuid-cloudflared-printed";
    hostname = "hooks.example.com";
  };
};
```

The shared secret behind each delivery's signature is the one thing you supply.
The room declares it, `haus-secret --check` asks for it after the first rebuild,
and the same value goes into the hook's settings on GitHub; until it has one the
receiver stays dormant and says so in its log. That signature is the whole
authentication story: no token lives in this room, and nothing in it can change
anything on GitHub.

Which is why `hooks` above creates nothing. It *declares* what should exist, and
`haus doctor` diffs it against the hooks that really do and prints the one `gh`
command that closes the gap. The gap worth catching is a hook subscribed to four
of the five events you care about, healthy from every angle while the one state
you watch never arrives.

The bridge slows a poll down and never removes one, since GitHub sends no
heartbeat to tell "nothing happened" from "the tunnel died": coverage expires
when the machine stops being able to confirm it, so a laptop that loses the
tunnel goes back to polling rather than going quiet. `github-signal status`
answers all of it in one screen: is the receiver listening, is each declared
hook healthy, when did the last delivery land, which scopes are covered now.

Deliveries can also be handed on byte for byte to anything else on the machine
that speaks GitHub webhooks, signature header untouched, so the far end verifies
each one itself:

```nix
haus.github.forwardTo = [ "127.0.0.1:42787" ];   # trill's own bridge
```

## Ports have names now [#ports-have-names-now]

Two projects both default to 3000, the second dies on `EADDRINUSE`, and the tab
you left open now shows the other project's app with the other project's
cookies, because a browser thinks `localhost:3000` is one origin.

```nix
haus.portless.enable = true;
```

One proxy on `:443` owns the machine's ports, each app registers a hostname
against a port the proxy assigned, and you open `https://myapp.localhost`: no
number, real HTTPS, no browser warning. Different names are different origins,
so cookies and `localStorage` stop leaking between projects. Run `portless`
where you would have run `npm run dev`.

Lanes are why this lives in haus rather than one project's `devDependencies`:
`scruff` puts five agents in five worktrees of the *same* repo, all wanting that
one port,
and the one that quietly takes 3001 is worse than the four that die. Inside a
lane, run `portless-lane`:

```console
$ portless-lane npm run dev
portless-lane: wiggly-crane.myapp.localhost -> :5402
```

Each lane gets its own hostname and port, derived from the lane's name so they
are the same every restart, which is what lets you leave a tab open on
`wiggly-crane.myapp.localhost` for the life of the lane. Outside a worktree it
falls back to plain `portless`, so it is safe in a script that runs in both
places.

The HTTPS needs a certificate authority your browser believes. portless
generates one on your Mac, and haus will **not** install it during a rebuild: a CA that vouches for any name should not
arrive while you are looking the other way. It is a card in `haus permissions`
instead, one click, and `portless clean` takes it back out. Until you click it
everything works and every page opens on a warning.

## Options [#options]

[Every setting, with types and defaults](/docs/haus/reference/options#development).
