Files
Loki/internal/loki/chat_diff.go
T
MichaelandClaude Opus 5.5 e12f07426f Diff : le compteur de lignes dit enfin la vérité
Repris d'AJEAN 0.16.0 (chat_diff.go et son test, repris tels quels : le
fichier n'avait pas bougé chez nous depuis la 0.13.5).

- Un fichier de 500 lignes écrit par l'agent affichait « +500 » pendant la
  frappe puis retombait à « +120 » : le diff envoyé à l'UI est plafonné à 120
  lignes, et l'UI recomptait sur la version tronquée. lineDiff et addedDiff
  rendent désormais les VRAIS totaux, calculés avant la coupe, et ils
  voyagent dans le journal (added/removed) — donc survivent au refresh.
  Repli sur l'ancien décompte pour les conversations d'avant.
- Une retouche d'une ligne dans un bloc de plus de 400 lignes s'affichait en
  remplacement complet (le LCS n'y était pas tenté). Les lignes communes en
  tête et en queue sont écartées d'abord : on voit le vrai changement, avec
  trois lignes de contexte, et il n'est plus repoussé hors de la fenêtre.
- Un contenu terminé par un saut de ligne ne compte plus une ligne de trop,
  côté serveur comme dans la bulle en cours de frappe (bodyLineCount).

Le vérificateur du mode Code (code_verify.go) journalise les mêmes champs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:30 +02:00

181 lines
5.1 KiB
Go

package loki
// chat_diff.go — petit diff ligne à ligne pour l'interface : quand l'IA modifie
// un fichier (outil `edit`) ou une page de mémoire (`mem_edit`, `mem_add`), on
// envoie à l'UI le détail des lignes ajoutées et retirées, qu'elle affiche en
// vert (+) et rouge (-). Aucune dépendance : LCS classique sur les lignes, avec
// des garde-fous pour ne jamais transformer un gros remplacement en pavé.
//
// Les lignes envoyées sont bornées (diffMaxShown), mais les compteurs +N / -N
// sont calculés AVANT la coupe et renvoyés à part : l'UI les comptait dans le
// diff tronqué, d'où un « +500 » pendant l'écriture qui retombait à « +120 ».
import "strings"
const (
diffMaxLines = 400 // au-delà (partie modifiée), on compare sans détail (LCS trop lente)
diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
diffContext = 3 // lignes identiques gardées autour d'un changement
diffOmitPrefix = "…"
)
// DiffLine est une ligne de diff : Op vaut " " (contexte), "-" ou "+".
type DiffLine struct {
Op string `json:"op"`
Text string `json:"text"`
}
// lineDiff compare deux blocs de texte ligne à ligne et renvoie les lignes à
// afficher (bornées) avec le nombre RÉEL de lignes ajoutées et retirées.
//
// Les lignes communes en tête et en queue sont écartées avant la LCS : un
// changement d'une ligne dans un gros bloc reste un +1 -1 lisible (au lieu d'un
// « tout retiré, tout ajouté ») et le changement n'est pas repoussé hors des
// lignes affichées par des centaines de lignes de contexte.
func lineDiff(oldText, newText string) (lines []DiffLine, added, removed int) {
a := splitLines(oldText)
b := splitLines(newText)
pre := 0
for pre < len(a) && pre < len(b) && a[pre] == b[pre] {
pre++
}
suf := 0
for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] {
suf++
}
ma, mb := a[pre:len(a)-suf], b[pre:len(b)-suf]
var mid []DiffLine
if len(ma) > diffMaxLines || len(mb) > diffMaxLines {
// Partie modifiée énorme : pas de LCS (coût quadratique), l'ancien en
// retrait puis le nouveau en ajout.
mid = make([]DiffLine, 0, len(ma)+len(mb))
for _, l := range ma {
mid = append(mid, DiffLine{Op: "-", Text: l})
}
for _, l := range mb {
mid = append(mid, DiffLine{Op: "+", Text: l})
}
} else {
mid = lcsDiff(ma, mb)
}
for _, d := range mid {
switch d.Op {
case "+":
added++
case "-":
removed++
}
}
// Contexte autour du changement : quelques lignes, le reste résumé.
var out []DiffLine
if pre > diffContext {
out = append(out, DiffLine{Op: " ", Text: omitted(pre - diffContext)})
}
for _, l := range a[max(0, pre-diffContext):pre] {
out = append(out, DiffLine{Op: " ", Text: l})
}
out = append(out, mid...)
for _, l := range a[len(a)-suf : len(a)-suf+min(suf, diffContext)] {
out = append(out, DiffLine{Op: " ", Text: l})
}
if suf > diffContext {
out = append(out, DiffLine{Op: " ", Text: omitted(suf - diffContext)})
}
return capLines(out), added, removed
}
// lcsDiff : diff exact par plus longue sous-séquence commune.
func lcsDiff(a, b []string) []DiffLine {
n, m := len(a), len(b)
lcs := make([][]int, n+1)
for i := range lcs {
lcs[i] = make([]int, m+1)
}
for i := n - 1; i >= 0; i-- {
for j := m - 1; j >= 0; j-- {
if a[i] == b[j] {
lcs[i][j] = lcs[i+1][j+1] + 1
} else if lcs[i+1][j] >= lcs[i][j+1] {
lcs[i][j] = lcs[i+1][j]
} else {
lcs[i][j] = lcs[i][j+1]
}
}
}
var out []DiffLine
i, j := 0, 0
for i < n && j < m {
switch {
case a[i] == b[j]:
out = append(out, DiffLine{Op: " ", Text: a[i]})
i++
j++
case lcs[i+1][j] >= lcs[i][j+1]:
out = append(out, DiffLine{Op: "-", Text: a[i]})
i++
default:
out = append(out, DiffLine{Op: "+", Text: b[j]})
j++
}
}
for ; i < n; i++ {
out = append(out, DiffLine{Op: "-", Text: a[i]})
}
for ; j < m; j++ {
out = append(out, DiffLine{Op: "+", Text: b[j]})
}
return out
}
// addedDiff présente un contenu entièrement nouveau (création d'une page ou
// d'un fichier) : toutes ses lignes en ajout.
func addedDiff(text string) (lines []DiffLine, added int) {
ls := splitLines(text)
out := make([]DiffLine, 0, len(ls))
for _, l := range ls {
out = append(out, DiffLine{Op: "+", Text: l})
}
return capLines(out), len(ls)
}
func splitLines(s string) []string {
s = strings.ReplaceAll(s, "\r\n", "\n")
// Un saut de ligne final termine la dernière ligne, il n'en ouvre pas une vide.
s = strings.TrimSuffix(s, "\n")
if s == "" {
return nil
}
return strings.Split(s, "\n")
}
func omitted(n int) string {
if n == 1 {
return diffOmitPrefix + "(1 ligne identique)"
}
return diffOmitPrefix + "(" + itoa(n) + " lignes identiques)"
}
// capLines borne la taille envoyée à l'UI et signale ce qui a été coupé.
func capLines(d []DiffLine) []DiffLine {
if len(d) <= diffMaxShown {
return d
}
cut := len(d) - diffMaxShown
out := append([]DiffLine{}, d[:diffMaxShown]...)
return append(out, DiffLine{Op: " ", Text: diffOmitPrefix + "(" + itoa(cut) + " lignes de plus)"})
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}