Shows what the conversation's context holds in a side pane: files, skills, MCP servers and memory files

会話のコンテキストに入っているものをサイドペインに表示する、Claude Code の mod です。
Files 2
E sync.sh
R Claude/sync.ps1
Skills 1
plugin-authoring
MCP servers 1
docs ×2
Memory 1
Project Claude/CLAUDE.md 1k
Files には、ファイル操作ツールで読んだ(R)ファイルと編集した(E)ファイルを、新しい順に表示します。Bash 経由で読んだファイルは対象外です。サブエージェントの呼び出しは、そのサブエージェント自身のコンテキストに入るため表示しません。コンパクションが起きるとメモリファイル以外は空になります。要約がそれらの内容に置き換わるためです。
ペインは、ターミナルの幅が 144 桁以上あればセッション開始時に開き、フルスクリーンではトランスクリプトの横に並びます。/context-pane を使えば、幅に関係なく開けます。
claude-code-mods の一部です。
/plugin marketplace add akagaya/claude-code-mods
/plugin install context-pane@claude-code-mods
次に起動した Claude Code のセッションから読み込まれます。
claude plugin validate .
claude plugin test .hooks/register.tsx 218 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { FileRef, MemoryRef, ServerRef, View } from '../types'
5
6const PANE = 'context'
7const TITLE = 'Context'
8const COMMAND = 'context-pane'
9const PANE_COLUMNS = 44
10const MAX_FILES = 200
11const MCP_NAME = /^mcp__(.+?)__/
12
13const EMPTY: View = { files: [], skills: [], servers: [], memory: [] }
14
15const view = atom({ plugin: 'context-pane', key: 'view' } as const, EMPTY)
16
17let cwd = ''
18
19const compact = (n: number): string =>
20 n >= 1_000_000
21 ? `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
22 : n >= 1000
23 ? `${Math.round(n / 1000)}k`
24 : `${n}`
25
26const slashed = (path: string): string => path.replace(/\\/g, '/')
27
28// Inside the working folder a path is shown from it; outside, whole.
29export const shownPath = (path: string, root: string): string => {
30 const full = slashed(path)
31 const base = slashed(root).replace(/\/$/, '')
32
33 return base !== '' && full.toLowerCase().startsWith(`${base.toLowerCase()}/`)
34 ? full.slice(base.length + 1)
35 : full
36}
37
38// The newest first; a file once edited stays marked as edited.
39export const withFile = (files: readonly FileRef[], path: string, isEdited: boolean): FileRef[] => {
40 const key = slashed(path).toLowerCase()
41 const known = files.find(file => slashed(file.path).toLowerCase() === key)
42 const rest = files.filter(file => file !== known)
43
44 return [{ path, isEdited: isEdited || known?.isEdited === true }, ...rest].slice(0, MAX_FILES)
45}
46
47export const withServer = (servers: readonly ServerRef[], name: string): ServerRef[] => {
48 const known = servers.find(server => server.name === name)
49
50 return known === undefined
51 ? [...servers, { name, calls: 1 }]
52 : servers.map(server => (server === known ? { name, calls: known.calls + 1 } : server))
53}
54
55const loadMemory = async ($: Engine): Promise<void> => {
56 try {
57 // `summary` estimates locally: no token-count request is sent.
58 const { context } = await $.session.usage({ breakdown: 'summary' })
59 const memory: MemoryRef[] = (context.breakdown?.memoryFiles ?? []).map(file => ({
60 path: file.path,
61 type: file.type,
62 tokens: file.tokens,
63 }))
64
65 await update($, view, shown => ({ ...shown, memory }))
66 } catch {
67 // The pane keeps what it last read.
68 }
69}
70
71const open = ($: Engine) => $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS })
72
73export const register: Register = on => {
74 on('session.start', async ($, e, next) => {
75 const started = await next(e)
76
77 cwd = e.cwd
78 await $.command.register({
79 name: COMMAND,
80 description: 'Show what the context holds in a side pane',
81 })
82 await loadMemory($)
83 void open($)
84
85 return started
86 })
87
88 on('command.run', { command: COMMAND }, async $ => {
89 await open($)
90
91 return { text: 'Context pane opened.' }
92 })
93
94 on('tool.call', async ($, e, next) => {
95 const ran = await next(e)
96
97 // A subagent's calls fill its own context, not this conversation's.
98 if (e.agentId !== undefined || ran.deny !== undefined || ran.isError === true) return ran
99
100 if (e.tool === 'Read') {
101 const path = e.file_path
102 await update($, view, shown => ({ ...shown, files: withFile(shown.files, path, false) }))
103 } else if (e.tool === 'Edit' || e.tool === 'Write') {
104 const path = e.file_path
105 await update($, view, shown => ({ ...shown, files: withFile(shown.files, path, true) }))
106 } else if (e.tool === 'NotebookEdit') {
107 const path = e.notebook_path
108 await update($, view, shown => ({ ...shown, files: withFile(shown.files, path, true) }))
109 } else {
110 const server = MCP_NAME.exec(String(e.tool))?.[1]
111
112 if (server !== undefined) {
113 await update($, view, shown => ({ ...shown, servers: withServer(shown.servers, server) }))
114 }
115 }
116
117 return ran
118 })
119
120 on('skill.prompt', async ($, e, next) => {
121 const prompted = await next(e)
122
123 await update($, view, shown =>
124 shown.skills.includes(e.skill) ? shown : { ...shown, skills: [...shown.skills, e.skill] },
125 )
126
127 return prompted
128 })
129
130 on('turn.complete', async ($, e, next) => {
131 const completed = await next(e)
132 // A CLAUDE.md of a folder first entered this turn is in the context from now on.
133 await loadMemory($)
134
135 return completed
136 })
137
138 on('session.compact', async ($, e, next) => {
139 const compacted = await next(e)
140
141 // The summary replaces what the files and skills put in the window.
142 if (e.agentId === undefined && e.trigger !== 'precompute' && 'messages' in compacted) {
143 await update($, view, shown => ({ ...shown, files: [], skills: [], servers: [] }))
144 await loadMemory($)
145 }
146
147 return compacted
148 })
149
150 on('session.end', async ($, e, next) => {
151 if (e.reason === 'clear') {
152 await update($, view, shown => ({ ...EMPTY, memory: shown.memory }))
153 }
154
155 return next(e)
156 })
157
158 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
159 const { Box, Text } = $.ui.resolve(e)
160 const { files, skills, servers, memory } = await read($, view)
161
162 const heading = (label: string, count: number) => (
163 <Text>
164 <Text bold>{label}</Text>
165 <Text dimColor> {count}</Text>
166 </Text>
167 )
168 const none = <Text dimColor> none</Text>
169
170 return (
171 <Box flexDirection="column">
172 {heading('Files', files.length)}
173 {files.length === 0 && none}
174 {files.map(file => (
175 <Text key={file.path} wrap="truncate-start">
176 <Text color={file.isEdited ? 'warning' : undefined} dimColor={!file.isEdited}>
177 {file.isEdited ? 'E ' : 'R '}
178 </Text>
179 {shownPath(file.path, cwd)}
180 </Text>
181 ))}
182
183 <Text> </Text>
184 {heading('Skills', skills.length)}
185 {skills.length === 0 && none}
186 {skills.map(skill => (
187 <Text key={skill} wrap="truncate">
188 {' '}
189 {skill}
190 </Text>
191 ))}
192
193 <Text> </Text>
194 {heading('MCP servers', servers.length)}
195 {servers.length === 0 && none}
196 {servers.map(server => (
197 <Text key={server.name} wrap="truncate">
198 {' '}
199 {server.name}
200 <Text dimColor> ×{server.calls}</Text>
201 </Text>
202 ))}
203
204 <Text> </Text>
205 {heading('Memory', memory.length)}
206 {memory.length === 0 && none}
207 {memory.map(file => (
208 <Text key={file.path} wrap="truncate-start">
209 <Text dimColor>{file.type} </Text>
210 {shownPath(file.path, cwd)}
211 <Text dimColor> {compact(file.tokens)}</Text>
212 </Text>
213 ))}
214 </Box>
215 )
216 })
217}
218types/index.d.ts 29 lines1export type FileRef = {
2 path: string
3 isEdited: boolean
4}
5
6export type ServerRef = {
7 name: string
8 calls: number
9}
10
11export type MemoryRef = {
12 path: string
13 type: string
14 tokens: number
15}
16
17export type View = {
18 files: FileRef[]
19 skills: string[]
20 servers: ServerRef[]
21 memory: MemoryRef[]
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'context-pane': { view: View }
27 }
28}
29