Vibe Writing

Minyoung Jeong @kkung@hackers.pub

Recently, I've had many occasions to send messages in English, and I've been getting a lot of help from AI for English writing. However, it was quite annoying to copy and paste every time. Then I remembered that Hammerspoon allows easy access to Mac's Accessibility API, so I created a simple solution. I tried to do most of the development using Vibe coding, but it kept trying to use non-existent APIs, so I ended up just using it as a reference and building it myself. ((The path of a Vibe coder remains long and arduous today...))

With the script below, you can select text and press Cmd+Shift+K to perform Korean<->English translation while maintaining the selected area.

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