Skip to content

Commit 7210676

Browse files
committed
🐛 fix: 鼠标右键粘贴+F2切换穿透模式
1 parent c941a8f commit 7210676

7 files changed

Lines changed: 250 additions & 52 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
charm.land/bubbletea/v2 v2.0.6
88
charm.land/glamour/v2 v2.0.0
99
charm.land/lipgloss/v2 v2.0.3
10+
github.com/atotto/clipboard v0.1.4
1011
github.com/charmbracelet/x/ansi v0.11.7
1112
github.com/fastschema/qjs v0.0.6
1213
github.com/getcharzp/onnxruntime_purego v1.24.0
@@ -19,7 +20,6 @@ require (
1920

2021
require (
2122
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
22-
github.com/atotto/clipboard v0.1.4 // indirect
2323
github.com/aymerick/douceur v0.2.0 // indirect
2424
github.com/charmbracelet/colorprofile v0.4.3 // indirect
2525
github.com/charmbracelet/ultraviolet v0.0.0-20260608091853-35bcb7319efa // indirect

tui/clipboard.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
package tui
22

3-
import "errors"
3+
import (
4+
"errors"
5+
6+
"github.com/atotto/clipboard"
7+
)
8+
9+
// readClipboardText 读系统剪贴板里的**文本**。跨平台走 atotto/clipboard —— bubbles textarea
10+
// 内部的 Paste 命令用的也是它,保持同一来源。
11+
//
12+
// Linux 上它依赖外部工具(wl-paste / xclip / xsel),缺工具时静默返错;那种情况由启动时的
13+
// clipboardTextHint 提示用户安装,这里不重复报错。图片走各平台的 readClipboardImage。
14+
func readClipboardText() (string, error) { return clipboard.ReadAll() }
415

516
// errNoClipboardImage 表示系统剪贴板当前没有图片数据 (可能是空,也可能只有文本)。
617
// 各平台的 readClipboardImage 实现应当把"没图"统一映射到这个值,

tui/i18n.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,18 @@ var translations = map[string]map[Lang]string{
169169
LangZH: "显示/隐藏右侧状态栏(也可按 Ctrl+B)",
170170
LangEN: "Show/hide the right status panel (or press Ctrl+B)",
171171
},
172+
"mouse.passthrough.on": {
173+
LangZH: "🖱 鼠标穿透已开启:文字选择与右键粘贴交回终端处理(deepx 的选区/复制、滚轮、拖拽暂停)。再按 F2 恢复。",
174+
LangEN: "🖱 Mouse passthrough ON: the terminal now handles text selection and right-click paste (deepx selection/copy, wheel and drag are paused). Press F2 to restore.",
175+
},
176+
"mouse.passthrough.off": {
177+
LangZH: "🖱 鼠标捕获已恢复:deepx 接管选区/复制、滚轮与右键粘贴。",
178+
LangEN: "🖱 Mouse capture restored: deepx handles selection/copy, wheel and right-click paste.",
179+
},
180+
"mouse.passthrough.badge": {
181+
LangZH: "鼠标穿透(F2 切换)",
182+
LangEN: "mouse passthrough (F2)",
183+
},
172184
"cmd.thinking.desc": {
173185
LangZH: "显示/隐藏模型思考过程(reasoning),暗显在对话流",
174186
LangEN: "Show/hide the model's thinking (reasoning), dimmed in the chat",
@@ -353,6 +365,8 @@ var translations = map[string]map[Lang]string{
353365
"- `ctrl+j` — 换行(多行输入)\n" +
354366
"- `Ctrl+B` — 显示/隐藏右侧状态栏\n" +
355367
"- `Ctrl+V` — 粘贴(含图片)\n" +
368+
"- `F2` — 鼠标穿透:交回终端做文字选择/右键粘贴(WSL2、Windows Terminal 常用),重启保持\n" +
369+
"- `鼠标右键` — 在输入框内粘贴剪贴板文本(穿透关闭时由 deepx 处理)\n" +
356370
"- `Esc` — 中断当前对话(按两次;防 vim 习惯误触。也可 Ctrl+C 单按即停)\n" +
357371
"- `Ctrl+C` — 清空输入框;输入为空时按两次退出程序(1 秒内;弹窗内则关弹窗)",
358372
LangEN: "\n**Slash commands**\n\n" +
@@ -388,6 +402,8 @@ var translations = map[string]map[Lang]string{
388402
"- `ctrl+j` — Newline (multi-line input)\n" +
389403
"- `Ctrl+B` — Show/hide the right status panel\n" +
390404
"- `Ctrl+V` — Paste (including images)\n" +
405+
"- `F2` — Mouse passthrough: let the terminal handle selection / right-click paste (handy on WSL2, Windows Terminal); remembered across restarts\n" +
406+
"- `Right click` — Paste clipboard text into the input (handled by deepx when passthrough is off)\n" +
391407
"- `Esc` — Interrupt current turn (press twice; avoids vim-habit misfires. Or Ctrl+C to stop in one press)\n" +
392408
"- `Ctrl+C` — Clear the input; when empty, press twice within 1s to quit (closes modal if open)",
393409
},

tui/meta.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ type meta struct {
2929
// ShowThinking 记忆是否把模型思考(reasoning_content)暗显进对话流(/thinking 切换)。默认关。
3030
ShowThinking bool `json:"show_thinking,omitempty"`
3131

32+
// MousePassthrough 记忆鼠标穿透模式(F2 切换):开启后关掉鼠标捕获,由终端原生处理
33+
// 文字选择与右键粘贴。需要它的用户(WSL2 / 习惯终端原生选择)是一直需要,故重启保持。
34+
MousePassthrough bool `json:"mouse_passthrough,omitempty"`
35+
3236
// Sandbox 记忆沙箱模式("native"/"docker",/sandbox 切换)。空 = native(默认)。
3337
Sandbox string `json:"sandbox,omitempty"`
3438
// SandboxDockerImage 记忆 docker 沙箱用的镜像(/sandbox docker <image>)。空 = ubuntu:24.04。

tui/model.go

Lines changed: 70 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import (
2828
"charm.land/glamour/v2"
2929
"charm.land/glamour/v2/styles"
3030
"charm.land/lipgloss/v2"
31-
"github.com/atotto/clipboard"
3231
"github.com/charmbracelet/x/ansi"
3332
)
3433

@@ -716,8 +715,9 @@ func initialModel(models agent.ModelConfig, needsSetup bool, version string, hub
716715
mode: agent.AgentMode_Auto,
717716
workingMode: agent.WorkingModeDefault, // 默认 kp;下方从 session 恢复
718717
status: "idle",
719-
hideStatusPanel: metaGet().HideStatus, // 记忆上次的状态栏显隐
720-
showThinking: metaGet().ShowThinking, // 记忆上次的思考显隐
718+
hideStatusPanel: metaGet().HideStatus, // 记忆上次的状态栏显隐
719+
showThinking: metaGet().ShowThinking, // 记忆上次的思考显隐
720+
mousePassthrough: metaGet().MousePassthrough, // 记忆上次的鼠标穿透选择(F2)
721721
spinner: sp,
722722
workspace: wd,
723723
setupInput: si,
@@ -1465,14 +1465,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
14651465
return m, nil
14661466
}
14671467
if msg.Button != tea.MouseLeft {
1468-
// 右键点击输入区 → 读取剪贴板文本并粘贴
1469-
if msg.Button == tea.MouseRight {
1470-
leftW, vpH := m.layout()
1471-
inInput := msg.Y >= vpH && msg.Y < m.height && msg.X >= 0 && msg.X < leftW
1472-
if inInput {
1473-
if text, err := clipboard.ReadAll(); err == nil && text != "" {
1474-
m.input.InsertString(text)
1475-
}
1468+
// 右键点击输入区 → 读剪贴板文本粘贴(issue #211)。走 insertPastedText 与 tea.PasteMsg
1469+
// 同一条管线:CRLF 归一 + 超长走占位符,少一样都会出问题(见该函数注释)。
1470+
if msg.Button == tea.MouseRight && m.inInputArea(msg.X, msg.Y) {
1471+
if text, err := readClipboardText(); err == nil && text != "" {
1472+
return m, m.insertPastedText(text)
14761473
}
14771474
}
14781475
return m, nil
@@ -1492,11 +1489,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
14921489
// 选区只认内容列 [chatLeft, leftW),分隔线列按 X 与上面互斥。
14931490
inChat := msg.X >= chatLeft && msg.X < leftW &&
14941491
msg.Y >= chatTop && msg.Y < chatBottom
1495-
// 输入区:左列 body 下方那块(空白行 + textarea 行),Y ∈ [vpH, m.height) 且 X ∈ [0, leftW)。
1496-
// X 上界收到 leftW —— 右侧是全高状态栏,点那里不该进输入编辑。
1497-
inInput := msg.Y >= vpH && msg.Y < m.height && msg.X >= 0 && msg.X < leftW
1498-
1499-
if inInput {
1492+
if m.inInputArea(msg.X, msg.Y) {
15001493
// 按下进入输入区:清 chat 选区 + 记拖拽锚点,准备"拖拽选择片段"。
15011494
// 同时把上一次的输入选区清掉:若接着是真拖动,MouseMotion 会重新点亮;
15021495
// 若只是单击松手,就等价于"点一下取消"(#188)。
@@ -1681,28 +1674,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
16811674
}
16821675
return m, nil
16831676
}
1684-
// 文本粘贴:超阈值(>800 字符,或行数超 pasteLineCap=min(height-10,2)、常态 >2 行)走占位符,否则让 textarea 正常接。
1685-
// 占位符方案避免超长文本被 textarea 的 CharLimit(4000)截断、也不撑爆输入区。
1686-
// 进入 textarea 之前先把 \r\n 归一为 \n:否则 textarea 的 Sanitizer 会把
1687-
// \r\n 拆成两个 \n(\r 一个、\n 一个),造成行翻倍、历史/输入框错乱(见 normalizeNewlines)。
1688-
text := msg.Content
1689-
text = normalizeNewlines(text)
1690-
numLines := strings.Count(text, "\n")
1691-
if len(text) > pasteTextThreshold || numLines > m.pasteLineCap() {
1692-
// 先回收上一轮已无引用的全文,再存新的;避免反复大段粘贴时 map 只增不减。
1693-
m.prunePastedTexts()
1694-
if m.pastedTexts == nil {
1695-
m.pastedTexts = make(map[int]string)
1696-
}
1697-
id := m.nextPasteID
1698-
m.nextPasteID++
1699-
m.pastedTexts[id] = text
1700-
m.input.InsertString(formatPastedTextRef(id, numLines))
1701-
return m, nil
1702-
}
1703-
var c tea.Cmd
1704-
m.input, c = m.input.Update(tea.PasteMsg{Content: text})
1705-
return m, c
1677+
return m, m.insertPastedText(msg.Content)
17061678

17071679
case tea.KeyPressMsg:
17081680
// 配置 modal 处于活动状态时,按键全部在这里处理,绕过主界面。两步:选供应商 / 填配置。
@@ -2494,14 +2466,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
24942466
return m, nil
24952467
case "f2":
24962468
// 切换鼠标穿透模式:关掉 MouseMode,让终端原生处理右键粘贴和文字选择。
2497-
m.mousePassthrough = !m.mousePassthrough
2498-
hint := "鼠标"
2499-
if m.mousePassthrough {
2500-
hint += "穿透已开启(终端原生选择/粘贴)"
2501-
} else {
2502-
hint += "捕获已恢复(deepx 选区/复制)"
2503-
}
2504-
m.appendChat("System", hint)
2469+
m.toggleMousePassthrough()
25052470
return m, nil
25062471
case "ctrl+v":
25072472
// 剪贴板有图就落盘并插入到输入框;没图则下落到 textinput 走文本粘贴。
@@ -3958,6 +3923,65 @@ func (m model) compactCmd(manual bool) tea.Cmd {
39583923
}
39593924
}
39603925

3926+
// inInputArea 判断鼠标坐标是否落在输入区:左列 body 下方那块(空白行 + textarea 行),
3927+
// Y ∈ [vpH, m.height) 且 X ∈ [0, leftW)。X 上界收到 leftW —— 右侧是全高状态栏,点那里不该进输入编辑。
3928+
// 左键(进入编辑/拖选)与右键(粘贴)共用同一判断,避免两处各写一份、日后改布局漏改一边。
3929+
func (m model) inInputArea(x, y int) bool {
3930+
leftW, vpH := m.layout()
3931+
return y >= vpH && y < m.height && x >= 0 && x < leftW
3932+
}
3933+
3934+
// insertPastedText 把一段外部文本按统一规则送进输入框。**所有粘贴入口都必须走这里**
3935+
// (终端 tea.PasteMsg、右键粘贴),绕过去会踩两个坑:
3936+
// - 不归一 CRLF:textarea 的 Sanitizer 会把 \r\n 拆成两个 \n(\r 一个、\n 一个),
3937+
// 行数翻倍、输入框与历史一起错乱(Windows / WSL2 剪贴板里 CRLF 是常态,见 normalizeNewlines);
3938+
// - 不走占位符:超阈值(>pasteTextThreshold 字符,或行数超 pasteLineCap)的文本直插,
3939+
// 会被 textarea 的 CharLimit(4000)静默截断,还把输入区撑爆。
3940+
//
3941+
// 返回值是要交回 bubbletea 的 Cmd(走占位符时为 nil)。
3942+
func (m *model) insertPastedText(text string) tea.Cmd {
3943+
text = normalizeNewlines(text)
3944+
if text == "" {
3945+
return nil
3946+
}
3947+
numLines := strings.Count(text, "\n")
3948+
if len(text) > pasteTextThreshold || numLines > m.pasteLineCap() {
3949+
// 先回收上一轮已无引用的全文,再存新的;避免反复大段粘贴时 map 只增不减。
3950+
m.prunePastedTexts()
3951+
if m.pastedTexts == nil {
3952+
m.pastedTexts = make(map[int]string)
3953+
}
3954+
id := m.nextPasteID
3955+
m.nextPasteID++
3956+
m.pastedTexts[id] = text
3957+
m.input.InsertString(formatPastedTextRef(id, numLines))
3958+
return nil
3959+
}
3960+
var c tea.Cmd
3961+
m.input, c = m.input.Update(tea.PasteMsg{Content: text})
3962+
return c
3963+
}
3964+
3965+
// toggleMousePassthrough 切换鼠标穿透(F2):关掉鼠标捕获后由终端原生处理选择与右键粘贴。
3966+
// 记忆到 meta(同 Ctrl+B 的状态栏显隐),否则每次启动都要重按一次 —— 需要穿透的用户是一直需要。
3967+
//
3968+
// 切换时必须清掉选区/拖拽态:穿透打开后 deepx 收不到任何鼠标事件,若此刻正拖着选区,
3969+
// MouseReleaseMsg 永远不会到,高亮会一直卡在屏幕上。
3970+
func (m *model) toggleMousePassthrough() {
3971+
m.mousePassthrough = !m.mousePassthrough
3972+
metaUpdate(func(mm *meta) { mm.MousePassthrough = m.mousePassthrough })
3973+
m.selecting = false
3974+
m.inputSelecting = false
3975+
m.inputDragging = false
3976+
m.scrollbarDragging = false
3977+
if m.mousePassthrough {
3978+
m.chatContent.Open(kindSystem, T("mouse.passthrough.on"))
3979+
} else {
3980+
m.chatContent.Open(kindSystem, T("mouse.passthrough.off"))
3981+
}
3982+
m.refreshViewport()
3983+
}
3984+
39613985
// compactFailNote 生成"自动压缩失败"提示的正文,按处置区分说法。
39623986
//
39633987
// 原来两种情况统一写"本轮改用撞窗口保护",既看不懂、也偏乐观 —— 那不是什么主动防护,

tui/mouse_passthrough_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package tui
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
tea "charm.land/bubbletea/v2"
8+
)
9+
10+
// 右键粘贴(issue #211)必须与终端 PasteMsg 走同一条 insertPastedText 管线。
11+
// 这两条是当初直接 InsertString 会踩的坑,单独钉住。
12+
13+
// CRLF 必须归一:不归一的话 textarea 的 Sanitizer 会把 \r\n 拆成两个 \n,
14+
// 行数翻倍、输入框和历史一起错乱 —— 而右键粘贴的主要用户就在 Windows / WSL2,剪贴板全是 CRLF。
15+
func TestInsertPastedText_NormalizesCRLF(t *testing.T) {
16+
m := initModel()
17+
if cmd := m.insertPastedText("第一行\r\n第二行"); cmd != nil {
18+
_ = cmd()
19+
}
20+
got := m.input.Value()
21+
if strings.Contains(got, "\r") {
22+
t.Fatalf("粘贴内容不该残留 \\r: %q", got)
23+
}
24+
if n := strings.Count(got, "\n"); n != 1 {
25+
t.Fatalf("两行文本应只有 1 个换行(CRLF 被拆成两个 \\n 就会变 2),got %d: %q", n, got)
26+
}
27+
}
28+
29+
// 超阈值文本走占位符:直插会被 textarea 的 CharLimit(4000)静默截断,还撑爆输入区。
30+
func TestInsertPastedText_LongTextUsesPlaceholder(t *testing.T) {
31+
m := initModel()
32+
long := strings.Repeat("x", pasteTextThreshold+1)
33+
if cmd := m.insertPastedText(long); cmd != nil {
34+
_ = cmd()
35+
}
36+
got := m.input.Value()
37+
if strings.Contains(got, long) {
38+
t.Fatal("超长文本应换成占位符,而不是整段插进输入框")
39+
}
40+
if !strings.Contains(got, "Pasted text") {
41+
t.Fatalf("输入框里应是占位符, got %q", got)
42+
}
43+
if len(m.pastedTexts) != 1 {
44+
t.Fatalf("全文应存进 pastedTexts 待提交时展开, got %d 条", len(m.pastedTexts))
45+
}
46+
for _, v := range m.pastedTexts {
47+
if v != long {
48+
t.Error("存下的应是完整原文")
49+
}
50+
}
51+
52+
// 多行同理(即使很短):超过 pasteLineCap 也走占位符
53+
m2 := initModel()
54+
if cmd := m2.insertPastedText("a\nb\nc\nd"); cmd != nil {
55+
_ = cmd()
56+
}
57+
if !strings.Contains(m2.input.Value(), "Pasted text") {
58+
t.Errorf("超过 pasteLineCap 行数应走占位符, got %q", m2.input.Value())
59+
}
60+
}
61+
62+
// 短文本正常进输入框,不该被占位符逻辑误伤。
63+
func TestInsertPastedText_ShortTextGoesInline(t *testing.T) {
64+
m := initModel()
65+
if cmd := m.insertPastedText("hello"); cmd != nil {
66+
_ = cmd()
67+
}
68+
if got := m.input.Value(); !strings.Contains(got, "hello") {
69+
t.Fatalf("短文本应直接进输入框, got %q", got)
70+
}
71+
if len(m.pastedTexts) != 0 {
72+
t.Error("短文本不该占用 pastedTexts")
73+
}
74+
}
75+
76+
// 输入区命中判断:左键(进入编辑)与右键(粘贴)共用,别再各写一份。
77+
func TestInInputArea(t *testing.T) {
78+
m := initModel() // width=80 height=30
79+
_, vpH := m.layout()
80+
leftW, _ := m.layout()
81+
82+
cases := []struct {
83+
name string
84+
x, y int
85+
want bool
86+
}{
87+
{"输入区左上角", 0, vpH, true},
88+
{"输入区内部", 5, vpH + 1, true},
89+
{"chat 区(输入区上方)", 5, vpH - 1, false},
90+
{"右侧状态栏同一行", leftW, vpH, false},
91+
{"屏幕下沿之外", 5, m.height, false},
92+
{"负坐标", -1, vpH, false},
93+
}
94+
for _, c := range cases {
95+
if got := m.inInputArea(c.x, c.y); got != c.want {
96+
t.Errorf("%s: inInputArea(%d,%d) = %v, want %v", c.name, c.x, c.y, got, c.want)
97+
}
98+
}
99+
}
100+
101+
// F2 开启穿透后,View 必须把鼠标捕获关掉,终端才能接管选择与右键粘贴。
102+
func TestWrapView_MouseModeFollowsPassthrough(t *testing.T) {
103+
m := initModel()
104+
if got := m.wrapView("x").MouseMode; got != tea.MouseModeCellMotion {
105+
t.Fatalf("默认应捕获鼠标, got %v", got)
106+
}
107+
m.mousePassthrough = true
108+
if got := m.wrapView("x").MouseMode; got != tea.MouseModeNone {
109+
t.Fatalf("穿透开启时应关掉鼠标捕获, got %v", got)
110+
}
111+
}
112+
113+
// 切换穿透时必须清掉选区/拖拽态:穿透打开后收不到任何鼠标事件,
114+
// 若此刻正拖着选区,MouseReleaseMsg 永远不会来,高亮会一直卡在屏幕上。
115+
func TestToggleMousePassthrough_ClearsDragState(t *testing.T) {
116+
t.Setenv("HOME", t.TempDir()) // metaUpdate 会落盘,重定向 HOME 免得动到真实 ~/.deepx/meta.json
117+
m := initModel()
118+
m.selecting = true
119+
m.inputSelecting = true
120+
m.inputDragging = true
121+
m.scrollbarDragging = true
122+
123+
m.toggleMousePassthrough()
124+
125+
if !m.mousePassthrough {
126+
t.Fatal("应切到穿透开启")
127+
}
128+
if m.selecting || m.inputSelecting || m.inputDragging || m.scrollbarDragging {
129+
t.Error("切换时应清掉所有选区/拖拽态,否则高亮会卡住")
130+
}
131+
if !strings.Contains(m.chatContent.String(), T("mouse.passthrough.on")) {
132+
t.Error("应给出开启提示")
133+
}
134+
135+
m.toggleMousePassthrough()
136+
if m.mousePassthrough {
137+
t.Fatal("再按一次应关闭")
138+
}
139+
if !strings.Contains(m.chatContent.String(), T("mouse.passthrough.off")) {
140+
t.Error("应给出关闭提示")
141+
}
142+
}

0 commit comments

Comments
 (0)