Ovvi

Layout and sizing

The split tree, drawing, dividers, the keyboard card, motion, keyboard moves, zoom, PTY size, selection per page and seen

What herdr returns

Each herdr tab is a split tree. Every split has a direction (right or down) and a ratio. herdr stores the tree across restarts. session.snapshot.layouts[] gives each tab's layout as a flat list:

{"tab_id":"w1:t1","area":{"width":120,"height":40},
 "panes":[{"pane_id":"w1:p1","rect":{…}},
          {"pane_id":"w1:p3","rect":{…}},
          {"pane_id":"w1:p2","rect":{…}}],
 "splits":[{"id":"split_0_root","direction":"right","ratio":0.55},
           {"id":"split_1_1","direction":"down","ratio":0.5}]}
  • A split's id is split_<n>_<path>, as herdr writes it:
    • <n> is the split's place in splits[], counted from 0. The list is depth-first, with a parent before its children.
    • <path> is root for the root split. For every other split it has one digit per step from the root: 0 = first child, 1 = second child.
    • Example: split_1_1 is the second split in the list, and it is the root's second child. A split under its first child would be split_2_10.
  • panes[] is in depth-first order, first child before second.
  • For layout.set_split_ratio, path is <path> as booleans (0 → false, 1 → true). The root is [].

Rebuilding the tree

build(path) = split at path exists ? Split(dir, ratio, build(path+0), build(path+1))
                                   : Leaf(next pane from panes[])
  • If the leaf count does not equal panes.count, the app falls back to layout.export {tab_id} (the same tree, nested). The order is depth-first in herdr's source but not a documented contract, hence the check.

Drawing and resizing

  • A tab page fills the window beside the sidebar, below the title row. Each split divides its rectangle by the ratio, with a fixed 8 pt gap between cards. The gap is the divider. herdr's cell rectangles are not used for drawing.
  • Each terminal card tells herdr its own size in cells, so the PTY fits the card.
  • Resizing works on the split tree only. To put a pane elsewhere, move it.

Dividers

  • Hit area. Each split's 8 pt gap is its divider, with a 16 pt hit area centered on it and the column or row resize pointer. On hover, and while it is dragged, a grip shows: a 36 × 3 pt capsule (3 × 36 pt for a side-by-side split) in the pane buttons' color (white 0.42 in dark, black 0.45 in light; 0.6 and 0.65 with Increase Contrast). It fades in over 0.2 s.
  • Drag. The split moves in the view at once. The app holds its own ratio for that split until herdr's snapshot shows a new ratio, herdr refuses the write, or herdr already has that ratio. On release the app sends layout.set_split_ratio {tab_id, path, ratio}, with the ratio kept in 0.1–0.9. Terminals keep their grid until the drag rests for 150 ms, so a drag does not flood herdr with PTY resizes or reflow at every step.
  • Double-click a divider: ratio 0.5.
  • Equal splits. herdr halves the pane it splits (50 / 25 / 25), and a closed pane's neighbor takes its place. With Settings ▸ General ▸ Balance splits automatically on (the default), the app evens out the run of same-direction splits that holds a pane it opens or closes, so three panes in a row get a third each, as in Rex:
    • A pane opens: a split (the header buttons, the menus, ⌘D, ⇧⌘D), a drop or Move Pane to Tab, and a card opened beside another (a link, a file, a plugin, ovvi open, ovvi agent). In the same write, the app reads a fresh snapshot and evens the new pane's run.
    • A pane closes in the app: in the same write, the app reads herdr's tree, closes the pane, and evens the run the pane was in. The page shows the evened run as the card goes, so the cards move once.
    • Another client opens a pane (herdr's own window, herdr pane split), or a pane closes by itself (its shell exits), from another client, or moves to another tab: the app evens its run once a snapshot shows it, if the tab did not change meanwhile. Here the cards move twice: herdr's change shows first. Every Ovvi with the setting on does this, for every client's panes.
    • A balance herdr refuses never fails the open or the close.
    • A child split in the other direction counts as one unit.
    • Each split gets "units on its first side ÷ units under it".
    • One layout.set_split_ratio per split whose ratio changes, then the app reads the snapshot.
    • With the setting off, the app never evens a run by itself: splits halve and closes leave herdr's layout, as in herdr's own window.
  • Balance (⌥⌘=, View ▸ Pane ▸ Balance Splits) applies the same rule to every split of the selected tab, with the setting on or off; an even tab sends nothing.
  • Resize Pane (View ▸ Pane and the palette; no key) sends pane.resize {pane_id, direction, amount: 0.05} for the keyboard card. herdr moves the nearest divider that way: right and down raise its ratio, left and up lower it. With no divider that way, herdr returns changed:false.
  • Accessibility. Each divider is an adjustable element, "Divider between ‹a› and ‹b›", where ‹a› and ‹b› are the first pane title of each side. Its value is the ratio as a percent. Increment and decrement move it 5 % and send the ratio. Its identifier is divider.<tab id>.<path>, with the path as 0 and 1 digits, or root for the root split.

The keyboard card

Every card is drawn at full strength, in a key window and in one that is not (D64). The keyboard card takes the keyboard-card fill and stroke, the others the plain card fill, from the look's tokens (Theme and accessibility). In Glass the two are close: the keyboard card is only a little brighter.

  • A window that watches the session dims every card's header and terminal content (its host view, not an overlay, so card edges stay crisp) to 0.85 (D66).
  • The card's accessibility value is "loading" until herdr's frames settle at the card's size, "failed" while its stream is stopped, and empty otherwise.

Layout motion

  • When the tree changes (a split, a close, a move, a ratio from herdr) or a card zooms or restores, the cards move and size over 0.13 s, ease-out (0.2 s feels slow). A new card slides in from its side and fades in over half the slide (0.06 s) as the others make room, its edge on theirs; a closed card fades away as the others grow (a scale would leave its terminal view at full size); a zoomed card grows to the full page and back while the others fade.
  • Each card and divider is placed in the page by the rectangle the tree gives it, and keeps its view (by pane id, or the split's path) whatever the tree does. Nested stacks, one per split, would make a new card for a pane whose leaf becomes a split or whose sibling closes, so it would jump.
  • Core Animation moves the cards, in the render server: each card is its own hosting view, and its frame takes its new place at once while an animation of its layer goes from the old place to the new one. The main thread draws none of the slide's frames (only the fades of a closed card and of a zoom's other cards run there), so herdr's answer, a new terminal or a web page that loads meanwhile drops none of them. A page's card draws its surface (fill, rim, edge, the light Glass shadow) as layers of its own, which the render server draws at each frame's size: the same before, during and after the motion, with the keyboard card's 1 pt border. Each card makes one motion, to a place known when it starts: its content lays out once, at its new size, as the motion starts, and shows only within the moving card (rounded, 10 pt). Its terminal or web page takes the new size then too, so nothing lays out again when the card lands. A window resize and the sidebar size the cards at every step, with no animation of their own.
  • A split shows at once under a stand-in id, in the place herdr will give it. herdr's tree takes over once it puts every pane in the same place; a snapshot read before the split's swap or balance does not move a card (Writes). After the split's writes, the second snapshot takes over anyway (another client may have changed the tab).
  • A split's new card slides in with the others, from the same frame, with its chooser already drawn. The window cannot be dragged during the slide: AppKit then skips its window drag region, which it would work out again at every frame. A kept page out of view does not draw its cards again for a split in the shown tab.
  • A divider drag follows the pointer with no animation.
  • Reduce Motion: no animation.

Measuring the slide

-recordLatency YES gives a record of each split's frames: the display link's frames for 1.2 s at the display's full rate, the hitches (a frame over one and a half display frames), the main thread's CPU time, each hitch with its CPU time, and the steps in that time, each with the CPU time of its frame: a snapshot applied (apply:‹its own ms›), the chooser's list shown (list), a terminal surface made (surface), a terminal shown at its size (ready, one for each terminal) and a pane's program known (program). A hitch with little CPU time waited on the render server. The record's frames are the main thread's: the slide's own frames, in the render server, show in a screen capture or Instruments' hitches. The on-demand UI test testSplitsKeepTheDisplayRate splits beside a shell, a program that redraws all of its screen 30 times a second, File Explorer, Git, Markdown and a browser, and among six panes. It runs under XCUITest, whose accessibility client takes about a quarter more of the main thread. A Release build that a script clicks, with no XCUITest, gives the numbers that count.

-recordMotion YES gives each card's place, its surface's and its content's laid out size at each display frame, from a change until 0.4 s after the last one. The on-demand UI test testSplitsMoveEachCardOnceToItsPlace reads them for ⌘D, a third card, ⇧⌘D, Split Left and Up, zoom, restore, a divider drag and close, on a terminal, File Explorer and a browser, in Glass and Classic, dark and light: each edge moves one way to its place, never past it, in steps the ease-out curve allows, lands once, and stays; the surface stays on the card; the content lays out once.

Moving the keyboard

The keyboard card is the app's own (see below). Keys move it, and never call herdr's focus methods (D13):

  • ⌥⌘ and an arrow (View ▸ Pane ▸ Select Pane Left, Right, Up, Down): the keyboard goes to a card next to it that way, picked from the tab's split tree at once, with no herdr call: of the cards along that edge, the one used last, else the one that shares most of the edge, the top (or left) one on a tie, as in Ghostty. So → then ← comes back to the same card. herdr's pane.neighbor takes a round trip per key and picks the card at the middle of the edge.
  • ⌘[ and ⌘] (Select Previous Pane, Select Next Pane): the previous and next card in the tree's order (left before right, top before bottom, as in Ghostty), round the ends.
  • A split (the header buttons, the menus, the palette, ⌘D, ⇧⌘D) gives the keyboard to the new pane once a snapshot shows it, as in Ghostty. Its card is lit at once, empty while herdr makes it. ⌘D pressed again meanwhile splits that newest card (herdr's pane once it is made), so three quick ⌘D give four cards left to right with the keyboard on the last; the panes made on the way join the keyboard history before it, so ⌘W steps back through them.
  • ⌘W closes the lit card at once, also a split card that herdr has not made yet. The card before it in the quick splits is then lit. A split that herdr has not started is not made. A pane that herdr makes for a closed card is closed in the same write, so it never shows. Three quick ⌘D and three quick ⌘W leave the one pane there was.
  • Moving the keyboard to a card that a zoom hides restores the zoom.
  • When the keyboard card closes, the keyboard goes to the card of its tab used before it (most recent first), as closing a tab goes to the tab used before it; else to herdr's focused pane of the tab.
  • ⌥⇧⌘ and an arrow (Move Pane): swaps the keyboard card with its neighbor that way, with pane.swap {pane_id, direction, focus:false}. The keyboard stays with the pane.
  • After a move to a new tab, another tab or a new space, the window selects the tab the pane went to, and the pane keeps the keyboard. A pane moved to another space gets a new pane id; its terminal and card stay.
  • Focus follows mouse (View ▸ Pane ▸ Focus Follows Mouse, off by default): a card gets the keyboard when the pointer rests on it for 0.15 s. Only in the key window, never while a mouse button is down, and never while the user types in a text field (the palette, a rename, a filter). While the pointer rests, the app checks again every 0.15 s, so a rename or a drag that ends under the pointer still counts; it moves the keyboard once. Never while a menu is open, and only for cards of the selected tab.

Zoom is the app's own

  • One zoomed card per page in each window, in memory.
  • The zoomed card takes the whole page; the other cards keep their rectangles, hidden under it. The same views stay in place, so no terminal view moves between hosts and no other PTY resizes. (Drawing the zoomed card in a layer of its own would move its terminal view to a new host, and SwiftUI does not give it back on restore.)
  • herdr's pane.zoom is not used. It moves herdr's focus, marks the whole tab seen, and makes herdr refuse moves into or out of that tab.
  • If a herdr TUI user zoomed a tab, the app ignores that flag and draws the full layout. A move into that tab returns zoomed_tab, which the app shows.

PTY size has one owner

  • One window controls each session (D66), so the PTY of a pane follows the card size in that window. A window that watches the session opens observe, which clips or pads to its own card size (Control between windows).
  • While our control stream is attached, herdr locks that PTY to our size and ignores herdr TUI layout resizes. After release the PTY keeps our size until a herdr TUI lays the tab out again.
  • herdr's layout rectangles never show the PTY size. A control frame's width and height do.

Selection and "seen" are the app's own

  • Each window has its own machine, open pages, page in view and sidebar width (Data).
  • Selection is per page. Each page of a window keeps its selected space, the selected tab of each space, the keyboard card of each tab, its zoomed card, and a tab it just made (selected once a snapshot shows it). The window's page holds them in memory, not the session's HostStore, so two windows on one session select on their own. Switching pages brings the selection back.
  • A tab that is gone falls back to herdr's active tab of that space, else its first tab. A space that is gone falls back to herdr's focused space, else the first one.
  • One PaneViewRegistry per window holds the terminals of all its pages. Only the shown tab of the page in view streams and draws; the cache keeps the 25 most recently shown other terminals streaming, over all pages of all windows (D46, What streams).
  • Control is per session, across windows (D66): the window where the user last typed or clicked in a terminal controls that session's panes. It is not part of a window's selection.
  • The card with the keyboard is the app's own too, one per tab in each window: coming back to a tab gives the keyboard to the card the user last used there. herdr's focused pane only picks it the first time a tab is shown, the same kind of fallback, and the app never tells herdr.
  • The app never calls herdr's focus methods (pane.focus, tab.focus, workspace.focus, agent.focus). Each of them moves every attached herdr TUI to that tab and marks the tab seen (herdr source, and seen in our tests). A herdr TUI may be open next to the app, and it must not jump.
  • So the app tracks "seen" itself, in memory, per agent, from completion_seq:
    • herdr gives every agent status change a number from one counter per server. When an agent finishes (working or blocked → idle), completion_seq is the number of that change. At the agent's next status change, for example when it starts working again, herdr sets it back to null. So an unseen finish clears when the agent starts working again, and the agent then shows Working's icon.
    • "Finished, not seen" is "not null and different from the seen value", not "greater than": the counter restarts at 0 when herdr restarts.
    • herdr's own done status is not used, because herdr goes straight to idle when a herdr TUI is already looking at that tab.
    • The first time the app sees an agent, it records the agent's current completion_seq as seen, so old finishes do not flood the sidebar or the notifications.
    • Leaving the agent's pane records the current value. The pane looked at is the keyboard card on the page in view of the last key window, or a floating window's pane while that window was key last; the app going to the background keeps it. While the user looks at a finish it stays Done.
    • The first snapshot after launch, or after a page opens, reports no changes. After a reconnect, changes count.
    • Each device keeps its own seen values, in memory in the session's store, shared by its windows. Seeing an agent on one device does not clear it on another.
    • Simpler choice: finishes that happen while the app is closed are not shown. Revisit when users ask for a list of finishes they missed.

On this page