Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66de8eea13 |
No files matched your search
@@ -4,6 +4,27 @@
|
||||
|
||||
---
|
||||
|
||||
## v8.0.3 (2026-08-07) — 体验增强
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
- **TUI 新增 Vim 风格键位**(默认开,可用 `/vim` 开关)
|
||||
- 输入框**模态编辑**:`Esc` 在 NORMAL / INSERT 间切换;NORMAL 下
|
||||
- 移动:`h j k l`、`w / b`(跳词)、`0 / $`(行首/行尾)、`^`(首个非空白)、`gg / G`(文首/文尾)
|
||||
- 进入插入:`i / a / o`(插入/追加/下方新行)、`I / A / O`(行首/行尾/上方新行)
|
||||
- 编辑:`dd`(删行)、`x`(删字)、`dw`(删词)、`d$`(删到行尾)、`u`(撤销)
|
||||
- 普通模式下 `回车` = 下移一行(与 vim 一致)
|
||||
- 核心状态机抽成纯模块 `xcli_core/vim_keys.py`(`VimInputState`,不依赖 textual),配 `tests/test_vim_keys.py` 单测,18 项动作全部验证通过
|
||||
- 聊天区导航(输入框未聚焦时生效):`j / k` 逐行滚动、`Ctrl+F / B` 翻页、`Ctrl+D / U` 半页、`Ctrl+Space` 切换焦点、`i` 聚焦并进入插入
|
||||
- 输入框下方提示栏实时显示 `-- INSERT --` / `-- NORMAL --` 模式;`/help` 已补充 Vim 键位说明
|
||||
- 可通过 `config.json` 的 `vim_mode: false` 默认关闭
|
||||
|
||||
### 🛠 工程
|
||||
|
||||
- 版本号统一更新至 v8.0.3(含 README 中英 badge、`about.txt`、`xcli_core/constants.py` 中央 `VERSION`、TUI banner、LSP/MCP `clientInfo.version`)
|
||||
|
||||
---
|
||||
|
||||
## v8.0.2 (2026-08-07) — 小修复版本
|
||||
|
||||
### 🐛 Bug 修复
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-8.0.2-blue" alt="version">
|
||||
<img src="https://img.shields.io/badge/version-8.0.3-blue" alt="version">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-green" alt="python">
|
||||
<img src="https://img.shields.io/badge/license-MIT-orange" alt="license">
|
||||
<img src="https://img.shields.io/badge/tests-113%20passed-brightgreen" alt="tests">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-8.0.2-blue" alt="version">
|
||||
<img src="https://img.shields.io/badge/version-8.0.3-blue" alt="version">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-green" alt="python">
|
||||
<img src="https://img.shields.io/badge/license-MIT-orange" alt="license">
|
||||
<img src="https://img.shields.io/badge/tests-150+%20passed-brightgreen" alt="tests">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
**产品标识**
|
||||
- **产品名称**:小狸智能体 增强-终端(Xiaoli Pro-CLI)
|
||||
- **内部版本号**:20260801
|
||||
- **发布版本**:发行版8.0.2
|
||||
- **发布版本**:发行版8.0.3
|
||||
|
||||
**技术规格**
|
||||
- **主要语言**:简体中文(UTF-8)
|
||||
|
||||
+1
-1
@@ -268,7 +268,7 @@ class LspClient:
|
||||
"workspace": {"workspaceFolders": True,
|
||||
"configuration": True},
|
||||
},
|
||||
"clientInfo": {"name": "xiaoli-cli", "version": "8.0.2"},
|
||||
"clientInfo": {"name": "xiaoli-cli", "version": "8.0.3"},
|
||||
})
|
||||
if isinstance(result, dict):
|
||||
self.capabilities = result.get("capabilities", {}) or {}
|
||||
|
||||
+1
-1
@@ -153,7 +153,7 @@ class McpClient:
|
||||
result = self._send("initialize", {
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "xiaoli-cli", "version": "8.0.2"},
|
||||
"clientInfo": {"name": "xiaoli-cli", "version": "8.0.3"},
|
||||
})
|
||||
# 通知 server 初始化完成(无响应,无需等待)
|
||||
self._send("notifications/initialized", {}, is_notification=True)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""VimInputState 纯逻辑单测(不依赖 textual)。
|
||||
|
||||
用一个极简 FakeBuffer 持有 (text, offset),把 EditResult 应用回去,
|
||||
逐动作断言文本/光标/模式变化。
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from xcli_core.vim_keys import VimInputState, EditResult
|
||||
|
||||
|
||||
class FakeBuffer:
|
||||
"""模拟输入框:持有 text 与 cursor offset。"""
|
||||
def __init__(self, text, offset, mode="INSERT"):
|
||||
self.st = VimInputState(mode=mode)
|
||||
self.text = text
|
||||
self.offset = offset
|
||||
|
||||
def press(self, token):
|
||||
res = self.st.feed(self.text, self.offset, token)
|
||||
self.text = res.text
|
||||
self.offset = res.offset
|
||||
return res
|
||||
|
||||
def press_seq(self, *tokens):
|
||||
return [self.press(t) for t in tokens]
|
||||
|
||||
|
||||
def test_escape_enters_normal_and_steps_left():
|
||||
b = FakeBuffer("hello", 5, mode="INSERT")
|
||||
r = b.press("escape")
|
||||
assert b.st.mode == "NORMAL"
|
||||
assert b.offset == 4
|
||||
assert r.message == "-- NORMAL --"
|
||||
|
||||
|
||||
def test_normal_h_l_clamp():
|
||||
b = FakeBuffer("hello", 0, mode="NORMAL")
|
||||
b.press("h") # 行首不动
|
||||
assert b.offset == 0
|
||||
b.press_seq("l", "l", "l", "l", "l") # 走到末尾
|
||||
assert b.offset == 5
|
||||
b.press("l") # 末尾不动
|
||||
assert b.offset == 5
|
||||
|
||||
|
||||
def test_j_k_move_lines():
|
||||
b = FakeBuffer("aa\nbb\ncc", 1, mode="NORMAL") # 第0行 col1
|
||||
b.press("j") # 第1行 col1
|
||||
assert b.offset == 4 # "aa\n"=3 +1
|
||||
b.press("k") # 回第0行 col1
|
||||
assert b.offset == 1
|
||||
|
||||
|
||||
def test_0_and_dollar():
|
||||
b = FakeBuffer("hello world", 5, mode="NORMAL")
|
||||
b.press("0")
|
||||
assert b.offset == 0
|
||||
b.press("$")
|
||||
assert b.offset == 11
|
||||
|
||||
|
||||
def test_i_enters_insert():
|
||||
b = FakeBuffer("abc", 1, mode="NORMAL")
|
||||
r = b.press("i")
|
||||
assert b.st.mode == "INSERT"
|
||||
assert r.action is None
|
||||
|
||||
|
||||
def test_a_insert_after():
|
||||
b = FakeBuffer("abc", 1, mode="NORMAL")
|
||||
r = b.press("a")
|
||||
assert b.st.mode == "INSERT"
|
||||
assert b.offset == 2
|
||||
|
||||
|
||||
def test_o_opens_new_line():
|
||||
b = FakeBuffer("line1\nline2", 2, mode="NORMAL") # 第0行
|
||||
r = b.press("o")
|
||||
assert b.st.mode == "INSERT"
|
||||
assert b.text == "line1\n\nline2"
|
||||
assert b.offset == 6 # "line1\n"=6
|
||||
|
||||
|
||||
def test_O_opens_above():
|
||||
b = FakeBuffer("line1\nline2", 7, mode="NORMAL") # 第1行 'l' of line2
|
||||
r = b.press("O")
|
||||
assert b.st.mode == "INSERT"
|
||||
assert b.text == "line1\n\nline2"
|
||||
assert b.offset == 6 # 新空行起始
|
||||
|
||||
|
||||
def test_dd_deletes_line():
|
||||
b = FakeBuffer("aaa\nbbb\nccc", 4, mode="NORMAL") # 第1行
|
||||
b.press_seq("d", "d")
|
||||
assert b.text == "aaa\nccc"
|
||||
assert b.offset == 4 # 回到第1行行首(aaa\n 之后)
|
||||
|
||||
|
||||
def test_dw_deletes_word():
|
||||
b = FakeBuffer("foo bar baz", 0, mode="NORMAL")
|
||||
b.press_seq("d", "w")
|
||||
assert b.text == "bar baz"
|
||||
assert b.offset == 0
|
||||
|
||||
|
||||
def test_d_dollar_deletes_to_eol():
|
||||
b = FakeBuffer("abc def", 2, mode="NORMAL") # 第0行 col2 ('c')
|
||||
b.press_seq("d", "$")
|
||||
assert b.text == "ab"
|
||||
assert b.offset == 2
|
||||
|
||||
|
||||
def test_x_deletes_char():
|
||||
b = FakeBuffer("abc", 1, mode="NORMAL") # 'b'
|
||||
b.press("x")
|
||||
assert b.text == "ac"
|
||||
assert b.offset == 1
|
||||
|
||||
|
||||
def test_w_b_word_motion():
|
||||
b = FakeBuffer("foo bar baz", 0, mode="NORMAL")
|
||||
b.press("w")
|
||||
assert b.offset == 4 # 下一词 "bar" 词首
|
||||
b.press("b")
|
||||
assert b.offset == 0
|
||||
|
||||
|
||||
def test_gg_and_G():
|
||||
b = FakeBuffer("a\nb\nc", 4, mode="NORMAL")
|
||||
b.press_seq("g", "g")
|
||||
assert b.offset == 0
|
||||
b.press("G")
|
||||
assert b.offset == 4 # 末行同列
|
||||
|
||||
|
||||
def test_u_sets_undo_action():
|
||||
b = FakeBuffer("hello", 2, mode="NORMAL")
|
||||
r = b.press("u")
|
||||
assert r.action == "undo"
|
||||
|
||||
|
||||
def test_pending_cleared_on_invalid_combo():
|
||||
b = FakeBuffer("abc", 1, mode="NORMAL")
|
||||
b.press("d") # 进入待定
|
||||
assert b.st.pending == "d"
|
||||
b.press("x") # 非法组合 -> 清待定, 当普通键
|
||||
assert b.st.pending is None
|
||||
assert b.st.mode == "NORMAL"
|
||||
|
||||
|
||||
def test_unmapped_key_ignored_in_normal():
|
||||
b = FakeBuffer("abc", 1, mode="NORMAL")
|
||||
r = b.press("z") # 未映射
|
||||
assert r.handled is True
|
||||
assert b.text == "abc"
|
||||
assert b.offset == 1
|
||||
|
||||
|
||||
def test_insert_typing_delegated():
|
||||
b = FakeBuffer("abc", 1, mode="INSERT")
|
||||
r = b.press("h") # 非 Esc:交给输入框默认处理
|
||||
assert r.handled is False
|
||||
@@ -7,7 +7,7 @@ LOVE_FILE_PATH = "love.txt"
|
||||
DEFAULT_MAX_HISTORY = 999999 # 无限制对话历史
|
||||
|
||||
# 版本号(v8.0 基于 v5.4.1 重新立项)
|
||||
VERSION = "8.0.2"
|
||||
VERSION = "8.0.3"
|
||||
VERSION_NAME = "小狸 Pro-CLI"
|
||||
|
||||
import os
|
||||
|
||||
@@ -28,7 +28,7 @@ class RichTUI:
|
||||
self.cli = cli
|
||||
# record=True 是关键:渲染过程被记录,可随时导出文本/SVG/HTML 自验
|
||||
self.console = Console(record=True, width=100)
|
||||
self.version = "8.0.2"
|
||||
self.version = "8.0.3"
|
||||
self.engine = ""
|
||||
self.model = ""
|
||||
self.mode = "普通"
|
||||
|
||||
+243
-5
@@ -11,6 +11,7 @@ if TEXTUAL_AVAILABLE:
|
||||
from textual.reactive import var
|
||||
from textual.binding import Binding
|
||||
from textual import on
|
||||
from .vim_keys import VimInputState
|
||||
import os
|
||||
from rich.syntax import Syntax
|
||||
from rich.panel import Panel
|
||||
@@ -560,6 +561,125 @@ if TEXTUAL_AVAILABLE:
|
||||
return
|
||||
# 其他键正常处理
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Vim 风格模态输入框(NORMAL / INSERT)
|
||||
# ═══════════════════════════════════════════════════
|
||||
|
||||
class VimSendTextArea(SendTextArea):
|
||||
"""
|
||||
带 Vim 模态编辑的输入框。
|
||||
|
||||
模式:
|
||||
INSERT —— 默认,正常打字;Esc 进入 NORMAL
|
||||
NORMAL —— h/j/k/l 移动,i/a/o 进入插入,dd 删行,x 删字,
|
||||
w/b 跳词,0/$ 行首行尾,gg/G 文首文尾,u 撤销 …
|
||||
|
||||
坐标:VimInputState 用字符偏移 offset,这里负责与 TextArea 的
|
||||
(row, col) 光标互相转换。所有 vim 逻辑都在 vim_keys.py(纯函数,
|
||||
可单测);本类只做「按键映射 + 把结果写回 TextArea」。
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.vim_enabled = True
|
||||
self.vim = VimInputState(mode="INSERT")
|
||||
|
||||
# ── offset <-> (row, col) ──
|
||||
def _cur_off(self) -> int:
|
||||
r, c = self.cursor_location
|
||||
text = self.text
|
||||
lines = text.split("\n")
|
||||
return sum(len(l) + 1 for l in lines[:r]) + c
|
||||
|
||||
def _set_off(self, off: int):
|
||||
text = self.text
|
||||
off = max(0, min(off, len(text)))
|
||||
r = text.count("\n", 0, off)
|
||||
line_start = text.rfind("\n", 0, off)
|
||||
c = off - (line_start + 1)
|
||||
self.cursor_location = (r, c)
|
||||
|
||||
# ── key 名字 -> vim token ──
|
||||
@staticmethod
|
||||
def _vim_token(key: str):
|
||||
m = {
|
||||
"escape": "escape", "h": "h", "l": "l", "j": "j", "k": "k",
|
||||
"w": "w", "b": "b", "e": "e", "0": "0", "$": "$", "^": "^",
|
||||
"g": "g", "G": "G", "d": "d", "x": "x", "u": "u",
|
||||
"i": "i", "a": "a", "o": "o", "O": "O", "I": "I", "A": "A",
|
||||
"left": "left", "right": "right", "up": "up", "down": "down",
|
||||
"shift+i": "I", "shift+a": "A", "shift+o": "O",
|
||||
"shift+g": "G", "shift+6": "^", "shift+4": "$",
|
||||
}
|
||||
return m.get(key)
|
||||
|
||||
def _notify_mode(self):
|
||||
app = self.app
|
||||
if hasattr(app, "_update_vim_mode_display"):
|
||||
try:
|
||||
app._update_vim_mode_display()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _apply(self, res):
|
||||
try:
|
||||
if res.text != self.text:
|
||||
self.text = res.text
|
||||
self._set_off(res.offset)
|
||||
self.vim.mode = res.mode
|
||||
self.vim.pending = res.pending
|
||||
if res.action == "undo":
|
||||
try:
|
||||
self.undo()
|
||||
except Exception:
|
||||
pass
|
||||
self._notify_mode()
|
||||
except Exception:
|
||||
# 任何意外都不应让输入框崩;退回默认行为
|
||||
pass
|
||||
|
||||
async def _on_key(self, event):
|
||||
# 回车:INSERT(或 vim 关闭)发送;NORMAL 下移一行
|
||||
if event.key in ("enter", "\r", "\n"):
|
||||
if self.vim_enabled and self.vim.mode == "NORMAL":
|
||||
event.prevent_default()
|
||||
res = self.vim.feed(self.text, self._cur_off(), "j")
|
||||
self._apply(res)
|
||||
else:
|
||||
event.prevent_default()
|
||||
try:
|
||||
self.app.action_send_message()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not self.vim_enabled:
|
||||
return # 退化为普通 TextArea
|
||||
|
||||
if self.vim.mode == "INSERT":
|
||||
if event.key == "escape":
|
||||
event.prevent_default()
|
||||
res = self.vim.feed(self.text, self._cur_off(), "escape")
|
||||
self._apply(res)
|
||||
# 其余键交给 TextArea 默认处理(打字)
|
||||
return
|
||||
|
||||
# ── NORMAL 模式 ──
|
||||
if event.key == "shift+enter":
|
||||
token = "o"
|
||||
else:
|
||||
token = self._vim_token(event.key)
|
||||
if token is None:
|
||||
# ctrl+/shift+ 组合键放回去,让 App 级绑定处理(聚焦切换等)
|
||||
if event.key.startswith("ctrl+") or event.key.startswith("shift+"):
|
||||
return
|
||||
# 普通单键未映射:吞掉,避免 TextArea 在 NORMAL 下误编辑
|
||||
event.prevent_default()
|
||||
return
|
||||
event.prevent_default()
|
||||
res = self.vim.feed(self.text, self._cur_off(), token)
|
||||
self._apply(res)
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# TUI 主应用
|
||||
# ═══════════════════════════════════════════════════
|
||||
@@ -583,6 +703,15 @@ if TEXTUAL_AVAILABLE:
|
||||
Binding("3", "tab_sessions", "会话", show=False),
|
||||
Binding("4", "tab_status", "状态", show=False),
|
||||
Binding("escape", "cancel", "取消", show=False),
|
||||
# ── Vim 风格导航(输入框未聚焦时生效)──
|
||||
Binding("j", "chat_down", "↓聊天", show=False),
|
||||
Binding("k", "chat_up", "↑聊天", show=False),
|
||||
Binding("i", "focus_insert", "插入", show=False),
|
||||
Binding("ctrl+f", "chat_page_down", "翻页↓", show=True),
|
||||
Binding("ctrl+b", "chat_page_up", "翻页↑", show=True),
|
||||
Binding("ctrl+d", "chat_half_down", "半页↓", show=True),
|
||||
Binding("ctrl+u", "chat_half_up", "半页↑", show=True),
|
||||
Binding("ctrl+space", "toggle_focus", "聚焦切换", show=True),
|
||||
]
|
||||
|
||||
sidebar_visible = var(True)
|
||||
@@ -605,8 +734,8 @@ if TEXTUAL_AVAILABLE:
|
||||
yield VerticalScroll(id="chat-scroll")
|
||||
with Vertical(id="input-area"):
|
||||
with Container(id="input-wrapper"):
|
||||
yield SendTextArea(
|
||||
placeholder="输入消息... (回车发送, Shift+回车换行)",
|
||||
yield VimSendTextArea(
|
||||
placeholder="输入消息... (回车发送, Shift+回车换行, Esc 进 Vim 普通模式)",
|
||||
id="user-input",
|
||||
soft_wrap=True,
|
||||
tab_behavior="indent",
|
||||
@@ -638,6 +767,8 @@ if TEXTUAL_AVAILABLE:
|
||||
self._switch_sidebar_tab("engine")
|
||||
# 精简欢迎(一行)
|
||||
self._render_welcome_compact()
|
||||
# 初始化 Vim 键位开关(默认开,可被 config.vim_mode 关闭)
|
||||
self._init_vim_mode()
|
||||
self.query_one("#user-input").focus()
|
||||
def _show_hint():
|
||||
try:
|
||||
@@ -646,6 +777,18 @@ if TEXTUAL_AVAILABLE:
|
||||
pass
|
||||
self.set_timer(1.5, _show_hint)
|
||||
|
||||
def _init_vim_mode(self):
|
||||
try:
|
||||
ta = self.query_one("#user-input")
|
||||
except Exception:
|
||||
return
|
||||
cfg = getattr(self.cli, "config", None)
|
||||
enabled = True
|
||||
if isinstance(cfg, dict) and "vim_mode" in cfg:
|
||||
enabled = bool(cfg["vim_mode"])
|
||||
ta.vim_enabled = enabled
|
||||
self._update_vim_mode_display()
|
||||
|
||||
# ── 欢迎界面 ──
|
||||
|
||||
def _render_welcome_compact(self):
|
||||
@@ -667,7 +810,7 @@ if TEXTUAL_AVAILABLE:
|
||||
"",
|
||||
" ╔══════════════════════════════════════════════╗",
|
||||
" ║ ║",
|
||||
" ║ 小狸 Pro-CLI v8.0.2 ║",
|
||||
" ║ 小狸 Pro-CLI v8.0.3 ║",
|
||||
" ║ 智能编程助手 · 动画增强版 ║",
|
||||
" ║ ║",
|
||||
" ╚══════════════════════════════════════════════╝",
|
||||
@@ -711,7 +854,7 @@ if TEXTUAL_AVAILABLE:
|
||||
("", "msg-dim"),
|
||||
(" ╔══════════════════════════════════════════════╗", "msg-welcome"),
|
||||
(" ║ ║", "msg-welcome"),
|
||||
(" ║ 小狸 Pro-CLI v8.0.2 ║", "msg-welcome"),
|
||||
(" ║ 小狸 Pro-CLI v8.0.3 ║", "msg-welcome"),
|
||||
(" ║ 智能编程助手 · 动画增强版 ║", "msg-welcome"),
|
||||
(" ║ ║", "msg-welcome"),
|
||||
(" ╚══════════════════════════════════════════════╝", "msg-welcome"),
|
||||
@@ -1087,6 +1230,7 @@ if TEXTUAL_AVAILABLE:
|
||||
'resume': lambda: self._tui_resume(args),
|
||||
'snapshot': lambda: self._save_screenshot(),
|
||||
'screenshot': lambda: self._save_screenshot(),
|
||||
'vim': lambda: self._toggle_vim(),
|
||||
}
|
||||
|
||||
handler = cmds.get(name)
|
||||
@@ -1114,12 +1258,21 @@ if TEXTUAL_AVAILABLE:
|
||||
/tools 列出工具
|
||||
/status 系统状态
|
||||
/clear 清屏
|
||||
/vim 开关 Vim 键位
|
||||
|
||||
⌨ 快捷键:
|
||||
Ctrl+C 退出 Ctrl+L 清屏
|
||||
Ctrl+N 新对话 回车 发送消息
|
||||
Shift+回车 换行 F1 侧栏
|
||||
Escape 取消生成 Tab 缩进"""
|
||||
Escape 取消生成 Tab 缩进
|
||||
Ctrl+Space 焦点切换 i 聚焦并输入
|
||||
Ctrl+F/B 翻页下/上 Ctrl+D/U 半页下/上
|
||||
|
||||
VIM 输入(默认开): 输入框内 Esc 进普通模式
|
||||
h j k l 移动 w/b 跳词 0/$ 行首/行尾
|
||||
i a o 插入/追加/换行 I A O 行首/行尾/上方
|
||||
dd 删行 x 删字 u 撤销 gg/G 文首/文尾
|
||||
普通模式下 回车 = 下移一行"""
|
||||
self._system(help_text)
|
||||
|
||||
def _handle_manual(self, args):
|
||||
@@ -1341,6 +1494,91 @@ if TEXTUAL_AVAILABLE:
|
||||
self._system("已取消")
|
||||
self.is_generating = False
|
||||
|
||||
# ── Vim 风格:聊天区滚动 / 聚焦切换 ──
|
||||
def _user_input(self):
|
||||
return self.query_one("#user-input")
|
||||
|
||||
def _chat_scroll(self):
|
||||
return self.query_one("#chat-scroll")
|
||||
|
||||
def action_toggle_focus(self):
|
||||
"""Ctrl+Space:在输入框与聊天区之间切换焦点。"""
|
||||
ta = self._user_input()
|
||||
if ta.has_focus:
|
||||
self.screen.set_focus(None)
|
||||
else:
|
||||
ta.focus()
|
||||
if getattr(ta, "vim_enabled", False):
|
||||
ta.vim.mode = "INSERT"
|
||||
self._update_vim_mode_display()
|
||||
|
||||
def action_focus_insert(self):
|
||||
"""i:输入框未聚焦时聚焦并进入 INSERT(模仿 vim 按 i 开始输入)。"""
|
||||
ta = self._user_input()
|
||||
if ta.has_focus:
|
||||
return
|
||||
ta.focus()
|
||||
if getattr(ta, "vim_enabled", False):
|
||||
ta.vim.mode = "INSERT"
|
||||
self._update_vim_mode_display()
|
||||
|
||||
def _guard_chat(self):
|
||||
"""聊天导航仅在输入框未聚焦时生效,避免与输入冲突。"""
|
||||
return self._user_input().has_focus
|
||||
|
||||
def action_chat_down(self):
|
||||
if self._guard_chat():
|
||||
return
|
||||
self._chat_scroll().scroll_down(3)
|
||||
|
||||
def action_chat_up(self):
|
||||
if self._guard_chat():
|
||||
return
|
||||
self._chat_scroll().scroll_up(3)
|
||||
|
||||
def action_chat_page_down(self):
|
||||
if self._guard_chat():
|
||||
return
|
||||
self._chat_scroll().scroll_page_down()
|
||||
|
||||
def action_chat_page_up(self):
|
||||
if self._guard_chat():
|
||||
return
|
||||
self._chat_scroll().scroll_page_up()
|
||||
|
||||
def action_chat_half_down(self):
|
||||
if self._guard_chat():
|
||||
return
|
||||
self._chat_scroll().scroll_relative(y=20)
|
||||
|
||||
def action_chat_half_up(self):
|
||||
if self._guard_chat():
|
||||
return
|
||||
self._chat_scroll().scroll_relative(y=-20)
|
||||
|
||||
def _update_vim_mode_display(self):
|
||||
"""在输入框下方提示栏显示当前 Vim 模式。"""
|
||||
try:
|
||||
hint = self.query_one("#input-hint")
|
||||
ta = self._user_input()
|
||||
except Exception:
|
||||
return
|
||||
if not getattr(ta, "vim_enabled", False):
|
||||
hint.update(" 回车发送 | Shift+回车换行 | Ctrl+L 清屏 | F1 侧栏 | Ctrl+P PLAN")
|
||||
return
|
||||
mode = getattr(getattr(ta, "vim", None), "mode", "INSERT")
|
||||
tag = "-- INSERT --" if mode == "INSERT" else "-- NORMAL --"
|
||||
hint.update(f" {tag} │ Esc 切模式 │ i插入 a追加 o换行 hjkl移动 dd删行 x删字 u撤销")
|
||||
|
||||
def _toggle_vim(self):
|
||||
ta = self._user_input()
|
||||
ta.vim_enabled = not getattr(ta, "vim_enabled", True)
|
||||
if ta.vim_enabled:
|
||||
self._system("Vim 键位: 开 (Esc 在 NORMAL/INSERT 间切换)")
|
||||
else:
|
||||
self._system("Vim 键位: 关 (普通输入)")
|
||||
self._update_vim_mode_display()
|
||||
|
||||
def _save_screenshot(self):
|
||||
"""导出当前 TUI 屏幕截图(SVG)——AI 可据此自验实时渲染(/snapshot)"""
|
||||
import os
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user