Skip to content

Commit 8de4e11

Browse files
author
SqlRush
committed
Support visual vim paste replace
1 parent 4fc4263 commit 8de4e11

5 files changed

Lines changed: 123 additions & 0 deletions

File tree

docs/cc-100-roadmap.md

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

745745
本轮补充:TUI Vim prompt editing 增加 visual/visual-line `J`/`gJ` 行拼接,支持选择范围内的 whitespace-normalized join 和 raw join,并沿用 undo、`gv` selection 记忆和 dot-repeat change 记录。
746746

747+
本轮补充:TUI Vim prompt editing 增加 visual/visual-line `p`/`P` 粘贴替换 selection,支持 characterwise 和 linewise register,替换出的文本会回写 unnamed register,并避免行选择替换到末尾时留下额外空行。
748+
747749
本轮补充:prompt history 写入现在保留 image pasted-content 的 media type、filename、dimensions 和 image-cache source path 元数据,同时继续不把 inline base64 image bytes 或 text-paste hash 写进图片历史记录。
748750

749751
本轮补充: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
@@ -238,6 +238,7 @@ test/parity/ # golden tests against TS/official behavior
238238
- 本轮补充:TUI Vim prompt editing 增加 `gu`/`gU`/`g~` case-conversion operator,复用 motion、linewise、find/till、text-object 和 dot-repeat operator 管线,并保持大小写转换不写入 yank register。
239239
- 本轮补充:TUI Vim prompt editing 增加 normal-mode `gJ` raw line join,不插入/规范化空格,并接入 dot-repeat。
240240
- 本轮补充:TUI Vim prompt editing 增加 visual/visual-line `J`/`gJ` 行拼接,支持选择范围内的 whitespace-normalized join 和 raw join,并沿用 undo、`gv` selection 记忆和 dot-repeat change 记录。
241+
- 本轮补充:TUI Vim prompt editing 增加 visual/visual-line `p`/`P` 粘贴替换 selection,支持 characterwise 和 linewise register,替换出的文本会回写 unnamed register,并避免行选择替换到末尾时留下额外空行。
241242
- 本轮补充:prompt history 写入现在保留 image pasted-content 的 media type、filename、dimensions 和 image-cache source path 元数据,同时继续不把 inline base64 image bytes 或 text-paste hash 写进图片历史记录。
242243
- 本轮补充:prompt history 读取旧 image pasted-content 记录时,如果缺少 source path 但对应 session 的 image-cache 文件仍存在,会自动补回 source path 并刷新内存 image path cache。
243244
- 本轮补充: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
@@ -235,6 +235,7 @@ M7 progress now includes:
235235
- `internal/tui`: Vim prompt editing now includes `gu`/`gU`/`g~` case-conversion operators across motion, linewise, find/till, text-object, and dot-repeat paths without updating the yank register.
236236
- `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.
237237
- `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.
238+
- `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.
238239
- `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.
239240
- `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.
240241
- `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/tui_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3891,6 +3891,28 @@ func TestREPLScreenVimVisualCharOperators(t *testing.T) {
38913891
if screen.VimMode != VimNormal || screen.Prompt.Text != " one\n two\nthree" || screen.Prompt.Cursor != len([]rune(" ")) {
38923892
t.Fatalf("after visual indent screen = %#v", screen)
38933893
}
3894+
3895+
screen = NewREPLScreen(40, 8, nil)
3896+
screen.SetVimEnabled(true)
3897+
typePromptText(&screen, "alpha beta gamma")
3898+
screen.ApplyKey(ParseKey("\x1b"))
3899+
for _, seq := range []string{"0", "w", "v", "e", "y", "$", "b", "v", "e", "p"} {
3900+
screen.ApplyKey(ParseKey(seq))
3901+
}
3902+
if screen.VimMode != VimNormal || screen.Prompt.Text != "alpha beta beta" || screen.Prompt.Cursor != len([]rune("alpha beta beta"))-1 || screen.VimRegister != "gamma" || screen.VimRegisterLinewise {
3903+
t.Fatalf("after visual paste screen = %#v", screen)
3904+
}
3905+
3906+
screen = NewREPLScreen(40, 8, nil)
3907+
screen.SetVimEnabled(true)
3908+
typePromptText(&screen, "red blue green")
3909+
screen.ApplyKey(ParseKey("\x1b"))
3910+
for _, seq := range []string{"0", "w", "v", "e", "y", "$", "b", "v", "e", "P"} {
3911+
screen.ApplyKey(ParseKey(seq))
3912+
}
3913+
if screen.VimMode != VimNormal || screen.Prompt.Text != "red blue blue" || screen.Prompt.Cursor != len([]rune("red blue blue"))-1 || screen.VimRegister != "green" || screen.VimRegisterLinewise {
3914+
t.Fatalf("after visual P screen = %#v", screen)
3915+
}
38943916
}
38953917

38963918
func TestREPLScreenVimVisualLineOperators(t *testing.T) {
@@ -4040,6 +4062,17 @@ func TestREPLScreenVimVisualLineOperators(t *testing.T) {
40404062
if screen.VimMode != VimNormal || screen.Prompt.Text != "one two\nthree" || screen.Prompt.Cursor != len([]rune("one")) {
40414063
t.Fatalf("after visual J screen = %#v", screen)
40424064
}
4065+
4066+
screen = NewREPLScreen(40, 8, nil)
4067+
screen.SetVimEnabled(true)
4068+
typePromptText(&screen, "one\ntwo\nthree\nfour")
4069+
screen.ApplyKey(ParseKey("\x1b"))
4070+
for _, seq := range []string{"g", "g", "y", "y", "j", "V", "j", "p"} {
4071+
screen.ApplyKey(ParseKey(seq))
4072+
}
4073+
if screen.VimMode != VimNormal || screen.Prompt.Text != "one\none\nfour" || screen.Prompt.Cursor != len([]rune("one\n")) || screen.VimRegister != "two\nthree\n" || !screen.VimRegisterLinewise {
4074+
t.Fatalf("after visual line paste screen = %#v", screen)
4075+
}
40434076
}
40444077

40454078
func TestREPLScreenVimGLineNavigationAndOperators(t *testing.T) {

internal/tui/vim.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,8 @@ func (s *REPLScreen) applyVimVisualRune(r rune) ScreenEvent {
315315
s.applyVimVisualJoin(false)
316316
case 'y', 'd', 'c':
317317
s.applyVimVisualOperator(r)
318+
case 'p', 'P':
319+
s.applyVimVisualPaste()
318320
case 'x':
319321
s.applyVimVisualOperator('d')
320322
case 's':
@@ -467,6 +469,26 @@ func (s *REPLScreen) applyVimVisualJoin(raw bool) {
467469
s.clearVimVisualState()
468470
}
469471

472+
func (s *REPLScreen) applyVimVisualPaste() {
473+
if s.VimRegister == "" {
474+
s.exitVimVisual()
475+
return
476+
}
477+
start, end, linewise, ok := s.vimVisualSelectionRange()
478+
if !ok {
479+
s.exitVimVisual()
480+
return
481+
}
482+
replacement := s.VimRegister
483+
replacementLinewise := s.VimRegisterLinewise || strings.HasSuffix(replacement, "\n")
484+
replaced := s.Prompt.rangeText(start, end)
485+
s.rememberVimVisualSelection()
486+
s.recordVimUndo()
487+
s.Prompt.replaceRangeWithPaste(start, end, linewise, replacement, replacementLinewise)
488+
s.setVimRegister(replaced, linewise)
489+
s.clearVimVisualState()
490+
}
491+
470492
func (s *REPLScreen) applyVimOperator(r rune) ScreenEvent {
471493
if isVimCountRune(r) && (r != '0' || s.VimCount > 0) {
472494
s.VimCount = s.VimCount*10 + int(r-'0')
@@ -1624,6 +1646,70 @@ func (p *PromptState) pasteLinewise(content string, after bool, count int) {
16241646
p.resetHistoryCursor()
16251647
}
16261648

1649+
func (p *PromptState) replaceRangeWithPaste(start int, end int, linewise bool, content string, contentLinewise bool) {
1650+
if linewise || contentLinewise {
1651+
p.replaceLineRangeWithPaste(start, end, content)
1652+
return
1653+
}
1654+
runes := []rune(p.Text)
1655+
start = p.clampCursor(start)
1656+
end = p.clampCursor(end)
1657+
if end < start {
1658+
start, end = end, start
1659+
}
1660+
insert := []rune(content)
1661+
replaced := make([]rune, 0, len(runes)-(end-start)+len(insert))
1662+
replaced = append(replaced, runes[:start]...)
1663+
replaced = append(replaced, insert...)
1664+
replaced = append(replaced, runes[end:]...)
1665+
p.Text = string(replaced)
1666+
if len(insert) > 0 {
1667+
p.Cursor = start + len(insert) - 1
1668+
} else {
1669+
p.Cursor = start
1670+
}
1671+
p.resetHistoryCursor()
1672+
}
1673+
1674+
func (p *PromptState) replaceLineRangeWithPaste(start int, end int, content string) {
1675+
lines := strings.Split(p.Text, "\n")
1676+
if len(lines) == 0 {
1677+
return
1678+
}
1679+
start = p.clampCursor(start)
1680+
end = p.clampCursor(end)
1681+
if end < start {
1682+
start, end = end, start
1683+
}
1684+
inclusiveEnd := end
1685+
if inclusiveEnd > start {
1686+
inclusiveEnd--
1687+
}
1688+
startLine := p.lineIndexAtCursor(start)
1689+
endLine := p.lineIndexAtCursor(inclusiveEnd)
1690+
if startLine < 0 {
1691+
startLine = 0
1692+
}
1693+
if endLine < startLine {
1694+
startLine, endLine = endLine, startLine
1695+
}
1696+
if startLine >= len(lines) {
1697+
startLine = len(lines) - 1
1698+
}
1699+
if endLine >= len(lines) {
1700+
endLine = len(lines) - 1
1701+
}
1702+
content = strings.TrimSuffix(content, "\n")
1703+
insertLines := strings.Split(content, "\n")
1704+
newLines := make([]string, 0, len(lines)-(endLine-startLine+1)+len(insertLines))
1705+
newLines = append(newLines, lines[:startLine]...)
1706+
newLines = append(newLines, insertLines...)
1707+
newLines = append(newLines, lines[endLine+1:]...)
1708+
p.Text = strings.Join(newLines, "\n")
1709+
p.Cursor = lineStartOffset(newLines, startLine)
1710+
p.resetHistoryCursor()
1711+
}
1712+
16271713
func lineStartOffset(lines []string, lineIndex int) int {
16281714
if lineIndex <= 0 {
16291715
return 0

0 commit comments

Comments
 (0)