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)
4

1 comment

If you have a fediverse account, you can comment on this article from your own instance. Search https://hackers.pub/ap/articles/0197b9e6-4a4b-7ac1-8f5b-eef6bebeec7d on your instance and reply to it.

1