diff --git a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml
new file mode 100644
index 00000000..425f9860
--- /dev/null
+++ b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
new file mode 100644
index 00000000..e706bca8
--- /dev/null
+++ b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Avalonia;
+using Avalonia.Controls;
+using AvaloniaEdit;
+using AvaloniaEdit.TextMate;
+using ClaudeDo.Ui.ViewModels.Modals;
+using TextMateSharp.Grammars;
+
+namespace ClaudeDo.Ui.Views.Controls;
+
+///
+/// Read-only diff renderer. false shows one editor with the unified
+/// stream; true shows the old state on the left and the new state on the right, aligned by
+/// . Syntax highlighting comes from TextMate, keyed off the file's
+/// extension — the same mechanism the 3-pane conflict resolver uses.
+///
+public partial class DiffTextView : UserControl
+{
+ public static readonly StyledProperty FileProperty =
+ AvaloniaProperty.Register(nameof(File));
+
+ public static readonly StyledProperty IsSplitProperty =
+ AvaloniaProperty.Register(nameof(IsSplit));
+
+ public static readonly StyledProperty WrapLinesProperty =
+ AvaloniaProperty.Register(nameof(WrapLines));
+
+ public DiffFileViewModel? File
+ {
+ get => GetValue(FileProperty);
+ set => SetValue(FileProperty, value);
+ }
+
+ public bool IsSplit
+ {
+ get => GetValue(IsSplitProperty);
+ set => SetValue(IsSplitProperty, value);
+ }
+
+ public bool WrapLines
+ {
+ get => GetValue(WrapLinesProperty);
+ set => SetValue(WrapLinesProperty, value);
+ }
+
+ // Grammars and themes are process-wide; loading the registry per control would be wasteful.
+ private static readonly RegistryOptions Registry = new(ThemeName.DarkPlus);
+
+ private TextMate.Installation? _leftTm, _rightTm;
+
+ // Row lookup per editor, indexed by document line number (1-based). Populated on rebuild
+ // and consumed by the margin and background renderers added in later tasks.
+ private RowInfo?[] _leftRows = Array.Empty();
+ private RowInfo?[] _rightRows = Array.Empty();
+
+ private AlignedDiff _aligned = AlignedDiff.Empty;
+
+ public DiffTextView()
+ {
+ InitializeComponent();
+ _leftTm = LeftEditor.InstallTextMate(Registry);
+ _rightTm = RightEditor.InstallTextMate(Registry);
+ Rebuild();
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == FileProperty || change.Property == IsSplitProperty)
+ Rebuild();
+ else if (change.Property == WrapLinesProperty)
+ ApplyWrap();
+ }
+
+ private void Rebuild()
+ {
+ _aligned = DiffAlignment.Build(File?.Lines);
+
+ var split = IsSplit;
+ RightEditor.IsVisible = split;
+ PaneDivider.IsVisible = split;
+ Grid.SetColumnSpan(LeftEditor, split ? 1 : 3);
+
+ if (split)
+ {
+ LeftEditor.Text = _aligned.LeftText;
+ RightEditor.Text = _aligned.RightText;
+ _leftRows = BuildRows(_aligned.SplitRows.Count,
+ i => new RowInfo(_aligned.SplitRows[i].LeftKind, _aligned.SplitRows[i].OldNo, null,
+ _aligned.SplitRows[i].LeftSpans));
+ _rightRows = BuildRows(_aligned.SplitRows.Count,
+ i => new RowInfo(_aligned.SplitRows[i].RightKind, null, _aligned.SplitRows[i].NewNo,
+ _aligned.SplitRows[i].RightSpans));
+ }
+ else
+ {
+ LeftEditor.Text = _aligned.UnifiedText;
+ RightEditor.Text = "";
+ _leftRows = BuildRows(_aligned.UnifiedRows.Count,
+ i => new RowInfo(_aligned.UnifiedRows[i].Kind, _aligned.UnifiedRows[i].OldNo,
+ _aligned.UnifiedRows[i].NewNo, _aligned.UnifiedRows[i].Spans));
+ _rightRows = Array.Empty();
+ }
+
+ ApplyWrap();
+ ApplyGrammar(File?.Path);
+ }
+
+ /// Row index i is document line i + 1, so slot 0 stays null and lookups can pass the
+ /// line number straight through.
+ private static RowInfo?[] BuildRows(int count, Func project)
+ {
+ var rows = new RowInfo?[count + 1];
+ for (var i = 0; i < count; i++) rows[i + 1] = project(i);
+ return rows;
+ }
+
+ private RowInfo? LeftRow(int line) =>
+ line > 0 && line < _leftRows.Length ? _leftRows[line] : null;
+
+ private RowInfo? RightRow(int line) =>
+ line > 0 && line < _rightRows.Length ? _rightRows[line] : null;
+
+ private void ApplyWrap()
+ {
+ LeftEditor.WordWrap = WrapLines;
+ RightEditor.WordWrap = WrapLines;
+ }
+
+ /// Only hunks are in the document, not whole files, so TextMate's line-by-line state can
+ /// be wrong at a fragment boundary (a line inside a block comment may highlight as code).
+ /// Accepted — every fragment-based diff viewer has this.
+ private void ApplyGrammar(string? path)
+ {
+ if (string.IsNullOrEmpty(path)) return;
+ var ext = Path.GetExtension(path);
+ if (string.IsNullOrEmpty(ext)) return;
+ var language = Registry.GetLanguageByExtension(ext);
+ if (language is null) return;
+ var scope = Registry.GetScopeByLanguageId(language.Id);
+ _leftTm?.SetGrammar(scope);
+ _rightTm?.SetGrammar(scope);
+ }
+
+ /// What one document line represents, for the margin and the background renderers.
+ internal sealed record RowInfo(
+ AlignedSide Kind, int? OldNo, int? NewNo, IReadOnlyList Spans);
+}