diff --git a/CHANGELOG.md b/CHANGELOG.md index def4e305d..b54e5b475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Shell: Fix Alt+Backspace not deleting a word inside modal text input (e.g. the `/btw` panel) — the global escape key binding was registered with `eager=True`, which swallowed the leading `escape` of the `escape,backspace` sequence before prompt_toolkit could match the emacs `backward-kill-word` binding; the eager flag is now disabled while a modal text input delegate is active so multi-key sequences resolve normally (fixes #1744) - Shell: Add `/btw` side question command — ask a quick question during streaming without interrupting the main conversation; uses the same system prompt and tool definitions for prompt cache alignment; responses display in a scrollable modal panel with streaming support - Shell: Redesign bottom dynamic area — split the monolithic `visualize.py` (1865 lines) into a modular package (`visualize/`) with dedicated modules for input routing, interactive prompts, approval/question panels, and btw modal; unify input semantics with `classify_input()` for consistent command routing - Shell: Add queue and steer dual-channel input during streaming — Enter queues messages for delivery after the current turn; Ctrl+S injects messages immediately into the running turn's context; queued messages display in the prompt area with count indicator and can be recalled with ↑ diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 7ab68c6c6..084e7b7e2 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,7 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- Shell: Fix Alt+Backspace not deleting a word inside modal text input (e.g. the `/btw` panel) — the global escape key binding was registered with `eager=True`, which swallowed the leading `escape` of the `escape,backspace` sequence before prompt_toolkit could match the emacs `backward-kill-word` binding; the eager flag is now disabled while a modal text input delegate is active so multi-key sequences resolve normally (fixes #1744) - Shell: Add `/btw` side question command — ask a quick question during streaming without interrupting the main conversation; uses the same system prompt and tool definitions for prompt cache alignment; responses display in a scrollable modal panel with streaming support - Shell: Redesign bottom dynamic area — split the monolithic `visualize.py` (1865 lines) into a modular package (`visualize/`) with dedicated modules for input routing, interactive prompts, approval/question panels, and btw modal; unify input semantics with `classify_input()` for consistent command routing - Shell: Add queue and steer dual-channel input during streaming — Enter queues messages for delivery after the current turn; Ctrl+S injects messages immediately into the running turn's context; queued messages display in the prompt area with count indicator and can be recalled with ↑ diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 34d66cbd9..2d9496f39 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,7 @@ ## 未发布 +- Shell:修复模态文本输入(如 `/btw` 面板)中 Alt+Backspace 无法按词删除的问题——全局 escape 键绑定注册时使用了 `eager=True`,会在 prompt_toolkit 匹配 emacs `backward-kill-word` 之前吞掉 `escape,backspace` 序列里的 escape;现在当模态文本输入 delegate 处于活跃状态时禁用 eager 标志,多键序列恢复正常解析(修复 #1744) - Shell:新增 `/btw` 侧问命令——在 streaming 期间提出快速问题,不打断主对话;使用相同的系统提示词和工具定义以对齐 Prompt 缓存;响应在可滚动的模态面板中显示,支持流式输出 - Shell:重新设计底部动态区——将单体 `visualize.py`(1865 行)拆分为模块化包(`visualize/`),包含输入路由、交互式提示、审批/提问面板和 btw 模态面板等独立模块;通过 `classify_input()` 统一输入语义,实现一致的命令路由 - Shell:新增 streaming 期间的排队和 steer 双通道输入——Enter 将消息排队,在当前轮次结束后发送;Ctrl+S 将消息立即注入到正在运行的轮次上下文中;排队消息在提示区域显示计数指示器,可通过 ↑ 键召回编辑 diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index 57d344cc7..d79ed1907 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -1369,9 +1369,16 @@ def _(event: KeyPressEvent) -> None: def _(event: KeyPressEvent) -> None: self._handle_running_prompt_key("c-d", event) + # `eager=True` ensures a plain Escape closes the modal immediately, but + # prompt_toolkit's eager matching short-circuits longer sequences such as + # `escape backspace` (Alt+Backspace → backward-kill-word). When the modal + # accepts text input, drop eager so the default Alt+X emacs bindings + # (backward-kill-word, backward-word, etc.) work against the buffer + # (fixes #1744). The standalone `escape` binding still fires when no + # longer sequence arrives, preserving the Escape-to-cancel behaviour. @_kb.add( "escape", - eager=True, + eager=Condition(lambda: self._active_ui_state() != PromptUIState.MODAL_TEXT_INPUT), filter=Condition(lambda: self._should_handle_running_prompt_key("escape")), ) def _(event: KeyPressEvent) -> None: diff --git a/tests/ui_and_conv/test_modal_lifecycle.py b/tests/ui_and_conv/test_modal_lifecycle.py index 3a2ded4a2..918b6a82c 100644 --- a/tests/ui_and_conv/test_modal_lifecycle.py +++ b/tests/ui_and_conv/test_modal_lifecycle.py @@ -1272,3 +1272,88 @@ async def test_clear_active_approval_sink_requeues_pending_requests( shell._pending_approval_requests.clear() # type: ignore[attr-defined] shell._clear_active_view() # type: ignore[attr-defined] assert len(shell._pending_approval_requests) == 0 # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# CustomPromptSession._active_ui_state — drives the escape key eager filter +# that fixes Alt+Backspace inside text-input modals (#1744). +# --------------------------------------------------------------------------- + + +def _make_prompt_session_stub(delegate: object | None): + """Partially build a CustomPromptSession with just enough state to call + `_active_ui_state()`. Avoids the full `__init__` which touches filesystem + history and clipboard.""" + from kimi_cli.ui.shell.prompt import CustomPromptSession + + session = object.__new__(CustomPromptSession) + session._running_prompt_delegate = None # type: ignore[attr-defined] + session._modal_delegates = [] # type: ignore[attr-defined] + if delegate is not None: + session._modal_delegates.append(delegate) # type: ignore[attr-defined] + return session + + +def test_active_ui_state_is_modal_text_input_for_text_accepting_delegate() -> None: + """When the active modal delegate accepts text input, the session reports + MODAL_TEXT_INPUT. The escape key binding's eager filter relies on this to + drop out of eager mode so that default emacs Alt+X sequences (notably + Alt+Backspace → backward-kill-word) reach the buffer (#1744). + """ + from kimi_cli.ui.shell.prompt import PromptUIState + + class _TextInputDelegate: + modal_priority = 10 + + @staticmethod + def running_prompt_hides_input_buffer() -> bool: + return False + + @staticmethod + def running_prompt_allows_text_input() -> bool: + return True + + session = _make_prompt_session_stub(_TextInputDelegate()) + assert session._active_ui_state() == PromptUIState.MODAL_TEXT_INPUT # type: ignore[attr-defined] + + +def test_active_ui_state_is_normal_for_non_text_input_delegate() -> None: + """A modal that does not accept text input (e.g. question panel in + multi-choice mode) should leave the session in NORMAL_INPUT so plain + Escape closes it eagerly.""" + from kimi_cli.ui.shell.prompt import PromptUIState + + class _ChoiceOnlyDelegate: + modal_priority = 10 + + @staticmethod + def running_prompt_hides_input_buffer() -> bool: + return False + + @staticmethod + def running_prompt_allows_text_input() -> bool: + return False + + session = _make_prompt_session_stub(_ChoiceOnlyDelegate()) + assert session._active_ui_state() == PromptUIState.NORMAL_INPUT # type: ignore[attr-defined] + + +def test_active_ui_state_is_modal_hidden_for_buffer_hiding_delegate() -> None: + """A modal that hides the input buffer (approval panel, btw modal) should + report MODAL_HIDDEN_INPUT; the eager escape filter stays eager in that + state.""" + from kimi_cli.ui.shell.prompt import PromptUIState + + class _HiddenBufferDelegate: + modal_priority = 10 + + @staticmethod + def running_prompt_hides_input_buffer() -> bool: + return True + + @staticmethod + def running_prompt_allows_text_input() -> bool: + return False + + session = _make_prompt_session_stub(_HiddenBufferDelegate()) + assert session._active_ui_state() == PromptUIState.MODAL_HIDDEN_INPUT # type: ignore[attr-defined]