Tool floats

A tool float is a command on a shortcut that floats over your work and closes again. Reach lazygit, a system monitor, or a dev server without leaving your pane layout. One ships built in, and you define the rest.

Scratch, the one that ships

; opens Scratch, a blank login shell in a card over your work. Press it again and the card goes away with the shell still running, so the next press puts you back where you were, scrollback and all. Type exit and it dies; the press after that gets a fresh one.

Each tab gets its own, the way each tab gets its own drawers, and closing a tab closes the shell in it. Scratch has no float = line and no Settings row, so a clean install still writes nothing to disk. Rebind it under Shortcuts like any other action, or hide its toolbar button with hide-toolbar-buttons = scratch. A hidden Scratch button comes back while its shell is busy out of sight, or while something in there needs you.

Add a tool float

Open Settings with , and choose Tools, then Add tool float. Give it a command to run, a shortcut to toggle it, and an icon for the toolbar. Point one at lazygit on L and that chord floats lazygit over your panes, then closes it on the same key.

12 import AppKit 13 14 final class PaneFocusRing: CALayer, PaneDecoration { // one ring per grid, reused across splits and closes, so a closed pane never leaves a lit border behind 15 let width: CGFloat = 1 16 let radius: CGFloat = 12 17 18 func path(in rect: CGRect) -> NSBezierPath { 19 let inset = rect.insetBy(dx: width / 2, dy: width / 2) 20 return NSBezierPath(roundedRect: inset, xRadius: radius, yRadius: radius) // inset keeps the stroke inside the pane bounds 21 } 22 23 func focusDidChange(_ pane: Pane) { // the grid calls this, the ring never asks. Focus lives in one place and the ring draws what it is handed 24 guard pane.isFocused else { isHidden = true; return } 25 isHidden = false 26 setNeedsDisplay() 27 } 28 } 29 30 extension PaneGrid { // Sources/ZenTerm/Panes/PaneGrid+Focus.swift holds the movement, the grid holds the state 31 func focus(_ direction: Direction) { 32 guard let next = neighbor(of: focused, in: direction) else { return } 33 focused = next 34 ring.focusDidChange(next) 35 } 36 37 func split(_ axis: Axis) { 38 let pane = Pane(profile: focused.profile) 39 insert(pane, after: focused, along: axis) // ⌘D splits down the vertical axis 40 focused = pane 41 } 42 } 43 44 struct FocusMode { 45 var isOn: Bool = false 46 var dimmed: CGFloat { isOn ? 0.35 : 1 } // unfocused panes fade, the focused pane holds its color so the eye lands on one place 47 } 48 49 // The grid owns focus, the ring only draws it. A split that closes hands focus back before the ring redraws. 50 extension PaneGrid: FocusReceiver { 51 var focusedPane: Pane { focused } 52 } 53
[1] Status
zen-term → feature/pane-focus-ring
[2] Files - Worktrees - Submodules
▼ ▪ Sources/ZenTerm
▼ ▪ Panes
M ▪ PaneFocusRing.swift +24 -6
M ▪ PaneGrid.swift +9 -3
?? ▪ FocusMode.swift
▼ ▪ Tabs
M ▪ TabBarView.swift +12
▼ ▪ Settings
M ▪ AppearanceSection.swift +5 -5
▼ ▪ Tests/ZenTermTests
?? ▪ PaneFocusTests.swift
1 of 9
[3] Local branches - Remotes - Tags
* feature/pane-focus-ring
1d main ✓
2d feature/tool-float-sizing ✓ ↓3
4d feature/settings-appearance ✓
1 of 4
[4] Commits - Reflog
c6e1e36 ● ○ Draw the focus ring inside the pane
e9ea16d ● ○ Keep the ring visible while a pane resi
a8b1197 ● ○ Move pane focus state onto the grid
6b1eb59 ● ○ Restore focus after a split closes
1902c54 ● ○ Add PaneFocusTests for the ring path
e43f5b0 ● ○ Trim the ring radius to match the pane
1 of 67
[5] Stash 0 of 0
[0] Unstaged changes
Δ Sources/ZenTerm/Panes/PaneFocusRing.swift
 
• func path(in rect: CGRect) -> NSBezierPath {
// Inset by half the stroke so the ring stays
// inside the pane it belongs to.
let inset = rect.insetBy(dx: width / 2, dy: width / 2)
- return NSBezierPath(roundedRect: rect, xRadius: radius)
+ return NSBezierPath(roundedRect: inset, xRadius: radius)
}
 
• func focusDidChange(_ pane: Pane) {
guard pane.isFocused else { isHidden = true; return }
isHidden = false
setNeedsDisplay()
}
 
// The grid owns focus. The ring reads it and draws,
// so a split never leaves two rings lit at once.
func focus(_ direction: Direction) {
guard let next = neighbor(of: focused, in: direction)
else { return }
focused = next
ring.focusDidChange(next)
}
 
// Focus mode dims every pane the ring is not on.
func dim(_ pane: Pane) -> CGFloat {
pane.id == focused.id ? 1 : FocusMode.dimmed
}
1 of 4
Stage: <space> | Commit: c | Stash: s | Discard: d | Reset: D | Keybindings: ?
Donate Ask Question 0.62.2
1 editor
2 build
3 git
A tool float: lazygit on ⌘⇧L

The same recipe works for btop, gh dash, or a dev server. Each float you add gets a toolbar button, a command-palette entry, and the shortcut you set.

Keep it warm

A float starts fresh on each open by default. Its persist setting changes that:

  • none closes the process when you dismiss the float, so each open is fresh.
  • dir keeps it alive per directory. The first open in a repo is cold, and reopening in the same repo is instant.
  • window keeps it alive for the window, reachable from any tab.

Keep lazygit on dir and it reopens on the same repo without rebuilding its status.

When a float needs you

Run an agent in a float and it can ask for you while the float is closed. Its toolbar dot takes your theme's attention color, and ZenTerm posts a notice naming the tab and the tool. Switch on that notice opens the float and takes you to it. Showing the float answers the notice.

A long command that finishes in a closed float posts a notice as well, and the dot turns your theme's positive color. That notice names the tab rather than the tool, and Switch goes to the tab. attention-toast and completion-toast in your config decide whether either one waits to be answered or clears itself.

Open it only in a Git repo

Set git on a float and it opens inside a Git repository and nowhere else. Bind lazygit that way and L stays quiet in a plain directory, which is what you want from a tool that has nothing to show without a repo.

Edit the file directly

Settings writes each float to a float = line in your config. The configuration reference lists every field a float takes, including a pinned working directory, its position in the toolbar, and the fraction of the tab it covers.