Skip to content
Closed
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ After setup, Kimi CLI will be ready to use. You can send `/help` to get more inf

Kimi CLI is not only a coding agent, but also a shell. You can switch the mode by pressing `Ctrl-K`. In shell mode, you can directly run shell commands without leaving Kimi CLI.

Press `Ctrl-T` anytime to show the current TODO list (from the latest SetTodoList tool result).

> [!NOTE]
> Built-in shell commands like `cd` are not supported yet.

Expand Down
16 changes: 16 additions & 0 deletions src/kimi_cli/ui/shell/liveview.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from kimi_cli.tools import extract_subtitle
from kimi_cli.ui.shell.console import console
from kimi_cli.ui.shell.keyboard import KeyEvent
from kimi_cli.ui.shell.todo import set_todo
from kimi_cli.wire.message import ApprovalRequest, ApprovalResponse


Expand All @@ -38,6 +39,10 @@ def __init__(self, tool_call: ToolCall):
def finished(self) -> bool:
return self._finished

@property
def tool_name(self) -> str:
return self._tool_name

@property
def _spinner_markup(self) -> str:
return self._title_markup + self._subtitle_markup
Expand Down Expand Up @@ -212,6 +217,17 @@ def append_tool_call_part(self, tool_call_part: ToolCallPart):
def append_tool_result(self, tool_result: ToolResult):
if view := self._tool_calls.get(tool_result.tool_call_id):
view.finish(tool_result.result)
# Persist TODO list when SetTodoList completes successfully
try:
if getattr(view, "tool_name", None) == "SetTodoList" and isinstance(
tool_result.result, ToolOk
):
output = getattr(tool_result.result, "output", None)
if isinstance(output, str) and output.strip():
set_todo(output)
except Exception:
# best-effort; never break UI on TODO capture
pass
self._live.update(self._compose())

def request_approval(self, approval_request: ApprovalRequest) -> None:
Expand Down
21 changes: 21 additions & 0 deletions src/kimi_cli/ui/shell/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from typing import override

from prompt_toolkit import PromptSession
from prompt_toolkit.application import run_in_terminal
from prompt_toolkit.application.current import get_app_or_none
from prompt_toolkit.completion import (
Completer,
Expand All @@ -29,10 +30,13 @@
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
from prompt_toolkit.patch_stdout import patch_stdout
from pydantic import BaseModel, ValidationError
from rich.panel import Panel

from kimi_cli.share import get_share_dir
from kimi_cli.soul import StatusSnapshot
from kimi_cli.ui.shell.console import console
from kimi_cli.ui.shell.metacmd import get_meta_commands
from kimi_cli.ui.shell.todo import get_todo
from kimi_cli.utils.logging import logger


Expand Down Expand Up @@ -427,6 +431,22 @@ def _toggle_mode(event: KeyPressEvent) -> None:
# Redraw UI
event.app.invalidate()

@_kb.add("c-t", eager=True)
def _show_todos(event: KeyPressEvent) -> None:
"""Show current TODO list captured from SetTodoList tool."""
todo = get_todo()

def _print():
if todo and todo.strip():
console.print(
Panel.fit(todo.strip(), title="TODOs", border_style="cyan", padding=(1, 2))
)
else:
console.print("[grey50]No TODOs yet[/grey50]")

# Print without breaking the current prompt buffer
run_in_terminal(_print)

self._session = PromptSession(
message=self._render_message,
prompt_continuation=FormattedText([("fg:#4d4d4d", "... ")]),
Expand Down Expand Up @@ -550,6 +570,7 @@ def _render_bottom_toolbar(self) -> FormattedText:
else:
shortcuts = [
"ctrl-k: toggle mode",
"ctrl-t: todos",
"ctrl-d: exit",
]
for shortcut in shortcuts:
Expand Down
15 changes: 15 additions & 0 deletions src/kimi_cli/ui/shell/todo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from __future__ import annotations

# Simple in-memory storage for the current session's TODO list
# Updated when SetTodoList tool returns successfully.

_todo_text: str | None = None


def set_todo(text: str) -> None:
global _todo_text
_todo_text = text


def get_todo() -> str | None:
return _todo_text
1 change: 0 additions & 1 deletion tests/test_pyinstaller_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ def test_pyinstaller_datas():
"kimi_cli/agents/koder",
),
("src/kimi_cli/agents/koder/system.md", "kimi_cli/agents/koder"),
("src/kimi_cli/deps/bin/rg", "kimi_cli/deps/bin"),
("src/kimi_cli/prompts/compact.md", "kimi_cli/prompts"),
("src/kimi_cli/prompts/init.md", "kimi_cli/prompts"),
(
Expand Down
157 changes: 157 additions & 0 deletions tests/test_ui_todo_and_prompt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import pytest
from prompt_toolkit.key_binding import KeyBindings

from kimi_cli.soul import StatusSnapshot
from kimi_cli.ui.shell.liveview import StepLiveView
from kimi_cli.ui.shell.todo import get_todo, set_todo


@pytest.fixture(autouse=True)
def reset_todo():
# Ensure TODO storage is clean for each test
set_todo("initial")
yield
set_todo("")


def _make_tool_call(tool_id: str, name: str):
from kosong.base.message import ToolCall

return ToolCall(id=tool_id, function=ToolCall.FunctionBody(name=name, arguments=None))


def _make_tool_result(tool_id: str, ok: bool = True, output: str | None = None):
from kosong.tooling import ToolError, ToolOk, ToolResult

result = ToolOk(output=output or "") if ok else ToolError(message="boom", brief="error")
return ToolResult(tool_call_id=tool_id, result=result)


def test_liveview_sets_todo_on_successful_set_todo_list():
with StepLiveView(StatusSnapshot(context_usage=0.0)) as view:
call = _make_tool_call("tc1", "SetTodoList")
view.append_tool_call(call)
view.append_tool_result(_make_tool_result("tc1", ok=True, output="- A [Pending]\n"))

assert get_todo() == "- A [Pending]\n"


def test_liveview_does_not_set_todo_for_other_tools_or_unsuccessful():
# Start with a known value
set_todo("keep-me")
with StepLiveView(StatusSnapshot(context_usage=0.0)) as view:
# Other tool should not change TODO
bash_call = _make_tool_call("tc2", "Bash")
view.append_tool_call(bash_call)
view.append_tool_result(_make_tool_result("tc2", ok=True, output="some output"))

# Unsuccessful SetTodoList should not change TODO
todo_call = _make_tool_call("tc3", "SetTodoList")
view.append_tool_call(todo_call)
view.append_tool_result(_make_tool_result("tc3", ok=False))

assert get_todo() == "keep-me"


class _CaptureFT:
def __init__(self, fragments):
self.fragments = fragments


def test_prompt_show_todos_prints_content_when_non_empty(monkeypatch):
# Capture the handler registered for Ctrl-T without affecting global KeyBindings
captured: dict[str, object] = {}

import kimi_cli.ui.shell.prompt as prompt_mod
from kimi_cli.ui.shell.console import console

class CapturingKeyBindings(KeyBindings): # type: ignore[misc]
def add(self, *keys, **kwargs): # type: ignore[override]
dec = super().add(*keys, **kwargs)

def wrapper(fn):
if "c-t" in keys:
captured["handler"] = fn
return dec(fn)

return wrapper

monkeypatch.setattr(prompt_mod, "KeyBindings", CapturingKeyBindings, raising=True)

# Make run_in_terminal call the function immediately
monkeypatch.setattr(prompt_mod, "run_in_terminal", lambda fn: fn(), raising=True)
monkeypatch.setattr(prompt_mod, "get_todo", lambda: "My TODOs\n- item 1", raising=True)

# Create session (this will register key bindings)
_ = prompt_mod.CustomPromptSession(lambda: StatusSnapshot(0.0))

# Invoke the captured Ctrl-T handler
assert "handler" in captured, "Ctrl-T handler was not registered"

with console.capture() as cap:
handler = captured["handler"]
handler(None) # type: ignore[misc]
out = cap.get()

assert "My TODOs" in out


def test_prompt_show_todos_prints_no_todos_when_empty(monkeypatch):
captured: dict[str, object] = {}

import kimi_cli.ui.shell.prompt as prompt_mod
from kimi_cli.ui.shell.console import console

class CapturingKeyBindings(KeyBindings): # type: ignore[misc]
def add(self, *keys, **kwargs): # type: ignore[override]
dec = super().add(*keys, **kwargs)

def wrapper(fn):
if "c-t" in keys:
captured["handler"] = fn
return dec(fn)

return wrapper

monkeypatch.setattr(prompt_mod, "KeyBindings", CapturingKeyBindings, raising=True)

monkeypatch.setattr(prompt_mod, "run_in_terminal", lambda fn: fn(), raising=True)
# Return empty/whitespace -> considered empty
monkeypatch.setattr(prompt_mod, "get_todo", lambda: " ", raising=True)

_ = prompt_mod.CustomPromptSession(lambda: StatusSnapshot(0.0))

assert "handler" in captured, "Ctrl-T handler was not registered"

with console.capture() as cap:
handler = captured["handler"]
handler(None) # type: ignore[misc]
out = cap.get()

assert "No TODOs yet" in out


def test_bottom_toolbar_shows_ctrl_t_shortcut(monkeypatch):
# Patch app getter to provide width
import kimi_cli.ui.shell.prompt as prompt_mod

class _Size:
columns = 200

class _Output:
def get_size(self):
return _Size()

class _App:
output = _Output()

monkeypatch.setattr(prompt_mod, "get_app_or_none", lambda: _App(), raising=True)

# Patch FormattedText to capture fragments
monkeypatch.setattr(prompt_mod, "FormattedText", _CaptureFT, raising=True)

session = prompt_mod.CustomPromptSession(lambda: StatusSnapshot(0.0))
ft = session._render_bottom_toolbar() # type: ignore[attr-defined]

text = "".join(seg for _, seg in ft.fragments) # type: ignore[attr-defined]
assert "ctrl-t: todos" in text
Loading