Project 4: Minimal Terminal Emulator (100 Lines)
Build a tiny but correct terminal emulator that attaches to a PTY, parses basic CSI sequences, and maintains a small screen grid.
Quick Reference
| Attribute | Value |
|---|---|
| Difficulty | Level 2: Intermediate |
| Time Estimate | 1-2 weeks |
| Main Programming Language | C (Alternatives: Rust, Zig) |
| Alternative Programming Languages | Rust, Zig |
| Coolness Level | Level 3: Genuinely Clever |
| Business Potential | Level 1: Resume Gold |
| Prerequisites | PTY basics, basic parser, arrays |
| Key Topics | screen model, cursor rules, basic CSI |
1. Learning Objectives
By completing this project, you will:
- Attach to a PTY and stream bytes into a minimal parser.
- Maintain a screen grid with cursor movement and line wrapping.
- Implement a minimal subset of CSI commands correctly.
- Render the screen grid to stdout or a simple UI reliably.
- Define invariants that keep the screen model consistent.
2. All Theory Needed (Per-Concept Breakdown)
Concept 1: Screen Model, Cursor, and Line Wrapping
Fundamentals
A terminal screen is a 2D grid of cells, each holding a character and attributes. The cursor identifies the active cell where the next character is written. When the cursor reaches the end of a line, terminals wrap to the next line; when they reach the bottom, they scroll. These behaviors are not cosmetic; they define how interactive programs display output. A minimal terminal must implement these rules to feel correct.
Deep Dive into the Concept
The simplest screen model is an array of rows, each row an array of cells. Each cell stores a codepoint (or byte for ASCII) and optional attributes such as color or bold. The cursor has a row and column position, typically 0-indexed internally. When you print a character, it goes into the current cell and the cursor advances. If the cursor moves past the last column, you either wrap to the next line or stay depending on wrap mode. Most terminals enable wrap mode by default, so writing at the last column moves the cursor to column 0 of the next line.
Scrolling is a key invariant. When the cursor moves beyond the last row, the screen must scroll: the top line disappears and a new blank line appears at the bottom. This behavior must happen in a deterministic order; otherwise, full-screen programs will display corrupt output. Even a minimal terminal must handle scrollback or at least screen scrolling to preserve expected behavior. The simplest approach is to drop the top line when you scroll and insert a blank line at the bottom.
Cursor movement commands (CSI H, CSI A/B/C/D) move the cursor relative or absolute. If you ignore bounds, you will write outside the screen grid, so the model must clamp values to valid ranges. Additionally, many commands are 1-based (CSI row;col H uses 1-based indices). A minimal terminal must translate these into 0-based internal indices correctly. These “off-by-one” bugs are a classic source of terminal emulator errors.
Finally, you must define rendering rules. For a minimal terminal, you can render the grid as plain text by printing rows with newlines. But you must take care to keep rendering consistent with the screen model. This means not letting output from the child process leak directly to stdout; it must go through the screen model. The model is the single source of truth for what should appear on screen.
How this fits on projects
This concept is central to this project and reused in P06 and P13.
Definitions & Key Terms
- Cell -> a single grid slot containing a character and attributes.
- Cursor -> current position where the next character is written.
- Wrap mode -> whether cursor wraps to next line at end.
- Scroll -> shift rows upward when cursor passes bottom.
Mental Model Diagram (ASCII)
+--------------------+
| hello world |
| next line | cursor -> (1,5)
| |
+--------------------+
How It Works (Step-by-Step)
- Initialize grid with rows and cols.
- On printable byte, write to cell and advance cursor.
- If column exceeds width, wrap to next line.
- If row exceeds height, scroll and clamp cursor.
Invariants:
- Cursor always within [0, rows-1] x [0, cols-1].
- Grid rows always same length.
Failure modes:
- Off-by-one errors on cursor positioning.
- Forgetting to scroll when row exceeds height.
Minimal Concrete Example
void put_char(char c) {
grid[cursor_r][cursor_c] = c;
cursor_c++;
if (cursor_c >= cols) { cursor_c = 0; cursor_r++; }
if (cursor_r >= rows) { scroll_up(); cursor_r = rows - 1; }
}
Common Misconceptions
- “Printing a newline just moves the cursor.” -> It also resets column to 0.
- “Wrapping is optional.” -> Most terminals wrap by default.
- “Screen is just text.” -> It is a stateful grid with attributes.
Check-Your-Understanding Questions
- Why are cursor coordinates 1-based in many CSI sequences?
- What happens when output reaches the bottom row?
- How do you prevent writes outside the grid?
Check-Your-Understanding Answers
- Historical terminal conventions; you must translate to 0-based.
- The screen scrolls and a new line appears at the bottom.
- Clamp cursor and scroll when necessary.
Real-World Applications
- Terminal emulators and TUIs
- Screen buffers in multiplexers
Where You’ll Apply It
- This project: Section 3.2 (screen updates), Section 4.3 (data structures)
- Also used in: P06-scrollback-buffer-implementation, P13-full-terminal-emulator
References
- DEC VT100 user guide
- xterm documentation on cursor movement
Key Insight
The screen model is a state machine; it is the authoritative source of truth for display.
Summary
A minimal terminal lives or dies by correct cursor and scroll semantics.
Homework/Exercises to Practice the Concept
- Implement a 10x4 grid and manually trace output.
- Test what happens when you write 41 characters to a 40-column screen.
- Implement and test
\nand\rhandling.
Solutions to the Homework/Exercises
- Draw the grid and advance the cursor per character.
- It should wrap to the next line after 40 columns.
\nmoves row +1;\rsets column to 0.
Concept 2: Basic Control Sequences and Action Mapping
Fundamentals
Terminal control sequences are instructions embedded in the output stream. A minimal terminal should support the core subset needed by typical programs: clear screen (CSI J), cursor positioning (CSI H), cursor movement (CSI A/B/C/D), and basic attributes like reset (CSI 0 m). These sequences are parsed into actions that mutate the screen model.
Deep Dive into the Concept
A minimal parser only needs a small set of CSI sequences, but it must handle defaults correctly. For example, CSI H defaults to row 1, column 1, while CSI A moves the cursor up by 1 if no parameter is provided. If you mistakenly treat missing parameters as 0, cursor movement will not occur. Similarly, CSI J with parameter 2 clears the entire screen, while parameter 0 clears from cursor to end. You can choose to support only one variant initially, but document it clearly and implement it deterministically.
Mapping parsed actions to screen mutations is the key step. A CSI H action sets the cursor to a specific row and column. CSI A/B/C/D adjust cursor position relative to current position. CSI J clears cells by resetting them to space and clearing attributes. CSI K clears part of the current line. Even a minimal emulator should handle at least one clear command to allow clear to work.
You must also handle control characters like carriage return (CR, 0x0D), line feed (LF, 0x0A), and backspace (BS, 0x08). These are not CSI sequences but still affect cursor position. CR sets column to 0; LF advances row; BS moves cursor left. Ignoring these will break even simple programs like printf.
Finally, design your action interface. The parser should emit an action enum with parameters, and the screen model should apply those actions. This separation makes it easier to test parsing and screen updates independently. It also mirrors how real terminals are structured.
How this fits on projects
This concept is essential here and used again in P05 and P07.
Definitions & Key Terms
- CSI -> Control Sequence Introducer (ESC [).
- Action -> parsed instruction applied to the screen.
- CR/LF/BS -> control characters that move cursor.
Mental Model Diagram (ASCII)
Bytes -> Parser -> Action (e.g., CURSOR_UP 1) -> Screen Model
How It Works (Step-by-Step)
- Parse CSI sequence into action.
- Apply action to cursor or grid.
- Clamp cursor to bounds.
- Redraw screen.
Invariants:
- Missing params use correct defaults.
- Cursor never escapes grid.
Failure modes:
- Misinterpreting defaults leads to broken cursor moves.
- Ignoring control characters breaks formatting.
Minimal Concrete Example
if (act.type == ACT_CSI && act.final == 'H') {
cursor_r = max(0, act.params[0]-1);
cursor_c = max(0, act.params[1]-1);
}
Common Misconceptions
- “CSI H is 0-based.” -> It is 1-based.
- “Only CSI matters.” -> CR/LF/BS are essential.
- “Missing params are errors.” -> They are defaults.
Check-Your-Understanding Questions
- What does
ESC [ Hdo with no parameters? - How does
\rdiffer from\n? - Why separate parser and screen logic?
Check-Your-Understanding Answers
- Moves cursor to row 1, column 1.
\rresets column;\nadvances row.- It makes testing and maintenance easier.
Real-World Applications
clearcommandtput cupand cursor movement
Where You’ll Apply It
- This project: Section 3.2 (functional requirements), Section 4.4 (algorithm)
- Also used in: P05-ansi-color-renderer, P07-vt100-state-machine
References
- ECMA-48 CSI definitions
- xterm escape sequence reference
Key Insight
Correct defaults and cursor semantics are more important than supporting many sequences.
Summary
A minimal control-sequence set can still feel like a real terminal if defaults are correct.
Homework/Exercises to Practice the Concept
- Implement CSI H and test with
tput cup 5 10. - Add CSI J (clear screen) and test with
clear. - Verify CR and LF behavior with
printf.
Solutions to the Homework/Exercises
- Parse parameters and set cursor to row-1, col-1.
- Implement clear by filling grid with spaces.
- Print “a\rb” and confirm “b” overwrites column 0.
Concept 3: PTY I/O Loop and Rendering Pipeline
Fundamentals
A terminal emulator must read bytes from the PTY, parse them, update the screen model, and render. The core loop multiplexes PTY output and user input. For a minimal emulator, rendering can be done by clearing the screen and printing the entire grid each frame. The key is to keep the loop responsive and deterministic.
Deep Dive into the Concept
The simplest event loop uses select() or poll() to wait on stdin and the PTY master. When input arrives from the user, you write it to the PTY master. When output arrives from the child process, you feed it into the parser and update the screen model. The renderer then prints the screen state. For a minimal emulator, you can render by moving the cursor to the top-left and printing all rows, which avoids partial updates. This is inefficient but easy to reason about and correct for small grids.
Avoid mixing direct PTY output with screen output. The emulator should be the only component writing to the display, otherwise output will interleave and corrupt the model. That means you should set the real terminal into raw or alternate screen mode to avoid user input echoing; however, for a minimal emulator you can also run in a child window or use a simple curses wrapper to isolate output.
Because the emulator itself is a program running in a terminal, you must consider recursion: you are reading from one PTY and writing to another (the user’s real terminal). This is why a minimal emulator often turns off canonical mode on stdin to capture user input cleanly. It also means you must restore the real terminal settings on exit to avoid leaving the user’s shell in a broken state.
How this fits on projects
This concept is reused in P08 (multiplexer) and P14 (web terminal).
Definitions & Key Terms
- Event loop -> loop that multiplexes input sources.
- Renderer -> outputs the screen model to the display.
- Alternate screen -> separate buffer used by full-screen apps.
Mental Model Diagram (ASCII)
stdin -> [event loop] -> PTY master -> child app
|
v
parser -> screen -> renderer -> stdout
How It Works (Step-by-Step)
- Set local terminal to raw mode.
- Use
select()on stdin and PTY master. - On PTY output, parse and update screen.
- Render the screen to stdout.
- On stdin input, forward bytes to PTY master.
Invariants:
- The screen model is updated only by parser actions.
- The renderer prints from the model, not raw bytes.
Failure modes:
- Mixing raw output with rendered output.
- Failing to restore terminal state on exit.
Minimal Concrete Example
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(STDIN_FILENO, &rfds);
FD_SET(master_fd, &rfds);
select(maxfd+1, &rfds, NULL, NULL, NULL);
Common Misconceptions
- “You can just print bytes directly.” -> That bypasses the screen model.
- “Rendering each byte is fine.” -> It becomes slow even for small output.
- “No need to restore terminal.” -> It will break the user’s shell.
Check-Your-Understanding Questions
- Why must you disable local echo in the emulator process?
- What happens if you render and also print PTY output directly?
- How do you avoid blocking on PTY reads?
Check-Your-Understanding Answers
- To prevent keystrokes from appearing twice.
- The output interleaves and corrupts the screen model.
- Use
select()or non-blocking reads.
Real-World Applications
- Minimal terminals used for testing parsers
- Terminal-based consoles in embedded systems
Where You’ll Apply It
- This project: Section 5.10 (phases), Section 7.1 (pitfalls)
- Also used in: P08-terminal-multiplexer-mini-tmux, P14-web-terminal-xterm-js-backend
References
select(2)andpoll(2)man pages- “The Linux Programming Interface” (Kerrisk), Chapter 63
Key Insight
A terminal emulator is just an event loop plus a screen model and a renderer.
Summary
Correct multiplexing and rendering are more important than fancy features at this stage.
Homework/Exercises to Practice the Concept
- Build an event loop that logs which fd is readable.
- Forward input and output without parsing to validate PTY wiring.
- Render a static grid and update one cell per second.
Solutions to the Homework/Exercises
- Use
select()and print flags when fds become ready. - Write stdin to PTY, print output to stdout.
- Update the grid and render every second.
3. Project Specification
3.1 What You Will Build
A minimal terminal emulator that:
- Spawns a child shell via PTY.
- Parses a tiny subset of CSI sequences.
- Maintains a screen grid of configurable size.
- Renders the grid to stdout or a simple TUI.
Intentionally excluded:
- Colors, Unicode width handling, scrollback, or GPU rendering.
3.2 Functional Requirements
- PTY integration: spawn child and forward input/output.
- Parser subset: support CSI H, CSI J (2), CSI A/B/C/D, and reset SGR.
- Screen grid: store characters in a fixed-size grid.
- Cursor rules: implement CR, LF, BS, and wrapping.
- Rendering: re-render the grid after each update batch.
- Exit handling: restore local terminal state.
3.3 Non-Functional Requirements
- Correctness: cursor never leaves grid bounds.
- Determinism: golden demo uses fixed input log.
- Simplicity: code kept intentionally small and readable.
3.4 Example Usage / Output
$ ./mini_term --cols 40 --rows 10
[mini-term] connected to /dev/pts/9
$ ls
file1 file2 file3
3.5 Data Formats / Schemas / Protocols
- Action struct:
{type, params, final}for CSI actions.
3.6 Edge Cases
- Cursor moves outside bounds (clamp).
- Sequence split across reads.
- Child exits and PTY master returns EOF.
3.7 Real World Outcome
A tiny terminal that runs ls, cat, and simple commands correctly.
3.7.1 How to Run (Copy/Paste)
cc -O2 -o mini_term mini_term.c
TZ=UTC LC_ALL=C ./mini_term --cols 40 --rows 10 --demo-log samples/hello.log
3.7.2 Golden Path Demo (Deterministic)
- Replay
samples/hello.logand render the grid. - Verify rendered output matches
samples/hello.expectedexactly.
3.7.3 Failure Demo (Deterministic)
$ ./mini_term --cols 0
error: cols and rows must be > 0
exit status: 64
3.7.4 If TUI: ASCII layout
+----------------------------------------+
| mini_term 40x10 |
|----------------------------------------|
| $ ls |
| file1 file2 file3 |
| |
| |
+----------------------------------------+
4. Solution Architecture
4.1 High-Level Design
PTY -> Parser -> Actions -> Screen Model -> Renderer -> stdout
4.2 Key Components
| Component | Responsibility | Key Decisions |
|---|---|---|
| PTY Manager | Spawn child and stream bytes | Simple select() loop |
| Parser | Decode minimal CSI and control chars | Small FSM |
| Screen Model | Grid + cursor + scroll | Fixed-size arrays |
| Renderer | Dump grid to stdout | Full redraw each frame |
4.3 Data Structures (No Full Code)
struct Cell { char ch; };
struct Screen {
int rows, cols;
struct Cell *cells;
int cur_r, cur_c;
};
4.4 Algorithm Overview
Key Algorithm: Update Loop
- Read bytes from PTY.
- Parse bytes into actions.
- Apply actions to screen.
- Render full grid.
Complexity Analysis:
- Time: O(rows*cols) per redraw
- Space: O(rows*cols)
5. Implementation Guide
5.1 Development Environment Setup
cc --version
5.2 Project Structure
mini-term/
|-- src/
| |-- main.c
| |-- parser.c
| `-- screen.c
|-- samples/
| |-- hello.log
| `-- hello.expected
|-- Makefile
`-- README.md
5.3 The Core Question You’re Answering
“What is the smallest screen model that still behaves like a real terminal?”
5.4 Concepts You Must Understand First
- Screen grid and cursor invariants.
- Defaults for CSI parameters.
- Control character behavior (CR/LF/BS).
5.5 Questions to Guide Your Design
- Where do you store cursor state?
- How do you batch renders for performance?
- What subset of CSI is enough for a demo?
5.6 Thinking Exercise
Simulate output of printf "hello\nworld" on a 5x2 grid and draw the result.
5.7 The Interview Questions They’ll Ask
- How does cursor wrapping work?
- Why does
\rmatter in terminal output? - How do you prevent screen corruption?
5.8 Hints in Layers
Hint 1: Implement control chars first CR, LF, and BS fix many display issues.
Hint 2: Keep the parser tiny Support only a handful of CSI commands.
Hint 3: Full redraw is OK At this scale, correctness beats performance.
Hint 4: Add a replay mode Use fixed logs for deterministic testing.
5.9 Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| Terminal design | “The Linux Programming Interface” | Ch. 62-64 |
| Parsing | “Language Implementation Patterns” | Ch. 1-2 |
5.10 Implementation Phases
Phase 1: PTY + loop (2-3 days)
Goals: Connect to PTY and forward input. Tasks:
- Implement PTY spawn.
- Add
select()loop. Checkpoint: Child shell runs.
Phase 2: Screen model (3-4 days)
Goals: Grid + cursor + scroll. Tasks:
- Implement grid and cursor.
- Add scroll logic. Checkpoint: Text renders correctly.
Phase 3: CSI subset (2-3 days)
Goals: Cursor movement and clear screen. Tasks:
- Add CSI parser for H, A/B/C/D, J.
- Add tests with known logs.
Checkpoint:
clearworks.
5.11 Key Implementation Decisions
| Decision | Options | Recommendation | Rationale |
|---|---|---|---|
| Rendering | Full redraw vs damage tracking | Full redraw | Simplicity |
| Parser scope | Minimal subset vs full | Minimal subset | Focus on invariants |
| Grid storage | 2D array vs flat | Flat array | Cache friendly |
6. Testing Strategy
6.1 Test Categories
| Category | Purpose | Examples |
|---|---|---|
| Unit Tests | Screen behavior | Wrap at column end |
| Integration Tests | PTY + parser | Replay logs |
| Edge Case Tests | Small grids | 1x1 screen |
6.2 Critical Test Cases
- Wrap: writing past last column wraps to next line.
- Scroll: writing past last row scrolls screen.
- CSI H: cursor moves to expected cell.
6.3 Test Data
Log: "ABCDEF" on 3x2 grid
Expected:
ABC
DEF
7. Common Pitfalls & Debugging
7.1 Frequent Mistakes
| Pitfall | Symptom | Solution |
|---|---|---|
| Off-by-one cursor | Text shifted | Convert 1-based to 0-based |
| No scroll logic | Output disappears | Implement scroll_up() |
| Mixed output | Glitches | Render from model only |
7.2 Debugging Strategies
- Add a debug overlay that prints cursor coordinates.
- Replay deterministic logs instead of live PTY.
7.3 Performance Traps
Full redraw is O(rows*cols); keep grid small for this project.
8. Extensions & Challenges
8.1 Beginner Extensions
- Add color attributes (SGR 30-37).
- Add a clear line (CSI K) command.
8.2 Intermediate Extensions
- Add scrollback buffer.
- Add a blinking cursor.
8.3 Advanced Extensions
- Add damage tracking to avoid full redraws.
- Add unicode width handling.
9. Real-World Connections
9.1 Industry Applications
- Minimal terminals used for testing
- Embedded device consoles
9.2 Related Open Source Projects
- tinyvt: minimal VT implementation
- xterm: reference behavior
9.3 Interview Relevance
- State management in UI systems
- Off-by-one bugs and invariants
10. Resources
10.1 Essential Reading
- DEC VT100 user guide (screen semantics)
- xterm control sequence docs
10.2 Video Resources
- OS lectures on terminals
10.3 Tools & Documentation
vttestfor generating sequences
10.4 Related Projects in This Series
- P02-escape-sequence-parser - parsing layer
- P06-scrollback-buffer-implementation - history buffer
11. Self-Assessment Checklist
11.1 Understanding
- I can explain wrap and scroll behavior.
- I can decode basic CSI sequences.
- I can trace a key from PTY to screen.
11.2 Implementation
- Screen grid is correct for basic commands.
- Parser handles split sequences.
- Terminal restores settings on exit.
11.3 Growth
- I can extend the emulator to support colors.
- I can explain design trade-offs.
12. Submission / Completion Criteria
Minimum Viable Completion:
- PTY-connected terminal shows basic output correctly.
- Cursor movement and clear screen commands work.
Full Completion:
- Deterministic log replay tests.
- Robust scroll and wrap behavior.
Excellence (Going Above & Beyond):
- Damage tracking and partial redraws.
- Unicode width support.