diff --git a/README.md b/README.md index 4b53425..fd8a50a 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ - **真正的桌面寵物** — 透明、無邊框、永遠置頂;透明區域**點穿**,只有壓在人物像素上滑鼠才會被吃掉,不擋你操作底下的視窗。 - **不搶焦點** — 視窗設為 non-focusable,點她不會讓終端機失焦。 - **拖曳 / 點擊 / 右鍵** — 拖曳時依方向播 running-left / running-right 並記住位置;點一下揮手;右鍵或系統匣開選單。 -- **對話氣泡** — 完成、需要授權、有問題要問你、工具失敗、整理記憶中……一眼看出 Claude Code 在等什麼。 +- **對話氣泡(兩行)** — 第一行是**對話名稱**(`/rename` 設的)或專案資料夾名,第二行才是訊息。同時開多個 session 時一眼就知道是哪一個在叫你。 - **工作中即時播報** — 氣泡會顯示正在用的工具與對象(`⚙️ 執行 npm run dist`、`📖 讀 renderer.js`),以及助理剛說的那句話(`💬 …`)。**注意:這不是思考過程**,原因見[能顯示什麼、不能顯示什麼](#能顯示什麼不能顯示什麼)。 - **多 session** — 同時開好幾個 Claude Code 時,以 `waiting > running > review > idle` 的優先序顯示最需要你注意的那個。 - **Codex 寵物自動出現** — 預設也掃描 `~/.codex/pets`,Codex hatch 出來的寵物直接在選單裡切換。 @@ -211,6 +211,8 @@ Spritesheet:**1536 × 2288**,8 欄 × 11 列,每格 **192 × 208**,透 事件紀錄:`%USERPROFILE%\.claude-pet\events.log`(超過 2 MB 自動清空)。 +氣泡的第一行是對話名稱或專案名,第二行是訊息;標題為空時整行會收起來,不會留空白。 + ## 架構與檔案 ``` @@ -264,6 +266,14 @@ Claude Code ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──PO |---|---|---| | 工具動作 | `⚙️ 執行 npm run dist`、`📖 讀 renderer.js` | hook 的 `tool_name` 與 `tool_input` | | 助理說明 | 助理剛講那段話的第一句 | 由 `transcript_path` 讀 transcript 尾端的 `text` 區塊 | +| 氣泡標題 | 對話名稱,沒設就用專案資料夾名 | transcript 的 `custom-title`(`/rename` 寫進去的),退回 `cwd` 的最後一段 | + +氣泡長這樣: + +``` +claude-pet +💬 兩行氣泡正確運作,這是真實事件。 +``` 同一段說明只播報一次(以 transcript 的 uuid 判斷),markdown 標記會先清掉, 等待授權時不播報以免蓋掉固定氣泡。讀 transcript 只讀最後 192 KB 並依檔案大小快取, diff --git a/main.js b/main.js index f2072a5..e7c0ece 100644 --- a/main.js +++ b/main.js @@ -99,16 +99,19 @@ function appendLog(line) { // Claude Code 從 2.1.238 起不再把 thinking 的文字寫進 transcript(只剩加密簽章), // 但助理的可見說明(text 區塊)還在。讀檔尾就夠了,transcript 會長到好幾 MB。 const TRANSCRIPT_TAIL = 192 * 1024; -const narrationCache = new Map(); // path → { size, id, text } +const narrationCache = new Map(); // path → { size, value } -function latestNarration(file) { +// 回傳 { narration: { id, text } | null, title: string | null } +// title 取自 /rename 寫進 transcript 的 custom-title,沒有就讓呼叫端退回專案名 +function readTranscript(file) { if (typeof file !== "string" || !file) return null; let stat; try { stat = fs.statSync(file); } catch { return null; } const cached = narrationCache.get(file); if (cached && cached.size === stat.size) return cached.value; - let value = null; + let narration = null; + let title = null; try { const start = Math.max(0, stat.size - TRANSCRIPT_TAIL); const fd = fs.openSync(file, "r"); @@ -117,24 +120,29 @@ function latestNarration(file) { fs.closeSync(fd); const lines = buf.toString("utf8").split("\n"); if (start > 0) lines.shift(); // 第一行多半被切一半 - for (let i = lines.length - 1; i >= 0; i--) { + // 由後往前找,先遇到的就是最新的;兩樣都拿到就停 + for (let i = lines.length - 1; i >= 0 && !(narration && title); i--) { const line = lines[i].trim(); if (!line) continue; let entry; try { entry = JSON.parse(line); } catch { continue; } - if (entry.type !== "assistant") continue; + if (!title && entry.type === "custom-title" && typeof entry.customTitle === "string" && entry.customTitle.trim()) { + title = entry.customTitle.trim().slice(0, 60); + continue; + } + if (narration || entry.type !== "assistant") continue; const content = entry.message?.content; if (!Array.isArray(content)) continue; for (const block of content) { if (block?.type === "text" && typeof block.text === "string" && block.text.trim()) { - value = { id: entry.uuid || entry.timestamp || String(i), text: block.text.trim().slice(0, 400) }; + narration = { id: entry.uuid || entry.timestamp || String(i), text: block.text.trim().slice(0, 400) }; break; } } - if (value) break; } } catch { /* 讀不到就算了,寵物不該因此出錯 */ } + const value = { narration, title }; narrationCache.set(file, { size: stat.size, value }); if (narrationCache.size > 32) narrationCache.delete(narrationCache.keys().next().value); return value; @@ -346,10 +354,13 @@ function startServer() { try { payload = JSON.parse(body || "{}"); } catch { /* bad json */ } if (payload && typeof payload === "object") { appendLog(JSON.stringify(payload)); - const narration = latestNarration(payload.transcriptPath); - if (narration) { - payload.narration = narration.text; - payload.narrationId = narration.id; + const info = readTranscript(payload.transcriptPath); + if (info) { + if (info.narration) { + payload.narration = info.narration.text; + payload.narrationId = info.narration.id; + } + if (info.title) payload.sessionTitle = info.title; } send("pet:event", payload); res.writeHead(204); diff --git a/package-lock.json b/package-lock.json index ce3e065..8feff53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-pet", - "version": "2.0.4", + "version": "2.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-pet", - "version": "2.0.4", + "version": "2.0.5", "devDependencies": { "electron": "^43.4.1", "electron-builder": "^26.0.12" diff --git a/package.json b/package.json index 10c84c0..4bd8b44 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-pet", - "version": "2.0.4", + "version": "2.0.5", "description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 2:9 個動作列 + 16 方向追視)", "main": "main.js", "private": true, diff --git a/renderer/index.html b/renderer/index.html index f9a858d..b714b21 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -8,7 +8,7 @@
- +
diff --git a/renderer/renderer.js b/renderer/renderer.js index d38bfc5..3d7f875 100644 --- a/renderer/renderer.js +++ b/renderer/renderer.js @@ -76,6 +76,7 @@ const TOOL_LABELS = { const canvas = document.getElementById("sprite"); const ctx = canvas.getContext("2d", { willReadFrequently: true }); const bubble = document.getElementById("bubble"); +const bubbleTitle = document.getElementById("bubble-title"); const bubbleText = document.getElementById("bubble-text"); let sheet = null; @@ -347,18 +348,23 @@ function shortTarget(target, toolName) { return t.slice(0, 44); } +// 氣泡第一行:對話名稱(/rename 設的)優先,其次專案資料夾名 +function bubbleTitleOf(p, cwd) { + return p.sessionTitle || projectName(cwd) || ""; +} + // 有什麼就顯示什麼:優先顯示新的助理說明,否則顯示正在用的工具 -function sayActivity(p) { +function sayActivity(p, title) { if (baseState() === "waiting") return; // 別蓋掉在等授權的固定氣泡 if (p.narration && p.narrationId && p.narrationId !== lastNarrationId) { lastNarrationId = p.narrationId; - say(`💬 ${firstSentence(p.narration)}`, ACTIVITY_MS); + say(`💬 ${firstSentence(p.narration)}`, ACTIVITY_MS, false, title); return; } if (!p.toolName) return; const label = TOOL_LABELS[p.toolName] || `🔧 ${p.toolName}`; const detail = p.toolName === "Task" && p.agentType ? p.agentType : p.toolTarget; - say(detail ? `${label} ${shortTarget(detail, p.toolName)}` : label, ACTIVITY_MS); + say(detail ? `${label} ${shortTarget(detail, p.toolName)}` : label, ACTIVITY_MS, false, title); } function projectName(cwd) { @@ -368,10 +374,12 @@ function projectName(cwd) { // ---------- 氣泡 ---------- -function say(text, ms, sticky = false) { +// title 是第一行(對話名稱或專案名),text 是第二行 +function say(text, ms, sticky = false, title = "") { clearTimeout(bubbleTimer); + bubbleTitle.textContent = title || ""; bubbleText.textContent = text; - bubble.title = text; + bubble.title = title ? `${title}\n${text}` : text; bubble.classList.remove("hidden"); bubbleSticky = sticky; if (ms > 0) bubbleTimer = setTimeout(hideBubble, ms); @@ -390,14 +398,13 @@ function handleEvent(p) { if (p.event === "__test") return handleTest(p); const s = session(p.sessionId, p.cwd); - const proj = projectName(s.cwd); - const suffix = proj ? `(${proj})` : ""; + const title = bubbleTitleOf(p, s.cwd); switch (p.event) { case "SessionStart": setState(s, "idle"); // Codex 的 waving 只在寵物第一次醒來(first-awake),新 thread 不會揮手;這裡只留資訊氣泡 - if (!p.source || p.source === "startup") say(`👋 嗨!${suffix}`, 3000); + if (!p.source || p.source === "startup") say("👋 嗨!", 3000, false, title); break; case "UserPromptSubmit": setState(s, "running"); @@ -406,13 +413,13 @@ function handleEvent(p) { case "PreToolUse": if (p.toolName === "AskUserQuestion") { setState(s, "waiting"); - say(`❓ 有問題想問你${suffix}`, 0, true); + say("❓ 有問題想問你", 0, true, title); } else if (p.toolName === "ExitPlanMode") { setState(s, "waiting"); - say(`📋 計畫等你確認${suffix}`, 0, true); + say("📋 計畫等你確認", 0, true, title); } else { setState(s, "running"); - sayActivity(p); + sayActivity(p, title); } break; case "PostToolUse": @@ -431,13 +438,13 @@ function handleEvent(p) { const t = p.notificationType; if (t === "permission_prompt") { setState(s, "waiting"); - say(`🔐 需要你授權${suffix}`, 0, true); + say("🔐 需要你授權", 0, true, title); } else if (t === "idle_prompt") { // Codex 的 waiting 只對應授權、提問、計畫確認;單純等你下指令不算,狀態不變 - say(`💤 在等你回覆${suffix}`, 4000); + say("💤 在等你回覆", 4000, false, title); } else if (t === "elicitation_dialog") { setState(s, "waiting"); - say(`❓ 有問題想問你${suffix}`, 0, true); + say("❓ 有問題想問你", 0, true, title); } else if (p.message) { say(`💬 ${p.message}`, 4000); } @@ -448,7 +455,7 @@ function handleEvent(p) { // Codex:turn 完成且未讀 → review(播 review 3 次後沉澱),直到使用者回到該 thread 才回 idle。 // 這裡對應成:維持 review 直到該 session 有下一個動作(UserPromptSubmit)、結束或 7 天到期。 setState(s, "review"); - say(`✅ 完成!${suffix}`, 8000); + say("✅ 完成!", 8000, false, title); break; case "StopFailure": // Codex:turn 失敗 → failed 狀態(播 failed 3 次後沉澱),維持到下一個 turn 或 1 小時到期 diff --git a/renderer/style.css b/renderer/style.css index 55a8cc3..a39e6c8 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -30,11 +30,28 @@ html, body { font-size: 13px; line-height: 1.35; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28); + overflow: hidden; + transition: opacity 0.18s ease, transform 0.18s ease; + cursor: pointer; +} + +/* 標題與內文各自佔一行,各自省略;沒有標題時整行收起來 */ +#bubble-title, +#bubble-text { + display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - transition: opacity 0.18s ease, transform 0.18s ease; - cursor: pointer; +} + +#bubble-title { + font-size: 11px; + line-height: 1.3; + color: #6b7280; +} + +#bubble-title:empty { + display: none; } #bubble::after {