Skip to content

Commit c1f3433

Browse files
author
SqlRush
committed
Support vim named registers
1 parent 0a9603c commit c1f3433

6 files changed

Lines changed: 193 additions & 7 deletions

File tree

docs/cc-100-roadmap.md

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

761761
本轮补充:TUI Vim prompt editing 将 `/``?``n``N` 接入 visual/visual-line 模式,搜索会临时进入 search prompt、Enter 后恢复 selection 并移动 active end,Esc 取消时保留原 visual selection。
762762

763+
本轮补充:TUI Vim prompt editing 增加 named register 前缀,支持 normal/operator/visual 路径里的 `"{reg}` yank/delete/paste、uppercase register append、black-hole register no-op,以及普通移动命令后清理未使用的 register selection。
764+
763765
本轮补充:prompt history 写入现在保留 image pasted-content 的 media type、filename、dimensions 和 image-cache source path 元数据,同时继续不把 inline base64 image bytes 或 text-paste hash 写进图片历史记录。
764766

765767
本轮补充: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
@@ -246,6 +246,7 @@ test/parity/ # golden tests against TS/official behavior
246246
- 本轮补充:TUI Vim prompt editing 增加 prompt-local `/``?` 搜索模式,支持 Enter 执行、Esc 取消、Backspace 编辑查询、wraparound 匹配,以及 `n`/`N` 重复上一搜索方向或反向搜索。
247247
- 本轮补充:TUI Vim prompt editing 将 `/``?` 搜索接入 operator motion,支持 `d/search``c?search`、搜索 count、取消清理 pending 状态,以及 search operator 的 dot-repeat 记录。
248248
- 本轮补充:TUI Vim prompt editing 将 `/``?``n``N` 接入 visual/visual-line 模式,搜索会临时进入 search prompt、Enter 后恢复 selection 并移动 active end,Esc 取消时保留原 visual selection。
249+
- 本轮补充:TUI Vim prompt editing 增加 named register 前缀,支持 normal/operator/visual 路径里的 `"{reg}` yank/delete/paste、uppercase register append、black-hole register no-op,以及普通移动命令后清理未使用的 register selection。
249250
- 本轮补充:prompt history 写入现在保留 image pasted-content 的 media type、filename、dimensions 和 image-cache source path 元数据,同时继续不把 inline base64 image bytes 或 text-paste hash 写进图片历史记录。
250251
- 本轮补充:prompt history 读取旧 image pasted-content 记录时,如果缺少 source path 但对应 session 的 image-cache 文件仍存在,会自动补回 source path 并刷新内存 image path cache。
251252
- 本轮补充: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
@@ -243,6 +243,7 @@ M7 progress now includes:
243243
- `internal/tui`: Vim prompt editing now includes prompt-local `/` and `?` search modes with Enter execution, Escape cancellation, Backspace query editing, wraparound matching, and `n`/`N` repeat or reverse-repeat search.
244244
- `internal/tui`: Vim prompt editing now treats `/` and `?` as operator motions for actions such as `d/search` and `c?search`, including search counts, pending-state cleanup on cancel, register updates, and dot-repeat recording for search operators.
245245
- `internal/tui`: Vim prompt editing now supports `/`, `?`, `n`, and `N` in visual and visual-line modes, returning from the search prompt to the active selection on Enter and preserving the original selection on Escape cancellation.
246+
- `internal/tui`: Vim prompt editing now supports `"{reg}` named register prefixes across normal/operator/visual yank, delete, and paste paths, including uppercase append registers, black-hole no-op handling, and cleanup of unused register selections after non-register commands.
246247
- `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.
247248
- `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.
248249
- `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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ type REPLScreen struct {
6969
VimReplayingChange bool
7070
VimRegister string
7171
VimRegisterLinewise bool
72+
VimRegisters map[rune]vimRegisterValue
73+
VimPendingRegister bool
74+
VimActiveRegister rune
7275
VimUndoStack []vimPromptSnapshot
7376
VimVisualAnchor int
7477
VimVisualLinewise bool

internal/tui/tui_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3768,6 +3768,63 @@ func TestREPLScreenVimYankPasteRegister(t *testing.T) {
37683768
}
37693769
}
37703770

3771+
func TestREPLScreenVimNamedRegisters(t *testing.T) {
3772+
screen := NewREPLScreen(40, 8, nil)
3773+
screen.SetVimEnabled(true)
3774+
typePromptText(&screen, "alpha beta gamma")
3775+
screen.ApplyKey(ParseKey("\x1b"))
3776+
for _, seq := range []string{"0", "\"", "a", "y", "w", "$", "\"", "a", "p"} {
3777+
screen.ApplyKey(ParseKey(seq))
3778+
}
3779+
if screen.Prompt.Text != "alpha beta gammaalpha " || screen.VimRegister != "alpha " || screen.VimRegisters['a'].Text != "alpha " || screen.VimRegisters['a'].Linewise {
3780+
t.Fatalf("after \"ayw \"ap screen = %#v", screen)
3781+
}
3782+
3783+
screen = NewREPLScreen(40, 8, nil)
3784+
screen.SetVimEnabled(true)
3785+
typePromptText(&screen, "alpha beta")
3786+
screen.ApplyKey(ParseKey("\x1b"))
3787+
for _, seq := range []string{"0", "\"", "a", "d", "w", "\"", "a", "P"} {
3788+
screen.ApplyKey(ParseKey(seq))
3789+
}
3790+
if screen.Prompt.Text != "alpha beta" || screen.VimRegister != "alpha " || screen.VimRegisters['a'].Text != "alpha " || screen.VimRegisters['a'].Linewise {
3791+
t.Fatalf("after \"adw \"aP screen = %#v", screen)
3792+
}
3793+
3794+
screen = NewREPLScreen(40, 8, nil)
3795+
screen.SetVimEnabled(true)
3796+
typePromptText(&screen, "one two three")
3797+
screen.ApplyKey(ParseKey("\x1b"))
3798+
for _, seq := range []string{"0", "\"", "a", "y", "w", "w", "\"", "A", "y", "w"} {
3799+
screen.ApplyKey(ParseKey(seq))
3800+
}
3801+
if screen.VimRegisters['a'].Text != "one two " || screen.VimRegister != "two " {
3802+
t.Fatalf("after uppercase append register screen = %#v", screen)
3803+
}
3804+
3805+
screen = NewREPLScreen(40, 8, nil)
3806+
screen.SetVimEnabled(true)
3807+
typePromptText(&screen, "red blue green")
3808+
screen.ApplyKey(ParseKey("\x1b"))
3809+
for _, seq := range []string{"0", "w", "v", "e", "\"", "b", "y", "$", "\"", "b", "P"} {
3810+
screen.ApplyKey(ParseKey(seq))
3811+
}
3812+
if screen.Prompt.Text != "red blue greenblue" || screen.VimRegisters['b'].Text != "blue" || screen.VimRegisters['b'].Linewise {
3813+
t.Fatalf("after visual \"by \"bP screen = %#v", screen)
3814+
}
3815+
3816+
screen = NewREPLScreen(40, 8, nil)
3817+
screen.SetVimEnabled(true)
3818+
typePromptText(&screen, "alpha beta")
3819+
screen.ApplyKey(ParseKey("\x1b"))
3820+
for _, seq := range []string{"0", "\"", "a", "l", "y", "w"} {
3821+
screen.ApplyKey(ParseKey(seq))
3822+
}
3823+
if _, ok := screen.VimRegisters['a']; ok {
3824+
t.Fatalf("register should not persist after non-register command: %#v", screen)
3825+
}
3826+
}
3827+
37713828
func TestREPLScreenVimLinewiseYankPaste(t *testing.T) {
37723829
screen := NewREPLScreen(40, 8, nil)
37733830
screen.SetVimEnabled(true)

internal/tui/vim.go

Lines changed: 129 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ type vimPromptSnapshot struct {
2727
PendingSpaceAfterImage bool
2828
}
2929

30+
type vimRegisterValue struct {
31+
Text string
32+
Linewise bool
33+
}
34+
3035
type vimRecordedChange struct {
3136
Kind string
3237
Text string
@@ -175,6 +180,9 @@ func (s *REPLScreen) applyVimNormalRune(r rune) ScreenEvent {
175180
if s.VimPendingMacro != 0 {
176181
return s.applyVimMacro(r)
177182
}
183+
if s.VimPendingRegister {
184+
return s.applyVimRegister(r)
185+
}
178186
if s.VimPendingG {
179187
return s.applyVimG(r)
180188
}
@@ -193,6 +201,9 @@ func (s *REPLScreen) applyVimNormalRune(r rune) ScreenEvent {
193201
return ScreenEvent{}
194202
}
195203
count := s.takeVimCount()
204+
if s.VimActiveRegister != 0 && r != '"' && !vimNormalCommandUsesRegister(r) {
205+
defer func() { s.VimActiveRegister = 0 }()
206+
}
196207
switch r {
197208
case 'i':
198209
s.enterVimInsert()
@@ -229,6 +240,9 @@ func (s *REPLScreen) applyVimNormalRune(r rune) ScreenEvent {
229240
case 'm', '`', '\'':
230241
s.VimPendingMark = r
231242
s.VimPendingCount = count
243+
case '"':
244+
s.VimPendingRegister = true
245+
s.VimPendingCount = count
232246
case 'q', '@':
233247
s.VimPendingMacro = r
234248
s.VimPendingCount = count
@@ -324,6 +338,9 @@ func (s *REPLScreen) applyVimVisualRune(r rune) ScreenEvent {
324338
if s.VimPendingG {
325339
return s.applyVimVisualG(r)
326340
}
341+
if s.VimPendingRegister {
342+
return s.applyVimRegister(r)
343+
}
327344
if isVimCountRune(r) {
328345
if r == '0' && s.VimCount == 0 {
329346
s.applyVimVisualMotion('0', 1)
@@ -333,6 +350,9 @@ func (s *REPLScreen) applyVimVisualRune(r rune) ScreenEvent {
333350
return ScreenEvent{}
334351
}
335352
count := s.takeVimCount()
353+
if s.VimActiveRegister != 0 && r != '"' && !vimVisualCommandUsesRegister(r) {
354+
defer func() { s.VimActiveRegister = 0 }()
355+
}
336356
switch r {
337357
case 'v':
338358
if s.VimMode == VimVisual {
@@ -354,6 +374,9 @@ func (s *REPLScreen) applyVimVisualRune(r rune) ScreenEvent {
354374
s.repeatVimSearch(true, count)
355375
case '/', '?':
356376
s.enterVimVisualSearch(r == '?', count)
377+
case '"':
378+
s.VimPendingRegister = true
379+
s.VimPendingCount = count
357380
case 'g':
358381
s.VimPendingG = true
359382
s.VimPendingCount = count
@@ -684,7 +707,8 @@ func (s *REPLScreen) applyVimVisualJoin(raw bool) {
684707
}
685708

686709
func (s *REPLScreen) applyVimVisualPaste() {
687-
if s.VimRegister == "" {
710+
replacement, registerLinewise, ok := s.takeVimPasteRegister()
711+
if !ok {
688712
s.exitVimVisual()
689713
return
690714
}
@@ -693,8 +717,7 @@ func (s *REPLScreen) applyVimVisualPaste() {
693717
s.exitVimVisual()
694718
return
695719
}
696-
replacement := s.VimRegister
697-
replacementLinewise := s.VimRegisterLinewise || strings.HasSuffix(replacement, "\n")
720+
replacementLinewise := registerLinewise || strings.HasSuffix(replacement, "\n")
698721
replaced := s.Prompt.rangeText(start, end)
699722
s.rememberVimVisualSelection()
700723
s.recordVimUndo()
@@ -933,6 +956,25 @@ func (s *REPLScreen) applyVimMacro(target rune) ScreenEvent {
933956
return ScreenEvent{}
934957
}
935958

959+
func (s *REPLScreen) applyVimRegister(target rune) ScreenEvent {
960+
count := s.VimPendingCount
961+
s.VimPendingRegister = false
962+
s.VimPendingCount = 0
963+
if !isVimRegisterRune(target) {
964+
s.VimActiveRegister = 0
965+
return ScreenEvent{}
966+
}
967+
if target == '"' {
968+
s.VimActiveRegister = 0
969+
} else {
970+
s.VimActiveRegister = target
971+
}
972+
if count > 1 {
973+
s.VimCount = count
974+
}
975+
return ScreenEvent{}
976+
}
977+
936978
func (s *REPLScreen) shouldStopVimMacroRecording(key Key) bool {
937979
if s.VimRecordingMacro == 0 || s.VimReplayingMacro || s.VimMode != VimNormal {
938980
return false
@@ -951,6 +993,7 @@ func (s *REPLScreen) hasVimPendingState() bool {
951993
s.VimPendingIndent != 0 ||
952994
s.VimPendingMark != 0 ||
953995
s.VimPendingMacro != 0 ||
996+
s.VimPendingRegister ||
954997
s.VimPendingReplace
955998
}
956999

@@ -1127,23 +1170,68 @@ func (s *REPLScreen) setVimRegister(content string, linewise bool) {
11271170
if linewise && !strings.HasSuffix(content, "\n") {
11281171
content += "\n"
11291172
}
1173+
if s.VimActiveRegister == '_' {
1174+
s.VimActiveRegister = 0
1175+
return
1176+
}
11301177
s.VimRegister = content
11311178
s.VimRegisterLinewise = linewise
1179+
s.writeVimNamedRegister(content, linewise)
1180+
s.VimActiveRegister = 0
1181+
}
1182+
1183+
func (s *REPLScreen) writeVimNamedRegister(content string, linewise bool) {
1184+
register := s.VimActiveRegister
1185+
if register == 0 || register == '"' || register == '_' || !isVimRegisterRune(register) {
1186+
return
1187+
}
1188+
key := normalizeVimRegister(register)
1189+
value := vimRegisterValue{Text: content, Linewise: linewise}
1190+
if isVimUpperRegister(register) {
1191+
if existing, ok := s.VimRegisters[key]; ok {
1192+
value.Text = existing.Text + content
1193+
value.Linewise = existing.Linewise || linewise
1194+
}
1195+
}
1196+
if s.VimRegisters == nil {
1197+
s.VimRegisters = make(map[rune]vimRegisterValue)
1198+
}
1199+
s.VimRegisters[key] = value
11321200
}
11331201

11341202
func (s *REPLScreen) applyVimPaste(after bool, count int) {
1135-
if s.VimRegister == "" {
1203+
register, linewise, ok := s.takeVimPasteRegister()
1204+
if !ok {
11361205
return
11371206
}
11381207
if count <= 0 {
11391208
count = 1
11401209
}
11411210
s.recordVimUndo()
1142-
if s.VimRegisterLinewise || strings.HasSuffix(s.VimRegister, "\n") {
1143-
s.Prompt.pasteLinewise(s.VimRegister, after, count)
1211+
if linewise || strings.HasSuffix(register, "\n") {
1212+
s.Prompt.pasteLinewise(register, after, count)
11441213
return
11451214
}
1146-
s.Prompt.pasteCharacterwise(s.VimRegister, after, count)
1215+
s.Prompt.pasteCharacterwise(register, after, count)
1216+
}
1217+
1218+
func (s *REPLScreen) takeVimPasteRegister() (string, bool, bool) {
1219+
register := s.VimActiveRegister
1220+
s.VimActiveRegister = 0
1221+
switch {
1222+
case register == 0 || register == '"':
1223+
return s.VimRegister, s.VimRegisterLinewise, s.VimRegister != ""
1224+
case register == '_':
1225+
return "", false, false
1226+
case !isVimRegisterRune(register):
1227+
return "", false, false
1228+
default:
1229+
value, ok := s.VimRegisters[normalizeVimRegister(register)]
1230+
if !ok || value.Text == "" {
1231+
return "", false, false
1232+
}
1233+
return value.Text, value.Linewise, true
1234+
}
11471235
}
11481236

11491237
func (s *REPLScreen) applyVimRepeatedCharMotion(count int, reverse bool) {
@@ -1401,6 +1489,7 @@ func (s *REPLScreen) clearVimPending() {
14011489
s.VimPendingIndent = 0
14021490
s.VimPendingMark = 0
14031491
s.VimPendingMacro = 0
1492+
s.VimPendingRegister = false
14041493
s.VimPendingCount = 0
14051494
s.VimCount = 0
14061495
s.VimPendingReplace = false
@@ -1455,6 +1544,39 @@ func isVimMacroRegisterRune(r rune) bool {
14551544
return r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9'
14561545
}
14571546

1547+
func isVimRegisterRune(r rune) bool {
1548+
return r == '"' || r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9'
1549+
}
1550+
1551+
func vimNormalCommandUsesRegister(r rune) bool {
1552+
switch r {
1553+
case 'd', 'c', 'y', 'Y', 'p', 'P', 's', 'S', 'D', 'C':
1554+
return true
1555+
default:
1556+
return false
1557+
}
1558+
}
1559+
1560+
func vimVisualCommandUsesRegister(r rune) bool {
1561+
switch r {
1562+
case 'y', 'd', 'c', 'p', 'P', 'x', 's':
1563+
return true
1564+
default:
1565+
return false
1566+
}
1567+
}
1568+
1569+
func isVimUpperRegister(r rune) bool {
1570+
return r >= 'A' && r <= 'Z'
1571+
}
1572+
1573+
func normalizeVimRegister(r rune) rune {
1574+
if isVimUpperRegister(r) {
1575+
return unicode.ToLower(r)
1576+
}
1577+
return r
1578+
}
1579+
14581580
func applyN(count int, fn func()) {
14591581
if count <= 0 {
14601582
count = 1

0 commit comments

Comments
 (0)