Terminal stack — how it fits together

Five-minute read: where things live, who owns what, and how bytes move. No methodology—just wiring.

Scope: This file is only the graphical terminal slice (dock → gterm → PTY → dps → pixels). For the whole OS at high level (correct names + layered diagram), see system-architecture.md. For the kernel + syscall contract around PTY blocking and flags, see pty-v1-design.md.

1. One diagram — terminal subsystem only (layers + PTY)

This is the main byte spine for that slice—the path stdin/stdout take through the PTY queues between gterm and dps. It is sanitized: it shows how data is supposed to flow so you can reason about it. It is not a claim that ownership is already clean in every repo corner (see §1a), and it is not “here is the entire operating system.”

flowchart TB
  subgraph hw["Hardware"]
    KB["Keyboard / mouse"]
    GPU["Display"]
  end

  subgraph kernel["Kernel"]
    SCHED["Scheduler / tasks"]
    SYSCALL["Syscalls"]
    PTY["PTY pair\n(src/fs/pty.rs + VFS)"]
    VFS["VFS / fds"]
  end

  subgraph comp["Compositor / WM"]
    DG["deskgui — WM v1, focus, dock"]
  end

  subgraph apps["Userland"]
    GT["gterm\n(apps/termd)"]
    DP["dps\n(apps/dps)"]
  end

  KB --> DG
  DG -->|"IPC: keys"| GT
  GT -->|"write master"| PTY
  PTY -->|"read slave"| DP
  DP -->|"write slave"| PTY
  PTY -->|"read master"| GT
  GT -->|"present RGBA"| DG
  DG --> GPU
  SYSCALL --> PTY
  SYSCALL --> VFS
  VFS --> PTY

Text version (same story):

  [Keyboard] → deskgui (WM) → gterm (key IPC)
                    ↑              │
                    │         PTY master fd
                    │              ↕
                    │         [Kernel PTY: two queues]
                    │              ↕
                    │         PTY slave = dps stdin/stdout
                    │              │
                    └──── framebuffer ← gterm draws scrollback + tail
  • PTY object = kernel: one pair (master + slave), byte queues. Not a separate “PTY daemon.”

  • gterm = one process: owns the master end, opens WM surface, spawns dps with slave bound to stdio (sys_openpty → sys_pty_rebind_std on the child path in apps/termd/src/main.rs).

  • dps = normal process: fd 0 / 1 are the slave; it does not know about gterm’s framebuffer.

1a. Reality check — what you actually have right now

The pretty diagram is aspirational structure. The codebase is still a spaghetti-adjacent stack: responsibilities leak, contracts are thin, and several layers do “someone else’s job.”

Tension

Honest description

PTY “split”

The pair is one kernel object, but behavior is split: kernel moves bytes; gterm owns master lifecycle + read loop + line splitting; dps owns slave stdio + readline. There is no single userland “PTY service”—you have to hold all three in your head.

gterm doing shell-ish work

Scancode → bytes, ^C/^D, wheel scroll, scrollback cap, \n row model—that is interactive/session policy, not “dumb paint.” There is no full VT emulator (no cursor addressing, no SGR in the blitter), but gterm does run a narrow display sanitizer on PTY bytes before rasterizing (see §2 / §3).

dps doing terminal-ish work

Per-char echo, backspace rubout, line assembly until \n—that is line discipline territory (historically kernel TTY + readline). The shell and the “fake tty” are fused in one binary today.

Compositor in the middle

Every keystroke and every frame goes through deskgui (focus, WM v1, blit). Nothing is a straight shot from hardware to shell; the desktop is always in the loop.

Weak contracts

Most behavior was nailed down after bugs (burst \n, UTF-8 splits, ANSI on stdout). The doc’s “intended path” sections are the contract we’re converging on, not a spec the kernel enforced from day one.

Bottom line: §1 is the map you steer toward. §2 is the target ownership we want. The gap between those and “how thick main.rs is in each app” is the real engineering debt—not shame, just truth.

1b. Client RGBA memory (gterm / WM v1)

The ping-pong compositor-facing buffer is not on the brk heap with alloc. p1_alloc_framebuffer (SDK/lib/cstd/src/p1_framebuffer.rs, re-exported as p1window::p1_alloc_framebuffer) maps a page-aligned two-half RGBA slab with sys_mmap_anon (syscall 9 into the per-task mmap_current range). SysAllocator in apps/termd/src/main.rs still uses brk only for Window, String, and other small heap objects—so large surfaces do not compete with break growth (see foundation-debt.md §5.3). Oversized ClientBounds vs the initial cap still clamp until a remap path exists.

2. Who owns what (short)

Owner

Responsibility

Kernel PTY

Pair of fds, buffering, opaque bytes in both directions. No line editing, no ANSI, no UTF-8 merging across arbitrary chunk boundaries—that’s policy for userland.

dps

Shell: prompt, readline, builtins, echo of typed characters to stdout, command execution, what bytes it writes to stdout (plain text today; clear may emit screen-clear escape only by design). Reads stdin = slave in.

gterm

Terminal viewer: WM window, read PTY master (see non-blocking READ_FLAG_NONBLOCK_PTY in pty-v1-design.md §1.2) → split on \n into scroll rows + tail, forward keys → PTY master.

Does not implement VT semantics (cursor motion, regions, SGR-to-pixels, mouse protocols). It does pass output through fold_pty_bytes_for_display so CSI / OSC / SS3 / C0 never hit draw_char as raw bytes, and draw_string uses one column per char (non-ASCII → ?) so wrapping matches the blitter.

Rule of thumb: no VT emulator in gterm — but yes display hygiene on the bytes we paint. Full “terminal emulation” (colors in the framebuffer, curses layout) either stays out of the stream, lives in dps / a future libline, or requires a deliberate new layer.

Deliberate split: “What the session means” (dps) vs “how we sanitize and paint lines” (gterm) vs “how fds are plumbed” (kernel PTY).

3. Terminal data flow

Keystroke (intended path)

  1. Hardware delivers scancode → deskgui routes to focused client (gterm).

  2. gterm handle_key_press: maps scancode → byte(s) (e.g. \n, printable char). Window close is Ctrl+Shift+Q (not bare Escape / raw scancode 0x01 — avoids VM/driver confusion with Tab). Ctrl+C: sys_signal_foreground(2) when the shell has a foreground child; otherwise 0x03 to the PTY for dps readline. Extended prefix: deskgui sends (raw & 0x7F), so 0xE0 arrives as 0x60; gterm treats both as extended, then maps arrows to CSI (ESC [ A–D) and extended Backspace-style codes to DEL (0x7F). Policy + layout roadmap: keyboard-input-and-layout.md (newdocs/ canonical).

  3. gterm pty_master_write: sys_write to PTY master fd.

  4. Kernel copies master → slave input queue.

  5. dps sys_read on stdin (fd 0) reads those bytes; readline echoes locally where designed, builds a line until \n, then runs the command.

Nothing in this path touches the compositor except getting the key event into gterm.

One line of shell output (intended path)

  1. dps runs syscall(1, 1, …) (stdout) → slave write.

  2. Kernel copies slave → master output queue.

  3. gterm poll_dps_output: sys_read_with_flags on PTY master with READ_FLAG_NONBLOCK_PTY, passes each chunk to append_pty_master_bytes (buffer until \n, strip trailing \r, push one scroll row).

  4. gterm render_terminal: every completed scroll line is stored already folded (fold_pty_bytes_for_display: CSI / OSC / SS3, BS/DEL, TAB→space). The incomplete tail is folded each frame from raw PTY_LINE_TAIL so the live prompt line matches what dps echoed. Then blit into the framebuffer; present_wm_v1 sends width/height plus source stride and buffer byte length so the compositor does not mis-read padded RGBA rows (see wm-v1-platform-roadmap.md §6, 6b).

What “broken” usually meant (historically)

  • ANSI on stdout with no parser in gterm → escape bytes show as gaps + literal [32m etc. Fix: dps sends plain text for normal output/prompt, or later a shared VT layer—not strip hacks in the viewer.

  • Burst \n or multi-byte reads → needed correct stdin consumption in dps and one row per \n in gterm scrollback (see code comments in dps / termd).

4. Shell platform roadmap (where this stack fits)

The byte path in §3 is only one slice. Phase 1 adds TIOCSWINSZ / TIOCGWINSZ, sys_signal_foreground on Ctrl+C, and compositor WIRE_WM_EVENT_CLIENT_BOUNDS (37) so gterm can refresh PTY winsize after WM resize. Remaining gaps (line discipline, full job control, TERM, arrows/history) are in system-architecture.md §7 (system-architecture.md).

Policy reminder: until a deliberate VT layer exists, gterm must not grow cursor / region / SGR bitmap behavior (§2). Escape bytes on the wire are still expected from real tools; fold_pty_bytes_for_display strips or skips them for safe painting, not to emulate xterm. Roadmap items that need real ANSI semantics must land in dps / libline / kernel TTY — or explicitly extend gterm with a spec, not ad hoc.

4b. Multiple dock terminals vs compositor (WM v1)

Each gterm is a separate PID with its own PTY + dps child; they do not share RAM by design. If closing one terminal killed or corrupted another, that was never “PTY coupling” — it was compositor + kernel page-table lifecycle (shared PML4[192] cleanup, present slot sizing, hit-test order). Those fixes live outside this file’s byte spine but directly affect multi-terminal sessions.

Read before debugging “close order” again: foundation-debt.md §3.5–§5.2 (§5.2 = COM1 serial signature when closing the front terminal kills the next blit), wm-v1-platform-roadmap.md §6, system-architecture.md §3.1–§3.2, and the documentation-map.md index.

5. Pointers in the tree

Piece

Where

PTY buffers / pairing

src/fs/pty.rs, wired via VFS

sys_openpty / sys_pty_rebind_std

src/kernel/syscalls.rs, constants in SDK/lib/cstd/src/sys.rs

gterm lifecycle + master I/O + mmap RGBA

apps/termd/src/main.rs (allocate_framebuffer → p1_alloc_framebuffer)

dps shell I/O

apps/dps/src/main.rs

Dock launches gterm

docs/launch-paths.md (/bin/gterm.elf)

Multi-window WM / present teardown

foundation-debt.md, wm-v1-platform-roadmap.md §6

Keyboard policy + layout roadmap

newdocs/keyboard-input-and-layout.md

If this doc drifts from code, trust the repo and update this file in one pass.