diff --git a/docs/skills-guide.md b/docs/skills-guide.md index aa36479..47441c9 100644 --- a/docs/skills-guide.md +++ b/docs/skills-guide.md @@ -96,27 +96,58 @@ AI 在调用 Skill 时遵循以下规范: ```json { + "kind": "choice_or_text", "prompt": "请选择下一步操作", "options": [ - {"value": "a", "label": "方案 A"}, - {"value": "b", "label": "方案 B"} + {"value": "a", "label": "方案 A", "description": "适合风险较低、改动较小的情况"}, + {"value": "b", "label": "方案 B", "description": "适合需要更完整验证的情况"} ], "default": "a", "title": "选择", "allow_cancel": true, - "allow_custom_input": true, - "custom_label": "其他(自行输入)", - "custom_prompt": "请输入自定义内容" + "custom": { + "label": "其他(自行输入)", + "placeholder": "请输入自定义内容" + } } ``` +推荐约定: + +- `single_select`:只能选择预设项,不要传 `custom` +- `text_input`:只允许自由输入,不要传 `options` +- `choice_or_text`:既给 `options`,也给 `custom`,用于“候选项可能不完整”的场景 + ### ask_user 返回 -- 用户选中某项:返回 JSON(`status=selected`) +- 用户选中某项:返回结构化结果(`status=selected`),并保留选中的 value/label +- 用户输入自定义内容:返回结构化结果(`status=custom`),并保留输入文本;可通过 `answer_type=text` 区分于预设选项 - 用户取消或交互不可用:任务会暂停并提示用户继续方式。你可以: - 直接回复选项 value/编号/文字 - 输入 `; 使用默认继续`(或 `; continue with default`)明确使用默认值继续 -- 若启用 `allow_custom_input`,UI 会提供“自定义输入”行,用户可直接输入内容并回车,返回 `status=custom` +- 若使用 `choice_or_text`,会显示支持自定义输入的对话框;用户可直接输入内容并回车,返回 `status=custom` +- 若为选项提供 `description`,UI 和暂停提示会把说明展示在选项下方 + +### 推荐调用示例 + +```json +{ + "kind": "choice_or_text", + "prompt": "请选择一个水果,或输入列表中没有的水果", + "title": "水果选择", + "options": [ + {"value": "apple", "label": "苹果"}, + {"value": "banana", "label": "香蕉"}, + {"value": "orange", "label": "橙子"} + ], + "default": "apple", + "custom": { + "label": "其他水果", + "placeholder": "输入自定义水果名称" + }, + "allow_cancel": true +} +``` ## 开发新 Skill diff --git a/src/aish/config.py b/src/aish/config.py index 5b0fc69..a044a2d 100644 --- a/src/aish/config.py +++ b/src/aish/config.py @@ -155,10 +155,6 @@ class TUISettings(BaseModel): ) show_time: bool = Field(default=True, description="Show time in status bar") show_cwd: bool = Field(default=True, description="Show current directory in status bar") - inline_ui: bool = Field( - default=True, - description="Use inline UI for selections (ask_user) at bottom of screen", - ) class ConfigModel(BaseModel): diff --git a/src/aish/i18n/de-DE.yaml b/src/aish/i18n/de-DE.yaml index 5beb4c8..3a36587 100644 --- a/src/aish/i18n/de-DE.yaml +++ b/src/aish/i18n/de-DE.yaml @@ -196,8 +196,8 @@ shell: ask_user: title: "Option auswählen" custom_label: "Benutzerdefiniert:" - hint_select: "↑/↓ zum Bewegen • Enter zum Bestätigen • Esc zum Abbrechen" - hint_custom: "Zum Eingeben tippen • Enter zum Bestätigen • Tab zu den Optionen • Esc zum Abbrechen" + hint_select: "Enter zum Auswählen · ↑/↓ zum Navigieren · Esc zum Abbrechen" + hint_custom: "Enter zum Auswählen · ↑/↓ zum Navigieren · Esc zum Abbrechen" paused: title: "⏸ Benutzereingabe erforderlich (Aufgabe pausiert)" prompt: "Frage: {prompt}" diff --git a/src/aish/i18n/en-US.yaml b/src/aish/i18n/en-US.yaml index 726c424..e4b9162 100644 --- a/src/aish/i18n/en-US.yaml +++ b/src/aish/i18n/en-US.yaml @@ -298,15 +298,15 @@ shell: ask_user: title: "Select an option" custom_label: "Custom:" - hint_select: "↑/↓ to move • Enter to confirm • Esc to cancel" - hint_custom: "Type to input custom value • Enter to confirm • Tab to options • Esc to cancel" + hint_select: "Enter to confirm · ↑/↓ to navigate · Esc to cancel" + hint_custom: "Enter to confirm · ↑/↓ to navigate · Esc to cancel" paused: title: "⏸ User input required (task paused)" prompt: "Question: {prompt}" reason: "Reason: {reason}" options_header: "Options:" custom_input: "You can also reply with any custom value." - how_to: "Reply with an option value (or number), or type `; continue with default` (default: {default})." + how_to: "Reply with an option value (or number), or type `; continue with default` (default: {default}). Option descriptions are shown beneath each choice when available." diagnose_cancelled: " └─ ❌ System diagnosis cancelled" diff --git a/src/aish/i18n/es-ES.yaml b/src/aish/i18n/es-ES.yaml index 0ae1cda..83cc113 100644 --- a/src/aish/i18n/es-ES.yaml +++ b/src/aish/i18n/es-ES.yaml @@ -196,8 +196,8 @@ shell: ask_user: title: "Selecciona una opción" custom_label: "Personalizado:" - hint_select: "↑/↓ para moverte • Enter para confirmar • Esc para cancelar" - hint_custom: "Escribe para introducir un valor • Enter para confirmar • Tab para ir a las opciones • Esc para cancelar" + hint_select: "Enter para elegir · ↑/↓ para navegar · Esc para cancelar" + hint_custom: "Enter para elegir · ↑/↓ para navegar · Esc para cancelar" paused: title: "⏸ Se requiere entrada del usuario (tarea en pausa)" prompt: "Pregunta: {prompt}" diff --git a/src/aish/i18n/fr-FR.yaml b/src/aish/i18n/fr-FR.yaml index 087e0e3..b85866c 100644 --- a/src/aish/i18n/fr-FR.yaml +++ b/src/aish/i18n/fr-FR.yaml @@ -196,8 +196,8 @@ shell: ask_user: title: "Selectionnez une option" custom_label: "Personnalise :" - hint_select: "↑/↓ pour se deplacer • Enter pour confirmer • Esc pour annuler" - hint_custom: "Tapez pour saisir une valeur • Enter pour confirmer • Tab pour aller aux options • Esc pour annuler" + hint_select: "Entree pour choisir · ↑/↓ pour naviguer · Esc pour annuler" + hint_custom: "Entree pour choisir · ↑/↓ pour naviguer · Esc pour annuler" paused: title: "⏸ Saisie utilisateur requise (tache en pause)" prompt: "Question : {prompt}" diff --git a/src/aish/i18n/ja-JP.yaml b/src/aish/i18n/ja-JP.yaml index 64581e3..dd76eef 100644 --- a/src/aish/i18n/ja-JP.yaml +++ b/src/aish/i18n/ja-JP.yaml @@ -196,8 +196,8 @@ shell: ask_user: title: "オプションを選択" custom_label: "カスタム:" - hint_select: "↑/↓ で移動 • Enter で確定 • Esc でキャンセル" - hint_custom: "入力して値を指定 • Enter で確定 • Tab で選択肢へ移動 • Esc でキャンセル" + hint_select: "Enter で選択 ・ ↑/↓ で移動 ・ Esc でキャンセル" + hint_custom: "Enter で選択 ・ ↑/↓ で移動 ・ Esc でキャンセル" paused: title: "⏸ ユーザー入力が必要です(タスクは一時停止中)" prompt: "質問: {prompt}" diff --git a/src/aish/i18n/zh-CN.yaml b/src/aish/i18n/zh-CN.yaml index 7850761..0842a62 100644 --- a/src/aish/i18n/zh-CN.yaml +++ b/src/aish/i18n/zh-CN.yaml @@ -298,15 +298,15 @@ shell: ask_user: title: "请选择" custom_label: "自定义:" - hint_select: "↑/↓ 选择 • Enter 确认 • Esc 取消" - hint_custom: "直接输入自定义内容 • Enter 确认 • Tab 切换选项 • Esc 取消" + hint_select: "按 Enter 键选择 · 按 ↑/↓ 键导航 · 按 Esc 键取消" + hint_custom: "按 Enter 键选择 · 按 ↑/↓ 键导航 · 按 Esc 键取消" paused: title: "⏸ 需要你的选择(任务已暂停)" prompt: "问题:{prompt}" reason: "原因:{reason}" options_header: "可选项:" custom_input: "你也可以直接回复任意自定义内容。" - how_to: "你可以直接回复选项 value(或编号),或输入 `; 使用默认继续` / `; continue with default`(默认:{default})。" + how_to: "你可以直接回复选项 value(或编号),或输入 `; 使用默认继续` / `; continue with default`(默认:{default})。如有选项说明,会显示在选项下方。" diagnose_cancelled: " └─ ❌ 系统诊断已取消" diff --git a/src/aish/interaction/__init__.py b/src/aish/interaction/__init__.py new file mode 100644 index 0000000..ab602e9 --- /dev/null +++ b/src/aish/interaction/__init__.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from .ask_user import ( + apply_interaction_response_to_data, + AskUserRequestBuilder, + AskUserInteractionAdapter, +) +from .models import ( + InteractionAnswer, + InteractionAnswerType, + InteractionCustomConfig, + InteractionKind, + InteractionOption, + InteractionRequest, + InteractionResponse, + InteractionSource, + InteractionStatus, + InteractionValidation, +) +from .service import InteractionService + +__all__ = [ + "AskUserRequestBuilder", + "AskUserInteractionAdapter", + "apply_interaction_response_to_data", + "InteractionAnswer", + "InteractionAnswerType", + "InteractionCustomConfig", + "InteractionKind", + "InteractionOption", + "InteractionRequest", + "InteractionResponse", + "InteractionService", + "InteractionSource", + "InteractionStatus", + "InteractionValidation", +] \ No newline at end of file diff --git a/src/aish/interaction/ask_user.py b/src/aish/interaction/ask_user.py new file mode 100644 index 0000000..ecbfce9 --- /dev/null +++ b/src/aish/interaction/ask_user.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import json +import uuid + +from aish.i18n import t +from aish.tools.result import ToolResult + +from .models import ( + InteractionAnswerType, + InteractionCustomConfig, + InteractionKind, + InteractionOption, + InteractionRequest, + InteractionResponse, + InteractionSource, + InteractionStatus, + InteractionValidation, +) + + +class AskUserRequestBuilder: + @staticmethod + def pick_text_default(default: object) -> str | None: + if isinstance(default, str): + return default + return None + + @staticmethod + def normalize_options(options: object) -> list[InteractionOption]: + if not isinstance(options, list): + return [] + + normalized: list[InteractionOption] = [] + for item in options: + if not isinstance(item, dict): + continue + value = item.get("value") + label = item.get("label") + if not isinstance(value, str) or not value.strip(): + continue + if not isinstance(label, str) or not label.strip(): + continue + description = item.get("description") + normalized.append( + InteractionOption( + value=value.strip(), + label=label.strip(), + description=description.strip() + if isinstance(description, str) and description.strip() + else None, + ) + ) + return normalized + + @staticmethod + def pick_default(default: object, options: list[InteractionOption]) -> str: + fallback = options[0].value if options else "" + if isinstance(default, str) and default in {option.value for option in options}: + return default + return fallback + + @classmethod + def from_tool_args( + cls, + *, + kind: str, + prompt: str, + options: object = None, + default: str | None = None, + title: str | None = None, + required: bool = True, + allow_cancel: bool = True, + metadata: object = None, + placeholder: str | None = None, + validation: object = None, + custom: object = None, + interaction_id: str | None = None, + ) -> InteractionRequest: + interaction_kind = InteractionKind(kind) + normalized_options = cls.normalize_options(options) + default_value = cls.pick_default(default, normalized_options) + if interaction_kind == InteractionKind.TEXT_INPUT: + default_value = cls.pick_text_default(default) + + request_metadata = dict(metadata) if isinstance(metadata, dict) else {} + + request_placeholder = ( + placeholder.strip() + if isinstance(placeholder, str) and placeholder.strip() + else None + ) + + request_validation = ( + InteractionValidation.from_dict(validation) + if isinstance(validation, dict) + else None + ) + + request_custom = None + if interaction_kind == InteractionKind.CHOICE_OR_TEXT: + custom_payload = custom if isinstance(custom, dict) else {} + label = str( + custom_payload.get("label") or t("shell.ask_user.custom_label") + ).strip() + custom_placeholder = custom_payload.get("placeholder") + request_custom = InteractionCustomConfig( + label=label, + placeholder=( + str(custom_placeholder).strip() + if isinstance(custom_placeholder, str) and custom_placeholder.strip() + else request_placeholder + ), + submit_mode=str(custom_payload.get("submit_mode") or "inline"), + ) + elif interaction_kind == InteractionKind.TEXT_INPUT: + request_custom = None + if request_validation is None: + request_validation = InteractionValidation(required=required, min_length=1) + + if interaction_kind in ( + InteractionKind.SINGLE_SELECT, + InteractionKind.CHOICE_OR_TEXT, + ): + request_placeholder = None + + return InteractionRequest( + id=interaction_id or f"interaction_{uuid.uuid4().hex[:12]}", + kind=interaction_kind, + title=title, + prompt=prompt, + required=bool(required), + allow_cancel=bool(allow_cancel), + source=InteractionSource(type="tool", name="ask_user"), + metadata=request_metadata, + options=normalized_options, + default=default_value, + placeholder=request_placeholder, + validation=request_validation, + custom=request_custom, + ) + +class AskUserInteractionAdapter: + @staticmethod + def to_tool_result( + request: InteractionRequest, + response: InteractionResponse, + ) -> ToolResult: + if response.status == InteractionStatus.SUBMITTED and response.answer is not None: + if response.answer.type == InteractionAnswerType.OPTION: + label = response.answer.label or response.answer.value + return ToolResult( + ok=True, + output=f"User selected: {label}", + data={ + "value": response.answer.value, + "label": label, + "status": "selected", + "interaction_id": response.interaction_id, + "answer_type": response.answer.type.value, + }, + meta={ + "interaction_id": response.interaction_id, + "interaction_status": response.status.value, + }, + ) + if response.answer.type == InteractionAnswerType.TEXT: + return ToolResult( + ok=True, + output=f"User input: {response.answer.value}", + data={ + "value": response.answer.value, + "label": response.answer.label or response.answer.value, + "status": "custom", + "interaction_id": response.interaction_id, + "answer_type": response.answer.type.value, + }, + meta={ + "interaction_id": response.interaction_id, + "interaction_status": response.status.value, + }, + ) + + reason = response.reason or response.status.value + pause_text = AskUserInteractionAdapter.build_pause_message( + request=request, + reason=reason, + ) + return ToolResult( + ok=False, + output=pause_text, + meta={ + "kind": "user_input_required", + "reason": reason, + "prompt": request.prompt, + "default": request.default, + "options": [option.to_dict() for option in request.options], + "interaction_id": response.interaction_id, + "interaction_status": response.status.value, + }, + stop_tool_chain=True, + ) + + @staticmethod + def build_pause_message(*, request: InteractionRequest, reason: str) -> str: + lines: list[str] = [] + lines.append(t("shell.ask_user.paused.title")) + lines.append(t("shell.ask_user.paused.prompt", prompt=request.prompt)) + lines.append(t("shell.ask_user.paused.reason", reason=reason)) + lines.append(t("shell.ask_user.paused.options_header")) + for index, option in enumerate(request.options, start=1): + lines.append(f" {index}. {option.label} ({option.value})") + if option.description: + lines.append(f" {option.description}") + if request.kind in (InteractionKind.CHOICE_OR_TEXT, InteractionKind.TEXT_INPUT): + lines.append(t("shell.ask_user.paused.custom_input")) + cancel_hint = request.metadata.get("cancel_hint") + if isinstance(cancel_hint, str) and cancel_hint.strip(): + lines.append("") + lines.append(cancel_hint.strip()) + lines.append("") + lines.append( + t( + "shell.ask_user.paused.how_to", + default=request.default or "", + ) + ) + lines.append("") + context = { + "kind": "ask_user_context", + "interaction_id": request.id, + "prompt": request.prompt, + "default": request.default or "", + "options": [option.to_dict() for option in request.options], + "suggested_continue_commands": [ + "; continue with default", + "; 使用默认继续", + ], + } + lines.append("```json") + lines.append(json.dumps(context, ensure_ascii=False)) + lines.append("```") + return "\n".join(lines).strip() + + +def apply_interaction_response_to_data( + data: dict[str, object], + response: InteractionResponse, +) -> None: + data["interaction_response"] = response.to_dict() + data.pop("selected_value", None) + data.pop("custom_input", None) \ No newline at end of file diff --git a/src/aish/interaction/models.py b/src/aish/interaction/models.py new file mode 100644 index 0000000..36925cc --- /dev/null +++ b/src/aish/interaction/models.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class InteractionKind(str, Enum): + SINGLE_SELECT = "single_select" + TEXT_INPUT = "text_input" + CHOICE_OR_TEXT = "choice_or_text" + CONFIRM = "confirm" + + +class InteractionStatus(str, Enum): + SUBMITTED = "submitted" + CANCELLED = "cancelled" + DISMISSED = "dismissed" + UNAVAILABLE = "unavailable" + + +class InteractionAnswerType(str, Enum): + OPTION = "option" + TEXT = "text" + CONFIRM = "confirm" + + +@dataclass(frozen=True) +class InteractionSource: + type: str + name: str + + def to_dict(self) -> dict[str, str]: + return {"type": self.type, "name": self.name} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "InteractionSource": + return cls( + type=str(data.get("type") or "tool"), + name=str(data.get("name") or "ask_user"), + ) + + +@dataclass(frozen=True) +class InteractionOption: + value: str + label: str + description: str | None = None + + def to_dict(self) -> dict[str, str]: + item = {"value": self.value, "label": self.label} + if self.description: + item["description"] = self.description + return item + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "InteractionOption": + description = data.get("description") + return cls( + value=str(data.get("value") or ""), + label=str(data.get("label") or ""), + description=str(description) if isinstance(description, str) else None, + ) + + +@dataclass(frozen=True) +class InteractionValidation: + required: bool = True + min_length: int | None = None + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = {"required": self.required} + if self.min_length is not None: + data["min_length"] = self.min_length + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "InteractionValidation": + min_length = data.get("min_length") + return cls( + required=bool(data.get("required", True)), + min_length=min_length if isinstance(min_length, int) else None, + ) + + +@dataclass(frozen=True) +class InteractionCustomConfig: + label: str + placeholder: str | None = None + submit_mode: str = "inline" + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = { + "label": self.label, + "submit_mode": self.submit_mode, + } + if self.placeholder is not None: + data["placeholder"] = self.placeholder + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "InteractionCustomConfig": + placeholder = data.get("placeholder") + return cls( + label=str(data.get("label") or ""), + placeholder=str(placeholder) if isinstance(placeholder, str) else None, + submit_mode=str(data.get("submit_mode") or "inline"), + ) + + +@dataclass(frozen=True) +class InteractionAnswer: + type: InteractionAnswerType + value: str + label: str | None = None + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = { + "type": self.type.value, + "value": self.value, + } + if self.label is not None: + data["label"] = self.label + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "InteractionAnswer": + label = data.get("label") + return cls( + type=InteractionAnswerType( + str(data.get("type") or InteractionAnswerType.TEXT.value) + ), + value=str(data.get("value") or ""), + label=str(label) if isinstance(label, str) else None, + ) + + +@dataclass(frozen=True) +class InteractionRequest: + id: str + kind: InteractionKind + prompt: str + title: str | None = None + required: bool = True + allow_cancel: bool = True + source: InteractionSource = field( + default_factory=lambda: InteractionSource(type="tool", name="ask_user") + ) + metadata: dict[str, Any] = field(default_factory=dict) + options: list[InteractionOption] = field(default_factory=list) + default: str | None = None + placeholder: str | None = None + validation: InteractionValidation | None = None + custom: InteractionCustomConfig | None = None + + def get_option_by_value(self, value: str) -> InteractionOption | None: + for option in self.options: + if option.value == value: + return option + return None + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = { + "id": self.id, + "kind": self.kind.value, + "prompt": self.prompt, + "required": self.required, + "allow_cancel": self.allow_cancel, + "source": self.source.to_dict(), + "metadata": dict(self.metadata), + "options": [option.to_dict() for option in self.options], + } + if self.title is not None: + data["title"] = self.title + if self.default is not None: + data["default"] = self.default + if self.placeholder is not None: + data["placeholder"] = self.placeholder + if self.validation is not None: + data["validation"] = self.validation.to_dict() + if self.custom is not None: + data["custom"] = self.custom.to_dict() + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "InteractionRequest": + source_data = data.get("source") if isinstance(data.get("source"), dict) else {} + validation_data = ( + data.get("validation") if isinstance(data.get("validation"), dict) else None + ) + custom_data = data.get("custom") if isinstance(data.get("custom"), dict) else None + options_data = data.get("options") if isinstance(data.get("options"), list) else [] + return cls( + id=str(data.get("id") or ""), + kind=InteractionKind( + str(data.get("kind") or InteractionKind.SINGLE_SELECT.value) + ), + prompt=str(data.get("prompt") or ""), + title=str(data.get("title")) if isinstance(data.get("title"), str) else None, + required=bool(data.get("required", True)), + allow_cancel=bool(data.get("allow_cancel", True)), + source=InteractionSource.from_dict(source_data), + metadata=dict(data.get("metadata") or {}), + options=[ + InteractionOption.from_dict(option) + for option in options_data + if isinstance(option, dict) + ], + default=str(data.get("default")) if isinstance(data.get("default"), str) else None, + placeholder=str(data.get("placeholder")) if isinstance(data.get("placeholder"), str) else None, + validation=InteractionValidation.from_dict(validation_data) + if validation_data is not None + else None, + custom=InteractionCustomConfig.from_dict(custom_data) + if custom_data is not None + else None, + ) + + +@dataclass(frozen=True) +class InteractionResponse: + interaction_id: str + status: InteractionStatus + answer: InteractionAnswer | None = None + reason: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = { + "interaction_id": self.interaction_id, + "status": self.status.value, + "metadata": dict(self.metadata), + } + if self.answer is not None: + data["answer"] = self.answer.to_dict() + if self.reason is not None: + data["reason"] = self.reason + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "InteractionResponse": + answer_data = data.get("answer") if isinstance(data.get("answer"), dict) else None + return cls( + interaction_id=str(data.get("interaction_id") or ""), + status=InteractionStatus( + str(data.get("status") or InteractionStatus.DISMISSED.value) + ), + answer=InteractionAnswer.from_dict(answer_data) if answer_data is not None else None, + reason=str(data.get("reason")) if isinstance(data.get("reason"), str) else None, + metadata=dict(data.get("metadata") or {}), + ) \ No newline at end of file diff --git a/src/aish/interaction/service.py b/src/aish/interaction/service.py new file mode 100644 index 0000000..06041e9 --- /dev/null +++ b/src/aish/interaction/service.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from collections.abc import Callable + +from .models import InteractionRequest, InteractionResponse, InteractionStatus + + +class InteractionService: + def __init__( + self, + renderer: Callable[[InteractionRequest], InteractionResponse], + ) -> None: + self._renderer = renderer + + def request(self, request: InteractionRequest) -> InteractionResponse: + try: + return self._renderer(request) + except KeyboardInterrupt: + raise + except Exception as exc: + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.UNAVAILABLE, + reason="error", + metadata={"exception_type": type(exc).__name__}, + ) \ No newline at end of file diff --git a/src/aish/llm.py b/src/aish/llm.py index f382ea3..06e7059 100644 --- a/src/aish/llm.py +++ b/src/aish/llm.py @@ -16,6 +16,7 @@ from aish.config import ConfigModel from aish.context_manager import ContextManager, MemoryType from aish.exception import is_litellm_exception, redact_secrets +from aish.interaction import InteractionRequest, InteractionResponse, InteractionStatus from aish.interruption import ShellState from aish.litellm_loader import load_litellm from aish.providers.registry import get_provider_for_model @@ -76,7 +77,7 @@ class LLMEventType(Enum): TOOL_EXECUTION_END = "tool_execution_end" ERROR = "error" TOOL_CONFIRMATION_REQUIRED = "tool_confirmation_required" - ASK_USER_REQUIRED = "ask_user_required" + INTERACTION_REQUIRED = "interaction_required" CANCELLED = "cancelled" @@ -362,7 +363,7 @@ def __init__( self.edit_file_tool = EditFileTool() from aish.tools.ask_user import AskUserTool - self.ask_user_tool = AskUserTool(request_choice=self.request_user_choice) + self.ask_user_tool = AskUserTool(request_interaction=self.request_interaction) self.skill_tool = SkillTool( skill_manager=self.skill_manager, prompt_manager=self.prompt_manager, @@ -759,37 +760,55 @@ def request_confirmation( # If any error occurs during confirmation, use default return default_on_timeout - def request_user_choice(self, data: dict) -> tuple[str | None, str]: - """Request a user choice via the shell UI. - - Returns: - (selected_value, status) - status in {"selected","cancelled","unavailable","error"}. - """ + def request_interaction( + self, + request: InteractionRequest, + ) -> InteractionResponse: + """Request a user interaction via the shell UI.""" # Non-interactive / no UI callback available. if not self.event_callback: - return None, "unavailable" + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.UNAVAILABLE, + reason="unavailable", + ) try: import sys if not (sys.stdin.isatty() and sys.stdout.isatty()): - return None, "unavailable" + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.UNAVAILABLE, + reason="unavailable", + ) except Exception: # Conservatively treat as unavailable. - return None, "unavailable" + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.UNAVAILABLE, + reason="unavailable", + ) try: - # Shell handler is expected to mutate data["selected_value"]. - self.emit_event(LLMEventType.ASK_USER_REQUIRED, data) - selected_value = data.get("selected_value") - if isinstance(selected_value, str) and selected_value: - return selected_value, "selected" - return None, "cancelled" + event_data = {"interaction_request": request.to_dict()} + self.emit_event(LLMEventType.INTERACTION_REQUIRED, event_data) + response_payload = event_data.get("interaction_response") + if isinstance(response_payload, dict): + return InteractionResponse.from_dict(response_payload) + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.CANCELLED, + reason="cancelled", + ) except KeyboardInterrupt: raise except Exception: - return None, "error" + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.UNAVAILABLE, + reason="error", + ) def _get_langfuse_metadata(self, generation_type: str) -> dict: """Generate Langfuse metadata for better observability""" diff --git a/src/aish/shell.py b/src/aish/shell.py index 9efab0a..963aaea 100644 --- a/src/aish/shell.py +++ b/src/aish/shell.py @@ -44,6 +44,7 @@ from .help_manager import HelpManager from .history_manager import HistoryManager from .i18n import t +from .interaction import InteractionRequest, InteractionResponse, InteractionStatus from .interruption import (InterruptionManager, PromptConfig, ShellState) from .llm import LLMCallbackResult, LLMEvent, LLMEventType, LLMSession @@ -67,9 +68,7 @@ from .shell_enhanced.shell_prompt_io import \ get_user_input as _prompt_get_user_input from .shell_enhanced.shell_prompt_io import \ - handle_ask_user_required as _prompt_handle_ask_user_required -from .shell_enhanced.shell_prompt_io import \ - handle_ask_user_required_inline as _prompt_handle_ask_user_required_inline + handle_interaction_required as _prompt_handle_interaction_required from .shell_enhanced.shell_prompt_io import \ handle_tool_confirmation_required as \ _prompt_handle_tool_confirmation_required @@ -207,7 +206,7 @@ def __init__( LLMEventType.TOOL_EXECUTION_END: self.handle_tool_execution_end, LLMEventType.ERROR: self.handle_error_event, LLMEventType.TOOL_CONFIRMATION_REQUIRED: self.handle_tool_confirmation_required, - LLMEventType.ASK_USER_REQUIRED: self.handle_ask_user_required, + LLMEventType.INTERACTION_REQUIRED: self.handle_interaction_required, LLMEventType.CANCELLED: self.handle_processing_cancelled, } self._llm_event_router = LLMEventRouter(self.event_handlers) @@ -3489,41 +3488,19 @@ def handle_tool_confirmation_required(self, event: LLMEvent) -> LLMCallbackResul """Handle tool confirmation required event - display confirmation dialog and get user response""" return _prompt_handle_tool_confirmation_required(self, event) - def handle_ask_user_required(self, event: LLMEvent) -> LLMCallbackResult: - """Handle ask_user event - show interactive single-choice UI. + def handle_interaction_required(self, event: LLMEvent) -> LLMCallbackResult: + """Handle interaction events with a single prompt UI.""" + return _prompt_handle_interaction_required(self, event) - Uses inline UI at bottom of screen if config.tui.inline_ui is True, - otherwise uses the traditional modal dialog. - """ - # Check if inline UI is enabled in config (default to True) - try: - inline_ui = getattr(getattr(self.config, "tui", None), "inline_ui", True) - except Exception: - inline_ui = True - if inline_ui: - return _prompt_handle_ask_user_required_inline(self, event) - return _prompt_handle_ask_user_required(self, event) - - def request_user_choice(self, data: dict) -> tuple[str | None, str]: - """Request a user choice via the shell UI. + def request_interaction( + self, + request: InteractionRequest, + ) -> InteractionResponse: + """Request a user interaction via the shell UI. This method is used by plan_agent to get user input for plan confirmation. - Args: - data: Dictionary containing: - - prompt: The question to ask - - options: List of option dicts with 'value' and 'label' - - default: Default option value - - title: Optional title for the selection - - allow_cancel: Whether to allow cancellation - - allow_custom_input: Whether to allow custom input - - Returns: - (selected_value, status) where status is one of: - - "selected": User made a selection - - "cancelled": User cancelled - - "unavailable": UI not available (non-TTY) - - "error": An error occurred + Returns the normalized interaction response. """ import sys @@ -3538,32 +3515,39 @@ def request_user_choice(self, data: dict) -> tuple[str | None, str]: # If no TTY but we have a running shell, try anyway # (This handles the case where plan_agent runs in ThreadPoolExecutor) if not has_tty and not self.running: - return None, "unavailable" + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.UNAVAILABLE, + reason="unavailable", + ) try: # Create event and handle it + event_data = {"interaction_request": request.to_dict()} event = LLMEvent( - event_type=LLMEventType.ASK_USER_REQUIRED, - data=data, + event_type=LLMEventType.INTERACTION_REQUIRED, + data=event_data, timestamp=time.time(), ) - self.handle_ask_user_required(event) + self.handle_interaction_required(event) - # Check if user made a selection - selected_value = data.get("selected_value") - if isinstance(selected_value, str) and selected_value: - return selected_value, "selected" + response_payload = event_data.get("interaction_response") + if isinstance(response_payload, dict): + return InteractionResponse.from_dict(response_payload) - # Check for custom input - custom_input = data.get("custom_input") - if isinstance(custom_input, str) and custom_input.strip(): - return custom_input.strip(), "selected" - - return None, "cancelled" + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.CANCELLED, + reason="cancelled", + ) except KeyboardInterrupt: raise except Exception: - return None, "error" + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.UNAVAILABLE, + reason="error", + ) def _display_security_panel(self, data: dict, panel_mode: str = "confirm"): """Display rich security panel for AI tool calls.""" diff --git a/src/aish/shell_enhanced/shell_llm_events.py b/src/aish/shell_enhanced/shell_llm_events.py index 6f31b35..33487e4 100644 --- a/src/aish/shell_enhanced/shell_llm_events.py +++ b/src/aish/shell_enhanced/shell_llm_events.py @@ -24,7 +24,7 @@ def handle(self, event: LLMEvent) -> LLMCallbackResult: result = handler(event) if event.event_type in { LLMEventType.TOOL_CONFIRMATION_REQUIRED, - LLMEventType.ASK_USER_REQUIRED, + LLMEventType.INTERACTION_REQUIRED, }: if isinstance(result, LLMCallbackResult): return result diff --git a/src/aish/shell_enhanced/shell_prompt_io.py b/src/aish/shell_enhanced/shell_prompt_io.py index c73cb7b..50bec88 100644 --- a/src/aish/shell_enhanced/shell_prompt_io.py +++ b/src/aish/shell_enhanced/shell_prompt_io.py @@ -14,6 +14,11 @@ from ..cancellation import CancellationReason from ..i18n import t +from ..interaction import (InteractionAnswer, InteractionAnswerType, + InteractionKind, + InteractionRequest, InteractionResponse, + InteractionStatus, + apply_interaction_response_to_data) from ..interruption import InterruptAction, PromptConfig, ShellState from ..llm import LLMCallbackResult, LLMEvent @@ -386,10 +391,8 @@ def handle_tool_confirmation_required(shell: Any, event: LLMEvent) -> LLMCallbac return LLMCallbackResult.CONTINUE -def handle_ask_user_required(shell: Any, event: LLMEvent) -> LLMCallbackResult: - """Handle ask_user event - show interactive single-choice UI.""" +def _prepare_interaction_prompt(shell: Any) -> None: self = shell - # Stop any active Live/animation before prompting. self._stop_animation() if self.current_live: try: @@ -397,96 +400,153 @@ def handle_ask_user_required(shell: Any, event: LLMEvent) -> LLMCallbackResult: self.current_live.stop() finally: self.current_live = None - self._finalize_content_preview() - data = event.data or {} - prompt = str(data.get("prompt") or "") - options = data.get("options") if isinstance(data.get("options"), list) else [] - default_value = data.get("default") - if not isinstance(default_value, str): - default_value = None - title = str(data.get("title") or t("shell.ask_user.title")) - allow_cancel = bool(data.get("allow_cancel", True)) - allow_custom_input = bool(data.get("allow_custom_input", False)) - custom_label_raw = data.get("custom_label") - custom_label = str(custom_label_raw or t("shell.ask_user.custom_label")) - if isinstance(custom_label_raw, str) and custom_label_raw.strip(): - label_text = custom_label_raw.strip() - else: - label_text = custom_label.strip() - values: list[tuple[str, str]] = [] - for item in options: - if not isinstance(item, dict): - continue - value = item.get("value") - label = item.get("label") - if isinstance(value, str) and value and isinstance(label, str) and label: - values.append((value, label)) +def _build_interaction_response( + request: Any, + selected_value: str | None, +) -> InteractionResponse: + if isinstance(selected_value, str) and selected_value: + selected_option = request.get_option_by_value(selected_value) + answer = InteractionAnswer( + type=InteractionAnswerType.OPTION + if selected_option is not None + else InteractionAnswerType.TEXT, + value=selected_option.value if selected_option is not None else selected_value, + label=selected_option.label if selected_option is not None else selected_value, + ) + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.SUBMITTED, + answer=answer, + ) + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.CANCELLED, + reason="cancelled", + ) + + +def _request_allows_custom_input(request: Any) -> bool: + return request.kind in ( + InteractionKind.CHOICE_OR_TEXT, + InteractionKind.TEXT_INPUT, + ) + + +def render_interaction_modal(shell: Any, request: Any) -> InteractionResponse: + self = shell + prompt = request.prompt + options = [option.to_dict() for option in request.options] + default_value = request.default + title = str(request.title or t("shell.ask_user.title")) + allow_cancel = request.allow_cancel + allow_custom_input = _request_allows_custom_input(request) + label_text = str(t("shell.ask_user.custom_label")) + custom_prompt = request.placeholder + if request.custom is not None: + label_text = request.custom.label + custom_prompt = request.custom.placeholder + + values = [(item["value"], item["label"]) for item in options] + description_by_value = { + item["value"]: item.get("description", "") for item in options + } + if not values and not allow_custom_input: + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.DISMISSED, + reason="dismissed", + ) + + selected_index = 0 + if default_value: + for index, (value, _) in enumerate(values): + if value == default_value: + selected_index = index + break selected_value: str | None = None try: - from functools import partial - from prompt_toolkit import Application from prompt_toolkit.buffer import Buffer - from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings - from prompt_toolkit.key_binding.defaults import load_key_bindings + from prompt_toolkit.filters import Condition + from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.layout import HSplit, Layout, VSplit, Window - from prompt_toolkit.layout.containers import Float, FloatContainer + from prompt_toolkit.layout.containers import ConditionalContainer from prompt_toolkit.layout.dimension import D from prompt_toolkit.styles import Style from prompt_toolkit.utils import get_cwidth - from prompt_toolkit.widgets import Box, RadioList - # Flush any pending key presses (e.g., the Enter used to submit the last prompt) - # to avoid instantly selecting/exiting the dialog. try: if sys.stdin.isatty(): termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH) except Exception: pass - radio_list = RadioList(values=values, default=default_value) custom_buffer = Buffer() if allow_custom_input else None + if ( + custom_buffer is not None + and request.kind == InteractionKind.TEXT_INPUT + and isinstance(default_value, str) + ): + custom_buffer.text = default_value + state = { + "selected_index": selected_index, + "custom_active": allow_custom_input and not values, + } + custom_placeholder_text = custom_prompt or label_text + + def _is_custom_selected() -> bool: + return allow_custom_input and bool(state["custom_active"]) + + def _sync_focus(event) -> None: + if allow_custom_input and custom_input_field is not None and _is_custom_selected(): + event.app.layout.focus(custom_input_field) + else: + event.app.layout.focus(options_window) kb = KeyBindings() - @kb.add("tab", eager=True) - def _next(event): - if allow_custom_input and custom_input_field is not None: - if event.app.layout.has_focus(custom_input_field): - event.app.layout.focus(radio_list) - else: - event.app.layout.focus(custom_input_field) + @kb.add("up", eager=True) + def _move_up(event): + if _is_custom_selected(): + if values: + state["custom_active"] = False + state["selected_index"] = min(max(0, len(values) - 1), state["selected_index"]) + _sync_focus(event) + event.app.invalidate() + return + if values: + state["selected_index"] = max(0, state["selected_index"] - 1) + _sync_focus(event) + event.app.invalidate() - @kb.add("s-tab", eager=True) - def _prev(event): - if allow_custom_input and custom_input_field is not None: - if event.app.layout.has_focus(custom_input_field): - event.app.layout.focus(radio_list) - else: - event.app.layout.focus(custom_input_field) + @kb.add("down", eager=True) + def _move_down(event): + if _is_custom_selected(): + return + if values and state["selected_index"] < len(values) - 1: + state["selected_index"] = min(len(values) - 1, state["selected_index"] + 1) + _sync_focus(event) + event.app.invalidate() + return + if allow_custom_input: + state["custom_active"] = True + _sync_focus(event) + event.app.invalidate() @kb.add("enter", eager=True) def _select(event): - if ( - allow_custom_input - and custom_buffer is not None - and custom_input_field is not None - ): - if event.app.layout.has_focus(custom_input_field): - text_value = (custom_buffer.text or "").strip() - if text_value: - event.app.exit(result=text_value) - return - # Ensure the currently highlighted item becomes the current value. - try: - radio_list._handle_enter() - except Exception: - pass - event.app.exit(result=radio_list.current_value) + if allow_custom_input and custom_buffer is not None and _is_custom_selected(): + text_value = (custom_buffer.text or "").strip() + if text_value: + event.app.exit(result=text_value) + return + return + if values: + event.app.exit(result=values[state["selected_index"]][0]) if allow_cancel: @@ -502,41 +562,155 @@ def _cancel(event): def _current_max_visible() -> int: return self._compute_ask_user_max_visible( - total_options=len(values), + total_options=max(1, len(values)), term_rows=(self._read_terminal_size() or (24, 80))[0], allow_custom_input=allow_custom_input, ) - options_window = Box( - body=radio_list, + def _regular_visible_range() -> tuple[int, int]: + total_regular = len(values) + if total_regular <= 0: + return (0, 0) + + visible_count = _current_max_visible() + visible_regular = max(1, visible_count) + + selected_regular = min(state["selected_index"], total_regular - 1) + start_index = max(0, selected_regular - visible_regular + 1) + if selected_regular < start_index: + start_index = selected_regular + if start_index + visible_regular > total_regular: + start_index = max(0, total_regular - visible_regular) + end_index = min(total_regular, start_index + visible_regular) + return (start_index, end_index) + + def _build_option_lines() -> list[tuple[str, str]]: + start_index, end_index = _regular_visible_range() + + fragments: list[tuple[str, str]] = [] + if start_index > 0: + fragments.append(("class:hint", " ^ 更多选项\n")) + + for index in range(start_index, end_index): + is_selected = index == state["selected_index"] and not _is_custom_selected() + value, label = values[index] + description = description_by_value.get(value, "") + prefix = ">" if is_selected else " " + style = "class:option.selected" if is_selected else "class:option" + fragments.append((style, f"{prefix} {index + 1}. {label}\n")) + if description: + desc_style = ( + "class:option.description.selected" + if is_selected + else "class:option.description" + ) + fragments.append((desc_style, f" {description}\n")) + + if end_index < len(values): + fragments.append(("class:hint", " v 更多选项\n")) + + return fragments + + def _visible_option_rows() -> int: + start_index, end_index = _regular_visible_range() + + rows = max(0, end_index - start_index) + rows += sum( + 1 + for index in range(start_index, end_index) + if description_by_value.get(values[index][0], "") + ) + if start_index > 0: + rows += 1 + if end_index < len(values): + rows += 1 + return rows + + def _separator_text() -> str: + term_cols = (self._read_terminal_size() or (24, 80))[1] + return "─" * max(20, term_cols - 1) + + options_window = Window( + content=FormattedTextControl( + text=_build_option_lines, + focusable=True, + show_cursor=False, + ), + wrap_lines=True, + dont_extend_height=True, height=lambda: D( - min=3, - preferred=_current_max_visible(), - max=_current_max_visible(), + min=0, + preferred=_visible_option_rows(), + max=_visible_option_rows(), ), ) - custom_input_label_window = None + custom_row_window = None custom_input_field = None - if allow_custom_input and custom_buffer is not None: - term_cols = (self._read_terminal_size() or (24, 80))[1] - max_label_width = max(8, term_cols // 3) - label_width = min(get_cwidth(label_text) + 1, max_label_width) - custom_input_label_window = Window( + if allow_custom_input: + max_label_width = max(8, (self._read_terminal_size() or (24, 80))[1] // 3) + label_width = min(max(8, get_cwidth(label_text) + 1), max_label_width) + _ = label_width + custom_header_window = Window( content=FormattedTextControl( - text=f"{label_text} ", style="class:input.label" + text=lambda: [ + ( + "class:option.selected" if _is_custom_selected() else "class:option", + f"> {label_text}" if _is_custom_selected() else f" {label_text}", + ) + ], + show_cursor=False, ), dont_extend_height=True, - width=D(preferred=label_width, min=label_width), + wrap_lines=True, ) - custom_input_field = Window( - content=BufferControl(buffer=custom_buffer, focusable=True), - height=1, - style="class:input", + + custom_body_prefix = Window( + content=FormattedTextControl(text=" "), + dont_extend_height=True, + width=D(preferred=4, min=4, max=4), ) - custom_row = HSplit( + + if custom_buffer is not None: + custom_input_field = Window( + content=BufferControl(buffer=custom_buffer, focusable=True), + height=1, + style="class:input", + ) + + custom_placeholder_body = VSplit( [ - VSplit([custom_input_label_window, custom_input_field], padding=1), + custom_body_prefix, + Window( + content=FormattedTextControl( + text=custom_placeholder_text, + style="class:option.placeholder", + ), + dont_extend_height=True, + wrap_lines=True, + ), + ], + padding=0, + ) + + custom_input_body = VSplit( + [custom_body_prefix, custom_input_field] + if custom_input_field is not None + else [custom_body_prefix], + padding=0, + ) + + custom_row_window = HSplit( + [ + custom_header_window, + ConditionalContainer( + content=custom_placeholder_body, + filter=Condition(lambda: not _is_custom_selected()), + ), + ConditionalContainer( + content=custom_input_body, + filter=Condition(_is_custom_selected), + ), ], padding=0, ) @@ -552,152 +726,59 @@ def _current_max_visible() -> int: dont_extend_height=True, ) - body_items = [prompt_window, options_window] - if allow_custom_input and custom_buffer is not None: - body_items.append(custom_row) - body_items.append(hint_window) - - body = HSplit(body_items, padding=1) - - rounded = { - "tl": "╭", - "tr": "╮", - "bl": "╰", - "br": "╯", - "h": "─", - "v": "│", - } - fill = partial(Window, style="class:frame.border") - - top_row_with_title = VSplit( - [ - fill(width=1, height=1, char=rounded["tl"]), - fill(char=rounded["h"]), - fill(width=1, height=1, char=rounded["v"]), - Window( - FormattedTextControl(lambda: f" {title} "), - style="class:frame.label", - dont_extend_width=True, - ), - fill(width=1, height=1, char=rounded["v"]), - fill(char=rounded["h"]), - fill(width=1, height=1, char=rounded["tr"]), - ], + separator_window_top = Window( + content=FormattedTextControl(text=_separator_text, style="class:separator"), + dont_extend_height=True, height=1, ) - - top_row_without_title = VSplit( - [ - fill(width=1, height=1, char=rounded["tl"]), - fill(char=rounded["h"]), - fill(width=1, height=1, char=rounded["tr"]), - ], + separator_window_bottom = Window( + content=FormattedTextControl(text=_separator_text, style="class:separator"), + dont_extend_height=True, height=1, ) - top_row = top_row_with_title if title else top_row_without_title - - middle_row = VSplit( - [ - fill(width=1, char=rounded["v"]), - body, - fill(width=1, char=rounded["v"]), - ] + title_window = Window( + content=FormattedTextControl(text=f"[ {title} ]", style="class:title"), + wrap_lines=True, + dont_extend_height=True, ) - bottom_row = VSplit( - [ - fill(width=1, height=1, char=rounded["bl"]), - fill(char=rounded["h"]), - fill(width=1, height=1, char=rounded["br"]), - ], - height=1, - ) + body_items = [title_window, separator_window_top, prompt_window, Window(height=1, char="")] + if values: + body_items.append(options_window) + if custom_row_window is not None: + body_items.append(custom_row_window) + body_items.append(separator_window_bottom) + body_items.append(hint_window) - frame_container = HSplit( - [top_row, middle_row, bottom_row], - style="class:frame", - ) + body = HSplit(body_items, padding=0) style = Style.from_dict( { - "frame.border": "#5f5f5f", - "frame.label": "bold #7aa2f7", - "radio-list": "fg:#c0caf5", - "radio-selected": "reverse", - "radio-checked": "bold #7dcfff", + "title": "bold", + "separator": "fg:#6c7086", + "option": "", + "option.selected": "bold fg:#7dcfff", + "option.description": "fg:#7a8499", + "option.description.selected": "fg:#9ccfd8", + "option.placeholder": "fg:#7a8499", + "input": "", "hint": "fg:#7a8499", - "input.label": "fg:#9aa5ce", - "input": "fg:#c0caf5", } ) - focus_target = radio_list - if allow_custom_input and custom_input_field is not None: - focus_target = custom_input_field - - def _line_count(text: str, width: int) -> int: - if width <= 0: - return 1 - count = 1 - current = 0 - for ch in text: - if ch == "\n": - count += 1 - current = 0 - continue - w = get_cwidth(ch) - if current + w > width: - count += 1 - current = w - else: - current += w - return max(1, count) - - def _dialog_height() -> int: - rows, cols = self._read_terminal_size() or (24, 80) - max_visible = self._compute_ask_user_max_visible( - total_options=len(values), - term_rows=rows, - allow_custom_input=allow_custom_input, - ) - inner_width = max(20, cols - 4) - prompt_lines = _line_count(prompt, inner_width) - hint_lines = _line_count(hint_text, inner_width) - custom_row_lines = 1 if allow_custom_input else 0 - item_count = 2 + (1 if allow_custom_input else 0) + 1 - padding_lines = max(0, item_count - 1) - min_height = ( - prompt_lines - + max_visible - + custom_row_lines - + hint_lines - + padding_lines - + 2 - ) - return max(6, min(rows, min_height)) - - # Overlay the dialog on top of existing content to avoid line shifts on every keypress. - overlay = FloatContainer( - content=Window(), - floats=[ - Float( - content=frame_container, - left=0, - right=0, - top=0, - height=_dialog_height, - transparent=False, - ) - ], + focus_target = ( + custom_input_field + if _is_custom_selected() and custom_input_field is not None + else options_window ) app = Application( - layout=Layout(overlay, focused_element=focus_target), - key_bindings=merge_key_bindings([load_key_bindings(), kb]), + layout=Layout(body, focused_element=focus_target), + key_bindings=kb, full_screen=False, style=style, - mouse_support=True, + mouse_support=False, ) try: app.input.flush() @@ -721,10 +802,7 @@ def _watch_ask_user_resize() -> None: except Exception: pass - threading.Thread( - target=_watch_ask_user_resize, - daemon=True, - ).start() + threading.Thread(target=_watch_ask_user_resize, daemon=True).start() try: while True: @@ -739,184 +817,21 @@ def _watch_ask_user_resize() -> None: except Exception: selected_value = None - # Mutate event.data so LLMSession can read the selection without changing callback return types. - try: - data["selected_value"] = selected_value - except Exception: - pass - - return LLMCallbackResult.CONTINUE - + return _build_interaction_response(request, selected_value) -def handle_ask_user_required_inline(shell: Any, event: LLMEvent) -> LLMCallbackResult: - """Handle ask_user event with status bar style UI. - - This displays a multi-line selection UI at the bottom of the screen, - styled like the status bar with gray background. - """ - self = shell - - # Guard: check if data already has selected_value (prevents duplicate calls) - if "selected_value" in event.data and event.data["selected_value"] is not None: - return LLMCallbackResult.CONTINUE - - # Stop any active Live/animation before prompting. - self._stop_animation() - if self.current_live: - try: - self.current_live.update("", refresh=True) - self.current_live.stop() - finally: - self.current_live = None - - self._finalize_content_preview() +def handle_interaction_required(shell: Any, event: LLMEvent) -> LLMCallbackResult: + """Handle interaction event with modal UI.""" data = event.data or {} - prompt = str(data.get("prompt") or "") - options = data.get("options") if isinstance(data.get("options"), list) else [] - default_value = data.get("default") - if not isinstance(default_value, str): - default_value = None - title = str(data.get("title") or t("shell.ask_user.title")) - allow_cancel = bool(data.get("allow_cancel", True)) - - # Normalize options - values: list[tuple[str, str]] = [] - for item in options: - if not isinstance(item, dict): - continue - value = item.get("value") - label = item.get("label") - if isinstance(value, str) and value and isinstance(label, str) and label: - values.append((value, label)) - - if not values: - data["selected_value"] = None + request_payload = data.get("interaction_request") + if not isinstance(request_payload, dict): return LLMCallbackResult.CONTINUE + request = InteractionRequest.from_dict(request_payload) + _prepare_interaction_prompt(shell) + response = render_interaction_modal(shell, request) - # Set initial selection - selected_index = 0 - if default_value: - for i, (v, _) in enumerate(values): - if v == default_value: - selected_index = i - break - - selected_value: str | None = None - - try: - from prompt_toolkit import Application - from prompt_toolkit.formatted_text import HTML - from prompt_toolkit.key_binding import KeyBindings - from prompt_toolkit.layout import HSplit, Layout, Window - from prompt_toolkit.layout.controls import FormattedTextControl - from prompt_toolkit.styles import Style - - # Flush any pending key presses - try: - if sys.stdin.isatty(): - termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH) - except Exception: - pass - - # Selection state - state = {"index": selected_index} - - kb = KeyBindings() - - @kb.add("up", eager=True) - def _up(event): - state["index"] = max(0, state["index"] - 1) - event.app.invalidate() - - @kb.add("down", eager=True) - def _down(event): - state["index"] = min(len(values) - 1, state["index"] + 1) - event.app.invalidate() - - @kb.add("enter", eager=True) - def _select(event): - event.app.exit(result=values[state["index"]][0]) - - if allow_cancel: - - @kb.add("escape", eager=True) - def _cancel(event): - event.app.exit(result=None) - - def get_content(): - """Build status bar style content for selection UI.""" - import html as html_module - from prompt_toolkit.formatted_text import merge_formatted_text - - lines = [] - - # Title line with status bar style - title_text = title or prompt - if title_text: - # Escape special characters for HTML - safe_title = html_module.escape(title_text) - lines.append(HTML(f'\n')) - - # Option lines with status bar style (gray background) - for idx, (value, label) in enumerate(values): - # Escape special characters for HTML - safe_label = html_module.escape(label) - if idx == state["index"]: - # Selected: cyan foreground, bold, with arrow - lines.append( - HTML(f'\n') - ) - else: - # Unselected: dim foreground - lines.append(HTML(f'\n')) - - # Hint line with status bar style - hint_parts = [" "] - hint_parts.append("↑↓ Select") - hint_parts.append(" · Enter Confirm") - if allow_cancel: - hint_parts.append(" · Esc Cancel") - hint = "".join(hint_parts) - lines.append(HTML(f'')) - - return merge_formatted_text(lines) - - # Create a dynamic control that updates on each render - control = FormattedTextControl(get_content) - window = Window(content=control, dont_extend_height=True, style="class:status-bar") - root = HSplit([window]) - - # Style with status bar appearance - style = Style.from_dict( - { - "status-bar": "bg:#1e1e1e", - } - ) - - app = Application( - layout=Layout(root), - key_bindings=kb, - full_screen=False, - style=style, - ) - - try: - app.input.flush() - app.input.flush_keys() - except Exception: - pass - - selected_value = app.run(in_thread=True) - - except KeyboardInterrupt: - raise - except Exception: - selected_value = None - - # Mutate event.data so LLMSession can read the selection try: - event.data["selected_value"] = selected_value + apply_interaction_response_to_data(data, response) except Exception: pass diff --git a/src/aish/tools/ask_user.py b/src/aish/tools/ask_user.py index 925508a..d5e7f8d 100644 --- a/src/aish/tools/ask_user.py +++ b/src/aish/tools/ask_user.py @@ -1,16 +1,21 @@ from __future__ import annotations -import json -import sys -from typing import Callable, Optional +from collections.abc import Callable -from aish.i18n import t +from aish.interaction import ( + AskUserRequestBuilder, + AskUserInteractionAdapter, + InteractionKind, + InteractionRequest, + InteractionResponse, + InteractionService, +) from aish.tools.base import ToolBase from aish.tools.result import ToolResult class AskUserTool(ToolBase): - """Ask the user to choose one option. + """Ask the user for structured input. Cancellation or unavailable interactive UI MUST pause the task and ask the user to decide how to proceed (manual selection or continue with default). @@ -18,14 +23,18 @@ class AskUserTool(ToolBase): def __init__( self, - request_choice: Callable[[dict], tuple[Optional[str], str]], + request_interaction: Callable[[InteractionRequest], InteractionResponse], ) -> None: super().__init__( name="ask_user", description=( "\n".join( [ - "Ask the user to choose one option from a list.", + "Ask the user for one of three interaction kinds:", + "- single_select: choose exactly one predefined option.", + "- text_input: enter free-form text only.", + "- choice_or_text: choose a predefined option or enter custom text.", + "Returns structured output so callers can distinguish selected options from custom text.", "If the UI is unavailable or the user cancels, the task will pause and require user input.", ] ) @@ -33,13 +42,22 @@ def __init__( parameters={ "type": "object", "properties": { + "id": { + "type": "string", + "description": "Optional interaction id. If omitted, one is generated.", + }, + "kind": { + "type": "string", + "enum": ["single_select", "text_input", "choice_or_text"], + "description": "Interaction type: single_select, text_input, or choice_or_text.", + }, "prompt": { "type": "string", "description": "Question/description shown to the user.", }, "options": { "type": "array", - "description": "List of options to choose from (must be non-empty).", + "description": "Predefined options. Required for single_select and choice_or_text; omit for text_input.", "items": { "type": "object", "properties": { @@ -53,230 +71,105 @@ def __init__( }, "default": { "type": "string", - "description": "Default option value used when user chooses to continue with default later.", + "description": "Default value used when present and valid for the interaction kind.", }, "title": { "type": "string", "description": "Optional UI title.", }, + "required": { + "type": "boolean", + "description": "Whether answering is required.", + "default": True, + }, "allow_cancel": { "type": "boolean", "description": "Whether user can cancel/ESC.", "default": True, }, - "cancel_hint": { - "type": "string", - "description": "Optional custom hint shown when the tool pauses due to cancel/unavailable.", + "metadata": { + "type": "object", + "description": "Optional metadata carried with the interaction request.", + "additionalProperties": True, }, - "allow_custom_input": { - "type": "boolean", - "description": "Whether to allow the user to input a custom value.", - "default": False, - }, - "custom_label": { + "placeholder": { "type": "string", - "description": "Label for the custom input option (when allow_custom_input=true).", + "description": "Placeholder text for text_input, or fallback placeholder for choice_or_text custom input.", }, - "custom_prompt": { - "type": "string", - "description": "Prompt shown when asking for a custom input value.", + "validation": { + "type": "object", + "description": "Optional validation config for text input interactions.", + "properties": { + "required": {"type": "boolean"}, + "min_length": {"type": "integer"}, + }, + "additionalProperties": False, + }, + "custom": { + "type": "object", + "description": "Custom text entry config for choice_or_text interactions. Do not provide this for single_select.", + "properties": { + "label": {"type": "string"}, + "placeholder": {"type": "string"}, + "submit_mode": {"type": "string"}, + }, + "additionalProperties": False, }, }, - "required": ["prompt", "options"], + "required": ["kind", "prompt"], }, ) - self._request_choice = request_choice - - @staticmethod - def _normalize_options(options: object) -> list[dict[str, str]]: - if not isinstance(options, list): - return [] - normalized: list[dict[str, str]] = [] - for item in options: - if not isinstance(item, dict): - continue - value = item.get("value") - label = item.get("label") - if not isinstance(value, str) or not value.strip(): - continue - if not isinstance(label, str) or not label.strip(): - continue - normalized.append({"value": value.strip(), "label": label.strip()}) - return normalized - - @staticmethod - def _pick_default(default: object, options: list[dict[str, str]]) -> str: - if options: - fallback = options[0]["value"] - else: - fallback = "" - if isinstance(default, str) and default in {o["value"] for o in options}: - return default - return fallback - - def _build_pause_message( - self, - *, - prompt: str, - options: list[dict[str, str]], - default_value: str, - reason: str, - cancel_hint: str | None, - allow_custom_input: bool, - ) -> str: - lines: list[str] = [] - lines.append(t("shell.ask_user.paused.title")) - lines.append(t("shell.ask_user.paused.prompt", prompt=prompt)) - lines.append(t("shell.ask_user.paused.reason", reason=reason)) - lines.append(t("shell.ask_user.paused.options_header")) - for idx, opt in enumerate(options, start=1): - lines.append(f" {idx}. {opt['label']} ({opt['value']})") - if allow_custom_input: - lines.append(t("shell.ask_user.paused.custom_input")) - if cancel_hint and str(cancel_hint).strip(): - lines.append("") - lines.append(str(cancel_hint).strip()) - lines.append("") - lines.append( - t( - "shell.ask_user.paused.how_to", - default=default_value, - ) + self._interaction_service = InteractionService( + renderer=request_interaction ) - lines.append("") - # Include structured context to help the model reliably continue on the next user turn. - context = { - "kind": "ask_user_context", - "prompt": prompt, - "default": default_value, - "options": options, - "suggested_continue_commands": [ - "; continue with default", - "; 使用默认继续", - ], - } - lines.append("```json") - lines.append(json.dumps(context, ensure_ascii=False)) - lines.append("```") - return "\n".join(lines).strip() def __call__( self, + kind: str, prompt: str, - options: list[dict], + options: list[dict] | None = None, default: str | None = None, title: str | None = None, + required: bool = True, allow_cancel: bool = True, - cancel_hint: str | None = None, - allow_custom_input: bool = False, - custom_label: str | None = None, - custom_prompt: str | None = None, + metadata: dict | None = None, + placeholder: str | None = None, + validation: dict | None = None, + custom: dict | None = None, + id: str | None = None, ) -> ToolResult: - normalized_options = self._normalize_options(options) - if not normalized_options: + request = AskUserRequestBuilder.from_tool_args( + kind=kind, + prompt=prompt, + options=options, + default=default, + title=title, + required=required, + allow_cancel=allow_cancel, + metadata=metadata, + placeholder=placeholder, + validation=validation, + custom=custom, + interaction_id=id, + ) + + if request.kind not in { + InteractionKind.SINGLE_SELECT, + InteractionKind.TEXT_INPUT, + InteractionKind.CHOICE_OR_TEXT, + }: return ToolResult( ok=False, - output="Error: options must be a non-empty list of {value,label}.", + output=f"Error: unsupported ask_user kind: {request.kind.value}.", meta={"kind": "invalid_args"}, ) - default_value = self._pick_default(default, normalized_options) - - # Fast fail for non-interactive environments. - if not (sys.stdin.isatty() and sys.stdout.isatty()): - reason = "unavailable" - pause_text = self._build_pause_message( - prompt=prompt, - options=normalized_options, - default_value=default_value, - reason=reason, - cancel_hint=cancel_hint, - allow_custom_input=allow_custom_input, - ) + if not request.options and request.kind != InteractionKind.TEXT_INPUT: return ToolResult( ok=False, - output=pause_text, - meta={ - "kind": "user_input_required", - "reason": reason, - "prompt": prompt, - "default": default_value, - "options": normalized_options, - }, - stop_tool_chain=True, - ) - - try: - selected, status = self._request_choice( - { - "prompt": prompt, - "options": normalized_options, - "default": default_value, - "title": title, - "allow_cancel": bool(allow_cancel), - "allow_custom_input": bool(allow_custom_input), - "custom_label": custom_label, - "custom_prompt": custom_prompt, - } - ) - except KeyboardInterrupt: - raise - except Exception: - selected, status = None, "error" - - allowed_values = {o["value"] for o in normalized_options} - if isinstance(selected, str) and selected in allowed_values: - label_lookup = {o["value"]: o["label"] for o in normalized_options} - label = label_lookup.get(selected, selected) - # Return a clear, LLM-friendly message instead of JSON - # Store the structured data in the data field for potential future use - return ToolResult( - ok=True, - output=f"User selected: {label}", - data={ - "value": selected, - "label": label, - "status": "selected", - }, - ) - if ( - allow_custom_input - and isinstance(selected, str) - and selected.strip() - and selected not in allowed_values - ): - return ToolResult( - ok=True, - output=f"User input: {selected}", - data={ - "value": selected, - "label": selected, - "status": "custom", - }, + output="Error: options must be a non-empty list of {value,label} for selection interactions.", + meta={"kind": "invalid_args"}, ) - reason = ( - "cancelled" - if status == "cancelled" - else ("unavailable" if status == "unavailable" else "error") - ) - pause_text = self._build_pause_message( - prompt=prompt, - options=normalized_options, - default_value=default_value, - reason=reason, - cancel_hint=cancel_hint, - allow_custom_input=allow_custom_input, - ) - return ToolResult( - ok=False, - output=pause_text, - meta={ - "kind": "user_input_required", - "reason": reason, - "prompt": prompt, - "default": default_value, - "options": normalized_options, - }, - stop_tool_chain=True, - ) + response = self._interaction_service.request(request) + return AskUserInteractionAdapter.to_tool_result(request, response) diff --git a/src/aish/tools/skill.py b/src/aish/tools/skill.py index 539e045..3db747e 100644 --- a/src/aish/tools/skill.py +++ b/src/aish/tools/skill.py @@ -29,7 +29,7 @@ - NEVER just announce or mention a skill in your text response without actually calling this tool - This is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task - Do not invoke a skill that is already running -- If a skill requires user feedback/choice, use the `ask_user` tool. You can enable custom input via `allow_custom_input`. If the user cancels or UI is unavailable, the task pauses +- If a skill requires user feedback/choice, use the `ask_user` tool with explicit interaction kinds: `single_select` for strict option-only choice, `text_input` for free text only, and `choice_or_text` for option-or-custom input. Use `custom` only with `choice_or_text`. If the user cancels or UI is unavailable, the task pauses """ diff --git a/tests/test_ask_user_tool.py b/tests/test_ask_user_tool.py index 41b389d..67905d8 100644 --- a/tests/test_ask_user_tool.py +++ b/tests/test_ask_user_tool.py @@ -5,6 +5,9 @@ from aish.config import ConfigModel from aish.context_manager import ContextManager +from aish.interaction import (InteractionAnswer, InteractionAnswerType, + InteractionKind, InteractionRequest, + InteractionResponse, InteractionStatus) from aish.llm import (LLMCallbackResult, LLMSession, ToolDispatchOutcome, ToolDispatchStatus) from aish.skills import SkillManager @@ -18,13 +21,22 @@ def test_ask_user_tool_selected(monkeypatch): monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False) monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False) - def request_choice(data): - assert data["prompt"] == "pick one" - assert data["default"] == "a" - return "b", "selected" + def request_interaction(request: InteractionRequest) -> InteractionResponse: + assert request.prompt == "pick one" + assert request.default == "a" + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.SUBMITTED, + answer=InteractionAnswer( + type=InteractionAnswerType.OPTION, + value="b", + label="B", + ), + ) - tool = AskUserTool(request_choice=request_choice) + tool = AskUserTool(request_interaction=request_interaction) result = tool( + kind="single_select", prompt="pick one", options=[ {"value": "a", "label": "A"}, @@ -45,14 +57,19 @@ def test_ask_user_tool_cancelled_pauses(monkeypatch): monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False) monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False) - def request_choice(_data): - return None, "cancelled" + def request_interaction(request: InteractionRequest) -> InteractionResponse: + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.CANCELLED, + reason="cancelled", + ) - tool = AskUserTool(request_choice=request_choice) + tool = AskUserTool(request_interaction=request_interaction) result = tool( + kind="single_select", prompt="pick one", options=[ - {"value": "a", "label": "A"}, + {"value": "a", "label": "A", "description": "Alpha option"}, {"value": "b", "label": "B"}, ], default="a", @@ -62,15 +79,55 @@ def request_choice(_data): assert result.meta.get("kind") == "user_input_required" assert result.meta.get("reason") == "cancelled" assert "continue with default" in result.output + assert "Alpha option" in result.output + +def test_ask_user_tool_preserves_option_descriptions(monkeypatch): + monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False) -def test_ask_user_tool_unavailable_pauses(): - # Ensure deterministic "unavailable" across environments. - monkeypatch = pytest.MonkeyPatch() - monkeypatch.setattr(sys.stdin, "isatty", lambda: False, raising=False) - monkeypatch.setattr(sys.stdout, "isatty", lambda: False, raising=False) - tool = AskUserTool(request_choice=lambda _data: ("a", "selected")) + captured: dict[str, object] = {} + + def request_interaction(request: InteractionRequest) -> InteractionResponse: + captured.update(request.to_dict()) + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.SUBMITTED, + answer=InteractionAnswer( + type=InteractionAnswerType.OPTION, + value="a", + label="A", + ), + ) + + tool = AskUserTool(request_interaction=request_interaction) result = tool( + kind="single_select", + prompt="pick one", + options=[ + {"value": "a", "label": "A", "description": "Alpha option"}, + {"value": "b", "label": "B"}, + ], + default="a", + ) + + assert result.ok is True + assert captured["options"] == [ + {"value": "a", "label": "A", "description": "Alpha option"}, + {"value": "b", "label": "B"}, + ] + + +def test_ask_user_tool_respects_interaction_response_status(): + tool = AskUserTool( + request_interaction=lambda request: InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.UNAVAILABLE, + reason="unavailable", + ) + ) + result = tool( + kind="single_select", prompt="pick one", options=[ {"value": "a", "label": "A"}, @@ -81,24 +138,31 @@ def test_ask_user_tool_unavailable_pauses(): assert result.ok is False assert result.meta.get("kind") == "user_input_required" assert result.meta.get("reason") == "unavailable" - monkeypatch.undo() def test_ask_user_tool_custom_input_allowed(monkeypatch): monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False) monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False) - def request_choice(_data): - return "mango", "selected" + def request_interaction(request: InteractionRequest) -> InteractionResponse: + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.SUBMITTED, + answer=InteractionAnswer( + type=InteractionAnswerType.TEXT, + value="mango", + ), + ) - tool = AskUserTool(request_choice=request_choice) + tool = AskUserTool(request_interaction=request_interaction) result = tool( + kind="choice_or_text", prompt="pick one", options=[ {"value": "a", "label": "A"}, {"value": "b", "label": "B"}, ], - allow_custom_input=True, + custom={"label": "Other"}, ) assert result.ok is True @@ -106,6 +170,120 @@ def request_choice(_data): assert result.output == "User input: mango" assert payload["value"] == "mango" assert payload["status"] == "custom" + assert payload["answer_type"] == "text" + + +def test_ask_user_tool_text_input_mode(monkeypatch): + monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False) + + def request_interaction(request: InteractionRequest) -> InteractionResponse: + assert request.kind == InteractionKind.TEXT_INPUT + assert request.options == [] + assert request.placeholder == "Enter fruit name" + return InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.SUBMITTED, + answer=InteractionAnswer( + type=InteractionAnswerType.TEXT, + value="dragonfruit", + ), + ) + + tool = AskUserTool(request_interaction=request_interaction) + result = tool( + kind="text_input", + prompt="Type a fruit", + placeholder="Enter fruit name", + ) + + assert result.ok is True + payload = result.data + assert result.output == "User input: dragonfruit" + assert payload["value"] == "dragonfruit" + assert payload["status"] == "custom" + assert payload["answer_type"] == "text" + + +def test_request_interaction_prefers_standard_response(monkeypatch): + monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False) + + config = ConfigModel(model="test-model", api_key="test-key") + + def event_callback(event): + event.data["interaction_response"] = { + "interaction_id": "interaction_1", + "status": "submitted", + "answer": { + "type": "option", + "value": "b", + "label": "B", + }, + } + return LLMCallbackResult.CONTINUE + + session = LLMSession( + config=config, + skill_manager=SkillManager(), + event_callback=event_callback, + ) + + response = session.request_interaction( + InteractionRequest.from_dict( + { + "id": "interaction_1", + "kind": "single_select", + "prompt": "pick one", + "required": True, + "allow_cancel": True, + "source": {"type": "tool", "name": "ask_user"}, + "metadata": {}, + "options": [ + {"value": "a", "label": "A"}, + {"value": "b", "label": "B"}, + ], + } + ) + ) + + assert response.status.value == "submitted" + assert response.answer is not None + assert response.answer.type == InteractionAnswerType.OPTION + assert response.answer.value == "b" + + +def test_request_interaction_returns_cancelled_without_response(monkeypatch): + monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False) + + config = ConfigModel(model="test-model", api_key="test-key") + + def event_callback(event): + return LLMCallbackResult.CONTINUE + + session = LLMSession( + config=config, + skill_manager=SkillManager(), + event_callback=event_callback, + ) + + response = session.request_interaction( + InteractionRequest.from_dict( + { + "id": "interaction_2", + "kind": "single_select", + "prompt": "pick one", + "required": True, + "allow_cancel": True, + "source": {"type": "tool", "name": "ask_user"}, + "metadata": {}, + "options": [{"value": "a", "label": "A"}], + } + ) + ) + + assert response.status == InteractionStatus.CANCELLED @pytest.mark.anyio @@ -118,7 +296,10 @@ async def test_handle_tool_calls_ask_user_user_input_required_breaks(monkeypatch { "id": "call_1", "type": "function", - "function": {"name": "ask_user", "arguments": "{}"}, + "function": { + "name": "ask_user", + "arguments": '{"kind":"single_select","prompt":"pick one","options":[{"value":"a","label":"A"}]}' + }, }, { "id": "call_2", diff --git a/tests/test_interaction_ask_user.py b/tests/test_interaction_ask_user.py new file mode 100644 index 0000000..61736fb --- /dev/null +++ b/tests/test_interaction_ask_user.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from aish.interaction import ( + AskUserRequestBuilder, + AskUserInteractionAdapter, + InteractionAnswer, + InteractionAnswerType, + InteractionKind, + InteractionRequest, + InteractionResponse, + InteractionService, + InteractionStatus, + apply_interaction_response_to_data, +) + + +def test_ask_user_request_builder_builds_choice_or_text_request(): + request = AskUserRequestBuilder.from_tool_args( + kind="choice_or_text", + prompt="pick one", + options=[ + {"value": "a", "label": "A", "description": "Alpha option"}, + {"value": "b", "label": "B"}, + ], + default="missing", + custom={"label": "Other", "placeholder": "Type here"}, + metadata={"cancel_hint": "custom cancel hint"}, + ) + + assert request.kind == InteractionKind.CHOICE_OR_TEXT + assert request.default == "a" + assert request.custom is not None + assert request.custom.label == "Other" + assert request.custom.placeholder == "Type here" + assert request.options[0].description == "Alpha option" + assert request.metadata["cancel_hint"] == "custom cancel hint" + + +def test_ask_user_request_builder_keeps_single_select_strict(): + request = AskUserRequestBuilder.from_tool_args( + kind="single_select", + prompt="pick one", + options=[ + {"value": "a", "label": "A"}, + {"value": "b", "label": "B"}, + ], + default="b", + placeholder="ignored", + custom={"label": "Other", "placeholder": "Should not appear"}, + ) + + assert request.kind == InteractionKind.SINGLE_SELECT + assert request.default == "b" + assert request.custom is None + assert request.placeholder is None + + +def test_ask_user_request_builder_builds_text_input_request(): + request = AskUserRequestBuilder.from_tool_args( + kind="text_input", + prompt="type a fruit", + placeholder="Enter fruit name", + default="dragonfruit", + ) + + assert request.kind == InteractionKind.TEXT_INPUT + assert request.options == [] + assert request.custom is None + assert request.default == "dragonfruit" + assert request.placeholder == "Enter fruit name" + assert request.validation is not None + assert request.validation.required is True + assert request.validation.min_length == 1 + + +def test_apply_interaction_response_writes_standard_payload_only(): + data: dict[str, object] = { + "selected_value": "stale", + "custom_input": "stale", + } + response = InteractionResponse( + interaction_id="interaction_1", + status=InteractionStatus.SUBMITTED, + answer=InteractionAnswer( + type=InteractionAnswerType.TEXT, + value="mango", + ), + ) + + apply_interaction_response_to_data(data, response) + + assert data["interaction_response"] == response.to_dict() + assert "selected_value" not in data + assert "custom_input" not in data + + +def test_interaction_request_round_trips_via_dict(): + request = AskUserRequestBuilder.from_tool_args( + kind="choice_or_text", + prompt="pick one", + options=[{"value": "a", "label": "A", "description": "Alpha option"}], + default="a", + custom={"label": "Other", "placeholder": "Type here"}, + ) + + restored = InteractionRequest.from_dict(request.to_dict()) + + assert restored.id == request.id + assert restored.kind == request.kind + assert restored.options[0].description == "Alpha option" + assert restored.custom is not None + assert restored.custom.placeholder == "Type here" + + +def test_interaction_service_delegates_to_renderer_without_tty_gate(): + request = AskUserRequestBuilder.from_tool_args( + kind="single_select", + prompt="pick one", + options=[{"value": "a", "label": "A"}], + ) + service = InteractionService( + renderer=lambda _request: InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.SUBMITTED, + ) + ) + + response = service.request(request) + + assert response.interaction_id == request.id + assert response.status == InteractionStatus.SUBMITTED + + +def test_ask_user_adapter_builds_pause_message_for_cancelled(): + request = AskUserRequestBuilder.from_tool_args( + kind="choice_or_text", + prompt="pick one", + options=[ + {"value": "a", "label": "A", "description": "Alpha option"}, + {"value": "b", "label": "B"}, + ], + default="a", + custom={"label": "Other"}, + ) + response = InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.CANCELLED, + reason="cancelled", + ) + + result = AskUserInteractionAdapter.to_tool_result(request, response) + + assert result.ok is False + assert result.meta["kind"] == "user_input_required" + assert result.meta["interaction_id"] == request.id + assert "Alpha option" in result.output + assert "continue with default" in result.output + + +def test_ask_user_adapter_builds_selected_tool_result(): + request = AskUserRequestBuilder.from_tool_args( + kind="single_select", + prompt="pick one", + options=[{"value": "a", "label": "A"}], + default="a", + ) + response = InteractionResponse( + interaction_id=request.id, + status=InteractionStatus.SUBMITTED, + answer=InteractionAnswer( + type=InteractionAnswerType.OPTION, + value="a", + label="A", + ), + ) + + result = AskUserInteractionAdapter.to_tool_result(request, response) + + assert result.ok is True + assert result.output == "User selected: A" + assert result.data["interaction_id"] == request.id + assert result.meta["interaction_status"] == "submitted" \ No newline at end of file diff --git a/tests/test_shell.py b/tests/test_shell.py index d0d6eb4..99fd95c 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -12,6 +12,8 @@ from aish.config import ConfigModel from aish.context_manager import MemoryType +from aish.interaction import AskUserRequestBuilder +from aish.llm import LLMCallbackResult, LLMEvent, LLMEventType from aish.providers.openai_codex import OPENAI_CODEX_PROVIDER_ADAPTER from aish.security.security_manager import SecurityDecision from aish.security.security_policy import RiskLevel @@ -85,6 +87,40 @@ def test_init_with_custom_model(self): shell = make_shell(config) assert shell.llm_session.model == "gpt-4" + @pytest.mark.parametrize( + ("kind", "custom"), + [ + ("single_select", None), + ("choice_or_text", {"label": "Other"}), + ], + ) + def test_handle_interaction_required_uses_single_prompt_ui(self, kind, custom): + config = ConfigModel(model="test-model") + shell = make_shell(config) + request = AskUserRequestBuilder.from_tool_args( + kind=kind, + prompt="pick one", + options=[ + {"value": "a", "label": "A"}, + {"value": "b", "label": "B"}, + ], + custom=custom, + ) + event = LLMEvent( + event_type=LLMEventType.INTERACTION_REQUIRED, + data={"interaction_request": request.to_dict()}, + timestamp=0.0, + ) + + with patch( + "aish.shell._prompt_handle_interaction_required", + return_value=LLMCallbackResult.CONTINUE, + ) as mock_modal: + result = shell.handle_interaction_required(event) + + assert result == LLMCallbackResult.CONTINUE + mock_modal.assert_called_once_with(shell, event) + @pytest.mark.asyncio async def test_model_command_show_current(self): """/model should show current model""" diff --git a/tests/test_shell_llm_event_router.py b/tests/test_shell_llm_event_router.py index f306054..35e0175 100644 --- a/tests/test_shell_llm_event_router.py +++ b/tests/test_shell_llm_event_router.py @@ -27,18 +27,18 @@ def test_router_uses_handler_for_confirmation_event(): def test_router_uses_handler_for_ask_user_event(): router = LLMEventRouter( { - LLMEventType.ASK_USER_REQUIRED: lambda _event: LLMCallbackResult.CANCEL, + LLMEventType.INTERACTION_REQUIRED: lambda _event: LLMCallbackResult.CANCEL, } ) - result = router.handle(make_event(LLMEventType.ASK_USER_REQUIRED)) + result = router.handle(make_event(LLMEventType.INTERACTION_REQUIRED)) assert result == LLMCallbackResult.CANCEL def test_router_ignores_non_callback_result_for_ask_user(): router = LLMEventRouter( { - LLMEventType.ASK_USER_REQUIRED: lambda _event: "ignored", + LLMEventType.INTERACTION_REQUIRED: lambda _event: "ignored", } ) - result = router.handle(make_event(LLMEventType.ASK_USER_REQUIRED)) + result = router.handle(make_event(LLMEventType.INTERACTION_REQUIRED)) assert result == LLMCallbackResult.CONTINUE diff --git a/tests/test_shell_prompt_io.py b/tests/test_shell_prompt_io.py index f125270..f5dd769 100644 --- a/tests/test_shell_prompt_io.py +++ b/tests/test_shell_prompt_io.py @@ -6,10 +6,11 @@ from rich.console import Console +from aish.interaction import AskUserRequestBuilder from aish.llm import LLMCallbackResult, LLMEvent, LLMEventType from aish.shell_enhanced.shell_prompt_io import ( display_security_panel, - handle_ask_user_required, + handle_interaction_required, handle_tool_confirmation_required, ) @@ -50,21 +51,65 @@ def _is_ui_resize_enabled(self) -> bool: return False -def test_handle_ask_user_required_sets_selected_value(): +def test_handle_interaction_required_sets_interaction_response(): shell = _DummyShell() + request = AskUserRequestBuilder.from_tool_args( + kind="choice_or_text", + prompt="Pick one", + options=[ + {"value": "opt1", "label": "Option 1"}, + {"value": "opt2", "label": "Option 2"}, + ], + default="opt1", + custom={"label": "Other", "placeholder": "This is intentionally very long to avoid squeezing input space"}, + ) event = LLMEvent( - event_type=LLMEventType.ASK_USER_REQUIRED, - data={ - "prompt": "Pick one", - "options": [ - {"value": "opt1", "label": "Option 1"}, - {"value": "opt2", "label": "Option 2"}, - ], - "default": "opt1", - "allow_cancel": True, - "allow_custom_input": True, - "custom_prompt": "This is intentionally very long to avoid squeezing input space", - }, + event_type=LLMEventType.INTERACTION_REQUIRED, + data={"interaction_request": request.to_dict()}, + timestamp=time.time(), + ) + + class _DummyApp: + def __init__(self, *args, **kwargs) -> None: + class _Input: + @staticmethod + def flush() -> None: + return + + @staticmethod + def flush_keys() -> None: + return + + self.input = _Input() + + def run(self, in_thread: bool = True) -> str: + _ = in_thread + return "opt2" + + with patch("prompt_toolkit.Application", _DummyApp): + result = handle_interaction_required(shell, event) + + assert result == LLMCallbackResult.CONTINUE + response_payload = event.data.get("interaction_response") + assert isinstance(response_payload, dict) + assert response_payload.get("interaction_id") == request.id + assert response_payload.get("answer", {}).get("value") == "opt2" + + +def test_handle_interaction_required_reads_interaction_request_payload(): + shell = _DummyShell() + request = AskUserRequestBuilder.from_tool_args( + kind="single_select", + prompt="Pick one", + options=[ + {"value": "opt1", "label": "Option 1"}, + {"value": "opt2", "label": "Option 2", "description": "Second option"}, + ], + default="opt1", + ) + event = LLMEvent( + event_type=LLMEventType.INTERACTION_REQUIRED, + data={"interaction_request": request.to_dict()}, timestamp=time.time(), ) @@ -86,10 +131,105 @@ def run(self, in_thread: bool = True) -> str: return "opt2" with patch("prompt_toolkit.Application", _DummyApp): - result = handle_ask_user_required(shell, event) + result = handle_interaction_required(shell, event) + + assert result == LLMCallbackResult.CONTINUE + response_payload = event.data.get("interaction_response") + assert isinstance(response_payload, dict) + assert response_payload.get("interaction_id") == request.id + assert response_payload.get("status") == "submitted" + + +def test_handle_interaction_required_supports_text_input(): + shell = _DummyShell() + request = AskUserRequestBuilder.from_tool_args( + kind="text_input", + prompt="Type a fruit", + placeholder="Enter fruit name", + ) + event = LLMEvent( + event_type=LLMEventType.INTERACTION_REQUIRED, + data={"interaction_request": request.to_dict()}, + timestamp=time.time(), + ) + + class _DummyApp: + def __init__(self, *args, **kwargs) -> None: + class _Input: + @staticmethod + def flush() -> None: + return + + @staticmethod + def flush_keys() -> None: + return + + self.input = _Input() + + def run(self, in_thread: bool = True) -> str: + _ = in_thread + return "dragonfruit" + + with patch("prompt_toolkit.Application", _DummyApp): + result = handle_interaction_required(shell, event) + + assert result == LLMCallbackResult.CONTINUE + response_payload = event.data.get("interaction_response") + assert isinstance(response_payload, dict) + assert response_payload.get("interaction_id") == request.id + assert response_payload.get("answer", {}).get("type") == "text" + assert response_payload.get("answer", {}).get("value") == "dragonfruit" + + +def test_handle_interaction_required_prefills_text_input_default(): + shell = _DummyShell() + request = AskUserRequestBuilder.from_tool_args( + kind="text_input", + prompt="Type a fruit", + placeholder="Enter fruit name", + default="kiwi", + ) + event = LLMEvent( + event_type=LLMEventType.INTERACTION_REQUIRED, + data={"interaction_request": request.to_dict()}, + timestamp=time.time(), + ) + + class _CapturingBuffer: + instances: list["_CapturingBuffer"] = [] + + def __init__(self, *args, **kwargs) -> None: + _ = args, kwargs + self.text = "" + self.__class__.instances.append(self) + + class _DummyApp: + def __init__(self, *args, **kwargs) -> None: + class _Input: + @staticmethod + def flush() -> None: + return + + @staticmethod + def flush_keys() -> None: + return + + self.input = _Input() + + def run(self, in_thread: bool = True) -> str: + _ = in_thread + return _CapturingBuffer.instances[-1].text + + with patch("prompt_toolkit.buffer.Buffer", _CapturingBuffer), patch( + "prompt_toolkit.Application", _DummyApp + ): + result = handle_interaction_required(shell, event) assert result == LLMCallbackResult.CONTINUE - assert event.data.get("selected_value") == "opt2" + response_payload = event.data.get("interaction_response") + assert isinstance(response_payload, dict) + assert response_payload.get("interaction_id") == request.id + assert response_payload.get("answer", {}).get("value") == "kiwi" def test_display_security_panel_shows_fallback_rule_details(monkeypatch):