Skip to content

Commit 4fc4263

Browse files
author
SqlRush
committed
Support visual vim line joins
1 parent 1d0f998 commit 4fc4263

5 files changed

Lines changed: 126 additions & 38 deletions

File tree

docs/cc-100-roadmap.md

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

743743
本轮补充:TUI Vim prompt editing 增加 normal-mode `gJ` raw line join,不插入/规范化空格,并接入 dot-repeat。
744744

745+
本轮补充:TUI Vim prompt editing 增加 visual/visual-line `J`/`gJ` 行拼接,支持选择范围内的 whitespace-normalized join 和 raw join,并沿用 undo、`gv` selection 记忆和 dot-repeat change 记录。
746+
745747
本轮补充:prompt history 写入现在保留 image pasted-content 的 media type、filename、dimensions 和 image-cache source path 元数据,同时继续不把 inline base64 image bytes 或 text-paste hash 写进图片历史记录。
746748

747749
本轮补充: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
@@ -237,6 +237,7 @@ test/parity/ # golden tests against TS/official behavior
237237
- 本轮补充:TUI Vim prompt editing 支持 normal-mode `gv` 重新进入上一次 characterwise/linewise visual selection,后续 visual operator 会复用恢复出的选择范围。
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。
240+
- 本轮补充:TUI Vim prompt editing 增加 visual/visual-line `J`/`gJ` 行拼接,支持选择范围内的 whitespace-normalized join 和 raw join,并沿用 undo、`gv` selection 记忆和 dot-repeat change 记录。
240241
- 本轮补充:prompt history 写入现在保留 image pasted-content 的 media type、filename、dimensions 和 image-cache source path 元数据,同时继续不把 inline base64 image bytes 或 text-paste hash 写进图片历史记录。
241242
- 本轮补充:prompt history 读取旧 image pasted-content 记录时,如果缺少 source path 但对应 session 的 image-cache 文件仍存在,会自动补回 source path 并刷新内存 image path cache。
242243
- 本轮补充: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
@@ -234,6 +234,7 @@ M7 progress now includes:
234234
- `internal/tui`: Vim prompt editing now supports normal-mode `gv` reselecting the previous characterwise or linewise visual selection before applying another visual operator.
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.
237+
- `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.
237238
- `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.
238239
- `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.
239240
- `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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4003,6 +4003,43 @@ func TestREPLScreenVimVisualLineOperators(t *testing.T) {
40034003
if screen.VimMode != VimNormal || screen.Prompt.Text != "one\ntwo\nthree\nfour" || screen.Prompt.Cursor != len([]rune("one\n")) {
40044004
t.Fatalf("after visual line outdent screen = %#v", screen)
40054005
}
4006+
4007+
screen = NewREPLScreen(40, 8, nil)
4008+
screen.SetVimEnabled(true)
4009+
typePromptText(&screen, "one\n two\nthree")
4010+
screen.ApplyKey(ParseKey("\x1b"))
4011+
for _, seq := range []string{"g", "g", "V", "j", "J"} {
4012+
screen.ApplyKey(ParseKey(seq))
4013+
}
4014+
if screen.VimMode != VimNormal || screen.Prompt.Text != "one two\nthree" || screen.Prompt.Cursor != len([]rune("one")) {
4015+
t.Fatalf("after visual line J screen = %#v", screen)
4016+
}
4017+
screen.ApplyKey(ParseKey("."))
4018+
if screen.Prompt.Text != "one two three" || screen.Prompt.Cursor != len([]rune("one two")) {
4019+
t.Fatalf("after visual line J dot-repeat screen = %#v", screen)
4020+
}
4021+
4022+
screen = NewREPLScreen(40, 8, nil)
4023+
screen.SetVimEnabled(true)
4024+
typePromptText(&screen, "one\n two\nthree")
4025+
screen.ApplyKey(ParseKey("\x1b"))
4026+
for _, seq := range []string{"g", "g", "V", "j", "g", "J"} {
4027+
screen.ApplyKey(ParseKey(seq))
4028+
}
4029+
if screen.VimMode != VimNormal || screen.Prompt.Text != "one two\nthree" || screen.Prompt.Cursor != len([]rune("one")) {
4030+
t.Fatalf("after visual line gJ screen = %#v", screen)
4031+
}
4032+
4033+
screen = NewREPLScreen(40, 8, nil)
4034+
screen.SetVimEnabled(true)
4035+
typePromptText(&screen, "one\n two\nthree")
4036+
screen.ApplyKey(ParseKey("\x1b"))
4037+
for _, seq := range []string{"g", "g", "v", "j", "J"} {
4038+
screen.ApplyKey(ParseKey(seq))
4039+
}
4040+
if screen.VimMode != VimNormal || screen.Prompt.Text != "one two\nthree" || screen.Prompt.Cursor != len([]rune("one")) {
4041+
t.Fatalf("after visual J screen = %#v", screen)
4042+
}
40064043
}
40074044

40084045
func TestREPLScreenVimGLineNavigationAndOperators(t *testing.T) {

internal/tui/vim.go

Lines changed: 85 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,8 @@ func (s *REPLScreen) applyVimVisualRune(r rune) ScreenEvent {
311311
s.applyVimVisualToggleCase()
312312
case 'u', 'U':
313313
s.applyVimVisualChangeCase(r)
314+
case 'J':
315+
s.applyVimVisualJoin(false)
314316
case 'y', 'd', 'c':
315317
s.applyVimVisualOperator(r)
316318
case 'x':
@@ -341,6 +343,8 @@ func (s *REPLScreen) applyVimVisualG(r rune) ScreenEvent {
341343
applyN(count, func() { s.Prompt.moveWordBackwardEnd() })
342344
case 'E':
343345
applyN(count, func() { s.Prompt.moveWORDBackwardEnd() })
346+
case 'J':
347+
s.applyVimVisualJoin(true)
344348
}
345349
return ScreenEvent{}
346350
}
@@ -444,6 +448,25 @@ func (s *REPLScreen) applyVimVisualChangeCase(op rune) {
444448
s.clearVimVisualState()
445449
}
446450

451+
func (s *REPLScreen) applyVimVisualJoin(raw bool) {
452+
start, end, _, ok := s.vimVisualSelectionRange()
453+
if !ok {
454+
s.exitVimVisual()
455+
return
456+
}
457+
s.rememberVimVisualSelection()
458+
s.recordVimUndo()
459+
joined := s.Prompt.joinRange(start, end, raw)
460+
if joined > 0 {
461+
kind := "join"
462+
if raw {
463+
kind = "joinRaw"
464+
}
465+
s.recordVimChange(vimRecordedChange{Kind: kind, Count: joined})
466+
}
467+
s.clearVimVisualState()
468+
}
469+
447470
func (s *REPLScreen) applyVimOperator(r rune) ScreenEvent {
448471
if isVimCountRune(r) && (r != '0' || s.VimCount > 0) {
449472
s.VimCount = s.VimCount*10 + int(r-'0')
@@ -1403,58 +1426,82 @@ func (p *PromptState) joinLines(count int) {
14031426
if count <= 0 {
14041427
count = 1
14051428
}
1406-
lines := strings.Split(p.Text, "\n")
14071429
current := p.currentLogicalLine()
1408-
if current >= len(lines)-1 {
1409-
return
1410-
}
1411-
linesToJoin := count
1412-
if linesToJoin > len(lines)-current-1 {
1413-
linesToJoin = len(lines) - current - 1
1414-
}
1415-
joined := lines[current]
1416-
cursorPos := len([]rune(joined))
1417-
for i := 1; i <= linesToJoin; i++ {
1418-
next := strings.TrimLeftFunc(lines[current+i], unicode.IsSpace)
1419-
if next == "" {
1420-
continue
1421-
}
1422-
if joined != "" && !strings.HasSuffix(joined, " ") {
1423-
joined += " "
1424-
}
1425-
joined += next
1426-
}
1427-
newLines := make([]string, 0, len(lines)-linesToJoin)
1428-
newLines = append(newLines, lines[:current]...)
1429-
newLines = append(newLines, joined)
1430-
newLines = append(newLines, lines[current+linesToJoin+1:]...)
1431-
p.Text = strings.Join(newLines, "\n")
1432-
p.Cursor = lineStartOffset(newLines, current) + cursorPos
1433-
p.resetHistoryCursor()
1430+
p.joinLineRange(current, current+count, false)
14341431
}
14351432

14361433
func (p *PromptState) joinLinesRaw(count int) {
14371434
if count <= 0 {
14381435
count = 1
14391436
}
1440-
lines := strings.Split(p.Text, "\n")
14411437
current := p.currentLogicalLine()
1442-
if current >= len(lines)-1 {
1443-
return
1438+
p.joinLineRange(current, current+count, true)
1439+
}
1440+
1441+
func (p *PromptState) joinRange(start int, end int, raw bool) int {
1442+
lines := strings.Split(p.Text, "\n")
1443+
if len(lines) == 0 {
1444+
return 0
14441445
}
1445-
linesToJoin := count
1446-
if linesToJoin > len(lines)-current-1 {
1447-
linesToJoin = len(lines) - current - 1
1446+
start = p.clampCursor(start)
1447+
end = p.clampCursor(end)
1448+
if end < start {
1449+
start, end = end, start
1450+
}
1451+
inclusiveEnd := end
1452+
if inclusiveEnd > start {
1453+
inclusiveEnd--
1454+
}
1455+
startLine := p.lineIndexAtCursor(start)
1456+
endLine := p.lineIndexAtCursor(inclusiveEnd)
1457+
return p.joinLineRange(startLine, endLine, raw)
1458+
}
1459+
1460+
func (p *PromptState) joinLineRange(startLine int, endLine int, raw bool) int {
1461+
lines := strings.Split(p.Text, "\n")
1462+
if len(lines) == 0 {
1463+
return 0
1464+
}
1465+
if startLine < 0 {
1466+
startLine = 0
1467+
}
1468+
if endLine < startLine {
1469+
startLine, endLine = endLine, startLine
1470+
}
1471+
if startLine >= len(lines) {
1472+
startLine = len(lines) - 1
1473+
}
1474+
if endLine >= len(lines) {
1475+
endLine = len(lines) - 1
1476+
}
1477+
if endLine <= startLine {
1478+
return 0
1479+
}
1480+
linesToJoin := endLine - startLine
1481+
cursorPos := len([]rune(lines[startLine]))
1482+
joined := lines[startLine]
1483+
if raw {
1484+
joined = strings.Join(lines[startLine:endLine+1], "")
1485+
} else {
1486+
for i := startLine + 1; i <= endLine; i++ {
1487+
next := strings.TrimLeftFunc(lines[i], unicode.IsSpace)
1488+
if next == "" {
1489+
continue
1490+
}
1491+
if joined != "" && !strings.HasSuffix(joined, " ") {
1492+
joined += " "
1493+
}
1494+
joined += next
1495+
}
14481496
}
1449-
cursorPos := len([]rune(lines[current]))
1450-
joined := strings.Join(lines[current:current+linesToJoin+1], "")
14511497
newLines := make([]string, 0, len(lines)-linesToJoin)
1452-
newLines = append(newLines, lines[:current]...)
1498+
newLines = append(newLines, lines[:startLine]...)
14531499
newLines = append(newLines, joined)
1454-
newLines = append(newLines, lines[current+linesToJoin+1:]...)
1500+
newLines = append(newLines, lines[endLine+1:]...)
14551501
p.Text = strings.Join(newLines, "\n")
1456-
p.Cursor = lineStartOffset(newLines, current) + cursorPos
1502+
p.Cursor = lineStartOffset(newLines, startLine) + cursorPos
14571503
p.resetHistoryCursor()
1504+
return linesToJoin
14581505
}
14591506

14601507
func (p *PromptState) indentLines(dir rune, count int) {

0 commit comments

Comments
 (0)