输入框上方的会话摘要卡片:目标、现在、下一步、等你处理的事。跨重启保留,回看旧会话也能看到;可展开详情、折叠成胶囊。

English | 简体中文
A recap card above the Claude Code prompt: the goal, what is happening now, and what waits on you. The recap is saved per conversation, so it is still there after you restart the app or open a session from days ago. Made for long sessions: step away, come back, and pick up where you left off at a glance.
<img src="docs/card-waiting.en.svg" alt="Card waiting on you, with the details open" width="720">
<img src="docs/card-idle.en.svg" alt="Card while idle" width="720">
Built on the source of where-am-i by hamzafer. See Credits.
The screenshots are drawn from sample data by docs/render_screenshots.py.
Always three lines:
HH:mm for today, MMDD HH:mm for other days, e.g. 1013 20:06).When the window gets narrow, the turn, time, stats and buttons on the right stay whole and the text on the left is cut instead.
In Claude Code in a terminal:
/plugin install status-card --marketplace tyler4400/tyler-claude-mods
Or from a shell:
claude plugin marketplace add tyler4400/tyler-claude-mods
claude plugin install status-card@tyler-claude-mods --scope user
The card appears after the first turn ends. If you install it in the middle of a turn, that turn's card has no call count or elapsed time on the right; they show from the next turn.
Upgrading from where-am-i or goal-card: these are earlier names of this mod. Uninstall the old one, then install status-card. Recaps are stored per plugin name, so old recaps do not carry over.
claude plugin uninstall where-am-i@tyler-claude-mods # or goal-card@tyler-claude-mods
claude plugin install status-card@tyler-claude-mods --scope user
Change these in /config or /plugin configure status-card@tyler-claude-mods. Changes apply right away.
| Option | Default | Meaning |
|---|---|---|
language | auto | Recap language: auto follows the latest messages you typed (Chinese or English); background notifications and messages from other sessions do not count; zh; en |
model | haiku | Model that writes the recap and the details |
accentColor | suggestion | Colour while working: a theme colour name (suggestion, claude, success, warning, error, …) or #hex |
waitingColor | warning | Colour while waiting on you |
idleColor | success | Colour while idle |
borderStyle | round | Card border: round, single, bold, double, none |
density | comfortable | Line spacing: comfortable leaves a blank row between lines; compact does not |
showMeta | true | Show "turn N · time" |
retentionDays | 30 | Recaps not updated for this many days are removed |
Two model calls per turn. When you send a message, one haiku call writes the "This turn: …" phrase from your message (first 1000 + last 500 characters) and the previous recap. Output is capped at 80 tokens.
After each turn of the main conversation, another haiku call updates the recap. Its input is:
Long text keeps its head and tail with a note saying how much was cut, and the model is told the cut is on purpose so it does not treat the text as incomplete. Output is capped at 700 tokens.
Opening the details makes one more call. Its input is the running summary, the latest card, every message you sent (as above) and the last 6 messages (first 600 + last 600 characters each). It writes two or three sentences, capped at 400 tokens.
These are real model requests and count toward your usage.
Where the data lives: in $.store, which Claude Code gives each plugin as a JSON file under ~/.claude/plugins/store/. There is one record per conversation (latest recap, running summary, turn, update time, language, last turn's stats, cached details) plus the fold preference. Records not updated for retentionDays are removed when a session starts.
When you resume a conversation after restarting the app, or fork one, Claude Code gives it a new session id but carries the conversation over unchanged. So records are not keyed by session id: the same conversation is recognised by a fingerprint of the working directory and its first message.
What it does and does not do: the tool.call hook only watches. Every call goes through unchanged; it counts calls and the files edited by Edit or Write. It does not change your messages or Claude's replies. Apart from the model calls above, it runs date +%z once per session to get the local time zone. It makes no other network requests and reads or writes no files outside its store.
Hooks: session.start, session.end, prompt.submit, tool.call, turn.complete, ui.render (AbovePrompt).
claude -p.claude plugin validate and claude plugin test on Claude Code 2.1.294 / 2.1.295.Built on the source of where-am-i in hamzafer/claude-code-mods (MIT, Copyright (c) 2026 Hamza Zafar). It was called where-am-i here too and was renamed status-card to avoid clashing with the original. This version turns it into a card and adds persistence, the running summary, language following, details, folding and options.
hooks/register.tsx 418 lines1// status-card:输入框上方的会话摘要卡片,摘要存进 $.store,重启或回看旧会话时恢复。
2// 借鉴 hamzafer/claude-code-mods 中 where-am-i 的源码(MIT),改为卡片样式并加上持久化、滚动总结和语言跟随。
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, PluginOptions, Register } from 'claude-code'
5
6import type { DetailView, Lang, SessionRecord, TurnProgress, TurnStats } from '../types'
7import {
8 clip,
9 countTurns,
10 describeTool,
11 detailRequest,
12 formatElapsed,
13 formatStats,
14 formatTime,
15 intentRequest,
16 isTypedPrompt,
17 labels,
18 parseIntent,
19 parseOffset,
20 parseSummary,
21 resolveLang,
22 summaryRequest,
23} from './recap'
24
25type Engine = EngineInterface
26
27// store 是 ~/.claude/plugins/store/ 下本 plugin 的 JSON 文件
28// conv:<指纹> 是一个对话的记录;id:<session id> 记住这个 session 属于哪个对话;session:<id> 是 0.3.0 之前的旧格式
29const CONV_PREFIX = 'conv:'
30const ID_PREFIX = 'id:'
31const LEGACY_PREFIX = 'session:'
32const FOLDED_KEY = 'pref:folded'
33const DAY_MS = 24 * 60 * 60 * 1000
34const MAX_LOG = 20
35const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
36
37// 由宿主保管,热重载后仍在;跨进程的部分另存 $.store
38const recordAtom = atom({ plugin: 'status-card', key: 'record' } as const, null as SessionRecord | null)
39const progress = atom({ plugin: 'status-card', key: 'progress' } as const, null as TurnProgress | null)
40// 工作期间每秒写一次,让已用时跟着走
41const clockNow = atom({ plugin: 'status-card', key: 'clockNow' } as const, 0)
42const isExpanded = atom({ plugin: 'status-card', key: 'isExpanded' } as const, false)
43const isFolded = atom({ plugin: 'status-card', key: 'isFolded' } as const, false)
44const detailView = atom({ plugin: 'status-card', key: 'detail' } as const, null as DetailView | null)
45
46type Settings = {
47 language: unknown
48 model: string
49 accentColor: string
50 waitingColor: string
51 idleColor: string
52 borderStyle: string
53 density: string
54 showMeta: boolean
55 retentionDays: number
56}
57
58function settingsFrom(options: PluginOptions): Settings {
59 const text = (key: string, fallback: string) =>
60 typeof options[key] === 'string' && (options[key] as string).trim() !== '' ? (options[key] as string).trim() : fallback
61 return {
62 language: options.language,
63 model: text('model', 'haiku'),
64 accentColor: text('accentColor', 'suggestion'),
65 waitingColor: text('waitingColor', 'warning'),
66 idleColor: text('idleColor', 'success'),
67 borderStyle: text('borderStyle', 'round'),
68 density: text('density', 'comfortable'),
69 showMeta: options.showMeta !== false,
70 retentionDays: typeof options.retentionDays === 'number' && options.retentionDays > 0 ? options.retentionDays : 30,
71 }
72}
73
74async function digest(text: string): Promise<string> {
75 const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
76 return Array.from(bytes.slice(0, 12), b => b.toString(16).padStart(2, '0')).join('')
77}
78
79// 续接或 fork 时 Claude Code 会换一个新的 session id,但对话内容原样带过去:
80// 用工作目录和第一条消息认出同一个对话,算出来后记在 id:<session id> 下,压缩上下文后也能找到
81async function conversationKey($: Engine): Promise<string | undefined> {
82 if (convKey) return convKey
83 const mapped = (await $.store.get(ID_PREFIX + (await $.session.id()))) as { conv?: string } | undefined
84 if (mapped?.conv) return (convKey = mapped.conv)
85 const first = (await $.session.messages()).find(m => m.role === 'user' && m.text.trim() !== '')
86 if (!first) return undefined
87 convKey = CONV_PREFIX + (await digest(`${await $.session.cwd()}\n${first.text.slice(0, 4000)}`))
88 return convKey
89}
90
91async function loadRecord($: Engine): Promise<SessionRecord | undefined> {
92 const key = await conversationKey($)
93 const saved = key ? ((await $.store.get(key)) as SessionRecord | undefined) : undefined
94 return saved ?? ((await $.store.get(LEGACY_PREFIX + (await $.session.id()))) as SessionRecord | undefined)
95}
96
97async function saveRecord($: Engine, record: SessionRecord): Promise<void> {
98 await update($, recordAtom, () => record)
99 const id = await $.session.id()
100 const key = (await conversationKey($)) ?? LEGACY_PREFIX + id
101 await $.store.set(key, record)
102 if (key.startsWith(CONV_PREFIX)) await $.store.set(ID_PREFIX + id, { conv: key, updatedAt: record.updatedAt })
103}
104
105async function pruneOldRecords($: Engine, retentionDays: number): Promise<void> {
106 const now = await $.clock.now()
107 for (const key of await $.store.keys()) {
108 if (![CONV_PREFIX, ID_PREFIX, LEGACY_PREFIX].some(prefix => key.startsWith(prefix))) continue
109 const entry = (await $.store.get(key)) as { updatedAt?: number } | undefined
110 if (!entry || now - (entry.updatedAt ?? 0) > retentionDays * DAY_MS) await $.store.delete(key)
111 }
112}
113
114async function restoreRecord($: Engine): Promise<void> {
115 const saved = await loadRecord($)
116 if (!saved) return
117 lang = saved.lang
118 await update($, recordAtom, () => saved)
119}
120
121async function setFolded($: Engine, value: boolean): Promise<void> {
122 await update($, isFolded, () => value)
123 await $.store.set(FOLDED_KEY, value)
124}
125
126// 第二行行首:这一轮已完成时用 ✅,和第一行的 🎯、第三行的 ⏳ 一样;工作中仍是状态色的圆点
127const DONE_MARK = '✅'
128
129// 模块级状态:热重载后重置,需要跨重载的都在 atom 和 $.store 里
130let settings = settingsFrom({})
131let prompt = ''
132let log: string[] = []
133let lang: Lang = 'zh'
134// 本机时区相对 UTC 的分钟数:mod 运行环境的时区不一定是本机的
135let offsetMinutes = 0
136let ticker: { cancel: () => void } | undefined
137let convKey: string | undefined
138
139async function writeIntent($: Engine, text: string): Promise<void> {
140 try {
141 const request = intentRequest({ lang, previous: await read($, recordAtom), prompt: text })
142 const result = await $.model.complete({ model: settings.model, ...request })
143 if (!result.isAnswered) return
144 const intent = parseIntent(result.text)
145 if (intent) await update($, progress, p => (p ? { ...p, intent } : p))
146 } catch {
147 // 拿不到一句话目标时显示你消息的开头
148 }
149}
150
151async function tick($: Engine): Promise<void> {
152 const now = await $.clock.now()
153 await update($, clockNow, () => now)
154}
155
156async function summarize($: Engine, answer: string, stats: TurnStats | undefined): Promise<void> {
157 const previous = await read($, recordAtom)
158 const messages = await $.session.messages()
159 const request = summaryRequest({ lang, previous, prompt, tools: log, answer, messages })
160 const result = await $.model.complete({ model: settings.model, ...request })
161 if (!result.isAnswered) return
162 const parsed = parseSummary(result.text)
163 if (!parsed) return
164 const record: SessionRecord = {
165 recap: parsed.recap,
166 overall: parsed.overall || previous?.overall || '',
167 // 上下文压缩后重启,session.messages() 只剩压缩后的消息,轮次不能因此倒退
168 turn: Math.max(countTurns(messages), (previous?.turn ?? 0) + 1),
169 lang,
170 updatedAt: await $.clock.now(),
171 detail: previous?.detail,
172 stats: stats ?? previous?.stats,
173 }
174 await saveRecord($, record)
175 // 详情展开着时跟着新的一轮刷新
176 if (await read($, isExpanded)) await loadDetail($)
177}
178
179async function loadDetail($: Engine): Promise<void> {
180 const record = await read($, recordAtom)
181 if (!record) {
182 await update($, detailView, () => ({ status: 'error', text: labels(lang).noRecap }))
183 return
184 }
185 if (record.detail?.turn === record.turn && record.detail.kind === 'mainline') {
186 const text = record.detail.text
187 await update($, detailView, () => ({ status: 'ready', text }))
188 return
189 }
190 await update($, detailView, () => ({ status: 'loading', text: '' }))
191 const request = detailRequest({ lang: record.lang, record, messages: await $.session.messages() })
192 const result = await $.model.complete({ model: settings.model, ...request })
193 if (!result.isAnswered || result.text.trim() === '') {
194 await update($, detailView, () => ({ status: 'error', text: labels(record.lang).failed }))
195 return
196 }
197 const text = result.text.trim()
198 const latest = (await read($, recordAtom)) ?? record
199 await saveRecord($, { ...latest, detail: { text, turn: record.turn, kind: 'mainline' } })
200 await update($, detailView, () => ({ status: 'ready', text }))
201}
202
203async function setExpanded($: Engine, value: boolean): Promise<void> {
204 await update($, isExpanded, () => value)
205 if (!value) return
206 try {
207 await loadDetail($)
208 } catch {
209 await update($, detailView, () => ({ status: 'error', text: labels(lang).failed }))
210 }
211}
212
213async function summarizeQuietly($: Engine, answer: string, stats: TurnStats | undefined): Promise<void> {
214 try {
215 await summarize($, answer, stats)
216 } catch {
217 // 摘要是附加功能:失败就保留上一份
218 }
219}
220
221export const register: Register = (on, options) => {
222 settings = settingsFrom(options)
223
224 on('session.start', async ($, e, next) => {
225 const result = await next(e)
226 try {
227 offsetMinutes = parseOffset((await $.process.run(['date', '+%z'])).stdout)
228 } catch {
229 offsetMinutes = 0
230 }
231 await pruneOldRecords($, settings.retentionDays)
232 convKey = undefined
233 await restoreRecord($)
234 const folded = await $.store.get(FOLDED_KEY)
235 if (typeof folded === 'boolean') await update($, isFolded, () => folded)
236 return result
237 })
238
239 // /clear 之后是新会话,旧卡片不再适用
240 on('session.end', async ($, e, next) => {
241 const result = await next(e)
242 if (e.reason === 'clear') {
243 convKey = undefined
244 await update($, recordAtom, () => null)
245 await update($, detailView, () => null)
246 await update($, isExpanded, () => false)
247 }
248 return result
249 })
250
251 on('prompt.submit', async ($, e, next) => {
252 // 续接的会话在启动时可能还没载入对话内容,这里再找一次
253 if (!(await read($, recordAtom))) await restoreRecord($)
254 prompt = e.text
255 log = []
256 // 不是你打的字、或太短(如"好""开始")不足以判断语言时,沿用上一次的
257 const isLangSample = isTypedPrompt(e.origin, e.text) && Array.from(e.text.trim()).length >= 8
258 if (isLangSample || settings.language === 'zh' || settings.language === 'en') {
259 lang = resolveLang(settings.language, e.text)
260 }
261 const startedAt = await $.clock.now()
262 await update($, clockNow, () => startedAt)
263 await update($, progress, () => ({ intent: '', calls: 0, files: [], startedAt }))
264 ticker?.cancel()
265 ticker = $.clock.every(1000, () => void tick($))
266 const text = e.text
267 $.clock.after(0, () => void writeIntent($, text))
268 return next(e)
269 })
270
271 // 只观察:记下在做什么,调用原样放行。其他 mod 在后台发起的调用不算
272 on('tool.call', async ($, e, next) => {
273 if (next.origin.plugin !== 'engine') return next(e)
274 const input = e as unknown as Record<string, unknown>
275 const line = describeTool(input, lang)
276 log = [...log, e.agentId ? `(agent) ${line}` : line].slice(-MAX_LOG)
277 const file = EDIT_TOOLS.has(String(input.tool)) && typeof input.file_path === 'string' ? input.file_path : ''
278 await update($, progress, p =>
279 p ? { ...p, calls: p.calls + 1, files: file && !p.files.includes(file) ? [...p.files, file] : p.files } : p,
280 )
281 return next(e)
282 })
283
284 on('turn.complete', async ($, e, next) => {
285 const result = await next(e)
286 if (!e.agentId) {
287 ticker?.cancel()
288 ticker = undefined
289 const finished = await read($, progress)
290 const stats = finished
291 ? { calls: finished.calls, files: finished.files.length, elapsedMs: (await $.clock.now()) - finished.startedAt }
292 : undefined
293 await update($, progress, () => null)
294 // 统计先写进记录,摘要还在生成时第二行右侧就已经是这一轮的最终数字
295 const record = await read($, recordAtom)
296 if (record && stats) await saveRecord($, { ...record, stats })
297 const answer = e.answer
298 // 放到定时器里跑,不拖住这一轮的收尾
299 $.clock.after(0, () => void summarizeQuietly($, answer, stats))
300 }
301 return result
302 })
303
304 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
305 const rest = await next(e) // 其他 mod 和 Claude Code 自己画在这里的内容保留在卡片下方
306 const record = await read($, recordAtom)
307 if (e.props.hasSurvey || !record) return rest
308
309 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
310 const L = labels(record.lang)
311 const isWorking = e.props.isWorking
312 const turnProgress = isWorking ? await read($, progress) : null
313 const isWaiting = !isWorking && record.recap.waiting !== ''
314 const stateColor = isWorking ? settings.accentColor : isWaiting ? settings.waitingColor : settings.idleColor
315 const updatedAt = formatTime(record.updatedAt, await $.clock.now(), offsetMinutes)
316 const meta = settings.showMeta ? `${L.turn(record.turn)} · ${updatedAt}` : ''
317
318 const border = settings.borderStyle === 'none' ? {} : { borderStyle: settings.borderStyle, borderColor: stateColor }
319
320 // 工作中显示这一轮的实时统计,结束后保留上一轮的最终统计
321 const stats: TurnStats | undefined = turnProgress
322 ? {
323 calls: turnProgress.calls,
324 files: turnProgress.files.length,
325 elapsedMs: (await read($, clockNow)) - turnProgress.startedAt,
326 }
327 : record.stats
328 const progressStats = stats ? formatStats(stats, record.lang) : ''
329 // 第二行:工作中是「本轮:…」(重载后丢了这一轮的进度时退回上一份摘要),结束后是这一轮做了什么
330 const nowText = turnProgress ? `${L.thisTurn}${L.sep}${turnProgress.intent || clip(prompt)}` : record.recap.now
331
332 if (await read($, isFolded)) {
333 // 胶囊依次优先显示:工作中这一轮在做什么、等你处理的事、目标;放不下时截断这段文字,右侧的时间、用时和展开按钮保持完整
334 const capsuleText = isWorking
335 ? nowText
336 : isWaiting
337 ? `⏳ ${L.waiting}${L.sep}${record.recap.waiting}`
338 : `🎯 ${record.recap.goal}`
339 const capsuleMeta = [settings.showMeta ? updatedAt : '', stats ? formatElapsed(stats.elapsedMs, record.lang) : '']
340 .filter(part => part !== '')
341 .join(' · ')
342 return (
343 <Box flexDirection="column">
344 {/* 外层整行,胶囊作为可收缩的子项:内容短时按内容宽,超出一行时收缩 */}
345 <Box flexDirection="row">
346 <Box flexDirection="row" flexShrink={1} minWidth={0} gap={1} paddingX={1} {...border}>
347 {/* 行首:工作中是状态色圆点;等你时文字自带 ⏳,不加标记;空闲是 ✅ */}
348 {isWorking ? <Text color={stateColor}>{'●'}</Text> : !isWaiting && <Text>{DONE_MARK}</Text>}
349 <Box flexShrink={1} minWidth={0} overflow="hidden">
350 {isWaiting
351 ? <Text color={settings.waitingColor} wrap="truncate-end">{capsuleText}</Text>
352 : <Text wrap="truncate-end">{capsuleText}</Text>}
353 </Box>
354 <Box flexDirection="row" flexShrink={0} gap={1}>
355 {capsuleMeta !== '' && <Text dimColor>{capsuleMeta}</Text>}
356 <Button key="unfold" plain label={L.unfold} onPress={() => setFolded($, false)} />
357 </Box>
358 </Box>
359 </Box>
360 {rest}
361 </Box>
362 )
363 }
364
365 const expanded = await read($, isExpanded)
366 const detail = expanded ? await read($, detailView) : null
367 // 第三行只在等你时有内容:工作中第二行已经说了在做什么,空闲时没有要你处理的事
368 const actionLine = isWaiting
369 ? <Text color={settings.waitingColor} wrap="truncate-end">{`⏳ ${L.waiting}${L.sep}${record.recap.waiting}`}</Text>
370 : <Text>{' '}</Text>
371
372 return (
373 <Box flexDirection="column">
374 <Box flexDirection="column" paddingX={1} rowGap={settings.density === 'compact' ? 0 : 1} {...border}>
375 <Box flexDirection="row" justifyContent="space-between" gap={2}>
376 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
377 <Text bold wrap="truncate-end">{`🎯 ${record.recap.goal}`}</Text>
378 </Box>
379 {meta !== '' && (
380 <Box flexShrink={0}>
381 <Text dimColor>{meta}</Text>
382 </Box>
383 )}
384 </Box>
385 <Box flexDirection="row" justifyContent="space-between" gap={2}>
386 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
387 <Text wrap="truncate-end">
388 {isWorking ? <Text color={stateColor}>{'● '}</Text> : `${DONE_MARK} `}
389 {nowText}
390 </Text>
391 </Box>
392 {progressStats !== '' && (
393 <Box flexShrink={0}>
394 <Text dimColor>{progressStats}</Text>
395 </Box>
396 )}
397 </Box>
398 <Box flexDirection="row" justifyContent="space-between" gap={2}>
399 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">{actionLine}</Box>
400 <Box flexDirection="row" flexShrink={0} gap={2}>
401 <Button key="detail" plain onPress={() => setExpanded($, !expanded)}>
402 {expanded ? L.collapse : L.expand}
403 </Button>
404 <Button key="fold" plain onPress={() => setFolded($, true)}>
405 {L.fold}
406 </Button>
407 </Box>
408 </Box>
409 {detail?.status === 'ready' && <Markdown text={detail.text} />}
410 {detail?.status === 'loading' && <Text dimColor>{L.loading}</Text>}
411 {detail?.status === 'error' && <Text dimColor>{detail.text}</Text>}
412 </Box>
413 {rest}
414 </Box>
415 )
416 })
417}
418hooks/recap.ts 282 lines1// 不依赖引擎的纯逻辑:语言判断、截断、提示词、解析和时间格式,单独测试
2import type { PromptOrigin } from 'claude-code'
3
4import type { Lang, Recap, SessionRecord, TurnStats } from '../types'
5
6const FIELD_MAX = 100
7const OVERALL_MAX = 400
8
9const LABELS = {
10 zh: {
11 turn: (n: number) => `第 ${n} 轮`,
12 sep: ':',
13 thisTurn: '本轮',
14 calls: (n: number) => `${n} 次调用`,
15 files: (n: number) => `改 ${n} 个文件`,
16 waiting: '等你',
17 expand: '详情',
18 collapse: '收起',
19 fold: '折叠',
20 unfold: '展开',
21 loading: '正在生成详情…',
22 failed: '详情生成失败,稍后再试。',
23 noRecap: '还没有摘要,等这一轮结束后再试。',
24 },
25 en: {
26 turn: (n: number) => `turn ${n}`,
27 sep: ': ',
28 thisTurn: 'This turn',
29 calls: (n: number) => `${n} ${n === 1 ? 'call' : 'calls'}`,
30 files: (n: number) => `${n} ${n === 1 ? 'file' : 'files'} edited`,
31 waiting: 'waiting on you',
32 expand: 'Details',
33 collapse: 'Hide',
34 fold: 'Fold',
35 unfold: 'Unfold',
36 loading: 'Writing the details…',
37 failed: 'Could not write the details. Try again later.',
38 noRecap: 'No recap yet. Try again after this turn ends.',
39 },
40}
41
42export function labels(lang: Lang) {
43 return LABELS[lang]
44}
45
46/** 中日韩文字占字母类字符三成以上视为中文会话 */
47export function detectLang(text: string): Lang {
48 const cjk = text.match(/[㐀-鿿豈-]/g)?.length ?? 0
49 const latin = text.match(/[A-Za-z]/g)?.length ?? 0
50 if (cjk === 0) return 'en'
51 // 一个汉字的信息量约等于一个英文单词,按 1:4 折算字母
52 return cjk / (cjk + latin / 4) >= 0.3 ? 'zh' : 'en'
53}
54
55export function resolveLang(option: unknown, sample: string): Lang {
56 if (option === 'zh' || option === 'en') return option
57 return detectLang(sample)
58}
59
60// 你亲手发出的:输入框、Remote Control、desktop 等 SDK 宿主、Slack 上点名本会话
61const TYPED_ORIGINS = new Set<PromptOrigin['kind']>(['composer', 'bridge', 'sdk', 'slack-ping'])
62// 引擎包装过的文本,不是谁打的字
63const ENGINE_WRAPPER = /^<(task-notification|local-command-caveat|local-command-stdout|command-name|command-message|command-args|system-reminder)\b/
64
65/**
66 * 只有你亲手发的消息能用来判断语言:后台任务通知、其他会话的消息、定时任务也走 prompt.submit,
67 * 而且多是英文模板,拿来判断会把中文会话的卡片带成英文。判断方式借鉴 hamzafer 的 session-saver
68 */
69export function isTypedPrompt(origin: PromptOrigin | undefined, text: string): boolean {
70 const head = text.trimStart()
71 if (ENGINE_WRAPPER.test(head) || head.startsWith('Another Claude session sent a message')) return false
72 // 类型上必有,但测试引擎等场景不带:只能按文本判断
73 if (!origin) return true
74 return TYPED_ORIGINS.has(origin.kind) || (origin.kind === 'plugin' && origin.asUser === true)
75}
76
77/** 保留开头和结尾(结论通常在末尾),中间注明省略了多少字,避免模型以为信息不全 */
78export function headTail(text: string, head: number, tail: number): string {
79 const chars = Array.from(text)
80 if (chars.length <= head + tail) return text
81 const omitted = chars.length - head - tail
82 return `${chars.slice(0, head).join('')}\n[…中间省略 ${omitted} 字 / ${omitted} chars omitted…]\n${chars.slice(-tail).join('')}`
83}
84
85// 中文句末标点直接断句;英文标点后面要有空白或到结尾,避免把版本号 0.3.3 当成句号
86const SENTENCE_END = /(?<=[。!?])\s*|(?<=[.!?])(?:\s+|$)/
87
88/** 一句话、最多 max 个字符;英文在词边界截断,中文直接截断 */
89export function clip(text: string, max = FIELD_MAX): string {
90 const first = text.trim().split(SENTENCE_END)[0] ?? ''
91 const chars = Array.from(first)
92 if (chars.length <= max) return first.replace(/[.。]$/, '')
93 const cut = chars.slice(0, max - 1).join('')
94 const space = cut.lastIndexOf(' ')
95 return `${space > max * 0.6 ? cut.slice(0, space) : cut}…`
96}
97
98const LANG_RULE: Record<Lang, string> = {
99 zh: '所有字段都用简体中文书写;代码标识符、文件路径、命令和专有名词保留原文。',
100 en: 'Write every field in English.',
101}
102
103const SYSTEM_REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g
104const TRAIL_ITEM_MAX = 150
105const TRAIL_HEAD = 3
106const TRAIL_TAIL = 12
107
108type Message = { role: 'user' | 'assistant'; text: string }
109
110/**
111 * 你发过的全部消息各取开头一句,太多时保留最早几条和最近十几条。
112 * 摘要只看这一轮时容易被眼前的小问题带偏,这条轨迹让模型认得出整个会话的主线。
113 */
114export function userTrail(messages: readonly Message[]): string {
115 const items = messages
116 .filter(m => m.role === 'user')
117 .map(m => m.text.replace(SYSTEM_REMINDER, '').replace(/\s+/g, ' ').trim())
118 .filter(text => text !== '')
119 .map((text, i) => {
120 const chars = Array.from(text)
121 return `${i + 1}. ${chars.length > TRAIL_ITEM_MAX ? chars.slice(0, TRAIL_ITEM_MAX).join('') + '…' : text}`
122 })
123 if (items.length <= TRAIL_HEAD + TRAIL_TAIL) return items.join('\n')
124 const omitted = items.length - TRAIL_HEAD - TRAIL_TAIL
125 return [...items.slice(0, TRAIL_HEAD), `[…省略 ${omitted} 条 / ${omitted} messages omitted…]`, ...items.slice(-TRAIL_TAIL)].join('\n')
126}
127
128const MAINLINE_RULE = [
129 'The mainline is what the whole session is working towards, judged from all of the person\'s messages, for example "build and publish a set of mods".',
130 'A bug, question or detour that comes up on the way is a sub-problem of the mainline, not a new mainline, even if it takes several turns.',
131 'The mainline changes only when the person moves on to a different piece of work for good.',
132].join(' ')
133
134export function summaryRequest(input: {
135 lang: Lang
136 previous: SessionRecord | null
137 prompt: string
138 tools: string[]
139 answer: string
140 messages: readonly Message[]
141}) {
142 const { lang, previous } = input
143 const system = [
144 'You keep a one-glance recap of a coding session. Plain words, no em dashes.',
145 'Reply with JSON only: {"goal","now","waiting","next","overall"}.',
146 `"goal", "now", "waiting", "next": one sentence each, at most ${FIELD_MAX} characters.`,
147 MAINLINE_RULE,
148 '"goal": the mainline. Keep the previous goal unless the mainline changed; never replace it with the current sub-problem.',
149 '"now": what was just done. "waiting": what the assistant is waiting on from the person, or "". "next": the next step.',
150 `"overall": a running summary of the whole session, at most ${OVERALL_MAX} characters, in this order: the mainline and why; milestones reached; the current sub-problem, if any; to-dos agreed but not done yet.`,
151 'Update the previous overall summary with this turn. Keep the mainline and open to-dos until they are done; shorten finished sub-problems to their outcome; drop details that no longer matter.',
152 'Text marked as omitted was cut for length on purpose: summarize from what is shown and do not treat the reply as incomplete.',
153 LANG_RULE[lang],
154 ].join('\n')
155 const prompt = [
156 `Previous recap: ${previous ? JSON.stringify(previous.recap) : 'none'}`,
157 `Previous overall summary: ${previous?.overall || 'none'}`,
158 `<all_messages_from_the_person>\n${userTrail(input.messages)}\n</all_messages_from_the_person>`,
159 `The person's latest message:\n${headTail(input.prompt, 1000, 500)}`,
160 `Tools used this turn: ${input.tools.join('; ') || 'none'}`,
161 `The assistant's reply:\n${headTail(input.answer, 1500, 1500)}`,
162 ].join('\n\n')
163 return { system, prompt, maxTokens: 900 }
164}
165
166export function detailRequest(input: { lang: Lang; record: SessionRecord; messages: readonly Message[] }) {
167 const recent = input.messages
168 .filter(m => m.text.trim() !== '')
169 .slice(-6)
170 .map(m => `[${m.role === 'user' ? 'person' : 'assistant'}] ${headTail(m.text, 600, 600)}`)
171 .join('\n\n')
172 const system = [
173 'The person has been away from this coding session long enough to forget it. Help them pick it up again.',
174 MAINLINE_RULE,
175 'Write one paragraph of 2 or 3 short sentences, in this order: the mainline and why; how far it got (the milestones that matter, and the current sub-problem only if one is open); what to do next to move the mainline forward, including to-dos agreed but not done yet.',
176 'Start from the mainline, not from the latest sub-problem. End with the next steps.',
177 'At most 180 Chinese characters or 90 English words. No bullets, headings or preamble.',
178 'Plain words, no em dashes. Text marked as omitted was cut for length on purpose: do not treat it as incomplete.',
179 LANG_RULE[input.lang],
180 ].join('\n')
181 const prompt = [
182 `Overall summary so far: ${input.record.overall || 'none'}`,
183 `Latest recap card: ${JSON.stringify(input.record.recap)}`,
184 `<all_messages_from_the_person>\n${userTrail(input.messages)}\n</all_messages_from_the_person>`,
185 `<recent_messages>\n${recent}\n</recent_messages>`,
186 'Write the paragraph now, not a reply to the messages.',
187 ].join('\n\n')
188 return { system, prompt, maxTokens: 400 }
189}
190
191/** 这一轮要做什么:你发消息时生成,工作期间固定显示,不随工具调用跳动 */
192export function intentRequest(input: { lang: Lang; previous: SessionRecord | null; prompt: string }) {
193 const system = [
194 'Say in one short phrase what the assistant is about to do in this turn of a coding session, based on the person\'s message.',
195 'If the message is short (like "ok", "start", "go on"), infer it from the previous recap\'s next step and what was waiting on the person.',
196 `At most ${FIELD_MAX} Chinese characters or 50 English words. Reply with the phrase only: no quotes, no trailing period.`,
197 LANG_RULE[input.lang],
198 ].join('\n')
199 const prompt = [
200 `Previous recap: ${input.previous ? JSON.stringify(input.previous.recap) : 'none'}`,
201 `The person's message:\n${headTail(input.prompt, 1000, 500)}`,
202 ].join('\n\n')
203 return { system, prompt, maxTokens: 150 }
204}
205
206export function parseIntent(text: string): string {
207 const line = text.trim().split('\n')[0] ?? ''
208 return clip(line.replace(/^["'“”「」]+|["'“”「」]+$/g, ''))
209}
210
211/** 已用时:45s、1m32s、1h05m;中文用 秒/分/时 */
212export function formatElapsed(ms: number, lang: Lang): string {
213 const total = Math.max(0, Math.floor(ms / 1000))
214 const h = Math.floor(total / 3600)
215 const m = Math.floor((total % 3600) / 60)
216 const sec = total % 60
217 const pad = (n: number) => String(n).padStart(2, '0')
218 if (lang === 'zh') return h > 0 ? `${h}时${pad(m)}分` : m > 0 ? `${m}分${pad(sec)}秒` : `${sec}秒`
219 return h > 0 ? `${h}h${pad(m)}m` : m > 0 ? `${m}m${pad(sec)}s` : `${sec}s`
220}
221
222/** 解析模型返回的 JSON,容忍外面包了代码块 */
223export function parseSummary(text: string): { recap: Recap; overall: string } | null {
224 const body = text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)
225 try {
226 const o = JSON.parse(body) as Record<string, unknown>
227 const raw = (k: string) => (typeof o[k] === 'string' ? (o[k] as string).replace(/\s*—\s*/g, ', ').trim() : '')
228 const goal = clip(raw('goal'))
229 if (!goal) return null
230 const recap = { goal, now: clip(raw('now')), waiting: clip(raw('waiting')), next: clip(raw('next')) }
231 return { recap, overall: Array.from(raw('overall')).slice(0, OVERALL_MAX * 2).join('') }
232 } catch {
233 return null
234 }
235}
236
237/** 一个人会怎么描述这次工具调用 */
238export function describeTool(e: Record<string, unknown>, lang: Lang): string {
239 const tool = String(e.tool)
240 const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
241 const file = (p: string) => p.split('/').slice(-2).join('/')
242 const zh = lang === 'zh'
243 if (tool === 'Bash') return `${zh ? '运行' : 'running'} ${s('description') || s('command').slice(0, 60)}`
244 if (tool === 'Edit' || tool === 'Write') return `${zh ? '编辑' : 'editing'} ${file(s('file_path'))}`
245 if (tool === 'Read') return `${zh ? '读取' : 'reading'} ${file(s('file_path'))}`
246 if (tool === 'Grep' || tool === 'Glob') return `${zh ? '搜索' : 'searching for'} ${s('pattern').slice(0, 40)}`
247 if (tool === 'Agent') return `${zh ? '启动子代理:' : 'starting an agent: '}${s('description')}`
248 if (tool === 'AskUserQuestion') return zh ? '向你提问' : 'asking you a question'
249 if (tool.startsWith('mcp__')) return `${zh ? '使用' : 'using'} ${tool.split('__').slice(1).join(' ')}`
250 return `${zh ? '使用' : 'using'} ${tool}`
251}
252
253/** 你发出的消息数,即轮次;工具结果的 user 行没有文字,不计入 */
254export function countTurns(messages: readonly { role: string; text: string }[]): number {
255 return messages.filter(m => m.role === 'user' && m.text.trim() !== '').length
256}
257
258/** 更新时间:当天只显示 `HH:mm`,其他日子 `MMDD HH:mm`(如 1013 20:06),按本机时区 */
259export function formatTime(at: number, now: number, offsetMinutes: number): string {
260 const local = (ms: number) => new Date(ms + offsetMinutes * 60_000)
261 const a = local(at)
262 const b = local(now)
263 const pad = (n: number) => String(n).padStart(2, '0')
264 const time = `${pad(a.getUTCHours())}:${pad(a.getUTCMinutes())}`
265 const sameDay = a.getUTCFullYear() === b.getUTCFullYear() && a.getUTCMonth() === b.getUTCMonth() && a.getUTCDate() === b.getUTCDate()
266 return sameDay ? time : `${pad(a.getUTCMonth() + 1)}${pad(a.getUTCDate())} ${time}`
267}
268
269/** `date +%z` 的输出(+0800)转成分钟 */
270export function parseOffset(z: string): number {
271 const m = z.trim().match(/^([+-])(\d{2})(\d{2})$/)
272 if (!m) return 0
273 const minutes = Number(m[2]) * 60 + Number(m[3])
274 return m[1] === '-' ? -minutes : minutes
275}
276
277/** 第二行右侧:调用次数 · 改了几个文件 · 用时 */
278export function formatStats(stats: TurnStats, lang: Lang): string {
279 const L = labels(lang)
280 return [L.calls(stats.calls), ...(stats.files > 0 ? [L.files(stats.files)] : []), formatElapsed(stats.elapsedMs, lang)].join(' · ')
281}
282types/index.d.ts 41 lines1export type Lang = 'zh' | 'en'
2
3/** 卡片上的四个字段:每轮由模型重写 */
4export type Recap = { goal: string; now: string; waiting: string; next: string }
5
6/** 展开的详情:按生成时的轮次缓存,轮次没变就不重新生成;kind 区分旧版格式,旧的缓存作废重写 */
7export type Detail = { text: string; turn: number; kind?: 'mainline' }
8
9/** 上一轮的统计:一轮结束后继续显示在第二行右侧 */
10export type TurnStats = { calls: number; files: number; elapsedMs: number }
11
12/** 每个会话在 $.store 里的一条记录,回看旧会话时靠它恢复卡片 */
13export type SessionRecord = {
14 recap: Recap
15 /** 整个会话的滚动总结,每轮在上一份基础上更新 */
16 overall: string
17 turn: number
18 lang: Lang
19 updatedAt: number
20 detail?: Detail
21 stats?: TurnStats
22}
23
24/** 进行中这一轮的进度:一句话目标由模型写,其余在本地统计 */
25export type TurnProgress = { intent: string; calls: number; files: string[]; startedAt: number }
26
27export type DetailView = { status: 'loading' | 'ready' | 'error'; text: string }
28
29declare module 'claude-code' {
30 interface PluginState {
31 'status-card': {
32 record: SessionRecord | null
33 progress: TurnProgress | null
34 clockNow: number
35 isExpanded: boolean
36 isFolded: boolean
37 detail: DetailView | null
38 }
39 }
40}
41