Skip to content

Commit 064feca

Browse files
author
SqlRush
committed
Support touch mouse script events
1 parent 75a1502 commit 064feca

6 files changed

Lines changed: 149 additions & 8 deletions

File tree

docs/cc-100-roadmap.md

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

705705
本轮补充:interaction script mouse payload 现在会把 DOM `mousemove`/`pointermove` 的 buttonless motion 映射成 SGR `35`,把带 `buttons`/`which` 的 move/drag 映射成 SGR motion button,避免 hover/move 录制事件误触发 dialog/viewport primary click。
706706

707+
本轮补充:interaction script touch payload 现在可从 `touches`/`targetTouches`/`changedTouches` 的首个 touch point 恢复坐标,`touchmove` 映射为 SGR drag motion,`touchcancel` 映射为 release,减少 DOM touch 录制 fixture 的手工改写。
708+
709+
本轮补充:REPL dialog 鼠标处理现在忽略 SGR motion/drag button,只响应实际 press/click,避免 pointer/touch move 回放关闭 permission/task dialog。
710+
707711
本轮补充:prompt/image history 的 `ImageDimensions` 读取 `width`/`height` 或仅 original 尺寸时,会默认 display 尺寸等于 original 尺寸,避免只有单尺寸字段的 image fixture 丢失 source metadata。
708712

709713
本轮补充:prompt history 的 pasted-content 类型现在会归一化 `inputImage`/`pasted-image`/`input_text`/`pasted-text` 等别名,runtime history 和 stored history 恢复都会映射到规范 `image`/`text`

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ test/parity/ # golden tests against TS/official behavior
236236
- 本轮补充:interaction script mouse payload 现在可从 `wheel`/`mousewheel``scrollUp`/`scrollDown``direction``deltaY` 和旧式 `wheelDelta` 推导 SGR wheel button,录制的 DOM/compact 滚轮事件可直接驱动 viewport 滚动。
237237
- 本轮补充:interaction script mouse payload 现在接受 DOM `which``buttons`/`buttonState` bitmask,并映射到 SGR left/middle/right button,避免录制脚本的右键/中键被误当成 primary click。
238238
- 本轮补充:interaction script mouse payload 现在会把 DOM `mousemove`/`pointermove` 的 buttonless motion 映射成 SGR `35`,把带 `buttons`/`which` 的 move/drag 映射成 SGR motion button,避免 hover/move 录制事件误触发 dialog/viewport primary click。
239+
- 本轮补充:interaction script touch payload 现在可从 `touches`/`targetTouches`/`changedTouches` 的首个 touch point 恢复坐标,`touchmove` 映射为 SGR drag motion,`touchcancel` 映射为 release,减少 DOM touch 录制 fixture 的手工改写。
240+
- 本轮补充:REPL dialog 鼠标处理现在忽略 SGR motion/drag button,只响应实际 press/click,避免 pointer/touch move 回放关闭 permission/task dialog。
239241
- 本轮补充:嵌套 contract message 接受 `messageId`/`messageID`/`message_id``messageUuid`/`messageUUID`/`message_uuid` 作为自身 ID/UUID 别名,indexed resume 会保留 payload 自带的 nested message id。
240242
- 本轮补充:嵌套 contract message 的 primary `id` 现在接受 JSON number,`LoadTranscript` 和 indexed resume 会保留为字符串 message id。
241243
- 本轮补充:基础 `SessionEntry` JSONL loader 接受 `role`/`entryType`/`messageType`、message ID/UUID、parent ID/UUID 和 `sessionID`/`session`/session UUID 别名,旧 entry 文件可通过 `session.Load` 保留类型、parent 和 session。

docs/first-second-parity-audit.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,8 @@ M7 progress now includes:
227227
- `internal/tui`: interaction script mouse payloads now infer SGR wheel buttons from DOM/compact wheel inputs such as `wheel`, `mousewheel`, `scrollUp`/`scrollDown`, `direction`, `deltaY`, and legacy `wheelDelta`, allowing recorded wheel events to drive viewport scrolling.
228228
- `internal/tui`: interaction script mouse payloads now accept DOM `which` and `buttons`/`buttonState` bitmasks and map them to SGR left/middle/right buttons so recorded secondary or auxiliary clicks are not treated as primary clicks.
229229
- `internal/tui`: interaction script mouse payloads now map buttonless DOM `mousemove`/`pointermove` events to SGR buttonless motion and pressed move/drag events to SGR motion buttons, preventing hover/move recordings from firing dialog or viewport primary-click actions.
230+
- `internal/tui`: interaction script touch payloads now recover coordinates from the first `touches`/`targetTouches`/`changedTouches` touch point, map `touchmove` to SGR drag motion, and map `touchcancel` to release events for DOM touch recording fixtures.
231+
- `internal/tui`: REPL dialogs now ignore SGR motion/drag mouse buttons and only act on real press/click events, preventing pointer or touch move replay from closing permission/task dialogs.
230232
- `internal/tui`: task runtime now canonicalizes state aliases such as `active`/`in_progress`, `success`/`done`, `error`, and `canceled` before status-line summaries, task dialogs, ordering, cancellation, and scripted task expectations.
231233
- `internal/tui`: scripted task runtime payloads and task expectations now accept adjacent fields such as `taskID`, `jobId`, `runId`, `label`, `displayName`, `phase`, `taskState`, `message`, `currentStep`, `percent`, `percentage`, and numeric/string task IDs or progress values.
232234
- `internal/tui`: permission runtime now treats action aliases such as `Reject`, `deny`, `decline`, `disallow`, and `no` as denied results, `Cancel`/`abort` as cancelled results, and keeps allowed aliases canonical for scripted dialog-result status checks.

internal/tui/screen.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ func (s *REPLScreen) applyMouse(key Key) ScreenEvent {
389389
if key.MouseRelease {
390390
return ScreenEvent{}
391391
}
392-
if s.Dialog != nil && isPrimaryMouseAction(key.MouseButton) {
392+
if s.Dialog != nil && isPrimaryMouseAction(key.MouseButton) && !isMouseMotionAction(key.MouseButton) {
393393
if index, ok := s.dialogActionAtMouse(key.MouseX, key.MouseY); ok {
394394
dialogID := s.Dialog.ID
395395
dialogKind := s.Dialog.Kind
@@ -411,14 +411,19 @@ func (s *REPLScreen) applyMouse(key Key) ScreenEvent {
411411
}
412412

413413
const (
414-
sgrMouseBaseMask = 3
415-
sgrMouseWheelMask = 64
414+
sgrMouseBaseMask = 3
415+
sgrMouseMotionMask = 32
416+
sgrMouseWheelMask = 64
416417
)
417418

418419
func isPrimaryMouseAction(button int) bool {
419420
return button&sgrMouseWheelMask == 0 && button&sgrMouseBaseMask == 0
420421
}
421422

423+
func isMouseMotionAction(button int) bool {
424+
return button&sgrMouseWheelMask == 0 && button&sgrMouseMotionMask != 0
425+
}
426+
422427
func mouseWheelDelta(button int) int {
423428
if button&sgrMouseWheelMask == 0 {
424429
return 0

internal/tui/script_aliases.go

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3928,11 +3928,20 @@ func (mouse *ScriptMouse) UnmarshalJSON(data []byte) error {
39283928
} else if button := intPtrJSONField(fields, "Button", "button", "Btn", "btn", "Code", "code", "Mask", "mask"); button != nil {
39293929
mouse.Button = *button
39303930
}
3931-
if x := intPtrJSONField(fields, "X", "x", "Column", "column", "Col", "col", "MouseX", "mouse_x", "mouseX", "ClientX", "client_x", "clientX", "ScreenX", "screen_x", "screenX", "PageX", "page_x", "pageX", "OffsetX", "offset_x", "offsetX", "ViewportX", "viewport_x", "viewportX"); x != nil {
3931+
touchPoint := scriptMouseTouchPointFields(fields)
3932+
if x := scriptMouseXJSONField(fields); x != nil {
39323933
mouse.X = *x
3934+
} else if touchPoint != nil {
3935+
if x := scriptMouseXJSONField(touchPoint); x != nil {
3936+
mouse.X = *x
3937+
}
39333938
}
3934-
if y := intPtrJSONField(fields, "Y", "y", "Row", "row", "Line", "line", "MouseY", "mouse_y", "mouseY", "ClientY", "client_y", "clientY", "ScreenY", "screen_y", "screenY", "PageY", "page_y", "pageY", "OffsetY", "offset_y", "offsetY", "ViewportY", "viewport_y", "viewportY"); y != nil {
3939+
if y := scriptMouseYJSONField(fields); y != nil {
39353940
mouse.Y = *y
3941+
} else if touchPoint != nil {
3942+
if y := scriptMouseYJSONField(touchPoint); y != nil {
3943+
mouse.Y = *y
3944+
}
39363945
}
39373946
if release := boolPtrJSONField(fields, "Release", "release", "Released", "released", "IsRelease", "is_release", "isRelease", "MouseRelease", "mouse_release", "mouseRelease", "MouseUp", "mouse_up", "mouseUp", "Up", "up", "ReleaseEvent", "release_event", "releaseEvent", "ReleasedEvent", "released_event", "releasedEvent"); release != nil {
39383947
mouse.Release = *release
@@ -3944,9 +3953,9 @@ func (mouse *ScriptMouse) UnmarshalJSON(data []byte) error {
39443953

39453954
func scriptMouseReleaseFromType(fields map[string]json.RawMessage) (bool, bool) {
39463955
switch scriptMouseEventName(fields) {
3947-
case "mouseup", "mouse-up", "pointerup", "pointer-up", "touchend", "touch-end", "release", "released", "buttonup", "button-up":
3956+
case "mouseup", "mouse-up", "pointerup", "pointer-up", "touchend", "touch-end", "touchcancel", "touch-cancel", "release", "released", "buttonup", "button-up":
39483957
return true, true
3949-
case "mousedown", "mouse-down", "pointerdown", "pointer-down", "touchstart", "touch-start", "click", "mousemove", "mouse-move", "pointermove", "pointer-move", "drag", "dragstart", "drag-start", "dragmove", "drag-move":
3958+
case "mousedown", "mouse-down", "pointerdown", "pointer-down", "touchstart", "touch-start", "click", "mousemove", "mouse-move", "pointermove", "pointer-move", "touchmove", "touch-move", "drag", "dragstart", "drag-start", "dragmove", "drag-move":
39503959
return false, true
39513960
default:
39523961
return false, false
@@ -4043,13 +4052,78 @@ func scriptMouseIsMotionEvent(fields map[string]json.RawMessage) bool {
40434052

40444053
func scriptMouseIsDragEvent(fields map[string]json.RawMessage) bool {
40454054
switch scriptMouseEventName(fields) {
4046-
case "drag", "dragstart", "drag-start", "dragmove", "drag-move":
4055+
case "touchmove", "touch-move", "drag", "dragstart", "drag-start", "dragmove", "drag-move":
40474056
return true
40484057
default:
40494058
return false
40504059
}
40514060
}
40524061

4062+
func scriptMouseXJSONField(fields map[string]json.RawMessage) *int {
4063+
return intPtrJSONField(fields, "X", "x", "Column", "column", "Col", "col", "MouseX", "mouse_x", "mouseX", "ClientX", "client_x", "clientX", "ScreenX", "screen_x", "screenX", "PageX", "page_x", "pageX", "OffsetX", "offset_x", "offsetX", "ViewportX", "viewport_x", "viewportX")
4064+
}
4065+
4066+
func scriptMouseYJSONField(fields map[string]json.RawMessage) *int {
4067+
return intPtrJSONField(fields, "Y", "y", "Row", "row", "Line", "line", "MouseY", "mouse_y", "mouseY", "ClientY", "client_y", "clientY", "ScreenY", "screen_y", "screenY", "PageY", "page_y", "pageY", "OffsetY", "offset_y", "offsetY", "ViewportY", "viewport_y", "viewportY")
4068+
}
4069+
4070+
func scriptMouseTouchPointFields(fields map[string]json.RawMessage) map[string]json.RawMessage {
4071+
eventName := scriptMouseEventName(fields)
4072+
names := []string{"Touches", "touches", "TargetTouches", "target_touches", "targetTouches", "ChangedTouches", "changed_touches", "changedTouches", "Touch", "touch", "Point", "point", "Contact", "contact"}
4073+
if eventName == "touchend" || eventName == "touch-end" || eventName == "touchcancel" || eventName == "touch-cancel" {
4074+
names = []string{"ChangedTouches", "changed_touches", "changedTouches", "Touches", "touches", "TargetTouches", "target_touches", "targetTouches", "Touch", "touch", "Point", "point", "Contact", "contact"}
4075+
}
4076+
for _, name := range names {
4077+
raw, ok := fields[name]
4078+
if !ok {
4079+
continue
4080+
}
4081+
if point, ok := scriptMousePointFieldsFromJSON(raw, 0); ok {
4082+
return point
4083+
}
4084+
}
4085+
return nil
4086+
}
4087+
4088+
func scriptMousePointFieldsFromJSON(raw json.RawMessage, depth int) (map[string]json.RawMessage, bool) {
4089+
raw = bytes.TrimSpace(raw)
4090+
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) || depth >= 8 {
4091+
return nil, false
4092+
}
4093+
if raw[0] == '[' {
4094+
var items []json.RawMessage
4095+
if err := json.Unmarshal(raw, &items); err != nil {
4096+
return nil, false
4097+
}
4098+
for _, item := range items {
4099+
if point, ok := scriptMousePointFieldsFromJSON(item, depth+1); ok {
4100+
return point, true
4101+
}
4102+
}
4103+
return nil, false
4104+
}
4105+
if raw[0] != '{' {
4106+
return nil, false
4107+
}
4108+
fields := map[string]json.RawMessage{}
4109+
if err := json.Unmarshal(raw, &fields); err != nil {
4110+
return nil, false
4111+
}
4112+
if scriptMouseXJSONField(fields) != nil || scriptMouseYJSONField(fields) != nil {
4113+
return fields, true
4114+
}
4115+
for _, name := range []string{"value", "payload", "data", "body", "resource", "attributes", "properties", "attrs", "edge", "node", "touch", "point", "contact"} {
4116+
nested, ok := fields[name]
4117+
if !ok {
4118+
continue
4119+
}
4120+
if point, ok := scriptMousePointFieldsFromJSON(nested, depth+1); ok {
4121+
return point, true
4122+
}
4123+
}
4124+
return nil, false
4125+
}
4126+
40534127
func scriptMouseWheelDirection(fields map[string]json.RawMessage) int {
40544128
direction := stringJSONField(fields, "Direction", "direction", "WheelDirection", "wheel_direction", "wheelDirection", "ScrollDirection", "scroll_direction", "scrollDirection")
40554129
switch scriptNormalizeName(direction) {

internal/tui/tui_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2162,6 +2162,15 @@ func TestREPLScreenDialogFocusAndConfirm(t *testing.T) {
21622162
if screen.Dialog != nil {
21632163
t.Fatalf("dialog should close after shift mouse click")
21642164
}
2165+
2166+
screen.Dialog = &Dialog{Title: "Permission", Body: "Allow?", Actions: []string{"Allow", "Deny"}, ID: "perm_4", Kind: DialogPermission}
2167+
motion := screen.ApplyKey(ParseKey("\x1b[<32;13;5M"))
2168+
if motion.Type != ScreenEventNone {
2169+
t.Fatalf("dialog mouse motion should be ignored: %#v", motion)
2170+
}
2171+
if screen.Dialog == nil || screen.Dialog.ID != "perm_4" {
2172+
t.Fatalf("dialog should remain after mouse motion: %#v", screen.Dialog)
2173+
}
21652174
}
21662175

21672176
func TestREPLScreenRedrawEvent(t *testing.T) {
@@ -5112,6 +5121,26 @@ func TestRunInteractionScriptIgnoresDOMButtonlessMouseMove(t *testing.T) {
51125121
}
51135122
}
51145123

5124+
func TestRunInteractionScriptAcceptsTouchMousePayloads(t *testing.T) {
5125+
steps, err := ParseInteractionScript([]byte(`[
5126+
{"dialog":{"title":"Permission","body":"Allow?","actions":["Allow","Deny"],"id":"perm_touch","kind":"permission"}},
5127+
{"mouseEvent":{"type":"touchmove","touches":[{"clientX":13,"clientY":5}]},"expectNoEvent":true,"expectDialog":{"active":true,"id":"perm_touch"}},
5128+
{"mouseEvent":{"type":"touchcancel","changedTouches":[{"clientX":13,"clientY":5}]},"expectNoEvent":true,"expectDialog":{"active":true,"id":"perm_touch"}},
5129+
{"mouseEvent":{"type":"touchstart","touches":[{"clientX":13,"clientY":5}]},"expectEvent":{"type":"dialog_action","value":"Deny","dialogId":"perm_touch","dialogKind":"permission"}}
5130+
]`))
5131+
if err != nil {
5132+
t.Fatal(err)
5133+
}
5134+
screen := NewREPLScreen(40, 8, nil)
5135+
result, err := RunInteractionScriptChecked(&screen, steps)
5136+
if err != nil {
5137+
t.Fatal(err)
5138+
}
5139+
if len(result.Events) != 1 || result.Events[0].Type != ScreenEventDialogAction || result.Events[0].DialogID != "perm_touch" || result.Events[0].Value != "Deny" {
5140+
t.Fatalf("events = %#v", result.Events)
5141+
}
5142+
}
5143+
51155144
func TestScriptMouseAcceptsCoordinateAliases(t *testing.T) {
51165145
for _, tc := range []struct {
51175146
name string
@@ -5231,6 +5260,31 @@ func TestScriptMouseMapsDOMMotionButtons(t *testing.T) {
52315260
}
52325261
}
52335262

5263+
func TestScriptMouseAcceptsTouchPointAliases(t *testing.T) {
5264+
for _, tc := range []struct {
5265+
name string
5266+
raw string
5267+
button int
5268+
release bool
5269+
}{
5270+
{name: "touch start point", raw: `{"type":"touchstart","touches":[{"clientX":13,"clientY":5}]}`, button: 0},
5271+
{name: "touch move drag", raw: `{"type":"touchmove","touches":[{"clientX":13,"clientY":5}]}`, button: 32},
5272+
{name: "touch end changed point", raw: `{"type":"touchend","changedTouches":[{"clientX":13,"clientY":5}]}`, button: 0, release: true},
5273+
{name: "touch cancel changed point", raw: `{"type":"touchcancel","changedTouches":[{"clientX":13,"clientY":5}]}`, button: 0, release: true},
5274+
{name: "wrapped touch point", raw: `{"type":"touchstart","touches":[{"resource":{"attributes":{"pageX":"13","pageY":"5"}}}]}`, button: 0},
5275+
} {
5276+
t.Run(tc.name, func(t *testing.T) {
5277+
var mouse ScriptMouse
5278+
if err := json.Unmarshal([]byte(tc.raw), &mouse); err != nil {
5279+
t.Fatal(err)
5280+
}
5281+
if mouse.Button != tc.button || mouse.X != 13 || mouse.Y != 5 || mouse.Release != tc.release {
5282+
t.Fatalf("mouse = %#v, button want %d release want %v", mouse, tc.button, tc.release)
5283+
}
5284+
})
5285+
}
5286+
}
5287+
52345288
func TestScriptBooleanAliasesAcceptNonStrictValues(t *testing.T) {
52355289
var mouse ScriptMouse
52365290
if err := json.Unmarshal([]byte(`{"button":0,"x":13,"y":5,"mouseUp":"yes"}`), &mouse); err != nil {

0 commit comments

Comments
 (0)