15 KiB
name, description, license, compatibility, metadata
| name | description | license | compatibility | metadata | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| gim | Guidelines for working with the Gim vim-like terminal text editor | Proprietary | opencode |
|
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.
// 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/)
// 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
-
Create
internal/motion/<name>.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} } -
Register in
internal/input/keymap.goinsideNewNormalKeymap():motions: map[string]action.Motion{ "X": motion.MyMotion{}, } -
Write integration tests in
internal/editor/integration_motion_<name>_test.go.
New Operator
-
Create
internal/operator/<name>.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 } -
Register in
internal/input/keymap.go:operators: map[string]action.Operator{ "X": operator.MyOp{}, } -
Write integration tests in
internal/editor/integration_operator_<name>_test.go.
New Standalone Action
-
Create
internal/action/<name>.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} } -
Register in
internal/input/keymap.go:actions: map[string]action.Action{ "X": action.MyAction{}, }
New Ex Command (:mycommand)
-
Add handler in
internal/command/handlers.go:func handleMyCommand(m action.Model, args []string, force bool) tea.Cmd { // force = true when called as :mycommand! return nil } -
Register in
internal/command/registry.goinsideregisterDefaults():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)
// 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
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 messagesView() string— renders current state as a string
Code Conventions
- Comment format:
// ReceiverType.MethodName: descriptionon 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.Cmdreturn value is almost alwaysnilunless async IO is needed
Build & Run
# 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— changingMotionTypeorModeconstants affects the entire codebaseinternal/action/interface.go— changingActionorModelinterfaces requires updating all implementationsinternal/input/keymap.go— duplicate key registrations silently shadow each other; verify no conflictsinternal/editor/update.go— the central dispatch loop; changes here affect all modes