macos: add sidebar agent activity support

Make the fork default to the macOS sidebar titlebar style and add the vertical session sidebar UI, including activity indicators for agent-driven terminal sessions.

Add the bundled agent hook helper and shell integration path wiring so supported CLIs can report activity state back to the terminal. Update fork branding, notices, icons, and build/test wiring for the new helper.
pull/12821/head
Scott McPherson 2026-05-26 13:03:23 -04:00
parent a746d0f728
commit f51268bf8f
64 changed files with 4439 additions and 152 deletions

View File

@ -15,6 +15,8 @@ A file for [guiding coding agents](https://agents.md/).
- **Formatting (Zig)**: `zig fmt .`
- **Formatting (Swift)**: `swiftlint lint --strict --fix`
- **Formatting (other)**: `prettier -w .`
- **Post-work check:** Run `zig build run` after completing work and use
Peekaboo to test the app.
## libghostty-vt

View File

@ -0,0 +1,572 @@
# Agent Hook Loading State Implementation
## Goal
Replace the sidebar spinner's current terminal-output heuristic with explicit agent lifecycle events for Claude Code and Codex CLI.
The desired behavior is:
- The sidebar tab status slot remains `title -> spacer -> spinner/bell`.
- Claude/Codex doing work shows the spinner.
- Claude/Codex waiting for user input shows the bell.
- Idle terminals do not show the spinner.
- Resizing the sidebar, resizing the terminal, TUI redraws, and repaint-heavy output do not activate the spinner.
- Generic terminal activity can remain as a fallback later, but Claude/Codex should not depend on output activity.
## Why This Change
The current spinner path is driven by PTY output:
- `src/termio/Termio.zig` calls `terminalActivityUnlocked`.
- `src/termio/stream_handler.zig` throttles `.terminal_activity`.
- `src/Surface.zig`, `src/apprt/surface.zig`, and `include/ghostty.h` forward that to the app.
- `macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift` sets `recentTerminalActivity`.
- `macos/Sources/Features/Terminal/TerminalSidebar.swift` treats `recentTerminalActivity` as `isWorking`.
That signal is not semantic. A resize or TUI redraw can produce output even when Claude/Codex is waiting at an input prompt. This caused false spinner activation and may have contributed to fragile behavior around Claude's full-screen TUI redraws.
CMUX solves this by using agent hook events, not terminal output. The useful reference points in the local CMUX clone are:
- `/Users/scott/Developer/cmux/Resources/bin/claude`
- `/Users/scott/Developer/cmux/CLI/CMUXCLI+AgentHookDefinitions.swift`
- `/Users/scott/Developer/cmux/CLI/cmux.swift`
- `/Users/scott/Developer/cmux/Sources/TerminalController.swift`
- `/Users/scott/Developer/cmux/Sources/Workspace+PanelLifecycle.swift`
Important CMUX findings:
- Claude is launched through a tiny wrapper that injects hook settings, then `exec`s the real `claude` binary.
- Codex uses installed native hooks in `~/.codex/hooks.json` plus enabled hooks in `~/.codex/config.toml`.
- Hook callbacks send pane-scoped status updates.
- Those hook callbacks are also how CMUX shows sidebar status indicators. The hook handler sends commands such as `set_status claude_code Running --icon=bolt.fill ...` or `set_status codex Running --icon=bolt.fill ...`; the sidebar renders that explicit status entry.
- CMUX stores session id, surface id, pid, prompt depth, and runtime status to avoid stale/nested hook events clearing newer state.
- The UI renders explicit state. It does not infer agent state from PTY output.
## Recommended Architecture
Use a small local hook bridge:
```text
Claude/Codex native hook event
-> bundled Ghostty hook helper
-> per-surface event channel
-> SurfaceView agent activity state
-> sidebar status indicator
```
Do not replace the real Claude or Codex process. The hook wrapper/helper only reports state. The real CLIs still run normally.
## Sidebar Status Indicators
The implementation should include the sidebar status indicator path in the same pass as the hook bridge. Do not build only the hook bridge and leave the sidebar on `recentTerminalActivity`.
CMUX's useful pattern is:
```text
agent lifecycle hook
-> normalize to Running / Needs input / Idle / Error
-> send pane-scoped status mutation
-> sidebar renders the status for that pane/tab
```
For this repo, the equivalent should be:
```text
TerminalAgentActivityEvent
-> TerminalAgentActivityState
-> TerminalSidebarStatusIndicatorState
-> TerminalSidebarStatusIndicator
```
Recommended sidebar indicator enum:
```swift
enum TerminalSidebarStatusIndicatorState: Equatable {
case none
case spinner(agent: String)
case bell(agent: String)
case error(agent: String)
}
```
Initial visual mapping:
- `.spinner` -> existing custom ring spinner.
- `.bell` -> existing bell/dot indicator, or a small bell icon if one is introduced.
- `.error` -> use the bell slot initially, preferably with a warning/error tint if the theme supports it.
- `.none` -> empty 12x12 status slot.
State mapping:
| Agent state | Sidebar indicator | Notes |
| --- | --- | --- |
| `running` | spinner | Claude/Codex is actively doing work. |
| `needsInput` | bell | The agent is waiting for approval, a question, or user input. |
| `error` | error/bell | The agent stopped because of an error or failed permission/tool state. |
| `idle` | none | Clear spinner and agent-specific bell. |
Tab-level precedence:
1. Any surface has `running` -> show spinner.
2. Else any surface has `error` -> show error/bell.
3. Else any surface has `needsInput` -> show bell.
4. Else existing terminal bell -> show bell.
5. Else no indicator.
This keeps the current layout:
```text
title -> spacer -> spinner/bell/error slot
```
The slot should remain fixed-size so title truncation and row layout do not shift while the state changes.
Optional later: add a CMUX-like text status row or tooltip such as `Claude Running`, `Codex needs input`, or `Codex error`. That is not required for the first pass; the first pass should keep the subtle tab-title-side indicator.
### State Model
Add a pane-scoped state enum on macOS:
```swift
enum TerminalAgentActivityState: Equatable {
case idle
case running(agent: String)
case needsInput(agent: String)
case error(agent: String)
}
```
Suggested state precedence for a tab:
1. Any surface in the tab is `.running` -> show spinner.
2. Else any surface is `.error` -> show error/bell.
3. Else any surface is `.needsInput` -> show bell.
4. Else existing terminal bell -> show bell.
5. Else no status indicator.
This keeps the existing `title -> Spacer -> TerminalSidebarStatusIndicator` layout in `TerminalSidebar.swift`.
### Event Schema
The bridge should write normalized events like:
```json
{
"version": 1,
"surface_id": "UUID-or-generated-surface-id",
"agent": "claude",
"event": "prompt-submit",
"state": "running",
"status_title": "Claude Code",
"status_value": "Running",
"session_id": "optional",
"turn_id": "optional",
"pid": 12345,
"timestamp": 1770000000.123
}
```
Keep the reducer fail-open:
- Unknown event: ignore.
- Missing session id: still apply if the event targets the current surface.
- Stop/session-end with a stale session id: ignore if a newer session is active.
- Malformed JSON line: ignore and keep the current state.
- `status_title` and `status_value` are optional display metadata. The reducer should derive a sensible title/value from `agent` and `state` if they are missing.
## Bridge Transport
Use a file-backed event bridge for the first implementation. It is simple, fast, and avoids adding a socket server.
Per surface:
- Generate a stable `agentSurfaceID` when the `SurfaceView` is created.
- Create a per-surface JSONL event file under a private temp directory, for example:
```text
$TMPDIR/ghostty-agent-hooks-$UID/<surface-id>.jsonl
```
- Watch the file or containing directory with `DispatchSourceFileSystemObject`.
- On change, read only appended bytes, split into lines, decode events, and update the surface state on the main actor.
The helper writes one JSON line using `O_APPEND` and exits. Hook events are rare, so this is much cheaper and less fragile than watching terminal output.
Alternative later: replace the JSONL transport with a Unix domain socket if we need request/response behavior. The state reducer and hook definitions should not depend on the transport.
## Environment Injection
Each terminal surface needs these environment variables:
```text
GHOSTTY_AGENT_SURFACE_ID=<stable surface id>
GHOSTTY_AGENT_EVENT_FILE=<absolute path to per-surface jsonl file>
GHOSTTY_AGENT_HOOK_HELPER=<absolute path to bundled helper executable>
GHOSTTY_AGENT_HOOKS_DISABLED=0/1
```
For Claude wrapper discovery, also prepend the bundled helper directory to `PATH`, but only in Ghostty-created terminals.
Relevant Ghostty files:
- `macos/Sources/Ghostty/Surface View/SurfaceView.swift`
- `SurfaceConfiguration.environmentVariables`
- `macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift`
- surface creation currently uses `let surface_cfg = baseConfig ?? SurfaceConfiguration()`
- `src/termio/Exec.zig`
- currently sets `GHOSTTY_RESOURCES_DIR` and adjusts `PATH`
Implementation detail:
- Enrich the surface configuration before `ghostty_surface_new`.
- Do this before the PTY child starts. These env vars cannot be added after the terminal process is already running.
## Hook Helper
Add a tiny bundled executable named `ghostty-agent-hook`.
Recommended implementation: a small Zig executable, because this repo already builds Zig and the helper should not depend on `jq`, Python, or Swift being available in the user's shell.
CLI contract:
```text
ghostty-agent-hook <agent> <event>
```
Examples:
```text
ghostty-agent-hook claude prompt-submit
ghostty-agent-hook claude stop
ghostty-agent-hook codex prompt-submit
ghostty-agent-hook codex permission-request
```
Behavior:
- Read stdin to EOF. It may contain hook JSON.
- Parse useful fields if present:
- `session_id`, `sessionId`
- `turn_id`, `turnId`
- `cwd`
- `transcript_path`, `transcriptPath`
- `hook_event_name`, `hookEventName`
- Determine normalized state:
- `prompt-submit`, `user-prompt-submit`, `pre-tool-use` -> `running`
- `notification`, `permission-request`, `ask-user-question` -> `needsInput`
- `stop`, `idle` -> `idle`
- `session-end` -> `idle` plus clear active session
- explicit error/failure signals -> `error`
- Include display metadata in the emitted event:
- `running` -> `status_value: "Running"`
- `needsInput` -> `status_value: "Needs input"`
- `error` -> `status_value: "Error"` or a short agent-specific error label
- `idle` -> `status_value: "Idle"` only for state bookkeeping; the sidebar should hide idle agent indicators
- Write the JSONL event to `$GHOSTTY_AGENT_EVENT_FILE`.
- Always print `{}` to stdout and exit 0 unless invoked with a setup command.
- Never block on app state. If the event file/env is missing, print `{}` and exit 0.
The stdout rule matters. Agent hook systems may interpret stdout as hook output, so status reporting must not break the agent.
## Claude Implementation
Add a bundled `claude` shim, similar to CMUX's `/Users/scott/Developer/cmux/Resources/bin/claude`.
Runtime flow:
```text
user types claude
-> Ghostty-bundled claude shim is found first on PATH
-> shim verifies it is inside a Ghostty terminal with hook env vars
-> shim finds the real claude binary, skipping its own directory
-> shim builds --settings JSON with hook definitions
-> shim execs the real claude binary
```
The wrapper should pass through unchanged when:
- Not inside a Ghostty terminal.
- `GHOSTTY_AGENT_HOOKS_DISABLED=1`.
- The real `claude` cannot be resolved.
- The invocation is a non-session subcommand such as `claude --help`, `claude --version`, `claude config`, etc.
The wrapper should use `exec`, not spawn and wait. There should not be an extra long-running wrapper process.
Claude hook settings should include:
```text
SessionStart -> ghostty-agent-hook claude session-start
UserPromptSubmit -> ghostty-agent-hook claude prompt-submit
PreToolUse -> ghostty-agent-hook claude pre-tool-use
Notification -> ghostty-agent-hook claude notification
Stop -> ghostty-agent-hook claude stop
SessionEnd -> ghostty-agent-hook claude session-end
```
Optional later:
- `PermissionRequest` can be used for richer "needs input" state, but do not implement blocking approval/feed behavior unless that is explicitly in scope.
- `AskUserQuestion` can be detected from `PreToolUse` payload and mapped to `needsInput`.
Use a generated `--session-id` only when the user did not already pass resume/session flags. This mirrors CMUX and helps stale-event filtering.
## Codex Implementation
Codex should use native installed hooks instead of a PATH wrapper.
Add an install path, either:
- `ghostty-agent-hook install codex`
- or a macOS setting/action that runs the same installer.
The installer should update:
```text
~/.codex/hooks.json
~/.codex/config.toml
```
Preserve existing user hooks. Remove/replace only Ghostty-owned hook entries using clear marker strings.
Suggested Codex hook events:
```text
SessionStart -> ghostty-agent-hook codex session-start
UserPromptSubmit -> ghostty-agent-hook codex prompt-submit
Stop -> ghostty-agent-hook codex stop
PreToolUse -> ghostty-agent-hook codex pre-tool-use
PermissionRequest -> ghostty-agent-hook codex permission-request
```
The installed shell command should no-op outside Ghostty:
```sh
ghostty_hook="${GHOSTTY_AGENT_HOOK_HELPER:-$(command -v ghostty-agent-hook 2>/dev/null || true)}"
if [ -n "${GHOSTTY_AGENT_SURFACE_ID:-}" ] && [ -n "$ghostty_hook" ]; then
"$ghostty_hook" codex prompt-submit
else
echo '{}'
fi
```
Codex hook format should mirror CMUX's nested hook format from:
```text
/Users/scott/Developer/cmux/CLI/CMUXCLI+AgentHookDefinitions.swift
```
CMUX also enables Codex hooks in `config.toml` by writing `[features] hooks = true` and handles hook trust. Check the local Codex CLI behavior while implementing. If current Codex requires trusted hook hashes, mirror CMUX's approach before calling the implementation complete.
## Swift App Integration
Add a small app-side component, for example:
```text
macos/Sources/Features/Terminal/TerminalAgentActivity.swift
```
Responsibilities:
- Define `TerminalAgentActivityEvent`.
- Define `TerminalAgentActivityState`.
- Define `TerminalSidebarStatusIndicatorState` or a nearby equivalent.
- Parse JSONL events.
- Reduce events into per-surface state.
- Derive the sidebar indicator state from all surfaces in a tab.
- Expose a published state from `SurfaceView`.
`SurfaceView_AppKit.swift` should own:
- `agentSurfaceID`
- `agentEventFileURL`
- event file watcher
- `@Published private(set) var agentActivityState`
`TerminalSidebar.swift` should stop using `recentTerminalActivity` for Claude/Codex work state. Suggested replacement:
```swift
private func tabAgentIndicatorState(_ controller: BaseTerminalController) -> TerminalSidebarStatusIndicatorState {
for surfaceView in controller.surfaceTree {
if case .running(let agent) = surfaceView.agentActivityState {
return .spinner(agent: agent)
}
}
for surfaceView in controller.surfaceTree {
if case .error(let agent) = surfaceView.agentActivityState {
return .error(agent: agent)
}
}
for surfaceView in controller.surfaceTree {
if case .needsInput(let agent) = surfaceView.agentActivityState {
return .bell(agent: agent)
}
}
return controller.bell ? .bell(agent: "terminal") : .none
}
```
Then store that indicator state directly on `TerminalSidebarSession` instead of only storing `isWorking` and `hasBell`:
```swift
let indicatorState = tabAgentIndicatorState(controller)
```
If `progressReport` is already used for real terminal progress reports, it can still force `.spinner(agent: "terminal")` when there is no higher-priority agent indicator. Do not let raw recent terminal output set the spinner.
Update `TerminalSidebarStatusIndicator` to accept the enum:
```swift
private struct TerminalSidebarStatusIndicator: View {
let state: TerminalSidebarStatusIndicatorState
let spinnerColor: NSColor
let bellColor: NSColor
let errorColor: NSColor
}
```
Accessibility labels should also reflect the hook state:
- `Claude running`
- `Codex needs input`
- `Codex error`
- no extra status for `.none`
## What To Do With Current Terminal Activity Code
Short term:
- Leave the Zig `.terminal_activity` path in place if removing it is risky.
- Remove `surfaceView.recentTerminalActivity` from `TerminalSidebarModel.tabIsWorking`.
- Keep `recentTerminalActivity` unused or behind a clearly named fallback setting.
Long term:
- Delete the `.terminal_activity` action path if no other feature uses it.
- If generic process activity is still desired, implement shell integration using `preexec`/`precmd` style events. CMUX has examples in:
- `/Users/scott/Developer/cmux/Resources/shell-integration/cmux-zsh-integration.zsh`
- `/Users/scott/Developer/cmux/Resources/shell-integration/cmux-bash-integration.bash`
Generic shell activity should be a separate state from AI-agent lifecycle.
## Stale State Handling
Avoid stuck spinners:
- Track active session id per surface and agent when available.
- Ignore `stop`/`session-end` for an older session if a newer session is running.
- Include PID when available:
- Claude wrapper can export the real Claude PID because it uses `exec`.
- Codex hook helper can record parent PID as a weaker fallback.
- Clear agent state when the surface closes.
- Add a defensive long TTL for `running` states, such as 6 hours, only as a last-resort cleanup. Do not use a short timeout for normal idle detection because long-running agent tasks are valid.
## Testing Plan
Unit tests:
- JSON event parsing.
- State reducer:
- prompt-submit -> running
- notification -> needsInput
- stop -> idle
- stale stop ignored
- malformed event ignored
- Hook installer preserves non-Ghostty Codex hooks.
- Claude wrapper real-binary resolution skips its own directory.
- Sidebar indicator derivation:
- any running surface wins over needs-input/error/terminal bell
- error wins over needs-input
- needs-input wins over terminal bell
- idle clears the agent indicator
Manual fake-hook test inside a Ghostty tab:
```sh
printf '{}\n' | "$GHOSTTY_AGENT_HOOK_HELPER" claude prompt-submit
sleep 2
printf '{}\n' | "$GHOSTTY_AGENT_HOOK_HELPER" claude stop
```
Expected:
- Spinner appears after `prompt-submit`.
- Spinner disappears after `stop`.
- Resizing the sidebar or terminal does not change state.
- If the fake event is `notification` or `permission-request`, the status slot shows the bell instead of the spinner.
Claude manual test:
```sh
claude
```
Then submit a real prompt. Expected:
- Spinner starts when Claude begins work.
- Spinner stops on completion.
- Bell appears when Claude needs user input or permission.
Codex manual test:
```sh
ghostty-agent-hook install codex
codex
```
Then submit a real prompt. Expected:
- Spinner starts on `UserPromptSubmit`.
- Spinner stops on `Stop`.
- Permission/input waits map to bell if hook coverage allows it.
Regression tests:
- Run a command with lots of output:
```sh
for i in $(seq 1 200); do echo active-spinner-$i; sleep 0.02; done
```
Expected:
- This should not activate the Claude/Codex spinner unless generic shell activity is deliberately enabled.
- Open Claude, do not submit a prompt, resize the sidebar and terminal.
Expected:
- No spinner activation.
- Claude TUI remains visually intact.
Build/verification:
```sh
zig build -Dxcframework-target=native
zig build run
```
Use Peekaboo to verify the sidebar indicator visually after the app launches.
## Implementation Order
1. Add the event model and reducer with unit tests.
2. Add the sidebar status indicator enum and update `TerminalSidebarStatusIndicator` to render spinner/bell/error from explicit state.
3. Add the file-backed event watcher to `SurfaceView_AppKit.swift`.
4. Inject per-surface hook environment variables before `ghostty_surface_new`.
5. Add the `ghostty-agent-hook` helper and bundle it.
6. Change `TerminalSidebar.swift` to use agent status state instead of `recentTerminalActivity`.
7. Add the Claude wrapper and PATH injection.
8. Add Codex hook install/uninstall.
9. Run fake-hook tests for spinner, bell, error, and idle clearing.
10. Test real Claude.
11. Test real Codex.
12. Remove or demote the old terminal-output activity spinner path.
## Risks And Guardrails
- Do not block agent hooks. Status updates must be fire-and-forget.
- Do not write noisy output from hook commands. Print `{}` and exit 0.
- Do not silently overwrite user Codex config. Preserve unknown hooks and use Ghostty markers.
- Avoid wrapper recursion. The Claude wrapper must find the real `claude` while skipping its own directory.
- Keep status pane-scoped. A hook event from one terminal must not update another tab.
- Do not use terminal output as the source of truth for Claude/Codex running state.

20
NOTICE Normal file
View File

@ -0,0 +1,20 @@
Mosttly
===============
Mosttly is an unofficial fork of Ghostty:
https://github.com/ghostty-org/ghostty
It is not affiliated with, endorsed by, or maintained by the Ghostty project.
The original Ghostty source is licensed under the MIT License. See LICENSE for
the full license text and copyright notice:
Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors
Additional modifications in this fork are copyrighted by their respective
contributors and are distributed under the same MIT License unless a file says
otherwise.
Some vendored or third-party components in this repository may carry their own
license notices. Those notices remain authoritative for those components.

View File

@ -1,26 +1,47 @@
<!-- LOGO -->
<h1>
<p align="center">
<img src="https://github.com/user-attachments/assets/fe853809-ba8b-400b-83ab-a9a0da25be8a" alt="Logo" width="128">
<br>Ghostty
<img src="images/icons/icon_1024.png" alt="Mosttly Ghostty logo" width="128">
<br>Mosttly Ghostty
</h1>
<p align="center">
Fast, native, feature-rich terminal emulator pushing modern features.
An unofficial Ghostty fork with sidebar-first tab/session changes.
<br />
A native GUI or embeddable library via <code>libghostty</code>.
Built for parallel agentic engineering.
<br />
Based on Ghostty, a fast native terminal emulator and embeddable <code>libghostty</code> library.
<br />
<a href="#about">About</a>
·
<a href="https://ghostty.org/download">Download</a>
<a href="https://github.com/ghostty-org/ghostty">Upstream Ghostty</a>
·
<a href="NOTICE">Notice</a>
·
<a href="https://ghostty.org/docs">Documentation</a>
·
<a href="CONTRIBUTING.md">Contributing</a>
·
<a href="HACKING.md">Developing</a>
</p>
</p>
## Fork Notice
Mosttly Ghostty is an unofficial fork of
[Ghostty](https://github.com/ghostty-org/ghostty). It is not affiliated with,
endorsed by, or maintained by the Ghostty project.
The original Ghostty source is licensed under the MIT License. See
[LICENSE](LICENSE) and [NOTICE](NOTICE). Upstream documentation at
[ghostty.org/docs](https://ghostty.org/docs) generally applies, but this fork
may differ where sidebar tabs/sessions and local distribution behavior have
been changed.
## Sidebar Agent Statuses
Sidebar agent status indicators are currently supported automatically for
Claude Code and Codex. Other CLIs can use the same underlying hook event
pipeline, but they do not have built-in integration yet. Support for the X.ai
CLI and Gemini CLI is in progress.
## About
Ghostty is a terminal emulator that differentiates itself by being

View File

@ -367,6 +367,21 @@ pub fn build(b: *std.Build) !void {
const test_run = b.addRunArtifact(test_exe);
test_step.dependOn(&test_run.step);
const agent_hook_test = b.addTest(.{
.name = "ghostty-agent-hook-test",
.filters = test_filters,
.root_module = b.createModule(.{
.root_source_file = b.path("src/agent_hook/main.zig"),
.target = config.baselineTarget(),
.optimize = .Debug,
.strip = false,
.omit_frame_pointer = false,
.unwind_tables = .sync,
}),
});
const agent_hook_test_run = b.addRunArtifact(agent_hook_test);
test_step.dependOn(&agent_hook_test_run.step);
// Normal tests always test our libghostty modules
//test_step.dependOn(test_lib_vt_step);

View File

@ -95,12 +95,54 @@
"fill" : "automatic",
"hidden" : false,
"image-name" : "Ghostty.png",
"name" : "Ghostty",
"name" : "Ghostty Top Left",
"position" : {
"scale" : 1,
"scale" : 0.7,
"translation-in-points" : [
-185.015625,
-143.8359375
-197.0,
-212.0
]
}
},
{
"blend-mode" : "normal",
"fill" : "automatic",
"hidden" : false,
"image-name" : "Ghostty.png",
"name" : "Ghostty Top Right",
"position" : {
"scale" : 0.7,
"translation-in-points" : [
198.0,
-212.0
]
}
},
{
"blend-mode" : "normal",
"fill" : "automatic",
"hidden" : false,
"image-name" : "Ghostty.png",
"name" : "Ghostty Bottom Left",
"position" : {
"scale" : 0.7,
"translation-in-points" : [
-197.0,
188.0
]
}
},
{
"blend-mode" : "normal",
"fill" : "automatic",
"hidden" : false,
"image-name" : "Ghostty.png",
"name" : "Ghostty Bottom Right",
"position" : {
"scale" : 0.7,
"translation-in-points" : [
198.0,
188.0
]
}
},
@ -112,12 +154,63 @@
"glass" : true,
"hidden" : false,
"image-name" : "Ghostty.png",
"name" : "GhosttyBlur",
"name" : "GhosttyBlur Top Left",
"position" : {
"scale" : 1,
"scale" : 0.7,
"translation-in-points" : [
-186.59375,
-143.8359375
-198.578125,
-212.0
]
}
},
{
"blend-mode" : "normal",
"fill" : {
"solid" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"glass" : true,
"hidden" : false,
"image-name" : "Ghostty.png",
"name" : "GhosttyBlur Top Right",
"position" : {
"scale" : 0.7,
"translation-in-points" : [
196.421875,
-212.0
]
}
},
{
"blend-mode" : "normal",
"fill" : {
"solid" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"glass" : true,
"hidden" : false,
"image-name" : "Ghostty.png",
"name" : "GhosttyBlur Bottom Left",
"position" : {
"scale" : 0.7,
"translation-in-points" : [
-198.578125,
188.0
]
}
},
{
"blend-mode" : "normal",
"fill" : {
"solid" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"glass" : true,
"hidden" : false,
"image-name" : "Ghostty.png",
"name" : "GhosttyBlur Bottom Right",
"position" : {
"scale" : 0.7,
"translation-in-points" : [
196.421875,
188.0
]
}
},
@ -167,4 +260,4 @@
],
"squares" : "shared"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 B

After

Width:  |  Height:  |  Size: 911 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 802 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 666 B

After

Width:  |  Height:  |  Size: 911 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 652 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 652 KiB

After

Width:  |  Height:  |  Size: 243 KiB

View File

@ -940,6 +940,7 @@ typedef enum {
GHOSTTY_ACTION_OPEN_URL,
GHOSTTY_ACTION_SHOW_CHILD_EXITED,
GHOSTTY_ACTION_PROGRESS_REPORT,
GHOSTTY_ACTION_TERMINAL_ACTIVITY,
GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD,
GHOSTTY_ACTION_COMMAND_FINISHED,
GHOSTTY_ACTION_START_SEARCH,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 243 KiB

View File

@ -86,7 +86,7 @@
A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = "<group>"; };
A5985CE52C33060F00C57AD3 /* man */ = {isa = PBXFileReference; lastKnownFileType = folder; name = man; path = "../zig-out/share/man"; sourceTree = "<group>"; };
A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = "<group>"; };
A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; };
A5B30531299BEAAA0047F10C /* Mosttly.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mosttly.app"; sourceTree = BUILT_PRODUCTS_DIR; };
A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ghostty-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@ -188,6 +188,7 @@
Features/Terminal/TerminalRestorable.swift,
"Features/Terminal/TerminalRestorableState+InteralState.swift",
Features/Terminal/TerminalTabColor.swift,
Features/Terminal/TerminalSidebar.swift,
Features/Terminal/TerminalView.swift,
Features/Terminal/TerminalViewContainer.swift,
"Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift",
@ -357,7 +358,7 @@
A5B30532299BEAAA0047F10C /* Products */ = {
isa = PBXGroup;
children = (
A5B30531299BEAAA0047F10C /* Ghostty.app */,
A5B30531299BEAAA0047F10C /* Mosttly.app */,
A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */,
A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */,
810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */,
@ -466,7 +467,7 @@
A51BFC262B30F1B800E92F16 /* Sparkle */,
);
productName = Ghostty;
productReference = A5B30531299BEAAA0047F10C /* Ghostty.app */;
productReference = A5B30531299BEAAA0047F10C /* Mosttly.app */;
productType = "com.apple.product-type.application";
};
A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */ = {
@ -753,25 +754,26 @@
GCC_OPTIMIZATION_LEVEL = fast;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Ghostty-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Ghostty;
INFOPLIST_KEY_CFBundleDisplayName = "Mosttly";
INFOPLIST_KEY_CFBundleName = "Mosttly";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript.";
INFOPLIST_KEY_NSAudioCaptureUsageDescription = "A program running within Ghostty would like to access your system's audio.";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar.";
INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Ghostty would like to use the camera.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Ghostty would like to access your Contacts.";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Mosttly would like to use AppleScript.";
INFOPLIST_KEY_NSAudioCaptureUsageDescription = "A program running within Mosttly would like to access your system's audio.";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Mosttly would like to use Bluetooth.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Mosttly would like to access your Calendar.";
INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Mosttly would like to use the camera.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Mosttly would like to access your Contacts.";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Mosttly would like to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Mosttly would like to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Mosttly would like to access your location information.";
INFOPLIST_KEY_NSMainNibFile = MainMenu;
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Mosttly would like to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Mosttly would like to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Mosttly would like to access your Photo Library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Mosttly would like to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Mosttly would like to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Mosttly requires elevated privileges.";
INFOPLIST_PREPROCESS = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -780,8 +782,9 @@
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.1;
"OTHER_LDFLAGS[arch=*]" = "-lstdc++";
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty;
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = "com.scottmcpherson.mosttly-ghostty";
PRODUCT_MODULE_NAME = Ghostty;
PRODUCT_NAME = "Mosttly";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/macOS/ghostty-bridging-header.h";
SWIFT_VERSION = 5.0;
@ -867,14 +870,14 @@
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin";
INFOPLIST_KEY_CFBundleDisplayName = "Mosttly Dock Tile Plugin";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSPrincipalClass = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile";
PRODUCT_BUNDLE_IDENTIFIER = "com.scottmcpherson.mosttly-ghostty-dock-tile";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@ -896,14 +899,14 @@
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin";
INFOPLIST_KEY_CFBundleDisplayName = "Mosttly Dock Tile Plugin";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSPrincipalClass = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile";
PRODUCT_BUNDLE_IDENTIFIER = "com.scottmcpherson.mosttly-ghostty-dock-tile";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@ -925,14 +928,14 @@
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin";
INFOPLIST_KEY_CFBundleDisplayName = "Mosttly Dock Tile Plugin";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSPrincipalClass = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile";
PRODUCT_BUNDLE_IDENTIFIER = "com.scottmcpherson.mosttly-ghostty-dock-tile";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@ -964,7 +967,7 @@
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ghostty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mosttly.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty";
};
name = Debug;
};
@ -987,7 +990,7 @@
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ghostty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mosttly.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty";
};
name = Release;
};
@ -1010,7 +1013,7 @@
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ghostty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mosttly.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty";
};
name = ReleaseLocal;
};
@ -1151,24 +1154,25 @@
EXECUTABLE_NAME = ghostty;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Ghostty-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Ghostty[DEBUG]";
INFOPLIST_KEY_CFBundleDisplayName = "Mosttly[DEBUG]";
INFOPLIST_KEY_CFBundleName = "Mosttly";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript.";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar.";
INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Ghostty would like to use the camera.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Ghostty would like to access your Contacts.";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Mosttly would like to use AppleScript.";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Mosttly would like to use Bluetooth.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Mosttly would like to access your Calendar.";
INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Mosttly would like to use the camera.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Mosttly would like to access your Contacts.";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Mosttly would like to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Mosttly would like to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Mosttly would like to access your location information.";
INFOPLIST_KEY_NSMainNibFile = MainMenu;
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Mosttly would like to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Mosttly would like to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Mosttly would like to access your Photo Library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Mosttly would like to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Mosttly would like to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Mosttly requires elevated privileges.";
INFOPLIST_PREPROCESS = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -1177,8 +1181,9 @@
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.1;
"OTHER_LDFLAGS[arch=*]" = "-lstdc++";
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty.debug;
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = "com.scottmcpherson.mosttly-ghostty.debug";
PRODUCT_MODULE_NAME = Ghostty;
PRODUCT_NAME = "Mosttly";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/macOS/ghostty-bridging-header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -1206,24 +1211,25 @@
GCC_OPTIMIZATION_LEVEL = fast;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Ghostty-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Ghostty;
INFOPLIST_KEY_CFBundleDisplayName = "Mosttly";
INFOPLIST_KEY_CFBundleName = "Mosttly";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript.";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar.";
INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Ghostty would like to use the camera.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Ghostty would like to access your Contacts.";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Mosttly would like to use AppleScript.";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Mosttly would like to use Bluetooth.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Mosttly would like to access your Calendar.";
INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Mosttly would like to use the camera.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Mosttly would like to access your Contacts.";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Mosttly would like to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Mosttly would like to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Mosttly would like to access your location information.";
INFOPLIST_KEY_NSMainNibFile = MainMenu;
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Mosttly would like to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Mosttly would like to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Mosttly would like to access your Photo Library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Mosttly would like to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Mosttly would like to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Mosttly requires elevated privileges.";
INFOPLIST_PREPROCESS = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -1232,8 +1238,9 @@
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.1;
"OTHER_LDFLAGS[arch=*]" = "-lstdc++";
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty;
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = "com.scottmcpherson.mosttly-ghostty";
PRODUCT_MODULE_NAME = Ghostty;
PRODUCT_NAME = "Mosttly";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/macOS/ghostty-bridging-header.h";
SWIFT_VERSION = 5.0;

View File

@ -15,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A5B30530299BEAAA0047F10C"
BuildableName = "Ghostty.app"
BuildableName = "Mosttly.app"
BlueprintName = "Ghostty"
ReferencedContainer = "container:Ghostty.xcodeproj">
</BuildableReference>
@ -73,7 +73,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A5B30530299BEAAA0047F10C"
BuildableName = "Ghostty.app"
BuildableName = "Mosttly.app"
BlueprintName = "Ghostty"
ReferencedContainer = "container:Ghostty.xcodeproj">
</BuildableReference>
@ -103,7 +103,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A5B30530299BEAAA0047F10C"
BuildableName = "Ghostty.app"
BuildableName = "Mosttly.app"
BlueprintName = "Ghostty"
ReferencedContainer = "container:Ghostty.xcodeproj">
</BuildableReference>

View File

@ -10,9 +10,9 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn {
// Separate defaults based on debug vs release builds so we can test icons
// without messing up releases.
#if DEBUG
private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty.debug")
private let ghosttyUserDefaults = UserDefaults(suiteName: "com.scottmcpherson.mosttly-ghostty.debug")
#else
private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty")
private let ghosttyUserDefaults = UserDefaults(suiteName: "com.scottmcpherson.mosttly-ghostty")
#endif
private var iconChangeObserver: Any?
@ -51,13 +51,8 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn {
private func resetIcon(dockTile: NSDockTile) {
let appIcon: NSImage?
if #available(macOS 26.0, *) {
#if DEBUG
// Use the `Blueprint` icon to distinguish Debug from Release builds.
appIcon = pluginBundle.image(forResource: "BlueprintImage")!
#else
// Reset to Ghostty.icon
// Reset to the bundled app icon.
appIcon = nil
#endif
} else {
// Use the bundled icon to keep the corner radius consistent with pre-Tahoe apps.
appIcon = pluginBundle.image(forResource: "AppIconImage")!

View File

@ -4,5 +4,5 @@ extension Notification.Name {
/// Distributed Notification for DockTilePlugin to update icon
///
/// Ghostty -> DockTilePlugin
static let ghosttyIconDidChange = Notification.Name("com.mitchellh.ghostty.iconDidChange")
static let ghosttyIconDidChange = Notification.Name("com.scottmcpherson.mosttly-ghostty.iconDidChange")
}

View File

@ -87,30 +87,40 @@ private struct TerminalSplitSubtreeView: View {
}
private struct TerminalSplitLeaf: View {
let surfaceView: Ghostty.SurfaceView
@ObservedObject var surfaceView: Ghostty.SurfaceView
let isSplit: Bool
let action: (TerminalSplitOperation) -> Void
@State private var dropState: DropState = .idle
@State private var isSelfDragging: Bool = false
private let paneHorizontalPadding: CGFloat = 6
private let paneTopPadding: CGFloat = 4
var body: some View {
GeometryReader { geometry in
Ghostty.InspectableSurface(
surfaceView: surfaceView,
isSplit: isSplit)
.padding(.horizontal, paneHorizontalPadding)
.padding(.top, paneTopPadding)
.background {
// If we're dragging ourself, we hide the entire drop zone. This makes
// it so that a released drop animates back to its source properly
// so it is a proper invalid drop zone.
if !isSelfDragging {
Color.clear
.onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate(
dropState: $dropState,
viewSize: geometry.size,
destinationSurface: surfaceView,
action: action
))
ZStack {
(surfaceView.backgroundColor ?? surfaceView.derivedConfig.backgroundColor)
.opacity(surfaceView.derivedConfig.backgroundOpacity)
// If we're dragging ourself, we hide the entire drop zone. This makes
// it so that a released drop animates back to its source properly
// so it is a proper invalid drop zone.
if !isSelfDragging {
Color.clear
.onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate(
dropState: $dropState,
viewSize: geometry.size,
destinationSurface: surfaceView,
action: action
))
}
}
}
.overlay {

View File

@ -0,0 +1,331 @@
import Foundation
enum TerminalAgentActivityState: Equatable {
case idle
case running(agent: String)
case needsInput(agent: String)
case error(agent: String)
var agent: String? {
switch self {
case .idle:
nil
case .running(let agent), .needsInput(let agent), .error(let agent):
agent
}
}
}
enum TerminalSidebarStatusIndicatorState: Equatable {
case none
case spinner(agent: String)
case bell(agent: String)
case error(agent: String)
var accessibilityDescription: String? {
switch self {
case .none:
nil
case .spinner(let agent):
"\(Self.displayName(for: agent)) running"
case .bell(let agent):
agent == "terminal" ? "Terminal bell" : "\(Self.displayName(for: agent)) needs input"
case .error(let agent):
"\(Self.displayName(for: agent)) error"
}
}
var isAttentionIndicator: Bool {
switch self {
case .bell, .error:
true
case .none, .spinner:
false
}
}
func visibleState(isSelected: Bool) -> TerminalSidebarStatusIndicatorState {
isSelected && isAttentionIndicator ? .none : self
}
static func derive(
from states: [TerminalAgentActivityState],
hasTerminalProgress: Bool,
hasTerminalBell: Bool
) -> TerminalSidebarStatusIndicatorState {
for state in states {
if case .running(let agent) = state {
return .spinner(agent: agent)
}
}
for state in states {
if case .error(let agent) = state {
return .error(agent: agent)
}
}
for state in states {
if case .needsInput(let agent) = state {
return .bell(agent: agent)
}
}
if hasTerminalProgress {
return .spinner(agent: "terminal")
}
if hasTerminalBell {
return .bell(agent: "terminal")
}
return .none
}
private static func displayName(for agent: String) -> String {
switch agent.lowercased() {
case "claude":
"Claude"
case "codex":
"Codex"
case "terminal":
"Terminal"
default:
agent
}
}
}
struct TerminalAgentActivityEvent: Decodable, Equatable {
let version: Int?
let surfaceID: String?
let agent: String
let event: String?
let state: String?
let statusTitle: String?
let statusValue: String?
let sessionID: String?
let turnID: String?
let pid: Int?
let timestamp: TimeInterval?
enum CodingKeys: String, CodingKey {
case version
case surfaceID = "surface_id"
case agent
case event
case state
case statusTitle = "status_title"
case statusValue = "status_value"
case sessionID = "session_id"
case turnID = "turn_id"
case pid
case timestamp
}
enum AlternateCodingKeys: String, CodingKey {
case surfaceId
case statusTitle
case statusValue
case sessionId
case turnId
}
init(
version: Int? = 1,
surfaceID: String?,
agent: String,
event: String?,
state: String?,
statusTitle: String? = nil,
statusValue: String? = nil,
sessionID: String? = nil,
turnID: String? = nil,
pid: Int? = nil,
timestamp: TimeInterval? = nil
) {
self.version = version
self.surfaceID = surfaceID
self.agent = agent
self.event = event
self.state = state
self.statusTitle = statusTitle
self.statusValue = statusValue
self.sessionID = sessionID
self.turnID = turnID
self.pid = pid
self.timestamp = timestamp
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let alternate = try decoder.container(keyedBy: AlternateCodingKeys.self)
self.version = try container.decodeIfPresent(Int.self, forKey: .version)
self.surfaceID = try container.decodeIfPresent(String.self, forKey: .surfaceID)
?? alternate.decodeIfPresent(String.self, forKey: .surfaceId)
self.agent = try container.decode(String.self, forKey: .agent)
self.event = try container.decodeIfPresent(String.self, forKey: .event)
self.state = try container.decodeIfPresent(String.self, forKey: .state)
self.statusTitle = try container.decodeIfPresent(String.self, forKey: .statusTitle)
?? alternate.decodeIfPresent(String.self, forKey: .statusTitle)
self.statusValue = try container.decodeIfPresent(String.self, forKey: .statusValue)
?? alternate.decodeIfPresent(String.self, forKey: .statusValue)
self.sessionID = try container.decodeIfPresent(String.self, forKey: .sessionID)
?? alternate.decodeIfPresent(String.self, forKey: .sessionId)
self.turnID = try container.decodeIfPresent(String.self, forKey: .turnID)
?? alternate.decodeIfPresent(String.self, forKey: .turnId)
self.pid = try container.decodeIfPresent(Int.self, forKey: .pid)
self.timestamp = try container.decodeIfPresent(TimeInterval.self, forKey: .timestamp)
}
static func parse(jsonLine line: String) -> TerminalAgentActivityEvent? {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(TerminalAgentActivityEvent.self, from: data)
}
}
struct TerminalAgentActivityReducer {
static let runningTTL: TimeInterval = 6 * 60 * 60
private(set) var state: TerminalAgentActivityState = .idle
private var activeSessionIDByAgent: [String: String] = [:]
private var runningStartedAt: Date?
mutating func apply(
_ event: TerminalAgentActivityEvent,
expectedSurfaceID: String? = nil,
now: Date = Date()
) -> TerminalAgentActivityState? {
if let expectedSurfaceID,
let eventSurfaceID = event.surfaceID,
eventSurfaceID != expectedSurfaceID {
return nil
}
let agent = event.agent.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !agent.isEmpty else { return nil }
guard let nextState = Self.state(from: event, agent: agent) else { return nil }
if Self.isSessionBoundaryEvent(event),
let sessionID = Self.normalized(event.sessionID),
let activeSessionID = activeSessionIDByAgent[agent],
activeSessionID != sessionID {
return nil
}
if let sessionID = Self.normalized(event.sessionID), Self.startsOrContinuesSession(nextState) {
activeSessionIDByAgent[agent] = sessionID
}
switch nextState {
case .idle:
activeSessionIDByAgent[agent] = nil
guard state.agent == nil || state.agent == agent else { return state }
state = .idle
runningStartedAt = nil
case .running:
state = nextState
runningStartedAt = now
case .needsInput, .error:
state = nextState
runningStartedAt = nil
}
return state
}
mutating func acknowledgeAttention() -> TerminalAgentActivityState? {
switch state {
case .needsInput(let agent), .error(let agent):
activeSessionIDByAgent[agent] = nil
state = .idle
runningStartedAt = nil
return state
case .idle, .running:
return nil
}
}
mutating func expireRunningState(now: Date = Date()) -> TerminalAgentActivityState? {
guard case .running = state,
let runningStartedAt,
now.timeIntervalSince(runningStartedAt) >= Self.runningTTL
else {
return nil
}
state = .idle
self.runningStartedAt = nil
return state
}
private static func state(
from event: TerminalAgentActivityEvent,
agent: String
) -> TerminalAgentActivityState? {
if normalized(event.event) == "stop" {
return .needsInput(agent: agent)
}
if let state = normalized(event.state) {
switch state {
case "running":
return .running(agent: agent)
case "needsinput", "needs-input", "needs_input":
return .needsInput(agent: agent)
case "error":
return .error(agent: agent)
case "idle":
return .idle
default:
return nil
}
}
switch normalized(event.event) {
case "session-start":
return .idle
case "prompt-submit", "user-prompt-submit", "pre-tool-use":
return .running(agent: agent)
case "notification", "permission-request", "ask-user-question":
return .needsInput(agent: agent)
case "stop":
return .needsInput(agent: agent)
case "idle", "session-end":
return .idle
case "error", "failure", "failed", "hook-error":
return .error(agent: agent)
default:
return nil
}
}
private static func startsOrContinuesSession(_ state: TerminalAgentActivityState) -> Bool {
switch state {
case .idle:
false
case .running, .needsInput, .error:
true
}
}
private static func isSessionBoundaryEvent(_ event: TerminalAgentActivityEvent) -> Bool {
switch normalized(event.event) {
case "stop", "idle", "session-end":
true
default:
false
}
}
private static func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed.lowercased()
}
}

View File

@ -22,6 +22,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
case .native: "Terminal"
case .hidden: "TerminalHiddenTitlebar"
case .transparent: "TerminalTransparentTitlebar"
case .sidebar: "TerminalTransparentTitlebar"
case .tabs:
#if compiler(>=6.2)
if #available(macOS 26.0, *) {
@ -433,6 +434,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig)
controller.isBackgroundOpaque = parentController.isBackgroundOpaque
guard let window = controller.window else { return controller }
if let parentWindow = parent as? TerminalWindow,
let newWindow = window as? TerminalWindow,
parentWindow.isSidebarCollapsed {
newWindow.setSidebarCollapsed(true, propagateToTabGroup: false)
}
// If the parent is miniaturized, then macOS exhibits really strange behaviors
// so we have to bring it back out.
@ -1089,6 +1095,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
container.initialContentSize = focusedSurface?.initialSize
window.contentView = container
(window as? TerminalWindow)?.installSidebarIfNeeded()
// If we have a default size, we want to apply it.
if let defaultSize {
@ -1415,6 +1422,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
focusedSurface.$backgroundColor
.sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) }
.store(in: &surfaceAppearanceCancellables)
focusedSurface.$foregroundColor
.sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) }
.store(in: &surfaceAppearanceCancellables)
}
private func syncAppearanceOnPropertyChange(_ surface: Ghostty.SurfaceView?) {

View File

@ -0,0 +1,728 @@
import AppKit
import Combine
import SwiftUI
struct TerminalSidebarTheme: Equatable {
let background: NSColor
let foreground: NSColor
let mutedForeground: NSColor
let separator: NSColor
let selectedBackground: NSColor
let selectedForeground: NSColor
let buttonTint: NSColor
let status: NSColor
let error: NSColor
let colorScheme: ColorScheme
static let fallback = TerminalSidebarTheme(
backgroundColor: .windowBackgroundColor,
foregroundColor: .labelColor)
init(backgroundColor: NSColor, foregroundColor: NSColor) {
let background = backgroundColor.usingColorSpace(.sRGB) ?? backgroundColor
let foreground = foregroundColor.usingColorSpace(.sRGB) ?? foregroundColor
self.background = background
self.foreground = foreground
self.mutedForeground = foreground.withAlphaComponent(0.68)
self.separator = foreground.withAlphaComponent(0.18)
self.selectedBackground = foreground.withAlphaComponent(
background.isLightColor ? 0.10 : 0.16)
self.selectedForeground = foreground
self.buttonTint = foreground.withAlphaComponent(0.72)
self.status = NSColor(
srgbRed: 59.0 / 255.0,
green: 130.0 / 255.0,
blue: 246.0 / 255.0,
alpha: 1.0)
self.error = .systemRed
self.colorScheme = background.isLightColor ? .light : .dark
}
static func == (lhs: TerminalSidebarTheme, rhs: TerminalSidebarTheme) -> Bool {
colorsEqual(lhs.background, rhs.background) &&
colorsEqual(lhs.foreground, rhs.foreground) &&
colorsEqual(lhs.mutedForeground, rhs.mutedForeground) &&
colorsEqual(lhs.separator, rhs.separator) &&
colorsEqual(lhs.selectedBackground, rhs.selectedBackground) &&
colorsEqual(lhs.selectedForeground, rhs.selectedForeground) &&
colorsEqual(lhs.buttonTint, rhs.buttonTint) &&
colorsEqual(lhs.status, rhs.status) &&
colorsEqual(lhs.error, rhs.error) &&
lhs.colorScheme == rhs.colorScheme
}
private static func colorsEqual(_ lhs: NSColor, _ rhs: NSColor) -> Bool {
let lhsRGB = lhs.usingColorSpace(.sRGB)
let rhsRGB = rhs.usingColorSpace(.sRGB)
guard let lhsRGB, let rhsRGB else {
return lhs.isEqual(rhs)
}
return abs(lhsRGB.redComponent - rhsRGB.redComponent) < 0.001 &&
abs(lhsRGB.greenComponent - rhsRGB.greenComponent) < 0.001 &&
abs(lhsRGB.blueComponent - rhsRGB.blueComponent) < 0.001 &&
abs(lhsRGB.alphaComponent - rhsRGB.alphaComponent) < 0.001
}
}
/// Owns the sidebar view for one terminal window.
final class TerminalSidebarController {
static let width: CGFloat = 176
static let minWidth: CGFloat = 136
static let maxWidth: CGFloat = 320
private(set) static var preferredWidth: CGFloat = width
private let model: TerminalSidebarModel
let view: NSView
init(hostWindow: TerminalWindow) {
self.model = TerminalSidebarModel(hostWindow: hostWindow)
let hostingView = NSHostingView(rootView: TerminalSidebarView(model: model))
hostingView.translatesAutoresizingMaskIntoConstraints = false
self.view = hostingView
}
func sync() {
model.syncSoon()
}
func updateTheme(_ theme: TerminalSidebarTheme) {
model.updateTheme(theme)
}
static func setPreferredWidth(_ width: CGFloat) {
preferredWidth = min(max(width, minWidth), maxWidth)
}
@discardableResult
static func newSession(from hostWindow: NSWindow?) -> TerminalController? {
guard let hostWindow else { return nil }
let parentWindow = hostWindow.tabGroup?.selectedWindow ?? hostWindow
guard let controller = parentWindow.windowController as? TerminalController
else { return nil }
return TerminalController.newTab(controller.ghostty, from: parentWindow)
}
}
private final class TerminalSidebarModel: ObservableObject {
@Published private(set) var sessions: [TerminalSidebarSession] = []
@Published private(set) var theme: TerminalSidebarTheme = .fallback
@Published var editingSessionID: ObjectIdentifier?
private weak var hostWindow: TerminalWindow?
private weak var observedTabGroup: NSWindowTabGroup?
private var tabGroupWindowsObservation: NSKeyValueObservation?
private var tabBarVisibleObservation: NSKeyValueObservation?
private var notificationObservers: [NSObjectProtocol] = []
init(hostWindow: TerminalWindow) {
self.hostWindow = hostWindow
let center = NotificationCenter.default
notificationObservers = [
center.addObserver(
forName: TerminalWindow.terminalTitleDidChangeNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.syncIfRelevant(window: notification.object as? NSWindow)
},
center.addObserver(
forName: TerminalWindow.terminalSidebarMetadataDidChangeNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.syncIfRelevant(window: notification.object as? NSWindow)
},
center.addObserver(
forName: .terminalWindowBellDidChangeNotification,
object: nil,
queue: .main
) { [weak self] notification in
let controller = notification.object as? BaseTerminalController
self?.syncIfRelevant(window: controller?.window)
},
center.addObserver(
forName: TerminalWindow.terminalDidAwake,
object: nil,
queue: .main
) { [weak self] notification in
self?.syncIfRelevant(window: notification.object as? NSWindow)
},
center.addObserver(
forName: TerminalWindow.terminalWillCloseNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.syncSoon()
},
center.addObserver(
forName: NSWindow.didBecomeKeyNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.syncIfRelevant(window: notification.object as? NSWindow)
},
center.addObserver(
forName: NSWindow.didBecomeMainNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.syncIfRelevant(window: notification.object as? NSWindow)
},
]
sync()
}
deinit {
for observer in notificationObservers {
NotificationCenter.default.removeObserver(observer)
}
tabGroupWindowsObservation?.invalidate()
tabBarVisibleObservation?.invalidate()
}
func syncSoon() {
DispatchQueue.main.async { [weak self] in
self?.sync()
}
}
func sync() {
guard let hostWindow else {
sessions = []
return
}
observeTabGroupIfNeeded(hostWindow.tabGroup)
updateTheme(hostWindow.sidebarTheme)
let windows = tabWindows()
let selectedWindow = hostWindow.tabGroup?.selectedWindow ?? hostWindow
sessions = windows.enumerated().compactMap { index, window in
guard let controller = window.windowController as? BaseTerminalController else {
return nil
}
let isSelected = window === selectedWindow
return TerminalSidebarSession(
id: ObjectIdentifier(window),
index: index + 1,
title: sidebarTitle(for: window),
keyEquivalent: (window as? TerminalWindow)?.keyEquivalent,
isSelected: isSelected,
indicatorState: tabIndicatorState(controller, isSelected: isSelected),
tabColor: (window as? TerminalWindow)?.tabColor.displayColor
)
}
}
func select(_ session: TerminalSidebarSession) {
guard let window = window(for: session.id) else { return }
if let tabGroup = window.tabGroup {
tabGroup.selectedWindow = window
}
acknowledgeIndicatorIfNeeded(session, in: window)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
focusTerminal(in: window)
syncSoon()
}
func rename(_ session: TerminalSidebarSession) {
editingSessionID = session.id
}
func commitRename(_ session: TerminalSidebarSession, title: String) {
guard let window = window(for: session.id),
let controller = window.windowController as? BaseTerminalController
else {
editingSessionID = nil
return
}
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
controller.titleOverride = trimmedTitle.isEmpty ? nil : trimmedTitle
editingSessionID = nil
focusSelectedTerminal()
syncSoon()
}
func cancelRename() {
editingSessionID = nil
focusSelectedTerminal()
}
func close(_ session: TerminalSidebarSession) {
guard let controller = window(for: session.id)?.windowController as? TerminalController else {
return
}
controller.closeTab(nil)
DispatchQueue.main.async { [weak self] in
self?.focusSelectedTerminal()
}
}
func newSession() {
guard let hostWindow else { return }
let newController = TerminalSidebarController.newSession(from: hostWindow)
DispatchQueue.main.async { [weak self, weak newController] in
guard let window = newController?.window else { return }
self?.focusTerminal(in: window)
}
}
func updateTheme(_ newTheme: TerminalSidebarTheme) {
guard theme != newTheme else { return }
theme = newTheme
}
private func syncIfRelevant(window: NSWindow?) {
guard let window else {
syncSoon()
return
}
if tabWindows().contains(where: { $0 === window }) {
syncSoon()
}
}
private func observeTabGroupIfNeeded(_ tabGroup: NSWindowTabGroup?) {
guard observedTabGroup !== tabGroup else { return }
tabGroupWindowsObservation?.invalidate()
tabBarVisibleObservation?.invalidate()
tabGroupWindowsObservation = nil
tabBarVisibleObservation = nil
observedTabGroup = tabGroup
guard let tabGroup else { return }
tabGroupWindowsObservation = tabGroup.observe(\.windows, options: [.new]) { [weak self] _, _ in
self?.syncSoon()
}
tabBarVisibleObservation = tabGroup.observe(\.isTabBarVisible, options: [.new]) { [weak self] _, _ in
self?.hostWindow?.hideNativeTabBarForSidebar()
self?.syncSoon()
}
}
private func tabWindows() -> [NSWindow] {
guard let hostWindow else { return [] }
return hostWindow.tabGroup?.windows ?? [hostWindow]
}
private func window(for id: ObjectIdentifier) -> NSWindow? {
tabWindows().first { ObjectIdentifier($0) == id }
}
private func acknowledgeIndicatorIfNeeded(_ session: TerminalSidebarSession, in window: NSWindow) {
guard session.indicatorState.isAttentionIndicator,
let controller = window.windowController as? BaseTerminalController
else {
return
}
acknowledgeAttentionIndicators(in: controller)
}
private func acknowledgeAttentionIndicators(in controller: BaseTerminalController) {
for surfaceView in controller.surfaceTree {
surfaceView.acknowledgeSidebarIndicator()
}
}
private func focusSelectedTerminal() {
guard let hostWindow else { return }
focusTerminal(in: hostWindow.tabGroup?.selectedWindow ?? hostWindow)
}
private func focusTerminal(in window: NSWindow) {
if let tabGroup = window.tabGroup {
tabGroup.selectedWindow = window
}
guard let controller = window.windowController as? BaseTerminalController,
let focusedSurface = controller.focusedSurface
else { return }
controller.focusSurface(focusedSurface)
}
private func sidebarTitle(for window: NSWindow) -> String {
let title = window.title.trimmingCharacters(in: .whitespacesAndNewlines)
return title.isEmpty ? "Untitled" : title
}
private func tabIndicatorState(
_ controller: BaseTerminalController,
isSelected: Bool = false
) -> TerminalSidebarStatusIndicatorState {
var agentStates: [TerminalAgentActivityState] = []
var hasTerminalProgress = false
for surfaceView in controller.surfaceTree {
agentStates.append(surfaceView.agentActivityState)
if let progressReport = surfaceView.progressReport,
progressReport.state != .remove,
progressReport.state != .pause {
hasTerminalProgress = true
}
}
let state = TerminalSidebarStatusIndicatorState.derive(
from: agentStates,
hasTerminalProgress: hasTerminalProgress,
hasTerminalBell: controller.bell
)
let visibleState = state.visibleState(isSelected: isSelected)
if visibleState != state {
acknowledgeAttentionIndicators(in: controller)
}
return visibleState
}
}
private struct TerminalSidebarSession: Identifiable, Equatable {
let id: ObjectIdentifier
let index: Int
let title: String
let keyEquivalent: String?
let isSelected: Bool
let indicatorState: TerminalSidebarStatusIndicatorState
let tabColor: NSColor?
static func == (lhs: TerminalSidebarSession, rhs: TerminalSidebarSession) -> Bool {
lhs.id == rhs.id &&
lhs.index == rhs.index &&
lhs.title == rhs.title &&
lhs.keyEquivalent == rhs.keyEquivalent &&
lhs.isSelected == rhs.isSelected &&
lhs.indicatorState == rhs.indicatorState &&
colorsEqual(lhs.tabColor, rhs.tabColor)
}
private static func colorsEqual(_ lhs: NSColor?, _ rhs: NSColor?) -> Bool {
switch (lhs, rhs) {
case (.none, .none):
return true
case (.some(let lhs), .some(let rhs)):
return lhs.isEqual(rhs)
default:
return false
}
}
}
private struct TerminalSidebarView: View {
@ObservedObject var model: TerminalSidebarModel
var body: some View {
VStack(spacing: 0) {
ScrollView {
LazyVStack(spacing: 3) {
ForEach(model.sessions) { session in
TerminalSidebarRow(
session: session,
theme: model.theme,
isEditing: model.editingSessionID == session.id,
onSelect: {
model.select(session)
},
onRename: {
model.rename(session)
},
onClose: {
model.close(session)
},
onCommitRename: { title in
model.commitRename(session, title: title)
},
onCancelRename: {
model.cancelRename()
}
)
}
}
.padding(.horizontal, 7)
.padding(.top, 7)
.padding(.bottom, 8)
}
}
.frame(
minWidth: TerminalSidebarController.minWidth,
maxWidth: .infinity)
.background(Color(nsColor: model.theme.background))
.overlay(alignment: .trailing) {
Rectangle()
.fill(Color(nsColor: model.theme.separator))
.frame(width: 0.5)
}
.environment(\.colorScheme, model.theme.colorScheme)
.accessibilityIdentifier("TerminalSidebar")
}
}
private struct TerminalSidebarRow: View {
let session: TerminalSidebarSession
let theme: TerminalSidebarTheme
let isEditing: Bool
let onSelect: () -> Void
let onRename: () -> Void
let onClose: () -> Void
let onCommitRename: (String) -> Void
let onCancelRename: () -> Void
@FocusState private var isRenameFieldFocused: Bool
@State private var draftTitle = ""
var body: some View {
HStack(spacing: 8) {
if let tabColor = session.tabColor {
RoundedRectangle(cornerRadius: 2)
.fill(Color(nsColor: tabColor))
.frame(width: 3)
} else {
Color.clear
.frame(width: 3)
}
if isEditing {
TextField("Session Name", text: $draftTitle)
.textFieldStyle(.plain)
.font(.system(size: 12.5, weight: .semibold))
.foregroundStyle(Color(nsColor: theme.foreground))
.tint(Color(nsColor: theme.foreground))
.focused($isRenameFieldFocused)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
.onSubmit(commitRename)
.onExitCommand(perform: onCancelRename)
.accessibilityIdentifier("TerminalSidebarRenameField")
} else {
Text(session.title)
.font(.system(size: 12.5, weight: .regular))
.foregroundStyle(Color(nsColor: titleForeground))
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(1)
Spacer(minLength: 6)
TerminalSidebarStatusIndicator(
state: session.indicatorState,
spinnerColor: theme.mutedForeground,
statusColor: theme.status,
errorColor: theme.error
)
}
}
.padding(.horizontal, 7)
.frame(height: 32)
.background(rowBackground)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
.overlay {
if !isEditing {
TerminalSidebarClickTarget(
onSelect: onSelect,
onRename: onRename,
onClose: onClose
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.accessibilityElement(children: isEditing ? .contain : .ignore)
.accessibilityLabel(accessibilityLabel)
.accessibilityIdentifier("TerminalSidebarSession-\(session.index)")
.accessibilityAction {
onSelect()
}
.onAppear {
guard isEditing else { return }
draftTitle = session.title
DispatchQueue.main.async {
isRenameFieldFocused = true
}
}
.onChange(of: isEditing) { newValue in
if newValue {
draftTitle = session.title
DispatchQueue.main.async {
isRenameFieldFocused = true
}
} else {
isRenameFieldFocused = false
}
}
.onChange(of: isRenameFieldFocused) { focused in
guard isEditing, !focused else { return }
commitRename()
}
}
private var titleForeground: NSColor {
session.isSelected ? theme.selectedForeground : theme.mutedForeground
}
private var accessibilityLabel: String {
let base = "Session \(session.index): \(session.title)"
guard let status = session.indicatorState.accessibilityDescription else { return base }
return "\(base), \(status)"
}
@ViewBuilder
private var rowBackground: some View {
if session.isSelected {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color(nsColor: theme.selectedBackground))
} else {
Color.clear
}
}
private func commitRename() {
onCommitRename(draftTitle)
}
}
private struct TerminalSidebarStatusIndicator: View {
let state: TerminalSidebarStatusIndicatorState
let spinnerColor: NSColor
let statusColor: NSColor
let errorColor: NSColor
var body: some View {
ZStack {
switch state {
case .spinner:
TerminalSidebarRingSpinner(color: spinnerColor)
case .bell:
Circle()
.fill(Color(nsColor: statusColor))
.frame(width: 7, height: 7)
case .error:
Circle()
.fill(Color(nsColor: errorColor))
.frame(width: 7, height: 7)
case .none:
EmptyView()
}
}
.frame(width: 12, height: 12)
.accessibilityHidden(true)
}
}
private struct TerminalSidebarRingSpinner: View {
let color: NSColor
@State private var rotation = 0.0
var body: some View {
ZStack {
Circle()
.stroke(
Color(nsColor: color).opacity(0.24),
style: StrokeStyle(lineWidth: 1.45)
)
Circle()
.trim(from: 0.10, to: 0.82)
.stroke(
Color(nsColor: color).opacity(0.68),
style: StrokeStyle(
lineWidth: 1.45,
lineCap: .round
)
)
.rotationEffect(.degrees(rotation))
.animation(
.linear(duration: 0.95).repeatForever(autoreverses: false),
value: rotation
)
}
.frame(width: 10, height: 10)
.onAppear {
rotation = 360
}
}
}
/// AppKit-backed hit target so single-click selection fires on mouseDown
/// while double-click rename remains available.
private struct TerminalSidebarClickTarget: NSViewRepresentable {
let onSelect: () -> Void
let onRename: () -> Void
let onClose: () -> Void
func makeNSView(context: Context) -> TerminalSidebarClickView {
let view = TerminalSidebarClickView()
view.setAccessibilityElement(false)
updateNSView(view, context: context)
return view
}
func updateNSView(_ view: TerminalSidebarClickView, context: Context) {
view.onSelect = onSelect
view.onRename = onRename
view.onClose = onClose
}
}
private final class TerminalSidebarClickView: NSView {
var onSelect: (() -> Void)?
var onRename: (() -> Void)?
var onClose: (() -> Void)?
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
true
}
override func mouseDown(with event: NSEvent) {
onSelect?()
if event.clickCount >= 2 {
onRename?()
}
}
override func rightMouseDown(with event: NSEvent) {
NSMenu.popUpContextMenu(contextMenu(), with: event, for: self)
}
private func contextMenu() -> NSMenu {
let menu = NSMenu()
let rename = NSMenuItem(
title: "Rename Tab",
action: #selector(renameSession),
keyEquivalent: "")
rename.target = self
menu.addItem(rename)
let close = NSMenuItem(
title: "Close",
action: #selector(closeSession),
keyEquivalent: "")
close.target = self
menu.addItem(close)
return menu
}
@objc private func renameSession() {
onRename?()
}
@objc private func closeSession() {
onClose?()
}
}

View File

@ -64,6 +64,10 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
return URL(fileURLWithPath: surfacePwd)
}
private var extendsIntoTitlebar: Bool {
ghostty.config.macosTitlebarStyle == .hidden
}
var body: some View {
switch ghostty.readiness {
case .loading:
@ -75,7 +79,9 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
VStack(spacing: 0) {
// If we're running in debug mode we show a warning so that users
// know that performance will be degraded.
if Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG || Ghostty.info.mode == GHOSTTY_BUILD_MODE_RELEASE_SAFE {
if ghostty.config.macosTitlebarStyle != .sidebar &&
(Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG ||
Ghostty.info.mode == GHOSTTY_BUILD_MODE_RELEASE_SAFE) {
DebugBuildWarningView()
}
@ -104,8 +110,9 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
.frame(idealWidth: lastFocusedSurface?.value?.initialSize?.width,
idealHeight: lastFocusedSurface?.value?.initialSize?.height)
}
// Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style
.ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == .hidden ? .top : [])
// Ignore safe area to extend up in to the titlebar region for
// titlebar styles that draw their own top chrome.
.ignoresSafeArea(.container, edges: extendsIntoTitlebar ? .top : [])
if let surfaceView = lastFocusedSurface?.value {
TerminalCommandPaletteView(

View File

@ -5,6 +5,12 @@ import SwiftUI
/// Modifying `NSThemeFrame` can sometimes be unpredictable.
class TerminalViewContainer: NSView {
private let terminalView: NSView
private var sidebarView: NSView?
private var sidebarWidth: CGFloat = 0
private var sidebarWidthConstraint: NSLayoutConstraint?
private var terminalLeadingConstraint: NSLayoutConstraint?
private var terminalLeadingSidebarConstraint: NSLayoutConstraint?
private var sidebarResizeHandle: SidebarResizeHandle?
/// Combined glass effect and inactive tint overlay view
private(set) var glassEffectView: NSView?
@ -22,6 +28,13 @@ class TerminalViewContainer: NSView {
return window.value(forKey: "_cornerRadius") as? CGFloat
}
var currentSidebarWidth: CGFloat {
guard sidebarView != nil else { return 0 }
let layoutWidth = sidebarView?.frame.width ?? 0
return layoutWidth > 0 ? layoutWidth : sidebarWidth
}
init<Root: View>(@ViewBuilder rootView: () -> Root) {
self.terminalView = NSHostingView(rootView: rootView())
super.init(frame: .zero)
@ -45,33 +58,161 @@ class TerminalViewContainer: NSView {
// with the correct idealWidth/idealHeight. Before that (when
// @FocusedValue hasn't propagated), it returns a tiny default.
// Fall back to initialContentSize in that case.
let terminalSize: NSSize
if let initialContentSize,
hostingSize.width < initialContentSize.width || hostingSize.height < initialContentSize.height {
return initialContentSize
terminalSize = initialContentSize
} else {
terminalSize = hostingSize
}
return hostingSize
return NSSize(
width: terminalSize.width + sidebarWidth,
height: terminalSize.height)
}
private func setup() {
addSubview(terminalView)
terminalView.translatesAutoresizingMaskIntoConstraints = false
let leadingConstraint = terminalView.leadingAnchor.constraint(equalTo: leadingAnchor)
terminalLeadingConstraint = leadingConstraint
NSLayoutConstraint.activate([
terminalView.topAnchor.constraint(equalTo: topAnchor),
terminalView.leadingAnchor.constraint(equalTo: leadingAnchor),
leadingConstraint,
terminalView.bottomAnchor.constraint(equalTo: bottomAnchor),
terminalView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
}
func installSidebar(_ sidebar: NSView, width: CGFloat) {
guard sidebarView !== sidebar else {
setSidebarWidth(width, propagateToTabGroup: false)
syncSidebarTitlebarWidth()
return
}
sidebarView?.removeFromSuperview()
sidebarResizeHandle?.removeFromSuperview()
sidebarView = sidebar
setSidebarWidth(width, propagateToTabGroup: false)
addSubview(sidebar)
sidebar.translatesAutoresizingMaskIntoConstraints = false
terminalLeadingConstraint?.isActive = false
let terminalToSidebar = terminalView.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor)
let widthConstraint = sidebar.widthAnchor.constraint(equalToConstant: sidebarWidth)
terminalLeadingSidebarConstraint = terminalToSidebar
sidebarWidthConstraint = widthConstraint
NSLayoutConstraint.activate([
sidebar.leadingAnchor.constraint(equalTo: leadingAnchor),
sidebar.topAnchor.constraint(equalTo: topAnchor),
sidebar.bottomAnchor.constraint(equalTo: bottomAnchor),
widthConstraint,
sidebar.widthAnchor.constraint(greaterThanOrEqualToConstant: TerminalSidebarController.minWidth),
sidebar.widthAnchor.constraint(lessThanOrEqualToConstant: TerminalSidebarController.maxWidth),
terminalToSidebar,
])
let resizeHandle = SidebarResizeHandle(container: self)
sidebarResizeHandle = resizeHandle
addSubview(resizeHandle, positioned: .above, relativeTo: sidebar)
NSLayoutConstraint.activate([
resizeHandle.centerXAnchor.constraint(equalTo: sidebar.trailingAnchor),
resizeHandle.topAnchor.constraint(equalTo: topAnchor),
resizeHandle.bottomAnchor.constraint(equalTo: bottomAnchor),
resizeHandle.widthAnchor.constraint(equalToConstant: 8),
])
invalidateIntrinsicContentSize()
syncSidebarTitlebarWidth()
}
func removeSidebar() {
guard let sidebarView else { return }
sidebarView.removeFromSuperview()
sidebarResizeHandle?.removeFromSuperview()
self.sidebarView = nil
sidebarResizeHandle = nil
sidebarWidth = 0
sidebarWidthConstraint = nil
terminalLeadingSidebarConstraint?.isActive = false
terminalLeadingSidebarConstraint = nil
terminalLeadingConstraint?.isActive = true
invalidateIntrinsicContentSize()
(window as? TerminalWindow)?.removeSidebarTitlebarBackground()
}
fileprivate func resizeSidebar(by deltaX: CGFloat) {
setSidebarWidth(sidebarWidth + deltaX, propagateToTabGroup: true)
sidebarWidthConstraint?.constant = sidebarWidth
layoutSubtreeIfNeeded()
}
func resizeSidebarFromTitlebar(by deltaX: CGFloat) {
resizeSidebar(by: deltaX)
}
func syncSidebarTitlebarWidth() {
guard sidebarView != nil else { return }
let width = clampedSidebarWidth(currentSidebarWidth)
(window as? TerminalWindow)?.setSidebarTitlebarWidth(width)
}
private func setSidebarWidth(_ width: CGFloat, propagateToTabGroup: Bool) {
sidebarWidth = clampedSidebarWidth(width)
TerminalSidebarController.setPreferredWidth(sidebarWidth)
sidebarWidthConstraint?.constant = sidebarWidth
invalidateIntrinsicContentSize()
(window as? TerminalWindow)?.setSidebarTitlebarWidth(sidebarWidth)
if propagateToTabGroup {
syncSidebarWidthAcrossTabGroup()
}
}
private func clampedSidebarWidth(_ width: CGFloat) -> CGFloat {
min(
max(width, TerminalSidebarController.minWidth),
TerminalSidebarController.maxWidth)
}
private func syncSidebarWidthAcrossTabGroup() {
guard let windows = window?.tabGroup?.windows else { return }
for window in windows where window.contentView !== self {
guard let container = window.contentView as? TerminalViewContainer else {
continue
}
container.setSidebarWidth(sidebarWidth, propagateToTabGroup: false)
}
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
updateGlassEffectIfNeeded()
updateGlassEffectTopInsetIfNeeded()
}
override func hitTest(_ point: NSPoint) -> NSView? {
if let sidebarResizeHandle {
let handlePoint = convert(point, to: sidebarResizeHandle)
if sidebarResizeHandle.bounds.contains(handlePoint) {
return sidebarResizeHandle
}
}
return super.hitTest(point)
}
override func layout() {
super.layout()
updateGlassEffectTopInsetIfNeeded()
syncSidebarTitlebarWidth()
}
func ghosttyConfigDidChange(_ config: Ghostty.Config, preferredBackgroundColor: NSColor?) {
@ -90,6 +231,45 @@ extension BaseTerminalController {
}
}
private final class SidebarResizeHandle: NSView {
private weak var container: TerminalViewContainer?
private var lastMouseX: CGFloat?
init(container: TerminalViewContainer) {
self.container = container
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func resetCursorRects() {
addCursorRect(bounds, cursor: .resizeLeftRight)
}
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
true
}
override func mouseDown(with event: NSEvent) {
lastMouseX = event.locationInWindow.x
}
override func mouseDragged(with event: NSEvent) {
let currentX = event.locationInWindow.x
let previousX = lastMouseX ?? currentX
lastMouseX = currentX
container?.resizeSidebar(by: currentX - previousX)
}
override func mouseUp(with event: NSEvent) {
lastMouseX = nil
}
}
// MARK: Glass
/// An `NSView` that contains a liquid glass background effect and

View File

@ -11,6 +11,12 @@ class TerminalWindow: NSWindow {
/// Posted when a terminal window will close
static let terminalWillCloseNotification = Notification.Name("TerminalWindowWillClose")
/// Posted when the window title changes.
static let terminalTitleDidChangeNotification = Notification.Name("TerminalWindowTitleDidChange")
/// Posted when tab/session metadata mirrored by the sidebar changes.
static let terminalSidebarMetadataDidChangeNotification = Notification.Name("TerminalWindowSidebarMetadataDidChange")
/// This is the key in UserDefaults to use for the default `level` value. This is
/// used by the manual float on top menu item feature.
static let defaultLevelKey: String = "TerminalDefaultLevel"
@ -43,6 +49,19 @@ class TerminalWindow: NSWindow {
delegate: self
)
/// Custom vertical session sidebar for `macos-titlebar-style = sidebar`.
private var sidebarController: TerminalSidebarController?
private var sidebarTitlebarBackgroundView: SidebarTitlebarBackgroundView?
private var sidebarTitlebarWidthConstraint: NSLayoutConstraint?
private var sidebarTitlebarResizeHandle: SidebarTitlebarResizeHandle?
private var sidebarTitlebarResizeHandleCenterXConstraint: NSLayoutConstraint?
private var sidebarTitlebarControlsView: SidebarTitlebarControlsView?
private var sidebarCollapsed = false
var isSidebarCollapsed: Bool {
sidebarCollapsed
}
/// Whether this window supports the update accessory. If this is false, then views within this
/// window should determine how to show update notifications.
var supportsUpdateAccessory: Bool {
@ -64,6 +83,7 @@ class TerminalWindow: NSWindow {
didSet {
guard tabColor != oldValue else { return }
tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor)
NotificationCenter.default.post(name: Self.terminalSidebarMetadataDidChangeNotification, object: self)
invalidateRestorableState()
}
}
@ -180,6 +200,10 @@ class TerminalWindow: NSWindow {
override var canBecomeMain: Bool { return true }
override func sendEvent(_ event: NSEvent) {
if handleSidebarToggleShortcut(event) {
return
}
if tabTitleEditor.handleMouseDown(event) {
return
}
@ -213,7 +237,11 @@ class TerminalWindow: NSWindow {
// Its possible we miss the accessory titlebar call so we check again
// whenever the window becomes main. Both of these are idempotent.
if tabBarView != nil {
if derivedConfig.macosTitlebarStyle == .sidebar {
configureSidebarChrome()
hideNativeTabBarForSidebar()
terminalController?.terminalViewContainer?.syncSidebarTitlebarWidth()
} else if tabBarView != nil {
tabBarDidAppear()
} else {
tabBarDidDisappear()
@ -231,6 +259,239 @@ class TerminalWindow: NSWindow {
tabTitleEditor.beginEditing(for: targetWindow)
}
func installSidebarIfNeeded() {
guard derivedConfig.macosTitlebarStyle == .sidebar else {
sidebarCollapsed = false
sidebarController = nil
terminalController?.terminalViewContainer?.removeSidebar()
removeSidebarTitlebarBackground()
return
}
guard !sidebarCollapsed else {
configureSidebarChrome()
terminalController?.terminalViewContainer?.removeSidebar()
hideNativeTabBarForSidebar()
return
}
guard let container = terminalController?.terminalViewContainer else { return }
configureSidebarChrome()
let controller = sidebarController ?? TerminalSidebarController(hostWindow: self)
sidebarController = controller
container.installSidebar(controller.view, width: TerminalSidebarController.preferredWidth)
container.syncSidebarTitlebarWidth()
controller.sync()
syncSidebarAppearance()
hideNativeTabBarForSidebar()
}
@discardableResult
func toggleSidebar(_ _: Any?) -> Bool {
guard derivedConfig.macosTitlebarStyle == .sidebar else { return false }
setSidebarCollapsed(!sidebarCollapsed, propagateToTabGroup: true)
return true
}
func setSidebarCollapsed(_ collapsed: Bool, propagateToTabGroup: Bool) {
guard derivedConfig.macosTitlebarStyle == .sidebar else { return }
sidebarCollapsed = collapsed
if collapsed {
terminalController?.terminalViewContainer?.removeSidebar()
hideNativeTabBarForSidebar()
} else {
installSidebarIfNeeded()
}
if propagateToTabGroup {
syncSidebarCollapsedAcrossTabGroup()
}
}
private func syncSidebarCollapsedAcrossTabGroup() {
guard let windows = tabGroup?.windows else { return }
for window in windows where window !== self {
guard let terminalWindow = window as? TerminalWindow else {
continue
}
terminalWindow.setSidebarCollapsed(sidebarCollapsed, propagateToTabGroup: false)
}
}
private func handleSidebarToggleShortcut(_ event: NSEvent) -> Bool {
guard event.type == .keyDown else { return false }
guard derivedConfig.macosTitlebarStyle == .sidebar else { return false }
guard event.isSidebarToggleShortcut else { return false }
guard !sidebarToggleConflictsWithGhosttyBinding(event) else { return false }
if !event.isARepeat {
toggleSidebar(event)
}
return true
}
private func sidebarToggleConflictsWithGhosttyBinding(_ event: NSEvent) -> Bool {
guard let surface = terminalController?.focusedSurface?.surfaceModel else {
return false
}
var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
return (event.characters ?? "").withCString { ptr in
ghosttyEvent.text = ptr
return surface.keyIsBinding(ghosttyEvent) != nil
}
}
private func syncSidebarAppearance() {
guard derivedConfig.macosTitlebarStyle == .sidebar else { return }
let theme = sidebarTheme
sidebarController?.updateTheme(theme)
sidebarTitlebarBackgroundView?.theme = theme
sidebarTitlebarControlsView?.theme = theme
}
private func configureSidebarChrome() {
styleMask.insert(.fullSizeContentView)
titleVisibility = .hidden
titlebarAppearsTransparent = true
toolbar = nil
guard !sidebarCollapsed else {
removeSidebarTitlebarBackground()
return
}
let currentWidth = terminalController?.terminalViewContainer?.currentSidebarWidth ?? 0
let width = currentWidth > 0 ? currentWidth : TerminalSidebarController.preferredWidth
setSidebarTitlebarWidth(width)
DispatchQueue.main.async { [weak self] in
if let container = self?.terminalController?.terminalViewContainer {
container.syncSidebarTitlebarWidth()
} else {
self?.setSidebarTitlebarWidth(TerminalSidebarController.preferredWidth)
}
}
}
func setSidebarTitlebarWidth(_ width: CGFloat) {
guard derivedConfig.macosTitlebarStyle == .sidebar else { return }
let width = min(
max(width, TerminalSidebarController.minWidth),
TerminalSidebarController.maxWidth)
guard let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") else {
return
}
let backgroundView = sidebarTitlebarBackgroundView ?? SidebarTitlebarBackgroundView()
backgroundView.hostWindow = self
backgroundView.theme = sidebarTheme
if backgroundView.superview !== titlebarView {
sidebarTitlebarWidthConstraint?.isActive = false
backgroundView.removeFromSuperview()
backgroundView.translatesAutoresizingMaskIntoConstraints = false
if let firstSubview = titlebarView.subviews.first {
titlebarView.addSubview(backgroundView, positioned: .below, relativeTo: firstSubview)
} else {
titlebarView.addSubview(backgroundView)
}
let widthConstraint = backgroundView.widthAnchor.constraint(equalToConstant: width)
NSLayoutConstraint.activate([
backgroundView.leadingAnchor.constraint(equalTo: titlebarView.leadingAnchor),
backgroundView.topAnchor.constraint(equalTo: titlebarView.topAnchor),
backgroundView.bottomAnchor.constraint(equalTo: titlebarView.bottomAnchor),
widthConstraint,
])
sidebarTitlebarWidthConstraint = widthConstraint
sidebarTitlebarBackgroundView = backgroundView
}
sidebarTitlebarWidthConstraint?.constant = width
backgroundView.needsDisplay = true
installSidebarTitlebarResizeHandle(in: titlebarView, width: width)
installSidebarTitlebarControls(in: titlebarView)
}
func removeSidebarTitlebarBackground() {
sidebarTitlebarWidthConstraint?.isActive = false
sidebarTitlebarWidthConstraint = nil
sidebarTitlebarBackgroundView?.removeFromSuperview()
sidebarTitlebarBackgroundView = nil
sidebarTitlebarResizeHandleCenterXConstraint?.isActive = false
sidebarTitlebarResizeHandleCenterXConstraint = nil
sidebarTitlebarResizeHandle?.removeFromSuperview()
sidebarTitlebarResizeHandle = nil
sidebarTitlebarControlsView?.removeFromSuperview()
sidebarTitlebarControlsView = nil
}
private func installSidebarTitlebarResizeHandle(in titlebarView: NSView, width: CGFloat) {
let resizeHandle = sidebarTitlebarResizeHandle ?? SidebarTitlebarResizeHandle(hostWindow: self)
resizeHandle.hostWindow = self
if resizeHandle.superview !== titlebarView {
sidebarTitlebarResizeHandleCenterXConstraint?.isActive = false
resizeHandle.removeFromSuperview()
resizeHandle.translatesAutoresizingMaskIntoConstraints = false
titlebarView.addSubview(resizeHandle)
let centerXConstraint = resizeHandle.centerXAnchor.constraint(
equalTo: titlebarView.leadingAnchor,
constant: width)
NSLayoutConstraint.activate([
centerXConstraint,
resizeHandle.topAnchor.constraint(equalTo: titlebarView.topAnchor),
resizeHandle.bottomAnchor.constraint(equalTo: titlebarView.bottomAnchor),
resizeHandle.widthAnchor.constraint(equalToConstant: 10),
])
sidebarTitlebarResizeHandleCenterXConstraint = centerXConstraint
sidebarTitlebarResizeHandle = resizeHandle
}
sidebarTitlebarResizeHandleCenterXConstraint?.constant = width
}
private func installSidebarTitlebarControls(in titlebarView: NSView) {
let controlsView = sidebarTitlebarControlsView ?? SidebarTitlebarControlsView(hostWindow: self)
controlsView.hostWindow = self
controlsView.theme = sidebarTheme
controlsView.updateTooltips()
if controlsView.superview !== titlebarView {
controlsView.removeFromSuperview()
controlsView.translatesAutoresizingMaskIntoConstraints = false
titlebarView.addSubview(controlsView)
NSLayoutConstraint.activate([
controlsView.trailingAnchor.constraint(equalTo: titlebarView.trailingAnchor, constant: -8),
controlsView.centerYAnchor.constraint(equalTo: titlebarView.centerYAnchor),
])
sidebarTitlebarControlsView = controlsView
}
}
func hideNativeTabBarForSidebar() {
guard derivedConfig.macosTitlebarStyle == .sidebar else { return }
for childViewController in titlebarAccessoryViewControllers where isTabBar(childViewController) {
childViewController.view.isHidden = true
childViewController.view.frame.size.height = 0
}
tabBarView?.isHidden = true
}
@objc private func renameTabFromContextMenu(_ sender: NSMenuItem) {
let targetWindow = sender.representedObject as? NSWindow ?? self
if beginInlineTabTitleEdit(for: targetWindow) {
@ -252,14 +513,24 @@ class TerminalWindow: NSWindow {
}
override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
let isNativeTabBar = isTabBar(childViewController)
if isNativeTabBar && derivedConfig.macosTitlebarStyle == .sidebar {
childViewController.layoutAttribute = .right
}
super.addTitlebarAccessoryViewController(childViewController)
// Tab bar is attached as a titlebar accessory view controller (layout bottom). We
// can detect when it is shown or hidden by overriding add/remove and searching for
// it. This has been verified to work on macOS 12 to 26
if isTabBar(childViewController) {
if isNativeTabBar {
childViewController.identifier = Self.tabBarIdentifier
tabBarDidAppear()
if derivedConfig.macosTitlebarStyle == .sidebar {
hideNativeTabBarForSidebar()
sidebarController?.sync()
} else {
tabBarDidAppear()
}
}
}
@ -331,6 +602,10 @@ class TerminalWindow: NSWindow {
var keyEquivalent: String? {
didSet {
defer {
NotificationCenter.default.post(name: Self.terminalSidebarMetadataDidChangeNotification, object: self)
}
// When our key equivalent is set, we must update the tab label.
guard let keyEquivalent else {
keyEquivalentLabel.attributedStringValue = NSAttributedString()
@ -402,6 +677,7 @@ class TerminalWindow: NSWindow {
/// Check ``titlebarFont`` down below
/// to see why we need to check `hasMoreThanOneTabs` here
titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs
NotificationCenter.default.post(name: Self.terminalTitleDidChangeNotification, object: self)
}
}
@ -503,6 +779,8 @@ class TerminalWindow: NSWindow {
let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor)
self.backgroundColor = backgroundColor.withAlphaComponent(1)
}
syncSidebarAppearance()
}
/// The preferred window background color. The current window background color may not be set
@ -511,31 +789,48 @@ class TerminalWindow: NSWindow {
/// This background color will include alpha transparency if set. If the caller doesn't want that,
/// change the alpha channel again manually.
var preferredBackgroundColor: NSColor? {
if let terminalController, !terminalController.surfaceTree.isEmpty {
let surface: Ghostty.SurfaceView?
// If our focused surface borders the top then we prefer its background color
if let focusedSurface = terminalController.focusedSurface,
let treeRoot = terminalController.surfaceTree.root,
let focusedNode = treeRoot.node(view: focusedSurface),
treeRoot.spatial().doesBorder(side: .up, from: focusedNode) {
surface = focusedSurface
} else {
// If it doesn't border the top, we use the top-left leaf
surface = terminalController.surfaceTree.root?.leftmostLeaf()
}
if let surface {
let backgroundColor = surface.backgroundColor ?? surface.derivedConfig.backgroundColor
let alpha = surface.derivedConfig.backgroundOpacity.clamped(to: 0.001...1)
return NSColor(backgroundColor).withAlphaComponent(alpha)
}
if let surface = preferredAppearanceSurface {
let backgroundColor = surface.backgroundColor ?? surface.derivedConfig.backgroundColor
let alpha = surface.derivedConfig.backgroundOpacity.clamped(to: 0.001...1)
return NSColor(backgroundColor).withAlphaComponent(alpha)
}
let alpha = derivedConfig.backgroundOpacity.clamped(to: 0.001...1)
return derivedConfig.backgroundColor.withAlphaComponent(alpha)
}
/// The preferred terminal foreground color that corresponds to `preferredBackgroundColor`.
var preferredForegroundColor: NSColor? {
if let surface = preferredAppearanceSurface {
return NSColor(surface.foregroundColor ?? surface.derivedConfig.foregroundColor)
}
return derivedConfig.foregroundColor
}
var sidebarTheme: TerminalSidebarTheme {
TerminalSidebarTheme(
backgroundColor: preferredBackgroundColor ?? derivedConfig.backgroundColor,
foregroundColor: preferredForegroundColor ?? derivedConfig.foregroundColor)
}
private var preferredAppearanceSurface: Ghostty.SurfaceView? {
guard let terminalController, !terminalController.surfaceTree.isEmpty else {
return nil
}
// If our focused surface borders the top then we prefer its colors.
if let focusedSurface = terminalController.focusedSurface,
let treeRoot = terminalController.surfaceTree.root,
let focusedNode = treeRoot.node(view: focusedSurface),
treeRoot.spatial().doesBorder(side: .up, from: focusedNode) {
return focusedSurface
}
// If it doesn't border the top, we use the top-left leaf.
return terminalController.surfaceTree.root?.leftmostLeaf()
}
func updateColorSchemeForSurfaceTree() {
terminalController?.updateColorSchemeForSurfaceTree()
}
@ -585,6 +880,7 @@ class TerminalWindow: NSWindow {
let title: String?
let backgroundBlur: Ghostty.Config.BackgroundBlur
let backgroundColor: NSColor
let foregroundColor: NSColor
let backgroundOpacity: Double
let macosWindowButtons: Ghostty.MacOSWindowButtons
let macosTitlebarStyle: Ghostty.Config.MacOSTitlebarStyle
@ -593,6 +889,7 @@ class TerminalWindow: NSWindow {
init() {
self.title = nil
self.backgroundColor = NSColor.windowBackgroundColor
self.foregroundColor = NSColor.labelColor
self.backgroundOpacity = 1
self.macosWindowButtons = .visible
self.backgroundBlur = .disabled
@ -603,6 +900,7 @@ class TerminalWindow: NSWindow {
init(_ config: Ghostty.Config) {
self.title = config.title
self.backgroundColor = NSColor(config.backgroundColor)
self.foregroundColor = NSColor(config.foregroundColor)
self.backgroundOpacity = config.backgroundOpacity
self.macosWindowButtons = config.macosWindowButtons
self.backgroundBlur = config.backgroundBlur
@ -621,6 +919,269 @@ class TerminalWindow: NSWindow {
}
}
private extension NSEvent {
var isSidebarToggleShortcut: Bool {
let shortcutModifiers: NSEvent.ModifierFlags = [.shift, .control, .option, .command]
guard modifierFlags.intersection(shortcutModifiers) == .command else {
return false
}
return charactersIgnoringModifiers?.lowercased() == "b"
}
}
private final class SidebarTitlebarBackgroundView: NSView {
weak var hostWindow: TerminalWindow?
var theme: TerminalSidebarTheme = .fallback {
didSet { applyTheme() }
}
private lazy var newSessionButton: NSButton = {
let button = NSButton()
button.image = NSImage(systemSymbolName: "plus", accessibilityDescription: "New Tab")
button.bezelStyle = .texturedRounded
button.isBordered = false
button.imagePosition = .imageOnly
button.contentTintColor = theme.buttonTint
button.target = self
button.action = #selector(newSession)
button.toolTip = sidebarTitlebarTooltip(
title: "New Tab",
action: "new_tab")
button.identifier = NSUserInterfaceItemIdentifier("TerminalSidebarNewSessionButton")
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
addSubview(newSessionButton)
NSLayoutConstraint.activate([
newSessionButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7),
newSessionButton.centerYAnchor.constraint(equalTo: centerYAnchor),
newSessionButton.widthAnchor.constraint(equalToConstant: 24),
newSessionButton.heightAnchor.constraint(equalToConstant: 24),
])
applyTheme()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var isFlipped: Bool { true }
override func hitTest(_ point: NSPoint) -> NSView? {
let hitView = super.hitTest(point)
return hitView === newSessionButton ? hitView : nil
}
override func draw(_ dirtyRect: NSRect) {
theme.background.setFill()
bounds.fill()
theme.separator.setFill()
NSRect(
x: bounds.maxX - 0.5,
y: bounds.minY,
width: 0.5,
height: bounds.height
).fill()
}
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
applyTheme()
updateTooltips()
}
func updateTooltips() {
newSessionButton.toolTip = sidebarTitlebarTooltip(
title: "New Tab",
action: "new_tab")
}
private func applyTheme() {
newSessionButton.contentTintColor = theme.buttonTint
needsDisplay = true
}
@objc private func newSession() {
let newController = TerminalSidebarController.newSession(from: hostWindow)
DispatchQueue.main.async { [weak newController] in
guard let controller = newController,
let focusedSurface = controller.focusedSurface
else { return }
controller.focusSurface(focusedSurface)
}
}
}
private final class SidebarTitlebarControlsView: NSView {
weak var hostWindow: TerminalWindow?
var theme: TerminalSidebarTheme = .fallback {
didSet { applyTheme() }
}
private lazy var splitDownButton = makeButton(
symbolName: "rectangle.split.1x2",
title: "Split Down",
identifier: "TerminalSidebarSplitDownButton",
action: #selector(splitDown))
private lazy var splitRightButton = makeButton(
symbolName: "rectangle.split.2x1",
title: "Split Right",
identifier: "TerminalSidebarSplitRightButton",
action: #selector(splitRight))
init(hostWindow: TerminalWindow) {
self.hostWindow = hostWindow
super.init(frame: .zero)
let stackView = NSStackView(views: [
splitDownButton,
splitRightButton,
])
stackView.orientation = .horizontal
stackView.spacing = 2
stackView.alignment = .centerY
stackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(stackView)
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
applyTheme()
updateTooltips()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var isFlipped: Bool { true }
override func hitTest(_ point: NSPoint) -> NSView? {
let hitView = super.hitTest(point)
return hitView is NSButton ? hitView : nil
}
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
applyTheme()
updateTooltips()
}
func updateTooltips() {
splitDownButton.toolTip = sidebarTitlebarTooltip(
title: "Split Down",
action: "new_split:down")
splitRightButton.toolTip = sidebarTitlebarTooltip(
title: "Split Right",
action: "new_split:right")
}
private func makeButton(
symbolName: String,
title: String,
identifier: String,
action: Selector
) -> NSButton {
let button = NSButton()
button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title)
button.bezelStyle = .texturedRounded
button.isBordered = false
button.imagePosition = .imageOnly
button.contentTintColor = theme.buttonTint
button.target = self
button.action = action
button.toolTip = title
button.identifier = NSUserInterfaceItemIdentifier(identifier)
button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
button.widthAnchor.constraint(equalToConstant: 24),
button.heightAnchor.constraint(equalToConstant: 24),
])
return button
}
private func applyTheme() {
splitDownButton.contentTintColor = theme.buttonTint
splitRightButton.contentTintColor = theme.buttonTint
}
@objc private func splitDown() {
hostWindow?.terminalController?.splitDown(self)
}
@objc private func splitRight() {
hostWindow?.terminalController?.splitRight(self)
}
}
private func sidebarTitlebarTooltip(title: String, action: String) -> String {
guard
let appDelegate = NSApp.delegate as? AppDelegate,
let shortcut = appDelegate.ghostty.config.keyboardShortcut(for: action)
else {
return title
}
return "\(title) (\(shortcut.description))"
}
private final class SidebarTitlebarResizeHandle: NSView {
weak var hostWindow: TerminalWindow?
private var lastMouseX: CGFloat?
init(hostWindow: TerminalWindow) {
self.hostWindow = hostWindow
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var mouseDownCanMoveWindow: Bool {
false
}
override func resetCursorRects() {
addCursorRect(bounds, cursor: .resizeLeftRight)
}
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
true
}
override func mouseDown(with event: NSEvent) {
lastMouseX = event.locationInWindow.x
}
override func mouseDragged(with event: NSEvent) {
let currentX = event.locationInWindow.x
let previousX = lastMouseX ?? currentX
lastMouseX = currentX
hostWindow?.terminalController?.terminalViewContainer?.resizeSidebarFromTitlebar(
by: currentX - previousX)
}
override func mouseUp(with event: NSEvent) {
lastMouseX = nil
}
}
// MARK: SwiftUI View
extension TerminalWindow {
@ -766,8 +1327,8 @@ extension TerminalWindow {
separator.identifier = Self.tabColorSeparatorIdentifier
menu.addItem(separator)
// Rename Tab...
let changeTitleItem = NSMenuItem(title: "Rename Tab...", action: #selector(TerminalWindow.renameTabFromContextMenu(_:)), keyEquivalent: "")
// Rename Tab
let changeTitleItem = NSMenuItem(title: "Rename Tab", action: #selector(TerminalWindow.renameTabFromContextMenu(_:)), keyEquivalent: "")
changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier
changeTitleItem.target = self
changeTitleItem.representedObject = target?.window

View File

@ -93,7 +93,8 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
// Only apply this for transparent and tabs titlebar styles
let isGlassStyle = derivedConfig.backgroundBlur.isGlassStyle
let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == .transparent ||
derivedConfig.macosTitlebarStyle == .tabs
derivedConfig.macosTitlebarStyle == .tabs ||
derivedConfig.macosTitlebarStyle == .sidebar
titlebarView.layer?.backgroundColor = (isGlassStyle && isTransparentTitlebar)
? NSColor.clear.cgColor

View File

@ -608,6 +608,9 @@ extension Ghostty {
case GHOSTTY_ACTION_PROGRESS_REPORT:
progressReport(app, target: target, v: action.action.progress_report)
case GHOSTTY_ACTION_TERMINAL_ACTIVITY:
terminalActivity(app, target: target)
case GHOSTTY_ACTION_CONFIG_CHANGE:
configChange(app, target: target, v: action.action.config_change)
@ -2018,6 +2021,27 @@ extension Ghostty {
}
}
private static func terminalActivity(
_ app: ghostty_app_t,
target: ghostty_target_s
) {
switch target.tag {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("terminal activity does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
DispatchQueue.main.async {
surfaceView.markTerminalActivity()
}
default:
assertionFailure()
}
}
private static func scrollbar(
_ app: ghostty_app_t,
target: ghostty_target_s,

View File

@ -359,7 +359,7 @@ extension Ghostty {
}
var macosTitlebarStyle: MacOSTitlebarStyle {
let defaultValue = MacOSTitlebarStyle.transparent
let defaultValue = MacOSTitlebarStyle.sidebar
guard let config = self.config else { return defaultValue }
var v: UnsafePointer<Int8>?
let key = "macos-titlebar-style"
@ -491,6 +491,26 @@ extension Ghostty {
)
}
var foregroundColor: Color {
var color: ghostty_config_color_s = .init()
let key = "foreground"
if !ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8))) {
#if os(macOS)
return Color(NSColor.labelColor)
#elseif os(iOS)
return Color(UIColor.label)
#else
#error("unsupported")
#endif
}
return .init(
red: Double(color.r) / 255,
green: Double(color.g) / 255,
blue: Double(color.b) / 255
)
}
var backgroundOpacity: Double {
guard let config = self.config else { return 1 }
var v: Double = 1
@ -930,7 +950,7 @@ extension Ghostty.Config {
}
enum MacOSTitlebarStyle: String {
static let `default` = MacOSTitlebarStyle.transparent
case native, transparent, tabs, hidden
static let `default` = MacOSTitlebarStyle.sidebar
case native, transparent, tabs, sidebar, hidden
}
}

View File

@ -1,5 +1,6 @@
import AppKit
import Combine
import Darwin
import SwiftUI
import CoreText
import UserNotifications
@ -22,6 +23,8 @@ extension Ghostty {
// The progress report (if any)
override var progressReport: Action.ProgressReport? {
didSet {
let wasSidebarActive = Self.isSidebarProgressActive(oldValue)
// Cancel any existing timer
progressReportTimer?.invalidate()
progressReportTimer = nil
@ -33,9 +36,25 @@ extension Ghostty {
self?.progressReportTimer = nil
}
}
if Self.isSidebarProgressActive(progressReport) != wasSidebarActive {
notifySidebarMetadataChanged()
}
}
}
/// True while the terminal has received recent output from the PTY.
@Published private(set) var recentTerminalActivity: Bool = false
/// Explicit lifecycle state reported by Claude/Codex agent hooks.
@Published private(set) var agentActivityState: TerminalAgentActivityState = .idle
/// Stable hook identity for this surface.
let agentSurfaceID: String
/// Per-surface JSONL file used by agent hook helpers.
let agentEventFileURL: URL
// The currently active key sequence. The sequence is not active if this is empty.
@Published var keySequence: [KeyboardShortcut] = []
@ -102,6 +121,10 @@ extension Ghostty {
/// dynamically updated. Otherwise, the background color is the default background color.
@Published private(set) var backgroundColor: Color?
/// The foreground color within the color palette of the surface. This is only set if it is
/// dynamically updated. Otherwise, the foreground color is the default foreground color.
@Published private(set) var foregroundColor: Color?
/// True when the bell is active. This is set inactive on focus or event.
@Published private(set) var bell: Bool = false
@ -201,6 +224,23 @@ extension Ghostty {
// Timer to remove progress report after 15 seconds
private var progressReportTimer: Timer?
// Timer to clear recent terminal output activity after the output goes quiet.
private var terminalActivityTimer: Timer?
// File-backed agent hook watcher state.
private var agentActivitySource: DispatchSourceFileSystemObject?
private var agentActivityFileDescriptor: CInt = -1
private var agentActivityReadOffset: UInt64 = 0
private var agentActivityPartialLine = ""
private var agentActivityReducer = TerminalAgentActivityReducer()
private var agentActivityTTLTimer: Timer?
// Used to suppress activity flashes caused by the terminal echoing user input.
private var lastUserInputAt: Date?
// Used to suppress activity flashes caused by terminal redraws after resizing.
private var terminalActivitySuppressedUntil: Date?
// This is the title from the terminal. This is nil if we're currently using
// the terminal title as the main title property. If the title is set manually
// by the user, this is set to the prior value (which may be empty, but non-nil).
@ -217,6 +257,9 @@ extension Ghostty {
override var acceptsFirstResponder: Bool { return true }
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
let surfaceUUID = uuid ?? UUID()
self.agentSurfaceID = surfaceUUID.uuidString.lowercased()
self.agentEventFileURL = Self.agentEventFileURL(surfaceID: agentSurfaceID)
self.markedText = NSMutableAttributedString()
// Our initial config always is our application wide config.
@ -235,7 +278,7 @@ extension Ghostty {
// Initialize with some default frame size. The important thing is that this
// is non-zero so that our layer bounds are non-zero so that our renderer
// can do SOMETHING.
super.init(id: uuid, frame: NSRect(x: 0, y: 0, width: 800, height: 600))
super.init(id: surfaceUUID, frame: NSRect(x: 0, y: 0, width: 800, height: 600))
// Our cache of screen data
cachedScreenContents = .init(duration: .milliseconds(500)) { [weak self] in
@ -347,7 +390,10 @@ extension Ghostty {
) { [weak self] event in self?.localEventHandler(event) }
// Setup our surface. This will also initialize all the terminal IO.
let surface_cfg = baseConfig ?? SurfaceConfiguration()
resetAgentEventFile()
var surface_cfg = baseConfig ?? SurfaceConfiguration()
configureAgentHookEnvironment(&surface_cfg)
let surface = surface_cfg.withCValue(view: self) { surface_cfg_c in
ghostty_surface_new(app, &surface_cfg_c)
}
@ -356,6 +402,7 @@ extension Ghostty {
return
}
self.surfaceModel = Ghostty.Surface(cSurface: surface)
startAgentActivityWatcher()
// Setup our tracking area so we get mouse moved events
updateTrackingAreas()
@ -393,6 +440,9 @@ extension Ghostty {
// Cancel progress report timer
progressReportTimer?.invalidate()
terminalActivityTimer?.invalidate()
agentActivityTTLTimer?.invalidate()
stopAgentActivityWatcher()
}
override func endSearch() {
@ -438,6 +488,8 @@ extension Ghostty {
}
override func sizeDidChange(_ size: CGSize) {
suppressTerminalActivityBriefly()
// Ghostty wants to know the actual framebuffer size... It is very important
// here that we use "size" and NOT the view frame. If we're in the middle of
// an animation (i.e. a fullscreen animation), the frame will not yet be updated.
@ -598,6 +650,225 @@ extension Ghostty {
}
}
func markTerminalActivity() {
let now = Date()
if let lastUserInputAt, now.timeIntervalSince(lastUserInputAt) < 0.25 {
return
}
if let terminalActivitySuppressedUntil,
now < terminalActivitySuppressedUntil,
!recentTerminalActivity
{
return
}
terminalActivityTimer?.invalidate()
terminalActivityTimer = Timer.scheduledTimer(
withTimeInterval: 1.25,
repeats: false
) { [weak self] _ in
guard let self else { return }
self.terminalActivityTimer = nil
guard self.recentTerminalActivity else { return }
self.recentTerminalActivity = false
self.notifySidebarMetadataChanged()
}
guard !recentTerminalActivity else { return }
recentTerminalActivity = true
notifySidebarMetadataChanged()
}
private func noteUserInputActivity() {
lastUserInputAt = Date()
}
private func suppressTerminalActivityBriefly() {
terminalActivitySuppressedUntil = Date().addingTimeInterval(0.75)
}
private func configureAgentHookEnvironment(_ config: inout SurfaceConfiguration) {
config.environmentVariables["GHOSTTY_AGENT_SURFACE_ID"] = agentSurfaceID
config.environmentVariables["GHOSTTY_AGENT_EVENT_FILE"] = agentEventFileURL.path
config.environmentVariables["GHOSTTY_AGENT_HOOKS_DISABLED"] =
ProcessInfo.processInfo.environment["GHOSTTY_AGENT_HOOKS_DISABLED"] ?? "0"
if let helperURL = Self.agentHookHelperURL {
config.environmentVariables["GHOSTTY_AGENT_HOOK_HELPER"] = helperURL.path
}
}
private func resetAgentEventFile() {
do {
try FileManager.default.createDirectory(
at: agentEventFileURL.deletingLastPathComponent(),
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
FileManager.default.createFile(
atPath: agentEventFileURL.path,
contents: Data(),
attributes: [.posixPermissions: 0o600]
)
agentActivityReadOffset = 0
agentActivityPartialLine = ""
} catch {
Ghostty.logger.warning("failed to reset agent hook event file: \(error.localizedDescription)")
}
}
private func startAgentActivityWatcher() {
stopAgentActivityWatcher()
let fd = open(agentEventFileURL.path, O_EVTONLY)
guard fd >= 0 else {
Ghostty.logger.warning("failed to watch agent hook event file: \(String(cString: strerror(errno)))")
return
}
agentActivityFileDescriptor = fd
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .extend, .delete, .rename],
queue: .main
)
source.setEventHandler { [weak self] in
self?.readAgentActivityEvents()
}
source.setCancelHandler { [fd] in
Darwin.close(fd)
}
agentActivitySource = source
source.resume()
readAgentActivityEvents()
}
private func stopAgentActivityWatcher() {
agentActivitySource?.cancel()
agentActivitySource = nil
agentActivityFileDescriptor = -1
}
private func readAgentActivityEvents() {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: agentEventFileURL.path),
let fileSize = attributes[.size] as? NSNumber
else {
return
}
let size = fileSize.uint64Value
if size < agentActivityReadOffset {
agentActivityReadOffset = 0
agentActivityPartialLine = ""
}
guard size > agentActivityReadOffset else { return }
do {
let handle = try FileHandle(forReadingFrom: agentEventFileURL)
try handle.seek(toOffset: agentActivityReadOffset)
let data = try handle.readToEnd() ?? Data()
try handle.close()
agentActivityReadOffset += UInt64(data.count)
guard !data.isEmpty, let chunk = String(data: data, encoding: .utf8) else { return }
processAgentActivityChunk(chunk)
} catch {
Ghostty.logger.warning("failed to read agent hook event file: \(error.localizedDescription)")
}
}
private func processAgentActivityChunk(_ chunk: String) {
let combined = agentActivityPartialLine + chunk
var lines = combined.components(separatedBy: .newlines)
if combined.last?.isNewline == false {
agentActivityPartialLine = lines.popLast() ?? ""
} else {
agentActivityPartialLine = ""
}
for line in lines {
processAgentActivityLine(line)
}
}
private func processAgentActivityLine(_ line: String) {
guard let event = TerminalAgentActivityEvent.parse(jsonLine: line) else { return }
guard let nextState = agentActivityReducer.apply(event, expectedSurfaceID: agentSurfaceID) else {
return
}
setAgentActivityState(nextState)
}
func acknowledgeSidebarIndicator() {
if let nextState = agentActivityReducer.acknowledgeAttention() {
setAgentActivityState(nextState)
}
if bell {
bell = false
notifySidebarMetadataChanged()
}
}
private func setAgentActivityState(_ nextState: TerminalAgentActivityState) {
let oldState = agentActivityState
agentActivityState = nextState
scheduleAgentActivityTTLTimer()
if oldState != nextState {
notifySidebarMetadataChanged()
}
}
private func scheduleAgentActivityTTLTimer() {
agentActivityTTLTimer?.invalidate()
guard case .running = agentActivityState else {
agentActivityTTLTimer = nil
return
}
agentActivityTTLTimer = Timer.scheduledTimer(
withTimeInterval: TerminalAgentActivityReducer.runningTTL,
repeats: false
) { [weak self] _ in
guard let self else { return }
self.agentActivityTTLTimer = nil
if let nextState = self.agentActivityReducer.expireRunningState() {
self.setAgentActivityState(nextState)
}
}
}
private static func agentEventFileURL(surfaceID: String) -> URL {
let uid = getuid()
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("ghostty-agent-hooks-\(uid)", isDirectory: true)
return root.appendingPathComponent("\(surfaceID).jsonl", isDirectory: false)
}
private static var agentHookHelperURL: URL? {
guard let resourcesURL = Bundle.main.resourceURL else { return nil }
let helperURL = resourcesURL
.appendingPathComponent("ghostty", isDirectory: true)
.appendingPathComponent("bin", isDirectory: true)
.appendingPathComponent("ghostty-agent-hook", isDirectory: false)
return FileManager.default.isExecutableFile(atPath: helperURL.path) ? helperURL : nil
}
private func notifySidebarMetadataChanged() {
NotificationCenter.default.post(
name: TerminalWindow.terminalSidebarMetadataDidChangeNotification,
object: window
)
}
private static func isSidebarProgressActive(_ report: Action.ProgressReport?) -> Bool {
guard let report else { return false }
return report.state != .remove && report.state != .pause
}
// MARK: Local Events
private func localEventHandler(_ event: NSEvent) -> NSEvent? {
@ -735,6 +1006,11 @@ extension Ghostty {
] as? Ghostty.Action.ColorChange else { return }
switch change.kind {
case .foreground:
DispatchQueue.main.async { [weak self] in
self?.foregroundColor = change.color
}
case .background:
DispatchQueue.main.async { [weak self] in
self?.backgroundColor = change.color
@ -812,6 +1088,7 @@ extension Ghostty {
override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
suppressTerminalActivityBriefly()
// The Core Animation compositing engine uses the layer's contentsScale property
// to determine whether to scale its contents during compositing. When the window
@ -1430,6 +1707,9 @@ extension Ghostty {
var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags)
key_ev.composing = composing
if action == GHOSTTY_ACTION_PRESS || action == GHOSTTY_ACTION_REPEAT {
noteUserInputActivity()
}
// For text, we only encode UTF8 if we don't have a single control
// character. Control characters are encoded by Ghostty itself.
@ -1473,6 +1753,9 @@ extension Ghostty {
key_ev.mods = GHOSTTY_MODS_NONE
key_ev.consumed_mods = GHOSTTY_MODS_NONE
key_ev.unshifted_codepoint = 0
if action == GHOSTTY_ACTION_PRESS || action == GHOSTTY_ACTION_REPEAT {
noteUserInputActivity()
}
return text.withCString { ptr in
key_ev.text = ptr
@ -1770,6 +2053,7 @@ extension Ghostty {
struct DerivedConfig {
let backgroundColor: Color
let foregroundColor: Color
let backgroundOpacity: Double
let backgroundBlur: Ghostty.Config.BackgroundBlur
let macosWindowShadow: Bool
@ -1779,6 +2063,7 @@ extension Ghostty {
init() {
self.backgroundColor = Color(NSColor.windowBackgroundColor)
self.foregroundColor = Color(NSColor.labelColor)
self.backgroundOpacity = 1
self.backgroundBlur = .disabled
self.macosWindowShadow = true
@ -1789,6 +2074,7 @@ extension Ghostty {
init(_ config: Ghostty.Config) {
self.backgroundColor = config.backgroundColor
self.foregroundColor = config.foregroundColor
self.backgroundOpacity = config.backgroundOpacity
self.backgroundBlur = config.backgroundBlur
self.macosWindowShadow = config.macosWindowShadow

View File

@ -90,15 +90,16 @@ struct ConfigTests {
// MARK: - Enum Properties
@Test func macosTitlebarStyleDefaultsToTransparent() throws {
@Test func macosTitlebarStyleDefaultsToSidebar() throws {
let config = try TemporaryConfig("")
#expect(config.macosTitlebarStyle == .transparent)
#expect(config.macosTitlebarStyle == .sidebar)
}
@Test(arguments: [
("native", Ghostty.Config.MacOSTitlebarStyle.native),
("transparent", Ghostty.Config.MacOSTitlebarStyle.transparent),
("tabs", Ghostty.Config.MacOSTitlebarStyle.tabs),
("sidebar", Ghostty.Config.MacOSTitlebarStyle.sidebar),
("hidden", Ghostty.Config.MacOSTitlebarStyle.hidden),
])
func macosTitlebarStyleValues(raw: String, expected: Ghostty.Config.MacOSTitlebarStyle) throws {
@ -243,6 +244,12 @@ struct ConfigTests {
let config = try TemporaryConfig("")
let newWindow = try #require(config.keyboardShortcut(for: "new_window"))
#expect(newWindow == .init("n", modifiers: [.command]))
let newTab = try #require(config.keyboardShortcut(for: "new_tab"))
#expect(newTab == .init("t", modifiers: [.command]))
let splitRight = try #require(config.keyboardShortcut(for: "new_split:right"))
#expect(splitRight == .init("d", modifiers: [.command]))
let splitDown = try #require(config.keyboardShortcut(for: "new_split:down"))
#expect(splitDown == .init("d", modifiers: [.shift, .command]))
let gotoToNextSplit = try #require(config.keyboardShortcut(for: "goto_split:next"))
#expect(gotoToNextSplit == .init("]", modifiers: [.command]))
}

View File

@ -0,0 +1,129 @@
@testable import Ghostty
import Testing
struct TerminalAgentActivityTests {
@Test func parsesJSONEvent() throws {
let event = try #require(TerminalAgentActivityEvent.parse(jsonLine: """
{"version":1,"surface_id":"surface-1","agent":"claude","event":"prompt-submit","state":"running","session_id":"s1"}
"""))
#expect(event.surfaceID == "surface-1")
#expect(event.agent == "claude")
#expect(event.event == "prompt-submit")
#expect(event.state == "running")
#expect(event.sessionID == "s1")
}
@Test func malformedJSONIsIgnored() {
#expect(TerminalAgentActivityEvent.parse(jsonLine: "{") == nil)
}
@Test func reducerMapsLifecycleStates() throws {
var reducer = TerminalAgentActivityReducer()
let surfaceID = "surface-1"
#expect(reducer.apply(event("prompt-submit", state: "running"), expectedSurfaceID: surfaceID) == .running(agent: "claude"))
#expect(reducer.apply(event("notification", state: "needsInput"), expectedSurfaceID: surfaceID) == .needsInput(agent: "claude"))
#expect(reducer.apply(event("stop", state: "idle"), expectedSurfaceID: surfaceID) == .needsInput(agent: "claude"))
#expect(reducer.apply(event("session-end", state: "idle"), expectedSurfaceID: surfaceID) == .idle)
}
@Test func reducerIgnoresStaleStop() throws {
var reducer = TerminalAgentActivityReducer()
let surfaceID = "surface-1"
#expect(reducer.apply(event("prompt-submit", state: "running", sessionID: "old"), expectedSurfaceID: surfaceID) == .running(agent: "claude"))
#expect(reducer.apply(event("prompt-submit", state: "running", sessionID: "new"), expectedSurfaceID: surfaceID) == .running(agent: "claude"))
#expect(reducer.apply(event("stop", state: "idle", sessionID: "old"), expectedSurfaceID: surfaceID) == nil)
#expect(reducer.state == .running(agent: "claude"))
}
@Test func reducerIgnoresWrongSurface() throws {
var reducer = TerminalAgentActivityReducer()
#expect(reducer.apply(event("prompt-submit", state: "running"), expectedSurfaceID: "other") == nil)
#expect(reducer.state == .idle)
}
@Test func reducerAcknowledgesAttentionStates() throws {
var reducer = TerminalAgentActivityReducer()
#expect(reducer.apply(event("notification", state: "needsInput")) == .needsInput(agent: "claude"))
#expect(reducer.acknowledgeAttention() == .idle)
#expect(reducer.state == .idle)
#expect(reducer.acknowledgeAttention() == nil)
#expect(reducer.apply(event("hook-error", state: nil)) == .error(agent: "claude"))
#expect(reducer.acknowledgeAttention() == .idle)
#expect(reducer.state == .idle)
}
@Test func runningStateExpiresAfterTTL() throws {
var reducer = TerminalAgentActivityReducer()
let start = Date()
#expect(reducer.apply(event("prompt-submit", state: "running"), now: start) == .running(agent: "claude"))
#expect(reducer.expireRunningState(now: start.addingTimeInterval(TerminalAgentActivityReducer.runningTTL - 1)) == nil)
#expect(reducer.expireRunningState(now: start.addingTimeInterval(TerminalAgentActivityReducer.runningTTL)) == .idle)
}
@Test func sidebarIndicatorPrecedence() {
#expect(TerminalSidebarStatusIndicatorState.derive(
from: [.needsInput(agent: "codex"), .running(agent: "claude")],
hasTerminalProgress: false,
hasTerminalBell: true
) == .spinner(agent: "claude"))
#expect(TerminalSidebarStatusIndicatorState.derive(
from: [.needsInput(agent: "codex"), .error(agent: "claude")],
hasTerminalProgress: false,
hasTerminalBell: true
) == .error(agent: "claude"))
#expect(TerminalSidebarStatusIndicatorState.derive(
from: [.idle, .needsInput(agent: "codex")],
hasTerminalProgress: true,
hasTerminalBell: true
) == .bell(agent: "codex"))
#expect(TerminalSidebarStatusIndicatorState.derive(
from: [.idle],
hasTerminalProgress: true,
hasTerminalBell: true
) == .spinner(agent: "terminal"))
#expect(TerminalSidebarStatusIndicatorState.derive(
from: [.idle],
hasTerminalProgress: false,
hasTerminalBell: true
) == .bell(agent: "terminal"))
}
@Test func sidebarAttentionIndicatorsCanBeAcknowledged() {
#expect(TerminalSidebarStatusIndicatorState.bell(agent: "codex").isAttentionIndicator)
#expect(TerminalSidebarStatusIndicatorState.error(agent: "codex").isAttentionIndicator)
#expect(!TerminalSidebarStatusIndicatorState.spinner(agent: "codex").isAttentionIndicator)
#expect(!TerminalSidebarStatusIndicatorState.none.isAttentionIndicator)
}
@Test func selectedTabsHideAttentionIndicators() {
#expect(TerminalSidebarStatusIndicatorState.bell(agent: "codex").visibleState(isSelected: true) == .none)
#expect(TerminalSidebarStatusIndicatorState.error(agent: "codex").visibleState(isSelected: true) == .none)
#expect(TerminalSidebarStatusIndicatorState.bell(agent: "codex").visibleState(isSelected: false) == .bell(agent: "codex"))
#expect(TerminalSidebarStatusIndicatorState.error(agent: "codex").visibleState(isSelected: false) == .error(agent: "codex"))
#expect(TerminalSidebarStatusIndicatorState.spinner(agent: "codex").visibleState(isSelected: true) == .spinner(agent: "codex"))
}
private func event(
_ event: String,
state: String?,
sessionID: String? = nil
) -> TerminalAgentActivityEvent {
TerminalAgentActivityEvent(
surfaceID: "surface-1",
agent: "claude",
event: event,
state: state,
sessionID: sessionID
)
}
}

View File

@ -1145,6 +1145,16 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
};
},
.terminal_activity => {
_ = self.rt_app.performAction(
.{ .surface = self },
.terminal_activity,
{},
) catch |err| {
log.warn("apprt failed to report terminal activity err={}", .{err});
};
},
.selection_scroll_tick => |active| {
self.selection_scroll_active = active;
try self.selectionScrollTick();

171
src/agent_hook/claude Executable file
View File

@ -0,0 +1,171 @@
#!/usr/bin/env bash
# Ghostty Claude Code wrapper. It injects lifecycle hooks only inside
# Ghostty-created terminals, then execs the real Claude binary.
find_real_claude() {
local self_dir
self_dir="$(cd "$(dirname "$0")" && pwd)"
local IFS=:
for dir in $PATH; do
[[ -z "$dir" ]] && continue
[[ "$dir" == "$self_dir" ]] && continue
[[ -x "$dir/claude" ]] || continue
if [[ ! "$dir/claude" -ef "$0" ]] && ! claude_candidate_is_known_wrapper "$dir/claude"; then
printf '%s' "$dir/claude"
return 0
fi
done
return 1
}
claude_candidate_is_known_wrapper() {
local candidate="$1"
LC_ALL=C grep -q \
-e 'Ghostty Claude Code wrapper' \
-e 'cmux claude wrapper' \
"$candidate" 2>/dev/null
}
exec_real_claude() {
exec "$REAL_CLAUDE" "$@"
}
claude_option_consumes_value() {
case "$1" in
--add-dir|--agent|--agents|--allowedTools|--allowed-tools|\
--append-system-prompt|--betas|--debug-file|--disallowedTools|\
--disallowed-tools|--effort|--fallback-model|--file|\
--input-format|--json-schema|--max-budget-usd|--mcp-config|\
--model|-m|-n|--name|--output-format|--permission-mode|--plugin-dir|\
--plugin-url|--remote-control-session-name-prefix|--setting-sources|\
--settings|--system-prompt|--tools)
return 0
;;
esac
return 1
}
claude_interactive_entry_flag() {
case "$1" in
--print|--print=*|-p|--resume|--resume=*|-r|--continue|-c|\
--session-id|--session-id=*|--remote-control|--remote-control=*|\
--from-pr|--from-pr=*|--worktree|--worktree=*|-w|-w=*)
return 0
;;
esac
return 1
}
claude_passthrough_option_flag() {
case "$1" in
--help|-h|--version|-v)
return 0
;;
esac
return 1
}
claude_builtin_command_name() {
case "$1" in
agents|auth|auto-mode|config|api-key|daemon|doctor|install|mcp|\
experimental-next|plugin|plugins|project|rc|remote-control|setup-token|\
ultrareview|update|upgrade)
return 0
;;
esac
return 1
}
claude_command_like_invocation() {
local candidate="$1"
[[ -n "$candidate" ]] || return 1
case "$candidate" in
*[![:alnum:]_-]*)
return 1
;;
esac
claude_builtin_command_name "$candidate"
}
should_inject_claude_hooks() {
(( $# == 0 )) && return 0
local arg
local skip_next=false
for arg in "$@"; do
if [[ "$skip_next" == true ]]; then
skip_next=false
continue
fi
case "$arg" in
--)
return 0
;;
-*)
if claude_passthrough_option_flag "$arg"; then
return 1
fi
if claude_interactive_entry_flag "$arg"; then
return 0
fi
if [[ "$arg" != *=* ]] && claude_option_consumes_value "$arg"; then
skip_next=true
fi
continue
;;
*)
if claude_command_like_invocation "$arg"; then
return 1
fi
return 0
;;
esac
done
return 0
}
REAL_CLAUDE="$(find_real_claude)" || {
echo "Error: claude not found in PATH" >&2
exit 127
}
if [[ -z "${GHOSTTY_AGENT_SURFACE_ID:-}" || "${GHOSTTY_AGENT_HOOKS_DISABLED:-0}" == "1" ]]; then
exec_real_claude "$@"
fi
if [[ -z "${GHOSTTY_AGENT_HOOK_HELPER:-}" && -z "$(command -v ghostty-agent-hook 2>/dev/null || true)" ]]; then
exec_real_claude "$@"
fi
if ! should_inject_claude_hooks "$@"; then
exec_real_claude "$@"
fi
SKIP_SESSION_ID=false
for arg in "$@"; do
case "$arg" in
--resume|--resume=*|-r|--session-id|--session-id=*|--continue|-c)
SKIP_SESSION_ID=true
break
;;
esac
done
unset CLAUDECODE
export GHOSTTY_AGENT_PID=$$
HOOK_COMMAND_PREFIX='ghostty_hook=\"${GHOSTTY_AGENT_HOOK_HELPER:-$(command -v ghostty-agent-hook 2>/dev/null || true)}\"; if [ -n \"${GHOSTTY_AGENT_SURFACE_ID:-}\" ] && [ -n \"$ghostty_hook\" ]; then \"$ghostty_hook\" claude '
HOOK_COMMAND_SUFFIX='; else printf \"%s\\n\" \"{}\"; fi'
HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"'"$HOOK_COMMAND_PREFIX"'session-start'"$HOOK_COMMAND_SUFFIX"'","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"'"$HOOK_COMMAND_PREFIX"'prompt-submit'"$HOOK_COMMAND_SUFFIX"'","timeout":10}]}],"PreToolUse":[{"matcher":"","hooks":[{"type":"command","command":"'"$HOOK_COMMAND_PREFIX"'pre-tool-use'"$HOOK_COMMAND_SUFFIX"'","timeout":5,"async":true}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"'"$HOOK_COMMAND_PREFIX"'notification'"$HOOK_COMMAND_SUFFIX"'","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"'"$HOOK_COMMAND_PREFIX"'stop'"$HOOK_COMMAND_SUFFIX"'","timeout":10}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"'"$HOOK_COMMAND_PREFIX"'session-end'"$HOOK_COMMAND_SUFFIX"'","timeout":1}]}]}}'
if [[ "$SKIP_SESSION_ID" == true ]]; then
exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@"
else
SESSION_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')"
exec "$REAL_CLAUDE" --session-id "$SESSION_ID" --settings "$HOOKS_JSON" "$@"
fi

892
src/agent_hook/main.zig Normal file
View File

@ -0,0 +1,892 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const JsonArray = std.json.Array;
const JsonObject = std.json.ObjectMap;
const JsonValue = std.json.Value;
const codex_events = [_]HookEvent{
.{ .agent_event = "SessionStart", .helper_event = "session-start", .label = "session_start" },
.{ .agent_event = "UserPromptSubmit", .helper_event = "prompt-submit", .label = "user_prompt_submit" },
.{ .agent_event = "Stop", .helper_event = "stop", .label = "stop" },
.{ .agent_event = "PreToolUse", .helper_event = "pre-tool-use", .label = "pre_tool_use" },
.{ .agent_event = "PermissionRequest", .helper_event = "permission-request", .label = "permission_request" },
};
const HookEvent = struct {
agent_event: []const u8,
helper_event: []const u8,
label: []const u8,
};
const HookInput = struct {
session_id: ?[]const u8 = null,
turn_id: ?[]const u8 = null,
cwd: ?[]const u8 = null,
transcript_path: ?[]const u8 = null,
hook_event_name: ?[]const u8 = null,
tool_name: ?[]const u8 = null,
fn deinit(self: HookInput, alloc: Allocator) void {
if (self.session_id) |v| alloc.free(v);
if (self.turn_id) |v| alloc.free(v);
if (self.cwd) |v| alloc.free(v);
if (self.transcript_path) |v| alloc.free(v);
if (self.hook_event_name) |v| alloc.free(v);
if (self.tool_name) |v| alloc.free(v);
}
};
const NormalizedState = enum {
idle,
running,
needs_input,
errored,
fn jsonValue(self: NormalizedState) []const u8 {
return switch (self) {
.idle => "idle",
.running => "running",
.needs_input => "needsInput",
.errored => "error",
};
}
fn statusValue(self: NormalizedState) []const u8 {
return switch (self) {
.idle => "Idle",
.running => "Running",
.needs_input => "Needs input",
.errored => "Error",
};
}
};
pub fn main() !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
const alloc = gpa.allocator();
const args = try std.process.argsAlloc(alloc);
defer std.process.argsFree(alloc, args);
if (args.len >= 3 and std.mem.eql(u8, args[1], "install")) {
if (std.mem.eql(u8, args[2], "codex")) {
try installCodexHooks(alloc);
return;
}
return error.UnsupportedAgent;
}
if (args.len >= 3 and std.mem.eql(u8, args[1], "uninstall")) {
if (std.mem.eql(u8, args[2], "codex")) {
try uninstallCodexHooks(alloc);
return;
}
return error.UnsupportedAgent;
}
defer writeEmptyHookOutput() catch {};
if (args.len < 3) return;
emitHookEvent(alloc, args[1], args[2]) catch {};
}
fn writeEmptyHookOutput() !void {
var buffer: [64]u8 = undefined;
var stdout = std.fs.File.stdout().writer(&buffer);
try stdout.interface.writeAll("{}\n");
try stdout.interface.flush();
}
fn emitHookEvent(alloc: Allocator, agent: []const u8, event_name: []const u8) !void {
const event_file = try envOwned(alloc, "GHOSTTY_AGENT_EVENT_FILE") orelse return;
defer alloc.free(event_file);
const surface_id = try envOwned(alloc, "GHOSTTY_AGENT_SURFACE_ID") orelse return;
defer alloc.free(surface_id);
const state = normalizeState(event_name, null) orelse return;
const stdin = std.fs.File.stdin().readToEndAlloc(alloc, 1024 * 1024) catch null;
defer if (stdin) |bytes| alloc.free(bytes);
const input = parseHookInput(alloc, stdin orelse "") catch HookInput{};
defer input.deinit(alloc);
const final_state = normalizeState(event_name, input.tool_name) orelse state;
var out: std.io.Writer.Allocating = .init(alloc);
defer out.deinit();
const writer = &out.writer;
var json: std.json.Stringify = .{ .writer = writer, .options = .{} };
try json.beginObject();
try json.objectField("version");
try json.write(@as(u8, 1));
try json.objectField("surface_id");
try json.write(surface_id);
try json.objectField("agent");
try json.write(agent);
try json.objectField("event");
try json.write(event_name);
try json.objectField("state");
try json.write(final_state.jsonValue());
try json.objectField("status_title");
try json.write(agentDisplayName(agent));
try json.objectField("status_value");
try json.write(final_state.statusValue());
try writeOptionalStringField(&json, "session_id", input.session_id);
try writeOptionalStringField(&json, "turn_id", input.turn_id);
try writeOptionalStringField(&json, "cwd", input.cwd);
try writeOptionalStringField(&json, "transcript_path", input.transcript_path);
try writeOptionalStringField(&json, "hook_event_name", input.hook_event_name);
if (try envOwned(alloc, "GHOSTTY_AGENT_PID")) |pid_raw| {
defer alloc.free(pid_raw);
if (std.fmt.parseInt(i64, std.mem.trim(u8, pid_raw, &std.ascii.whitespace), 10)) |pid| {
try json.objectField("pid");
try json.write(pid);
} else |_| {}
}
const timestamp_ms = std.time.milliTimestamp();
try json.objectField("timestamp");
try json.write(@as(f64, @floatFromInt(timestamp_ms)) / 1000.0);
try json.endObject();
try appendLine(event_file, out.written());
}
fn writeOptionalStringField(json: *std.json.Stringify, key: []const u8, value: ?[]const u8) !void {
const raw = value orelse return;
const trimmed = std.mem.trim(u8, raw, &std.ascii.whitespace);
if (trimmed.len == 0) return;
try json.objectField(key);
try json.write(trimmed);
}
fn parseHookInput(alloc: Allocator, bytes: []const u8) !HookInput {
if (std.mem.trim(u8, bytes, &std.ascii.whitespace).len == 0) return .{};
const parsed = try std.json.parseFromSlice(JsonValue, alloc, bytes, .{});
defer parsed.deinit();
if (parsed.value != .object) return .{};
const object = parsed.value.object;
return .{
.session_id = try dupeField(alloc, object, &.{ "session_id", "sessionId" }),
.turn_id = try dupeField(alloc, object, &.{ "turn_id", "turnId" }),
.cwd = try dupeField(alloc, object, &.{"cwd"}),
.transcript_path = try dupeField(alloc, object, &.{ "transcript_path", "transcriptPath" }),
.hook_event_name = try dupeField(alloc, object, &.{ "hook_event_name", "hookEventName" }),
.tool_name = try dupeField(alloc, object, &.{ "tool_name", "toolName", "name" }),
};
}
fn dupeField(alloc: Allocator, object: JsonObject, names: []const []const u8) !?[]const u8 {
const value = stringField(object, names) orelse return null;
return try alloc.dupe(u8, value);
}
fn stringField(object: JsonObject, names: []const []const u8) ?[]const u8 {
for (names) |name| {
const value = object.get(name) orelse continue;
switch (value) {
.string => |s| return s,
else => {},
}
}
return null;
}
fn normalizeState(event_name: []const u8, tool_name: ?[]const u8) ?NormalizedState {
if (eventNameMatches(event_name, "pre-tool-use")) {
if (tool_name) |tool| {
if (std.ascii.eqlIgnoreCase(tool, "AskUserQuestion")) return .needs_input;
}
}
if (eventNameMatches(event_name, "prompt-submit") or
eventNameMatches(event_name, "user-prompt-submit") or
eventNameMatches(event_name, "pre-tool-use"))
{
return .running;
}
if (eventNameMatches(event_name, "notification") or
eventNameMatches(event_name, "permission-request") or
eventNameMatches(event_name, "ask-user-question"))
{
return .needs_input;
}
if (eventNameMatches(event_name, "stop")) {
return .needs_input;
}
if (eventNameMatches(event_name, "session-start") or
eventNameMatches(event_name, "idle") or
eventNameMatches(event_name, "session-end"))
{
return .idle;
}
if (eventNameMatches(event_name, "error") or
eventNameMatches(event_name, "failure") or
eventNameMatches(event_name, "failed") or
eventNameMatches(event_name, "hook-error"))
{
return .errored;
}
return null;
}
fn eventNameMatches(event_name: []const u8, expected: []const u8) bool {
const trimmed = std.mem.trim(u8, event_name, &std.ascii.whitespace);
if (std.ascii.eqlIgnoreCase(trimmed, expected)) return true;
var buffer: [96]u8 = undefined;
var len: usize = 0;
for (trimmed, 0..) |c, i| {
if (c == '_') {
if (len >= buffer.len) return false;
buffer[len] = '-';
len += 1;
continue;
}
if (std.ascii.isUpper(c) and i > 0) {
if (len >= buffer.len) return false;
buffer[len] = '-';
len += 1;
}
if (len >= buffer.len) return false;
buffer[len] = std.ascii.toLower(c);
len += 1;
}
return std.mem.eql(u8, buffer[0..len], expected);
}
fn agentDisplayName(agent: []const u8) []const u8 {
if (std.ascii.eqlIgnoreCase(agent, "claude")) return "Claude Code";
if (std.ascii.eqlIgnoreCase(agent, "codex")) return "Codex";
return agent;
}
fn appendLine(path: []const u8, line: []const u8) !void {
if (std.fs.path.dirname(path)) |dir| try std.fs.cwd().makePath(dir);
const fd = try std.posix.open(path, .{
.ACCMODE = .WRONLY,
.CREAT = true,
.APPEND = true,
.CLOEXEC = true,
}, 0o600);
var file = std.fs.File{ .handle = fd };
defer file.close();
try file.writeAll(line);
try file.writeAll("\n");
}
fn installCodexHooks(alloc: Allocator) !void {
const config_dir = try codexConfigDir(alloc);
defer alloc.free(config_dir);
try std.fs.cwd().makePath(config_dir);
const hooks_path = try std.fs.path.join(alloc, &.{ config_dir, "hooks.json" });
defer alloc.free(hooks_path);
const config_path = try std.fs.path.join(alloc, &.{ config_dir, "config.toml" });
defer alloc.free(config_path);
const hooks_content = try readFileAllocIfExists(alloc, hooks_path);
defer alloc.free(hooks_content);
var arena = std.heap.ArenaAllocator.init(alloc);
defer arena.deinit();
const arena_alloc = arena.allocator();
var root = try parseHooksRoot(arena_alloc, hooks_content);
const hooks = try ensureObjectField(arena_alloc, &root.object, "hooks");
for (&codex_events) |event| {
try removeOwnedHooksFromEvent(arena_alloc, hooks, event.agent_event);
try appendCodexHookGroup(arena_alloc, hooks, event);
}
const rendered_hooks = try renderJson(alloc, root, .{ .whitespace = .indent_2 });
defer alloc.free(rendered_hooks);
try writeFile(hooks_path, rendered_hooks);
const trust_entries = try codexHookTrustEntries(alloc, hooks, hooks_path);
defer freeTrustEntries(alloc, trust_entries);
const config_content = try readFileAllocIfExists(alloc, config_path);
defer alloc.free(config_content);
const rendered_config = try codexConfigTomlInstalling(alloc, config_content, trust_entries);
defer alloc.free(rendered_config);
try writeFile(config_path, rendered_config);
try printInstallStatus("Codex hooks installed at ", hooks_path);
}
fn uninstallCodexHooks(alloc: Allocator) !void {
const config_dir = try codexConfigDir(alloc);
defer alloc.free(config_dir);
const hooks_path = try std.fs.path.join(alloc, &.{ config_dir, "hooks.json" });
defer alloc.free(hooks_path);
const config_path = try std.fs.path.join(alloc, &.{ config_dir, "config.toml" });
defer alloc.free(config_path);
const hooks_content = try readFileAllocIfExists(alloc, hooks_path);
defer alloc.free(hooks_content);
if (hooks_content.len > 0) {
var arena = std.heap.ArenaAllocator.init(alloc);
defer arena.deinit();
const arena_alloc = arena.allocator();
var root = try parseHooksRoot(arena_alloc, hooks_content);
if (root.object.getPtr("hooks")) |hooks_value| {
if (hooks_value.* == .object) {
const hooks = &hooks_value.object;
for (&codex_events) |event| {
try removeOwnedHooksFromEvent(arena_alloc, hooks, event.agent_event);
}
}
}
const rendered_hooks = try renderJson(alloc, root, .{ .whitespace = .indent_2 });
defer alloc.free(rendered_hooks);
try writeFile(hooks_path, rendered_hooks);
}
const config_content = try readFileAllocIfExists(alloc, config_path);
defer alloc.free(config_content);
if (config_content.len > 0) {
const rendered_config = try codexConfigTomlUninstalling(alloc, config_content);
defer alloc.free(rendered_config);
try writeFile(config_path, rendered_config);
}
try printInstallStatus("Codex Ghostty hooks removed from ", hooks_path);
}
fn codexConfigDir(alloc: Allocator) ![]const u8 {
if (try envOwned(alloc, "CODEX_HOME")) |codex_home| return codex_home;
const home = try envOwned(alloc, "HOME") orelse return error.HomeNotSet;
defer alloc.free(home);
return try std.fs.path.join(alloc, &.{ home, ".codex" });
}
fn parseHooksRoot(alloc: Allocator, content: []const u8) !JsonValue {
if (std.mem.trim(u8, content, &std.ascii.whitespace).len == 0) {
return JsonValue{ .object = JsonObject.init(alloc) };
}
const parsed = try std.json.parseFromSlice(JsonValue, alloc, content, .{});
if (parsed.value != .object) return error.InvalidHooksJson;
return parsed.value;
}
fn ensureObjectField(alloc: Allocator, object: *JsonObject, key: []const u8) !*JsonObject {
const gop = try object.getOrPut(key);
if (!gop.found_existing or gop.value_ptr.* != .object) {
gop.value_ptr.* = JsonValue{ .object = JsonObject.init(alloc) };
}
return &gop.value_ptr.object;
}
fn removeOwnedHooksFromEvent(alloc: Allocator, hooks: *JsonObject, event_name: []const u8) !void {
const value = hooks.getPtr(event_name) orelse return;
if (value.* != .array) return;
var rewritten = JsonArray.init(alloc);
for (value.array.items) |group_value| {
var group = group_value;
switch (group) {
.object => |*group_object| {
if (group_object.getPtr("hooks")) |hook_list_value| {
if (hook_list_value.* == .array) {
var hook_list = JsonArray.init(alloc);
for (hook_list_value.array.items) |hook_value| {
if (!isOwnedHookValue(hook_value)) try hook_list.append(hook_value);
}
if (hook_list.items.len == 0) continue;
hook_list_value.* = JsonValue{ .array = hook_list };
}
} else if (isOwnedHookValue(group)) {
continue;
}
try rewritten.append(group);
},
else => try rewritten.append(group),
}
}
if (rewritten.items.len == 0) {
_ = hooks.swapRemove(event_name);
} else {
value.* = JsonValue{ .array = rewritten };
}
}
fn isOwnedHookValue(value: JsonValue) bool {
if (value != .object) return false;
const command_value = value.object.get("command") orelse return false;
if (command_value != .string) return false;
return isOwnedHookCommand(command_value.string);
}
fn isOwnedHookCommand(command: []const u8) bool {
return std.mem.indexOf(u8, command, "ghostty-agent-hook") != null and
std.mem.indexOf(u8, command, " codex ") != null;
}
fn appendCodexHookGroup(alloc: Allocator, hooks: *JsonObject, event: HookEvent) !void {
const gop = try hooks.getOrPut(event.agent_event);
if (!gop.found_existing or gop.value_ptr.* != .array) {
gop.value_ptr.* = JsonValue{ .array = JsonArray.init(alloc) };
}
var hook_object = JsonObject.init(alloc);
try hook_object.put("type", JsonValue{ .string = "command" });
try hook_object.put("command", JsonValue{ .string = try codexHookCommand(alloc, event.helper_event) });
try hook_object.put("timeout", JsonValue{ .integer = 5000 });
var hook_list = JsonArray.init(alloc);
try hook_list.append(JsonValue{ .object = hook_object });
var group_object = JsonObject.init(alloc);
try group_object.put("hooks", JsonValue{ .array = hook_list });
try gop.value_ptr.array.append(JsonValue{ .object = group_object });
}
fn codexHookCommand(alloc: Allocator, event_name: []const u8) ![]const u8 {
return try std.fmt.allocPrint(
alloc,
"ghostty_hook=\"${{GHOSTTY_AGENT_HOOK_HELPER:-$(command -v ghostty-agent-hook 2>/dev/null || true)}}\"; " ++
"if [ -n \"${{GHOSTTY_AGENT_SURFACE_ID:-}}\" ] && [ -n \"$ghostty_hook\" ]; then " ++
"GHOSTTY_AGENT_PID=\"${{PPID:-}}\" \"$ghostty_hook\" codex {s}; else printf \"%s\\n\" \"{{}}\"; fi",
.{event_name},
);
}
fn renderJson(
alloc: Allocator,
value: JsonValue,
options: std.json.Stringify.Options,
) ![]const u8 {
var out: std.io.Writer.Allocating = .init(alloc);
defer out.deinit();
try std.json.Stringify.value(value, options, &out.writer);
try out.writer.writeByte('\n');
return try alloc.dupe(u8, out.written());
}
const TrustEntry = struct {
key: []const u8,
trusted_hash: []const u8,
};
fn codexHookTrustEntries(alloc: Allocator, hooks: *JsonObject, hooks_path: []const u8) ![]TrustEntry {
const source_path = try normalizedPath(alloc, hooks_path);
defer alloc.free(source_path);
var entries = std.ArrayListUnmanaged(TrustEntry).empty;
for (&codex_events) |event| {
const groups_value = hooks.get(event.agent_event) orelse continue;
if (groups_value != .array) continue;
for (groups_value.array.items, 0..) |group_value, group_index| {
if (group_value != .object) continue;
const hooks_value = group_value.object.get("hooks") orelse continue;
if (hooks_value != .array) continue;
for (hooks_value.array.items, 0..) |hook_value, hook_index| {
if (hook_value != .object) continue;
const command_value = hook_value.object.get("command") orelse continue;
if (command_value != .string or !isOwnedHookCommand(command_value.string)) continue;
const timeout_ms = intValue(hook_value.object.get("timeout")) orelse 600;
const key = try std.fmt.allocPrint(
alloc,
"{s}:{s}:{d}:{d}",
.{ source_path, event.label, group_index, hook_index },
);
const trusted_hash = try codexCommandHookHash(
alloc,
event.label,
command_value.string,
@intCast(@max(timeout_ms, 1)),
);
try entries.append(alloc, .{ .key = key, .trusted_hash = trusted_hash });
}
}
}
return try entries.toOwnedSlice(alloc);
}
fn freeTrustEntries(alloc: Allocator, entries: []TrustEntry) void {
for (entries) |entry| {
alloc.free(entry.key);
alloc.free(entry.trusted_hash);
}
alloc.free(entries);
}
fn codexCommandHookHash(
alloc: Allocator,
event_label: []const u8,
command: []const u8,
timeout_ms: i64,
) ![]const u8 {
var out: std.io.Writer.Allocating = .init(alloc);
defer out.deinit();
const writer = &out.writer;
try writer.writeAll("{\"event_name\":");
try std.json.Stringify.value(event_label, .{}, writer);
try writer.writeAll(",\"hooks\":[{\"async\":false,\"command\":");
try std.json.Stringify.value(command, .{}, writer);
try writer.print(",\"timeout\":{d},\"type\":\"command\"", .{timeout_ms});
try writer.writeAll("}]}");
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(out.written(), &digest, .{});
const hex = std.fmt.bytesToHex(digest, .lower);
return try std.fmt.allocPrint(alloc, "sha256:{s}", .{hex});
}
fn normalizedPath(alloc: Allocator, path: []const u8) ![]const u8 {
return std.fs.realpathAlloc(alloc, path) catch {
if (std.fs.path.dirname(path)) |dir| {
const real_dir = std.fs.realpathAlloc(alloc, dir) catch return try alloc.dupe(u8, path);
defer alloc.free(real_dir);
return try std.fs.path.join(alloc, &.{ real_dir, std.fs.path.basename(path) });
}
return try alloc.dupe(u8, path);
};
}
fn intValue(value: ?JsonValue) ?i64 {
const raw = value orelse return null;
return switch (raw) {
.integer => |v| v,
else => null,
};
}
const feature_begin = "# ghostty-agent-codex-hooks-feature begin";
const feature_end = "# ghostty-agent-codex-hooks-feature end";
const feature_previous_prefix = "# ghostty-agent-codex-hooks-feature previous line: ";
const trust_begin = "# ghostty-agent-codex-hook-trust begin";
const trust_end = "# ghostty-agent-codex-hook-trust end";
fn codexConfigTomlInstalling(
alloc: Allocator,
content: []const u8,
entries: []const TrustEntry,
) ![]const u8 {
var arena = std.heap.ArenaAllocator.init(alloc);
defer arena.deinit();
const work_alloc = arena.allocator();
var lines = try tomlLines(work_alloc, content);
removeMarkedBlock(work_alloc, &lines, feature_begin, feature_end, feature_previous_prefix);
removeMarkedBlock(work_alloc, &lines, trust_begin, trust_end, null);
if (!hooksFeatureEnabled(lines.items)) {
if (findDottedFeaturesHooksIndex(lines.items)) |index| {
const previous = try std.fmt.allocPrint(work_alloc, "{s}{s}", .{ feature_previous_prefix, lines.items[index] });
try lines.replaceRange(work_alloc, index, 1, &.{ feature_begin, previous, "features.hooks = true", feature_end });
} else if (findFeaturesTable(lines.items)) |features| {
if (findKeyInRange(lines.items, "hooks", features.start + 1, features.end)) |index| {
const previous = try std.fmt.allocPrint(work_alloc, "{s}{s}", .{ feature_previous_prefix, lines.items[index] });
try lines.replaceRange(work_alloc, index, 1, &.{ feature_begin, previous, "hooks = true", feature_end });
} else {
try lines.insertSlice(work_alloc, features.start + 1, &.{ feature_begin, "hooks = true", feature_end });
}
} else {
if (lines.items.len > 0 and lines.items[lines.items.len - 1].len > 0) {
try lines.append(work_alloc, "");
}
try lines.appendSlice(work_alloc, &.{ "[features]", feature_begin, "hooks = true", feature_end });
}
}
if (entries.len > 0) {
if (lines.items.len > 0 and lines.items[lines.items.len - 1].len > 0) {
try lines.append(work_alloc, "");
}
try lines.append(work_alloc, trust_begin);
for (entries) |entry| {
const key = try tomlBasicString(work_alloc, entry.key);
const hash = try tomlBasicString(work_alloc, entry.trusted_hash);
try lines.append(work_alloc, try std.fmt.allocPrint(work_alloc, "[hooks.state.\"{s}\"]", .{key}));
try lines.append(work_alloc, try std.fmt.allocPrint(work_alloc, "trusted_hash = \"{s}\"", .{hash}));
}
try lines.append(work_alloc, trust_end);
}
const result = try tomlContent(work_alloc, lines.items);
return try alloc.dupe(u8, result);
}
fn codexConfigTomlUninstalling(alloc: Allocator, content: []const u8) ![]const u8 {
var arena = std.heap.ArenaAllocator.init(alloc);
defer arena.deinit();
const work_alloc = arena.allocator();
var lines = try tomlLines(work_alloc, content);
removeMarkedBlock(work_alloc, &lines, feature_begin, feature_end, feature_previous_prefix);
removeMarkedBlock(work_alloc, &lines, trust_begin, trust_end, null);
removeEmptyFeaturesTable(work_alloc, &lines);
const result = try tomlContent(work_alloc, lines.items);
return try alloc.dupe(u8, result);
}
fn tomlLines(alloc: Allocator, content: []const u8) !std.ArrayListUnmanaged([]const u8) {
var lines = std.ArrayListUnmanaged([]const u8).empty;
if (content.len == 0) return lines;
var it = std.mem.splitScalar(u8, content, '\n');
while (it.next()) |line| {
if (line.len == 0 and it.index == null) break;
try lines.append(alloc, line);
}
return lines;
}
fn tomlContent(alloc: Allocator, lines: []const []const u8) ![]const u8 {
if (lines.len == 0) return try alloc.dupe(u8, "");
var out = std.ArrayListUnmanaged(u8).empty;
for (lines) |line| {
try out.appendSlice(alloc, line);
try out.append(alloc, '\n');
}
return try out.toOwnedSlice(alloc);
}
fn removeMarkedBlock(
alloc: Allocator,
lines: *std.ArrayListUnmanaged([]const u8),
begin: []const u8,
end: []const u8,
previous_prefix: ?[]const u8,
) void {
var index: usize = 0;
while (index < lines.items.len) {
if (!std.mem.eql(u8, lines.items[index], begin)) {
index += 1;
continue;
}
var end_index = index;
while (end_index < lines.items.len and !std.mem.eql(u8, lines.items[end_index], end)) {
end_index += 1;
}
if (end_index < lines.items.len) end_index += 1;
if (previous_prefix) |prefix| {
var restored = std.ArrayListUnmanaged([]const u8).empty;
for (lines.items[index..end_index]) |line| {
if (std.mem.startsWith(u8, line, prefix)) {
restored.append(alloc, line[prefix.len..]) catch {};
}
}
lines.replaceRange(alloc, index, end_index - index, restored.items) catch {};
restored.deinit(alloc);
} else {
lines.replaceRange(alloc, index, end_index - index, &.{}) catch {};
}
}
}
fn hooksFeatureEnabled(lines: []const []const u8) bool {
if (findDottedFeaturesHooksIndex(lines)) |index| {
return lineDefinesTrueValue(lines[index]);
}
if (findFeaturesTable(lines)) |features| {
if (findKeyInRange(lines, "hooks", features.start + 1, features.end)) |index| {
return lineDefinesTrueValue(lines[index]);
}
}
return false;
}
const TableRange = struct { start: usize, end: usize };
fn findFeaturesTable(lines: []const []const u8) ?TableRange {
for (lines, 0..) |line, index| {
if (!lineIsTable(line, "features")) continue;
var end = index + 1;
while (end < lines.len and !lineIsAnyTable(lines[end])) end += 1;
return .{ .start = index, .end = end };
}
return null;
}
fn findDottedFeaturesHooksIndex(lines: []const []const u8) ?usize {
for (lines, 0..) |line, index| {
if (lineDefinesDottedKey(line, "features.hooks")) return index;
}
return null;
}
fn findKeyInRange(lines: []const []const u8, key: []const u8, start: usize, end: usize) ?usize {
var index = start;
while (index < end) : (index += 1) {
if (lineDefinesKey(lines[index], key)) return index;
}
return null;
}
fn lineIsTable(line: []const u8, name: []const u8) bool {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
return trimmed.len == name.len + 2 and
trimmed[0] == '[' and
trimmed[trimmed.len - 1] == ']' and
std.mem.eql(u8, std.mem.trim(u8, trimmed[1 .. trimmed.len - 1], &std.ascii.whitespace), name);
}
fn lineIsAnyTable(line: []const u8) bool {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
return trimmed.len >= 2 and trimmed[0] == '[';
}
fn lineDefinesKey(line: []const u8, key: []const u8) bool {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0 or trimmed[0] == '#') return false;
if (!std.mem.startsWith(u8, trimmed, key)) return false;
var index = key.len;
while (index < trimmed.len and (trimmed[index] == ' ' or trimmed[index] == '\t')) index += 1;
return index < trimmed.len and trimmed[index] == '=';
}
fn lineDefinesDottedKey(line: []const u8, key: []const u8) bool {
return lineDefinesKey(line, key);
}
fn lineDefinesTrueValue(line: []const u8) bool {
const equals = std.mem.indexOfScalar(u8, line, '=') orelse return false;
const value = std.mem.trim(u8, line[equals + 1 ..], &std.ascii.whitespace);
return std.mem.startsWith(u8, value, "true") and
(value.len == 4 or value[4] == '#' or value[4] == ' ' or value[4] == '\t');
}
fn removeEmptyFeaturesTable(alloc: Allocator, lines: *std.ArrayListUnmanaged([]const u8)) void {
const features = findFeaturesTable(lines.items) orelse return;
var has_content = false;
for (lines.items[features.start + 1 .. features.end]) |line| {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len > 0 and trimmed[0] != '#') {
has_content = true;
break;
}
}
if (!has_content) {
lines.replaceRange(alloc, features.start, features.end - features.start, &.{}) catch {};
}
}
fn tomlBasicString(alloc: Allocator, value: []const u8) ![]const u8 {
var out = std.ArrayListUnmanaged(u8).empty;
for (value) |c| {
switch (c) {
'\\' => try out.appendSlice(alloc, "\\\\"),
'"' => try out.appendSlice(alloc, "\\\""),
'\n' => try out.appendSlice(alloc, "\\n"),
'\r' => try out.appendSlice(alloc, "\\r"),
'\t' => try out.appendSlice(alloc, "\\t"),
else => try out.append(alloc, c),
}
}
return try out.toOwnedSlice(alloc);
}
fn readFileAllocIfExists(alloc: Allocator, path: []const u8) ![]const u8 {
const file = std.fs.openFileAbsolute(path, .{}) catch |err| switch (err) {
error.FileNotFound => return try alloc.dupe(u8, ""),
else => return err,
};
defer file.close();
return try file.readToEndAlloc(alloc, 10 * 1024 * 1024);
}
fn writeFile(path: []const u8, content: []const u8) !void {
if (std.fs.path.dirname(path)) |dir| try std.fs.cwd().makePath(dir);
const file = try std.fs.createFileAbsolute(path, .{});
defer file.close();
try file.writeAll(content);
}
fn printInstallStatus(prefix: []const u8, path: []const u8) !void {
var buffer: [1024]u8 = undefined;
var stdout = std.fs.File.stdout().writer(&buffer);
try stdout.interface.writeAll(prefix);
try stdout.interface.writeAll(path);
try stdout.interface.writeByte('\n');
try stdout.interface.flush();
}
fn envOwned(alloc: Allocator, key: []const u8) !?[]const u8 {
return std.process.getEnvVarOwned(alloc, key) catch |err| switch (err) {
error.EnvironmentVariableNotFound => null,
else => return err,
};
}
test "agent hook state normalization" {
try std.testing.expectEqual(NormalizedState.running, normalizeState("prompt-submit", null).?);
try std.testing.expectEqual(NormalizedState.running, normalizeState("UserPromptSubmit", null).?);
try std.testing.expectEqual(NormalizedState.running, normalizeState("pre_tool_use", null).?);
try std.testing.expectEqual(NormalizedState.needs_input, normalizeState("permission-request", null).?);
try std.testing.expectEqual(NormalizedState.needs_input, normalizeState("PermissionRequest", null).?);
try std.testing.expectEqual(NormalizedState.needs_input, normalizeState("stop", null).?);
try std.testing.expectEqual(NormalizedState.idle, normalizeState("SessionEnd", null).?);
try std.testing.expectEqual(NormalizedState.errored, normalizeState("failure", null).?);
try std.testing.expectEqual(NormalizedState.needs_input, normalizeState("pre-tool-use", "AskUserQuestion").?);
}
test "codex hook install preserves non Ghostty hooks" {
const alloc = std.testing.allocator;
const existing =
\\{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"echo user","timeout":10}]}]}}
;
var arena = std.heap.ArenaAllocator.init(alloc);
defer arena.deinit();
const arena_alloc = arena.allocator();
var root = try parseHooksRoot(arena_alloc, existing);
const hooks = try ensureObjectField(arena_alloc, &root.object, "hooks");
try removeOwnedHooksFromEvent(arena_alloc, hooks, "UserPromptSubmit");
try appendCodexHookGroup(arena_alloc, hooks, codex_events[1]);
const rendered = try renderJson(alloc, root, .{ .whitespace = .minified });
defer alloc.free(rendered);
try std.testing.expect(std.mem.indexOf(u8, rendered, "echo user") != null);
try std.testing.expect(std.mem.indexOf(u8, rendered, "ghostty-agent-hook") != null);
}
test "codex config install and uninstall markers" {
const alloc = std.testing.allocator;
const entries = [_]TrustEntry{.{
.key = "/tmp/hooks.json:user_prompt_submit:0:0",
.trusted_hash = "sha256:abc",
}};
const installed = try codexConfigTomlInstalling(alloc, "[features]\nmodel = \"x\"\n", entries[0..]);
defer alloc.free(installed);
try std.testing.expect(std.mem.indexOf(u8, installed, "hooks = true") != null);
try std.testing.expect(std.mem.indexOf(u8, installed, "[hooks.state.") != null);
try std.testing.expect(std.mem.indexOf(u8, installed, "model = \"x\"") != null);
const uninstalled = try codexConfigTomlUninstalling(alloc, installed);
defer alloc.free(uninstalled);
try std.testing.expect(std.mem.indexOf(u8, uninstalled, "ghostty-agent-codex") == null);
try std.testing.expect(std.mem.indexOf(u8, uninstalled, "model = \"x\"") != null);
}

View File

@ -315,6 +315,9 @@ pub const Action = union(Key) {
/// Show a native GUI notification about the progress of some TUI operation.
progress_report: terminal.osc.Command.ProgressReport,
/// Terminal output was received for the surface.
terminal_activity,
/// Show the on-screen keyboard.
show_on_screen_keyboard,
@ -402,6 +405,7 @@ pub const Action = union(Key) {
open_url,
show_child_exited,
progress_report,
terminal_activity,
show_on_screen_keyboard,
command_finished,
start_search,

View File

@ -728,6 +728,7 @@ pub const Application = extern struct {
.present_terminal => return Action.presentTerminal(target),
.progress_report => return Action.progressReport(target, value),
.terminal_activity => {},
.prompt_title => return Action.promptTitle(target, value),

View File

@ -91,6 +91,9 @@ pub const Message = union(enum) {
/// Report the progress of an action using a GUI element
progress_report: terminal.osc.Command.ProgressReport,
/// Terminal output was received for this surface.
terminal_activity,
/// A command has started in the shell, start a timer.
start_command,

View File

@ -27,6 +27,34 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty
deps.help_strings.addImport(build_data_exe);
// Agent hook helper and shims used by macOS terminals to surface
// Claude/Codex lifecycle state without watching terminal output.
{
const agent_hook_exe = b.addExecutable(.{
.name = "ghostty-agent-hook",
.root_module = b.createModule(.{
.root_source_file = b.path("src/agent_hook/main.zig"),
.target = cfg.target,
.optimize = cfg.optimize,
.strip = cfg.strip,
.omit_frame_pointer = cfg.strip,
.unwind_tables = if (cfg.strip) .none else .sync,
}),
});
const helper_install = b.addInstallFile(
agent_hook_exe.getEmittedBin(),
"share/ghostty/bin/ghostty-agent-hook",
);
try steps.append(b.allocator, &helper_install.step);
const claude_install = b.addInstallFile(
b.path("src/agent_hook/claude"),
"share/ghostty/bin/claude",
);
try steps.append(b.allocator, &claude_install.step);
}
// Terminfo
terminfo: {
const os_tag = cfg.target.result.os.tag;

View File

@ -49,7 +49,7 @@ pub fn init(
};
const env = try std.process.getEnvMap(b.allocator);
const app_path = b.fmt("macos/build/{s}/Ghostty.app", .{xc_config});
const app_path = b.fmt("macos/build/{s}/Mosttly.app", .{xc_config});
// Our step to build the Ghostty macOS app.
const build = build: {

View File

@ -55,7 +55,7 @@ pub const i18n: bool = config.i18n;
/// There are many places that don't use this variable so simply swapping
/// this variable is NOT ENOUGH to change the bundle ID. I just wanted to
/// avoid it in Zig coe as much as possible.
pub const bundle_id = "com.mitchellh.ghostty";
pub const bundle_id = "com.scottmcpherson.mosttly-ghostty";
/// True if we should have "slow" runtime safety checks. The initial motivation
/// for this was terminal page/pagelist integrity checks. These were VERY

View File

@ -2121,8 +2121,9 @@ keybind: Keybinds = .{},
/// * `ghostty` - Use the background and foreground colors specified in the
/// Ghostty configuration. This is only supported on Linux builds.
///
/// On macOS, if `macos-titlebar-style` is `tabs` or `transparent`, the window theme will be
/// automatically set based on the luminosity of the terminal background color.
/// On macOS, if `macos-titlebar-style` is `tabs`, `sidebar`, or `transparent`,
/// the window theme will be automatically set based on the luminosity of the
/// terminal background color.
/// This only applies to terminal windows. This setting will still apply to
/// non-terminal windows within Ghostty.
///
@ -3219,7 +3220,7 @@ keybind: Keybinds = .{},
@"macos-window-buttons": MacWindowButtons = .visible,
/// The style of the macOS titlebar. Available values are: "native",
/// "transparent", "tabs", and "hidden".
/// "transparent", "tabs", "sidebar", and "hidden".
///
/// The "native" style uses the native macOS titlebar with zero customization.
/// The titlebar will match your window theme (see `window-theme`).
@ -3243,6 +3244,10 @@ keybind: Keybinds = .{},
/// macOS 14 does not have this issue and any other macOS version has not
/// been tested.
///
/// The "sidebar" style uses native macOS tab groups but hides the system tab
/// bar and shows a lightweight vertical session sidebar beside the terminal
/// content.
///
/// The "hidden" style hides the titlebar. Unlike `window-decoration = none`,
/// however, it does not remove the frame from the window or cause it to have
/// squared corners. Changing to or from this option at run-time may affect
@ -3253,12 +3258,10 @@ keybind: Keybinds = .{},
/// areas of the frame to drag the window. This is a standard macOS behavior
/// and not something Ghostty enables.
///
/// The default value is "transparent". This is an opinionated choice
/// but its one I think is the most aesthetically pleasing and works in
/// most cases.
/// The default value is "sidebar" for this fork.
///
/// Changing this option at runtime only applies to new windows.
@"macos-titlebar-style": MacTitlebarStyle = .transparent,
@"macos-titlebar-style": MacTitlebarStyle = .sidebar,
/// Whether the proxy icon in the macOS titlebar is visible. The proxy icon
/// is the icon that represents the folder of the current working directory.
@ -8995,6 +8998,7 @@ pub const MacTitlebarStyle = enum {
native,
transparent,
tabs,
sidebar,
hidden,
};

View File

@ -62,6 +62,32 @@ pub fn prependEnv(
});
}
/// Prepend a value and remove any existing exact occurrences later in the
/// variable. This is useful for PATH-like variables where precedence matters.
/// The returned value is always allocated so it must be freed.
pub fn prependEnvDedup(
alloc: Allocator,
current: []const u8,
value: []const u8,
) Error![]u8 {
if (current.len == 0) return try alloc.dupe(u8, value);
var result: std.ArrayListUnmanaged(u8) = .empty;
errdefer result.deinit(alloc);
try result.appendSlice(alloc, value);
var it = std.mem.tokenizeScalar(u8, current, std.fs.path.delimiter);
while (it.next()) |entry| {
if (std.mem.eql(u8, entry, value)) continue;
try result.append(alloc, std.fs.path.delimiter);
try result.appendSlice(alloc, entry);
}
return try result.toOwnedSlice(alloc);
}
/// The result of getenv, with a shared deinit to properly handle allocation
/// on Windows.
pub const GetEnvResult = struct {
@ -176,3 +202,26 @@ test "prependEnv existing" {
try testing.expectEqualStrings(result, "foo:a:b");
}
}
test "prependEnvDedup existing" {
const testing = std.testing;
const alloc = testing.allocator;
const current = try std.fmt.allocPrint(alloc, "a{c}foo{c}b{c}foo", .{
std.fs.path.delimiter,
std.fs.path.delimiter,
std.fs.path.delimiter,
});
defer alloc.free(current);
const result = try prependEnvDedup(alloc, current, "foo");
defer alloc.free(result);
const expected = try std.fmt.allocPrint(alloc, "foo{c}a{c}b", .{
std.fs.path.delimiter,
std.fs.path.delimiter,
});
defer alloc.free(expected);
try testing.expectEqualStrings(result, expected);
}

View File

@ -40,6 +40,7 @@ pub const getEnvMap = env.getEnvMap;
pub const appendEnv = env.appendEnv;
pub const appendEnvAlways = env.appendEnvAlways;
pub const prependEnv = env.prependEnv;
pub const prependEnvDedup = env.prependEnvDedup;
pub const getenv = env.getenv;
pub const setenv = env.setenv;
pub const unsetenv = env.unsetenv;

View File

@ -86,6 +86,34 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"path"* && -n "$GHOSTTY_BIN_DIR" ]]; then
fi
fi
# Keep Ghostty's agent hook shims ahead of user PATH changes. This must run
# from shell integration because startup files may rewrite PATH after the
# terminal process has already injected the environment.
if [[ -n "${GHOSTTY_AGENT_HOOK_HELPER:-}" && "${GHOSTTY_AGENT_HOOKS_DISABLED:-0}" != "1" ]]; then
_ghostty_prepend_agent_hook_path() {
local helper_dir="${GHOSTTY_AGENT_HOOK_HELPER%/*}"
local old_path="$PATH"
local new_path="$helper_dir"
local entry
while [[ -n "$old_path" ]]; do
entry="${old_path%%:*}"
if [[ "$old_path" == "$entry" ]]; then
old_path=""
else
old_path="${old_path#*:}"
fi
[[ -z "$entry" || "$entry" == "$helper_dir" ]] && continue
new_path="$new_path:$entry"
done
export PATH="$new_path"
}
_ghostty_prepend_agent_hook_path
unset -f _ghostty_prepend_agent_hook_path
fi
# Sudo
if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
# Wrap `sudo` command to ensure Ghostty terminfo is preserved.

View File

@ -286,6 +286,34 @@ _ghostty_deferred_init() {
fi
fi
# Keep Ghostty's agent hook shims ahead of user PATH changes. This must
# run from shell integration because startup files may rewrite PATH after
# the terminal process has already injected the environment.
if [[ -n "${GHOSTTY_AGENT_HOOK_HELPER:-}" && "${GHOSTTY_AGENT_HOOKS_DISABLED:-0}" != "1" ]]; then
_ghostty_prepend_agent_hook_path() {
builtin local helper_dir="${GHOSTTY_AGENT_HOOK_HELPER%/*}"
builtin local old_path="$PATH"
builtin local new_path="$helper_dir"
builtin local entry
while [[ -n "$old_path" ]]; do
entry="${old_path%%:*}"
if [[ "$old_path" == "$entry" ]]; then
old_path=""
else
old_path="${old_path#*:}"
fi
[[ -z "$entry" || "$entry" == "$helper_dir" ]] && continue
new_path="$new_path:$entry"
done
builtin export PATH="$new_path"
}
_ghostty_prepend_agent_hook_path
builtin unset -f _ghostty_prepend_agent_hook_path
fi
# Sudo
if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* ]] && [[ -n "$TERMINFO" ]]; then
# Wrap `sudo` command to ensure Ghostty terminfo is preserved

View File

@ -627,6 +627,13 @@ const Subprocess = struct {
// then we get it ourselves.
var env = cfg.env;
if (comptime build_config.is_debug and builtin.target.os.tag.isDarwin()) {
// Dev macOS app launches often inherit the agent/shell environment.
// Keep visual testing aligned with normal app launches unless the
// user explicitly restores this via env overrides below.
env.remove("NO_COLOR");
}
// If we have a resources dir then set our env var
if (cfg.resources_dir) |dir| {
log.info("found Ghostty resources dir: {s}", .{dir});
@ -817,6 +824,25 @@ const Subprocess = struct {
);
}
// If a per-surface agent hook helper is configured, prepend its
// directory so bundled agent shims such as "claude" are found before
// the user's real agent binary. The shim is only visible inside
// Ghostty-created terminals because this runs after surface env setup.
agent_hooks_path: {
if (env.get("GHOSTTY_AGENT_HOOKS_DISABLED")) |disabled| {
if (std.mem.eql(u8, disabled, "1")) break :agent_hooks_path;
}
const helper = env.get("GHOSTTY_AGENT_HOOK_HELPER") orelse break :agent_hooks_path;
const helper_dir = std.fs.path.dirname(helper) orelse break :agent_hooks_path;
const path = env.get("PATH") orelse "";
try env.put(
"PATH",
try internal_os.prependEnvDedup(alloc, path, helper_dir),
);
}
// Build our args list
const args: []const [:0]const u8 = execCommand(
alloc,

View File

@ -644,8 +644,12 @@ pub fn processOutput(self: *Termio, buf: []const u8) void {
// We are modifying terminal state from here on out and we need
// the lock to grab our read data.
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
self.processOutputLocked(buf);
self.renderer_state.mutex.unlock();
if (std.time.Instant.now()) |now| {
self.terminal_stream.handler.terminalActivityUnlocked(now);
} else |_| {}
}
/// Process output from readdata but the lock is already held.

View File

@ -82,6 +82,9 @@ pub const StreamHandler = struct {
/// this to determine if we need to default the window title.
seen_title: bool = false,
/// Last time we reported terminal output activity to the apprt.
last_terminal_activity: ?std.time.Instant = null,
pub const Stream = terminal.Stream(StreamHandler);
/// True if we have tmux control mode built in.
@ -105,6 +108,21 @@ pub const StreamHandler = struct {
try self.renderer_wakeup.notify();
}
/// Reports terminal output activity to the surface, throttled so high
/// throughput commands don't spam the app runtime.
pub fn terminalActivityUnlocked(self: *StreamHandler, now: std.time.Instant) void {
const min_interval = 250 * std.time.ns_per_ms;
if (self.last_terminal_activity) |last| {
if (now.since(last) < min_interval) return;
}
self.last_terminal_activity = now;
const msg: apprt.surface.Message = .terminal_activity;
if (self.surface_mailbox.push(msg, .{ .instant = {} }) == 0) {
_ = self.surface_mailbox.push(msg, .{ .forever = {} });
}
}
/// Change the configuration for this handler.
pub fn changeConfig(self: *StreamHandler, config: *termio.DerivedConfig) void {
self.osc_color_report_format = config.osc_color_report_format;