Skip to content

Commit b56ed07

Browse files
author
SqlRush
committed
Support vim screen line motions
1 parent 1c1e247 commit b56ed07

4 files changed

Lines changed: 83 additions & 1 deletion

File tree

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ test/parity/ # golden tests against TS/official behavior
238238
- 本轮补充:tail、byte-tail、window 和 metadata-only transcript loader 也改用 canonical message type 处理 progress bridge 与 compact-boundary,`progress_update``system-event` 等别名不再只在 full loader 路径生效。
239239
- 本轮补充:tail、byte-tail、window 和 streaming transcript search 现在复用 full/index loader 的 wrapped record 展开逻辑,可从 JSON:API/resource、GraphQL edge/node 和 collection/list wrapper 中恢复 transcript 批次,并保留 progress bridge。
240240
- 本轮补充:TUI Vim prompt editing 增加基础 visual/visual-line 模式,支持 `v`/`V` 进入选择、motion 扩展 selection、visual `o` 切换 active end、visual `<`/`>` 行缩进/反缩进、visual `~` 大小写切换、visual `u`/`U` 小写/大写转换、`y`/`d`/`c` 以及常用 visual `x`/`s` aliases 作用于选择范围、Esc 回到 normal,并让 interaction script 可用 `visual`/`visualLine` 断言当前 mode。
241+
- 本轮补充:TUI Vim prompt editing 增加 prompt-local `H`/`M`/`L` screen-line motions,normal/visual/operator 路径都可用,`H`/`L` 支持 count 定位首/末附近行,operator line-motion 可 dot-repeat。
241242
- 本轮补充:TUI Vim prompt editing 支持 normal-mode `gv` 重新进入上一次 characterwise/linewise visual selection,后续 visual operator 会复用恢复出的选择范围。
242243
- 本轮补充:TUI Vim prompt editing 增加 `gu`/`gU`/`g~` case-conversion operator,复用 motion、linewise、find/till、text-object 和 dot-repeat operator 管线,并保持大小写转换不写入 yank register。
243244
- 本轮补充:TUI Vim prompt editing 增加 normal-mode `gJ` raw line join,不插入/规范化空格,并接入 dot-repeat。

docs/first-second-parity-audit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ M7 progress now includes:
245245
- `internal/tui`: Vim prompt editing now includes normal-mode `gJ` raw line joins that do not insert or normalize whitespace and can be dot-repeated.
246246
- `internal/tui`: Vim prompt editing now supports normal, operator, and visual `+`/`-`/`_` first-nonblank line motions, including linewise operator ranges and dot-repeat replay.
247247
- `internal/tui`: Vim prompt editing now supports normal, operator, and visual `g_` last-nonblank line motions, preserving charwise operator ranges for commands such as `dg_`.
248+
- `internal/tui`: Vim prompt editing now supports prompt-local `H`/`M`/`L` screen-line motions across normal, visual, and linewise operator paths, including counted top/bottom targeting and dot-repeat replay for operators.
248249
- `internal/tui`: Vim prompt editing now includes visual and visual-line `J`/`gJ` joins across selected line ranges, sharing the normal whitespace-normalized and raw join semantics plus undo, `gv`, and dot-repeat recording.
249250
- `internal/tui`: Vim prompt editing now includes visual and visual-line `p`/`P` paste-over-selection behavior for characterwise and linewise registers, updating the unnamed register with the replaced text and avoiding extra trailing blank lines for end-of-buffer line replacements.
250251
- `internal/tui`: Vim prompt editing now includes visual and visual-line `r{char}` selection replacement, replacing non-newline characters while preserving line structure, supporting undo, and remembering the previous visual range for `gv`.

internal/tui/tui_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3573,6 +3573,59 @@ func TestREPLScreenVimFirstNonBlankLineMotions(t *testing.T) {
35733573
}
35743574
}
35753575

3576+
func TestREPLScreenVimScreenLineMotions(t *testing.T) {
3577+
screen := NewREPLScreen(40, 8, nil)
3578+
screen.SetVimEnabled(true)
3579+
typePromptText(&screen, "one\ntwo\nthree\nfour\nfive")
3580+
screen.ApplyKey(ParseKey("\x1b"))
3581+
screen.ApplyKey(ParseKey("H"))
3582+
if screen.Prompt.Cursor != 0 {
3583+
t.Fatalf("cursor after H = %d", screen.Prompt.Cursor)
3584+
}
3585+
screen.ApplyKey(ParseKey("L"))
3586+
if screen.Prompt.Cursor != len([]rune("one\ntwo\nthree\nfour\n")) {
3587+
t.Fatalf("cursor after L = %d", screen.Prompt.Cursor)
3588+
}
3589+
screen.ApplyKey(ParseKey("M"))
3590+
if screen.Prompt.Cursor != len([]rune("one\ntwo\n")) {
3591+
t.Fatalf("cursor after M = %d", screen.Prompt.Cursor)
3592+
}
3593+
for _, seq := range []string{"2", "H"} {
3594+
screen.ApplyKey(ParseKey(seq))
3595+
}
3596+
if screen.Prompt.Cursor != len([]rune("one\n")) {
3597+
t.Fatalf("cursor after 2H = %d", screen.Prompt.Cursor)
3598+
}
3599+
for _, seq := range []string{"2", "L"} {
3600+
screen.ApplyKey(ParseKey(seq))
3601+
}
3602+
if screen.Prompt.Cursor != len([]rune("one\ntwo\nthree\n")) {
3603+
t.Fatalf("cursor after 2L = %d", screen.Prompt.Cursor)
3604+
}
3605+
3606+
screen = NewREPLScreen(40, 8, nil)
3607+
screen.SetVimEnabled(true)
3608+
typePromptText(&screen, "one\ntwo\nthree\nfour\nfive")
3609+
screen.ApplyKey(ParseKey("\x1b"))
3610+
for _, seq := range []string{"g", "g", "v", "L"} {
3611+
screen.ApplyKey(ParseKey(seq))
3612+
}
3613+
if screen.VimMode != VimVisual || screen.Prompt.Cursor != len([]rune("one\ntwo\nthree\nfour\n")) {
3614+
t.Fatalf("after visual L screen = %#v", screen)
3615+
}
3616+
3617+
screen = NewREPLScreen(40, 8, nil)
3618+
screen.SetVimEnabled(true)
3619+
typePromptText(&screen, "one\ntwo\nthree\nfour\nfive")
3620+
screen.ApplyKey(ParseKey("\x1b"))
3621+
for _, seq := range []string{"g", "g", "j", "d", "L"} {
3622+
screen.ApplyKey(ParseKey(seq))
3623+
}
3624+
if screen.Prompt.Text != "one" || screen.Prompt.Cursor != len([]rune("one")) || screen.VimRegister != "two\nthree\nfour\nfive\n" || !screen.VimRegisterLinewise {
3625+
t.Fatalf("after dL screen = %#v", screen)
3626+
}
3627+
}
3628+
35763629
func TestREPLScreenVimLastNonBlankGMotion(t *testing.T) {
35773630
screen := NewREPLScreen(40, 8, nil)
35783631
screen.SetVimEnabled(true)

internal/tui/vim.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,8 @@ func (s *REPLScreen) applyVimNormalRune(r rune) ScreenEvent {
235235
s.VimPendingCount = count
236236
case 'G':
237237
s.Prompt.goToLine(vimGTargetLine(s.Prompt.lineCount(), count))
238+
case 'H', 'M', 'L':
239+
s.Prompt.goToLine(vimScreenTargetLine(s.Prompt.lineCount(), r, count))
238240
case '+', '-', '_':
239241
s.Prompt.moveVimFirstNonBlankMotion(r, count)
240242
case '/', '?':
@@ -408,7 +410,7 @@ func (s *REPLScreen) applyVimVisualRune(r rune) ScreenEvent {
408410
s.applyVimVisualOperator('d')
409411
case 's':
410412
s.applyVimVisualOperator('c')
411-
case 'h', 'l', 'j', 'k', 'w', 'W', 'b', 'B', 'e', 'E', '$', '^', '|', '%', 'G', '+', '-', '_':
413+
case 'h', 'l', 'j', 'k', 'w', 'W', 'b', 'B', 'e', 'E', '$', '^', '|', '%', 'G', 'H', 'M', 'L', '+', '-', '_':
412414
s.applyVimVisualMotion(r, count)
413415
}
414416
return ScreenEvent{}
@@ -623,6 +625,8 @@ func (s *REPLScreen) applyVimVisualMotion(motion rune, count int) {
623625
s.Prompt.moveMatchingPair()
624626
case 'G':
625627
s.Prompt.goToLine(vimGTargetLine(s.Prompt.lineCount(), count))
628+
case 'H', 'M', 'L':
629+
s.Prompt.goToLine(vimScreenTargetLine(s.Prompt.lineCount(), motion, count))
626630
case '+', '-', '_':
627631
s.Prompt.moveVimFirstNonBlankMotion(motion, count)
628632
}
@@ -788,6 +792,8 @@ func (s *REPLScreen) applyVimOperator(r rune) ScreenEvent {
788792
s.enterVimSearchOperator(r == '?', operator, count)
789793
case 'G':
790794
s.applyVimLineMotionOperator(operator, vimGTargetLine(s.Prompt.lineCount(), count), 'G', count)
795+
case 'H', 'M', 'L':
796+
s.applyVimLineMotionOperator(operator, vimScreenTargetLine(s.Prompt.lineCount(), r, count), r, count)
791797
case '+', '-', '_':
792798
s.applyVimLineMotionOperator(operator, s.Prompt.vimFirstNonBlankMotionTargetLine(r, count), r, count)
793799
case 'g':
@@ -1492,6 +1498,8 @@ func (s *REPLScreen) replayVimLastChange() {
14921498
switch change.Motion {
14931499
case 'G':
14941500
s.applyVimLineMotionOperator(change.Operator, vimGTargetLine(s.Prompt.lineCount(), change.Count), change.Motion, change.Count)
1501+
case 'H', 'M', 'L':
1502+
s.applyVimLineMotionOperator(change.Operator, vimScreenTargetLine(s.Prompt.lineCount(), change.Motion, change.Count), change.Motion, change.Count)
14951503
case 'g':
14961504
targetLine := 1
14971505
if change.Count > 1 {
@@ -1666,6 +1674,25 @@ func vimGTargetLine(lineCount int, count int) int {
16661674
return count
16671675
}
16681676

1677+
func vimScreenTargetLine(lineCount int, motion rune, count int) int {
1678+
if lineCount < 1 {
1679+
lineCount = 1
1680+
}
1681+
if count <= 0 {
1682+
count = 1
1683+
}
1684+
switch motion {
1685+
case 'H':
1686+
return count
1687+
case 'M':
1688+
return (lineCount + 1) / 2
1689+
case 'L':
1690+
return lineCount - count + 1
1691+
default:
1692+
return 1
1693+
}
1694+
}
1695+
16691696
func (p *PromptState) vimFirstNonBlankMotionTargetLine(motion rune, count int) int {
16701697
if count <= 0 {
16711698
count = 1

0 commit comments

Comments
 (0)