Dirty Tracking Strategies
Terminal emulators need to track which parts of the screen have changed to optimize rendering. This page explains the two main approaches and why MonoTerm chose line-based tracking.What is Dirty Tracking?
Dirty tracking answers: “What changed since last render?”Copy
╔═══════════════════════════════════════════════════════════════════════╗
║ ║
║ DIRTY TRACKING PURPOSE ║
║ ║
║ ║
║ Without Dirty Tracking: ║
║ ║
║ Every frame: ║
║ ┌────────────────────────────────────────────┐ ║
║ │ Re-render ENTIRE screen │ ║
║ │ Even if only 1 character changed │ ║
║ │ 120 cols x 40 rows = 4,800 cells │ ║
║ └────────────────────────────────────────────┘ ║
║ ║
║ = Wasteful, slow ║
║ ║
║ ║
║ With Dirty Tracking: ║
║ ║
║ Every frame: ║
║ ┌────────────────────────────────────────────┐ ║
║ │ Check what changed │ ║
║ │ Re-render ONLY changed parts │ ║
║ │ Maybe just 1 line or a few cells │ ║
║ └────────────────────────────────────────────┘ ║
║ ║
║ = Efficient, fast ║
║ ║
╚═══════════════════════════════════════════════════════════════════════╝
Two Approaches: Epoch vs Content Comparison
Epoch (Sequence Number)
Epoch is a monotonically increasing counter that increments whenever a write happens.Copy
╔═══════════════════════════════════════════════════════════════════════╗
║ ║
║ EPOCH / SEQUENCE NUMBER ║
║ ║
║ ║
║ Concept: ║
║ ║
║ State changes ──▶ epoch++ ║
║ ║
║ ┌───────────────────┐ ┌───────────────────┐ ║
║ │ State │ │ State │ ║
║ │ content: "hello" │ ──▶ │ content: "hello" │ ║
║ │ epoch: 5 │ │ epoch: 6 │ ║
║ └───────────────────┘ └───────────────────┘ ║
║ (before write) (after write, same content) ║
║ ║
║ ║
║ Renderer Logic: ║
║ ║
║ if (last_rendered_epoch < current_epoch) { ║
║ // Something changed, need to render ║
║ render(); ║
║ last_rendered_epoch = current_epoch; ║
║ } ║
║ ║
╚═══════════════════════════════════════════════════════════════════════╝
Content Comparison
Content comparison examines the actual data. Same content = no change detected.Copy
╔═══════════════════════════════════════════════════════════════════════╗
║ ║
║ CONTENT-BASED TRACKING ║
║ ║
║ ║
║ Concept: ║
║ ║
║ State changes ──▶ compare(old, new) ║
║ ║
║ ┌───────────────────┐ ┌───────────────────┐ ║
║ │ State │ │ State │ ║
║ │ content: "hello" │ ──▶ │ content: "hello" │ ║
║ └───────────────────┘ └───────────────────┘ ║
║ (before write) (after write, same content) ║
║ ║
║ Content unchanged! (same content) ║
║ ║
║ ║
║ Renderer Logic: ║
║ ║
║ if (old_content != new_content) { ║
║ // Content actually changed, need to render ║
║ render(); ║
║ } ║
║ ║
╚═══════════════════════════════════════════════════════════════════════╝
The Critical Difference
The key difference: what happens when the SAME content is written again.Copy
╔═══════════════════════════════════════════════════════════════════════╗
║ EPOCH: SAME CONTENT WRITTEN AGAIN ║
╠═══════════════════════════════════════════════════════════════════════╣
║ ║
║ Scenario: Line 2 written with same content ║
║ ║
║ ║
║ Line 0: "hello" ║
║ Line 1: "world" ║
║ Line 2: "foo" <── "foo" written again (same content!) ║
║ Line 3: "test" ║
║ Line 4: "done" ║
║ ║
║ ║
║ Epoch Behavior: ║
║ ║
║ Line 2 epoch: 5 ──▶ 6 (incremented because write happened) ║
║ ║
║ Renderer sees: "epoch changed (5->6), must re-render" ║
║ ║
║ Result: RE-RENDER (even though content is identical) ║
║ ║
║ X Wasteful ║
║ ║
╚═══════════════════════════════════════════════════════════════════════╝
╔═══════════════════════════════════════════════════════════════════════╗
║ CONTENT: SAME CONTENT WRITTEN AGAIN ║
╠═══════════════════════════════════════════════════════════════════════╣
║ ║
║ Same Scenario: Line 2 written with same content ║
║ ║
║ ║
║ Line 0: "hello" ║
║ Line 1: "world" ║
║ Line 2: "foo" <── "foo" written ──▶ still "foo" (same!) ║
║ Line 3: "test" ║
║ Line 4: "done" ║
║ ║
║ ║
║ Content Behavior: ║
║ ║
║ Line 2: "foo" ──▶ "foo" (unchanged because content is same) ║
║ ║
║ Renderer sees: "content unchanged, skip" ║
║ ║
║ Result: SKIP (correctly identifies no change) ║
║ ║
║ OK Efficient ║
║ ║
╚═══════════════════════════════════════════════════════════════════════╝
The Fundamental Distinction: Actions vs Content
Copy
╔═══════════════════════════════════════════════════════════════════════╗
║ WHAT EPOCH VS CONTENT TRACKING ACTUALLY TRACKS ║
╠═══════════════════════════════════════════════════════════════════════╣
║ ║
║ EPOCH CONTENT ║
║ ===== ======= ║
║ ║
║ Question asked: Question asked: ║
║ "Did a write happen?" "Is the content different?" ║
║ ║
║ On write(): On write(): ║
║ ──▶ epoch++ ──▶ compare old vs new ║
║ ║
║ Does it look at content? Does it look at content? ║
║ ──▶ NO ──▶ YES ║
║ ║
╚═══════════════════════════════════════════════════════════════════════╝
Why This Matters for AI CLI
AI CLI applications have a specific output pattern where content-based tracking excels.Copy
╔═══════════════════════════════════════════════════════════════════════╗
║ AI CLI OUTPUT PATTERN ║
╠═══════════════════════════════════════════════════════════════════════╣
║ ║
║ Typical AI CLI (Claude Code, Aider, etc.): ║
║ ║
║ ┌─────────────────────────────────────────────┐ ║
║ │ * Thinking... │ <── Rewritten often║
║ │ * Thinking... │ <── Same content! ║
║ │ * Thinking... │ <── Same content! ║
║ │ │ ║
║ │ Here is my response... │ <── Actual new ║
║ │ The answer to your question is... │ <── Actual new ║
║ └─────────────────────────────────────────────┘ ║
║ ║
║ ║
║ "Thinking..." line is rewritten every ~100ms ║
║ But content is IDENTICAL each time ║
║ ║
║ ║
║ ┌─────────────────────────────────────────────────────────────┐ ║
║ │ Tracking Method │ "Thinking..." rewrite │ Efficiency │ ║
║ ├────────────────────┼─────────────────────────┼──────────────┤ ║
║ │ Epoch │ Re-render every time │ X Wasteful │ ║
║ │ Content │ Skip (same content) │ OK Efficient│ ║
║ └─────────────────────────────────────────────────────────────┘ ║
║ ║
╚═══════════════════════════════════════════════════════════════════════╝
Partial Update: Natural with Content Comparison
Content comparison makes partial updates natural.Copy
╔═══════════════════════════════════════════════════════════════════════╗
║ EPOCH: PARTIAL UPDATE IS DIFFICULT ║
╠═══════════════════════════════════════════════════════════════════════╣
║ ║
║ Scenario: 5 lines, only Line 2 actually changed ║
║ ║
║ ║
║ Line 0: "hello" ║
║ Line 1: "world" ║
║ Line 2: "foo" ──▶ "bar" (content changed) ║
║ Line 3: "test" ║
║ Line 4: "done" ║
║ ║
║ ║
║ Single Epoch (whole state): ║
║ ║
║ [Entire State] ─── epoch: 5 ──▶ 6 ║
║ │ ║
║ └── "Something changed" ║
║ ║
║ Renderer knows: "epoch changed" ║
║ Renderer does NOT know: "WHAT changed" ║
║ Renderer does NOT know: "WHERE changed" ║
║ ║
║ Options: ║
║ A) Re-render entire screen (wasteful) ║
║ B) Implement separate change tracking (complex) ║
║ ║
║ X Partial update requires extra work ║
║ ║
╚═══════════════════════════════════════════════════════════════════════╝
╔═══════════════════════════════════════════════════════════════════════╗
║ CONTENT: PARTIAL UPDATE IS NATURAL ║
╠═══════════════════════════════════════════════════════════════════════╣
║ ║
║ Same Scenario: 5 lines, only Line 2 changed ║
║ ║
║ ║
║ Per-Line Comparison: ║
║ ║
║ Line 0: "hello" ──▶ "hello" (same OK) ║
║ Line 1: "world" ──▶ "world" (same OK) ║
║ Line 2: "foo" ──▶ "bar" (DIFFERENT X) <── Found it! ║
║ Line 3: "test" ──▶ "test" (same OK) ║
║ Line 4: "done" ──▶ "done" (same OK) ║
║ ║
║ ║
║ Renderer immediately knows: ║
║ - WHAT changed: Line 2 ║
║ - WHERE: Index 2 ║
║ ║
║ ──▶ DiffHint::Partial { dirty_rows: [2] } ║
║ ──▶ Only Line 2 sent over IPC ║
║ ──▶ Only Line 2 re-rendered ║
║ ║
║ OK Partial update is built-in ║
║ ║
╚═══════════════════════════════════════════════════════════════════════╝
Why Line-Level is the Sweet Spot
Terminal dirty tracking could be done at different granularities.Copy
╔═══════════════════════════════════════════════════════════════════════╗
║ GRANULARITY OPTIONS ║
╠═══════════════════════════════════════════════════════════════════════╣
║ ║
║ Screen Size: 120 cols x 40 rows = 4,800 cells ║
║ ║
║ ║
║ ┌────────────────────────────────────────────────────────────────┐ ║
║ │ │ ║
║ │ CELL-LEVEL TRACKING │ ║
║ │ │ ║
║ │ Track each cell individually: │ ║
║ │ │ ║
║ │ 4,800 comparisons to make │ ║
║ │ │ ║
║ │ Problem 1: Tracking overhead is massive │ ║
║ │ Problem 2: GPU doesn't benefit (see below) │ ║
║ │ │ ║
║ │ X Too granular │ ║
║ │ │ ║
║ └────────────────────────────────────────────────────────────────┘ ║
║ ║
║ ║
║ ┌────────────────────────────────────────────────────────────────┐ ║
║ │ │ ║
║ │ SCREEN-LEVEL TRACKING │ ║
║ │ │ ║
║ │ Track entire screen as one unit: │ ║
║ │ │ ║
║ │ One comparison for whole screen │ ║
║ │ │ ║
║ │ Problem: Any change ──▶ re-render everything │ ║
║ │ Typing one character ──▶ 4,800 cells re-rendered │ ║
║ │ │ ║
║ │ X Too coarse │ ║
║ │ │ ║
║ └────────────────────────────────────────────────────────────────┘ ║
║ ║
║ ║
║ ┌────────────────────────────────────────────────────────────────┐ ║
║ │ │ ║
║ │ LINE-LEVEL TRACKING <── SWEET SPOT │ ║
║ │ │ ║
║ │ Track each line: │ ║
║ │ │ ║
║ │ 40 lines = 40 comparisons to manage │ ║
║ │ Reasonable overhead │ ║
║ │ │ ║
║ │ Typing on line 5 ──▶ only line 5 re-rendered │ ║
║ │ 120 cells instead of 4,800 │ ║
║ │ │ ║
║ │ OK Optimal balance │ ║
║ │ │ ║
║ └────────────────────────────────────────────────────────────────┘ ║
║ ║
╚═══════════════════════════════════════════════════════════════════════╝
Why Line-Level Matches GPU Rendering
Copy
╔═══════════════════════════════════════════════════════════════════════╗
║ GPU RENDERING AND LINE-LEVEL DIRTY TRACKING ║
╠═══════════════════════════════════════════════════════════════════════╣
║ ║
║ Terminal screen layout: ║
║ ║
║ ┌────────────────────────────────────────────────────┐ ║
║ │ Line 0: $ ls -la │ ║
║ ├────────────────────────────────────────────────────┤ ║
║ │ Line 1: total 48 │ ║
║ ├────────────────────────────────────────────────────┤ ║
║ │ Line 2: drwxr-xr-x 5 user staff 160 Jan 15 10:00 │ ║
║ ├────────────────────────────────────────────────────┤ ║
║ │ Line 3: -rw-r--r-- 1 user staff 1234 Jan 15 10:01│ ║
║ ├────────────────────────────────────────────────────┤ ║
║ │ ... │ ║
║ └────────────────────────────────────────────────────┘ ║
║ ║
║ ║
║ GPU draws in ROWS: ║
║ ║
║ ┌────────────────────────────────────────────────────┐ ║
║ │ [Row 0 texture] [Row 0 texture] [Row 0 texture] │ <─ 1 draw ║
║ ├────────────────────────────────────────────────────┤ ║
║ │ [Row 1 texture] [Row 1 texture] [Row 1 texture] │ <─ 1 draw ║
║ ├────────────────────────────────────────────────────┤ ║
║ │ ... │ ║
║ └────────────────────────────────────────────────────┘ ║
║ ║
║ ║
║ Perfect Alignment: ║
║ ║
║ - Line dirty ──▶ row needs redraw ║
║ - Line clean ──▶ row skip ║
║ - 1:1 mapping between dirty tracking and GPU units ║
║ ║
╚═══════════════════════════════════════════════════════════════════════╝
MonoTerm’s DiffHint System
MonoTerm uses DiffHint to communicate what changed.Copy
╔═══════════════════════════════════════════════════════════════════════╗
║ DIFFHINT: COMMUNICATING WHAT CHANGED ║
╠═══════════════════════════════════════════════════════════════════════╣
║ ║
║ DiffHint enum: ║
║ ║
║ ┌────────────────────────────────────────────────────────────────┐ ║
║ │ │ ║
║ │ Full │ ║
║ │ ──── │ ║
║ │ All lines need to be rendered. │ ║
║ │ Used for: Initial render, resize, scroll │ ║
║ │ │ ║
║ ├────────────────────────────────────────────────────────────────┤ ║
║ │ │ ║
║ │ Partial { dirty_rows: [2, 5, 7] } │ ║
║ │ ────────────────────────────────── │ ║
║ │ Only specified lines changed. │ ║
║ │ Used for: Normal typing, single-line updates │ ║
║ │ │ ║
║ ├────────────────────────────────────────────────────────────────┤ ║
║ │ │ ║
║ │ ScrollOnly { delta: -3 } │ ║
║ │ ───────────────────────── │ ║
║ │ Screen scrolled, no content changed. │ ║
║ │ Used for: Pure scroll events │ ║
║ │ │ ║
║ ├────────────────────────────────────────────────────────────────┤ ║
║ │ │ ║
║ │ None │ ║
║ │ ──── │ ║
║ │ Nothing changed (cursor only maybe). │ ║
║ │ Used for: Cursor blink, no-op updates │ ║
║ │ │ ║
║ └────────────────────────────────────────────────────────────────┘ ║
║ ║
╚═══════════════════════════════════════════════════════════════════════╝
Comparison Summary
Copy
╔═══════════════════════════════════════════════════════════════════════╗
║ TRACKING METHOD COMPARISON ║
╠═══════════════════════════════════════════════════════════════════════╣
║ ║
║ ┌────────────────────────────────────────────────────────────────┐ ║
║ │ Aspect │ Epoch │ Content │ ║
║ ├──────────────────────┼──────────────────┼──────────────────────┤ ║
║ │ Tracks │ "Write event" │ "Content different" │ ║
║ ├──────────────────────┼──────────────────┼──────────────────────┤ ║
║ │ Same content │ Re-render X │ Skip OK │ ║
║ │ rewritten │ │ │ ║
║ ├──────────────────────┼──────────────────┼──────────────────────┤ ║
║ │ Computation cost │ None (++) │ Compare operation │ ║
║ ├──────────────────────┼──────────────────┼──────────────────────┤ ║
║ │ Partial update │ Extra tracking │ Built-in │ ║
║ │ │ needed │ │ ║
║ └────────────────────────────────────────────────────────────────┘ ║
║ ║
║ ║
║ When to use Epoch: ║
║ - Same-process multi-renderer coordination ║
║ - Version tracking (e.g., resize synchronization) ║
║ - When write always means content changed ║
║ ║
║ When to use Content Comparison: ║
║ - Cross-boundary (IPC) dirty tracking ║
║ - When same content may be rewritten ║
║ - When partial updates are desired ║
║ - AI CLI workloads (frequent rewrites) ║
║ ║
╚═══════════════════════════════════════════════════════════════════════╝
Summary
Copy
╔═══════════════════════════════════════════════════════════════════════╗
║ DIRTY TRACKING SUMMARY ║
╠═══════════════════════════════════════════════════════════════════════╣
║ ║
║ ┌───────────────────────────────────────────────────────────────┐ ║
║ │ │ ║
║ │ MonoTerm uses LINE-LEVEL CONTENT COMPARISON because: │ ║
║ │ │ ║
║ │ 1. AI CLIs frequently rewrite same content │ ║
║ │ ──▶ Content comparison skips redundant work │ ║
║ │ │ ║
║ │ 2. GPU renders in rows │ ║
║ │ ──▶ Line-level matches GPU rendering units │ ║
║ │ │ ║
║ │ 3. Partial updates reduce IPC traffic │ ║
║ │ ──▶ Only send changed lines │ ║
║ │ │ ║
║ │ 4. 40 lines is manageable │ ║
║ │ ──▶ Not too granular, not too coarse │ ║
║ │ │ ║
║ └───────────────────────────────────────────────────────────────┘ ║
║ ║
║ ║
║ Result: ║
║ ║
║ ┌───────────────────────────────────────────────────────────────┐ ║
║ │ │ ║
║ │ Typical typing: │ ║
║ │ Only 1 line dirty │ ║
║ │ ──▶ 1 line sent over IPC │ ║
║ │ ──▶ 1 line re-rendered by GPU │ ║
║ │ │ ║
║ │ AI "Thinking..." rewrite: │ ║
║ │ Same content detected │ ║
║ │ ──▶ 0 lines sent over IPC │ ║
║ │ ──▶ 0 lines re-rendered by GPU │ ║
║ │ │ ║
║ └───────────────────────────────────────────────────────────────┘ ║
║ ║
╚═══════════════════════════════════════════════════════════════════════╝