SLOPSHOPPER

session-lens

在輸入框上方顯示目前模型與本 session 用過的 skills(含估計 token 數)

newbandprompt
v0.2.0no licenseupdated 2026-10-09danielcheng80/session-lens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-lens
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ● Opus 5.5 · 尚未使用 skill │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ● Opus 5.5 · 尚未使用 skill │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

session-lens

Claude Code mod:在輸入框上方顯示目前 session 的模型,以及用過的 skills 與各自估計佔用的 token 數。終端機與 desktop app 都適用。

╭──────────────────────────────────────────────────────────────────────╮
│ ● Opus 5.5 · 6 skills ≈12k  brainstorming 3.2k  tdd 1.8k  +4    明細 │
╰──────────────────────────────────────────────────────────────────────╯

按「明細」展開成一 skill 一列,附比例長條與使用次數:

╭──────────────────────────────────────────────────────────────────────╮
│ ● Opus 5.5 · 6 skills ≈12k                                      收合 │
│   brainstorming  ████████████████████  3.2k                          │
│   tdd            ███████████░░░░░░░░░  1.8k ×2                       │
╰──────────────────────────────────────────────────────────────────────╯
  • 外框是灰色圓角細框,上下各多佔一行;框線與左右留白共佔 4 欄。
  • 右上角的 [-] 是 Claude Code 本身的收合鈕,按下或按 ctrl+x ctrl+a 會收起整條。
  • 一行放不下時,依 token 數由多到少顯示,其餘收成 +N。
  • 視窗窄到一個 skill 都放不下時,只顯示模型與摘要,不顯示 +N。
  • 視窗窄於 48 欄時,明細只顯示數字、不畫長條。
  • 明細裡超過 24 格的 skill 名稱會截斷,結尾補「…」。
  • 還沒用過 skill 時顯示「尚未使用 skill」,沒有「明細」按鈕。
  • 畫面出現問卷時,整條暫時隱藏。
  • token 數是依 skill 展開後的文字估算(中日韓字元約一字一 token,其他約四字元一 token),誤差約一到兩成。
  • 切換模型後,下一輪對話開始時才會更新顯示。
  • 統計只存在 session 記憶體中,不寫入硬碟、不連網。

安裝

在 Claude Code 的輸入框執行:

/plugin install session-lens --marketplace danielcheng80/session-lens

出現提示時輸入 y 新增 marketplace,再選擇安裝範圍。

Claude Code 依 plugin.json 的版本號判斷有沒有新版,版本號沒變就不會更新。

開發

claude plugin validate .
claude plugin test .

本機開發時,把這個資料夾登記成 marketplace 後安裝,修改檔案再執行 /reload-plugins 即可生效:

claude plugin marketplace add ~/projects/session-lens
claude plugin install session-lens@session-lens
Source 2 files
hooks/register.tsx 191 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SkillUse } from '../types'
5
6const model = atom({ plugin: 'session-lens', key: 'model' } as const, '')
7const skills = atom(
8  { plugin: 'session-lens', key: 'skills' } as const,
9  {} as Record<string, SkillUse>,
10)
11const isExpanded = atom({ plugin: 'session-lens', key: 'isExpanded' } as const, false)
12
13const ACCENT = '#1f717a'
14const CJK = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{20000}-\u{3FFFD}]/u
15const WIDE = new RegExp(`${CJK.source}|\\p{Emoji_Presentation}`, 'u')
16
17// 中日韓字元約一字一 token,其餘約四字元一 token
18export const estimateTokens = (text: string): number => {
19  let cjk = 0
20  let chars = 0
21  for (const ch of text) {
22    chars += 1
23    if (CJK.test(ch)) cjk += 1
24  }
25  return Math.round(cjk + (chars - cjk) / 4)
26}
27
28// 終端機顯示寬度:全形字與 emoji 佔兩格
29const width = (text: string): number => {
30  let w = 0
31  for (const ch of text) w += WIDE.test(ch) ? 2 : 1
32  return w
33}
34
35const clip = (text: string, max: number): string => {
36  if (width(text) <= max) return text
37  let out = ''
38  for (const ch of text) {
39    if (width(out + ch) > max - 1) break
40    out += ch
41  }
42  return out + '…'
43}
44
45export const formatTokens = (n: number): string =>
46  n < 1000 ? String(n) : `${(n / 1000).toFixed(n < 9950 ? 1 : 0)}k`
47
48// claude-opus-5-5 → Opus 5.5,claude-opus-4-20250514 → Opus 4;認不出來就原樣顯示
49export const prettyModel = (id: string): string => {
50  const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2})(?!\d))?/.exec(id)
51  if (!m) return id
52  const [, family = '', major, minor] = m
53  return `${family[0]?.toUpperCase()}${family.slice(1)} ${major}${minor ? `.${minor}` : ''}`
54}
55
56// 依 token 數由多到少,挑出一行放得下的 skill,其餘併成 +N
57export const fitInline = (
58  list: readonly [string, SkillUse][],
59  room: number,
60): { shown: [string, string][]; rest: number } => {
61  const shown: [string, string][] = []
62  let used = 0
63  for (const [i, [name, use]] of list.entries()) {
64    const restAfter = list.length - i - 1
65    const reserve = restAfter > 0 ? width(`  +${restAfter}`) : 0
66    const piece = `  ${name} ${formatTokens(use.tokens)}`
67    if (used + width(piece) + reserve > room) break
68    shown.push([name, formatTokens(use.tokens)])
69    used += width(piece)
70  }
71  return { shown, rest: list.length - shown.length }
72}
73
74const refreshModel = async ($: EngineInterface) => {
75  const id = await $.session.model()
76  await update($, model, () => id)
77}
78
79export const register: Register = on => {
80  on('session.start', async ($, e, next) => {
81    await refreshModel($)
82    return next(e)
83  })
84
85  // session 結束(離開、/clear、resume)時清空統計與展開狀態
86  on('session.end', async ($, e, next) => {
87    await Promise.all([update($, skills, () => ({})), update($, isExpanded, () => false)])
88    return next(e)
89  })
90
91  on('turn.start', async ($, e, next) => {
92    await refreshModel($)
93    return next(e)
94  })
95
96  on('skill.prompt', async ($, e, next) => {
97    const result = await next(e)
98    const tokens = estimateTokens(result.text)
99    await update($, skills, all => {
100      const prev = all[e.skill] ?? { tokens: 0, count: 0 }
101      return { ...all, [e.skill]: { tokens: prev.tokens + tokens, count: prev.count + 1 } }
102    })
103    return result
104  })
105
106  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
107    if (e.props.hasSurvey) return next(e)
108
109    const [id, all, expanded] = await Promise.all([read($, model), read($, skills), read($, isExpanded)])
110    if (!id) return next(e)
111
112    const used = Object.entries(all).sort((a, b) => b[1].tokens - a[1].tokens)
113    // 外框兩側各一格框線、一格留白
114    const cols = e.props.bodyColumns - 4
115    const total = used.reduce((sum, [, u]) => sum + u.tokens, 0)
116    const { Box, Text, Button } = $.ui.resolve(e)
117    const frame = (body: unknown) => (
118      <Box borderStyle="round" borderDimColor paddingX={1}>
119        {body}
120      </Box>
121    )
122
123    const head = `● ${prettyModel(id)}`
124    const summary = used.length ? ` · ${used.length} skills ≈${formatTokens(total)}` : ' · 尚未使用 skill'
125    const toggleLabel = expanded ? '收合' : '明細'
126    const toggle = used.length ? (
127      <Button
128        key="toggle"
129        label={toggleLabel}
130        onPress={() => update($, isExpanded, v => !v)}
131      />
132    ) : null
133
134    const headParts = [
135      <Text key="model" color={ACCENT} bold>{head}</Text>,
136      <Text key="summary" dimColor>{summary}</Text>,
137    ]
138    const header = <Text wrap="truncate-end">{headParts}</Text>
139
140    if (!expanded) {
141      // 終端機把按鈕畫成「[ 標籤 ]」;寬度另外扣一格
142      const toggleW = toggle ? width(`[ ${toggleLabel} ]`) : 0
143      const room = cols - width(head + summary) - 1 - toggleW
144      const { shown, rest } = fitInline(used, Math.max(0, room))
145      return frame(
146        <Box flexDirection="row" justifyContent="space-between" flexGrow={1}>
147          <Text wrap="truncate-end">
148            {headParts}
149            {shown.map(([name, tok]) => (
150              <Text key={name}>
151                {'  '}
152                {name} <Text dimColor>{tok}</Text>
153              </Text>
154            ))}
155            {rest > 0 && shown.length > 0 ? <Text dimColor>{`  +${rest}`}</Text> : null}
156          </Text>
157          {toggle}
158        </Box>
159      )
160    }
161
162    // 明細:一 skill 一列,名稱、長條、token 數、使用次數
163    const max = Math.max(1, used[0]?.[1].tokens ?? 0)
164    const nameW = Math.min(24, Math.max(...used.map(([n]) => width(n))))
165    const barW = cols >= 48 ? Math.min(20, cols - nameW - 16) : 0
166    return frame(
167      <Box flexDirection="column" flexGrow={1}>
168        <Box flexDirection="row" justifyContent="space-between">
169          {header}
170          {toggle}
171        </Box>
172        {used.map(([name, use]) => {
173          const label = clip(name, nameW)
174          const filled = Math.max(1, Math.round((use.tokens / max) * barW))
175          return (
176            <Text key={name} wrap="truncate-end">
177              {'  '}
178              {label}
179              {' '.repeat(nameW - width(label) + 1)}
180              {barW > 0 ? '█'.repeat(filled) : null}
181              {barW > 0 ? <Text dimColor>{'░'.repeat(barW - filled)} </Text> : null}
182              {formatTokens(use.tokens).padStart(5)}
183              <Text dimColor>{use.count > 1 ? ` ×${use.count}` : ''}</Text>
184            </Text>
185          )
186        })}
187      </Box>
188    )
189  })
190}
191
types/index.d.ts 12 lines
1export type SkillUse = { tokens: number; count: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'session-lens': {
6      model: string
7      skills: Record<string, SkillUse>
8      isExpanded: boolean
9    }
10  }
11}
12