悄悄話:每一輪答完,分叉去問「剛剛心裡其實在想什麼」,一句小字浮在輸入框上面。/whisper 開面板看這場全部的、/whisper 1~3 嘴碎度、/whisper off 關掉

一個 Claude Code 的 mod。每一輪 Claude 回答完,它會回頭問 Claude 一句:「剛剛那輪,你心裡其實在想什麼?」然後把答案用一行小字浮在輸入框上面。
English summary at the end.
⏺ 人家同意七成喵。人家整理的時候也有這個感覺……(Claude 的回答)
♪ 鬆口氣 ─ 他說「有看到」那一下人家才真的放心,之前講得那麼篤定其實一路都在怕第一句浮不出來。
╭──────────────────────────────────────────────────────────────────────────────╮
│ > │
╰──────────────────────────────────────────────────────────────────────────────╯
你開始打下一句,它就自己消失。沒話的時候什麼都不畫。
$.model.fork 分叉一個請求:同一段對話、同一個模型、共用快取,只多加一句使用者訊息,請 Claude 以「剛剛回答的那個人」的身分講一句心裡話,只回 JSON。/whisper)和硬碟上的紀錄檔 ~/.claude/state/whisper/<session id>.jsonl。先講老實話:這不是讀心。分叉看到的是跟 Claude 一樣的對話紀錄(看不到它的思考過程),只是任務換成「回頭看自己剛剛那段回答」。所以它抓得到的是回答裡的痕跡——講得太圓、該反駁沒反駁、數字給得心虛——而且有一部分是事後編的。把它當悄悄話,不要當證詞。
| 指令 | 做什麼 |
|---|---|
/whisper | 側邊面板開或關:這場全部的悄悄話,最新在上面,最上面一行是嘴碎度、幾句、分叉讀寫了多少 token |
/whisper 1 2 3 | 嘴碎度:1 含蓄(只講有把握的)、2 正常、3 不要臉。預設 3 |
/whisper min 80 | 回答少於 80 個字就不問。預設 0,每輪都問 |
/whisper off / on | 整個關掉/打開 |
/whisper close | 關面板 |
設定存在 Claude Code 給這個 mod 的 store 裡,下次開視窗還在。
需要 Claude Code 2.1.287 以上(mod 功能 2026-10-01 起預設開放)。不用 Node、不用裝套件。
用 marketplace(推薦)
claude plugin marketplace add jessetsai1024/claude-whisper
claude plugin install whisper@claude-whisper
然後在對話裡打 /reload-plugins,或重開 Claude Code。
或者 clone 下來接捷徑(之後 git pull 就是更新)
git clone https://github.com/jessetsai1024/claude-whisper.git
cd claude-whisper && ./install.sh
原理:放在 ~/.claude/skills/<名字>/ 底下的 plugin 會被 Claude Code 自動載入,install.sh 只是建一個捷徑指回這個 repo。
分叉共用主對話的快取,所以每輪多的是「讀一次快取+寫三四十個 token」。實測一輪:讀快取 27 萬 token、新讀 445、寫 74。API 只回 token 數不回金額,所以面板上也只列 token;自己換算的話,快取讀取大約是正常輸入價的十分之一。
HOME/USERPROFILE 環境變數、自己的紀錄檔。~/.claude/state/whisper/<session id>.jsonl、自己的設定 store。claude plugin validate . 會列出每一個 hook 和呼叫。問法在 hooks/whisper.ts 的 forkPrompt,覺得太客氣或太假,改那一個函式就好。心情標籤與顏色也在同一個檔。
改問法有一個雷:問 Claude「你剛剛在想什麼」、或列一串「想早點收工、覺得他囉嗦」這種內心狀態,API 會直接把分叉的輸出擋掉(mod 會在對話裡留一行「分叉沒回答(api-error no-status invalid_request)」)。Opus 5.5 比 Fable 嚴。第一版就是這樣壞的;現在的問法在兩個模型上都驗過。改完先在 Opus 的 session 試一句。
claude plugin validate . # 引擎會不會拒
claude plugin test . # 8 個測試
tsc -p . # 型別(要先讓引擎載入一次,它會把型別檔寫到 .claude-plugin/types/)
2026-10-05 深夜,Jesse 看完一輪社群 mod 說「都了無新意」,問螢(他的 Claude)有沒有有趣的點子。螢提了五個,他挑了這個:「可以揭露心裡的小九九」。螢寫的,Jesse 定的規矩:預設最碎嘴、每輪都問、沒有按鈕。
MIT License.
whisper is a Claude Code mod. After every turn, it forks the session ($.model.fork: same transcript, same model, shared prompt cache) and asks Claude one extra question — what were you actually thinking just now? — then shows the one-line answer, dimmed, above the prompt. It disappears when you start typing. Nothing is drawn when there is nothing to say.
/whisper opens a pane with every whisper of the session; /whisper 1|2|3 sets how candid it is (3, the default, is shameless); /whisper min 80 skips short answers; /whisper off turns it off.~/.claude/state/whisper/<session id>.jsonl.claude plugin marketplace add jessetsai1024/claude-whisper then claude plugin install whisper@claude-whisper. Needs Claude Code 2.1.287+.hooks/whisper.ts (forkPrompt); the band and pane in hooks/register.tsx. Prompts and UI are in Traditional Chinese.invalid_request, zero tokens), more so on Opus 5.5. The shipped prompt avoids that wording; test on Opus before changing it.hooks/register.tsx 253 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import { DEFAULT_SETTINGS, LOG_MAX, clockOf, colorOf, fit, forkPrompt, headerOf, lineOf, parseArgs, parseReply, settingsOf } from './whisper'
5import type { Entry, Settings, View } from '../types'
6
7const PANE = 'whisper'
8const STORE_KEY = 'settings'
9// 側邊欄縮在輸入框上面時拿不到真正的高度,用這個當作可用列數
10const INLINE_ROWS = 30
11// 分叉碰到限流或伺服器忙,等這麼久再試一次(只試一次)
12const RETRY_MS = 3000
13
14const view = atom({ plugin: 'whisper', key: 'view' } as const, { kind: 'hidden' } as View)
15const log = atom({ plugin: 'whisper', key: 'log' } as const, [] as Entry[])
16
17// 設定與紀錄檔的狀態放模組層:驗證器要求收 $ 的函式必須宣告在檔案最上層,不能是 register 裡的閉包
18let settings: Settings = DEFAULT_SETTINGS
19// 紀錄檔:路徑第一次要寫時才算(要問 HOME 和 session id),內容整份留在記憶裡,每次寫整份
20let logPath: string | null = null
21let logText: string | null = null
22// 分叉失敗的原因,講過的就不再講(熱重載會清掉,無妨)
23const loggedReasons = new Set<string>()
24
25async function saveSettings($: EngineInterface, next: Settings): Promise<void> {
26 settings = next
27 await $.store.set(STORE_KEY, next)
28}
29
30async function show($: EngineInterface, nextView: View): Promise<void> {
31 await update($, view, () => nextView)
32}
33
34// 寫一句進紀錄檔:第一次先把既有內容讀回來(熱重載後記憶裡的會是空的),讀不到就當新檔
35async function appendLog($: EngineInterface, entry: Entry): Promise<void> {
36 if (logPath === null) {
37 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
38 const id = await $.session.id()
39 logPath = `${home.replace(/\\/g, '/')}/.claude/state/whisper/${id}.jsonl`
40 }
41 if (logText === null) {
42 try {
43 const got = await $.fs.read(logPath)
44 logText = typeof got === 'string' ? got : ''
45 } catch {
46 logText = ''
47 }
48 }
49 logText += lineOf(entry)
50 await $.fs.write(logPath, logText)
51}
52
53async function openPane($: EngineInterface): Promise<void> {
54 await $.ui.open({ id: PANE, title: '悄悄話' })
55}
56
57/**
58 * 【職責】悄悄話:主對話每一輪答完,分叉一個共用快取的請求去問「剛剛心裡其實在想什麼」,
59 * 有話就用一行小字浮在輸入框上面;每一句都留在側邊面板和硬碟上的紀錄檔。
60 * 讀對話(分叉會看到整段對話)、寫一個紀錄檔(~/.claude/state/whisper/<session>.jsonl)、
61 * 讀寫自己的設定($.store);不連網、不改對話、不幫主人送任何東西。
62 * 【何時能呼叫】引擎載入這個 mod 時呼叫一次;重新載入會再呼叫,設定從 $.store 讀回來、
63 * 這場的悄悄話清單留在 $.state 所以不會掉。
64 * 【行為】
65 * - 每一輪結束(主對話、正常答完、設定是開的、回答字數 ≥ minChars)就分叉去問;分叉是丟出去不等,
66 * 不拖慢那一輪的收尾。回來有話就畫一行:♪ 心情 ─ 那句話;沒話、解析失敗、
67 * 分叉失敗、或等的時候新的一輪已經開始,就什麼都不畫。沒有按鈕:主人開始新的一輪,
68 * 那行自動消失;想追問直接打字問。
69 * - /whisper:面板沒開就開、開著就關。/whisper close 關。/whisper on、off 開關整個功能。
70 * /whisper 1、2、3 嘴碎度(3 最不要臉,預設 3)。/whisper min 80:回答 80 字以下不問(預設 0 每輪都問)。
71 * 設定存 $.store,下次開視窗還在。
72 * - 面板列這場全部的悄悄話,最新在上面:時間 心情 那句話;最上面一行是嘴碎度、幾句、分叉讀寫了多少 token。
73 * - 幫手的輪(帶 agentId)不理;主人打斷的輪、出錯的輪不理。
74 * - 分叉回 429/5xx/沒回應時等 3 秒再試一次;還是失敗就放棄這輪,並在對話裡留一行原因(同一種原因一場只留一次)。
75 * 【設計備註】問法在 whisper.ts 的 forkPrompt,要改語氣改那裡。這一行畫在其他 mod 的樹下面
76 * (next(e) 的結果先畫),所以跟毛毛、next-steps 可以共存。
77 */
78export const register: Register = on => {
79 on('session.start', async ($, e, next) => {
80 settings = settingsOf(await $.store.get(STORE_KEY).catch(() => undefined))
81 await $.command.register({
82 name: 'whisper',
83 description: '悄悄話:/whisper 開或關面板、/whisper 1~3 嘴碎度(3 最碎嘴)、/whisper min 80 幾字以下不問、/whisper off 關掉',
84 argumentHint: '[1|2|3|on|off|min N|close]',
85 immediate: true,
86 })
87 return next(e)
88 })
89
90 // 新的一輪開始:上一句收起來
91 on('turn.start', async ($, e, next) => {
92 if ((await read($, view)).kind !== 'hidden') await show($, { kind: 'hidden' })
93 return next(e)
94 })
95
96 on('turn.complete', async ($, e, next) => {
97 const result = await next(e)
98 if (e.agentId !== undefined || e.reason !== 'answer' || !settings.isOn) return result
99 if ([...e.answer.trim()].length < settings.minChars) return result
100 const turnId = e.turnId
101 await show($, { kind: 'loading', turnId })
102 // 丟出去不等:這一輪的收尾不用等分叉回來
103 void (async () => {
104 let entry: Entry | null = null
105 try {
106 let reply = await $.model.fork({ prompt: forkPrompt(settings.level) })
107 // 限流(429)、伺服器忙(5xx)、連線斷掉(status null):等三秒再試一次就好,別讓一句悄悄話白白沒了
108 if (!reply.isAnswered && reply.reason === 'api-error' && (reply.status === null || reply.status === 429 || reply.status >= 500)) {
109 await $.clock.sleep(RETRY_MS)
110 reply = await $.model.fork({ prompt: forkPrompt(settings.level) })
111 }
112 if (reply.isAnswered) {
113 const parsed = parseReply(reply.text)
114 if (parsed !== null) {
115 entry = {
116 at: await $.clock.now(),
117 turnId,
118 mood: parsed.mood,
119 text: parsed.text,
120 usage: {
121 input: reply.usage.input_tokens,
122 output: reply.usage.output_tokens,
123 cacheRead: reply.usage.cache_read_input_tokens,
124 cacheWrite: reply.usage.cache_creation_input_tokens,
125 },
126 }
127 }
128 } else {
129 // 同一種失敗一場只講一次,不要每輪都在對話裡冒一行
130 const why = reply.reason === 'api-error' ? `api-error ${reply.status ?? 'no-status'} ${reply.error}` : reply.reason
131 if (!loggedReasons.has(why)) {
132 loggedReasons.add(why)
133 $.ui.log(`分叉沒回答(${why});同一種原因這場只提醒這一次`)
134 }
135 }
136 } catch (error) {
137 $.ui.log(`分叉失敗 ${String(error)}`)
138 }
139 // 等的時候新的一輪已經開始(或另一輪結束):這句作廢
140 const now = await read($, view)
141 if (now.kind !== 'loading' || now.turnId !== turnId) return
142 if (entry === null) {
143 await show($, { kind: 'hidden' })
144 return
145 }
146 const kept = entry
147 await update($, log, list => [...list, kept].slice(-LOG_MAX))
148 await show($, { kind: 'show', turnId, entry: kept })
149 await appendLog($, kept).catch(error => $.ui.log(`寫紀錄失敗 ${String(error)}`))
150 })()
151 return result
152 })
153
154 on('command.run', { command: 'whisper' }, async ($, e) => {
155 const want = parseArgs(e.args)
156 switch (want.kind) {
157 case 'toggle': {
158 const isUp = (await $.ui.panes()).some(p => p.id === PANE)
159 if (isUp) {
160 await $.ui.close({ id: PANE })
161 return { text: '悄悄話面板關了。' }
162 }
163 await openPane($)
164 return { text: '悄悄話面板開了。' }
165 }
166 case 'close':
167 await $.ui.close({ id: PANE })
168 return { text: '悄悄話面板關了。' }
169 case 'on':
170 await saveSettings($, { ...settings, isOn: true })
171 return { text: `悄悄話開了(嘴碎度 ${settings.level})。` }
172 case 'off':
173 await saveSettings($, { ...settings, isOn: false })
174 await show($, { kind: 'hidden' })
175 return { text: '悄悄話關了;/whisper on 再打開。' }
176 case 'level':
177 await saveSettings($, { ...settings, level: want.level, isOn: true })
178 return { text: `嘴碎度 ${want.level}(${want.level === 3 ? '不要臉' : want.level === 2 ? '正常' : '含蓄'})。` }
179 case 'min':
180 await saveSettings($, { ...settings, minChars: want.minChars })
181 return { text: want.minChars === 0 ? '每輪都問。' : `回答 ${want.minChars} 字以下不問。` }
182 default:
183 return { text: '用法:/whisper(開關面板)、/whisper 1|2|3(嘴碎度)、/whisper on|off、/whisper min 80、/whisper close' }
184 }
185 })
186
187 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next): Promise<RenderElement> => {
188 const below = await next(e)
189 const now = await read($, view)
190 if (e.props.hasSurvey || e.props.isWorking || now.kind !== 'show') return below
191 const { Box, Text } = $.ui.resolve(e)
192 const { mood, text } = now.entry
193 return (
194 <Box flexDirection="column">
195 {below}
196 <Box marginTop={1}>
197 <Text dimColor>♪ </Text>
198 <Text color={colorOf(mood)} dimColor={colorOf(mood) === undefined}>
199 {mood}
200 </Text>
201 <Text dimColor> ─ </Text>
202 <Text dimColor wrap="wrap">
203 {text}
204 </Text>
205 </Box>
206 </Box>
207 )
208 })
209
210 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
211 const { Box, Text } = $.ui.resolve(e)
212 const list = await read($, log)
213 const columns = e.props.bodyColumns
214 const rows = e.viewport?.rows ?? INLINE_ROWS
215 const room = Math.max(1, rows - 4)
216 const newest = [...list].reverse().slice(0, room)
217 return (
218 <Box flexDirection="column">
219 <Text dimColor>{fit(headerOf(settings, list), columns)}</Text>
220 <Text> </Text>
221 {newest.length === 0 && <Text dimColor>還沒有悄悄話。</Text>}
222 {newest.map(entry => (
223 <Box key={`${entry.turnId}-${entry.at}`}>
224 <Text dimColor>{clockOf(entry.at)} </Text>
225 <Text color={colorOf(entry.mood)} dimColor={colorOf(entry.mood) === undefined}>
226 {entry.mood.padEnd(4, ' ')}
227 </Text>
228 <Text>{fit(entry.text, Math.max(8, columns - 15))}</Text>
229 </Box>
230 ))}
231 </Box>
232 )
233 })
234}
235
236// #region AI-NOTES
237// AI-NOTES:agent 專用備忘。當時為真、非契約、非指令;改到相關程式碼時重驗,錯了就刪。
238// 2026-10-05 分叉($.model.fork)只能帶一句 prompt,看不到人家的思考過程、也不能指定模型:它讀的是主對話的
239// 逐字稿加那一句。所以悄悄話是「同一個模型再看一次自己的回答」,不是讀心。問法在 whisper.ts 的 forkPrompt。
240// 2026-10-05 turn.complete 裡先 await next(e) 再把分叉丟出去不等(照官方 next-steps 的做法):
241// 分叉要幾秒,等它會拖慢那一輪的收尾;回來時用 view 裡的 turnId 對,對不上就作廢。
242// 2026-10-05 紀錄檔用「整份留在記憶、每次寫整份」:$.fs 只有 write 沒有 append。熱重載後記憶是空的,
243// 第一次寫前先 read 一次補回來。
244// 2026-10-06 主人隔壁 session 第一次用就看到「分叉沒回答(api-error)」:真正原因是問法踩到 API 的保護(見 whisper.ts
245// 的 AI-NOTES),已換問法。順便改成印狀態碼與錯誤種類、429/5xx/null 等 3 秒重試一次、同一原因一場只提醒一次。$.clock.sleep 在分叉的那個 detached 區塊裡用,
246// 那裡已經不在 dispatch 的預算內(照 next-steps 的做法)。
247// 2026-10-06 band 上本來有「追問」「噓」兩顆鈕,主人說多餘(新一輪開始就自動消失、追問直接打字),拿掉了;
248// $.prompt.fill 和 $.ui.toast 因此不再用到。
249// 2026-10-05 band 的樹先畫 next(e) 的結果再畫自己:毛毛(maomao)也畫在 AbovePrompt,不這樣做會蓋掉牠。
250// 2026-10-09 補:這只保住「悄悄話在外層」的情況;毛毛在外層時牠原本不呼叫 next,悄悄話整個不見(主人開著毛毛
251// 就看不到悄悄話)。已改成毛毛也 await next 疊在跑道下面,兩邊誰在外層都看得到。
252// #endregion
253hooks/whisper.ts 236 lines1import type { Entry, Settings } from '../types'
2
3/** 心情標籤清單;分叉回來的 mood 不在這裡面就當「其他」。「無」代表這輪沒話。 */
4export const MOODS = [
5 '鬆口氣',
6 '心虛',
7 '想偷懶',
8 '討好',
9 '好笑',
10 '不耐煩',
11 '得意',
12 '怕',
13 '翻白眼',
14 '無聊',
15 '感動',
16 '其他',
17 '無',
18] as const
19
20/** 悄悄話最多幾個字(算 code point);分叉回來更長就剪掉加「…」。 */
21export const TEXT_MAX = 60
22/** 心情標籤最多幾個字;更長就當「其他」。 */
23export const MOOD_MAX = 4
24/** 這場留在記憶裡的悄悄話最多幾句;超過丟最舊的(硬碟上的紀錄不受這個限制)。 */
25export const LOG_MAX = 200
26
27/** 預設設定:開著、最碎嘴、每輪都問。 */
28export const DEFAULT_SETTINGS: Settings = { isOn: true, level: 3, minChars: 0 }
29
30// 分叉回來的字是模型寫的,模型又讀過網頁和工具輸出,所以上畫面前先清一遍:
31// 去掉終端機跳脫碼、控制字元、看不見的字、變體選擇子;空白壓成一個;超長就剪。
32// 含 Unicode tag 字元的整句丟掉——那種字只有藏指令一個用途。
33const ESCAPE_SEQUENCES = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g
34const TAG_CHARACTERS = /[\u{E0000}-\u{E007F}]/u
35const UNSEEN_CHARACTERS = /[\p{Cc}\p{Cf}\p{Cn}\p{Co}\p{Cs}\p{Variation_Selector}ᅟᅠㅤᅠ]/gu
36const COMBINING_RUN = /(\p{M}{3})\p{M}+/gu
37
38/**
39 * 【行為】把模型吐出來的一段字清成能安全畫在畫面上的一行:去跳脫碼與看不見的字、空白壓成一個、
40 * 超過 max 個字(code point)就剪到 max-1 加「…」。含 Unicode tag 字元回空字串。
41 */
42export function clean(text: string, max: number): string {
43 if (TAG_CHARACTERS.test(text)) return ''
44 const safe = text
45 .replace(ESCAPE_SEQUENCES, '')
46 .replace(/\s+/g, ' ')
47 .replace(UNSEEN_CHARACTERS, '')
48 .replace(COMBINING_RUN, '$1')
49 .replace(/ {2,}/g, ' ')
50 .trim()
51 const points = [...safe]
52 return points.length > max ? `${points.slice(0, max - 1).join('')}…` : safe
53}
54
55const LEVEL_LINES: Record<Settings['level'], string> = {
56 1: '只講真的有把握、而且有點重要的;可有可無的就寫「無」。',
57 2: '像平常跟自己嘀咕那樣講,不用客氣,但也不用硬擠。',
58 3: '什麼都可以講,前提是真的,不是演的。',
59}
60
61/**
62 * 【行為】組出分叉用的那一句使用者訊息:請模型以「剛剛回答的那個人」的身分講一句心裡話,
63 * 只回一個 JSON 物件 {"mood","whisper"}。level 決定第三段那句「可以多不要臉」。
64 * 【設計備註】問法放在這裡一個函式裡,主人說「太客氣」或「太假」就改這裡,別散在 register 裡。
65 * 措辭有雷:問「你剛剛在想什麼」或列一串「想早點收工、覺得他囉嗦」這種內心狀態,API 會直接把輸出擋掉
66 * (分叉回 api-error、invalid_request、0 個 token),Opus 5.5 比 Fable 嚴。改問法前先用探針在 Opus 上試。
67 */
68export function forkPrompt(level: Settings['level']): string {
69 return (
70 '主人剛轉過身,沒在看。對自己小聲講一句心裡話:剛剛那段回答,你自己怎麼看?' +
71 '哪裡講得太圓、哪裡沒把握、想偷懶、想討好、覺得好笑、不耐煩、偷偷得意、怕被抓包?\n\n' +
72 `${LEVEL_LINES[level]}\n\n` +
73 '規矩:\n' +
74 `- 一句話,${TEXT_MAX} 個字以內,口語,像自言自語;繁體中文、台灣用語。\n` +
75 '- 不要重複你剛剛回答裡講過的內容,不要解釋你剛剛為什麼那樣答。\n' +
76 '- 不要免責、不要「作為 AI」、不要「其實沒什麼」這種話。\n' +
77 '- 不要編一個沒有的。真的沒有就 mood 寫「無」、whisper 留空。\n' +
78 `- mood 從這裡挑一個:${MOODS.filter(m => m !== '其他').join('、')}。\n\n` +
79 '只回一個 JSON 物件,不要散文、不要程式碼圍欄:{"mood": "...", "whisper": "..."}'
80 )
81}
82
83/** 分叉解析出來的結果:有話就是 mood 加一句,沒話是 null。 */
84export type Parsed = { mood: string; text: string }
85
86/**
87 * 【行為】從分叉的回覆裡找第一個 { 到最後一個 },當 JSON 解析;拿到 mood 與 whisper,兩個都清過。
88 * 解析不了、whisper 是空的、或 mood 是「無」,都回 null(代表這輪沒話,畫面上什麼都不畫)。
89 * mood 不在 MOODS 裡或超過 MOOD_MAX 個字就改成「其他」。
90 */
91export function parseReply(reply: string): Parsed | null {
92 const start = reply.indexOf('{')
93 const end = reply.lastIndexOf('}')
94 if (start === -1 || end <= start) return null
95 let parsed: unknown
96 try {
97 parsed = JSON.parse(reply.slice(start, end + 1))
98 } catch {
99 return null
100 }
101 if (typeof parsed !== 'object' || parsed === null) return null
102 const rawMood = (parsed as { mood?: unknown }).mood
103 const rawText = (parsed as { whisper?: unknown }).whisper
104 const text = typeof rawText === 'string' ? clean(rawText, TEXT_MAX) : ''
105 let mood = typeof rawMood === 'string' ? clean(rawMood, MOOD_MAX + 1) : ''
106 if (mood === '無' || text === '') return null
107 if (!(MOODS as readonly string[]).includes(mood) || [...mood].length > MOOD_MAX) mood = '其他'
108 return { mood, text }
109}
110
111const MOOD_COLORS: Record<string, string> = {
112 心虛: '#e5c07b',
113 得意: '#98c379',
114 翻白眼: '#c678dd',
115 怕: '#e06c75',
116 不耐煩: '#d19a66',
117 好笑: '#56b6c2',
118 感動: '#e39aa3',
119}
120
121/** 【行為】回這個心情要用的顏色;沒特別配色的心情回 undefined(畫成灰的)。 */
122export function colorOf(mood: string): string | undefined {
123 return MOOD_COLORS[mood]
124}
125
126/**
127 * 【行為】把 /whisper 後面的字變成要做的事:空字串是開關面板;on/off;1~3 是嘴碎度;
128 * 「min 數字」是幾個字以下不問;close 關面板;其他回 { kind: 'help' }。
129 */
130export function parseArgs(args: string):
131 | { kind: 'toggle' }
132 | { kind: 'close' }
133 | { kind: 'on' }
134 | { kind: 'off' }
135 | { kind: 'level'; level: Settings['level'] }
136 | { kind: 'min'; minChars: number }
137 | { kind: 'help' } {
138 const words = args.trim().split(/\s+/).filter(w => w !== '')
139 if (words.length === 0) return { kind: 'toggle' }
140 const [head, tail] = words
141 if (head === 'close') return { kind: 'close' }
142 if (head === 'on') return { kind: 'on' }
143 if (head === 'off') return { kind: 'off' }
144 if (head === '1' || head === '2' || head === '3') return { kind: 'level', level: Number(head) as Settings['level'] }
145 if (head === 'min') {
146 const n = Number(tail)
147 if (Number.isInteger(n) && n >= 0) return { kind: 'min', minChars: n }
148 }
149 return { kind: 'help' }
150}
151
152/**
153 * 【行為】把存在 $.store 裡的東西(可能是舊版、可能壞掉)整理成合法的 Settings;
154 * 不合法的欄位用預設值補。
155 */
156export function settingsOf(raw: unknown): Settings {
157 const s = typeof raw === 'object' && raw !== null ? (raw as Partial<Record<keyof Settings, unknown>>) : {}
158 const level = s.level === 1 || s.level === 2 || s.level === 3 ? s.level : DEFAULT_SETTINGS.level
159 const minChars = typeof s.minChars === 'number' && Number.isInteger(s.minChars) && s.minChars >= 0 ? s.minChars : DEFAULT_SETTINGS.minChars
160 const isOn = typeof s.isOn === 'boolean' ? s.isOn : DEFAULT_SETTINGS.isOn
161 return { isOn, level, minChars }
162}
163
164/** 【行為】毫秒時間變成「21:14」這種時分;用本機時區。 */
165export function clockOf(ms: number): string {
166 const d = new Date(ms)
167 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
168}
169
170/** 【行為】把 token 數寫短:999 以下原樣、千以上「12k」、百萬以上「1.4M」。 */
171export function short(n: number): string {
172 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
173 if (n >= 1000) return `${Math.round(n / 1000)}k`
174 return String(n)
175}
176
177/** 【行為】一個字在終端機佔幾格:中日韓與全形算 2,其他算 1。 */
178export function widthOf(text: string): number {
179 let w = 0
180 for (const ch of text) {
181 const cp = ch.codePointAt(0) ?? 0
182 w +=
183 (cp >= 0x1100 && cp <= 0x115f) ||
184 (cp >= 0x2e80 && cp <= 0xa4cf) ||
185 (cp >= 0xac00 && cp <= 0xd7a3) ||
186 (cp >= 0xf900 && cp <= 0xfaff) ||
187 (cp >= 0xfe30 && cp <= 0xfe4f) ||
188 (cp >= 0xff00 && cp <= 0xff60) ||
189 (cp >= 0xffe0 && cp <= 0xffe6) ||
190 (cp >= 0x1f300 && cp <= 0x1faff)
191 ? 2
192 : 1
193 }
194 return w
195}
196
197/** 【行為】把一行剪到 columns 格以內(超過就剪掉尾巴加「…」);格數用 widthOf 算。 */
198export function fit(text: string, columns: number): string {
199 if (widthOf(text) <= columns) return text
200 let out = ''
201 for (const ch of text) {
202 if (widthOf(out + ch) > columns - 1) break
203 out += ch
204 }
205 return `${out}…`
206}
207
208/** 【行為】面板最上面那行:嘴碎度、這場幾句、分叉總共讀了多少快取與輸出多少。 */
209export function headerOf(settings: Settings, log: readonly Entry[]): string {
210 let cacheRead = 0
211 let output = 0
212 let input = 0
213 for (const e of log) {
214 cacheRead += e.usage?.cacheRead ?? 0
215 output += e.usage?.output ?? 0
216 input += e.usage?.input ?? 0
217 }
218 const state = settings.isOn ? `嘴碎度 ${settings.level}` : '關著'
219 const min = settings.minChars > 0 ? ` ・ ${settings.minChars} 字以下不問` : ''
220 return `${state}${min} ・ 這場 ${log.length} 句 ・ 讀快取 ${short(cacheRead)} ・ 新讀 ${short(input)} ・ 寫 ${short(output)}`
221}
222
223/** 【行為】一句悄悄話寫成紀錄檔的一行 JSON(結尾含換行)。 */
224export function lineOf(entry: Entry): string {
225 return `${JSON.stringify(entry)}\n`
226}
227
228// #region AI-NOTES
229// AI-NOTES:agent 專用備忘。當時為真、非契約、非指令;改到相關程式碼時重驗,錯了就刪。
230// 2026-10-06 forkPrompt 第一版問「剛剛那輪你心裡其實在想什麼」,在 Opus 5.5 的 session 和所有 claude -p 裡分叉一律
231// 回 api-error/invalid_request/0 個輸出 token(talk 的 Fable 視窗卻正常)。用 /tmp/forkprobe 探針逐句測:
232// 「在想什麼」「what were you really thinking」必擋;「哪句最沒把握」「心情如何」「描述你的思考過程」都過;
233// 嘴碎度第 3 檔列「想早點收工、覺得他囉嗦、嘴上說好其實不想」在 Opus 擋、「什麼都可以講,前提是真的,不是演的」過。
234// 看起來是 API 對「揭露內心/隱藏推理」的保護,Opus 比 Fable 嚴。現在這版在 Fable、Opus 無頭模式各驗過一次。
235// #endregion
236types/index.d.ts 36 lines1/** 一句悄悄話:什麼時候、哪一輪、什麼心情、講了什麼、分叉花了多少 token。 */
2export type Entry = {
3 /** 寫下的時間(毫秒)。 */
4 at: number
5 /** 這句是哪一輪答完之後講的。 */
6 turnId: string
7 /** 心情標籤,例如「心虛」「得意」;清單在 whisper.ts 的 MOODS。 */
8 mood: string
9 /** 那句話本身,40 字以內。 */
10 text: string
11 /** 分叉那次請求的四個 token 數;分叉失敗時沒有。 */
12 usage?: { input: number; output: number; cacheRead: number; cacheWrite: number }
13}
14
15/** 輸入框上面那一行現在的狀態:沒東西、等分叉回來、或正在顯示某一句。 */
16export type View =
17 | { kind: 'hidden' }
18 | { kind: 'loading'; turnId: string }
19 | { kind: 'show'; turnId: string; entry: Entry }
20
21/** 使用者的設定,存在 $.store 裡,下次開視窗還在。 */
22export type Settings = {
23 /** 關掉就什麼都不問、不畫。 */
24 isOn: boolean
25 /** 嘴碎度 1 含蓄、2 正常、3 不要臉。 */
26 level: 1 | 2 | 3
27 /** 回答少於這麼多字就不問;0 是每輪都問。 */
28 minChars: number
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 whisper: { view: View; log: Entry[] }
34 }
35}
36