SLOPSHOPPER

status-card

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

newbandguardpromptmodelprocess
A shopper browsing a rack in a slop shop
README

English | 简体中文

status-card

Mods Code

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.

What the card shows

Always three lines:

  1. 🎯 Goal: the mainline of the whole session. It changes only when you move on to different work. On the right: turn N · last update (HH:mm for today, MMDD HH:mm for other days, e.g. 1013 20:06).
  2. Now: while Claude works, a dot in the working colour and This turn: what it is doing, in one phrase; after the turn, ✅ and what the turn did. On the right: calls · files edited · elapsed. It ticks every second while Claude works, then keeps the turn's final numbers, even after a restart. It does not repeat the per-tool status Claude Code already shows.
  3. ⏳ waiting on you: what Claude needs from you. Empty while Claude works (line 2 already says what it is doing) and when nothing waits on you. On the right: the Details and Fold buttons.

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.

  • The border colour shows the state: working, waiting on you, or idle.
  • Details: for when you come back and have forgotten what the session was about. One paragraph of two or three sentences (at most 90 English words or 180 Chinese characters): the mainline and why, how far it got, and what to do next, including to-dos agreed but not done yet. It starts from the mainline, not from the latest small problem. It is drawn in the card only and never enters the conversation. It is cached per turn and only rewritten after a new turn; while it is open, a new turn refreshes it.
  • Fold: shrinks the card to a one-line capsule showing one thing: while Claude works, a dot in the working colour and what it is doing this turn; when something waits on you, ⏳ and what it is; otherwise ✅ and the goal; on the right the update time, the elapsed time and an Unfold button. The capsule is as wide as its content and at most one full line; when it does not fit, the text on the left is cut first. The fold state is kept across sessions.

Install

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

Configuration

Change these in /config or /plugin configure status-card@tyler-claude-mods. Changes apply right away.

OptionDefaultMeaning
languageautoRecap language: auto follows the latest messages you typed (Chinese or English); background notifications and messages from other sessions do not count; zh; en
modelhaikuModel that writes the recap and the details
accentColorsuggestionColour while working: a theme colour name (suggestion, claude, success, warning, error, …) or #hex
waitingColorwarningColour while waiting on you
idleColorsuccessColour while idle
borderStyleroundCard border: round, single, bold, double, none
densitycomfortableLine spacing: comfortable leaves a blank row between lines; compact does not
showMetatrueShow "turn N · time"
retentionDays30Recaps not updated for this many days are removed

How it works

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:

  • the previous recap and a running summary of the whole session (updated every turn from the previous one, about 400 characters at most: the mainline, milestones reached, the current small problem, open to-dos);
  • every message you sent in the session, the first 150 characters of each; past 15 messages it keeps the first 3 and the latest 12. This is how it tells the mainline from the problem at hand;
  • your message for this turn (first 1000 + last 500 characters);
  • short labels for this turn's tool calls (a command's description or first 60 characters, file paths, search terms);
  • Claude's reply (first 1500 + last 1500 characters).

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).

Limitations

  • No card before the first turn ends.
  • A forked conversation has the same fingerprint as the original, so the two share one card until a new turn in either overwrites it.
  • If the context was compacted before you resume, the first message becomes the compaction summary, the fingerprint no longer matches, and the card comes back only after the next turn ends.
  • If a model call fails or returns something malformed, the previous recap stays.
  • The turn number counts your messages with text in the session, slash commands included. After compaction it keeps counting up and never goes back.
  • Drawn only in Claude Code's desktop app and terminal; not in the VS Code extension or claude -p.
  • Checked with claude plugin validate and claude plugin test on Claude Code 2.1.294 / 2.1.295.

Credits

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.

Source 3 files
hooks/register.tsx 418 lines
1// 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}
418
hooks/recap.ts 282 lines
1// 不依赖引擎的纯逻辑:语言判断、截断、提示词、解析和时间格式,单独测试
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}
282
types/index.d.ts 41 lines
1export 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