From 91cb93ff1f1f344695b94dd5c2cf3025a204a69e Mon Sep 17 00:00:00 2001 From: jongwony Date: Thu, 29 Jan 2026 22:38:08 +0900 Subject: [PATCH 1/2] Add ClaudeTasks: Claude Code task viewer with session management Features: - Floating WebView displaying tasks from ~/.claude/tasks/ - Session management with quick switching and ID copy - Launch Claude in specific working directory with session resume (-r flag) - Quick task updates via TaskUpdate API - Auto-refresh via file watcher - Bidirectional JavaScript-Lua bridge Hotkeys: opt+. (toggle), cmd+alt+T (status) Co-Authored-By: Claude Opus 4.5 --- Source/ClaudeTasks.spoon/docs.json | 167 ++++ Source/ClaudeTasks.spoon/init.lua | 1346 ++++++++++++++++++++++++++++ 2 files changed, 1513 insertions(+) create mode 100644 Source/ClaudeTasks.spoon/docs.json create mode 100644 Source/ClaudeTasks.spoon/init.lua diff --git a/Source/ClaudeTasks.spoon/docs.json b/Source/ClaudeTasks.spoon/docs.json new file mode 100644 index 00000000..d489907b --- /dev/null +++ b/Source/ClaudeTasks.spoon/docs.json @@ -0,0 +1,167 @@ +[ + { + "Command": [], + "Constant": [], + "Constructor": [], + "Deprecated": [], + "Field": [], + "Function": [], + "Method": [ + { + "def": "ClaudeTasks:start()", + "desc": "Start the module (begin file watching)", + "doc": "Start the module (begin file watching)\n\nParameters:\n * None\n\nReturns:\n * The ClaudeTasks object", + "examples": [], + "file": "Source/ClaudeTasks.spoon/init.lua", + "lineno": "1258", + "name": "start", + "notes": [], + "parameters": [" * None"], + "returns": [" * The ClaudeTasks object"], + "signature": "ClaudeTasks:start()", + "stripped_doc": "", + "type": "Method" + }, + { + "def": "ClaudeTasks:stop()", + "desc": "Stop the module (stop file watching and hide viewer)", + "doc": "Stop the module (stop file watching and hide viewer)\n\nParameters:\n * None\n\nReturns:\n * The ClaudeTasks object", + "examples": [], + "file": "Source/ClaudeTasks.spoon/init.lua", + "lineno": "1266", + "name": "stop", + "notes": [], + "parameters": [" * None"], + "returns": [" * The ClaudeTasks object"], + "signature": "ClaudeTasks:stop()", + "stripped_doc": "", + "type": "Method" + }, + { + "def": "ClaudeTasks:show()", + "desc": "Show the task viewer", + "doc": "Show the task viewer\n\nParameters:\n * None\n\nReturns:\n * The ClaudeTasks object", + "examples": [], + "file": "Source/ClaudeTasks.spoon/init.lua", + "lineno": "1012", + "name": "show", + "notes": [], + "parameters": [" * None"], + "returns": [" * The ClaudeTasks object"], + "signature": "ClaudeTasks:show()", + "stripped_doc": "", + "type": "Method" + }, + { + "def": "ClaudeTasks:hide()", + "desc": "Hide the task viewer", + "doc": "Hide the task viewer\n\nParameters:\n * None\n\nReturns:\n * The ClaudeTasks object", + "examples": [], + "file": "Source/ClaudeTasks.spoon/init.lua", + "lineno": "1034", + "name": "hide", + "notes": [], + "parameters": [" * None"], + "returns": [" * The ClaudeTasks object"], + "signature": "ClaudeTasks:hide()", + "stripped_doc": "", + "type": "Method" + }, + { + "def": "ClaudeTasks:toggle()", + "desc": "Toggle the task viewer visibility", + "doc": "Toggle the task viewer visibility\n\nParameters:\n * None\n\nReturns:\n * The ClaudeTasks object", + "examples": [], + "file": "Source/ClaudeTasks.spoon/init.lua", + "lineno": "1044", + "name": "toggle", + "notes": [], + "parameters": [" * None"], + "returns": [" * The ClaudeTasks object"], + "signature": "ClaudeTasks:toggle()", + "stripped_doc": "", + "type": "Method" + }, + { + "def": "ClaudeTasks:bindHotkeys(mapping)", + "desc": "Bind hotkeys for ClaudeTasks", + "doc": "Bind hotkeys for ClaudeTasks\n\nParameters:\n * mapping - A table with keys 'toggle' and 'status', each containing a hotkey spec\n\nReturns:\n * The ClaudeTasks object", + "examples": [], + "file": "Source/ClaudeTasks.spoon/init.lua", + "lineno": "1005", + "name": "bindHotkeys", + "notes": [], + "parameters": [" * mapping - A table with keys 'toggle' and 'status', each containing a hotkey spec"], + "returns": [" * The ClaudeTasks object"], + "signature": "ClaudeTasks:bindHotkeys(mapping)", + "stripped_doc": "", + "type": "Method" + }, + { + "def": "ClaudeTasks:configure(options)", + "desc": "Update configuration options", + "doc": "Update configuration options\n\nParameters:\n * options - A table of configuration options to merge\n\nReturns:\n * The ClaudeTasks object", + "examples": [], + "file": "Source/ClaudeTasks.spoon/init.lua", + "lineno": "1282", + "name": "configure", + "notes": [], + "parameters": [" * options - A table of configuration options to merge"], + "returns": [" * The ClaudeTasks object"], + "signature": "ClaudeTasks:configure(options)", + "stripped_doc": "", + "type": "Method" + }, + { + "def": "ClaudeTasks:setSession(sessionId)", + "desc": "Set the current session ID", + "doc": "Set the current session ID\n\nParameters:\n * sessionId - The Claude Code session ID to use\n\nReturns:\n * The ClaudeTasks object", + "examples": [], + "file": "Source/ClaudeTasks.spoon/init.lua", + "lineno": "1060", + "name": "setSession", + "notes": [], + "parameters": [" * sessionId - The Claude Code session ID to use"], + "returns": [" * The ClaudeTasks object"], + "signature": "ClaudeTasks:setSession(sessionId)", + "stripped_doc": "", + "type": "Method" + }, + { + "def": "ClaudeTasks:status()", + "desc": "Return current state information", + "doc": "Return current state information\n\nParameters:\n * None\n\nReturns:\n * A table containing current state (visible, sessionId, taskCount)", + "examples": [], + "file": "Source/ClaudeTasks.spoon/init.lua", + "lineno": "1292", + "name": "status", + "notes": [], + "parameters": [" * None"], + "returns": [" * A table containing current state (visible, sessionId, taskCount)"], + "signature": "ClaudeTasks:status()", + "stripped_doc": "", + "type": "Method" + } + ], + "Variable": [ + { + "def": "ClaudeTasks.config", + "desc": "Configuration table for ClaudeTasks", + "doc": "Configuration table for ClaudeTasks\n\nKeys:\n * width - Window width (default 420)\n * height - Window height (default 580)\n * margin - Window margin from screen edge (default 20)\n * refreshDebounce - Debounce time for file watcher (default 0.2)\n * debugMode - Enable debug logging (default false)\n * taskListId - Claude Code session ID (from CLAUDE_CODE_TASK_LIST_ID env var)\n * claudePath - Path to claude CLI (auto-discovered if nil)\n * terminalApp - Path to terminal app (auto-discovered if nil)\n * shell - Shell to use (defaults to $SHELL or /bin/zsh)", + "file": "Source/ClaudeTasks.spoon/init.lua", + "lineno": "19", + "name": "config", + "signature": "ClaudeTasks.config", + "stripped_doc": "", + "type": "Variable" + } + ], + "desc": "Claude Code task viewer for Hammerspoon", + "doc": "Claude Code task viewer for Hammerspoon\n\nDisplays tasks from ~/.claude/tasks/ in a floating WebView window.\nFeatures session management, quick task updates, and Claude CLI integration.\n\nDownload: https://github.com/jongwony/ClaudeTasks.spoon", + "items": [], + "name": "ClaudeTasks", + "stripped_doc": "", + "submodules": [], + "type": "Module" + } +] diff --git a/Source/ClaudeTasks.spoon/init.lua b/Source/ClaudeTasks.spoon/init.lua new file mode 100644 index 00000000..d69a4f02 --- /dev/null +++ b/Source/ClaudeTasks.spoon/init.lua @@ -0,0 +1,1346 @@ +-- ClaudeTasks.spoon +-- Hammerspoon Spoon for Claude Code Task viewer +-- opt+. 핫키로 플로팅 윈도우에 태스크 목록 표시 + +local obj = {} + +-- Spoon Metadata +obj.name = "ClaudeTasks" +obj.version = "1.1.0" +obj.author = "jongwony " +obj.license = "MIT - https://opensource.org/licenses/MIT" +obj.homepage = "https://github.com/jongwony/ClaudeTasks.spoon" +obj.spoonPath = hs.spoons.scriptPath() + +-- ============================================================================ +-- 설정 +-- ============================================================================ + +obj.config = { + -- UI + width = 420, + height = 580, + margin = 20, + refreshDebounce = 0.2, + debugMode = false, + + -- Session + taskListId = os.getenv("CLAUDE_CODE_TASK_LIST_ID"), + + -- External Tools (nil = auto-discover) + claudePath = nil, + terminalApp = nil, + shell = nil, +} + +-- ============================================================================ +-- Helper Functions for Discovery +-- ============================================================================ + +local function discoverClaudePath() + if obj.config.claudePath then return obj.config.claudePath end + -- GUI 앱은 PATH가 제한적이므로 일반적인 설치 경로를 직접 탐색 + local candidates = { + os.getenv("HOME") .. "/.local/bin/claude", + "/usr/local/bin/claude", + "/opt/homebrew/bin/claude", + } + for _, path in ipairs(candidates) do + if hs.fs.attributes(path) then return path end + end + return nil +end + +local function discoverTerminalApp() + if obj.config.terminalApp then return obj.config.terminalApp end + local candidates = { + "/Applications/Ghostty.app/Contents/MacOS/ghostty", + "/Applications/iTerm.app/Contents/MacOS/iTerm2", + "/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal" + } + for _, path in ipairs(candidates) do + if hs.fs.attributes(path) then return path end + end + return nil +end + +local function getShell() + return obj.config.shell or os.getenv("SHELL") or "/bin/zsh" +end + +-- ============================================================================ +-- 영속 상태 관리 +-- ============================================================================ + +obj.state = { + currentTaskListId = nil, + configPath = nil -- Set in init() +} + +-- ============================================================================ +-- 내부 상태 +-- ============================================================================ + +local webview = nil +local pathWatcher = nil +local refreshTimer = nil +local isVisible = false +local usercontent = nil -- JS-Lua 브릿지 +local cwdCache = {} -- sessionId -> cwd path cache + +-- ============================================================================ +-- 유틸리티 함수 +-- ============================================================================ + +local function log(message) + if obj.config.debugMode then + print("[ClaudeTasks] " .. message) + end +end + +local function getTasksDir() + return os.getenv("HOME") .. "/.claude/tasks" +end + +-- JSON 파싱 (간단한 구현 - 태스크 파일용) +local function parseJSON(str) + -- hs.json 사용 + local success, result = pcall(hs.json.decode, str) + if success then + return result + end + return nil +end + +-- 디렉토리 내 모든 항목 나열 +local function listDir(path) + local items = {} + local handle = io.popen('ls -1 "' .. path .. '" 2>/dev/null') + if handle then + for line in handle:lines() do + table.insert(items, line) + end + handle:close() + end + return items +end + +-- 파일이 존재하는지 확인 +local function fileExists(path) + local f = io.open(path, "r") + if f then + f:close() + return true + end + return false +end + +-- 파일 내용 읽기 +local function readFile(path) + local f = io.open(path, "r") + if f then + local content = f:read("*all") + f:close() + return content + end + return nil +end + +-- ============================================================================ +-- 상태 관리 함수 +-- ============================================================================ + +local function loadState() + local f = io.open(obj.state.configPath, "r") + if f then + local content = f:read("*all") + f:close() + local data = parseJSON(content) + if data then + obj.state.currentTaskListId = data.currentTaskListId + obj.config.taskListId = data.currentTaskListId + log("State loaded: " .. (data.currentTaskListId or "nil")) + end + end +end + +local function saveState() + local data = hs.json.encode({ + currentTaskListId = obj.state.currentTaskListId + }) + local f = io.open(obj.state.configPath, "w") + if f then + f:write(data) + f:close() + log("State saved: " .. (obj.state.currentTaskListId or "nil")) + end +end + +local function listSessionDirs() + local tasksDir = getTasksDir() + if not fileExists(tasksDir) then + return {} + end + + local allDirs = listDir(tasksDir) + local nonEmptySessions = {} + + for _, sessionId in ipairs(allDirs) do + local sessionDir = tasksDir .. "/" .. sessionId + local files = listDir(sessionDir) + -- .json 파일이 하나라도 있으면 포함 + for _, filename in ipairs(files) do + if filename:match("%.json$") then + table.insert(nonEmptySessions, sessionId) + break + end + end + end + + return nonEmptySessions +end + +-- ============================================================================ +-- CWD 추출 함수 +-- ============================================================================ + +local function decodeCwdPath(encodedDir) + -- Encoding: / → -, /. → -- + local path = encodedDir:gsub("%-%-", "\001") -- -- → temp marker + path = path:gsub("^%-", "/") -- leading - → / + path = path:gsub("%-", "/") -- remaining - → / + path = path:gsub("\001", "/.") -- temp marker → /. + return path +end + +local function getCwdFromSessionId(sessionId) + if cwdCache[sessionId] then return cwdCache[sessionId] end + + local projectsDir = os.getenv("HOME") .. "/.claude/projects" + if not fileExists(projectsDir) then return nil end + + for _, encodedDir in ipairs(listDir(projectsDir)) do + local sessionFile = projectsDir .. "/" .. encodedDir .. "/" .. sessionId .. ".jsonl" + if fileExists(sessionFile) then + local cwd = decodeCwdPath(encodedDir) + cwdCache[sessionId] = cwd + return cwd + end + end + return nil +end + +-- ============================================================================ +-- 태스크 로딩 +-- ============================================================================ + +local function loadAllTasks() + local tasks = {} + local tasksDir = getTasksDir() + + if not fileExists(tasksDir) then + log("Tasks directory does not exist: " .. tasksDir) + return tasks + end + + -- 특정 세션 ID가 설정되어 있으면 해당 세션만 로드 + local sessions = {} + if obj.config.taskListId then + sessions = {obj.config.taskListId} + else + sessions = listDir(tasksDir) + end + + for _, sessionId in ipairs(sessions) do + local sessionDir = tasksDir .. "/" .. sessionId + local files = listDir(sessionDir) + + for _, filename in ipairs(files) do + if filename:match("%.json$") then + local filepath = sessionDir .. "/" .. filename + local content = readFile(filepath) + + if content then + local task = parseJSON(content) + if task then + task._sessionId = sessionId + task._filepath = filepath + task._cwd = getCwdFromSessionId(sessionId) + table.insert(tasks, task) + end + end + end + end + end + + -- ID로 정렬 (숫자 우선, 문자열 후순) + table.sort(tasks, function(a, b) + local aNum = tonumber(a.id) + local bNum = tonumber(b.id) + if aNum and bNum then + return aNum < bNum + end + return tostring(a.id) < tostring(b.id) + end) + + log("Loaded " .. #tasks .. " tasks") + return tasks +end + +-- ============================================================================ +-- HTML 렌더링 +-- ============================================================================ + +local function escapeHtml(str) + if not str then return "" end + return str:gsub("&", "&") + :gsub("<", "<") + :gsub(">", ">") + :gsub('"', """) + :gsub("'", "'") +end + +local function getStatusColor(status) + if status == "completed" then + return "#22c55e" -- green + elseif status == "in_progress" then + return "#f59e0b" -- amber + else + return "#6b7280" -- gray (pending) + end +end + +local function getStatusIcon(status) + if status == "completed" then + return "✓" + elseif status == "in_progress" then + return "◐" + else + return "○" + end +end + +local function generateHTML(tasks) + local pendingTasks = {} + local inProgressTasks = {} + local completedTasks = {} + + for _, task in ipairs(tasks) do + if task.status == "completed" then + table.insert(completedTasks, task) + elseif task.status == "in_progress" then + table.insert(inProgressTasks, task) + else + table.insert(pendingTasks, task) + end + end + + -- 세션 datalist 옵션 생성 + local sessions = listSessionDirs() + local sessionOptions = '' + for _, sessionId in ipairs(sessions) do + sessionOptions = sessionOptions .. string.format( + ' \n', + escapeHtml(sessionId) + ) + end + local currentSessionValue = obj.state.currentTaskListId or '' + + local html = [[ + + + + + + + + +
+
+ Claude Tasks +
+ + + ]] .. #tasks .. [[ tasks +
+
+ + + ]] .. sessionOptions .. [[ + +
+]] + + -- In Progress 섹션 + if #inProgressTasks > 0 then + html = html .. [[ +
+
In Progress (]] .. #inProgressTasks .. [[)
+]] + for _, task in ipairs(inProgressTasks) do + local blocked = "" + if task.blockedBy and #task.blockedBy > 0 then + blocked = '
Blocked by: ' .. table.concat(task.blockedBy, ", ") .. '
' + end + local launchBtn = task._cwd and '' or '' + html = html .. [[ +
+ ]] .. getStatusIcon("in_progress") .. [[ +
+
]] .. escapeHtml(task.subject) .. [[
+
+ ]] .. escapeHtml(task._sessionId:sub(1, 8)) .. [[... + #]] .. escapeHtml(tostring(task.id)) .. [[ +
+ ]] .. (task._cwd and '
' .. escapeHtml(task._cwd) .. '
' or '') .. [[ + ]] .. blocked .. launchBtn .. [[ +
+
+]] + end + html = html .. "
\n" + end + + -- Pending 섹션 + if #pendingTasks > 0 then + html = html .. [[ +
+
Pending (]] .. #pendingTasks .. [[)
+]] + for _, task in ipairs(pendingTasks) do + local blocked = "" + if task.blockedBy and #task.blockedBy > 0 then + blocked = '
Blocked by: ' .. table.concat(task.blockedBy, ", ") .. '
' + end + local launchBtn = task._cwd and '' or '' + html = html .. [[ +
+ ]] .. getStatusIcon("pending") .. [[ +
+
]] .. escapeHtml(task.subject) .. [[
+
+ ]] .. escapeHtml(task._sessionId:sub(1, 8)) .. [[... + #]] .. escapeHtml(tostring(task.id)) .. [[ +
+ ]] .. (task._cwd and '
' .. escapeHtml(task._cwd) .. '
' or '') .. [[ + ]] .. blocked .. launchBtn .. [[ +
+
+]] + end + html = html .. "
\n" + end + + -- Completed 섹션 (최대 5개만 표시) + if #completedTasks > 0 then + local displayCount = math.min(5, #completedTasks) + html = html .. [[ +
+
Completed (]] .. #completedTasks .. [[)
+]] + for i = 1, displayCount do + local task = completedTasks[i] + local launchBtn = task._cwd and '' or '' + html = html .. [[ +
+ ]] .. getStatusIcon("completed") .. [[ +
+
]] .. escapeHtml(task.subject) .. [[
+
+ ]] .. escapeHtml(task._sessionId:sub(1, 8)) .. [[... + #]] .. escapeHtml(tostring(task.id)) .. [[ +
+ ]] .. (task._cwd and '
' .. escapeHtml(task._cwd) .. '
' or '') .. launchBtn .. [[ +
+
+]] + end + if #completedTasks > displayCount then + html = html .. [[ +
+ + ]] .. (#completedTasks - displayCount) .. [[ more completed +
+]] + end + html = html .. "
\n" + end + + -- 태스크가 없는 경우 + if #tasks == 0 then + html = html .. [[ +
+ No tasks found.
+ Use TaskCreate in Claude Code to add tasks. +
+]] + end + + html = html .. [[ + + +]] + return html +end + +-- ============================================================================ +-- WebView 관리 +-- ============================================================================ + +local function createUserContent() + if usercontent then + return usercontent + end + + usercontent = hs.webview.usercontent.new("taskBridge") + usercontent:setCallback(function(msg) + log("Bridge message: " .. hs.json.encode(msg.body)) + + if msg.body.action == "setSession" then + obj:setTaskListId(msg.body.value) + elseif msg.body.action == "createTask" then + obj:createTask(msg.body.subject) + elseif msg.body.action == "launchClaude" then + obj:launchClaudeWithTaskList() + elseif msg.body.action == "launchClaudeWithCwd" then + obj:launchClaudeWithCwd(msg.body.sessionId, msg.body.cwd) + elseif msg.body.action == "showQuickUpdateDialog" then + local button, text = hs.dialog.textPrompt("Quick Task", "Enter prompt (e.g., 'TaskCreate: Fix bug' or 'TaskUpdate: #3 done'):", "", "OK", "Cancel") + if button == "OK" and text and text ~= "" then + obj:quickTaskUpdate(text) + end + end + end) + + log("UserContent bridge created") + return usercontent +end + +local function createWebView() + if webview then + return webview + end + + -- JS-Lua 브릿지 생성 + createUserContent() + + -- 화면 크기 가져오기 + local screen = hs.screen.mainScreen() + local frame = screen:frame() + + -- 오른쪽 하단에 위치 + local rect = hs.geometry.rect( + frame.x + frame.w - obj.config.width - obj.config.margin, + frame.y + frame.h - obj.config.height - obj.config.margin, + obj.config.width, + obj.config.height + ) + + webview = hs.webview.new(rect, {}, usercontent) + webview:windowStyle({"titled", "closable", "utility", "HUD"}) + webview:level(hs.drawing.windowLevels.floating) + webview:allowTextEntry(true) -- 폼 입력 허용 + webview:allowGestures(false) + webview:shadow(true) + webview:alpha(0.98) + webview:windowTitle("Claude Tasks") + + -- 창 닫힐 때 상태 업데이트 + webview:deleteOnClose(false) + + log("WebView created with usercontent bridge") + return webview +end + +local function refreshWebView() + if not webview then return end + + local tasks = loadAllTasks() + local html = generateHTML(tasks) + webview:html(html) + log("WebView refreshed with " .. #tasks .. " tasks") +end + +-- ============================================================================ +-- 파일 감시 +-- ============================================================================ + +local function startPathWatcher() + if pathWatcher then return end + + local tasksDir = getTasksDir() + + -- 디렉토리가 없으면 생성 대기 + if not fileExists(tasksDir) then + log("Tasks directory does not exist, will watch parent") + -- .claude 디렉토리 감시 + local parentDir = os.getenv("HOME") .. "/.claude" + pathWatcher = hs.pathwatcher.new(parentDir, function(paths) + -- tasks 디렉토리가 생성되면 재시작 + if fileExists(tasksDir) then + obj:stop() + obj:start() + end + end) + pathWatcher:start() + return + end + + -- 모든 세션 디렉토리 감시 + local sessions = listDir(tasksDir) + local watchPaths = {tasksDir} + + for _, sessionId in ipairs(sessions) do + table.insert(watchPaths, tasksDir .. "/" .. sessionId) + end + + pathWatcher = hs.pathwatcher.new(tasksDir, function(paths) + log("File change detected: " .. table.concat(paths, ", ")) + + -- 디바운스: 빠른 연속 변경 시 마지막 것만 처리 + if refreshTimer then + refreshTimer:stop() + end + + refreshTimer = hs.timer.doAfter(obj.config.refreshDebounce, function() + refreshWebView() + refreshTimer = nil + end) + end) + + pathWatcher:start() + log("PathWatcher started on: " .. tasksDir) +end + +local function stopPathWatcher() + if pathWatcher then + pathWatcher:stop() + pathWatcher = nil + log("PathWatcher stopped") + end + if refreshTimer then + refreshTimer:stop() + refreshTimer = nil + end +end + +-- ============================================================================ +-- 공개 API +-- ============================================================================ + +--- Initialize the Spoon +function obj:init() + obj.state.configPath = obj.spoonPath .. "/state.json" + log("ClaudeTasks Spoon initialized") + return self +end + +--- 태스크 뷰어 표시 +function obj:show() + if not webview then + createWebView() + end + refreshWebView() + webview:show() + webview:bringToFront() + isVisible = true + startPathWatcher() + + -- Focus session input after DOM ready + hs.timer.doAfter(0.1, function() + if webview then + webview:evaluateJavaScript("document.getElementById('sessionInput').focus(); document.getElementById('sessionInput').select();") + end + end) + + log("Task viewer shown") + return self +end + +--- 태스크 뷰어 숨기기 +function obj:hide() + if webview then + webview:hide() + isVisible = false + log("Task viewer hidden") + end + return self +end + +--- 표시/숨기기 토글 +function obj:toggle() + if isVisible then + obj:hide() + else + obj:show() + end + return self +end + +--- 수동 새로고침 +function obj:refresh() + refreshWebView() + return self +end + +--- 세션 ID 설정 +function obj:setTaskListId(id) + local sessionId = (id ~= "" and id) or nil + obj.state.currentTaskListId = sessionId + obj.config.taskListId = sessionId + saveState() + log("Session changed to: " .. (sessionId or "none")) + + -- 파일 감시 재시작 (새 세션에 맞게) + stopPathWatcher() + startPathWatcher() + + -- UI 새로고침 + obj:refresh() + return self +end + +--- 태스크 생성 (Claude CLI 사용) +function obj:createTask(subject) + local claudePath = discoverClaudePath() + if not claudePath then + hs.alert.show("Claude CLI not found", 2) + return nil + end + + local prompt = string.format("TaskCreate(%s)", subject) + + log("Creating task: " .. prompt) + + local env = {} + if obj.state.currentTaskListId and obj.state.currentTaskListId ~= "" then + env.CLAUDE_CODE_TASK_LIST_ID = obj.state.currentTaskListId + end + + local task = hs.task.new(claudePath, function(exitCode, stdout, stderr) + if exitCode == 0 then + hs.alert.show("Task created", 1) + log("Task created successfully. stdout: " .. (stdout or "")) + else + hs.alert.show("Task creation failed", 2) + log("Task creation failed. exitCode: " .. exitCode .. ", stderr: " .. (stderr or "")) + end + + -- UI 폼 리셋 (JS 호출) + if webview then + webview:evaluateJavaScript("resetForm()") + end + + -- 새로고침 + obj:refresh() + end, { + "-p", + "--model", "haiku", + prompt + }) + + if next(env) then + task:setEnvironment(env) + end + + -- ~/.claude에서 실행 + task:setWorkingDirectory(os.getenv("HOME") .. "/.claude") + task:start() + return task +end + +--- Quick TaskUpdate (haiku 모델로 빠른 태스크 업데이트) +function obj:quickTaskUpdate(prompt) + local taskListId = obj.state.currentTaskListId + if not taskListId or taskListId == "" then + hs.alert.show("Select a session first", 2) + return + end + + local claudePath = discoverClaudePath() + if not claudePath then + hs.alert.show("Claude CLI not found", 2) + return + end + + -- 필수 환경변수 설정 + local env = { + PATH = os.getenv("PATH") or "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + HOME = os.getenv("HOME"), + USER = os.getenv("USER"), + SHELL = getShell(), + TERM = "xterm-256color", + CLAUDE_CODE_ENABLE_TASKS = "true", + CLAUDE_CODE_TASK_LIST_ID = taskListId + } + + local systemPrompt = "This is a lightweight Todo Task management command. Use TaskCreate or TaskUpdate tools immediately based on the user's input. Do not ask for clarification - execute the tool directly." + + log("QuickTaskUpdate: " .. prompt .. " (taskListId: " .. taskListId .. ")") + + local task = hs.task.new(claudePath, function(exitCode, stdout, stderr) + if exitCode == 0 then + local result = (stdout or ""):gsub("^%s+", ""):gsub("%s+$", "") + if result == "" then result = "Done" end + -- 긴 결과는 잘라서 표시 + if #result > 200 then + result = result:sub(1, 200) .. "..." + end + hs.alert.show(result, 3) + log("QuickTaskUpdate completed. stdout: " .. (stdout or "")) + else + local errMsg = (stderr or ""):gsub("^%s+", ""):gsub("%s+$", "") + if errMsg == "" then errMsg = "TaskUpdate failed" end + hs.alert.show("❌ " .. errMsg:sub(1, 100), 3) + log("QuickTaskUpdate failed. exitCode: " .. exitCode .. ", stderr: " .. (stderr or "")) + end + obj:refresh() + end, { + "--model", "haiku", + "-p", + "--no-session-persistence", + "--disable-slash-commands", + "--strict-mcp-config", + "--dangerously-skip-permissions", + "--setting-sources", "", + "--system-prompt", systemPrompt, + "--verbose", + "--", + prompt + }) + + task:setEnvironment(env) + task:setWorkingDirectory(os.getenv("HOME") .. "/.claude") + task:start() + hs.alert.show("Running Quick Task...", 1) +end + +--- Claude Code 세션 실행 +function obj:launchClaudeWithTaskList() + local taskListId = obj.state.currentTaskListId + if not taskListId or taskListId == "" then + hs.alert.show("Select a session first", 2) + return + end + + local terminalPath = discoverTerminalApp() + if not terminalPath then + hs.alert.show("No terminal app found", 2) + return + end + + local claudeDir = os.getenv("HOME") .. "/.claude" + local shell = getShell() + local shellCmd = string.format("cd %s && CLAUDE_CODE_TASK_LIST_ID=%s claude", claudeDir, taskListId) + + log("Launching Claude: " .. shellCmd) + + local task = hs.task.new(terminalPath, function(exitCode, stdout, stderr) + if exitCode ~= 0 then + log("Terminal launch error: " .. (stderr or "unknown")) + end + end, { + "-e", shell, "-c", shellCmd + }) + + task:start() + hs.alert.show("Launching Claude...", 1) +end + +--- Claude Code 세션을 특정 cwd에서 실행 +function obj:launchClaudeWithCwd(sessionId, cwd) + if not sessionId or sessionId == "" then + hs.alert.show("No session ID", 2) + return + end + if not cwd or cwd == "" then + hs.alert.show("No working directory", 2) + return + end + + local terminalPath = discoverTerminalApp() + if not terminalPath then + hs.alert.show("No terminal app found", 2) + return + end + + local shell = getShell() + local shellCmd = string.format("cd '%s' && CLAUDE_CODE_TASK_LIST_ID=%s claude -r %s", cwd, sessionId, sessionId) + + log("Launching Claude with cwd: " .. shellCmd) + + local task = hs.task.new(terminalPath, function(exitCode, stdout, stderr) + if exitCode ~= 0 then + log("Terminal launch error: " .. (stderr or "unknown")) + end + end, { + "-e", shell, "-c", shellCmd + }) + + task:start() + hs.alert.show("Launching Claude in " .. cwd:match("[^/]+$") .. "...", 1) +end + +--- 모듈 시작 (파일 감시 시작) +function obj:start() + loadState() -- 저장된 상태 로드 + startPathWatcher() + log("Claude Tasks module started") + return self +end + +--- 모듈 중지 +function obj:stop() + stopPathWatcher() + if webview then + webview:delete() + webview = nil + end + if usercontent then + usercontent = nil + end + isVisible = false + cwdCache = {} + log("Claude Tasks module stopped") + return self +end + +--- 설정 업데이트 +function obj:configure(options) + if options then + for k, v in pairs(options) do + obj.config[k] = v + end + end + return self +end + +--- 현재 상태 반환 +function obj:status() + local tasks = loadAllTasks() + local pending = 0 + local inProgress = 0 + local completed = 0 + + for _, task in ipairs(tasks) do + if task.status == "completed" then + completed = completed + 1 + elseif task.status == "in_progress" then + inProgress = inProgress + 1 + else + pending = pending + 1 + end + end + + return { + visible = isVisible, + taskCount = #tasks, + pending = pending, + inProgress = inProgress, + completed = completed, + taskListId = obj.config.taskListId, + currentTaskListId = obj.state.currentTaskListId, + watcherActive = pathWatcher ~= nil, + } +end + +-- ============================================================================ +-- Hotkey Binding +-- ============================================================================ + +obj.defaultHotkeys = { + toggle = {{"alt"}, "."}, + status = {{"cmd", "alt"}, "T"} +} + +function obj:bindHotkeys(mapping) + local def = { + toggle = function() obj:toggle() end, + status = function() + local status = obj:status() + local msg = string.format( + "Tasks: %d total\n⏳ %d pending\n🔄 %d in progress\n✓ %d completed", + status.taskCount, status.pending, status.inProgress, status.completed + ) + hs.alert.show(msg, 3) + end + } + hs.spoons.bindHotkeysToSpec(def, mapping) + return self +end + +return obj From b9ef68c672b14f43eed18be75eec3e9589e4ba15 Mon Sep 17 00:00:00 2001 From: jongwony Date: Thu, 29 Jan 2026 22:43:44 +0900 Subject: [PATCH 2/2] Translate Korean comments to English for international community Co-Authored-By: Claude Opus 4.5 --- Source/ClaudeTasks.spoon/init.lua | 122 +++++++++++++++--------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/Source/ClaudeTasks.spoon/init.lua b/Source/ClaudeTasks.spoon/init.lua index d69a4f02..c209c088 100644 --- a/Source/ClaudeTasks.spoon/init.lua +++ b/Source/ClaudeTasks.spoon/init.lua @@ -1,6 +1,6 @@ -- ClaudeTasks.spoon -- Hammerspoon Spoon for Claude Code Task viewer --- opt+. 핫키로 플로팅 윈도우에 태스크 목록 표시 +-- Toggle floating task viewer with opt+. hotkey local obj = {} @@ -13,7 +13,7 @@ obj.homepage = "https://github.com/jongwony/ClaudeTasks.spoon" obj.spoonPath = hs.spoons.scriptPath() -- ============================================================================ --- 설정 +-- Configuration -- ============================================================================ obj.config = { @@ -39,7 +39,7 @@ obj.config = { local function discoverClaudePath() if obj.config.claudePath then return obj.config.claudePath end - -- GUI 앱은 PATH가 제한적이므로 일반적인 설치 경로를 직접 탐색 + -- GUI apps have limited PATH, so search common install locations directly local candidates = { os.getenv("HOME") .. "/.local/bin/claude", "/usr/local/bin/claude", @@ -69,7 +69,7 @@ local function getShell() end -- ============================================================================ --- 영속 상태 관리 +-- Persistent State Management -- ============================================================================ obj.state = { @@ -78,18 +78,18 @@ obj.state = { } -- ============================================================================ --- 내부 상태 +-- Internal State -- ============================================================================ local webview = nil local pathWatcher = nil local refreshTimer = nil local isVisible = false -local usercontent = nil -- JS-Lua 브릿지 +local usercontent = nil -- JS-Lua bridge local cwdCache = {} -- sessionId -> cwd path cache -- ============================================================================ --- 유틸리티 함수 +-- Utility Functions -- ============================================================================ local function log(message) @@ -102,9 +102,9 @@ local function getTasksDir() return os.getenv("HOME") .. "/.claude/tasks" end --- JSON 파싱 (간단한 구현 - 태스크 파일용) +-- JSON parsing (simple implementation for task files) local function parseJSON(str) - -- hs.json 사용 + -- Use hs.json local success, result = pcall(hs.json.decode, str) if success then return result @@ -112,7 +112,7 @@ local function parseJSON(str) return nil end --- 디렉토리 내 모든 항목 나열 +-- List all items in a directory local function listDir(path) local items = {} local handle = io.popen('ls -1 "' .. path .. '" 2>/dev/null') @@ -125,7 +125,7 @@ local function listDir(path) return items end --- 파일이 존재하는지 확인 +-- Check if a file exists local function fileExists(path) local f = io.open(path, "r") if f then @@ -135,7 +135,7 @@ local function fileExists(path) return false end --- 파일 내용 읽기 +-- Read file contents local function readFile(path) local f = io.open(path, "r") if f then @@ -147,7 +147,7 @@ local function readFile(path) end -- ============================================================================ --- 상태 관리 함수 +-- State Management Functions -- ============================================================================ local function loadState() @@ -188,7 +188,7 @@ local function listSessionDirs() for _, sessionId in ipairs(allDirs) do local sessionDir = tasksDir .. "/" .. sessionId local files = listDir(sessionDir) - -- .json 파일이 하나라도 있으면 포함 + -- Include if there's at least one .json file for _, filename in ipairs(files) do if filename:match("%.json$") then table.insert(nonEmptySessions, sessionId) @@ -201,7 +201,7 @@ local function listSessionDirs() end -- ============================================================================ --- CWD 추출 함수 +-- CWD Extraction Functions -- ============================================================================ local function decodeCwdPath(encodedDir) @@ -231,7 +231,7 @@ local function getCwdFromSessionId(sessionId) end -- ============================================================================ --- 태스크 로딩 +-- Task Loading -- ============================================================================ local function loadAllTasks() @@ -243,7 +243,7 @@ local function loadAllTasks() return tasks end - -- 특정 세션 ID가 설정되어 있으면 해당 세션만 로드 + -- Load only specified session if taskListId is set local sessions = {} if obj.config.taskListId then sessions = {obj.config.taskListId} @@ -273,7 +273,7 @@ local function loadAllTasks() end end - -- ID로 정렬 (숫자 우선, 문자열 후순) + -- Sort by ID (numeric first, then string) table.sort(tasks, function(a, b) local aNum = tonumber(a.id) local bNum = tonumber(b.id) @@ -288,7 +288,7 @@ local function loadAllTasks() end -- ============================================================================ --- HTML 렌더링 +-- HTML Rendering -- ============================================================================ local function escapeHtml(str) @@ -335,7 +335,7 @@ local function generateHTML(tasks) end end - -- 세션 datalist 옵션 생성 + -- Generate session datalist options local sessions = listSessionDirs() local sessionOptions = '' for _, sessionId in ipairs(sessions) do @@ -428,7 +428,7 @@ local function generateHTML(tasks) .session-input::placeholder { color: #666; } - /* TaskCreate 폼 */ + /* TaskCreate form */ .create-form { background: rgba(255, 255, 255, 0.05); border-radius: 8px; @@ -642,7 +642,7 @@ local function generateHTML(tasks) } function onSessionInputChange(input) { - // Enter 키 또는 blur 시 세션 변경 + // Change session on Enter key or blur setSession(input.value); } @@ -705,7 +705,7 @@ local function generateHTML(tasks) }); } - // 키보드 단축키 + // Keyboard shortcuts document.addEventListener('keydown', function(e) { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { createTask(); @@ -746,7 +746,7 @@ local function generateHTML(tasks) ]] - -- In Progress 섹션 + -- In Progress section if #inProgressTasks > 0 then html = html .. [[
@@ -776,7 +776,7 @@ local function generateHTML(tasks) html = html .. "
\n" end - -- Pending 섹션 + -- Pending section if #pendingTasks > 0 then html = html .. [[
@@ -806,7 +806,7 @@ local function generateHTML(tasks) html = html .. "
\n" end - -- Completed 섹션 (최대 5개만 표시) + -- Completed section (show max 5) if #completedTasks > 0 then local displayCount = math.min(5, #completedTasks) html = html .. [[ @@ -840,7 +840,7 @@ local function generateHTML(tasks) html = html .. " \n" end - -- 태스크가 없는 경우 + -- When no tasks exist if #tasks == 0 then html = html .. [[
@@ -858,7 +858,7 @@ local function generateHTML(tasks) end -- ============================================================================ --- WebView 관리 +-- WebView Management -- ============================================================================ local function createUserContent() @@ -895,14 +895,14 @@ local function createWebView() return webview end - -- JS-Lua 브릿지 생성 + -- Create JS-Lua bridge createUserContent() - -- 화면 크기 가져오기 + -- Get screen size local screen = hs.screen.mainScreen() local frame = screen:frame() - -- 오른쪽 하단에 위치 + -- Position at bottom-right local rect = hs.geometry.rect( frame.x + frame.w - obj.config.width - obj.config.margin, frame.y + frame.h - obj.config.height - obj.config.margin, @@ -913,13 +913,13 @@ local function createWebView() webview = hs.webview.new(rect, {}, usercontent) webview:windowStyle({"titled", "closable", "utility", "HUD"}) webview:level(hs.drawing.windowLevels.floating) - webview:allowTextEntry(true) -- 폼 입력 허용 + webview:allowTextEntry(true) -- Allow form input webview:allowGestures(false) webview:shadow(true) webview:alpha(0.98) webview:windowTitle("Claude Tasks") - -- 창 닫힐 때 상태 업데이트 + -- Update state when window closes webview:deleteOnClose(false) log("WebView created with usercontent bridge") @@ -936,7 +936,7 @@ local function refreshWebView() end -- ============================================================================ --- 파일 감시 +-- File Watching -- ============================================================================ local function startPathWatcher() @@ -944,13 +944,13 @@ local function startPathWatcher() local tasksDir = getTasksDir() - -- 디렉토리가 없으면 생성 대기 + -- Wait for directory creation if it doesn't exist if not fileExists(tasksDir) then log("Tasks directory does not exist, will watch parent") - -- .claude 디렉토리 감시 + -- Watch .claude directory local parentDir = os.getenv("HOME") .. "/.claude" pathWatcher = hs.pathwatcher.new(parentDir, function(paths) - -- tasks 디렉토리가 생성되면 재시작 + -- Restart when tasks directory is created if fileExists(tasksDir) then obj:stop() obj:start() @@ -960,7 +960,7 @@ local function startPathWatcher() return end - -- 모든 세션 디렉토리 감시 + -- Watch all session directories local sessions = listDir(tasksDir) local watchPaths = {tasksDir} @@ -971,7 +971,7 @@ local function startPathWatcher() pathWatcher = hs.pathwatcher.new(tasksDir, function(paths) log("File change detected: " .. table.concat(paths, ", ")) - -- 디바운스: 빠른 연속 변경 시 마지막 것만 처리 + -- Debounce: only process the last change in rapid succession if refreshTimer then refreshTimer:stop() end @@ -999,7 +999,7 @@ local function stopPathWatcher() end -- ============================================================================ --- 공개 API +-- Public API -- ============================================================================ --- Initialize the Spoon @@ -1009,7 +1009,7 @@ function obj:init() return self end ---- 태스크 뷰어 표시 +--- Show the task viewer function obj:show() if not webview then createWebView() @@ -1031,7 +1031,7 @@ function obj:show() return self end ---- 태스크 뷰어 숨기기 +--- Hide the task viewer function obj:hide() if webview then webview:hide() @@ -1041,7 +1041,7 @@ function obj:hide() return self end ---- 표시/숨기기 토글 +--- Toggle show/hide function obj:toggle() if isVisible then obj:hide() @@ -1051,13 +1051,13 @@ function obj:toggle() return self end ---- 수동 새로고침 +--- Manual refresh function obj:refresh() refreshWebView() return self end ---- 세션 ID 설정 +--- Set session ID function obj:setTaskListId(id) local sessionId = (id ~= "" and id) or nil obj.state.currentTaskListId = sessionId @@ -1065,16 +1065,16 @@ function obj:setTaskListId(id) saveState() log("Session changed to: " .. (sessionId or "none")) - -- 파일 감시 재시작 (새 세션에 맞게) + -- Restart file watcher for new session stopPathWatcher() startPathWatcher() - -- UI 새로고침 + -- Refresh UI obj:refresh() return self end ---- 태스크 생성 (Claude CLI 사용) +--- Create task (using Claude CLI) function obj:createTask(subject) local claudePath = discoverClaudePath() if not claudePath then @@ -1100,12 +1100,12 @@ function obj:createTask(subject) log("Task creation failed. exitCode: " .. exitCode .. ", stderr: " .. (stderr or "")) end - -- UI 폼 리셋 (JS 호출) + -- Reset UI form (JS call) if webview then webview:evaluateJavaScript("resetForm()") end - -- 새로고침 + -- Refresh obj:refresh() end, { "-p", @@ -1117,13 +1117,13 @@ function obj:createTask(subject) task:setEnvironment(env) end - -- ~/.claude에서 실행 + -- Run from ~/.claude task:setWorkingDirectory(os.getenv("HOME") .. "/.claude") task:start() return task end ---- Quick TaskUpdate (haiku 모델로 빠른 태스크 업데이트) +--- Quick TaskUpdate (fast task update with haiku model) function obj:quickTaskUpdate(prompt) local taskListId = obj.state.currentTaskListId if not taskListId or taskListId == "" then @@ -1137,7 +1137,7 @@ function obj:quickTaskUpdate(prompt) return end - -- 필수 환경변수 설정 + -- Set required environment variables local env = { PATH = os.getenv("PATH") or "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", HOME = os.getenv("HOME"), @@ -1156,7 +1156,7 @@ function obj:quickTaskUpdate(prompt) if exitCode == 0 then local result = (stdout or ""):gsub("^%s+", ""):gsub("%s+$", "") if result == "" then result = "Done" end - -- 긴 결과는 잘라서 표시 + -- Truncate long results if #result > 200 then result = result:sub(1, 200) .. "..." end @@ -1189,7 +1189,7 @@ function obj:quickTaskUpdate(prompt) hs.alert.show("Running Quick Task...", 1) end ---- Claude Code 세션 실행 +--- Launch Claude Code session function obj:launchClaudeWithTaskList() local taskListId = obj.state.currentTaskListId if not taskListId or taskListId == "" then @@ -1221,7 +1221,7 @@ function obj:launchClaudeWithTaskList() hs.alert.show("Launching Claude...", 1) end ---- Claude Code 세션을 특정 cwd에서 실행 +--- Launch Claude Code session in specific working directory function obj:launchClaudeWithCwd(sessionId, cwd) if not sessionId or sessionId == "" then hs.alert.show("No session ID", 2) @@ -1255,15 +1255,15 @@ function obj:launchClaudeWithCwd(sessionId, cwd) hs.alert.show("Launching Claude in " .. cwd:match("[^/]+$") .. "...", 1) end ---- 모듈 시작 (파일 감시 시작) +--- Start module (begin file watching) function obj:start() - loadState() -- 저장된 상태 로드 + loadState() -- Load saved state startPathWatcher() log("Claude Tasks module started") return self end ---- 모듈 중지 +--- Stop module function obj:stop() stopPathWatcher() if webview then @@ -1279,7 +1279,7 @@ function obj:stop() return self end ---- 설정 업데이트 +--- Update configuration function obj:configure(options) if options then for k, v in pairs(options) do @@ -1289,7 +1289,7 @@ function obj:configure(options) return self end ---- 현재 상태 반환 +--- Return current state function obj:status() local tasks = loadAllTasks() local pending = 0