feat(tui-v3): Shift+Enter on Windows + logical-line arrow navigation

- _ptk_keypress_to_bytes 在 Windows 上对 Enter 收到 \r 时用
  GetAsyncKeyState(VK_SHIFT) 物理检测 Shift,按下时返回 \n 以触发换行
  (PTK 在某些 Windows 终端区分不出 Shift+Enter 与裸 Enter)。
- 新增 _line_region / _logical_visual_range 辅助函数,↑/↓ 按键:
  不在该逻辑行的视觉首/末行 → 视觉滚行;在视觉首行且光标在行首 →
  历史导航,否则先跳逻辑行首/尾,下次按键再跨行/进历史。多行粘贴
  内部导航更符合直觉。

Refs:
- Shift+Enter 物理检测 from upstream PR #519 by @jlu005807
  https://github.com/lsdefine/GenericAgent/pull/519
- 多行输入逻辑行边界导航思路 from upstream PR #520 by @jlu005807
  https://github.com/lsdefine/GenericAgent/pull/520
This commit is contained in:
yanghang committed 2026-05-28 21:14:34 +08:00
1 parent 7c9ce6b4ac
commit 468f7e519d
1 file changed
+60 -2
+60 -2
View File
@@ -807,7 +807,12 @@ def _ptk_keypress_to_bytes(kp) -> bytes:
return any(n in a for a in aliases for n in needles)
# Enter submits; Ctrl+J / Shift+Enter insert newline when PTK can distinguish.
# Windows terminals send \r for both Enter/Shift+Enter — detect Shift physically.
if has('controlm', 'c-m') or key_s in ('\r', '\n'):
if _IS_WINDOWS:
import ctypes
if ctypes.windll.user32.GetAsyncKeyState(0x10) & 0x8000:
return b'\n'
return b'\r'
if has('controlj', 'c-j', 's-enter', 'shift-enter'):
return b'\n'
@@ -2542,6 +2547,27 @@ class SB:
return i, p - st
return len(segs) - 1, len(segs[-1][1])
def _line_region(self, pos: int | None = None) -> tuple[int, int, int, int]:
"""(行号, 总行数, 行起始偏移, 行结束偏移) 基于逻辑行(\\n分割)"""
p = self.pos if pos is None else pos
ls = self.buf.rfind('\n', 0, p) + 1
le = self.buf.find('\n', p)
if le == -1:
le = len(self.buf)
line_no = self.buf[:p].count('\n')
total = self.buf.count('\n') + 1
return line_no, total, ls, le
def _logical_visual_range(self, segs, line_no):
"""返回第 line_no 个逻辑行在 segs 中的 (first_vi, last_vi)"""
lines = self.buf.split('\n')
iw = max(1, _term()[0] - 6)
first = 0
for i in range(line_no):
first += max(1, len(_wrap_cells(lines[i], iw)))
cnt = max(1, len(_wrap_cells(lines[line_no], iw)))
return first, first + cnt - 1
def _cur_v(self, d: int) -> None:
"""↑/↓ roam by VISUAL row (a long single-line paste wraps to many rows
yet stays one logical line — must still roam). At the top/bottom visual
@@ -5157,7 +5183,23 @@ class SB:
self._pending_drain_t = 0.0
self._render_live()
else:
self._sel = None; self._cur_v(-1)
self._sel = None
line_no, total, ls, le = self._line_region()
segs = self._segs(max(1, _term()[0] - 6))
vi, _ = self._seg_at(segs)
first_vi, _ = self._logical_visual_range(segs, line_no)
if vi == first_vi: # 在视觉首行
if line_no == 0:
if self.pos == ls:
self._nav_hist(-1)
else:
self.pos = ls
elif self.pos == ls:
self._cur_v(-1) # 已在行首 → 正常上移
else:
self.pos = ls # 跳到该行行首
else:
self._cur_v(-1)
elif o == 0x13: # Ctrl+S stash/restore draft
self._stash_draft()
elif o == 0x0e: # ↓ visual-row down (history at bottom)
@@ -5165,7 +5207,23 @@ class SB:
n = len(self._cmd_matches(self.buf))
self._palette_sel = min(n - 1, self._palette_sel + 1) if n else 0
else:
self._sel = None; self._cur_v(1)
self._sel = None
line_no, total, ls, le = self._line_region()
segs = self._segs(max(1, _term()[0] - 6))
vi, _ = self._seg_at(segs)
first_vi, last_vi = self._logical_visual_range(segs, line_no)
if vi == last_vi: # 在视觉末行
if line_no == total - 1:
if self.pos == le:
self._nav_hist(1)
else:
self.pos = le
elif self.pos == le:
self._cur_v(1) # 已在行末 → 正常下移
else:
self.pos = le # 跳到该行行末
else:
self._cur_v(1)
elif o == 0x02: # ← caret left
self._sel = None; self.pos = max(0, self.pos - 1)
elif o == 0x06: # → caret right