氣泡改成兩行,第一行顯示對話名稱或專案名

摘要:
氣泡上方加一行標題(/rename 設的對話名稱,沒設就用專案資料夾名),訊息移到第二行。

根本原因:
原本把專案名以「(專案名)」的形式接在訊息尾巴,訊息本身就短,
加了括號後更容易被氣泡的單行省略號截掉,而且同時開多個 session 時
要看到最後才知道是哪一個在叫你。

影響:
多 session 並行時不容易分辨氣泡是哪個專案發出的;訊息也被括號擠掉。

修法:
- index.html 氣泡內加 #bubble-title;style.css 把單行限制從 #bubble 移到
  #bubble-title 與 #bubble-text 各自身上,兩行各自省略,
  並以 :empty 讓沒有標題時整行收起來,不留空白。
- say() 增加第四個參數 title,事件處理處的八個呼叫點改傳標題、移除 suffix。
- main.js 的 latestNarration 改名為 readTranscript,同一次反向掃描同時取出
  助理說明與 custom-title(/rename 會把它寫進 transcript),兩者都拿到就提早結束;
  收到事件時把 sessionTitle 一併併進 payload。
- 標題優先序:對話名稱 > 專案資料夾名 > 不顯示。

驗證:
- 單元測試以真實 transcript 執行 readTranscript:取到對話名稱 "claude-pet"、
  同時取到助理說明、快取一致、路徑不存在或為 null 時回 null,6 項全過。
- 實機截圖確認兩行版面正確。截圖當下剛好收到另一個 session 的真實事件,
  標題顯示 "telegram-codex-bot"、第二行是該 session 的助理說明,
  等於用實際流量驗證了多 session 下的標題辨識。
- 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar,確認版本 2.0.5 與十項改動都在出貨檔案裡,
  包含舊的 suffix 已完全移除。

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