|
-- ~/.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()) |