Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users.

## Unreleased

- Shell: Fix `Ctrl+Z` suspending the process when the raw keyboard listener is active — clear `ISIG` and `IEXTEN` terminal flags in raw mode so control characters (`Ctrl+Z`, `Ctrl+C`) are received as byte events instead of kernel signals; `Ctrl+C` now cancels the active run in the standalone visualization path

- Shell: Show the current working directory, git branch, dirty state, and ahead/behind sync status directly in the prompt toolbar
- Shell: Surface active background bash task counts in the toolbar, rotate shortcut tips on a timer, and gracefully truncate the toolbar on narrow terminals to avoid overflow
- Web: Fix tool execution status synchronization on cancel and approval — tools now correctly transition to `output-denied` state when generation is stopped, and show the loading spinner (instead of checkmark) while executing after approval
Expand Down
2 changes: 2 additions & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ This page documents the changes in each Kimi Code CLI release.

## Unreleased

- Shell: Fix `Ctrl+Z` suspending the process when the raw keyboard listener is active — clear `ISIG` and `IEXTEN` terminal flags in raw mode so control characters (`Ctrl+Z`, `Ctrl+C`) are received as byte events instead of kernel signals; `Ctrl+C` now cancels the active run in the standalone visualization path

- Shell: Show the current working directory, git branch, dirty state, and ahead/behind sync status directly in the prompt toolbar
- Shell: Surface active background bash task counts in the toolbar, rotate shortcut tips on a timer, and gracefully truncate the toolbar on narrow terminals to avoid overflow
- Web: Fix tool execution status synchronization on cancel and approval — tools now correctly transition to `output-denied` state when generation is stopped, and show the loading spinner (instead of checkmark) while executing after approval
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## 未发布

- Shell:修复原始键盘监听器激活时 `Ctrl+Z` 导致进程挂起的问题——在 raw 模式下清除 `ISIG` 和 `IEXTEN` 终端标志,使控制字符(`Ctrl+Z`、`Ctrl+C`)作为字节事件接收而非内核信号;`Ctrl+C` 现在可在独立可视化路径中取消当前运行

- Shell:在提示工具栏中显示当前工作目录、Git 分支、脏状态以及与远端的 ahead/behind 同步状态
- Shell:在工具栏中显示活跃后台 Bash 任务数量,按时间轮换快捷键提示,并在窄终端中优雅截断内容以避免溢出
- Web:修复取消和审批时工具执行状态同步问题——停止生成时工具现在正确过渡到 `output-denied` 状态,审批通过后执行期间显示加载动画(而非勾选图标)
Expand Down
5 changes: 4 additions & 1 deletion src/kimi_cli/ui/shell/keyboard.py
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Windows keyboard listener missing CTRL_C byte handling added to Unix listener

The PR adds \x03KeyEvent.CTRL_C mapping to _listen_for_keyboard_unix at src/kimi_cli/ui/shell/keyboard.py:190-191, but the corresponding _listen_for_keyboard_windows function (lines 256-275) was not updated with the same case. This means on Windows, when using the standalone _LiveView visualization path, pressing Ctrl+C will never emit KeyEvent.CTRL_C, so the cancel-via-Ctrl+C feature (src/kimi_cli/ui/shell/visualize.py:1188) cannot trigger. Users on Windows would have to rely on ESC to cancel. The existing pattern shows both listeners should handle the same set of bytes (both already handle \x05 for CTRL_E).

(Refers to lines 262-263)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class KeyEvent(Enum):
ESCAPE = auto()
TAB = auto()
SPACE = auto()
CTRL_C = auto()
CTRL_E = auto()
NUM_1 = auto()
NUM_2 = auto()
Expand Down Expand Up @@ -117,7 +118,7 @@ def _listen_for_keyboard_unix(
fd = sys.stdin.fileno()
oldterm = termios.tcgetattr(fd)
rawattr = termios.tcgetattr(fd)
rawattr[3] = rawattr[3] & ~termios.ICANON & ~termios.ECHO
rawattr[3] = rawattr[3] & ~termios.ICANON & ~termios.ECHO & ~termios.ISIG & ~termios.IEXTEN
rawattr[6][termios.VMIN] = 0
rawattr[6][termios.VTIME] = 0
raw_enabled = False
Expand Down Expand Up @@ -186,6 +187,8 @@ def disable_raw() -> None:
emit(KeyEvent.SPACE)
elif c == b"\t":
emit(KeyEvent.TAB)
elif c == b"\x03": # Ctrl+C
emit(KeyEvent.CTRL_C)
elif c == b"\x05": # Ctrl+E
emit(KeyEvent.CTRL_E)
elif c == b"1":
Expand Down
4 changes: 2 additions & 2 deletions src/kimi_cli/ui/shell/visualize.py
Original file line number Diff line number Diff line change
Expand Up @@ -1184,8 +1184,8 @@ def dispatch_keyboard_event(self, event: KeyEvent) -> None:
self.refresh_soon()
return

# handle ESC key to cancel the run
if event == KeyEvent.ESCAPE and self._cancel_event is not None:
# handle ESC / Ctrl+C to cancel the run
if event in (KeyEvent.ESCAPE, KeyEvent.CTRL_C) and self._cancel_event is not None:
self._cancel_event.set()
Comment on lines +1187 to 1189
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route Ctrl+C through question panels as a cancel

Because dispatch_keyboard_event() returns from the question-panel branch before reaching this new CTRL_C cancellation path, pressing Ctrl+C during a QuestionRequest in _LiveView is now a no-op. That path is used whenever visualize() runs without a prompt session (src/kimi_cli/ui/shell/visualize.py:103-119), e.g. command-mode runs from Shell.run() (src/kimi_cli/ui/shell/__init__.py:163-167). In those runs, question-based tools treat an empty response as “dismissed” rather than cancelled (src/kimi_cli/tools/ask_user/__init__.py:120-126, src/kimi_cli/tools/plan/__init__.py:198-205), so users cannot abort the active run with Ctrl+C once a question panel is visible.

Useful? React with 👍 / 👎.

return

Expand Down
125 changes: 125 additions & 0 deletions tests/ui_and_conv/test_keyboard_raw_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Tests for keyboard listener raw-mode flags and key event mapping."""

from __future__ import annotations

import asyncio
import importlib

import pytest

from kimi_cli.ui.shell.keyboard import KeyEvent

shell_visualize = importlib.import_module("kimi_cli.ui.shell.visualize")
_LiveView = shell_visualize._LiveView


# ---------------------------------------------------------------------------
# keyboard.py: terminal flags
# ---------------------------------------------------------------------------


def test_unix_raw_mode_clears_isig_and_iexten():
"""The raw-mode lflags must clear ISIG and IEXTEN so that Ctrl+Z does not
generate SIGTSTP and Ctrl+C is received as a byte instead of SIGINT."""
import sys

if sys.platform == "win32":
pytest.skip("Unix-only test")

import termios

# Simulate what _listen_for_keyboard_unix does to build rawattr.
# We cannot call the real function (it enters a blocking loop), so we
# replicate the flag arithmetic on a synthetic lflags word.
original_lflags = termios.ICANON | termios.ECHO | termios.ISIG | termios.IEXTEN

# This is the line under test (keyboard.py:121):
result_lflags = (
original_lflags & ~termios.ICANON & ~termios.ECHO & ~termios.ISIG & ~termios.IEXTEN
)

assert not (result_lflags & termios.ICANON), "ICANON should be cleared"
assert not (result_lflags & termios.ECHO), "ECHO should be cleared"
assert not (result_lflags & termios.ISIG), "ISIG should be cleared"
assert not (result_lflags & termios.IEXTEN), "IEXTEN should be cleared"


# ---------------------------------------------------------------------------
# keyboard.py: byte → KeyEvent mapping
# ---------------------------------------------------------------------------


class TestKeyEventMapping:
"""Verify that specific control bytes map to the expected KeyEvent values."""

# We cannot easily unit-test the threaded listener in isolation, so we
# test the byte→event logic by reading the source mapping. If someone
# changes the handler chain the test will catch it.

_BYTE_TO_EVENT: dict[bytes, KeyEvent] = {
b"\x03": KeyEvent.CTRL_C,
b"\x05": KeyEvent.CTRL_E,
b"\x1b": KeyEvent.ESCAPE,
b"\r": KeyEvent.ENTER,
b"\n": KeyEvent.ENTER,
b" ": KeyEvent.SPACE,
b"\t": KeyEvent.TAB,
b"1": KeyEvent.NUM_1,
b"2": KeyEvent.NUM_2,
b"3": KeyEvent.NUM_3,
b"4": KeyEvent.NUM_4,
b"5": KeyEvent.NUM_5,
b"6": KeyEvent.NUM_6,
}

@pytest.mark.parametrize(
("raw_byte", "expected_event"),
list(_BYTE_TO_EVENT.items()),
ids=[f"0x{b[0]:02x}→{e.name}" for b, e in _BYTE_TO_EVENT.items()],
)
def test_byte_event_mapping(self, raw_byte: bytes, expected_event: KeyEvent):
"""Each control byte should have a matching KeyEvent enum member."""
# Ensure the enum value exists (catches accidental removal).
assert expected_event in KeyEvent

def test_ctrl_c_enum_exists(self):
"""CTRL_C must be a member of KeyEvent."""
assert hasattr(KeyEvent, "CTRL_C")


# ---------------------------------------------------------------------------
# visualize.py _LiveView: Ctrl+C cancels the run
# ---------------------------------------------------------------------------


def test_live_view_ctrl_c_sets_cancel_event():
"""Dispatching CTRL_C on _LiveView should set the cancel event,
exactly like ESCAPE does."""
from kimi_cli.wire.types import StatusUpdate

cancel_event = asyncio.Event()
view = _LiveView(StatusUpdate(context_usage=0.0), cancel_event)

assert not cancel_event.is_set()
view.dispatch_keyboard_event(KeyEvent.CTRL_C)
assert cancel_event.is_set(), "CTRL_C should set cancel_event"


def test_live_view_escape_still_sets_cancel_event():
"""Regression guard: ESCAPE should still cancel after adding CTRL_C."""
from kimi_cli.wire.types import StatusUpdate

cancel_event = asyncio.Event()
view = _LiveView(StatusUpdate(context_usage=0.0), cancel_event)

view.dispatch_keyboard_event(KeyEvent.ESCAPE)
assert cancel_event.is_set(), "ESCAPE should still set cancel_event"


def test_live_view_ctrl_c_without_cancel_event_is_noop():
"""When no cancel_event is provided, CTRL_C should not raise."""
from kimi_cli.wire.types import StatusUpdate

view = _LiveView(StatusUpdate(context_usage=0.0), cancel_event=None)
# Should not raise
view.dispatch_keyboard_event(KeyEvent.CTRL_C)
Loading