--- name: gim description: Guidelines for working with the Gim vim-like terminal text editor license: Proprietary compatibility: opencode metadata: language: Go 1.25.5 framework: BubbleTea module: git.gophernest.net/azpect/TextEditor --- # Gim — Agent Skill Guide Gim is a vim-like terminal text editor written in Go using the BubbleTea TUI framework. This document gives AI agents everything needed to contribute effectively. --- ## Project Identity | Field | Value | |-------|-------| | Module path | `git.gophernest.net/azpect/TextEditor` | | Go version | 1.25.5 | | Entry point | `cmd/gim/main.go` | | TUI framework | BubbleTea v1.3.10 | | Styling | Lipgloss v1.1.0 | | Syntax highlighting | Chroma v2.23.1 | | Testing | teatest (charmbracelet/x/exp/teatest) | --- ## Directory Layout ``` Gim/ ├── cmd/gim/ │ └── main.go # Binary entry point ├── internal/ │ ├── action/ # Executable actions (implement Action interface) │ │ ├── interface.go # Core interfaces: Action, Motion, Operator, etc. │ │ ├── insert.go # Insert mode entry actions (i, a, o, O, I, A, C, s, S) │ │ ├── delete.go # Delete actions (x, X, dd, D) │ │ ├── replace.go # Replace mode actions (r, R) │ │ ├── find.go # Find char motions (f, F, t, T, ;, ,) │ │ ├── command.go # Command mode state management │ │ ├── command_history.go # Command history navigation │ │ ├── paste.go # Paste (p, P) │ │ ├── yank.go # Yank (y, yy, Y) │ │ ├── undo.go # Undo/redo (u, ctrl+r) │ │ ├── visual.go # Visual mode entry (v, V, ctrl+v) │ │ ├── scroll.go # Scroll actions (ctrl+d, ctrl+u, etc.) │ │ └── repeat.go # Dot operator (.) │ ├── command/ # Ex command mode (:) registry and handlers │ │ ├── registry.go # Command registration and lookup │ │ └── handlers.go # Handler functions (:w, :q, :wq, etc.) │ ├── core/ # Shared data structures and types │ │ ├── types.go # Mode, MotionType, RegisterType enums │ │ ├── buffer.go # Buffer struct and operations │ │ ├── gap_buffer.go # GapBuffer (efficient per-line editing) │ │ ├── window.go # Window (viewport over a buffer) │ │ ├── position.go # Position{Line, Col} type │ │ ├── register.go # Register (vim clipboard) types │ │ ├── undo.go # UndoStack (UndoStack, Change, ChangeBlock) │ │ ├── settings.go # EditorSettings (NewDefaultSettings) │ │ └── command.go # Command parsing helpers │ ├── editor/ # BubbleTea Model — orchestrates everything │ │ ├── model.go # Model struct definition │ │ ├── init.go # tea.Model Init() │ │ ├── update.go # tea.Model Update() │ │ ├── view.go # tea.Model View() │ │ ├── builder.go # ModelBuilder (fluent constructor) │ │ ├── helpers_test.go # Shared test helpers (all tests use these) │ │ └── integration_*_test.go # Integration tests by feature area │ ├── input/ # Key-input state machine │ │ ├── handler.go # InputState machine, key dispatch │ │ └── keymap.go # Keybinding registries per mode │ ├── motion/ # Motion implementations │ │ ├── basic.go # h, j, k, l │ │ ├── jump.go # gg, G, 0, ^, $, %, {, } │ │ └── word.go # w, W, b, B, e, E │ ├── operator/ # Operator implementations │ │ ├── delete.go # d{motion}, dd │ │ ├── yank.go # y{motion}, yy │ │ └── change.go # c{motion}, cc │ ├── program/ # BubbleTea program bootstrap │ │ └── program_builder.go # ProgramBuilder (EmptyProgram, FileProgram) │ ├── style/ # Visual rendering │ │ └── style.go # Lipgloss styles, Chroma syntax highlighting │ ├── textobject/ # Text object ranges │ │ └── word.go # iw, aw, iW, aW │ └── theme/ # Color themes │ └── theme.go # RegisterAll(), kanagawa-wave default ``` --- ## Key Interfaces (`internal/action/interface.go`) All extension points in the editor are interface-based. Implementing the right interface is how new features are added. ```go // Action — any executable editor command type Action interface { Execute(m Model) tea.Cmd } // Motion — moves the cursor; returns a range type type Motion interface { Action Type() core.MotionType // CharwiseExclusive | CharwiseInclusive | Linewise } // Operator — acts on a range defined by a motion type Operator interface { Operate(m Model, start, end core.Position, mtype core.MotionType) tea.Cmd } // DoublePresser — supports the doubled form (dd, yy, cc) type DoublePresser interface { DoublePress(m Model, count int) tea.Cmd } // Repeatable — action that accepts a leading count (3w, 5dd) type Repeatable interface { WithCount(n int) Action } // CharMotion — motion that needs a char argument (f, F, t, T) type CharMotion interface { Motion WithChar(char string) Motion } // Resolvable — motion whose behavior depends on model state at execution time type Resolvable interface { Motion Resolve(m Model) Motion } // TextObject — selects a structured range (iw, aw, iW, aW) type TextObject interface { GetRange(m Model, cursor core.Position, modifier string) (start, end core.Position, mtype core.MotionType) } ``` `action.Model` is the main interface that all actions receive — it provides access to windows, buffers, mode, registers, and input dispatch. --- ## Core Types (`internal/core/`) ```go // Position — line and column (both 0-indexed) type Position struct{ Line, Col int } // Mode type Mode int const ( NormalMode, InsertMode, CommandMode, CommandOutputMode, VisualMode, VisualLineMode, VisualBlockMode, ReplaceMode, WaitingMode ) // MotionType — determines how operator ranges are calculated type MotionType int const ( CharwiseExclusive // end col not included (w, b, h, l, 0, ^) CharwiseInclusive // end col included (e, $, f) Linewise // whole lines (j, k, G, gg, {, }) ) // RegisterType type RegisterType int const ( CharwiseRegister, LinewiseRegister, BlockwiseRegister ) // Buffer — document held in memory type Buffer struct { Id, Type int Filename string Filetype string Lines []*GapBuffer Modified bool UndoStack *UndoStack } // Window — viewport over a buffer type Window struct { Id, Number int Buffer *Buffer Cursor Position Anchor Position // visual mode anchor ScrollY int Height int Width int } ``` --- ## Adding Features — Step-by-Step Patterns ### New Normal-Mode Motion 1. Create `internal/motion/.go`: ```go package motion type MyMotion struct{ Count int } func (m MyMotion) Execute(model action.Model) tea.Cmd { // Move model.ActiveWindow().Cursor return nil } func (m MyMotion) Type() core.MotionType { return core.CharwiseExclusive } func (m MyMotion) WithCount(n int) action.Action { return MyMotion{Count: n} } ``` 2. Register in `internal/input/keymap.go` inside `NewNormalKeymap()`: ```go motions: map[string]action.Motion{ "X": motion.MyMotion{}, } ``` 3. Write integration tests in `internal/editor/integration_motion__test.go`. ### New Operator 1. Create `internal/operator/.go`: ```go package operator type MyOp struct{} func (o MyOp) Operate(m action.Model, start, end core.Position, mt core.MotionType) tea.Cmd { // Act on the range [start, end) return nil } // Double-press support (e.g. "xx" acts on current line) var _ action.DoublePresser = MyOp{} func (o MyOp) DoublePress(m action.Model, count int) tea.Cmd { return nil } ``` 2. Register in `internal/input/keymap.go`: ```go operators: map[string]action.Operator{ "X": operator.MyOp{}, } ``` 3. Write integration tests in `internal/editor/integration_operator__test.go`. ### New Standalone Action 1. Create `internal/action/.go`: ```go package action type MyAction struct{ Count int } func (a MyAction) Execute(m Model) tea.Cmd { // Mutate model state return nil } func (a MyAction) WithCount(n int) Action { return MyAction{Count: n} } ``` 2. Register in `internal/input/keymap.go`: ```go actions: map[string]action.Action{ "X": action.MyAction{}, } ``` ### New Ex Command (`:mycommand`) 1. Add handler in `internal/command/handlers.go`: ```go func handleMyCommand(m action.Model, args []string, force bool) tea.Cmd { // force = true when called as :mycommand! return nil } ``` 2. Register in `internal/command/registry.go` inside `registerDefaults()`: ```go r.Register(Command{ Name: "mycommand", ShortForm: "myc", Handler: handleMyCommand, }) ``` --- ## Testing ### Framework All tests use **teatest** — a BubbleTea test driver that runs the full model loop. Integration tests live in `internal/editor/`. Unit tests for isolated logic live alongside their package (`*_test.go` next to the source). ### Shared Test Helpers (`internal/editor/helpers_test.go`) ```go // Create a default test model newTestModel(t *testing.T) *teatest.TestModel // Create a model with specific initial buffer content newTestModelWithLines(t *testing.T, lines []string) *teatest.TestModel // Create a model with content and cursor at a specific position newTestModelWithLinesAndCursorPos(t *testing.T, lines []string, pos core.Position) *teatest.TestModel // Functional options variant (preferred for complex setups) newTestModelWithOptions(t *testing.T, opts ...TestModelOption) *teatest.TestModel // Options: WithLines(lines []string) WithCursorPos(pos core.Position) WithTermSize(width, height int) WithRegister(name rune, regType core.RegisterType, content []string) // Send one or more keys to the model sendKeys(tm *teatest.TestModel, keys ...string) // Send a string of keys as a sequence sendKeyString(tm *teatest.TestModel, keyString string) // Retrieve the final model state for assertions getFinalModel(t *testing.T, tm *teatest.TestModel) *editor.Model ``` ### Test File Naming Convention | File | Coverage area | |------|---------------| | `integration_motion_basic_test.go` | h, j, k, l | | `integration_motion_jump_test.go` | gg, G, 0, $, %, {, } | | `integration_motion_word_test.go` | w, W, b, B, e, E | | `integration_operator_delete_test.go` | d{motion}, dd | | `integration_operator_change_test.go` | c{motion}, cc | | `integration_operator_yank_test.go` | y{motion}, yy | | `integration_insert_test.go` | i, a, o, O, I, A, C, s, S | | `integration_delete_test.go` | x, X, D, dd | | `integration_visual_test.go` | v, V, ctrl+v | | `integration_paste_test.go` | p, P | | `integration_yank_test.go` | y, yy, Y | | `integration_repeat_test.go` | . (dot operator) | | `integration_replace_test.go` | r, R | | `integration_undo_test.go` | u, ctrl+r | | `integration_scroll_test.go` | ctrl+d, ctrl+u, etc. | | `integration_command_test.go` | :w, :q, :wq, etc. | | `integration_textobject_test.go` | iw, aw, iW, aW | ### Example Integration Test ```go func TestMyFeature(t *testing.T) { t.Run("moves forward one word", func(t *testing.T) { tm := newTestModelWithLines(t, []string{"hello world"}) sendKeys(tm, "w") m := getFinalModel(t, tm) pos := m.ActiveWindow().Cursor if pos.Col != 6 { t.Errorf("expected col 6, got %d", pos.Col) } }) } ``` Run all tests: ``` go test ./... ``` Run a specific package: ``` go test git.gophernest.net/azpect/TextEditor/internal/editor ``` --- ## Input State Machine (`internal/input/handler.go`) The handler dispatches key presses through a state machine. Understanding states is critical when tracing how a key sequence is processed. | State | Meaning | |-------|---------| | `StateReady` | Waiting for first key of a command | | `StateCount` | Accumulating leading count digits (e.g. `12`) | | `StateOperatorPending` | Operator received, waiting for motion (e.g. after `d`) | | `StateMotionCount` | Accumulating count after operator (e.g. `d3`) | | `StateWaitingForChar` | f/t/F/T received, waiting for target char | | `StateWaitingForTextObject` | i/a received in operator pending, waiting for object (w/W) | --- ## BubbleTea Elm Architecture ``` tea.KeyMsg ↓ editor.Update() ↓ input.Handler.Handle(key) ↓ action.Action.Execute(model) ← motions, operators, actions all land here ↓ model state mutation ↓ editor.View() ← renders buffer with Lipgloss + Chroma ``` The editor model implements `tea.Model`: - `Init() tea.Cmd` — returns nil (no startup IO) - `Update(msg tea.Msg) (tea.Model, tea.Cmd)` — handles key, resize, and mouse messages - `View() string` — renders current state as a string --- ## Code Conventions - **Comment format:** `// ReceiverType.MethodName: description` on every exported method - **Builder pattern** for complex structs — `NewBufferBuilder().WithFilename(f).Build()` - **Functional options** for test setup — `WithLines(...)`, `WithCursorPos(...)` - **Interface guards** for compile-time checks: `var _ action.DoublePresser = MyOp{}` - **No panics** in normal paths — return early or propagate via `tea.Cmd` - **No global mutable state** — all state lives inside `editor.Model` - `tea.Cmd` return value is almost always `nil` unless async IO is needed --- ## Build & Run ```bash # Run the editor (no file) go run ./cmd/gim # Run with a file go run ./cmd/gim myfile.go # Build binary go build -o gim ./cmd/gim # Run all tests go test ./... # Run tests with verbose output go test -v ./internal/editor/... ``` --- ## What NOT to Change Without Care - `internal/core/types.go` — changing `MotionType` or `Mode` constants affects the entire codebase - `internal/action/interface.go` — changing `Action` or `Model` interfaces requires updating all implementations - `internal/input/keymap.go` — duplicate key registrations silently shadow each other; verify no conflicts - `internal/editor/update.go` — the central dispatch loop; changes here affect all modes