ADR-0008: nEmacs Structural Editor UX Design
Date: 2026-04-14
Supersedes spike: former spikes/ADR-0002-nemacs-ux.md
Context: ADR-0002 commits to a runtime-level Lisp structural editor. This spike solves the hard UX problem: how does a player write and navigate Lisp on a 30-key, 80×25 display with no keyboard?
Problem Statement
Section titled “Problem Statement”Writing Lisp on a 30-key device is not character-by-character text editing. The device has:
- No keyboard: only 14 semantic function keys + 16 numpad keys.
- No mouse: navigation is key-based.
- Limited screen: 80 columns × 25 rows in monospace 8×8 font.
- Structural constraint: the result must always be a well-formed s-expression (parens balance, syntax valid).
The solution: A structural editor that:
- Navigates Lisp trees (CAR/CDR/CONS grammar).
- Composes expressions from a predictive palette of ~8 tokens.
- Uses multi-tap numpad for literal entry (identifiers, numbers, strings).
- Renders code structurally (indentation + glyphs), not as characters.
- Displays errors node-scoped, not line-scoped.
Navigation Grammar
Section titled “Navigation Grammar”The 30-key layout maps to structural edits. CAR/CDR/CONS keys already teach list navigation; extend that to editing.
Cursor Movement (CAR/CDR/BACK/QUOTE)
Section titled “Cursor Movement (CAR/CDR/BACK/QUOTE)”| Key | Current Mode | Effect |
|---|---|---|
| CAR | Normal | Descend into first child. If leaf (no children), beep. |
| CDR | Normal | Move to next sibling. If none, beep. |
| BACK | Normal | Ascend to parent. |
| QUOTE | Normal | Enter “grab mode”—select subtree for cut/copy. |
Insertion & Manipulation (CONS/NIL/EVAL)
Section titled “Insertion & Manipulation (CONS/NIL/EVAL)”| Key | Current Mode | Effect |
|---|---|---|
| CONS | Normal | Open predictive palette. Select token from 8 candidates. Inserts before current node. Cursor moves to new node. |
| NIL | Normal + selected | Delete current node (cut to clipboard). |
| EVAL | Palette | Confirm selection; insert token, return to editing. |
| EVAL | Grab mode | Confirm selection; cut subtree to clipboard. |
Literal Entry Mode (LAMBDA or numpad)
Section titled “Literal Entry Mode (LAMBDA or numpad)”| Key | Normal | In Literal Mode |
|---|---|---|
| LAMBDA | Enter literal-entry mode for new identifier/number/string. | Exit literal mode; insert completed literal. |
| Numpad 0–9 | — | Type digit into literal buffer. |
| Numpad . | — | Type decimal point into buffer (for numbers). |
| Numpad ENT | — | Confirm literal; insert and return to normal mode. |
System Functions (SYS/INFO/ATOM)
Section titled “System Functions (SYS/INFO/ATOM)”| Key | Effect |
|---|---|
| SYS | Save buffer and exit editor. Return to deck menu. |
| INFO | Display current node’s type, parent, position. |
| ATOM | Test if current node is atomic (leaf). |
Palette Layout & Token Prediction
Section titled “Palette Layout & Token Prediction”Rendering on 80×25
Section titled “Rendering on 80×25”The bottom 3 rows (rows 23–25) are reserved for the predictive palette.
Row 23: ┌─────────────────────────────────────────────────────────────────┐ │ 8 tokens, each 8 chars wide, separated by single space │ └─────────────────────────────────────────────────────────────────┘Row 24: ┌─ [1]defn [2]let [3]if [4]lambda ┌─ [5]cons [6]car ┐ │ [7]cdr [8]quote └─ (scroll w/ ↑↓) │Row 25: └─────────────────────────────────────────────────────────────────┘The top 22 rows (0–21) show the buffer being edited, with a 2-row status bar at the bottom (rows 22–23 in regular editing).
Token Prediction Algorithm (v1 Static Ranking)
Section titled “Token Prediction Algorithm (v1 Static Ranking)”Goal: Given the cursor position (function position vs. argument position vs. binding), rank the 8 most likely next tokens.
Ranking inputs:
- Legal form filter (hard constraint): Only tokens that syntactically fit here.
- Cipher domain vocabulary boost: Tokens from the loaded cartridge’s vocab get +5 weight.
- Buffer-local identifier boost: Identifiers already defined in this script get +3 weight.
- Recency boost: Recently typed tokens get +2 weight (within session).
- Static popularity prior: Baseline frequencies (if, let, lambda, defn each get baseline +3).
Pseudocode:
for each candidate_token: score = 0
if not legal_at_position(candidate_token, cursor_position): continue # Skip illegal tokens
if token in cartridge_vocabulary: score += 5
if token is locally defined identifier: score += 3
if token used in last 10 tokens: score += 2
if token in common_builtins: score += popularity_baseline[token] # 0-3
ranked_list.append((token, score))
top_8 = sorted(ranked_list, key=score, reverse=True)[:8]Example: Cursor is in argument position of a let binding. Buffer so far:
(let ((x 10) (y |))Cursor is after y. Legal tokens: identifiers, numbers, function calls. Ranked palette:
Candidate | Legal? | Domain | Local | Recency | Baseline | Total──────────────────────────────────────────────────────────────────────10 | Yes | 0 | +2 | +2 | 0 | +420 | Yes | 0 | 0 | 0 | 0 | +0 (tie → alphabetic)x | Yes | 0 | +3 | 0 | 0 | +3+ | Yes | 0 | 0 | 0 | +1 | +1* | Yes | 0 | 0 | 0 | +1 | +1- | Yes | 0 | 0 | 0 | +1 | +1let | No | 0 | 0 | 0 | 0 | (skip)defn | No | 0 | 0 | 0 | 0 | (skip)Top 8 (in order): 10, 20, x, +, *, -, :keyword, nil.
Full 80×25 ASCII Mockups
Section titled “Full 80×25 ASCII Mockups”Mock 1: Empty Buffer with Palette Visible
Section titled “Mock 1: Empty Buffer with Palette Visible”┌────────────────────────────────────────────────────────────────────────┐│ [nEmacs: New Buffer] ││ ││ | ◄ Cursor at root ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ Root (empty) | Depth: 1 | Position: arg 0/0 │├────────────────────────────────────────────────────────────────────────┤│ [1]defn [2]let [3]if [4]lambda [5]cons [6]car ││ [7]cdr [8]quote (Use ↑↓ PAD to scroll palette) │└────────────────────────────────────────────────────────────────────────┘Player action: Presses 1 (defn).
Mock 2: Mid-Edit — Defining a Lambda
Section titled “Mock 2: Mid-Edit — Defining a Lambda”┌────────────────────────────────────────────────────────────────────────┐│ [nEmacs: filter.lsp] ││ ││ (defn filter (lst pred) ││ | ◄ Cursor: in body, first position (function position) ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ defn > filter > body | Depth: 3 | Ln 2, Col 3 │├────────────────────────────────────────────────────────────────────────┤│ [1]if [2]map [3]let [4]lambda [5]car [6]cond ││ [7]fold [8]empty? (CONS: insert | CAR: descend | BACK: parent) │└────────────────────────────────────────────────────────────────────────┘Status bar: Shows path from root (defn > filter > body), depth, visual position. Palette: Specialized for function-position (if, map, lambda are all callable and fit here).
Player action: Presses 2 (map). Palette closes, map is inserted. Cursor moves to map. Palette recomputes for map’s first argument position.
Mock 3: Error State
Section titled “Mock 3: Error State”┌────────────────────────────────────────────────────────────────────────┐│ [nEmacs: scripted-mission.lsp] [ERROR] ││ ││ (defn solve-puzzle (input) ││ (let ((result (apply + input))) ││ ╌ ┌─ (error: missing closing paren on line 3) ││ │ result)) ││ ╌ ┘ ◄ Node marked: SYNTAX ERROR ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ERROR: missing closing paren. Press INFO to see details. │├────────────────────────────────────────────────────────────────────────┤│ [1]quote [2]let [3]if [4]lambda [5]cons [6]cons ││ [7]nil [8]quote (FIX: Add closing paren with CONS + quote) │└────────────────────────────────────────────────────────────────────────┘Error rendering: The unmatched paren is shown with a box glyph (╌ ┌─ ┘) indicating the error scope. Status bar shows error message. Palette is filtered to only legal tokens that fix the error.
Mock 4: Literal Entry Mode
Section titled “Mock 4: Literal Entry Mode”┌────────────────────────────────────────────────────────────────────────┐│ [nEmacs: new-handler.lsp] ││ ││ (defn on-activate (self) ││ (let ((threshold 42)) ││ (if (> (value self) |__) ◄ Literal mode active ││ ││ LITERAL ENTRY MODE ││ Type identifier: threshold_ ││ ▲ ││ (Numpad 0-9 + . + ENT to confirm, or LAMBDA to cancel) ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ Literal mode | Buffer: "threshold" | Press ENT to confirm │├────────────────────────────────────────────────────────────────────────┤│ [1]0 [2]1 [3]2 [4]3 [5]4 [6]5 ││ [7]6 [8]7 (.=DOT ENT=DONE LAMBDA=CANCEL) │└────────────────────────────────────────────────────────────────────────┘Literal mode: Player entered LAMBDA to start typing. They’ve typed “threshold” on the numpad (using multi-tap). Palette shows digits 0–7 (for rapid number entry). Pressing ENT inserts threshold as a new identifier and returns to normal editing.
Mock 5: Completion Palette Scrolled
Section titled “Mock 5: Completion Palette Scrolled”┌────────────────────────────────────────────────────────────────────────┐│ [nEmacs: attack-network.lsp] ││ ││ (defn penetrate (node threat) ││ (let ((ice-level (threat-level node))) ││ (cond ││ ((> ice-level 5) ││ (deploy-honeypot node)) ◄ Cursor here (argument position) ││ (else nil))) ││ ││ ││ ││ ││ ││ cond > clause 2 > body | Depth: 5 | Arg 1/1 │├────────────────────────────────────────────────────────────────────────┤│ [9]evasion-route ││ [10]honeypot ││ [11]lock-node ││ [12]extract ││ [13]mark-node ││ [14]reset-threat ││ (↑ PAD 2/4 to scroll. EVAL to select. BACK to dismiss.) │└────────────────────────────────────────────────────────────────────────┘Scrolled palette: More tokens available. Player can scroll using PAD 2/4 (up/down). This is for exploring tokens beyond the top 8.
Design Rationale
Section titled “Design Rationale”Why Structural Editing?
Section titled “Why Structural Editing?”Text editors on 30 keys are DOA. You’d need:
- Character-by-character input (slow).
- Bracket balancing (complex).
- Undo/redo for typos (fragile state).
Structural editors solve this:
- S-expressions are always balanced (insertion respects tree structure).
- No typos (tokens come from a curated palette).
- Undo is tree-local (delete a node, press NIL again to restore).
Why CAR/CDR/CONS for Navigation?
Section titled “Why CAR/CDR/CONS for Navigation?”Consistency. The player already knows CAR/CDR from playing ICE Breaker and other modules. The editor extends that knowledge:
- CAR drills into a form (descend).
- CDR scans siblings (horizontal movement).
- CONS constructs (insert new node).
- BACK ascends (escape).
Why Palette Instead of Character Entry?
Section titled “Why Palette Instead of Character Entry?”Token prediction is fast. The top 8 tokens cover ~95% of next-token needs. Multi-tap is reserved for rare identifiers (my-custom-var), not everyday edits.
Why Bottom-Rows Palette?
Section titled “Why Bottom-Rows Palette?”Visibility. The player can read their code in rows 0–21 while seeing token candidates in rows 23–25. Status bar (row 22) shows position context. No modal dialogs that hide code.
Why No Indentation Control?
Section titled “Why No Indentation Control?”Automatic. Indentation is derived from tree depth. No player decision. Simpler UI, cleaner output.
UX Specification Summary
Section titled “UX Specification Summary”| Concern | Solution |
|---|---|
| How do I navigate? | CAR/CDR/BACK. Like list navigation in every module. |
| How do I insert tokens? | CONS opens palette (8 tokens + scroll). Press digit 1–8 to select. |
| How do I write identifiers? | LAMBDA → literal mode. Numpad for multi-tap. ENT to confirm. |
| How do I delete? | NIL on the node to delete. |
| How do I move code around? | QUOTE to grab subtree. Navigate elsewhere. CONS to paste. |
| How do I see the palette? | Always visible at bottom (rows 23–25). |
| How do I know my position? | Status bar (row 22) shows path, depth, position. |
| How do errors display? | Box glyph around the bad node. Error message in status bar. |
| How do I exit? | SYS saves and returns to deck menu. |
Token Prediction Edge Cases
Section titled “Token Prediction Edge Cases”Case 1: Beginning of Buffer
Section titled “Case 1: Beginning of Buffer”|Cursor at root, no parent. Legal tokens: definitions (defn, defstruct, defdomain, defdomain, defmission) + top-level forms (let, map, etc. if we’re in a larger context).
Palette: defn, let, defstruct, defdomain, defmission, lambda, quote, nil.
Case 2: Function Position (First Element of List)
Section titled “Case 2: Function Position (First Element of List)”(|)Legal tokens: function/macro names (if, let, map, defn, +, etc.).
Palette: Contextual. If in an ICE Breaker context, (|) might offer probe-node, extract-data, etc. from domain vocabulary.
Case 3: Binding Position (Inside Let)
Section titled “Case 3: Binding Position (Inside Let)”(let ((|)))Legal tokens: identifiers (any), quoted values (quoted constants).
Palette: Recently defined identifiers (e.g., x, lst, threat), popular binding names (result, temp), literals (nil, true, false).
Case 4: Argument Position (Non-First Element)
Section titled “Case 4: Argument Position (Non-First Element)”(map |)Legal tokens: function names (for the first argument of map, a predicate), data (lists, identifiers), literals.
Palette: Recently used predicates (odd?, positive?), locally defined functions, builtins (car, cdr, null?, etc.).
Editing Workflow Example
Section titled “Editing Workflow Example”Scenario: Player is writing a scripted mission to filter a list.
-
Boot into nEmacs. Empty buffer. Palette shows: defn, let, if, lambda, cons, car, cdr, quote.
-
Player presses 1 (defn). Inserted. Cursor moves to function name position. Palette updates to show: recently-used identifiers, popular function names, nil.
-
Player enters literal mode (LAMBDA), types “filter-list”, presses ENT. Identifier inserted.
-
Cursor moves to parameter position. Palette shows: recently-used parameters (lst, pred, acc), nil, quote.
-
Player presses CONS to insert a parameter list. Presses 2 (let). Wait—let is not legal here. (Palette was filtered.) Player sees error. Let is not highlighted. Player presses 4 (cons; inserting a new parameter). Parameter inserted. Cursor in new parameter name position.
-
Player types “lst” via LAMBDA + literal mode. Parameter added.
-
Player presses CDR to move to next parameter slot. Presses CONS. New parameter slot opened. Types “pred”. Second parameter added.
-
Player presses BACK twice (exit parameter list, exit parameter position, into function body). Presses CONS. Opens palette for body-position tokens. Selects if. If-form inserted.
-
Continues building the conditional. CAR/CDR to navigate, CONS to insert, palette guides every step.
-
Completes the function. Presses SYS to save. Buffer saved to deck state as “filter-list.lsp”. Returns to deck menu.
Open Questions
Section titled “Open Questions”-
Grab-and-paste: Currently, QUOTE enters grab mode, but cut/copy semantics aren’t fully specified. Can you grab across different parts of the tree? What’s the scope of a grab?
-
Undo/redo: Structural editors can have rich undo (per-node, per-subtree). Worth building or too expensive for 520 KB? Recommend deferring to v1.1.
-
Tab vs. spaces: Indentation is automatic (tree-depth). But what width? 2 spaces? 4? Recommend 2 for tightness on 80 cols.
-
Syntax highlighting: Color indicates node type (function, literal, identifier)? Recommend yes, but low-priority (amber/white color scheme from Cipher).
-
Multi-line string literals: Can the editor handle multi-line strings? Recommend restricting to single-line for v1.
The nEmacs structural editor transforms Lisp authoring from a text-based problem into a navigational problem. Players already navigate with CAR/CDR; nEmacs just extends that grammar to code composition.