feat: TUI 新增 Vim 风格键位(模态输入 + 聊天导航),版本升 8.0.3

- 新增 xcli_core/vim_keys.py:纯逻辑 Vim 状态机(NORMAL/INSERT),h/j/k/l/w/b/0/$/^/gg/G/i/a/o/I/A/O/dd/x/dw/d$/u
- tui.py:VimSendTextArea 子类接入模态编辑;XiaoliTUI 增加 j/k、Ctrl+F/B/D/U 聊天滚动、Ctrl+Space 聚焦切换、i 聚焦插入、/vim 开关、模式提示栏、/help 更新
- 新增 tests/test_vim_keys.py(18 项动作单测全过)
- 版本号 8.0.2 -> 8.0.3(constants/about/README/tui/rich_tui/lsp/mcp)
This commit is contained in:
shuiyu67 committed 2026-08-07 23:35:42 +08:00
1 parent 6f4fa5076f
commit 66de8eea13
11 files changed
+710 -12

No files matched your search

+275
View File
@@ -0,0 +1,275 @@
"""
Vim 风格输入编辑状态机(纯逻辑,不依赖 textual)
================================================
把“模态编辑”的核心(NORMAL / INSERT 双模式 + 各种 motion / operator)抽成
纯函数 + 一个小状态类,便于在不依赖 textual 的环境里做单元测试。
widget 侧(tui.py 的 VimSendTextArea)只负责:
- 把 textual 的 key 名字映射成这里的 token(见 tui.py 的 _vim_token)
- 调用 VimInputState.feed(text, offset, token) 拿到 EditResult
- 把 EditResult 应用回 TextArea(设 text / 设光标 / 触发 undo 等)
坐标约定:文本用单一字符串表示,光标用「字符偏移 offset」表示(从 0 开始)。
widget 负责 offset <-> (row, col) 的转换。
"""
from dataclasses import dataclass
from typing import Optional
@dataclass
class EditResult:
"""一次 vim 动作的执行结果。"""
text: str # 应用后的文本
offset: int # 应用后的光标偏移
mode: str # 结束时的模式: "INSERT" / "NORMAL"
pending: Optional[str] # 多键序列的待定前缀: "d" / "g" / None
handled: bool = True # False 表示“交给输入框默认处理”(仅 INSERT 非 Esc 键)
action: Optional[str] = None # 额外指令: "undo"
message: str = "" # 状态栏提示语
# ── 文本/坐标辅助 ──────────────────────────────────────────────────────────
def _line_start_off(text: str, off: int) -> int:
"""当前行行首偏移。"""
s = text.rfind("\n", 0, off)
return s + 1
def _line_end_off(text: str, off: int) -> int:
"""当前行行尾(不含换行符)偏移。"""
e = text.find("\n", off)
return e if e != -1 else len(text)
def _first_nonblank_off(text: str, off: int) -> int:
"""当前行第一个非空白字符偏移,若整行空白则回到行首。"""
s = _line_start_off(text, off)
e = _line_end_off(text, off)
i = s
while i < e and text[i].isspace():
i += 1
return i
def _word_forward_off(text: str, off: int) -> int:
"""跳到下一个 word 的词首(vim 的 w):越过当前词 + 词间空白。"""
n = len(text)
i = off
# 越过错当前空白
while i < n and text[i].isspace():
i += 1
# 越过当前 word
if i < n and (text[i].isalnum() or text[i] == "_"):
while i < n and (text[i].isalnum() or text[i] == "_"):
i += 1
else:
while i < n and not text[i].isspace() and not (text[i].isalnum() or text[i] == "_"):
i += 1
# 越过词间空白,落到下一词首
while i < n and text[i].isspace():
i += 1
return i
def _word_back_off(text: str, off: int) -> int:
"""跳到上一个 word 的词首(vim 的 b)。"""
i = off
# 越过光标前空白
while i > 0 and text[i - 1].isspace():
i -= 1
# 越过当前 word
if i > 0 and (text[i - 1].isalnum() or text[i - 1] == "_"):
while i > 0 and (text[i - 1].isalnum() or text[i - 1] == "_"):
i -= 1
else:
while i > 0 and not text[i - 1].isspace() and not (text[i - 1].isalnum() or text[i - 1] == "_"):
i -= 1
# 越过词间空白,落到上一词首
while i > 0 and text[i - 1].isspace():
i -= 1
return i
def _offset_to_rc(text: str, off: int):
off = max(0, min(off, len(text)))
row = text.count("\n", 0, off)
line_start = text.rfind("\n", 0, off)
col = off - (line_start + 1)
return row, col
def _rc_to_offset(text: str, row: int, col: int) -> int:
lines = text.split("\n")
row = max(0, min(row, len(lines) - 1))
line = lines[row]
col = max(0, min(col, len(line)))
off = 0
for l in lines[:row]:
off += len(l) + 1
return off + col
# ── 状态机 ────────────────────────────────────────────────────────────────
class VimInputState:
"""
Vim 输入状态机。
mode: "INSERT" 或 "NORMAL"
pending: 多键序列待定前缀("d" / "g"),单键动作时为 None
用法:
st = VimInputState(mode="INSERT") # 输入框默认进入 INSERT
res = st.feed(text, offset, token)
# 把 res 应用回输入框
"""
def __init__(self, mode: str = "INSERT"):
self.mode = mode
self.pending: Optional[str] = None
# 进入 INSERT 时返回的结果构造器
@staticmethod
def _insert(text, offset, msg) -> EditResult:
return EditResult(text, offset, "INSERT", None, message=msg)
def feed(self, text: str, offset: int, token: str) -> EditResult:
"""
token 是已经映射好的 vim 键名,例如:
"h","l","j","k","w","b","e","0","$","^","g","G","d","x","u",
"i","a","o","O","I","A","escape","left","right","up","down"
"""
if self.mode == "INSERT":
if token == "escape":
# 回到 NORMAL,光标左移一格(vim 习惯:Esc 后光标退到本字上)
new_off = offset
if offset > 0 and text[offset - 1] != "\n":
new_off = offset - 1
self.mode = "NORMAL"
self.pending = None
return EditResult(text, new_off, "NORMAL", None, message="-- NORMAL --")
# 其余键交给 TextArea 默认处理(打字)
return EditResult(text, offset, "INSERT", None, handled=False)
# ── NORMAL 模式 ──
# 多键序列待定前缀
if self.pending == "d":
self.pending = None
if token == "d":
return self._delete_line(text, offset, "dd")
if token == "w":
return self._delete_word(text, offset, "dw")
if token == "$":
return self._delete_to_eol(text, offset, "d$")
# 非法组合:清掉 pending 后把当前键当单键重新处理
# (落到下面的单键逻辑)
if self.pending == "g":
self.pending = None
if token == "g":
return EditResult(text, 0, "NORMAL", None, message="gg")
# 其余:当作普通单键处理
# ── 单键 ──
if token in ("i",):
self.mode = "INSERT"
return self._insert(text, offset, "-- INSERT --")
if token == "I":
self.mode = "INSERT"
return self._insert(text, _first_nonblank_off(text, offset), "-- INSERT (行首) --")
if token == "a":
self.mode = "INSERT"
return self._insert(text, min(offset + 1, len(text)), "-- INSERT (追加) --")
if token == "A":
self.mode = "INSERT"
return self._insert(text, _line_end_off(text, offset), "-- INSERT (行尾) --")
if token == "o":
self.mode = "INSERT"
e = _line_end_off(text, offset)
new_text = text[:e] + "\n" + text[e:]
return self._insert(new_text, e + 1, "-- INSERT (新行) --")
if token == "O":
self.mode = "INSERT"
s = _line_start_off(text, offset)
new_text = text[:s] + "\n" + text[s:]
return self._insert(new_text, s, "-- INSERT (上方新行) --")
if token in ("h", "left"):
new_off = max(_line_start_off(text, offset), offset - 1)
return EditResult(text, new_off, "NORMAL", None)
if token in ("l", "right"):
le = _line_end_off(text, offset)
new_off = min(le, offset + 1) if offset < len(text) else offset
return EditResult(text, new_off, "NORMAL", None)
if token in ("j", "down"):
r, c = _offset_to_rc(text, offset)
return EditResult(text, _rc_to_offset(text, r + 1, c), "NORMAL", None)
if token in ("k", "up"):
r, c = _offset_to_rc(text, offset)
return EditResult(text, _rc_to_offset(text, r - 1, c), "NORMAL", None)
if token == "w":
return EditResult(text, _word_forward_off(text, offset), "NORMAL", None)
if token == "b":
return EditResult(text, _word_back_off(text, offset), "NORMAL", None)
if token == "e":
i = _word_forward_off(text, offset)
new_off = max(offset, i - 1) if i > offset else offset
return EditResult(text, new_off, "NORMAL", None)
if token == "0":
return EditResult(text, _line_start_off(text, offset), "NORMAL", None)
if token == "^":
return EditResult(text, _first_nonblank_off(text, offset), "NORMAL", None)
if token == "$":
return EditResult(text, _line_end_off(text, offset), "NORMAL", None)
if token == "G":
r, c = _offset_to_rc(text, offset)
last_row = text.count("\n")
return EditResult(text, _rc_to_offset(text, last_row, c), "NORMAL", None)
if token == "g":
self.pending = "g"
return EditResult(text, offset, "NORMAL", "g", message="g")
if token == "d":
self.pending = "d"
return EditResult(text, offset, "NORMAL", "d", message="d")
if token == "x":
if offset < len(text):
new_text = text[:offset] + text[offset + 1:]
return EditResult(new_text, offset, "NORMAL", None, message="x")
return EditResult(text, offset, "NORMAL", None)
if token == "u":
return EditResult(text, offset, "NORMAL", None, action="undo", message="u")
if token == "escape":
return EditResult(text, offset, "NORMAL", None, message="-- NORMAL --")
# 未知键:忽略(保持 NORMAL)
return EditResult(text, offset, "NORMAL", None)
# ── operator 实现 ──
def _delete_line(self, text, offset, msg) -> EditResult:
s = _line_start_off(text, offset)
e = _line_end_off(text, offset)
if e < len(text):
e2 = e + 1 # 连同行尾换行一起删
else:
e2 = e
if s > 0:
s = s - 1 # 最后一行:删掉上一行的换行
new_text = text[:s] + text[e2:]
new_off = min(s, len(new_text))
return EditResult(new_text, new_off, "NORMAL", None, message=msg)
def _delete_word(self, text, offset, msg) -> EditResult:
s = offset
e = _word_forward_off(text, offset)
new_text = text[:s] + text[e:]
new_off = min(s, len(new_text))
return EditResult(new_text, new_off, "NORMAL", None, message=msg)
def _delete_to_eol(self, text, offset, msg) -> EditResult:
s = offset
e = _line_end_off(text, offset)
new_text = text[:s] + text[e:]
new_off = min(s, len(new_text))
return EditResult(new_text, new_off, "NORMAL", None, message=msg)