diff --git a/src/kimi_cli/session_state.py b/src/kimi_cli/session_state.py index 8e6a2560b..c6025c54f 100644 --- a/src/kimi_cli/session_state.py +++ b/src/kimi_cli/session_state.py @@ -13,9 +13,14 @@ class ApprovalStateData(BaseModel): + approval_mode: Literal["manual", "edits", "auto"] = "manual" + """Approval mode: manual, edits (auto-approve file ops), auto (auto-approve everything).""" yolo: bool = False + """Legacy flag, migrated to approval_mode='auto'. Kept for backward compat.""" afk: bool = False + """Legacy flag, migrated to approval_mode='auto'. Kept for backward compat.""" auto_approve_actions: set[str] = Field(default_factory=set) + """Legacy set, migrated to approval_mode='edits'. Kept for backward compat.""" class TodoItemState(BaseModel): @@ -96,6 +101,21 @@ def _migrate_legacy_metadata(session_dir: Path, state: SessionState) -> str: return "migrated" if changed else "no_change" +def _migrate_legacy_approval_state(state: SessionState) -> None: + """Migrate legacy yolo/afk to approval_mode. + + auto_approve_actions is intentionally NOT promoted to edits mode here. + The legacy set still works when approval_mode is manual (checked in + Approval.request), so promoting it would broaden the approval scope + and misrepresent the session state in the UI. + """ + approval = state.approval + if approval.approval_mode != "manual": + return + if approval.yolo or approval.afk: + approval.approval_mode = "auto" + + def load_session_state(session_dir: Path) -> SessionState: state_file = session_dir / STATE_FILE_NAME if not state_file.exists(): @@ -111,6 +131,9 @@ def load_session_state(session_dir: Path) -> SessionState: track("session_load_failed", reason=type(e).__name__) state = SessionState() + # Migrate legacy approval flags to approval_mode + _migrate_legacy_approval_state(state) + # One-time migration from legacy metadata.json (best-effort) migration = _migrate_legacy_metadata(session_dir, state) if migration in ("migrated", "no_change"): diff --git a/src/kimi_cli/soul/__init__.py b/src/kimi_cli/soul/__init__.py index 5cde510dc..75b0d190f 100644 --- a/src/kimi_cli/soul/__init__.py +++ b/src/kimi_cli/soul/__init__.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from kimi_cli.llm import LLM, ModelCapability from kimi_cli.soul.agent import Runtime + from kimi_cli.soul.approval import ApprovalMode from kimi_cli.utils.slashcmd import SlashCommand @@ -98,6 +99,8 @@ class StatusSnapshot: """The maximum number of tokens the context can hold.""" mcp_status: MCPStatusSnapshot | None = None """The current MCP startup snapshot, if MCP is configured.""" + approval_mode: ApprovalMode = "manual" + """Approval mode: manual, edits (auto-approve file ops), auto (auto-approve everything).""" @runtime_checkable diff --git a/src/kimi_cli/soul/agent.py b/src/kimi_cli/soul/agent.py index a208e0879..d615cc526 100644 --- a/src/kimi_cli/soul/agent.py +++ b/src/kimi_cli/soul/agent.py @@ -274,23 +274,27 @@ async def create( additional_dirs_info = "\n\n".join(parts) # Merge invocation flags with persisted session state. - effective_yolo = yolo or session.state.approval.yolo + effective_mode = session.state.approval.approval_mode + if yolo or session.state.approval.yolo or session.state.approval.afk: + effective_mode = "auto" if afk and not session.state.approval.afk: session.state.approval.afk = True session.save_state() - saved_actions = set(session.state.approval.auto_approve_actions) def _on_approval_change() -> None: + session.state.approval.approval_mode = approval_state.approval_mode + # Also update legacy flags so older code reading them directly sees consistency. session.state.approval.yolo = approval_state.yolo session.state.approval.afk = approval_state.afk session.state.approval.auto_approve_actions = set(approval_state.auto_approve_actions) session.save_state() approval_state = ApprovalState( - yolo=effective_yolo, + approval_mode=effective_mode, + yolo=yolo or session.state.approval.yolo, afk=session.state.approval.afk, runtime_afk=runtime_afk, - auto_approve_actions=saved_actions, + auto_approve_actions=set(session.state.approval.auto_approve_actions), on_change=_on_approval_change, ) notifications = NotificationManager( diff --git a/src/kimi_cli/soul/approval.py b/src/kimi_cli/soul/approval.py index c3f295998..91b092d4f 100644 --- a/src/kimi_cli/soul/approval.py +++ b/src/kimi_cli/soul/approval.py @@ -17,6 +17,15 @@ type Response = Literal["approve", "approve_for_session", "reject"] +# Actions that touch files but do not execute arbitrary shell commands. +# Action strings passed by WriteFile and StrReplaceFile tools. +_FILE_ACTIONS = frozenset({ + "edit file", + "edit file outside of working directory", +}) + +type ApprovalMode = Literal["manual", "edits", "auto"] + class ApprovalResult: """Result of an approval request. Behaves as bool for backward compatibility.""" @@ -59,18 +68,22 @@ def __init__( afk: bool = False, runtime_afk: bool = False, auto_approve_actions: set[str] | None = None, + approval_mode: ApprovalMode = "manual", on_change: Callable[[], None] | None = None, ): + # Derive approval_mode from legacy flags if not explicitly provided. + if approval_mode == "manual" and (yolo or afk): + approval_mode = "auto" + elif approval_mode == "manual" and auto_approve_actions: + approval_mode = "edits" + + self.approval_mode: ApprovalMode = approval_mode + """Current approval mode: manual, edits, or auto.""" + # Keep legacy fields for backward compat with tests and external code. self.yolo = yolo self.afk = afk - """Persisted session flag. True when no user is present. - - Implies auto-approve and is restored with the session. - """ self.runtime_afk = runtime_afk - """Invocation-only afk flag, e.g. ``--afk`` or ``--print``. Not persisted.""" self.auto_approve_actions: set[str] = auto_approve_actions or set() - """Set of action names that should automatically be approved.""" self._on_change = on_change def notify_change(self) -> None: @@ -101,7 +114,12 @@ def runtime(self) -> ApprovalRuntime: return self._runtime def set_yolo(self, yolo: bool) -> None: + """Legacy setter; maps to approval_mode for backward compat.""" self._state.yolo = yolo + if yolo: + self._state.approval_mode = "auto" + elif self._state.approval_mode == "auto" and not self._state.afk: + self._state.approval_mode = "manual" self._state.notify_change() def set_afk(self, afk: bool) -> None: @@ -112,6 +130,10 @@ def set_afk(self, afk: bool) -> None: behavior via ``/afk``. """ self._state.afk = afk + if afk: + self._state.approval_mode = "auto" + elif self._state.approval_mode == "auto" and not self._state.yolo: + self._state.approval_mode = "manual" if not afk: self._state.runtime_afk = False self._state.notify_change() @@ -120,13 +142,30 @@ def set_runtime_afk(self, afk: bool) -> None: """Toggle invocation-only afk mode without persisting it.""" self._state.runtime_afk = afk - def is_auto_approve(self) -> bool: - """True when tool calls should be auto-approved. + def set_approval_mode(self, mode: ApprovalMode) -> None: + self._state.approval_mode = mode + self._state.yolo = mode == "auto" + self._state.afk = False + self._state.auto_approve_actions.clear() + self._state.notify_change() - Afk implies auto-approve, so this returns True whenever either the - explicit yolo flag or afk is set. - """ - return self._state.yolo or self.is_afk() + def cycle_approval_mode(self) -> ApprovalMode: + """Cycle to the next approval mode (manual → edits → auto → manual).""" + match self._state.approval_mode: + case "manual": + new_mode = "edits" + case "edits": + new_mode = "auto" + case "auto": + new_mode = "manual" + case _: + new_mode = "manual" + self.set_approval_mode(new_mode) + return new_mode + + def is_auto_approve(self) -> bool: + """True when tool calls should be auto-approved.""" + return self._state.approval_mode == "auto" or self.is_afk() def is_yolo(self) -> bool: """True only when the user explicitly opted into yolo.""" @@ -148,6 +187,22 @@ def is_runtime_afk(self) -> bool: """True only when afk came from this invocation.""" return self._state.runtime_afk + def get_auto_approve_actions(self) -> set[str]: + """Return the set of action names that are auto-approved for this session.""" + return set(self._state.auto_approve_actions) + + def get_approval_mode(self) -> ApprovalMode: + """Return the current approval mode.""" + return self._state.approval_mode + + def _should_auto_approve(self, action: str) -> bool: + """Determine if an action should be auto-approved based on current mode.""" + if self._state.approval_mode == "auto" or self.is_afk(): + return True + if self._state.approval_mode == "edits": + return action in _FILE_ACTIONS + return False + async def request( self, sender: str, @@ -182,13 +237,14 @@ async def request( action=action, description=description, ) - if self.is_auto_approve(): + + if self._should_auto_approve(action): from kimi_cli.telemetry import track track( "tool_approved", tool_name=tool_call.function.name, - approval_mode="afk" if self.is_afk() else "yolo", + approval_mode=self._state.approval_mode, ) return ApprovalResult(approved=True) @@ -245,8 +301,14 @@ async def request( tool_name=tool_call.function.name, approval_mode="manual", ) - self._state.auto_approve_actions.add(action) - self._state.notify_change() + # Promote approval mode based on action type to keep UX simple. + if action in _FILE_ACTIONS and self._state.approval_mode == "manual": + self.set_approval_mode("edits") + elif self._state.approval_mode in ("manual", "edits"): + self.set_approval_mode("auto") + else: + self._state.auto_approve_actions.add(action) + self._state.notify_change() for pending in self._runtime.list_pending(): if pending.action == action: self._runtime.resolve(pending.id, "approve") diff --git a/src/kimi_cli/soul/kimisoul.py b/src/kimi_cli/soul/kimisoul.py index 877c565f2..3d35941c6 100644 --- a/src/kimi_cli/soul/kimisoul.py +++ b/src/kimi_cli/soul/kimisoul.py @@ -491,6 +491,7 @@ def status(self) -> StatusSnapshot: context_tokens=token_count, max_context_tokens=max_size, mcp_status=self._mcp_status_snapshot(), + approval_mode=self._approval.get_approval_mode(), ) @property @@ -501,6 +502,11 @@ def agent(self) -> Agent: def runtime(self) -> Runtime: return self._runtime + def cycle_approval_mode(self) -> str: + """Cycle to the next approval mode and return the new mode name.""" + new_mode = self._approval.cycle_approval_mode() + return new_mode + @property def context(self) -> Context: return self._context diff --git a/src/kimi_cli/ui/shell/__init__.py b/src/kimi_cli/ui/shell/__init__.py index 8a91828d9..157a1c538 100644 --- a/src/kimi_cli/ui/shell/__init__.py +++ b/src/kimi_cli/ui/shell/__init__.py @@ -411,10 +411,27 @@ async def run(self, command: str | None = None) -> bool: ) await self.soul.start_background_mcp_loading() - async def _plan_mode_toggle() -> bool: - if isinstance(self.soul, KimiSoul): - return await self.soul.toggle_plan_mode_from_manual() - return False + async def _mode_cycle() -> str: + """Cycle through unified modes: manual → edits → auto → plan → manual.""" + if not isinstance(self.soul, KimiSoul): + return "manual" + status = self.soul.status + if status.plan_mode: + await self.soul.toggle_plan_mode_from_manual() + self.soul.runtime.approval.set_approval_mode("manual") + return "manual" + match status.approval_mode: + case "manual": + self.soul.runtime.approval.set_approval_mode("edits") + return "edits" + case "edits": + self.soul.runtime.approval.set_approval_mode("auto") + return "auto" + case "auto": + await self.soul.toggle_plan_mode_from_manual() + self.soul.runtime.approval.set_approval_mode("manual") + return "plan" + return "manual" def _mcp_status_block(columns: int): if not isinstance(self.soul, KimiSoul): @@ -468,7 +485,7 @@ def _bg_task_counts() -> BgTaskCounts: editor_command_provider=lambda: ( self.soul.runtime.config.default_editor if isinstance(self.soul, KimiSoul) else "" ), - plan_mode_toggle_callback=_plan_mode_toggle, + mode_cycle_callback=_mode_cycle, ) as prompt_session: self._prompt_session = prompt_session if self._prefill_text: diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index b7a950b09..610803610 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -877,6 +877,14 @@ def __bool__(self) -> bool: _GIT_BRANCH_TTL = 5.0 _GIT_STATUS_TTL = 15.0 _TIP_ROTATE_INTERVAL = 30.0 + +_MODE_TOAST_MESSAGES: dict[str, str] = { + "manual": "manual mode — agent asks before each action", + "edits": "edits mode — agent can edit files without asking", + "auto": "auto mode — agent will auto-approve all actions without asking", + "plan": "plan mode — read-only planning, no actions executed", +} + _MAX_CWD_COLS = 30 _MAX_BRANCH_COLS = 22 @@ -1155,7 +1163,7 @@ def _current_toast(position: Literal["left", "right"] = "left") -> _ToastEntry | def _build_toolbar_tips(clipboard_available: bool) -> list[str]: tips = [ "ctrl-x: toggle mode", - "shift-tab: plan mode", + "shift-tab: cycle modes", "ctrl-o: editor", "ctrl-j: newline", "/feedback: send feedback", @@ -1184,7 +1192,7 @@ def __init__( agent_mode_slash_commands: Sequence[SlashCommand[Any]], shell_mode_slash_commands: Sequence[SlashCommand[Any]], editor_command_provider: Callable[[], str] = lambda: "", - plan_mode_toggle_callback: Callable[[], Awaitable[bool]] | None = None, + mode_cycle_callback: Callable[[], Awaitable[str]] | None = None, ) -> None: history_dir = get_share_dir() / "user-history" history_dir.mkdir(parents=True, exist_ok=True) @@ -1195,7 +1203,7 @@ def __init__( self._fast_refresh_provider = fast_refresh_provider self._background_task_count_provider = background_task_count_provider self._editor_command_provider = editor_command_provider - self._plan_mode_toggle_callback = plan_mode_toggle_callback + self._mode_cycle_callback = mode_cycle_callback self._model_capabilities = model_capabilities self._model_name = model_name self._last_history_content: str | None = None @@ -1292,21 +1300,23 @@ def _(event: KeyPressEvent) -> None: @_kb.add("s-tab", eager=True) def _(event: KeyPressEvent) -> None: - """Toggle plan mode with Shift+Tab.""" + """Cycle unified modes with Shift+Tab: manual → edits → auto → plan → manual.""" if self._active_prompt_delegate() is not None: return - if self._plan_mode_toggle_callback is not None: + if self._mode_cycle_callback is not None: async def _toggle() -> None: - assert self._plan_mode_toggle_callback is not None - new_state = await self._plan_mode_toggle_callback() + assert self._mode_cycle_callback is not None + new_mode = await self._mode_cycle_callback() from kimi_cli.telemetry import track - track("shortcut_plan_toggle", enabled=new_state) - if new_state: - toast("plan mode ON", topic="plan_mode", duration=3.0, immediate=True) - else: - toast("plan mode OFF", topic="plan_mode", duration=3.0, immediate=True) + track("shortcut_mode_cycle", mode=new_mode) + toast( + _MODE_TOAST_MESSAGES.get(new_mode, f"{new_mode} mode"), + topic="mode_cycle", + duration=5.0, + immediate=True, + ) event.app.invalidate() event.app.create_background_task(_toggle()) @@ -1786,13 +1796,19 @@ def _render_agent_prompt_message(self) -> FormattedText: return fragments # 4. Input section header — style varies by mode: - # normal: ── input ───────────────── (grey, solid) + # manual: ── input ───────────────── (grey, solid) + # edits: ╌╌ input · edits ╌╌╌╌╌╌╌╌ (green, dashed) + # auto: ╌╌ input · auto ╌╌╌╌╌╌╌╌╌╌ (amber, dashed) # plan: ╌╌ input · plan ╌╌╌╌╌╌╌╌╌ (blue, dashed) status = self._status_provider() # Build title parts title_parts = ["input"] if status.plan_mode: title_parts.append("plan") + elif status.approval_mode == "edits": + title_parts.append("edits") + elif status.approval_mode == "auto": + title_parts.append("auto") # Queue count from running prompt delegate running = self._running_prompt_delegate queue_count = len(getattr(running, "_queued_messages", [])) @@ -1802,6 +1818,12 @@ def _render_agent_prompt_message(self) -> FormattedText: if status.plan_mode: dash = "╌" style = "fg:#60a5fa" # blue + elif status.approval_mode == "edits": + dash = "╌" + style = "fg:#4ade80" # green + elif status.approval_mode == "auto": + dash = "╌" + style = "fg:#fbbf24" # amber else: dash = "─" style = "class:running-prompt-separator" @@ -2114,14 +2136,17 @@ def _render_bottom_toolbar(self) -> FormattedText: self._tip_rotation_index += 1 self._last_tip_rotate_time = now - # Status flags: yolo / afk / plan + # Approval mode badge: manual / edits / auto status = self._status_provider() - if status.yolo_enabled: - fragments.extend([(tc.yolo_label, "yolo"), ("", " ")]) - remaining -= 6 # "yolo" = 4, " " = 2 - if status.afk_enabled: - fragments.extend([(tc.afk_label, "afk"), ("", " ")]) - remaining -= 5 # "afk" = 3, " " = 2 + match status.approval_mode: + case "manual": + pass # no badge in manual mode (default) + case "edits": + fragments.extend([(tc.edits_label, "◐ edits"), ("", " ")]) + remaining -= 9 # "◐ edits" = 7, " " = 2 + case "auto": + fragments.extend([(tc.auto_label, "● auto"), ("", " ")]) + remaining -= 8 # "● auto" = 6, " " = 2 if status.plan_mode: fragments.extend([(tc.plan_label, "plan"), ("", " ")]) remaining -= 6 diff --git a/src/kimi_cli/ui/theme.py b/src/kimi_cli/ui/theme.py index 9452a8276..a28c5cd92 100644 --- a/src/kimi_cli/ui/theme.py +++ b/src/kimi_cli/ui/theme.py @@ -142,6 +142,9 @@ class ToolbarColors: afk_label: str plan_label: str plan_prompt: str + manual_label: str + edits_label: str + auto_label: str cwd: str bg_tasks: str tip: str @@ -153,6 +156,9 @@ class ToolbarColors: afk_label="bold fg:#ff8800", plan_label="bold fg:#00aaff", plan_prompt="fg:#00aaff", + manual_label="bold fg:#9ca3af", + edits_label="bold fg:#4ade80", + auto_label="bold fg:#fbbf24", cwd="fg:#666666", bg_tasks="fg:#888888", tip="fg:#555555", @@ -164,6 +170,9 @@ class ToolbarColors: afk_label="bold fg:#c2410c", plan_label="bold fg:#2563eb", plan_prompt="fg:#2563eb", + manual_label="bold fg:#6b7280", + edits_label="bold fg:#16a34a", + auto_label="bold fg:#d97706", cwd="fg:#6b7280", bg_tasks="fg:#4b5563", tip="fg:#9ca3af", diff --git a/tests/core/test_session_state.py b/tests/core/test_session_state.py index 56b0119a8..c459f472f 100644 --- a/tests/core/test_session_state.py +++ b/tests/core/test_session_state.py @@ -412,7 +412,7 @@ def on_change(): current_tool_call.reset(token) assert result.approved is True - assert "shell_exec" in state.auto_approve_actions + assert state.approval_mode == "auto" assert len(changes) == 1 @pytest.mark.asyncio @@ -447,7 +447,7 @@ async def test_approve_for_session_resolves_already_pending_same_action(self): finally: current_tool_call.reset(token) - assert "write_file" in state.auto_approve_actions + assert state.approval_mode == "auto" assert approval.runtime.list_pending() == [] def test_no_callback_does_not_raise(self): diff --git a/tests/ui_and_conv/test_prompt_tips.py b/tests/ui_and_conv/test_prompt_tips.py index fc322804b..907ffe308 100644 --- a/tests/ui_and_conv/test_prompt_tips.py +++ b/tests/ui_and_conv/test_prompt_tips.py @@ -13,6 +13,7 @@ from kimi_cli.ui.shell import prompt as shell_prompt from kimi_cli.ui.shell.prompt import ( _GIT_STATUS_TTL, + _MODE_TOAST_MESSAGES, PROMPT_SYMBOL, BgTaskCounts, CustomPromptSession, @@ -138,7 +139,7 @@ def get_size() -> Any: def test_build_toolbar_tips_without_clipboard() -> None: assert _build_toolbar_tips(clipboard_available=False) == [ "ctrl-x: toggle mode", - "shift-tab: plan mode", + "shift-tab: cycle modes", "ctrl-o: editor", "ctrl-j: newline", "/feedback: send feedback", @@ -150,7 +151,7 @@ def test_build_toolbar_tips_without_clipboard() -> None: def test_build_toolbar_tips_with_clipboard() -> None: assert _build_toolbar_tips(clipboard_available=True) == [ "ctrl-x: toggle mode", - "shift-tab: plan mode", + "shift-tab: cycle modes", "ctrl-o: editor", "ctrl-j: newline", "/feedback: send feedback", @@ -393,6 +394,50 @@ def test_bottom_toolbar_drops_agent_badge_before_bash_when_narrow(monkeypatch: A ) +def test_bottom_toolbar_shows_edits_approval_badge(monkeypatch: Any) -> None: + prompt_session = _make_toolbar_session(tips=[]) + prompt_session._status_provider = lambda: StatusSnapshot( + context_usage=0.0, + approval_mode="edits", + ) + + lines = _render_toolbar_lines(prompt_session, 120, monkeypatch) + + assert "◐ edits" in lines[1], f"edits badge missing: {lines[1]!r}" + + +def test_bottom_toolbar_shows_auto_approval_badge(monkeypatch: Any) -> None: + prompt_session = _make_toolbar_session(tips=[]) + prompt_session._status_provider = lambda: StatusSnapshot( + context_usage=0.0, + approval_mode="auto", + ) + + lines = _render_toolbar_lines(prompt_session, 120, monkeypatch) + + assert "● auto" in lines[1], f"auto badge missing: {lines[1]!r}" + + +def test_bottom_toolbar_no_badge_in_manual_mode(monkeypatch: Any) -> None: + prompt_session = _make_toolbar_session(tips=[]) + prompt_session._status_provider = lambda: StatusSnapshot( + context_usage=0.0, + approval_mode="manual", + ) + + lines = _render_toolbar_lines(prompt_session, 120, monkeypatch) + + assert "◐ edits" not in lines[1], f"edits badge should not appear in manual mode: {lines[1]!r}" + assert "● auto" not in lines[1], f"auto badge should not appear in manual mode: {lines[1]!r}" + + +def test_mode_toast_messages_use_friendly_names() -> None: + assert _MODE_TOAST_MESSAGES["manual"] == "manual mode — agent asks before each action" + assert _MODE_TOAST_MESSAGES["edits"] == "edits mode — agent can edit files without asking" + assert _MODE_TOAST_MESSAGES["auto"] == "auto mode — agent will auto-approve all actions without asking" + assert _MODE_TOAST_MESSAGES["plan"] == "plan mode — read-only planning, no actions executed" + + def test_mode_shows_full_with_model_name_on_wide_terminal(monkeypatch: Any) -> None: """On a wide terminal the full mode string (with model name and thinking dot) is shown.""" session = _make_toolbar_session(model_name="fast-model")