In the previous chapter, Frame Renderer, we learned how Ink takes a tree of components and paints them onto an in-memory grid called a Frame.
We now have a snapshot of what we want the terminal to look like. But there is a problem.
If we simply clear the entire terminal screen and print the new frame every time a state changes (like a spinner rotating), the screen will flash white, text will jump, and the user experience will feel "glitchy." This is known as Flickering.
To fix this, Ink uses Output Diffing (implemented in LogUpdate).
Imagine you are painting a portrait.
Output Diffing is the process of comparing the Previous Frame with the Next Frame and generating the minimum list of commands needed to transform one into the other.
Let's look at a simple loading spinner that rotates every 100ms.
Frame A:
โ Loading...
Frame B:
โ Loading...
Notice that the text " Loading..." didn't change. Only the first character changed from โ to โ .
Ink should figure this out and send a command to the terminal saying: "Go to column 0, row 0. Draw 'โ '. Done."
The core logic lives in log-update.ts. Its job is to generate a Diff.
A Diff is a list of instructions. It might look like this logically:
Ink iterates through every cell of the new grid and compares it to the old grid.
// Conceptual Logic
function calculateDiff(prevScreen, nextScreen) {
const patches = [];
// Loop through every row (y) and column (x)
for (let y = 0; y < rows; y++) {
for (let x = 0; x < columns; x++) {
// If the cell is different...
if (prevScreen[y][x] !== nextScreen[y][x]) {
// ...add an instruction to fix it
patches.push({ type: 'cursorMove', x, y });
patches.push({ type: 'write', char: nextScreen[y][x] });
}
}
}
return patches;
}
Explanation: This loop ensures we only touch the parts of the screen that actually changed. If 90% of your UI is static (like a border or a title), Ink never rewrites it after the first render.
Terminals are like typewriters. To write text in the middle of the screen, you have to move the carriage (cursor) there first.
Ink uses ANSI Escape Codes to move the cursor.
\x1b[A: Move Up\x1b[C: Move Right
In log-update.ts, there is a helper to generate these moves efficiently.
// log-update.ts (Simplified)
function moveCursorTo(screen, targetX, targetY) {
const dx = targetX - screen.cursor.x;
const dy = targetY - screen.cursor.y;
// Generate a move instruction
return { type: 'cursorMove', x: dx, y: dy };
}
Why is this hard? Ink has to track where the cursor currently is. If we just wrote a character at (5, 5), the cursor is now at (6, 5). If the next change is at (6, 5), we don't need to move the cursor at all! This optimization saves bytes and makes rendering faster.
Let's trace how the "Spinner" updates.
LogUpdate sees that only index 0 changed.\r) and the new character.process.stdout.log-update.tsThis file is the final output stage of Ink. Let's look at the real implementation of the diffing loop.
diffEach)
Ink uses a helper diffEach to iterate efficiently.
// log-update.ts (Simplified logic inside render)
diffEach(prev.screen, next.screen, (x, y, removed, added) => {
// 1. Move the cursor to the location of the change
moveCursorTo(screen, x, y);
// 2. If a new cell exists (Update/Create)
if (added) {
// Check if color changed and apply codes
const styleStr = stylePool.transition(currentStyleId, added.styleId);
// Write the actual character
writeCellWithStyleStr(screen, added, styleStr);
}
// 3. If a cell was removed (Shrink)
else if (removed) {
// Overwrite with a space
writeCellWithStyleStr(screen, { char: ' ' }, '');
}
});
Explanation:
moveCursorTo: Logic to get the typewriter head to the right spot.stylePool.transition: Calculates if we need to switch from "Red" to "Blue". It returns ANSI codes like \x1b[31m.writeCell...: Pushes the character into the output buffer.fullResetSequence)Sometimes, diffing is too dangerous. If the user resizes the terminal window, the text wraps differently, and our coordinate system (X, Y) becomes invalid.
In this case, Ink creates a "Keyframe" (to use video terminology). It clears the screen and redraws everything.
// log-update.ts (Simplified)
if (next.viewport.width !== prev.viewport.width) {
// If width changed, we can't trust the old coordinates.
// Force a full re-render.
return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool);
}
Note: The function name _CAUSES_FLICKER is a deliberate warning to developers that this path is expensive and visually jarring, so it's used only when absolutely necessary.
Finally, all these small move/write instructions are collected into a Diff array. LogUpdate joins them into a single string.
// log-update.ts (Simplified)
const output = patches.map(p => {
if (p.type === 'cursorMove') return ansi.cursorMove(p.x, p.y);
if (p.type === 'stdout') return p.content;
return '';
}).join('');
process.stdout.write(output);
This single write call ensures the terminal updates in one "tick," making the animation smooth.
Congratulations! You have navigated the entire architecture of Ink.
Let's recap the journey of a single keystroke:
A. Input Processing Pipeline detects it.useInput fires, updating React state (setText("A")).<Box> elements.\x1b[C A to the terminal.
The user sees the letter A appear instantly, unaware of the massive amount of engineering that ensured it appeared in exactly the right box, with the right color, without flickering.
This concludes the Ink Architecture Tutorial. You now possess the knowledge to build, debug, and contribute to complex terminal interfaces. Happy hacking!
Generated by Code IQ