Files
2026-07-13 12:38:34 +08:00

830 lines
38 KiB
Swift

import AppKit
import MetalKit
import QuartzCore
struct Configuration {
// Content (programmatic):
var tool: String = "GMAIL_GET_PROFILE"
var account: String = "gmail_pall-seba"
var callerAgent: String? = nil // claude | codex | openclaw | composio (omit → CLI mode)
var callerName: String? = nil // overrides agent.displayName
var title: String? = nil // overrides the auto-built title
var subtitle: String? = nil // overrides the auto-built subtitle
var denyLabel: String = "Deny"
var allowSessionLabel: String = "Allow for 1 hr"
var allowOnceLabel: String = "Allow once"
// Window:
var width: CGFloat = 460
var height: CGFloat = 200
var margin: CGFloat = 24
var timeoutSeconds: TimeInterval?
// Result channel: if set, the decision JSON is written to this file path.
// Otherwise it is written to stdout. Either way the process exits 0 for a
// button press, 1 for dismissed / timeout / window-close.
var callbackFile: String?
var resolvedAgent: Agent? { callerAgent.map(Agents.resolve) }
var resolvedCallerName: String { callerName ?? resolvedAgent?.displayName ?? "The composio cli" }
// Sentinel used when no custom --title is supplied; makeContentView
// builds an attributed string with an inline agent logo at this point.
var resolvedTitle: String {
title ?? ""
}
var resolvedSubtitle: String {
if let subtitle, !subtitle.isEmpty { return subtitle }
return "Approve once, for 1 hour, or deny."
}
init(arguments: [String]) {
var index = 0
while index < arguments.count {
let argument = arguments[index]
let value = index + 1 < arguments.count ? arguments[index + 1] : nil
switch argument {
case "--tool":
if let value { tool = value; index += 1 }
case "--account":
if let value { account = value; index += 1 }
case "--caller-agent", "--agent":
if let value { callerAgent = value; index += 1 }
case "--caller-name":
if let value { callerName = value; index += 1 }
case "--title", "--message":
if let value { title = value; index += 1 }
case "--subtitle", "--detail":
if let value { subtitle = value; index += 1 }
case "--deny-label":
if let value { denyLabel = value; index += 1 }
case "--allow-session-label":
if let value { allowSessionLabel = value; index += 1 }
case "--allow-once-label":
if let value { allowOnceLabel = value; index += 1 }
case "--callback-file":
if let value { callbackFile = value; index += 1 }
case "--width":
if let value, let parsed = Double(value) { width = CGFloat(parsed); index += 1 }
case "--height":
if let value, let parsed = Double(value) { height = CGFloat(parsed); index += 1 }
case "--margin":
if let value, let parsed = Double(value) { margin = CGFloat(parsed); index += 1 }
case "--timeout":
if let value, let parsed = Double(value), parsed > 0 {
timeoutSeconds = parsed
index += 1
}
case "--help", "-h":
print("""
Usage: composio-native-ui [options]
Content:
--tool <slug> Tool slug shown in the title. Default: GMAIL_GET_PROFILE
--account <name> Account identifier. Default: gmail_pall-seba
--caller-agent <id> Agent calling the tool. claude | codex | openclaw | composio (default).
--caller-name <text> Override the agent display name shown in the title.
--title <text> Override the auto-built title entirely.
--message <text> Alias for --title, used by CLI dev previews.
--subtitle <text> Subtitle / description line.
--deny-label <text> Override the "Deny" button label.
--allow-session-label <text>
--allow-once-label <text> Override the action button labels.
Window:
--width <points> Window width. Default: 460
--height <points> Window height. Default: 200
--margin <points> Margin from visible screen edges. Default: 24
--timeout <secs> Auto-close after the given number of seconds.
Result:
--callback-file <path> Write the decision JSON to this file. If omitted, JSON is printed to stdout.
Emits a single JSON line:
{"decision":"allow_once|allow_session|deny|dismissed","tool":"...","account":"..."}
Exit code: 0 on button press, 1 on dismissed/timeout/window-close.
""")
Foundation.exit(0)
default:
break
}
index += 1
}
}
}
// MARK: - Shader
final class ShaderRenderer: NSObject, MTKViewDelegate {
private let commandQueue: MTLCommandQueue
private let pipelineState: MTLRenderPipelineState
private let startTime = CACurrentMediaTime()
init?(device: MTLDevice, pixelFormat: MTLPixelFormat) {
guard let commandQueue = device.makeCommandQueue() else { return nil }
self.commandQueue = commandQueue
let source = """
#include <metal_stdlib>
using namespace metal;
struct VertexOut {
float4 position [[position]];
float2 uv;
};
vertex VertexOut vertex_main(uint vertexID [[vertex_id]]) {
float2 positions[3] = {
float2(-1.0, -1.0),
float2( 3.0, -1.0),
float2(-1.0, 3.0)
};
VertexOut out;
out.position = float4(positions[vertexID], 0.0, 1.0);
out.uv = positions[vertexID] * 0.5 + 0.5;
return out;
}
// Ordered 8x8 Bayer matrix, normalised to [0,1).
float bayer8(int2 p) {
const int M[64] = {
0, 32, 8, 40, 2, 34, 10, 42,
48, 16, 56, 24, 50, 18, 58, 26,
12, 44, 4, 36, 14, 46, 6, 38,
60, 28, 52, 20, 62, 30, 54, 22,
3, 35, 11, 43, 1, 33, 9, 41,
51, 19, 59, 27, 49, 17, 57, 25,
15, 47, 7, 39, 13, 45, 5, 37,
63, 31, 55, 23, 61, 29, 53, 21
};
int x = ((p.x % 8) + 8) % 8;
int y = ((p.y % 8) + 8) % 8;
return float(M[y * 8 + x]) / 64.0;
}
// Matches the dashboard's <Dither + SineWave> composition:
// colorB="#c2c2c2" pattern="bayer8" pixelSize=2
// spread=0.8 threshold=0.59
// SineWave amplitude=0.1 angle=162 frequency=0.5 softness=1 speed=-0.4
fragment float4 fragment_main(VertexOut in [[stage_in]], constant float &time [[buffer(0)]]) {
const float PI = 3.14159265;
float2 uv = in.uv;
// SineWave field across the canvas. Angle 162° from horizontal.
float ang = 162.0 * PI / 180.0;
float2 dir = float2(cos(ang), sin(ang));
float frequency = 0.5;
float amplitude = 0.10;
float softness = 1.0;
float speed = -0.4;
float coord = dot(uv - 0.5, dir) * frequency * 6.28318 + time * speed * 3.0;
float wave = 0.5 + 0.5 * sin(coord); // 0..1
// softness=1 → broad, gentle band; softness<1 would sharpen.
wave = pow(wave, 1.0 / max(softness, 0.001));
// amplitude=0.1 modulates a small swing around 0.5.
float sineValue = 0.5 + (wave - 0.5) * (amplitude * 4.0);
// Bayer-ordered dither at pixelSize=2.
int2 pix = int2(floor(in.position.xy * 0.5));
float b = bayer8(pix);
float spread = 0.80;
float threshold = 0.59;
// Standard ordered-dither comparison.
float mask = step(threshold, sineValue + (b - 0.5) * spread);
float3 white = float3(1.0, 1.0, 1.0);
float3 grey = float3(0.7607, 0.7607, 0.7607); // #c2c2c2
float3 color = mix(grey, white, mask);
return float4(color, 1.0);
}
"""
do {
let library = try device.makeLibrary(source: source, options: nil)
let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = library.makeFunction(name: "vertex_main")
descriptor.fragmentFunction = library.makeFunction(name: "fragment_main")
descriptor.colorAttachments[0].pixelFormat = pixelFormat
descriptor.colorAttachments[0].isBlendingEnabled = false
self.pipelineState = try device.makeRenderPipelineState(descriptor: descriptor)
} catch {
fputs("Failed to compile Metal shader: \(error)\n", stderr)
return nil
}
super.init()
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}
func draw(in view: MTKView) {
guard
let descriptor = view.currentRenderPassDescriptor,
let drawable = view.currentDrawable,
let commandBuffer = commandQueue.makeCommandBuffer(),
let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)
else { return }
var elapsed = Float(CACurrentMediaTime() - startTime)
encoder.setRenderPipelineState(pipelineState)
encoder.setFragmentBytes(&elapsed, length: MemoryLayout<Float>.stride, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
encoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
}
}
@MainActor
final class MetalBackgroundView: MTKView {
private var shaderRenderer: ShaderRenderer?
init(frame: CGRect) {
let device = MTLCreateSystemDefaultDevice()
super.init(frame: frame, device: device)
wantsLayer = true
layer?.isOpaque = true
colorPixelFormat = .bgra8Unorm
framebufferOnly = true
isPaused = false
enableSetNeedsDisplay = false
preferredFramesPerSecond = 60
clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
if let device, let renderer = ShaderRenderer(device: device, pixelFormat: colorPixelFormat) {
shaderRenderer = renderer
delegate = renderer
}
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - Tokens
// SVG logos for each caller agent — embedded so the sidecar remains a
// single binary with no asset bundle. macOS NSImage decodes SVG directly.
struct Agent {
let id: String
let displayName: String
let svg: String
func makeImage() -> NSImage? {
if svg.hasPrefix("data:image/png;base64,") {
let encoded = String(svg.dropFirst("data:image/png;base64,".count))
guard let data = Data(base64Encoded: encoded),
let image = NSImage(data: data) else { return nil }
return image
}
guard let data = svg.data(using: .utf8),
let image = NSImage(data: data) else { return nil }
return image
}
}
enum Agents {
static let composioSVG = #"""
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2367_5)">
<path d="M91.7032 28.1801L35.3611 16.6572C31.6669 15.8988 28.1929 18.7367 28.1929 22.5043V49.1954V50.6144V77.3052C28.1929 81.0729 31.6669 83.9112 35.3611 83.1526L91.7032 71.6296" stroke="black" stroke-width="2.8556" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M48.1992 7.38531C48.1993 2.09223 53.6097 -1.44765 58.4546 0.57874L58.6851 0.679333L58.6902 0.68227L88.8308 14.6023C91.4759 15.7947 93.1338 18.4366 93.1346 21.3053V33.0975C93.1346 37.3994 89.4707 40.797 85.1902 40.4658L51.0547 37.918V61.8914L85.185 59.3435L85.585 59.323C89.6842 59.2231 93.1331 62.5334 93.109 66.6876V78.4797C93.109 81.3707 91.4075 83.9737 88.8105 85.1812L88.806 85.1827L58.691 99.0774L58.6917 99.0782C53.7808 101.351 48.1992 97.7714 48.1992 92.3759V81.1383C47.9779 81.2256 47.741 81.2834 47.4921 81.3015L30.8347 82.5007C29.4429 82.6007 28.2584 81.4977 28.2582 80.1023V67.7354C28.2583 67.256 28.4014 66.8063 28.6474 66.4277L14.9439 67.4513H14.9388C10.6701 67.7539 7.00001 64.3671 7 60.0823V39.7271C7.00031 35.4239 10.6671 32.0248 14.9491 32.3589H14.9483L28.3486 33.3589C28.2905 33.152 28.2583 32.9345 28.2582 32.7099V19.6826C28.2582 17.7807 29.9597 16.3299 31.8377 16.6304L47.6992 19.168C47.8735 19.1959 48.0404 19.2435 48.1992 19.3059V7.38531ZM85.4075 62.191H85.4023L51.0547 64.755V79.6601L90.2541 71.4669V66.6774L90.2496 66.435C90.1323 63.9438 87.9489 61.9928 85.4075 62.191ZM27.7075 36.1748C27.9471 36.5495 28.0863 36.9936 28.0864 37.4678V62.7813C28.0864 63.0748 28.0314 63.3559 27.9344 63.6169L48.1992 62.1044V37.7043L27.7075 36.1748ZM51.0547 35.0544L85.4023 37.6183L85.4075 37.6191L85.6511 37.6316C88.1632 37.6928 90.279 35.6595 90.279 33.0975V28.1405L51.0547 19.9411V35.0544Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_2367_5">
<rect width="86.4662" height="100" fill="white" transform="translate(7)"/>
</clipPath>
</defs>
</svg>
"""#
static let claudeSVG = #"""
<svg preserveAspectRatio="xMidYMid" viewBox="0 0 256 257" xmlns="http://www.w3.org/2000/svg"><path fill="#D97757" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>
"""#
// Codex icon from the dashboard asset, with the white app-tile background removed
// so it renders cleanly on the native permission card.
static let codexSVG = #"""
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-_R_0_" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>
"""#
static let openclawSVG = #"""
<svg viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="openclaw__lobster-gradient" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#ff4d4d"/><stop offset="100%" stop-color="#991b1b"/></linearGradient></defs><path d="M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z" fill="url(#openclaw__lobster-gradient)"/><path d="M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z" fill="url(#openclaw__lobster-gradient)"/><path d="M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z" fill="url(#openclaw__lobster-gradient)"/><path d="M45 15 Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/><path d="M75 15 Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/><circle cx="45" cy="35" r="6" fill="#050810"/><circle cx="75" cy="35" r="6" fill="#050810"/><circle cx="46" cy="34" r="2.5" fill="#00e5cc"/><circle cx="76" cy="34" r="2.5" fill="#00e5cc"/></svg>
"""#
static let composio = Agent(id: "composio", displayName: "Composio", svg: composioSVG)
static let claude = Agent(id: "claude", displayName: "Claude", svg: claudeSVG)
static let codex = Agent(id: "codex", displayName: "Codex", svg: codexSVG)
static let openclaw = Agent(id: "openclaw", displayName: "OpenClaw", svg: openclawSVG)
static func resolve(_ id: String) -> Agent {
switch id.lowercased() {
case "claude", "claude-code", "claudecode": return claude
case "codex": return codex
case "openclaw", "open-claw": return openclaw
case "composio", "": return composio
default: return composio
}
}
}
// Matches the dashboard's light theme (oklch tokens flattened to sRGB).
enum Palette {
static let cardBg = NSColor.white.withAlphaComponent(0.86) // bg-card/80
static let border = NSColor(white: 0.0, alpha: 0.10) // border-border
static let divider = NSColor(white: 0.0, alpha: 0.08)
static let textPrimary = NSColor(white: 0.07, alpha: 1.0) // near-black
static let textSecondary = NSColor(white: 0.40, alpha: 1.0) // text-muted-foreground
static let textMuted = NSColor(white: 0.55, alpha: 1.0)
static let primary = NSColor(white: 0.09, alpha: 1.0) // primary button
static let primaryHover = NSColor(white: 0.18, alpha: 1.0)
static let onPrimary = NSColor.white
}
// MARK: - Card chrome
// Light-theme card: rounded-2xl + border + subtle inner stroke, like
// `border-border bg-card/80 rounded-2xl shadow-* backdrop-blur-md`.
@MainActor
final class CardView: NSView {
private let innerStroke = CAShapeLayer()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer?.cornerRadius = 16
layer?.cornerCurve = .continuous
layer?.masksToBounds = true
innerStroke.fillColor = NSColor.clear.cgColor
innerStroke.strokeColor = Palette.border.cgColor
innerStroke.lineWidth = 1
layer?.addSublayer(innerStroke)
}
required init?(coder: NSCoder) { fatalError() }
override func layout() {
super.layout()
innerStroke.frame = bounds
innerStroke.path = CGPath(
roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5),
cornerWidth: 15.5, cornerHeight: 15.5, transform: nil
)
}
}
enum AccentStyle {
case primary // dark filled, white text
case secondary // transparent w/ border, dark text
}
@MainActor
final class AccentButton: NSButton {
private let action_: () -> Void
private let style: AccentStyle
private let fill = CALayer()
private let stroke = CAShapeLayer()
private let glow = CALayer()
private var isHovering = false
private var isPressed = false
init(title: String, style: AccentStyle = .primary, action: @escaping () -> Void) {
self.action_ = action
self.style = style
super.init(frame: .zero)
self.title = ""
isBordered = false
wantsLayer = true
layer?.masksToBounds = false
focusRingType = .none
glow.backgroundColor = NSColor.clear.cgColor
glow.shadowColor = NSColor.black.cgColor
glow.shadowOpacity = 0.0
glow.shadowRadius = 12
glow.shadowOffset = CGSize(width: 0, height: 2)
layer?.addSublayer(glow)
fill.cornerRadius = 8
fill.cornerCurve = .continuous
layer?.addSublayer(fill)
stroke.fillColor = NSColor.clear.cgColor
stroke.lineWidth = 1
layer?.addSublayer(stroke)
let textColor: NSColor
switch style {
case .primary:
fill.backgroundColor = Palette.primary.cgColor
stroke.strokeColor = NSColor.clear.cgColor
textColor = Palette.onPrimary
case .secondary:
fill.backgroundColor = NSColor.white.withAlphaComponent(0.55).cgColor
stroke.strokeColor = Palette.border.cgColor
textColor = Palette.textPrimary
}
let attr = NSMutableAttributedString(string: title, attributes: [
.font: NSFont.systemFont(ofSize: 12.5, weight: .medium),
.foregroundColor: textColor,
.kern: 0.1,
])
attributedTitle = attr
target = self
self.action = #selector(invoke)
let tracking = NSTrackingArea(rect: .zero,
options: [.mouseEnteredAndExited, .inVisibleRect, .activeInActiveApp],
owner: self, userInfo: nil)
addTrackingArea(tracking)
}
required init?(coder: NSCoder) { fatalError() }
override var intrinsicContentSize: NSSize {
let base = super.intrinsicContentSize
return NSSize(width: base.width + 22, height: 30)
}
override func layout() {
super.layout()
fill.frame = bounds
glow.frame = bounds
glow.shadowPath = CGPath(roundedRect: bounds, cornerWidth: 8, cornerHeight: 8, transform: nil)
stroke.frame = bounds
stroke.path = CGPath(
roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5),
cornerWidth: 7.5, cornerHeight: 7.5, transform: nil
)
}
override func resetCursorRects() {
addCursorRect(bounds, cursor: .pointingHand)
}
override func mouseEntered(with event: NSEvent) {
isHovering = true
setState(hover: true, pressed: isPressed)
}
override func mouseExited(with event: NSEvent) {
isHovering = false
setState(hover: false, pressed: false)
}
override func mouseDown(with event: NSEvent) {
isPressed = true
setState(hover: isHovering, pressed: true)
super.mouseDown(with: event)
isPressed = false
setState(hover: isHovering, pressed: false)
}
private func setState(hover: Bool, pressed: Bool) {
CATransaction.begin()
CATransaction.setAnimationDuration(0.14)
let scale: CGFloat = pressed ? 0.97 : 1.0
layer?.transform = CATransform3DMakeScale(scale, scale, 1)
switch style {
case .primary:
glow.shadowOpacity = hover ? 0.18 : 0.0
fill.backgroundColor = (hover ? Palette.primaryHover : Palette.primary).cgColor
case .secondary:
glow.shadowOpacity = 0.0
fill.backgroundColor = (hover
? NSColor.white.withAlphaComponent(0.85)
: NSColor.white.withAlphaComponent(0.55)).cgColor
}
CATransaction.commit()
}
@objc private func invoke() { action_() }
}
// MARK: - Decision callback
enum Decision: String {
case deny = "deny"
case allowOnce = "allow_once"
case allowSession = "allow_session"
case dismissed = "dismissed"
}
@MainActor
final class DecisionSink {
static let shared = DecisionSink()
private var hasEmitted = false
private var configuration: Configuration?
func configure(_ config: Configuration) {
self.configuration = config
}
func emitAndExit(_ decision: Decision) {
guard !hasEmitted, let config = configuration else {
Foundation.exit(decision == .dismissed ? 1 : 0)
}
hasEmitted = true
let payload: [String: Any] = [
"decision": decision.rawValue,
"tool": config.tool,
"account": config.account,
]
let line: String
if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]),
let json = String(data: data, encoding: .utf8) {
line = json + "\n"
} else {
line = "{\"decision\":\"\(decision.rawValue)\"}\n"
}
if let path = config.callbackFile {
try? line.write(toFile: path, atomically: true, encoding: .utf8)
} else {
FileHandle.standardOutput.write(Data(line.utf8))
}
Foundation.exit(decision == .dismissed ? 1 : 0)
}
}
// MARK: - App
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
private let configuration: Configuration
private var window: NSWindow?
init(configuration: Configuration) {
self.configuration = configuration
super.init()
}
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
DecisionSink.shared.configure(configuration)
createWindow()
if let timeoutSeconds = configuration.timeoutSeconds {
Timer.scheduledTimer(withTimeInterval: timeoutSeconds, repeats: false) { _ in
Task { @MainActor in
DecisionSink.shared.emitAndExit(.dismissed)
}
}
}
}
func windowWillClose(_ notification: Notification) {
DecisionSink.shared.emitAndExit(.dismissed)
}
private func createWindow() {
let frame = bottomRightFrame()
let panel = NSPanel(
contentRect: frame,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = false
panel.ignoresMouseEvents = false
panel.isMovableByWindowBackground = true
panel.isFloatingPanel = true
panel.animationBehavior = .none
panel.level = .floating
panel.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary]
panel.delegate = self
panel.contentView = makeContentView()
panel.setFrame(frame, display: true, animate: false)
panel.orderFrontRegardless()
DispatchQueue.main.async { [weak panel] in
panel?.setFrame(self.bottomRightFrame(), display: true, animate: false)
}
window = panel
}
private func bottomRightFrame() -> NSRect {
let screen = screenForPlacement()
let visibleFrame = screen.visibleFrame
let size = NSSize(width: configuration.width, height: configuration.height)
let origin = NSPoint(
x: visibleFrame.maxX - size.width - configuration.margin,
y: visibleFrame.minY + configuration.margin
)
return NSRect(origin: origin, size: size)
}
private func screenForPlacement() -> NSScreen {
let mouseLocation = NSEvent.mouseLocation
if let containingMouse = NSScreen.screens.first(where: { NSMouseInRect(mouseLocation, $0.frame, false) }) {
return containingMouse
}
return NSScreen.main ?? NSScreen.screens.first ?? NSScreen()
}
private func makeContentView() -> NSView {
let root = NSView(frame: NSRect(x: 0, y: 0, width: configuration.width, height: configuration.height))
root.wantsLayer = true
root.layer?.backgroundColor = NSColor.clear.cgColor
root.layer?.masksToBounds = false
// Soft drop shadow behind the card.
let shadowHost = NSView()
shadowHost.wantsLayer = true
shadowHost.translatesAutoresizingMaskIntoConstraints = false
if let l = shadowHost.layer {
l.shadowColor = NSColor.black.cgColor
l.shadowOpacity = 0.18
l.shadowRadius = 30
l.shadowOffset = CGSize(width: 0, height: -12)
l.masksToBounds = false
}
root.addSubview(shadowHost)
let card = CardView()
card.translatesAutoresizingMaskIntoConstraints = false
shadowHost.addSubview(card)
// Dithered sine-wave shader — fills the card.
let background = MetalBackgroundView(frame: .zero)
background.translatesAutoresizingMaskIntoConstraints = false
card.addSubview(background)
// Translucent white wash so the dither reads more softly under text.
let wash = NSView()
wash.wantsLayer = true
wash.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.55).cgColor
wash.translatesAutoresizingMaskIntoConstraints = false
card.addSubview(wash)
// --- Bottom-left brand mark is always Composio.
let logoView = NSImageView()
logoView.image = Agents.composio.makeImage()
logoView.imageScaling = .scaleProportionallyUpOrDown
logoView.imageAlignment = .alignCenter
logoView.translatesAutoresizingMaskIntoConstraints = false
card.addSubview(logoView)
// --- Title: "[agent-icon] wants to use TOOL with ACCOUNT".
// Agent logo is inlined into the attributed string via NSTextAttachment,
// so it sits on the same baseline as the rest of the title.
let toolSlug = configuration.tool
let account = configuration.account
let titleFont = NSFont.systemFont(ofSize: 15, weight: .semibold)
let monoFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .semibold)
let textAttrs: [NSAttributedString.Key: Any] = [
.font: titleFont,
.foregroundColor: Palette.textPrimary,
.kern: -0.2,
]
let monoAttrs: [NSAttributedString.Key: Any] = [
.font: monoFont,
.foregroundColor: Palette.textPrimary,
]
let titleAttr = NSMutableAttributedString()
if let customTitle = configuration.title, !customTitle.isEmpty {
// Caller supplied a verbatim title — render as-is, still
// monospacing the tool/account if they appear.
titleAttr.append(NSAttributedString(string: customTitle, attributes: textAttrs))
if let r = customTitle.range(of: toolSlug) {
titleAttr.addAttribute(.font, value: monoFont,
range: NSRange(r, in: customTitle))
}
if let r = customTitle.range(of: account) {
titleAttr.addAttribute(.font, value: monoFont,
range: NSRange(r, in: customTitle))
}
} else if let agent = configuration.resolvedAgent {
// Inline agent logo as the subject of the sentence.
if let agentImage = agent.makeImage() {
let attachment = NSTextAttachment()
attachment.image = agentImage
attachment.bounds = CGRect(x: 0, y: -7, width: 28, height: 28)
titleAttr.append(NSAttributedString(attachment: attachment))
titleAttr.append(NSAttributedString(string: " ", attributes: textAttrs))
}
titleAttr.append(NSAttributedString(string: "wants to use ", attributes: textAttrs))
titleAttr.append(NSAttributedString(string: toolSlug, attributes: monoAttrs))
} else {
// No caller agent — plain CLI-mode sentence, no inline logo.
titleAttr.append(NSAttributedString(string: "The composio cli wants to execute ", attributes: textAttrs))
titleAttr.append(NSAttributedString(string: toolSlug, attributes: monoAttrs))
}
let title = NSTextField(labelWithAttributedString: titleAttr)
title.lineBreakMode = .byTruncatingTail
title.maximumNumberOfLines = 2
title.translatesAutoresizingMaskIntoConstraints = false
card.addSubview(title)
// --- Account line: "Account: <account>"
let accountAttr = NSMutableAttributedString()
accountAttr.append(NSAttributedString(string: "Account: ", attributes: [
.font: NSFont.systemFont(ofSize: 12, weight: .regular),
.foregroundColor: Palette.textSecondary,
]))
accountAttr.append(NSAttributedString(string: account, attributes: [
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .semibold),
.foregroundColor: Palette.textPrimary,
]))
let accountField = NSTextField(labelWithAttributedString: accountAttr)
accountField.lineBreakMode = .byTruncatingTail
accountField.maximumNumberOfLines = 1
accountField.translatesAutoresizingMaskIntoConstraints = false
card.addSubview(accountField)
// --- Three actions matching the elicitation oneOf decisions.
let denyButton = AccentButton(title: configuration.denyLabel, style: .secondary) {
DecisionSink.shared.emitAndExit(.deny)
}
let allowSessionButton = AccentButton(title: configuration.allowSessionLabel, style: .secondary) {
DecisionSink.shared.emitAndExit(.allowSession)
}
let allowOnceButton = AccentButton(title: configuration.allowOnceLabel, style: .primary) {
DecisionSink.shared.emitAndExit(.allowOnce)
}
// Enter triggers the default action.
allowOnceButton.keyEquivalent = "\r"
let buttonRow = NSStackView(views: [denyButton, allowSessionButton, allowOnceButton])
buttonRow.orientation = .horizontal
buttonRow.alignment = .centerY
buttonRow.spacing = 8
buttonRow.translatesAutoresizingMaskIntoConstraints = false
card.addSubview(buttonRow)
let hPad: CGFloat = 22
let headerTop: CGFloat = 20
let bodyBottom: CGFloat = 18
let logoSize: CGFloat = 32
NSLayoutConstraint.activate([
shadowHost.leadingAnchor.constraint(equalTo: root.leadingAnchor),
shadowHost.trailingAnchor.constraint(equalTo: root.trailingAnchor),
shadowHost.topAnchor.constraint(equalTo: root.topAnchor),
shadowHost.bottomAnchor.constraint(equalTo: root.bottomAnchor),
card.leadingAnchor.constraint(equalTo: shadowHost.leadingAnchor),
card.trailingAnchor.constraint(equalTo: shadowHost.trailingAnchor),
card.topAnchor.constraint(equalTo: shadowHost.topAnchor),
card.bottomAnchor.constraint(equalTo: shadowHost.bottomAnchor),
background.leadingAnchor.constraint(equalTo: card.leadingAnchor),
background.trailingAnchor.constraint(equalTo: card.trailingAnchor),
background.topAnchor.constraint(equalTo: card.topAnchor),
background.bottomAnchor.constraint(equalTo: card.bottomAnchor),
wash.leadingAnchor.constraint(equalTo: card.leadingAnchor),
wash.trailingAnchor.constraint(equalTo: card.trailingAnchor),
wash.topAnchor.constraint(equalTo: card.topAnchor),
wash.bottomAnchor.constraint(equalTo: card.bottomAnchor),
// Logo anchored to the bottom-left, aligned with button row baseline.
logoView.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: hPad),
logoView.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -bodyBottom - 4),
logoView.widthAnchor.constraint(equalToConstant: logoSize),
logoView.heightAnchor.constraint(equalToConstant: logoSize),
title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: hPad),
title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -hPad),
title.topAnchor.constraint(equalTo: card.topAnchor, constant: headerTop),
accountField.leadingAnchor.constraint(equalTo: title.leadingAnchor),
accountField.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -hPad),
accountField.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
buttonRow.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -hPad),
buttonRow.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -bodyBottom),
])
return root
}
}
let configuration = Configuration(arguments: Array(CommandLine.arguments.dropFirst()))
let application = NSApplication.shared
let delegate = AppDelegate(configuration: configuration)
application.delegate = delegate
application.run()