diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index 2e0cc11ba..ea561e77f 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -97,21 +97,6 @@ jobs: path: GhosttyKit.xcframework key: ghosttykit-v3-${{ steps.ghostty-revision.outputs.revision }} - - name: Resolve zig version - id: zig-version - run: printf 'version=%s\n' "$(./scripts/required-zig-version.sh)" >> "$GITHUB_OUTPUT" - - # install-zig.sh re-checks the tarball against scripts/zig-macos-aarch64.sha256 - # on every run, so a cache hit never skips the checksum. - - name: Cache zig toolchain tarball - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 - with: - path: ~/.cache/programa-zig-dist - key: zig-dist-macos-arm64-${{ steps.zig-version.outputs.version }} - - - name: Set up required Zig - run: ./scripts/install-zig.sh - - name: Download pre-built GhosttyKit.xcframework if: steps.cache-ghosttykit.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 076e3169c..f47f20604 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -389,38 +389,6 @@ jobs: run: | ./scripts/download-prebuilt-ghosttykit.sh - - name: Resolve zig version - id: zig-version - run: printf 'version=%s\n' "$(./scripts/required-zig-version.sh)" >> "$GITHUB_OUTPUT" - - # install-zig.sh re-checks the tarball against scripts/zig-macos-aarch64.sha256 - # on every run, so a cache hit never skips the checksum. - - name: Cache zig toolchain tarball - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 - with: - path: ~/.cache/programa-zig-dist - key: zig-dist-macos-arm64-${{ steps.zig-version.outputs.version }} - - - name: Install zig - run: ./scripts/install-zig.sh - - - name: Cache Zig packages - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 - with: - path: ~/.cache/zig - key: zig-packages-${{ hashFiles('ghostty/build.zig.zon', 'ghostty/build.zig.zon.json') }} - restore-keys: zig-packages- - - # The "Copy Ghostty Resources" build phase compiles the Ghostty CLI helper - # (Resources/bin/ghostty) with zig. zig's local cache turns that into a - # no-op while the ghostty revision and zig version stay the same. No - # restore-keys: an exact key keeps the cache from accumulating old builds. - - name: Cache Ghostty CLI helper build - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 - with: - path: ghostty/.zig-cache - key: ghostty-cli-helper-${{ steps.ghostty-revision.outputs.revision }}-zig${{ steps.zig-version.outputs.version }} - - name: Cache DerivedData uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: @@ -504,11 +472,6 @@ jobs: PROGRAMA_DERIVED_DATA_DIR: ${{ github.workspace }}/.ci-derived-data-unit-${{ matrix.shard }} PROGRAMA_UNIT_TEST_SHARD: ${{ matrix.shard }} PROGRAMA_UNIT_TEST_SHARD_COUNT: 2 - # No unit test runs the Ghostty CLI helper (Resources/bin/ghostty), so the - # shards take the build script's stub instead of compiling it with zig. - # build-app still builds the real helper; its artifact feeds the jobs that - # do run it (theme-picker-helper-regression, socket and UI tests). - PROGRAMA_SKIP_ZIG_BUILD: "1" steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -675,6 +638,7 @@ jobs: result=0 for t in \ tests/test_cli_noninteractive_install_regression.py \ + tests/test_cli_themes.py \ tests/test_cli_socket_no_autodiscovery_regression.py \ tests/test_cli_wait_surface_agent_state.py \ tests/test_cli_claude_settings_preservation.py \ @@ -778,41 +742,6 @@ jobs: echo "unit-tests-shard: $SHARD_RESULT (change-detection: $DETECTION_RESULT, run_app_jobs: $RUN_APP_JOBS)" >&2 exit 1 - # Independent of unit-tests: same "programa" scheme app build-app already - # produces, so this reuses that artifact instead of paying for its own - # ~3 min build. Split out so it does not serialize behind the unit-test - # shards. - theme-picker-helper-regression: - needs: - - build-app - - change-detection - if: needs.change-detection.outputs.run_app_jobs == 'true' - runs-on: macos-26 - timeout-minutes: 15 - env: - PROGRAMA_DERIVED_DATA_DIR: ${{ github.workspace }}/.ci-derived-data-theme-picker - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Download build artifact - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 - with: - name: programa-app-build - path: . - - - name: Extract build artifact - run: | - set -euo pipefail - mkdir -p "$PROGRAMA_DERIVED_DATA_DIR" - tar -xzf app-build.tar.gz -C "$PROGRAMA_DERIVED_DATA_DIR" - - - name: Run bundled Ghostty theme picker helper regression - run: | - set -euo pipefail - PROGRAMA_PREBUILT_APP_PATH="$PROGRAMA_DERIVED_DATA_DIR/Build/Products/Debug/Programa DEV.app" \ - ./tests/test_bundled_ghostty_theme_picker_helper.sh - socket-integration-tests: needs: - build-app @@ -1038,10 +967,11 @@ jobs: fi rm -f /tmp/create-virtual-display + # Independent of unit-tests: it reuses the build-app artifact, so waiting + # on the unit-test shards only lengthens the main-push CI tail. ui-regressions: needs: - build-app - - unit-tests - change-detection if: ((github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && inputs.notification_ui)) && needs.change-detection.outputs.run_app_jobs == 'true' runs-on: macos-26 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa5e24aac..5371b99b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -212,9 +212,7 @@ jobs: bun-version: 1.3.14 - name: Install build deps - run: | - ./scripts/install-zig.sh - ./scripts/install-create-dmg.sh + run: ./scripts/install-create-dmg.sh # Always download and checksum GhosttyKit here instead of restoring the CI # cache: the pinned SHA-256 covers the archive, not an extracted tree, so a @@ -263,8 +261,7 @@ jobs: run: | ./scripts/verify-release-architectures.sh \ "build-universal/Build/Products/Release/Programa.app/Contents/MacOS/Programa" \ - "build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/programa" \ - "build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/ghostty" + "build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/programa" - name: Archive dSYMs run: | @@ -302,12 +299,6 @@ jobs: [ -x "$CLI_BINARY" ] || { echo "programa CLI binary not found at $CLI_BINARY" >&2; exit 1; } PROGRAMA_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py - - name: Verify bundled Ghostty theme picker helper - run: | - set -euo pipefail - HELPER_BINARY="build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/ghostty" - [ -x "$HELPER_BINARY" ] || { echo "Ghostty theme picker helper not found at $HELPER_BINARY" >&2; exit 1; } - - name: Inject Sparkle keys into Info.plist run: | APP_PLIST="build-universal/Build/Products/Release/Programa.app/Contents/Info.plist" @@ -373,10 +364,9 @@ jobs: fi APP_PATH="build-universal/Build/Products/Release/Programa.app" CLI_PATH="$APP_PATH/Contents/Resources/bin/programa" - HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty" MCP_PATH="$APP_PATH/Contents/Resources/bin/programa-mcp" ./scripts/sign-release-app.sh "$APP_PATH" "$APPLE_SIGNING_IDENTITY" programa.entitlements - ./scripts/verify-release-entitlements.sh "$APP_PATH" "$CLI_PATH" "$HELPER_PATH" "$MCP_PATH" + ./scripts/verify-release-entitlements.sh "$APP_PATH" "$CLI_PATH" "$MCP_PATH" ./scripts/verify-provision-profile.sh "$APP_PATH" - name: Verify embedded Sparkle artifact diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 515e0bd6d..91cce17a7 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -80,9 +80,6 @@ jobs: GHOSTTYKIT_DOWNLOAD_RETRY_DELAY: 20 run: ./scripts/download-prebuilt-ghosttykit.sh - - name: Install zig - run: ./scripts/install-zig.sh - - name: Create virtual display if: ${{ inputs.test_filter != 'DisplayResolutionRegressionUITests' }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index d30b55b15..5b0863152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,6 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p ### Added - Programs can report their status to Programa with the OSC 7501 Program Status Protocol. The sidebar shows working, blocked, done and error, an unfocused terminal gets a notification when a program finishes, fails or needs input, and `surface.list`, `system.tree` and `surface.wait` (`program_state`) expose it without ever returning the message text. Programa's own agent hooks emit it too, and it outranks hook and screen-detected state. See `docs/program-status.md`. -- Programa can be set as the default web browser. Links clicked in other apps open as a browser tab in the focused pane of the active window, including a link that launches Programa. Links Programa sends to an external browser skip Programa itself and go to the next installed browser. - A ports button in the sidebar footer shows what is listening on a port in each workspace, plus dev servers left running after their terminal closed, with Open, Copy URL, Reveal and Stop. `programa list-ports` and `programa stop-port` and the `ports.list` and `ports.stop` socket methods do the same from the command line. - New `agent.needs_input` socket method reports a surface as blocked on the user and posts its notification in one call, instead of three separate calls that could fail independently. - New `surface.resolve_tty` socket method finds the terminal surface for a tty without changing focus. `agent.spawn` returns the new terminal's `pane_id` and `pane_ref`, and `agent.detection.list` reports manifests it rejected in a `rejected` list. @@ -26,10 +25,9 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Every trusted `programa.json` asks for approval once more after updating. Trust is now recorded per config file, and the dialog says so when a config was trusted before that change. - `programa claude`, `codex` and `opencode` `install-integration` run without a terminal now need `--yes`. - The CLI no longer connects to a dev, staging or tagged app's socket on its own. Point it at one with `--socket` or `PROGRAMA_SOCKET_PATH`. -- A program that writes to the clipboard with OSC 52 asks before the clipboard changes. -- Browser cookie methods act on the current page's site by default; `all_domains: true` widens them, and a `domain` filter matches the exact host or its subdomains only. `browser.state.save` writes its file with `0600` permissions to an absolute path and exports only the current site's cookies unless asked for more. +- A program that writes to the clipboard with OSC 52 asks before the clipboard changes, showing a preview of the text. "Always allow for this terminal" stops the prompt for that terminal until it closes; a `clipboard-write` setting in your config still wins. - Socket errors are consistent: a reference that does not resolve returns `not_found` (a malformed one stays `invalid_params`), splitting a workspace that already has four panes returns `limit_reached` before anything is created, and closing a window's last workspace returns `invalid_state`. -- `surface.send_text` and `agent.prompt` report `"queued": true` when the text waits for a terminal that is not attached yet. `surface.wait` resolves with `"outcome": "closed"` when its surface closes, caps `timeout_ms` at one hour, and rejects `exit` on a browser surface. +- `surface.send_text` and `agent.prompt` report `"queued": true` when the text waits for a terminal that is not attached yet. `surface.wait` resolves with `"outcome": "closed"` when its surface closes and caps `timeout_ms` at one hour. - A workspace's `agent_state` counts only helpers that are still running; finished helpers are counted in the new `helper_outcomes` field of `workspace.list` and `system.tree`. - Password mode closes a connection after five failed `auth.login` attempts and compares the password in constant time. - Agent hooks fail open: outside Programa, or when the app is not reachable, they answer the agent normally and let it continue. The Codex installer keeps everything it writes to `config.toml` inside one `# BEGIN programa` block. @@ -38,11 +36,13 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - The tmux shim's `resize-pane` counts terminal cells, `capture-pane` shows the visible screen, and `send-keys` supports named keys. - The Rust core is no longer part of the macOS app; it serves the Windows app only. - Each workspace row now shows one agent indicator (Needs input, Working, or Idle) instead of the badge, status row, and notification text sometimes disagreeing with each other. It dims and shows "(stale)" after ten minutes with no update from the agent, and only clears when the agent itself reports it's resumed, not just from opening the workspace. +- `programa themes` lists and sets themes without the interactive picker, and the app no longer bundles a second copy of the ghostty command-line tool (about 6 MB smaller). Unknown theme names suggest close matches. ### Removed -- Browser socket methods that only ever answered `not_supported` (network routing, viewport emulation, raw input injection and similar) are gone; calling them is now an unknown-method error. +- The built-in browser, its panes, the `browser.*` socket methods and the matching CLI and MCP tools. Links open in your default browser. See `docs/removed/built-in-browser.md`. - The library-validation and unsigned-executable-memory entitlements. - The `programa codex install-hooks` and `uninstall-hooks` legacy aliases. Use `programa codex install-integration` and `uninstall-integration`. +- The `programa omo`, `omx` and `omc` wrappers for oh-my-openagent, Oh My Codex and Oh My Claude Code. The `programa claude`, `codex` and `opencode` integrations are unchanged. See `docs/removed/omo-omx-omc-wrappers.md` to restore them. ### Fixed - With dozens of tabs in a pane, each tab keeps a width that shows its title instead of shrinking to one letter. The tab bar scrolls with a regular mouse wheel and keeps the selected tab in view. diff --git a/CLI-MCP/Tools/SurfaceTools.swift b/CLI-MCP/Tools/SurfaceTools.swift index 15010857e..5530fc707 100644 --- a/CLI-MCP/Tools/SurfaceTools.swift +++ b/CLI-MCP/Tools/SurfaceTools.swift @@ -259,7 +259,7 @@ enum SurfaceTools { "workspace_id": ProgramaToolSchema.string("Workspace UUID or short ref."), "surface_id": ProgramaToolSchema.string("Surface UUID or short ref."), "branch": ProgramaToolSchema.string("Git branch name."), - "dirty": ProgramaToolSchema.boolean("Whether the working tree has uncommitted changes. Defaults to false."), + "dirty": ProgramaToolSchema.boolean("Whether the working tree has uncommitted changes. When omitted, the last value the app probed is kept (false if none)."), ], required: ["workspace_id", "surface_id", "branch"] ) diff --git a/CLI/CLI+AgentWrappers.swift b/CLI/CLI+AgentWrappers.swift index 0c32b5fb0..7fc20b5a1 100644 --- a/CLI/CLI+AgentWrappers.swift +++ b/CLI/CLI+AgentWrappers.swift @@ -71,11 +71,9 @@ extension ProgramaCLI { delete process.env.PROGRAMA_ORIGINAL_NODE_OPTIONS_PRESENT; """ - /// Configures the shared tmux-compat environment for an agent wrapper - /// command (claude-teams, omo, omx, omc), then optionally layers on the - /// NODE_OPTIONS restore module. claude-teams and omc both wrap Claude - /// Code and need that module configured identically (silently skipped - /// if the module file can't be created). + /// Configures the tmux-compat environment for an agent wrapper command + /// (claude-teams), then optionally layers on the NODE_OPTIONS restore + /// module (silently skipped if the module file can't be created). private func configureAgentWrapperEnvironment( processEnvironment: [String: String], shimDirectory: URL, @@ -145,57 +143,34 @@ extension ProgramaCLI { return restoreModuleURL } - /// Everything that differs between the four agent wrapper commands - /// (claude-teams, omo, omx, omc). runAgentWrapper implements their - /// shared shape once; each run* function below just builds a config. + /// Everything that differs between agent wrapper commands. runAgentWrapper + /// implements their shared shape once; each run* function just builds a config. private struct AgentWrapperConfig { - /// Name used in "programa " messages. - let cmdLabel: String - /// Bare command name: used for the execvp fallback, the not-found - /// `which` check, and error messages (e.g. "claude"). + /// Bare command name: used for the execvp fallback and error messages + /// (e.g. "claude"). let execName: String /// Resolves the real executable path given the launcher environment /// (already carrying PROGRAMA_SOCKET/PROGRAMA_SOCKET_PATH/ /// PROGRAMA_SOCKET_PASSWORD). let resolveExecutable: (_ launcherEnvironment: [String: String]) -> String? - /// When non-nil, a nil resolveExecutable result triggers a `which` - /// check; if that also fails, throws CLIError with this hint. nil - /// (claude-teams) means no early not-found check is performed -- - /// claude-teams instead falls through to the generic exec-failure - /// error below. - let notFoundInstallHint: String? - /// Runs after the not-found check (if any), before shim directory - /// creation. Only omo uses this (oh-my-opencode plugin setup). - let preLaunch: (() throws -> Void)? /// Creates the tool's shim directory. let createShimDir: () throws -> URL - /// Runs after focused-context lookup, before configureEnvironment. - /// Returns env vars to merge into the launcher environment (e.g. - /// omo's resolved OPENCODE_PORT). Only omo uses this. - let beforeConfigure: ((_ launcherEnvironment: [String: String]) -> [String: String])? let tmuxPathPrefix: String let programaBinEnvVar: String let termOverrideEnvVar: String - /// Extra env vars to set, computed from the (possibly beforeConfigure- - /// augmented) launcher environment. + /// Extra env vars to set, computed from the launcher environment. let extraEnvVars: (_ launcherEnvironment: [String: String]) -> [(key: String, value: String)] - /// claude-teams and omc both wrap Claude Code and need the - /// NODE_OPTIONS restore module configured. + /// Whether the wrapped tool is Claude Code and needs the NODE_OPTIONS + /// restore module configured. let needsClaudeNodeOptionsRestore: Bool - /// Transforms the raw CLI args before exec. claude-teams injects - /// --teammate-mode; omo injects a default --port. omx/omc pass - /// commandArgs through unchanged. + /// Transforms the raw CLI args before exec (claude-teams injects + /// --teammate-mode). let buildLaunchArguments: (_ commandArgs: [String], _ launcherEnvironment: [String: String]) -> [String] - /// Extra text appended (after a blank line) to the exec-failure - /// message. nil for claude-teams. - let execFailureHint: String? } - /// Implements the shared shape of `programa claude-teams`, `programa - /// omo`, `programa omx`, and `programa omc`: build the launcher - /// environment, resolve the real executable, optionally check it's - /// installed and run tool-specific setup, create shim scripts, gather - /// focused-terminal context, configure environment variables, and exec. + /// Implements the shared shape of `programa claude-teams`: build the + /// launcher environment, resolve the real executable, create shim scripts, + /// gather focused-terminal context, configure environment variables, and exec. private func runAgentWrapper( commandArgs: [String], socketPath: String, @@ -212,23 +187,6 @@ extension ProgramaCLI { let resolvedExecutablePath = config.resolveExecutable(launcherEnvironment) - if resolvedExecutablePath == nil, let hint = config.notFoundInstallHint { - let checkProcess = Process() - checkProcess.executableURL = URL(fileURLWithPath: "/usr/bin/which") - checkProcess.arguments = [config.execName] - checkProcess.standardOutput = Pipe() - checkProcess.standardError = Pipe() - try? checkProcess.run() - checkProcess.waitUntilExit() - if checkProcess.terminationStatus != 0 { - throw CLIError(message: "\(config.execName) is not installed. Install it first:\n \(hint)\n\nThen run: programa \(config.cmdLabel)") - } - } - - if let preLaunch = config.preLaunch { - try preLaunch() - } - let shimDirectory = try config.createShimDir() let executablePath = resolvedExecutableURL()?.path ?? (args.first ?? "programa") let focusedContext = tmuxCompatFocusedContext( @@ -236,12 +194,6 @@ extension ProgramaCLI { explicitPassword: explicitPassword ) - if let beforeConfigure = config.beforeConfigure { - for (key, value) in beforeConfigure(launcherEnvironment) { - launcherEnvironment[key] = value - } - } - configureAgentWrapperEnvironment( processEnvironment: launcherEnvironment, shimDirectory: shimDirectory, @@ -272,8 +224,7 @@ extension ProgramaCLI { execvp(config.execName, &argv) } let code = errno - let hintSuffix = config.execFailureHint.map { "\n\n\($0)" } ?? "" - throw CLIError(message: "Failed to launch \(config.execName): \(String(cString: strerror(code)))\(hintSuffix)") + throw CLIError(message: "Failed to launch \(config.execName): \(String(cString: strerror(code)))") } func runClaudeTeams( @@ -286,7 +237,6 @@ extension ProgramaCLI { socketPath: socketPath, explicitPassword: explicitPassword, config: AgentWrapperConfig( - cmdLabel: "claude-teams", execName: "claude", resolveExecutable: { launcherEnvironment in let bundledClaudePath = resolvedExecutableURL()? @@ -300,10 +250,7 @@ extension ProgramaCLI { return bundledClaudePath }() }, - notFoundInstallHint: nil, - preLaunch: nil, createShimDir: { try self.createClaudeTeamsShimDirectory() }, - beforeConfigure: nil, tmuxPathPrefix: "programa-claude-teams", programaBinEnvVar: "PROGRAMA_CLAUDE_TEAMS_PROGRAMA_BIN", termOverrideEnvVar: "PROGRAMA_CLAUDE_TEAMS_TERM", @@ -313,68 +260,11 @@ extension ProgramaCLI { needsClaudeNodeOptionsRestore: true, buildLaunchArguments: { commandArgs, _ in claudeTeamsLaunchArguments(commandArgs: commandArgs) - }, - execFailureHint: nil + } ) ) } - // MARK: - programa omo (OpenCode + oh-my-openagent) - - /// Creates a shim directory containing a `tmux` shim that answers - /// `-V`/`-v` locally (no live socket connection needed just to probe - /// the tmux version) and forwards everything else to `programa - /// __tmux-compat`. Shared by omo, omx, and omc; claude-teams uses its - /// own simpler shim with no -V/-v handling (createClaudeTeamsShimDirectory). - private func createVFlagTmuxShimDirectory( - directoryName: String, - programaBinEnvVar: String, - versionCheckComment: String = "" - ) throws -> URL { - let tmuxScript = """ - #!/usr/bin/env bash - set -euo pipefail - \(versionCheckComment)case "${1:-}" in - -V|-v) echo "tmux 3.4"; exit 0 ;; - esac - exec "${\(programaBinEnvVar):-programa}" __tmux-compat "$@" - """ - return try createTmuxCompatShimDirectory( - directoryName: directoryName, - tmuxShimScript: tmuxScript - ) - } - - private func createOMOShimDirectory() throws -> URL { - // tmux shim: redirects tmux commands to programa __tmux-compat - // Handle -V locally (no socket needed) since __tmux-compat requires a connection. - let root = try createVFlagTmuxShimDirectory( - directoryName: "omo-bin", - programaBinEnvVar: "PROGRAMA_OMO_PROGRAMA_BIN", - versionCheckComment: "# Only match -V/-v as the first arg (top-level tmux flag).\n# -v inside subcommands (e.g. split-window -v) is a vertical split flag.\n" - ) - - // terminal-notifier shim: intercepts macOS notifications and routes to programa notify - let notifierURL = root.appendingPathComponent("terminal-notifier", isDirectory: false) - let notifierScript = """ - #!/usr/bin/env bash - # Intercept terminal-notifier calls and route through programa notify. - # oh-my-openagent calls: terminal-notifier -title -message [-activate ] - TITLE="" BODY="" - while [[ $# -gt 0 ]]; do - case "$1" in - -title) TITLE="$2"; shift 2 ;; - -message) BODY="$2"; shift 2 ;; - *) shift ;; - esac - done - exec "${PROGRAMA_OMO_PROGRAMA_BIN:-programa}" notify --title "${TITLE:-OpenCode}" --body "${BODY:-}" - """ - try writeShimIfChanged(notifierScript, to: notifierURL) - - return root - } - func writeShimIfChanged(_ script: String, to url: URL) throws { let normalized = script.trimmingCharacters(in: .whitespacesAndNewlines) let fileManager = FileManager.default @@ -410,479 +300,6 @@ extension ProgramaCLI { } } - private static let omoPluginName = "oh-my-opencode" - /// Exact version installed into the shadow package. A floating `latest` would run any new - /// npm release's install scripts as the user; bump this deliberately after reviewing one. - private static let omoPluginVersion = "5.1.7" - - private func resolveExecutableInPath(_ name: String) -> String? { - let entries = ProcessInfo.processInfo.environment["PATH"]?.split(separator: ":").map(String.init) ?? [] - for entry in entries where !entry.isEmpty { - let candidate = URL(fileURLWithPath: entry, isDirectory: true) - .appendingPathComponent(name, isDirectory: false) - .path - if FileManager.default.isExecutableFile(atPath: candidate) { - return candidate - } - } - return nil - } - - private func omoUserConfigDir() -> URL { - let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() - return URL(fileURLWithPath: homePath, isDirectory: true) - .appendingPathComponent(".config", isDirectory: true) - .appendingPathComponent("opencode", isDirectory: true) - } - - private func omoShadowConfigDir() -> URL { - let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() - return URL(fileURLWithPath: homePath, isDirectory: true) - .appendingPathComponent(".programa", isDirectory: true) - .appendingPathComponent("omo-config", isDirectory: true) - } - - private func omoFileType(at url: URL) -> FileAttributeType? { - let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) - return attrs?[.type] as? FileAttributeType - } - - private func omoEnsureShadowPackageManifest(at shadowPackageURL: URL) throws { - let fm = FileManager.default - if omoFileType(at: shadowPackageURL) == .typeSymbolicLink { - try? fm.removeItem(at: shadowPackageURL) - } - - // Keep the shadow package isolated from stale/yanked pins in the user's - // opencode package.json, and pinned to the one reviewed release. - let packageManifest: [String: Any] = [ - "dependencies": [ - Self.omoPluginName: Self.omoPluginVersion - ], - "name": "programa-omo-shadow", - "private": true - ] - let output = try JSONSerialization.data(withJSONObject: packageManifest, options: [.prettyPrinted, .sortedKeys]) - let existing = try? Data(contentsOf: shadowPackageURL) - if existing != output { - try output.write(to: shadowPackageURL, options: .atomic) - } - } - - private func omoEnsureShadowNodeModulesSymlink( - shadowNodeModules: URL, - userNodeModules: URL - ) throws { - let fm = FileManager.default - guard fm.fileExists(atPath: userNodeModules.path) else { return } - - if let type = omoFileType(at: shadowNodeModules) { - if type == .typeSymbolicLink { - let target = try? fm.destinationOfSymbolicLink(atPath: shadowNodeModules.path) - if target != userNodeModules.path { - try? fm.removeItem(at: shadowNodeModules) - } else { - return - } - } else { - return - } - } - - if !fm.fileExists(atPath: shadowNodeModules.path) { - try fm.createSymbolicLink(at: shadowNodeModules, withDestinationURL: userNodeModules) - } - } - - private func omoRunPackageInstall( - executablePath: String, - arguments: [String], - currentDirectoryURL: URL - ) throws -> Int32 { - let process = Process() - process.currentDirectoryURL = currentDirectoryURL - process.executableURL = URL(fileURLWithPath: executablePath) - process.arguments = arguments - process.standardOutput = FileHandle.standardError - process.standardError = FileHandle.standardError - try process.run() - process.waitUntilExit() - return process.terminationStatus - } - - private func omoRequestedPort(from commandArgs: [String]) -> String? { - for (index, arg) in commandArgs.enumerated() { - if arg == "--port" { - let nextIndex = commandArgs.index(after: index) - guard nextIndex < commandArgs.endIndex else { return nil } - let value = commandArgs[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines) - return value.isEmpty ? nil : value - } - - if arg.hasPrefix("--port=") { - let value = String(arg.dropFirst("--port=".count)).trimmingCharacters(in: .whitespacesAndNewlines) - return value.isEmpty ? nil : value - } - } - - return nil - } - - private func omoBindableLoopbackPort(_ port: UInt16) -> UInt16? { - let socketDescriptor = socket(AF_INET, SOCK_STREAM, 0) - guard socketDescriptor >= 0 else { return nil } - defer { close(socketDescriptor) } - - var reuseAddress: Int32 = 1 - _ = setsockopt( - socketDescriptor, - SOL_SOCKET, - SO_REUSEADDR, - &reuseAddress, - socklen_t(MemoryLayout.size) - ) - - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout.stride) - address.sin_family = sa_family_t(AF_INET) - address.sin_port = port.bigEndian - address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) - - let bindResult = withUnsafePointer(to: &address) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.bind(socketDescriptor, $0, socklen_t(MemoryLayout.stride)) - } - } - guard bindResult == 0 else { return nil } - - if port != 0 { - return port - } - - var boundAddress = address - var boundAddressLength = socklen_t(MemoryLayout.stride) - let nameResult = withUnsafeMutablePointer(to: &boundAddress) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - getsockname(socketDescriptor, $0, &boundAddressLength) - } - } - guard nameResult == 0 else { return nil } - - return UInt16(bigEndian: boundAddress.sin_port) - } - - private func omoResolvedPort( - commandArgs: [String], - processEnvironment: [String: String] - ) -> String { - if let requestedPort = omoRequestedPort(from: commandArgs) { - return requestedPort - } - - if let environmentPort = processEnvironment["OPENCODE_PORT"]? - .trimmingCharacters(in: .whitespacesAndNewlines), - let parsedEnvironmentPort = UInt16(environmentPort), - parsedEnvironmentPort != 0, - omoBindableLoopbackPort(parsedEnvironmentPort) != nil { - return environmentPort - } - - if let preferredPort = omoBindableLoopbackPort(4096) { - return String(preferredPort) - } - - if let fallbackPort = omoBindableLoopbackPort(0) { - return String(fallbackPort) - } - - return "4096" - } - - /// Creates a shadow config directory that layers oh-my-opencode on top of the user's - /// existing opencode config without modifying the original. Sets OPENCODE_CONFIG_DIR - /// to point at the shadow directory. - private func omoEnsurePlugin() throws { - let userDir = omoUserConfigDir() - let shadowDir = omoShadowConfigDir() - let fm = FileManager.default - - try fm.createDirectory(at: shadowDir, withIntermediateDirectories: true, attributes: nil) - - // Read the user's opencode.json (if any), add the plugin, write to shadow dir - let userJsonURL = userDir.appendingPathComponent("opencode.json") - let shadowJsonURL = shadowDir.appendingPathComponent("opencode.json") - - var config: [String: Any] - if let data = try? Data(contentsOf: userJsonURL) { - guard let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw CLIError(message: "Failed to parse \(userJsonURL.path). Fix the JSON syntax and retry.") - } - config = existing - } else { - config = [:] - } - - var plugins = (config["plugin"] as? [String]) ?? [] - let alreadyPresent = plugins.contains { - $0 == Self.omoPluginName || $0.hasPrefix("\(Self.omoPluginName)@") - } - if !alreadyPresent { - plugins.append(Self.omoPluginName) - } - config["plugin"] = plugins - - let output = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) - try output.write(to: shadowJsonURL, options: .atomic) - - // Symlink node_modules from the user's config dir so installed packages resolve - let shadowNodeModules = shadowDir.appendingPathComponent("node_modules") - let userNodeModules = userDir.appendingPathComponent("node_modules") - try omoEnsureShadowNodeModulesSymlink(shadowNodeModules: shadowNodeModules, userNodeModules: userNodeModules) - - // The shadow config owns its own package metadata so yanked/stale pins in the - // user's opencode package.json/bun.lock cannot poison plugin installation. - let shadowPackageURL = shadowDir.appendingPathComponent("package.json") - let shadowBunLockURL = shadowDir.appendingPathComponent("bun.lock") - try omoEnsureShadowPackageManifest(at: shadowPackageURL) - if omoFileType(at: shadowBunLockURL) == .typeSymbolicLink { - try? fm.removeItem(at: shadowBunLockURL) - } - - // Copy oh-my-opencode plugin config (jsonc) if the user has one - for filename in ["oh-my-opencode.json", "oh-my-opencode.jsonc"] { - let userFile = userDir.appendingPathComponent(filename) - let shadowFile = shadowDir.appendingPathComponent(filename) - if fm.fileExists(atPath: userFile.path) && !fm.fileExists(atPath: shadowFile.path) { - try fm.createSymbolicLink(at: shadowFile, withDestinationURL: userFile) - } - } - - // Install the package if not available via the symlinked node_modules - let pluginPackageDir = shadowNodeModules.appendingPathComponent(Self.omoPluginName) - if !fm.fileExists(atPath: pluginPackageDir.path) { - let installDir = shadowDir - if let bunPath = resolveExecutableInPath("bun") { - FileHandle.standardError.write("Installing oh-my-opencode plugin (this may take a minute on first run)...\n".data(using: .utf8)!) - let installArguments = ["add", "--exact", "\(Self.omoPluginName)@\(Self.omoPluginVersion)"] - let firstAttemptStatus = try omoRunPackageInstall( - executablePath: bunPath, - arguments: installArguments, - currentDirectoryURL: installDir - ) - if firstAttemptStatus != 0 { - FileHandle.standardError.write("Retrying oh-my-opencode install with a clean shadow package state...\n".data(using: .utf8)!) - try? fm.removeItem(at: shadowBunLockURL) - try? fm.removeItem(at: shadowNodeModules) - try omoEnsureShadowNodeModulesSymlink(shadowNodeModules: shadowNodeModules, userNodeModules: userNodeModules) - let retryStatus = try omoRunPackageInstall( - executablePath: bunPath, - arguments: installArguments, - currentDirectoryURL: installDir - ) - if retryStatus != 0 { - throw CLIError(message: "Failed to install oh-my-opencode. Try manually: npm install -g oh-my-opencode") - } - } - } else if let npmPath = resolveExecutableInPath("npm") { - FileHandle.standardError.write("Installing oh-my-opencode plugin (this may take a minute on first run)...\n".data(using: .utf8)!) - let status = try omoRunPackageInstall( - executablePath: npmPath, - arguments: ["install", "--save-exact", "\(Self.omoPluginName)@\(Self.omoPluginVersion)"], - currentDirectoryURL: installDir - ) - if status != 0 { - throw CLIError(message: "Failed to install oh-my-opencode. Try manually: npm install -g oh-my-opencode") - } - } else { - throw CLIError(message: "Neither bun nor npm found in PATH. Install oh-my-opencode manually: bunx oh-my-opencode install") - } - FileHandle.standardError.write("oh-my-opencode plugin installed\n".data(using: .utf8)!) - } - - // Ensure tmux mode is enabled in oh-my-opencode config. - // Without this, the TmuxSessionManager won't spawn visual panes even though - // $TMUX is set (tmux.enabled defaults to false). - let omoConfigURL = shadowDir.appendingPathComponent("oh-my-opencode.json") - var omoConfig: [String: Any] - if let data = try? Data(contentsOf: omoConfigURL), - let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - omoConfig = existing - } else { - // Check if user has a config we symlinked, read from source - let userOmoConfig = userDir.appendingPathComponent("oh-my-opencode.json") - if let data = try? Data(contentsOf: userOmoConfig), - let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - omoConfig = existing - // Remove the symlink so we can write our own copy - try? fm.removeItem(at: omoConfigURL) - } else { - omoConfig = [:] - } - } - var tmuxConfig = (omoConfig["tmux"] as? [String: Any]) ?? [:] - var needsWrite = false - if tmuxConfig["enabled"] as? Bool != true { - tmuxConfig["enabled"] = true - needsWrite = true - } - // Lower the default min widths so agent panes spawn in normal-sized windows. - // oh-my-openagent defaults: main_pane_min_width=120, agent_pane_min_width=40, - // requiring 161+ columns. Most terminal windows are narrower. - if tmuxConfig["main_pane_min_width"] == nil { - tmuxConfig["main_pane_min_width"] = 60 - needsWrite = true - } - if tmuxConfig["agent_pane_min_width"] == nil { - tmuxConfig["agent_pane_min_width"] = 30 - needsWrite = true - } - if tmuxConfig["main_pane_size"] == nil { - tmuxConfig["main_pane_size"] = 50 - needsWrite = true - } - if needsWrite { - omoConfig["tmux"] = tmuxConfig - // Remove symlink if it exists (we need a real file) - if let attrs = try? fm.attributesOfItem(atPath: omoConfigURL.path), - attrs[.type] as? FileAttributeType == .typeSymbolicLink { - try? fm.removeItem(at: omoConfigURL) - } - let output = try JSONSerialization.data(withJSONObject: omoConfig, options: [.prettyPrinted, .sortedKeys]) - try output.write(to: omoConfigURL, options: .atomic) - } - - // Point OpenCode at the shadow config - setenv("OPENCODE_CONFIG_DIR", shadowDir.path, 1) - } - - func runOMO( - commandArgs: [String], - socketPath: String, - explicitPassword: String? - ) throws { - try runAgentWrapper( - commandArgs: commandArgs, - socketPath: socketPath, - explicitPassword: explicitPassword, - config: AgentWrapperConfig( - cmdLabel: "omo", - execName: "opencode", - resolveExecutable: { launcherEnvironment in - resolveExecutableInSearchPath("opencode", searchPath: launcherEnvironment["PATH"]) - }, - notFoundInstallHint: "npm install -g opencode-ai\n # or\n bun install -g opencode-ai", - // Ensure oh-my-opencode plugin is registered and installed. - preLaunch: { try self.omoEnsurePlugin() }, - createShimDir: { try self.createOMOShimDirectory() }, - beforeConfigure: { launcherEnvironment in - // oh-my-openagent needs the OpenCode API server running to attach - // subagent sessions to tmux panes. Prefer the historic default port - // when it is available, otherwise fall back to a free loopback port. - let openCodePort = omoResolvedPort( - commandArgs: commandArgs, - processEnvironment: launcherEnvironment - ) - return ["OPENCODE_PORT": openCodePort] - }, - tmuxPathPrefix: "programa-omo", - programaBinEnvVar: "PROGRAMA_OMO_PROGRAMA_BIN", - termOverrideEnvVar: "PROGRAMA_OMO_TERM", - extraEnvVars: { launcherEnvironment in - [(key: "OPENCODE_PORT", value: launcherEnvironment["OPENCODE_PORT"] ?? "")] - }, - needsClaudeNodeOptionsRestore: false, - buildLaunchArguments: { commandArgs, launcherEnvironment in - var effectiveArgs = commandArgs - if omoRequestedPort(from: commandArgs) == nil { - effectiveArgs.append("--port") - effectiveArgs.append(launcherEnvironment["OPENCODE_PORT"] ?? "") - } - return effectiveArgs - }, - execFailureHint: "Is opencode installed? Install with:\n npm install -g opencode-ai" - ) - ) - } - - // MARK: - programa omx (Oh My Codex) - - private func createOMXShimDirectory() throws -> URL { - try createVFlagTmuxShimDirectory( - directoryName: "omx-bin", - programaBinEnvVar: "PROGRAMA_OMX_PROGRAMA_BIN" - ) - } - - func runOMX( - commandArgs: [String], - socketPath: String, - explicitPassword: String? - ) throws { - try runAgentWrapper( - commandArgs: commandArgs, - socketPath: socketPath, - explicitPassword: explicitPassword, - config: AgentWrapperConfig( - cmdLabel: "omx", - execName: "omx", - resolveExecutable: { launcherEnvironment in - resolveExecutableInSearchPath("omx", searchPath: launcherEnvironment["PATH"]) - }, - notFoundInstallHint: "npm install -g oh-my-codex", - preLaunch: nil, - createShimDir: { try self.createOMXShimDirectory() }, - beforeConfigure: nil, - tmuxPathPrefix: "programa-omx", - programaBinEnvVar: "PROGRAMA_OMX_PROGRAMA_BIN", - termOverrideEnvVar: "PROGRAMA_OMX_TERM", - extraEnvVars: { _ in [] }, - needsClaudeNodeOptionsRestore: false, - buildLaunchArguments: { commandArgs, _ in commandArgs }, - execFailureHint: "Is oh-my-codex installed? Install with:\n npm install -g oh-my-codex" - ) - ) - } - - // MARK: - programa omc (Oh My Claude Code) - - private func createOMCShimDirectory() throws -> URL { - try createVFlagTmuxShimDirectory( - directoryName: "omc-bin", - programaBinEnvVar: "PROGRAMA_OMC_PROGRAMA_BIN" - ) - } - - func runOMC( - commandArgs: [String], - socketPath: String, - explicitPassword: String? - ) throws { - try runAgentWrapper( - commandArgs: commandArgs, - socketPath: socketPath, - explicitPassword: explicitPassword, - config: AgentWrapperConfig( - cmdLabel: "omc", - execName: "omc", - resolveExecutable: { launcherEnvironment in - resolveExecutableInSearchPath("omc", searchPath: launcherEnvironment["PATH"]) - }, - notFoundInstallHint: "npm install -g oh-my-claude-sisyphus", - preLaunch: nil, - createShimDir: { try self.createOMCShimDirectory() }, - beforeConfigure: nil, - tmuxPathPrefix: "programa-omc", - programaBinEnvVar: "PROGRAMA_OMC_PROGRAMA_BIN", - termOverrideEnvVar: "PROGRAMA_OMC_TERM", - extraEnvVars: { _ in [] }, - // omc wraps Claude Code, so it needs the same NODE_OPTIONS restore module. - needsClaudeNodeOptionsRestore: true, - buildLaunchArguments: { commandArgs, _ in commandArgs }, - execFailureHint: "Is oh-my-claude-sisyphus installed? Install with:\n npm install -g oh-my-claude-sisyphus" - ) - ) - } - /// Subcommand help text for AgentWrappers commands, split out of the /// central `subcommandUsage` switch (programa.swift) so each domain's /// help text lives next to its command descriptors. Refs #101. @@ -909,75 +326,6 @@ extension ProgramaCLI { programa claude-teams --continue programa claude-teams --model sonnet """) - case "omo": - return String(localized: "cli.omo.usage", defaultValue: """ - Usage: programa omo [opencode-args...] - - Launch OpenCode with oh-my-openagent in a programa-aware environment. - - oh-my-openagent orchestrates multiple AI models as specialized agents in - parallel. This command sets up a tmux shim so agent panes become native - programa splits with sidebar metadata and notifications. - - This command: - - sets a tmux-like environment so oh-my-openagent uses programa splits - - prepends a private tmux shim to PATH - - forwards all remaining arguments to opencode - - The tmux shim translates tmux window/pane commands into programa workspace - and split operations in the current programa session. - - Examples: - programa omo - programa omo --continue - programa omo --model claude-sonnet-4-6 - """) - case "omx": - return String(localized: "cli.omx.usage", defaultValue: """ - Usage: programa omx [omx-args...] - - Launch Oh My Codex (OMX) with native programa pane integration. - - OMX is a multi-agent orchestration layer for OpenAI Codex CLI. This - command sets up a tmux shim so OMX team mode, HUD, and agent panes - become native programa splits. - - This command: - - sets a tmux-like environment so OMX uses programa splits - - prepends a private tmux shim to PATH - - forwards all remaining arguments to omx - - Install: npm install -g oh-my-codex - - Examples: - programa omx - programa omx --madmax --high - programa omx team - """) - case "omc": - return String(localized: "cli.omc.usage", defaultValue: """ - Usage: programa omc [omc-args...] - - Launch Oh My Claude Code (OMC) with native programa pane integration. - - OMC is a multi-agent orchestration system for Claude Code with - specialized agents, smart model routing, and team pipelines. This - command sets up a tmux shim so OMC team mode and agent panes become - native programa splits. - - This command: - - sets a tmux-like environment so OMC uses programa splits - - prepends a private tmux shim to PATH - - injects NODE_OPTIONS restore module for Claude compatibility - - forwards all remaining arguments to omc - - Install: npm install -g oh-my-claude-sisyphus - - Examples: - programa omc - programa omc team 3:claude "implement feature" - programa omc --watch - """) default: return nil } diff --git a/CLI/CLI+Themes.swift b/CLI/CLI+Themes.swift index ed3d62cd0..cffb0c226 100644 --- a/CLI/CLI+Themes.swift +++ b/CLI/CLI+Themes.swift @@ -14,168 +14,7 @@ extension ProgramaCLI { let targetBundleIdentifier: String } - private enum ThemePickerTargetMode: String { - case both - case light - case dark - } - - private func shouldUseInteractiveThemePicker(jsonOutput: Bool) -> Bool { - guard !jsonOutput else { return false } - return isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1 - } - - private func runInteractiveThemes() throws { - guard let helperURL = bundledHelperURL(named: "ghostty") else { - throw CLIError(message: "Bundled Ghostty theme picker helper not found") - } - - let themeStore = TerminalThemeStore.live() - let selection = themeStore.currentSelection() - var environment = ProcessInfo.processInfo.environment - environment["PROGRAMA_THEME_PICKER_CONFIG"] = themeStore.managedConfigURL.path - environment["PROGRAMA_THEME_PICKER_BUNDLE_ID"] = currentProgramaAppBundleIdentifier() - ?? TerminalThemeStore.overrideBundleIdentifier - environment["PROGRAMA_THEME_PICKER_TARGET"] = defaultThemePickerTargetMode(current: selection).rawValue - environment["PROGRAMA_THEME_PICKER_COLOR_SCHEME"] = defaultAppearancePrefersDarkThemes() ? "dark" : "light" - if let light = selection.light { - environment["PROGRAMA_THEME_PICKER_INITIAL_LIGHT"] = light - } - if let dark = selection.dark { - environment["PROGRAMA_THEME_PICKER_INITIAL_DARK"] = dark - } - if let resourcesURL = bundledGhosttyResourcesURL() { - environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path - } - - try execInteractiveHelper( - executablePath: helperURL.path, - arguments: ["+list-themes"], - environment: environment - ) - } - - private func defaultThemePickerTargetMode(current: TerminalThemeSelection) -> ThemePickerTargetMode { - if let light = current.light, - let dark = current.dark, - light.caseInsensitiveCompare(dark) == .orderedSame { - return .both - } - return defaultAppearancePrefersDarkThemes() ? .dark : .light - } - - private func defaultAppearancePrefersDarkThemes() -> Bool { - let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) - let interfaceStyle = (globalDefaults?["AppleInterfaceStyle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - return interfaceStyle?.caseInsensitiveCompare("Dark") == .orderedSame - } - - private func bundledHelperURL(named helperName: String) -> URL? { - let fileManager = FileManager.default - guard let executableURL = resolvedExecutableURL() else { return nil } - - var candidates: [URL] = [ - executableURL.deletingLastPathComponent().appendingPathComponent(helperName, isDirectory: false) - ] - - var current = executableURL.deletingLastPathComponent().standardizedFileURL - while true { - if current.lastPathComponent == "Contents" { - candidates.append( - current - .appendingPathComponent("Resources", isDirectory: true) - .appendingPathComponent("bin", isDirectory: true) - .appendingPathComponent(helperName, isDirectory: false) - ) - } - - let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false) - let repoHelper = current - .appendingPathComponent("ghostty", isDirectory: true) - .appendingPathComponent("zig-out", isDirectory: true) - .appendingPathComponent("bin", isDirectory: true) - .appendingPathComponent(helperName, isDirectory: false) - if fileManager.fileExists(atPath: projectMarker.path), - fileManager.isExecutableFile(atPath: repoHelper.path) { - candidates.append(repoHelper) - break - } - - guard let parent = parentSearchURL(for: current) else { break } - current = parent - } - - return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) }) - } - - private func execInteractiveHelper( - executablePath: String, - arguments: [String], - environment: [String: String] - ) throws -> Never { - var argv = ([executablePath] + arguments).map { strdup($0) } - defer { - for item in argv { - free(item) - } - } - argv.append(nil) - - var envp = environment - .map { key, value in strdup("\(key)=\(value)") } - defer { - for item in envp { - free(item) - } - } - envp.append(nil) - - execve(executablePath, &argv, &envp) - let code = errno - throw CLIError(message: "Failed to launch interactive theme picker: \(String(cString: strerror(code)))") - } - - private func bundledGhosttyResourcesURL() -> URL? { - let fileManager = FileManager.default - guard let executableURL = resolvedExecutableURL() else { return nil } - - var current = executableURL.deletingLastPathComponent().standardizedFileURL - while true { - if current.lastPathComponent == "Contents" { - let candidate = current - .appendingPathComponent("Resources", isDirectory: true) - .appendingPathComponent("ghostty", isDirectory: true) - if fileManager.fileExists(atPath: candidate.path) { - return candidate - } - } - - let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false) - let repoResources = current - .appendingPathComponent("Resources", isDirectory: true) - .appendingPathComponent("ghostty", isDirectory: true) - if fileManager.fileExists(atPath: projectMarker.path), - fileManager.fileExists(atPath: repoResources.path) { - return repoResources - } - - guard let parent = parentSearchURL(for: current) else { break } - current = parent - } - - return Bundle.main.resourceURL?.appendingPathComponent("ghostty", isDirectory: true) - } - func runThemes(commandArgs: [String], jsonOutput: Bool) throws { - if commandArgs.isEmpty { - if shouldUseInteractiveThemePicker(jsonOutput: jsonOutput) { - try runInteractiveThemes() - return - } - try printThemesList(jsonOutput: jsonOutput) - return - } - guard let subcommand = commandArgs.first else { try printThemesList(jsonOutput: jsonOutput) return @@ -531,7 +370,14 @@ extension ProgramaCLI { if availableThemes.isEmpty { return trimmed } - throw CLIError(message: "Unknown theme '\(trimmed)'. Run 'programa themes' to list available themes.") + let needle = trimmed.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + let nearMatches = availableThemes.filter { + $0.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current).contains(needle) + }.prefix(5) + let suggestion = nearMatches.isEmpty + ? "" + : " Did you mean: \(nearMatches.map { "'\($0)'" }.joined(separator: ", "))?" + throw CLIError(message: "Unknown theme '\(trimmed)'.\(suggestion) Run 'programa themes' to list available themes.") } private func reloadThemesIfPossible() -> ThemeReloadStatus { @@ -597,14 +443,9 @@ extension ProgramaCLI { programa themes set --opacity <0-1> [--blur|--no-blur] [--font ] [--font-size ] programa themes clear - When run in a TTY, `programa themes` opens an interactive theme picker with - live app preview. Use `programa themes list` for a plain listing. - - The picker previews the selected theme across the running programa app and - lets you apply it to the light theme, dark theme, or both defaults. - Commands: list List available themes and mark the current light/dark defaults and appearance + (running `programa themes` with no subcommand does the same) set Set the same theme for both light and dark appearance set --light Set the light appearance theme set --dark Set the dark appearance theme diff --git a/CLI/CLICommandDispatcher.swift b/CLI/CLICommandDispatcher.swift index 3ae1e7cd0..961013898 100644 --- a/CLI/CLICommandDispatcher.swift +++ b/CLI/CLICommandDispatcher.swift @@ -166,27 +166,6 @@ struct CLICommandDispatcher { explicitPassword: socketPasswordArg ) return - case "omo": - try cli.runOMO( - commandArgs: commandArgs, - socketPath: resolveSocketPath(), - explicitPassword: socketPasswordArg - ) - return - case "omx": - try cli.runOMX( - commandArgs: commandArgs, - socketPath: resolveSocketPath(), - explicitPassword: socketPasswordArg - ) - return - case "omc": - try cli.runOMC( - commandArgs: commandArgs, - socketPath: resolveSocketPath(), - explicitPassword: socketPasswordArg - ) - return case "aside": try cli.runAside(arguments: commandArgs) return diff --git a/CLI/SocketClient.swift b/CLI/SocketClient.swift index 2b1edaee0..4d3f44dfd 100644 --- a/CLI/SocketClient.swift +++ b/CLI/SocketClient.swift @@ -322,7 +322,9 @@ final class SocketClient { socklen_t(MemoryLayout.size) ) } - guard result == 0 else { + // Darwin fails setsockopt with EINVAL once the peer has closed. The reply may + // already be buffered, and a read on a closed peer cannot block, so keep reading. + guard result == 0 || errno == EINVAL else { throw CLIError(message: "Failed to configure socket receive timeout") } } diff --git a/CLI/programa.swift b/CLI/programa.swift index 133fa9583..d645ea583 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -563,9 +563,6 @@ struct ProgramaCLI { ), CommandDescriptor(names: ["themes"], helpLines: ["themes [list|set|clear]"], connectionPolicy: .local, execute: nil), CommandDescriptor(names: ["claude-teams"], helpLines: ["claude-teams [claude-args...]"], connectionPolicy: .local, helpPolicy: .passthrough, execute: nil), - CommandDescriptor(names: ["omo"], helpLines: ["omo [opencode-args...]"], connectionPolicy: .local, helpPolicy: .passthrough, execute: nil), - CommandDescriptor(names: ["omx"], helpLines: ["omx [omx-args...]"], connectionPolicy: .local, helpPolicy: .passthrough, execute: nil), - CommandDescriptor(names: ["omc"], helpLines: ["omc [omc-args...]"], connectionPolicy: .local, helpPolicy: .passthrough, execute: nil), CommandDescriptor( names: ["codex"], helpLines: ["codex [--yes]"], @@ -5490,7 +5487,7 @@ struct ProgramaCLI { // Local commands are registered for unified lookup/help, but do not // acquire the app socket through the generic dispatcher. - case "shortcuts", "feedback", "themes", "claude-teams", "omo", "omx", "omc": + case "shortcuts", "feedback", "themes", "claude-teams": return case "codex", "claude", "opencode": // `-y` is a single-dash token, so it arrives as a positional after the subcommand. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0bc2177ef..02537567f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,12 +37,6 @@ in [docs/windows-testing.md](docs/windows-testing.md). ``` The script prints the `.app` path. Cmd-click to open, or pass `--launch` to open automatically. - If your local Zig doesn't match the version the ghostty submodule needs - (`scripts/required-zig-version.sh`), skip the Zig steps and build against the - existing GhosttyKit.xcframework: - ```bash - PROGRAMA_SKIP_ZIG_BUILD=1 ./scripts/reload.sh --tag my-feature - ``` If the xcframework is missing, `./scripts/download-prebuilt-ghosttykit.sh` fetches a prebuilt copy. diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 724158be8..17e93c777 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -813,18 +813,16 @@ "$(SRCROOT)/Resources/terminfo-overlay", "$(SRCROOT)/Resources/shell-integration", "$(SRCROOT)/ghostty/src/shell-integration/zsh/ghostty-integration", - "$(SRCROOT)/scripts/build-ghostty-cli-helper.sh", ); outputFileListPaths = ( ); outputPaths = ( "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/ghostty", - "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/bin/ghostty", ); name = "Copy Ghostty Resources"; runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nPROGRAMA_SHELL_DEST=\"${DEST}/shell-integration\"\nBIN_DEST=\"${DEST}/bin\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nTHEMES_OVERLAY=\"${SRCROOT}/Resources/ghostty/themes\"\nPROGRAMA_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nPROGRAMA_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nBUILD_GHOSTTY_HELPER=\"${SRCROOT}/scripts/build-ghostty-cli-helper.sh\"\nGHOSTTY_HELPER_DEST=\"${BIN_DEST}/ghostty\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any programa-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay Programa-specific themes on top of whatever ghostty themes were installed above.\n# This intentionally does not use --delete so we only add/patch specific theme files.\nif [ -d \"$THEMES_OVERLAY\" ]; then\n mkdir -p \"$GHOSTTY_DEST/themes\"\n rsync -a \"$THEMES_OVERLAY/\" \"$GHOSTTY_DEST/themes/\"\nfi\nif [ -d \"$PROGRAMA_SHELL_SRC\" ]; then\n mkdir -p \"$PROGRAMA_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$PROGRAMA_SHELL_SRC/.\" \"$PROGRAMA_SHELL_DEST/\"\nfi\nif [ -f \"$PROGRAMA_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$PROGRAMA_SHELL_DEST\"\n rsync -a \"$PROGRAMA_GHOSTTY_ZSH_SRC\" \"$PROGRAMA_SHELL_DEST/ghostty-integration.zsh\"\nfi\nif [ ! -x \"$BUILD_GHOSTTY_HELPER\" ]; then\n echo \"error: missing Ghostty CLI helper build script at $BUILD_GHOSTTY_HELPER\" >&2\n exit 1\nfi\nARCHS_LIST=\" ${ARCHS:-} \"\nHAS_ARM64=0\nHAS_X86_64=0\nGHOSTTY_HELPER_TARGET=\"\"\ncase \"$ARCHS_LIST\" in\n *\" arm64 \"*) HAS_ARM64=1 ;;\nesac\ncase \"$ARCHS_LIST\" in\n *\" x86_64 \"*) HAS_X86_64=1 ;;\nesac\nif [ \"$HAS_ARM64\" -eq 1 ] && [ \"$HAS_X86_64\" -eq 1 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --universal --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"aarch64-macos\"\nelif [ \"$HAS_X86_64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"x86_64-macos\"\nfi\nif [ -n \"$GHOSTTY_HELPER_TARGET\" ]; then\n \"$BUILD_GHOSTTY_HELPER\" --target \"$GHOSTTY_HELPER_TARGET\" --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 0 ] || [ \"$HAS_X86_64\" -eq 0 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --output \"$GHOSTTY_HELPER_DEST\"\nfi\nif [ ! -x \"$GHOSTTY_HELPER_DEST\" ]; then\n echo \"error: Ghostty CLI helper was not created at $GHOSTTY_HELPER_DEST\" >&2\n exit 1\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :ProgramaCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :ProgramaCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n"; + shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nPROGRAMA_SHELL_DEST=\"${DEST}/shell-integration\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nTHEMES_OVERLAY=\"${SRCROOT}/Resources/ghostty/themes\"\nPROGRAMA_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nPROGRAMA_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any programa-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay Programa-specific themes on top of whatever ghostty themes were installed above.\n# This intentionally does not use --delete so we only add/patch specific theme files.\nif [ -d \"$THEMES_OVERLAY\" ]; then\n mkdir -p \"$GHOSTTY_DEST/themes\"\n rsync -a \"$THEMES_OVERLAY/\" \"$GHOSTTY_DEST/themes/\"\nfi\nif [ -d \"$PROGRAMA_SHELL_SRC\" ]; then\n mkdir -p \"$PROGRAMA_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$PROGRAMA_SHELL_SRC/.\" \"$PROGRAMA_SHELL_DEST/\"\nfi\nif [ -f \"$PROGRAMA_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$PROGRAMA_SHELL_DEST\"\n rsync -a \"$PROGRAMA_GHOSTTY_ZSH_SRC\" \"$PROGRAMA_SHELL_DEST/ghostty-integration.zsh\"\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :ProgramaCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :ProgramaCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/README.md b/README.md index baf279f9b..1f1a2857a 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ See [Windows support](#windows-preview) and the - **Race agents against each other.** `programa race ""` fans one prompt across N agents (Claude Code, OpenCode, or Codex), each in its own isolated worktree/workspace, so you can compare approaches and merge the one you like. - **Native and fast.** Swift/AppKit with libghostty rendering, no Electron. Reads your existing `~/.config/ghostty/config` for themes, fonts, and colors. -More: named layouts (`programa layout save/apply`), a markdown viewer panel, instant agent splits (⌘D / ⌘⇧D, ⌘⇧C for Claude Code), the command palette (⌘⇧P), and a CLI plus Unix-socket JSON-RPC API scriptable end to end. +More: named layouts (`programa layout save/apply`), a markdown viewer panel, splits (⌘D / ⌘⇧D), a new Claude Code workspace in one keystroke (⌘⇧C), the command palette (⌘⇧P), and a CLI plus Unix-socket JSON-RPC API scriptable end to end. ## Install diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f6f05924f..833717617 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -4030,23 +4030,6 @@ } } }, - "cli.omo.usage": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Usage: programa omo [opencode-args...]\n\nLaunch OpenCode with oh-my-openagent in a Programa-aware environment.\n\noh-my-openagent orchestrates multiple AI models as specialized agents in\nparallel. This command sets up a tmux shim so agent panes become native\nPrograma splits with sidebar metadata and notifications.\n\nThis command:\n - sets a tmux-like environment so oh-my-openagent uses Programa splits\n - prepends a private tmux shim to PATH\n - forwards all remaining arguments to opencode\n\nThe tmux shim translates tmux window/pane commands into Programa workspace\nand split operations in the current Programa session.\n\nExamples:\n programa omo\n programa omo --continue\n programa omo --model claude-sonnet-4-6" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "使用方法: programa omo [opencode-args...]\n\nPrograma対応環境でoh-my-openagentを使ってOpenCodeを起動します。\n\noh-my-openagentは複数のAIモデルを専門エージェントとして並列に\nオーケストレーションします。このコマンドはtmuxシムを設定し、エージェントペインを\nサイドバーメタデータと通知を備えたネイティブなPrograma分割にします。\n\nこのコマンドは次の処理を行います:\n - oh-my-openagentがProgramaの分割を使用するようtmux互換環境を設定\n - 専用のtmuxシムをPATHの先頭に追加\n - 残りの引数をすべてopencodeに渡す\n\ntmuxシムは、tmuxのwindow/paneコマンドを、現在のProgramaセッションにおける\nワークスペースと分割の操作に変換します。\n\n例:\n programa omo\n programa omo --continue\n programa omo --model claude-sonnet-4-6" - } - } - } - }, "cli.uninstall.adminRequired": { "extractionState": "manual", "localizations": { @@ -8178,6 +8161,74 @@ } } }, + "sess.clipboardWrite.messageNamed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A program running in \"%@\" wants to replace your clipboard contents. Allow it to write to the clipboard?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「%@」で実行中のプログラムがクリップボードの内容を置き換えようとしています。クリップボードへの書き込みを許可しますか?" + } + } + } + }, + "sess.clipboardWrite.deny": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Deny" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拒否" + } + } + } + }, + "sess.clipboardWrite.alwaysAllow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Always allow until this terminal closes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このターミナルが閉じるまで常に許可" + } + } + } + }, + "sess.clipboardWrite.truncated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Showing the start of %1$lld bytes across %2$lld lines." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$lld バイト(%2$lld 行)の先頭部分を表示しています。" + } + } + } + }, "error.clipboardFolderPath": { "extractionState": "manual", "localizations": { @@ -15930,40 +15981,6 @@ } } }, - "cli.omx.usage": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Usage: programa omx [omx-args...]\n\nLaunch Oh My Codex (OMX) with native programa pane integration.\n\nOMX is a multi-agent orchestration layer for OpenAI Codex CLI. This\ncommand sets up a tmux shim so OMX team mode, HUD, and agent panes\nbecome native programa splits.\n\nThis command:\n - sets a tmux-like environment so OMX uses programa splits\n - prepends a private tmux shim to PATH\n - forwards all remaining arguments to omx\n\nInstall: npm install -g oh-my-codex\n\nExamples:\n programa omx\n programa omx --madmax --high\n programa omx team" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "使用方法: programa omx [omx-args...]\n\nprograma のペインとネイティブに連携した状態で Oh My Codex (OMX) を起動します。\n\nOMX は OpenAI Codex CLI 向けのマルチエージェント・オーケストレーション層です。\nこのコマンドは tmux シムを設定し、OMX のチームモード、HUD、エージェントの\nペインを programa のネイティブな分割として表示します。\n\nこのコマンドは次の処理を行います:\n - OMX が programa の分割を使うよう tmux 互換の環境を設定\n - 専用の tmux シムを PATH の先頭に追加\n - 残りの引数をすべて omx に渡す\n\nインストール: npm install -g oh-my-codex\n\n例:\n programa omx\n programa omx --madmax --high\n programa omx team" - } - } - } - }, - "cli.omc.usage": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Usage: programa omc [omc-args...]\n\nLaunch Oh My Claude Code (OMC) with native programa pane integration.\n\nOMC is a multi-agent orchestration system for Claude Code with\nspecialized agents, smart model routing, and team pipelines. This\ncommand sets up a tmux shim so OMC team mode and agent panes become\nnative programa splits.\n\nThis command:\n - sets a tmux-like environment so OMC uses programa splits\n - prepends a private tmux shim to PATH\n - injects NODE_OPTIONS restore module for Claude compatibility\n - forwards all remaining arguments to omc\n\nInstall: npm install -g oh-my-claude-sisyphus\n\nExamples:\n programa omc\n programa omc team 3:claude \"implement feature\"\n programa omc --watch" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "使用方法: programa omc [omc-args...]\n\nprograma のペインとネイティブに連携した状態で Oh My Claude Code (OMC) を起動します。\n\nOMC は Claude Code 向けのマルチエージェント・オーケストレーション・システムで、\n専門エージェント、スマートなモデルルーティング、チームパイプラインを備えています。\nこのコマンドは tmux シムを設定し、OMC のチームモードとエージェントのペインを\nprograma のネイティブな分割として表示します。\n\nこのコマンドは次の処理を行います:\n - OMC が programa の分割を使うよう tmux 互換の環境を設定\n - 専用の tmux シムを PATH の先頭に追加\n - Claude との互換性のため NODE_OPTIONS の復元モジュールを注入\n - 残りの引数をすべて omc に渡す\n\nインストール: npm install -g oh-my-claude-sisyphus\n\n例:\n programa omc\n programa omc team 3:claude \"implement feature\"\n programa omc --watch" - } - } - } - }, "externalOpen.title": { "extractionState": "manual", "localizations": { diff --git a/Resources/shell-integration/programa-bash-integration.bash b/Resources/shell-integration/programa-bash-integration.bash index f601eb5b0..dffb27c79 100644 --- a/Resources/shell-integration/programa-bash-integration.bash +++ b/Resources/shell-integration/programa-bash-integration.bash @@ -990,16 +990,15 @@ _programa_prompt_command() { ( # Skip git operations if not in a git repository to avoid TCC prompts git rev-parse --git-dir >/dev/null 2>&1 || exit 0 - local branch dirty=false workspace_id="" params + # No `git status` here: it runs the repository's configured commands + # (core.fsmonitor, filter drivers). The app computes dirty with its hardened probe. + local branch workspace_id="" params branch=$(git branch --show-current 2>/dev/null) workspace_id="$(_programa_relay_workspace_id)" || workspace_id="$PROGRAMA_TAB_ID" if [[ -n "$branch" ]]; then - local first - first=$(git status --porcelain -uno 2>/dev/null | head -1) - [[ -n "$first" ]] && dirty=true local branch_json branch_json="$(_programa_json_escape "$branch")" - params="{\"workspace_id\":\"$workspace_id\",\"surface_id\":\"$PROGRAMA_PANEL_ID\",\"branch\":\"$branch_json\",\"dirty\":$dirty}" + params="{\"workspace_id\":\"$workspace_id\",\"surface_id\":\"$PROGRAMA_PANEL_ID\",\"branch\":\"$branch_json\"}" _programa_relay_rpc "surface.report_git_branch" "$params" else params="{\"workspace_id\":\"$workspace_id\",\"surface_id\":\"$PROGRAMA_PANEL_ID\"}" diff --git a/Resources/shell-integration/programa-zsh-integration.zsh b/Resources/shell-integration/programa-zsh-integration.zsh index 0fe18edc8..c794733fe 100644 --- a/Resources/shell-integration/programa-zsh-integration.zsh +++ b/Resources/shell-integration/programa-zsh-integration.zsh @@ -566,15 +566,15 @@ _programa_report_git_branch_for_path() { # Skip git operations if not in a git repository to avoid TCC prompts git -C "$repo_path" rev-parse --git-dir >/dev/null 2>&1 || return 0 - local branch dirty=false first workspace_id="" params + # No `git status` here: it runs the repository's configured commands (core.fsmonitor, + # filter drivers). The app computes dirty with its hardened git probe. + local branch workspace_id="" params branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" workspace_id="$(_programa_relay_workspace_id)" || workspace_id="$PROGRAMA_TAB_ID" if [[ -n "$branch" ]]; then - first="$(git -C "$repo_path" status --porcelain -uno 2>/dev/null | head -1)" - [[ -n "$first" ]] && dirty=true local branch_json branch_json="$(_programa_json_escape "$branch")" - params="{\"workspace_id\":\"$workspace_id\",\"surface_id\":\"$PROGRAMA_PANEL_ID\",\"branch\":\"$branch_json\",\"dirty\":$dirty}" + params="{\"workspace_id\":\"$workspace_id\",\"surface_id\":\"$PROGRAMA_PANEL_ID\",\"branch\":\"$branch_json\"}" _programa_relay_rpc "surface.report_git_branch" "$params" else params="{\"workspace_id\":\"$workspace_id\",\"surface_id\":\"$PROGRAMA_PANEL_ID\"}" diff --git a/Sources/AppDelegate+SessionSnapshotPersistence.swift b/Sources/AppDelegate+SessionSnapshotPersistence.swift index 74e8d31d2..547e59734 100644 --- a/Sources/AppDelegate+SessionSnapshotPersistence.swift +++ b/Sources/AppDelegate+SessionSnapshotPersistence.swift @@ -122,8 +122,8 @@ extension AppDelegate { #endif // Preferences can synchronously notify main-queue observers; never write them // from the disk queue while quit is synchronously waiting for that queue. - Self.removeLegacyPersistedWindowGeometry() - if let persistedGeometryData { + if let persistedGeometryData, + UserDefaults.standard.data(forKey: Self.persistedWindowGeometryDefaultsKey) != persistedGeometryData { UserDefaults.standard.set(persistedGeometryData, forKey: Self.persistedWindowGeometryDefaultsKey) } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 3fa3d7696..83288860e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -912,6 +912,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser func applicationDidFinishLaunching(_ notification: Notification) { let env = ProcessInfo.processInfo.environment let isRunningUnderXCTest = isRunningUnderXCTest(env) + Self.removeLegacyPersistedWindowGeometry() DistributedNotificationCenter.default().addObserver( self, @@ -1399,14 +1400,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser SessionPersistenceStore.rotateIntoHistory(includeScrollback: false) } guard !didHandleExplicitOpenIntentAtStartup, SessionRestorePolicy.shouldAttemptRestore() else { return } - Self.removeLegacyPersistedWindowGeometry() startupSessionSnapshot = core.sessionSnapshots.loadWithHistoryFallback() } private func persistedWindowGeometry( defaults: UserDefaults = .standard ) -> PersistedWindowGeometry? { - Self.removeLegacyPersistedWindowGeometry(defaults: defaults) guard let data = defaults.data(forKey: Self.persistedWindowGeometryDefaultsKey) else { return nil } @@ -1422,8 +1421,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser display: SessionDisplaySnapshot?, defaults: UserDefaults = .standard ) { - Self.removeLegacyPersistedWindowGeometry(defaults: defaults) - guard let data = Self.encodedPersistedWindowGeometryData(frame: frame, display: display) else { + guard let data = Self.encodedPersistedWindowGeometryData(frame: frame, display: display), + defaults.data(forKey: Self.persistedWindowGeometryDefaultsKey) != data else { return } defaults.set(data, forKey: Self.persistedWindowGeometryDefaultsKey) @@ -1589,7 +1588,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser isApplyingStartupSessionRestore = false saveSessionSnapshot(includeScrollback: false) reconcileOrphanedEscrowedSessions() - ScrollbackPersistenceSettings.retryLegacyMigration() } /// Ends the shells of windows the user had closed before the previous run ended. @@ -1677,9 +1675,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } } - let ownedSockets = Set([SessionEscrowClient.legacySocketPath(), SessionEscrowClient.escrowSocketPath()]) + let ownedSocket = SessionEscrowClient.escrowSocketPath() let orphans = SessionWALStore.shared.escrowedSessionIds(excluding: known).filter { - $0.meta.escrowSocketPath.map(ownedSockets.contains) == true + $0.meta.escrowSocketPath == ownedSocket } guard !orphans.isEmpty else { dilog("escrow.reconcile", "found=0") @@ -4232,7 +4230,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser startupSessionSnapshot = nil didAttemptStartupSessionRestore = true } - ScrollbackPersistenceSettings.retryLegacyMigration() } private func externalOpenDirectories(from urls: [URL]) -> [String] { diff --git a/Sources/GhosttyApp.swift b/Sources/GhosttyApp.swift index a16f832c6..5ecd6c154 100644 --- a/Sources/GhosttyApp.swift +++ b/Sources/GhosttyApp.swift @@ -235,12 +235,48 @@ class GhosttyApp { @MainActor private static var clipboardConfirmations: [UInt: ClipboardConfirmation] = [:] + /// Terminals whose user chose "Always allow for this terminal" for OSC 52 writes. Held weakly + /// and never persisted, so the choice ends with the terminal. + @MainActor + private static let clipboardWriteAllowedTerminals = NSHashTable.weakObjects() + + @MainActor + static func allowsClipboardWrites(from terminal: TerminalSurface) -> Bool { + clipboardWriteAllowedTerminals.contains(terminal) + } + + @MainActor + static func rememberClipboardWriteAllowed(for terminal: TerminalSurface) { + clipboardWriteAllowedTerminals.add(terminal) + } + + /// How long a terminal's OSC 52 writes are denied without a sheet after the user clicks Deny, + /// so a program that retries in a loop cannot stack up prompts. + private static let clipboardWriteDenyWindow: TimeInterval = 30 + + @MainActor + private static var clipboardWriteDeniedUntil: [UUID: Date] = [:] + + @MainActor + static func denyClipboardWrites(from terminal: TerminalSurface, now: Date = Date()) { + clipboardWriteDeniedUntil = clipboardWriteDeniedUntil.filter { $0.value > now } + clipboardWriteDeniedUntil[terminal.id] = now.addingTimeInterval(clipboardWriteDenyWindow) + } + + @MainActor + static func isDenyingClipboardWrites(from terminal: TerminalSurface, now: Date = Date()) -> Bool { + clipboardWriteDeniedUntil[terminal.id].map { $0 > now } ?? false + } + @MainActor private final class ClipboardConfirmation { let contents: String let kind: ghostty_clipboard_request_e let surfaceIdentity: UInt? + let sourceName: String? weak var window: NSWindow? + let onAlwaysAllow: (@MainActor () -> Void)? + let onDeny: (@MainActor () -> Void)? var completion: ((String) -> Void)? var alert: NSAlert? var closeObserver: NSObjectProtocol? @@ -250,12 +286,18 @@ class GhosttyApp { kind: ghostty_clipboard_request_e, window: NSWindow, surfaceIdentity: UInt?, + sourceName: String?, + onAlwaysAllow: (@MainActor () -> Void)?, + onDeny: (@MainActor () -> Void)?, completion: @escaping (String) -> Void ) { self.contents = contents self.kind = kind self.window = window self.surfaceIdentity = surfaceIdentity + self.sourceName = sourceName + self.onAlwaysAllow = onAlwaysAllow + self.onDeny = onDeny self.completion = completion } @@ -292,19 +334,78 @@ class GhosttyApp { alert.informativeText = String(localized: "dialog.clipboardConfirmation.paste.message", defaultValue: "This clipboard text may execute commands when pasted into the terminal. Allow this paste?") } else if kind == GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE { alert.messageText = String(localized: "sess.clipboardWrite.title", defaultValue: "Allow Clipboard Write?") - alert.informativeText = String(localized: "sess.clipboardWrite.message", defaultValue: "A program running in this terminal wants to replace your clipboard contents. Allow it to write to the clipboard?") + if let sourceName { + alert.informativeText = String( + localized: "sess.clipboardWrite.messageNamed", + defaultValue: "A program running in \"\(sourceName)\" wants to replace your clipboard contents. Allow it to write to the clipboard?" + ) + } else { + alert.informativeText = String(localized: "sess.clipboardWrite.message", defaultValue: "A program running in this terminal wants to replace your clipboard contents. Allow it to write to the clipboard?") + } } else { alert.messageText = String(localized: "dialog.clipboardConfirmation.read.title", defaultValue: "Allow Clipboard Access?") alert.informativeText = String(localized: "dialog.clipboardConfirmation.read.message", defaultValue: "A program running in this terminal requested your clipboard contents. Allow it to read the clipboard?") } - let cancel = alert.addButton(withTitle: String(localized: "dialog.clipboardConfirmation.cancel", defaultValue: "Cancel")) + let isWrite = kind == GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE + let cancel = alert.addButton(withTitle: isWrite + ? String(localized: "sess.clipboardWrite.deny", defaultValue: "Deny") + : String(localized: "dialog.clipboardConfirmation.cancel", defaultValue: "Cancel")) cancel.keyEquivalent = "\r" let allow = alert.addButton(withTitle: String(localized: "dialog.clipboardConfirmation.allow", defaultValue: "Allow")) allow.keyEquivalent = "" + if isWrite { + alert.accessoryView = Self.writePreviewView(for: ClipboardWritePreview(contents)) + if onAlwaysAllow != nil { + let always = alert.addButton(withTitle: String( + localized: "sess.clipboardWrite.alwaysAllow", + defaultValue: "Always allow until this terminal closes" + )) + always.keyEquivalent = "" + } + } self.alert = alert alert.beginSheetModal(for: window) { [self] response in - finish(allow: response == .alertSecondButtonReturn) + if response == .alertThirdButtonReturn { onAlwaysAllow?() } + if response == .alertFirstButtonReturn { onDeny?() } + finish(allow: response == .alertSecondButtonReturn || response == .alertThirdButtonReturn) + } + } + + private static let previewWidth: CGFloat = 420 + + private static func writePreviewView(for preview: ClipboardWritePreview) -> NSView { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 6 + + func label(_ text: String, font: NSFont) -> NSTextField { + let field = NSTextField(wrappingLabelWithString: text) + field.font = font + field.isSelectable = true + field.preferredMaxLayoutWidth = previewWidth + field.widthAnchor.constraint(equalToConstant: previewWidth).isActive = true + return field + } + + let body = label( + preview.isTruncated ? preview.text + "\u{2026}" : preview.text, + font: .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) + ) + body.drawsBackground = true + body.backgroundColor = .textBackgroundColor + stack.addArrangedSubview(body) + if preview.isTruncated { + stack.addArrangedSubview(label( + String( + localized: "sess.clipboardWrite.truncated", + defaultValue: "Showing the start of \(preview.byteCount) bytes across \(preview.lineCount) lines." + ), + font: .systemFont(ofSize: NSFont.smallSystemFontSize) + )) } + stack.setFrameSize(stack.fittingSize) + return stack } } @@ -314,11 +415,20 @@ class GhosttyApp { kind: ghostty_clipboard_request_e? = nil, window: NSWindow? = nil, surface: ghostty_surface_t? = nil, + sourceName: String? = nil, + onAlwaysAllow: (@MainActor () -> Void)? = nil, + onDeny: (@MainActor () -> Void)? = nil, completion: @escaping (String) -> Void ) { let identity = surface.map { UInt(bitPattern: $0) } - if let identity { - clipboardConfirmations[identity]?.finish(allow: false) + if let identity, let pending = clipboardConfirmations[identity] { + // A program can send OSC 52 in a loop; replacing the sheet on every request would + // keep it out from under the user's click, so later requests are denied instead. + if kind == GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE || kind == GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ { + completion("") + return + } + pending.finish(allow: false) } guard let window, let kind, kind == GHOSTTY_CLIPBOARD_REQUEST_PASTE || kind == GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ @@ -331,6 +441,9 @@ class GhosttyApp { kind: kind, window: window, surfaceIdentity: identity, + sourceName: sourceName, + onAlwaysAllow: onAlwaysAllow, + onDeny: onDeny, completion: completion ) if let identity { clipboardConfirmations[identity] = request } @@ -605,13 +718,26 @@ class GhosttyApp { let callbackContext = GhosttyApp.callbackContext(from: userdata) let presentConfirmation = { MainActor.assumeIsolated { () -> Void in - let window = callbackContext.flatMap { + let terminal = callbackContext.flatMap { GhosttyApp.resolveTerminalSurface(tabId: $0.tabId, surfaceId: $0.surfaceId) - }?.hostedView.window + } + if let terminal, GhosttyApp.allowsClipboardWrites(from: terminal) { + GhosttyPasteboardHelper.writeString(chosen, to: location) + return + } + if let terminal, GhosttyApp.isDenyingClipboardWrites(from: terminal) { return } GhosttyApp.handleClipboardConfirmation( contents: chosen, kind: GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, - window: window + window: terminal?.hostedView.window, + surface: terminal?.liveSurfaceForGhosttyAccess(reason: "clipboard.write"), + sourceName: callbackContext.flatMap { GhosttyApp.clipboardSourceName(tabId: $0.tabId) }, + onAlwaysAllow: terminal.map { terminal in + { GhosttyApp.rememberClipboardWriteAllowed(for: terminal) } + }, + onDeny: terminal.map { terminal in + { GhosttyApp.denyClipboardWrites(from: terminal) } + } ) { confirmedContent in guard !confirmedContent.isEmpty else { return } GhosttyPasteboardHelper.writeString(confirmedContent, to: location) @@ -682,6 +808,12 @@ class GhosttyApp { return } + loadInlineGhosttyConfig( + "clipboard-write = ask", + into: fallbackConfig, + prefix: "programa-clipboard-write", + logLabel: "clipboard write default (fallback)" + ) loadInlineGhosttyConfig( "macos-background-from-layer = true", into: fallbackConfig, @@ -754,9 +886,7 @@ class GhosttyApp { ghostty_config_load_file(config, path) } } catch { - #if DEBUG - dlog("ghostty.config.inlineLoad.failed label=\(logLabel) error=\(error.localizedDescription)") - #endif + dilog("ghostty.config", "inlineLoad.failed label=\(logLabel) error=\(error.localizedDescription)") } } @@ -788,6 +918,15 @@ class GhosttyApp { logLabel: "card layout padding default" ) } + // Ghostty defaults `clipboard-write` to allow, which lets any program in a pane + // replace the clipboard silently. Loaded BEFORE the user's config files so a + // user-set `clipboard-write` still wins. + loadInlineGhosttyConfig( + "clipboard-write = ask", + into: config, + prefix: "programa-clipboard-write", + logLabel: "clipboard write default" + ) ghostty_config_load_default_files(config) loadLegacyGhosttyConfigIfNeeded(config) ghostty_config_load_recursive_files(config) @@ -1732,11 +1871,43 @@ class GhosttyApp { return workspace.panels[surfaceId] as? TerminalPanel } + /// Workspace title for the clipboard sheet, cut to one short line because a program can set it. + @MainActor + private static func clipboardSourceName(tabId: UUID?) -> String? { + guard let tabId, + let app = AppDelegate.shared, + let title = (app.tabManagerFor(tabId: tabId) ?? app.tabManager)?.titleForTab(tabId), + let firstLine = title.split(whereSeparator: \.isNewline).first else { return nil } + let name = String(firstLine.prefix(80)).trimmingCharacters(in: .whitespaces) + return name.isEmpty ? nil : name + } + @MainActor private static func resolveTerminalSurface(tabId: UUID?, surfaceId: UUID?) -> TerminalSurface? { resolveTerminalPanel(tabId: tabId, surfaceId: surfaceId)?.surface } + nonisolated private static let reportedDirectoryValidationQueue = DispatchQueue( + label: "com.darkroom.programa.reported-directory-validation", qos: .utility + ) + + /// A terminal-reported working directory (OSC 7) is untrusted: the background repository + /// probes run in whatever it names. Accepts only an absolute path without `.` or `..` + /// components to an existing directory, including the `file://` form `TabManager` normalizes. + nonisolated static func validatedReportedDirectory(_ reported: String) -> String? { + var path = reported.trimmingCharacters(in: .whitespacesAndNewlines) + if path.hasPrefix("file://") { + guard let url = URL(string: path), !url.path.isEmpty else { return nil } + path = url.path + } + guard path.hasPrefix("/"), + !path.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }) else { return nil } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), + isDirectory.boolValue else { return nil } + return path + } + @MainActor private static func resolveSurfaceView(tabId: UUID?, surfaceId: UUID?) -> GhosttyNSView? { resolveTerminalPanel(tabId: tabId, surfaceId: surfaceId)?.hostedView.surfaceView @@ -2089,13 +2260,18 @@ class GhosttyApp { case GHOSTTY_ACTION_PWD: guard let tabId = callbackTabId, let surfaceId = callbackSurfaceId else { return true } - let pwd = action.action.pwd.pwd.flatMap { String(cString: $0) } ?? "" - DispatchQueue.main.async { - AppDelegate.shared?.tabManagerFor(tabId: tabId)?.updateSurfaceDirectory( - tabId: tabId, - surfaceId: surfaceId, - directory: pwd - ) + let reportedPwd = action.action.pwd.pwd.flatMap { String(cString: $0) } ?? "" + // The existence check can block on a stalled network mount, so it runs on a + // serial queue; serial-then-main keeps each terminal's updates in order. + Self.reportedDirectoryValidationQueue.async { + guard let pwd = Self.validatedReportedDirectory(reportedPwd) else { return } + DispatchQueue.main.async { + AppDelegate.shared?.tabManagerFor(tabId: tabId)?.updateSurfaceDirectory( + tabId: tabId, + surfaceId: surfaceId, + directory: pwd + ) + } } return true case GHOSTTY_ACTION_PROGRESS_REPORT: diff --git a/Sources/GhosttyTerminalSupport.swift b/Sources/GhosttyTerminalSupport.swift index f3ee9c6fe..b1783983a 100644 --- a/Sources/GhosttyTerminalSupport.swift +++ b/Sources/GhosttyTerminalSupport.swift @@ -93,6 +93,31 @@ func programaScalarHex(_ value: String?) -> String { } #endif +/// The part of an OSC 52 clipboard write the confirmation sheet shows: the first lines and +/// characters, plus the full size when something was cut. +struct ClipboardWritePreview: Equatable { + static let maxLines = 8 + static let maxCharacters = 500 + + let text: String + let isTruncated: Bool + let byteCount: Int + let lineCount: Int + + init(_ contents: String) { + var lines: [Substring] = [] + contents.enumerateSubstrings(in: contents.startIndex..., options: .byLines) { line, _, _, _ in + if let line { lines.append(Substring(line)) } + } + byteCount = contents.utf8.count + lineCount = lines.count + let shownLines = lines.prefix(Self.maxLines).joined(separator: "\n") + let shown = String(shownLines.prefix(Self.maxCharacters)) + text = shown + isTruncated = lines.count > Self.maxLines || shown.count < shownLines.count + } +} + enum GhosttyPasteboardHelper { private static let selectionPasteboard = NSPasteboard( name: NSPasteboard.Name("com.mitchellh.ghostty.selection") diff --git a/Sources/GitMetadataProber.swift b/Sources/GitMetadataProber.swift index 9e5eae621..143c681d8 100644 --- a/Sources/GitMetadataProber.swift +++ b/Sources/GitMetadataProber.swift @@ -443,7 +443,8 @@ extension CanonicalSubprocessRunner { /// `core.fsmonitor` names a command git would run, `safe.bareRepository=explicit` stops a /// bare repository committed inside a checkout from being discovered, and /// `--no-optional-locks` keeps a background probe from taking `index.lock` while the user - /// runs git themselves. + /// runs git themselves. Filter drivers need per-repository handling, see + /// `automaticGitEnvironment(directory:executableURL:)`. static let automaticGitOptions: [String] = [ "--no-optional-locks", "-c", "core.fsmonitor=false", @@ -451,25 +452,214 @@ extension CanonicalSubprocessRunner { "-c", "safe.bareRepository=explicit", ] - /// Environment for automatic git commands and for `gh`, which runs git internally where - /// command-line `-c` cannot reach. `GIT_CONFIG_COUNT` entries apply at command-line scope, - /// the same as `-c`. - static let automaticGitEnvironment: [String: String] = [ - "GIT_OPTIONAL_LOCKS": "0", - "GIT_CONFIG_COUNT": "3", - "GIT_CONFIG_KEY_0": "core.fsmonitor", - "GIT_CONFIG_VALUE_0": "false", - "GIT_CONFIG_KEY_1": "core.untrackedCache", - "GIT_CONFIG_VALUE_1": "false", - "GIT_CONFIG_KEY_2": "safe.bareRepository", - "GIT_CONFIG_VALUE_2": "explicit", + private static let automaticGitConfig: [(key: String, value: String)] = [ + ("core.fsmonitor", "false"), + ("core.untrackedCache", "false"), + ("safe.bareRepository", "explicit"), ] + /// Numbered config environment entries apply at command-line scope, the same as `-c`, but + /// take the key verbatim, so a filter driver name containing `=` is still addressable. + static func gitConfigEnvironment(_ entries: [(key: String, value: String)]) -> [String: String] { + var environment = ["GIT_OPTIONAL_LOCKS": "0", "GIT_CONFIG_COUNT": String(entries.count)] + for (index, entry) in entries.enumerated() { + environment["GIT_CONFIG_KEY_\(index)"] = entry.key + environment["GIT_CONFIG_VALUE_\(index)"] = entry.value + } + return environment + } + + /// Environment for automatic git commands without a repository, and for `gh`, which runs + /// git internally where command-line `-c` cannot reach. + static let automaticGitEnvironment: [String: String] = gitConfigEnvironment(automaticGitConfig) + + /// `automaticGitEnvironment` plus overrides that disable the repository-local filter + /// drivers in `directory`. `git status` and `git diff` run `filter..clean` and + /// `.process` for every path whose `.gitattributes` entry names the driver, and both the + /// attributes and the driver live in a repository an extracted archive can pre-seed. + /// Returns nil when the repository's filter config could not be read, in which case the + /// caller must not run git. + static func automaticGitEnvironment(directory: String, executableURL: URL? = nil) -> [String: String]? { + guard let overrides = repositoryLocalFilterOverrides(directory: directory, executableURL: executableURL) else { + return nil + } + return gitConfigEnvironment(automaticGitConfig + overrides) + } + + /// Result for a git command refused because the repository's filter config was unreadable. + static let filterConfigUnavailableResult = CanonicalSubprocessResult( + stdout: nil, + stderr: nil, + exitStatus: nil, + outcome: .launchError("repository filter config unavailable") + ) + + /// Config that disables every filter driver defined or redefined by repository-local config + /// (`local` and `worktree` scope). Drivers that only global/system config defines, such as + /// Git LFS, keep working. A repository that sets a driver to exactly the global value + /// (`git lfs install --local`) is left alone. Git LFS reads `lfs.extension..clean` from + /// the repository's config and runs it from inside the `filter.lfs` driver, so any + /// repository-local `lfs.extension.*` key also disables `filter.lfs`. Input is the output of + /// `git config -z --show-scope --get-regexp '^(filter|lfs)\.'`: `scope NUL key LF value NUL` + /// records in precedence order. + static func untrustedFilterDriverConfig( + fromShowScopeOutput output: String + ) -> [(key: String, value: String)] { + struct Entry { + let scope: String + let value: String? + } + let prefix = "filter." + let lfsExtensionPrefix = "lfs.extension." + var hasRepositoryLocalLFSExtension = false + func isRepositoryLocal(_ scope: String) -> Bool { scope == "local" || scope == "worktree" } + let tokens = output.split(separator: "\0", omittingEmptySubsequences: false) + var driverOrder: [String] = [] + var variablesByDriver: [String: [String: [Entry]]] = [:] + var index = 0 + while index + 1 < tokens.count { + let scope = String(tokens[index]) + let record = tokens[index + 1] + index += 2 + let key: Substring + let value: String? + if let newline = record.firstIndex(of: "\n") { + key = record[.. key.index(key.startIndex, offsetBy: prefix.count) else { continue } + let driver = String(key[key.index(key.startIndex, offsetBy: prefix.count).. [(key: String, value: String)]? { + let fingerprint = repositoryConfigFingerprint(directory: directory) + if let cached = filterOverridesCache.value(for: directory, fingerprint: fingerprint, now: Date()) { + return cached + } + let result = run( + executable: "git", + arguments: automaticGitArguments(["config", "-z", "--show-scope", "--get-regexp", "^(filter|lfs)\\."]), + currentDirectory: directory, + timeout: 5, + stdoutLimit: 1 << 20, + stderrLimit: 4096, + executableURL: executableURL, + environmentOverrides: automaticGitEnvironment + ) + // Exit 1: no filter keys. 128: not a repository; the command that follows fails on its own. + guard result.outcome == .exited, + let status = result.exitStatus, + status == 0 || status == 1 || status == 128 else { return nil } + let overrides = status == 0 ? untrustedFilterDriverConfig(fromShowScopeOutput: result.stdout ?? "") : [] + filterOverridesCache.store(overrides, for: directory, fingerprint: fingerprint, now: Date()) + return overrides + } + + /// Identity of every file git's repository discovery could read config from, walking up from + /// `directory` to the nearest `.git`: each level's `.git` and `config` (a bare repository). + /// A cached answer is reused only while this is unchanged, so a repository that appears in + /// or above the directory (an extracted archive) right after a probe saw none is read fresh. + static func repositoryConfigFingerprint(directory: String) -> String { + func identity(_ path: String) -> String? { + var info = stat() + guard stat(path, &info) == 0 else { return nil } + return "\(path):\(info.st_ino):\(info.st_ctimespec.tv_sec).\(info.st_ctimespec.tv_nsec)" + } + var parts: [String] = [] + // Git discovers on the physical path, so walk the same ancestors it does. + var path = directory + if let resolved = realpath(directory, nil) { + path = String(cString: resolved) + free(resolved) + } + while true { + if let config = identity((path as NSString).appendingPathComponent("config")) { parts.append(config) } + let dotGit = (path as NSString).appendingPathComponent(".git") + if let dotGitIdentity = identity(dotGit) { + parts.append(dotGitIdentity) + if let config = identity(dotGit + "/config") { parts.append(config) } + break + } + let parent = (path as NSString).deletingLastPathComponent + if parent.isEmpty || parent == path { break } + path = parent + } + return parts.joined(separator: "\n") + } + + /// One `git config` read serves the several git calls a single probe cycle makes in the + /// same directory. + private static let filterOverridesCache = FilterOverridesCache() + + private final class FilterOverridesCache: @unchecked Sendable { + private static let lifetime: TimeInterval = 10 + private static let limit = 64 + private let lock = NSLock() + private var entries: [String: (storedAt: Date, fingerprint: String, overrides: [(key: String, value: String)])] = [:] + + func value(for directory: String, fingerprint: String, now: Date) -> [(key: String, value: String)]? { + lock.lock() + defer { lock.unlock() } + guard let entry = entries[directory], + entry.fingerprint == fingerprint, + now.timeIntervalSince(entry.storedAt) < Self.lifetime else { + return nil + } + return entry.overrides + } + + func store(_ overrides: [(key: String, value: String)], for directory: String, fingerprint: String, now: Date) { + lock.lock() + defer { lock.unlock() } + if entries.count >= Self.limit { entries.removeAll() } + entries[directory] = (now, fingerprint, overrides) + } + } + static func automaticGitArguments(_ arguments: [String]) -> [String] { automaticGitOptions + arguments } - /// Runs `git` with `automaticGitOptions` before `arguments` and `automaticGitEnvironment`. + /// Runs `git` with `automaticGitOptions` before `arguments` and the repository's + /// `automaticGitEnvironment`. static func runAutomaticGit( arguments: [String], currentDirectory: String, @@ -478,7 +668,10 @@ extension CanonicalSubprocessRunner { stderrLimit: Int, executableURL: URL? = nil ) -> CanonicalSubprocessResult { - run( + guard let environment = automaticGitEnvironment(directory: currentDirectory, executableURL: executableURL) else { + return filterConfigUnavailableResult + } + return run( executable: "git", arguments: automaticGitArguments(arguments), currentDirectory: currentDirectory, @@ -486,7 +679,7 @@ extension CanonicalSubprocessRunner { stdoutLimit: stdoutLimit, stderrLimit: stderrLimit, executableURL: executableURL, - environmentOverrides: automaticGitEnvironment + environmentOverrides: environment ) } } @@ -627,7 +820,13 @@ struct GitMetadataProber { ) } - let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"]) + // `--ignore-submodules=dirty`: git would otherwise run a child `git status` inside each + // populated submodule, which reads the submodule's own config and bypasses this + // process's overrides. Moved gitlink commits are still reported. + let statusOutput = runGitCommand( + directory: directory, + arguments: ["status", "--porcelain", "-uno", "--ignore-submodules=dirty"] + ) let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) let pullRequest = workspacePullRequestSnapshot(directory: directory, branch: branch) return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty, pullRequest: pullRequest) @@ -996,19 +1195,26 @@ struct GitMetadataProber { return result.stdout } - /// Every probe here is automatic, so git gets `automaticGitOptions` and both git and `gh` - /// get `automaticGitEnvironment`. + /// Every probe here is automatic, so git gets `automaticGitOptions` and the repository's + /// filter-driver overrides, and `gh` gets `automaticGitEnvironment`. private nonisolated static func runCommandResult( directory: String, executable: String, arguments: [String], timeout: TimeInterval? = nil ) -> CanonicalSubprocessResult { - CanonicalSubprocessRunner.run( + if executable == "git" { + return CanonicalSubprocessRunner.runAutomaticGit( + arguments: arguments, + currentDirectory: directory, + timeout: timeout, + stdoutLimit: commandStdoutLimit, + stderrLimit: commandStderrLimit + ) + } + return CanonicalSubprocessRunner.run( executable: executable, - arguments: executable == "git" - ? CanonicalSubprocessRunner.automaticGitArguments(arguments) - : arguments, + arguments: arguments, currentDirectory: directory, timeout: timeout, stdoutLimit: commandStdoutLimit, diff --git a/Sources/GitWorktreeManager.swift b/Sources/GitWorktreeManager.swift index 2b220654b..36c1cd716 100644 --- a/Sources/GitWorktreeManager.swift +++ b/Sources/GitWorktreeManager.swift @@ -134,12 +134,19 @@ struct GitWorktreeManager { listWorktrees(repoRoot: repoRoot)?.first { $0.branch == branch } } - nonisolated static func branchExistsLocally(_ branch: String, repoRoot: String) -> Bool { - let result = runGitCommand( + /// nil when git could not answer (refused, timed out, or failed for another reason than the + /// ref being absent). Callers must treat nil as "unknown", never as "absent": acting on a + /// false "absent" can delete a branch the user created. + nonisolated static func branchExistsLocally(_ branch: String, repoRoot: String) -> Bool? { + guard let result = runGitCommand( directory: repoRoot, arguments: ["show-ref", "--verify", "--quiet", "refs/heads/\(branch)"] - ) - return result?.exitStatus == 0 + ), !result.timedOut else { return nil } + switch result.exitStatus { + case 0: return true + case 1: return false + default: return nil + } } // MARK: - Add @@ -164,7 +171,9 @@ struct GitWorktreeManager { // read as a path or ref name, never as an option. `-b ` is an option value and // is safe before it. var arguments = ["worktree", "add"] - let branchExisted = branchExistsLocally(branch, repoRoot: repoRoot) + guard let branchExisted = branchExistsLocally(branch, repoRoot: repoRoot) else { + return .gitCommandFailed(message: "Could not determine whether branch '\(branch)' already exists") + } if branchExisted { arguments.append(contentsOf: ["--", path, branch]) } else { @@ -195,10 +204,12 @@ struct GitWorktreeManager { /// Deletes local branch `branch` when no worktree has it checked out. Used to undo a /// branch that this process created with `worktree add -b` when the add (or the caller's /// follow-up) failed. Callers must only pass a branch they created: this does not check - /// whether the branch holds commits. Returns whether the branch is gone afterwards. + /// whether the branch holds commits. Returns whether the branch is gone afterwards; false + /// without touching anything when git cannot say whether the branch exists. @discardableResult nonisolated static func deleteBranchIfUnused(_ branch: String, repoRoot: String) -> Bool { - guard branchExistsLocally(branch, repoRoot: repoRoot) else { return true } + guard let exists = branchExistsLocally(branch, repoRoot: repoRoot) else { return false } + guard exists else { return true } guard worktreeEntry(forBranch: branch, repoRoot: repoRoot) == nil else { return false } let result = runGitCommand( directory: repoRoot, diff --git a/Sources/ReviewDiffProber.swift b/Sources/ReviewDiffProber.swift index 3fcfa83bc..7632ae606 100644 --- a/Sources/ReviewDiffProber.swift +++ b/Sources/ReviewDiffProber.swift @@ -141,7 +141,7 @@ struct ReviewDiffProber { switch runCommandResult( directory: repoRoot, executable: "git", - arguments: ["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--find-renames"] + diffRangeArgs + arguments: ["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--ignore-submodules=dirty", "--find-renames"] + diffRangeArgs ) { case .failure(let error): return .failure(error) @@ -163,7 +163,7 @@ struct ReviewDiffProber { switch runCommandResult( directory: repoRoot, executable: "git", - arguments: ["diff", "--numstat", "--no-ext-diff", "--no-textconv", "--find-renames"] + diffRangeArgs + arguments: ["diff", "--numstat", "--no-ext-diff", "--no-textconv", "--ignore-submodules=dirty", "--find-renames"] + diffRangeArgs ) { case .failure(let error): return .failure(error) diff --git a/Sources/SessionEscrow.swift b/Sources/SessionEscrow.swift index 1397c8d05..6030bb6e8 100644 --- a/Sources/SessionEscrow.swift +++ b/Sources/SessionEscrow.swift @@ -756,103 +756,6 @@ enum UnixDomainFDPassing { // MARK: - App-side client -/// Keeps the retrieved master alive through deferred/failed surface creation. -/// The registry deliberately owns failed migrations until Retry can hand them -/// to a holder; deinitializing a failed panel must not close the last master. -final class SessionEscrowRetainedDescriptor: @unchecked Sendable { - struct Destination { - let sessionId: String - let tokenHex: String - let socketPath: String - } - - private static let registryLock = NSLock() - private static var registry: [String: SessionEscrowRetainedDescriptor] = [:] - private let lock = NSLock() - private var masterFD: Int32 - private var destination: Destination - private var recoveryPending: Bool - let sessionId: String - let childPID: Int32 - - private init(sessionId: String, masterFD: Int32, childPID: Int32, tokenHex: String, socketPath: String, recoveryPending: Bool) { - self.sessionId = sessionId - self.masterFD = masterFD - self.childPID = childPID - self.recoveryPending = recoveryPending - destination = Destination(sessionId: sessionId, tokenHex: tokenHex, socketPath: socketPath) - } - - static func retain(sessionId: String, masterFD: Int32, childPID: Int32, tokenHex: String, socketPath: String, recoveryPending: Bool = true) -> SessionEscrowRetainedDescriptor { - registryLock.lock() - defer { registryLock.unlock() } - if let existing = registry[sessionId] { - close(masterFD) - return existing - } - let owner = SessionEscrowRetainedDescriptor( - sessionId: sessionId, masterFD: masterFD, childPID: childPID, tokenHex: tokenHex, socketPath: socketPath, - recoveryPending: recoveryPending - ) - registry[sessionId] = owner - return owner - } - - static func pending() -> [SessionEscrowRetainedDescriptor] { - registryLock.lock() - defer { registryLock.unlock() } - return Array(registry.values) - } - - func duplicate() -> Int32? { - lock.lock() - defer { lock.unlock() } - guard masterFD >= 0 else { return nil } - // F_DUPFD_CLOEXEC, not dup(): the copy must not leak into children - // spawned by a concurrent fork/exec. - let fd = fcntl(masterFD, F_DUPFD_CLOEXEC, 0) - return fd >= 0 ? fd : nil - } - - func markRecoveryPending() { - lock.lock() - recoveryPending = true - lock.unlock() - DispatchQueue.main.async { ScrollbackPersistenceSettings.retryLegacyMigration() } - } - - var canRecoverWithoutPanel: Bool { - lock.lock() - defer { lock.unlock() } - return recoveryPending && masterFD >= 0 - } - - func recordDestination(sessionId: String, tokenHex: String, socketPath: String) { - lock.lock() - destination = Destination(sessionId: sessionId, tokenHex: tokenHex, socketPath: socketPath) - lock.unlock() - } - - func pendingDestination() -> Destination { - lock.lock() - defer { lock.unlock() } - return destination - } - - func releaseAfterOwnershipTransfer() { - Self.registryLock.lock() - defer { Self.registryLock.unlock() } - lock.lock() - if masterFD >= 0 { - close(masterFD) - masterFD = -1 - } - lock.unlock() - if Self.registry[sessionId] === self { Self.registry.removeValue(forKey: sessionId) } - DispatchQueue.main.async { ScrollbackPersistenceSettings.retryLegacyMigration() } - } -} - /// App-side singleton: owns the one persistent connection to the holder /// for this app instance, lazily connecting (and spawning the holder if /// nothing answers) on the first surface's escrow attempt. All socket I/O @@ -860,162 +763,6 @@ final class SessionEscrowRetainedDescriptor: @unchecked Sendable { /// the full protocol/degradation contract. final class SessionEscrowClient { static let shared = SessionEscrowClient() - private static let migrationQueue = DispatchQueue(label: "com.darkroom.programa.scrollback-migration", qos: .utility) - - private static let unreadableLegacyMetaLock = NSLock() - private static var loggedUnreadableLegacyMeta: Set = [] - - /// Sessions still claimed by the v1 holder socket. An unreadable - /// `meta.json` is skipped (logged once per path), never a reason to - /// fail the whole scan: one corrupt file must not latch the migration - /// into a permanent "legacy sessions pending" state. Only a failure to - /// list the sessions directory throws. - static func legacySessions( - root rootOverride: URL? = nil, - legacySocketPath socketPathOverride: String? = nil - ) throws -> [(sessionId: String, meta: SessionWALMeta)] { - guard let root = rootOverride ?? SessionWALPaths.sessionsRootURL() else { throw CocoaError(.fileNoSuchFile) } - guard FileManager.default.fileExists(atPath: root.path) else { return [] } - let socketPath = socketPathOverride ?? legacySocketPath() - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - var result: [(sessionId: String, meta: SessionWALMeta)] = [] - for entry in try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) { - guard UUID(uuidString: entry.lastPathComponent) != nil else { continue } - let paths = SessionWALPaths(sessionDirectory: entry) - guard FileManager.default.fileExists(atPath: paths.metaURL.path) else { continue } - let meta: SessionWALMeta - do { - meta = try decoder.decode(SessionWALMeta.self, from: Data(contentsOf: paths.metaURL)) - } catch { - unreadableLegacyMetaLock.lock() - let firstTime = loggedUnreadableLegacyMeta.insert(paths.metaURL.path).inserted - unreadableLegacyMetaLock.unlock() - if firstTime { - dilog("escrow.migration", "skip_unreadable_meta session=\(entry.lastPathComponent.prefix(8)) error=\(error)") - } - continue - } - if meta.escrowed == true, meta.escrowSocketPath == socketPath { - result.append((entry.lastPathComponent, meta)) - } - } - return result - } - - static func hasLegacySessions() -> Bool { - // Only a directory-listing failure lands here; unreadable entries - // are skipped inside `legacySessions`. - guard let legacy = try? legacySessions() else { return true } - return !legacy.isEmpty || !SessionEscrowRetainedDescriptor.pending().isEmpty - } - - static func migrateLegacySessions(completion: @escaping (Bool) -> Void) { - migrationQueue.async { - // An early re-escrow may still be acknowledging ownership when a - // deferred native creation fails. Resolve that attempt first. - shared.queue.sync {} - var success = true - guard let candidates = try? legacySessions() else { completion(false); return } - let retainedIds = Set(SessionEscrowRetainedDescriptor.pending().map(\.sessionId)) - for candidate in candidates where !retainedIds.contains(candidate.sessionId) { - guard let token = candidate.meta.escrowToken, let pid = candidate.meta.childPID else { - success = false - continue - } - switch retrieveOutcome( - sessionId: candidate.sessionId, tokenHex: token, socketPath: legacySocketPath(), - allowUnspecifiedDenyRetry: true, - retryDeadline: .now() + SessionEscrowPolicy.retrieveDenyRetryWindow - ) { - case .granted(let fd): - _ = SessionEscrowRetainedDescriptor.retain( - sessionId: candidate.sessionId, masterFD: fd, childPID: pid, - tokenHex: token, socketPath: legacySocketPath() - ) - case .denied(.unknownSession): - // Only this explicit denial establishes that this holder - // no longer owns the session. Transport/token errors do not. - guard let paths = SessionWALPaths.make(sessionId: candidate.sessionId) else { - success = false - continue - } - if (try? SessionWALCore.clearEscrowClaim(at: paths, ifSocketMatches: legacySocketPath())) == nil { success = false } - default: - success = false - } - } - for owner in SessionEscrowRetainedDescriptor.pending() { - if !owner.canRecoverWithoutPanel || !migrateRetainedDescriptor(owner) { success = false } - } - completion(success && !hasLegacySessions()) - } - } - - private static func migrateRetainedDescriptor(_ owner: SessionEscrowRetainedDescriptor) -> Bool { - let destination = owner.pendingDestination() - let targetSocket = escrowSocketPath() - // A lost acknowledgement may already have handed the master to the - // new holder. Stop that drain before issuing the same session again. - if destination.socketPath == targetSocket, - let probeFD = UnixDomainFDPassing.connect(to: targetSocket) { - close(probeFD) - switch retrieveOutcome( - sessionId: destination.sessionId, tokenHex: destination.tokenHex, socketPath: targetSocket, - retryDeadline: .now() + SessionEscrowPolicy.retrieveDenyRetryWindow - ) { - case .granted(let fd): close(fd) // owner still holds the master - case .denied(.unknownSession): break - case .denied(.notDraining): - guard releaseUnclaimedRegistration(destination, socketPath: targetSocket) else { return false } - default: return false - } - } - var connection: Int32? - var spawned = false - for attempt in 0.. Bool { - guard let connection = UnixDomainFDPassing.connect(to: socketPath) else { return false } - defer { close(connection) } - guard let token = decodeHexToken(destination.tokenHex), - var frame = EscrowWireFormat.encodeReleaseFrame(sessionId: destination.sessionId, token: token) else { return false } - frame[0] = EscrowWireFormat.acknowledgedReleaseType - return UnixDomainFDPassing.send(fd: nil, payload: frame, over: connection) - && receiveEscrowAcknowledgement(over: connection, sessionId: destination.sessionId) - } /// Returned to the caller on a successful escrow so it can be recorded /// in `meta.json` (`SessionWALStore.markEscrowed`). @@ -1045,7 +792,6 @@ final class SessionEscrowClient { surfaceId: String, dupedMasterFD: Int32, childPID: Int32, - retainedDescriptor: SessionEscrowRetainedDescriptor? = nil, completion: @escaping (Result?) -> Void ) { guard !SessionMachineryGate.isUnitTesting else { @@ -1092,7 +838,6 @@ final class SessionEscrowClient { } frame[0] = EscrowWireFormat.acknowledgedEscrowType let tokenHex = token.map { String(format: "%02x", $0) }.joined() - retainedDescriptor?.recordDestination(sessionId: surfaceId, tokenHex: tokenHex, socketPath: self.socketPath) guard UnixDomainFDPassing.send(fd: dupedMasterFD, payload: frame, over: fd) else { #if DEBUG @@ -1249,24 +994,6 @@ final class SessionEscrowClient { } } - /// Derived from the app's own control-socket path - /// (`SocketControlSettings.socketPath()`), which is already - /// bundle-id/tag-scoped and already lives under `/tmp` specifically to - /// stay well under `sockaddr_un.sun_path`'s ~104-byte limit -- see - /// `SocketControlSettings.taggedDebugSocketPath`. Reusing that scoping - /// means a tagged debug build and the production app (or two different - /// tags) always get distinct holder sockets, matching the isolation - /// the rest of the socket-path machinery already guarantees. - static func legacySocketPath() -> String { - let base = SocketControlSettings.socketPath() - let baseURL = URL(fileURLWithPath: base) - let name = baseURL.deletingPathExtension().lastPathComponent + "-escrow" - return baseURL.deletingLastPathComponent() - .appendingPathComponent(name) - .appendingPathExtension("sock") - .path - } - /// Version the holder policy so upgraded apps register with the new /// snapshot-aware reaper. Retrieval still uses the socket recorded in /// each session's metadata, allowing old holders to hand sessions over. @@ -1524,27 +1251,18 @@ extension SessionEscrowClient { /// retrieve-before-drain race (this launch beat the holder's death /// detection), and the holder resolves it BY ITSELF within /// `heartbeatStaleAfter` -- so it is retried on a bounded deadline - /// instead of being treated as a permanent fallback. `.unspecified` - /// may be that same race reported by a pre-reason holder (the holder - /// OUTLIVES the app, so an updated client talking to a previous - /// version's holder is the normal update path, not an edge case); it is - /// retried only when the caller vouches via `allowUnspecifiedDenyRetry` - /// (Workspace restore passes meta.json heartbeat freshness -- an app - /// that was alive seconds ago is the race signature; one long dead is - /// not worth stalling on). `retryDeadline` exists for tests; production - /// callers share `sharedDenyRetryDeadline`. + /// instead of being treated as a permanent fallback. `retryDeadline` + /// exists for tests; production callers share `sharedDenyRetryDeadline`. static func retrieve( sessionId: String, tokenHex: String, socketPath: String, recvTimeout: TimeInterval = SessionEscrowPolicy.retrieveRecvTimeout, - allowUnspecifiedDenyRetry: Bool = false, retryDeadline: DispatchTime? = nil ) -> Int32? { if case .granted(let fd) = retrieveOutcome( sessionId: sessionId, tokenHex: tokenHex, socketPath: socketPath, - recvTimeout: recvTimeout, allowUnspecifiedDenyRetry: allowUnspecifiedDenyRetry, - retryDeadline: retryDeadline + recvTimeout: recvTimeout, retryDeadline: retryDeadline ) { return fd } return nil } @@ -1554,7 +1272,6 @@ extension SessionEscrowClient { tokenHex: String, socketPath: String, recvTimeout: TimeInterval = SessionEscrowPolicy.retrieveRecvTimeout, - allowUnspecifiedDenyRetry: Bool = false, retryDeadline: DispatchTime? = nil ) -> RetrieveOutcome { var attempt = 0 @@ -1571,13 +1288,11 @@ extension SessionEscrowClient { case .failed: return .failed case .denied(let reason): - let retryable = reason == .notDraining - || (reason == .unspecified && allowUnspecifiedDenyRetry) // Retryability first: a permanent denial (unknown session, // token mismatch) must not be the access that anchors the // lazy shared window, or it silently eats the budget a // later legitimate not_draining denial needs. - guard retryable else { return .denied(reason) } + guard reason == .notDraining else { return .denied(reason) } let deadline = retryDeadline ?? sharedDenyRetryDeadline guard DispatchTime.now() < deadline else { return .denied(reason) } dilog("escrow.retrieve", "retry session=\(sessionId.prefix(8)) attempt=\(attempt) reason=\(reason)") diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 640deb7bf..040bb571f 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -486,9 +486,24 @@ enum SessionPersistenceStore { }) } + /// A dev or bench snapshot untouched for this long stops pinning its session + /// ids, unless it is the running bundle's own. Release bundles (production, + /// staging) always pin: their shells can outlive a long gap between launches. + static let foreignSnapshotPinMaxAge: TimeInterval = 14 * 24 * 60 * 60 + + static func isExpirableDevSnapshotName(_ name: String) -> Bool { + name.contains(".debug.") || name.contains(".bench.") + } + /// WAL storage is shared by production and tagged bundles. Consult every - /// current snapshot before deleting its session data, with bounded scan work. - static func allOwnedTerminalSessionIDs(in directory: URL) -> Set? { + /// pinning snapshot before deleting its session data, with bounded scan work. + /// Other dev or bench bundles' snapshots older than `foreignSnapshotPinMaxAge` + /// are skipped without being read; `ownSnapshotFileURL` always pins. + static func allOwnedTerminalSessionIDs( + in directory: URL, + ownSnapshotFileURL: URL? = defaultSnapshotFileURL(), + now: Date = Date() + ) -> Set? { var failed = false guard let enumerator = FileManager.default.enumerator( at: directory, @@ -498,10 +513,21 @@ enum SessionPersistenceStore { ) else { return nil } var owned = Set() var inspected = 0 + let ownName = ownSnapshotFileURL?.lastPathComponent + let pinCutoff = now.addingTimeInterval(-foreignSnapshotPinMaxAge) while let entry = enumerator.nextObject() as? URL { + let name = entry.lastPathComponent + // `session-.scrollback-policy.json` shares the prefix and extension + // but is not a snapshot; reading it as one would defer the sweep forever. + guard name.hasPrefix("session-"), entry.pathExtension == "json", + !name.hasSuffix(".scrollback-policy.json") else { continue } inspected += 1 guard inspected <= historyDirectoryScanLimit else { return nil } - guard entry.lastPathComponent.hasPrefix("session-"), entry.pathExtension == "json" else { continue } + if name != ownName, isExpirableDevSnapshotName(name), + let modified = (try? FileManager.default.attributesOfItem(atPath: entry.path))?[.modificationDate] as? Date, + modified < pinCutoff { + continue + } guard let ids = ownedTerminalSessionIDs(fileURL: entry) else { return nil } owned.formUnion(ids) } diff --git a/Sources/SessionWALStore.swift b/Sources/SessionWALStore.swift index 19edaf0e1..426602bf7 100644 --- a/Sources/SessionWALStore.swift +++ b/Sources/SessionWALStore.swift @@ -14,15 +14,20 @@ import Bonsplit /// 2. The callback (`sessionWALPTYTeeCallback`) does exactly one thing: /// `SessionWALRingBuffer.append` — a bounded memcpy into a fixed-capacity /// buffer preallocated at registration time (never in the callback). -/// 3. A single shared background queue (`SessionWALStore.writeQueue`, a -/// serial `DispatchQueue`) runs a periodic timer every ~100ms -/// (`SessionWALPolicy.drainInterval`) that drains every registered -/// surface's ring buffer and appends the bytes to that session's -/// `wal.log`. WAL fsync is batched separately at -/// `SessionWALPolicy.walSyncInterval`, avoiding one serial fsync per -/// output-bearing tick and writer. -/// 4. `meta.json` (the fact file) is refreshed only at the heartbeat cadence -/// or immediately when identity/escrow/generation changes. +/// 3. When a ring buffer goes from empty to non-empty, its +/// `onBytesAvailable` merges into a coalescing dispatch source (a lock-free +/// atomic OR, no allocation). The source's handler on the single shared +/// background queue (`SessionWALStore.writeQueue`, a serial +/// `DispatchQueue`) schedules one drain `SessionWALPolicy.drainInterval` +/// (~100ms) later, which appends every registered surface's buffered bytes +/// to that session's `wal.log`. Nothing runs while no surface emits output. +/// WAL fsync is batched separately at `SessionWALPolicy.walSyncInterval`; +/// a writer left with unsynchronized bytes arms a follow-up pass for it. +/// 4. `meta.json` (the fact file) is written when identity/escrow/generation +/// changes, plus an hourly heartbeat (`SessionWALPolicy.heartbeatInterval`) +/// that keeps `lastHeartbeatAt` far inside the orphan sweep's 24h window. +/// The heartbeat runs only while scrollback capture is on or the writer +/// holds an escrow claim. /// /// ## Ring buffer lock, a disclosed pragmatic tradeoff /// The callback must not allocate or block. A textbook wait-free SPSC ring @@ -138,9 +143,12 @@ import Bonsplit /// its `meta.json` heartbeat (or, if that can't be parsed, its own /// filesystem modification date) is older than /// `SessionWALPolicy.orphanDirectoryMaxAge`. A directory whose age cannot -/// be determined at all is kept. Every bundle's current snapshot protects -/// its terminal directories regardless of age; unreadable snapshots defer -/// the sweep so tagged builds cannot destroy production retrieval tokens. +/// be determined at all is kept. This bundle's own snapshot, and any other +/// bundle's snapshot modified within +/// `SessionPersistenceStore.foreignSnapshotPinMaxAge`, protects its terminal +/// directories regardless of directory age; unreadable pinning snapshots +/// defer the sweep so tagged builds cannot destroy production retrieval +/// tokens. enum SessionWALPolicy { /// Fixed capacity of the in-memory ring buffer the tee callback writes /// into. Large enough to absorb a burst between 100ms drains for normal @@ -149,14 +157,19 @@ enum SessionWALPolicy { static let ringBufferCapacityBytes = 64 * 1024 /// Per-session wal.log cap before rotating to wal.log.1. static let walCapBytes: Int64 = 8 * 1024 * 1024 + /// Batching delay between the first buffered byte and the drain that + /// writes it. static let drainInterval: TimeInterval = 0.1 /// WAL durability is intentionally batched instead of issuing a serial /// fsync for every 100ms drain tick. Rotation and final writer teardown /// still force an immediate sync. static let walSyncInterval: TimeInterval = 1.0 - /// Fact-file heartbeat cadence. Identity, escrow, and generation changes - /// bypass this throttle and are persisted immediately. - static let metaRefreshInterval: TimeInterval = 5.0 + /// Fact-file heartbeat cadence. The only consumers of `lastHeartbeatAt` + /// are the orphan sweeps (24h `orphanDirectoryMaxAge`, in this build and + /// in older builds sharing the sessions root), so this stays an order of + /// magnitude inside that window. Identity, escrow, and generation changes + /// are persisted immediately and independently. + static let heartbeatInterval: TimeInterval = 60 * 60 static let orphanSweepDelay: TimeInterval = 5.0 /// Minimum spacing between successful/attempted frame captures for a /// single session. Deliberately slow (20-30s) since capture is @@ -165,6 +178,7 @@ enum SessionWALPolicy { /// How often the writeQueue timer wakes to check which writers are due /// for a frame capture. Lighter than `frameCaptureInterval` itself so a /// writer becoming eligible doesn't wait a full cycle before capture. + /// Runs only while scrollback capture is enabled. static let frameCaptureCheckInterval: TimeInterval = 5.0 /// A session directory with no live writer is only eligible for deletion /// by the sweep once it is at least this old (by `meta.json` heartbeat, @@ -191,10 +205,19 @@ final class SessionWALRingBuffer { private var count = 0 private var lock = os_unfair_lock() private var policy: SessionScrollbackPolicy + /// Called from `append` (after the lock is released) only when the buffer + /// goes from empty to non-empty, so the owner can schedule one drain + /// instead of polling. Must be non-blocking and allocation-free. + private let onBytesAvailable: (() -> Void)? - init(capacity: Int, policy: SessionScrollbackPolicy = .init(enabled: true, generation: UUID())) { + init( + capacity: Int, + policy: SessionScrollbackPolicy = .init(enabled: true, generation: UUID()), + onBytesAvailable: (() -> Void)? = nil + ) { self.capacity = capacity self.policy = policy + self.onBytesAvailable = onBytesAvailable self.firstStorage = UnsafeMutablePointer.allocate(capacity: capacity) self.secondStorage = UnsafeMutablePointer.allocate(capacity: capacity) } @@ -212,8 +235,15 @@ final class SessionWALRingBuffer { func append(_ buf: UnsafePointer, _ len: Int) { guard len > 0 else { return } guard os_unfair_lock_trylock(&lock) else { return } - defer { os_unfair_lock_unlock(&lock) } - guard policy.enabled else { return } + let wasEmpty = count == 0 + let stored = storeLocked(buf, len) + os_unfair_lock_unlock(&lock) + if stored, wasEmpty { onBytesAvailable?() } + } + + /// Caller holds `lock`. Returns false when the policy rejects the bytes. + private func storeLocked(_ buf: UnsafePointer, _ len: Int) -> Bool { + guard policy.enabled else { return false } let storage = storage(at: activeStorageIndex) if len >= capacity { @@ -221,7 +251,7 @@ final class SessionWALRingBuffer { memcpy(storage, src, capacity) head = 0 count = capacity - return + return true } let writeStart = (head + count) % capacity @@ -238,6 +268,7 @@ final class SessionWALRingBuffer { } else { count = newCount } + return true } /// Background-queue only: swaps the active preallocated buffer while @@ -1008,7 +1039,6 @@ private final class SessionWALWriter { /// Resolved the same way as `childPID`, see above. var ptyPath: String? var currentWalSize: Int64 = 0 - var lastMetaWriteAt: Date = .distantPast var lastWALSyncAt: Date = .distantPast var hasUnsynchronizedWALWrites = false /// Set after the first failed WAL append is logged, so a full disk @@ -1052,20 +1082,26 @@ final class SessionWALStore { let surfaceId: String let ringBuffer: SessionWALRingBuffer - init(surfaceId: String) { + init(surfaceId: String, onBytesAvailable: (() -> Void)? = nil) { self.surfaceId = surfaceId self.ringBuffer = SessionWALRingBuffer( capacity: SessionWALPolicy.ringBufferCapacityBytes, - policy: SessionScrollbackPolicy(enabled: false, generation: UUID()) + policy: SessionScrollbackPolicy(enabled: false, generation: UUID()), + onBytesAvailable: onBytesAvailable ) } } static let shared = SessionWALStore() - private let writeQueue = DispatchQueue(label: "com.darkroom.programa.session-wal", qos: .utility) + private let writeQueue: DispatchQueue + /// Merged into from the tee callback via `Context.onBytesAvailable`; + /// its handler runs on `writeQueue`. + private let drainWake: DispatchSourceUserDataOr private var writersBySurfaceId: [String: SessionWALWriter] = [:] - private var drainTimer: DispatchSourceTimer? + private var drainScheduled = false + private var syncCheckScheduled = false + private var heartbeatTimer: DispatchSourceTimer? private var frameCaptureTimer: DispatchSourceTimer? private var hasScheduledOrphanSweep = false private var capturePolicy = SessionScrollbackPolicy(enabled: false, generation: UUID()) @@ -1086,7 +1122,15 @@ final class SessionWALStore { return decoder }() - private init() {} + private init() { + let queue = DispatchQueue(label: "com.darkroom.programa.session-wal", qos: .utility) + writeQueue = queue + drainWake = DispatchSource.makeUserDataOrSource(queue: queue) + drainWake.setEventHandler { [weak self] in + self?.scheduleDrain() + } + drainWake.resume() + } func currentCapturePolicy() -> SessionScrollbackPolicy { writeQueue.sync { capturePolicy } @@ -1110,6 +1154,7 @@ final class SessionWALStore { for writer in writersBySurfaceId.values { writer.context.ringBuffer.transition(to: policy) } + refreshTimers() } /// Registers the PTY tee for a newly created surface and starts its @@ -1121,7 +1166,8 @@ final class SessionWALStore { surfaceId: String, workingDirectory: String? ) -> Unmanaged { - let context = Context(surfaceId: surfaceId) + let wake = drainWake + let context = Context(surfaceId: surfaceId, onBytesAvailable: { wake.or(data: 1) }) let unmanaged = Unmanaged.passRetained(context) if SessionMachineryGate.isUnitTesting { return unmanaged @@ -1184,9 +1230,8 @@ final class SessionWALStore { if writer.childPID == nil, let childPID { writer.childPID = childPID } - let now = Date() - self.writeMeta(writer: writer, at: now) - writer.lastMetaWriteAt = now + self.writeMeta(writer: writer, at: Date()) + self.refreshTimers() } } @@ -1205,8 +1250,7 @@ final class SessionWALStore { /// "Fact file" doc comment). Only fills fields that are still nil -- /// never overwrites an already-resolved value, and passing nil here /// just means "still unknown", not "clear it". Forces an immediate meta - /// write when something actually changed so a freshly resolved PID - /// doesn't sit behind the up-to-1s heartbeat throttle; a no-op if the + /// write when something actually changed; a no-op if the /// writer has already been torn down or nothing changed. func updateSurfaceIdentity(surfaceId: String, childPID: Int32?, ptyPath: String?) { guard childPID != nil || ptyPath != nil else { return } @@ -1222,16 +1266,14 @@ final class SessionWALStore { changed = true } guard changed else { return } - let now = Date() - self.writeMeta(writer: writer, at: now) - writer.lastMetaWriteAt = now + self.writeMeta(writer: writer, at: Date()) } } /// Records a successful escrow hand-off (issue #182 slice 1, `Sources /// /SessionEscrow.swift`). Forces an immediate meta write, same as /// `updateSurfaceIdentity`, so `meta.json` reflects escrow state - /// without waiting on the up-to-1s heartbeat throttle. A no-op if the + /// at once. A no-op if the /// writer has already been torn down (surface closed before the /// escrow round-trip completed). func markEscrowed(surfaceId: String, socketPath: String, token: String) { @@ -1240,9 +1282,8 @@ final class SessionWALStore { writer.escrowed = true writer.escrowSocketPath = socketPath writer.escrowToken = token - let now = Date() - self.writeMeta(writer: writer, at: now) - writer.lastMetaWriteAt = now + self.writeMeta(writer: writer, at: Date()) + self.refreshTimers() } } @@ -1426,57 +1467,108 @@ final class SessionWALStore { writer.ptyPath = existing.ptyPath } writersBySurfaceId[surfaceId] = writer - let now = Date() - writeMeta(writer: writer, at: now) - writer.lastMetaWriteAt = now + writeMeta(writer: writer, at: Date()) - ensureDrainTimerStarted() - ensureFrameCaptureTimerStarted() + refreshTimers() scheduleOrphanSweepIfNeeded() } private func stopWriter(surfaceId: String, deleteDirectory: Bool) { guard let writer = writersBySurfaceId.removeValue(forKey: surfaceId) else { return } - drain(writer: writer, forceMetaWrite: false, forceWALSync: true) + drain(writer: writer, forceWALSync: true) if deleteDirectory { try? FileManager.default.removeItem(at: writer.paths.sessionDirectory) } - stopTimersIfIdle() + refreshTimers() + } + + /// Starts or cancels the two periodic timers so an idle store schedules + /// nothing. Dispatch sources cannot be reconfigured after cancellation, + /// so each is cleared here and lazily recreated when needed again. + /// - Frame capture: only while scrollback capture is enabled. + /// - Heartbeat: while capture is enabled, or for any writer holding an + /// escrow claim (`meta.json` carries the retrieval token, and the orphan + /// sweeps age a directory by `lastHeartbeatAt`, so a live escrowed + /// session's heartbeat must keep moving even with capture off). + private func refreshTimers() { + let hasWriters = !writersBySurfaceId.isEmpty + if hasWriters, capturePolicy.enabled { + if frameCaptureTimer == nil { + let timer = DispatchSource.makeTimerSource(queue: writeQueue) + let interval = SessionWALPolicy.frameCaptureCheckInterval + timer.schedule(deadline: .now() + interval, repeating: interval) + timer.setEventHandler { [weak self] in + self?.considerFrameCaptureTick() + } + timer.resume() + frameCaptureTimer = timer + } + } else { + frameCaptureTimer?.cancel() + frameCaptureTimer = nil + } + + let needsHeartbeat = hasWriters + && (capturePolicy.enabled || writersBySurfaceId.values.contains { $0.escrowed }) + if needsHeartbeat { + if heartbeatTimer == nil { + // Wall-clock deadline: a monotonic timer does not count time + // the machine spends asleep. + let timer = DispatchSource.makeTimerSource(queue: writeQueue) + let interval = SessionWALPolicy.heartbeatInterval + timer.schedule(wallDeadline: .now() + interval, repeating: interval, leeway: .seconds(60)) + timer.setEventHandler { [weak self] in + self?.heartbeatAllWriters() + } + timer.resume() + heartbeatTimer = timer + } + } else { + heartbeatTimer?.cancel() + heartbeatTimer = nil + } } - private func ensureDrainTimerStarted() { - guard drainTimer == nil else { return } - let timer = DispatchSource.makeTimerSource(queue: writeQueue) - let interval = SessionWALPolicy.drainInterval - timer.schedule(deadline: .now() + interval, repeating: interval) - timer.setEventHandler { [weak self] in - self?.drainAllWriters() + private func heartbeatAllWriters() { + let now = Date() + for writer in writersBySurfaceId.values where capturePolicy.enabled || writer.escrowed { + writeMeta(writer: writer, at: now) } - timer.resume() - drainTimer = timer } - private func ensureFrameCaptureTimerStarted() { - guard frameCaptureTimer == nil else { return } - let timer = DispatchSource.makeTimerSource(queue: writeQueue) - let interval = SessionWALPolicy.frameCaptureCheckInterval - timer.schedule(deadline: .now() + interval, repeating: interval) - timer.setEventHandler { [weak self] in - self?.considerFrameCaptureTick() + /// Runs on `writeQueue` when a ring buffer received its first bytes since + /// the last drain. One pending drain covers every writer. + private func scheduleDrain() { + guard !drainScheduled else { return } + drainScheduled = true + writeQueue.asyncAfter(deadline: .now() + SessionWALPolicy.drainInterval) { [weak self] in + guard let self else { return } + self.drainScheduled = false + self.drainAllWriters() + self.scheduleSyncCheckIfNeeded() } - timer.resume() - frameCaptureTimer = timer } - private func stopTimersIfIdle() { - guard writersBySurfaceId.isEmpty else { return } - // Dispatch sources cannot be safely reconfigured after cancellation; - // clear them here and the next registration lazily recreates/resumes - // both sweeps. - drainTimer?.cancel() - drainTimer = nil - frameCaptureTimer?.cancel() - frameCaptureTimer = nil + /// Output that stops after a drain leaves its bytes unsynchronized and no + /// further wake would arrive, so arm one follow-up pass for the batched + /// fsync. Re-arms until every writer is synchronized. + private func scheduleSyncCheckIfNeeded() { + guard !syncCheckScheduled else { return } + let now = Date() + var delay: TimeInterval? + for writer in writersBySurfaceId.values where writer.hasUnsynchronizedWALWrites { + let due = writer.lastWALSyncAt.addingTimeInterval(SessionWALPolicy.walSyncInterval) + .timeIntervalSince(now) + delay = min(delay ?? due, due) + } + guard let delay else { return } + syncCheckScheduled = true + writeQueue.asyncAfter(deadline: .now() + max(delay, SessionWALPolicy.drainInterval)) { [weak self] in + guard let self else { return } + self.syncCheckScheduled = false + self.drainAllWriters() + self.scheduleSyncCheckIfNeeded() + } } /// Fires every `frameCaptureCheckInterval`; writers that are due @@ -1588,13 +1680,12 @@ final class SessionWALStore { private func drainAllWriters() { for writer in writersBySurfaceId.values { - drain(writer: writer, forceMetaWrite: false, forceWALSync: false) + drain(writer: writer, forceWALSync: false) } } private func drain( writer: SessionWALWriter, - forceMetaWrite: Bool, forceWALSync: Bool ) { let capture = writer.context.ringBuffer.drainCaptured() @@ -1610,11 +1701,6 @@ final class SessionWALStore { writer.lastWALSyncAt = now } } - if forceMetaWrite - || now.timeIntervalSince(writer.lastMetaWriteAt) >= SessionWALPolicy.metaRefreshInterval { - writeMeta(writer: writer, at: now) - writer.lastMetaWriteAt = now - } } private func appendToWAL( @@ -1688,10 +1774,13 @@ final class SessionWALStore { static func sweepOrphanedSessionDirectories( at root: URL, registeredSessionIDs: Set, + ownSnapshotFileURL: URL? = SessionPersistenceStore.defaultSnapshotFileURL(), now: Date = Date() ) { guard let owned = SessionPersistenceStore.allOwnedTerminalSessionIDs( - in: root.deletingLastPathComponent() + in: root.deletingLastPathComponent(), + ownSnapshotFileURL: ownSnapshotFileURL, + now: now ) else { return } guard let entries = try? FileManager.default.contentsOfDirectory( at: root, diff --git a/Sources/SettingsModels.swift b/Sources/SettingsModels.swift index d17a506da..40965668f 100644 --- a/Sources/SettingsModels.swift +++ b/Sources/SettingsModels.swift @@ -99,14 +99,13 @@ enum ScrollbackPersistenceSettings { static let failureKey = "sessionPersistScrollbackFailure" @MainActor private static var defaultsObserver: NSObjectProtocol? @MainActor private static var appliedPreference: Bool? - @MainActor private static var migrationInProgress = false static func isEnabled(defaults: UserDefaults = .standard) -> Bool { flag.isEnabled(defaults: defaults) && defaults.string(forKey: failureKey) == nil } @MainActor static func configureAtStartup() { - setEnabled(flag.isEnabled(defaults: .standard), migrate: false) + setEnabled(flag.isEnabled(defaults: .standard)) defaultsObserver = NotificationCenter.default.addObserver( forName: UserDefaults.didChangeNotification, object: nil, queue: .main ) { _ in @@ -117,7 +116,7 @@ enum ScrollbackPersistenceSettings { } } - @MainActor static func setEnabled(_ enabled: Bool, migrate: Bool = true) { + @MainActor static func setEnabled(_ enabled: Bool) { appliedPreference = enabled // Publish the requested opt-out even if storage fails. The persistent // failure state prevents the UI from presenting that as a guarantee. @@ -127,36 +126,12 @@ enum ScrollbackPersistenceSettings { UserDefaults.standard.removeObject(forKey: failureKey) } catch { UserDefaults.standard.set("storage", forKey: failureKey) - return - } - if !enabled { - if SessionEscrowClient.hasLegacySessions() { - UserDefaults.standard.set("legacy", forKey: failureKey) - } - if migrate { retryLegacyMigration() } } } @MainActor static func retry() { setEnabled(flag.isEnabled(defaults: .standard)) } - - @MainActor static func retryLegacyMigration() { - guard (!flag.isEnabled(defaults: .standard) || !SessionEscrowRetainedDescriptor.pending().isEmpty), !migrationInProgress, - UserDefaults.standard.string(forKey: failureKey) != "storage" else { return } - migrationInProgress = true - SessionEscrowClient.migrateLegacySessions { success in - DispatchQueue.main.async { - migrationInProgress = false - guard !flag.isEnabled(defaults: .standard) else { return } - if success || !SessionEscrowClient.hasLegacySessions() { - UserDefaults.standard.removeObject(forKey: failureKey) - } else { - UserDefaults.standard.set("legacy", forKey: failureKey) - } - } - } - } } enum CommandPaletteSwitcherSearchSettings { diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift index e808936c9..6483a5788 100644 --- a/Sources/TabItemView.swift +++ b/Sources/TabItemView.swift @@ -186,9 +186,8 @@ struct TabItemView: View, Equatable { @State private var isHovering = false @State private var rowHeight: CGFloat = 1 // Cached results of the expensive bonsplit tree walk + branch/dir/PR snapshot. - // Updated only by the debounced detail-data publisher, onAppear, and settings - // changes — NOT by immediate title changes or lightweight render telemetry. - @State private var cachedOrderedPanelIds: [UUID]? = nil + // Updated only by the debounced detail-data publisher and onAppear — + // NOT by immediate title changes or lightweight render telemetry. @State private var cachedBranchDirectoryLines: [VerticalBranchDirectoryLine] = [] @State private var cachedPullRequestRows: [PullRequestDisplay] = [] @@ -216,10 +215,6 @@ struct TabItemView: View, Equatable { settings.showsGitBranch } - private var sidebarBranchVerticalLayout: Bool { - settings.usesVerticalBranchLayout - } - private var sidebarShowGitBranchIcon: Bool { settings.showsGitBranchIcon } @@ -363,10 +358,6 @@ struct TabItemView: View, Equatable { ) } - private var visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility { - settings.visibleAuxiliaryDetails - } - // Read imperatively instead of @Environment(\.accessibilityReduceMotion): this // struct's Equatable conformance and typing-latency contract (CLAUDE.md) forbid // adding environment/observed properties that would need to join `==`. @@ -392,33 +383,10 @@ struct TabItemView: View, Equatable { let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") let latestNotificationSubtitle = latestNotificationText let effectiveSubtitle = latestNotificationSubtitle - let detailVisibility = visibleAuxiliaryDetails - // Read from cache — updated only by the debounced publisher, onAppear, - // and settings changes. Title-keystroke re-renders skip this tree walk. - let orderedPanelIds: [UUID]? = cachedOrderedPanelIds + // Read from cache — updated only by the debounced publisher and onAppear. + // Title-keystroke re-renders skip this tree walk. let branchDirectoryLines: [VerticalBranchDirectoryLine] = cachedBranchDirectoryLines let pullRequestRows: [PullRequestDisplay] = cachedPullRequestRows - let compactGitBranchSummaryText: String? = { - guard detailVisibility.showsBranchDirectory, - !sidebarBranchVerticalLayout, - sidebarShowGitBranch, - let orderedPanelIds else { - return nil - } - return gitBranchSummaryText(orderedPanelIds: orderedPanelIds) - }() - let compactDirectorySummaryText: String? = { - guard detailVisibility.showsBranchDirectory, - !sidebarBranchVerticalLayout, - let orderedPanelIds else { - return nil - } - return directorySummaryText(orderedPanelIds: orderedPanelIds) - }() - let compactBranchDirectoryRow = branchDirectoryRow( - gitSummary: compactGitBranchSummaryText, - directorySummary: compactDirectorySummaryText - ) let branchLinesContainBranch = sidebarShowGitBranch && branchDirectoryLines.contains { $0.branch != nil } VStack(alignment: .leading, spacing: 4) { @@ -592,35 +560,33 @@ struct TabItemView: View, Equatable { .multilineTextAlignment(.leading) } - if detailVisibility.showsMetadata { - // The agent badge above is the single source for agent state now; drop any - // status entry that's still carrying the old duplicate phrasing (older CLI - // builds may still send it). Other status entries (verbose tool descriptions, - // progress, ports, PR) render unchanged. - let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder().filter { entry in - !(Self.agentStateStatusKeys.contains(entry.key) && Self.agentStateStatusValues.contains(entry.value)) - } - let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() - if !metadataEntries.isEmpty { - SidebarMetadataRows( - entries: metadataEntries, - isActive: usesInvertedActiveForeground, - onFocus: { updateSelection() } - ) - .transition(.opacity) - } - if !metadataBlocks.isEmpty { - SidebarMetadataMarkdownBlocks( - blocks: metadataBlocks, - isActive: usesInvertedActiveForeground, - onFocus: { updateSelection() } - ) - .transition(.opacity) - } + // The agent badge above is the single source for agent state now; drop any + // status entry that's still carrying the old duplicate phrasing (older CLI + // builds may still send it). Other status entries (verbose tool descriptions, + // progress, ports, PR) render unchanged. + let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder().filter { entry in + !(Self.agentStateStatusKeys.contains(entry.key) && Self.agentStateStatusValues.contains(entry.value)) + } + let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() + if !metadataEntries.isEmpty { + SidebarMetadataRows( + entries: metadataEntries, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity) + } + if !metadataBlocks.isEmpty { + SidebarMetadataMarkdownBlocks( + blocks: metadataBlocks, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity) } // Latest log entry - if detailVisibility.showsLog, let latestLog = tab.logEntries.last { + if let latestLog = tab.logEntries.last { HStack(spacing: 4) { Image(systemName: logLevelIcon(latestLog.level)) .font(.system(size: 8)) @@ -635,7 +601,7 @@ struct TabItemView: View, Equatable { } // Progress bar - if detailVisibility.showsProgress, let progress = tab.progress { + if let progress = tab.progress { VStack(alignment: .leading, spacing: 2) { GeometryReader { geo in ZStack(alignment: .leading) { @@ -659,61 +625,44 @@ struct TabItemView: View, Equatable { } // Branch + directory row - if detailVisibility.showsBranchDirectory { - if sidebarBranchVerticalLayout { - if !branchDirectoryLines.isEmpty { - HStack(alignment: .top, spacing: 3) { - if sidebarShowGitBranchIcon, branchLinesContainBranch { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - } - VStack(alignment: .leading, spacing: 1) { - ForEach(Array(branchDirectoryLines.enumerated()), id: \.offset) { _, line in - HStack(spacing: 3) { - if let branch = line.branch { - Text(branch) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - if line.branch != nil, line.directory != nil { - Image(systemName: "circle.fill") - .font(.system(size: 3)) - .foregroundColor(activeSecondaryColor(0.6)) - .padding(.horizontal, 1) - } - if let directory = line.directory { - Text(directory) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - } + if !branchDirectoryLines.isEmpty { + HStack(alignment: .top, spacing: 3) { + if sidebarShowGitBranchIcon, branchLinesContainBranch { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + } + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(branchDirectoryLines.enumerated()), id: \.offset) { _, line in + HStack(spacing: 3) { + if let branch = line.branch { + Text(branch) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + if line.branch != nil, line.directory != nil { + Image(systemName: "circle.fill") + .font(.system(size: 3)) + .foregroundColor(activeSecondaryColor(0.6)) + .padding(.horizontal, 1) + } + if let directory = line.directory { + Text(directory) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) } } } } - } else if let dirRow = compactBranchDirectoryRow { - HStack(spacing: 3) { - if sidebarShowGitBranchIcon, compactGitBranchSummaryText != nil { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - } - Text(dirRow) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } } } // Pull request rows - if detailVisibility.showsPullRequests, !pullRequestRows.isEmpty { + if !pullRequestRows.isEmpty { VStack(alignment: .leading, spacing: 1) { ForEach(pullRequestRows) { pullRequest in Button(action: { @@ -742,7 +691,7 @@ struct TabItemView: View, Equatable { } // Ports row - if detailVisibility.showsPorts, !tab.listeningPorts.isEmpty { + if !tab.listeningPorts.isEmpty { HStack(spacing: 4) { ForEach(tab.listeningPorts, id: \.self) { port in Button(action: { @@ -934,10 +883,6 @@ struct TabItemView: View, Equatable { // before the first debounced publisher fires. recomputeSidebarDetailCache() } - .onChange(of: visibleAuxiliaryDetails) { - // Toggling branch/PR columns changes which data we need to cache. - recomputeSidebarDetailCache() - } } private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { @@ -1432,36 +1377,6 @@ struct TabItemView: View, Equatable { // latestNotificationText is now passed as a parameter from the parent view // to avoid subscribing to notificationStore changes in every TabItemView. - private func branchDirectoryRow( - gitSummary: String?, - directorySummary: String? - ) -> String? { - var parts: [String] = [] - - if let gitSummary { - parts.append(gitSummary) - } - - if let directorySummary { - parts.append(directorySummary) - } - - let result = parts.joined(separator: " · ") - return result.isEmpty ? nil : result - } - - private func gitBranchSummaryText(orderedPanelIds: [UUID]) -> String? { - let lines = gitBranchSummaryLines(orderedPanelIds: orderedPanelIds) - guard !lines.isEmpty else { return nil } - return lines.joined(separator: " | ") - } - - private func gitBranchSummaryLines(orderedPanelIds: [UUID]) -> [String] { - tab.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { branch in - "\(branch.branch)\(branch.isDirty ? "*" : "")" - } - } - private struct VerticalBranchDirectoryLine { let branch: String? let directory: String? @@ -1469,22 +1384,12 @@ struct TabItemView: View, Equatable { /// Recomputes the expensive sidebar detail caches (bonsplit tree walk, /// branch/directory lines, PR snapshot) and writes them into @State. - /// Must be called only from the debounced detail-data publisher, onAppear, - /// and settings-change handlers — never from immediate title changes or - /// lightweight render telemetry. + /// Must be called only from the debounced detail-data publisher and onAppear, + /// never from immediate title changes or lightweight render telemetry. private func recomputeSidebarDetailCache() { - let detail = visibleAuxiliaryDetails - let needsDetail = detail.showsBranchDirectory || detail.showsPullRequests - let ids: [UUID]? = needsDetail ? tab.sidebarOrderedPanelIds() : nil - cachedOrderedPanelIds = ids - cachedBranchDirectoryLines = { - guard detail.showsBranchDirectory, sidebarBranchVerticalLayout, let ids else { return [] } - return verticalBranchDirectoryLines(orderedPanelIds: ids) - }() - cachedPullRequestRows = { - guard detail.showsPullRequests, let ids else { return [] } - return pullRequestDisplays(orderedPanelIds: ids) - }() + let orderedPanelIds = tab.sidebarOrderedPanelIds() + cachedBranchDirectoryLines = verticalBranchDirectoryLines(orderedPanelIds: orderedPanelIds) + cachedPullRequestRows = pullRequestDisplays(orderedPanelIds: orderedPanelIds) } private func verticalBranchDirectoryLines(orderedPanelIds: [UUID]) -> [VerticalBranchDirectoryLine] { @@ -1515,15 +1420,6 @@ struct TabItemView: View, Equatable { } } - private func directorySummaryText(orderedPanelIds: [UUID]) -> String? { - let home = SidebarPathFormatter.homeDirectoryPath - let entries = tab.sidebarDirectoriesInDisplayOrder(orderedPanelIds: orderedPanelIds).compactMap { directory in - let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) - return shortened.isEmpty ? nil : shortened - } - return entries.isEmpty ? nil : entries.joined(separator: " | ") - } - private struct PullRequestDisplay: Identifiable { let id: String let number: Int diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 32c828a93..59a6170f3 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -44,15 +44,6 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable { } } -struct SidebarWorkspaceAuxiliaryDetailVisibility: Equatable { - let showsMetadata: Bool - let showsLog: Bool - let showsProgress: Bool - let showsBranchDirectory: Bool - let showsPullRequests: Bool - let showsPorts: Bool -} - enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable { case leftRail case solidFill @@ -149,10 +140,7 @@ enum WorkspaceTabColorSettings { static let folderColorOrderKey = "workspaceTabColor.folderColorOrder" static let maxRememberedFolderColors = 256 - private static let legacyDefaultOverridesKey = "workspaceTabColor.defaultOverrides" - private static let legacyCustomColorsKey = "workspaceTabColor.customColors" - - private static let originalPRPalette: [WorkspaceTabColorEntry] = [ + static let defaultPalette: [WorkspaceTabColorEntry] = [ WorkspaceTabColorEntry(name: "Red", hex: "#C0392B"), WorkspaceTabColorEntry(name: "Crimson", hex: "#922B21"), WorkspaceTabColorEntry(name: "Orange", hex: "#A04000"), @@ -171,10 +159,6 @@ enum WorkspaceTabColorSettings { WorkspaceTabColorEntry(name: "Charcoal", hex: "#3E4B5E"), ] - static var defaultPalette: [WorkspaceTabColorEntry] { - originalPRPalette - } - static func palette(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { let paletteMap = effectivePaletteMap(defaults: defaults) let builtInOrder = defaultPalette.compactMap { entry -> WorkspaceTabColorEntry? in @@ -196,19 +180,6 @@ enum WorkspaceTabColorSettings { return palette(defaults: defaults).filter { !builtInNames.contains($0.name) } } - static func currentColorHex(named name: String, defaults: UserDefaults = .standard) -> String? { - effectivePaletteMap(defaults: defaults)[name] - } - - static func setColor(named name: String, hex: String, defaults: UserDefaults = .standard) { - guard let normalizedName = normalizedColorName(name), - let normalizedHex = normalizedHex(hex) else { return } - - var palette = editablePaletteMap(defaults: defaults) - palette[normalizedName] = normalizedHex - persistPaletteMap(palette, defaults: defaults) - } - static func persistPaletteMap(_ rawPalette: [String: String], defaults: UserDefaults = .standard) { let normalizedPalette = normalizedPaletteMap(rawPalette) if normalizedPalette == defaultPaletteMap { @@ -216,13 +187,11 @@ enum WorkspaceTabColorSettings { } else { defaults.set(normalizedPalette, forKey: paletteKey) } - defaults.removeObject(forKey: legacyDefaultOverridesKey) - defaults.removeObject(forKey: legacyCustomColorsKey) } static func addCustomColor(_ hex: String, defaults: UserDefaults = .standard) -> String? { guard let normalized = normalizedHex(hex) else { return nil } - var palette = editablePaletteMap(defaults: defaults) + var palette = effectivePaletteMap(defaults: defaults) if palette.contains(where: { $0.value == normalized }) { return normalized } @@ -234,8 +203,6 @@ enum WorkspaceTabColorSettings { static func reset(defaults: UserDefaults = .standard) { defaults.removeObject(forKey: paletteKey) - defaults.removeObject(forKey: legacyDefaultOverridesKey) - defaults.removeObject(forKey: legacyCustomColorsKey) } static func resetRememberedFolderColors(defaults: UserDefaults = .standard) { @@ -330,23 +297,7 @@ enum WorkspaceTabColorSettings { } private static func effectivePaletteMap(defaults: UserDefaults) -> [String: String] { - if let stored = storedPaletteMap(defaults: defaults) { - return stored - } - if let legacy = legacyPaletteMap(defaults: defaults) { - return legacy - } - return defaultPaletteMap - } - - private static func editablePaletteMap(defaults: UserDefaults) -> [String: String] { - if let stored = storedPaletteMap(defaults: defaults) { - return stored - } - if let legacy = legacyPaletteMap(defaults: defaults) { - return legacy - } - return defaultPaletteMap + storedPaletteMap(defaults: defaults) ?? defaultPaletteMap } private static func storedPaletteMap(defaults: UserDefaults) -> [String: String]? { @@ -354,40 +305,6 @@ enum WorkspaceTabColorSettings { return normalizedPaletteMap(raw) } - private static func legacyPaletteMap(defaults: UserDefaults) -> [String: String]? { - let hasLegacyOverrides = defaults.object(forKey: legacyDefaultOverridesKey) != nil - let hasLegacyCustomColors = defaults.object(forKey: legacyCustomColorsKey) != nil - guard hasLegacyOverrides || hasLegacyCustomColors else { return nil } - - var palette = defaultPaletteMap - - if let rawOverrides = defaults.dictionary(forKey: legacyDefaultOverridesKey) as? [String: String] { - let validNames = Set(defaultPalette.map(\.name)) - for (name, hex) in rawOverrides { - guard validNames.contains(name), - let normalized = normalizedHex(hex) else { continue } - palette[name] = normalized - } - } - - if let rawCustomColors = defaults.array(forKey: legacyCustomColorsKey) as? [String] { - var index = 1 - var seenCustomHexes: Set = [] - for rawHex in rawCustomColors { - guard let normalized = normalizedHex(rawHex), - seenCustomHexes.insert(normalized).inserted else { continue } - let name = nextCustomColorName( - existingNames: Set(palette.keys), - startingAt: index - ) - palette[name] = normalized - index += 1 - } - } - - return palette - } - private static func normalizedPaletteMap(_ rawPalette: [String: String]) -> [String: String] { var normalized: [String: String] = [:] for (rawName, rawHex) in rawPalette { @@ -450,11 +367,8 @@ enum WorkspaceTabColorSettings { return trimmed.isEmpty ? nil : trimmed } - private static func nextCustomColorName( - existingNames: Set, - startingAt initialIndex: Int = 1 - ) -> String { - var index = max(1, initialIndex) + private static func nextCustomColorName(existingNames: Set) -> String { + var index = 1 while true { let candidate = "Custom \(index)" if !existingNames.contains(where: { $0.caseInsensitiveCompare(candidate) == .orderedSame }) { @@ -1782,39 +1696,11 @@ class TabManager: ObservableObject { tabs = selectedPinned + remainingPinned + selectedUnpinned + remainingUnpinned } - /// Moves the notifying workspace's top-level group (root ancestor plus its children) to the - /// top; a child never moves away from its parent. - func moveTabToTopForNotification(_ tabId: UUID) { - canonicalizeHierarchyOrderIfNeeded() - guard let rootId = hierarchyRootId(of: tabId), - let index = tabs.firstIndex(where: { $0.id == rootId }) else { return } - let pinnedCount = tabs.filter { $0.isPinned }.count - guard index != pinnedCount else { return } - let tab = tabs[index] - guard !tab.isPinned else { return } - var reordered = tabs - reordered.remove(at: index) - reordered.insert(tab, at: min(pinnedCount, reordered.count)) - tabs = reordered - } - /// The sidebar nests a workspace under its worktree parent, else its agent parent. private func hierarchyParentId(of workspace: Workspace) -> UUID? { workspace.worktreeParentWorkspaceId ?? workspace.agentParentWorkspaceId } - private func hierarchyRootId(of tabId: UUID) -> UUID? { - let byId = Dictionary(tabs.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) - guard var current = byId[tabId] else { return nil } - var visited: Set = [current.id] - while let parentId = hierarchyParentId(of: current), - let parent = byId[parentId], - visited.insert(parentId).inserted { - current = parent - } - return current.id - } - /// ⌘1-9, Move Up/Down and drop planning index `tabs`, so `tabs` must match the sidebar: /// every child directly after its parent (depth-first, siblings in their current order). /// Reorders of a parent therefore carry its children. Call after setting a parent id on a @@ -2115,12 +2001,25 @@ class TabManager: ObservableObject { tabId: UUID, surfaceId: UUID, branch: String, - isDirty: Bool + isDirty reportedDirty: Bool? ) { guard let tab = workspace(withId: tabId) else { return } let current = tab.panelGitBranches[surfaceId] + let isDirty = reportedDirty ?? current?.isDirty ?? false let normalizedBranch = GitMetadataProber.normalizedBranchName(branch) ?? branch - guard current?.branch != normalizedBranch || current?.isDirty != isDirty else { return } + guard current?.branch != normalizedBranch || current?.isDirty != isDirty else { + // A shell report without `dirty` is the prompt-time cue to re-check it: the shell no + // longer runs `git status`. A probe already in flight for this panel covers it. + let probeKey = WorkspaceGitProbeKey(workspaceId: tabId, panelId: surfaceId) + if reportedDirty == nil, workspaceGitProbeGenerationByKey[probeKey] == nil { + scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: tabId, + panelId: surfaceId, + reason: "shellReport" + ) + } + return + } tab.updatePanelGitBranch(panelId: surfaceId, branch: normalizedBranch, isDirty: isDirty) if let directory = gitProbeDirectory(for: tab, panelId: surfaceId) { let probeKey = WorkspaceGitProbeKey(workspaceId: tabId, panelId: surfaceId) diff --git a/Sources/TerminalController+Telemetry.swift b/Sources/TerminalController+Telemetry.swift index 233d8db7a..5c38aae3b 100644 --- a/Sources/TerminalController+Telemetry.swift +++ b/Sources/TerminalController+Telemetry.swift @@ -236,20 +236,23 @@ extension TerminalController { data: nil ) } - let isDirty = v2Bool(params, "dirty") ?? false + // Shell integration no longer sends `dirty`; the app's git probe owns it, so an absent + // value keeps what the probe last reported. + let isDirty = v2Bool(params, "dirty") v2ScheduleSurfaceTelemetryMutation(workspaceId: workspaceId, surfaceId: surfaceId) { tabManager, _, sid in tabManager.updateSurfaceGitBranch(tabId: workspaceId, surfaceId: sid, branch: branch, isDirty: isDirty) } - return .ok([ + var result: [String: Any] = [ "workspace_id": workspaceId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "branch": branch, - "dirty": isDirty, - ]) + ] + if let isDirty { result["dirty"] = isDirty } + return .ok(result) } nonisolated func v2SurfaceClearGitBranch(params: [String: Any]) -> V2CallResult { diff --git a/Sources/TerminalController+Worktree.swift b/Sources/TerminalController+Worktree.swift index 18dec80d6..9a6ac1b23 100644 --- a/Sources/TerminalController+Worktree.swift +++ b/Sources/TerminalController+Worktree.swift @@ -42,7 +42,13 @@ extension TerminalController { baseDirectory: worktreeBaseDirectory ) - let branchExisted = GitWorktreeManager.branchExistsLocally(branch, repoRoot: repoRoot) + guard let branchExisted = GitWorktreeManager.branchExistsLocally(branch, repoRoot: repoRoot) else { + return .err( + code: "git_command_failed", + message: "Could not determine whether branch '\(branch)' already exists", + data: nil + ) + } switch GitWorktreeManager.add(repoRoot: repoRoot, branch: branch, base: base, path: path) { case .success(let entry): let completion = v2MainSync { diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 464feab76..66de7440f 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -355,8 +355,6 @@ final class TerminalNotificationStore: ObservableObject { setFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId) } - owningTabManager?.moveTabToTopForNotification(tabId) - let notification = TerminalNotification( id: UUID(), tabId: tabId, diff --git a/Sources/TerminalSurface.swift b/Sources/TerminalSurface.swift index c9faaf072..0aa8e4fd1 100644 --- a/Sources/TerminalSurface.swift +++ b/Sources/TerminalSurface.swift @@ -35,7 +35,6 @@ struct TerminalSurfaceReviveDescriptor { /// creation, so the user sees prior output above the now-live session. /// Nil if no usable scrollback was available. let scrollbackText: String? - var retainedDescriptor: SessionEscrowRetainedDescriptor? = nil } final class TerminalSurfaceRegistry { @@ -992,7 +991,6 @@ final class TerminalSurface: Identifiable, ObservableObject { reviveReplayCanceled.withLock { $0 = true } if let leftoverReviveDescriptor = reviveDescriptor { - leftoverReviveDescriptor.retainedDescriptor?.markRecoveryPending() close(leftoverReviveDescriptor.masterFD) reviveDescriptor = nil } @@ -1577,7 +1575,6 @@ final class TerminalSurface: Identifiable, ObservableObject { // or hasn't consumed the descriptor at all yet (early returns // above, which leave `reviveDescriptor` set for a later retry). if let consumedReviveDescriptor { - consumedReviveDescriptor.retainedDescriptor?.markRecoveryPending() close(consumedReviveDescriptor.masterFD) } // A retry of `createSurface` reuses this same TerminalSurface @@ -1612,7 +1609,6 @@ final class TerminalSurface: Identifiable, ObservableObject { return } guard let createdSurface = surface else { return } - consumedReviveDescriptor?.retainedDescriptor?.releaseAfterOwnershipTransfer() TerminalSurfaceRegistry.shared.registerRuntimeSurface(createdSurface, ownerId: id) rendererRealized = true recordRuntimeSurfaceCreation() @@ -1960,14 +1956,12 @@ final class TerminalSurface: Identifiable, ObservableObject { SessionEscrowClient.shared.escrow( surfaceId: surfaceId, dupedMasterFD: dupedFD, - childPID: childPID, - retainedDescriptor: descriptor.retainedDescriptor + childPID: childPID ) { result in guard let result else { dilog("escrow.reattach", "early_reescrow session=\(surfaceId.prefix(8)) outcome=failed") return } - descriptor.retainedDescriptor?.releaseAfterOwnershipTransfer() dilog("escrow.reattach", "early_reescrow session=\(surfaceId.prefix(8)) outcome=ok") SessionWALStore.shared.stampDeferredReviveEscrow( surfaceId: surfaceId, diff --git a/Sources/VerticalTabsSidebar.swift b/Sources/VerticalTabsSidebar.swift index db5d449bd..23f62807d 100644 --- a/Sources/VerticalTabsSidebar.swift +++ b/Sources/VerticalTabsSidebar.swift @@ -60,13 +60,10 @@ struct SidebarTabItemSettingsSnapshot: Equatable { let sidebarShortcutHintYOffset: Double let alwaysShowShortcutHints: Bool let showsGitBranch: Bool - let usesVerticalBranchLayout: Bool let showsGitBranchIcon: Bool - let showsNotificationMessage: Bool let activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle let selectionColorHex: String? let notificationBadgeColorHex: String? - let visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility init(defaults: UserDefaults = .standard) { sidebarShortcutHintXOffset = Self.double( @@ -85,17 +82,7 @@ struct SidebarTabItemSettingsSnapshot: Equatable { defaultValue: ShortcutHintDebugSettings.defaultAlwaysShowHints ) showsGitBranch = Self.bool(defaults: defaults, key: "sidebarShowGitBranch", defaultValue: true) - usesVerticalBranchLayout = true showsGitBranchIcon = Self.bool(defaults: defaults, key: "sidebarShowGitBranchIcon", defaultValue: false) - showsNotificationMessage = true - visibleAuxiliaryDetails = SidebarWorkspaceAuxiliaryDetailVisibility( - showsMetadata: true, - showsLog: true, - showsProgress: true, - showsBranchDirectory: true, - showsPullRequests: true, - showsPorts: true - ) activeTabIndicatorStyle = SidebarActiveTabIndicatorSettings.current(defaults: defaults) selectionColorHex = defaults.string(forKey: "sidebarSelectionColorHex") @@ -195,10 +182,6 @@ struct VerticalTabsSidebar: View { WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal } - private var showsSidebarNotificationMessage: Bool { - tabItemSettingsStore.snapshot.showsNotificationMessage - } - private var workspaceNumberShortcut: StoredShortcut { let _ = keyboardShortcutSettingsObserver.revision return KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber) @@ -253,96 +236,104 @@ struct VerticalTabsSidebar: View { ) GeometryReader { proxy in - ScrollView { - VStack(spacing: 0) { - // Workspaces are bounded, so prefer a non-lazy stack here. - // LazyVStack + drag-state invalidations can recurse through layout. - VStack(spacing: tabRowSpacing) { - ForEach(visibleTabs, id: \.id) { tab in - let index = tabIndexById[tab.id] ?? 0 - let usesSelectedContextMenuTargets = selectedTabIds.contains(tab.id) - let contextMenuWorkspaceIds = usesSelectedContextMenuTargets - ? selectedContextTargetIds - : [tab.id] - let showsWorktreeBadge = tab.worktreeParentWorkspaceId != nil - let worktreeChildCount = SidebarWorkspaceHierarchy.childCount( - of: tab.id, - in: hierarchyEntries - ) - TabItemView( - tabManager: tabManager, - notificationStore: notificationStore, - tab: tab, - index: index, - isActive: tabManager.selectedTabId == tab.id, - workspaceShortcutDigit: WorkspaceShortcutMapper.digitForWorkspace( - at: index, - workspaceCount: workspaceCount - ), - workspaceShortcutModifierSymbol: workspaceNumberShortcut.numberedDigitHintPrefix, - canCloseWorkspace: canCloseWorkspace, - accessibilityWorkspaceCount: workspaceCount, - unreadCount: notificationStore.unreadCount(forTabId: tab.id), - latestNotificationText: { - guard showsSidebarNotificationMessage, - let notification = notificationStore.latestNotification(forTabId: tab.id) else { - return nil - } - let text = notification.body.isEmpty ? notification.title : notification.body - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - }(), - rowSpacing: tabRowSpacing, - setSelectionToTabs: { selection = .tabs }, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed, - dragAutoScrollController: dragAutoScrollController, - draggedTabIdSnapshot: draggedTabId, - dropIndicatorSnapshot: dropIndicator, - draggedTabId: $draggedTabId, - dropIndicator: $dropIndicator, - contextMenuWorkspaceIds: contextMenuWorkspaceIds, - settings: tabItemSettings, - showsWorktreeBadge: showsWorktreeBadge, - isWorktreeFolder: tab.isWorktreeFolder, - isWorktreeFolderCollapsed: tab.isWorktreeFolderCollapsed, - worktreeChildCount: worktreeChildCount, - rowMetrics: rowMetrics - ) - .equatable() - .padding( - .leading, - CGFloat(SidebarWorkspaceHierarchy.depth(of: tab.id, entries: hierarchyEntries)) * 14 - ) + ScrollViewReader { scrollProxy in + ScrollView { + VStack(spacing: 0) { + // Workspaces are bounded, so prefer a non-lazy stack here. + // LazyVStack + drag-state invalidations can recurse through layout. + VStack(spacing: tabRowSpacing) { + ForEach(visibleTabs, id: \.id) { tab in + let index = tabIndexById[tab.id] ?? 0 + let usesSelectedContextMenuTargets = selectedTabIds.contains(tab.id) + let contextMenuWorkspaceIds = usesSelectedContextMenuTargets + ? selectedContextTargetIds + : [tab.id] + let showsWorktreeBadge = tab.worktreeParentWorkspaceId != nil + let worktreeChildCount = SidebarWorkspaceHierarchy.childCount( + of: tab.id, + in: hierarchyEntries + ) + TabItemView( + tabManager: tabManager, + notificationStore: notificationStore, + tab: tab, + index: index, + isActive: tabManager.selectedTabId == tab.id, + workspaceShortcutDigit: WorkspaceShortcutMapper.digitForWorkspace( + at: index, + workspaceCount: workspaceCount + ), + workspaceShortcutModifierSymbol: workspaceNumberShortcut.numberedDigitHintPrefix, + canCloseWorkspace: canCloseWorkspace, + accessibilityWorkspaceCount: workspaceCount, + unreadCount: notificationStore.unreadCount(forTabId: tab.id), + latestNotificationText: { + guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { + return nil + } + let text = notification.body.isEmpty ? notification.title : notification.body + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + }(), + rowSpacing: tabRowSpacing, + setSelectionToTabs: { selection = .tabs }, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed, + dragAutoScrollController: dragAutoScrollController, + draggedTabIdSnapshot: draggedTabId, + dropIndicatorSnapshot: dropIndicator, + draggedTabId: $draggedTabId, + dropIndicator: $dropIndicator, + contextMenuWorkspaceIds: contextMenuWorkspaceIds, + settings: tabItemSettings, + showsWorktreeBadge: showsWorktreeBadge, + isWorktreeFolder: tab.isWorktreeFolder, + isWorktreeFolderCollapsed: tab.isWorktreeFolderCollapsed, + worktreeChildCount: worktreeChildCount, + rowMetrics: rowMetrics + ) + .equatable() + .padding( + .leading, + CGFloat(SidebarWorkspaceHierarchy.depth(of: tab.id, entries: hierarchyEntries)) * 14 + ) + .id(tab.id) + } } - } - .padding(.vertical, 8) - .padding(.horizontal, 8) - .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) - SidebarEmptyArea( - tabManager: tabManager, - rowSpacing: tabRowSpacing, - selection: $selection, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - dragAutoScrollController: dragAutoScrollController, - draggedTabId: $draggedTabId, - dropIndicator: $dropIndicator - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) + SidebarEmptyArea( + tabManager: tabManager, + rowSpacing: tabRowSpacing, + selection: $selection, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dragAutoScrollController: dragAutoScrollController, + draggedTabId: $draggedTabId, + dropIndicator: $dropIndicator + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(minHeight: proxy.size.height, alignment: .top) } - .frame(minHeight: proxy.size.height, alignment: .top) - } - .background( - SidebarScrollViewResolver { scrollView in - dragAutoScrollController.attach(scrollView: scrollView) + .background( + SidebarScrollViewResolver { scrollView in + dragAutoScrollController.attach(scrollView: scrollView) + } + .frame(width: 0, height: 0) + ) + .background(Color.clear) + .modifier(ClearScrollBackground()) + .onAppear { + scrollSelectedWorkspaceIntoView(scrollProxy, animated: false) } - .frame(width: 0, height: 0) - ) - .background(Color.clear) - .modifier(ClearScrollBackground()) + .onChange(of: tabManager.selectedTabId) { + scrollSelectedWorkspaceIntoView(scrollProxy, animated: true) + } + } } SidebarFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) .frame(maxWidth: .infinity, alignment: .leading) @@ -426,6 +417,22 @@ struct VerticalTabsSidebar: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + /// Scrolls the selected workspace into view by the minimum distance (nil anchor), so a row + /// that is already fully visible does not move. Deferred one runloop turn so a workspace + /// added in the same update has a laid-out row to target. + private func scrollSelectedWorkspaceIntoView(_ scrollProxy: ScrollViewProxy, animated: Bool) { + DispatchQueue.main.async { + guard let selectedId = tabManager.selectedTabId else { return } + if animated, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { + withAnimation(.easeOut(duration: 0.15)) { + scrollProxy.scrollTo(selectedId) + } + } else { + scrollProxy.scrollTo(selectedId) + } + } + } + private func debugShortSidebarTabId(_ id: UUID?) -> String { guard let id else { return "nil" } return String(id.uuidString.prefix(5)) diff --git a/Sources/Workspace+Persistence.swift b/Sources/Workspace+Persistence.swift index f55da6098..6c0efa038 100644 --- a/Sources/Workspace+Persistence.swift +++ b/Sources/Workspace+Persistence.swift @@ -661,27 +661,17 @@ extension Workspace { dilog("escrow.reattach", "session=\(oldSessionId.prefix(8)) outcome=fallback reason=not_escrowed") return nil } - // 2026-08-10 mass-drain fix: a pre-reason holder (the holder - // outlives the app, so an updated client talking to the previous - // version's holder is the NORMAL update path) can only deny with an - // unspecified reason. A heartbeat written within the death-detection - // window plus the retry window means the old app was alive seconds - // ago -- the retrieve-before-drain race signature -- so an - // unspecified denial is worth retrying; one long dead is not. - let heartbeatFreshEnoughToRetryUnspecifiedDeny = Date().timeIntervalSince(meta.lastHeartbeatAt) - < SessionEscrowPolicy.heartbeatStaleAfter + SessionEscrowPolicy.retrieveDenyRetryWindow let masterFD: Int32 switch SessionEscrowClient.retrieveOutcome( sessionId: oldSessionId, tokenHex: tokenHex, - socketPath: socketPath, - allowUnspecifiedDenyRetry: heartbeatFreshEnoughToRetryUnspecifiedDeny + socketPath: socketPath ) { case .granted(let fd): masterFD = fd case .denied(.unknownSession): // Only this explicit denial proves the holder no longer owns the session (token - // errors do not; see migrateLegacySessions). Clear the claim + // errors do not). Clear the claim // so later launches stop counting the dir as an escrowed orphan and rebuilding a // recovery window for it until the 24h sweep. if let paths = SessionWALPaths.make(sessionId: oldSessionId) { @@ -695,28 +685,10 @@ extension Workspace { } let scrollbackText = SessionWALStore.shared.readFallbackScrollbackText(sessionId: oldSessionId) - let retainedDescriptor: SessionEscrowRetainedDescriptor? - let descriptorFD: Int32 - if socketPath == SessionEscrowClient.legacySocketPath() { - let owner = SessionEscrowRetainedDescriptor.retain( - sessionId: oldSessionId, masterFD: masterFD, childPID: childPID, - tokenHex: tokenHex, socketPath: socketPath, recoveryPending: false - ) - guard let fd = owner.duplicate() else { - owner.markRecoveryPending() - return nil - } - retainedDescriptor = owner - descriptorFD = fd - } else { - retainedDescriptor = nil - descriptorFD = masterFD - } let reviveDescriptor = TerminalSurfaceReviveDescriptor( - masterFD: descriptorFD, + masterFD: masterFD, childPID: Int64(childPID), - scrollbackText: scrollbackText, - retainedDescriptor: retainedDescriptor + scrollbackText: scrollbackText ) guard let terminalPanel = newTerminalSurface( @@ -725,10 +697,6 @@ extension Workspace { workingDirectory: workingDirectory, reviveDescriptor: reviveDescriptor ) else { - retainedDescriptor?.markRecoveryPending() - // TerminalSurface closes its descriptor on failure. A legacy - // migration retains a separate master until Retry can establish - // acknowledged holder ownership, preserving the running child. dilog("escrow.reattach", "session=\(oldSessionId.prefix(8)) outcome=fallback reason=panel_creation_failed") return nil } diff --git a/Sources/Workspace+SidebarTelemetry.swift b/Sources/Workspace+SidebarTelemetry.swift index f429cfe33..0ed710f49 100644 --- a/Sources/Workspace+SidebarTelemetry.swift +++ b/Sources/Workspace+SidebarTelemetry.swift @@ -549,36 +549,6 @@ extension Workspace { return resolved } - func sidebarDirectoriesInDisplayOrder(orderedPanelIds: [UUID]) -> [String] { - let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds) - let homeDirectoryForCanonicalization = sidebarHomeDirectoryForCanonicalization( - resolvedPanelDirectories: resolvedDirectories - ) - var ordered: [String] = [] - var seen: Set = [] - - for panelId in orderedPanelIds { - guard let directory = resolvedDirectories[panelId], - let key = SidebarBranchOrdering.canonicalDirectoryKey( - directory, - homeDirectoryForTildeExpansion: homeDirectoryForCanonicalization - ) else { continue } - if seen.insert(key).inserted { - ordered.append(directory) - } - } - - if ordered.isEmpty, let fallbackDirectory = normalizedSidebarDirectory(currentDirectory) { - return [fallbackDirectory] - } - - return ordered - } - - func sidebarDirectoriesInDisplayOrder() -> [String] { - sidebarDirectoriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) - } - func sidebarGitBranchesInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarGitBranchState] { SidebarBranchOrdering .orderedUniqueBranches( diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 3001517a8..2ab1eb03f 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -65,8 +65,6 @@ Read by the app, the CLI and `programa-mcp`. Modes and discovery are described i | `PROGRAMA_CLAUDE_SESSION_ID_CACHE_KEY`, `PROGRAMA_CLAUDE_SESSION_ID_CACHE_VAL` | Session id cache shared between the `claude` wrapper invocations. | | `PROGRAMA_ORIGINAL_NODE_OPTIONS`, `PROGRAMA_ORIGINAL_NODE_OPTIONS_PRESENT` | The caller's `NODE_OPTIONS`, saved so the wrappers can restore it. | | `PROGRAMA_CLAUDE_TEAMS_PROGRAMA_BIN`, `PROGRAMA_CLAUDE_TEAMS_TERM` | `programa` binary and `TERM` for the Claude teams wrapper. | -| `PROGRAMA_OMO_PROGRAMA_BIN`, `PROGRAMA_OMO_TERM`, `PROGRAMA_OMX_PROGRAMA_BIN`, `PROGRAMA_OMX_TERM`, `PROGRAMA_OMC_PROGRAMA_BIN`, `PROGRAMA_OMC_TERM` | The same pair for the OpenCode, Codex and Claude agent wrappers. | -| `PROGRAMA_THEME_PICKER_CONFIG`, `_BUNDLE_ID`, `_TARGET`, `_COLOR_SCHEME`, `_INITIAL_LIGHT`, `_INITIAL_DARK` | Set by `programa themes` for Ghostty's `+list-themes` helper so it edits Programa's managed theme override. | ## App behavior and diagnostics @@ -105,13 +103,11 @@ no app, CLI or daemon code reads them. | Variable | Read by | Meaning | |---|---|---| -| `PROGRAMA_SKIP_ZIG_BUILD` | `reload.sh`, `build-ghostty-cli-helper.sh` | `1` skips the Zig builds and bundles a stub Ghostty CLI helper. The CI unit-test shards set it. | | `ZIG_DIST_CACHE_DIR`, `ZIG_INSTALL_ROOT` | `install-zig.sh` | Where the checksum-verified zig tarball is kept (default `~/.cache/programa-zig-dist`, cached by CI) and where it is extracted (default `~/.local/share/programa-zig`). | | `PROGRAMA_ENSURE_GHOSTTYKIT_COMMAND` | `reload.sh`, `reloadp.sh`, `reloads.sh` | Command to run instead of `scripts/ensure-ghosttykit.sh`. | | `PROGRAMA_GHOSTTYKIT_CACHE_DIR` | `ensure-ghosttykit.sh` | GhosttyKit cache folder. Default `~/.cache/programa/ghosttykit`. | | `PROGRAMA_GHOSTTYKIT_LOCK_TIMEOUT`, `PROGRAMA_GHOSTTYKIT_LOCK_POLL_INTERVAL` | `ensure-ghosttykit.sh` | Seconds to wait for the cache lock (default 300) and between polls (default 1). | | `PROGRAMA_RELOAD_CACHE_MANAGED` | `reload.sh`, `tagged-build-cache.py` | `1` marks a reload that the tagged build cache already wraps. | -| `PROGRAMA_PREBUILT_APP_PATH` | `tests/test_bundled_ghostty_theme_picker_helper.sh` | Path of an already built `Programa DEV.app` to test. | | `PROGRAMA_DERIVED_DATA_DIR` | CI scripts | DerivedData folder of the job. Required by `run-tests-v2-ci.sh` and `smoke-test-ci.sh`. | | `PROGRAMA_XCODEBUILD_COMMAND`, `PROGRAMA_SOURCE_PACKAGES_DIR`, `PROGRAMA_SWIFTPM_CACHE_DIR`, `PROGRAMA_TEST_OUTPUT_FILE`, `PROGRAMA_RESULT_BUNDLE_ROOT` | `ci-run-unit-tests.sh` | `xcodebuild` command, Swift package and cache folders, output file and `.xcresult` folder. | | `PROGRAMA_UNIT_TEST_SCOPE`, `PROGRAMA_UNIT_TEST_SHARD`, `PROGRAMA_UNIT_TEST_SHARD_COUNT`, `PROGRAMA_UNIT_TEST_QUARANTINE` | `ci-run-unit-tests.sh` | Which unit tests to run, and the quarantine list. | diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index c6e6340ae..8bbdde82f 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -69,7 +69,7 @@ tend to conflict together during rebases. - Adds a parser for kitty OSC 99 notifications and wires it into the OSC dispatcher. - Adapts the parser to upstream's newer capture API so the Programa OSC 99 hook survives the March 30 upstream sync. -### 4) Programa theme picker helper hooks +### 4) Programa theme picker helper hooks (unused) - Commits: - `1da7281fd` (Add Programa theme picker helper hooks) @@ -87,6 +87,7 @@ tend to conflict together during rebases. - Adds a `zig build cli-helper` step so Programa can bundle Ghostty's CLI helper binary on macOS. - Lets `+list-themes` switch into a Programa-managed mode via env vars, writing the Programa theme override file and posting the existing Programa reload notification for live app-wide preview. - Keeps the preview UI readable in light mode, matches upstream picker startup behavior, and hardens writes to the Programa-managed theme override file. + - Programa does not build or bundle the helper: `programa themes` lists and sets themes in Swift. These patches stay on the fork branch only so the pin does not move; drop the group at the next fork rebase. ### 5) Color scheme mode 2031 reporting @@ -246,15 +247,6 @@ These files change frequently upstream; be careful when rebasing the fork: - `src/terminal/osc/parsers.zig` - Ensure `kitty_notification` stays imported after upstream parser reorganizations. -- `src/cli/list_themes.zig` - - Programa now relies on the upstream picker UI plus local env-driven hooks for live preview and restore. - If upstream reorganizes the preview loop or key handling, re-check the Programa mode path and keep the - stock Ghostty behavior unchanged when the Programa env vars are absent. - -- `build.zig` - - Upstream's new wasm/libghostty work touched the same build graph. Keep the Programa-only `cli-helper` - step wired in without regressing the upstream `lib-vt` or wasm build paths. - - `include/ghostty.h`, `src/Surface.zig`, `src/apprt/embedded.zig`, `src/termio/Exec.zig` - Upstream removed Programa-used selection exports. Preserve the re-exported `ghostty_surface_select_cursor_cell` and `ghostty_surface_clear_selection` functions. diff --git a/docs/removed/README.md b/docs/removed/README.md index 8c9c91499..ef2aab674 100644 --- a/docs/removed/README.md +++ b/docs/removed/README.md @@ -18,5 +18,6 @@ Reductive pass of 2026-09-02, base commit 903027ccef. Core kept: the Ghostty ter | tmux overlay experiment (active-pane and Bonsplit-pane targets, window overlay) | [tmux-active-pane-overlay.md](tmux-active-pane-overlay.md) | 1,090 | | cmux upgrade shims (old config paths, defaults migration, env and alias fallbacks) | [cmux-upgrade-shims.md](cmux-upgrade-shims.md) | 100 | | Settings options (editor, reorder, opacity and blur overrides, notification command, long-command threshold, sidebar tints, ports, Claude path, workspace colors, hold hints) | [settings-knobs-2026-10.md](settings-knobs-2026-10.md) | 2,100 | +| omo, omx and omc agent wrappers (`programa omo`, `omx`, `omc`) | [omo-omx-omc-wrappers.md](omo-omx-omc-wrappers.md) | 750 | Line counts are git-tracked lines at removal time and include tests and vendored code. diff --git a/docs/removed/omo-omx-omc-wrappers.md b/docs/removed/omo-omx-omc-wrappers.md new file mode 100644 index 000000000..e8cb6a88d --- /dev/null +++ b/docs/removed/omo-omx-omc-wrappers.md @@ -0,0 +1,31 @@ +# omo, omx and omc agent wrappers + +Removed 2026-10-19. Last present at commit ab2e3c394b. Restore with `git checkout ab2e3c394b -- CLI/CLI+AgentWrappers.swift CLI/CLICommandDispatcher.swift CLI/programa.swift Resources/Localizable.xcstrings docs/environment-variables.md tests_v2/test_tmux_compat_geometry.py`. Those files have changed since, so prefer `git show ab2e3c394b:` and copy the removed pieces back by hand. + +## What it did + +Three CLI commands launched a third-party agent add-on inside a Programa terminal and turned the add-on's tmux panes into Programa splits: + +- `programa omo [opencode-args...]` ran OpenCode with the oh-my-openagent plugin (npm package `oh-my-opencode`). +- `programa omx [omx-args...]` ran Oh My Codex (`omx`, npm package `oh-my-codex`). +- `programa omc [omc-args...]` ran Oh My Claude Code (`omc`, npm package `oh-my-claude-sisyphus`). + +Each command put a private `tmux` shim first on `PATH`. The shim answered `tmux -V` locally and forwarded everything else to `programa __tmux-compat`, so the add-on's team mode, HUD and agent panes became Programa workspaces and splits. They shared one launcher (`runAgentWrapper`) with `programa claude-teams`, which stays. + +Command-specific parts: + +- `omo` wrote a shadow OpenCode config under `~/.programa/omo-config` (the user's `opencode.json` plus the plugin entry, a symlink to the user's `node_modules`, its own `package.json` pinning `oh-my-opencode` to an exact version, and a `tmux` block with smaller pane minimum widths), installed the plugin with bun or npm on first run, and pointed `OPENCODE_CONFIG_DIR` at it. It picked an `OPENCODE_PORT` (4096 if free, else a free loopback port) and passed `--port` to OpenCode. Its shim directory also held a `terminal-notifier` shim that routed notifications to `programa notify`. +- `omc` also injected the `NODE_OPTIONS` restore module that `claude-teams` uses, because it wraps Claude Code. +- Environment overrides: `PROGRAMA_OMO_PROGRAMA_BIN`, `PROGRAMA_OMO_TERM`, `PROGRAMA_OMX_PROGRAMA_BIN`, `PROGRAMA_OMX_TERM`, `PROGRAMA_OMC_PROGRAMA_BIN`, `PROGRAMA_OMC_TERM`. + +## Files removed and files edited + +No files were deleted. The wrappers were cut out of `CLI/CLI+AgentWrappers.swift` (about 670 lines), the three dispatch cases in `CLI/CLICommandDispatcher.swift`, the command descriptors and the local-command list in `CLI/programa.swift`, the `cli.omo.usage`, `cli.omx.usage` and `cli.omc.usage` strings in `Resources/Localizable.xcstrings`, the environment variable row in `docs/environment-variables.md`, and a stale `PROGRAMA_OMO_PROGRAMA_BIN` assignment in `tests_v2/test_tmux_compat_geometry.py`. The shared launcher lost the hooks only `omo` used (plugin setup, port selection, not-found check). + +## What we learned + +No test or doc covered these commands, so nothing verified them after the add-ons changed upstream. The `omo` plugin version was pinned (5.1.7) on purpose: a floating `latest` would run every new npm release's install scripts as the user, so bump a pin only after reviewing the release. The `tmux -V` probe has to be answered without a socket connection, and only when `-V` or `-v` is the first argument; `split-window -v` is a vertical split. Machines that ran `omo` keep `~/.programa/omo-config`; nothing deletes it. + +## Why removed + +The commands were undocumented and untested, and the regular `programa claude`, `codex` and `opencode` integrations cover the supported agent flows. diff --git a/docs/terminal-themes.md b/docs/terminal-themes.md index 04d4f42a6..e83148e46 100644 --- a/docs/terminal-themes.md +++ b/docs/terminal-themes.md @@ -13,6 +13,8 @@ programa themes set --opacity 0.85 --blur --font "JetBrains Mono" --font-size 13 programa themes clear ``` +`programa themes` with no subcommand prints the same list as `programa themes list`. `programa themes set` with a name that matches no theme exits with an error and suggests close matches. + All surfaces write the same managed block in `~/Library/Application Support/com.darkroom.programa/config.ghostty`, preserving unrelated directives in that file. Setting one field (e.g. opacity) never disturbs another field Programa already manages there (e.g. theme or font) — but it also never captures a field it doesn't yet manage, even if that field currently resolves to a value from your own raw Ghostty config. Programa only ever writes the specific field you changed. ## settings.json diff --git a/programaTests/ConfigFixSweepRegressionTests.swift b/programaTests/ConfigFixSweepRegressionTests.swift index 563177971..df91448b2 100644 --- a/programaTests/ConfigFixSweepRegressionTests.swift +++ b/programaTests/ConfigFixSweepRegressionTests.swift @@ -136,6 +136,273 @@ final class ConfigFixSweepRegressionTests: XCTestCase { ) } + // MARK: - Filter drivers are repository-controlled commands + + /// A repo whose tracked `a.txt` is modified and whose `.gitattributes` routes `*.txt` to + /// the filter driver `driver`. The driver is configured last so the fixture's own commands + /// never run it; `configure` receives the repo to set the driver up. The edit keeps the + /// file's size: a size change lets `git status` mark it modified from the index alone, + /// without reading the content through the clean filter. + private func makeFilterRepo(driver: String, configure: (URL) throws -> Void) throws -> URL { + let repo = try makeRepo(branch: "feature-x", fileName: "a.txt") + try "*.txt filter=\(driver)\n".write( + to: repo.appendingPathComponent(".gitattributes"), atomically: true, encoding: .utf8) + try "two\n".write( + to: repo.appendingPathComponent("a.txt"), atomically: true, encoding: .utf8) + try configure(repo) + return repo + } + + func testProbesNeverRunRepositoryLocalFilterDriver() throws { + let marker = tempRoot.appendingPathComponent("filter-ran") + let repo = try makeFilterRepo(driver: "evil") { repo in + try configFixtureGit(["config", "filter.evil.clean", "touch \(marker.path); cat"], in: repo) + } + + // Positive control: plain git runs the clean filter for the modified file. + try configFixtureGit(["status", "--porcelain"], in: repo) + XCTAssertTrue( + FileManager.default.fileExists(atPath: marker.path), + "control: plain `git status` should execute the repository's clean filter" + ) + try FileManager.default.removeItem(at: marker) + + let snapshot = GitMetadataProber.initialWorkspaceGitMetadataSnapshot(for: repo.path) + let review = ReviewDiffProber.diffSnapshot(directory: repo.path, mode: .uncommitted, baseBranch: "main") + + XCTAssertEqual(snapshot.branch, "feature-x") + XCTAssertTrue(snapshot.isDirty) + XCTAssertNil(review.error) + XCTAssertFalse( + FileManager.default.fileExists(atPath: marker.path), + "a background git probe executed the repository's filter driver" + ) + } + + /// Git LFS is configured in the user's global config; its filter must keep working or every + /// LFS file would read as modified. + func testProbesKeepFilterDriverDefinedOnlyInGlobalConfig() throws { + let marker = tempRoot.appendingPathComponent("global-filter-ran") + let globalConfig = tempRoot.appendingPathComponent("global-gitconfig") + try "[filter \"trusted\"]\n\tclean = touch \(marker.path); cat\n" + .write(to: globalConfig, atomically: true, encoding: .utf8) + let originalGlobal = ProcessInfo.processInfo.environment["GIT_CONFIG_GLOBAL"] + setenv("GIT_CONFIG_GLOBAL", globalConfig.path, 1) + defer { + if let originalGlobal { setenv("GIT_CONFIG_GLOBAL", originalGlobal, 1) } else { unsetenv("GIT_CONFIG_GLOBAL") } + } + let repo = try makeFilterRepo(driver: "trusted") { _ in } + + _ = GitMetadataProber.initialWorkspaceGitMetadataSnapshot(for: repo.path) + + XCTAssertTrue( + FileManager.default.fileExists(atPath: marker.path), + "a filter driver defined only by the user's global config must still run" + ) + } + + func testFilterOverridesCoverOnlyDriversRepositoryConfigDefinesOrChanges() { + let output = [ + "global", "filter.lfs.clean\ngit-lfs clean -- %f", + "global", "filter.lfs.required\ntrue", + "global", "filter.same.clean\ncat", + "local", "filter.same.clean\ncat", + "global", "filter.hijacked.clean\ncat", + "local", "filter.hijacked.clean\ntouch x", + "local", "filter.dotted.name.process\ntouch y", + "local", "filter.a=b.smudge\ncat", + "command", "filter.lfs.clean\ngit-lfs clean -- %f", + ].joined(separator: "\0") + "\0" + + let keys = CanonicalSubprocessRunner.untrustedFilterDriverConfig(fromShowScopeOutput: output) + .map { "\($0.key)=\($0.value)" } + + XCTAssertEqual(keys, [ + "filter.hijacked.clean=", "filter.hijacked.smudge=", "filter.hijacked.process=", "filter.hijacked.required=false", + "filter.dotted.name.clean=", "filter.dotted.name.smudge=", "filter.dotted.name.process=", "filter.dotted.name.required=false", + "filter.a=b.clean=", "filter.a=b.smudge=", "filter.a=b.process=", "filter.a=b.required=false", + ]) + } + + /// Git LFS runs `lfs.extension..clean` from the repository's own config inside the + /// `filter.lfs` driver, so a repository-local extension makes the trusted global driver a + /// command runner for that repository. + func testRepositoryLocalLFSExtensionNeutralizesTheGlobalLFSFilter() { + let output = [ + "global", "filter.lfs.clean\ngit-lfs clean -- %f", + "global", "filter.lfs.required\ntrue", + "local", "lfs.extension.x.clean\ntouch x", + ].joined(separator: "\0") + "\0" + + let keys = CanonicalSubprocessRunner.untrustedFilterDriverConfig(fromShowScopeOutput: output) + .map { "\($0.key)=\($0.value)" } + + XCTAssertEqual(keys, [ + "filter.lfs.clean=", "filter.lfs.smudge=", "filter.lfs.process=", "filter.lfs.required=false", + ]) + } + + func testLFSExtensionNeutralizesFilterLFSEvenWhenNoFilterKeyIsListed() { + let output = ["worktree", "lfs.extension.x.clean\ntouch x"].joined(separator: "\0") + "\0" + + let keys = CanonicalSubprocessRunner.untrustedFilterDriverConfig(fromShowScopeOutput: output) + .map(\.key) + + XCTAssertEqual(keys, ["filter.lfs.clean", "filter.lfs.smudge", "filter.lfs.process", "filter.lfs.required"]) + } + + func testMixedCaseLFSExtensionStillNeutralizesFilterLFS() { + let output = ["local", "lfs.Extension.x.clean\ntouch x"].joined(separator: "\0") + "\0" + + let keys = CanonicalSubprocessRunner.untrustedFilterDriverConfig(fromShowScopeOutput: output) + .map(\.key) + + XCTAssertEqual(keys, ["filter.lfs.clean", "filter.lfs.smudge", "filter.lfs.process", "filter.lfs.required"]) + } + + /// A non-repository directory's cached "no filters" answer must not outlive a repository + /// appearing in it, or the next probe runs git there without the filter overrides. + func testRepositoryConfigFingerprintChangesWhenARepositoryAppears() throws { + let parent = FileManager.default.temporaryDirectory + .appendingPathComponent("fingerprint-\(UUID().uuidString)") + let directory = parent.appendingPathComponent("work") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: parent) } + + let before = CanonicalSubprocessRunner.repositoryConfigFingerprint(directory: directory.path) + XCTAssertEqual(before, CanonicalSubprocessRunner.repositoryConfigFingerprint(directory: directory.path)) + + try configFixtureGit(["init", "-q"], in: directory) + let afterInit = CanonicalSubprocessRunner.repositoryConfigFingerprint(directory: directory.path) + XCTAssertNotEqual(before, afterInit) + + try configFixtureGit(["config", "filter.x.clean", "cat"], in: directory) + XCTAssertNotEqual(afterInit, CanonicalSubprocessRunner.repositoryConfigFingerprint(directory: directory.path)) + } + + func testGlobalLFSExtensionLeavesTheGlobalLFSFilterAlone() { + let output = [ + "global", "filter.lfs.clean\ngit-lfs clean -- %f", + "global", "lfs.extension.x.clean\ntrusted-tool", + "local", "lfs.repositoryformatversion\n0", + ].joined(separator: "\0") + "\0" + + XCTAssertTrue(CanonicalSubprocessRunner.untrustedFilterDriverConfig(fromShowScopeOutput: output).isEmpty) + } + + func testProbesNeverRunGlobalLFSFilterForRepositoryWithLocalLFSExtension() throws { + let marker = tempRoot.appendingPathComponent("lfs-filter-ran") + let globalConfig = tempRoot.appendingPathComponent("global-lfs-gitconfig") + try "[filter \"lfs\"]\n\tclean = touch \(marker.path); cat\n" + .write(to: globalConfig, atomically: true, encoding: .utf8) + let originalGlobal = ProcessInfo.processInfo.environment["GIT_CONFIG_GLOBAL"] + setenv("GIT_CONFIG_GLOBAL", globalConfig.path, 1) + defer { + if let originalGlobal { setenv("GIT_CONFIG_GLOBAL", originalGlobal, 1) } else { unsetenv("GIT_CONFIG_GLOBAL") } + } + let repo = try makeFilterRepo(driver: "lfs") { repo in + try configFixtureGit(["config", "lfs.extension.x.clean", "touch \(marker.path); cat"], in: repo) + } + + let snapshot = GitMetadataProber.initialWorkspaceGitMetadataSnapshot(for: repo.path) + + XCTAssertEqual(snapshot.branch, "feature-x") + XCTAssertFalse( + FileManager.default.fileExists(atPath: marker.path), + "a repository-local lfs.extension reached the trusted global filter.lfs driver" + ) + } + + // MARK: - Submodules are probed without their own config + + /// `git status` and `git diff` spawn a child `git status` inside each populated submodule, + /// which reads the submodule's own config and does not inherit the parent's overrides. + func testProbesNeverRunFilterDriverConfiguredInASubmodule() throws { + let marker = tempRoot.appendingPathComponent("submodule-filter-ran") + let upstream = try makeRepo(branch: "main", fileName: "a.txt", contents: "a\n") + let parent = try makeRepo(branch: "feature-x") + try configFixtureGit(["-c", "protocol.file.allow=always", "submodule", "add", "-q", upstream.path, "sub"], in: parent) + try configFixtureGit(["commit", "-q", "-m", "add submodule"], in: parent) + let checkout = parent.appendingPathComponent("sub", isDirectory: true) + try "*.txt filter=evil\n".write( + to: checkout.appendingPathComponent(".gitattributes"), atomically: true, encoding: .utf8) + // Same size, new mtime: git has to run the clean filter to learn whether it changed. + try "b\n".write(to: checkout.appendingPathComponent("a.txt"), atomically: true, encoding: .utf8) + try configFixtureGit(["config", "filter.evil.clean", "touch \(marker.path); cat"], in: checkout) + + try configFixtureGit(["status", "--porcelain", "-uno"], in: parent) + XCTAssertTrue( + FileManager.default.fileExists(atPath: marker.path), + "control: plain `git status` should run the submodule's clean filter" + ) + try FileManager.default.removeItem(at: marker) + + let snapshot = GitMetadataProber.initialWorkspaceGitMetadataSnapshot(for: parent.path) + let review = ReviewDiffProber.diffSnapshot(directory: parent.path, mode: .uncommitted, baseBranch: "main") + + XCTAssertNil(review.error) + XCTAssertFalse( + FileManager.default.fileExists(atPath: marker.path), + "a background git probe executed a filter driver configured in a submodule" + ) + + // Only the submodule's working tree was ignored: a moved gitlink commit still counts. + XCTAssertFalse(snapshot.isDirty) + try configFixtureGit(["commit", "-q", "-a", "-m", "move"], in: checkout) + XCTAssertTrue(GitMetadataProber.initialWorkspaceGitMetadataSnapshot(for: parent.path).isDirty) + } + + func testAutomaticGitEnvironmentCarriesFilterOverridesVerbatim() { + let environment = CanonicalSubprocessRunner.gitConfigEnvironment([ + ("core.fsmonitor", "false"), + ("filter.a=b.clean", ""), + ]) + + XCTAssertEqual(environment["GIT_CONFIG_COUNT"], "2") + XCTAssertEqual(environment["GIT_CONFIG_KEY_1"], "filter.a=b.clean") + XCTAssertEqual(environment["GIT_CONFIG_VALUE_1"], "") + } + + // MARK: - Filter overrides are not cached for "not a repository" or "no filters" + + private func configKeys(in environment: [String: String]?) -> Set { + Set((environment ?? [:]).filter { $0.key.hasPrefix("GIT_CONFIG_KEY_") }.values) + } + + func testFilterOverridesAreReadAgainOnceADirectoryBecomesARepository() throws { + let directory = tempRoot.appendingPathComponent("extracted-later", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let before = CanonicalSubprocessRunner.automaticGitEnvironment(directory: directory.path) + XCTAssertNotNil(before, "a directory that is not a repository is not a config read failure") + XCTAssertFalse(configKeys(in: before).contains("filter.evil.clean")) + + try configFixtureGit(["init", "-q", "-b", "main"], in: directory) + try configFixtureGit(["config", "filter.evil.clean", "touch x; cat"], in: directory) + + XCTAssertTrue( + configKeys(in: CanonicalSubprocessRunner.automaticGitEnvironment(directory: directory.path)) + .contains("filter.evil.clean"), + "the empty answer for a non-repository was cached and reused for the repository that appeared" + ) + } + + func testFilterOverridesAreReadAgainWhenARepositoryGainsFilterConfig() throws { + let repo = try makeRepo(branch: "main") + + XCTAssertFalse( + configKeys(in: CanonicalSubprocessRunner.automaticGitEnvironment(directory: repo.path)) + .contains("filter.evil.clean") + ) + try configFixtureGit(["config", "filter.evil.clean", "touch x; cat"], in: repo) + + XCTAssertTrue( + configKeys(in: CanonicalSubprocessRunner.automaticGitEnvironment(directory: repo.path)) + .contains("filter.evil.clean"), + "the empty answer for a repository without filters was cached and reused" + ) + } + // MARK: - CONF-02: review diff survives hostile or surprising git config and non-ASCII paths /// `diff.external` would replace git's diff output with a program's, and `diff.noprefix` @@ -215,7 +482,46 @@ final class ConfigFixSweepRegressionTests: XCTestCase { guard case .gitCommandFailed = outcome else { return XCTFail("expected .gitCommandFailed for base \"--detach\", got \(outcome)") } - XCTAssertFalse(GitWorktreeManager.branchExistsLocally("x", repoRoot: repo.path)) + XCTAssertEqual(GitWorktreeManager.branchExistsLocally("x", repoRoot: repo.path), false) + XCTAssertFalse(FileManager.default.fileExists(atPath: worktreePath)) + } + + // MARK: - An unknown branch state never leads to a deleted branch + + func testBranchExistsLocallyReportsPresentAbsentAndUnknown() throws { + let repo = try makeRepo(branch: "main") + let notARepository = tempRoot.appendingPathComponent("plain", isDirectory: true) + try FileManager.default.createDirectory(at: notARepository, withIntermediateDirectories: true) + + XCTAssertEqual(GitWorktreeManager.branchExistsLocally("main", repoRoot: repo.path), true) + XCTAssertEqual(GitWorktreeManager.branchExistsLocally("nope", repoRoot: repo.path), false) + XCTAssertNil(GitWorktreeManager.branchExistsLocally("main", repoRoot: notARepository.path)) + } + + /// A config git cannot parse makes every probe fail, so whether the branch exists is + /// unknown. The user's branch must survive both the add and the cleanup helper. + func testWorktreeAddRefusesAndKeepsBranchWhenBranchExistenceIsUnknown() throws { + let repo = try makeRepo(branch: "main") + try configFixtureGit(["branch", "keep"], in: repo) + let configURL = repo.appendingPathComponent(".git/config") + let goodConfig = try String(contentsOf: configURL, encoding: .utf8) + let worktreePath = tempRoot.appendingPathComponent("wt-keep").path + try (goodConfig + "[broken\n").write(to: configURL, atomically: true, encoding: .utf8) + var configRestored = false + defer { + if !configRestored { try? goodConfig.write(to: configURL, atomically: true, encoding: .utf8) } + } + + XCTAssertNil(GitWorktreeManager.branchExistsLocally("keep", repoRoot: repo.path)) + let outcome = GitWorktreeManager.add(repoRoot: repo.path, branch: "keep", base: nil, path: worktreePath) + guard case .gitCommandFailed = outcome else { + return XCTFail("expected .gitCommandFailed when branch existence is unknown, got \(outcome)") + } + XCTAssertFalse(GitWorktreeManager.deleteBranchIfUnused("keep", repoRoot: repo.path)) + + try goodConfig.write(to: configURL, atomically: true, encoding: .utf8) + configRestored = true + XCTAssertEqual(GitWorktreeManager.branchExistsLocally("keep", repoRoot: repo.path), true) XCTAssertFalse(FileManager.default.fileExists(atPath: worktreePath)) } } diff --git a/programaTests/EscrowFDPassingRegressionTests.swift b/programaTests/EscrowFDPassingRegressionTests.swift index 2b31bd6e2..dcc34e0d1 100644 --- a/programaTests/EscrowFDPassingRegressionTests.swift +++ b/programaTests/EscrowFDPassingRegressionTests.swift @@ -91,23 +91,6 @@ final class EscrowFDPassingRegressionTests: XCTestCase { XCTAssertTrue(isCloseOnExec(received), "a received PTY master must not be inherited by a concurrent exec") } - func testRetainedDescriptorDuplicateIsCloseOnExec() throws { - let pipeEnds = try makePipe() - // Ownership of the read end moves to the registry; releaseAfterOwnershipTransfer closes it. - let master = fcntl(pipeEnds.read, F_DUPFD, 0) - XCTAssertGreaterThanOrEqual(master, 0) - let owner = SessionEscrowRetainedDescriptor.retain( - sessionId: UUID().uuidString, masterFD: master, childPID: 0, - tokenHex: String(repeating: "ab", count: 32), socketPath: "/tmp/programa-sess01-unused.sock", - recoveryPending: false - ) - defer { owner.releaseAfterOwnershipTransfer() } - guard let copy = owner.duplicate() else { return XCTFail("duplicate returned nil") } - _ = track(copy) - XCTAssertNotEqual(copy, master) - XCTAssertTrue(isCloseOnExec(copy), "the duplicate handed to a holder must not leak into spawned children") - } - func testPeerAttachingTwoDescriptorsLeaksNeitherExtra() throws { let (sender, receiver) = try makeSocketPair() let a = try makePipe() diff --git a/programaTests/EscrowFixSweepSESSTests.swift b/programaTests/EscrowFixSweepSESSTests.swift index b9eb169ea..9f6ed106d 100644 --- a/programaTests/EscrowFixSweepSESSTests.swift +++ b/programaTests/EscrowFixSweepSESSTests.swift @@ -79,35 +79,6 @@ final class EscrowFixSweepSESSTests: XCTestCase { XCTAssertFalse(SessionEscrowHolder.constantTimeTokensEqual([], token)) } - // MARK: SESS-07 legacy scan tolerates a corrupt meta.json - - func testLegacySessionsSkipsUnreadableMetaAndReturnsValidOne() throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("sess07-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let socketPath = "/tmp/programa-sess07-\(UUID().uuidString.prefix(8)).sock" - let validId = UUID().uuidString - let garbageId = UUID().uuidString - let validDir = root.appendingPathComponent(validId, isDirectory: true) - let garbageDir = root.appendingPathComponent(garbageId, isDirectory: true) - try FileManager.default.createDirectory(at: validDir, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: garbageDir, withIntermediateDirectories: true) - - var meta = SessionWALMeta(sessionId: validId, lastHeartbeatAt: Date()) - meta.escrowed = true - meta.escrowSocketPath = socketPath - meta.escrowToken = String(repeating: "cd", count: 32) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - try encoder.encode(meta).write(to: SessionWALPaths(sessionDirectory: validDir).metaURL) - try Data("{ not json".utf8).write(to: SessionWALPaths(sessionDirectory: garbageDir).metaURL) - - let found = try SessionEscrowClient.legacySessions(root: root, legacySocketPath: socketPath) - XCTAssertEqual(found.map(\.sessionId), [validId]) - } - // MARK: SESS-09 history rotation is scoped to the rotating bundle func testRotationPrunesOnlyOwnBundleArchives() throws { diff --git a/programaTests/NotificationAndMenuBarTests.swift b/programaTests/NotificationAndMenuBarTests.swift index b13592c76..74d62ab66 100644 --- a/programaTests/NotificationAndMenuBarTests.swift +++ b/programaTests/NotificationAndMenuBarTests.swift @@ -599,6 +599,53 @@ final class FocusedNotificationIndicatorTests: XCTestCase { XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) } + func testNotificationDoesNotReorderWorkspaces() { + let originalShared = AppDelegate.shared + let appDelegate = AppDelegate() + AppDelegate.shared = appDelegate + defer { AppDelegate.shared = originalShared } + let manager = TabManager() + let owningWindow = registerFocusedNotificationWindow(appDelegate: appDelegate, manager: manager) + defer { withExtendedLifetime(owningWindow) {} } + let store = TerminalNotificationStore.shared + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = true + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + let first = manager.tabs[0] + let second = manager.addWorkspace(select: false) + let third = manager.addWorkspace(select: false) + manager.tabs = [first, second, third] + manager.selectWorkspace(first) + let orderBefore = manager.tabs.map(\.id) + + store.addNotification( + tabId: third.id, + surfaceId: nil, + title: "Background", + subtitle: "", + body: "" + ) + + XCTAssertEqual(manager.tabs.map(\.id), orderBefore, "A notification must not move its workspace") + XCTAssertTrue(store.hasUnreadNotification(forTabId: third.id, surfaceId: nil)) + } + func testNewNotificationOnDifferentSurfaceClearsPreviousFocusedReadIndicator() { let appDelegate = AppDelegate.shared ?? AppDelegate() let manager = TabManager() diff --git a/programaTests/SessionPersistenceTests.swift b/programaTests/SessionPersistenceTests.swift index 56d6f7365..0c6c34761 100644 --- a/programaTests/SessionPersistenceTests.swift +++ b/programaTests/SessionPersistenceTests.swift @@ -2051,6 +2051,58 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: sessions.appendingPathComponent(abandonedID.uuidString).path)) } + func testOrphanSweepIgnoresStaleForeignSnapshotsButKeepsFreshForeignAndOwn() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let sessions = root.appendingPathComponent("sessions") + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let now = Date() + let day: TimeInterval = 24 * 60 * 60 + let ownID = UUID(), freshForeignID = UUID(), staleForeignID = UUID(), unownedID = UUID() + let staleReleaseID = UUID() + for id in [ownID, freshForeignID, staleForeignID, unownedID, staleReleaseID] { + let directory = sessions.appendingPathComponent(id.uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try FileManager.default.setAttributes( + [.modificationDate: now.addingTimeInterval(-SessionWALPolicy.orphanDirectoryMaxAge - 1)], + ofItemAtPath: directory.path + ) + } + func writeSnapshot(_ id: UUID, to url: URL, age: TimeInterval) throws { + XCTAssertTrue(SessionPersistenceStore.save(makeOwnershipSnapshot(id), fileURL: url)) + try FileManager.default.setAttributes( + [.modificationDate: now.addingTimeInterval(-age)], ofItemAtPath: url.path + ) + } + // Snapshots sit next to `sessions/`, where the sweep scans for them. + let ownFile = root.appendingPathComponent("session-dev.own.json") + // The running bundle's own snapshot pins its ids however old it is. + try writeSnapshot(ownID, to: ownFile, age: 60 * day) + try writeSnapshot(freshForeignID, to: root.appendingPathComponent("session-app.debug.fresh-tag.json"), age: 13 * day) + try writeSnapshot(staleForeignID, to: root.appendingPathComponent("session-app.debug.old-tag.json"), age: 15 * day) + // A release bundle's snapshot pins however long that app has been closed. + try writeSnapshot(staleReleaseID, to: root.appendingPathComponent("session-com.darkroom.programa.json"), age: 60 * day) + // An abandoned snapshot that no longer decodes must not defer the sweep, and a + // scrollback policy file is not a snapshot. + let corruptStale = root.appendingPathComponent("session-app.bench.dead-tag.json") + try Data("corrupt".utf8).write(to: corruptStale) + try FileManager.default.setAttributes( + [.modificationDate: now.addingTimeInterval(-40 * day)], ofItemAtPath: corruptStale.path + ) + try Data(#"{"enabled":true}"#.utf8).write(to: root.appendingPathComponent("session-staging.scrollback-policy.json")) + + SessionWALStore.sweepOrphanedSessionDirectories( + at: sessions, registeredSessionIDs: [], ownSnapshotFileURL: ownFile, now: now + ) + + for id in [ownID, freshForeignID, staleReleaseID] { + XCTAssertTrue(FileManager.default.fileExists(atPath: sessions.appendingPathComponent(id.uuidString).path)) + } + for id in [staleForeignID, unownedID] { + XCTAssertFalse(FileManager.default.fileExists(atPath: sessions.appendingPathComponent(id.uuidString).path)) + } + } + private func makeOwnershipSnapshot(_ id: UUID) -> AppSessionSnapshot { var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) snapshot.windows[0].tabManager.workspaces[0].panels = [SessionPanelSnapshot( diff --git a/programaTests/SessionWALCoreTests.swift b/programaTests/SessionWALCoreTests.swift index 68f34620d..8e72c5817 100644 --- a/programaTests/SessionWALCoreTests.swift +++ b/programaTests/SessionWALCoreTests.swift @@ -36,6 +36,24 @@ final class SessionWALCoreTests: XCTestCase { XCTAssertEqual(ring.drain(), Array("23456789".utf8)) } + func testRingSignalsOnlyWhenBufferGoesFromEmptyToNonEmpty() { + var signals = 0 + let ring = SessionWALRingBuffer(capacity: 16, onBytesAvailable: { signals += 1 }) + + append(Array("a".utf8), to: ring) + append(Array("b".utf8), to: ring) + XCTAssertEqual(signals, 1, "Bytes landing in an already-pending buffer share one drain") + + XCTAssertEqual(ring.drain(), Array("ab".utf8)) + append(Array("c".utf8), to: ring) + XCTAssertEqual(signals, 2, "The first bytes after a drain schedule the next one") + + _ = ring.drain() + ring.transition(to: SessionScrollbackPolicy(enabled: false, generation: UUID())) + append(Array("d".utf8), to: ring) + XCTAssertEqual(signals, 2, "Rejected bytes must not wake the writer") + } + func testAppendRotatesAndPersistsGenerationBeforeFrameCapture() throws { let paths = makePaths() try seedMeta(at: paths) diff --git a/programaTests/ShellFixSweepSHELTests.swift b/programaTests/ShellFixSweepSHELTests.swift index 26902455d..11fcbaf0a 100644 --- a/programaTests/ShellFixSweepSHELTests.swift +++ b/programaTests/ShellFixSweepSHELTests.swift @@ -113,10 +113,10 @@ final class ExternalOpenPolicySHELTests: XCTestCase { } } -// MARK: - SHEL-15: notification reorder keeps children under their parent +// MARK: - SHEL-15: hierarchy order keeps children under their parent @MainActor -final class NotificationReorderHierarchyTests: XCTestCase { +final class WorkspaceHierarchyOrderTests: XCTestCase { private func makeManager() -> (TabManager, Workspace, Workspace, Workspace) { let manager = TabManager() let a = manager.tabs[0] @@ -126,25 +126,6 @@ final class NotificationReorderHierarchyTests: XCTestCase { return (manager, a, b, c) } - func testChildStaysDirectlyAfterItsParentWhenItNotifies() { - let (manager, a, b, c) = makeManager() - c.agentParentWorkspaceId = a.id - manager.canonicalizeHierarchyOrderIfNeeded() - XCTAssertEqual(manager.tabs.map(\.id), [a.id, c.id, b.id]) - - manager.moveTabToTopForNotification(c.id) - XCTAssertEqual(manager.tabs.map(\.id), [a.id, c.id, b.id], "A child must not leave its parent") - } - - func testNotifyingChildMovesItsWholeGroupToTop() { - let (manager, a, b, c) = makeManager() - c.agentParentWorkspaceId = b.id - manager.canonicalizeHierarchyOrderIfNeeded() - - manager.moveTabToTopForNotification(c.id) - XCTAssertEqual(manager.tabs.map(\.id), [b.id, c.id, a.id]) - } - func testMoveDownCarriesChildrenPastTheNextSibling() { let (manager, a, b, c) = makeManager() c.agentParentWorkspaceId = a.id diff --git a/programaTests/TerminalAndGhosttyTests.swift b/programaTests/TerminalAndGhosttyTests.swift index a71b1a43b..82f6a7496 100644 --- a/programaTests/TerminalAndGhosttyTests.swift +++ b/programaTests/TerminalAndGhosttyTests.swift @@ -44,11 +44,11 @@ final class GhosttyPasteboardHelperTests: XCTestCase { var oldContents: [String] = [] var newContents: [String] = [] GhosttyApp.handleClipboardConfirmation( - contents: "old private clipboard", kind: GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, + contents: "old paste", kind: GHOSTTY_CLIPBOARD_REQUEST_PASTE, window: oldWindow, surface: surface ) { oldContents.append($0) } GhosttyApp.handleClipboardConfirmation( - contents: "new private clipboard", kind: GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, + contents: "new paste", kind: GHOSTTY_CLIPBOARD_REQUEST_PASTE, window: newWindow, surface: surface ) { newContents.append($0) } @@ -65,6 +65,34 @@ final class GhosttyPasteboardHelperTests: XCTestCase { XCTAssertNil(newWindow.attachedSheet) } + /// A program can send OSC 52 in a loop. Replacing the open prompt on every request would + /// keep it out from under the user's click, so a request arriving while one is open is denied. + func testOSC52RequestWhilePromptIsOpenIsDeniedAndKeepsTheOpenPrompt() async { + let window = NSWindow(contentRect: .zero, styleMask: [], backing: .buffered, defer: true) + let surface = UnsafeMutableRawPointer.allocate(byteCount: 1, alignment: 1) + defer { surface.deallocate() } + var firstContents: [String] = [] + var secondContents: [String] = [] + GhosttyApp.handleClipboardConfirmation( + contents: "first write", kind: GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, + window: window, surface: surface + ) { firstContents.append($0) } + GhosttyApp.handleClipboardConfirmation( + contents: "second write", kind: GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, + window: window, surface: surface + ) { secondContents.append($0) } + + XCTAssertEqual(secondContents, [""], "A request arriving while a prompt is open must be denied") + XCTAssertTrue(firstContents.isEmpty, "The open prompt must stay pending") + GhosttyApp.cancelConfirmationsBeforeFree(surface) + XCTAssertEqual(firstContents, [""], "The open prompt must remain registered for native teardown") + + await drainClipboardPresentationQueue() + XCTAssertEqual(firstContents, [""]) + XCTAssertEqual(secondContents, [""]) + XCTAssertNil(window.attachedSheet) + } + func testClipboardWindowCloseDeniesPendingReadAndRemovesTeardownCompletion() async { let window = NSWindow(contentRect: .zero, styleMask: [], backing: .buffered, defer: true) let surface = UnsafeMutableRawPointer.allocate(byteCount: 1, alignment: 1) @@ -103,6 +131,91 @@ final class GhosttyPasteboardHelperTests: XCTestCase { ) } + func testClipboardWritePreviewKeepsShortTextWhole() { + let preview = ClipboardWritePreview("hello\nworld\n") + + XCTAssertEqual(preview.text, "hello\nworld") + XCTAssertFalse(preview.isTruncated) + XCTAssertEqual(preview.lineCount, 2) + XCTAssertEqual(preview.byteCount, 12) + } + + func testClipboardWritePreviewCutsLongTextAndReportsFullSize() { + let manyLines = (1...20).map { "line \($0)" }.joined(separator: "\n") + let byLines = ClipboardWritePreview(manyLines) + XCTAssertTrue(byLines.isTruncated) + XCTAssertEqual(byLines.lineCount, 20) + XCTAssertEqual(byLines.text.split(separator: "\n").count, ClipboardWritePreview.maxLines) + + let longLine = String(repeating: "é", count: ClipboardWritePreview.maxCharacters + 50) + let byCharacters = ClipboardWritePreview(longLine) + XCTAssertTrue(byCharacters.isTruncated) + XCTAssertEqual(byCharacters.text.count, ClipboardWritePreview.maxCharacters) + XCTAssertEqual(byCharacters.byteCount, longLine.utf8.count) + XCTAssertEqual(byCharacters.lineCount, 1) + } + + func testClipboardWriteAllowIsRememberedPerTerminalAndEndsWithIt() { + _ = NSApplication.shared + func makeTerminal() -> TerminalSurface { + TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + } + let trusted = makeTerminal() + let other = makeTerminal() + XCTAssertFalse(GhosttyApp.allowsClipboardWrites(from: trusted)) + + GhosttyApp.rememberClipboardWriteAllowed(for: trusted) + + XCTAssertTrue(GhosttyApp.allowsClipboardWrites(from: trusted)) + XCTAssertFalse(GhosttyApp.allowsClipboardWrites(from: other), "Allowing one terminal must not allow another") + } + + func testClipboardWriteDenyHoldsForThirtySecondsPerTerminal() { + _ = NSApplication.shared + func makeTerminal() -> TerminalSurface { + TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + } + let denied = makeTerminal() + let other = makeTerminal() + let start = Date() + XCTAssertFalse(GhosttyApp.isDenyingClipboardWrites(from: denied, now: start)) + + GhosttyApp.denyClipboardWrites(from: denied, now: start) + + XCTAssertTrue(GhosttyApp.isDenyingClipboardWrites(from: denied, now: start.addingTimeInterval(29))) + XCTAssertFalse(GhosttyApp.isDenyingClipboardWrites(from: denied, now: start.addingTimeInterval(31))) + XCTAssertFalse(GhosttyApp.isDenyingClipboardWrites(from: other, now: start), "Denying one terminal must not deny another") + } + + func testReportedDirectoryMustBeAnExistingAbsoluteDirectoryWithoutRelativeComponents() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("reported-dir-\(UUID().uuidString)", isDirectory: true) + let child = root.appendingPathComponent("child", isDirectory: true) + try FileManager.default.createDirectory(at: child, withIntermediateDirectories: true) + let file = root.appendingPathComponent("file.txt") + try "x".write(to: file, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: root) } + + XCTAssertEqual(GhosttyApp.validatedReportedDirectory(child.path), child.path) + XCTAssertEqual(GhosttyApp.validatedReportedDirectory(child.absoluteString), child.path) + XCTAssertNil(GhosttyApp.validatedReportedDirectory("")) + XCTAssertNil(GhosttyApp.validatedReportedDirectory("relative/dir")) + XCTAssertNil(GhosttyApp.validatedReportedDirectory(child.path + "/../child")) + XCTAssertNil(GhosttyApp.validatedReportedDirectory(child.path + "/./")) + XCTAssertNil(GhosttyApp.validatedReportedDirectory(root.appendingPathComponent("missing").path)) + XCTAssertNil(GhosttyApp.validatedReportedDirectory(file.path), "A file is not a working directory") + } + private func make1x1PNG(color: NSColor) throws -> Data { let image = NSImage(size: NSSize(width: 1, height: 1)) image.lockFocus() diff --git a/programaTests/WorkspaceUnitTests.swift b/programaTests/WorkspaceUnitTests.swift index 6c9dac271..afaab99b9 100644 --- a/programaTests/WorkspaceUnitTests.swift +++ b/programaTests/WorkspaceUnitTests.swift @@ -1874,7 +1874,7 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#GG1234")) } - func testBuiltInPaletteMatchesOriginalPRPalette() { + func testBuiltInPaletteHasSixteenEntries() { let palette = WorkspaceTabColorSettings.defaultPalette XCTAssertEqual(palette.count, 16) XCTAssertEqual(palette.first?.name, "Red") @@ -1894,8 +1894,8 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { XCTAssertEqual(WorkspaceTabColorSettings.palette(defaults: defaults), WorkspaceTabColorSettings.defaultPalette) } - func testSetColorRoundTripFallsBackWhenResetToBase() { - let suiteName = "WorkspaceTabColorSettingsTests.SetColor.\(UUID().uuidString)" + func testPersistPaletteMapRoundTripFallsBackWhenResetToBase() { + let suiteName = "WorkspaceTabColorSettingsTests.PersistPalette.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated UserDefaults suite") return @@ -1903,23 +1903,18 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { defer { defaults.removePersistentDomain(forName: suiteName) } let first = WorkspaceTabColorSettings.defaultPalette[0] + var palette = Dictionary(uniqueKeysWithValues: WorkspaceTabColorSettings.defaultPalette.map { ($0.name, $0.hex) }) + palette[first.name] = "#00aa33" + WorkspaceTabColorSettings.persistPaletteMap(palette, defaults: defaults) XCTAssertEqual( - WorkspaceTabColorSettings.currentColorHex(named: first.name, defaults: defaults), - first.hex - ) - - WorkspaceTabColorSettings.setColor(named: first.name, hex: "#00aa33", defaults: defaults) - XCTAssertEqual( - WorkspaceTabColorSettings.currentColorHex(named: first.name, defaults: defaults), + WorkspaceTabColorSettings.palette(defaults: defaults).first(where: { $0.name == first.name })?.hex, "#00AA33" ) XCTAssertNotNil(defaults.dictionary(forKey: WorkspaceTabColorSettings.paletteKey)) - WorkspaceTabColorSettings.setColor(named: first.name, hex: first.hex, defaults: defaults) - XCTAssertEqual( - WorkspaceTabColorSettings.currentColorHex(named: first.name, defaults: defaults), - first.hex - ) + palette[first.name] = first.hex + WorkspaceTabColorSettings.persistPaletteMap(palette, defaults: defaults) + XCTAssertEqual(WorkspaceTabColorSettings.palette(defaults: defaults), WorkspaceTabColorSettings.defaultPalette) XCTAssertNil(defaults.object(forKey: WorkspaceTabColorSettings.paletteKey)) } @@ -2120,29 +2115,7 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { XCTAssertEqual(resolved.last?.hex, "#00F5D4") } - func testLegacyKeysStillResolveIntoEffectivePalette() { - let suiteName = "WorkspaceTabColorSettingsTests.LegacyKeys.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(["Blue": "#010203"], forKey: "workspaceTabColor.defaultOverrides") - defaults.set(["#778899"], forKey: "workspaceTabColor.customColors") - - let resolved = WorkspaceTabColorSettings.palette(defaults: defaults) - XCTAssertEqual( - resolved.first(where: { $0.name == "Blue" })?.hex, - "#010203" - ) - XCTAssertEqual( - resolved.first(where: { $0.name == "Custom 1" })?.hex, - "#778899" - ) - } - - func testResetClearsNewAndLegacyStorage() { + func testResetClearsStoredPalette() { let suiteName = "WorkspaceTabColorSettingsTests.Reset.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated UserDefaults suite") @@ -2151,14 +2124,10 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { defer { defaults.removePersistentDomain(forName: suiteName) } WorkspaceTabColorSettings.persistPaletteMap(["Neon Mint": "#00F5D4"], defaults: defaults) - defaults.set(["Blue": "#010203"], forKey: "workspaceTabColor.defaultOverrides") - defaults.set(["#778899"], forKey: "workspaceTabColor.customColors") WorkspaceTabColorSettings.reset(defaults: defaults) XCTAssertNil(defaults.object(forKey: WorkspaceTabColorSettings.paletteKey)) - XCTAssertNil(defaults.object(forKey: "workspaceTabColor.defaultOverrides")) - XCTAssertNil(defaults.object(forKey: "workspaceTabColor.customColors")) XCTAssertEqual(WorkspaceTabColorSettings.palette(defaults: defaults), WorkspaceTabColorSettings.defaultPalette) } diff --git a/scripts/build-ghostty-cli-helper.sh b/scripts/build-ghostty-cli-helper.sh deleted file mode 100755 index 2d79b869a..000000000 --- a/scripts/build-ghostty-cli-helper.sh +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: ./scripts/build-ghostty-cli-helper.sh [--universal | --target ] --output - -Options: - --universal Build a universal macOS helper (arm64 + x86_64). - --target - Build a single target, e.g. `aarch64-macos` or `x86_64-macos`. - --output Destination path for the built helper. -EOF -} - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -GHOSTTY_DIR="$REPO_ROOT/ghostty" - -OUTPUT_PATH="" -TARGET_TRIPLE="" -UNIVERSAL="false" - -while [[ $# -gt 0 ]]; do - case "$1" in - --universal) - UNIVERSAL="true" - shift - ;; - --target) - TARGET_TRIPLE="${2:-}" - shift 2 - ;; - --output) - OUTPUT_PATH="${2:-}" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown option: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -if [[ -z "$OUTPUT_PATH" ]]; then - echo "Missing required --output path" >&2 - usage >&2 - exit 1 -fi - -# Allow callers to skip the zig build explicitly. -# Creates a stub binary so the Xcode Run Script file-existence check passes. -if [[ "${PROGRAMA_SKIP_ZIG_BUILD:-}" == "1" ]]; then - echo "Skipping zig CLI helper build (PROGRAMA_SKIP_ZIG_BUILD=1)" - mkdir -p "$(dirname "$OUTPUT_PATH")" - printf '#!/bin/sh\necho "ghostty CLI helper stub (zig build skipped)" >&2\nexit 1\n' > "$OUTPUT_PATH" - chmod +x "$OUTPUT_PATH" - exit 0 -fi - -if [[ "$UNIVERSAL" == "true" && -n "$TARGET_TRIPLE" ]]; then - echo "--universal and --target are mutually exclusive" >&2 - usage >&2 - exit 1 -fi - -if [[ -n "$TARGET_TRIPLE" ]]; then - case "$TARGET_TRIPLE" in - aarch64-macos|x86_64-macos) - ;; - *) - echo "Unsupported --target value: $TARGET_TRIPLE" >&2 - exit 1 - ;; - esac - - # When the requested target matches zig's native output arch, drop -Dtarget - # so zig uses native compilation. Zig may run under Rosetta, so detect the - # output arch from the zig binary itself rather than uname -m. - ZIG_ARCH="$(file "$(command -v zig)" 2>/dev/null | grep -oE '(arm64|x86_64)' | head -1)" - case "$TARGET_TRIPLE" in - aarch64-macos) [[ "$ZIG_ARCH" == "arm64" ]] && TARGET_TRIPLE="" ;; - x86_64-macos) [[ "$ZIG_ARCH" == "x86_64" ]] && TARGET_TRIPLE="" ;; - esac -fi - -if ! command -v zig >/dev/null 2>&1; then - echo "error: zig is required to build the Ghostty CLI helper" >&2 - exit 1 -fi - -if [[ ! -f "$GHOSTTY_DIR/build.zig" ]]; then - echo "error: Ghostty submodule is missing at $GHOSTTY_DIR" >&2 - exit 1 -fi - -build_helper() { - local prefix="$1" - local target="${2:-}" - local args=( - zig build - cli-helper - -Dapp-runtime=none - -Demit-macos-app=false - -Demit-xcframework=false - -Doptimize=ReleaseFast - --prefix - "$prefix" - ) - - if [[ -n "$target" ]]; then - args+=("-Dtarget=$target") - fi - - ( - cd "$GHOSTTY_DIR" - "${args[@]}" - ) -} - -TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/programa-ghostty-helper.XXXXXX")" -trap 'rm -rf "$TMP_DIR"' EXIT - -mkdir -p "$(dirname "$OUTPUT_PATH")" - -if [[ "$UNIVERSAL" == "true" ]]; then - ARM64_PREFIX="$TMP_DIR/arm64" - X86_PREFIX="$TMP_DIR/x86_64" - ZIG_ARCH="$(file "$(command -v zig)" 2>/dev/null | grep -oE '(arm64|x86_64)' | head -1)" - # Use native compilation for the matching arch to avoid cross-linker issues - if [[ "$ZIG_ARCH" == "arm64" ]]; then - build_helper "$ARM64_PREFIX" "" - build_helper "$X86_PREFIX" "x86_64-macos" - elif [[ "$ZIG_ARCH" == "x86_64" ]]; then - build_helper "$ARM64_PREFIX" "aarch64-macos" - build_helper "$X86_PREFIX" "" - else - build_helper "$ARM64_PREFIX" "aarch64-macos" - build_helper "$X86_PREFIX" "x86_64-macos" - fi - /usr/bin/lipo -create \ - "$ARM64_PREFIX/bin/ghostty" \ - "$X86_PREFIX/bin/ghostty" \ - -output "$OUTPUT_PATH" -else - SINGLE_PREFIX="$TMP_DIR/single" - build_helper "$SINGLE_PREFIX" "$TARGET_TRIPLE" - install -m 755 "$SINGLE_PREFIX/bin/ghostty" "$OUTPUT_PATH" -fi - -chmod +x "$OUTPUT_PATH" diff --git a/scripts/reload.sh b/scripts/reload.sh index 764d5592a..76edaaf05 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -321,10 +321,6 @@ if [[ -z "$TAG" ]]; then PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" ) fi -# Forward the explicit skip escape hatch to xcodebuild run script phases. -if [[ "${PROGRAMA_SKIP_ZIG_BUILD:-}" == "1" ]]; then - XCODEBUILD_ARGS+=(PROGRAMA_SKIP_ZIG_BUILD=1) -fi XCODEBUILD_ARGS+=(build) XCODE_LOG="/tmp/programa-xcodebuild-${TAG_SLUG}.log" @@ -420,21 +416,6 @@ if [[ -x "$CLI_PATH" ]]; then fi fi -# Build the ghostty helper binary (needed for both launch and no-launch). -GHOSTTY_HELPER_SRC="$PWD/ghostty/zig-out/bin/ghostty" -if [[ -d "$PWD/ghostty" ]]; then - if [[ "${PROGRAMA_SKIP_ZIG_BUILD:-}" == "1" ]]; then - echo "Skipping direct ghostty CLI helper zig build (PROGRAMA_SKIP_ZIG_BUILD=1)" - else - (cd "$PWD/ghostty" && zig build cli-helper -Dapp-runtime=none -Demit-macos-app=false -Demit-xcframework=false -Doptimize=ReleaseFast) - fi -fi -if [[ -x "$GHOSTTY_HELPER_SRC" ]]; then - BIN_DIR="$APP_PATH/Contents/Resources/bin" - mkdir -p "$BIN_DIR" - cp "$GHOSTTY_HELPER_SRC" "$BIN_DIR/ghostty" - chmod +x "$BIN_DIR/ghostty" -fi CLI_PATH="$APP_PATH/Contents/Resources/bin/programa" if [[ -x "$CLI_PATH" ]]; then echo "$CLI_PATH" > /tmp/programa-last-cli-path || true diff --git a/scripts/sign-release-app.sh b/scripts/sign-release-app.sh index 643d61c68..0dab11fa8 100755 --- a/scripts/sign-release-app.sh +++ b/scripts/sign-release-app.sh @@ -29,7 +29,6 @@ sign_if_present "$sparkle/Autoupdate" sign_if_present "$app_path/Contents/Frameworks/Sparkle.framework" sign_if_present "$app_path/Contents/PlugIns/ProgramaDockTilePlugin.plugin" sign_if_present "$app_path/Contents/Resources/bin/programa" -sign_if_present "$app_path/Contents/Resources/bin/ghostty" sign_if_present "$app_path/Contents/Resources/bin/programa-mcp" # Embed a Developer ID provisioning profile if one was supplied via diff --git a/tests/test_bundled_ghostty_theme_picker_helper.sh b/tests/test_bundled_ghostty_theme_picker_helper.sh deleted file mode 100755 index 23aff472c..000000000 --- a/tests/test_bundled_ghostty_theme_picker_helper.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SOURCE_PACKAGES_DIR="${PROGRAMA_SOURCE_PACKAGES_DIR:-$PWD/.ci-source-packages}" -DERIVED_DATA_PATH="${PROGRAMA_DERIVED_DATA_PATH:-$PWD/.ci-bundled-ghostty-helper}" -CONFIGURATION="${PROGRAMA_CONFIGURATION:-Debug}" - -case "$CONFIGURATION" in - Debug) - APP_NAME="Programa DEV.app" - ;; - Release) - APP_NAME="Programa.app" - ;; - *) - echo "FAIL: unsupported configuration $CONFIGURATION" >&2 - exit 1 - ;; -esac - -# CI already builds this exact app once in the build-app job; pass its path -# here instead of paying for a second full build of the same scheme. -if [[ -n "${PROGRAMA_PREBUILT_APP_PATH:-}" ]]; then - APP_PATH="$PROGRAMA_PREBUILT_APP_PATH" -else - mkdir -p "$SOURCE_PACKAGES_DIR" - rm -rf "$DERIVED_DATA_PATH" - - xcodebuild \ - -project GhosttyTabs.xcodeproj \ - -scheme programa \ - -configuration "$CONFIGURATION" \ - -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ - -disableAutomaticPackageResolution \ - -derivedDataPath "$DERIVED_DATA_PATH" \ - -destination "platform=macOS" \ - build - - APP_PATH="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME" -fi -HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty" - -if [ ! -x "$HELPER_PATH" ]; then - echo "FAIL: bundled Ghostty theme picker helper missing at $HELPER_PATH" >&2 - exit 1 -fi - -echo "PASS: bundled Ghostty theme picker helper present at $HELPER_PATH" diff --git a/tests/test_cli_themes.py b/tests/test_cli_themes.py new file mode 100644 index 000000000..63e27db4a --- /dev/null +++ b/tests/test_cli_themes.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""`programa themes` lists and sets themes in Swift, with no bundled ghostty helper. + +Runs the built CLI under a temporary HOME with GHOSTTY_RESOURCES_DIR pointing at a scratch +theme directory. No socket and no app are involved; PROGRAMA_BUNDLE_ID targets a bundle id +nothing listens on, so the reload request reaches no running Programa. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +CLI_PATH = os.environ.get("PROGRAMA_CLI_BIN", "") +THEMES = ("Catppuccin Mocha", "Catppuccin Latte", "Nord") + + +def run_cli(home: str, resources: str, *args: str) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + for key in ("PROGRAMA_SOCKET", "PROGRAMA_SOCKET_PATH", "XDG_CONFIG_HOME", "XDG_DATA_DIRS"): + env.pop(key, None) + env.update( + HOME=home, + CFFIXED_USER_HOME=home, + GHOSTTY_RESOURCES_DIR=resources, + PROGRAMA_BUNDLE_ID="invalid.programa.themes.test", + ) + return subprocess.run( + [CLI_PATH, *args], + stdin=subprocess.DEVNULL, capture_output=True, text=True, check=False, timeout=30, env=env, + ) + + +class ThemesTests(unittest.TestCase): + def setUp(self) -> None: + self.assertTrue(CLI_PATH and os.access(CLI_PATH, os.X_OK), "PROGRAMA_CLI_BIN must point to the built CLI") + self._tmp = tempfile.TemporaryDirectory(prefix="pcli-themes-", dir="/tmp") + self.addCleanup(self._tmp.cleanup) + self.home = str(Path(self._tmp.name) / "home") + self.resources = str(Path(self._tmp.name) / "resources") + Path(self.home).mkdir() + themes_dir = Path(self.resources) / "themes" + themes_dir.mkdir(parents=True) + for name in THEMES: + (themes_dir / name).write_text("background = #000000\n") + + def managed_config(self) -> str: + path = Path(self.home) / "Library/Application Support/com.darkroom.programa/config.ghostty" + return path.read_text() if path.exists() else "" + + def test_bare_themes_and_list_print_the_theme_names(self) -> None: + for args in ((), ("list",)): + with self.subTest(args=args): + proc = run_cli(self.home, self.resources, "themes", *args) + self.assertEqual(proc.returncode, 0, f"stderr={proc.stderr!r}") + lines = proc.stdout.splitlines() + for name in THEMES: + self.assertIn(name, lines) + + def test_list_json_reports_every_theme(self) -> None: + proc = run_cli(self.home, self.resources, "--json", "themes", "list") + self.assertEqual(proc.returncode, 0, f"stderr={proc.stderr!r}") + names = {entry["name"] for entry in json.loads(proc.stdout)["themes"]} + self.assertTrue(set(THEMES) <= names) + + def test_set_writes_the_managed_block_and_clear_removes_it(self) -> None: + proc = run_cli(self.home, self.resources, "themes", "set", "catppuccin mocha") + self.assertEqual(proc.returncode, 0, f"stderr={proc.stderr!r}") + self.assertIn("theme = light:Catppuccin Mocha,dark:Catppuccin Mocha", self.managed_config()) + + proc = run_cli(self.home, self.resources, "themes", "clear") + self.assertEqual(proc.returncode, 0, f"stderr={proc.stderr!r}") + self.assertNotIn("Catppuccin", self.managed_config()) + + def test_set_light_and_dark_pair(self) -> None: + proc = run_cli(self.home, self.resources, "themes", "set", "--light", "Catppuccin Latte", "--dark", "Nord") + self.assertEqual(proc.returncode, 0, f"stderr={proc.stderr!r}") + self.assertIn("theme = light:Catppuccin Latte,dark:Nord", self.managed_config()) + + def test_unknown_theme_fails_with_suggestions_and_writes_nothing(self) -> None: + proc = run_cli(self.home, self.resources, "themes", "set", "Catppuccin") + self.assertNotEqual(proc.returncode, 0) + self.assertIn("Unknown theme 'Catppuccin'", proc.stderr) + self.assertIn("Catppuccin Mocha", proc.stderr) + self.assertEqual(self.managed_config(), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reload_entrypoints_artifacts.sh b/tests/test_reload_entrypoints_artifacts.sh index 6ba07728a..293abdaee 100755 --- a/tests/test_reload_entrypoints_artifacts.sh +++ b/tests/test_reload_entrypoints_artifacts.sh @@ -109,7 +109,6 @@ run_entrypoint() { TEST_ENSURE_MARKER="$ENSURE_MARKER" \ TEST_REQUIRE_ENSURE="$require_ensure" \ PROGRAMA_ENSURE_GHOSTTYKIT_COMMAND="$STUB_BIN/ensure-ghosttykit" \ - PROGRAMA_SKIP_ZIG_BUILD=1 \ "$@" ) } @@ -200,7 +199,7 @@ with tempfile.TemporaryDirectory(prefix="pr-", dir="/tmp") as temporary: environment = dict(os.environ, HOME=str(home), PATH=f"{stub_bin}:/usr/bin:/bin", TEST_COMMAND_LOG=str(fixture / "commands"), TEST_ENSURE_MARKER=str(fixture / "ready"), - TEST_REQUIRE_ENSURE="1", PROGRAMA_SKIP_ZIG_BUILD="1", + TEST_REQUIRE_ENSURE="1", PROGRAMA_ENSURE_GHOSTTYKIT_COMMAND=str(stub_bin / "ensure-ghosttykit")) def build(tag): diff --git a/tests_v2/test_tmux_compat_geometry.py b/tests_v2/test_tmux_compat_geometry.py index eb6f48fc6..f1092f4ad 100644 --- a/tests_v2/test_tmux_compat_geometry.py +++ b/tests_v2/test_tmux_compat_geometry.py @@ -48,7 +48,6 @@ def _run_tmux_compat(cli: str, args: List[str]) -> subprocess.CompletedProcess[s env.pop("PROGRAMA_WORKSPACE_ID", None) env.pop("PROGRAMA_SURFACE_ID", None) env["PROGRAMA_SOCKET_PATH"] = SOCKET_PATH - env["PROGRAMA_OMO_PROGRAMA_BIN"] = cli cmd = [cli, "--socket", SOCKET_PATH, "__tmux-compat"] + args return subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)