コンテキストの内訳を分析し、課題点と改善点を提示する(読み取り専用)

Claude Code の Mods(関数フック型プラグイン)を作って配布するリポジトリ。1リポジトリ = 1マーケットプレイス、mods/<name>/ が1 mod。
| mod | 内容 | コマンド |
|---|---|---|
context-doctor | コンテキストの内訳を分析し、課題点と改善点を出す(読み取り専用) | /ctx |
session-recap | ツール別の呼び出し回数とエラー報告件数を一覧にする(読み取り専用・tool 名の集計のみ、input は出力しない) | /recap |
repeat-call-notice | 1 ターンで同じツールを同じ入力で N 回以上呼んだら、ターン終了時にトーストで知らせる(読み取り専用・tool 名と回数のみ、input は出力しない。N=3 は仮置き) | トースト |
このリポジトリは現在 private です。リポジトリへのアクセス権(GitHub 上で閲覧できる権限)がある人だけが取得できます。公開するかどうかは未判断で、判断の経緯は docs/design/open-questions.md の Q-001 を参照してください。
以下の手順は、リポジトリへのアクセス権があり、GitHub 認証済みの利用者向けです。
/plugin install context-doctor --marketplace dtakamiya/claude-code-mods
y でマーケットプレイスを追加 → スコープを選択。terminal セッションのプロンプトで実行する。
Claude Code 2.1.296(validate / test / tsc 通過。UI の目視確認は未実施)。Mods API は early access のため、バージョンが変わったら再検証する。
Mods はサンプルボックスなしで利用者の権限のまま動く。このリポジトリの mod は既定で読み取り専用。$.process.run・$.fs 書込・外部ネットワークを使う mod は、使う理由を各 mod の README に書く。
claude plugin validate mods/<name>
claude plugin test mods/<name>
claude --plugin-dir mods/<name> # 目視確認
詳細は CLAUDE.md。
hooks/register.tsx 63 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { analyze, render } from './analyze'
5import { EMPTY_PANE_VIEW } from './pane-atom'
6import { buildPaneRows } from './pane-model'
7import { buildPaneView, buildSnapshot } from './pane-snapshot'
8import { PaneBody } from './pane-view'
9
10const PANE_ID = 'context-doctor'
11const PANE_ARG = 'pane'
12const NO_BREAKDOWN_TEXT = 'まだ内訳がありません(最初の応答後に再実行してください)。'
13const PANE_OPENED_TEXT = 'Context ペインを開きました。'
14
15// validate の静的走査が読めるよう、参照はこのファイルに置く(リテラルの plugin / key)。
16const paneView = atom({ plugin: 'context-doctor', key: 'view' } as const, EMPTY_PANE_VIEW)
17
18export const register: Register = on => {
19 on('session.start', async ($, e, next) => {
20 await $.command.register({
21 name: 'ctx',
22 description: 'コンテキストの内訳を分析し、課題と改善案を出す',
23 argumentHint: PANE_ARG,
24 })
25 return next(e)
26 })
27
28 on('command.run', { command: 'ctx' }, async ($, e) => {
29 const { context } = await $.session.usage({ breakdown: 'summary' })
30 const b = context.breakdown
31 if (!b) return { text: NO_BREAKDOWN_TEXT }
32 const snap = buildSnapshot(b)
33 if (e.args.trim() !== PANE_ARG) return { text: render(snap, analyze(snap)) }
34
35 const view = buildPaneView(snap, Date.now())
36 await update($, paneView, () => view)
37 const opened = await $.ui.open({ id: PANE_ID, title: 'Context' })
38 return { text: opened.isPlaced ? PANE_OPENED_TEXT : render(snap, analyze(snap)) }
39 })
40
41 on('session.measure', async ($, e, next) => {
42 if (e.changed.includes('context')) {
43 const panes = await $.ui.panes()
44 if (panes.some(pane => pane.id === PANE_ID && pane.isPlaced)) {
45 const b = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
46 if (b) {
47 const view = buildPaneView(buildSnapshot(b), Date.now())
48 await update($, paneView, () => view)
49 }
50 }
51 }
52 return next(e)
53 })
54
55 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
56 const view = await read($, paneView)
57 const { bodyColumns, placement, scroll } = e.props
58 const findings = view.snapshot ? analyze(view.snapshot) : []
59 const rows = buildPaneRows({ view, findings, bodyColumns, placement, bodyRows: scroll.bodyRows })
60 return <PaneBody ui={$.ui.resolve(e)} rows={rows} />
61 })
62}
63hooks/analyze.ts 210 lines1// 純粋関数: 内訳 → 所見。`$` に触れない(テストしやすさと読み取り専用の保証のため)。
2// 所見は「観察」(数値で直接言えること)と「仮説」(推測)を分けて持つ。因果は断定しない。
3export type Row = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }
4export type McpTool = { serverName: string; tokens: number; isLoaded: boolean }
5export type MemoryFile = { path: string; type: string; tokens: number }
6export type Snapshot = {
7 percentage: number
8 totalTokens: number
9 maxTokens: number
10 categories: readonly Row[]
11 mcpTools: readonly McpTool[]
12 memoryFiles: readonly MemoryFile[]
13 skills?: { totalSkills: number; includedSkills: number; tokens: number }
14 agents: readonly { tokens: number }[]
15 slashCommands?: { totalCommands: number; includedCommands: number; tokens: number }
16 autoCompactThreshold?: number
17 isAutoCompactEnabled: boolean
18}
19export type Level = 'high' | 'mid' | 'info'
20export type Finding = {
21 level: Level
22 observation: string
23 hypothesis?: string
24 suggestion?: string
25 /** ペイン用の 1 行見出し(観察できる数値のみ)。 */
26 headline?: string
27 shortHeadline?: string
28 /** ペイン用の 1 行アクション。 */
29 action?: string
30 shortAction?: string
31 /** 回収できる見込みのトークン(並べ替え用)。 */
32 tokens?: number
33}
34
35// しきい値はすべて仮置き(実測による裏付け待ち: docs/design/open-questions.md Q-002)。
36export const THRESHOLDS = {
37 /** 自動圧縮が無効なときの使用率の目安(%) */
38 fallbackPercent: 80,
39 /** 圧縮点までの残りが窓のこの割合以下なら「近い」 */
40 nearCompactShare: 0.05,
41 /** MCP(読込済み): 窓比 OR 絶対量 */
42 mcpShare: 0.1,
43 mcpAbsolute: 20_000,
44 /** メモリ: 窓比 OR 絶対量 */
45 memoryShare: 0.05,
46 memoryAbsolute: 20_000,
47} as const
48
49const TOP_N = 3
50const COMPACT_ACTION = '/compact で要約(区切りなら /clear)'
51const COMPACT_ACTION_SHORT = '/compact で要約'
52const LEVEL_ORDER: Record<Level, number> = { high: 0, mid: 1, info: 2 }
53const LEVEL_LABEL: Record<Level, string> = { high: '要対応', mid: '注意', info: '参考' }
54
55const sum = (xs: readonly number[]) => xs.reduce((a, b) => a + b, 0)
56const share = (n: number, total: number) => (total > 0 ? n / total : 0)
57export const fmt = (n: number) => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
58const pct = (n: number, total: number) => `${Math.round(share(n, total) * 100)}%`
59const baseName = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p
60
61function topBy<T>(items: readonly T[], key: (t: T) => string, tokens: (t: T) => number): [string, number][] {
62 const acc = new Map<string, number>()
63 for (const it of items) acc.set(key(it), (acc.get(key(it)) ?? 0) + tokens(it))
64 return [...acc.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP_N)
65}
66const listTop = (xs: readonly [string, number][]) => xs.map(([k, v]) => `${k} ${fmt(v)}`).join('、')
67
68function compactionFinding(s: Snapshot): Finding | undefined {
69 // 注意: totalTokens に buffer 行が含まれるかは未確認(U-1)。そのため
70 // 「totalTokens と autoCompactThreshold の差」を観察として示すに留め、実余裕とは断定しない。
71 if (s.isAutoCompactEnabled && s.autoCompactThreshold !== undefined) {
72 const remaining = s.autoCompactThreshold - s.totalTokens
73 if (remaining <= 0) {
74 return {
75 level: 'high',
76 observation: `使用量が自動圧縮の基準 ${fmt(s.autoCompactThreshold)} トークンを ${fmt(-remaining)} 超えている`,
77 hypothesis: '基準を超えた状態のため、近く自動圧縮が走るか、すでに走りきれていない可能性がある',
78 suggestion: '/compact で先に要約するのが第一候補。会話を捨てて良い区切りなら /clear も可(履歴は失われる)',
79 headline: `圧縮点を ${fmt(-remaining)} 超過`,
80 shortHeadline: `圧縮点を ${fmt(-remaining)} 超過`,
81 action: COMPACT_ACTION,
82 shortAction: COMPACT_ACTION_SHORT,
83 }
84 }
85 if (s.maxTokens > 0 && remaining <= s.maxTokens * THRESHOLDS.nearCompactShare) {
86 return {
87 level: 'high',
88 observation: `自動圧縮の基準まで残り ${fmt(remaining)} トークン(窓の ${pct(remaining, s.maxTokens)})`,
89 suggestion: '/compact を第一候補に。作業の区切りなら /clear も可(履歴は失われる)。長い出力を返すコマンドは避ける',
90 headline: `圧縮点まで残り ${fmt(remaining)}(窓の ${pct(remaining, s.maxTokens)})`,
91 shortHeadline: `圧縮点まで ${fmt(remaining)}`,
92 action: COMPACT_ACTION,
93 shortAction: COMPACT_ACTION_SHORT,
94 }
95 }
96 return undefined
97 }
98 if (!s.isAutoCompactEnabled && s.percentage >= THRESHOLDS.fallbackPercent) {
99 return {
100 level: 'high',
101 observation: `窓の ${s.percentage}% を使用中。自動圧縮は無効`,
102 hypothesis: '自動では圧縮されないため、窓を使い切る前に手動対応が必要になる可能性がある',
103 suggestion: '/compact を第一候補に。作業の区切りなら /clear も可(履歴は失われる)',
104 headline: `窓の ${s.percentage}% を使用・自動圧縮は無効`,
105 shortHeadline: `${s.percentage}% 使用・自動圧縮なし`,
106 action: COMPACT_ACTION,
107 shortAction: COMPACT_ACTION_SHORT,
108 }
109 }
110 return undefined
111}
112
113function mcpFindings(s: Snapshot): Finding[] {
114 const loaded = s.mcpTools.filter(t => t.isLoaded)
115 const unloaded = s.mcpTools.filter(t => !t.isLoaded)
116 const out: Finding[] = []
117 const total = sum(loaded.map(t => t.tokens))
118 const top = topBy(loaded, t => t.serverName, t => t.tokens)[0]
119 const topMcp = top ? `${top[0]} ${fmt(top[1])}` : ''
120 if (total > 0 && (share(total, s.maxTokens) >= THRESHOLDS.mcpShare || total >= THRESHOLDS.mcpAbsolute)) {
121 out.push({
122 level: 'mid',
123 observation: `読込済み MCP ツール定義が ${fmt(total)} トークン(窓の ${pct(total, s.maxTokens)})。上位サーバー: ${listTop(topBy(loaded, t => t.serverName, t => t.tokens))}`,
124 hypothesis: '使っていないサーバーが含まれていれば、無効化で窓を空けられる可能性がある',
125 suggestion: '/mcp で上位サーバーの要否を確認し、不要なものを無効化する',
126 headline: `MCP ${fmt(total)}(${pct(total, s.maxTokens)})上位 ${topMcp}`,
127 shortHeadline: `MCP ${fmt(total)} (${pct(total, s.maxTokens)})`,
128 action: '/mcp で上位サーバーの要否を確認',
129 shortAction: '/mcp で要否確認',
130 tokens: total,
131 })
132 }
133 const unloadedTotal = sum(unloaded.map(t => t.tokens))
134 if (unloaded.length > 0) {
135 out.push({
136 level: 'info',
137 observation: `未ロードの MCP ツール ${unloaded.length} 件(${fmt(unloadedTotal)} トークン)は集計から除外`,
138 hypothesis: '呼ばれた時点でオンデマンド読込される想定。窓への影響は呼び出し後に変わる可能性がある',
139 })
140 }
141 return out
142}
143
144function memoryFinding(s: Snapshot): Finding | undefined {
145 const total = sum(s.memoryFiles.map(f => f.tokens))
146 if (!(share(total, s.maxTokens) >= THRESHOLDS.memoryShare || total >= THRESHOLDS.memoryAbsolute) || total <= 0) return undefined
147 const top = [...s.memoryFiles].sort((a, b) => b.tokens - a.tokens).slice(0, TOP_N)
148 const list = top.map(f => `${baseName(f.path)}(${f.type})${fmt(f.tokens)}`).join('、')
149 return {
150 level: 'mid',
151 observation: `メモリファイルが ${fmt(total)} トークン(窓の ${pct(total, s.maxTokens)})。上位: ${list}`,
152 hypothesis: '常時読み込みが長いほど、毎ターン窓を占有している可能性がある',
153 suggestion: '長文は参照ファイルへ切り出し、常時読み込みを短くする',
154 headline: `メモリ ${fmt(total)}(${pct(total, s.maxTokens)})`,
155 shortHeadline: `メモリ ${fmt(total)} (${pct(total, s.maxTokens)})`,
156 action: '長文を参照ファイルへ切り出す',
157 shortAction: '長文を切り出す',
158 tokens: total,
159 }
160}
161
162function listingFindings(s: Snapshot): Finding[] {
163 const out: Finding[] = []
164 if (s.skills) {
165 const cut = s.skills.includedSkills < s.skills.totalSkills
166 out.push({
167 level: 'info',
168 observation: `スキル一覧 ${fmt(s.skills.tokens)} トークン(${s.skills.includedSkills}/${s.skills.totalSkills} 件を掲載${cut ? '・予算で切り捨てあり' : ''})`,
169 hypothesis: cut ? '掲載外のスキルはモデルから見えにくい可能性がある(因果は未検証)' : undefined,
170 suggestion: cut ? '使わないスキルやプラグインを減らすと掲載枠に余裕ができる' : undefined,
171 })
172 }
173 if (s.agents.length > 0) {
174 out.push({ level: 'info', observation: `エージェント説明 ${s.agents.length} 件(${fmt(sum(s.agents.map(a => a.tokens)))} トークン)` })
175 }
176 if (s.slashCommands) {
177 const c = s.slashCommands
178 out.push({ level: 'info', observation: `スラッシュコマンド一覧 ${fmt(c.tokens)} トークン(${c.includedCommands}/${c.totalCommands} 件を掲載)` })
179 }
180 return out
181}
182
183export function analyze(s: Snapshot): Finding[] {
184 const found = [compactionFinding(s), ...mcpFindings(s), memoryFinding(s), ...listingFindings(s)]
185 const out = found.filter((f): f is Finding => f !== undefined)
186 // Array.prototype.sort は安定。重大度順、同順位は元の並び。
187 return [...out].sort((a, b) => LEVEL_ORDER[a.level] - LEVEL_ORDER[b.level])
188}
189
190function breakdownLine(s: Snapshot): string {
191 const used = [...s.categories].filter(c => c.kind === 'used').sort((a, b) => b.tokens - a.tokens).slice(0, TOP_N)
192 const kindSum = (k: Row['kind']) => sum(s.categories.filter(c => c.kind === k).map(c => c.tokens))
193 const usedText = used.length ? used.map(c => `${c.name} ${fmt(c.tokens)}`).join('、') : '-'
194 return `内訳(使用上位): ${usedText} / 空き ${fmt(kindSum('free'))} / 圧縮バッファ ${fmt(kindSum('buffer'))}`
195}
196
197function renderFinding(f: Finding): string {
198 return [
199 `- [${LEVEL_LABEL[f.level]}] 観察: ${f.observation}`,
200 ...(f.hypothesis ? [` 仮説: ${f.hypothesis}`] : []),
201 ...(f.suggestion ? [` → ${f.suggestion}`] : []),
202 ].join('\n')
203}
204
205export function render(s: Snapshot, findings: readonly Finding[]): string {
206 const head = `コンテキスト ${s.percentage}%(${fmt(s.totalTokens)}/${fmt(s.maxTokens)} トークン・推定値)`
207 const body = findings.length === 0 ? ['所見なし。'] : findings.map(renderFinding)
208 return [head, breakdownLine(s), ...body, '※ 数値は推定値。しきい値は仮置きで、実測で見直す予定'].join('\n')
209}
210hooks/pane-atom.ts 5 lines1import type { PaneView } from '../types'
2
3/** ペインの初期値。atom 本体は validate の静的走査の都合で register.tsx に置く。 */
4export const EMPTY_PANE_VIEW: PaneView = { v: 1, status: 'empty' }
5hooks/pane-model.ts 111 lines1// 純粋関数: ペインの値 + 所見 + 幅・高さ → 描画用の行モデル。`$` にも JSX にも依存しない。
2import type { Finding, Snapshot } from './analyze'
3import type { PaneRow } from './pane-row'
4import { breakdownCount, breakdownRows, findingRows, headerRows, pickFindings, referenceRows, type Tier } from './pane-sections'
5import type { PaneView } from '../types'
6
7export type { PaneRow } from './pane-row'
8
9/** 仮置き: これ未満の本文幅では狭幅表示にする。目視確認の結果で調整する。 */
10export const NARROW_BODY_COLUMNS = 40
11/** 仮置き: この桁数以上で所見を 1 行に連結する。 */
12export const WIDE_BODY_COLUMNS = 68
13
14const SUPPORTED_VIEW_VERSION = 1
15const MAX_FINDINGS = 3
16const MAX_FINDINGS_NARROW = 2
17const EMPTY_MESSAGE = 'まだ内訳がありません(最初の応答後に開き直してください)'
18const pad2 = (n: number) => String(n).padStart(2, '0')
19
20export type PaneModelInput = {
21 view: PaneView
22 findings: readonly Finding[]
23 bodyColumns: number
24 /** `dock`(fullscreen の横置き)のときだけ「参考」を出す。 */
25 placement?: 'dock' | 'inline'
26 /** 本体が使える行数。超えるときは下位の情報から省く。無ければ省かない。 */
27 bodyRows?: number
28 /** 通常は view.measuredAtMs。無ければ測定時刻は出さない。 */
29 measuredAtMs?: number
30}
31
32/** 何を出すか。高さが足りないとき、`RELAXATIONS` の順に 1 つずつ緩める。 */
33type Options = {
34 reference: boolean
35 deferred: boolean
36 footer: boolean
37 legend: boolean
38 findingCount: number
39 /** 内訳の表示件数(超えた分は畳む)。 */
40 breakdownVisible: number
41}
42
43const RELAXATIONS: readonly ((o: Options) => Options)[] = [
44 o => ({ ...o, reference: false }),
45 o => ({ ...o, deferred: false }),
46 o => ({ ...o, footer: false }),
47 o => ({ ...o, legend: false }),
48 o => ({ ...o, findingCount: Math.min(o.findingCount, 2) }),
49 o => ({ ...o, findingCount: 1 }),
50]
51
52const tierOf = (bodyColumns: number): Tier =>
53 bodyColumns < NARROW_BODY_COLUMNS ? 'narrow' : bodyColumns >= WIDE_BODY_COLUMNS ? 'wide' : 'regular'
54
55const clock = (ms: number) => `${pad2(new Date(ms).getHours())}:${pad2(new Date(ms).getMinutes())}`
56
57function footerRow(tier: Tier, at?: number): PaneRow {
58 const time = at === undefined ? '' : clock(at)
59 const text = tier === 'narrow' ? `${time} 推定値` : `${at === undefined ? '' : `測定 ${time} ・`}推定値 ・しきい値は仮置き`
60 return { kind: 'note', text: text.trim() }
61}
62
63const BLANK: PaneRow = { kind: 'blank', text: '' }
64
65function compose(s: Snapshot, findings: readonly Finding[], tier: Tier, input: PaneModelInput, o: Options, at?: number): PaneRow[] {
66 const shown = pickFindings(findings, o.findingCount)
67 const reference = o.reference && input.placement === 'dock' ? referenceRows(findings, input.bodyColumns) : []
68 return [
69 ...headerRows(s, tier, input.bodyColumns, o.legend),
70 BLANK,
71 { kind: 'heading', text: tier === 'narrow' ? '所見' : '所見(今すぐ効く順)' },
72 ...findingRows(shown, tier, input.bodyColumns),
73 BLANK,
74 ...breakdownRows(s, tier, o.breakdownVisible, o.deferred),
75 ...(reference.length > 0 ? [BLANK, ...reference] : []),
76 ...(o.footer ? [BLANK, footerRow(tier, at)] : []),
77 ]
78}
79
80/** 空・版違い・Snapshot なしは「内訳なし」の1行。それ以外は幅に応じた 3 段、高さに応じて省略。 */
81export function buildPaneRows(input: PaneModelInput): readonly PaneRow[] {
82 const { view, findings, bodyColumns, bodyRows } = input
83 const at = input.measuredAtMs ?? view.measuredAtMs
84 if (view.v !== SUPPORTED_VIEW_VERSION || view.status !== 'ok' || !view.snapshot) {
85 return [{ kind: 'message', text: EMPTY_MESSAGE }]
86 }
87 const s: Snapshot = view.snapshot
88 const tier = tierOf(bodyColumns)
89 const total = breakdownCount(s)
90 const initial: Options = {
91 reference: true,
92 deferred: true,
93 footer: true,
94 legend: true,
95 findingCount: tier === 'narrow' ? MAX_FINDINGS_NARROW : MAX_FINDINGS,
96 breakdownVisible: total,
97 }
98 const build = (o: Options) => compose(s, findings, tier, input, o, at)
99 if (bodyRows === undefined) return build(initial)
100
101 const relaxed = RELAXATIONS.reduce(
102 (o, relax) => (build(o).length > bodyRows ? relax(o) : o),
103 initial,
104 )
105 // 内訳は、収まるまで下位を 1 件ずつ畳む(畳む行が 1 行増えるので、2 件以上減らせるときだけ)。
106 const fitted = Array.from({ length: Math.max(0, total - 1) }, (_, i) => total - 2 - i)
107 .filter(v => v >= 1)
108 .reduce((o, v) => (build(o).length > bodyRows ? { ...o, breakdownVisible: v } : o), relaxed)
109 return build(fitted)
110}
111hooks/pane-snapshot.ts 54 lines1// 純粋関数: 内訳 → Snapshot の組立と、$.state へ書く前の整形。`$` に触れない。
2import type { SessionContextBreakdown } from 'claude-code'
3
4import type { Snapshot } from './analyze'
5import type { PaneView } from '../types'
6
7/** Snapshot の組立に使う内訳の項目。 */
8export type BreakdownSource = Pick<
9 SessionContextBreakdown,
10 | 'percentage'
11 | 'totalTokens'
12 | 'maxTokens'
13 | 'categories'
14 | 'mcpTools'
15 | 'memoryFiles'
16 | 'skills'
17 | 'agents'
18 | 'slashCommands'
19 | 'autoCompactThreshold'
20 | 'isAutoCompactEnabled'
21>
22
23/** register が従来インラインで行っていた組立。値は加工せずそのまま渡す(挙動不変)。 */
24export function buildSnapshot(b: BreakdownSource): Snapshot {
25 return {
26 percentage: b.percentage,
27 totalTokens: b.totalTokens,
28 maxTokens: b.maxTokens,
29 categories: b.categories,
30 mcpTools: b.mcpTools,
31 memoryFiles: b.memoryFiles,
32 skills: b.skills,
33 agents: b.agents,
34 slashCommands: b.slashCommands,
35 autoCompactThreshold: b.autoCompactThreshold,
36 isAutoCompactEnabled: b.isAutoCompactEnabled,
37 }
38}
39
40/** `undefined` のキーを再帰的に落とした新しい値を返す($.state は undefined を受け付けない)。入力は変更しない。 */
41export function dropUndefined<T>(value: T): T {
42 if (Array.isArray(value)) return value.map(dropUndefined) as T
43 if (value === null || typeof value !== 'object') return value
44 const entries = Object.entries(value as Record<string, unknown>)
45 .filter(([, v]) => v !== undefined)
46 .map(([k, v]) => [k, dropUndefined(v)] as const)
47 return Object.fromEntries(entries) as T
48}
49
50/** atom に書く値。書込前に undefined キーを落とす。 */
51export function buildPaneView(snapshot: Snapshot, measuredAtMs: number): PaneView {
52 return dropUndefined({ v: 1, status: 'ok', snapshot, measuredAtMs })
53}
54hooks/pane-view.tsx 84 lines1// 行モデル → Box / Text。ロジックを持たない(色とグリフの対応だけ)。色はテーマキーで、生の色名は使わない。
2import type { Elements } from 'claude-code'
3
4import type { Level } from './analyze'
5import type { BarKind, Stage } from './pane-bar'
6import { ACTION_INDENT, ACTION_PREFIX, FINDING_GAP, type PaneRow } from './pane-row'
7
8type Ui = Pick<Elements['terminal'], 'Box' | 'Text'>
9
10const STAGE_COLOR: Record<Stage, 'success' | 'warning' | 'error'> = { ok: 'success', warn: 'warning', danger: 'error', over: 'error' }
11const LEVEL_COLOR: Record<Level, 'error' | 'warning' | undefined> = { high: 'error', mid: 'warning', info: undefined }
12const BAR_GLYPH: Record<BarKind, string> = { used: '█', margin: '▒', compact: '┃', buffer: '░', free: '░' }
13const ITEM_GLYPH = { used: '█', free: '▒', buffer: '░' } as const
14const ITEM_COLOR = { used: undefined, free: 'subtle', buffer: 'inactive' } as const
15
16export function PaneBody({ ui, rows }: { ui: Ui; rows: readonly PaneRow[] }) {
17 const { Box, Text } = ui
18 const dim = (text: string) => <Text color="inactive" dimColor>{text}</Text>
19 const stageLabel = (row: PaneRow) =>
20 row.label && row.stage ? (
21 <Text color={STAGE_COLOR[row.stage]} bold inverse>{row.label}</Text>
22 ) : undefined
23
24 const renderBar = (row: PaneRow) => (
25 <Box flexDirection="row">
26 <Text>{' ['}</Text>
27 {(row.runs ?? []).map(run => {
28 const glyph = BAR_GLYPH[run.kind].repeat(run.count)
29 if (run.kind === 'used') return <Text color={row.stage ? STAGE_COLOR[row.stage] : undefined}>{glyph}</Text>
30 if (run.kind === 'margin') return <Text color="subtle">{glyph}</Text>
31 if (run.kind === 'compact') return <Text bold color={row.stage === 'over' ? 'error' : undefined}>{glyph}</Text>
32 return dim(glyph)
33 })}
34 <Text>{']'}</Text>
35 </Box>
36 )
37
38 const renderRow = (row: PaneRow) => {
39 switch (row.kind) {
40 case 'bar':
41 return renderBar(row)
42 case 'usage':
43 case 'stage':
44 return (
45 <Box flexDirection="row">
46 {row.text ? <Text bold>{row.text}</Text> : undefined}
47 {stageLabel(row) ? <Text>{row.text ? ' ' : ''}</Text> : undefined}
48 {stageLabel(row)}
49 {row.tail ? <Text>{` ${row.tail}`}</Text> : undefined}
50 </Box>
51 )
52 case 'finding':
53 return (
54 <Box flexDirection="row">
55 <Text bold color={LEVEL_COLOR[row.level ?? 'info']}>{row.label}</Text>
56 <Text>{` ${row.text}`}</Text>
57 {row.tail ? <Text color="suggestion">{`${FINDING_GAP}${ACTION_PREFIX}${row.tail}`}</Text> : undefined}
58 </Box>
59 )
60 case 'action':
61 return <Text color="suggestion">{`${ACTION_INDENT}${ACTION_PREFIX}${row.text}`}</Text>
62 case 'item': {
63 const kind = row.itemKind ?? 'used'
64 return (
65 <Box flexDirection="row">
66 <Text wrap="truncate-end" color={kind === 'used' ? undefined : ITEM_COLOR[kind]} dimColor={kind === 'buffer'}>{row.text}</Text>
67 {row.barLen ? <Text color={ITEM_COLOR[kind]} dimColor={kind === 'buffer'}>{` ${ITEM_GLYPH[kind].repeat(row.barLen)}`}</Text> : undefined}
68 </Box>
69 )
70 }
71 case 'heading':
72 return <Text bold>{row.text}</Text>
73 case 'blank':
74 return <Text>{' '}</Text>
75 case 'message':
76 return <Text dimColor>{row.text}</Text>
77 default:
78 return dim(row.text)
79 }
80 }
81
82 return <Box flexDirection="column">{rows.map(renderRow)}</Box>
83}
84hooks/pane-row.ts 66 lines1// 行モデルの型。ペインの描画(pane-view.tsx)と組立(pane-model.ts / pane-sections.ts)が共有する。
2import type { Level } from './analyze'
3import type { BarRun, Stage } from './pane-bar'
4import { displayWidth } from './pane-text'
5
6export type PaneRowKind =
7 | 'message'
8 | 'blank'
9 | 'heading'
10 | 'usage'
11 | 'stage'
12 | 'bar'
13 | 'legend'
14 | 'finding'
15 | 'action'
16 | 'item'
17 | 'note'
18 | 'reference'
19
20export type PaneRow = {
21 kind: PaneRowKind
22 text: string
23 /** usage / stage / finding の行頭ラベル(`[注意]` など)。 */
24 label?: string
25 /** usage / stage: ラベルの横に出す補足(圧縮点まで N)。finding: 同じ行に連結するアクション。 */
26 tail?: string
27 stage?: Stage
28 /** finding の重大度。 */
29 level?: Level
30 /** bar のセル配分。 */
31 runs?: readonly BarRun[]
32 /** item のカテゴリ種別と、ミニバーの桁数(0 ならバーなし)。 */
33 itemKind?: 'used' | 'free' | 'buffer'
34 barLen?: number
35}
36
37// pane-view.tsx が実際に並べる文字列と同じ幅の計算。モデルの幅予算とテストが共有する。
38export const ACTION_PREFIX = '→ '
39export const ACTION_INDENT = ' '
40export const FINDING_GAP = ' '
41const BAR_FRAME_WIDTH = 3 // ' [' と ']'
42const LABEL_GAP = 2
43const TAIL_GAP = 1
44const ITEM_BAR_GAP = 1
45const LABEL_TEXT_GAP = 1
46
47/** 行を描いたときの表示桁数。曖昧幅の記号(█ ▒ ┃ ░ →)は 1 桁と数える。 */
48export function rowWidth(row: PaneRow): number {
49 const w = displayWidth
50 switch (row.kind) {
51 case 'bar':
52 return BAR_FRAME_WIDTH + (row.runs ?? []).reduce((n, r) => n + r.count, 0)
53 case 'usage':
54 case 'stage':
55 return w(row.text) + (row.label ? (row.text ? LABEL_GAP : 0) + w(row.label) : 0) + (row.tail ? TAIL_GAP + w(row.tail) : 0)
56 case 'finding':
57 return w(row.label ?? '') + LABEL_TEXT_GAP + w(row.text) + (row.tail ? w(FINDING_GAP + ACTION_PREFIX) + w(row.tail) : 0)
58 case 'action':
59 return w(ACTION_INDENT + ACTION_PREFIX + row.text)
60 case 'item':
61 return w(row.text) + (row.barLen ? ITEM_BAR_GAP + row.barLen : 0)
62 default:
63 return w(row.text)
64 }
65}
66hooks/pane-sections.ts 156 lines1// 純粋関数: ペインの各セクション(ヘッダ・所見・内訳)の行を組み立てる。`$` に依存しない。
2import { fmt, THRESHOLDS, type Finding, type Level, type Row, type Snapshot } from './analyze'
3import { buildBarRuns, STAGE_LABEL, stageOf } from './pane-bar'
4import { padEndWidth, padStartWidth, truncateWidth } from './pane-text'
5import { rowWidth, type PaneRow } from './pane-row'
6
7export type Tier = 'narrow' | 'regular' | 'wide'
8
9/** 仮置き: 内訳の名前列の表示幅。 */
10const NAME_WIDTH = 18
11const TOKEN_WIDTH = 7
12const PERCENT_WIDTH = 4
13const NARROW_PERCENT_WIDTH = 5
14/** 仮置き: ミニバーの倍率(割合 × この値)と上限桁数。 */
15const MINI_BAR_SCALE = 16
16const MINI_BAR_MAX = 8
17/** 仮置き: バー幅の上限と、本文幅から差し引く桁数(`[` `]` と余白)。 */
18const BAR_WIDTH_MAX = 40
19const BAR_MARGIN = 4
20const LEVEL_LABEL: Partial<Record<Level, string>> = { high: '要対応', mid: '注意' }
21const LEVEL_RANK: Record<Level, number> = { high: 0, mid: 1, info: 2 }
22const NO_FINDING_TEXT = '所見なし(今すぐ効く対応はありません)'
23const NO_FINDING_SHORT = '所見なし'
24
25const share = (n: number, total: number) => (total > 0 ? n / total : 0)
26const percent = (n: number, total: number) => `${Math.round(share(n, total) * 100)}%`
27const sumOf = (xs: readonly { tokens: number }[]) => xs.reduce((a, c) => a + c.tokens, 0)
28
29function remainingText(s: Snapshot): string {
30 if (!s.isAutoCompactEnabled || s.autoCompactThreshold === undefined) {
31 return `自動圧縮は無効(${THRESHOLDS.fallbackPercent}% で危険)`
32 }
33 const left = s.autoCompactThreshold - s.totalTokens
34 return left <= 0 ? `圧縮点を ${fmt(-left)} 超過` : `圧縮点まで ${fmt(left)}`
35}
36
37function legendText(s: Snapshot, tier: Tier): string {
38 if (!s.isAutoCompactEnabled || s.autoCompactThreshold === undefined) return ' █使用 ░空き'
39 if (tier === 'regular') return ` █使用 ▒余裕 ┃圧縮点 ${fmt(s.autoCompactThreshold)} ░バッファ`
40 const buffer = sumOf(s.categories.filter(c => c.kind === 'buffer'))
41 return ` █使用 ▒余裕 ┃圧縮点 ${fmt(s.autoCompactThreshold)} ░バッファ ${fmt(buffer)}`
42}
43
44/** 使用率・段階・バー(・凡例)。tier で行の分け方が変わる。 */
45export function headerRows(s: Snapshot, tier: Tier, bodyColumns: number, showLegend: boolean): PaneRow[] {
46 const stage = stageOf(s)
47 const label = `[${STAGE_LABEL[stage]}]`
48 const tail = remainingText(s)
49 const barWidth = Math.max(1, Math.min(BAR_WIDTH_MAX, bodyColumns - BAR_MARGIN))
50 const bar: PaneRow = { kind: 'bar', text: '', stage, runs: buildBarRuns(s, barWidth) }
51 const legend: PaneRow[] = showLegend && tier !== 'narrow' ? [{ kind: 'legend', text: legendText(s, tier) }] : []
52 if (tier === 'narrow') return [{ kind: 'usage', text: `${s.percentage}%`, label, stage }, bar, { kind: 'note', text: tail }]
53 const unit = tier === 'wide' ? ' トークン' : ''
54 const text = `${s.percentage}% 使用 ${fmt(s.totalTokens)} / ${fmt(s.maxTokens)}${unit}(推定)`
55 const single: PaneRow = { kind: 'usage', text, label, tail, stage }
56 if (tier === 'wide' && rowWidth(single) <= bodyColumns) return [single, bar, ...legend]
57 return [{ kind: 'usage', text }, { kind: 'stage', text: '', label, tail, stage }, bar, ...legend]
58}
59
60/** 重大度順、同順位は回収トークンの多い順(安定)。high / mid のみ。 */
61export function pickFindings(findings: readonly Finding[], max: number): Finding[] {
62 return findings
63 .filter(f => f.level !== 'info')
64 .map((f, i) => ({ f, i }))
65 .sort((a, b) => LEVEL_RANK[a.f.level] - LEVEL_RANK[b.f.level] || (b.f.tokens ?? 0) - (a.f.tokens ?? 0) || a.i - b.i)
66 .slice(0, max)
67 .map(x => x.f)
68}
69
70type Variant = { headline: string; action?: string }
71
72/** 見出し・アクションの候補(好みの順)。狭幅は短縮版を先に、それ以外は完全版を先に。 */
73function variantsOf(f: Finding, narrow: boolean): Variant[] {
74 const full: Variant = { headline: f.headline ?? f.observation, action: f.action }
75 const short: Variant = { headline: f.shortHeadline ?? full.headline, action: f.shortAction ?? full.action }
76 const mixed: Variant = { headline: full.headline, action: short.action }
77 return narrow ? [short, full] : [full, mixed, short]
78}
79
80const firstFitting = <T>(candidates: readonly T[], fits: (t: T) => boolean): T | undefined => candidates.find(fits) ?? candidates[candidates.length - 1]
81
82/** 1 件 → 1〜2 行。広めで 1 行に収まるときだけ連結し、収まらなければ見出し行+アクション行。 */
83function oneFinding(f: Finding, tier: Tier, bodyColumns: number): PaneRow[] {
84 const label = `[${LEVEL_LABEL[f.level] ?? ''}]`
85 const variants = variantsOf(f, tier === 'narrow')
86 const fits = (row: PaneRow) => rowWidth(row) <= bodyColumns
87 const head = (v: Variant): PaneRow => ({ kind: 'finding', level: f.level, label, text: v.headline })
88 const joined = (v: Variant): PaneRow => ({ ...head(v), tail: v.action })
89 const action = (v: Variant): PaneRow => ({ kind: 'action', text: v.action ?? '' })
90 if (tier === 'wide') {
91 const single = variants.find(v => v.action && fits(joined(v)))
92 if (single) return [joined(single)]
93 }
94 const v = firstFitting(variants, x => fits(head(x)) && (!x.action || fits(action(x)))) ?? { headline: f.observation }
95 return [head(v), ...(v.action ? [action(v)] : [])]
96}
97
98export function findingRows(shown: readonly Finding[], tier: Tier, bodyColumns: number): PaneRow[] {
99 if (shown.length === 0) return [{ kind: 'message', text: tier === 'narrow' ? NO_FINDING_SHORT : NO_FINDING_TEXT }]
100 return shown.flatMap(f => oneFinding(f, tier, bodyColumns))
101}
102
103type BreakdownItem = Row & { kind: 'used' | 'free' | 'buffer' }
104
105/** used・free・buffer を tokens 降順(安定)に。deferred は含めない。 */
106function breakdownItems(s: Snapshot): BreakdownItem[] {
107 return s.categories
108 .filter((c): c is BreakdownItem => c.kind !== 'deferred')
109 .map((c, i) => ({ c, i }))
110 .sort((a, b) => b.c.tokens - a.c.tokens || a.i - b.i)
111 .map(x => x.c)
112}
113
114export const breakdownCount = (s: Snapshot) => breakdownItems(s).length
115
116function itemRow(c: BreakdownItem, s: Snapshot, narrow: boolean): PaneRow {
117 const pct = percent(c.tokens, s.maxTokens)
118 const name = padEndWidth(c.name, NAME_WIDTH)
119 if (narrow) return { kind: 'item', itemKind: c.kind, text: `${name}${padStartWidth(pct, NARROW_PERCENT_WIDTH)}` }
120 const barLen = c.tokens > 0 ? Math.min(MINI_BAR_MAX, Math.max(1, Math.round(share(c.tokens, s.maxTokens) * MINI_BAR_SCALE))) : 0
121 return {
122 kind: 'item',
123 itemKind: c.kind,
124 text: `${name} ${padStartWidth(fmt(c.tokens), TOKEN_WIDTH)} ${padStartWidth(pct, PERCENT_WIDTH)}`,
125 barLen,
126 }
127}
128
129/** `visible` 件を超える下位は `他 n 件 合計 x%` に畳む。割合の合計は保つ。 */
130export function breakdownRows(s: Snapshot, tier: Tier, visible: number, showDeferred: boolean): PaneRow[] {
131 const items = breakdownItems(s)
132 const narrow = tier === 'narrow'
133 const shown = items.slice(0, visible)
134 const rest = items.slice(visible)
135 const folded: PaneRow[] =
136 rest.length === 0
137 ? []
138 : [{ kind: 'note', text: `他 ${rest.length} 件 合計 ${percent(sumOf(rest), s.maxTokens)}` }]
139 const deferred = sumOf(s.categories.filter(c => c.kind === 'deferred'))
140 const deferredRow: PaneRow[] = showDeferred && deferred > 0 && !narrow ? [{ kind: 'note', text: `窓外(遅延読込) ${fmt(deferred)}` }] : []
141 return [
142 { kind: 'heading', text: narrow ? '内訳' : '内訳(窓に占める割合・降順)' },
143 ...shown.map(c => itemRow(c, s, narrow)),
144 ...folded,
145 ...deferredRow,
146 ]
147}
148
149const REFERENCE_MAX = 3
150export function referenceRows(findings: readonly Finding[], bodyColumns: number): PaneRow[] {
151 const infos = findings.filter(f => f.level === 'info').slice(0, REFERENCE_MAX)
152 if (infos.length === 0) return []
153 const text = (f: Finding) => truncateWidth(`・${f.headline ?? f.observation}`, bodyColumns)
154 return [{ kind: 'heading', text: '参考' }, ...infos.map((f): PaneRow => ({ kind: 'reference', text: text(f) }))]
155}
156hooks/pane-bar.ts 58 lines1// 純粋関数: 使用率の段階判定と、1 本のバーのセル配分。`$` にも JSX にも依存しない。
2import { THRESHOLDS, type Snapshot } from './analyze'
3
4/** 仮置き(実測の裏付けなし): 圧縮点までの残りが窓のこの割合以下なら「注意」。 */
5export const WARN_COMPACT_SHARE = 0.15
6/** 仮置き(実測の裏付けなし): 自動圧縮が無効なとき、この使用率(%)以上で「注意」。 */
7export const WARN_FALLBACK_PERCENT = 60
8
9export type Stage = 'ok' | 'warn' | 'danger' | 'over'
10export const STAGE_LABEL: Record<Stage, string> = { ok: '余裕', warn: '注意', danger: '危険', over: '超過' }
11
12export type BarKind = 'used' | 'margin' | 'compact' | 'buffer' | 'free'
13export type BarRun = { kind: BarKind; count: number }
14
15const hasCompactPoint = (s: Snapshot): s is Snapshot & { autoCompactThreshold: number } =>
16 s.isAutoCompactEnabled && s.autoCompactThreshold !== undefined
17
18/** 段階は既存の所見(compactionFinding)と同じ条件にそろえる。 */
19export function stageOf(s: Snapshot): Stage {
20 if (hasCompactPoint(s)) {
21 const left = s.autoCompactThreshold - s.totalTokens
22 if (left <= 0) return 'over'
23 if (s.maxTokens > 0 && left <= s.maxTokens * THRESHOLDS.nearCompactShare) return 'danger'
24 if (s.maxTokens > 0 && left <= s.maxTokens * WARN_COMPACT_SHARE) return 'warn'
25 return 'ok'
26 }
27 if (s.percentage >= THRESHOLDS.fallbackPercent) return 'danger'
28 if (s.percentage >= WARN_FALLBACK_PERCENT) return 'warn'
29 return 'ok'
30}
31
32const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))
33const cellOf = (amount: number, maxTokens: number, width: number) => (maxTokens > 0 ? Math.round((amount / maxTokens) * width) : 0)
34
35function toRuns(kinds: readonly BarKind[]): BarRun[] {
36 return kinds.reduce<BarRun[]>((runs, kind) => {
37 const last = runs[runs.length - 1]
38 return last?.kind === kind ? [...runs.slice(0, -1), { kind, count: last.count + 1 }] : [...runs, { kind, count: 1 }]
39 }, [])
40}
41
42/** 幅 `width` のバー: 使用 → 余裕 → 圧縮点(常に 1 桁)→ バッファ。超過時は使用が圧縮点を越える。 */
43export function buildBarRuns(s: Snapshot, width: number): BarRun[] {
44 const w = Math.max(1, Math.floor(width))
45 const used = clamp(cellOf(s.totalTokens, s.maxTokens, w), 0, w)
46 if (!hasCompactPoint(s) || w < 2) {
47 return toRuns(Array.from({ length: w }, (_, i): BarKind => (i < used ? 'used' : 'free')))
48 }
49 const compactAt = clamp(cellOf(s.autoCompactThreshold, s.maxTokens, w), 1, w - 1)
50 return toRuns(
51 Array.from({ length: w }, (_, i): BarKind => {
52 if (i === compactAt) return 'compact'
53 if (i < used) return 'used'
54 return i < compactAt ? 'margin' : 'buffer'
55 }),
56 )
57}
58hooks/pane-text.ts 29 lines1// 純粋関数: 表示幅(東アジア全角を 2 桁)で揃える文字列ヘルパー。`$` に依存しない。
2const WIDE_CHAR = /[ᄀ-ᅟ⺀-가-힣豈-︰--⦆¢-₩]/
3const ELLIPSIS = '…'
4
5const charWidth = (ch: string) => (WIDE_CHAR.test(ch) ? 2 : 1)
6
7export const displayWidth = (text: string) => [...text].reduce((w, ch) => w + charWidth(ch), 0)
8
9/** 幅 `width` に収まるよう末尾を切る。切ったときだけ `…` を付ける。 */
10export function truncateWidth(text: string, width: number): string {
11 if (displayWidth(text) <= width) return text
12 const chars = [...text]
13 let out = ''
14 let used = 0
15 for (const ch of chars) {
16 if (used + charWidth(ch) > width - 1) break
17 out += ch
18 used += charWidth(ch)
19 }
20 return out + ELLIPSIS
21}
22
23export function padEndWidth(text: string, width: number): string {
24 const fitted = truncateWidth(text, width)
25 return fitted + ' '.repeat(Math.max(0, width - displayWidth(fitted)))
26}
27
28export const padStartWidth = (text: string, width: number) => ' '.repeat(Math.max(0, width - displayWidth(text))) + text
29types/index.d.ts 30 lines1// 自己完結の契約(import 不可)。PaneViewSnapshot は hooks/analyze.ts の Snapshot と同形で、
2// 形が変わったら両方を直し、PaneView.v を上げる。
3export type PaneViewSnapshot = {
4 percentage: number
5 totalTokens: number
6 maxTokens: number
7 categories: readonly { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }[]
8 mcpTools: readonly { serverName: string; tokens: number; isLoaded: boolean }[]
9 memoryFiles: readonly { path: string; type: string; tokens: number }[]
10 skills?: { totalSkills: number; includedSkills: number; tokens: number }
11 agents: readonly { tokens: number }[]
12 slashCommands?: { totalCommands: number; includedCommands: number; tokens: number }
13 autoCompactThreshold?: number
14 isAutoCompactEnabled: boolean
15}
16
17/** ペインが読む値。幅に依存しないデータ(Snapshot と測定時刻)だけを持つ。`v` は形の版。 */
18export type PaneView = {
19 v: 1
20 status: 'empty' | 'ok'
21 snapshot?: PaneViewSnapshot
22 measuredAtMs?: number
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'context-doctor': { view: PaneView }
28 }
29}
30