Skip to content

Instantly share code, notes, and snippets.

@Vashkatsi
Last active June 9, 2026 12:06
Show Gist options
  • Select an option

  • Save Vashkatsi/346969e4fea9ef81fba9d0ae9c9d4718 to your computer and use it in GitHub Desktop.

Select an option

Save Vashkatsi/346969e4fea9ef81fba9d0ae9c9d4718 to your computer and use it in GitHub Desktop.
Hammerspoon macOS Cmd+C+C translator for ChatGPT and Gemini

macOS Cmd+C+C AI Translator

A small Hammerspoon automation for macOS. Press Cmd+C+C to copy selected text, open the selected AI provider, paste a translation prompt, and optionally submit it automatically.

Behavior

  • If copied text is primarily Russian → translate to English.
  • If copied text is not primarily Russian → translate to Russian.
  • Preserves formatting, code blocks, URLs, commands, names, numbers, and technical terms.

Supported providers

  • ChatGPT macOS app
  • Gemini macOS app / PWA
  • Browser fallback for ChatGPT and Gemini

Requirements

  • macOS
  • Hammerspoon
  • ChatGPT app and/or Gemini app/PWA
  • Accessibility permissions for Hammerspoon

Install Hammerspoon

brew install --cask hammerspoon

Create config directory

mkdir -p ~/.hammerspoon

Install script

Download or copy init.lua into:

~/.hammerspoon/init.lua

Example:

cp init.lua ~/.hammerspoon/init.lua

Reload Hammerspoon

From the menu bar:

Hammerspoon → Reload Config

Or from terminal:

osascript -e 'tell application "Hammerspoon" to reload config'

macOS permissions

Open:

System Settings → Privacy & Security → Accessibility

Enable:

Hammerspoon

macOS may also ask for Automation or Input Monitoring permissions.

Usage

  1. Select text in any app.
  2. Press:

Cmd+C+C

  1. The selected provider opens.
  2. The translation prompt is pasted.
  3. If auto-submit is enabled, the prompt is sent automatically.

Menu

The menu bar item shows the active provider:

GPT Gemini AI

Menu options:

ChatGPT Gemini Auto-submit: ON/OFF Restore clipboard: ON/OFF Reload Hammerspoon config

Check app bundle IDs

ChatGPT:

osascript -e 'id of app "ChatGPT"'

Gemini:

osascript -e 'id of app "Gemini"'

If the returned bundle ID differs from the default config, update this section in init.lua:

chatGpt = {
    appName = "ChatGPT",
    bundleId = "com.openai.chat",
}

For Gemini:

gemini = {
    appName = "Gemini",
    bundleId = "actual.gemini.bundle.id",
}

If unsure, keep Gemini bundleId = nil.

Configuration

Main options in init.lua:

submitAutomatically = true
restoreClipboardAfterPaste = true
activeProvider = "chatgpt"

Available providers:

activeProvider = "chatgpt"
activeProvider = "gemini"

Disable auto-submit:

submitAutomatically = false

Disable clipboard restore:

restoreClipboardAfterPaste = false

Troubleshooting

Cmd+C still works normally, but Cmd+C+C does nothing

Reload Hammerspoon:

osascript -e 'tell application "Hammerspoon" to reload config'

Then check Accessibility permissions:

System Settings → Privacy & Security → Accessibility

ChatGPT does not open

Check the bundle ID:

osascript -e 'id of app "ChatGPT"'

Update:

bundleId = "returned.bundle.id"

Gemini opens in browser instead of app

Check if macOS can find the Gemini app:

open -a "Gemini"

If it opens, check bundle ID:

osascript -e 'id of app "Gemini"'

Then set:

bundleId = "returned.bundle.id"

Text is pasted into the wrong app

Increase delays:

openDelay = 1.30
appOpenDelay = 1.50
webOpenDelay = 2.80

Uninstall

Remove the config:

rm ~/.hammerspoon/init.lua

Or uninstall Hammerspoon:

brew uninstall --cask hammerspoon
-- ~/.hammerspoon/init.lua
--[[
Cmd+C+C translator MVP with provider switcher.
Flow:
1. First Cmd+C works as normal copy.
2. Second Cmd+C within doubleTapInterval triggers:
- read clipboard
- build translation prompt
- open selected provider: ChatGPT or Gemini
- paste prompt
- optionally press Return
- restore clipboard
Menu:
AI/GPT/Gemini -> ChatGPT / Gemini
]]
local config = {
doubleTapInterval = 0.45,
copyReadDelay = 0.18,
pasteDelay = 0.05,
restoreClipboardDelay = 0.90,
submitAutomatically = true,
restoreClipboardAfterPaste = true,
maxClipboardBytes = 120000,
showAlerts = true,
-- chatgpt | gemini
activeProvider = "chatgpt",
chatGpt = {
appName = "ChatGPT",
-- Check with:
-- osascript -e 'id of app "ChatGPT"'
bundleId = "com.openai.chat",
openDelay = 1.00,
newChatDelay = 0.35,
browserFallbackEnabled = true,
browserFallbackUrl = "https://chatgpt.com/",
browserFallbackDelay = 1.60
},
gemini = {
appName = "Gemini",
-- For Gemini PWA/Desktop app this may be different.
-- Check with:
-- osascript -e 'id of app "Gemini"'
--
-- Leave nil if unsure. It will use: open -a "Gemini"
bundleId = nil,
appOpenDelay = 1.20,
browserFallbackEnabled = true,
webUrl = "https://gemini.google.com/",
webOpenDelay = 2.20
}
}
local providerLabels = {
chatgpt = "ChatGPT",
gemini = "Gemini"
}
local state = {
lastCopyTapAt = 0,
isProcessing = false,
eventTap = nil,
menu = nil
}
local keyCodeC = hs.keycodes.map["c"]
local translationPromptTemplate = [[
You are a precise translation engine.
Detect the language of the input.
Rules:
- If the input is primarily Russian, translate it to natural, precise English.
- If the input is not primarily Russian, translate it to natural, precise Russian.
- Preserve meaning, formatting, paragraphs, lists, code blocks, numbers, names, URLs, commands, and technical terms.
- Do not explain anything.
- Do not add comments.
- Return only the translation.
Input:
{{clipboard}}
]]
local updateMenu
local function alert(message)
if config.showAlerts then
hs.alert.show(message)
end
end
local function trim(value)
if type(value) ~= "string" then
return ""
end
return value:match("^%s*(.-)%s*$")
end
local function getClipboardText()
local value = hs.pasteboard.getContents()
if type(value) ~= "string" then
return nil
end
return value
end
local function setClipboardText(value)
if type(value) ~= "string" then
return false
end
return hs.pasteboard.setContents(value)
end
local function buildTranslationPrompt(copiedText)
return translationPromptTemplate:gsub("{{clipboard}}", function()
return copiedText
end)
end
local function shellQuote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function isAutoRepeat(event)
local properties = hs.eventtap.event.properties
local value = event:getProperty(properties.keyboardEventAutorepeat)
return value ~= nil and value ~= 0
end
local function isCommandC(event)
if event:getKeyCode() ~= keyCodeC then
return false
end
if isAutoRepeat(event) then
return false
end
local flags = event:getFlags()
return flags.cmd == true
and flags.alt ~= true
and flags.ctrl ~= true
and flags.shift ~= true
and flags.fn ~= true
end
local function finishProcessing()
state.isProcessing = false
end
local function restoreClipboard(copiedText)
if not config.restoreClipboardAfterPaste then
return
end
hs.timer.doAfter(config.restoreClipboardDelay, function()
setClipboardText(copiedText)
end)
end
local function submitIfNeeded()
if not config.submitAutomatically then
return
end
hs.timer.doAfter(0.08, function()
hs.eventtap.keyStroke({}, "return", 0)
end)
end
local function pastePrompt(prompt, copiedText)
local clipboardSet = setClipboardText(prompt)
if not clipboardSet then
alert("Translator: failed to set clipboard")
finishProcessing()
return
end
hs.timer.doAfter(config.pasteDelay, function()
hs.eventtap.keyStroke({ "cmd" }, "v", 0)
submitIfNeeded()
restoreClipboard(copiedText)
finishProcessing()
end)
end
local function openUrlAndPaste(url, delay, prompt, copiedText)
hs.execute('/usr/bin/open ' .. shellQuote(url), true)
hs.timer.doAfter(delay, function()
pastePrompt(prompt, copiedText)
end)
end
local function launchOrFocusApp(appConfig)
if type(appConfig.bundleId) == "string" and appConfig.bundleId ~= "" then
local launchedByBundleId = hs.application.launchOrFocusByBundleID(appConfig.bundleId)
if launchedByBundleId then
return true
end
end
if type(appConfig.appName) ~= "string" or appConfig.appName == "" then
return false
end
local _, status = hs.execute(
"/usr/bin/open -a " .. shellQuote(appConfig.appName),
true
)
return status == true
end
local function openChatGptAndPaste(prompt, copiedText)
local launched = launchOrFocusApp(config.chatGpt)
if not launched then
if config.chatGpt.browserFallbackEnabled then
alert("Translator: ChatGPT app not found, opening browser")
openUrlAndPaste(
config.chatGpt.browserFallbackUrl,
config.chatGpt.browserFallbackDelay,
prompt,
copiedText
)
return
end
alert("Translator: ChatGPT app not found")
finishProcessing()
return
end
hs.timer.doAfter(config.chatGpt.openDelay, function()
-- Important:
-- Do NOT press Option+Space here.
-- It may be captured by Gemini or another app.
-- Cmd+N should create a new ChatGPT chat in the ChatGPT app.
hs.eventtap.keyStroke({ "cmd" }, "n", 0)
hs.timer.doAfter(config.chatGpt.newChatDelay, function()
pastePrompt(prompt, copiedText)
end)
end)
end
local function openGeminiAndPaste(prompt, copiedText)
local launched = launchOrFocusApp(config.gemini)
if launched then
hs.timer.doAfter(config.gemini.appOpenDelay, function()
pastePrompt(prompt, copiedText)
end)
return
end
if config.gemini.browserFallbackEnabled then
alert("Translator: Gemini app not found, opening browser")
openUrlAndPaste(
config.gemini.webUrl,
config.gemini.webOpenDelay,
prompt,
copiedText
)
return
end
alert("Translator: Gemini app not found")
finishProcessing()
end
local function openProviderAndPaste(prompt, copiedText)
if config.activeProvider == "gemini" then
openGeminiAndPaste(prompt, copiedText)
return
end
openChatGptAndPaste(prompt, copiedText)
end
local function processDoubleCopy()
local copiedText = getClipboardText()
if copiedText == nil or trim(copiedText) == "" then
alert("Translator: clipboard is empty")
finishProcessing()
return
end
if config.maxClipboardBytes ~= nil and #copiedText > config.maxClipboardBytes then
alert("Translator: copied text is too large: " .. tostring(#copiedText) .. " bytes")
finishProcessing()
return
end
local prompt = buildTranslationPrompt(copiedText)
openProviderAndPaste(prompt, copiedText)
end
local function handleCopyTap()
local now = hs.timer.secondsSinceEpoch()
local isDoubleTap = now - state.lastCopyTapAt <= config.doubleTapInterval
if isDoubleTap then
state.lastCopyTapAt = 0
state.isProcessing = true
-- Let the second Cmd+C reach the active app and update clipboard first.
hs.timer.doAfter(config.copyReadDelay, function()
processDoubleCopy()
end)
return
end
state.lastCopyTapAt = now
end
local function startEventTap()
if state.eventTap ~= nil then
state.eventTap:stop()
state.eventTap = nil
end
state.eventTap = hs.eventtap.new({ hs.eventtap.event.types.keyDown }, function(event)
if state.isProcessing then
return false
end
if isCommandC(event) then
handleCopyTap()
end
-- false = do not block normal Cmd+C.
return false
end)
state.eventTap:start()
end
local function getActiveProviderLabel()
return providerLabels[config.activeProvider] or "Unknown"
end
local function setActiveProvider(provider)
if providerLabels[provider] == nil then
alert("Translator: unknown provider: " .. tostring(provider))
return
end
config.activeProvider = provider
hs.settings.set("translator_active_provider", provider)
if updateMenu ~= nil then
updateMenu()
end
alert("Translator provider: " .. providerLabels[provider])
end
local function toggleAutoSubmit()
config.submitAutomatically = not config.submitAutomatically
hs.settings.set("translator_submit_automatically", config.submitAutomatically)
if updateMenu ~= nil then
updateMenu()
end
alert("Translator auto-submit: " .. tostring(config.submitAutomatically))
end
local function toggleRestoreClipboard()
config.restoreClipboardAfterPaste = not config.restoreClipboardAfterPaste
hs.settings.set("translator_restore_clipboard", config.restoreClipboardAfterPaste)
if updateMenu ~= nil then
updateMenu()
end
alert("Translator restore clipboard: " .. tostring(config.restoreClipboardAfterPaste))
end
local function buildMenuItems()
return {
{
title = "Provider: " .. getActiveProviderLabel(),
disabled = true
},
{
title = "-"
},
{
title = "ChatGPT",
checked = config.activeProvider == "chatgpt",
fn = function()
setActiveProvider("chatgpt")
end
},
{
title = "Gemini",
checked = config.activeProvider == "gemini",
fn = function()
setActiveProvider("gemini")
end
},
{
title = "-"
},
{
title = config.submitAutomatically and "Auto-submit: ON" or "Auto-submit: OFF",
fn = function()
toggleAutoSubmit()
end
},
{
title = config.restoreClipboardAfterPaste and "Restore clipboard: ON" or "Restore clipboard: OFF",
fn = function()
toggleRestoreClipboard()
end
},
{
title = "-"
},
{
title = "Reload Hammerspoon config",
fn = function()
hs.reload()
end
}
}
end
updateMenu = function()
if state.menu == nil then
return
end
local title = "AI"
if config.activeProvider == "chatgpt" then
title = "GPT"
elseif config.activeProvider == "gemini" then
title = "Gemini"
end
state.menu:setTitle(title)
state.menu:setTooltip("Cmd+C+C translator: " .. getActiveProviderLabel())
state.menu:setMenu(buildMenuItems())
end
local function restoreSettings()
local savedProvider = hs.settings.get("translator_active_provider")
if type(savedProvider) == "string" and providerLabels[savedProvider] ~= nil then
config.activeProvider = savedProvider
end
local savedAutoSubmit = hs.settings.get("translator_submit_automatically")
if type(savedAutoSubmit) == "boolean" then
config.submitAutomatically = savedAutoSubmit
end
local savedRestoreClipboard = hs.settings.get("translator_restore_clipboard")
if type(savedRestoreClipboard) == "boolean" then
config.restoreClipboardAfterPaste = savedRestoreClipboard
end
end
local function startMenu()
state.menu = hs.menubar.new()
if state.menu == nil then
return
end
updateMenu()
end
restoreSettings()
startEventTap()
startMenu()
alert("Translator loaded: Cmd+C+C → " .. getActiveProviderLabel())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment