From d14d6cee56c470190bfaf3fff6a852e8a642364d Mon Sep 17 00:00:00 2001 From: lordquest Date: Sat, 18 Jul 2026 20:53:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(tui):=20=E4=B8=8A=E4=B8=8B=E9=94=AE?= =?UTF-8?q?=E5=9C=A8=E6=8A=98=E8=A1=8C/=E5=8E=86=E5=8F=B2=E9=A1=B9?= =?UTF-8?q?=E5=86=85=E5=BA=94=E7=A7=BB=E5=8A=A8=E5=85=89=E6=A0=87=E8=80=8C?= =?UTF-8?q?=E9=9D=9E=E8=AF=AF=E7=BF=BB=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tui/down_history_nav_test.go | 79 ++++++++++++++++++++++++++++++++++++ tui/model.go | 22 ++++++++-- tui/up_wrap_test.go | 66 ++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 tui/down_history_nav_test.go create mode 100644 tui/up_wrap_test.go diff --git a/tui/down_history_nav_test.go b/tui/down_history_nav_test.go new file mode 100644 index 0000000..751c521 --- /dev/null +++ b/tui/down_history_nav_test.go @@ -0,0 +1,79 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +// 复现「翻阅态下,光标在多行历史项的中间行,按 ↓ 应下移光标,而非翻到下一条历史」。 +// 当前 down 键只要 inputHistoryIndex>=0 就直接 navigateHistoryDown(), +// 不判断光标是否已在历史项底部 —— 与 up 键(只在顶端才翻)不对称, +// 导致翻阅一条多行历史时,在中间行按 ↓ 会跳过剩余内容直接翻下一条。 +func TestDownArrowInsideMultilineHistoryMovesCursor(t *testing.T) { + m := initModel() + m.input.SetWidth(40) + m.input.SetHeight(inputTextRows) + // 一条多行历史项, 让它在输入框里占 3 逻辑行 + m.inputHistory = []string{"h1\nh2\nh3"} + m.inputHistoryIndex = -1 + + // 按 ↑ 进入翻阅态, 显示 "h1\nh2\nh3", 光标默认在末尾(第2行) + mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyUp})) + m = mm.(model) + if m.inputHistoryIndex < 0 { + t.Fatalf("前置失败: ↑ 未进入翻阅态") + } + // 把光标从末尾上移到中间行(第1行): 按两次 ↑(在翻阅态内应只移光标, 因为不在顶端) + mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyUp})) + m = mm.(model) + mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyUp})) + m = mm.(model) + t.Logf("[before ↓] line=%d RowOffset=%d Height=%d historyIdx=%d val=%q", + m.input.Line(), m.input.LineInfo().RowOffset, m.input.LineInfo().Height, m.inputHistoryIndex, m.input.Value()) + + // 此刻光标在第1行(共3行, 0/1/2), 按 ↓ 应下移光标到末行, 不该翻历史 + mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown})) + after := mm.(model) + t.Logf("[after ↓] line=%d RowOffset=%d historyIdx=%d val=%q", + after.input.Line(), after.input.LineInfo().RowOffset, after.inputHistoryIndex, after.input.Value()) + + if after.inputHistoryIndex < 0 { + t.Errorf("❌ 翻阅态中间行按↓误恢复草稿/退出翻阅(historyIdx=%d)", after.inputHistoryIndex) + } + if after.input.Value() != "h1\nh2\nh3" { + t.Errorf("❌ 翻阅态中间行按↓不应替换历史项, 实际 val=%q", after.input.Value()) + } + if after.input.Line() <= m.input.Line() { + t.Errorf("❌ 翻阅态中间行按↓应下移光标, 实际 line 未增大 (before=%d after=%d)", + m.input.Line(), after.input.Line()) + } +} + +// 对照: 光标已在多行历史项底部时, 按 ↓ 仍应翻下一条历史(标准 chat 行为)。 +func TestDownArrowAtHistoryBottomNavigates(t *testing.T) { + m := initModel() + m.input.SetWidth(40) + m.input.SetHeight(inputTextRows) + m.inputHistory = []string{"h1\nh2\nh3", "__NEXT__"} + // 直接置为翻阅态、停在第 0 条(多行)底部, 后面还有第 1 条可翻 + m.inputHistoryIndex = 0 + m.input.SetValue("h1\nh2\nh3") + m.input.CursorEnd() // 光标在底部 + + li := m.input.LineInfo() + lastLine := strings.Count(m.input.Value(), "\n") + if m.input.Line() != lastLine || li.RowOffset != li.Height-1 { + t.Fatalf("前置失败: 光标应在历史项底部, 实际 line=%d lastLine=%d RowOffset=%d Height=%d", + m.input.Line(), lastLine, li.RowOffset, li.Height) + } + + mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown})) + after := mm.(model) + t.Logf("[down@bottom] historyIdx=%d val=%q lenHistory=%d", after.inputHistoryIndex, after.input.Value(), len(after.inputHistory)) + if after.input.Value() != "__NEXT__" { + t.Errorf("❌ 历史项底部按↓应翻下一条, 实际 val=%q (historyIdx=%d)", + after.input.Value(), after.inputHistoryIndex) + } +} diff --git a/tui/model.go b/tui/model.go index 82596ae..061af5b 100644 --- a/tui/model.go +++ b/tui/model.go @@ -2395,27 +2395,41 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.chatViewport, c = m.chatViewport.Update(msg) return m, c case "up": - // ↑ 键:光标在首行(或输入框为空)时,翻阅上一条历史。 + // ↑ 键:光标在「内容最顶端」(或输入框为空)时,翻阅上一条历史。 + // 顶端必须同时满足:逻辑行 == 0 且处于该行顶部虚拟行(RowOffset == 0)。 + // 否则(如首行是长行被软折成多个虚拟行、光标在其中下移过)交给 textarea + // 在折行内上移光标,不能误判为「已在首行」而触发历史翻阅 —— 否则长文本里 + // 键盘 ↑ 完全失效、只能靠鼠标滚轮(见 up_wrap 回归测试)。 // 有弹窗/流式中时不拦截,交给 textarea 处理光标移动。 if m.showSetup || m.showMcpAdd || m.showReasoningModal || m.showSkillAdd || m.showWebConfig || m.askPending || m.reviewPending { // 弹窗打开时不拦截,让 textarea 处理 } else if m.streaming || m.compactingFG { // 流式中不拦截 - } else if m.input.Value() == "" || m.input.Line() == 0 { + } else if m.input.Value() == "" || + (m.input.Line() == 0 && m.input.LineInfo().RowOffset == 0) { m.navigateHistoryUp() return m, nil } case "down": // ↓ 键:正在翻阅历史时,翻到下一条(更新的)消息;翻到底则恢复草稿。 + // 与 ↑ 对称:只有光标已到当前历史项的「底部」(最后逻辑行 + 最后虚拟行)才翻下一条, + // 否则下移光标 —— 否则翻阅一条多行历史时,在中间行按 ↓ 会直接翻走、跳过多行内容 + // (详见 down_history_nav 回归测试)。 if m.showSetup || m.showMcpAdd || m.showReasoningModal || m.showSkillAdd || m.showWebConfig || m.askPending || m.reviewPending { // 弹窗打开时不拦截 } else if m.streaming || m.compactingFG { // 流式中不拦截 } else if m.inputHistoryIndex >= 0 { - m.navigateHistoryDown() - return m, nil + li := m.input.LineInfo() + lastLine := strings.Count(m.input.Value(), "\n") // 最后逻辑行号(0-indexed) + atBottom := m.input.Line() == lastLine && li.RowOffset == li.Height-1 + if atBottom { + m.navigateHistoryDown() + return m, nil + } + // 未到底部: 下放给 textarea 下移光标, 不翻历史 } case "ctrl+j": // 在光标处插入换行,实现多行输入。Enter 仍走下方 submit 分支。 diff --git a/tui/up_wrap_test.go b/tui/up_wrap_test.go new file mode 100644 index 0000000..48c6ac6 --- /dev/null +++ b/tui/up_wrap_test.go @@ -0,0 +1,66 @@ +package tui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +// 复现「多行(实为被 wrap 的长行)输入时方向键↑失效」: +// 窄宽下一个超长单行被折成多虚拟行,光标停在(内容行0,底部虚拟行)。 +// 此时 m.input.Line() 恒为 0,旧条件 Line()==0 误判为「已在首行」→ 触发历史翻阅, +// 光标无法在折行内上移;而鼠标滚轮直接 m.input.Update(KeyUp) 绕过该分支故正常。 +// 修复后:仅在光标真处于「最顶端(内容行0 且 RowOffset==0)」时才翻阅历史。 +func TestUpArrowOnWrappedLineBug(t *testing.T) { + m := initModel() + m.input.SetWidth(10) // 窄宽 → 长行大量 wrap + m.input.SetHeight(inputTextRows) + + long := "一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯辰巳午未申酉戌亥" + m.input.SetValue(long) // 单行,无换行 + m.input.CursorEnd() // 光标在(行0, 底虚拟行) + + // 注入一条历史,使「误触历史翻阅」可观测(值会被替换成历史项)。 + m.inputHistory = []string{"__HISTORY_OLD__"} + m.inputHistoryIndex = -1 + + before := m.input.Line() + li := m.input.LineInfo() + t.Logf("[before] line=%d RowOffset=%d Height=%d value=%q", before, li.RowOffset, li.Height, m.input.Value()) + + mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyUp})) + after := mm.(model) + t.Logf("[up] value=%q historyIdx=%d", after.input.Value(), after.inputHistoryIndex) + + if after.input.Value() == "__HISTORY_OLD__" { + t.Errorf("❌ ↑ 在 wrap 行内误触发历史翻阅(光标无法上移): value 被替换为历史项") + } + if after.input.Value() != long { + t.Errorf("❌ 输入值不应被改,实际 %q", after.input.Value()) + } +} + +// 对照:光标真在内容顶端(行0 且 RowOffset==0)时,↑ 仍应翻阅历史(标准 chat 行为)。 +func TestUpArrowAtTrueTopNavigatesHistory(t *testing.T) { + m := initModel() + m.input.SetWidth(40) // 宽,单行不 wrap + m.input.SetHeight(inputTextRows) + m.input.SetValue("短行") // 单行,无换行,光标在行首即顶端 + m.input.MoveToBegin() + + li := m.input.LineInfo() + if m.input.Line() != 0 || li.RowOffset != 0 { + t.Fatalf("前置失败:预期光标在真顶端(line=0,RowOffset=0),实际 line=%d RowOffset=%d", + m.input.Line(), li.RowOffset) + } + + m.inputHistory = []string{"__HISTORY_OLD__"} + m.inputHistoryIndex = -1 + + mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyUp})) + after := mm.(model) + t.Logf("[up@top] value=%q historyIdx=%d", after.input.Value(), after.inputHistoryIndex) + if after.input.Value() != "__HISTORY_OLD__" { + t.Errorf("❌ 真顶端↑应翻阅历史,实际 value=%q", after.input.Value()) + } +}