Layout follow-ups: per-pane shell, eager-tab names, JSON schema#58
Merged
Conversation
Remove the file-level `shell:` default from LayoutFile. A pane's shell now comes solely from its own `shell:` (else the ghostty-config / login shell). Simplifies the format — the file-level default was redundant with per-pane shells, which are more precise. Drops the now-unused defaultShell plumbing through LayoutBuilder.makePane / LayoutReconciler.BuildContext.
A tab warmed off-screen by SurfaceIncubator had a live shell but no onTitleChange callback wired (that's done in TerminalSurface.configure, which only runs when SwiftUI renders the pane). So its OSC-2 title never reached pane.title and the tab name stayed stale until first viewed. Wire onTitleChange when warming so the tab name tracks the running process immediately.
Ship schemas/layout.schema.json describing the layout format (recursive split/leaf nodes, per-pane cwd/run/shell, validation of the first/second-requires-split constraint). LayoutFile.yaml() now prepends a `yaml-language-server` modeline pointing at the schema, so saved files get completion/validation in editors using the YAML Language Server with no setup; hand-authored files can add the same line. README documents it.
…he shell GHOSTTY_ACTION_SET_TITLE is one-shot — it forwards to onTitleChange with no replay. A layout-spawned pane runs its `run` command and the program sets its title before SwiftUI's `configure` wires onTitleChange, so that title was dropped and the tab kept the shell's name (e.g. `nu`). Manually running a program worked because the callback was already wired. Cache the last title on the NSView and replay it whenever onTitleChange is (re)assigned, so the latest title is never lost regardless of wiring order.
Tab names were derived from the terminal's OSC title. With shell integration (Starship/ghostty) the title is a stream of prompt/cwd strings that overwrite a command's title within milliseconds, and ghostty's SET_TITLE carries no provenance — so a shell that titles itself from its prompt (nushell) is indistinguishable from a program setting its own title. The OSC title is unusable as a command name. Name tabs from the live foreground process instead, like tmux's automatic-rename on macOS: read the foreground pid's short kernel comm (proc_pidinfo PROC_PIDT_SHORTBSDINFO), a path-free basename. AppState polls every live pane (~250ms, republishing only on change), so the name tracks the running process — a command (btop), a nested shell (zsh launched from nu), or the pane's own shell when idle — regardless of titles. An OSC title arriving is just an extra command-boundary refresh signal; its string is unused. Also fix configuredShell falling back to $SHELL (the app-launcher's shell, /bin/zsh) instead of the login shell, which forced layout panes onto zsh; leaving config.command unset lets libghostty resolve the login shell. The idle-pane display fallback reads the login shell from the password DB for the same reason. Removes the now-dead OSC-title chain: TerminalTab.title, Pane.title, PaneSnapshot.title (titles aren't persisted — derived live), and the title cache; onTitleChange is now a parameterless command-boundary signal.
Layout save never recorded `shell:`, so a pane the user had dropped into a different shell (e.g. `zsh` launched from their usual `nu`) reopened in the default shell. Save now captures the foreground shell as `shell:` when it differs from the login shell — resolved to an absolute, launchable path via the kernel exec_path (ProcessInspector.runningShell). A pane idle in the login shell records no `shell:`, keeping the layout portable across machines. `run` and `shell` are mutually exclusive on a leaf: the foreground is either a command or a shell.
The layout format gains two ergonomic changes:
- A tab is now itself a node (with an optional `name:`) instead of
`{ name?, layout: <node> }`. A single-pane tab is just the pane
fields; the `layout:` wrapper is gone.
- A split node nests its fields under a `split:` mapping
(`{ direction, ratio, first, second }`) instead of the flat
`split: <dir>` + sibling keys, so `split:` reads as one unit.
tabs:
- run: "npm run dev"
- name: Dev
split:
direction: horizontal
first: { shell: /bin/zsh }
second: {}
LayoutTab decodes as a node-DTO plus `name`; LayoutNode's split case
reads the nested `split:` mapping. The JSON schema, README example,
CLAUDE.md, and all test fixtures move to the new shape. This is a
breaking change to the on-disk format — an old-format file no longer
parses (surfaced as LayoutFileError, not applied).
The reconciler matched a declared plain-shell leaf (no `run:`) to any idle pane positionally, ignoring which shell it was running — so a pane sitting in `nu` satisfied a declared `shell: /bin/zsh` and was reused, making the apply a no-op when it should have respawned. The idle-pane pool now tracks each pane's live shell name (via liveShellName → ProcessInspector.runningProcessName, the foreground comm). A leaf declaring a specific `shell:` reuses an idle pane only if it's running that shell (compared by basename); a leaf with no `shell:` still matches any idle pane positionally. So swapping a pane's `shell:` respawns it.
Documentation hygiene after the tab-naming and layout-format changes: - CLAUDE.md: ProcessInspector now resolves three ways (add runningShell); refresh the layout test-coverage rows for the shell save/match tests. - README: rename the layouts section and tighten its wording. Code tidy: the OSC SET_TITLE handler built a title string only to discard it (tab names come from the foreground process now). Drop the string construction and make the signal parameterless (GhosttyTerminalNSView.surfaceDidReportTitle). .gitignore: drop stale entries (`GhosttyKit/ghostty.h`, `/ghostty` — nothing produces those paths) and ignore the local `.codegraph/` tool dir.
# Conflicts: # CLAUDE.md
thdxg
added a commit
that referenced
this pull request
Jun 7, 2026
shell is per-pane only; a pane with no shell uses the login shell. The file-level shell field was removed in #58.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three small follow-ups to the declarative layout feature (#53) and eager-tab-start (#56), now that both are on
main.1. Shell is per-pane only
Removed the file-level
shell:default from the layout format — it was redundant with the more precise per-paneshell:. A pane's shell now comes solely from its ownshell:(else the Ghostty-config / login shell). Dropped the unuseddefaultShellplumbing throughLayoutBuilder/LayoutReconciler.2. Eager-warmed tabs show their running process in the tab name
A tab started off-screen by
SurfaceIncubatorhad a live shell but noonTitleChangecallback wired (that's done inTerminalSurface.configure, which only runs when SwiftUI renders the pane). So its OSC-2 title never reachedpane.titleand the tab name stayed stale until first viewed. Now the title callback is wired when warming, so the tab name tracks the running process immediately. (currentPwdalready worked off-screen — it's set directly by the callback dispatcher.)3. JSON schema for
.macterm/layout.yamlAdds
schemas/layout.schema.jsondescribing the format (recursive split/leaf nodes,cwd/run/shell,split/ratio/first/second, thefirst/second-requires-splitconstraint, enum'd direction, ranged ratio).LayoutFile.yaml()prepends a# yaml-language-server: $schema=…modeline, so files saved by Macterm get completion + validation in editors using the YAML Language Server (VS Code, Neovim, Zed, …) with zero setup; hand-authored files can add the line themselves.Notes
jsonschemaagainst 7 valid/invalid sample layouts (nested splits, plain-shell{}, per-pane shell, and the rejection cases:first/secondwithoutsplit, bad direction, typo'd keys, out-of-range ratio, missingtabs). All behave as the decoder does.format/lintclean.$id/modeline point atmain, so they resolve once this merges.