From 9938f0d5d30c3c4a2136343ca27ee51090174f01 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 3 Apr 2026 13:18:23 -0700 Subject: [PATCH 01/14] feat: adding word actions, not done, and lots of failing tests This is a test for the new AI reviewing tool --- .../editor/integration_motion_word_test.go | 825 ++++++++++++++++++ internal/input/keymap.go | 6 + internal/motion/word.go | 216 +++++ 3 files changed, 1047 insertions(+) diff --git a/internal/editor/integration_motion_word_test.go b/internal/editor/integration_motion_word_test.go index 73cdffb..08f813c 100644 --- a/internal/editor/integration_motion_word_test.go +++ b/internal/editor/integration_motion_word_test.go @@ -1685,3 +1685,828 @@ func TestMoveForwardWORDEndInVisualMode(t *testing.T) { } }) } + +// --- B Motion Tests --- + +func TestMoveBackwardWORD(t *testing.T) { + t.Run("test 'B' moves backward one WORD", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) + sendKeys(tm, "B") + + m := getFinalModel(t, tm) + // Should move to start of "world" (index 6) + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("CursorX() = %d, want 6", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'BB' moves backward two WORDs", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "B", "B") + + m := getFinalModel(t, tm) + // Should move to start of "one" (index 0) + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test '2B' moves backward two WORDs with count", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "2", "B") + + m := getFinalModel(t, tm) + // Should move to start of "one" (index 0) + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'B' on punctuation-heavy text", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "B") + + m := getFinalModel(t, tm) + // "hello.world" is one WORD, should move to "next" (index 12) + if m.ActiveWindow().Cursor.Col != 12 { + t.Errorf("CursorX() = %d, want 12", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'B' vs 'b' on punctuation", func(t *testing.T) { + lines := []string{"hello.world next"} + + // Test 'b' + tm1 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm1, "b") + m1 := getFinalModel(t, tm1) + + // 'b' moves to start of "next" (index 12) + if m1.ActiveWindow().Cursor.Col != 12 { + t.Errorf("'b': CursorX() = %d, want 12", m1.ActiveWindow().Cursor.Col) + } + + // Test 'B' + tm2 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm2, "B") + m2 := getFinalModel(t, tm2) + + // 'B' treats "hello.world" as one WORD, moves to index 12 + if m2.ActiveWindow().Cursor.Col != 12 { + t.Errorf("'B': CursorX() = %d, want 12", m2.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'B' crosses lines backward", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 1}) + sendKeys(tm, "B") + + m := getFinalModel(t, tm) + // Should move to start of "hello" on previous line + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'B' at beginning of file", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) // Cursor at 0,0 + sendKeys(tm, "B") + + m := getFinalModel(t, tm) + // Should stay at 0,0 + if m.ActiveWindow().Cursor.Col != 0 || m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("Cursor = (%d,%d), want (0,0)", m.ActiveWindow().Cursor.Col, m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("test 'B' with multiple spaces", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 13, Line: 0}) + sendKeys(tm, "B") + + m := getFinalModel(t, tm) + // Should skip spaces and move to "hello" + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'B' on empty lines", func(t *testing.T) { + lines := []string{"hello", "", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 2}) + sendKeys(tm, "B") + + m := getFinalModel(t, tm) + // Should skip empty line and move to "hello" + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'B' complex code-like text", func(t *testing.T) { + lines := []string{"foo.bar(baz) next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 16, Line: 0}) + sendKeys(tm, "B") + + m := getFinalModel(t, tm) + // "foo.bar(baz)" is one WORD, should move to "next" (index 13) + if m.ActiveWindow().Cursor.Col != 13 { + t.Errorf("CursorX() = %d, want 13", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'BB' complex code-like text", func(t *testing.T) { + lines := []string{"foo.bar(baz) next.thing"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 22, Line: 0}) + sendKeys(tm, "B", "B") + + m := getFinalModel(t, tm) + // Should move to start of first WORD "foo.bar(baz)" (index 0) + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) +} + +func TestMoveBackwardWORDWithOperator(t *testing.T) { + t.Run("test 'dB' deletes backward WORD including punctuation", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "d", "B") + + m := getFinalModel(t, tm) + // Should delete " next" leaving "hello.worldt" + if m.ActiveBuffer().Lines[0].String() != "hello.worldt" { + t.Errorf("Line(0) = %q, want 'hello.worldt'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'dB' vs 'db' on dotted text", func(t *testing.T) { + lines := []string{"hello.world next"} + + // First test 'db' + tm1 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm1, "d", "b") + m1 := getFinalModel(t, tm1) + + // 'db' should delete "next" leaving "hello.world " + if m1.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("'db': Line(0) = %q, want 'hello.world t'", m1.ActiveBuffer().Lines[0].String()) + } + + // Now test 'dB' + tm2 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm2, "d", "B") + m2 := getFinalModel(t, tm2) + + // 'dB' should delete " next" leaving "hello.worldt" + if m2.ActiveBuffer().Lines[0].String() != "hello.worldt" { + t.Errorf("'dB': Line(0) = %q, want 'hello.worldt'", m2.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'd2B' deletes two WORDs backward", func(t *testing.T) { + lines := []string{"one.a two.b three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 16, Line: 0}) + sendKeys(tm, "d", "2", "B") + + m := getFinalModel(t, tm) + // Should delete "two.b three" leaving "one.a e" + if m.ActiveBuffer().Lines[0].String() != "one.a e" { + t.Errorf("Line(0) = %q, want 'one.a e'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'yB' yanks WORD including punctuation", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "y", "B") + + m := getFinalModel(t, tm) + // Text should remain unchanged + if m.ActiveBuffer().Lines[0].String() != "hello.world next" { + t.Errorf("Line(0) = %q, want 'hello.world next'", m.ActiveBuffer().Lines[0].String()) + } + // Cursor should be at start of yanked region (index 12) + if m.ActiveWindow().Cursor.Col != 12 { + t.Errorf("CursorX() = %d, want 12", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'cB' changes WORD backward", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "c", "B") + + m := getFinalModel(t, tm) + // Should delete " next" and enter insert mode + if m.Mode() != core.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.ActiveBuffer().Lines[0].String() != "hello.worldt" { + t.Errorf("Line(0) = %q, want 'hello.worldt'", m.ActiveBuffer().Lines[0].String()) + } + }) +} + +func TestMoveBackwardWORDVisualMode(t *testing.T) { + t.Run("test 'vB' selects backward WORD", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "v", "B") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + // Cursor at start of "next" (index 12) + if m.ActiveWindow().Cursor.Col != 12 { + t.Errorf("CursorX() = %d, want 12", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'vBd' deletes selected WORD", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "v", "B", "d") + + m := getFinalModel(t, tm) + // Should delete " next" leaving "hello.worldt" + if m.ActiveBuffer().Lines[0].String() != "hello.worldt" { + t.Errorf("Line(0) = %q, want 'hello.worldt'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'v2B' selects backward two WORDs", func(t *testing.T) { + lines := []string{"foo.bar baz.qux rest"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 19, Line: 0}) + sendKeys(tm, "v", "2", "B") + + m := getFinalModel(t, tm) + // Cursor at start of "baz.qux" (index 8) + if m.ActiveWindow().Cursor.Col != 8 { + t.Errorf("CursorX() = %d, want 8", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'VB' in visual line mode", func(t *testing.T) { + lines := []string{"hello.world", "next line"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 1}) + sendKeys(tm, "V", "B") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualLineMode { + t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) + } + // Should select both lines + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + }) +} + +// --- ge Motion Tests --- + +func TestMoveBackwardWordEnd(t *testing.T) { + t.Run("test 'ge' moves backward to previous word end", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should move to end of "hello" (index 4) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gege' moves backward two word ends", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "g", "e", "g", "e") + + m := getFinalModel(t, tm) + // Should move to end of "one" (index 2) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test '2ge' moves backward two word ends with count", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "2", "g", "e") + + m := getFinalModel(t, tm) + // Should move to end of "one" (index 2) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ge' on punctuation-heavy text", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should move to end of "world" (index 10) + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ge' crosses lines backward", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should move to end of "hello" on previous line (index 4) + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ge' at beginning of file", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) // Cursor at 0,0 + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should stay at 0,0 + if m.ActiveWindow().Cursor.Col != 0 || m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("Cursor = (%d,%d), want (0,0)", m.ActiveWindow().Cursor.Col, m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("test 'ge' with multiple spaces", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 13, Line: 0}) + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should skip spaces and move to end of "hello" (index 4) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ge' on empty lines", func(t *testing.T) { + lines := []string{"hello", "", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2}) + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should skip empty line and move to end of "hello" (index 4) + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ge' respects word classes", func(t *testing.T) { + lines := []string{"foo-bar baz"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should move to end of "bar" (index 6) + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("CursorX() = %d, want 6", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ge' on method chain", func(t *testing.T) { + lines := []string{"obj.method().chain() next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 24, Line: 0}) + sendKeys(tm, "g", "e") + + m := getFinalModel(t, tm) + // Should move to end of ")" (index 19) + if m.ActiveWindow().Cursor.Col != 19 { + t.Errorf("CursorX() = %d, want 19", m.ActiveWindow().Cursor.Col) + } + }) +} + +func TestMoveBackwardWordEndWithOperator(t *testing.T) { + t.Run("test 'dge' deletes backward to word end", func(t *testing.T) { + lines := []string{"hello world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "d", "g", "e") + + m := getFinalModel(t, tm) + // Should delete from 't' back to end of "world" (inclusive) + if m.ActiveBuffer().Lines[0].String() != "hello world t" { + t.Errorf("Line(0) = %q, want 'hello world t'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'd2ge' deletes backward two word ends", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "d", "2", "g", "e") + + m := getFinalModel(t, tm) + // Should delete backward to end of "one" + if m.ActiveBuffer().Lines[0].String() != "one e" { + t.Errorf("Line(0) = %q, want 'one e'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'yge' yanks backward to word end", func(t *testing.T) { + lines := []string{"hello world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "y", "g", "e") + + m := getFinalModel(t, tm) + // Text should remain unchanged + if m.ActiveBuffer().Lines[0].String() != "hello world next" { + t.Errorf("Line(0) = %q, want 'hello world next'", m.ActiveBuffer().Lines[0].String()) + } + // Cursor should be at end of "world" (index 10) + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'cge' changes backward to word end", func(t *testing.T) { + lines := []string{"hello world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "c", "g", "e") + + m := getFinalModel(t, tm) + // Should delete and enter insert mode + if m.Mode() != core.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.ActiveBuffer().Lines[0].String() != "hello world t" { + t.Errorf("Line(0) = %q, want 'hello world t'", m.ActiveBuffer().Lines[0].String()) + } + }) +} + +func TestMoveBackwardWordEndVisualMode(t *testing.T) { + t.Run("test 'vge' selects backward to word end", func(t *testing.T) { + lines := []string{"hello world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "v", "g", "e") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + // Cursor at end of "world" (index 10) + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'vged' deletes selection", func(t *testing.T) { + lines := []string{"hello world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "v", "g", "e", "d") + + m := getFinalModel(t, tm) + // Should delete selection leaving "hello world t" + if m.ActiveBuffer().Lines[0].String() != "hello world t" { + t.Errorf("Line(0) = %q, want 'hello world t'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'v2ge' selects backward two word ends", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "v", "2", "g", "e") + + m := getFinalModel(t, tm) + // Cursor at end of "one" (index 2) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'Vge' in visual line mode", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) + sendKeys(tm, "V", "g", "e") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualLineMode { + t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) + } + // Should select both lines + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + }) +} + +// --- gE Motion Tests --- + +func TestMoveBackwardWORDEnd(t *testing.T) { + t.Run("test 'gE' moves backward to previous WORD end", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // Should move to end of "hello" (index 4) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gEgE' moves backward two WORD ends", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "g", "E", "g", "E") + + m := getFinalModel(t, tm) + // Should move to end of "one" (index 2) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test '2gE' moves backward two WORD ends with count", func(t *testing.T) { + lines := []string{"one two three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0}) + sendKeys(tm, "2", "g", "E") + + m := getFinalModel(t, tm) + // Should move to end of "one" (index 2) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gE' on punctuation-heavy text", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // "hello.world" is one WORD ending at index 10 + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gE' vs 'ge' on punctuation", func(t *testing.T) { + lines := []string{"hello.world next"} + + // Test 'ge' + tm1 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm1, "g", "e") + m1 := getFinalModel(t, tm1) + + // 'ge' treats punctuation as separate word + if m1.ActiveWindow().Cursor.Col != 10 { + t.Errorf("'ge': CursorX() = %d, want 10", m1.ActiveWindow().Cursor.Col) + } + + // Test 'gE' + tm2 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm2, "g", "E") + m2 := getFinalModel(t, tm2) + + // 'gE' treats "hello.world" as one WORD + if m2.ActiveWindow().Cursor.Col != 10 { + t.Errorf("'gE': CursorX() = %d, want 10", m2.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gE' crosses lines backward", func(t *testing.T) { + lines := []string{"hello", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // Should move to end of "hello" on previous line (index 4) + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gE' at beginning of file", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLines(t, lines) // Cursor at 0,0 + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // Should stay at 0,0 + if m.ActiveWindow().Cursor.Col != 0 || m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("Cursor = (%d,%d), want (0,0)", m.ActiveWindow().Cursor.Col, m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("test 'gE' with multiple spaces", func(t *testing.T) { + lines := []string{"hello world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 13, Line: 0}) + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // Should skip spaces and move to end of "hello" (index 4) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gE' on empty lines", func(t *testing.T) { + lines := []string{"hello", "", "world"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2}) + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // Should skip empty line and move to end of "hello" (index 4) + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gE' complex code-like text", func(t *testing.T) { + lines := []string{"foo.bar(baz) next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 16, Line: 0}) + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // "foo.bar(baz)" is one WORD ending at index 11 + if m.ActiveWindow().Cursor.Col != 11 { + t.Errorf("CursorX() = %d, want 11", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'gE' on method chain", func(t *testing.T) { + lines := []string{"obj.method().chain() next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 24, Line: 0}) + sendKeys(tm, "g", "E") + + m := getFinalModel(t, tm) + // Entire chain is one WORD, ends at index 19 + if m.ActiveWindow().Cursor.Col != 19 { + t.Errorf("CursorX() = %d, want 19", m.ActiveWindow().Cursor.Col) + } + }) +} + +func TestMoveBackwardWORDEndWithOperator(t *testing.T) { + t.Run("test 'dgE' deletes backward to WORD end", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "d", "g", "E") + + m := getFinalModel(t, tm) + // Should delete from 't' back to end of "hello.world" (inclusive) + if m.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("Line(0) = %q, want 'hello.world t'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'dgE' vs 'dge' on dotted text", func(t *testing.T) { + lines := []string{"hello.world next"} + + // First test 'dge' + tm1 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm1, "d", "g", "e") + m1 := getFinalModel(t, tm1) + + // 'dge' should delete to end of "world" + if m1.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("'dge': Line(0) = %q, want 'hello.world t'", m1.ActiveBuffer().Lines[0].String()) + } + + // Now test 'dgE' + tm2 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm2, "d", "g", "E") + m2 := getFinalModel(t, tm2) + + // 'dgE' should delete to end of "hello.world" (treats as one WORD) + if m2.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("'dgE': Line(0) = %q, want 'hello.world t'", m2.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'd2gE' deletes backward two WORD ends", func(t *testing.T) { + lines := []string{"one.a two.b three"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 16, Line: 0}) + sendKeys(tm, "d", "2", "g", "E") + + m := getFinalModel(t, tm) + // Should delete backward to end of "one.a" + if m.ActiveBuffer().Lines[0].String() != "one.a e" { + t.Errorf("Line(0) = %q, want 'one.a e'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'ygE' yanks backward to WORD end", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "y", "g", "E") + + m := getFinalModel(t, tm) + // Text should remain unchanged + if m.ActiveBuffer().Lines[0].String() != "hello.world next" { + t.Errorf("Line(0) = %q, want 'hello.world next'", m.ActiveBuffer().Lines[0].String()) + } + // Cursor should be at end of "hello.world" (index 10) + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'cgE' changes backward to WORD end", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "c", "g", "E") + + m := getFinalModel(t, tm) + // Should delete and enter insert mode + if m.Mode() != core.InsertMode { + t.Errorf("Mode() = %v, want InsertMode", m.Mode()) + } + if m.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("Line(0) = %q, want 'hello.world t'", m.ActiveBuffer().Lines[0].String()) + } + }) +} + +func TestMoveBackwardWORDEndVisualMode(t *testing.T) { + t.Run("test 'vgE' selects backward to WORD end", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "v", "g", "E") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + // Cursor at end of "hello.world" (index 10) + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'vgEd' deletes selection", func(t *testing.T) { + lines := []string{"hello.world next"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) + sendKeys(tm, "v", "g", "E", "d") + + m := getFinalModel(t, tm) + // Should delete selection leaving "hello.world t" + if m.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("Line(0) = %q, want 'hello.world t'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'v2gE' selects backward two WORD ends", func(t *testing.T) { + lines := []string{"foo.bar baz.qux rest"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 19, Line: 0}) + sendKeys(tm, "v", "2", "g", "E") + + m := getFinalModel(t, tm) + // Cursor at end of "foo.bar" (index 6) + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("CursorX() = %d, want 6", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'VgE' in visual line mode", func(t *testing.T) { + lines := []string{"hello.world", "next line"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) + sendKeys(tm, "V", "g", "E") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualLineMode { + t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) + } + // Should select both lines + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + }) +} diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 8f0db71..4876bed 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -38,6 +38,9 @@ func NewNormalKeymap() *Keymap { "e": motion.MoveForwardWordEnd{Count: 1}, "E": motion.MoveForwardWORDEnd{Count: 1}, "b": motion.MoveBackwardWord{Count: 1}, + "B": motion.MoveBackwardWORD{Count: 1}, + "ge": motion.MoveBackwardWordEnd{Count: 1}, + "gE": motion.MoveBackwardWORDEnd{Count: 1}, "ctrl+u": motion.ScrollUpHalfPage{}, "ctrl+d": motion.ScrollDownHalfPage{}, ";": action.RepeatFind{Count: 1, Reverse: false}, @@ -124,6 +127,9 @@ func NewVisualKeymap() *Keymap { "e": motion.MoveForwardWordEnd{Count: 1}, "E": motion.MoveForwardWORDEnd{Count: 1}, "b": motion.MoveBackwardWord{Count: 1}, + "B": motion.MoveBackwardWORD{Count: 1}, + "ge": motion.MoveBackwardWordEnd{Count: 1}, + "gE": motion.MoveBackwardWORDEnd{Count: 1}, // TODO: O and o. These are fun ones! Should be simple too }, operators: map[string]action.Operator{ diff --git a/internal/motion/word.go b/internal/motion/word.go index 0ba5426..8984276 100644 --- a/internal/motion/word.go +++ b/internal/motion/word.go @@ -412,3 +412,219 @@ func (a MoveBackwardWord) Type() core.MotionType { return core.CharwiseExclusive func (a MoveBackwardWord) WithCount(n int) action.Action { return MoveBackwardWord{Count: n} } + +// prevWORDStart: Finds the start of the previous WORD from position (x,y), +// treating all non-whitespace as a single class. +func prevWORDStart(buf *core.Buffer, x, y int) (int, int) { + line := buf.Line(y) + + // Back one to avoid being stuck on the current start + x-- + if x < 0 { + if y == 0 { + return 0, 0 // beginning of file, stay put + } + y-- + line = buf.Line(y) + x = len(line) - 1 + if x < 0 { + return 0, y // landed on an empty line + } + } + + // Skip whitespace backward, crossing lines if needed + for { + for x >= 0 && (line[x] == ' ' || line[x] == '\t') { + x-- + } + if x >= 0 { + break // landed on a non-whitespace char + } + if y == 0 { + return 0, 0 + } + y-- + line = buf.Line(y) + x = len(line) - 1 + if len(line) == 0 { + return 0, y // empty line acts as a word boundary + } + } + + // Skip to the start of the current WORD (all non-whitespace is one class) + for x-1 >= 0 && line[x-1] != ' ' && line[x-1] != '\t' { + x-- + } + + return x, y +} + +// prevWordEnd: Finds the end of the previous word from position (x,y), +// respecting word character classes. +func prevWordEnd(buf *core.Buffer, x, y int) (int, int) { + line := buf.Line(y) + + // Back one to avoid being stuck on the current end + x-- + if x < 0 { + if y == 0 { + return 0, 0 // beginning of file, stay put + } + y-- + line = buf.Line(y) + x = len(line) - 1 + if x < 0 { + return 0, y // landed on an empty line + } + } + + // Skip whitespace backward, crossing lines if needed + for { + for x >= 0 && (line[x] == ' ' || line[x] == '\t') { + x-- + } + if x >= 0 { + break // landed on a non-whitespace char, this is our word end! + } + if y == 0 { + return 0, 0 + } + y-- + line = buf.Line(y) + x = len(line) - 1 + if len(line) == 0 { + return 0, y // empty line acts as a word boundary + } + } + + // We're now at the end of a word - that's the answer! + return x, y +} + +// prevWORDEnd: Finds the end of the previous WORD from position (x,y), +// treating all non-whitespace as a single class. +func prevWORDEnd(buf *core.Buffer, x, y int) (int, int) { + line := buf.Line(y) + + // Back one to avoid being stuck on the current end + x-- + if x < 0 { + if y == 0 { + return 0, 0 // beginning of file, stay put + } + y-- + line = buf.Line(y) + x = len(line) - 1 + if x < 0 { + return 0, y // landed on an empty line + } + } + + // Skip whitespace backward, crossing lines if needed + for { + for x >= 0 && (line[x] == ' ' || line[x] == '\t') { + x-- + } + if x >= 0 { + break // landed on a non-whitespace char, this is our WORD end! + } + if y == 0 { + return 0, 0 + } + y-- + line = buf.Line(y) + x = len(line) - 1 + if len(line) == 0 { + return 0, y // empty line acts as a word boundary + } + } + + // We're now at the end of a WORD - that's the answer! + return x, y +} + +// MoveBackwardWORD implements Motion (B) - charwise +type MoveBackwardWORD struct { + Count int +} + +// MoveBackwardWORD.Execute: Moves the cursor backward by Count WORDs (B motion). +func (a MoveBackwardWORD) Execute(m action.Model) tea.Cmd { + win := m.ActiveWindow() + buf := m.ActiveBuffer() + + x := win.Cursor.Col + y := win.Cursor.Line + for i := 0; i < a.Count; i++ { + x, y = prevWORDStart(buf, x, y) + } + win.SetCursorCol(x) + win.SetCursorLine(y) + return nil +} + +// MoveBackwardWORD.Type: Returns CharwiseExclusive for backward WORD motion. +func (a MoveBackwardWORD) Type() core.MotionType { return core.CharwiseExclusive } + +// MoveBackwardWORD.WithCount: Returns a new MoveBackwardWORD with the given count. +func (a MoveBackwardWORD) WithCount(n int) action.Action { + return MoveBackwardWORD{Count: n} +} + +// MoveBackwardWordEnd implements Motion (ge) - charwise +type MoveBackwardWordEnd struct { + Count int +} + +// MoveBackwardWordEnd.Execute: Moves the cursor to the end of the previous word (ge motion). +func (a MoveBackwardWordEnd) Execute(m action.Model) tea.Cmd { + win := m.ActiveWindow() + buf := m.ActiveBuffer() + + x := win.Cursor.Col + y := win.Cursor.Line + for i := 0; i < a.Count; i++ { + x, y = prevWordEnd(buf, x, y) + } + win.SetCursorCol(x) + win.SetCursorLine(y) + return nil +} + +// MoveBackwardWordEnd.Type: Returns CharwiseInclusive for backward word-end motion. +func (a MoveBackwardWordEnd) Type() core.MotionType { return core.CharwiseInclusive } + +// MoveBackwardWordEnd.WithCount: Returns a new MoveBackwardWordEnd with the given count. +func (a MoveBackwardWordEnd) WithCount(n int) action.Action { + return MoveBackwardWordEnd{Count: n} +} + +// BUG: gE and ge are broken + +// MoveBackwardWORDEnd implements Motion (gE) - charwise +type MoveBackwardWORDEnd struct { + Count int +} + +// MoveBackwardWORDEnd.Execute: Moves the cursor to the end of the previous WORD (gE motion). +func (a MoveBackwardWORDEnd) Execute(m action.Model) tea.Cmd { + win := m.ActiveWindow() + buf := m.ActiveBuffer() + + x := win.Cursor.Col + y := win.Cursor.Line + for i := 0; i < a.Count; i++ { + x, y = prevWORDEnd(buf, x, y) + } + win.SetCursorCol(x) + win.SetCursorLine(y) + return nil +} + +// MoveBackwardWORDEnd.Type: Returns CharwiseInclusive for backward WORD-end motion. +func (a MoveBackwardWORDEnd) Type() core.MotionType { return core.CharwiseInclusive } + +// MoveBackwardWORDEnd.WithCount: Returns a new MoveBackwardWORDEnd with the given count. +func (a MoveBackwardWORDEnd) WithCount(n int) action.Action { + return MoveBackwardWORDEnd{Count: n} +} -- 2.47.2 From 3efba6d57588199fc1fee2e0a7e1e47ecbc686ac Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 3 Apr 2026 13:27:47 -0700 Subject: [PATCH 02/14] fix: trying to fix qodo --- .github/workflows/qodo_review.yml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/qodo_review.yml b/.github/workflows/qodo_review.yml index a4137aa..7b683f6 100644 --- a/.github/workflows/qodo_review.yml +++ b/.github/workflows/qodo_review.yml @@ -11,15 +11,20 @@ jobs: - name: Run Qodo Merge uses: https://github.com/Codium-ai/pr-agent@main env: - # Platform Settings CONFIG.GIT_PROVIDER: "gitea" + GITEA.URL: "https://git.gophernest.net" GITEA.PERSONAL_ACCESS_TOKEN: ${{ secrets.GIT_TOKEN }} - - # AI Settings - CONFIG.MODEL_PROVIDER: "google" - GOOGLE.API_KEY: ${{ secrets.GEMINI_API_KEY }} - CONFIG.MODEL: "google/gemini-3-flash" # The 2026 standard for fast reviews - - # Automation - GITEA.PR_OPENED_COMMANDS: '["/review", "/describe"]' \ No newline at end of file + + # MANDATORY: Even on Gitea, the Action wrapper often looks for this name + GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }} + + # KEY CHANGE: The variable name MUST be this for AI Studio + GOOGLE_AI_STUDIO.GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + + # KEY CHANGE: The model name MUST have the 'gemini/' prefix + CONFIG.MODEL: "gemini/gemini-3-flash" + + # Ensures it runs even if you just push code to an existing PR + GITEA.PR_OPENED_COMMANDS: '["/review", "/describe"]' + GITEA.PR_SYNCHRONIZED_COMMANDS: '["/review"]' -- 2.47.2 From 24eea1d08e711705c19266f7006b2e84a871f17a Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 3 Apr 2026 13:35:21 -0700 Subject: [PATCH 03/14] fix: more qodo fixes --- .github/workflows/qodo_review.yml | 19 +++++++++---------- .pr_agent.toml | 5 +++++ 2 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 .pr_agent.toml diff --git a/.github/workflows/qodo_review.yml b/.github/workflows/qodo_review.yml index 7b683f6..f380e27 100644 --- a/.github/workflows/qodo_review.yml +++ b/.github/workflows/qodo_review.yml @@ -13,18 +13,17 @@ jobs: env: CONFIG.GIT_PROVIDER: "gitea" - GITEA.URL: "https://git.gophernest.net" - GITEA.PERSONAL_ACCESS_TOKEN: ${{ secrets.GIT_TOKEN }} + GITEA__URL: "https://git.gophernest.net" + GITEA__PERSONAL_ACCESS_TOKEN: ${{ secrets.GIT_TOKEN }} # MANDATORY: Even on Gitea, the Action wrapper often looks for this name GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }} - # KEY CHANGE: The variable name MUST be this for AI Studio - GOOGLE_AI_STUDIO.GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + # AI Provider (CRITICAL: Use double underscores) + CONFIG__MODEL_PROVIDER: "google" + GOOGLE_AI_STUDIO__GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + CONFIG__MODEL: "gemini/gemini-1.5-flash" - # KEY CHANGE: The model name MUST have the 'gemini/' prefix - CONFIG.MODEL: "gemini/gemini-3-flash" - - # Ensures it runs even if you just push code to an existing PR - GITEA.PR_OPENED_COMMANDS: '["/review", "/describe"]' - GITEA.PR_SYNCHRONIZED_COMMANDS: '["/review"]' + # Automation Triggers + GITEA__PR_OPENED_COMMANDS: '["/review", "/describe"]' + GITEA__PR_SYNCHRONIZED_COMMANDS: '["/review"]' diff --git a/.pr_agent.toml b/.pr_agent.toml new file mode 100644 index 0000000..19b71ec --- /dev/null +++ b/.pr_agent.toml @@ -0,0 +1,5 @@ +[pr_reviewer] +extra_instructions = "This is a Go project for a terminal-based text editor. Focus on performance, memory management, and clean TUI (Terminal User Interface) logic." + +[pr_description] +publish_description_as_comment = true -- 2.47.2 From e54d49109e2714cfcfba3a6a4cd4bad0820fe195 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 3 Apr 2026 13:41:32 -0700 Subject: [PATCH 04/14] fix: trying to fix ci/cd qodo --- .../{EditorTests.yml => editor_tests.yml} | 0 .github/workflows/qodo_review.yml | 21 ++++++++++++------- .pr_agent.toml | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) rename .github/workflows/{EditorTests.yml => editor_tests.yml} (100%) diff --git a/.github/workflows/EditorTests.yml b/.github/workflows/editor_tests.yml similarity index 100% rename from .github/workflows/EditorTests.yml rename to .github/workflows/editor_tests.yml diff --git a/.github/workflows/qodo_review.yml b/.github/workflows/qodo_review.yml index f380e27..696d8c9 100644 --- a/.github/workflows/qodo_review.yml +++ b/.github/workflows/qodo_review.yml @@ -2,7 +2,7 @@ name: Qodo AI PR Reviewer on: pull_request: - types: [opened, synchronize] + types: [opened, synchronize, reopened] jobs: qodo_review: @@ -11,19 +11,26 @@ jobs: - name: Run Qodo Merge uses: https://github.com/Codium-ai/pr-agent@main env: - CONFIG.GIT_PROVIDER: "gitea" - + # --- Git Provider Configuration --- + CONFIG__GIT_PROVIDER: "gitea" GITEA__URL: "https://git.gophernest.net" - GITEA__PERSONAL_ACCESS_TOKEN: ${{ secrets.GIT_TOKEN }} - # MANDATORY: Even on Gitea, the Action wrapper often looks for this name + # Using your specific secret name: GIT_TOKEN + GITEA__PERSONAL_ACCESS_TOKEN: ${{ secrets.GIT_TOKEN }} GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }} - # AI Provider (CRITICAL: Use double underscores) + # --- AI Provider Configuration (Gemini) --- CONFIG__MODEL_PROVIDER: "google" GOOGLE_AI_STUDIO__GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} CONFIG__MODEL: "gemini/gemini-1.5-flash" - # Automation Triggers + # --- Event Handling Fixes --- + # This line forces the bot to process 'synchronize' (push) events + GITHUB_ACTION_CONFIG__PR_ACTIONS: '["opened", "reopened", "synchronize"]' + + # Automatic commands to run on specific events GITEA__PR_OPENED_COMMANDS: '["/review", "/describe"]' GITEA__PR_SYNCHRONIZED_COMMANDS: '["/review"]' + + # Optional: Extra flavor for your Go editor + PR_REVIEWER__EXTRA_INSTRUCTIONS: "Focus on Go concurrency and terminal UI performance." diff --git a/.pr_agent.toml b/.pr_agent.toml index 19b71ec..0b3bc8e 100644 --- a/.pr_agent.toml +++ b/.pr_agent.toml @@ -1,5 +1,5 @@ [pr_reviewer] -extra_instructions = "This is a Go project for a terminal-based text editor. Focus on performance, memory management, and clean TUI (Terminal User Interface) logic." +extra_instructions = "This is a Go project for a vim-like text editor. Focus on performance, concurrency, and efficient TUI rendering." [pr_description] publish_description_as_comment = true -- 2.47.2 From b9e9fb2f5fce0b6f9efbdc43189cbb891a2d85bc Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 3 Apr 2026 13:44:53 -0700 Subject: [PATCH 05/14] fix: again dude wtf --- .github/workflows/qodo_review.yml | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/qodo_review.yml b/.github/workflows/qodo_review.yml index 696d8c9..69b14dd 100644 --- a/.github/workflows/qodo_review.yml +++ b/.github/workflows/qodo_review.yml @@ -11,26 +11,24 @@ jobs: - name: Run Qodo Merge uses: https://github.com/Codium-ai/pr-agent@main env: - # --- Git Provider Configuration --- + # --- 1. Git Provider Setup --- CONFIG__GIT_PROVIDER: "gitea" GITEA__URL: "https://git.gophernest.net" - - # Using your specific secret name: GIT_TOKEN GITEA__PERSONAL_ACCESS_TOKEN: ${{ secrets.GIT_TOKEN }} GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }} - # --- AI Provider Configuration (Gemini) --- + # --- 2. AI Provider Setup (Gemini 3) --- CONFIG__MODEL_PROVIDER: "google" GOOGLE_AI_STUDIO__GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - CONFIG__MODEL: "gemini/gemini-1.5-flash" + CONFIG__MODEL: "gemini/gemini-1.5-pro" - # --- Event Handling Fixes --- - # This line forces the bot to process 'synchronize' (push) events + # --- 3. THE FIX: Force the bot to stop skipping 'synchronize' --- + # This variable explicitly adds 'synchronize' to the allowed triggers GITHUB_ACTION_CONFIG__PR_ACTIONS: '["opened", "reopened", "synchronize"]' - # Automatic commands to run on specific events + # Tell it what to do when those events happen GITEA__PR_OPENED_COMMANDS: '["/review", "/describe"]' GITEA__PR_SYNCHRONIZED_COMMANDS: '["/review"]' - # Optional: Extra flavor for your Go editor - PR_REVIEWER__EXTRA_INSTRUCTIONS: "Focus on Go concurrency and terminal UI performance." + # --- 4. Extra Context for your Go project --- + PR_REVIEWER__EXTRA_INSTRUCTIONS: "This is a Go-based vim-like text editor. Focus on concurrency safety and efficient TUI rendering logic." -- 2.47.2 From 47a867a5374b88159263791834a77dff32d8e581 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 3 Apr 2026 14:26:51 -0700 Subject: [PATCH 06/14] fix: gemini pro attempt --- .github/workflows/qodo_review.yml | 45 ++++++++++++++++--------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/.github/workflows/qodo_review.yml b/.github/workflows/qodo_review.yml index 69b14dd..d458aac 100644 --- a/.github/workflows/qodo_review.yml +++ b/.github/workflows/qodo_review.yml @@ -1,34 +1,37 @@ -name: Qodo AI PR Reviewer +name: Qodo PR Agent (Gemini) on: pull_request: - types: [opened, synchronize, reopened] + types: [opened, reopened, synchronize] + # Allows you to trigger commands like /review or /improve by commenting on the PR + issue_comment: + types: [created] jobs: - qodo_review: + pr_agent_job: + # Make sure this matches the label of your self-hosted Gitea act_runner runs-on: ubuntu-latest + name: Run PR Agent + # Prevent infinite loops if the bot comments on its own reviews + if: ${{ github.event.sender.type != 'Bot' }} + steps: - - name: Run Qodo Merge - uses: https://github.com/Codium-ai/pr-agent@main + - name: Qodo PR Agent + uses: Codium-ai/pr-agent@main env: - # --- 1. Git Provider Setup --- + # --- 1. Gitea Configuration --- CONFIG__GIT_PROVIDER: "gitea" - GITEA__URL: "https://git.gophernest.net" + + # Automatically pulls your self-hosted Gitea URL + GITEA__URL: ${{ github.server_url }} + + # Gitea Actions automatically provide this token for repository access GITEA__PERSONAL_ACCESS_TOKEN: ${{ secrets.GIT_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }} - # --- 2. AI Provider Setup (Gemini 3) --- - CONFIG__MODEL_PROVIDER: "google" - GOOGLE_AI_STUDIO__GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + # --- 2. Gemini Configuration --- + # Specify the Gemini model (e.g., gemini-1.5-pro or gemini-1.5-flash) CONFIG__MODEL: "gemini/gemini-1.5-pro" + CONFIG__FALLBACK_MODELS: '["gemini/gemini-1.5-flash"]' - # --- 3. THE FIX: Force the bot to stop skipping 'synchronize' --- - # This variable explicitly adds 'synchronize' to the allowed triggers - GITHUB_ACTION_CONFIG__PR_ACTIONS: '["opened", "reopened", "synchronize"]' - - # Tell it what to do when those events happen - GITEA__PR_OPENED_COMMANDS: '["/review", "/describe"]' - GITEA__PR_SYNCHRONIZED_COMMANDS: '["/review"]' - - # --- 4. Extra Context for your Go project --- - PR_REVIEWER__EXTRA_INSTRUCTIONS: "This is a Go-based vim-like text editor. Focus on concurrency safety and efficient TUI rendering logic." + # Provide your API key from Gitea Secrets + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} -- 2.47.2 From 4a2c8895af9363b8ebb8b6cb1a24af181a54e9dc Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 3 Apr 2026 14:30:39 -0700 Subject: [PATCH 07/14] fix: copied from docs --- .github/workflows/qodo_review.yml | 43 ++++++++++++------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/.github/workflows/qodo_review.yml b/.github/workflows/qodo_review.yml index d458aac..4e9cc20 100644 --- a/.github/workflows/qodo_review.yml +++ b/.github/workflows/qodo_review.yml @@ -1,37 +1,26 @@ -name: Qodo PR Agent (Gemini) +name: PR Agent (Gemini) on: pull_request: - types: [opened, reopened, synchronize] - # Allows you to trigger commands like /review or /improve by commenting on the PR + types: [opened, reopened, ready_for_review] issue_comment: - types: [created] jobs: pr_agent_job: - # Make sure this matches the label of your self-hosted Gitea act_runner - runs-on: ubuntu-latest - name: Run PR Agent - # Prevent infinite loops if the bot comments on its own reviews if: ${{ github.event.sender.type != 'Bot' }} - + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + contents: write steps: - - name: Qodo PR Agent - uses: Codium-ai/pr-agent@main + - name: PR Agent action step + uses: qodo-ai/pr-agent@main env: - # --- 1. Gitea Configuration --- - CONFIG__GIT_PROVIDER: "gitea" - - # Automatically pulls your self-hosted Gitea URL - GITEA__URL: ${{ github.server_url }} - - # Gitea Actions automatically provide this token for repository access - GITEA__PERSONAL_ACCESS_TOKEN: ${{ secrets.GIT_TOKEN }} - - # --- 2. Gemini Configuration --- - # Specify the Gemini model (e.g., gemini-1.5-pro or gemini-1.5-flash) - CONFIG__MODEL: "gemini/gemini-1.5-pro" - CONFIG__FALLBACK_MODELS: '["gemini/gemini-1.5-flash"]' - - # Provide your API key from Gitea Secrets - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }} + config.model: "gemini/gemini-1.5-flash" + config.fallback_models: '["gemini/gemini-1.5-flash"]' + GOOGLE_AI_STUDIO.GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + github_action_config.auto_review: "true" + github_action_config.auto_describe: "true" + github_action_config.auto_improve: "true" -- 2.47.2 From c58ec77dba4f45b121b94d6016656208ccee8e08 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 3 Apr 2026 14:32:48 -0700 Subject: [PATCH 08/14] fix: forgot to add sync --- .github/workflows/qodo_review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/qodo_review.yml b/.github/workflows/qodo_review.yml index 4e9cc20..7779fd7 100644 --- a/.github/workflows/qodo_review.yml +++ b/.github/workflows/qodo_review.yml @@ -2,7 +2,7 @@ name: PR Agent (Gemini) on: pull_request: - types: [opened, reopened, ready_for_review] + types: [opened, reopened, ready_for_review, synchronize] issue_comment: jobs: -- 2.47.2 From 194b848d6bcd23577973a28d01dc8e479576f3a2 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 3 Apr 2026 14:35:51 -0700 Subject: [PATCH 09/14] fix: added gitea to provider --- .github/workflows/qodo_review.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/qodo_review.yml b/.github/workflows/qodo_review.yml index 7779fd7..ede11fd 100644 --- a/.github/workflows/qodo_review.yml +++ b/.github/workflows/qodo_review.yml @@ -19,6 +19,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }} config.model: "gemini/gemini-1.5-flash" + config.git_provider: "gitea" config.fallback_models: '["gemini/gemini-1.5-flash"]' GOOGLE_AI_STUDIO.GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} github_action_config.auto_review: "true" -- 2.47.2 From 5833f2312b270ed0a7bed95142acd41938ac90d6 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 3 Apr 2026 14:38:08 -0700 Subject: [PATCH 10/14] fix: removing action --- .github/workflows/qodo_review.yml | 27 --------------------------- .pr_agent.toml | 5 ----- 2 files changed, 32 deletions(-) delete mode 100644 .github/workflows/qodo_review.yml delete mode 100644 .pr_agent.toml diff --git a/.github/workflows/qodo_review.yml b/.github/workflows/qodo_review.yml deleted file mode 100644 index ede11fd..0000000 --- a/.github/workflows/qodo_review.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: PR Agent (Gemini) - -on: - pull_request: - types: [opened, reopened, ready_for_review, synchronize] - issue_comment: - -jobs: - pr_agent_job: - if: ${{ github.event.sender.type != 'Bot' }} - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - contents: write - steps: - - name: PR Agent action step - uses: qodo-ai/pr-agent@main - env: - GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }} - config.model: "gemini/gemini-1.5-flash" - config.git_provider: "gitea" - config.fallback_models: '["gemini/gemini-1.5-flash"]' - GOOGLE_AI_STUDIO.GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - github_action_config.auto_review: "true" - github_action_config.auto_describe: "true" - github_action_config.auto_improve: "true" diff --git a/.pr_agent.toml b/.pr_agent.toml deleted file mode 100644 index 0b3bc8e..0000000 --- a/.pr_agent.toml +++ /dev/null @@ -1,5 +0,0 @@ -[pr_reviewer] -extra_instructions = "This is a Go project for a vim-like text editor. Focus on performance, concurrency, and efficient TUI rendering." - -[pr_description] -publish_description_as_comment = true -- 2.47.2 From 7ba94eaeea2845077777d38bd5a4ab6944b768b0 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 3 Apr 2026 15:20:47 -0700 Subject: [PATCH 11/14] doc: added notes to qodo.md --- qodo.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 qodo.md diff --git a/qodo.md b/qodo.md new file mode 100644 index 0000000..4826f5f --- /dev/null +++ b/qodo.md @@ -0,0 +1,25 @@ +### The Core Commands +* `/review` + Asks Gemini to read the entire PR diff and provide a structured summary, score the PR, identify potential bugs, and suggest high-level fixes. +* `/describe` + Automatically rewrites the PR's title and description based on the actual code changes. (Great for when you just want to push code and not write documentation). +* `/improve` + Scans the code and provides actionable, copy-pasteable snippets to improve the code, focusing on performance, security, and best practices. +* `/ask ""` + Turns the PR comment section into a chat window. It uses the PR diff as context. Example: `/ask "Did I properly handle the null pointer edge cases in the new database function?"` + +### Specialized Tools +* `/test` + Asks the AI to generate unit tests specifically tailored for the new or modified code in the PR. +* `/update_changelog` + Automatically drafts an update for your `CHANGELOG.md` file based on the PR's contents. +* `/generate_labels` + Analyzes the code changes and recommends appropriate labels for the PR (e.g., `bug`, `enhancement`, `refactor`). +* `/help` + Forces the bot to reply with a quick cheat sheet of all available commands and usage instructions in case you forget them. + +### Pro-Tip: Steering the AI +You can actually pass arguments directly to the commands to give Gemini specific instructions for that specific run. + +For example, if you want a review but want it to be hyper-paranoid about security, you can type: +`/review --pr_reviewer.extra_instructions="Focus heavily on potential security vulnerabilities and SQL injection risks."` -- 2.47.2 From 8b7a479ecb912cb1e3aee4e1bf1f2d22f6f08494 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Sat, 4 Apr 2026 11:08:33 -0700 Subject: [PATCH 12/14] feat: finally got the tests passing. Most of the tests were just written poorly, the code was right. Though the yank related questions were actually broken. --- flake.nix | 2 + .../editor/integration_motion_word_test.go | 96 ++++++++----------- internal/motion/word.go | 71 ++++++++++++-- internal/operator/yank.go | 30 ++---- 4 files changed, 114 insertions(+), 85 deletions(-) diff --git a/flake.nix b/flake.nix index f9e1db1..12556b6 100644 --- a/flake.nix +++ b/flake.nix @@ -24,6 +24,8 @@ glibc_multi ]; + name = "Gim"; + # Define the shell that will be executed. # Here, we explicitly use zsh. # Note: pkgs.zsh needs to be included in `packages` or `nativeBuildInputs` diff --git a/internal/editor/integration_motion_word_test.go b/internal/editor/integration_motion_word_test.go index 08f813c..9efbad1 100644 --- a/internal/editor/integration_motion_word_test.go +++ b/internal/editor/integration_motion_word_test.go @@ -1707,9 +1707,8 @@ func TestMoveBackwardWORD(t *testing.T) { sendKeys(tm, "B", "B") m := getFinalModel(t, tm) - // Should move to start of "one" (index 0) - if m.ActiveWindow().Cursor.Col != 0 { - t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) } }) @@ -1719,9 +1718,8 @@ func TestMoveBackwardWORD(t *testing.T) { sendKeys(tm, "2", "B") m := getFinalModel(t, tm) - // Should move to start of "one" (index 0) - if m.ActiveWindow().Cursor.Col != 0 { - t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) } }) @@ -1767,9 +1765,8 @@ func TestMoveBackwardWORD(t *testing.T) { sendKeys(tm, "B") m := getFinalModel(t, tm) - // Should move to start of "hello" on previous line - if m.ActiveWindow().Cursor.Line != 0 { - t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + if m.ActiveWindow().Cursor.Line != 1 { + t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line) } if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) @@ -1795,8 +1792,8 @@ func TestMoveBackwardWORD(t *testing.T) { m := getFinalModel(t, tm) // Should skip spaces and move to "hello" - if m.ActiveWindow().Cursor.Col != 0 { - t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) + if m.ActiveWindow().Cursor.Col != 9 { + t.Errorf("CursorX() = %d, want 9", m.ActiveWindow().Cursor.Col) } }) @@ -1806,9 +1803,8 @@ func TestMoveBackwardWORD(t *testing.T) { sendKeys(tm, "B") m := getFinalModel(t, tm) - // Should skip empty line and move to "hello" - if m.ActiveWindow().Cursor.Line != 0 { - t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + if m.ActiveWindow().Cursor.Line != 2 { + t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line) } if m.ActiveWindow().Cursor.Col != 0 { t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) @@ -1847,9 +1843,8 @@ func TestMoveBackwardWORDWithOperator(t *testing.T) { sendKeys(tm, "d", "B") m := getFinalModel(t, tm) - // Should delete " next" leaving "hello.worldt" - if m.ActiveBuffer().Lines[0].String() != "hello.worldt" { - t.Errorf("Line(0) = %q, want 'hello.worldt'", m.ActiveBuffer().Lines[0].String()) + if m.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("Line(0) = %q, want 'hello.world t'", m.ActiveBuffer().Lines[0].String()) } }) @@ -1861,7 +1856,6 @@ func TestMoveBackwardWORDWithOperator(t *testing.T) { sendKeys(tm1, "d", "b") m1 := getFinalModel(t, tm1) - // 'db' should delete "next" leaving "hello.world " if m1.ActiveBuffer().Lines[0].String() != "hello.world t" { t.Errorf("'db': Line(0) = %q, want 'hello.world t'", m1.ActiveBuffer().Lines[0].String()) } @@ -1871,9 +1865,8 @@ func TestMoveBackwardWORDWithOperator(t *testing.T) { sendKeys(tm2, "d", "B") m2 := getFinalModel(t, tm2) - // 'dB' should delete " next" leaving "hello.worldt" - if m2.ActiveBuffer().Lines[0].String() != "hello.worldt" { - t.Errorf("'dB': Line(0) = %q, want 'hello.worldt'", m2.ActiveBuffer().Lines[0].String()) + if m2.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("'dB': Line(0) = %q, want 'hello.world t'", m2.ActiveBuffer().Lines[0].String()) } }) @@ -1889,6 +1882,7 @@ func TestMoveBackwardWORDWithOperator(t *testing.T) { } }) + // BUG: This is a failing tests, cursor is not moving at start of yank t.Run("test 'yB' yanks WORD including punctuation", func(t *testing.T) { lines := []string{"hello.world next"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) @@ -1915,8 +1909,8 @@ func TestMoveBackwardWORDWithOperator(t *testing.T) { if m.Mode() != core.InsertMode { t.Errorf("Mode() = %v, want InsertMode", m.Mode()) } - if m.ActiveBuffer().Lines[0].String() != "hello.worldt" { - t.Errorf("Line(0) = %q, want 'hello.worldt'", m.ActiveBuffer().Lines[0].String()) + if m.ActiveBuffer().Lines[0].String() != "hello.world t" { + t.Errorf("Line(0) = %q, want 'hello.world t'", m.ActiveBuffer().Lines[0].String()) } }) } @@ -1943,9 +1937,8 @@ func TestMoveBackwardWORDVisualMode(t *testing.T) { sendKeys(tm, "v", "B", "d") m := getFinalModel(t, tm) - // Should delete " next" leaving "hello.worldt" - if m.ActiveBuffer().Lines[0].String() != "hello.worldt" { - t.Errorf("Line(0) = %q, want 'hello.worldt'", m.ActiveBuffer().Lines[0].String()) + if m.ActiveBuffer().Lines[0].String() != "hello.world " { + t.Errorf("Line(0) = %q, want 'hello.world '", m.ActiveBuffer().Lines[0].String()) } }) @@ -1971,8 +1964,8 @@ func TestMoveBackwardWORDVisualMode(t *testing.T) { t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) } // Should select both lines - if m.ActiveWindow().Cursor.Line != 0 { - t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + if m.ActiveWindow().Cursor.Line != 1 { + t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line) } }) } @@ -2114,9 +2107,8 @@ func TestMoveBackwardWordEndWithOperator(t *testing.T) { sendKeys(tm, "d", "g", "e") m := getFinalModel(t, tm) - // Should delete from 't' back to end of "world" (inclusive) - if m.ActiveBuffer().Lines[0].String() != "hello world t" { - t.Errorf("Line(0) = %q, want 'hello world t'", m.ActiveBuffer().Lines[0].String()) + if m.ActiveBuffer().Lines[0].String() != "hello worl" { + t.Errorf("Line(0) = %q, want 'hello worl'", m.ActiveBuffer().Lines[0].String()) } }) @@ -2126,12 +2118,12 @@ func TestMoveBackwardWordEndWithOperator(t *testing.T) { sendKeys(tm, "d", "2", "g", "e") m := getFinalModel(t, tm) - // Should delete backward to end of "one" - if m.ActiveBuffer().Lines[0].String() != "one e" { - t.Errorf("Line(0) = %q, want 'one e'", m.ActiveBuffer().Lines[0].String()) + if m.ActiveBuffer().Lines[0].String() != "on" { + t.Errorf("Line(0) = %q, want 'on'", m.ActiveBuffer().Lines[0].String()) } }) + // BUG: This is a failing tests, cursor is not moving at start of yank t.Run("test 'yge' yanks backward to word end", func(t *testing.T) { lines := []string{"hello world next"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) @@ -2158,8 +2150,8 @@ func TestMoveBackwardWordEndWithOperator(t *testing.T) { if m.Mode() != core.InsertMode { t.Errorf("Mode() = %v, want InsertMode", m.Mode()) } - if m.ActiveBuffer().Lines[0].String() != "hello world t" { - t.Errorf("Line(0) = %q, want 'hello world t'", m.ActiveBuffer().Lines[0].String()) + if m.ActiveBuffer().Lines[0].String() != "hello worl" { + t.Errorf("Line(0) = %q, want 'hello worl'", m.ActiveBuffer().Lines[0].String()) } }) } @@ -2186,9 +2178,8 @@ func TestMoveBackwardWordEndVisualMode(t *testing.T) { sendKeys(tm, "v", "g", "e", "d") m := getFinalModel(t, tm) - // Should delete selection leaving "hello world t" - if m.ActiveBuffer().Lines[0].String() != "hello world t" { - t.Errorf("Line(0) = %q, want 'hello world t'", m.ActiveBuffer().Lines[0].String()) + if m.ActiveBuffer().Lines[0].String() != "hello worl" { + t.Errorf("Line(0) = %q, want 'hello worl'", m.ActiveBuffer().Lines[0].String()) } }) @@ -2381,9 +2372,8 @@ func TestMoveBackwardWORDEndWithOperator(t *testing.T) { sendKeys(tm, "d", "g", "E") m := getFinalModel(t, tm) - // Should delete from 't' back to end of "hello.world" (inclusive) - if m.ActiveBuffer().Lines[0].String() != "hello.world t" { - t.Errorf("Line(0) = %q, want 'hello.world t'", m.ActiveBuffer().Lines[0].String()) + if m.ActiveBuffer().Lines[0].String() != "hello.worl" { + t.Errorf("Line(0) = %q, want 'hello.worl'", m.ActiveBuffer().Lines[0].String()) } }) @@ -2396,8 +2386,8 @@ func TestMoveBackwardWORDEndWithOperator(t *testing.T) { m1 := getFinalModel(t, tm1) // 'dge' should delete to end of "world" - if m1.ActiveBuffer().Lines[0].String() != "hello.world t" { - t.Errorf("'dge': Line(0) = %q, want 'hello.world t'", m1.ActiveBuffer().Lines[0].String()) + if m1.ActiveBuffer().Lines[0].String() != "hello.worl" { + t.Errorf("'dge': Line(0) = %q, want 'hello.worl'", m1.ActiveBuffer().Lines[0].String()) } // Now test 'dgE' @@ -2405,9 +2395,8 @@ func TestMoveBackwardWORDEndWithOperator(t *testing.T) { sendKeys(tm2, "d", "g", "E") m2 := getFinalModel(t, tm2) - // 'dgE' should delete to end of "hello.world" (treats as one WORD) - if m2.ActiveBuffer().Lines[0].String() != "hello.world t" { - t.Errorf("'dgE': Line(0) = %q, want 'hello.world t'", m2.ActiveBuffer().Lines[0].String()) + if m2.ActiveBuffer().Lines[0].String() != "hello.worl" { + t.Errorf("'dgE': Line(0) = %q, want 'hello.worl'", m2.ActiveBuffer().Lines[0].String()) } }) @@ -2417,12 +2406,12 @@ func TestMoveBackwardWORDEndWithOperator(t *testing.T) { sendKeys(tm, "d", "2", "g", "E") m := getFinalModel(t, tm) - // Should delete backward to end of "one.a" - if m.ActiveBuffer().Lines[0].String() != "one.a e" { - t.Errorf("Line(0) = %q, want 'one.a e'", m.ActiveBuffer().Lines[0].String()) + if m.ActiveBuffer().Lines[0].String() != "one." { + t.Errorf("Line(0) = %q, want 'one.'", m.ActiveBuffer().Lines[0].String()) } }) + // BUG: This is a failing tests, cursor is not moving at start of yank t.Run("test 'ygE' yanks backward to WORD end", func(t *testing.T) { lines := []string{"hello.world next"} tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) @@ -2449,7 +2438,7 @@ func TestMoveBackwardWORDEndWithOperator(t *testing.T) { if m.Mode() != core.InsertMode { t.Errorf("Mode() = %v, want InsertMode", m.Mode()) } - if m.ActiveBuffer().Lines[0].String() != "hello.world t" { + if m.ActiveBuffer().Lines[0].String() != "hello.worl" { t.Errorf("Line(0) = %q, want 'hello.world t'", m.ActiveBuffer().Lines[0].String()) } }) @@ -2477,9 +2466,8 @@ func TestMoveBackwardWORDEndVisualMode(t *testing.T) { sendKeys(tm, "v", "g", "E", "d") m := getFinalModel(t, tm) - // Should delete selection leaving "hello.world t" - if m.ActiveBuffer().Lines[0].String() != "hello.world t" { - t.Errorf("Line(0) = %q, want 'hello.world t'", m.ActiveBuffer().Lines[0].String()) + if m.ActiveBuffer().Lines[0].String() != "hello.worl" { + t.Errorf("Line(0) = %q, want 'hello.worl'", m.ActiveBuffer().Lines[0].String()) } }) diff --git a/internal/motion/word.go b/internal/motion/word.go index 8984276..97f152c 100644 --- a/internal/motion/word.go +++ b/internal/motion/word.go @@ -451,7 +451,7 @@ func prevWORDStart(buf *core.Buffer, x, y int) (int, int) { } } - // Skip to the start of the current WORD (all non-whitespace is one class) + // Skip to the start of the WORD (all non-whitespace is one class) for x-1 >= 0 && line[x-1] != ' ' && line[x-1] != '\t' { x-- } @@ -463,6 +463,7 @@ func prevWORDStart(buf *core.Buffer, x, y int) (int, int) { // respecting word character classes. func prevWordEnd(buf *core.Buffer, x, y int) (int, int) { line := buf.Line(y) + origY := y // Back one to avoid being stuck on the current end x-- @@ -473,8 +474,44 @@ func prevWordEnd(buf *core.Buffer, x, y int) (int, int) { y-- line = buf.Line(y) x = len(line) - 1 - if x < 0 { - return 0, y // landed on an empty line + // Don't return early for empty line - we'll handle it in whitespace skip + } + + // Skip backward through current word class if we're on one + // BUT: if we crossed lines in the "back one" step, we're already at the end of a word + if y == origY && x >= 0 && line[x] != ' ' && line[x] != '\t' { + if isWordChar(line[x]) { + // Skip word characters + for x >= 0 && isWordChar(line[x]) { + x-- + if x < 0 { + if y == 0 { + return 0, 0 + } + y-- + line = buf.Line(y) + x = len(line) - 1 + if x < 0 { + return 0, y + } + } + } + } else { + // Skip punctuation + for x >= 0 && isWordPunctuation(line[x]) { + x-- + if x < 0 { + if y == 0 { + return 0, 0 + } + y-- + line = buf.Line(y) + x = len(line) - 1 + if x < 0 { + return 0, y + } + } + } } } @@ -497,7 +534,7 @@ func prevWordEnd(buf *core.Buffer, x, y int) (int, int) { } } - // We're now at the end of a word - that's the answer! + // We're now at the end of the previous word - that's the answer! return x, y } @@ -505,6 +542,7 @@ func prevWordEnd(buf *core.Buffer, x, y int) (int, int) { // treating all non-whitespace as a single class. func prevWORDEnd(buf *core.Buffer, x, y int) (int, int) { line := buf.Line(y) + origY := y // Back one to avoid being stuck on the current end x-- @@ -515,8 +553,25 @@ func prevWORDEnd(buf *core.Buffer, x, y int) (int, int) { y-- line = buf.Line(y) x = len(line) - 1 - if x < 0 { - return 0, y // landed on an empty line + // Don't return early for empty line - we'll handle it in whitespace skip + } + + // Skip backward through current WORD if we're on one + // BUT: if we crossed lines in the "back one" step, we're already at the end of a WORD + if y == origY && x >= 0 && line[x] != ' ' && line[x] != '\t' { + for x >= 0 && line[x] != ' ' && line[x] != '\t' { + x-- + if x < 0 { + if y == 0 { + return 0, 0 + } + y-- + line = buf.Line(y) + x = len(line) - 1 + if x < 0 { + return 0, y + } + } } } @@ -539,7 +594,7 @@ func prevWORDEnd(buf *core.Buffer, x, y int) (int, int) { } } - // We're now at the end of a WORD - that's the answer! + // We're now at the end of the previous WORD - that's the answer! return x, y } @@ -599,8 +654,6 @@ func (a MoveBackwardWordEnd) WithCount(n int) action.Action { return MoveBackwardWordEnd{Count: n} } -// BUG: gE and ge are broken - // MoveBackwardWORDEnd implements Motion (gE) - charwise type MoveBackwardWORDEnd struct { Count int diff --git a/internal/operator/yank.go b/internal/operator/yank.go index 748b6d9..cd2135b 100644 --- a/internal/operator/yank.go +++ b/internal/operator/yank.go @@ -30,8 +30,14 @@ func (o YankOperator) Operate(m action.Model, start, end core.Position, mtype co }) } - win.SetCursorCol(start.Col) - win.SetCursorLine(start.Line) + // Normalize so cursor is set to the earlier position (important for backward motions) + cursorPos := start + if end.Line < start.Line || (end.Line == start.Line && end.Col < start.Col) { + cursorPos = end + } + + win.SetCursorCol(cursorPos.Col) + win.SetCursorLine(cursorPos.Line) return nil } @@ -66,16 +72,6 @@ func yankNormalMode(m action.Model, start, end core.Position, mtype core.MotionT switch { case mtype.IsCharwise(): - // This shouldn't happen - // if start.Line != end.Line { - // m.SetCommandOutput(&core.CommandOutput{ - // Lines: []string{"Start line and end line must match for charwise yank operations."}, - // Inline: true, - // IsError: true, - // }) - // return - // } - line := buf.Line(start.Line) startX := min(start.Col, end.Col) @@ -92,16 +88,6 @@ func yankNormalMode(m action.Model, start, end core.Position, mtype core.MotionT m.UpdateDefaultRegister(core.CharwiseRegister, []string{cnt}) case mtype == core.Linewise: - // This shouldn't happen - // if start.Col != end.Col { - // m.SetCommandOutput(&core.CommandOutput{ - // Lines: []string{"Start column and end column must match for linewise yank operations."}, - // Inline: true, - // IsError: true, - // }) - // return - // } - // These don't need to be validated, they are validated before being passed into the function startY := min(start.Line, end.Line) endY := max(start.Line, end.Line) -- 2.47.2 From b23072b43fa9ba4a7b1c8f1ad5f093e0cc709254 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Sat, 4 Apr 2026 11:09:53 -0700 Subject: [PATCH 13/14] doc: updated FEATURES.md --- FEATURES.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 39598fa..4b062c2 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -14,8 +14,8 @@ - [x] `b` - Backward to start of word - [x] `W` - Forward to start of WORD (whitespace-delimited) - [x] `E` - Forward to end of WORD -- [ ] `B` - Backward to start of WORD -- [ ] `ge` - Backward to end of word +- [x] `B` - Backward to start of WORD +- [x] `ge` - Backward to end of word ### Line Movement - [x] `0` - Move to start of line -- 2.47.2 From 1166a67c6403945b395d8708a43eeb9b3966d895 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Sat, 4 Apr 2026 11:38:19 -0700 Subject: [PATCH 14/14] fix: updated via using feedback from Qodo --- internal/motion/word.go | 22 ++++++++++++++++++++-- internal/operator/yank.go | 4 ++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/internal/motion/word.go b/internal/motion/word.go index 97f152c..91c0087 100644 --- a/internal/motion/word.go +++ b/internal/motion/word.go @@ -534,7 +534,19 @@ func prevWordEnd(buf *core.Buffer, x, y int) (int, int) { } } - // We're now at the end of the previous word - that's the answer! + // Now x,y is at the start of the target word. Move forward to its end. + if x >= 0 { + if isWordChar(line[x]) { + for x+1 < len(line) && isWordChar(line[x+1]) { + x++ + } + } else if isWordPunctuation(line[x]) { + for x+1 < len(line) && isWordPunctuation(line[x+1]) { + x++ + } + } + } + return x, y } @@ -594,7 +606,13 @@ func prevWORDEnd(buf *core.Buffer, x, y int) (int, int) { } } - // We're now at the end of the previous WORD - that's the answer! + // Now x,y is at the start of the target WORD. Move forward to its end. + if x >= 0 { + for x+1 < len(line) && line[x+1] != ' ' && line[x+1] != '\t' { + x++ + } + } + return x, y } diff --git a/internal/operator/yank.go b/internal/operator/yank.go index cd2135b..e162fbc 100644 --- a/internal/operator/yank.go +++ b/internal/operator/yank.go @@ -87,6 +87,10 @@ func yankNormalMode(m action.Model, start, end core.Position, mtype core.MotionT cnt := line[startX:endX] m.UpdateDefaultRegister(core.CharwiseRegister, []string{cnt}) + win := m.ActiveWindow() + win.SetCursorCol(startX) + win.SetCursorLine(start.Line) + case mtype == core.Linewise: // These don't need to be validated, they are validated before being passed into the function startY := min(start.Line, end.Line) -- 2.47.2