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
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
35
README.md
|
|
@ -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
|
||||
|
|
|
|||
15
build.zig
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 717 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 572 B After Width: | Height: | Size: 911 B |
|
Before Width: | Height: | Size: 802 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 717 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 717 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 666 B After Width: | Height: | Size: 911 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 652 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 652 KiB After Width: | Height: | Size: 243 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 454 KiB After Width: | Height: | Size: 717 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 243 KiB |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")!
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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?) {
|
||||
|
|
|
|||
|
|
@ -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?()
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||