@@ -1158,12 +1158,7 @@ struct ToolRow: View {
11581158 . foregroundStyle ( . secondary)
11591159
11601160 if oldDesc != newDesc {
1161- if !oldDesc. isEmpty {
1162- diffLine ( text: oldDesc, isOld: true )
1163- }
1164- if !newDesc. isEmpty {
1165- diffLine ( text: newDesc, isOld: false )
1166- }
1161+ diffBeforeAfter ( previous: oldDesc, current: newDesc)
11671162 } else {
11681163 Text ( " No description changes " )
11691164 . font ( . scaled( . caption, scale: fontScale) )
@@ -1188,12 +1183,7 @@ struct ToolRow: View {
11881183 . padding ( . top, 4 )
11891184
11901185 if oldSchema != newSchema {
1191- if !oldSchema. isEmpty {
1192- diffLine ( text: oldSchema, isOld: true )
1193- }
1194- if !newSchema. isEmpty {
1195- diffLine ( text: newSchema, isOld: false )
1196- }
1186+ diffBeforeAfter ( previous: oldSchema, current: newSchema)
11971187 } else {
11981188 Text ( " No schema changes " )
11991189 . font ( . scaled( . caption, scale: fontScale) )
@@ -1219,20 +1209,78 @@ struct ToolRow: View {
12191209 }
12201210
12211211 @ViewBuilder
1222- private func diffLine( text: String , isOld: Bool ) -> some View {
1223- HStack ( alignment: . top, spacing: 4 ) {
1224- Image ( systemName: isOld ? " minus.circle.fill " : " plus.circle.fill " )
1225- . font ( . scaled( . caption2, scale: fontScale) )
1226- . foregroundStyle ( isOld ? . red : . green)
1227- Text ( text)
1228- . font ( . scaledMonospaced( . caption, scale: fontScale) )
1229- . foregroundStyle ( isOld ? . secondary : . primary)
1230- . lineLimit ( isOld ? 6 : nil )
1212+ private func diffBeforeAfter( previous: String , current: String ) -> some View {
1213+ let parts = computeWordDiff ( previous, current)
1214+ VStack ( alignment: . leading, spacing: 6 ) {
1215+ diffBox (
1216+ label: " BEFORE (APPROVED) " ,
1217+ attributed: renderDiffSide ( parts, keep: . removed) ,
1218+ accent: . red,
1219+ isEmpty: previous. isEmpty
1220+ )
1221+ diffBox (
1222+ label: " AFTER (CURRENT) " ,
1223+ attributed: renderDiffSide ( parts, keep: . added) ,
1224+ accent: . green,
1225+ isEmpty: current. isEmpty
1226+ )
12311227 }
1232- . padding ( 4 )
1233- . frame ( maxWidth: . infinity, alignment: . leading)
1234- . background ( ( isOld ? Color . red : Color . green) . opacity ( 0.08 ) )
1235- . cornerRadius ( 4 )
1228+ }
1229+
1230+ @ViewBuilder
1231+ private func diffBox( label: String , attributed: AttributedString , accent: Color , isEmpty: Bool ) -> some View {
1232+ VStack ( alignment: . leading, spacing: 3 ) {
1233+ Text ( label)
1234+ . font ( . system( size: 9 * fontScale, weight: . semibold) )
1235+ . tracking ( 0.5 )
1236+ . foregroundStyle ( . secondary)
1237+ if isEmpty {
1238+ Text ( " (empty) " )
1239+ . font ( . scaledMonospaced( . caption, scale: fontScale) )
1240+ . foregroundStyle ( . tertiary)
1241+ . padding ( 6 )
1242+ . frame ( maxWidth: . infinity, alignment: . leading)
1243+ . background ( accent. opacity ( 0.04 ) )
1244+ . overlay (
1245+ RoundedRectangle ( cornerRadius: 4 )
1246+ . strokeBorder ( accent. opacity ( 0.25 ) , lineWidth: 1 )
1247+ )
1248+ . cornerRadius ( 4 )
1249+ } else {
1250+ Text ( attributed)
1251+ . font ( . scaledMonospaced( . caption, scale: fontScale) )
1252+ . textSelection ( . enabled)
1253+ . frame ( maxWidth: . infinity, alignment: . leading)
1254+ . padding ( 6 )
1255+ . background ( accent. opacity ( 0.04 ) )
1256+ . overlay (
1257+ RoundedRectangle ( cornerRadius: 4 )
1258+ . strokeBorder ( accent. opacity ( 0.25 ) , lineWidth: 1 )
1259+ )
1260+ . cornerRadius ( 4 )
1261+ }
1262+ }
1263+ }
1264+
1265+ /// Build AttributedString for one side of the diff. `keep` is the change-type
1266+ /// (removed or added) that belongs in this side; same-type parts render plain
1267+ /// in both sides; the opposite change-type is dropped.
1268+ private func renderDiffSide( _ parts: [ ToolDiffPart ] , keep: ToolDiffPart . Kind ) -> AttributedString {
1269+ var result = AttributedString ( )
1270+ for part in parts {
1271+ switch part. kind {
1272+ case . same:
1273+ result += AttributedString ( part. text)
1274+ case . added, . removed:
1275+ if part. kind != keep { continue }
1276+ var span = AttributedString ( part. text)
1277+ let accent : Color = part. kind == . removed ? . red : . green
1278+ span. backgroundColor = accent. opacity ( 0.25 )
1279+ span. foregroundColor = accent
1280+ result += span
1281+ }
1282+ }
1283+ return result
12361284 }
12371285
12381286 private func loadDiff( ) {
@@ -1314,6 +1362,88 @@ struct ToolRow: View {
13141362 }
13151363}
13161364
1365+ // MARK: - Tool Description Word Diff
1366+
1367+ struct ToolDiffPart {
1368+ enum Kind { case same, added, removed }
1369+ let kind : Kind
1370+ let text : String
1371+ }
1372+
1373+ /// Split a string into tokens, preserving runs of whitespace as their own tokens
1374+ /// (matches the web UI's `split(/(\s+)/)` so the LCS behaves identically).
1375+ private func splitPreservingWhitespace( _ s: String ) -> [ String ] {
1376+ var tokens : [ String ] = [ ]
1377+ var buffer = " "
1378+ var bufferIsWhitespace : Bool ? = nil
1379+ for ch in s {
1380+ let chIsWhitespace = ch. isWhitespace
1381+ if bufferIsWhitespace == nil {
1382+ bufferIsWhitespace = chIsWhitespace
1383+ buffer. append ( ch)
1384+ } else if bufferIsWhitespace == chIsWhitespace {
1385+ buffer. append ( ch)
1386+ } else {
1387+ tokens. append ( buffer)
1388+ buffer = String ( ch)
1389+ bufferIsWhitespace = chIsWhitespace
1390+ }
1391+ }
1392+ if !buffer. isEmpty { tokens. append ( buffer) }
1393+ return tokens
1394+ }
1395+
1396+ /// Word-level diff via longest common subsequence. Returns parts in original order.
1397+ func computeWordDiff( _ oldText: String , _ newText: String ) -> [ ToolDiffPart ] {
1398+ let oldTokens = splitPreservingWhitespace ( oldText)
1399+ let newTokens = splitPreservingWhitespace ( newText)
1400+ let m = oldTokens. count
1401+ let n = newTokens. count
1402+ if m == 0 && n == 0 { return [ ] }
1403+ if m == 0 { return [ ToolDiffPart ( kind: . added, text: newText) ] }
1404+ if n == 0 { return [ ToolDiffPart ( kind: . removed, text: oldText) ] }
1405+
1406+ var dp = Array ( repeating: Array ( repeating: 0 , count: n + 1 ) , count: m + 1 )
1407+ for i in 1 ... m {
1408+ for j in 1 ... n {
1409+ if oldTokens [ i - 1 ] == newTokens [ j - 1 ] {
1410+ dp [ i] [ j] = dp [ i - 1 ] [ j - 1 ] + 1
1411+ } else {
1412+ dp [ i] [ j] = max ( dp [ i - 1 ] [ j] , dp [ i] [ j - 1 ] )
1413+ }
1414+ }
1415+ }
1416+
1417+ var parts : [ ToolDiffPart ] = [ ]
1418+ var i = m
1419+ var j = n
1420+ while i > 0 || j > 0 {
1421+ if i > 0 && j > 0 && oldTokens [ i - 1 ] == newTokens [ j - 1 ] {
1422+ parts. append ( ToolDiffPart ( kind: . same, text: oldTokens [ i - 1 ] ) )
1423+ i -= 1
1424+ j -= 1
1425+ } else if j > 0 && ( i == 0 || dp [ i] [ j - 1 ] >= dp [ i - 1 ] [ j] ) {
1426+ parts. append ( ToolDiffPart ( kind: . added, text: newTokens [ j - 1 ] ) )
1427+ j -= 1
1428+ } else {
1429+ parts. append ( ToolDiffPart ( kind: . removed, text: oldTokens [ i - 1 ] ) )
1430+ i -= 1
1431+ }
1432+ }
1433+ parts. reverse ( )
1434+
1435+ // Merge consecutive parts of the same kind to minimize attribute spans.
1436+ var merged : [ ToolDiffPart ] = [ ]
1437+ for part in parts {
1438+ if let last = merged. last, last. kind == part. kind {
1439+ merged [ merged. count - 1 ] = ToolDiffPart ( kind: last. kind, text: last. text + part. text)
1440+ } else {
1441+ merged. append ( part)
1442+ }
1443+ }
1444+ return merged
1445+ }
1446+
13171447// MARK: - Flow Layout (for wrapping annotation badges)
13181448
13191449/// A simple horizontal flow layout that wraps items to the next line.
0 commit comments