Usage limits above the prompt, a pane with background work and the file each open Claude Code session is on

A Claude Code mod with two parts:
5時間 42%・週 18%. It turns red at 80%. The 隠す button hides it for the session./dash) with the usage limits and when they reset, the background work running in this session (shell commands, subagents), and every open Claude Code session on this PC: its folder, the file it last touched, its last prompt.The labels are in Japanese. Made on Windows 11 with Claude Code 2.1.286 (the version with function-hook plugins, "mods"). The tests in hooks/ui.test.ts draw it for both the terminal and the desktop app (claude plugin test .).
In Claude Code:
/plugin marketplace add tsurutanmen/session-dash
/plugin install session-dash@session-dash
Open a new session to see it.
Each session writes a small file to ~/.claude/session-dash/sessions/<session id>.json: its folder, the last tool and file, and the first 80 characters of the last prompt. That is how sessions see each other. Nothing leaves your PC. Delete the folder to clear it.
The usage limits come from Claude Code itself. They are also copied to ~/.claude/session-dash/limits.json, so other local programs (a desktop wallpaper, for example) can show them.
Five tools that work together, all MIT:
| homedot | A Dots-style personal agent on Claude Code, in a WSL2 VM on your own PC |
| session-bridge | Let open Claude Code sessions talk to each other, hold meetings, and answer your voice |
| claude-desk | "Hey Claude" voice listener with VOICEVOX replies, and a desktop wallpaper of your sessions |
| homedot-panel | homedot and the 5-hour limit in Claude Code's status line |
| session-dash | Usage limits above the prompt, and every open session in one pane |
More Claude Code plugins: tsurutanmen/claude-plugins
MIT
Claude Code の mod です。2つのものを出します。
5時間 42%・週 18%。80% を超えると赤くなります。「隠す」を押すと、そのセッションでは消えます。/dash):使用量とリセットの時刻、このセッションの裏で動いている作業(シェルのコマンド・サブエージェント)、このPCで開いている Claude Code のセッション全部を出します。セッションごとに、フォルダ、最後にさわったファイル、最後のプロンプトが見えます。Windows 11 と Claude Code 2.1.286 で作りました。hooks/ui.test.ts のテストで、ターミナルとデスクトップアプリの両方の描き方を確かめています(claude plugin test .)。
Claude Code で次の2行を打ちます。
/plugin marketplace add tsurutanmen/session-dash
/plugin install session-dash@session-dash
新しいセッションを開くと出ます。
各セッションは ~/.claude/session-dash/sessions/<セッションid>.json に小さなファイルを書きます。中身はフォルダ、最後の道具とファイル、最後のプロンプトの先頭80文字です。セッション同士はこれで互いを見ています。PCの外には何も出しません。消したいときはフォルダごと消してください。
使用量は Claude Code 自身が出す数字です。~/.claude/session-dash/limits.json にも写すので、ほかのプログラム(デスクトップの壁紙など)からも読めます。
MIT
hooks/register.tsx 211 lines1import type { Register } from 'claude-code'
2
3import type { DashLimit, DashPeer, DashTask } from '../types'
4
5const PANE = 'session-dash'
6// What the pane and band draw. Kept in the module: a reload starts it over and session.start fills it again.
7const M: { tasks: DashTask[]; peers: DashPeer[]; limits: DashLimit[]; isBandHidden: boolean } =
8 { tasks: [], peers: [], limits: [], isBandHidden: false }
9
10const FILE_TOOLS: Record<string, string> = { Read: 'file_path', Edit: 'file_path', Write: 'file_path', NotebookEdit: 'notebook_path', Grep: 'path', Glob: 'path' }
11const SHOW_FOR_MS = 3 * 60 * 60 * 1000
12
13// Cut to a width in terminal cells, counting wide (CJK) characters as two.
14function fit(text: string, cells: number): string {
15 let out = ''
16 let used = 0
17 for (const ch of text) {
18 const w = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/.test(ch) ? 2 : 1
19 if (used + w > cells) return out.length < text.length ? out.slice(0, -1) + '…' : out
20 out += ch
21 used += w
22 }
23 return out
24}
25
26function ago(ms: number): string {
27 const s = Math.max(0, Math.round(ms / 1000))
28 if (s < 60) return `${s}秒前`
29 if (s < 3600) return `${Math.round(s / 60)}分前`
30 return `${Math.round(s / 3600)}時間前`
31}
32
33function base(path: string): string {
34 return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path
35}
36
37function limitLabel(kind: string): string {
38 return kind === 'five_hour' ? '5時間' : kind === 'seven_day' ? '週' : kind
39}
40
41// Module state: starts over on a reload, which is fine (session.start runs again).
42const S: { dir: string; selfId: string; self: DashPeer | null; lastWrite: number } = { dir: '', selfId: '', self: null, lastWrite: 0 }
43
44async function writeSelf($: any, patch: Partial<DashPeer>, force = false) {
45 if (!S.dir || !S.self) return
46 S.self = { ...S.self, ...patch, at: await $.clock.now() }
47 const now = S.self.at
48 if (!force && now - S.lastWrite < 3000) return
49 S.lastWrite = now
50 await $.fs.write(`${S.dir}/sessions/${S.selfId}.json`, JSON.stringify(S.self))
51}
52
53async function loadPeers($: any): Promise<DashPeer[] | null> {
54 if (!S.dir) return null
55 const now = await $.clock.now()
56 let entries: { name: string; kind: string }[] = []
57 try {
58 entries = await $.fs.list(`${S.dir}/sessions`)
59 } catch {
60 return null
61 }
62 const found: DashPeer[] = []
63 for (const entry of entries) {
64 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
65 try {
66 const peer = JSON.parse(await $.fs.read(`${S.dir}/sessions/${entry.name}`)) as DashPeer
67 if (!peer.isEnded && now - peer.at < SHOW_FOR_MS) found.push(peer)
68 } catch {
69 // a file another session is writing right now; next tick reads it
70 }
71 }
72 found.sort((a, b) => b.at - a.at)
73 return found
74}
75
76async function loadLimits($: any): Promise<DashLimit[] | null> {
77 try {
78 const usage = await $.session.usage()
79 const list: DashLimit[] = usage.rateLimits.map((r: DashLimit) => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt }))
80 return list
81 } catch {
82 return null // usage is not reported in every mode
83 }
84}
85
86export const register: Register = on => {
87 on('session.start', async ($, e, next) => {
88 const ran = await next(e)
89 const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
90 if (home) S.dir = `${home}/.claude/session-dash`
91 S.selfId = await $.session.id()
92 S.self = { id: S.selfId, cwd: await $.session.cwd(), prompt: '', tool: '', file: '', at: await $.clock.now(), isEnded: false }
93 await writeSelf($, {}, true)
94
95 await $.command.register({ name: 'dash', description: '使用量・裏の作業・各セッションの作業ファイルのパネルを開く' })
96 void $.ui.open({ id: PANE, title: 'セッション' })
97
98 const syncPeers = async () => { const l = await loadPeers($); if (l) { M.peers = l; $.ui.invalidate('ui.render') } }
99 const syncLimits = async () => { const l = await loadLimits($); if (l) { M.limits = l; $.ui.invalidate('ui.render'); if (S.dir) await $.fs.write(`${S.dir}/limits.json`, JSON.stringify(l)) } }
100 void syncPeers()
101 void syncLimits()
102 $.clock.every(5000, () => void syncPeers())
103 $.clock.every(60000, () => void syncLimits())
104
105 return ran
106 })
107
108 on('command.run', { command: 'dash' }, async $ => {
109 await $.ui.open({ id: PANE, title: 'セッション' })
110 return { text: 'パネルを開きました。' }
111 })
112
113 on('prompt.submit', async ($, e, next) => {
114 await writeSelf($, { prompt: e.text.replace(/\s+/g, ' ').slice(0, 80) }, true)
115 return next(e)
116 })
117
118 on('tool.call', async ($, e, next) => {
119 const key = FILE_TOOLS[e.tool]
120 const path = key ? (e as Record<string, unknown>)[key] : undefined
121 if (typeof path === 'string' && path) {
122 await writeSelf($, { tool: e.tool, file: path })
123 }
124 const isBackground = (e as Record<string, unknown>).run_in_background === true
125 const ran = await next(e)
126 if (isBackground && !ran.deny) {
127 const label = String((e as Record<string, unknown>).description ?? (e as Record<string, unknown>).command ?? e.tool)
128 const task: DashTask = { id: e.tool_use_id, kind: e.tool === 'Bash' ? 'shell' : e.tool === 'Agent' ? 'subagent' : e.tool, label, startedAt: await $.clock.now() }
129 M.tasks = [...M.tasks, task].slice(-30)
130 $.ui.invalidate('ui.render')
131 }
132 return ran
133 })
134
135 // The engine's own list of work still in flight, each time a turn stops.
136 on('classic.Stop', async ($, e, next) => {
137 const now = await $.clock.now()
138 const live = (e.background_tasks ?? []).map(t => ({
139 id: t.id,
140 kind: t.type,
141 label: t.description || t.command || t.name || t.type,
142 startedAt: now,
143 }))
144 M.tasks = live.map(t => ({ ...t, startedAt: M.tasks.find(o => o.label === t.label)?.startedAt ?? t.startedAt }))
145 $.ui.invalidate('ui.render')
146 return next(e)
147 })
148
149 on('session.end', async ($, e, next) => {
150 await writeSelf($, { isEnded: true }, true)
151 return next(e)
152 })
153
154 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
155 const list = M.limits
156 if (e.props.hasSurvey || M.isBandHidden || list.length === 0) {
157 return next(e)
158 }
159 const { Box, Button, Text } = $.ui.resolve(e)
160 const parts = list.map(l => `${limitLabel(l.kind)} ${Math.round(l.percentUsed)}%`)
161 const high = list.some(l => l.percentUsed >= 80)
162 return (
163 <Box>
164 <Text color={high ? 'red' : undefined} dimColor={!high}>
165 {fit(parts.join('・'), Math.max(10, (e.props.bodyColumns ?? 80) - 10))}{' '}
166 </Text>
167 <Button key="hide" label="隠す" onPress={() => { M.isBandHidden = true; $.ui.invalidate('ui.render') }} />
168 </Box>
169 )
170 })
171
172 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
173 const { Box, Text } = $.ui.resolve(e)
174 const cols = Math.max(20, e.props.bodyColumns ?? 60)
175 const now = await $.clock.now()
176 const lim = M.limits
177 const work = M.tasks
178 const all = M.peers
179
180 return (
181 <Box flexDirection="column">
182 <Text bold>使用量</Text>
183 {lim.length === 0 && <Text dimColor>まだありません</Text>}
184 {lim.map(l => (
185 <Text color={l.percentUsed >= 80 ? 'red' : undefined}>
186 {fit(`${limitLabel(l.kind)} ${Math.round(l.percentUsed)}%${l.resetsAt ? `(${l.resetsAt.slice(5, 16).replace('T', ' ')} リセット)` : ''}`, cols)}
187 </Text>
188 ))}
189 <Text> </Text>
190 <Text bold>裏の作業 {work.length > 0 ? `(${work.length})` : ''}</Text>
191 {work.length === 0 && <Text dimColor>なし</Text>}
192 {work.map(t => (
193 <Text>{fit(`${t.kind === 'shell' ? '⚙' : t.kind === 'subagent' ? '◆' : '・'} ${t.label} ${ago(now - t.startedAt)}`, cols)}</Text>
194 ))}
195 <Text> </Text>
196 <Text bold>セッション ({all.length})</Text>
197 {all.length === 0 && <Text dimColor>なし</Text>}
198 {all.map(p => (
199 <Box flexDirection="column">
200 <Text color={p.id === S.selfId ? 'cyan' : undefined}>
201 {fit(`${p.id === S.selfId ? '▶ ' : ''}${base(p.cwd)} ${ago(now - p.at)}`, cols)}
202 </Text>
203 {p.file ? <Text dimColor>{fit(` ${p.tool} ${base(p.file)}`, cols)}</Text> : null}
204 {p.prompt ? <Text dimColor>{fit(` 「${p.prompt}」`, cols)}</Text> : null}
205 </Box>
206 ))}
207 </Box>
208 )
209 })
210}
211types/index.d.ts 14 lines1export type DashTask = { id: string; kind: string; label: string; startedAt: number }
2
3export type DashPeer = {
4 id: string
5 cwd: string
6 prompt: string
7 tool: string
8 file: string
9 at: number
10 isEnded: boolean
11}
12
13export type DashLimit = { kind: string; percentUsed: number; resetsAt?: string }
14