Skip to content

Commit 540a89a

Browse files
committed
feat: add Context Health page for system prompt analysis
Analyze plugin/skill/rule composition of Claude Code's context window. Detects duplicate plugins, estimates token usage, and provides optimization suggestions with one-click plugin toggle.
1 parent 083e167 commit 540a89a

File tree

4 files changed

+754
-93
lines changed

4 files changed

+754
-93
lines changed

app/layouts/default.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ const links_main: NavigationMenuItem[] = [{
6363
icon: 'i-lucide-puzzle',
6464
to: '/plugins',
6565
onSelect: () => { open.value = false }
66+
}, {
67+
label: 'Context Health',
68+
icon: 'i-lucide-heart-pulse',
69+
to: '/context-health',
70+
onSelect: () => { open.value = false }
6671
}, {
6772
label: 'Plans',
6873
icon: 'i-lucide-map',

app/pages/context-health.vue

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
<script setup lang="ts">
2+
import { h, resolveComponent } from 'vue'
3+
import type { TableColumn } from '@nuxt/ui'
4+
import type { ContextHealthResponse, ContextSuggestion, PluginHealthItem } from '~/types'
5+
6+
useSeoMeta({ title: 'Context Health' })
7+
8+
const UBadge = resolveComponent('UBadge')
9+
const UButton = resolveComponent('UButton')
10+
11+
const { data, status, refresh } = useFetch<ContextHealthResponse>('/api/context-health')
12+
13+
const is_toggling = ref<string | null>(null)
14+
15+
async function togglePlugin(id_plugin: string, is_enabled: boolean): Promise<void> {
16+
is_toggling.value = id_plugin
17+
try {
18+
await $fetch('/api/plugins/toggle', {
19+
method: 'POST',
20+
body: { id_plugin, is_enabled }
21+
})
22+
await refresh()
23+
}
24+
finally {
25+
is_toggling.value = null
26+
}
27+
}
28+
29+
async function applySuggestion(suggestion: ContextSuggestion): Promise<void> {
30+
if (suggestion.action_type === 'disable_plugin' && suggestion.action_target) {
31+
await togglePlugin(suggestion.action_target, false)
32+
}
33+
}
34+
35+
const list_stats = computed(() => {
36+
if (!data.value) return []
37+
return [
38+
{
39+
key: 'tokens',
40+
label: 'Est. Tokens',
41+
value: formatCompact(data.value.tokens_estimated),
42+
icon: 'i-lucide-binary',
43+
},
44+
{
45+
key: 'plugins',
46+
label: 'Plugins Enabled',
47+
value: String(data.value.count_plugins_enabled),
48+
icon: 'i-lucide-puzzle',
49+
},
50+
{
51+
key: 'skills',
52+
label: 'Skills in Context',
53+
value: String(data.value.count_skills_total),
54+
icon: 'i-lucide-zap',
55+
},
56+
{
57+
key: 'rules',
58+
label: 'Rule Files',
59+
value: String(data.value.count_rules),
60+
icon: 'i-lucide-scale',
61+
},
62+
]
63+
})
64+
65+
const set_duplicate_plugins = computed(() => {
66+
if (!data.value) return new Set<string>()
67+
return new Set(data.value.list_duplicates.flatMap(dup => dup.list_sources))
68+
})
69+
70+
const columns_plugins: TableColumn<PluginHealthItem>[] = [
71+
{
72+
accessorKey: 'name_plugin',
73+
header: 'Plugin',
74+
cell: ({ row }) => {
75+
const plugin = row.original
76+
const is_dup = set_duplicate_plugins.value.has(plugin.id_plugin)
77+
const children = [
78+
h('span', { class: 'font-mono text-sm' }, plugin.name_plugin),
79+
]
80+
if (is_dup) {
81+
children.push(h(UBadge, {
82+
color: 'error',
83+
variant: 'subtle',
84+
size: 'xs',
85+
class: 'ml-2',
86+
}, () => 'Duplicate'))
87+
}
88+
if (plugin.count_skills === 0 && plugin.is_enabled) {
89+
children.push(h(UBadge, {
90+
color: 'neutral',
91+
variant: 'subtle',
92+
size: 'xs',
93+
class: 'ml-2',
94+
}, () => '0 skills'))
95+
}
96+
return h('div', { class: 'flex items-center gap-1 flex-wrap' }, children)
97+
},
98+
},
99+
{
100+
accessorKey: 'source',
101+
header: 'Source',
102+
cell: ({ row }) => h('span', { class: 'text-sm text-dimmed font-mono' }, row.original.source),
103+
},
104+
{
105+
accessorKey: 'count_skills',
106+
header: 'Skills',
107+
cell: ({ row }) => h('span', { class: 'tabular-nums' }, String(row.original.count_skills)),
108+
},
109+
{
110+
accessorKey: 'tokens_estimated',
111+
header: 'Est. Tokens',
112+
cell: ({ row }) => h('span', {
113+
class: 'tabular-nums text-sm',
114+
}, row.original.tokens_estimated > 0 ? formatCompact(row.original.tokens_estimated) : '-'),
115+
},
116+
{
117+
accessorKey: 'is_enabled',
118+
header: 'Enabled',
119+
cell: ({ row }) => {
120+
const plugin = row.original
121+
return h(UButton, {
122+
color: plugin.is_enabled ? 'success' : 'neutral',
123+
variant: 'subtle',
124+
size: 'xs',
125+
loading: is_toggling.value === plugin.id_plugin,
126+
onClick: () => togglePlugin(plugin.id_plugin, !plugin.is_enabled),
127+
}, () => plugin.is_enabled ? 'On' : 'Off')
128+
},
129+
},
130+
]
131+
132+
const tokens_rules = computed(() => {
133+
if (!data.value?.list_rules) return 0
134+
return data.value.list_rules.reduce((sum, r) => sum + r.tokens_estimated, 0)
135+
})
136+
137+
const color_severity: Record<ContextSuggestion['severity'], string> = {
138+
high: 'error',
139+
medium: 'warning',
140+
low: 'info',
141+
}
142+
</script>
143+
144+
<template>
145+
<UDashboardPanel id="context-health">
146+
<template #header>
147+
<UDashboardNavbar title="Context Health">
148+
<template #leading>
149+
<UDashboardSidebarCollapse />
150+
</template>
151+
<template #trailing>
152+
<UButton
153+
icon="i-lucide-refresh-cw"
154+
variant="ghost"
155+
size="sm"
156+
:loading="status === 'pending'"
157+
@click="refresh()"
158+
/>
159+
</template>
160+
</UDashboardNavbar>
161+
</template>
162+
163+
<template #body>
164+
<div class="p-6 space-y-6">
165+
<!-- Summary Cards -->
166+
<div v-if="status === 'pending'" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
167+
<USkeleton v-for="i in 4" :key="i" class="h-24" />
168+
</div>
169+
<div v-else-if="data" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
170+
<StatCard
171+
v-for="item in list_stats"
172+
:key="item.key"
173+
:label="item.label"
174+
:count="item.value"
175+
:icon="item.icon"
176+
/>
177+
</div>
178+
179+
<!-- Suggestions -->
180+
<UCard v-if="data?.list_suggestions?.length">
181+
<template #header>
182+
<h3 class="text-sm font-medium text-dimmed">Optimization Suggestions</h3>
183+
</template>
184+
<div class="space-y-3">
185+
<div
186+
v-for="(suggestion, idx) in data.list_suggestions"
187+
:key="idx"
188+
class="flex items-start gap-3 p-3 rounded-lg bg-elevated/50"
189+
>
190+
<UBadge
191+
:color="color_severity[suggestion.severity] ?? 'neutral'"
192+
variant="subtle"
193+
size="sm"
194+
class="mt-0.5 shrink-0"
195+
>
196+
{{ suggestion.severity }}
197+
</UBadge>
198+
<div class="flex-1 min-w-0">
199+
<p class="text-sm font-medium">{{ suggestion.title }}</p>
200+
<p class="text-sm text-dimmed mt-0.5">{{ suggestion.description }}</p>
201+
</div>
202+
<UButton
203+
v-if="suggestion.action_type === 'disable_plugin' && suggestion.action_target"
204+
color="error"
205+
variant="soft"
206+
size="xs"
207+
:loading="is_toggling === suggestion.action_target"
208+
@click="applySuggestion(suggestion)"
209+
>
210+
Disable
211+
</UButton>
212+
</div>
213+
</div>
214+
</UCard>
215+
216+
<!-- Plugin Table -->
217+
<UCard>
218+
<template #header>
219+
<h3 class="text-sm font-medium text-dimmed">Plugins</h3>
220+
</template>
221+
222+
<div v-if="status === 'pending'">
223+
<USkeleton v-for="i in 6" :key="i" class="h-10 mb-2" />
224+
</div>
225+
<UTable
226+
v-else-if="data?.list_plugins?.length"
227+
:data="data.list_plugins"
228+
:columns="columns_plugins"
229+
/>
230+
<div v-else class="text-sm text-dimmed">
231+
No plugins found.
232+
</div>
233+
</UCard>
234+
235+
<!-- Duplicates -->
236+
<UCard v-if="data?.list_duplicates?.length">
237+
<template #header>
238+
<h3 class="text-sm font-medium text-dimmed">Duplicate Skills</h3>
239+
</template>
240+
<div class="space-y-2">
241+
<div
242+
v-for="dup in data.list_duplicates"
243+
:key="dup.name_skill"
244+
class="flex items-center gap-3 p-2 rounded-lg bg-elevated/50"
245+
>
246+
<UIcon name="i-lucide-copy" class="size-4 text-error shrink-0" />
247+
<span class="font-mono text-sm">{{ dup.name_skill }}</span>
248+
<div class="flex gap-1 flex-wrap">
249+
<UBadge
250+
v-for="src in dup.list_sources"
251+
:key="src"
252+
color="neutral"
253+
variant="subtle"
254+
size="xs"
255+
>
256+
{{ src }}
257+
</UBadge>
258+
</div>
259+
</div>
260+
</div>
261+
</UCard>
262+
263+
<!-- Rules -->
264+
<UCard v-if="data?.list_rules?.length">
265+
<template #header>
266+
<h3 class="text-sm font-medium text-dimmed">
267+
Rule Files ({{ formatCompact(tokens_rules) }} est. tokens)
268+
</h3>
269+
</template>
270+
<div class="space-y-1">
271+
<div
272+
v-for="rule in data.list_rules"
273+
:key="rule.path_relative"
274+
class="flex items-center justify-between py-1.5 px-2 rounded hover:bg-elevated/50"
275+
>
276+
<span class="font-mono text-sm text-dimmed">{{ rule.path_relative }}</span>
277+
<span class="text-xs tabular-nums text-dimmed">~{{ rule.tokens_estimated }} tokens</span>
278+
</div>
279+
</div>
280+
</UCard>
281+
</div>
282+
</template>
283+
</UDashboardPanel>
284+
</template>

0 commit comments

Comments
 (0)