Vibe Writing

Minyoung Jeong @kkung@hackers.pub
요즘 영어로 메세지를 보낼일이 이래저래 많은데, 영문 작성을 위해서 AI의 도움을 많이 받고 있다. 그런데 매번 C-C & C-V 하기가 여간 귀찮은게 아니였는데, Hammerspoon을 이용하면 손쉽게 맥의 Accessibility API를 쓸 수 있단걸 떠올리고 간단히 하나 만들었다. 그리고 작성 대부분을 Vibe coding으로 해보려고 했는데 자꾸 없는 API를 쓰려 해서 결국 참고만 하고 직접 만듦. ((Vibe coder의 길은 오늘도 멀고 험난하다..))
아래 스크립트를 이용하면 입력한 텍스트를 선택하고 Cmd+Shift+K를 누르면 선택 영역을 유지한 상태로 한<->영 번역을 수행한다.
local config = {
-- OpenAI API Key
openai_api_key = "sk-proj--",
-- I'll use Response API
openai_api_url = "https://api.openai.com/v1/responses",
-- Model name
openai_model = "gpt-4o",
}
local function callOpenAI(text, callback)
if not config.openai_api_key then
hs.alert.show("Config error: missing openai_api_key")
return
end
local insturction = "입력된 문장이 영어일 경우 한국어로, 한국어일 경우 영어로 변환해줘. 의미를 모국어 사용자가 자연스럽게 받아들일 수 있게 정확하고 유창하게 전달하고, 불필요한 문장을 생략하여 명료하게 작성해. 번역어 외의 다른 문장을 추가하지 말고 번역 그 자체만 반환해."
local request = hs.json.encode({
model = config.openai_model,
instructions = insturction,
input = text,
max_output_tokens = 5000,
})
print(request)
local headers = {
["Content-Type"] = "application/json",
["Authorization"] = "Bearer " .. config.openai_api_key
}
hs.http.asyncPost(config.openai_api_url, request, headers, function(status, response, _)
if status == 200 then
local success, data = pcall(hs.json.decode, response)
if success and data.status == "completed" then
local translated = data.output[1].content[1].text
print("TR: " .. translated)
callback(translated)
else
hs.alert.show("Call failed " .. data)
callback(nil)
end
else
print(status, response)
hs.alert.show("Call failed " .. status)
callback(nil)
end
end)
end
local function getSelectedTextCB()
local orig_cb = hs.pasteboard.getContents()
print("Original Clipboard " .. orig_cb)
hs.eventtap.keyStroke({"cmd"}, "c")
local sel = hs.pasteboard.getContents()
print("Selected Text " .. orig_cb)
hs.timer.doAfter(0.1, function()
if orig_cb then
hs.pasteboard.setContents(orig_cb)
end
end)
return sel
end
local function getFocusedElement()
local ax = hs.axuielement
local sys = ax.systemWideElement()
local focused = sys:attributeValue("AXFocusedUIElement")
return focused
end
local function getSelectedTextAX()
local focused = getFocusedElement()
if not focused then
hs.alert.show("Could not found focused element")
return nil
end
local selected_text = focused:attributeValue("AXSelectedText")
if selected_text and selected_text ~= "" then
return selected_text
end
end
local function getSelectedText()
local sel = getSelectedTextAX()
if sel == nil then
sel = getSelectedTextCB()
end
return sel
end
local function replaceSelectedTextCB(new_text)
local orig_cb = hs.pasteboard.getContents()
hs.pasteboard.setContents(new_text)
hs.eventtap.keyStroke({"cmd"}, "v")
hs.timer.doAfter(0.1, function()
if orig_cb then
hs.pasteboard.setContents(orig_cb)
end
end)
end
local function replaceSelectedTextAX(new_text)
local focused = getFocusedElement()
local ran = focused:attributeValue("AXSelectedTextRange")
if ran then
local val = focused:attributeValue("AXValue")
local start_pos = utf8.offset(val, ran.location + 1)
local end_pos = utf8.offset(val, ran.location + ran.length + 1)
print(hs.inspect(val) .. "start_pos=" .. start_pos .. ", end_pos=" .. end_pos)
local new_val = string.sub(val, 1, start_pos - 1) ..
new_text ..
string.sub(val, end_pos, -1)
print(hs.inspect(new_text) .. ", " .. new_val)
focused:setAttributeValue("AXValue", new_val)
hs.timer.doAfter(0.1, function()
-- adjust cursor position
local new_ran = {location = ran.location, length = utf8.len(new_text)}
focused:setAttributeValue("AXSelectedTextRange", new_ran)
end)
return true
else
return nil
end
end
local function replaceSelectedText(new_text)
if replaceSelectedTextAX(new_text) == nil then
replaceSelectedTextCB(new_text)
end
end
hs.hotkey.bind({"cmd", "shift"}, "k", function()
local sel = getSelectedText()
if sel and sel ~= "" then
callOpenAI(sel, function(translated)
replaceSelectedText(translated)
end)
end
end)