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

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 │
╰──────────────────────────────────────────────────────────────────────╯
[-] 是 Claude Code 本身的收合鈕,按下或按 ctrl+x ctrl+a 會收起整條。+N。+N。在 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-lenshooks/register.tsx 191 lines1import { 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}
191types/index.d.ts 12 lines1export 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