Skip to content

Commit fa88993

Browse files
committed
feat: tool approval badges (new/changed), annotations, word-level diff
Web UI (ServerDetail.vue): - "new" badge (blue) for pending tools, "changed" badge (yellow) - Word-level diff using LCS algorithm for changed tool descriptions: removed words in red strikethrough, added words in green bold - Tool annotations already shown via AnnotationBadges component macOS (ServerDetailView.swift): - "NEW" badge (blue) and "CHANGED" badge (orange) on ToolRow - Annotation hint badges: read-only, destructive, idempotent, open-world shown below tool description with subtle colored backgrounds Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 96cda77 commit fa88993

2 files changed

Lines changed: 143 additions & 13 deletions

File tree

frontend/src/views/ServerDetail.vue

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,15 @@
285285
</span>
286286
</div>
287287
<p class="text-sm text-base-content/70 mt-1">{{ tool.description }}</p>
288-
<!-- Show diff for changed tools -->
289-
<div v-if="tool.status === 'changed' && tool.previous_description" class="mt-2 text-xs space-y-1">
290-
<div class="bg-error/10 px-2 py-1 rounded font-mono line-through">{{ tool.previous_description }}</div>
291-
<div class="bg-success/10 px-2 py-1 rounded font-mono">{{ tool.current_description || tool.description }}</div>
288+
<!-- Show word-level diff for changed tools -->
289+
<div v-if="tool.status === 'changed' && tool.previous_description" class="mt-2 text-xs">
290+
<div class="bg-base-300/50 px-2 py-1.5 rounded font-mono leading-relaxed">
291+
<template v-for="(part, i) in computeWordDiff(tool.previous_description, tool.current_description || tool.description)" :key="i">
292+
<span v-if="part.type === 'removed'" class="bg-error/20 text-error line-through px-0.5 rounded">{{ part.text }}</span>
293+
<span v-else-if="part.type === 'added'" class="bg-success/20 text-success font-semibold px-0.5 rounded">{{ part.text }}</span>
294+
<span v-else>{{ part.text }}</span>
295+
</template>
296+
</div>
292297
</div>
293298
</div>
294299
<button
@@ -325,7 +330,17 @@
325330
class="card bg-base-100 shadow-md"
326331
>
327332
<div class="card-body">
328-
<h4 class="card-title text-lg">{{ tool.name }}</h4>
333+
<div class="flex items-center gap-2">
334+
<h4 class="card-title text-lg">{{ tool.name }}</h4>
335+
<span
336+
v-if="getToolApprovalStatus(tool.name) === 'pending'"
337+
class="badge badge-info badge-sm"
338+
>new</span>
339+
<span
340+
v-else-if="getToolApprovalStatus(tool.name) === 'changed'"
341+
class="badge badge-warning badge-sm"
342+
>changed</span>
343+
</div>
329344
<p class="text-sm text-base-content/70">
330345
{{ tool.description || 'No description available' }}
331346
</p>
@@ -553,6 +568,72 @@ const filteredTools = computed(() => {
553568
)
554569
})
555570
571+
// Tool approval status lookup for the main tools grid
572+
function getToolApprovalStatus(toolName: string): string | null {
573+
const approval = toolApprovals.value.find(t => t.tool_name === toolName)
574+
if (!approval) return null
575+
return approval.status
576+
}
577+
578+
// Word-level diff for changed tool descriptions
579+
interface DiffPart {
580+
type: 'same' | 'added' | 'removed'
581+
text: string
582+
}
583+
584+
function computeWordDiff(oldText: string, newText: string): DiffPart[] {
585+
const oldWords = oldText.split(/(\s+)/)
586+
const newWords = newText.split(/(\s+)/)
587+
588+
// Longest Common Subsequence to find matching words
589+
const m = oldWords.length
590+
const n = newWords.length
591+
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
592+
593+
for (let i = 1; i <= m; i++) {
594+
for (let j = 1; j <= n; j++) {
595+
if (oldWords[i - 1] === newWords[j - 1]) {
596+
dp[i][j] = dp[i - 1][j - 1] + 1
597+
} else {
598+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
599+
}
600+
}
601+
}
602+
603+
// Backtrack to build diff
604+
const parts: DiffPart[] = []
605+
let i = m, j = n
606+
const stack: DiffPart[] = []
607+
608+
while (i > 0 || j > 0) {
609+
if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) {
610+
stack.push({ type: 'same', text: oldWords[i - 1] })
611+
i--
612+
j--
613+
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
614+
stack.push({ type: 'added', text: newWords[j - 1] })
615+
j--
616+
} else {
617+
stack.push({ type: 'removed', text: oldWords[i - 1] })
618+
i--
619+
}
620+
}
621+
622+
// Reverse since we built from end to start
623+
stack.reverse()
624+
625+
// Merge consecutive parts of the same type
626+
for (const part of stack) {
627+
if (parts.length > 0 && parts[parts.length - 1].type === part.type) {
628+
parts[parts.length - 1].text += part.text
629+
} else {
630+
parts.push({ ...part })
631+
}
632+
}
633+
634+
return parts
635+
}
636+
556637
// Methods
557638
async function loadServerDetails() {
558639
loading.value = true

native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -905,14 +905,23 @@ struct ToolRow: View {
905905

906906
annotationBadgesCollapsed
907907

908-
if let status = tool.approvalStatus, status != "approved" {
909-
Text(status.capitalized)
910-
.font(.scaled(.caption2, scale: fontScale).bold())
908+
if tool.approvalStatus == "pending" {
909+
Text("NEW")
910+
.font(.system(size: 9 * fontScale, weight: .bold))
911911
.foregroundStyle(.white)
912-
.padding(.horizontal, 6)
913-
.padding(.vertical, 2)
914-
.background(status == "changed" ? Color.red : .orange)
915-
.clipShape(Capsule())
912+
.padding(.horizontal, 5)
913+
.padding(.vertical, 1)
914+
.background(.blue)
915+
.cornerRadius(3)
916+
}
917+
if tool.approvalStatus == "changed" {
918+
Text("CHANGED")
919+
.font(.system(size: 9 * fontScale, weight: .bold))
920+
.foregroundStyle(.white)
921+
.padding(.horizontal, 5)
922+
.padding(.vertical, 1)
923+
.background(.orange)
924+
.cornerRadius(3)
916925
}
917926

918927
Spacer()
@@ -931,7 +940,28 @@ struct ToolRow: View {
931940
.lineLimit(1)
932941
.padding(.leading, 26)
933942
.padding(.trailing, 8)
934-
.padding(.bottom, 6)
943+
.padding(.bottom, hasCollapsedAnnotations ? 2 : 6)
944+
}
945+
946+
// Annotation hints below description (collapsed state only)
947+
if !isExpanded, let annotations = tool.annotations, hasAnyAnnotation(annotations) {
948+
HStack(spacing: 4) {
949+
if annotations.readOnlyHint == true {
950+
annotationHintBadge("read-only", color: .secondary)
951+
}
952+
if annotations.destructiveHint == true {
953+
annotationHintBadge("destructive", color: .red)
954+
}
955+
if annotations.idempotentHint == true {
956+
annotationHintBadge("idempotent", color: .secondary)
957+
}
958+
if annotations.openWorldHint == true {
959+
annotationHintBadge("open-world", color: .orange)
960+
}
961+
}
962+
.padding(.leading, 26)
963+
.padding(.trailing, 8)
964+
.padding(.bottom, 6)
935965
}
936966

937967
// Expanded detail section
@@ -1198,6 +1228,25 @@ struct ToolRow: View {
11981228
}
11991229
}
12001230

1231+
// MARK: - Collapsed Annotations Helper
1232+
1233+
private var hasCollapsedAnnotations: Bool {
1234+
guard let annotations = tool.annotations else { return false }
1235+
return hasAnyAnnotation(annotations)
1236+
}
1237+
1238+
/// Compact text-only annotation hint badge for collapsed tool rows.
1239+
@ViewBuilder
1240+
private func annotationHintBadge(_ text: String, color: Color) -> some View {
1241+
Text(text)
1242+
.font(.system(size: 9 * fontScale))
1243+
.foregroundStyle(color)
1244+
.padding(.horizontal, 4)
1245+
.padding(.vertical, 1)
1246+
.background(color.opacity(0.1))
1247+
.cornerRadius(3)
1248+
}
1249+
12011250
// MARK: - Badge
12021251

12031252
@ViewBuilder

0 commit comments

Comments
 (0)