466 lines
15 KiB
Markdown
466 lines
15 KiB
Markdown
---
|
|
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/<name>.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_<name>_test.go`.
|
|
|
|
### New Operator
|
|
|
|
1. Create `internal/operator/<name>.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_<name>_test.go`.
|
|
|
|
### New Standalone Action
|
|
|
|
1. Create `internal/action/<name>.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
|