Skip to content

Commit 71f7aa8

Browse files
author
SqlRush
committed
Support vim prompt marks
1 parent 1745ee5 commit 71f7aa8

6 files changed

Lines changed: 122 additions & 0 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,8 @@ M7 补充:prompt history `LogEntry` 读取现在接受 `sessionID`/`session`/`
750750

751751
本轮补充:TUI Vim prompt editing 增加 normal-mode `R` replace mode,输入会从当前 cursor 开始覆盖现有字符、超过文本尾部时追加,并接入 undo 与 dot-repeat。
752752

753+
本轮补充:TUI Vim prompt editing 增加 prompt-local marks,支持 `m{mark}` 设置位置、`` `{mark}` 精确跳转、`'{mark}` 跳到 mark 所在行首,并支持 `d`/`c`/`y` 等 operator 以 mark 作为 motion。
754+
753755
本轮补充:prompt history 写入现在保留 image pasted-content 的 media type、filename、dimensions 和 image-cache source path 元数据,同时继续不把 inline base64 image bytes 或 text-paste hash 写进图片历史记录。
754756

755757
本轮补充:prompt history 读取旧 image pasted-content 记录时,如果缺少 source path 但对应 session 的 image-cache 文件仍存在,会自动补回 source path 并刷新内存 image path cache。

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ test/parity/ # golden tests against TS/official behavior
241241
- 本轮补充:TUI Vim prompt editing 增加 visual/visual-line `p`/`P` 粘贴替换 selection,支持 characterwise 和 linewise register,替换出的文本会回写 unnamed register,并避免行选择替换到末尾时留下额外空行。
242242
- 本轮补充:TUI Vim prompt editing 增加 visual/visual-line `r{char}` selection 替换,按选区把非换行字符替换为目标字符,保留行结构、接入 undo,并让 `gv` 能重选替换前的 visual range。
243243
- 本轮补充:TUI Vim prompt editing 增加 normal-mode `R` replace mode,输入会从当前 cursor 开始覆盖现有字符、超过文本尾部时追加,并接入 undo 与 dot-repeat。
244+
- 本轮补充:TUI Vim prompt editing 增加 prompt-local marks,支持 `m{mark}` 设置位置、`` `{mark}` 精确跳转、`'{mark}` 跳到 mark 所在行首,并支持 `d`/`c`/`y` 等 operator 以 mark 作为 motion。
244245
- 本轮补充:prompt history 写入现在保留 image pasted-content 的 media type、filename、dimensions 和 image-cache source path 元数据,同时继续不把 inline base64 image bytes 或 text-paste hash 写进图片历史记录。
245246
- 本轮补充:prompt history 读取旧 image pasted-content 记录时,如果缺少 source path 但对应 session 的 image-cache 文件仍存在,会自动补回 source path 并刷新内存 image path cache。
246247
- 本轮补充:interaction script key 字段现在接受 DOM-style key event object,可从 `key`/`code`(包括 `Numpad*`、扩展数字区括号/hash/backspace 和标点 key code)、旧式 `keyIdentifier`、数字 `keyCode`/`which`/`charCode`(包括标点和数字区运算符)、`keypress.which` 字符码、`ctrlKey`/`altKey`/`metaKey`/`shiftKey``modifiers` 数组还原现有 key 名,wrapper payload 中的 key event 也可驱动脚本回放。

docs/first-second-parity-audit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ M7 progress now includes:
238238
- `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.
239239
- `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`.
240240
- `internal/tui`: Vim prompt editing now includes normal-mode `R` replace mode, overwriting from the current cursor, appending past the end of text, and supporting undo plus dot-repeat.
241+
- `internal/tui`: Vim prompt editing now includes prompt-local marks with `m{mark}`, exact `` `{mark}` jumps, linewise `'{mark}` jumps, and mark-based operator motions for `d`/`c`/`y` style operators.
241242
- `internal/session`: prompt history now persists image pasted-content metadata such as media type, filename, dimensions, and image-cache source path while still omitting inline base64 image bytes and text-paste hashes for images.
242243
- `internal/session`: prompt history loading now backfills missing image source paths from existing per-session image-cache files and refreshes the in-memory image path cache when those files exist.
243244
- `internal/tui`: interaction script key fields now accept DOM-style key event objects with `key`/`code` including `Numpad*`, extended numpad paren/hash/backspace codes, and punctuation key codes, legacy `keyIdentifier`, numeric `keyCode`/`which`/`charCode` including punctuation and numpad operators, `keypress.which` char-code replay, modifier booleans such as `ctrlKey`/`altKey`/`metaKey`/`shiftKey`, and modifier arrays, including wrapper payloads and modifier-only event filtering.

internal/tui/screen.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ type REPLScreen struct {
7676
VimLastVisualCursor int
7777
VimLastVisualLinewise bool
7878
VimLastVisualValid bool
79+
VimMarks map[rune]int
80+
VimPendingMark rune
7981
Focused bool
8082
ReverseSearch ReverseSearchState
8183
StashedPrompt *PromptStash

internal/tui/tui_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4165,6 +4165,39 @@ func TestREPLScreenVimJKLineMotionsAndOperators(t *testing.T) {
41654165
if screen.Prompt.Text != "one" || screen.VimRegister != "two\nthree\n" {
41664166
t.Fatalf("after dk screen = %#v", screen)
41674167
}
4168+
4169+
screen = NewREPLScreen(40, 8, nil)
4170+
screen.SetVimEnabled(true)
4171+
typePromptText(&screen, "alpha beta gamma")
4172+
screen.ApplyKey(ParseKey("\x1b"))
4173+
for _, seq := range []string{"0", "w", "m", "a", "$", "`", "a"} {
4174+
screen.ApplyKey(ParseKey(seq))
4175+
}
4176+
if screen.Prompt.Cursor != len([]rune("alpha ")) {
4177+
t.Fatalf("cursor after `a = %d", screen.Prompt.Cursor)
4178+
}
4179+
4180+
screen = NewREPLScreen(40, 8, nil)
4181+
screen.SetVimEnabled(true)
4182+
typePromptText(&screen, "one\ntwo\nthree")
4183+
screen.ApplyKey(ParseKey("\x1b"))
4184+
for _, seq := range []string{"g", "g", "j", "l", "m", "a", "G", "'", "a"} {
4185+
screen.ApplyKey(ParseKey(seq))
4186+
}
4187+
if screen.Prompt.Cursor != len([]rune("one\n")) {
4188+
t.Fatalf("cursor after 'a = %d", screen.Prompt.Cursor)
4189+
}
4190+
4191+
screen = NewREPLScreen(40, 8, nil)
4192+
screen.SetVimEnabled(true)
4193+
typePromptText(&screen, "one\ntwo\nthree\nfour")
4194+
screen.ApplyKey(ParseKey("\x1b"))
4195+
for _, seq := range []string{"g", "g", "j", "m", "a", "G", "d", "'", "a"} {
4196+
screen.ApplyKey(ParseKey(seq))
4197+
}
4198+
if screen.Prompt.Text != "one" || screen.VimRegister != "two\nthree\nfour\n" || !screen.VimRegisterLinewise {
4199+
t.Fatalf("after d'a screen = %#v", screen)
4200+
}
41684201
}
41694202

41704203
func TestREPLScreenVimEditCommands(t *testing.T) {

internal/tui/vim.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type vimRecordedChange struct {
3636
Object rune
3737
Dir rune
3838
Below bool
39+
Linewise bool
3940
}
4041

4142
func (s *REPLScreen) SetVimEnabled(enabled bool) {
@@ -150,6 +151,9 @@ func (s *REPLScreen) applyVimNormalRune(r rune) ScreenEvent {
150151
if s.VimPendingTextObject != 0 {
151152
return s.applyVimTextObject(r)
152153
}
154+
if s.VimPendingMark != 0 {
155+
return s.applyVimMark(r)
156+
}
153157
if s.VimPendingG {
154158
return s.applyVimG(r)
155159
}
@@ -195,6 +199,9 @@ func (s *REPLScreen) applyVimNormalRune(r rune) ScreenEvent {
195199
s.VimPendingCount = count
196200
case 'G':
197201
s.Prompt.goToLine(vimGTargetLine(s.Prompt.lineCount(), count))
202+
case 'm', '`', '\'':
203+
s.VimPendingMark = r
204+
s.VimPendingCount = count
198205
case '>', '<':
199206
s.VimPendingIndent = r
200207
s.VimPendingCount = count
@@ -555,6 +562,10 @@ func (s *REPLScreen) applyVimOperator(r rune) ScreenEvent {
555562
s.VimPendingOperator = operator
556563
s.VimPendingTextObject = r
557564
s.VimPendingCount = count
565+
case '`', '\'':
566+
s.VimPendingOperator = operator
567+
s.VimPendingMark = r
568+
s.VimPendingCount = count
558569
case 'G':
559570
s.applyVimLineMotionOperator(operator, vimGTargetLine(s.Prompt.lineCount(), count), 'G', count)
560571
case 'g':
@@ -671,6 +682,48 @@ func (s *REPLScreen) applyVimTextObject(obj rune) ScreenEvent {
671682
return ScreenEvent{}
672683
}
673684

685+
func (s *REPLScreen) applyVimMark(mark rune) ScreenEvent {
686+
action := s.VimPendingMark
687+
operator := s.VimPendingOperator
688+
count := s.VimPendingCount
689+
s.clearVimPending()
690+
if !isVimMarkRune(mark) {
691+
return ScreenEvent{}
692+
}
693+
switch action {
694+
case 'm':
695+
if s.VimMarks == nil {
696+
s.VimMarks = make(map[rune]int)
697+
}
698+
s.VimMarks[mark] = s.Prompt.clampCursor(s.Prompt.Cursor)
699+
case '`', '\'':
700+
target, ok := s.VimMarks[mark]
701+
if !ok {
702+
return ScreenEvent{}
703+
}
704+
linewise := action == '\''
705+
if operator != 0 {
706+
s.applyVimMarkOperator(operator, mark, target, linewise, count)
707+
return ScreenEvent{}
708+
}
709+
if linewise {
710+
s.Prompt.goToLine(s.Prompt.lineIndexAtCursor(target) + 1)
711+
return ScreenEvent{}
712+
}
713+
s.Prompt.Cursor = s.Prompt.clampCursor(target)
714+
}
715+
return ScreenEvent{}
716+
}
717+
718+
func (s *REPLScreen) applyVimMarkOperator(operator rune, mark rune, target int, linewise bool, count int) {
719+
start, end, ok := s.Prompt.markMotionRange(target, linewise)
720+
if !ok {
721+
return
722+
}
723+
s.applyVimRangeOperator(operator, start, end, linewise)
724+
s.recordVimChange(vimRecordedChange{Kind: "operatorMark", Operator: operator, Target: mark, Count: count, Linewise: linewise})
725+
}
726+
674727
func (s *REPLScreen) applyVimCharMotion(target rune) ScreenEvent {
675728
motion := s.VimPendingCharMotion
676729
operator := s.VimPendingOperator
@@ -1057,6 +1110,12 @@ func (s *REPLScreen) replayVimLastChange() {
10571110
return
10581111
}
10591112
s.applyVimRangeOperator(change.Operator, start, end, false)
1113+
case "operatorMark":
1114+
target, ok := s.VimMarks[change.Target]
1115+
if !ok {
1116+
return
1117+
}
1118+
s.applyVimMarkOperator(change.Operator, change.Target, target, change.Linewise, change.Count)
10601119
}
10611120
}
10621121

@@ -1086,6 +1145,7 @@ func (s *REPLScreen) clearVimPending() {
10861145
s.VimPendingTextObject = 0
10871146
s.VimPendingG = false
10881147
s.VimPendingIndent = 0
1148+
s.VimPendingMark = 0
10891149
s.VimPendingCount = 0
10901150
s.VimCount = 0
10911151
s.VimPendingReplace = false
@@ -1132,6 +1192,10 @@ func isVimCountRune(r rune) bool {
11321192
return r >= '0' && r <= '9'
11331193
}
11341194

1195+
func isVimMarkRune(r rune) bool {
1196+
return r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z'
1197+
}
1198+
11351199
func applyN(count int, fn func()) {
11361200
if count <= 0 {
11371201
count = 1
@@ -1358,6 +1422,25 @@ func (p *PromptState) lineMotionRange(targetLine int) (int, int, bool) {
13581422
return start, end, true
13591423
}
13601424

1425+
func (p *PromptState) markMotionRange(target int, linewise bool) (int, int, bool) {
1426+
target = p.clampCursor(target)
1427+
if linewise {
1428+
return p.lineMotionRange(p.lineIndexAtCursor(target) + 1)
1429+
}
1430+
start := p.clampCursor(p.Cursor)
1431+
end := target
1432+
runes := []rune(p.Text)
1433+
if end >= start {
1434+
if end < len(runes) {
1435+
end++
1436+
}
1437+
} else if start < len(runes) {
1438+
start++
1439+
}
1440+
start, end, _, ok := orderedRange(start, end, false)
1441+
return start, end, ok
1442+
}
1443+
13611444
func (p *PromptState) lineRange(count int) (int, int) {
13621445
if count <= 0 {
13631446
count = 1

0 commit comments

Comments
 (0)