Imported from zerebos/ghostty-config (
AGENTS.md). Install upstream withnpx skills add zerebos/ghostty-config. Copyright stays with the author.
AGENTS.md: Ghostty Config
Operating guide for automated agents working in this repository. Read this before making any changes.
Quick orientation
- Runtime: Bun ≥ 1.3 required. All commands run from the repo root.
- Module system:
"type": "module"ES imports only, norequire. - Framework: Svelte 5 with runes. This is not Svelte 3/4. Do not use
writable,derived,readable, or any legacy store primitives. See the Svelte 5 section below.
Required checks: run before marking anything ready
bun run check # svelte-kit sync + svelte-check (type errors)
bun run lint # ESLint strict flat config
bun run test # unit tests via Vitest (bun is just the script runner)
All three must pass cleanly. Do not open or mark a PR ready if any fail.
Architecture: read before touching settings
These files form the core of the settings system with strict relationships between them. The triad that most changes must stay in sync is types.ts (shapes) → registry.ts (per-setting data: config key + widget) → navigation.ts (placement tree).
src/lib/settings/types.ts
Defines SettingInfo — the registry entry shape — as a union ScalarSettingInfo | RepeatableSettingInfo discriminated on the repeatable?: true value-shape flag. An entry carries config-key metadata (key, name, description, note, platform, since, default) plus an optional widget?: WidgetDef, the data-only discriminated union for widget selection + widget metadata. The union's job is compile-time safety: satisfies SettingsRegistry rejects any entry whose default shape (string vs string[]) or widget kind (ScalarWidgetDef vs RepeatableWidgetDef) disagrees with repeatable — do not collapse it back to one interface. There is no SettingDef union and no TypeToValue — the store is flat strings (see the store-flatten note below). Widget-metadata types (DropdownOption, FeatureDef, PillOption, SpecialValue) live here too, consumed by WidgetDef and the renderer.
src/lib/settings/registry.ts
The flat camelCase-keyed record of every setting. The export pattern is:
export const registry = { ... } satisfies SettingsRegistry;
satisfies without as const is intentional and must be preserved. It keeps repeatable: true as a literal so the SettingValues mapped type at the bottom of the file can resolve each key to string[] (repeatable) or string (everything else) — this is what replaced the old type/TypeToValue machinery. The store holds only strings; a registry default is a literal string ("13", "false", "" for unset) or a string[] for repeatable settings. Initializers may still mutate .default (it's a plain string), so don't add as const.
Each entry also carries its widget?: WidgetDef (omit it for a plain Text/RepeatableText input — that's the renderer's default). Everything about a setting except its placement lives on the entry, so an upstream Ghostty sync (defaults, descriptions, enum option lists) is a single-file edit. validateRegistry() runs at import time in dev (and in registry.test.ts) to check the data invariants types can't: option-backed widgets (dropdown/pill/theme) must include their own non-empty default among their options — the tripwire for upstream enum drift — and duration widgets must satisfy allowEmpty ⟺ unset ("") default.
src/lib/settings/navigation.ts
A typed tree of panels → groups → setting keys that drives the sidebar UI. Placement only — entries are bare camelCase registry identifiers; widget selection and widget metadata live on the registry entry, not here. (Group-level preview keys stay here: which preview renders above a group genuinely is placement.) validateNavigation() runs at import time in dev and throws if:
- any key in the nav tree doesn't exist in the registry (typo protection)
- any registry key isn't referenced anywhere in the nav tree (exhaustiveness)
Adding a setting to registry.ts without placing it in navigation.ts will throw at dev startup. Do not add a bypass or suppress this validation.
src/lib/settings/options.ts
Option lists derived purely from build-time data (the generated themes/macicons modules): computed once at module scope and referenced directly by widget defs in registry.ts. No runtime population step — the registry is complete at import time. Add new static option lists here (shared or large lists belong here; a short inline enum can stay on the widget def).
src/lib/settings/initializers.ts
Populates registry fields that require genuinely runtime data: OS detection for platform-dependent defaults, and (future) async sources like a native font list. Sync initializers run first via runSyncInitializers(); async ones after. Add new runtime-populated fields here, do not mutate the registry from a component — and if the data is static, use options.ts instead.
src/lib/settings/codecs.ts
The config store is flat strings (string | string[]), mirroring Ghostty's own string-at-rest model. Every widget binds the raw store string and converts through a codec here — including the Switch/Number/Range primitives (boolCodec/numberCodec), which parse/serialize internally rather than binding a native boolean/number. A codec is a co-located parse + serialize pair (Codec<T>), unit-tested as pure functions in codecs.test.ts (no component mount; precedent is keybinds.ts/keybinds.test.ts). Add new formats here — do not re-implement parse/serialize inside a component or in config.svelte.ts.
Symmetric vs. parse-and-validate: most formats are symmetric (parse(serialize(v)) round-trips), so they're a Codec<T>. When a format canonicalizes — serialize(parse(x)) !== x, as with durations ("90m" → "1h30m") — expose a parse-for-display + validate helper instead of a symmetric codec (see parseDuration/humanizeDuration).
src/lib/stores/config.svelte.ts
Live config state as a Svelte 5 $state object. Key exports:
config- the live state objectdefaults- static defaults snapshot used for diffingdiff()- returns only keys differing from defaults; the serialization source of truthdiffFromDefaults(conf)- same logic applied to an arbitrary config object (import preview)load(conf)- merges a parsed config into live stateresetSetting(key)/isNonDefault(key)- per-setting utilities
Theme colors are never written into this store — selecting a theme only sets the theme string. That is why diff() needs no exclusion logic to keep exports clean; do not reintroduce any "apply theme colors to config" helper.
src/lib/stores/theme.svelte.ts
The theme-layering view-model. For each color key (background, foreground, cursorColor, cursorText, selectionBackground, selectionForeground, palette element-wise), effectiveColors() resolves explicit override > active theme > app default; preview.mode (ephemeral view state, never serialized) picks which half of a light:A,dark:B dual theme renders; parseTheme in codecs.ts owns the string shape. Consumers of effectiveColors() are deliberately few:
+layout.svelteis the single color-key read funnel — it resolves effective colors into the global--config-*CSS vars. Every preview/surface (chrome,CursorPreview, the DOM terminal) reads those CSS vars only, inheriting layered colors for free. Do not add new direct readers ofconfig.<colorkey>oreffectiveColors()in components; read the vars. This is machine-enforced: ano-restricted-syntaxrule ineslint.config.jsrejects directconfigcolor-key access outside the store, the resolver, and the settings renderer.- The color editors in the settings renderer display effective colors and write overrides into
config(palette edits are per-index — never write a whole themed array intoconfig.palette, that would re-create the export leak).
Override detection is value-inferred (isNonDefault, i.e. value ≠ app default) — the same rule diff() uses, kept deliberately consistent.
Generated files: never hand-edit
| File | Generator |
|---|---|
src/lib/data/themes.ts |
scripts/generate-themes.ts |
src/lib/data/macicons.ts |
scripts/generate-macicons.ts |
These are overwritten on the next CI sync. If the output needs to change, edit the generator script, not the generated file.
notes/ is local-only
The notes/ directory (plans, scratch, handoff docs) is gitignored and never ships. Do not reference notes/... paths or note filenames from anything committed — source comments, tests, README, PR descriptions, or this file. If something in a note genuinely needs to be public, surface the content itself where it belongs (a convention section in this file, a comment at the relevant code site, or a proper committed doc) instead of linking into notes/.
Svelte 5: patterns in use
| Purpose | Use | Do not use |
|---|---|---|
| Reactive state | $state(value) |
writable(value) |
| Derived values | $derived(expr) / $derived.by(() => ...) |
derived(store, fn) |
| Side effects | $effect(() => ...) |
$: statement |
| Component props | let { prop } = $props() |
export let prop |
| Two-way bindable props | $bindable() in props destructure |
- |
| Store-like modules | .svelte.ts files exporting $state values and mutation functions |
Writable store exports |
The ESLint config does not catch all legacy store usage, be deliberate.
Terminal preview: DOM-based, not ghostty-web
The interactive terminal preview (src/lib/views/InteractiveTerminalDom.svelte, surfaced through FloatingTerminal + MacDock) is a custom DOM renderer, not a real terminal emulator. This is a deliberate architecture decision — do not "upgrade" it back to ghostty-web (Ghostty's WASM-compiled VT parser) or any other real-emulator surface.
Why. The whole point of this tool is live config preview: change a setting and watch existing on-screen output re-theme in place. The DOM renderer does this for free — every color, font, and metric is a CSS custom property (--config-bg, --config-fg, --config-palette-N, --config-font-family, …), so a config change reactively restyles output that's already rendered, including scrollback and command history.
A real emulator like ghostty-web keeps its state inside a surface that must be destroyed and recreated to apply new config. That wipes scrollback and history on every tweak — you'd have to re-run ls -la just to see how a theme change affects it. The workarounds (fully static preview, or record/replay of a canned session) each sacrifice the interactivity that justified having a terminal at all. The fidelity ghostty-web buys (glyph shaping, ligatures, GPU compositing) is marginal for a config preview, and a web build isn't native-accurate anyway — while the dimensions a config tool actually changes (palette, fg/bg, font, cursor, selection, padding, opacity) all render faithfully in the DOM.
Consequences for contributors.
- The fake shell under
src/lib/terminal/(filesystem,commands,completion,exec,utils) is a curated demo surface, not a shell emulator. Add commands only when they meaningfully showcase config; resist turning it into a general-purpose shell. ghostty-webwas removed as a dependency along with the oldLivePreview.svelte/InteractiveTerminal.svelteviews and the dev-only/app/live-previewroute. Don't reintroduce them.- A genuinely-usable in-browser terminal is a different product (a "playground"), not the config preview. If that's ever built, it lives as a separate surface — it does not replace the DOM preview.
Other conventions
Async handlers: use withPendingGuard(fn) from $lib/utils/debounce for anything that must not fire concurrently. Use debounce(fn, ms) (leading-edge by default in this codebase) for rate-limiting sync actions.
User feedback: use the toast system (success() / error() from $lib/stores/toasts.svelte) for all success/failure feedback. Do not introduce inline button label swapping or other ad-hoc feedback patterns.
Serialization: only values differing from defaults appear in the generated config output. diff() handles this, do not add serialization logic elsewhere.
Defaults: a registry default is a string (or string[]) and must equal Ghostty's actual default state, since diff() / isNonDefault() compare against it as strings. Where that default lives depends on the value encoding:
- Scalar settings (rendered by
text/number/dropdown/pill/duration/color/dual-number/… widgets) — the default lives only indefault, string-encoded ("13","false","native"). Use""only when Ghostty's genuine default is unset/empty. - Override-encoded settings (
feature-list; alsopalette/keybind) — the stored value is already relative to per-item sub-defaults, so the settingdefaultis""/[](no overrides) and the real per-item defaults live in the widget metadata on the entry'swidget(e.g. afeature-listwidget'sfeatures[].default), not indefaultitself. This is why""is correct there despite the items having defaults. - Durations specifically: a concrete default → set the string (
"5s") and give the entry'sdurationwidgetallowEmpty: false; a genuinely-unset default →""+allowEmpty: true.validateRegistry()enforces this pairing (allowEmpty⟺""default).
The ""-means-genuinely-unset rule is load-bearing, not just style: the settings renderer derives a number widget's clearability from it (nullable={setting.default === ""}), so a wrong "" default makes a required setting blankable and a wrong concrete default makes an optional setting un-clearable.
Setting keys: Ghostty config keys are kebab-case (e.g. font-size). Registry keys are camelCase (e.g. fontSize). The key field on each registry entry is the Ghostty string; the JS property name is the camelCase identifier. These must remain in sync with Ghostty's actual config schema.
Code style quick reference:
- Double quotes for strings, semicolons everywhere
brace-style: stroustrup(braces on multi-line blocks, allowed to omit for single-line)import typewhen only types are needed- Grouped imports: external →
$lib→ relative, alphabetical within each group class:foo={condition}not manual class string concatenation
AI self-disclosure
If you are an autonomous agent submitting a PR where the human operator did not personally review the output before submission, say so explicitly in the PR description. For example:
This PR was generated by [agent/tool]. The human operator [reviewed the diff and ran the checks locally / did not review the output before submission].
This is a transparency expectation, not a penalty against AI-assisted work. It helps calibrate review effort. PRs that appear to be unreviewed agent output without this disclosure will be closed without detailed feedback.