Skip to content

Layout follow-ups: per-pane shell, eager-tab names, JSON schema#58

Merged
thdxg merged 10 commits into
mainfrom
claude/layout-followups
Jun 7, 2026
Merged

Layout follow-ups: per-pane shell, eager-tab names, JSON schema#58
thdxg merged 10 commits into
mainfrom
claude/layout-followups

Conversation

@thdxg

@thdxg thdxg commented Jun 7, 2026

Copy link
Copy Markdown
Owner

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-pane shell:. A pane's shell now comes solely from its own shell: (else the Ghostty-config / login shell). Dropped the unused defaultShell plumbing through LayoutBuilder/LayoutReconciler.

2. Eager-warmed tabs show their running process in the tab name

A tab started 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. Now the title callback is wired when warming, so the tab name tracks the running process immediately. (currentPwd already worked off-screen — it's set directly by the callback dispatcher.)

3. JSON schema for .macterm/layout.yaml

Adds schemas/layout.schema.json describing the format (recursive split/leaf nodes, cwd/run/shell, split/ratio/first/second, the first/second-requires-split constraint, 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

  • Validated the schema with jsonschema against 7 valid/invalid sample layouts (nested splits, plain-shell {}, per-pane shell, and the rejection cases: first/second without split, bad direction, typo'd keys, out-of-range ratio, missing tabs). All behave as the decoder does.
  • 279 tests pass; format/lint clean.
  • The schema's $id/modeline point at main, so they resolve once this merges.

thdxg added 3 commits June 7, 2026 12:20
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.
@github-actions github-actions Bot added area:ui Views, Settings UI area:state AppState, models, persistence area:tests Test changes area:docs Documentation labels Jun 7, 2026
…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.
@github-actions github-actions Bot added the area:terminal Terminal surface, ghostty integration label Jun 7, 2026
thdxg added 6 commits June 7, 2026 18:47
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.
@thdxg thdxg merged commit 925b366 into main Jun 7, 2026
5 checks passed
@thdxg thdxg deleted the claude/layout-followups branch June 7, 2026 11:52
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:docs Documentation area:state AppState, models, persistence area:terminal Terminal surface, ghostty integration area:tests Test changes area:ui Views, Settings UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant