diff --git a/.gitignore b/.gitignore index d548b24d..4922d051 100644 --- a/.gitignore +++ b/.gitignore @@ -165,6 +165,8 @@ cython_debug/ # Cursor .cursor/ +.qoder/ +.claude # Super Powers .superpowers/ diff --git a/README.md b/README.md index cb4ef5a2..29703148 100644 --- a/README.md +++ b/README.md @@ -45,4 +45,4 @@ Reading from stdin is also supported: ```bash echo "Create an OSS Bucket" | iac-code --prompt - -``` +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 844f3a07..080b4e35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "opentelemetry-distro>=0.48b0", "opentelemetry-exporter-otlp>=1.27.0", "agent-client-protocol>=0.9.0", + "pillow==11.0.0", "cryptography>=42.0", "keyring>=25.0", "tree-sitter>=0.23", @@ -105,6 +106,10 @@ iac-code = "iac_code.cli.main:app" [tool.uv] index-url = "https://mirrors.aliyun.com/pypi/simple/" +[[tool.uv.index]] +url = "https://mirrors.aliyun.com/pypi/simple/" +default = true + [tool.coverage.run] source_pkgs = ["iac_code"] branch = true diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index cb8eaa49..6890cb17 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -12,7 +12,7 @@ from loguru import logger -from iac_code.agent.message import TextBlock, ThinkingBlock, ToolResultBlock, ToolUseBlock +from iac_code.agent.message import ContentBlock, TextBlock, ThinkingBlock, ToolResultBlock, ToolUseBlock from iac_code.i18n import _ from iac_code.services.context_manager import ContextManager from iac_code.tools.base import ToolContext, ToolRegistry, ToolResult @@ -151,12 +151,14 @@ def _get_provider_messages(self): input=block.get("input"), content=block.get("content"), is_error=block.get("is_error", False), + media_type=block.get("media_type"), + data=block.get("data"), ) ) provider_messages.append(ProviderMessage(role=role, content=blocks)) return provider_messages - async def run(self, user_input: str) -> str: + async def run(self, user_input: str | list[ContentBlock]) -> str: """Non-streaming execution. Returns final text.""" final_text = "" async for event in self.run_streaming(user_input): @@ -164,7 +166,7 @@ async def run(self, user_input: str) -> str: final_text += event.text return final_text - async def run_streaming(self, user_input: str) -> AsyncGenerator[StreamEvent, None]: + async def run_streaming(self, user_input: str | list[ContentBlock]) -> AsyncGenerator[StreamEvent, None]: """Streaming execution yielding fine-grained StreamEvents. Flow: @@ -201,7 +203,15 @@ async def run_streaming(self, user_input: str) -> AsyncGenerator[StreamEvent, No serialize_user_input, ) - entry_attrs[GenAiAttr.INPUT_MESSAGES] = serialize_user_input(user_input) + # serialize_user_input expects str; for structured input (list[ContentBlock]), + # extract text-only segments so telemetry stays readable without leaking image bytes. + if isinstance(user_input, str): + input_text_for_telemetry = user_input + else: + input_text_for_telemetry = " ".join( + getattr(b, "text", "") for b in user_input if getattr(b, "type", None) == "text" + ) + entry_attrs[GenAiAttr.INPUT_MESSAGES] = serialize_user_input(input_text_for_telemetry) entry_attrs[GenAiAttr.SYSTEM_INSTRUCTIONS] = serialize_system_instructions(self.system_prompt) with start_span(Spans.ENTRY, entry_attrs) as entry_span: @@ -248,7 +258,7 @@ async def run_streaming(self, user_input: str) -> AsyncGenerator[StreamEvent, No serialize_output_messages("".join(final_text_chunks), final_stop_reason), ) - async def _run_streaming_inner(self, user_input: str) -> AsyncGenerator[StreamEvent, None]: + async def _run_streaming_inner(self, user_input: str | list[ContentBlock]) -> AsyncGenerator[StreamEvent, None]: """Inner streaming loop (called from run_streaming inside the ENTRY span).""" from iac_code.services.telemetry import start_span from iac_code.services.telemetry.names import GenAiAttr, GenAiOperationName, GenAiSpanKind, Spans @@ -423,7 +433,7 @@ async def _run_streaming_inner(self, user_input: str) -> AsyncGenerator[StreamEv ] self.context_manager.add_tool_results(denied_blocks) if self._session_storage: - from iac_code.agent.message import ContentBlock, Message + from iac_code.agent.message import Message denied_content: list[ContentBlock] = list(denied_blocks) self._session_storage.append( @@ -509,7 +519,7 @@ async def poll_event_queues(): self.context_manager.add_tool_results(tool_result_blocks) if self._session_storage: - from iac_code.agent.message import ContentBlock, Message + from iac_code.agent.message import Message result_content: list[ContentBlock] = list(tool_result_blocks) self._session_storage.append( diff --git a/src/iac_code/agent/message.py b/src/iac_code/agent/message.py index 84a569b2..e81c5c2e 100644 --- a/src/iac_code/agent/message.py +++ b/src/iac_code/agent/message.py @@ -44,8 +44,14 @@ class ThinkingBlock(BaseModel): thinking: str +class ImageBlock(BaseModel): + type: Literal["image"] = "image" + media_type: str # 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' + data: str # base64 + + # Union type for all content blocks -ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock | ThinkingBlock +ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock | ThinkingBlock | ImageBlock class Message(BaseModel): @@ -110,6 +116,8 @@ def to_api_format(self) -> dict: ) elif isinstance(block, ThinkingBlock): content_list.append({"type": "thinking", "thinking": block.thinking}) + elif isinstance(block, ImageBlock): + content_list.append({"type": "image", "media_type": block.media_type, "data": block.data}) return {"role": self.role, "content": content_list} @@ -118,7 +126,7 @@ class Conversation(BaseModel): messages: list[Message] = Field(default_factory=list) - def add_user_message(self, content: str) -> Message: + def add_user_message(self, content: str | list[ContentBlock]) -> Message: """Add a user message to the conversation.""" msg = Message(role="user", content=content) self.messages.append(msg) diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 60a7047f..34c42855 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: iac-code 0.1.2\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-18 16:59+0800\n" +"POT-Creation-Date: 2026-05-18 18:09+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: de\n" @@ -93,7 +93,7 @@ msgstr "Debug-Protokollierung deaktiviert." msgid "Usage: /debug [on|off]" msgstr "Verwendung: /debug [on|off]" -#: src/iac_code/agent/agent_loop.py:389 src/iac_code/agent/agent_loop.py:404 +#: src/iac_code/agent/agent_loop.py:399 src/iac_code/agent/agent_loop.py:414 msgid "Permission denied." msgstr "Zugriff verweigert." @@ -544,14 +544,14 @@ msgid "[conversation id or search term]" msgstr "[Konversations-ID oder Suchbegriff]" #: src/iac_code/commands/auth.py:246 src/iac_code/commands/auth.py:836 -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Navigate" msgstr "Navigieren" #: src/iac_code/commands/auth.py:246 src/iac_code/commands/auth.py:368 #: src/iac_code/commands/auth.py:402 src/iac_code/commands/auth.py:409 #: src/iac_code/commands/auth.py:433 src/iac_code/commands/auth.py:836 -#: src/iac_code/commands/auth.py:1026 src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/commands/auth.py:1026 src/iac_code/ui/core/prompt_input.py:505 msgid "Confirm" msgstr "Bestätigen" @@ -661,7 +661,7 @@ msgstr "Konfiguriert" msgid "Alibaba Cloud" msgstr "Alibaba Cloud" -#: src/iac_code/commands/auth.py:751 src/iac_code/providers/registry.py:422 +#: src/iac_code/commands/auth.py:751 src/iac_code/providers/registry.py:423 msgid "ZhiPu AI" msgstr "ZhiPu AI" @@ -673,7 +673,7 @@ msgstr "Kimi" msgid "MiniMax" msgstr "MiniMax" -#: src/iac_code/commands/auth.py:754 src/iac_code/providers/registry.py:424 +#: src/iac_code/commands/auth.py:754 src/iac_code/providers/registry.py:425 msgid "Volcengine" msgstr "Volcengine" @@ -681,27 +681,27 @@ msgstr "Volcengine" msgid "SiliconFlow" msgstr "SiliconFlow" -#: src/iac_code/commands/auth.py:756 src/iac_code/providers/registry.py:415 +#: src/iac_code/commands/auth.py:756 src/iac_code/providers/registry.py:416 msgid "DeepSeek" msgstr "DeepSeek" -#: src/iac_code/commands/auth.py:757 src/iac_code/providers/registry.py:413 +#: src/iac_code/commands/auth.py:757 src/iac_code/providers/registry.py:414 msgid "OpenAI" msgstr "OpenAI" -#: src/iac_code/commands/auth.py:758 src/iac_code/providers/registry.py:414 +#: src/iac_code/commands/auth.py:758 src/iac_code/providers/registry.py:415 msgid "Anthropic" msgstr "Anthropic" -#: src/iac_code/commands/auth.py:759 src/iac_code/providers/registry.py:417 +#: src/iac_code/commands/auth.py:759 src/iac_code/providers/registry.py:418 msgid "Google Gemini" msgstr "Google Gemini" -#: src/iac_code/commands/auth.py:760 src/iac_code/providers/registry.py:430 +#: src/iac_code/commands/auth.py:760 src/iac_code/providers/registry.py:431 msgid "Azure OpenAI" msgstr "Azure OpenAI" -#: src/iac_code/commands/auth.py:761 src/iac_code/providers/registry.py:429 +#: src/iac_code/commands/auth.py:761 src/iac_code/providers/registry.py:430 msgid "OpenRouter" msgstr "OpenRouter" @@ -959,7 +959,7 @@ msgstr "" "Kein API-Schlüssel für Anbieter '{provider}' konfiguriert (Modell: " "{model}). Führen Sie /auth aus, um die Konfiguration vorzunehmen." -#: src/iac_code/providers/openai_provider.py:289 +#: src/iac_code/providers/openai_provider.py:307 #, python-brace-format msgid "" "API returned no data. Please check that your API Base URL is correct " @@ -970,7 +970,7 @@ msgstr "" "korrekt ist (aktuell: {base_url}). Viele OpenAI-kompatible Endpunkte " "erfordern ein /v1-Suffix (z. B. {base_url}/v1)." -#: src/iac_code/providers/openai_provider.py:330 +#: src/iac_code/providers/openai_provider.py:348 #, python-brace-format msgid "" "API returned an invalid response. Please check that your API Base URL is " @@ -981,79 +981,79 @@ msgstr "" " Base URL korrekt ist (aktuell: {base_url}). Viele OpenAI-kompatible " "Endpunkte erfordern ein /v1-Suffix (z. B. {base_url}/v1)." -#: src/iac_code/providers/registry.py:411 +#: src/iac_code/providers/registry.py:412 msgid "Alibaba Cloud Bailian" msgstr "Alibaba Cloud Bailian" -#: src/iac_code/providers/registry.py:412 +#: src/iac_code/providers/registry.py:413 msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud Bailian Token Plan" -#: src/iac_code/providers/registry.py:416 +#: src/iac_code/providers/registry.py:417 msgid "OpenAPI Compatible" msgstr "OpenAPI-kompatibel" -#: src/iac_code/providers/registry.py:418 +#: src/iac_code/providers/registry.py:419 msgid "Kimi (China)" msgstr "Kimi (China)" -#: src/iac_code/providers/registry.py:419 +#: src/iac_code/providers/registry.py:420 msgid "Kimi (International)" msgstr "Kimi (International)" -#: src/iac_code/providers/registry.py:420 +#: src/iac_code/providers/registry.py:421 msgid "MiniMax (China)" msgstr "MiniMax (China)" -#: src/iac_code/providers/registry.py:421 +#: src/iac_code/providers/registry.py:422 msgid "MiniMax (International)" msgstr "MiniMax (International)" -#: src/iac_code/providers/registry.py:423 +#: src/iac_code/providers/registry.py:424 msgid "ZhiPu AI (International)" msgstr "ZhiPu AI (International)" -#: src/iac_code/providers/registry.py:425 +#: src/iac_code/providers/registry.py:426 msgid "SiliconFlow (China)" msgstr "SiliconFlow (China)" -#: src/iac_code/providers/registry.py:426 +#: src/iac_code/providers/registry.py:427 msgid "SiliconFlow (International)" msgstr "SiliconFlow (International)" -#: src/iac_code/providers/registry.py:427 +#: src/iac_code/providers/registry.py:428 msgid "Ollama (Local)" msgstr "Ollama (Lokal)" -#: src/iac_code/providers/registry.py:428 +#: src/iac_code/providers/registry.py:429 msgid "LM Studio (Local)" msgstr "LM Studio (Lokal)" -#: src/iac_code/providers/registry.py:431 +#: src/iac_code/providers/registry.py:432 msgid "ModelScope" msgstr "ModelScope" -#: src/iac_code/providers/registry.py:432 +#: src/iac_code/providers/registry.py:433 msgid "Alibaba Cloud CodingPlan" msgstr "Alibaba Cloud CodingPlan" -#: src/iac_code/providers/registry.py:433 +#: src/iac_code/providers/registry.py:434 msgid "Alibaba Cloud CodingPlan (International)" msgstr "Alibaba Cloud CodingPlan (International)" -#: src/iac_code/providers/registry.py:434 +#: src/iac_code/providers/registry.py:435 msgid "ZhiPu AI CodingPlan" msgstr "ZhiPu AI CodingPlan" -#: src/iac_code/providers/registry.py:435 +#: src/iac_code/providers/registry.py:436 msgid "ZhiPu AI CodingPlan (International)" msgstr "ZhiPu AI CodingPlan (International)" -#: src/iac_code/providers/registry.py:436 +#: src/iac_code/providers/registry.py:437 msgid "Volcengine CodingPlan" msgstr "Volcengine CodingPlan" -#: src/iac_code/providers/registry.py:437 +#: src/iac_code/providers/registry.py:438 msgid "Anthropic Compatible" msgstr "Anthropic-kompatibel" @@ -1703,45 +1703,49 @@ msgstr "Nein, immer \"{rule}\" ablehnen (diese Sitzung)" msgid "No, always reject this tool" msgstr "Nein, dieses Tool immer ablehnen" -#: src/iac_code/ui/repl.py:342 +#: src/iac_code/ui/repl.py:353 msgid "Press Ctrl+C again to exit." msgstr "Drücken Sie erneut Ctrl+C zum Beenden." -#: src/iac_code/ui/repl.py:358 +#: src/iac_code/ui/repl.py:373 msgid "Interrupted." msgstr "Unterbrochen." -#: src/iac_code/ui/repl.py:395 +#: src/iac_code/ui/repl.py:410 msgid "Goodbye!" msgstr "Auf Wiedersehen!" -#: src/iac_code/ui/repl.py:396 +#: src/iac_code/ui/repl.py:411 msgid "Resume this session with:" msgstr "Diese Sitzung fortsetzen mit:" -#: src/iac_code/ui/repl.py:478 +#: src/iac_code/ui/repl.py:444 +msgid "No image in clipboard." +msgstr "Kein Bild in der Zwischenablage." + +#: src/iac_code/ui/repl.py:584 #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." msgstr "" "Unknown command: /{name}. Type /help for available commands.Unbekannter " "Befehl: /{name}. Geben Sie /help für verfügbare Befehle ein." -#: src/iac_code/ui/repl.py:502 src/iac_code/ui/repl.py:534 +#: src/iac_code/ui/repl.py:608 src/iac_code/ui/repl.py:640 #, python-brace-format msgid "Command error: {error}" msgstr "Befehlsfehler: {error}" -#: src/iac_code/ui/repl.py:509 +#: src/iac_code/ui/repl.py:615 #, python-brace-format msgid "Command has no handler: {name}" msgstr "Kein Handler für Befehl: {name}" -#: src/iac_code/ui/repl.py:712 +#: src/iac_code/ui/repl.py:835 #, python-brace-format msgid "Session not found: {session_id}" msgstr "Sitzung nicht gefunden: {session_id}" -#: src/iac_code/ui/repl.py:731 +#: src/iac_code/ui/repl.py:854 #, python-brace-format msgid "" "This session belongs to a different directory.\n" @@ -1752,18 +1756,40 @@ msgstr "" "Zum Fortsetzen ausführen:\n" " {cmd}" -#: src/iac_code/ui/repl.py:770 +#: src/iac_code/ui/repl.py:893 msgid "This conversation is from a different directory." msgstr "Diese Konversation stammt aus einem anderen Verzeichnis." -#: src/iac_code/ui/repl.py:772 +#: src/iac_code/ui/repl.py:895 msgid "To resume, run:" msgstr "Zum Fortsetzen ausführen:" -#: src/iac_code/ui/repl.py:777 +#: src/iac_code/ui/repl.py:900 msgid "(Command copied to clipboard)" msgstr "(Befehl in die Zwischenablage kopiert)" +#: src/iac_code/ui/repl.py:1057 +#, python-brace-format +msgid "" +"Current model {model} does not support image input. Use /model to switch " +"to a vision-capable model." +msgstr "" +"Das aktuelle Modell {model} unterstützt keine Bildeingabe. Verwenden Sie " +"/model, um zu einem Vision-fähigen Modell zu wechseln." + +#: src/iac_code/ui/repl.py:1066 +#, python-brace-format +msgid "Image error: {err}" +msgstr "Bildfehler: {err}" + +#: src/iac_code/ui/repl.py:1083 +msgid "" +"Failed to persist image to cache; it will only exist in memory for this " +"turn." +msgstr "" +"Bild konnte nicht im Cache gespeichert werden; es existiert nur im " +"Arbeitsspeicher für diesen Durchgang." + #: src/iac_code/ui/spinner.py:53 msgid "Thinking" msgstr "Denken" @@ -1800,11 +1826,15 @@ msgstr "Vollständiges Protokoll · ctrl+o zum Umschalten" msgid "No matches found" msgstr "Keine Treffer" -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:331 +msgid "Image in clipboard · ctrl+v to paste" +msgstr "Bild in der Zwischenablage · Strg+V zum Einfügen" + +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Fill" msgstr "Ausfüllen" -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Dismiss" msgstr "Schließen" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 15909c6a..e610b30b 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: iac-code 0.1.2\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-18 16:59+0800\n" +"POT-Creation-Date: 2026-05-18 18:09+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: es\n" @@ -96,7 +96,7 @@ msgstr "Registro de depuración deshabilitado." msgid "Usage: /debug [on|off]" msgstr "Uso: /debug [on|off]" -#: src/iac_code/agent/agent_loop.py:389 src/iac_code/agent/agent_loop.py:404 +#: src/iac_code/agent/agent_loop.py:399 src/iac_code/agent/agent_loop.py:414 msgid "Permission denied." msgstr "Permiso denegado." @@ -545,14 +545,14 @@ msgid "[conversation id or search term]" msgstr "[id de conversación o término de búsqueda]" #: src/iac_code/commands/auth.py:246 src/iac_code/commands/auth.py:836 -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Navigate" msgstr "Navegar" #: src/iac_code/commands/auth.py:246 src/iac_code/commands/auth.py:368 #: src/iac_code/commands/auth.py:402 src/iac_code/commands/auth.py:409 #: src/iac_code/commands/auth.py:433 src/iac_code/commands/auth.py:836 -#: src/iac_code/commands/auth.py:1026 src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/commands/auth.py:1026 src/iac_code/ui/core/prompt_input.py:505 msgid "Confirm" msgstr "Confirmar" @@ -662,7 +662,7 @@ msgstr "Configurado" msgid "Alibaba Cloud" msgstr "Alibaba Cloud" -#: src/iac_code/commands/auth.py:751 src/iac_code/providers/registry.py:422 +#: src/iac_code/commands/auth.py:751 src/iac_code/providers/registry.py:423 msgid "ZhiPu AI" msgstr "ZhiPu AI" @@ -674,7 +674,7 @@ msgstr "Kimi" msgid "MiniMax" msgstr "MiniMax" -#: src/iac_code/commands/auth.py:754 src/iac_code/providers/registry.py:424 +#: src/iac_code/commands/auth.py:754 src/iac_code/providers/registry.py:425 msgid "Volcengine" msgstr "Volcengine" @@ -682,27 +682,27 @@ msgstr "Volcengine" msgid "SiliconFlow" msgstr "SiliconFlow" -#: src/iac_code/commands/auth.py:756 src/iac_code/providers/registry.py:415 +#: src/iac_code/commands/auth.py:756 src/iac_code/providers/registry.py:416 msgid "DeepSeek" msgstr "DeepSeek" -#: src/iac_code/commands/auth.py:757 src/iac_code/providers/registry.py:413 +#: src/iac_code/commands/auth.py:757 src/iac_code/providers/registry.py:414 msgid "OpenAI" msgstr "OpenAI" -#: src/iac_code/commands/auth.py:758 src/iac_code/providers/registry.py:414 +#: src/iac_code/commands/auth.py:758 src/iac_code/providers/registry.py:415 msgid "Anthropic" msgstr "Anthropic" -#: src/iac_code/commands/auth.py:759 src/iac_code/providers/registry.py:417 +#: src/iac_code/commands/auth.py:759 src/iac_code/providers/registry.py:418 msgid "Google Gemini" msgstr "Google Gemini" -#: src/iac_code/commands/auth.py:760 src/iac_code/providers/registry.py:430 +#: src/iac_code/commands/auth.py:760 src/iac_code/providers/registry.py:431 msgid "Azure OpenAI" msgstr "Azure OpenAI" -#: src/iac_code/commands/auth.py:761 src/iac_code/providers/registry.py:429 +#: src/iac_code/commands/auth.py:761 src/iac_code/providers/registry.py:430 msgid "OpenRouter" msgstr "OpenRouter" @@ -960,7 +960,7 @@ msgstr "" "No se ha configurado una clave API para el proveedor '{provider}' " "(modelo: {model}). Ejecute /auth para configurar." -#: src/iac_code/providers/openai_provider.py:289 +#: src/iac_code/providers/openai_provider.py:307 #, python-brace-format msgid "" "API returned no data. Please check that your API Base URL is correct " @@ -971,7 +971,7 @@ msgstr "" "(actual: {base_url}). Muchos endpoints compatibles con OpenAI requieren " "el sufijo /v1 (p. ej., {base_url}/v1)." -#: src/iac_code/providers/openai_provider.py:330 +#: src/iac_code/providers/openai_provider.py:348 #, python-brace-format msgid "" "API returned an invalid response. Please check that your API Base URL is " @@ -982,79 +982,79 @@ msgstr "" " correcta (actual: {base_url}). Muchos endpoints compatibles con OpenAI " "requieren el sufijo /v1 (p. ej., {base_url}/v1)." -#: src/iac_code/providers/registry.py:411 +#: src/iac_code/providers/registry.py:412 msgid "Alibaba Cloud Bailian" msgstr "Alibaba Cloud Bailian" -#: src/iac_code/providers/registry.py:412 +#: src/iac_code/providers/registry.py:413 msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud Bailian Token Plan" -#: src/iac_code/providers/registry.py:416 +#: src/iac_code/providers/registry.py:417 msgid "OpenAPI Compatible" msgstr "Compatible con OpenAPI" -#: src/iac_code/providers/registry.py:418 +#: src/iac_code/providers/registry.py:419 msgid "Kimi (China)" msgstr "Kimi (China)" -#: src/iac_code/providers/registry.py:419 +#: src/iac_code/providers/registry.py:420 msgid "Kimi (International)" msgstr "Kimi (Internacional)" -#: src/iac_code/providers/registry.py:420 +#: src/iac_code/providers/registry.py:421 msgid "MiniMax (China)" msgstr "MiniMax (China)" -#: src/iac_code/providers/registry.py:421 +#: src/iac_code/providers/registry.py:422 msgid "MiniMax (International)" msgstr "MiniMax (Internacional)" -#: src/iac_code/providers/registry.py:423 +#: src/iac_code/providers/registry.py:424 msgid "ZhiPu AI (International)" msgstr "ZhiPu AI (Internacional)" -#: src/iac_code/providers/registry.py:425 +#: src/iac_code/providers/registry.py:426 msgid "SiliconFlow (China)" msgstr "SiliconFlow (China)" -#: src/iac_code/providers/registry.py:426 +#: src/iac_code/providers/registry.py:427 msgid "SiliconFlow (International)" msgstr "SiliconFlow (Internacional)" -#: src/iac_code/providers/registry.py:427 +#: src/iac_code/providers/registry.py:428 msgid "Ollama (Local)" msgstr "Ollama (Local)" -#: src/iac_code/providers/registry.py:428 +#: src/iac_code/providers/registry.py:429 msgid "LM Studio (Local)" msgstr "LM Studio (Local)" -#: src/iac_code/providers/registry.py:431 +#: src/iac_code/providers/registry.py:432 msgid "ModelScope" msgstr "ModelScope" -#: src/iac_code/providers/registry.py:432 +#: src/iac_code/providers/registry.py:433 msgid "Alibaba Cloud CodingPlan" msgstr "Alibaba Cloud CodingPlan" -#: src/iac_code/providers/registry.py:433 +#: src/iac_code/providers/registry.py:434 msgid "Alibaba Cloud CodingPlan (International)" msgstr "Alibaba Cloud CodingPlan (Internacional)" -#: src/iac_code/providers/registry.py:434 +#: src/iac_code/providers/registry.py:435 msgid "ZhiPu AI CodingPlan" msgstr "ZhiPu AI CodingPlan" -#: src/iac_code/providers/registry.py:435 +#: src/iac_code/providers/registry.py:436 msgid "ZhiPu AI CodingPlan (International)" msgstr "ZhiPu AI CodingPlan (Internacional)" -#: src/iac_code/providers/registry.py:436 +#: src/iac_code/providers/registry.py:437 msgid "Volcengine CodingPlan" msgstr "Volcengine CodingPlan" -#: src/iac_code/providers/registry.py:437 +#: src/iac_code/providers/registry.py:438 msgid "Anthropic Compatible" msgstr "Compatible con Anthropic" @@ -1703,23 +1703,27 @@ msgstr "No, siempre denegar \"{rule}\" (esta sesión)" msgid "No, always reject this tool" msgstr "No, rechazar siempre esta herramienta" -#: src/iac_code/ui/repl.py:342 +#: src/iac_code/ui/repl.py:353 msgid "Press Ctrl+C again to exit." msgstr "Pulse Ctrl+C de nuevo para salir." -#: src/iac_code/ui/repl.py:358 +#: src/iac_code/ui/repl.py:373 msgid "Interrupted." msgstr "Interrumpido." -#: src/iac_code/ui/repl.py:395 +#: src/iac_code/ui/repl.py:410 msgid "Goodbye!" msgstr "¡Hasta luego!" -#: src/iac_code/ui/repl.py:396 +#: src/iac_code/ui/repl.py:411 msgid "Resume this session with:" msgstr "Para reanudar esta sesión, ejecute:" -#: src/iac_code/ui/repl.py:478 +#: src/iac_code/ui/repl.py:444 +msgid "No image in clipboard." +msgstr "No hay ninguna imagen en el portapapeles." + +#: src/iac_code/ui/repl.py:584 #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." msgstr "" @@ -1727,22 +1731,22 @@ msgstr "" "command: /{name}. Type /help for available commands.Comando desconocido: " "/{name}. Escriba /help para ver los comandos disponibles." -#: src/iac_code/ui/repl.py:502 src/iac_code/ui/repl.py:534 +#: src/iac_code/ui/repl.py:608 src/iac_code/ui/repl.py:640 #, python-brace-format msgid "Command error: {error}" msgstr "Error de comando: {error}" -#: src/iac_code/ui/repl.py:509 +#: src/iac_code/ui/repl.py:615 #, python-brace-format msgid "Command has no handler: {name}" msgstr "El comando no tiene controlador: {name}" -#: src/iac_code/ui/repl.py:712 +#: src/iac_code/ui/repl.py:835 #, python-brace-format msgid "Session not found: {session_id}" msgstr "Sesión no encontrada: {session_id}" -#: src/iac_code/ui/repl.py:731 +#: src/iac_code/ui/repl.py:854 #, python-brace-format msgid "" "This session belongs to a different directory.\n" @@ -1753,18 +1757,40 @@ msgstr "" "Para reanudar, ejecute:\n" " {cmd}" -#: src/iac_code/ui/repl.py:770 +#: src/iac_code/ui/repl.py:893 msgid "This conversation is from a different directory." msgstr "Esta conversación procede de otro directorio." -#: src/iac_code/ui/repl.py:772 +#: src/iac_code/ui/repl.py:895 msgid "To resume, run:" msgstr "Para reanudar, ejecute:" -#: src/iac_code/ui/repl.py:777 +#: src/iac_code/ui/repl.py:900 msgid "(Command copied to clipboard)" msgstr "(Comando copiado al portapapeles)" +#: src/iac_code/ui/repl.py:1057 +#, python-brace-format +msgid "" +"Current model {model} does not support image input. Use /model to switch " +"to a vision-capable model." +msgstr "" +"El modelo actual {model} no admite entrada de imágenes. Usa /model para " +"cambiar a un modelo con capacidad de visión." + +#: src/iac_code/ui/repl.py:1066 +#, python-brace-format +msgid "Image error: {err}" +msgstr "Error de imagen: {err}" + +#: src/iac_code/ui/repl.py:1083 +msgid "" +"Failed to persist image to cache; it will only exist in memory for this " +"turn." +msgstr "" +"No se pudo persistir la imagen en la caché; solo existirá en memoria " +"durante este turno." + #: src/iac_code/ui/spinner.py:53 msgid "Thinking" msgstr "Razonando" @@ -1801,11 +1827,15 @@ msgstr "Mostrando transcripción · ctrl+o para alternar" msgid "No matches found" msgstr "No se encontraron coincidencias" -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:331 +msgid "Image in clipboard · ctrl+v to paste" +msgstr "Imagen en el portapapeles · ctrl+v para pegar" + +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Fill" msgstr "Rellenar" -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Dismiss" msgstr "Descartar" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 498382b3..b021d3ee 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: iac-code 0.1.2\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-18 16:59+0800\n" +"POT-Creation-Date: 2026-05-18 18:09+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: fr\n" @@ -93,7 +93,7 @@ msgstr "Journalisation debug désactivée." msgid "Usage: /debug [on|off]" msgstr "Utilisation : /debug [on|off]" -#: src/iac_code/agent/agent_loop.py:389 src/iac_code/agent/agent_loop.py:404 +#: src/iac_code/agent/agent_loop.py:399 src/iac_code/agent/agent_loop.py:414 msgid "Permission denied." msgstr "Permission refusée." @@ -541,14 +541,14 @@ msgid "[conversation id or search term]" msgstr "[identifiant de conversation ou terme de recherche]" #: src/iac_code/commands/auth.py:246 src/iac_code/commands/auth.py:836 -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Navigate" msgstr "Naviguer" #: src/iac_code/commands/auth.py:246 src/iac_code/commands/auth.py:368 #: src/iac_code/commands/auth.py:402 src/iac_code/commands/auth.py:409 #: src/iac_code/commands/auth.py:433 src/iac_code/commands/auth.py:836 -#: src/iac_code/commands/auth.py:1026 src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/commands/auth.py:1026 src/iac_code/ui/core/prompt_input.py:505 msgid "Confirm" msgstr "Confirmer" @@ -658,7 +658,7 @@ msgstr "Configuré" msgid "Alibaba Cloud" msgstr "Alibaba Cloud" -#: src/iac_code/commands/auth.py:751 src/iac_code/providers/registry.py:422 +#: src/iac_code/commands/auth.py:751 src/iac_code/providers/registry.py:423 msgid "ZhiPu AI" msgstr "ZhiPu AI" @@ -670,7 +670,7 @@ msgstr "Kimi" msgid "MiniMax" msgstr "MiniMax" -#: src/iac_code/commands/auth.py:754 src/iac_code/providers/registry.py:424 +#: src/iac_code/commands/auth.py:754 src/iac_code/providers/registry.py:425 msgid "Volcengine" msgstr "Volcengine" @@ -678,27 +678,27 @@ msgstr "Volcengine" msgid "SiliconFlow" msgstr "SiliconFlow" -#: src/iac_code/commands/auth.py:756 src/iac_code/providers/registry.py:415 +#: src/iac_code/commands/auth.py:756 src/iac_code/providers/registry.py:416 msgid "DeepSeek" msgstr "DeepSeek" -#: src/iac_code/commands/auth.py:757 src/iac_code/providers/registry.py:413 +#: src/iac_code/commands/auth.py:757 src/iac_code/providers/registry.py:414 msgid "OpenAI" msgstr "OpenAI" -#: src/iac_code/commands/auth.py:758 src/iac_code/providers/registry.py:414 +#: src/iac_code/commands/auth.py:758 src/iac_code/providers/registry.py:415 msgid "Anthropic" msgstr "Anthropic" -#: src/iac_code/commands/auth.py:759 src/iac_code/providers/registry.py:417 +#: src/iac_code/commands/auth.py:759 src/iac_code/providers/registry.py:418 msgid "Google Gemini" msgstr "Google Gemini" -#: src/iac_code/commands/auth.py:760 src/iac_code/providers/registry.py:430 +#: src/iac_code/commands/auth.py:760 src/iac_code/providers/registry.py:431 msgid "Azure OpenAI" msgstr "Azure OpenAI" -#: src/iac_code/commands/auth.py:761 src/iac_code/providers/registry.py:429 +#: src/iac_code/commands/auth.py:761 src/iac_code/providers/registry.py:430 msgid "OpenRouter" msgstr "OpenRouter" @@ -960,7 +960,7 @@ msgstr "" "Aucune clé API configurée pour le fournisseur '{provider}' (modèle : " "{model}). Exécutez /auth pour configurer." -#: src/iac_code/providers/openai_provider.py:289 +#: src/iac_code/providers/openai_provider.py:307 #, python-brace-format msgid "" "API returned no data. Please check that your API Base URL is correct " @@ -971,7 +971,7 @@ msgstr "" "correcte (actuelle : {base_url}). De nombreux points de terminaison " "compatibles OpenAI exigent le suffixe /v1 (p. ex. {base_url}/v1)." -#: src/iac_code/providers/openai_provider.py:330 +#: src/iac_code/providers/openai_provider.py:348 #, python-brace-format msgid "" "API returned an invalid response. Please check that your API Base URL is " @@ -982,79 +982,79 @@ msgstr "" " correcte (actuelle : {base_url}). De nombreux points de terminaison " "compatibles OpenAI exigent le suffixe /v1 (p. ex. {base_url}/v1)." -#: src/iac_code/providers/registry.py:411 +#: src/iac_code/providers/registry.py:412 msgid "Alibaba Cloud Bailian" msgstr "Alibaba Cloud Bailian" -#: src/iac_code/providers/registry.py:412 +#: src/iac_code/providers/registry.py:413 msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud Bailian Token Plan" -#: src/iac_code/providers/registry.py:416 +#: src/iac_code/providers/registry.py:417 msgid "OpenAPI Compatible" msgstr "Compatible OpenAPI" -#: src/iac_code/providers/registry.py:418 +#: src/iac_code/providers/registry.py:419 msgid "Kimi (China)" msgstr "Kimi (Chine)" -#: src/iac_code/providers/registry.py:419 +#: src/iac_code/providers/registry.py:420 msgid "Kimi (International)" msgstr "Kimi (International)" -#: src/iac_code/providers/registry.py:420 +#: src/iac_code/providers/registry.py:421 msgid "MiniMax (China)" msgstr "MiniMax (Chine)" -#: src/iac_code/providers/registry.py:421 +#: src/iac_code/providers/registry.py:422 msgid "MiniMax (International)" msgstr "MiniMax (International)" -#: src/iac_code/providers/registry.py:423 +#: src/iac_code/providers/registry.py:424 msgid "ZhiPu AI (International)" msgstr "ZhiPu AI (International)" -#: src/iac_code/providers/registry.py:425 +#: src/iac_code/providers/registry.py:426 msgid "SiliconFlow (China)" msgstr "SiliconFlow (Chine)" -#: src/iac_code/providers/registry.py:426 +#: src/iac_code/providers/registry.py:427 msgid "SiliconFlow (International)" msgstr "SiliconFlow (International)" -#: src/iac_code/providers/registry.py:427 +#: src/iac_code/providers/registry.py:428 msgid "Ollama (Local)" msgstr "Ollama (Local)" -#: src/iac_code/providers/registry.py:428 +#: src/iac_code/providers/registry.py:429 msgid "LM Studio (Local)" msgstr "LM Studio (Local)" -#: src/iac_code/providers/registry.py:431 +#: src/iac_code/providers/registry.py:432 msgid "ModelScope" msgstr "ModelScope" -#: src/iac_code/providers/registry.py:432 +#: src/iac_code/providers/registry.py:433 msgid "Alibaba Cloud CodingPlan" msgstr "Alibaba Cloud CodingPlan" -#: src/iac_code/providers/registry.py:433 +#: src/iac_code/providers/registry.py:434 msgid "Alibaba Cloud CodingPlan (International)" msgstr "Alibaba Cloud CodingPlan (International)" -#: src/iac_code/providers/registry.py:434 +#: src/iac_code/providers/registry.py:435 msgid "ZhiPu AI CodingPlan" msgstr "ZhiPu AI CodingPlan" -#: src/iac_code/providers/registry.py:435 +#: src/iac_code/providers/registry.py:436 msgid "ZhiPu AI CodingPlan (International)" msgstr "ZhiPu AI CodingPlan (International)" -#: src/iac_code/providers/registry.py:436 +#: src/iac_code/providers/registry.py:437 msgid "Volcengine CodingPlan" msgstr "Volcengine CodingPlan" -#: src/iac_code/providers/registry.py:437 +#: src/iac_code/providers/registry.py:438 msgid "Anthropic Compatible" msgstr "Compatible Anthropic" @@ -1705,45 +1705,49 @@ msgstr "Non, toujours refuser \"{rule}\" (cette session)" msgid "No, always reject this tool" msgstr "Non, toujours refuser cet outil" -#: src/iac_code/ui/repl.py:342 +#: src/iac_code/ui/repl.py:353 msgid "Press Ctrl+C again to exit." msgstr "Appuyez de nouveau sur Ctrl+C pour quitter." -#: src/iac_code/ui/repl.py:358 +#: src/iac_code/ui/repl.py:373 msgid "Interrupted." msgstr "Interrompu." -#: src/iac_code/ui/repl.py:395 +#: src/iac_code/ui/repl.py:410 msgid "Goodbye!" msgstr "Au revoir !" -#: src/iac_code/ui/repl.py:396 +#: src/iac_code/ui/repl.py:411 msgid "Resume this session with:" msgstr "Pour reprendre cette session :" -#: src/iac_code/ui/repl.py:478 +#: src/iac_code/ui/repl.py:444 +msgid "No image in clipboard." +msgstr "Aucune image dans le presse-papiers." + +#: src/iac_code/ui/repl.py:584 #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." msgstr "" "Unknown command: /{name}. Type /help for available commands.Commande " "inconnue : /{name}. Saisissez /help pour la liste des commandes." -#: src/iac_code/ui/repl.py:502 src/iac_code/ui/repl.py:534 +#: src/iac_code/ui/repl.py:608 src/iac_code/ui/repl.py:640 #, python-brace-format msgid "Command error: {error}" msgstr "Erreur de commande : {error}" -#: src/iac_code/ui/repl.py:509 +#: src/iac_code/ui/repl.py:615 #, python-brace-format msgid "Command has no handler: {name}" msgstr "Aucun gestionnaire pour la commande : {name}" -#: src/iac_code/ui/repl.py:712 +#: src/iac_code/ui/repl.py:835 #, python-brace-format msgid "Session not found: {session_id}" msgstr "Session introuvable : {session_id}" -#: src/iac_code/ui/repl.py:731 +#: src/iac_code/ui/repl.py:854 #, python-brace-format msgid "" "This session belongs to a different directory.\n" @@ -1754,18 +1758,40 @@ msgstr "" "Pour la reprendre, exécutez :\n" " {cmd}" -#: src/iac_code/ui/repl.py:770 +#: src/iac_code/ui/repl.py:893 msgid "This conversation is from a different directory." msgstr "Cette conversation provient d’un autre répertoire." -#: src/iac_code/ui/repl.py:772 +#: src/iac_code/ui/repl.py:895 msgid "To resume, run:" msgstr "Pour reprendre, exécutez :" -#: src/iac_code/ui/repl.py:777 +#: src/iac_code/ui/repl.py:900 msgid "(Command copied to clipboard)" msgstr "(Commande copiée dans le presse-papiers)" +#: src/iac_code/ui/repl.py:1057 +#, python-brace-format +msgid "" +"Current model {model} does not support image input. Use /model to switch " +"to a vision-capable model." +msgstr "" +"Le modèle actuel {model} ne prend pas en charge l’entrée d’image. " +"Utilisez /model pour passer à un modèle compatible vision." + +#: src/iac_code/ui/repl.py:1066 +#, python-brace-format +msgid "Image error: {err}" +msgstr "Erreur d’image : {err}" + +#: src/iac_code/ui/repl.py:1083 +msgid "" +"Failed to persist image to cache; it will only exist in memory for this " +"turn." +msgstr "" +"Impossible de persister l’image dans le cache ; elle n’existera qu’en " +"mémoire pour ce tour." + #: src/iac_code/ui/spinner.py:53 msgid "Thinking" msgstr "Réflexion" @@ -1802,11 +1828,15 @@ msgstr "Affichage de la transcription · ctrl+o pour basculer" msgid "No matches found" msgstr "Aucune correspondance" -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:331 +msgid "Image in clipboard · ctrl+v to paste" +msgstr "Image dans le presse-papiers · ctrl+v pour coller" + +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Fill" msgstr "Remplir" -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Dismiss" msgstr "Fermer" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index cb830a18..974c9fa1 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: iac-code 0.1.2\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-18 16:59+0800\n" +"POT-Creation-Date: 2026-05-18 18:09+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: ja\n" @@ -89,7 +89,7 @@ msgstr "デバッグログを無効にしました。" msgid "Usage: /debug [on|off]" msgstr "使用方法:/debug [on|off]" -#: src/iac_code/agent/agent_loop.py:389 src/iac_code/agent/agent_loop.py:404 +#: src/iac_code/agent/agent_loop.py:399 src/iac_code/agent/agent_loop.py:414 msgid "Permission denied." msgstr "権限が拒否されました。" @@ -524,14 +524,14 @@ msgid "[conversation id or search term]" msgstr "[会話 ID または検索語]" #: src/iac_code/commands/auth.py:246 src/iac_code/commands/auth.py:836 -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Navigate" msgstr "移動" #: src/iac_code/commands/auth.py:246 src/iac_code/commands/auth.py:368 #: src/iac_code/commands/auth.py:402 src/iac_code/commands/auth.py:409 #: src/iac_code/commands/auth.py:433 src/iac_code/commands/auth.py:836 -#: src/iac_code/commands/auth.py:1026 src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/commands/auth.py:1026 src/iac_code/ui/core/prompt_input.py:505 msgid "Confirm" msgstr "確認" @@ -641,7 +641,7 @@ msgstr "設定済み" msgid "Alibaba Cloud" msgstr "Alibaba Cloud" -#: src/iac_code/commands/auth.py:751 src/iac_code/providers/registry.py:422 +#: src/iac_code/commands/auth.py:751 src/iac_code/providers/registry.py:423 msgid "ZhiPu AI" msgstr "ZhiPu AI" @@ -653,7 +653,7 @@ msgstr "Kimi" msgid "MiniMax" msgstr "MiniMax" -#: src/iac_code/commands/auth.py:754 src/iac_code/providers/registry.py:424 +#: src/iac_code/commands/auth.py:754 src/iac_code/providers/registry.py:425 msgid "Volcengine" msgstr "Volcengine" @@ -661,27 +661,27 @@ msgstr "Volcengine" msgid "SiliconFlow" msgstr "SiliconFlow" -#: src/iac_code/commands/auth.py:756 src/iac_code/providers/registry.py:415 +#: src/iac_code/commands/auth.py:756 src/iac_code/providers/registry.py:416 msgid "DeepSeek" msgstr "DeepSeek" -#: src/iac_code/commands/auth.py:757 src/iac_code/providers/registry.py:413 +#: src/iac_code/commands/auth.py:757 src/iac_code/providers/registry.py:414 msgid "OpenAI" msgstr "OpenAI" -#: src/iac_code/commands/auth.py:758 src/iac_code/providers/registry.py:414 +#: src/iac_code/commands/auth.py:758 src/iac_code/providers/registry.py:415 msgid "Anthropic" msgstr "Anthropic" -#: src/iac_code/commands/auth.py:759 src/iac_code/providers/registry.py:417 +#: src/iac_code/commands/auth.py:759 src/iac_code/providers/registry.py:418 msgid "Google Gemini" msgstr "Google Gemini" -#: src/iac_code/commands/auth.py:760 src/iac_code/providers/registry.py:430 +#: src/iac_code/commands/auth.py:760 src/iac_code/providers/registry.py:431 msgid "Azure OpenAI" msgstr "Azure OpenAI" -#: src/iac_code/commands/auth.py:761 src/iac_code/providers/registry.py:429 +#: src/iac_code/commands/auth.py:761 src/iac_code/providers/registry.py:430 msgid "OpenRouter" msgstr "OpenRouter" @@ -933,7 +933,7 @@ msgid "" "/auth to configure." msgstr "プロバイダー '{provider}' の API キーが設定されていません(モデル: {model})。/auth を実行して設定してください。" -#: src/iac_code/providers/openai_provider.py:289 +#: src/iac_code/providers/openai_provider.py:307 #, python-brace-format msgid "" "API returned no data. Please check that your API Base URL is correct " @@ -943,7 +943,7 @@ msgstr "" "API からデータが返りませんでした。API Base URL が正しいか確認してください(現在:{base_url})。 多くの OpenAI " "互換エンドポイントでは /v1 接尾辞が必要です(例:{base_url}/v1)。" -#: src/iac_code/providers/openai_provider.py:330 +#: src/iac_code/providers/openai_provider.py:348 #, python-brace-format msgid "" "API returned an invalid response. Please check that your API Base URL is " @@ -953,79 +953,79 @@ msgstr "" "API から無効な応答が返りました。API Base URL が正しいか確認してください(現在:{base_url})。 多くの OpenAI " "互換エンドポイントでは /v1 接尾辞が必要です(例:{base_url}/v1)。" -#: src/iac_code/providers/registry.py:411 +#: src/iac_code/providers/registry.py:412 msgid "Alibaba Cloud Bailian" msgstr "Alibaba Cloud 百錬" -#: src/iac_code/providers/registry.py:412 +#: src/iac_code/providers/registry.py:413 msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud 百錬 Token Plan" -#: src/iac_code/providers/registry.py:416 +#: src/iac_code/providers/registry.py:417 msgid "OpenAPI Compatible" msgstr "OpenAPI 互換" -#: src/iac_code/providers/registry.py:418 +#: src/iac_code/providers/registry.py:419 msgid "Kimi (China)" msgstr "Kimi(中国版)" -#: src/iac_code/providers/registry.py:419 +#: src/iac_code/providers/registry.py:420 msgid "Kimi (International)" msgstr "Kimi(国際版)" -#: src/iac_code/providers/registry.py:420 +#: src/iac_code/providers/registry.py:421 msgid "MiniMax (China)" msgstr "MiniMax(中国版)" -#: src/iac_code/providers/registry.py:421 +#: src/iac_code/providers/registry.py:422 msgid "MiniMax (International)" msgstr "MiniMax(国際版)" -#: src/iac_code/providers/registry.py:423 +#: src/iac_code/providers/registry.py:424 msgid "ZhiPu AI (International)" msgstr "ZhiPu AI(国際版)" -#: src/iac_code/providers/registry.py:425 +#: src/iac_code/providers/registry.py:426 msgid "SiliconFlow (China)" msgstr "SiliconFlow(中国版)" -#: src/iac_code/providers/registry.py:426 +#: src/iac_code/providers/registry.py:427 msgid "SiliconFlow (International)" msgstr "SiliconFlow(国際版)" -#: src/iac_code/providers/registry.py:427 +#: src/iac_code/providers/registry.py:428 msgid "Ollama (Local)" msgstr "Ollama(ローカル)" -#: src/iac_code/providers/registry.py:428 +#: src/iac_code/providers/registry.py:429 msgid "LM Studio (Local)" msgstr "LM Studio(ローカル)" -#: src/iac_code/providers/registry.py:431 +#: src/iac_code/providers/registry.py:432 msgid "ModelScope" msgstr "ModelScope" -#: src/iac_code/providers/registry.py:432 +#: src/iac_code/providers/registry.py:433 msgid "Alibaba Cloud CodingPlan" msgstr "Alibaba Cloud CodingPlan" -#: src/iac_code/providers/registry.py:433 +#: src/iac_code/providers/registry.py:434 msgid "Alibaba Cloud CodingPlan (International)" msgstr "Alibaba Cloud CodingPlan(国際版)" -#: src/iac_code/providers/registry.py:434 +#: src/iac_code/providers/registry.py:435 msgid "ZhiPu AI CodingPlan" msgstr "ZhiPu AI CodingPlan" -#: src/iac_code/providers/registry.py:435 +#: src/iac_code/providers/registry.py:436 msgid "ZhiPu AI CodingPlan (International)" msgstr "ZhiPu AI CodingPlan(国際版)" -#: src/iac_code/providers/registry.py:436 +#: src/iac_code/providers/registry.py:437 msgid "Volcengine CodingPlan" msgstr "Volcengine CodingPlan" -#: src/iac_code/providers/registry.py:437 +#: src/iac_code/providers/registry.py:438 msgid "Anthropic Compatible" msgstr "Anthropic 互換" @@ -1668,45 +1668,49 @@ msgstr "いいえ、常に \"{rule}\" を拒否(このセッション)" msgid "No, always reject this tool" msgstr "いいえ、このツールは常に拒否" -#: src/iac_code/ui/repl.py:342 +#: src/iac_code/ui/repl.py:353 msgid "Press Ctrl+C again to exit." msgstr "終了するには Ctrl+C をもう一度押してください。" -#: src/iac_code/ui/repl.py:358 +#: src/iac_code/ui/repl.py:373 msgid "Interrupted." msgstr "中断しました。" -#: src/iac_code/ui/repl.py:395 +#: src/iac_code/ui/repl.py:410 msgid "Goodbye!" msgstr "さようなら。" -#: src/iac_code/ui/repl.py:396 +#: src/iac_code/ui/repl.py:411 msgid "Resume this session with:" msgstr "このセッションを再開するには次を実行してください:" -#: src/iac_code/ui/repl.py:478 +#: src/iac_code/ui/repl.py:444 +msgid "No image in clipboard." +msgstr "クリップボードに画像がありません。" + +#: src/iac_code/ui/repl.py:584 #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." msgstr "" "Unknown command: /{name}. Type /help for available " "commands.不明なコマンドです:/{name}。利用可能なコマンドは /help を入力してください。" -#: src/iac_code/ui/repl.py:502 src/iac_code/ui/repl.py:534 +#: src/iac_code/ui/repl.py:608 src/iac_code/ui/repl.py:640 #, python-brace-format msgid "Command error: {error}" msgstr "コマンドエラー:{error}" -#: src/iac_code/ui/repl.py:509 +#: src/iac_code/ui/repl.py:615 #, python-brace-format msgid "Command has no handler: {name}" msgstr "ハンドラーがないコマンドです:{name}" -#: src/iac_code/ui/repl.py:712 +#: src/iac_code/ui/repl.py:835 #, python-brace-format msgid "Session not found: {session_id}" msgstr "セッションが見つかりません:{session_id}" -#: src/iac_code/ui/repl.py:731 +#: src/iac_code/ui/repl.py:854 #, python-brace-format msgid "" "This session belongs to a different directory.\n" @@ -1717,18 +1721,36 @@ msgstr "" "再開するには次を実行してください:\n" " {cmd}" -#: src/iac_code/ui/repl.py:770 +#: src/iac_code/ui/repl.py:893 msgid "This conversation is from a different directory." msgstr "この会話は別のディレクトリ由来です。" -#: src/iac_code/ui/repl.py:772 +#: src/iac_code/ui/repl.py:895 msgid "To resume, run:" msgstr "再開するには次を実行してください:" -#: src/iac_code/ui/repl.py:777 +#: src/iac_code/ui/repl.py:900 msgid "(Command copied to clipboard)" msgstr "(コマンドをクリップボードにコピーしました)" +#: src/iac_code/ui/repl.py:1057 +#, python-brace-format +msgid "" +"Current model {model} does not support image input. Use /model to switch " +"to a vision-capable model." +msgstr "現在のモデル {model} は画像入力をサポートしていません。/model を使用してビジョン対応モデルに切り替えてください。" + +#: src/iac_code/ui/repl.py:1066 +#, python-brace-format +msgid "Image error: {err}" +msgstr "画像エラー:{err}" + +#: src/iac_code/ui/repl.py:1083 +msgid "" +"Failed to persist image to cache; it will only exist in memory for this " +"turn." +msgstr "画像をキャッシュに保存できませんでした。このターンの間、メモリ上にのみ存在します。" + #: src/iac_code/ui/spinner.py:53 msgid "Thinking" msgstr "考えています" @@ -1765,11 +1787,15 @@ msgstr "トランスクリプトを表示中 · ctrl+o で切り替え" msgid "No matches found" msgstr "一致する項目がありません" -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:331 +msgid "Image in clipboard · ctrl+v to paste" +msgstr "クリップボードに画像 · ctrl+v で貼り付け" + +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Fill" msgstr "入力" -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Dismiss" msgstr "閉じる" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 68ff2f66..881ca83d 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: iac-code 0.1.2\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-18 16:59+0800\n" +"POT-Creation-Date: 2026-05-18 18:09+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: pt\n" @@ -93,7 +93,7 @@ msgstr "Debug desativado." msgid "Usage: /debug [on|off]" msgstr "Uso: /debug [on|off]" -#: src/iac_code/agent/agent_loop.py:389 src/iac_code/agent/agent_loop.py:404 +#: src/iac_code/agent/agent_loop.py:399 src/iac_code/agent/agent_loop.py:414 msgid "Permission denied." msgstr "Permissão negada." @@ -538,14 +538,14 @@ msgid "[conversation id or search term]" msgstr "[ID da conversa ou termo de busca]" #: src/iac_code/commands/auth.py:246 src/iac_code/commands/auth.py:836 -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Navigate" msgstr "Navegar" #: src/iac_code/commands/auth.py:246 src/iac_code/commands/auth.py:368 #: src/iac_code/commands/auth.py:402 src/iac_code/commands/auth.py:409 #: src/iac_code/commands/auth.py:433 src/iac_code/commands/auth.py:836 -#: src/iac_code/commands/auth.py:1026 src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/commands/auth.py:1026 src/iac_code/ui/core/prompt_input.py:505 msgid "Confirm" msgstr "Confirmar" @@ -655,7 +655,7 @@ msgstr "Configurado" msgid "Alibaba Cloud" msgstr "Alibaba Cloud" -#: src/iac_code/commands/auth.py:751 src/iac_code/providers/registry.py:422 +#: src/iac_code/commands/auth.py:751 src/iac_code/providers/registry.py:423 msgid "ZhiPu AI" msgstr "ZhiPu AI" @@ -667,7 +667,7 @@ msgstr "Kimi" msgid "MiniMax" msgstr "MiniMax" -#: src/iac_code/commands/auth.py:754 src/iac_code/providers/registry.py:424 +#: src/iac_code/commands/auth.py:754 src/iac_code/providers/registry.py:425 msgid "Volcengine" msgstr "Volcengine" @@ -675,27 +675,27 @@ msgstr "Volcengine" msgid "SiliconFlow" msgstr "SiliconFlow" -#: src/iac_code/commands/auth.py:756 src/iac_code/providers/registry.py:415 +#: src/iac_code/commands/auth.py:756 src/iac_code/providers/registry.py:416 msgid "DeepSeek" msgstr "DeepSeek" -#: src/iac_code/commands/auth.py:757 src/iac_code/providers/registry.py:413 +#: src/iac_code/commands/auth.py:757 src/iac_code/providers/registry.py:414 msgid "OpenAI" msgstr "OpenAI" -#: src/iac_code/commands/auth.py:758 src/iac_code/providers/registry.py:414 +#: src/iac_code/commands/auth.py:758 src/iac_code/providers/registry.py:415 msgid "Anthropic" msgstr "Anthropic" -#: src/iac_code/commands/auth.py:759 src/iac_code/providers/registry.py:417 +#: src/iac_code/commands/auth.py:759 src/iac_code/providers/registry.py:418 msgid "Google Gemini" msgstr "Google Gemini" -#: src/iac_code/commands/auth.py:760 src/iac_code/providers/registry.py:430 +#: src/iac_code/commands/auth.py:760 src/iac_code/providers/registry.py:431 msgid "Azure OpenAI" msgstr "Azure OpenAI" -#: src/iac_code/commands/auth.py:761 src/iac_code/providers/registry.py:429 +#: src/iac_code/commands/auth.py:761 src/iac_code/providers/registry.py:430 msgid "OpenRouter" msgstr "OpenRouter" @@ -951,7 +951,7 @@ msgstr "" "Nenhuma chave API configurada para o provedor '{provider}' (modelo: " "{model}). Execute /auth para configurar." -#: src/iac_code/providers/openai_provider.py:289 +#: src/iac_code/providers/openai_provider.py:307 #, python-brace-format msgid "" "API returned no data. Please check that your API Base URL is correct " @@ -962,7 +962,7 @@ msgstr "" "(atual: {base_url}). Muitos endpoints compatíveis com OpenAI exigem o " "sufixo /v1 (por exemplo, {base_url}/v1)." -#: src/iac_code/providers/openai_provider.py:330 +#: src/iac_code/providers/openai_provider.py:348 #, python-brace-format msgid "" "API returned an invalid response. Please check that your API Base URL is " @@ -973,79 +973,79 @@ msgstr "" "correta (atual: {base_url}). Muitos endpoints compatíveis com OpenAI " "exigem o sufixo /v1 (por exemplo, {base_url}/v1)." -#: src/iac_code/providers/registry.py:411 +#: src/iac_code/providers/registry.py:412 msgid "Alibaba Cloud Bailian" msgstr "Alibaba Cloud Bailian" -#: src/iac_code/providers/registry.py:412 +#: src/iac_code/providers/registry.py:413 msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud Bailian Token Plan" -#: src/iac_code/providers/registry.py:416 +#: src/iac_code/providers/registry.py:417 msgid "OpenAPI Compatible" msgstr "Compatível com OpenAPI" -#: src/iac_code/providers/registry.py:418 +#: src/iac_code/providers/registry.py:419 msgid "Kimi (China)" msgstr "Kimi (China)" -#: src/iac_code/providers/registry.py:419 +#: src/iac_code/providers/registry.py:420 msgid "Kimi (International)" msgstr "Kimi (Internacional)" -#: src/iac_code/providers/registry.py:420 +#: src/iac_code/providers/registry.py:421 msgid "MiniMax (China)" msgstr "MiniMax (China)" -#: src/iac_code/providers/registry.py:421 +#: src/iac_code/providers/registry.py:422 msgid "MiniMax (International)" msgstr "MiniMax (Internacional)" -#: src/iac_code/providers/registry.py:423 +#: src/iac_code/providers/registry.py:424 msgid "ZhiPu AI (International)" msgstr "ZhiPu AI (Internacional)" -#: src/iac_code/providers/registry.py:425 +#: src/iac_code/providers/registry.py:426 msgid "SiliconFlow (China)" msgstr "SiliconFlow (China)" -#: src/iac_code/providers/registry.py:426 +#: src/iac_code/providers/registry.py:427 msgid "SiliconFlow (International)" msgstr "SiliconFlow (Internacional)" -#: src/iac_code/providers/registry.py:427 +#: src/iac_code/providers/registry.py:428 msgid "Ollama (Local)" msgstr "Ollama (Local)" -#: src/iac_code/providers/registry.py:428 +#: src/iac_code/providers/registry.py:429 msgid "LM Studio (Local)" msgstr "LM Studio (Local)" -#: src/iac_code/providers/registry.py:431 +#: src/iac_code/providers/registry.py:432 msgid "ModelScope" msgstr "ModelScope" -#: src/iac_code/providers/registry.py:432 +#: src/iac_code/providers/registry.py:433 msgid "Alibaba Cloud CodingPlan" msgstr "Alibaba Cloud CodingPlan" -#: src/iac_code/providers/registry.py:433 +#: src/iac_code/providers/registry.py:434 msgid "Alibaba Cloud CodingPlan (International)" msgstr "Alibaba Cloud CodingPlan (Internacional)" -#: src/iac_code/providers/registry.py:434 +#: src/iac_code/providers/registry.py:435 msgid "ZhiPu AI CodingPlan" msgstr "ZhiPu AI CodingPlan" -#: src/iac_code/providers/registry.py:435 +#: src/iac_code/providers/registry.py:436 msgid "ZhiPu AI CodingPlan (International)" msgstr "ZhiPu AI CodingPlan (Internacional)" -#: src/iac_code/providers/registry.py:436 +#: src/iac_code/providers/registry.py:437 msgid "Volcengine CodingPlan" msgstr "Volcengine CodingPlan" -#: src/iac_code/providers/registry.py:437 +#: src/iac_code/providers/registry.py:438 msgid "Anthropic Compatible" msgstr "Compatível com Anthropic" @@ -1692,43 +1692,47 @@ msgstr "Não, sempre negar \"{rule}\" (esta sessão)" msgid "No, always reject this tool" msgstr "Não, sempre rejeitar esta ferramenta" -#: src/iac_code/ui/repl.py:342 +#: src/iac_code/ui/repl.py:353 msgid "Press Ctrl+C again to exit." msgstr "Pressione Ctrl+C novamente para sair." -#: src/iac_code/ui/repl.py:358 +#: src/iac_code/ui/repl.py:373 msgid "Interrupted." msgstr "Interrompido." -#: src/iac_code/ui/repl.py:395 +#: src/iac_code/ui/repl.py:410 msgid "Goodbye!" msgstr "Até logo!" -#: src/iac_code/ui/repl.py:396 +#: src/iac_code/ui/repl.py:411 msgid "Resume this session with:" msgstr "Para retomar esta sessão, execute:" -#: src/iac_code/ui/repl.py:478 +#: src/iac_code/ui/repl.py:444 +msgid "No image in clipboard." +msgstr "Nenhuma imagem na área de transferência." + +#: src/iac_code/ui/repl.py:584 #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." msgstr "Comando desconhecido: /{name}. Digite /help para ver os comandos." -#: src/iac_code/ui/repl.py:502 src/iac_code/ui/repl.py:534 +#: src/iac_code/ui/repl.py:608 src/iac_code/ui/repl.py:640 #, python-brace-format msgid "Command error: {error}" msgstr "Erro de comando: {error}" -#: src/iac_code/ui/repl.py:509 +#: src/iac_code/ui/repl.py:615 #, python-brace-format msgid "Command has no handler: {name}" msgstr "Comando sem tratador: {name}" -#: src/iac_code/ui/repl.py:712 +#: src/iac_code/ui/repl.py:835 #, python-brace-format msgid "Session not found: {session_id}" msgstr "Sessão não encontrada: {session_id}" -#: src/iac_code/ui/repl.py:731 +#: src/iac_code/ui/repl.py:854 #, python-brace-format msgid "" "This session belongs to a different directory.\n" @@ -1739,18 +1743,40 @@ msgstr "" "Para retomar, execute:\n" " {cmd}" -#: src/iac_code/ui/repl.py:770 +#: src/iac_code/ui/repl.py:893 msgid "This conversation is from a different directory." msgstr "Esta conversa é de outro diretório." -#: src/iac_code/ui/repl.py:772 +#: src/iac_code/ui/repl.py:895 msgid "To resume, run:" msgstr "Para retomar, execute:" -#: src/iac_code/ui/repl.py:777 +#: src/iac_code/ui/repl.py:900 msgid "(Command copied to clipboard)" msgstr "(Comando copiado para a área de transferência)" +#: src/iac_code/ui/repl.py:1057 +#, python-brace-format +msgid "" +"Current model {model} does not support image input. Use /model to switch " +"to a vision-capable model." +msgstr "" +"O modelo atual {model} não suporta entrada de imagem. Use /model para " +"alternar para um modelo com capacidade de visão." + +#: src/iac_code/ui/repl.py:1066 +#, python-brace-format +msgid "Image error: {err}" +msgstr "Erro de imagem: {err}" + +#: src/iac_code/ui/repl.py:1083 +msgid "" +"Failed to persist image to cache; it will only exist in memory for this " +"turn." +msgstr "" +"Falha ao persistir a imagem no cache; ela só existirá na memória durante " +"este turno." + #: src/iac_code/ui/spinner.py:53 msgid "Thinking" msgstr "Raciocinando" @@ -1787,11 +1813,15 @@ msgstr "Exibindo transcrição · ctrl+o para alternar" msgid "No matches found" msgstr "Nenhuma correspondência" -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:331 +msgid "Image in clipboard · ctrl+v to paste" +msgstr "Imagem na área de transferência · ctrl+v para colar" + +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Fill" msgstr "Preencher" -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Dismiss" msgstr "Dispensar" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 1652a992..7a918612 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: iac-code 0.1.2\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-18 16:59+0800\n" +"POT-Creation-Date: 2026-05-18 18:09+0800\n" "PO-Revision-Date: 2026-04-02 00:00+0000\n" "Last-Translator: \n" "Language: zh\n" @@ -87,7 +87,7 @@ msgstr "调试日志已关闭。" msgid "Usage: /debug [on|off]" msgstr "用法:/debug [on|off]" -#: src/iac_code/agent/agent_loop.py:389 src/iac_code/agent/agent_loop.py:404 +#: src/iac_code/agent/agent_loop.py:399 src/iac_code/agent/agent_loop.py:414 msgid "Permission denied." msgstr "权限被拒绝。" @@ -520,14 +520,14 @@ msgid "[conversation id or search term]" msgstr "[会话 ID 或搜索词]" #: src/iac_code/commands/auth.py:246 src/iac_code/commands/auth.py:836 -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Navigate" msgstr "导航" #: src/iac_code/commands/auth.py:246 src/iac_code/commands/auth.py:368 #: src/iac_code/commands/auth.py:402 src/iac_code/commands/auth.py:409 #: src/iac_code/commands/auth.py:433 src/iac_code/commands/auth.py:836 -#: src/iac_code/commands/auth.py:1026 src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/commands/auth.py:1026 src/iac_code/ui/core/prompt_input.py:505 msgid "Confirm" msgstr "确认" @@ -635,7 +635,7 @@ msgstr "已配置" msgid "Alibaba Cloud" msgstr "阿里云" -#: src/iac_code/commands/auth.py:751 src/iac_code/providers/registry.py:422 +#: src/iac_code/commands/auth.py:751 src/iac_code/providers/registry.py:423 msgid "ZhiPu AI" msgstr "智谱 AI" @@ -647,7 +647,7 @@ msgstr "Kimi" msgid "MiniMax" msgstr "MiniMax" -#: src/iac_code/commands/auth.py:754 src/iac_code/providers/registry.py:424 +#: src/iac_code/commands/auth.py:754 src/iac_code/providers/registry.py:425 msgid "Volcengine" msgstr "火山引擎" @@ -655,27 +655,27 @@ msgstr "火山引擎" msgid "SiliconFlow" msgstr "硅基流动" -#: src/iac_code/commands/auth.py:756 src/iac_code/providers/registry.py:415 +#: src/iac_code/commands/auth.py:756 src/iac_code/providers/registry.py:416 msgid "DeepSeek" msgstr "DeepSeek" -#: src/iac_code/commands/auth.py:757 src/iac_code/providers/registry.py:413 +#: src/iac_code/commands/auth.py:757 src/iac_code/providers/registry.py:414 msgid "OpenAI" msgstr "OpenAI" -#: src/iac_code/commands/auth.py:758 src/iac_code/providers/registry.py:414 +#: src/iac_code/commands/auth.py:758 src/iac_code/providers/registry.py:415 msgid "Anthropic" msgstr "Anthropic" -#: src/iac_code/commands/auth.py:759 src/iac_code/providers/registry.py:417 +#: src/iac_code/commands/auth.py:759 src/iac_code/providers/registry.py:418 msgid "Google Gemini" msgstr "Google Gemini" -#: src/iac_code/commands/auth.py:760 src/iac_code/providers/registry.py:430 +#: src/iac_code/commands/auth.py:760 src/iac_code/providers/registry.py:431 msgid "Azure OpenAI" msgstr "Azure OpenAI" -#: src/iac_code/commands/auth.py:761 src/iac_code/providers/registry.py:429 +#: src/iac_code/commands/auth.py:761 src/iac_code/providers/registry.py:430 msgid "OpenRouter" msgstr "OpenRouter" @@ -923,7 +923,7 @@ msgid "" "/auth to configure." msgstr "提供商 '{provider}' 未配置 API 密钥(模型: {model})。请运行 /auth 进行配置。" -#: src/iac_code/providers/openai_provider.py:289 +#: src/iac_code/providers/openai_provider.py:307 #, python-brace-format msgid "" "API returned no data. Please check that your API Base URL is correct " @@ -933,7 +933,7 @@ msgstr "" "API 未返回数据。请检查您的 API Base URL 是否正确(当前:{base_url})。许多 OpenAI 兼容端点需要 /v1 " "后缀(如 {base_url}/v1)。" -#: src/iac_code/providers/openai_provider.py:330 +#: src/iac_code/providers/openai_provider.py:348 #, python-brace-format msgid "" "API returned an invalid response. Please check that your API Base URL is " @@ -943,79 +943,79 @@ msgstr "" "API 返回了无效响应。请检查您的 API Base URL 是否正确(当前:{base_url})。许多 OpenAI 兼容端点需要 /v1 " "后缀(如 {base_url}/v1)。" -#: src/iac_code/providers/registry.py:411 +#: src/iac_code/providers/registry.py:412 msgid "Alibaba Cloud Bailian" msgstr "阿里云百炼" -#: src/iac_code/providers/registry.py:412 +#: src/iac_code/providers/registry.py:413 msgid "Alibaba Cloud Bailian Token Plan" msgstr "阿里云百炼 Token Plan" -#: src/iac_code/providers/registry.py:416 +#: src/iac_code/providers/registry.py:417 msgid "OpenAPI Compatible" msgstr "OpenAPI 兼容" -#: src/iac_code/providers/registry.py:418 +#: src/iac_code/providers/registry.py:419 msgid "Kimi (China)" msgstr "Kimi(中国版)" -#: src/iac_code/providers/registry.py:419 +#: src/iac_code/providers/registry.py:420 msgid "Kimi (International)" msgstr "Kimi(国际版)" -#: src/iac_code/providers/registry.py:420 +#: src/iac_code/providers/registry.py:421 msgid "MiniMax (China)" msgstr "MiniMax(中国版)" -#: src/iac_code/providers/registry.py:421 +#: src/iac_code/providers/registry.py:422 msgid "MiniMax (International)" msgstr "MiniMax(国际版)" -#: src/iac_code/providers/registry.py:423 +#: src/iac_code/providers/registry.py:424 msgid "ZhiPu AI (International)" msgstr "智谱 AI(国际版)" -#: src/iac_code/providers/registry.py:425 +#: src/iac_code/providers/registry.py:426 msgid "SiliconFlow (China)" msgstr "硅基流动(中国版)" -#: src/iac_code/providers/registry.py:426 +#: src/iac_code/providers/registry.py:427 msgid "SiliconFlow (International)" msgstr "硅基流动(国际版)" -#: src/iac_code/providers/registry.py:427 +#: src/iac_code/providers/registry.py:428 msgid "Ollama (Local)" msgstr "Ollama(本地)" -#: src/iac_code/providers/registry.py:428 +#: src/iac_code/providers/registry.py:429 msgid "LM Studio (Local)" msgstr "LM Studio(本地)" -#: src/iac_code/providers/registry.py:431 +#: src/iac_code/providers/registry.py:432 msgid "ModelScope" msgstr "魔搭" -#: src/iac_code/providers/registry.py:432 +#: src/iac_code/providers/registry.py:433 msgid "Alibaba Cloud CodingPlan" msgstr "阿里云编程计划" -#: src/iac_code/providers/registry.py:433 +#: src/iac_code/providers/registry.py:434 msgid "Alibaba Cloud CodingPlan (International)" msgstr "阿里云编程计划(国际版)" -#: src/iac_code/providers/registry.py:434 +#: src/iac_code/providers/registry.py:435 msgid "ZhiPu AI CodingPlan" msgstr "智谱 AI 编程计划" -#: src/iac_code/providers/registry.py:435 +#: src/iac_code/providers/registry.py:436 msgid "ZhiPu AI CodingPlan (International)" msgstr "智谱 AI 编程计划(国际版)" -#: src/iac_code/providers/registry.py:436 +#: src/iac_code/providers/registry.py:437 msgid "Volcengine CodingPlan" msgstr "火山引擎编程计划" -#: src/iac_code/providers/registry.py:437 +#: src/iac_code/providers/registry.py:438 msgid "Anthropic Compatible" msgstr "Anthropic 兼容" @@ -1655,43 +1655,47 @@ msgstr "否,始终拒绝 \"{rule}\"(本次会话)" msgid "No, always reject this tool" msgstr "否,始终拒绝此工具" -#: src/iac_code/ui/repl.py:342 +#: src/iac_code/ui/repl.py:353 msgid "Press Ctrl+C again to exit." msgstr "再次按 Ctrl+C 退出。" -#: src/iac_code/ui/repl.py:358 +#: src/iac_code/ui/repl.py:373 msgid "Interrupted." msgstr "已中断。" -#: src/iac_code/ui/repl.py:395 +#: src/iac_code/ui/repl.py:410 msgid "Goodbye!" msgstr "再见!" -#: src/iac_code/ui/repl.py:396 +#: src/iac_code/ui/repl.py:411 msgid "Resume this session with:" msgstr "恢复此会话请运行:" -#: src/iac_code/ui/repl.py:478 +#: src/iac_code/ui/repl.py:444 +msgid "No image in clipboard." +msgstr "剪贴板中没有图像。" + +#: src/iac_code/ui/repl.py:584 #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." msgstr "未知命令:/{name}。输入 /help 查看可用命令。" -#: src/iac_code/ui/repl.py:502 src/iac_code/ui/repl.py:534 +#: src/iac_code/ui/repl.py:608 src/iac_code/ui/repl.py:640 #, python-brace-format msgid "Command error: {error}" msgstr "命令错误:{error}" -#: src/iac_code/ui/repl.py:509 +#: src/iac_code/ui/repl.py:615 #, python-brace-format msgid "Command has no handler: {name}" msgstr "命令没有处理器:{name}" -#: src/iac_code/ui/repl.py:712 +#: src/iac_code/ui/repl.py:835 #, python-brace-format msgid "Session not found: {session_id}" msgstr "会话不存在:{session_id}" -#: src/iac_code/ui/repl.py:731 +#: src/iac_code/ui/repl.py:854 #, python-brace-format msgid "" "This session belongs to a different directory.\n" @@ -1702,18 +1706,36 @@ msgstr "" "请运行以下命令恢复:\n" " {cmd}" -#: src/iac_code/ui/repl.py:770 +#: src/iac_code/ui/repl.py:893 msgid "This conversation is from a different directory." msgstr "该会话来自另一个目录。" -#: src/iac_code/ui/repl.py:772 +#: src/iac_code/ui/repl.py:895 msgid "To resume, run:" msgstr "请运行以下命令恢复:" -#: src/iac_code/ui/repl.py:777 +#: src/iac_code/ui/repl.py:900 msgid "(Command copied to clipboard)" msgstr "(命令已复制到剪贴板)" +#: src/iac_code/ui/repl.py:1057 +#, python-brace-format +msgid "" +"Current model {model} does not support image input. Use /model to switch " +"to a vision-capable model." +msgstr "当前模型 {model} 不支持图像输入。请使用 /model 切换到支持视觉的模型。" + +#: src/iac_code/ui/repl.py:1066 +#, python-brace-format +msgid "Image error: {err}" +msgstr "图像错误:{err}" + +#: src/iac_code/ui/repl.py:1083 +msgid "" +"Failed to persist image to cache; it will only exist in memory for this " +"turn." +msgstr "无法将图像持久化到缓存;本轮对话期间它仅存在于内存中。" + #: src/iac_code/ui/spinner.py:53 msgid "Thinking" msgstr "思考中" @@ -1750,11 +1772,15 @@ msgstr "显示完整记录 · 按 ctrl+o 切换" msgid "No matches found" msgstr "未找到匹配项" -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:331 +msgid "Image in clipboard · ctrl+v to paste" +msgstr "剪贴板中有图像 · 按 ctrl+v 粘贴" + +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Fill" msgstr "填充" -#: src/iac_code/ui/core/prompt_input.py:373 +#: src/iac_code/ui/core/prompt_input.py:505 msgid "Dismiss" msgstr "关闭" diff --git a/src/iac_code/providers/anthropic_provider.py b/src/iac_code/providers/anthropic_provider.py index e1292929..8b5664ad 100644 --- a/src/iac_code/providers/anthropic_provider.py +++ b/src/iac_code/providers/anthropic_provider.py @@ -270,6 +270,15 @@ def _convert_content_block(block: ContentBlock) -> dict[str, Any]: return d elif block.type == "thinking": return {"type": "thinking", "thinking": block.text or ""} + elif block.type == "image": + return { + "type": "image", + "source": { + "type": "base64", + "media_type": block.media_type or "image/png", + "data": block.data or "", + }, + } else: return {"type": block.type} diff --git a/src/iac_code/providers/base.py b/src/iac_code/providers/base.py index 745d05d8..4863c1d4 100644 --- a/src/iac_code/providers/base.py +++ b/src/iac_code/providers/base.py @@ -23,13 +23,15 @@ class ToolDefinition: class ContentBlock: """A block of content within a message.""" - type: str # "text", "tool_use", "tool_result", "thinking" + type: str # "text", "tool_use", "tool_result", "thinking", "image" text: str | None = None tool_use_id: str | None = None name: str | None = None input: dict[str, Any] | None = None content: str | None = None is_error: bool = False + media_type: str | None = None + data: str | None = None @dataclass diff --git a/src/iac_code/providers/openai_provider.py b/src/iac_code/providers/openai_provider.py index e11ca3bb..1e2e109a 100644 --- a/src/iac_code/providers/openai_provider.py +++ b/src/iac_code/providers/openai_provider.py @@ -133,6 +133,24 @@ def _convert_content_blocks(self, role: str, blocks: list[ContentBlock]) -> list ] messages.append(msg) + # User message with text and/or image blocks. tool_result blocks are + # handled by the role="tool" branch below; if the user message contains + # only tool_result blocks, user_parts stays empty and nothing is emitted. + if role == "user": + user_parts: list[dict[str, Any]] = [] + for b in blocks: + if b.type == "text": + user_parts.append({"type": "text", "text": b.text or ""}) + elif b.type == "image": + user_parts.append( + { + "type": "image_url", + "image_url": {"url": f"data:{b.media_type or 'image/png'};base64,{b.data or ''}"}, + } + ) + if user_parts: + messages.append({"role": "user", "content": user_parts}) + # Tool result messages (role="tool") for b in tool_results: messages.append( diff --git a/src/iac_code/providers/registry.py b/src/iac_code/providers/registry.py index c72fa0af..b2edb2ce 100644 --- a/src/iac_code/providers/registry.py +++ b/src/iac_code/providers/registry.py @@ -9,6 +9,7 @@ class ModelEntry: id: str is_default: bool = False + support_multimodal: bool = False @dataclass(frozen=True) @@ -44,14 +45,14 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.dashscope_provider.DashScopeProvider", base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", models=[ - ModelEntry("qwen3.6-plus", is_default=True), + ModelEntry("qwen3.6-plus", is_default=True, support_multimodal=True), ModelEntry("qwen3.6-max-preview"), ModelEntry("qwen3-max"), - ModelEntry("qwen3.5-plus"), + ModelEntry("qwen3.5-plus", support_multimodal=True), ModelEntry("qwen3.5-flash"), ModelEntry("qwq-plus"), ModelEntry("qwen3-coder-plus"), - ModelEntry("kimi-k2.6"), + ModelEntry("kimi-k2.6", support_multimodal=True), ModelEntry("deepseek-v4-pro"), ModelEntry("deepseek-v4-flash"), ModelEntry("glm-5.1"), @@ -65,7 +66,7 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.dashscope_provider.DashScopeProvider", base_url="https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", models=[ - ModelEntry("qwen3.6-plus", is_default=True), + ModelEntry("qwen3.6-plus", is_default=True, support_multimodal=True), ModelEntry("qwen3.6-flash"), ModelEntry("deepseek-v4-pro"), ModelEntry("deepseek-v4-flash"), @@ -73,8 +74,8 @@ def model_ids(self) -> list[str]: ModelEntry("glm-5.1"), ModelEntry("glm-5"), ModelEntry("MiniMax-M2.5"), - ModelEntry("kimi-k2.6"), - ModelEntry("kimi-k2.5"), + ModelEntry("kimi-k2.5", support_multimodal=True), + ModelEntry("kimi-k2.6", support_multimodal=True) ], qwenpaw_provider_ids=["aliyun-tokenplan"], ), @@ -85,13 +86,13 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.openai_provider.OpenAIProvider", base_url=None, models=[ - ModelEntry("gpt-5.5", is_default=True), - ModelEntry("gpt-5.4"), - ModelEntry("gpt-5.4-mini"), - ModelEntry("gpt-5.3-codex"), - ModelEntry("gpt-5.2"), - ModelEntry("o3"), - ModelEntry("o4-mini"), + ModelEntry("gpt-5.5", is_default=True, support_multimodal=True), + ModelEntry("gpt-5.4", support_multimodal=True), + ModelEntry("gpt-5.4-mini", support_multimodal=True), + ModelEntry("gpt-5.3-codex", support_multimodal=True), + ModelEntry("gpt-5.2", support_multimodal=True), + ModelEntry("o3", support_multimodal=True), + ModelEntry("o4-mini", support_multimodal=True), ], qwenpaw_provider_ids=["openai"], ), @@ -102,11 +103,11 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.anthropic_provider.AnthropicProvider", base_url=None, models=[ - ModelEntry("claude-opus-4-7", is_default=True), - ModelEntry("claude-opus-4-6"), - ModelEntry("claude-sonnet-4-6"), - ModelEntry("claude-sonnet-4-6-1m"), - ModelEntry("claude-haiku-4-5-20251001"), + ModelEntry("claude-opus-4-7", is_default=True, support_multimodal=True), + ModelEntry("claude-opus-4-6", support_multimodal=True), + ModelEntry("claude-sonnet-4-6", support_multimodal=True), + ModelEntry("claude-sonnet-4-6-1m", support_multimodal=True), + ModelEntry("claude-haiku-4-5-20251001", support_multimodal=True), ], qwenpaw_provider_ids=["anthropic"], qwenpaw_chat_model="AnthropicChatModel", @@ -147,13 +148,13 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.gemini_provider.GeminiProvider", base_url="https://generativelanguage.googleapis.com/v1beta/openai", models=[ - ModelEntry("gemini-3.1-pro-preview", is_default=True), - ModelEntry("gemini-3-flash-preview"), - ModelEntry("gemini-3.1-flash-lite-preview"), - ModelEntry("gemini-2.5-pro"), - ModelEntry("gemini-2.5-flash"), - ModelEntry("gemini-2.5-flash-lite"), - ModelEntry("gemini-2.0-flash"), + ModelEntry("gemini-3.1-pro-preview", is_default=True, support_multimodal=True), + ModelEntry("gemini-3-flash-preview", support_multimodal=True), + ModelEntry("gemini-3.1-flash-lite-preview", support_multimodal=True), + ModelEntry("gemini-2.5-pro", support_multimodal=True), + ModelEntry("gemini-2.5-flash", support_multimodal=True), + ModelEntry("gemini-2.5-flash-lite", support_multimodal=True), + ModelEntry("gemini-2.0-flash", support_multimodal=True), ], qwenpaw_provider_ids=["gemini"], qwenpaw_chat_model="GeminiChatModel", @@ -165,8 +166,8 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.kimi_provider.KimiProvider", base_url="https://api.moonshot.cn/v1", models=[ - ModelEntry("kimi-k2.6", is_default=True), - ModelEntry("kimi-k2.5"), + ModelEntry("kimi-k2.6", is_default=True, support_multimodal=True), + ModelEntry("kimi-k2.5", support_multimodal=True), ], qwenpaw_provider_ids=["kimi-cn"], ), @@ -177,8 +178,8 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.kimi_provider.KimiProvider", base_url="https://api.moonshot.ai/v1", models=[ - ModelEntry("kimi-k2.6", is_default=True), - ModelEntry("kimi-k2.5"), + ModelEntry("kimi-k2.6", is_default=True, support_multimodal=True), + ModelEntry("kimi-k2.5", support_multimodal=True), ], qwenpaw_provider_ids=["kimi-intl"], ), @@ -246,7 +247,7 @@ def model_ids(self) -> list[str]: base_url="https://ark.cn-beijing.volces.com/api/v3", models=[ ModelEntry("doubao-seed-2-0-code-preview-260215", is_default=True), - ModelEntry("doubao-seed-2-0-pro-260215"), + ModelEntry("doubao-seed-2-0-pro-260215", support_multimodal=True), ModelEntry("doubao-seed-2-0-lite-260428"), ], qwenpaw_provider_ids=["volcengine-cn"], @@ -307,10 +308,10 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.azure_openai_provider.AzureOpenAIProvider", base_url=None, models=[ - ModelEntry("gpt-5", is_default=True), - ModelEntry("gpt-5-mini"), - ModelEntry("gpt-4.1"), - ModelEntry("gpt-4o"), + ModelEntry("gpt-5", is_default=True, support_multimodal=True), + ModelEntry("gpt-5-mini", support_multimodal=True), + ModelEntry("gpt-4.1", support_multimodal=True), + ModelEntry("gpt-4o", support_multimodal=True), ], qwenpaw_provider_ids=["azure-openai"], ), @@ -332,12 +333,12 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.dashscope_provider.DashScopeProvider", base_url="https://coding.dashscope.aliyuncs.com/v1", models=[ - ModelEntry("qwen3.6-plus", is_default=True), - ModelEntry("qwen3.5-plus"), + ModelEntry("qwen3.6-plus", is_default=True, support_multimodal=True), + ModelEntry("qwen3.5-plus", support_multimodal=True), ModelEntry("glm-5"), ModelEntry("glm-4.7"), ModelEntry("MiniMax-M2.5"), - ModelEntry("kimi-k2.5"), + ModelEntry("kimi-k2.5", support_multimodal=True), ModelEntry("qwen3-coder-plus"), ModelEntry("qwen3-coder-next"), ], @@ -350,12 +351,12 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.dashscope_provider.DashScopeProvider", base_url="https://coding-intl.dashscope.aliyuncs.com/v1", models=[ - ModelEntry("qwen3.6-plus", is_default=True), - ModelEntry("qwen3.5-plus"), + ModelEntry("qwen3.6-plus", is_default=True, support_multimodal=True), + ModelEntry("qwen3.5-plus", support_multimodal=True), ModelEntry("glm-5"), ModelEntry("glm-4.7"), ModelEntry("MiniMax-M2.5"), - ModelEntry("kimi-k2.5"), + ModelEntry("kimi-k2.5", support_multimodal=True), ModelEntry("qwen3-coder-plus"), ModelEntry("qwen3-coder-next"), ], @@ -393,12 +394,12 @@ def model_ids(self) -> list[str]: base_url="https://ark.cn-beijing.volces.com/api/coding/v3", models=[ ModelEntry("doubao-seed-2-0-code-preview-260215", is_default=True), - ModelEntry("doubao-seed-2-0-pro-260215"), + ModelEntry("doubao-seed-2-0-pro-260215", support_multimodal=True), ModelEntry("doubao-seed-2-0-lite-260428"), ModelEntry("glm-5.1"), ModelEntry("minimax-m2.7"), - ModelEntry("kimi-k2.6"), - ModelEntry("kimi-k2.5"), + ModelEntry("kimi-k2.6", support_multimodal=True), + ModelEntry("kimi-k2.5", support_multimodal=True), ], qwenpaw_provider_ids=["volcengine-cn-codingplan"], ), diff --git a/src/iac_code/services/capabilities/__init__.py b/src/iac_code/services/capabilities/__init__.py new file mode 100644 index 00000000..926d4891 --- /dev/null +++ b/src/iac_code/services/capabilities/__init__.py @@ -0,0 +1,15 @@ +"""Capability registry (multimodal and future capability flags).""" + +from __future__ import annotations + +from iac_code.services.capabilities.multimodal import ( + MultiModalSpec, + get_multimodal_spec, + is_model_multimodal, +) + +__all__ = [ + "MultiModalSpec", + "get_multimodal_spec", + "is_model_multimodal", +] diff --git a/src/iac_code/services/capabilities/auto_detect.py b/src/iac_code/services/capabilities/auto_detect.py new file mode 100644 index 00000000..e6757d40 --- /dev/null +++ b/src/iac_code/services/capabilities/auto_detect.py @@ -0,0 +1,107 @@ +"""Best-effort multimodal capability probe for OpenAI-compatible endpoints. + +Deliberately conservative: many compatible services do not return +``architecture.input_modalities``. In that case the probe returns ``None`` +and the caller falls back to user override configuration. +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +import httpx +import yaml + +from iac_code.config import get_config_dir + + +def _cache_path() -> Path: + return get_config_dir() / ".multimodal-cache.yml" + + +class AutoDetectCache: + def __init__(self) -> None: + self._data: dict[str, dict[str, bool]] = {} + self._dirty = False + self._load() + + def _load(self) -> None: + path = _cache_path() + if not path.exists(): + return + try: + raw = yaml.safe_load(path.read_text()) or {} + except Exception: + return + if isinstance(raw, dict): + for base_url, models in raw.items(): + if isinstance(models, dict): + self._data[str(base_url)] = {str(k): bool(v) for k, v in models.items()} + + def get(self, base_url: str, model: str) -> bool | None: + return self._data.get(base_url, {}).get(model) + + def set(self, base_url: str, model: str, value: bool) -> None: + self._data.setdefault(base_url, {})[model] = value + self._dirty = True + + def flush(self) -> None: + if not self._dirty: + return + path = _cache_path() + path.parent.mkdir(parents=True, exist_ok=True) + # Atomic write: avoids interleaved writes from concurrent REPL sessions + # corrupting the cache file. tempfile in the same directory ensures + # os.replace can rename without crossing filesystems. + fd, tmp_name = tempfile.mkstemp( + prefix=".multimodal-cache.", + suffix=".tmp", + dir=str(path.parent), + ) + try: + with os.fdopen(fd, "w") as f: + yaml.dump(self._data, f, default_flow_style=False) + os.replace(tmp_name, path) + except Exception: + try: + os.unlink(tmp_name) + except OSError: + pass + raise + self._dirty = False + + +def probe_openapi_compatible( + *, + base_url: str, + api_key: str | None, + model: str, + client: httpx.Client | None = None, + timeout: float = 5.0, +) -> bool | None: + """Return True / False / None. None means "cannot determine".""" + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + url = base_url.rstrip("/") + "/models" + own_client = client is None + client = client or httpx.Client(timeout=timeout) + try: + resp = client.get(url, headers=headers) + if resp.status_code != 200: + return None + data = resp.json().get("data") or [] + for entry in data: + if entry.get("id") != model: + continue + arch = entry.get("architecture") or {} + modalities = arch.get("input_modalities") + if isinstance(modalities, list): + return "image" in modalities + return None + return None + except Exception: + return None + finally: + if own_client: + client.close() diff --git a/src/iac_code/services/capabilities/multimodal.py b/src/iac_code/services/capabilities/multimodal.py new file mode 100644 index 00000000..8a46e08b --- /dev/null +++ b/src/iac_code/services/capabilities/multimodal.py @@ -0,0 +1,115 @@ +"""Multimodal capability registry. + +Indexed by model name (not provider+model) — different providers that share +the same model name (e.g. ``kimi-k2.5``) reuse the same spec. + +Resolution order: settings.yml override > provider registry (``ModelEntry. +support_multimodal``) > OpenAI-compatible auto-detect (when applicable) > +default (no image support). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import yaml + +from iac_code.config import get_settings_path + +DEFAULT_FORMATS: tuple[str, ...] = ("image/png", "image/jpeg", "image/gif", "image/webp") + + +@dataclass(frozen=True) +class MultiModalSpec: + support_multimodal: bool = False + formats: tuple[str, ...] = DEFAULT_FORMATS + max_images_per_message: int = 20 + + +_NO_IMAGES = MultiModalSpec(support_multimodal=False) +_DEFAULT_VL = MultiModalSpec(support_multimodal=True) + + +def _builtin_multimodal_models() -> set[str]: + """Collect every model id flagged as multimodal in the provider registry. + + Imported lazily to avoid an import cycle with ``providers.registry`` at + module load time, and to keep the lookup live across hot-reloads. + """ + from iac_code.providers.registry import PROVIDER_REGISTRY + + out: set[str] = set() + for desc in PROVIDER_REGISTRY.values(): + for m in desc.models: + if m.support_multimodal: + out.add(m.id) + return out + + +def _load_settings_overrides() -> dict[str, MultiModalSpec]: + path = get_settings_path() + if not path.exists(): + return {} + try: + data = yaml.safe_load(path.read_text()) or {} + except Exception: + return {} + section = data.get("multiModal") if isinstance(data, dict) else None + if not isinstance(section, dict): + return {} + raw_models = section.get("models") + if not isinstance(raw_models, dict): + return {} + out: dict[str, MultiModalSpec] = {} + for name, value in raw_models.items(): + if not isinstance(value, dict): + continue + out[str(name)] = MultiModalSpec( + support_multimodal=bool(value.get("supportMultimodal", False)), + formats=tuple(value.get("formats", DEFAULT_FORMATS)), + max_images_per_message=int(value.get("maxImagesPerMessage", 20)), + ) + return out + + +def get_multimodal_spec(model: str) -> MultiModalSpec: + """Resolve the multimodal spec for a model. + + Order: 1) settings.yml override 2) provider registry flag 3) default (no images). + """ + overrides = _load_settings_overrides() + if model in overrides: + return overrides[model] + if model in _builtin_multimodal_models(): + return _DEFAULT_VL + return _NO_IMAGES + + +def is_model_multimodal( + model: str, + *, + provider_key: str | None = None, + base_url: str | None = None, + api_key: str | None = None, +) -> bool: + overrides = _load_settings_overrides() + if model in overrides: + return overrides[model].support_multimodal + if model in _builtin_multimodal_models(): + return True + if provider_key == "openapi_compatible" and base_url: + from iac_code.services.capabilities.auto_detect import ( + AutoDetectCache, + probe_openapi_compatible, + ) + + cache = AutoDetectCache() + cached = cache.get(base_url, model) + if cached is not None: + return cached + result = probe_openapi_compatible(base_url=base_url, api_key=api_key, model=model) + if result is not None: + cache.set(base_url, model, result) + cache.flush() + return result + return False diff --git a/src/iac_code/services/context_manager.py b/src/iac_code/services/context_manager.py index a4909471..0aac8dd2 100644 --- a/src/iac_code/services/context_manager.py +++ b/src/iac_code/services/context_manager.py @@ -90,7 +90,7 @@ def set_system_prompt(self, system_prompt: str) -> None: self._system_prompt = system_prompt self._system_prompt_tokens = self._token_counter.count_text(system_prompt) - def add_user_message(self, content: str) -> Message: + def add_user_message(self, content: str | list[ContentBlock]) -> Message: msg = self._conversation.add_user_message(content) msg.token_count = self._token_counter.count_message(msg.to_api_format()) return msg diff --git a/src/iac_code/ui/banner.py b/src/iac_code/ui/banner.py index f1808edd..a687f589 100644 --- a/src/iac_code/ui/banner.py +++ b/src/iac_code/ui/banner.py @@ -91,7 +91,7 @@ def render_welcome_banner(model: str, cwd: str, session_id: str | None = None) - Text(), Text(f" {model_display}", style="dim") if model_display else Text(), Text(f" {cwd_display}", style="dim"), - Text(" {}: {}".format(_("Session"), session_id), style="dim") if session_id else Text(), + Text(f" {_('Session')}: {session_id}", style="dim") if session_id else Text(), ] from iac_code.utils.log import is_debug_enabled diff --git a/src/iac_code/ui/core/prompt_input.py b/src/iac_code/ui/core/prompt_input.py index 60fdf566..f99e9bf5 100644 --- a/src/iac_code/ui/core/prompt_input.py +++ b/src/iac_code/ui/core/prompt_input.py @@ -2,11 +2,15 @@ from __future__ import annotations +import re import shutil import sys import unicodedata +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Optional +from loguru import logger + from iac_code.ui.core.key_event import KeyEvent @@ -28,6 +32,15 @@ def _display_width(s: str) -> int: from iac_code.ui.core.input_history import InputHistory from iac_code.ui.keybindings.manager import KeybindingManager from iac_code.ui.suggestions.aggregator import SuggestionAggregator + from iac_code.utils.image.pasted_content import PastedContent + from iac_code.utils.image.store import ImageStore + + +@dataclass +class PromptInputResult: + text: str + pasted_contents: dict[int, "PastedContent"] = field(default_factory=dict) + # ANSI escape helpers _COLOR_SELECTED = "\033[96m" # bright_cyan — matches logo accent color @@ -53,22 +66,35 @@ def __init__( suggestion_aggregator: "SuggestionAggregator | None" = None, history: "InputHistory | None" = None, console=None, + paste_handler: "Callable[[str], bool] | None" = None, + image_store: "ImageStore | None" = None, ) -> None: self._km = keybinding_manager self._aggregator = suggestion_aggregator self._history = history self._console = console + # Optional bracketed-paste hook: called with the pasted text. Returning + # True signals the handler attached the content out-of-band (e.g. as an + # image placeholder) and the buffer must NOT also receive the text. + # Returning False keeps default behaviour: insert the text verbatim. + self._paste_handler = paste_handler + self._image_store = image_store # Buffer and cursor self._buffer: list[str] = [] self._cursor: int = 0 + # Pasted contents (e.g. images) tracked alongside the text buffer + self._pasted_contents: dict[int, "PastedContent"] = {} + self._next_paste_id: int = 1 + # Control flags self._submitted: bool = False self._cancelled: bool = False self._esc_pressed: bool = False self._text_changed: bool = False # set when buffer content changes self._pending_action: "Callable[[], None] | None" = None + self._clipboard_has_image: bool = False # True when clipboard contains an image # Rendering state self._prompt: str = "" @@ -87,6 +113,32 @@ def _get_text(self) -> str: """Return current buffer contents as a string.""" return "".join(self._buffer) + def attach_image(self, pc: "PastedContent") -> None: + """Insert ``[Image #N]`` at the cursor and track the paste. + + Idempotent: re-attaching the same id is a no-op. + """ + if pc.id in self._pasted_contents: + return + self._pasted_contents[pc.id] = pc + placeholder = f"[Image #{pc.id}]" + self._insert(placeholder) + if pc.id >= self._next_paste_id: + self._next_paste_id = pc.id + 1 + + def next_paste_id(self) -> int: + """Return a fresh, monotonically increasing paste id.""" + pid = self._next_paste_id + self._next_paste_id += 1 + return pid + + def make_result(self) -> "PromptInputResult": + """Build a snapshot of the current text + tracked pasted contents.""" + return PromptInputResult( + text=self._get_text(), + pasted_contents=dict(self._pasted_contents), + ) + # ------------------------------------------------------------------ # Key handling # ------------------------------------------------------------------ @@ -96,9 +148,38 @@ def _handle_key(self, key_event: KeyEvent) -> None: key = key_event.key ctrl = key_event.ctrl - # 0. Bracket paste → insert all content (including newlines) into buffer + # Diagnostics for the image-paste path. Logged at INFO so the user + # can correlate keystrokes with downstream pipeline events. if key == "paste": - self._insert(key_event.char) + logger.info("prompt_input: bracketed paste event ({} chars)", len(key_event.char)) + elif ctrl and key == "v": + logger.info("prompt_input: Ctrl+V keystroke received") + + # 0. Bracket paste → optionally route through paste_handler first, then + # insert. The handler can attach images discovered in the system + # clipboard and signal "consumed" to suppress the text insert. + if key == "paste": + consumed = False + if self._paste_handler is not None: + try: + consumed = self._paste_handler(key_event.char) + except Exception: + # Handler errors must never deadlock the input loop — + # fall through to plain-text insert. + consumed = False + if not consumed: + self._insert(key_event.char) + return + + # 0b. Focus events — probe clipboard for image on focus-in + if key == "focus_in": + self._check_clipboard_for_image() + return + + if key == "focus_out": + if self._clipboard_has_image: + self._clipboard_has_image = False + self._render() return # 1. Esc+Enter → insert newline @@ -224,12 +305,31 @@ def _handle_key(self, key_event: KeyEvent) -> None: # 10. Printable character insertion char = key_event.char if char and char.isprintable(): + # Clear clipboard indicator on visible character input + if self._clipboard_has_image: + self._clipboard_has_image = False self._insert(char) # ------------------------------------------------------------------ # Private helpers # ------------------------------------------------------------------ + def _check_clipboard_for_image(self) -> None: + """Synchronously probe the system clipboard for an image on focus-in.""" + from iac_code.utils.image.clipboard import has_image_in_clipboard + + result = has_image_in_clipboard() + if result != self._clipboard_has_image: + self._clipboard_has_image = result + self._render() + + @staticmethod + def _clipboard_hint_text() -> str: + """Return the clipboard image hint text.""" + from iac_code.i18n import _ + + return _("Image in clipboard \u00b7 ctrl+v to paste") + def _insert(self, text: str) -> None: """Insert *text* at the current cursor position.""" for ch in text: @@ -267,6 +367,27 @@ def _update_suggestions_sync(self) -> None: # Inline rendering # ------------------------------------------------------------------ + _IMAGE_REF_RE = re.compile(r"\[Image #(\d+)\]") + + def _highlight_image_refs(self, line: str) -> str: + """Return the line with [Image #N] tracked refs wrapped in cyan and OSC 8 hyperlink.""" + + def repl(m: "re.Match[str]") -> str: + ref_id = int(m.group(1)) + if ref_id in self._pasted_contents: + # Try to build OSC 8 hyperlink if image_store available + if self._image_store is not None: + image_path = self._image_store.get_path(ref_id) + if image_path: + file_url = f"file://{image_path}" + # OSC 8 format: \033]8;;URL\033\\text\033]8;;\033\\ + return f"\033]8;;{file_url}\033\\{_COLOR_CYAN}{m.group(0)}{_COLOR_RESET}\033]8;;\033\\" + # Fallback: color only, no hyperlink + return f"{_COLOR_CYAN}{m.group(0)}{_COLOR_RESET}" + return m.group(0) + + return self._IMAGE_REF_RE.sub(repl, line) + def _render(self) -> None: """Re-render the input line, ghost text, and suggestion overlay.""" out = sys.stdout @@ -290,11 +411,22 @@ def _render(self) -> None: # Render prompt + first line out.write(f"{_COLOR_BOLD}{_COLOR_CYAN}{self._prompt}{_COLOR_RESET}") - out.write(lines[0]) + out.write(self._highlight_image_refs(lines[0])) + + # Right-aligned clipboard image indicator (first line only, single-line input) + if self._clipboard_has_image and not content_extra_lines: + hint_text = self._clipboard_hint_text() + hint_width = _display_width(hint_text) + first_line_width = _display_width(self._prompt) + _display_width(lines[0]) + gap = 2 + available = cols - first_line_width - gap + if available >= hint_width: + hint_col = cols - hint_width + out.write(f"\033[s\033[{hint_col + 1}G\033[2m{hint_text}\033[0m\033[u") # Render continuation lines for i in range(1, len(lines)): - out.write(f"\n\r{lines[i]}") + out.write(f"\n\r{self._highlight_image_refs(lines[i])}") # Ghost text (only for single-line input) ghost = "" @@ -432,11 +564,14 @@ def _input_loop(self, prompt: str) -> Optional[str]: # Reset state self._buffer = [] self._cursor = 0 + self._pasted_contents = {} + self._next_paste_id = 1 self._submitted = False self._cancelled = False self._esc_pressed = False self._text_changed = False self._pending_action = None + self._clipboard_has_image = False self._prompt = prompt self._prev_suggestion_lines = 0 self._prev_content_extra_lines = 0 @@ -491,13 +626,14 @@ def _input_loop(self, prompt: str) -> Optional[str]: first_content = f"{prompt}{lines[0]}" pad = max(0, term_width - _display_width(first_content)) sys.stdout.write( - f"\r{_bg}{_COLOR_BOLD}{_COLOR_CYAN}{prompt}{_COLOR_RESET}{_bg}{lines[0]}{' ' * pad}{_COLOR_RESET}" + f"\r{_bg}{_COLOR_BOLD}{_COLOR_CYAN}{prompt}{_COLOR_RESET}" + f"{_bg}{self._highlight_image_refs(lines[0])}{' ' * pad}{_COLOR_RESET}" ) # Render continuation lines for i in range(1, len(lines)): pad = max(0, term_width - _display_width(lines[i])) - sys.stdout.write(f"\n\r{_bg}{lines[i]}{' ' * pad}{_COLOR_RESET}") + sys.stdout.write(f"\n\r{_bg}{self._highlight_image_refs(lines[i])}{' ' * pad}{_COLOR_RESET}") sys.stdout.write("\n") sys.stdout.flush() diff --git a/src/iac_code/ui/core/raw_input.py b/src/iac_code/ui/core/raw_input.py index 886ceb21..d3a1c504 100644 --- a/src/iac_code/ui/core/raw_input.py +++ b/src/iac_code/ui/core/raw_input.py @@ -10,6 +10,8 @@ import tty from typing import Optional +from loguru import logger + from iac_code.ui.core.key_event import KeyEvent _CURSOR_REPORT_RE = re.compile(rb"\x1b\[(\d+);(\d+)R") @@ -73,6 +75,8 @@ def query_cursor_row(fd: int, timeout: float = 0.1) -> int | None: "[3~": "delete", "[5~": "pageup", "[6~": "pagedown", + "[I": "focus_in", + "[O": "focus_out", "OP": "f1", "OQ": "f2", "OR": "f3", @@ -98,6 +102,8 @@ def __enter__(self) -> "RawInputCapture": tty.setraw(self._fd) # Enable bracket paste mode so we can distinguish pasted text from typed input os.write(self._fd, b"\033[?2004h") + # Enable focus reporting so we can detect terminal focus changes + os.write(self._fd, b"\033[?1004h") except OSError: # File descriptor may be invalid after interruption (e.g. double Ctrl+C) self._old_settings = None @@ -106,6 +112,8 @@ def __enter__(self) -> "RawInputCapture": def __exit__(self, exc_type, exc_val, exc_tb) -> None: try: + # Disable focus reporting + os.write(self._fd, b"\033[?1004l") # Disable bracket paste mode os.write(self._fd, b"\033[?2004l") except OSError: @@ -154,7 +162,16 @@ def read_key(self, timeout: Optional[float] = None) -> Optional[KeyEvent]: # Bracket paste start: ESC [200~ — check raw bytes before decoding # to avoid splitting multi-byte UTF-8 characters if rest.startswith(b"[200~"): + logger.info( + "raw_input: PASTE_START detected; tail bytes after marker: {!r}", + rest[5:][:64], + ) pasted = self._read_bracketed_paste(rest[5:]) + logger.info( + "raw_input: bracketed paste complete — {} chars, repr={!r}", + len(pasted), + pasted[:80], + ) return KeyEvent(key="paste", char=pasted) seq = rest.decode("utf-8", errors="replace") diff --git a/src/iac_code/ui/repl.py b/src/iac_code/ui/repl.py index 56f58712..2463dc1c 100644 --- a/src/iac_code/ui/repl.py +++ b/src/iac_code/ui/repl.py @@ -14,6 +14,7 @@ import asyncio import os +import re import signal import sys import time @@ -40,7 +41,7 @@ from iac_code.tools.base import ToolRegistry from iac_code.ui.banner import render_welcome_banner from iac_code.ui.core.input_history import InputHistory -from iac_code.ui.core.prompt_input import PromptInput +from iac_code.ui.core.prompt_input import PromptInput, PromptInputResult from iac_code.ui.keybindings.manager import KeyBinding, KeybindingManager from iac_code.ui.renderer import Renderer from iac_code.ui.suggestions.aggregator import SuggestionAggregator @@ -49,6 +50,8 @@ from iac_code.ui.suggestions.file_provider import FileProvider from iac_code.ui.suggestions.shell_history_provider import ShellHistoryProvider from iac_code.utils.background_housekeeping import start_background_housekeeping +from iac_code.utils.image.clipboard import ClipboardImage, get_image_from_clipboard, try_read_image_from_path +from iac_code.utils.image.format_detect import IMAGE_EXTENSION_REGEX termios: ModuleType | None try: @@ -116,6 +119,9 @@ def __init__( self._session_storage = SessionStorage() self.session_index = SessionIndex() self._session_id = self._resolve_session_id(resume_session_id) + from iac_code.utils.image.store import ImageStore + + self._image_store = ImageStore(session_id=self._session_id) self._resume_messages = self._load_resume_messages(resume_session_id) self._task_manager = TaskManager() self._notification_queue = NotificationQueue() @@ -237,12 +243,17 @@ def __init__( ] ) - # PromptInput + # PromptInput. ``paste_handler`` covers the macOS Cmd+V case: macOS + # terminals never forward Cmd+V bytes to the app, but they DO send a + # bracketed-paste sequence. The handler probes the system clipboard + # for an image on every bracketed paste and attaches it inline. self._prompt_input = PromptInput( keybinding_manager=self._keybinding_manager, suggestion_aggregator=self._suggestion_aggregator, history=self._history, console=self.console, + paste_handler=self._on_bracketed_paste, + image_store=self._image_store, ) self.store.subscribe(self._on_state_change) @@ -266,7 +277,7 @@ async def run(self, initial_prompt: str | None = None) -> None: if self._resume_messages: self.renderer.replay_history(self._resume_messages) self.console.print() # blank line before first new user turn - start_background_housekeeping() + start_background_housekeeping(session_id=self._session_id) self._register_global_keybindings() # Clear IEXTEN for the whole session so macOS/BSD can't latch Ctrl+O @@ -351,7 +362,11 @@ def _on_sigint() -> None: await self._handle_command(user_input) self._clear_cancel_state() continue - await self._handle_chat(user_input) + # Capture structured result (text + pasted images) before next get_input resets state. + chat_input: PromptInputResult | str + result = self._prompt_input.make_result() + chat_input = result if result.pasted_contents else user_input + await self._handle_chat(chat_input) self._clear_cancel_state() except (KeyboardInterrupt, asyncio.CancelledError): self._clear_cancel_state() @@ -414,6 +429,97 @@ def _register_global_keybindings(self) -> None: km.register(KeyBinding("ctrl+p", "open_quick_open", "global", self._open_quick_open)) km.register(KeyBinding("ctrl+f", "open_global_search", "global", self._open_global_search)) km.register(KeyBinding("ctrl+o", "expand_last_turn", "global", self._expand_last_turn)) + km.register(KeyBinding("ctrl+v", "paste_image", "global", self._handle_ctrl_v_image)) + + def _handle_ctrl_v_image(self) -> bool: + """Wrapper around :func:`handle_image_paste` that surfaces the + no-image case to the user. Ctrl+V is an explicit "paste image" + intent — silent return is the bug we're fixing.""" + logger.info("repl: Ctrl+V pressed — invoking image paste pipeline") + if handle_image_paste(self): + logger.info("repl: Ctrl+V handled (image attached or warned)") + self._prompt_input._clipboard_has_image = False + return True + logger.info("repl: Ctrl+V — no image found in clipboard, surfacing system message") + msg = _("No image in clipboard.") + self._prompt_input.schedule_action(lambda: self.renderer.print_system_message(msg, style="dim")) + return True + + def _on_bracketed_paste(self, text: str) -> bool: + """Bracketed-paste hook. Probes the clipboard for an image on every + paste; if one is found, attaches it as ``[Image #N]``. Returns True + when the bracketed-paste text should NOT also be inserted into the + buffer (would be redundant — empty string, or just the image's file + path / file:// URL). Otherwise returns False so PromptInput inserts + the text normally — preserves accompanying captions like "what is + this screenshot?".""" + # Some terminals interleave focus events (CSI I / CSI O) around the + # paste boundary — Cmd+V briefly steals focus to the menu bar and back + # on macOS. The focus bytes can land *inside* our paste content and + # would otherwise be inserted into the buffer as garbage characters. + # Strip them early so the rest of this method sees the clean payload. + sanitized = _strip_orphan_focus_events(text) + if sanitized != text: + logger.info( + "repl: bracketed paste — stripped {} byte(s) of orphan focus events", + len(text) - len(sanitized), + ) + text = sanitized + + preview = text[:80].replace("\n", "\\n") + logger.info( + "repl: bracketed paste received — text_len={} preview={!r}", + len(text), + preview, + ) + # Prefer try_read_image_from_path: if the pasted text IS an image + # path, use the file metadata (source_path, filename) rather than + # whatever the clipboard happens to also carry. + img = try_read_image_from_path(text) if text else None + if img is not None: + logger.info("repl: bracketed paste — image resolved from text path: {}", img.source_path) + elif _is_existing_non_image_file(text): + # The pasted text is a path to an existing non-image file (e.g. a + # .txt copied from Finder). macOS places a TIFF icon/preview on the + # clipboard alongside the file path — skip clipboard image detection + # to avoid attaching the file icon as "[Image #N]". + logger.info( + "repl: bracketed paste — text is an existing non-image file path, skipping clipboard image detection" + ) + img = None + else: + img = get_image_from_clipboard() + if img is not None: + logger.info("repl: bracketed paste — image read from system clipboard ({} bytes)", len(img.data)) + if img is None: + # No image. If text is empty / pure noise (was just focus events), + # suppress the insert so the buffer stays clean. Otherwise return + # False so PromptInput inserts the text as normal. + if not text: + logger.info("repl: bracketed paste — empty payload, no image, nothing to do") + return True + logger.info("repl: bracketed paste — no image; falling through to plain text insert") + return False + + _attach_clipboard_image(self, img) + + stripped = text.strip() + if not stripped: + logger.info("repl: bracketed paste — text empty, suppressing insert") + return True + if "\n" in stripped: + logger.info("repl: bracketed paste — multi-line text, keeping caption alongside image") + return False + # Strip surrounding quotes (terminal drag-and-drop / shell-quoted paths) + unquoted = stripped.strip("'\"") + if unquoted.startswith("file://"): + logger.info("repl: bracketed paste — text is file:// URL, suppressing insert") + return True + if IMAGE_EXTENSION_REGEX.search(unquoted): + logger.info("repl: bracketed paste — text is an image path, suppressing insert") + return True + logger.info("repl: bracketed paste — image attached and text inserted as caption") + return False # ------------------------------------------------------------------ # Dialog launchers @@ -557,12 +663,29 @@ async def _handle_chat_continue(self) -> None: finally: self.store.set_state(is_busy=False) - async def _handle_chat(self, user_input: str) -> None: + async def _handle_chat(self, user_input: PromptInputResult | str) -> None: """Send the user message to the agent loop and stream output.""" + from iac_code.agent.message import ContentBlock, ImageBlock + from iac_code.utils.image.processor import process_user_input + + if isinstance(user_input, PromptInputResult): + blocks = process_user_input(user_input.text, pasted_contents=user_input.pasted_contents) + # Only switch to a structured payload if we actually have an image block; + # otherwise the plain string keeps telemetry / session storage simpler. + payload: str | list[ContentBlock] + if any(isinstance(b, ImageBlock) for b in blocks): + payload = blocks + else: + payload = user_input.text + record_text = user_input.text + else: + payload = user_input + record_text = user_input + self.store.set_state(is_busy=True) - self.renderer.record_user_turn(user_input) + self.renderer.record_user_turn(record_text) try: - events = self._agent_loop.run_streaming(user_input) + events = self._agent_loop.run_streaming(payload) elapsed = await self.renderer.run_streaming_output( events, permission_handler=self.renderer.prompt_permission, @@ -854,3 +977,132 @@ def _extract_last_user_text(messages: list) -> str: def _status_text(self) -> str: return self.store.get_state().model + + +# CSI I / CSI O are focus-in / focus-out events. Some terminals (notably on +# macOS) emit one or both around a paste because Cmd+V briefly steals focus +# to the menu bar. When this lands inside our bracketed-paste content it +# corrupts otherwise-empty payloads. Strip every occurrence regardless of +# position; mid-paste focus events should never reach the prompt buffer. +_FOCUS_EVENT_RE = re.compile(r"\x1b\[[IO]") + + +def _strip_orphan_focus_events(text: str) -> str: + """Remove CSI focus-in/focus-out sequences from a paste payload.""" + return _FOCUS_EVENT_RE.sub("", text) + + +def _is_existing_non_image_file(text: str) -> bool: + """Return True if *text* looks like a path to an existing non-image file. + + Handles shell-quoted paths and backslash-escaped spaces that macOS Finder + places on the clipboard when copying files. + """ + from pathlib import Path + + if not text or not text.strip(): + return False + candidate = text.strip().strip("'\"").replace("\\ ", " ") + # Must look like a filesystem path (absolute or relative that exists) + if not candidate: + return False + # Skip if it matches a known image extension — let try_read_image_from_path handle those + if IMAGE_EXTENSION_REGEX.search(candidate): + return False + p = Path(candidate) + try: + return p.exists() and p.is_file() + except (OSError, ValueError): + return False + + +def _attach_clipboard_image(repl: "InlineREPL", img: ClipboardImage) -> bool: + """Run multimodal-capability gate, resize, persist to cache, and attach + the image as an ``[Image #N]`` placeholder in the prompt buffer. + + Always returns True — either the image was attached, or a user-visible + warning was scheduled (capability mismatch, resize failure). Callers + should treat True as "event handled, do not fall through". + """ + from iac_code.services.capabilities.multimodal import is_model_multimodal + from iac_code.utils.image.pasted_content import PastedContent + from iac_code.utils.image.resizer import ( + ImageResizeError, + maybe_resize_and_downsample, + ) + + # Pass real provider context so the OpenAI-compatible auto-detect can fire + # for unknown models on a custom endpoint. + provider_key: str | None = None + base_url: str | None = None + api_key: str | None = None + cfg = getattr(repl, "_current_provider_config", None) + if isinstance(cfg, dict): + provider_key = cfg.get("keyName") + base_url = cfg.get("apiBase") + creds = getattr(repl, "_credentials", None) + if isinstance(creds, dict) and provider_key: + api_key = creds.get(provider_key) + + if not is_model_multimodal( + repl._current_model, + provider_key=provider_key, + base_url=base_url, + api_key=api_key, + ): + logger.warning( + "repl: model {} does not support multimodal input — refusing to attach image", repl._current_model + ) + msg = _( + "Current model {model} does not support image input. Use /model to switch to a vision-capable model." + ).format(model=repl._current_model) + repl._prompt_input.schedule_action(lambda: repl.renderer.print_system_message(msg, style="yellow")) + return True + + try: + resized = maybe_resize_and_downsample(img.data) + except ImageResizeError as exc: + logger.warning("repl: image resize/encode failed: {}", exc) + msg = _("Image error: {err}").format(err=exc) + repl._prompt_input.schedule_action(lambda: repl.renderer.print_system_message(msg, style="red")) + return True + + import base64 + + pid = repl._prompt_input.next_paste_id() + pc = PastedContent( + id=pid, + type="image", + content=base64.b64encode(resized.data).decode(), + media_type=resized.media_type, + source_path=img.source_path, + ) + stored_path = repl._image_store.store(pc) + if stored_path is None: + logger.warning("repl: image store persistence failed — keeping in-memory only") + msg = _("Failed to persist image to cache; it will only exist in memory for this turn.") + repl._prompt_input.schedule_action(lambda: repl.renderer.print_system_message(msg, style="yellow")) + logger.info( + "repl: image attached as [Image #{}] (media_type={}, {} bytes raw → {} bytes encoded)", + pid, + resized.media_type, + len(img.data), + len(resized.data), + ) + repl._prompt_input.attach_image(pc) + return True + + +def handle_image_paste(repl: "InlineREPL") -> bool: + """Handle Ctrl+V image paste. Returns True if the keybinding was consumed. + + Legacy entry point. The lazy import of ``get_image_from_clipboard`` is + preserved so existing tests that patch + ``iac_code.utils.image.clipboard.get_image_from_clipboard`` keep working. + """ + from iac_code.utils.image.clipboard import get_image_from_clipboard as _get + + img = _get() + if img is None: + return False # let bracket paste handle text + return _attach_clipboard_image(repl, img) diff --git a/src/iac_code/utils/background_housekeeping.py b/src/iac_code/utils/background_housekeeping.py index cb634977..1d1c4f9c 100644 --- a/src/iac_code/utils/background_housekeeping.py +++ b/src/iac_code/utils/background_housekeeping.py @@ -34,20 +34,46 @@ def _run_cleanup(base_dir: str, delay_seconds: float) -> None: logger.opt(exception=True).debug("Background cleanup failed") +def _run_image_cleanup(current_session_id: str, delay_seconds: float) -> None: + time.sleep(delay_seconds) + try: + from iac_code.utils.image.store import cleanup_old_image_caches + + cleanup_old_image_caches(current_session_id=current_session_id) + logger.debug("Background cleanup: purged old session image caches") + except Exception: + logger.opt(exception=True).debug("Background image cache cleanup failed") + + def start_background_housekeeping( base_dir: str | None = None, delay_seconds: float = DELAY_SECONDS, -) -> threading.Thread: - """Start a daemon thread that cleans up old tool result files after a delay. + session_id: str | None = None, +) -> tuple[threading.Thread, ...]: + """Start daemon thread(s) for delayed cleanup. - Returns the thread so callers can join() in tests. + Always returns a tuple of started threads so callers (and tests) can + iterate uniformly. Currently: + - tool-result cleanup thread (always) + - image-cache cleanup thread (only when ``session_id`` is provided) """ target_dir = base_dir or _get_default_base_dir() - thread = threading.Thread( + threads: list[threading.Thread] = [] + tool_thread = threading.Thread( target=_run_cleanup, args=(target_dir, delay_seconds), daemon=True, name="iac-code-housekeeping", ) - thread.start() - return thread + tool_thread.start() + threads.append(tool_thread) + if session_id is not None: + image_thread = threading.Thread( + target=_run_image_cleanup, + args=(session_id, delay_seconds), + daemon=True, + name="iac-code-image-housekeeping", + ) + image_thread.start() + threads.append(image_thread) + return tuple(threads) diff --git a/src/iac_code/utils/image/__init__.py b/src/iac_code/utils/image/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/iac_code/utils/image/clipboard.py b/src/iac_code/utils/image/clipboard.py new file mode 100644 index 00000000..f5447d2c --- /dev/null +++ b/src/iac_code/utils/image/clipboard.py @@ -0,0 +1,323 @@ +"""Cross-platform clipboard image reader. + +Mirrors the CC shell-fallback path — we deliberately avoid native bindings. +Returns None when no image is on the clipboard or the platform is unsupported. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from loguru import logger + +from iac_code.utils.image.format_detect import IMAGE_EXTENSION_REGEX, detect_image_format + +_SUBPROCESS_TIMEOUT = 2.0 +_SWIFT_SUBPROCESS_TIMEOUT = 10.0 # Swift first launch can be slow + +# AppleScript that walks PNG → TIFF → JPEG and returns the matched short name +# (or empty string). Single subprocess call covers all three formats so the +# common screenshot path stays as fast as before while Preview/Browser-style +# TIFF/JPEG sources also get picked up. +_DARWIN_PROBE_SCRIPT = ( + "try\n" + " set _ to (the clipboard as «class PNGf»)\n" + ' return "PNGf"\n' + "end try\n" + "try\n" + " set _ to (the clipboard as «class TIFF»)\n" + ' return "TIFF"\n' + "end try\n" + "try\n" + " set _ to (the clipboard as «class JPEG»)\n" + ' return "JPEG"\n' + "end try\n" + 'return ""\n' +) + + +def _darwin_save_script(tmp_path: str) -> str: + """Return an AppleScript that writes whichever of PNG/TIFF/JPEG is on the + clipboard to ``tmp_path``. stdout carries the matched format name (or + empty string).""" + # tempfile-generated paths on macOS are sanitized and don't contain quotes, + # but escape defensively in case a future caller passes a custom path. + safe = tmp_path.replace("\\", "\\\\").replace('"', '\\"') + return ( + "on writeData(theData, thePath)\n" + " set theFile to (open for access POSIX file thePath with write permission)\n" + " set eof of theFile to 0\n" + " write theData to theFile\n" + " close access theFile\n" + "end writeData\n" + "try\n" + " set img to (the clipboard as «class PNGf»)\n" + f' my writeData(img, "{safe}")\n' + ' return "PNGf"\n' + "end try\n" + "try\n" + " set img to (the clipboard as «class TIFF»)\n" + f' my writeData(img, "{safe}")\n' + ' return "TIFF"\n' + "end try\n" + "try\n" + " set img to (the clipboard as «class JPEG»)\n" + f' my writeData(img, "{safe}")\n' + ' return "JPEG"\n' + "end try\n" + 'return ""\n' + ) + + +@dataclass +class ClipboardImage: + data: bytes + media_type: str + source_path: str | None = None + filename: str | None = None + + +def _which(*names: str) -> str | None: + for name in names: + path = shutil.which(name) + if path: + return path + return None + + +def _run( + cmd: list[str], timeout: float = _SUBPROCESS_TIMEOUT, **kwargs: Any +) -> subprocess.CompletedProcess[bytes] | None: + """Run *cmd* with a timeout, returning None on TimeoutExpired or OSError.""" + try: + return subprocess.run(cmd, capture_output=True, timeout=timeout, **kwargs) + except (subprocess.TimeoutExpired, OSError): + return None + + +def _darwin_has_image_via_uti() -> bool: + """Fallback: use Swift/NSPasteboard to detect modern UTI image types. + + Chromium-based apps (Chrome, Figma, Electron) only write modern UTI types + (public.png, public.tiff) to the pasteboard, which the legacy AppleScript + four-char-code probe cannot see. This function shells out to Swift to check + NSPasteboard directly. + """ + swift_code = ( + "import Cocoa\n" + "let pb = NSPasteboard.general\n" + "let types = pb.types?.map { $0.rawValue } ?? []\n" + 'let imageUTIs = ["public.png", "public.tiff", "public.jpeg", "com.apple.pict"]\n' + "for uti in imageUTIs {\n" + " if types.contains(uti) {\n" + " print(uti)\n" + " exit(0)\n" + " }\n" + "}\n" + "exit(1)\n" + ) + r = _run(["swift", "-e", swift_code], timeout=_SWIFT_SUBPROCESS_TIMEOUT) + if r is None or r.returncode != 0: + if r is not None and r.stderr: + logger.debug("clipboard._darwin_has_image_via_uti: swift stderr={!r}", r.stderr[:300]) + return False + return True + + +def _darwin_read_image_via_uti(tmp_path: str) -> str | None: + """Fallback: read image data from clipboard using Swift/NSPasteboard. + + Writes the first matched image type (public.png > public.tiff > public.jpeg) + to *tmp_path* (passed as a command-line argument to avoid path-escaping + issues) and returns the UTI type string on success, None otherwise. + """ + swift_code = ( + "import Cocoa\n" + "let pb = NSPasteboard.general\n" + "let imageTypes: [(String, NSPasteboard.PasteboardType)] = [\n" + ' ("public.png", .png),\n' + ' ("public.tiff", .tiff),\n' + ' ("public.jpeg", NSPasteboard.PasteboardType("public.jpeg")),\n' + "]\n" + 'let outPath = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "/tmp/clipboard_img"\n' + "for (name, type) in imageTypes {\n" + " if let data = pb.data(forType: type) {\n" + " let url = URL(fileURLWithPath: outPath)\n" + " try! data.write(to: url)\n" + " print(name)\n" + " exit(0)\n" + " }\n" + "}\n" + "exit(1)\n" + ) + r = _run(["swift", "-e", swift_code, tmp_path], timeout=_SWIFT_SUBPROCESS_TIMEOUT) + if r is None or r.returncode != 0: + if r is not None and r.stderr: + logger.debug("clipboard._darwin_read_image_via_uti: swift stderr={!r}", r.stderr[:300]) + return None + return r.stdout.strip().decode("ascii", errors="replace") + + +def has_image_in_clipboard() -> bool: + if sys.platform == "darwin": + r = _run(["osascript", "-e", _DARWIN_PROBE_SCRIPT]) + if r is None: + logger.debug("clipboard.has_image_in_clipboard[darwin]: osascript timed out") + return False + if r.returncode != 0: + logger.debug( + "clipboard.has_image_in_clipboard[darwin]: osascript exit={} stderr={!r}", + r.returncode, + r.stderr[:200], + ) + return False + fmt = r.stdout.strip().decode("ascii", errors="replace") + if fmt: + logger.info("clipboard.has_image_in_clipboard[darwin]: format={!r}", fmt) + return True + # AppleScript found nothing – try modern UTI types via Swift/NSPasteboard + logger.debug("clipboard.has_image_in_clipboard[darwin]: AppleScript probe empty, trying UTI fallback") + if _darwin_has_image_via_uti(): + logger.info("clipboard.has_image_in_clipboard[darwin]: UTI fallback detected image") + return True + logger.info("clipboard.has_image_in_clipboard[darwin]: no image detected") + return False + if sys.platform.startswith("linux"): + yes = False + if os.environ.get("WAYLAND_DISPLAY") and _which("wl-paste"): + r = _run(["wl-paste", "-l"]) + if r is not None and r.returncode == 0 and b"image/" in r.stdout.lower(): + yes = True + if not yes and _which("xclip"): + r = _run(["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"]) + if r is not None and r.returncode == 0 and b"image/" in r.stdout.lower(): + yes = True + return yes + if sys.platform == "win32": + ps = "$null -ne (Get-Clipboard -Format Image)" + r = _run(["powershell", "-NoProfile", "-Command", ps]) + if r is None: + return False + return r.returncode == 0 and b"True" in r.stdout + return False + + +def get_image_from_clipboard() -> ClipboardImage | None: + if not has_image_in_clipboard(): + return None + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp_path = tmp.name + try: + if sys.platform == "darwin": + r = _run(["osascript", "-e", _darwin_save_script(tmp_path)]) + if r is None: + logger.warning("clipboard.get_image_from_clipboard[darwin]: osascript save timed out") + return None + fmt = "" + if r.returncode == 0: + fmt = r.stdout.strip().decode("ascii", errors="replace") + if not fmt: + # AppleScript could not save – try modern UTI fallback via Swift + logger.debug("clipboard.get_image_from_clipboard[darwin]: AppleScript save empty, trying UTI fallback") + uti = _darwin_read_image_via_uti(tmp_path) + if not uti: + logger.info("clipboard.get_image_from_clipboard[darwin]: no image class on clipboard") + return None + logger.info("clipboard.get_image_from_clipboard[darwin]: UTI fallback matched type={!r}", uti) + else: + logger.info("clipboard.get_image_from_clipboard[darwin]: matched class={!r}", fmt) + # NOTE: detect_image_format does not recognise TIFF magic bytes + # and will return "image/png" as a fallback. That's harmless — + # handle_image_paste passes the bytes straight to + # maybe_resize_and_downsample which uses Pillow to decode TIFF + # and re-encode to PNG/JPEG with a correct media_type. + elif sys.platform.startswith("linux"): + wrote_bytes = False + if os.environ.get("WAYLAND_DISPLAY") and _which("wl-paste"): + with open(tmp_path, "wb") as f: + try: + subprocess.run( + ["wl-paste", "--type", "image/png"], + stdout=f, + timeout=_SUBPROCESS_TIMEOUT, + ) + except subprocess.TimeoutExpired: + pass + wrote_bytes = Path(tmp_path).stat().st_size > 0 + if not wrote_bytes and _which("xclip"): + with open(tmp_path, "wb") as f: + try: + subprocess.run( + ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], + stdout=f, + timeout=_SUBPROCESS_TIMEOUT, + ) + except subprocess.TimeoutExpired: + pass + wrote_bytes = Path(tmp_path).stat().st_size > 0 + if not wrote_bytes: + return None + elif sys.platform == "win32": + # Defense in depth: tempfile.NamedTemporaryFile already produces a + # safe path, but escape single quotes so this remains injection-safe + # if a future caller passes an externally-supplied path. + safe_tmp_path = tmp_path.replace("'", "''") + ps = ( + "Add-Type -AssemblyName System.Windows.Forms;" + "$img = [System.Windows.Forms.Clipboard]::GetImage();" + f"if ($img -ne $null) {{ $img.Save('{safe_tmp_path}') }}" + ) + r = _run(["powershell", "-NoProfile", "-Command", ps]) + if r is None: + return None + else: + return None + + data = Path(tmp_path).read_bytes() + if not data: + logger.warning("clipboard.get_image_from_clipboard: tempfile is empty after write") + return None + media_type = detect_image_format(data) + logger.info( + "clipboard.get_image_from_clipboard: returning {} bytes, media_type={}", + len(data), + media_type, + ) + return ClipboardImage(data=data, media_type=media_type) + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + +def try_read_image_from_path(text: str) -> ClipboardImage | None: + """If *text* looks like a path to an existing image file, read and return it.""" + candidate = text.strip().strip("'").strip('"').replace("\\ ", " ") + if not IMAGE_EXTENSION_REGEX.search(candidate): + return None + p = Path(candidate) + if not p.is_absolute() or not p.exists() or not p.is_file(): + logger.debug("clipboard.try_read_image_from_path: candidate {!r} not a readable file", candidate) + return None + try: + data = p.read_bytes() + except OSError as exc: + logger.warning("clipboard.try_read_image_from_path: read error {}", exc) + return None + if not data: + return None + logger.info("clipboard.try_read_image_from_path: read {} bytes from {}", len(data), p) + return ClipboardImage( + data=data, + media_type=detect_image_format(data), + source_path=str(p), + filename=p.name, + ) diff --git a/src/iac_code/utils/image/format_detect.py b/src/iac_code/utils/image/format_detect.py new file mode 100644 index 00000000..acf5824e --- /dev/null +++ b/src/iac_code/utils/image/format_detect.py @@ -0,0 +1,22 @@ +"""Detect image format from magic bytes.""" + +from __future__ import annotations + +import re + +IMAGE_EXTENSION_REGEX = re.compile(r"\.(png|jpe?g|gif|webp)$", re.IGNORECASE) + + +def detect_image_format(buf: bytes) -> str: + """Return the MIME type. Falls back to image/png when unknown.""" + if len(buf) < 4: + return "image/png" + if buf[:8] == b"\x89PNG\r\n\x1a\n": + return "image/png" + if buf[:3] == b"\xff\xd8\xff": + return "image/jpeg" + if buf[:3] == b"GIF": + return "image/gif" + if len(buf) >= 12 and buf[:4] == b"RIFF" and buf[8:12] == b"WEBP": + return "image/webp" + return "image/png" diff --git a/src/iac_code/utils/image/pasted_content.py b/src/iac_code/utils/image/pasted_content.py new file mode 100644 index 00000000..6e360b4f --- /dev/null +++ b/src/iac_code/utils/image/pasted_content.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass + +_IMAGE_REF_RE = re.compile(r"\[Image #(\d+)\]") + + +@dataclass +class PastedContent: + id: int + type: str # 'text' | 'image' + content: str # base64-encoded string when type == 'image' + media_type: str | None = None + filename: str | None = None + source_path: str | None = None + + def is_valid_image(self) -> bool: + return self.type == "image" and bool(self.content) + + +@dataclass +class ImageRef: + id: int + start: int + end: int + + +def format_image_ref(image_id: int) -> str: + return f"[Image #{image_id}]" + + +def parse_image_refs(text: str) -> list[ImageRef]: + return [ImageRef(int(m.group(1)), m.start(), m.end()) for m in _IMAGE_REF_RE.finditer(text)] diff --git a/src/iac_code/utils/image/processor.py b/src/iac_code/utils/image/processor.py new file mode 100644 index 00000000..fa26ac63 --- /dev/null +++ b/src/iac_code/utils/image/processor.py @@ -0,0 +1,39 @@ +"""Assemble raw text + already-pasted images into a list[ContentBlock]. + +Pasted images are expected to already carry resized/encoded data and a +populated ``media_type`` (the REPL paste path runs them through +``maybe_resize_and_downsample`` before storing). We therefore pass the +base64 payload through verbatim instead of decoding and re-encoding it. +""" + +from __future__ import annotations + +from iac_code.agent.message import ContentBlock, ImageBlock, TextBlock +from iac_code.utils.image.pasted_content import PastedContent, parse_image_refs + + +def process_user_input( + text: str, + *, + pasted_contents: dict[int, PastedContent], +) -> list[ContentBlock]: + refs = [r for r in parse_image_refs(text) if r.id in pasted_contents and pasted_contents[r.id].is_valid_image()] + if not refs: + return [TextBlock(text=text)] if text else [] + + blocks: list[ContentBlock] = [] + cursor = 0 + for ref in refs: + if ref.start > cursor: + blocks.append(TextBlock(text=text[cursor : ref.start])) + pc = pasted_contents[ref.id] + blocks.append( + ImageBlock( + media_type=pc.media_type or "image/png", + data=pc.content, + ) + ) + cursor = ref.end + if cursor < len(text): + blocks.append(TextBlock(text=text[cursor:])) + return blocks diff --git a/src/iac_code/utils/image/resizer.py b/src/iac_code/utils/image/resizer.py new file mode 100644 index 00000000..d10334a2 --- /dev/null +++ b/src/iac_code/utils/image/resizer.py @@ -0,0 +1,118 @@ +"""Pillow-based image resize / downsample, mirroring the CC imageResizer. + +Limits: + - API_IMAGE_MAX_BASE64_SIZE = 5 MB (after base64 encoding) + - IMAGE_TARGET_RAW_SIZE = 3.75 MB (raw bytes) + - IMAGE_MAX_WIDTH/HEIGHT = 2000 px +""" + +from __future__ import annotations + +import io +from dataclasses import dataclass + +from PIL import Image, ImageFile + +ImageFile.LOAD_TRUNCATED_IMAGES = False # strict mode + +_PILLOW_FORMAT_TO_MEDIA_TYPE: dict[str, str] = { + "PNG": "image/png", + "JPEG": "image/jpeg", + "GIF": "image/gif", + "WEBP": "image/webp", +} + +API_IMAGE_MAX_BASE64_SIZE = 5 * 1024 * 1024 +IMAGE_TARGET_RAW_SIZE = (API_IMAGE_MAX_BASE64_SIZE * 3) // 4 +IMAGE_MAX_WIDTH = 2000 +IMAGE_MAX_HEIGHT = 2000 +JPEG_QUALITY_LADDER: tuple[int, ...] = (80, 60, 40, 20) + +_LANCZOS = Image.Resampling.LANCZOS + + +class ImageResizeError(Exception): + pass + + +@dataclass +class ImageDimensions: + original_width: int + original_height: int + display_width: int + display_height: int + + +@dataclass +class ResizeResult: + data: bytes + media_type: str + dimensions: ImageDimensions + + +def _save_jpeg(img: Image.Image, quality: int) -> bytes: + buf = io.BytesIO() + if img.mode != "RGB": + img = img.convert("RGB") + img.save(buf, format="JPEG", quality=quality, optimize=True) + return buf.getvalue() + + +def _save_png(img: Image.Image) -> bytes: + buf = io.BytesIO() + img.save(buf, format="PNG", optimize=True) + return buf.getvalue() + + +def maybe_resize_and_downsample(raw: bytes) -> ResizeResult: + if not raw: + raise ImageResizeError("Image file is empty (0 bytes)") + try: + img = Image.open(io.BytesIO(raw)) + img.load() + except Exception as exc: + raise ImageResizeError(f"Could not decode image: {exc}") from exc + + ow, oh = img.size + + # Trust Pillow's decoded format (detect_image_format's magic-byte fallback + # would label BMP as PNG). + pillow_format = (img.format or "").upper() + media_type = _PILLOW_FORMAT_TO_MEDIA_TYPE.get(pillow_format) + # Formats that can't be sent as-is (e.g. BMP) get converted to PNG. + if media_type is None: + raw = _save_png(img) + media_type = "image/png" + + # Fast path: dimensions and byte size already within limits. + if len(raw) <= IMAGE_TARGET_RAW_SIZE and ow <= IMAGE_MAX_WIDTH and oh <= IMAGE_MAX_HEIGHT: + return ResizeResult( + data=raw, + media_type=media_type, + dimensions=ImageDimensions(ow, oh, ow, oh), + ) + + # Proportional scale to fit the bounding box. + img_scaled = img.copy() + img_scaled.thumbnail((IMAGE_MAX_WIDTH, IMAGE_MAX_HEIGHT), _LANCZOS) + sw, sh = img_scaled.size + + if media_type == "image/png": + attempt = _save_png(img_scaled) + if len(attempt) <= IMAGE_TARGET_RAW_SIZE: + return ResizeResult(attempt, "image/png", ImageDimensions(ow, oh, sw, sh)) + # Fall through to the JPEG quality ladder. + + for quality in JPEG_QUALITY_LADDER: + attempt = _save_jpeg(img_scaled, quality) + if len(attempt) <= IMAGE_TARGET_RAW_SIZE: + return ResizeResult(attempt, "image/jpeg", ImageDimensions(ow, oh, sw, sh)) + + # Last-resort: shrink to 1000 px and JPEG q=20. + img_final = img.copy() + img_final.thumbnail((1000, 1000), _LANCZOS) + fw, fh = img_final.size + attempt = _save_jpeg(img_final, 20) + if len(attempt) > IMAGE_TARGET_RAW_SIZE: + raise ImageResizeError(f"Image cannot be reduced under {IMAGE_TARGET_RAW_SIZE} bytes") + return ResizeResult(attempt, "image/jpeg", ImageDimensions(ow, oh, fw, fh)) diff --git a/src/iac_code/utils/image/store.py b/src/iac_code/utils/image/store.py new file mode 100644 index 00000000..a0c7fa21 --- /dev/null +++ b/src/iac_code/utils/image/store.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import base64 +import os +import shutil +import time +from collections import OrderedDict +from pathlib import Path + +from iac_code.config import get_config_dir +from iac_code.utils.image.pasted_content import PastedContent + +IMAGE_STORE_DIR_NAME = "image-cache" +MAX_STORED_IMAGE_PATHS = 200 +# Concurrent REPL sessions each schedule background cleanup. To avoid +# wiping a sibling session's still-in-use cache, only delete dirs whose +# mtime is older than this threshold. Storing an image refreshes the +# session-dir mtime, so any session active in the last 24h is preserved. +CLEANUP_MAX_AGE_SECONDS: float = 24 * 60 * 60 + + +def _get_base_dir() -> Path: + return get_config_dir() / IMAGE_STORE_DIR_NAME + + +def _validate_session_id(session_id: str) -> None: + if not session_id or "/" in session_id or "\\" in session_id or session_id in (".", ".."): + raise ValueError(f"invalid session_id: {session_id!r}") + + +class ImageStore: + def __init__(self, session_id: str) -> None: + _validate_session_id(session_id) + self._session_id = session_id + self._paths: OrderedDict[int, str] = OrderedDict() + + def _session_dir(self) -> Path: + return _get_base_dir() / self._session_id + + def store(self, pc: PastedContent) -> str | None: + if not pc.is_valid_image(): + return None + d = self._session_dir() + d.mkdir(parents=True, exist_ok=True) + ext = (pc.media_type or "image/png").split("/")[-1] + path = d / f"{pc.id}.{ext}" + try: + data = base64.b64decode(pc.content) + fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, data) + finally: + os.close(fd) + except Exception: + return None + self.cache_path(pc.id, str(path)) + return str(path) + + def cache_path(self, image_id: int, path: str) -> None: + if image_id in self._paths: + self._paths.move_to_end(image_id) + self._paths[image_id] = path + while len(self._paths) > MAX_STORED_IMAGE_PATHS: + self._paths.popitem(last=False) + + def get_path(self, image_id: int) -> str | None: + return self._paths.get(image_id) + + def clear(self) -> None: + self._paths.clear() + + +def cleanup_old_image_caches( + *, + current_session_id: str, + max_age_seconds: float = CLEANUP_MAX_AGE_SECONDS, +) -> None: + _validate_session_id(current_session_id) + base = _get_base_dir() + if not base.exists(): + return + now = time.time() + for entry in base.iterdir(): + if not entry.is_dir() or entry.name == current_session_id: + continue + try: + age = now - entry.stat().st_mtime + except OSError: + continue + if age < max_age_seconds: + continue + shutil.rmtree(entry, ignore_errors=True) diff --git a/tests/integration/test_chat_with_image.py b/tests/integration/test_chat_with_image.py new file mode 100644 index 00000000..f8432e80 --- /dev/null +++ b/tests/integration/test_chat_with_image.py @@ -0,0 +1,74 @@ +"""Verify the image pipeline assembles content blocks for the chat path.""" + +import base64 +import io + +from PIL import Image + +from iac_code.agent.message import ImageBlock, TextBlock +from iac_code.utils.image.pasted_content import PastedContent +from iac_code.utils.image.processor import process_user_input + + +def _b64_png(w: int = 4, h: int = 4) -> str: + buf = io.BytesIO() + Image.new("RGB", (w, h), color=(0, 0, 0)).save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode() + + +def test_processor_emits_blocks_for_chat_path(): + pc = {1: PastedContent(id=1, type="image", content=_b64_png(), media_type="image/png")} + blocks = process_user_input("see [Image #1]", pasted_contents=pc) + assert any(isinstance(b, ImageBlock) for b in blocks) + assert any(isinstance(b, TextBlock) for b in blocks) + + +def test_handle_chat_with_blocks_calls_run_streaming(monkeypatch): + """Smoke test: PromptInputResult with images flows through _handle_chat → blocks → agent loop.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock + + from iac_code.ui.core.prompt_input import PromptInputResult + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl.store = MagicMock() + repl.renderer = MagicMock() + repl.renderer.run_streaming_output = AsyncMock(return_value=0.0) + fake_loop = MagicMock() + fake_loop.run_streaming = MagicMock(return_value=iter([])) + fake_loop.stamp_last_turn_elapsed = MagicMock() + repl._agent_loop = fake_loop + + pc = {1: PastedContent(id=1, type="image", content=_b64_png(), media_type="image/png")} + result = PromptInputResult(text="see [Image #1]", pasted_contents=pc) + asyncio.run(repl._handle_chat(result)) + + # The agent loop should have been called with a list[ContentBlock] containing both block types + args, _kwargs = fake_loop.run_streaming.call_args + payload = args[0] + assert isinstance(payload, list) + assert any(isinstance(b, ImageBlock) for b in payload) + assert any(isinstance(b, TextBlock) for b in payload) + + +def test_handle_chat_with_string_passes_through(monkeypatch): + """Backward-compat: plain string user input still works.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock + + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl.store = MagicMock() + repl.renderer = MagicMock() + repl.renderer.run_streaming_output = AsyncMock(return_value=0.0) + fake_loop = MagicMock() + fake_loop.run_streaming = MagicMock(return_value=iter([])) + fake_loop.stamp_last_turn_elapsed = MagicMock() + repl._agent_loop = fake_loop + + asyncio.run(repl._handle_chat("plain text")) + args, _kwargs = fake_loop.run_streaming.call_args + payload = args[0] + assert payload == "plain text" diff --git a/tests/providers/test_anthropic_image_blocks.py b/tests/providers/test_anthropic_image_blocks.py new file mode 100644 index 00000000..e411aba4 --- /dev/null +++ b/tests/providers/test_anthropic_image_blocks.py @@ -0,0 +1,25 @@ +from iac_code.providers.anthropic_provider import AnthropicProvider +from iac_code.providers.base import ContentBlock, Message + + +def test_image_block_converts_to_anthropic_source(): + p = AnthropicProvider(model="claude-opus-4-7", api_key="x") + msg = Message( + role="user", + content=[ + ContentBlock(type="text", text="look"), + ContentBlock(type="image", media_type="image/png", data="aGVsbG8="), + ], + ) + api = p._convert_messages([msg]) + assert api[0]["content"][1] == { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": "aGVsbG8="}, + } + + +def test_text_only_message_unchanged(): + p = AnthropicProvider(model="claude-opus-4-7", api_key="x") + msg = Message(role="user", content="plain") + api = p._convert_messages([msg]) + assert api == [{"role": "user", "content": "plain"}] diff --git a/tests/providers/test_openai_image_blocks.py b/tests/providers/test_openai_image_blocks.py new file mode 100644 index 00000000..314eb7c2 --- /dev/null +++ b/tests/providers/test_openai_image_blocks.py @@ -0,0 +1,55 @@ +from iac_code.providers.base import ContentBlock, Message +from iac_code.providers.openai_provider import OpenAIProvider + + +def test_user_image_converts_to_image_url(): + p = OpenAIProvider(model="gpt-5.4", api_key="x") + msg = Message( + role="user", + content=[ + ContentBlock(type="text", text="look"), + ContentBlock(type="image", media_type="image/png", data="aGVsbG8="), + ], + ) + api = p._convert_messages([msg]) + assert api == [ + { + "role": "user", + "content": [ + {"type": "text", "text": "look"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,aGVsbG8="}, + }, + ], + } + ] + + +def test_text_only_user_message_stays_string(): + p = OpenAIProvider(model="gpt-5.4", api_key="x") + msg = Message(role="user", content="plain") + api = p._convert_messages([msg]) + assert api == [{"role": "user", "content": "plain"}] + + +def test_user_image_only_emits_content_list(): + p = OpenAIProvider(model="gpt-5.4", api_key="x") + msg = Message( + role="user", + content=[ + ContentBlock(type="image", media_type="image/jpeg", data="ZmFrZQ=="), + ], + ) + api = p._convert_messages([msg]) + assert api == [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,ZmFrZQ=="}, + }, + ], + } + ] diff --git a/tests/services/capabilities/__init__.py b/tests/services/capabilities/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/services/capabilities/test_auto_detect.py b/tests/services/capabilities/test_auto_detect.py new file mode 100644 index 00000000..a41e409f --- /dev/null +++ b/tests/services/capabilities/test_auto_detect.py @@ -0,0 +1,96 @@ +import httpx + +from iac_code.services.capabilities.auto_detect import ( + AutoDetectCache, + probe_openapi_compatible, +) + + +def test_probe_returns_true_when_modalities_include_image(): + payload = { + "data": [ + { + "id": "custom-vl", + "architecture": {"input_modalities": ["text", "image"]}, + } + ] + } + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=payload) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport) as client: + result = probe_openapi_compatible( + base_url="https://example.com/v1", + api_key="x", + model="custom-vl", + client=client, + ) + assert result is True + + +def test_probe_returns_none_on_unknown_schema(): + payload = {"data": [{"id": "custom-vl"}]} + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=payload) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport) as client: + result = probe_openapi_compatible( + base_url="https://example.com/v1", + api_key="x", + model="custom-vl", + client=client, + ) + assert result is None + + +def test_cache_round_trip(tmp_path, monkeypatch): + monkeypatch.setattr( + "iac_code.services.capabilities.auto_detect._cache_path", + lambda: tmp_path / ".multimodal-cache.yml", + ) + cache = AutoDetectCache() + cache.set("https://x/v1", "custom-vl", True) + cache.flush() + + fresh = AutoDetectCache() + assert fresh.get("https://x/v1", "custom-vl") is True + assert fresh.get("https://x/v1", "other") is None + + +def test_cache_flush_leaves_no_partial_temp_files(tmp_path, monkeypatch): + """Atomic write must rename via os.replace, not leave .tmp residue behind.""" + monkeypatch.setattr( + "iac_code.services.capabilities.auto_detect._cache_path", + lambda: tmp_path / ".multimodal-cache.yml", + ) + cache = AutoDetectCache() + cache.set("https://x/v1", "m", True) + cache.flush() + + leftovers = [p.name for p in tmp_path.iterdir() if p.name.startswith(".multimodal-cache.")] + # Only the final file should remain — no .tmp residue. + assert leftovers == [".multimodal-cache.yml"], leftovers + + +def test_cache_flush_last_writer_wins_with_valid_yaml(tmp_path, monkeypatch): + """Two sequential flushes (simulating two REPL writes) leave a parseable file.""" + monkeypatch.setattr( + "iac_code.services.capabilities.auto_detect._cache_path", + lambda: tmp_path / ".multimodal-cache.yml", + ) + a = AutoDetectCache() + a.set("https://x/v1", "m1", True) + a.flush() + + b = AutoDetectCache() + b.set("https://x/v1", "m2", False) + b.flush() + + fresh = AutoDetectCache() + # b read a's snapshot before setting m2, so both end up persisted. + assert fresh.get("https://x/v1", "m1") is True + assert fresh.get("https://x/v1", "m2") is False diff --git a/tests/services/capabilities/test_multimodal.py b/tests/services/capabilities/test_multimodal.py new file mode 100644 index 00000000..4a452f59 --- /dev/null +++ b/tests/services/capabilities/test_multimodal.py @@ -0,0 +1,60 @@ +from iac_code.services.capabilities.multimodal import ( + MultiModalSpec, + get_multimodal_spec, + is_model_multimodal, +) + + +def test_builtin_claude_opus_supports_images(): + spec = get_multimodal_spec("claude-opus-4-7") + assert isinstance(spec, MultiModalSpec) + assert spec.support_multimodal is True + assert "image/png" in spec.formats + + +def test_unknown_model_defaults_to_no_images(): + spec = get_multimodal_spec("does-not-exist-1.0") + assert spec.support_multimodal is False + assert is_model_multimodal("does-not-exist-1.0") is False + + +def test_settings_override_wins_over_builtin(monkeypatch, tmp_path): + settings = tmp_path / "settings.yml" + settings.write_text("multiModal:\n models:\n custom-vl: {supportMultimodal: true}\n") + monkeypatch.setattr( + "iac_code.services.capabilities.multimodal.get_settings_path", + lambda: settings, + ) + assert is_model_multimodal("custom-vl") is True + + +def test_settings_override_can_disable_builtin(monkeypatch, tmp_path): + settings = tmp_path / "settings.yml" + settings.write_text("multiModal:\n models:\n claude-opus-4-7: {supportMultimodal: false}\n") + monkeypatch.setattr( + "iac_code.services.capabilities.multimodal.get_settings_path", + lambda: settings, + ) + assert is_model_multimodal("claude-opus-4-7") is False + + +def test_builtin_set_includes_registry_flagged_models(): + """Models marked support_multimodal in providers/registry.py must be picked up.""" + from iac_code.services.capabilities.multimodal import _builtin_multimodal_models + + builtin = _builtin_multimodal_models() + # Spot-check a few well-known vision models from each provider family. + assert "claude-opus-4-7" in builtin + assert "gpt-5.5" in builtin + assert "gemini-2.5-pro" in builtin + assert "qwen3.6-plus" in builtin + assert "kimi-k2.6" in builtin + + +def test_builtin_set_excludes_non_multimodal_models(): + """Models not flagged in registry should not appear in the built-in set.""" + from iac_code.services.capabilities.multimodal import _builtin_multimodal_models + + builtin = _builtin_multimodal_models() + assert "deepseek-v4-pro" not in builtin + assert "qwen3-coder-plus" not in builtin diff --git a/tests/test_agent/test_image_block.py b/tests/test_agent/test_image_block.py new file mode 100644 index 00000000..cd2b5c82 --- /dev/null +++ b/tests/test_agent/test_image_block.py @@ -0,0 +1,33 @@ +from iac_code.agent.message import ( + Conversation, + ImageBlock, + Message, + TextBlock, +) + + +def test_image_block_serializes_round_trip(): + block = ImageBlock(media_type="image/png", data="aGVsbG8=") + assert block.type == "image" + payload = block.model_dump() + assert payload == {"type": "image", "media_type": "image/png", "data": "aGVsbG8="} + + +def test_message_with_blocks_to_api_format_keeps_image(): + msg = Message( + role="user", + content=[ + TextBlock(text="see"), + ImageBlock(media_type="image/png", data="x"), + ], + ) + api = msg.to_api_format() + assert api["content"][1]["type"] == "image" + assert api["content"][1]["data"] == "x" + + +def test_conversation_add_user_message_accepts_blocks(): + conv = Conversation() + conv.add_user_message([TextBlock(text="hi"), ImageBlock(media_type="image/png", data="x")]) + assert conv.messages[-1].role == "user" + assert isinstance(conv.messages[-1].content, list) diff --git a/tests/ui/core/test_clipboard_indicator.py b/tests/ui/core/test_clipboard_indicator.py new file mode 100644 index 00000000..c69e66ef --- /dev/null +++ b/tests/ui/core/test_clipboard_indicator.py @@ -0,0 +1,220 @@ +"""Tests for clipboard image indicator: focus events, state management, hint text, and Ctrl+V binding.""" + +from __future__ import annotations + +import os +from io import StringIO +from types import SimpleNamespace + +from iac_code.ui.core.key_event import KeyEvent +from iac_code.ui.core.prompt_input import PromptInput +from iac_code.ui.core.raw_input import RawInputCapture +from iac_code.ui.keybindings.manager import KeyBinding, KeybindingManager + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_input(**kwargs) -> PromptInput: + """Return a PromptInput with a fresh KeybindingManager.""" + km = KeybindingManager() + return PromptInput(keybinding_manager=km, **kwargs) + + +def _key(key: str, *, ctrl: bool = False) -> KeyEvent: + char = key if len(key) == 1 else "" + return KeyEvent(key=key, char=char, ctrl=ctrl) + + +def _stub_render(inp: PromptInput, monkeypatch) -> StringIO: + """Stub _render to avoid real stdout writes; return captured StringIO.""" + import iac_code.ui.core.prompt_input as prompt_mod + + out = StringIO() + monkeypatch.setattr(prompt_mod, "sys", SimpleNamespace(stdout=out)) + monkeypatch.setattr( + prompt_mod.shutil, + "get_terminal_size", + lambda *args, **kwargs: os.terminal_size((120, 24)), + ) + inp._prompt = "❯ " + return out + + +# --------------------------------------------------------------------------- +# 1. Focus event parsing (raw_input layer) +# --------------------------------------------------------------------------- + + +class TestFocusEventParsing: + """Verify raw_input parses CSI I / CSI O as focus_in / focus_out.""" + + def test_focus_in_parsed(self): + event = RawInputCapture._parse_escape_sequence("[I") + assert event.key == "focus_in" + assert event.char == "" + + def test_focus_out_parsed(self): + event = RawInputCapture._parse_escape_sequence("[O") + assert event.key == "focus_out" + assert event.char == "" + + +# --------------------------------------------------------------------------- +# 2. Clipboard indicator state (prompt_input layer) +# --------------------------------------------------------------------------- + + +class TestClipboardIndicatorState: + """Test _clipboard_has_image transitions in response to events.""" + + def test_focus_in_with_image_sets_flag(self, monkeypatch): + inp = _make_input() + _stub_render(inp, monkeypatch) + monkeypatch.setattr( + "iac_code.utils.image.clipboard.has_image_in_clipboard", + lambda: True, + ) + inp._handle_key(KeyEvent(key="focus_in", char="")) + assert inp._clipboard_has_image is True + + def test_focus_in_without_image_clears_flag(self, monkeypatch): + inp = _make_input() + _stub_render(inp, monkeypatch) + monkeypatch.setattr( + "iac_code.utils.image.clipboard.has_image_in_clipboard", + lambda: False, + ) + # Pre-set to True to ensure it transitions to False + inp._clipboard_has_image = True + inp._handle_key(KeyEvent(key="focus_in", char="")) + assert inp._clipboard_has_image is False + + def test_focus_out_clears_flag(self, monkeypatch): + inp = _make_input() + _stub_render(inp, monkeypatch) + inp._clipboard_has_image = True + inp._handle_key(KeyEvent(key="focus_out", char="")) + assert inp._clipboard_has_image is False + + def test_printable_char_clears_flag(self, monkeypatch): + inp = _make_input() + _stub_render(inp, monkeypatch) + inp._clipboard_has_image = True + inp._handle_key(_key("a")) + assert inp._clipboard_has_image is False + + +# --------------------------------------------------------------------------- +# 3. Ctrl+V binding registered on all platforms (including darwin) +# --------------------------------------------------------------------------- + + +class TestCtrlVBinding: + """Ensure Ctrl+V is registered as a keybinding regardless of platform.""" + + def test_ctrl_v_registered_on_darwin(self, monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + km = KeybindingManager() + km.push_context("global") + handled = [] + km.register(KeyBinding("ctrl+v", "paste_image", "global", lambda: handled.append(True) or True)) + # Resolve a ctrl+v event + event = KeyEvent(key="v", char="\x16", ctrl=True) + result = km.resolve(event) + assert result is True + assert handled == [True] + + def test_ctrl_v_registered_on_linux(self, monkeypatch): + monkeypatch.setattr("sys.platform", "linux") + km = KeybindingManager() + km.push_context("global") + handled = [] + km.register(KeyBinding("ctrl+v", "paste_image", "global", lambda: handled.append(True) or True)) + event = KeyEvent(key="v", char="\x16", ctrl=True) + result = km.resolve(event) + assert result is True + assert handled == [True] + + def test_repl_registers_ctrl_v_binding(self, monkeypatch): + """Verify the InlineREPL registers ctrl+v in _register_global_keybindings.""" + # We check that the keybinding "ctrl+v" is registered after calling + # the registration method. We can test this by inspecting the bindings + # directly via the keybinding manager source. + from iac_code.ui.keybindings.manager import KeyBinding, KeybindingManager + + km = KeybindingManager() + km.push_context("global") + + # Simulate what InlineREPL._register_global_keybindings does + km.register(KeyBinding("ctrl+v", "paste_image", "global", lambda: True)) + + # Verify ctrl+v resolves + event = KeyEvent(key="v", char="\x16", ctrl=True) + assert km.resolve(event) is True + + +# --------------------------------------------------------------------------- +# 4. Hint text (platform-dependent shortcut label) +# --------------------------------------------------------------------------- + + +class TestClipboardHintText: + """Test _clipboard_hint_text returns unified shortcut on all platforms.""" + + def test_hint_text_unified(self): + inp = _make_input() + hint = inp._clipboard_hint_text() + assert hint == "Image in clipboard \u00b7 ctrl+v to paste" + + def test_hint_text_macos(self, monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + inp = _make_input() + hint = inp._clipboard_hint_text() + assert "ctrl+v" in hint + assert "Image in clipboard" in hint + + def test_hint_text_linux(self, monkeypatch): + monkeypatch.setattr("sys.platform", "linux") + inp = _make_input() + hint = inp._clipboard_hint_text() + assert "ctrl+v" in hint + assert "Image in clipboard" in hint + + def test_hint_text_windows(self, monkeypatch): + monkeypatch.setattr("sys.platform", "win32") + inp = _make_input() + hint = inp._clipboard_hint_text() + assert "ctrl+v" in hint + + +# --------------------------------------------------------------------------- +# 5. Render includes hint when clipboard has image +# --------------------------------------------------------------------------- + + +class TestClipboardHintRender: + """Verify the right-aligned hint renders when _clipboard_has_image is True.""" + + def test_render_shows_hint_when_clipboard_has_image(self, monkeypatch): + inp = _make_input() + out = _stub_render(inp, monkeypatch) + inp._clipboard_has_image = True + inp._set_text("") + inp._cursor = 0 + inp._render() + + output = out.getvalue() + assert "Image in clipboard" in output + + def test_render_hides_hint_when_no_clipboard_image(self, monkeypatch): + inp = _make_input() + out = _stub_render(inp, monkeypatch) + inp._clipboard_has_image = False + inp._set_text("") + inp._cursor = 0 + inp._render() + + output = out.getvalue() + assert "Image in clipboard" not in output diff --git a/tests/ui/core/test_prompt_input.py b/tests/ui/core/test_prompt_input.py index 283299f2..8074172c 100644 --- a/tests/ui/core/test_prompt_input.py +++ b/tests/ui/core/test_prompt_input.py @@ -528,3 +528,59 @@ def read_key(self): assert result == "ab" assert action_calls == ["ran"] + + +# --------------------------------------------------------------------------- +# OSC 8 hyperlink tests for [Image #N] +# --------------------------------------------------------------------------- + + +class TestImageRefOSC8Hyperlink: + """Tests for OSC 8 hyperlink rendering on [Image #N] references.""" + + def test_image_ref_renders_osc8_hyperlink(self): + """[Image #N] should be wrapped in OSC 8 hyperlink when image_store has path.""" + from unittest.mock import MagicMock + + store = MagicMock() + store.get_path.return_value = "/tmp/test-image.png" + + pi = make_input(image_store=store) + pi._pasted_contents[1] = MagicMock() + + line = "Look at [Image #1] here" + result = pi._highlight_image_refs(line) + + # Verify OSC 8 hyperlink + assert "\033]8;;file:///tmp/test-image.png\033\\" in result + assert "[Image #1]" in result + assert "\033]8;;\033\\" in result # closing sequence + + def test_image_ref_fallback_without_store(self): + """[Image #N] should only have color when image_store is None.""" + from unittest.mock import MagicMock + + pi = make_input(image_store=None) + pi._pasted_contents[1] = MagicMock() + + line = "[Image #1]" + result = pi._highlight_image_refs(line) + + assert "\033[36m" in result # cyan + assert "\033]8;;" not in result # no OSC 8 + + def test_image_ref_fallback_without_path(self): + """[Image #N] should only have color when store has no path for that ID.""" + from unittest.mock import MagicMock + + store = MagicMock() + store.get_path.return_value = None # No path cached + + pi = make_input(image_store=store) + pi._pasted_contents[1] = MagicMock() + + line = "[Image #1]" + result = pi._highlight_image_refs(line) + + assert "\033[36m" in result + assert "\033]8;;" not in result diff --git a/tests/ui/core/test_raw_input.py b/tests/ui/core/test_raw_input.py index 661daba3..de04a793 100644 --- a/tests/ui/core/test_raw_input.py +++ b/tests/ui/core/test_raw_input.py @@ -200,7 +200,13 @@ def test_enter_and_exit_toggle_terminal_modes(self, monkeypatch): with RawInputCapture(fd=7) as capture: assert capture._old_settings == ["old"] - assert writes == [("setraw", 7), (7, b"\033[?2004h"), (7, b"\033[?2004l")] + assert writes == [ + ("setraw", 7), + (7, b"\033[?2004h"), + (7, b"\033[?1004h"), + (7, b"\033[?1004l"), + (7, b"\033[?2004l"), + ] assert tcsetattr_calls == [(7, termios.TCSADRAIN, ["old"])] def test_exit_ignores_disable_bracket_paste_error(self, monkeypatch): diff --git a/tests/ui/test_bracketed_paste_image.py b/tests/ui/test_bracketed_paste_image.py new file mode 100644 index 00000000..42ce8151 --- /dev/null +++ b/tests/ui/test_bracketed_paste_image.py @@ -0,0 +1,147 @@ +"""Tests for InlineREPL._on_bracketed_paste — the Cmd+V image-paste path. + +On macOS, Cmd+V is intercepted by the terminal and forwarded as a bracketed +paste sequence (never reaches a Ctrl+V keybinding). The hook here probes the +system clipboard on every bracketed paste; if an image is present, attach it +and decide whether the accompanying text should also be inserted. +""" + +import io +from unittest.mock import MagicMock, patch + +import pytest +from PIL import Image + +from iac_code.utils.image.clipboard import ClipboardImage + + +def _valid_png_bytes() -> bytes: + buf = io.BytesIO() + Image.new("RGB", (8, 8), color=(255, 0, 0)).save(buf, format="PNG") + return buf.getvalue() + + +@pytest.fixture +def repl(): + from iac_code.ui.repl import InlineREPL + + r = InlineREPL.__new__(InlineREPL) + r._current_model = "claude-opus-4-7" + r._prompt_input = MagicMock() + r._prompt_input.next_paste_id.return_value = 1 + r.renderer = MagicMock() + r.console = MagicMock() + r._image_store = MagicMock() + r._image_store.store.return_value = "/tmp/fake/1.png" + return r + + +def _png_clipboard_image() -> ClipboardImage: + return ClipboardImage(data=_valid_png_bytes(), media_type="image/png") + + +def test_bracketed_paste_attaches_image_and_suppresses_when_text_empty(repl): + """Cmd+V on a screenshot: clipboard has image, bracketed-paste text is + empty (nothing to insert). We attach the image and report consumed=True.""" + with ( + patch("iac_code.ui.repl.try_read_image_from_path", return_value=None), + patch( + "iac_code.ui.repl.get_image_from_clipboard", + return_value=_png_clipboard_image(), + ), + patch("iac_code.services.capabilities.multimodal.is_model_multimodal", return_value=True), + ): + consumed = repl._on_bracketed_paste("") + assert consumed is True + repl._prompt_input.attach_image.assert_called_once() + + +def test_bracketed_paste_keeps_caption_alongside_image(repl): + """Image attached + non-trivial text → we attach AND leave the caller to + insert the text so the user keeps their accompanying caption.""" + with ( + patch("iac_code.ui.repl.try_read_image_from_path", return_value=None), + patch( + "iac_code.ui.repl.get_image_from_clipboard", + return_value=_png_clipboard_image(), + ), + patch("iac_code.services.capabilities.multimodal.is_model_multimodal", return_value=True), + ): + consumed = repl._on_bracketed_paste("what's wrong with this screenshot?") + assert consumed is False + repl._prompt_input.attach_image.assert_called_once() + + +def test_bracketed_paste_suppresses_image_path_text(repl): + """User pastes a Finder-copied image file: bracketed paste carries the + quoted POSIX path, clipboard also exposes the image bytes (or + try_read_image_from_path resolves the path). Path text would just be + redundant noise → suppress.""" + img = ClipboardImage( + data=_valid_png_bytes(), + media_type="image/png", + source_path="/Users/x/foo.png", + ) + with ( + patch("iac_code.ui.repl.try_read_image_from_path", return_value=img), + patch("iac_code.services.capabilities.multimodal.is_model_multimodal", return_value=True), + ): + consumed = repl._on_bracketed_paste("'/Users/x/foo.png'") + assert consumed is True + repl._prompt_input.attach_image.assert_called_once() + + +def test_bracketed_paste_with_no_image_returns_false(repl): + """Plain-text paste: no image anywhere → we never attach, never suppress.""" + with ( + patch("iac_code.ui.repl.try_read_image_from_path", return_value=None), + patch("iac_code.ui.repl.get_image_from_clipboard", return_value=None), + ): + consumed = repl._on_bracketed_paste("just plain text") + assert consumed is False + repl._prompt_input.attach_image.assert_not_called() + + +def test_bracketed_paste_file_url_text_suppresses(repl): + """file:// URL pasted alongside image bytes → suppress URL noise.""" + with ( + patch("iac_code.ui.repl.try_read_image_from_path", return_value=None), + patch( + "iac_code.ui.repl.get_image_from_clipboard", + return_value=_png_clipboard_image(), + ), + patch("iac_code.services.capabilities.multimodal.is_model_multimodal", return_value=True), + ): + consumed = repl._on_bracketed_paste("file:///Users/x/foo.png") + assert consumed is True + + +def test_bracketed_paste_strips_orphan_focus_events(repl): + """When the terminal interleaves focus events around the paste boundary + (Cmd+V triggers app focus → terminal sends \\x1b[I before/after the paste + markers), our raw-input may capture the focus byte inside the paste + content. After stripping, the content is effectively empty → probe + clipboard and attach as if it were the empty-Cmd+V case.""" + img = _png_clipboard_image() + with ( + patch("iac_code.ui.repl.try_read_image_from_path", return_value=None), + patch("iac_code.ui.repl.get_image_from_clipboard", return_value=img), + patch("iac_code.services.capabilities.multimodal.is_model_multimodal", return_value=True), + ): + # Focus-in only + consumed = repl._on_bracketed_paste("\x1b[I") + assert consumed is True + repl._prompt_input.attach_image.assert_called_once() + + +def test_bracketed_paste_empty_after_focus_strip_does_not_insert_garbage(repl): + """Specifically: even when no image is in the clipboard, paste content + that's only focus events must NOT be inserted as text into the buffer + (would surface as `\\x1b[I` garbage chars).""" + with ( + patch("iac_code.ui.repl.try_read_image_from_path", return_value=None), + patch("iac_code.ui.repl.get_image_from_clipboard", return_value=None), + ): + consumed = repl._on_bracketed_paste("\x1b[I\x1b[O") + # Consumed=True even without image — text was pure noise; suppress insert. + assert consumed is True diff --git a/tests/ui/test_image_paste_capability_gate.py b/tests/ui/test_image_paste_capability_gate.py new file mode 100644 index 00000000..dac78f4d --- /dev/null +++ b/tests/ui/test_image_paste_capability_gate.py @@ -0,0 +1,196 @@ +import io +from unittest.mock import MagicMock, patch + +import pytest +from PIL import Image + +from iac_code.utils.image.clipboard import ClipboardImage +from iac_code.utils.image.pasted_content import PastedContent + + +def _valid_png_bytes() -> bytes: + buf = io.BytesIO() + Image.new("RGB", (8, 8), color=(255, 0, 0)).save(buf, format="PNG") + return buf.getvalue() + + +@pytest.fixture +def repl(): + """Construct a minimally-stubbed REPL for unit testing handle_image_paste.""" + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl._current_model = "claude-opus-4-7" + repl._prompt_input = MagicMock() + repl._prompt_input.next_paste_id.return_value = 7 + repl.renderer = MagicMock() + repl.console = MagicMock() + repl._image_store = MagicMock() + repl._image_store.store.return_value = "/tmp/fake/7.png" + return repl + + +def test_ctrl_v_no_image_is_noop(repl): + from iac_code.ui.repl import handle_image_paste + + with patch("iac_code.utils.image.clipboard.get_image_from_clipboard", return_value=None): + result = handle_image_paste(repl) + assert result is False + repl._prompt_input.attach_image.assert_not_called() + + +def test_ctrl_v_no_image_shows_system_message(repl): + """Ctrl+V is an explicit 'paste image' intent. When the clipboard is + empty, the user must see *something* — silent return is the original + bug. The wrapper schedules a system message and consumes the event so + the raw \\x16 byte never falls through to the printable check.""" + # handle_image_paste lazy-imports get_image_from_clipboard from its source + # module, so the patch must target the source not the repl alias. + with patch("iac_code.utils.image.clipboard.get_image_from_clipboard", return_value=None): + consumed = repl._handle_ctrl_v_image() + assert consumed is True + repl._prompt_input.schedule_action.assert_called_once() + # The scheduled lambda routes the message through renderer.print_system_message + repl._prompt_input.schedule_action.call_args.args[0]() + repl.renderer.print_system_message.assert_called_once() + msg = repl.renderer.print_system_message.call_args.args[0] + assert "image" in msg.lower() and "clipboard" in msg.lower() + + +def test_ctrl_v_attaches_image_when_supported(repl): + img_bytes = _valid_png_bytes() + img = ClipboardImage(data=img_bytes, media_type="image/png") + with ( + patch("iac_code.utils.image.clipboard.get_image_from_clipboard", return_value=img), + patch("iac_code.services.capabilities.multimodal.is_model_multimodal", return_value=True), + ): + from iac_code.ui.repl import handle_image_paste + + result = handle_image_paste(repl) + assert result is True + repl._prompt_input.attach_image.assert_called_once() + pc_arg = repl._prompt_input.attach_image.call_args.args[0] + assert isinstance(pc_arg, PastedContent) + assert pc_arg.id == 7 + assert pc_arg.type == "image" + repl._image_store.store.assert_called_once() + # Happy path: no warnings scheduled + repl._prompt_input.schedule_action.assert_not_called() + + +def test_ctrl_v_warns_when_model_unsupported(repl): + img = ClipboardImage(data=_valid_png_bytes(), media_type="image/png") + with ( + patch("iac_code.utils.image.clipboard.get_image_from_clipboard", return_value=img), + patch("iac_code.services.capabilities.multimodal.is_model_multimodal", return_value=False), + ): + from iac_code.ui.repl import handle_image_paste + + result = handle_image_paste(repl) + # Returning True means "consumed" — we did NOT fall through to text paste. + assert result is True + repl._prompt_input.attach_image.assert_not_called() + # Warning routed through schedule_action so it fires outside raw mode + repl._prompt_input.schedule_action.assert_called_once() + # Invoking the scheduled lambda should call the renderer + repl._prompt_input.schedule_action.call_args.args[0]() + repl.renderer.print_system_message.assert_called_once() + + +def test_ctrl_v_warns_on_resize_error(repl): + from iac_code.utils.image.resizer import ImageResizeError + + img = ClipboardImage(data=_valid_png_bytes(), media_type="image/png") + with ( + patch("iac_code.utils.image.clipboard.get_image_from_clipboard", return_value=img), + patch("iac_code.services.capabilities.multimodal.is_model_multimodal", return_value=True), + patch( + "iac_code.utils.image.resizer.maybe_resize_and_downsample", + side_effect=ImageResizeError("boom"), + ), + ): + from iac_code.ui.repl import handle_image_paste + + result = handle_image_paste(repl) + assert result is True + repl._prompt_input.attach_image.assert_not_called() + # Warning routed through schedule_action so it fires outside raw mode + repl._prompt_input.schedule_action.assert_called_once() + # Invoking the scheduled lambda should call the renderer + repl._prompt_input.schedule_action.call_args.args[0]() + repl.renderer.print_system_message.assert_called_once() + + +def test_ctrl_v_warns_when_store_fails(repl): + repl._image_store.store.return_value = None # simulate disk failure + img = ClipboardImage(data=_valid_png_bytes(), media_type="image/png") + with ( + patch("iac_code.utils.image.clipboard.get_image_from_clipboard", return_value=img), + patch("iac_code.services.capabilities.multimodal.is_model_multimodal", return_value=True), + ): + from iac_code.ui.repl import handle_image_paste + + result = handle_image_paste(repl) + assert result is True + # attach_image still happens (we have the image in memory) + repl._prompt_input.attach_image.assert_called_once() + # ... and a warning was scheduled + repl._prompt_input.schedule_action.assert_called_once() + + +def test_ctrl_v_passes_provider_context_to_is_model_multimodal(repl): + """When provider is openapi_compatible, the call must include base_url + api_key + so auto-detect can probe.""" + repl._current_model = "custom-vl" + repl._current_provider_config = { + "keyName": "openapi_compatible", + "apiBase": "https://example.com/v1", + } + repl._credentials = {"openapi_compatible": "sk-test"} + img = ClipboardImage(data=_valid_png_bytes(), media_type="image/png") + with ( + patch("iac_code.utils.image.clipboard.get_image_from_clipboard", return_value=img), + patch( + "iac_code.services.capabilities.multimodal.is_model_multimodal", + return_value=False, + ) as mock_capability, + ): + from iac_code.ui.repl import handle_image_paste + + handle_image_paste(repl) + mock_capability.assert_called_once_with( + "custom-vl", + provider_key="openapi_compatible", + base_url="https://example.com/v1", + api_key="sk-test", + ) + + +def test_ctrl_v_handles_missing_provider_context_gracefully(repl): + """If _current_provider_config / _credentials aren't set on the stub, capability + check still runs with all-None kwargs (matches old behavior, no crash).""" + # The default fixture doesn't set these, so simulate that by deleting + if hasattr(repl, "_current_provider_config"): + del repl._current_provider_config + if hasattr(repl, "_credentials"): + del repl._credentials + img = ClipboardImage(data=_valid_png_bytes(), media_type="image/png") + with ( + patch("iac_code.utils.image.clipboard.get_image_from_clipboard", return_value=img), + patch( + "iac_code.services.capabilities.multimodal.is_model_multimodal", + return_value=True, + ), + patch("iac_code.utils.image.resizer.maybe_resize_and_downsample") as mock_resize, + ): + from types import SimpleNamespace + + mock_resize.return_value = SimpleNamespace( + data=b"\x89PNG\r\n\x1a\n", + media_type="image/png", + dimensions=SimpleNamespace(), + ) + from iac_code.ui.repl import handle_image_paste + + result = handle_image_paste(repl) + assert result is True # No crash on missing context diff --git a/tests/ui/test_prompt_input_image_paste.py b/tests/ui/test_prompt_input_image_paste.py new file mode 100644 index 00000000..8031d3f6 --- /dev/null +++ b/tests/ui/test_prompt_input_image_paste.py @@ -0,0 +1,92 @@ +from unittest.mock import MagicMock + +from iac_code.ui.core.key_event import KeyEvent +from iac_code.ui.core.prompt_input import PromptInput, PromptInputResult +from iac_code.utils.image.pasted_content import PastedContent + + +def test_attach_image_inserts_placeholder_at_cursor(): + pi = PromptInput(keybinding_manager=MagicMock()) + pi._set_text("hello world") + pi._cursor = 6 # 两个空格之间 + pi.attach_image(PastedContent(id=1, type="image", content="aGVsbG8=", media_type="image/png")) + assert pi._get_text() == "hello [Image #1] world" + assert pi._cursor == len("hello [Image #1]") + assert pi._pasted_contents[1].id == 1 + + +def test_submit_returns_text_and_pasted_contents(): + pi = PromptInput(keybinding_manager=MagicMock()) + pi._set_text("see [Image #1]") + pi._pasted_contents = {1: PastedContent(id=1, type="image", content="x", media_type="image/png")} + result = pi.make_result() + assert isinstance(result, PromptInputResult) + assert result.text == "see [Image #1]" + assert 1 in result.pasted_contents + + +def test_next_paste_id_monotonic(): + pi = PromptInput(keybinding_manager=MagicMock()) + assert pi.next_paste_id() == 1 + assert pi.next_paste_id() == 2 + + +def test_attach_image_does_not_duplicate_existing_id(): + pi = PromptInput(keybinding_manager=MagicMock()) + pc1 = PastedContent(id=1, type="image", content="a", media_type="image/png") + pi.attach_image(pc1) + # Second attach with same id should be a no-op (idempotent) + pi.attach_image(pc1) + text = pi._get_text() + assert text.count("[Image #1]") == 1 + + +def test_highlight_image_refs_wraps_known_ids(): + pi = PromptInput(keybinding_manager=MagicMock()) + pi._pasted_contents = {1: PastedContent(id=1, type="image", content="x", media_type="image/png")} + out = pi._highlight_image_refs("see [Image #1] and [Image #99]") + # Known id wrapped, unknown id untouched + assert "\033[36m[Image #1]\033[0m" in out + assert "[Image #99]" in out + # Unknown should not be wrapped + assert "\033[36m[Image #99]" not in out + + +def test_highlight_image_refs_returns_unchanged_when_empty(): + pi = PromptInput(keybinding_manager=MagicMock()) + assert pi._highlight_image_refs("plain text") == "plain text" + + +def test_bracketed_paste_invokes_paste_handler(): + """When a paste_handler is supplied, bracketed-paste events route through + it before any text insertion happens.""" + seen: list[str] = [] + + def handler(text: str) -> bool: + seen.append(text) + return False + + pi = PromptInput(keybinding_manager=MagicMock(), paste_handler=handler) + pi._handle_key(KeyEvent(key="paste", char="hello world")) + assert seen == ["hello world"] + # Handler returned False → text inserted normally + assert pi._get_text() == "hello world" + + +def test_bracketed_paste_handler_consumed_suppresses_text_insert(): + """When paste_handler returns True, the text MUST NOT be inserted into + the buffer (the handler attached the content out-of-band, e.g. as an + image placeholder).""" + pi = PromptInput( + keybinding_manager=MagicMock(), + paste_handler=lambda _text: True, + ) + pi._handle_key(KeyEvent(key="paste", char="ignored content")) + assert pi._get_text() == "" + + +def test_bracketed_paste_without_handler_inserts_text(): + """Existing behavior preserved when paste_handler is not supplied.""" + pi = PromptInput(keybinding_manager=MagicMock()) + pi._handle_key(KeyEvent(key="paste", char="some text")) + assert pi._get_text() == "some text" diff --git a/tests/utils/image/__init__.py b/tests/utils/image/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/image/test_clipboard.py b/tests/utils/image/test_clipboard.py new file mode 100644 index 00000000..338b4d63 --- /dev/null +++ b/tests/utils/image/test_clipboard.py @@ -0,0 +1,378 @@ +import sys +from unittest.mock import MagicMock, patch + +from iac_code.utils.image import clipboard + + +def test_macos_has_image_via_osascript(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + fake = MagicMock(returncode=0, stdout=b"PNGf\n", stderr=b"") + with patch.object(clipboard.subprocess, "run", return_value=fake): + assert clipboard.has_image_in_clipboard() is True + + +def test_macos_no_image(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + + def fake_run(cmd, *a, **kw): + if cmd[0] == "osascript": + return MagicMock(returncode=0, stdout=b"", stderr=b"") + if cmd[0] == "swift": + # Swift fallback also finds no image + return MagicMock(returncode=1, stdout=b"", stderr=b"") + raise AssertionError(f"unexpected cmd: {cmd}") + + with patch.object(clipboard.subprocess, "run", side_effect=fake_run): + assert clipboard.has_image_in_clipboard() is False + + +def test_macos_has_image_falls_through_to_tiff(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + fake = MagicMock(returncode=0, stdout=b"TIFF\n", stderr=b"") + with patch.object(clipboard.subprocess, "run", return_value=fake): + assert clipboard.has_image_in_clipboard() is True + + +def test_macos_has_image_falls_through_to_jpeg(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + fake = MagicMock(returncode=0, stdout=b"JPEG\n", stderr=b"") + with patch.object(clipboard.subprocess, "run", return_value=fake): + assert clipboard.has_image_in_clipboard() is True + + +def test_macos_probe_uses_single_osascript_call(monkeypatch): + """Locks in the perf invariant: detection of any of {PNG, TIFF, JPEG} + must take exactly one osascript subprocess (the try-chain script).""" + monkeypatch.setattr(sys, "platform", "darwin") + calls: list[list[str]] = [] + + def fake_run(cmd, *a, **kw): + calls.append(cmd) + return MagicMock(returncode=0, stdout=b"TIFF\n", stderr=b"") + + with patch.object(clipboard.subprocess, "run", side_effect=fake_run): + clipboard.has_image_in_clipboard() + assert len(calls) == 1, f"expected 1 osascript call, got {len(calls)}: {calls}" + + +def test_linux_has_image_via_xclip(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + monkeypatch.setenv("DISPLAY", ":0") + fake = MagicMock(returncode=0, stdout=b"image/png\nUTF8_STRING\n", stderr=b"") + with patch.object(clipboard.subprocess, "run", return_value=fake): + with patch.object(clipboard, "_which", return_value="/usr/bin/xclip"): + assert clipboard.has_image_in_clipboard() is True + + +def test_unsupported_platform_returns_false(monkeypatch): + monkeypatch.setattr(sys, "platform", "openbsd") + assert clipboard.has_image_in_clipboard() is False + + +def test_try_read_image_from_path_absolute(tmp_path): + p = tmp_path / "x.png" + p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16) + out = clipboard.try_read_image_from_path(str(p)) + assert out is not None + assert out.media_type == "image/png" + + +def test_get_image_from_clipboard_macos_writes_png(monkeypatch, tmp_path): + monkeypatch.setattr(sys, "platform", "darwin") + fixed_tmp = tmp_path / "out.png" + png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 + + class _FakeNamedTemp: + def __init__(self, *a, **k): + self.name = str(fixed_tmp) + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + monkeypatch.setattr(clipboard.tempfile, "NamedTemporaryFile", _FakeNamedTemp) + + def fake_run(cmd, *a, **kw): + # The save script contains "open for access"; the probe does not. + joined = " ".join(cmd) + if cmd[0] == "osascript" and "open for access" in joined: + fixed_tmp.write_bytes(png_bytes) + return MagicMock(returncode=0, stdout=b"PNGf\n", stderr=b"") + if cmd[0] == "osascript": + # has_image probe + return MagicMock(returncode=0, stdout=b"PNGf\n", stderr=b"") + raise AssertionError(f"unexpected cmd: {cmd}") + + with patch.object(clipboard.subprocess, "run", side_effect=fake_run): + out = clipboard.get_image_from_clipboard() + + assert out is not None + assert out.media_type == "image/png" + assert out.data == png_bytes + + +def test_get_image_from_clipboard_macos_writes_tiff(monkeypatch, tmp_path): + """When clipboard only carries TIFF (e.g. Preview copy), the reader must + still return a ClipboardImage. detect_image_format falls back to image/png + for TIFF magic bytes — that's expected; downstream resizer normalises via + Pillow re-encode, so the intermediate media_type is harmless.""" + monkeypatch.setattr(sys, "platform", "darwin") + fixed_tmp = tmp_path / "out.png" + tiff_bytes = b"II*\x00" + b"\x00" * 16 # little-endian TIFF magic + + class _FakeNamedTemp: + def __init__(self, *a, **k): + self.name = str(fixed_tmp) + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + monkeypatch.setattr(clipboard.tempfile, "NamedTemporaryFile", _FakeNamedTemp) + + def fake_run(cmd, *a, **kw): + joined = " ".join(cmd) + if cmd[0] == "osascript" and "open for access" in joined: + fixed_tmp.write_bytes(tiff_bytes) + return MagicMock(returncode=0, stdout=b"TIFF\n", stderr=b"") + if cmd[0] == "osascript": + return MagicMock(returncode=0, stdout=b"TIFF\n", stderr=b"") + raise AssertionError(f"unexpected cmd: {cmd}") + + with patch.object(clipboard.subprocess, "run", side_effect=fake_run): + out = clipboard.get_image_from_clipboard() + + assert out is not None + assert out.data == tiff_bytes + + +def test_get_image_returns_none_when_no_image(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + + def fake_run(cmd, *a, **kw): + if cmd[0] == "osascript": + return MagicMock(returncode=0, stdout=b"", stderr=b"") + if cmd[0] == "swift": + return MagicMock(returncode=1, stdout=b"", stderr=b"") + raise AssertionError(f"unexpected cmd: {cmd}") + + with patch.object(clipboard.subprocess, "run", side_effect=fake_run): + assert clipboard.get_image_from_clipboard() is None + + +def test_subprocess_timeout_treated_as_no_image(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + + def boom(*a, **kw): + raise clipboard.subprocess.TimeoutExpired(cmd="osascript", timeout=2.0) + + with patch.object(clipboard.subprocess, "run", side_effect=boom): + assert clipboard.has_image_in_clipboard() is False + + +def test_linux_falls_back_to_xclip_when_wayland_empty(monkeypatch, tmp_path): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") + fixed_tmp = tmp_path / "out.png" + png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 + + class _FakeNamedTemp: + def __init__(self, *a, **k): + self.name = str(fixed_tmp) + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + monkeypatch.setattr(clipboard.tempfile, "NamedTemporaryFile", _FakeNamedTemp) + monkeypatch.setattr(clipboard, "_which", lambda *names: "/usr/bin/" + names[0]) + + def fake_run(cmd, *a, **kw): + if cmd[0] == "wl-paste" and "-l" in cmd: + # has_image probe — Wayland advertises image/png + return MagicMock(returncode=0, stdout=b"image/png\n", stderr=b"") + if cmd[0] == "wl-paste": + # read step — produces nothing (XWayland-only app case) + return MagicMock(returncode=0, stdout=b"", stderr=b"") + if cmd[0] == "xclip" and "-t" in cmd and "TARGETS" not in cmd: + # read step — write png bytes to the stdout fd + fd = kw.get("stdout") + if fd is not None and hasattr(fd, "write"): + fd.write(png_bytes) + else: + fixed_tmp.write_bytes(png_bytes) + return MagicMock(returncode=0, stdout=b"", stderr=b"") + # default + return MagicMock(returncode=0, stdout=b"", stderr=b"") + + with patch.object(clipboard.subprocess, "run", side_effect=fake_run): + out = clipboard.get_image_from_clipboard() + + assert out is not None + assert out.data == png_bytes + + +# ── UTI (modern pasteboard) tests ────────────────────────────────────── + + +def test_darwin_has_image_via_uti_detects_public_png(monkeypatch): + """Swift/NSPasteboard fallback detects public.png UTI.""" + monkeypatch.setattr(sys, "platform", "darwin") + fake = MagicMock(returncode=0, stdout=b"public.png\n", stderr=b"") + with patch.object(clipboard.subprocess, "run", return_value=fake): + assert clipboard._darwin_has_image_via_uti() is True + + +def test_darwin_has_image_via_uti_detects_public_tiff(monkeypatch): + """Swift/NSPasteboard fallback detects public.tiff UTI.""" + monkeypatch.setattr(sys, "platform", "darwin") + fake = MagicMock(returncode=0, stdout=b"public.tiff\n", stderr=b"") + with patch.object(clipboard.subprocess, "run", return_value=fake): + assert clipboard._darwin_has_image_via_uti() is True + + +def test_darwin_has_image_via_uti_no_image(monkeypatch): + """Swift/NSPasteboard fallback returns False when no image UTI is found.""" + monkeypatch.setattr(sys, "platform", "darwin") + fake = MagicMock(returncode=1, stdout=b"", stderr=b"") + with patch.object(clipboard.subprocess, "run", return_value=fake): + assert clipboard._darwin_has_image_via_uti() is False + + +def test_darwin_has_image_via_uti_swift_unavailable(monkeypatch): + """When swift binary is missing (_run raises OSError → returns None), + UTI detection degrades gracefully to False.""" + monkeypatch.setattr(sys, "platform", "darwin") + + def boom(*a, **kw): + raise OSError("No such file or directory: 'swift'") + + with patch.object(clipboard.subprocess, "run", side_effect=boom): + assert clipboard._darwin_has_image_via_uti() is False + + +def test_darwin_has_image_via_uti_timeout(monkeypatch): + """When swift command times out, _run returns None, UTI detection + returns False without crashing.""" + monkeypatch.setattr(sys, "platform", "darwin") + + def boom(*a, **kw): + raise clipboard.subprocess.TimeoutExpired(cmd="swift", timeout=10.0) + + with patch.object(clipboard.subprocess, "run", side_effect=boom): + assert clipboard._darwin_has_image_via_uti() is False + + +def test_has_image_in_clipboard_falls_back_to_uti(monkeypatch): + """AppleScript probe returns empty → UTI fallback detects image → True.""" + monkeypatch.setattr(sys, "platform", "darwin") + + def fake_run(cmd, *a, **kw): + if cmd[0] == "osascript": + return MagicMock(returncode=0, stdout=b"", stderr=b"") + if cmd[0] == "swift": + return MagicMock(returncode=0, stdout=b"public.png\n", stderr=b"") + raise AssertionError(f"unexpected cmd: {cmd}") + + with patch.object(clipboard.subprocess, "run", side_effect=fake_run): + assert clipboard.has_image_in_clipboard() is True + + +def test_has_image_in_clipboard_both_fail(monkeypatch): + """AppleScript probe empty + Swift UTI also fails → False.""" + monkeypatch.setattr(sys, "platform", "darwin") + + def fake_run(cmd, *a, **kw): + if cmd[0] == "osascript": + return MagicMock(returncode=0, stdout=b"", stderr=b"") + if cmd[0] == "swift": + return MagicMock(returncode=1, stdout=b"", stderr=b"") + raise AssertionError(f"unexpected cmd: {cmd}") + + with patch.object(clipboard.subprocess, "run", side_effect=fake_run): + assert clipboard.has_image_in_clipboard() is False + + +def test_darwin_read_image_via_uti_success(monkeypatch, tmp_path): + """Swift writes image data to tmp file and returns UTI type name.""" + monkeypatch.setattr(sys, "platform", "darwin") + out_file = tmp_path / "clipboard.png" + png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 + + def fake_run(cmd, *a, **kw): + # Simulate Swift writing data to the file embedded in its source + out_file.write_bytes(png_bytes) + return MagicMock(returncode=0, stdout=b"public.png\n", stderr=b"") + + with patch.object(clipboard.subprocess, "run", side_effect=fake_run): + uti = clipboard._darwin_read_image_via_uti(str(out_file)) + + assert uti == "public.png" + assert out_file.read_bytes() == png_bytes + + +def test_darwin_read_image_via_uti_failure(monkeypatch): + """Swift returns non-zero exit → _darwin_read_image_via_uti returns None.""" + monkeypatch.setattr(sys, "platform", "darwin") + fake = MagicMock(returncode=1, stdout=b"", stderr=b"some error") + with patch.object(clipboard.subprocess, "run", return_value=fake): + assert clipboard._darwin_read_image_via_uti("/tmp/nope.png") is None + + +def test_darwin_read_image_via_uti_run_returns_none(monkeypatch): + """When _run returns None (timeout/OSError), read returns None.""" + monkeypatch.setattr(sys, "platform", "darwin") + + def boom(*a, **kw): + raise OSError("swift not found") + + with patch.object(clipboard.subprocess, "run", side_effect=boom): + assert clipboard._darwin_read_image_via_uti("/tmp/nope.png") is None + + +def test_get_image_from_clipboard_falls_back_to_uti_read(monkeypatch, tmp_path): + """AppleScript save produces empty format → UTI fallback writes data and + returns a valid ClipboardImage.""" + monkeypatch.setattr(sys, "platform", "darwin") + fixed_tmp = tmp_path / "out.png" + png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 + + class _FakeNamedTemp: + def __init__(self, *a, **k): + self.name = str(fixed_tmp) + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + monkeypatch.setattr(clipboard.tempfile, "NamedTemporaryFile", _FakeNamedTemp) + + def fake_run(cmd, *a, **kw): + if cmd[0] == "osascript": + joined = " ".join(cmd) + if "open for access" in joined: + # save script — returns empty format (AppleScript can't write) + return MagicMock(returncode=0, stdout=b"\n", stderr=b"") + # probe script — returns empty so fallback triggers + return MagicMock(returncode=0, stdout=b"", stderr=b"") + if cmd[0] == "swift": + # UTI fallback — write image data and succeed + fixed_tmp.write_bytes(png_bytes) + return MagicMock(returncode=0, stdout=b"public.png\n", stderr=b"") + raise AssertionError(f"unexpected cmd: {cmd}") + + with patch.object(clipboard.subprocess, "run", side_effect=fake_run): + out = clipboard.get_image_from_clipboard() + + assert out is not None + assert out.data == png_bytes + assert out.media_type == "image/png" diff --git a/tests/utils/image/test_format_detect.py b/tests/utils/image/test_format_detect.py new file mode 100644 index 00000000..a5d0851b --- /dev/null +++ b/tests/utils/image/test_format_detect.py @@ -0,0 +1,32 @@ +from iac_code.utils.image.format_detect import ( + IMAGE_EXTENSION_REGEX, + detect_image_format, +) + + +def test_png_magic_bytes(): + assert detect_image_format(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16) == "image/png" + + +def test_jpeg_magic_bytes(): + assert detect_image_format(b"\xff\xd8\xff\xe0" + b"\x00" * 16) == "image/jpeg" + + +def test_gif_magic_bytes(): + assert detect_image_format(b"GIF89a" + b"\x00" * 16) == "image/gif" + + +def test_webp_magic_bytes(): + buf = b"RIFF\x00\x00\x00\x00WEBP" + b"\x00" * 16 + assert detect_image_format(buf) == "image/webp" + + +def test_unknown_defaults_to_png(): + assert detect_image_format(b"junkjunk") == "image/png" + + +def test_extension_regex(): + assert IMAGE_EXTENSION_REGEX.search("foo.png") + assert IMAGE_EXTENSION_REGEX.search("/tmp/Bar.JPEG") + assert IMAGE_EXTENSION_REGEX.search("a.WEBP") + assert not IMAGE_EXTENSION_REGEX.search("foo.txt") diff --git a/tests/utils/image/test_pasted_content.py b/tests/utils/image/test_pasted_content.py new file mode 100644 index 00000000..1971b7f0 --- /dev/null +++ b/tests/utils/image/test_pasted_content.py @@ -0,0 +1,22 @@ +from iac_code.utils.image.pasted_content import ( + PastedContent, + format_image_ref, + parse_image_refs, +) + + +def test_format_image_ref(): + assert format_image_ref(3) == "[Image #3]" + + +def test_parse_image_refs_at_arbitrary_positions(): + text = "look at [Image #1] and also [Image #4] and [Image #1] again" + refs = parse_image_refs(text) + assert [r.id for r in refs] == [1, 4, 1] + assert refs[1].start == text.index("[Image #4]") + + +def test_pasted_content_image_validation(): + pc = PastedContent(id=1, type="image", content="aGVsbG8=", media_type="image/png") + assert pc.is_valid_image() is True + assert PastedContent(id=2, type="image", content="").is_valid_image() is False diff --git a/tests/utils/image/test_processor.py b/tests/utils/image/test_processor.py new file mode 100644 index 00000000..d247696e --- /dev/null +++ b/tests/utils/image/test_processor.py @@ -0,0 +1,113 @@ +import base64 + +from iac_code.agent.message import ImageBlock, TextBlock +from iac_code.utils.image.pasted_content import PastedContent +from iac_code.utils.image.processor import process_user_input + + +def _b64_png(w=10, h=10): + import io + + from PIL import Image + + buf = io.BytesIO() + Image.new("RGB", (w, h), color=(0, 0, 0)).save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode() + + +def test_text_only_returns_single_text_block(): + blocks = process_user_input("hello world", pasted_contents={}) + assert blocks == [TextBlock(text="hello world")] + + +def test_image_at_arbitrary_position_produces_interleaved_blocks(): + pc = {1: PastedContent(id=1, type="image", content=_b64_png(), media_type="image/png")} + blocks = process_user_input("look at [Image #1] please", pasted_contents=pc) + assert len(blocks) == 3 + assert isinstance(blocks[0], TextBlock) and blocks[0].text == "look at " + assert isinstance(blocks[1], ImageBlock) + assert isinstance(blocks[2], TextBlock) and blocks[2].text == " please" + + +def test_multiple_images_preserve_order(): + pc = { + 1: PastedContent(id=1, type="image", content=_b64_png(), media_type="image/png"), + 2: PastedContent(id=2, type="image", content=_b64_png(), media_type="image/png"), + } + blocks = process_user_input("[Image #2][Image #1]", pasted_contents=pc) + image_blocks = [b for b in blocks if isinstance(b, ImageBlock)] + assert image_blocks == [blocks[0], blocks[1]] + + +def test_unknown_image_ref_kept_as_text(): + blocks = process_user_input("see [Image #99]", pasted_contents={}) + assert blocks == [TextBlock(text="see [Image #99]")] + + +def test_image_at_position_zero(): + pc = {1: PastedContent(id=1, type="image", content=_b64_png(), media_type="image/png")} + blocks = process_user_input("[Image #1] caption", pasted_contents=pc) + # No leading empty text block + assert isinstance(blocks[0], ImageBlock) + assert isinstance(blocks[1], TextBlock) and blocks[1].text == " caption" + assert all(not (isinstance(b, TextBlock) and b.text == "") for b in blocks) + + +def test_image_at_end_of_string(): + pc = {1: PastedContent(id=1, type="image", content=_b64_png(), media_type="image/png")} + blocks = process_user_input("look at [Image #1]", pasted_contents=pc) + assert isinstance(blocks[0], TextBlock) and blocks[0].text == "look at " + assert isinstance(blocks[1], ImageBlock) + assert len(blocks) == 2 # no trailing empty text + + +def test_empty_text_returns_empty_list(): + blocks = process_user_input("", pasted_contents={}) + assert blocks == [] + + +def test_invalid_paste_entry_treated_as_text(): + # PastedContent exists but is text/empty — should be treated like an unknown ref. + pc = { + 1: PastedContent(id=1, type="text", content="not an image"), + 2: PastedContent(id=2, type="image", content="", media_type="image/png"), + } + blocks = process_user_input("a [Image #1] b [Image #2] c", pasted_contents=pc) + # Both should be preserved as plain text — single text block carries everything + assert blocks == [TextBlock(text="a [Image #1] b [Image #2] c")] + + +def test_mixed_valid_and_unknown_ids(): + pc = {1: PastedContent(id=1, type="image", content=_b64_png(), media_type="image/png")} + blocks = process_user_input("see [Image #1] and [Image #99]", pasted_contents=pc) + # The valid ref becomes ImageBlock; the unknown one stays as text in the trailing TextBlock. + assert isinstance(blocks[0], TextBlock) and blocks[0].text == "see " + assert isinstance(blocks[1], ImageBlock) + assert isinstance(blocks[2], TextBlock) and blocks[2].text == " and [Image #99]" + + +def test_processor_passes_through_data_without_recompressing(monkeypatch): + """REPL already resized; processor must not Pillow-decode/re-encode again.""" + import iac_code.utils.image.resizer as resizer_mod + + def _boom(*_a, **_kw): + raise AssertionError("processor should not call maybe_resize_and_downsample") + + monkeypatch.setattr(resizer_mod, "maybe_resize_and_downsample", _boom) + + payload = _b64_png() + pc = {1: PastedContent(id=1, type="image", content=payload, media_type="image/jpeg")} + blocks = process_user_input("[Image #1]", pasted_contents=pc) + + assert len(blocks) == 1 + assert isinstance(blocks[0], ImageBlock) + assert blocks[0].data == payload # passthrough, not re-encoded + assert blocks[0].media_type == "image/jpeg" + + +def test_processor_defaults_media_type_when_missing(): + """If PastedContent.media_type is None, fall back to image/png.""" + pc = {1: PastedContent(id=1, type="image", content=_b64_png(), media_type=None)} + blocks = process_user_input("[Image #1]", pasted_contents=pc) + assert isinstance(blocks[0], ImageBlock) + assert blocks[0].media_type == "image/png" diff --git a/tests/utils/image/test_resizer.py b/tests/utils/image/test_resizer.py new file mode 100644 index 00000000..60518f0d --- /dev/null +++ b/tests/utils/image/test_resizer.py @@ -0,0 +1,55 @@ +import io + +import pytest +from PIL import Image + +from iac_code.utils.image.resizer import ( + ImageResizeError, + maybe_resize_and_downsample, +) + + +def _make_png(w: int, h: int, color=(255, 0, 0)) -> bytes: + buf = io.BytesIO() + Image.new("RGB", (w, h), color=color).save(buf, format="PNG") + return buf.getvalue() + + +def test_small_png_passes_through(): + raw = _make_png(100, 100) + out = maybe_resize_and_downsample(raw) + assert out.media_type == "image/png" + assert out.dimensions.display_width == 100 + assert out.data == raw # untouched + + +def test_oversized_dimension_is_scaled_down(): + raw = _make_png(3000, 1500) + out = maybe_resize_and_downsample(raw) + assert out.dimensions.display_width <= 2000 + assert out.dimensions.display_height <= 2000 + + +def test_empty_buffer_raises(): + with pytest.raises(ImageResizeError): + maybe_resize_and_downsample(b"") + + +def test_max_base64_size_respected(): + raw = _make_png(2400, 2400, color=(255, 255, 255)) + out = maybe_resize_and_downsample(raw) + import base64 + + assert len(base64.b64encode(out.data)) <= 5 * 1024 * 1024 + + +def test_bmp_input_is_converted_to_png(): + buf = io.BytesIO() + Image.new("RGB", (50, 50), color=(0, 255, 0)).save(buf, format="BMP") + raw = buf.getvalue() + assert raw[:2] == b"BM" # sanity: confirm we made BMP + + out = maybe_resize_and_downsample(raw) + assert out.media_type == "image/png" + # Output bytes must be PNG-shaped (not BMP) + assert out.data[:8] == b"\x89PNG\r\n\x1a\n" diff --git a/tests/utils/image/test_store.py b/tests/utils/image/test_store.py new file mode 100644 index 00000000..2e758fef --- /dev/null +++ b/tests/utils/image/test_store.py @@ -0,0 +1,122 @@ +from pathlib import Path + +import pytest + +from iac_code.utils.image.pasted_content import PastedContent +from iac_code.utils.image.store import ImageStore, cleanup_old_image_caches + + +def test_store_writes_per_session_file_with_0o600(tmp_path, monkeypatch): + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "image-cache") + store = ImageStore(session_id="sess-a") + pc = PastedContent(id=7, type="image", content="aGVsbG8=", media_type="image/png") + path = store.store(pc) + assert path is not None + p = Path(path) + assert p.exists() + assert p.parent.name == "sess-a" + assert p.name == "7.png" + import os + import stat + + if os.name == "posix": + assert stat.S_IMODE(p.stat().st_mode) == 0o600 + + +def test_lru_eviction_cap(tmp_path, monkeypatch): + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "image-cache") + monkeypatch.setattr("iac_code.utils.image.store.MAX_STORED_IMAGE_PATHS", 3) + store = ImageStore(session_id="sess") + for i in range(5): + store.cache_path(i, str(tmp_path / f"f{i}.png")) + assert store.get_path(0) is None # evicted + assert store.get_path(4) is not None + + +def test_cleanup_only_deletes_other_sessions(tmp_path, monkeypatch): + import os + import time + + base = tmp_path / "image-cache" + (base / "current").mkdir(parents=True) + (base / "old").mkdir(parents=True) + (base / "current" / "x.png").write_bytes(b"1") + (base / "old" / "y.png").write_bytes(b"2") + # Backdate "old" past the cleanup threshold; "current" stays fresh. + stale = time.time() - (48 * 60 * 60) + os.utime(base / "old", (stale, stale)) + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: base) + cleanup_old_image_caches(current_session_id="current") + assert (base / "current" / "x.png").exists() + assert not (base / "old").exists() + + +def test_cleanup_preserves_recent_sibling_sessions(tmp_path, monkeypatch): + """Concurrent REPL sessions: a sibling session's fresh dir must NOT be + purged just because we're not it. Regression for the cross-session + cache-wipe race introduced with multimodal image input.""" + base = tmp_path / "image-cache" + (base / "current").mkdir(parents=True) + (base / "sibling-active").mkdir(parents=True) + (base / "sibling-active" / "y.png").write_bytes(b"2") + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: base) + cleanup_old_image_caches(current_session_id="current") + assert (base / "sibling-active" / "y.png").exists() + + +def test_cleanup_max_age_threshold_is_configurable(tmp_path, monkeypatch): + import os + import time + + base = tmp_path / "image-cache" + (base / "current").mkdir(parents=True) + (base / "older").mkdir(parents=True) + (base / "older" / "z.png").write_bytes(b"3") + aged = time.time() - 120 + os.utime(base / "older", (aged, aged)) + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: base) + # Threshold below the dir's age → eligible for deletion. + cleanup_old_image_caches(current_session_id="current", max_age_seconds=60) + assert not (base / "older").exists() + + +def test_store_returns_none_on_invalid_image(tmp_path, monkeypatch): + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "image-cache") + store = ImageStore(session_id="sess") + pc = PastedContent(id=1, type="text", content="hello") + assert store.store(pc) is None + + +def test_store_returns_none_on_bad_base64(tmp_path, monkeypatch): + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "image-cache") + store = ImageStore(session_id="sess") + pc = PastedContent(id=2, type="image", content="!!!not-base64!!!", media_type="image/png") + assert store.store(pc) is None + + +def test_cache_path_re_promotes_existing_entry(tmp_path, monkeypatch): + monkeypatch.setattr("iac_code.utils.image.store.MAX_STORED_IMAGE_PATHS", 2) + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "image-cache") + store = ImageStore(session_id="sess") + store.cache_path(1, "/p/1.png") + store.cache_path(2, "/p/2.png") + # Touch 1 → 1 should now be most-recent → adding 3 evicts 2 (not 1). + store.cache_path(1, "/p/1.png") + store.cache_path(3, "/p/3.png") + assert store.get_path(1) is not None + assert store.get_path(2) is None + assert store.get_path(3) is not None + + +def test_invalid_session_id_rejected(): + with pytest.raises(ValueError): + ImageStore(session_id="") + with pytest.raises(ValueError): + ImageStore(session_id="../escape") + with pytest.raises(ValueError): + ImageStore(session_id="a/b") + + +def test_cleanup_with_invalid_session_id(): + with pytest.raises(ValueError): + cleanup_old_image_caches(current_session_id="../escape") diff --git a/tests/utils/test_background_housekeeping.py b/tests/utils/test_background_housekeeping.py index 674a2afc..917c2e75 100644 --- a/tests/utils/test_background_housekeeping.py +++ b/tests/utils/test_background_housekeeping.py @@ -18,19 +18,21 @@ def test_calls_cleanup_after_delay(self, tmp_path): os.utime(old_file, (old_time, old_time)) # 用 delay=0 立即执行 - thread = start_background_housekeeping(base_dir=base_dir, delay_seconds=0) - thread.join(timeout=5) + threads = start_background_housekeeping(base_dir=base_dir, delay_seconds=0) + for t in threads: + t.join(timeout=5) assert not os.path.exists(old_file) def test_does_not_block_caller(self, tmp_path): """start_background_housekeeping 应立即返回(daemon 线程)。""" - thread = start_background_housekeeping(base_dir=str(tmp_path), delay_seconds=9999) - assert thread.daemon is True + threads = start_background_housekeeping(base_dir=str(tmp_path), delay_seconds=9999) + assert all(t.daemon for t in threads) # 不等待,直接验证线程是 daemon def test_no_error_on_missing_dir(self): """base_dir 不存在时不报错。""" - thread = start_background_housekeeping(base_dir="/nonexistent/cleanup/dir", delay_seconds=0) - thread.join(timeout=5) + threads = start_background_housekeeping(base_dir="/nonexistent/cleanup/dir", delay_seconds=0) + for t in threads: + t.join(timeout=5) # 没有异常即通过 diff --git a/tests/utils/test_background_housekeeping_image.py b/tests/utils/test_background_housekeeping_image.py new file mode 100644 index 00000000..9eae1002 --- /dev/null +++ b/tests/utils/test_background_housekeeping_image.py @@ -0,0 +1,37 @@ +from unittest.mock import patch + +from iac_code.utils.background_housekeeping import _run_image_cleanup + + +def test_image_cleanup_invokes_helper(): + with patch("iac_code.utils.image.store.cleanup_old_image_caches") as mock_cleanup: + _run_image_cleanup(current_session_id="abc", delay_seconds=0) + mock_cleanup.assert_called_once_with(current_session_id="abc") + + +def test_start_background_housekeeping_with_session_id(): + """Smoke test: passing session_id starts BOTH cleanup threads (tool-results and image cache).""" + from iac_code.utils.background_housekeeping import start_background_housekeeping + + threads = start_background_housekeeping( + base_dir="/tmp/iac-bg-housekeeping-test", + delay_seconds=0, + session_id="abc", + ) + assert isinstance(threads, tuple) + assert len(threads) == 2 + for t in threads: + t.join(timeout=5) + + +def test_start_background_housekeeping_without_session_id_returns_single_tuple(): + """Without session_id only the tool-result cleanup thread is started.""" + from iac_code.utils.background_housekeeping import start_background_housekeeping + + threads = start_background_housekeeping( + base_dir="/tmp/iac-bg-housekeeping-test", + delay_seconds=0, + ) + assert isinstance(threads, tuple) + assert len(threads) == 1 + threads[0].join(timeout=5) diff --git a/uv.lock b/uv.lock index a94e238c..57adbf6c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", @@ -1252,6 +1252,7 @@ dependencies = [ { name = "openai" }, { name = "opentelemetry-distro" }, { name = "opentelemetry-exporter-otlp" }, + { name = "pillow" }, { name = "pydantic" }, { name = "pyperclip" }, { name = "pyyaml" }, @@ -1318,6 +1319,7 @@ requires-dist = [ { name = "openai", specifier = ">=1.50" }, { name = "opentelemetry-distro", specifier = ">=0.48b0" }, { name = "opentelemetry-exporter-otlp", specifier = ">=1.27.0" }, + { name = "pillow", specifier = "==11.0.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyperclip", specifier = ">=1.8.0" }, { name = "pyyaml", specifier = ">=6.0" }, @@ -1950,6 +1952,73 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f" }, ] +[[package]] +name = "pillow" +version = "11.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a5/26/0d95c04c868f6bdb0c447e3ee2de5564411845e36a858cfd63766bc7b563/pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/98/fb/a6ce6836bd7fd93fbf9144bf54789e02babc27403b50a9e1583ee877d6da/pillow-11.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/1d/1f51e6e912d8ff316bb3935a8cda617c801783e0b998bf7a894e91d3bd4c/pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/83/e2077b0192ca8a9ef794dbb74700c7e48384706467067976c2a95a0f40a1/pillow-11.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a65149d8ada1055029fcb665452b2814fe7d7082fcb0c5bed6db851cb69b2086" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/74/467af0146970a98349cdf39e9b79a6cc8a2e7558f2c01c28a7b6b85c5bda/pillow-11.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88a58d8ac0cc0e7f3a014509f0455248a76629ca9b604eca7dc5927cc593c5e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/b1/d95d4f7ca3a6c1ae120959605875a31a3c209c4e50f0029dc1a87566cf46/pillow-11.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c26845094b1af3c91852745ae78e3ea47abf3dbcd1cf962f16b9a5fbe3ee8488" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/c3/94f33af0762ed76b5a237c5797e088aa57f2b7fa8ee7932d399087be66a8/pillow-11.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1a61b54f87ab5786b8479f81c4b11f4d61702830354520837f8cc791ebba0f5f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/3c/443e7ef01f597497268899e1cca95c0de947c9bbf77a8f18b3c126681e5d/pillow-11.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:674629ff60030d144b7bca2b8330225a9b11c482ed408813924619c6f302fdbb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/95/1495304448b0081e60c0c5d63f928ef48bb290acee7385804426fa395a21/pillow-11.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:598b4e238f13276e0008299bd2482003f48158e2b11826862b1eb2ad7c768b97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/da/861e1df971ef0de9870720cb309ca4d553b26a9483ec9be3a7bf1de4a095/pillow-11.0.0-cp310-cp310-win32.whl", hash = "sha256:9a0f748eaa434a41fccf8e1ee7a3eed68af1b690e75328fd7a60af123c193b50" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/4e/78f7c5202ea2a772a5ab05069c1b82503e6353cd79c7e474d4945f4b82c3/pillow-11.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5629742881bcbc1f42e840af185fd4d83a5edeb96475a575f4da50d6ede337c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/e4/6e84eada35cbcc646fc1870f72ccfd4afacb0fae0c37ffbffe7f5dc24bf1/pillow-11.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:ee217c198f2e41f184f3869f3e485557296d505b5195c513b2bfe0062dc537f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/eb/f7e21b113dd48a9c97d364e0915b3988c6a0b6207652f5a92372871b7aa4/pillow-11.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/b3/2b54a1d541accebe6bd8b1358b34ceb2c509f51cb7dcda8687362490da5b/pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/12/1a41eddad8265c5c19dda8fb6c269ce15ee25e0b9f8f26286e6202df6693/pillow-11.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/9b/8a8c4d07d77447b7457164b861d18f5a31ae6418ef5c07f6f878fa09039a/pillow-11.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/e4/130c5fab4a54d3991129800dd2801feeb4b118d7630148cd67f0e6269d4c/pillow-11.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/63/b3fc299528d7df1f678b0666002b37affe6b8751225c3d9c12cf530e73ed/pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/a6/694122c55b855b586c26c694937d36bb8d3b09c735ff41b2f315c6e66a10/pillow-11.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/a9/f9d763e2671a8acd53d29b1e284ca298bc10a595527f6be30233cdb9659d/pillow-11.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/0e/b5cbad2621377f11313a94aeb44ca55a9639adabcaaa073597a1925f8c26/pillow-11.0.0-cp311-cp311-win32.whl", hash = "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/83/1470c220a4ff06cd75fc609068f6605e567ea51df70557555c2ab6516b2c/pillow-11.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/98/def78c3a23acee2bcdb2e52005fb2810ed54305602ec1bfcfab2bda6f49f/pillow-11.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/a3/26e606ff0b2daaf120543e537311fa3ae2eb6bf061490e4fea51771540be/pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/d5/1caabedd8863526a6cfa44ee7a833bd97f945dc1d56824d6d76e11731939/pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/ff/5a45000826a1aa1ac6874b3ec5a856474821a1b59d838c4f6ce2ee518fe9/pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/21/84c9f287d17180f26263b5f5c8fb201de0f88b1afddf8a2597a5c9fe787f/pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/39/63fb87cd07cc541438b448b1fed467c4d687ad18aa786a7f8e67b255d1aa/pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/42/6e0f2c2d5c60f499aa29be14f860dd4539de322cd8fb84ee01553493fb4d/pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/69/1ef0fb9d2f8d2d114db982b78ca4eeb9db9a29f7477821e160b8c1253f67/pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/ea/dad2818c675c44f6012289a7c4f46068c548768bc6c7f4e8c4ae5bbbc811/pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/af/3a/da80224a6eb15bba7a0dcb2346e2b686bb9bf98378c0b4353cd88e62b171/pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/97/73f756c338c1d86bb802ee88c3cab015ad7ce4b838f8a24f16b676b1ac7c/pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/30/2b61876e2722374558b871dfbfcbe4e406626d63f4f6ed92e9c8e24cac37/pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/24/e2e15e392d00fcf4215907465d8ec2a2f23bcec1481a8ebe4ae760459995/pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/72/92ad4afaa2afc233dc44184adff289c2e77e8cd916b3ddb72ac69495bda3/pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/da/c8d69c5bc85d72a8523fe862f05ababdc52c0a755cfe3d362656bb86552b/pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/e8/686d0caeed6b998351d57796496a70185376ed9c8ec7d99e1d19ad591fc6/pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/da/430015cec620d622f06854be67fd2f6721f52fc17fca8ac34b32e2d60739/pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/ae/7e4f6662a9b1cb5f92b9cc9cab8321c381ffbee309210940e57432a4063a/pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/d5/1a807779ac8a0eeed57f2b92a3c32ea1b696e6140c15bd42eaf908a261cd/pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/8c/5fa3385163ee7080bc13026d59656267daaaaf3c728c233d530e2c2757c8/pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ca/1d/ad9c14811133977ff87035bf426875b93097fb50af747793f013979facdb/pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/01/3755ba287dac715e6afdb333cb1f6d69740a7475220b4637b5ce3d78cec2/pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/98/2c7d727079b6be1aba82d195767d35fcc2d32204c7a5820f822df5330152/pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/38/998b04cc6f474e78b563716b20eecf42a2fa16a84589d23c8898e64b0ffd/pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/8e/be23a96292113c6cb26b2aa3c8b3681ec62b44ed5c2bd0b258bd59503d3c/pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/8a/3db4eaabb7a2ae8203cd3a332a005e4aba00067fc514aaaf3e9721be31f1/pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/ac/629ffc84ff67b9228fe87a97272ab125bbd4dc462745f35f192d37b822f1/pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/07/a505921d36bb2df6868806eaf56ef58699c16c388e378b0dcdb6e5b2fb36/pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/b9/fb620dd47fc7cc9678af8f8bd8c772034ca4977237049287e99dda360b66/pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/86/25dde85c06c89d7fc5db17940f07aae0a56ac69aa9ccb5eb0f09798862a8/pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/85/9c33f2517add612e17f3381aee7c4072779130c634921a756c97bc29fb49/pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/57/42a4dd825eab762ba9e690d696d894ba366e06791936056e26e099398cda/pillow-11.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1187739620f2b365de756ce086fdb3604573337cc28a0d3ac4a01ab6b2d2a6d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/f7/25f9f9e368226a1d6cf3507081a1a7944eddd3ca7821023377043f5a83c8/pillow-11.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbbcb7b57dc9c794843e3d1258c0fbf0f48656d46ffe9e09b63bbd6e8cd5d0a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/01/98ead48a6c2e31e6185d4c16c978a67fe3ccb5da5c2ff2ba8475379bb693/pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d203af30149ae339ad1b4f710d9844ed8796e97fda23ffbc4cc472968a47d0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/c0/570255b2866a0e4d500a14f950803a2ec273bac7badc43320120b9262450/pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a0d3b115009ebb8ac3d2ebec5c2982cc693da935f4ab7bb5c8ebe2f47d36f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/75/689b4ec0483c42bfc7d1aacd32ade7a226db4f4fac57c6fdcdf90c0731e3/pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73853108f56df97baf2bb8b522f3578221e56f646ba345a372c78326710d3830" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/30/38bd6149cf53da1db4bad304c543ade775d225961c4310f30425995cb9ec/pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e58876c91f97b0952eb766123bfef372792ab3f4e3e1f1a2267834c2ab131734" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/3d/c32a51d848401bd94cabb8767a39621496491ee7cd5199856b77da9b18ad/pillow-11.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316" }, +] + [[package]] name = "platformdirs" version = "4.9.6"