Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3027,11 +3027,16 @@ final class TerminalSurface: Identifiable, ObservableObject {
private var backgroundSurfaceStartQueued = false
private var surfaceCallbackContext: Unmanaged<GhosttySurfaceCallbackContext>?
/// The desired focus state for the Ghostty C surface. May be set before the
/// C surface exists (e.g. during layout restoration); `createSurface` syncs
/// it on creation. Also used as a dedup guard to avoid redundant
/// `ghostty_surface_set_focus` calls (prevents prompt redraws with P10k).
/// Initialized to `true` to match Ghostty's default (Terminal.zig focused=true).
private var desiredFocusState: Bool = true
/// C surface exists (e.g. during layout restoration); `createSurface` seeds
/// the initial runtime focus state from this value, then keeps using it as a
/// dedup guard to avoid redundant `ghostty_surface_set_focus` calls
/// (prevents prompt redraws with P10k).
///
/// Start unfocused and only opt into focus when the workspace/AppKit focus
/// path explicitly requests it. `createSurface` passes this through as the
/// runtime surface's initial focus state so background panes never need a
/// synthetic focus-loss transition during creation.
private var desiredFocusState: Bool = false
#if DEBUG
private var needsConfirmCloseOverrideForTesting: Bool?
private var runtimeSurfaceFreedOutOfBandForTesting = false
Expand Down Expand Up @@ -3658,6 +3663,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
surfaceCallbackContext?.release()
surfaceCallbackContext = callbackContext
surfaceConfig.scale_factor = scaleFactors.layer
surfaceConfig.focused = desiredFocusState
surfaceConfig.context = surfaceContext
#if DEBUG
let templateFontText = String(format: "%.2f", surfaceConfig.font_size)
Expand Down Expand Up @@ -3918,11 +3924,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
}

// Sync the desired focus state to the newly created C surface. Ghostty
// surfaces default to focused=true, but this surface may have been
// logically unfocused before the C surface existed (e.g. during layout
// restoration). Always sync unconditionally so we don't couple to
// Ghostty's default.
// Re-apply the desired focus state after creation so the live runtime
// surface converges with any focus changes that happened while the
// surface was being initialized.
ghostty_surface_set_focus(createdSurface, desiredFocusState)

NotificationCenter.default.post(
Expand Down
23 changes: 23 additions & 0 deletions docs/ghostty-fork.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,20 @@ The fork branch HEAD is now the section 7 cmux theme picker helper commit.
- When true, sets `bg_color[3] = 0` in the per-frame uniform update so the Metal renderer skips the full-screen background fill.
- Allows the host app to provide the terminal background via `CALayer.backgroundColor` for instant coverage during view resizes, avoiding alpha double-stacking.

### 9) Initial focus seeding and DECSET 1004 startup behavior

- Status: working tree change (not yet committed)
- Files:
- `include/ghostty.h`
- `macos/Sources/Ghostty/Surface View/SurfaceView.swift`
- `src/Surface.zig`
- `src/apprt/embedded.zig`
- `src/termio/stream_handler.zig`
- Summary:
- Adds an explicit initial `focused` flag to surface creation so host apps can start background panes unfocused.
- Seeds renderer and termio focus bookkeeping from that initial state before the IO thread starts.
- Keeps DECSET 1004 enablement side-effect free so focus sequences are emitted only on subsequent real focus transitions, preventing `CSI I/O` from leaking into shells during pane creation.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
## Upstreamed fork changes

### cursor-click-to-move respects OSC 133 click-to-move
Expand Down Expand Up @@ -146,4 +160,13 @@ These files change frequently upstream; be careful when rebasing the fork:
If upstream refactors the bg_color uniform update or the glass conditional, re-check that both
paths still zero out `bg_color[3]` correctly.

- `src/Surface.zig`, `src/apprt/embedded.zig`, `macos/Sources/Ghostty/Surface View/SurfaceView.swift`
- The initial `focused` plumbing has to stay aligned across the C config, embedded runtime surface,
and macOS wrapper. If upstream refactors surface creation or post-create focus sync, re-check that
background panes can start unfocused without synthesizing a focus-loss transition during creation.

- `src/termio/stream_handler.zig`
- Keep DECSET 1004 enablement side-effect free. xterm-compatible focus reporting should only emit
`CSI I` / `CSI O` on actual focus transitions, not immediately when the mode is enabled.

If you resolve a conflict, update this doc with what changed.
11 changes: 11 additions & 0 deletions ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -437,11 +437,19 @@ typedef enum {
GHOSTTY_SURFACE_CONTEXT_SPLIT = 2,
} ghostty_surface_context_e;

typedef enum {
GHOSTTY_SURFACE_IO_EXEC = 0,
GHOSTTY_SURFACE_IO_MANUAL = 1,
} ghostty_surface_io_mode_e;

typedef void (*ghostty_io_write_cb)(void*, const char*, uintptr_t);

typedef struct {
ghostty_platform_e platform_tag;
ghostty_platform_u platform;
void* userdata;
double scale_factor;
bool focused;
float font_size;
const char* working_directory;
const char* command;
Expand All @@ -450,6 +458,9 @@ typedef struct {
const char* initial_input;
bool wait_after_command;
ghostty_surface_context_e context;
ghostty_surface_io_mode_e io_mode;
ghostty_io_write_cb io_write_cb;
void* io_write_userdata;
} ghostty_surface_config_s;

typedef struct {
Expand Down
141 changes: 141 additions & 0 deletions scripts/ensure-ghosttykit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"

cd "$PROJECT_DIR"

hash_stdin() {
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 | awk '{print $1}'
else
sha256sum | awk '{print $1}'
fi
}

hash_file() {
local path="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$path" | awk '{print $1}'
else
sha256sum "$path" | awk '{print $1}'
fi
}

extract_surface_config_block() {
local path="$1"
python3 - "$path" <<'PY'
from pathlib import Path
import sys

text = Path(sys.argv[1]).read_text()
start = text.index("typedef struct {\n ghostty_platform_e platform_tag;")
end = text.index("} ghostty_surface_config_s;") + len("} ghostty_surface_config_s;")
print(text[start:end], end="")
PY
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 str.index() raises ValueError on a missing substring

Path.index() in Python raises ValueError (not a clean exit) when the target string is not found. Because this function is called from a $(...) subshell under set -euo pipefail, the outer script will abort on failure — but the developer/user will see a raw Python traceback rather than the informative "out of sync" message. Consider using a try/except block for a cleaner error:

try:
    start = text.index("typedef struct {\n  ghostty_platform_e platform_tag;")
    end   = text.index("} ghostty_surface_config_s;") + len("} ghostty_surface_config_s;")
except ValueError as e:
    print(f"error: ghostty_surface_config_s block not found in {sys.argv[1]}: {e}", file=sys.stderr)
    sys.exit(1)


if [[ ! -d "$PROJECT_DIR/ghostty" ]]; then
echo "error: ghostty submodule is missing. Run ./scripts/setup.sh first." >&2
exit 1
fi

if ! command -v zig >/dev/null 2>&1; then
echo "Error: zig is not installed." >&2
echo "Install via: brew install zig" >&2
exit 1
fi

ROOT_SURFACE_CONFIG="$(extract_surface_config_block "$PROJECT_DIR/ghostty.h")"
SUBMODULE_SURFACE_CONFIG="$(extract_surface_config_block "$PROJECT_DIR/ghostty/include/ghostty.h")"
if [[ "$ROOT_SURFACE_CONFIG" != "$SUBMODULE_SURFACE_CONFIG" ]]; then
echo "error: ghostty_surface_config_s is out of sync between ghostty.h and ghostty/include/ghostty.h." >&2
echo "Update the ghostty submodule SHA or sync the checked-in header before building." >&2
exit 1
fi

GHOSTTY_SHA="$(git -C ghostty rev-parse HEAD)"
GHOSTTY_KEY="$GHOSTTY_SHA"
UNTRACKED_FILES="$(git -C ghostty ls-files --others --exclude-standard)"
if ! git -C ghostty diff --quiet --ignore-submodules=all HEAD -- || [[ -n "$UNTRACKED_FILES" ]]; then
DIRTY_HASH="$(
{
printf 'head=%s\n' "$GHOSTTY_SHA"
git -C ghostty diff --binary HEAD -- .
if [[ -n "$UNTRACKED_FILES" ]]; then
printf '\n--untracked--\n'
while IFS= read -r path; do
[[ -n "$path" ]] || continue
printf 'path=%s\n' "$path"
hash_file "$PROJECT_DIR/ghostty/$path"
done <<< "$UNTRACKED_FILES"
fi
} | hash_stdin
)"
GHOSTTY_KEY="${GHOSTTY_SHA}-dirty-${DIRTY_HASH}"
fi

CACHE_ROOT="${CMUX_GHOSTTYKIT_CACHE_DIR:-$HOME/.cache/cmux/ghosttykit}"
CACHE_DIR="$CACHE_ROOT/$GHOSTTY_KEY"
CACHE_XCFRAMEWORK="$CACHE_DIR/GhosttyKit.xcframework"
LOCAL_XCFRAMEWORK="$PROJECT_DIR/ghostty/macos/GhosttyKit.xcframework"
LOCAL_KEY_STAMP="$LOCAL_XCFRAMEWORK/.ghostty_state_key"
LEGACY_LOCAL_SHA_STAMP="$LOCAL_XCFRAMEWORK/.ghostty_sha"
LOCK_DIR="$CACHE_ROOT/$GHOSTTY_KEY.lock"

mkdir -p "$CACHE_ROOT"

echo "==> Ghostty build key: $GHOSTTY_KEY"

LOCK_TIMEOUT=300
LOCK_START=$SECONDS
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
if (( SECONDS - LOCK_START > LOCK_TIMEOUT )); then
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Do not delete the cache lock solely because this process waited >300s; it can remove an active lock and allow concurrent GhosttyKit builds/copies.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/ensure-ghosttykit.sh, line 94:

<comment>Do not delete the cache lock solely because this process waited >300s; it can remove an active lock and allow concurrent GhosttyKit builds/copies.</comment>

<file context>
@@ -0,0 +1,141 @@
+LOCK_TIMEOUT=300
+LOCK_START=$SECONDS
+while ! mkdir "$LOCK_DIR" 2>/dev/null; do
+  if (( SECONDS - LOCK_START > LOCK_TIMEOUT )); then
+    echo "==> Lock stale (>${LOCK_TIMEOUT}s), removing and retrying..."
+    rmdir "$LOCK_DIR" 2>/dev/null || rm -rf "$LOCK_DIR"
</file context>
Fix with Cubic

echo "==> Lock stale (>${LOCK_TIMEOUT}s), removing and retrying..."
rmdir "$LOCK_DIR" 2>/dev/null || rm -rf "$LOCK_DIR"
continue
Comment on lines +96 to +100
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid deleting potentially live locks after timeout.

At Line 98-100, a waiting process force-removes the lock directory after 300s. A legitimate long build can exceed that and get its live lock deleted, allowing concurrent cache writes for the same key.

Suggested fix
 LOCK_TIMEOUT=300
 LOCK_START=$SECONDS
+LOCK_OWNER_PID_FILE="$LOCK_DIR/pid"
 while ! mkdir "$LOCK_DIR" 2>/dev/null; do
+  if [[ -f "$LOCK_OWNER_PID_FILE" ]]; then
+    read -r lock_pid < "$LOCK_OWNER_PID_FILE" || lock_pid=""
+    if [[ -n "$lock_pid" ]] && ! kill -0 "$lock_pid" 2>/dev/null; then
+      echo "==> Removing orphaned GhosttyKit cache lock (pid $lock_pid)..."
+      rm -rf "$LOCK_DIR"
+      continue
+    fi
+  fi
   if (( SECONDS - LOCK_START > LOCK_TIMEOUT )); then
-    echo "==> Lock stale (>${LOCK_TIMEOUT}s), removing and retrying..."
-    rmdir "$LOCK_DIR" 2>/dev/null || rm -rf "$LOCK_DIR"
-    continue
+    echo "error: timed out waiting for GhosttyKit cache lock for $GHOSTTY_KEY" >&2
+    exit 1
   fi
   echo "==> Waiting for GhosttyKit cache lock for $GHOSTTY_KEY..."
   sleep 1
 done
-trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT
+echo "$$" > "$LOCK_OWNER_PID_FILE"
+trap 'rm -rf "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT

Also applies to: 105-105

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/ensure-ghosttykit.sh` around lines 96 - 100, The current mkdir-based
lock loop (using LOCK_DIR, LOCK_START, LOCK_TIMEOUT and SECONDS) force-removes
the lock directory after timeout which can delete a live lock; instead,
implement a safe-stale-check: when the timeout is reached, inspect a pid/owner
file inside "$LOCK_DIR" (e.g., "$LOCK_DIR/pid"), read the PID, and use kill -0
PID to determine if the locking process is still alive; only remove rmdir/"rm
-rf $LOCK_DIR" if the owner process is gone (or pid file missing/outdated),
otherwise continue waiting (or renew timeout); apply the same change to the
later removal logic around the other timeout branch as well.

fi
echo "==> Waiting for GhosttyKit cache lock for $GHOSTTY_KEY..."
sleep 1
done
Comment on lines +95 to +104
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stale-lock timeout doesn't reset LOCK_START, causing tight spin after first timeout

After the lock is forcibly removed at line 96 (rmdir/rm -rf) and continue jumps to the top of the loop, LOCK_START is never reset. If another process immediately re-creates the lock directory (active contention), the condition (( SECONDS - LOCK_START > LOCK_TIMEOUT )) will be true on the very next check, so the loop will spin without the sleep 1 delay — repeatedly force-removing the lock in a tight loop rather than once and then falling back to polite polling.

Reset LOCK_START after clearing the stale lock so the full 300-second window applies before the next forced eviction:

Suggested change
LOCK_START=$SECONDS
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
if (( SECONDS - LOCK_START > LOCK_TIMEOUT )); then
echo "==> Lock stale (>${LOCK_TIMEOUT}s), removing and retrying..."
rmdir "$LOCK_DIR" 2>/dev/null || rm -rf "$LOCK_DIR"
continue
fi
echo "==> Waiting for GhosttyKit cache lock for $GHOSTTY_KEY..."
sleep 1
done
if (( SECONDS - LOCK_START > LOCK_TIMEOUT )); then
echo "==> Lock stale (>${LOCK_TIMEOUT}s), removing and retrying..."
rmdir "$LOCK_DIR" 2>/dev/null || rm -rf "$LOCK_DIR"
LOCK_START=$SECONDS
continue
fi

trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT

if [[ -d "$CACHE_XCFRAMEWORK" ]]; then
echo "==> Reusing cached GhosttyKit.xcframework"
else
LOCAL_KEY=""
if [[ -f "$LOCAL_KEY_STAMP" ]]; then
LOCAL_KEY="$(cat "$LOCAL_KEY_STAMP")"
elif [[ -f "$LEGACY_LOCAL_SHA_STAMP" ]]; then
LOCAL_KEY="$(cat "$LEGACY_LOCAL_SHA_STAMP")"
fi

if [[ -d "$LOCAL_XCFRAMEWORK" && "$LOCAL_KEY" == "$GHOSTTY_KEY" ]]; then
echo "==> Seeding cache from existing local GhosttyKit.xcframework (build key matches)"
else
echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..."
(
cd ghostty
zig build -Demit-xcframework=true -Dxcframework-target=universal -Doptimize=ReleaseFast
)
echo "$GHOSTTY_KEY" > "$LOCAL_KEY_STAMP"
echo "$GHOSTTY_SHA" > "$LEGACY_LOCAL_SHA_STAMP"
fi

if [[ ! -d "$LOCAL_XCFRAMEWORK" ]]; then
echo "Error: GhosttyKit.xcframework not found at $LOCAL_XCFRAMEWORK" >&2
exit 1
fi

TMP_DIR="$(mktemp -d "$CACHE_ROOT/.ghosttykit-tmp.XXXXXX")"
mkdir -p "$CACHE_DIR"
cp -R "$LOCAL_XCFRAMEWORK" "$TMP_DIR/GhosttyKit.xcframework"
rm -rf "$CACHE_XCFRAMEWORK"
mv "$TMP_DIR/GhosttyKit.xcframework" "$CACHE_XCFRAMEWORK"
rmdir "$TMP_DIR"
echo "==> Cached GhosttyKit.xcframework at $CACHE_XCFRAMEWORK"
fi

echo "==> Creating symlink for GhosttyKit.xcframework..."
ln -sfn "$CACHE_XCFRAMEWORK" GhosttyKit.xcframework
2 changes: 2 additions & 0 deletions scripts/reload.sh
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ if [[ -z "$TAG" ]]; then
exit 1
fi

"$PWD/scripts/ensure-ghosttykit.sh"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fragile $PWD-based path vs $SCRIPT_DIR

reload.sh uses $PWD/scripts/ensure-ghosttykit.sh, but setup.sh uses the more robust $SCRIPT_DIR/ensure-ghosttykit.sh. If reload.sh is invoked with an absolute path from a directory other than the project root (e.g., /abs/path/to/scripts/reload.sh --tag foo from /tmp), $PWD will not be the project root and the path to ensure-ghosttykit.sh won't resolve correctly.

Consider deriving SCRIPT_DIR at the top of reload.sh (the same way setup.sh and ensure-ghosttykit.sh do) and using it here:

Suggested change
"$PWD/scripts/ensure-ghosttykit.sh"
"$SCRIPT_DIR/scripts/ensure-ghosttykit.sh"

Or, since reload.sh itself lives in scripts/, simply compute SCRIPT_DIR at the top of reload.sh:

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

and then call:

"$SCRIPT_DIR/ensure-ghosttykit.sh"


if should_skip_ghostty_cli_helper_zig_build; then
if [[ "${CMUX_SKIP_ZIG_BUILD:-}" != "1" ]]; then
echo "Auto-enabling CMUX_SKIP_ZIG_BUILD=1 for Ghostty CLI helper (${AUTO_SKIP_ZIG_BUILD_REASON})"
Expand Down
65 changes: 1 addition & 64 deletions scripts/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,70 +16,7 @@ if ! command -v zig &> /dev/null; then
exit 1
fi

GHOSTTY_SHA="$(git -C ghostty rev-parse HEAD)"
CACHE_ROOT="${CMUX_GHOSTTYKIT_CACHE_DIR:-$HOME/.cache/cmux/ghosttykit}"
CACHE_DIR="$CACHE_ROOT/$GHOSTTY_SHA"
CACHE_XCFRAMEWORK="$CACHE_DIR/GhosttyKit.xcframework"
LOCAL_XCFRAMEWORK="$PROJECT_DIR/ghostty/macos/GhosttyKit.xcframework"
LOCAL_SHA_STAMP="$LOCAL_XCFRAMEWORK/.ghostty_sha"
LOCK_DIR="$CACHE_ROOT/$GHOSTTY_SHA.lock"

mkdir -p "$CACHE_ROOT"

echo "==> Ghostty submodule commit: $GHOSTTY_SHA"

LOCK_TIMEOUT=300
LOCK_START=$SECONDS
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
if (( SECONDS - LOCK_START > LOCK_TIMEOUT )); then
echo "==> Lock stale (>${LOCK_TIMEOUT}s), removing and retrying..."
rmdir "$LOCK_DIR" 2>/dev/null || rm -rf "$LOCK_DIR"
continue
fi
echo "==> Waiting for GhosttyKit cache lock for $GHOSTTY_SHA..."
sleep 1
done
trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT

if [ -d "$CACHE_XCFRAMEWORK" ]; then
echo "==> Reusing cached GhosttyKit.xcframework"
else
# Only reuse local xcframework if its SHA stamp matches the current ghostty commit.
# Without this check, a stale build from a previous commit could be cached under
# the wrong SHA, producing ABI mismatches.
LOCAL_SHA=""
if [ -f "$LOCAL_SHA_STAMP" ]; then
LOCAL_SHA="$(cat "$LOCAL_SHA_STAMP")"
fi

if [ -d "$LOCAL_XCFRAMEWORK" ] && [ "$LOCAL_SHA" = "$GHOSTTY_SHA" ]; then
echo "==> Seeding cache from existing local GhosttyKit.xcframework (SHA matches)"
else
echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..."
(
cd ghostty
zig build -Demit-xcframework=true -Dxcframework-target=universal -Doptimize=ReleaseFast
)
# Stamp the build output with the SHA it was built from
echo "$GHOSTTY_SHA" > "$LOCAL_SHA_STAMP"
fi

if [ ! -d "$LOCAL_XCFRAMEWORK" ]; then
echo "Error: GhosttyKit.xcframework not found at $LOCAL_XCFRAMEWORK"
exit 1
fi

TMP_DIR="$(mktemp -d "$CACHE_ROOT/.ghosttykit-tmp.XXXXXX")"
mkdir -p "$CACHE_DIR"
cp -R "$LOCAL_XCFRAMEWORK" "$TMP_DIR/GhosttyKit.xcframework"
rm -rf "$CACHE_XCFRAMEWORK"
mv "$TMP_DIR/GhosttyKit.xcframework" "$CACHE_XCFRAMEWORK"
rmdir "$TMP_DIR"
echo "==> Cached GhosttyKit.xcframework at $CACHE_XCFRAMEWORK"
fi

echo "==> Creating symlink for GhosttyKit.xcframework..."
ln -sfn "$CACHE_XCFRAMEWORK" GhosttyKit.xcframework
"$SCRIPT_DIR/ensure-ghosttykit.sh"

echo "==> Setup complete!"
echo ""
Expand Down
Loading