HELGE SVERREAll-stack Developer
Bergen, Norwayv13.0
est. 2012  |  300+ repos  |  4000+ contributions
Theme:
Building Token: A Rust Text Editor with AI Agents
December 19, 2025

Token is a text editor written in Rust. Multi-cursor editing, tree-sitter syntax highlighting, split views, CSV spreadsheet mode, configurable keybindings—around 15,000 lines of code across 333 commits. Most of it was written through 116 conversations with Amp Code agents.

The project started December 3rd and the core features were done by December 19th. This documents how the AI collaboration was structured.

The Approach

AI agents work well for focused, well-defined tasks. The human job is providing structure: clear phases, written specs, and explicit invariants. The more complex the project, the more this structure matters.

Text editors are deceptively complex: cursor choreography with selections, keyboard modifier edge cases across platforms, grapheme cluster handling, viewport scrolling. Getting these consistent across dozens of AI sessions requires documentation that both humans and agents can reference.

Three Work Modes

Each session starts with an explicit mode declaration:

ModePurposeInputsExample
BuildNew behavior that didn't existFeature spec, reference docs"Implement split view (Phase 3)"
ImproveBetter architecture without changing behaviorOrganization docs, roadmap"Extract modules from main.rs"
SweepFix a cluster of related bugsBug tracker, gap doc"Multi-cursor selection bugs"

This prevents scope creep and keeps AI contributions coherent.

Documentation Structure

Three types of docs drive the work:

Reference Documentation — A source of truth for cross-cutting concerns. EDITOR_UI_REFERENCE.md defines viewport math, coordinate systems, cursor behavior, and scrolling semantics. Before implementation, the Oracle reviewed this doc and found 15+ issues: off-by-one errors in viewport calculations, division-by-zero edge cases, semantic inconsistencies between documented and actual selection models.

Feature Specifications — Written before implementation. SELECTION_MULTICURSOR.md defined data structures, invariants, keyboard shortcuts, message enums, and a phased implementation plan.

Gap Documents — For features at 60-90% completion. MULTI_CURSOR_SELECTION_GAPS.md listed what's implemented vs. missing, design decisions needed, and success criteria. Turns vague incompleteness into concrete tasks.

Agent Configuration

AGENTS.md tells agents how to work in your codebase: build commands, architecture patterns, conventions. Specifying make test instead of letting agents invent cargo test --all-features --no-fail-fast saves a lot of friction.

Development Timeline

PhaseDatesFocus
FoundationDec 3-5Setup, reference docs, architecture
Feature DevelopmentDec 6Split view, undo/redo, multi-cursor selection
Codebase RefactorDec 6Extract modules from main.rs (3100→20 lines)
Research & PolishDec 7Zed research, cursor API fixes, test extraction
Keymapping SystemDec 15Configurable YAML keybindings, 74 default bindings
Syntax HighlightingDec 15Tree-sitter integration, 17 languages
CSV Viewer/EditorDec 16Spreadsheet view with cell editing
Workspace ManagementDec 17Sidebar file tree, focus system, global shortcuts
Unified Text EditingDec 19EditableState system for modals and CSV cells

Example: Multi-Cursor Implementation

Adding multi-cursor to a single-cursor editor touches nearly every file. Here's how it was structured:

1. Invariants upfront:

// MUST maintain: cursors.len() == selections.len()
// MUST maintain: cursors[i].to_position() == selections[i].head

2. Migration helpers:

impl AppModel {
    pub fn cursor(&self) -> &Cursor { &self.editor.cursors[0] }
}

Old code keeps working via the accessor while new code uses explicit indexing.

3. Phased implementation:

  • Phase 0: Per-cursor primitives (move_cursor_left_at(idx))
  • Phase 1: All-cursor wrappers (move_all_cursors_left())
  • Phase 2-4: Update handlers, add tests
  • Phase 5: Bug sweep

The issue was straightforward: all cursor movement handlers used .cursor_mut() which only returned cursors[0]. The fix was adding per-index primitives, then wrapping them in all-cursor helpers that call deduplicate_cursors() after each movement. Test count went from 351 to 383.

Threads: T-d4c75d42, T-6c1b5841, T-e751be48

Example: Split View Implementation

Split view was implemented in 7 phases across a single thread (T-29b1dd08):

PhaseDescription
1Core data structures: ID types, EditorArea, Tab, EditorGroup, LayoutNode
2Layout system: compute_layout(), group_at_point(), splitter hit testing
3Update AppModel: Replace Document/EditorState with EditorArea, add accessors
4Messages: LayoutMsg enum, split/close/focus operations, 17 tests
5Rendering: Multi-group rendering, tab bars, splitters, focus indicators
6Document sync: Shared document architecture (edits affect all views)
7Keyboard shortcuts: Cmd+\, Cmd+W, Cmd+1/2/3/4, Ctrl+Tab

Key architectural decision: documents are shared (HashMap<DocumentId, Document>), editors are view-specific (HashMap<EditorId, EditorState>). Multiple editors can view the same document with independent cursors and viewports.

Example: Module Extraction

By December 6th, main.rs had grown to 3,100 lines. A series of extraction sessions (T-ce688bab through T-072af2cb) broke it apart:

  1. update_layout and helpers → update/layout.rs
  2. update_document and undo/redo → update/document.rs
  3. update_editorupdate/editor.rs
  4. Rendererview.rs
  5. PerfStatsperf.rs
  6. handle_keyinput.rs
  7. App and ApplicationHandlerapp.rs

After: main.rs was 20 lines. All 669 tests passing.

Example: Keymapping System

Research phase (T-35b11d40) compared how VSCode, Helix, Zed, and Neovim handle configurable keymaps:

EditorPatternKey Insight
VSCodeFlat vector + context matchingUser overrides via insertion order
HelixTrie-basedEfficient prefix matching for modal editing
ZedFlat with depth indexingContext depth + insertion order for precedence
NeovimLua scriptingFull programmability

Implementation (T-019b217e) resulted in:

  • 74 default bindings in keymap.yaml, embedded at compile time
  • Platform-aware cmd modifier (Cmd on macOS, Ctrl elsewhere)
  • Context conditions: has_selection, has_multiple_cursors, modal_active
  • Chord support with KeyAction::{Execute, AwaitMore, NoMatch}
  • command: Unbound pattern to disable defaults
  • User config layering: embedded defaults → project-local → user config

The refactor reduced input.rs from 477 to 220 lines.

Cross-Platform Bug Example

Thread T-519a8c9d: Cmd+Z was inserting 'z' instead of undoing on macOS.

Root cause: key handler only checked control_key(), not super_key() (macOS Command key).

// Before (broken on macOS)
if modifiers.control_key() && key == "z" { ... }

// After (cross-platform)
if (modifiers.control_key() || modifiers.super_key()) && key == "z" { ... }

Thread Reference

All 116 conversation threads are public at ampcode.com/@helgesverre. The full thread list with summaries is in docs/BUILDING_WITH_AI.md.

Notable threads:

  • T-7b92a860 — UI Reference doc creation and Oracle review (found 15+ issues before implementation)
  • T-35b11d40 — Keymap system research
  • T-29b1dd08 — Split view (all 7 phases)
  • T-d4c75d42 — Multi-cursor movement
  • T-019b22cc — Syntax highlighting with tree-sitter
  • T-019b2783 — CSV viewer with cell editing
  • T-019b3643 — Unified text editing system

Token is MIT licensed at github.com/HelgeSverre/token.




<!-- generated with nested tables and zero regrets -->