SLOPSHOPPER

context-doctor

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

newpanecommand
v0.3.0no licenseupdated 2026-10-10dtakamiya/claude-code-mods/mods/context-doctor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-doctor
│ ┃ context-doctor ✕ › 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 │ │ › /ctx │ ⎿ context-doctor: まだ内訳がありません(最初の応答後に再実行してください)。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · context-doctor
まだ内訳がありません(最初の応答後に開き直してください)
README

claude-code-mods

Claude Code の Mods(関数フック型プラグイン)を作って配布するリポジトリ。1リポジトリ = 1マーケットプレイス、mods/<name>/ が1 mod。

収録 mod

mod内容コマンド
context-doctorコンテキストの内訳を分析し、課題点と改善点を出す(読み取り専用)/ctx
session-recapツール別の呼び出し回数とエラー報告件数を一覧にする(読み取り専用・tool 名の集計のみ、input は出力しない)/recap
repeat-call-notice1 ターンで同じツールを同じ入力で N 回以上呼んだら、ターン終了時にトーストで知らせる(読み取り専用・tool 名と回数のみ、input は出力しない。N=3 は仮置き)トースト

公開範囲(private リポジトリ)

このリポジトリは現在 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。

Source 11 files
hooks/register.tsx 63 lines
1import { 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}
63
hooks/analyze.ts 210 lines
1// 純粋関数: 内訳 → 所見。`$` に触れない(テストしやすさと読み取り専用の保証のため)。
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}
210
hooks/pane-atom.ts 5 lines
1import type { PaneView } from '../types'
2
3/** ペインの初期値。atom 本体は validate の静的走査の都合で register.tsx に置く。 */
4export const EMPTY_PANE_VIEW: PaneView = { v: 1, status: 'empty' }
5
hooks/pane-model.ts 111 lines
1// 純粋関数: ペインの値 + 所見 + 幅・高さ → 描画用の行モデル。`$` にも 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}
111
hooks/pane-snapshot.ts 54 lines
1// 純粋関数: 内訳 → 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}
54
hooks/pane-view.tsx 84 lines
1// 行モデル → 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}
84
hooks/pane-row.ts 66 lines
1// 行モデルの型。ペインの描画(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}
66
hooks/pane-sections.ts 156 lines
1// 純粋関数: ペインの各セクション(ヘッダ・所見・内訳)の行を組み立てる。`$` に依存しない。
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}
156
hooks/pane-bar.ts 58 lines
1// 純粋関数: 使用率の段階判定と、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}
58
hooks/pane-text.ts 29 lines
1// 純粋関数: 表示幅(東アジア全角を 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
29
types/index.d.ts 30 lines
1// 自己完結の契約(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