diff --git a/.github/workflows/build-ghosttykit.yml b/.github/workflows/build-ghosttykit.yml index 682a26b52..c4c38601a 100644 --- a/.github/workflows/build-ghosttykit.yml +++ b/.github/workflows/build-ghosttykit.yml @@ -32,6 +32,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: build-ghosttykit: runs-on: macos-15 diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index ec6d55343..6f2b17e5a 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -21,6 +21,9 @@ on: workflow_dispatch: +permissions: + contents: read + jobs: change-detection: runs-on: ubuntu-latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3792c468b..0bfa77bcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: change-detection: runs-on: ubuntu-latest diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index dfba74b84..6ad413933 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -26,6 +26,9 @@ on: required: false default: "120" +permissions: + contents: read + jobs: tests: runs-on: macos-15 diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 9933e6471..ff4442a0b 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -29,6 +29,9 @@ on: - macos-15 - macos-14 +permissions: + contents: read + jobs: e2e: runs-on: ${{ inputs.runner || 'macos-15' }} diff --git a/CLAUDE.md b/CLAUDE.md index 9a1db421e..9b48d3316 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,7 +159,7 @@ The app has a **Debug** menu in the macOS menu bar (only in DEBUG builds). Use i - **Typing-latency-sensitive paths** (read carefully before touching these areas): - `WindowTerminalHostView.hitTest()` in `Sources/WindowTerminalHostView.swift`: called on every event including keyboard. Keyboard events (`.keyDown`/`.keyUp`/`.flagsChanged`) take an early `switch currentEvent?.type` fast path; everything else — all pointer events, and an ambiguous/nil `currentEvent` — must fall through to the full divider/sidebar/drag routing below it. Do not add work to that keyboard fast path. - `NSWindow.programa_sendEvent` in `Sources/WindowSwizzles.swift`: runs for every event. The hit-view context it caches is only ever read for pointer-down events, so it is computed only for those — do not restore an unconditional hit-test here (#183). - - `TabItemView` in `ContentView.swift`: uses `Equatable` conformance + `.equatable()` to skip body re-evaluation during typing. Do not add `@EnvironmentObject`, `@ObservedObject` (besides `tab`), or `@Binding` properties without updating the `==` function. Do not remove `.equatable()` from the ForEach call site. Do not read `tabManager` or `notificationStore` in the body; use the precomputed `let` parameters instead. + - `TabItemView` in `Sources/TabItemView.swift`: uses `Equatable` conformance + `.equatable()` to skip body re-evaluation during typing. Do not add `@EnvironmentObject`, `@ObservedObject` (besides `tab`), or `@Binding` properties without updating the `==` function. Do not remove `.equatable()` from the ForEach call site. Do not read `tabManager` or `notificationStore` in the body; use the precomputed `let` parameters instead. - `TerminalSurface.forceRefresh()` in `GhosttyTerminalView.swift`: called on every keystroke. Do not add allocations, file I/O, or formatting here. - **Terminal find layering contract:** `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn. - **Submodule safety:** When modifying a submodule (ghostty), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd && git merge-base --is-ancestor HEAD origin/main`. Note: `vendor/bonsplit` is NOT a submodule — it is vendored in-tree (MIT, from the manaflow-ai fork); edit and commit it like any other source directory. diff --git a/CLI-MCP/MCPSocketBridge.swift b/CLI-MCP/MCPSocketBridge.swift index 7e9fd1b04..52e70c2c5 100644 --- a/CLI-MCP/MCPSocketBridge.swift +++ b/CLI-MCP/MCPSocketBridge.swift @@ -113,7 +113,7 @@ struct MCPSocketBridge { if let socketPassword { _ = try Self.sendRequest( - method: "auth.login", + method: V2MethodNames.authLogin, params: ["password": socketPassword], socketFD: socketFD ) @@ -169,11 +169,11 @@ struct MCPSocketBridge { static func responseTimeout(method: String, params: [String: Any]) -> TimeInterval { let timeoutMs: Int switch method { - case "surface.wait": + case V2MethodNames.surfaceWait: timeoutMs = timeoutInteger(params["timeout_ms"]) ?? timeoutInteger(params["timeout"]) ?? 30_000 - case "browser.wait": + case V2MethodNames.browserWait: timeoutMs = timeoutInteger(params["timeout_ms"]) ?? 5_000 - case "browser.download.wait": + case V2MethodNames.browserDownloadWait: timeoutMs = timeoutInteger(params["timeout_ms"]) ?? timeoutInteger(params["timeout"]) ?? 10_000 default: return defaultResponseTimeoutSeconds diff --git a/CLI-MCP/Resources/SurfaceTextResource.swift b/CLI-MCP/Resources/SurfaceTextResource.swift index 7e7fd30e9..6b662b3f5 100644 --- a/CLI-MCP/Resources/SurfaceTextResource.swift +++ b/CLI-MCP/Resources/SurfaceTextResource.swift @@ -30,7 +30,7 @@ enum SurfaceTextResource { let bridge = MCPSocketBridge() do { - let result = try bridge.send(method: "surface.read_text", params: params) + let result = try bridge.send(method: V2MethodNames.surfaceReadText, params: params) let text = (result["text"] as? String) ?? "" return [.text(text, uri: "programa://surface/\(surfaceId)/text", mimeType: "text/plain")] } catch { diff --git a/CLI-MCP/Resources/TreeResource.swift b/CLI-MCP/Resources/TreeResource.swift index ba2c14c67..3b2e76c6d 100644 --- a/CLI-MCP/Resources/TreeResource.swift +++ b/CLI-MCP/Resources/TreeResource.swift @@ -30,7 +30,7 @@ enum TreeResource { let bridge = MCPSocketBridge() do { - let result = try bridge.send(method: "system.tree", params: params) + let result = try bridge.send(method: V2MethodNames.systemTree, params: params) return [try ResourceCatalog.jsonContent(for: result, uri: uri)] } catch { throw MCPErrorMapping.mcpError(for: error) diff --git a/CLI-MCP/Tools/BrowserTools.swift b/CLI-MCP/Tools/BrowserTools.swift index fa48dc6ea..0850252fd 100644 --- a/CLI-MCP/Tools/BrowserTools.swift +++ b/CLI-MCP/Tools/BrowserTools.swift @@ -47,7 +47,7 @@ enum BrowserTools { static let tools: [ProgramaTool] = [ ProgramaTool( name: "browser_open_split", - socketMethod: "browser.open_split", + socketMethod: V2MethodNames.browserOpenSplit, description: "Opens a new browser surface as a split next to a source surface (or reuses an existing sibling browser pane when one is already positioned to the right). Never raises/activates the Programa window or switches the selected workspace; inside the target workspace the new tab becomes that workspace's focused surface, so the user only sees a change if they are already looking at that workspace. Returns the new surface_id.", inputSchema: ProgramaToolSchema.object(properties: [ "url": ProgramaToolSchema.string("Initial URL to load. Opens a blank browser tab if omitted."), @@ -59,7 +59,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_navigate", - socketMethod: "browser.navigate", + socketMethod: V2MethodNames.browserNavigate, description: "Navigates an existing browser surface to a URL.", inputSchema: ProgramaToolSchema.object( properties: [ @@ -73,7 +73,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_back", - socketMethod: "browser.back", + socketMethod: V2MethodNames.browserBack, description: "Navigates a browser surface back one entry in its history.", inputSchema: ProgramaToolSchema.object( properties: [ @@ -86,7 +86,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_forward", - socketMethod: "browser.forward", + socketMethod: V2MethodNames.browserForward, description: "Navigates a browser surface forward one entry in its history.", inputSchema: ProgramaToolSchema.object( properties: [ @@ -99,7 +99,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_reload", - socketMethod: "browser.reload", + socketMethod: V2MethodNames.browserReload, description: "Reloads a browser surface's current page.", inputSchema: ProgramaToolSchema.object( properties: [ @@ -112,7 +112,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_url_get", - socketMethod: "browser.url.get", + socketMethod: V2MethodNames.browserUrlGet, description: "Returns a browser surface's current URL.", inputSchema: ProgramaToolSchema.object( properties: [ @@ -125,7 +125,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_is_webview_focused", - socketMethod: "browser.is_webview_focused", + socketMethod: V2MethodNames.browserIsWebviewFocused, description: "Reports whether keyboard focus is currently inside a browser surface's web view. Read-only; does not move focus.", inputSchema: ProgramaToolSchema.object( properties: [ @@ -138,7 +138,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_snapshot", - socketMethod: "browser.snapshot", + socketMethod: V2MethodNames.browserSnapshot, description: "Returns an accessibility-tree-style snapshot of the page (roles, names, and element refs usable by selector-based tools), plus title/url/ready_state and the page text/HTML.", inputSchema: ProgramaToolSchema.object(properties: [ "interactive": ProgramaToolSchema.boolean("If true, include only interactive elements (links, buttons, inputs, etc). Defaults to false (include the full tree)."), @@ -153,7 +153,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_eval", - socketMethod: "browser.eval", + socketMethod: V2MethodNames.browserEval, description: "Runs arbitrary JavaScript in the browser surface's page context and returns its result (JSON-normalized).", inputSchema: ProgramaToolSchema.object( properties: [ @@ -167,7 +167,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_wait", - socketMethod: "browser.wait", + socketMethod: V2MethodNames.browserWait, description: "Waits (up to a timeout) for a condition to become true: a selector to appear, the URL to contain a substring, the page text to contain a substring, a document.readyState value, or a custom JS boolean expression. Provide at most one condition; defaults to waiting for document.readyState === 'complete'.", inputSchema: ProgramaToolSchema.object(properties: [ "timeout_ms": ProgramaToolSchema.integer("Maximum time to wait, in milliseconds. Defaults to 5000."), @@ -183,7 +183,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_click", - socketMethod: "browser.click", + socketMethod: V2MethodNames.browserClick, description: "Clicks the element matched by a selector (scrolls it into view first, dispatches a real click event or calls .click()).", inputSchema: ProgramaToolSchema.object( properties: [ @@ -198,7 +198,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_dblclick", - socketMethod: "browser.dblclick", + socketMethod: V2MethodNames.browserDblclick, description: "Double-clicks the element matched by a selector.", inputSchema: ProgramaToolSchema.object( properties: [ @@ -213,7 +213,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_hover", - socketMethod: "browser.hover", + socketMethod: V2MethodNames.browserHover, description: "Hovers the element matched by a selector (dispatches mouseover/mouseenter events).", inputSchema: ProgramaToolSchema.object( properties: [ @@ -228,7 +228,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_type", - socketMethod: "browser.type", + socketMethod: V2MethodNames.browserType, description: "Appends text to the element matched by a selector (focuses it, then appends to its value/textContent and fires input/change events). Use browser_fill to replace the value instead of appending.", inputSchema: ProgramaToolSchema.object( properties: [ @@ -244,7 +244,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_fill", - socketMethod: "browser.fill", + socketMethod: V2MethodNames.browserFill, description: "Sets the element matched by a selector to an exact value (replacing any existing value) and fires input/change events. Accepts an empty string to clear the field.", inputSchema: ProgramaToolSchema.object( properties: [ @@ -261,7 +261,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_press", - socketMethod: "browser.press", + socketMethod: V2MethodNames.browserPress, description: "Dispatches a keydown, keypress, and keyup for a key to the page's currently focused element (or body if none).", inputSchema: ProgramaToolSchema.object( properties: [ @@ -275,7 +275,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_keydown", - socketMethod: "browser.keydown", + socketMethod: V2MethodNames.browserKeydown, description: "Dispatches only a keydown event for a key to the page's currently focused element (or body if none). Use browser_press for a full keydown/keypress/keyup sequence.", inputSchema: ProgramaToolSchema.object( properties: [ @@ -289,7 +289,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_keyup", - socketMethod: "browser.keyup", + socketMethod: V2MethodNames.browserKeyup, description: "Dispatches only a keyup event for a key to the page's currently focused element (or body if none).", inputSchema: ProgramaToolSchema.object( properties: [ @@ -303,7 +303,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_check", - socketMethod: "browser.check", + socketMethod: V2MethodNames.browserCheck, description: "Sets a checkbox/radio element matched by a selector to checked, firing input/change events. Fails if the element has no 'checked' property.", inputSchema: ProgramaToolSchema.object( properties: [ @@ -318,7 +318,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_uncheck", - socketMethod: "browser.uncheck", + socketMethod: V2MethodNames.browserUncheck, description: "Sets a checkbox/radio element matched by a selector to unchecked, firing input/change events. Fails if the element has no 'checked' property.", inputSchema: ProgramaToolSchema.object( properties: [ @@ -333,7 +333,7 @@ enum BrowserTools { ), ProgramaTool( name: "browser_select", - socketMethod: "browser.select", + socketMethod: V2MethodNames.browserSelect, description: "Sets a