crabcode 0.1.1__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. {crabcode-0.1.1 → crabcode-0.1.3}/.gitignore +2 -1
  2. {crabcode-0.1.1 → crabcode-0.1.3}/PKG-INFO +16 -1
  3. {crabcode-0.1.1 → crabcode-0.1.3}/README.md +16 -5
  4. {crabcode-0.1.1 → crabcode-0.1.3}/README.zh-CN.md +16 -5
  5. {crabcode-0.1.1 → crabcode-0.1.3}/packages/cli/crabcode_cli/__init__.py +1 -1
  6. crabcode-0.1.3/packages/cli/crabcode_cli/__main__.py +4 -0
  7. {crabcode-0.1.1 → crabcode-0.1.3}/packages/cli/crabcode_cli/app.py +29 -2
  8. {crabcode-0.1.1 → crabcode-0.1.3}/packages/cli/crabcode_cli/pipe.py +50 -3
  9. {crabcode-0.1.1 → crabcode-0.1.3}/packages/cli/crabcode_cli/repl.py +170 -12
  10. {crabcode-0.1.1 → crabcode-0.1.3}/packages/cli/pyproject.toml +2 -2
  11. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/__init__.py +1 -1
  12. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/agent_manager.py +1 -0
  13. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/api/anthropic_adapter.py +8 -0
  14. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/api/azure_adapter.py +2 -0
  15. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/api/base.py +2 -1
  16. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/api/codex_adapter.py +271 -9
  17. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/api/openai_adapter.py +30 -2
  18. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/events.py +82 -2
  19. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/logging_utils.py +11 -2
  20. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/prompts/system.py +17 -13
  21. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/prompts/templates.py +2 -0
  22. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/query/loop.py +134 -38
  23. crabcode-0.1.3/packages/core/crabcode_core/schedule/__init__.py +5 -0
  24. crabcode-0.1.3/packages/core/crabcode_core/schedule/models.py +204 -0
  25. crabcode-0.1.3/packages/core/crabcode_core/schedule/store.py +349 -0
  26. crabcode-0.1.3/packages/core/crabcode_core/session/__init__.py +12 -0
  27. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/session/storage.py +19 -0
  28. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/__init__.py +4 -0
  29. crabcode-0.1.3/packages/core/crabcode_core/tools/checkpoint.py +66 -0
  30. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/file_edit.py +28 -2
  31. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/grep.py +6 -2
  32. crabcode-0.1.3/packages/core/crabcode_core/tools/revert.py +90 -0
  33. crabcode-0.1.3/packages/core/crabcode_core/tools/schedule.py +612 -0
  34. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/types/config.py +17 -0
  35. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/types/event.py +19 -0
  36. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/types/message.py +12 -1
  37. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/types/tool.py +2 -0
  38. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/pyproject.toml +1 -1
  39. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/__init__.py +1 -1
  40. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/acp/agent.py +1 -1
  41. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/event_bus.py +15 -6
  42. {crabcode-0.1.1/packages/gateway/crabcode_gateway/grpc_ → crabcode-0.1.3/packages/gateway/crabcode_gateway/grpc}/server.py +18 -18
  43. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/middleware.py +99 -25
  44. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/routes/config.py +92 -9
  45. crabcode-0.1.3/packages/gateway/crabcode_gateway/routes/event.py +356 -0
  46. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/routes/permission.py +1 -0
  47. crabcode-0.1.3/packages/gateway/crabcode_gateway/routes/session.py +371 -0
  48. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/routes/snapshot.py +23 -0
  49. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/schemas.py +40 -1
  50. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/server.py +2 -2
  51. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/pyproject.toml +2 -2
  52. {crabcode-0.1.1 → crabcode-0.1.3}/packages/search/crabcode_search/embedder.py +57 -7
  53. {crabcode-0.1.1 → crabcode-0.1.3}/packages/search/pyproject.toml +2 -2
  54. crabcode-0.1.3/packages/vscode/.vscode/launch.json +19 -0
  55. crabcode-0.1.3/packages/vscode/.vscode/tasks.json +18 -0
  56. crabcode-0.1.3/packages/vscode/esbuild.js +35 -0
  57. crabcode-0.1.3/packages/vscode/logo.png +0 -0
  58. crabcode-0.1.3/packages/vscode/package-lock.json +545 -0
  59. crabcode-0.1.3/packages/vscode/package.json +219 -0
  60. crabcode-0.1.3/packages/vscode/resources/icon.svg +17 -0
  61. crabcode-0.1.3/packages/vscode/scripts/gen-types.py +413 -0
  62. crabcode-0.1.3/packages/vscode/src/chatPanel.ts +5930 -0
  63. crabcode-0.1.3/packages/vscode/src/client/protocol.ts +164 -0
  64. crabcode-0.1.3/packages/vscode/src/client/types.ts +392 -0
  65. crabcode-0.1.3/packages/vscode/src/connection.ts +410 -0
  66. crabcode-0.1.3/packages/vscode/src/extension.ts +697 -0
  67. crabcode-0.1.3/packages/vscode/src/gatewayManager.ts +466 -0
  68. crabcode-0.1.3/packages/vscode/src/pendingEdits.ts +1890 -0
  69. crabcode-0.1.3/packages/vscode/tsconfig.json +19 -0
  70. {crabcode-0.1.1 → crabcode-0.1.3}/pyproject.toml +4 -1
  71. crabcode-0.1.1/packages/core/crabcode_core/session/__init__.py +0 -4
  72. crabcode-0.1.1/packages/gateway/crabcode_gateway/routes/event.py +0 -168
  73. crabcode-0.1.1/packages/gateway/crabcode_gateway/routes/session.py +0 -159
  74. crabcode-0.1.1/tests/test_acp.py +0 -267
  75. crabcode-0.1.1/tests/test_browser_tool.py +0 -382
  76. crabcode-0.1.1/tests/test_hooks.py +0 -170
  77. crabcode-0.1.1/tests/test_imports.py +0 -14
  78. crabcode-0.1.1/tests/test_logging.py +0 -88
  79. crabcode-0.1.1/tests/test_multi_agent.py +0 -415
  80. crabcode-0.1.1/tests/test_plan_mode.py +0 -738
  81. crabcode-0.1.1/tests/test_provider_adapters.py +0 -393
  82. crabcode-0.1.1/tests/test_snapshot.py +0 -293
  83. crabcode-0.1.1/tests/test_tool_input_helpers.py +0 -108
  84. crabcode-0.1.1/tests/test_web_search.py +0 -462
  85. {crabcode-0.1.1 → crabcode-0.1.3}/.github/workflows/python-publish.yml +0 -0
  86. {crabcode-0.1.1 → crabcode-0.1.3}/packages/cli/crabcode_cli/banner.py +0 -0
  87. {crabcode-0.1.1 → crabcode-0.1.3}/packages/cli/crabcode_cli/render/__init__.py +0 -0
  88. {crabcode-0.1.1 → crabcode-0.1.3}/packages/cli/crabcode_cli/render/messages.py +0 -0
  89. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/api/__init__.py +0 -0
  90. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/api/gemini_adapter.py +0 -0
  91. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/api/model_info.py +0 -0
  92. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/api/ollama_adapter.py +0 -0
  93. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/api/registry.py +0 -0
  94. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/compact/__init__.py +0 -0
  95. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/compact/compact.py +0 -0
  96. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/config/__init__.py +0 -0
  97. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/config/claudemd.py +0 -0
  98. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/config/manager.py +0 -0
  99. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/hooks/__init__.py +0 -0
  100. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/hooks/manager.py +0 -0
  101. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/lsp/__init__.py +0 -0
  102. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/lsp/client.py +0 -0
  103. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/lsp/diagnostics.py +0 -0
  104. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/lsp/manager.py +0 -0
  105. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/mcp/__init__.py +0 -0
  106. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/mcp/client.py +0 -0
  107. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/mcp/config.py +0 -0
  108. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/permissions/__init__.py +0 -0
  109. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/permissions/manager.py +0 -0
  110. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/plan/__init__.py +0 -0
  111. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/plan/executor.py +0 -0
  112. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/plan/types.py +0 -0
  113. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/prompts/__init__.py +0 -0
  114. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/prompts/context.py +0 -0
  115. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/prompts/profile.py +0 -0
  116. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/query/__init__.py +0 -0
  117. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/session/export.py +0 -0
  118. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/session/meta_db.py +0 -0
  119. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/session/title_gen.py +0 -0
  120. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/skills/__init__.py +0 -0
  121. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/skills/loader.py +0 -0
  122. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/skills/matcher.py +0 -0
  123. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/snapshot/__init__.py +0 -0
  124. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/snapshot/snapshot.py +0 -0
  125. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/snapshot/tracker.py +0 -0
  126. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/team/__init__.py +0 -0
  127. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/team/bridge.py +0 -0
  128. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/team/inbox.py +0 -0
  129. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/team/manager.py +0 -0
  130. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/team/message_bus.py +0 -0
  131. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/team/models.py +0 -0
  132. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/team/recovery.py +0 -0
  133. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/_input_helpers.py +0 -0
  134. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/agent.py +0 -0
  135. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/ask_user.py +0 -0
  136. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/bash.py +0 -0
  137. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/browser.py +0 -0
  138. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/checklist.py +0 -0
  139. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/diff_utils.py +0 -0
  140. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/file_read.py +0 -0
  141. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/file_write.py +0 -0
  142. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/glob.py +0 -0
  143. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/lint.py +0 -0
  144. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/memory.py +0 -0
  145. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/skill.py +0 -0
  146. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/switch_mode.py +0 -0
  147. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/team.py +0 -0
  148. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/tools/web_search.py +0 -0
  149. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/types/__init__.py +0 -0
  150. {crabcode-0.1.1 → crabcode-0.1.3}/packages/core/crabcode_core/utf8_sanitize.py +0 -0
  151. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/acp/__init__.py +0 -0
  152. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/acp/session.py +0 -0
  153. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/acp/transport.py +0 -0
  154. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/acp/types.py +0 -0
  155. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/adapter.py +0 -0
  156. {crabcode-0.1.1/packages/gateway/crabcode_gateway/grpc_ → crabcode-0.1.3/packages/gateway/crabcode_gateway/grpc}/__init__.py +0 -0
  157. {crabcode-0.1.1/packages/gateway/crabcode_gateway/grpc_ → crabcode-0.1.3/packages/gateway/crabcode_gateway/grpc}/proto/crabcode.proto +0 -0
  158. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/routes/__init__.py +0 -0
  159. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/routes/agent.py +0 -0
  160. {crabcode-0.1.1 → crabcode-0.1.3}/packages/gateway/crabcode_gateway/routes/health.py +0 -0
  161. {crabcode-0.1.1 → crabcode-0.1.3}/packages/search/crabcode_search/__init__.py +0 -0
  162. {crabcode-0.1.1 → crabcode-0.1.3}/packages/search/crabcode_search/background.py +0 -0
  163. {crabcode-0.1.1 → crabcode-0.1.3}/packages/search/crabcode_search/chunker.py +0 -0
  164. {crabcode-0.1.1 → crabcode-0.1.3}/packages/search/crabcode_search/indexer.py +0 -0
  165. {crabcode-0.1.1 → crabcode-0.1.3}/packages/search/crabcode_search/store.py +0 -0
  166. {crabcode-0.1.1 → crabcode-0.1.3}/packages/search/crabcode_search/tool.py +0 -0
@@ -10,4 +10,5 @@ build/
10
10
  .ruff_cache/
11
11
  .crabcode/
12
12
  .claude/
13
- .DS_Store
13
+ .DS_Store
14
+ node_modules
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crabcode
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: CrabCode - AI coding assistant in the terminal
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: anthropic>=0.40.0
@@ -12,6 +12,21 @@ Requires-Dist: pydantic>=2.0.0
12
12
  Requires-Dist: rich>=13.0.0
13
13
  Requires-Dist: tiktoken>=0.7.0
14
14
  Requires-Dist: typer>=0.12.0
15
+ Provides-Extra: all
16
+ Requires-Dist: agent-client-protocol>=0.9.0; extra == 'all'
17
+ Requires-Dist: anthropic[bedrock]>=0.40.0; extra == 'all'
18
+ Requires-Dist: anthropic[vertex]>=0.40.0; extra == 'all'
19
+ Requires-Dist: fastapi>=0.115.0; extra == 'all'
20
+ Requires-Dist: grpcio-health-checking>=1.65.0; extra == 'all'
21
+ Requires-Dist: grpcio>=1.65.0; extra == 'all'
22
+ Requires-Dist: httpx>=0.27.0; extra == 'all'
23
+ Requires-Dist: modelscope>=1.10.0; extra == 'all'
24
+ Requires-Dist: playwright>=1.48.0; extra == 'all'
25
+ Requires-Dist: sentence-transformers>=2.0.0; extra == 'all'
26
+ Requires-Dist: sse-starlette>=2.0.0; extra == 'all'
27
+ Requires-Dist: tree-sitter-language-pack>=1.0.0; extra == 'all'
28
+ Requires-Dist: usearch>=2.12.0; extra == 'all'
29
+ Requires-Dist: uvicorn[standard]>=0.30.0; extra == 'all'
15
30
  Provides-Extra: bedrock
16
31
  Requires-Dist: anthropic[bedrock]>=0.40.0; extra == 'bedrock'
17
32
  Provides-Extra: browser
@@ -30,12 +30,15 @@ pip install crabcode[search]
30
30
  pip install crabcode[bedrock] # AWS Bedrock
31
31
  pip install crabcode[vertex] # Google Vertex AI
32
32
 
33
+ # With gateway server support
34
+ pip install crabcode[gateway]
35
+
36
+ # Install all optional features at once
37
+ pip install crabcode[all]
38
+
33
39
  # Combine extras
34
40
  pip install crabcode[search,bedrock]
35
- # Example: browser + search
36
41
  pip install crabcode[browser,search]
37
- # With gateway server support
38
- pip install crabcode[gateway]
39
42
  ```
40
43
 
41
44
  ### Development
@@ -146,7 +149,7 @@ crabcode gateway --password secret
146
149
 
147
150
  **WebSocket `/ws`** supports incoming commands (`send_message`, `permission_response`, `choice_response`, `push_context`) and outgoing event payloads — a single connection handles all interaction, making it ideal for VSCode extensions.
148
151
 
149
- **gRPC** service is available when `--grpc-port` is set, with streaming `SendMessage` and `SubscribeEvents` RPCs. See `packages/gateway/crabcode_gateway/grpc_/proto/crabcode.proto` for the full service definition.
152
+ **gRPC** service is available when `--grpc-port` is set, with streaming `SendMessage` and `SubscribeEvents` RPCs. See `packages/gateway/crabcode_gateway/grpc/proto/crabcode.proto` for the full service definition.
150
153
 
151
154
  ### ACP (Agent Client Protocol) Support
152
155
 
@@ -243,6 +246,9 @@ Or configure in `~/.crabcode/settings.json`:
243
246
  "model": "gpt-4o",
244
247
  "base_url": "https://api.openai.com/v1",
245
248
  "api_key_env": "OPENAI_API_KEY",
249
+ "http_headers": {
250
+ "X-Workspace": "crabcode"
251
+ },
246
252
  "thinking_enabled": false,
247
253
  "max_tokens": 16384
248
254
  },
@@ -260,12 +266,17 @@ Or configure in `~/.crabcode/settings.json`:
260
266
  | `model` | Model ID | — |
261
267
  | `base_url` | Custom API endpoint (for routers or local deployments) | — |
262
268
  | `api_key_env` | **Name** of the env var that holds the API key (not the key itself) | `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` |
269
+ | `http_headers` | Extra HTTP headers to attach to every API request for this config | `{}` |
263
270
  | `format` | Wire format for router mode: `anthropic` \| `openai` \| `codex` | — |
264
271
  | `thinking_enabled` | Enable extended thinking (set `false` for models that don't support it) | `true` |
265
272
  | `thinking_budget` | Thinking token budget | `10000` |
273
+ | `reasoning_effort` | OpenAI Responses/Codex reasoning effort; when set, this takes precedence over the `thinking_budget` mapping. Options: `none` \| `minimal` \| `low` \| `medium` \| `high` \| `xhigh` | — |
266
274
  | `max_tokens` | Maximum output tokens | `16384` |
267
275
  | `timeout` | API call timeout in seconds (prevents hanging on slow/unresponsive APIs) | `300` |
268
276
  | `context_window` | Override the model's context window size (tokens). Used when auto-detection fails or is inaccurate — see [Context Window](#context-window) below. | auto-detected |
277
+ | `prompt_cache_key` | Prompt cache routing key for OpenAI Responses/Codex requests; defaults to `http_headers.session_id` when omitted | — |
278
+ | `prompt_cache_retention` | OpenAI Responses/Codex prompt cache retention policy: `in_memory` \| `24h` | — |
279
+ | `extra_body` | Provider-specific fields appended to the request JSON body | `{}` |
269
280
 
270
281
  The `env` map lets you define environment variables directly in the config file — they are injected at startup so you don't need to `export` them in your shell.
271
282
 
@@ -1365,6 +1376,6 @@ crabcode/
1365
1376
  │ │ ├── types.py # ACP types + tool kind mapping
1366
1377
  │ │ └── transport.py # stdio transport (run_agent wrapper)
1367
1378
  │ ├── routes/ # FastAPI route groups (session, agent, config, event, health)
1368
- │ └── grpc_/ # gRPC service + proto definition
1379
+ │ └── grpc/ # gRPC service + proto definition
1369
1380
  └── tests/
1370
1381
  ```
@@ -30,12 +30,15 @@ pip install crabcode[search]
30
30
  pip install crabcode[bedrock] # AWS Bedrock
31
31
  pip install crabcode[vertex] # Google Vertex AI
32
32
 
33
+ # 含网关服务器支持
34
+ pip install crabcode[gateway]
35
+
36
+ # 一键安装全部可选特性
37
+ pip install crabcode[all]
38
+
33
39
  # 组合安装
34
40
  pip install crabcode[search,bedrock]
35
- # 示例:浏览器 + 搜索
36
41
  pip install crabcode[browser,search]
37
- # 含网关服务器支持
38
- pip install crabcode[gateway]
39
42
  ```
40
43
 
41
44
  ### 开发模式
@@ -146,7 +149,7 @@ crabcode gateway --password secret
146
149
 
147
150
  **WebSocket `/ws`** 支持收发命令(`send_message`、`permission_response`、`choice_response`、`push_context`)和事件推送 —— 单个连接即可完成所有交互,适合 VSCode 扩展使用。
148
151
 
149
- **gRPC** 在启用 `--grpc-port` 后可用,提供流式 `SendMessage` 和 `SubscribeEvents` RPC。完整服务定义见 `packages/gateway/crabcode_gateway/grpc_/proto/crabcode.proto`。
152
+ **gRPC** 在启用 `--grpc-port` 后可用,提供流式 `SendMessage` 和 `SubscribeEvents` RPC。完整服务定义见 `packages/gateway/crabcode_gateway/grpc/proto/crabcode.proto`。
150
153
 
151
154
  ### ACP(Agent Client Protocol)支持
152
155
 
@@ -243,6 +246,9 @@ export AZURE_OPENAI_ENDPOINT=https://my-resource.openai.azure.com/
243
246
  "model": "gpt-4o",
244
247
  "base_url": "https://api.openai.com/v1",
245
248
  "api_key_env": "OPENAI_API_KEY",
249
+ "http_headers": {
250
+ "X-Workspace": "crabcode"
251
+ },
246
252
  "thinking_enabled": false,
247
253
  "max_tokens": 16384
248
254
  },
@@ -260,12 +266,17 @@ export AZURE_OPENAI_ENDPOINT=https://my-resource.openai.azure.com/
260
266
  | `model` | 模型 ID | — |
261
267
  | `base_url` | 自定义 API 地址(适用于第三方转发或本地部署) | — |
262
268
  | `api_key_env` | 存放 API Key 的**环境变量名**(不是 Key 本身) | `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` |
269
+ | `http_headers` | 为该配置的每次 API 请求附加额外 HTTP Header | `{}` |
263
270
  | `format` | Router 模式下的协议格式:`anthropic` \| `openai` \| `codex` \| `ollama` \| `gemini` \| `azure` | — |
264
271
  | `thinking_enabled` | 是否启用思考模式(不支持该功能的模型需设为 `false`) | `true` |
265
272
  | `thinking_budget` | 思考 token 预算 | `10000` |
273
+ | `reasoning_effort` | OpenAI Responses/Codex 推理强度;显式配置时优先于 `thinking_budget` 映射。可选:`none` \| `minimal` \| `low` \| `medium` \| `high` \| `xhigh` | — |
266
274
  | `max_tokens` | 最大输出 token 数 | `16384` |
267
275
  | `timeout` | API 调用超时时间(秒),防止网络卡住时无限等待 | `300` |
268
276
  | `context_window` | 覆盖模型的上下文窗口大小(token 数)。当自动检测失败或不准确时使用——详见下方[上下文窗口管理](#上下文窗口管理)。 | 自动检测 |
277
+ | `prompt_cache_key` | OpenAI Responses/Codex 请求的 Prompt Cache 路由 key;未配置时默认使用 `http_headers.session_id` | — |
278
+ | `prompt_cache_retention` | OpenAI Responses/Codex Prompt Cache 保留策略:`in_memory` \| `24h` | — |
279
+ | `extra_body` | 追加到请求 JSON body 的 provider 专用字段 | `{}` |
269
280
 
270
281
  `env` 字段用于直接在配置文件中定义环境变量,启动时会自动注入,无需在 shell 中 `export`。
271
282
 
@@ -1361,6 +1372,6 @@ crabcode/
1361
1372
  │ │ ├── types.py # ACP 类型定义 + 工具类型映射
1362
1373
  │ │ └── transport.py # stdio 传输层(run_agent 封装)
1363
1374
  │ ├── routes/ # FastAPI 路由组(session、agent、config、event、health)
1364
- │ └── grpc_/ # gRPC 服务 + proto 定义
1375
+ │ └── grpc/ # gRPC 服务 + proto 定义
1365
1376
  └── tests/
1366
1377
  ```
@@ -1,3 +1,3 @@
1
1
  """CrabCode CLI - terminal frontend."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
@@ -0,0 +1,4 @@
1
+ from crabcode_cli.app import entry
2
+
3
+ if __name__ == "__main__":
4
+ entry()
@@ -31,6 +31,7 @@ def main(
31
31
  cwd: Optional[str] = typer.Option(None, "--cwd", help="Working directory"),
32
32
  resume: Optional[str] = typer.Option(None, "-r", "--resume", help="Resume a session by ID"),
33
33
  continue_last: bool = typer.Option(False, "-c", "--continue", help="Continue most recent session"),
34
+ image: Optional[list[str]] = typer.Option(None, "-i", "--image", help="Image file(s) to attach (repeatable)"),
34
35
  ) -> None:
35
36
  """CrabCode — AI coding assistant in the terminal."""
36
37
  from crabcode_core.types.config import ApiConfig, CrabCodeSettings
@@ -51,6 +52,8 @@ def main(
51
52
  settings.api.base_url = file_settings.api.base_url
52
53
  if file_settings.api.api_key_env:
53
54
  settings.api.api_key_env = file_settings.api.api_key_env
55
+ if file_settings.api.http_headers:
56
+ settings.api.http_headers = dict(file_settings.api.http_headers)
54
57
  if file_settings.api.format:
55
58
  settings.api.format = file_settings.api.format
56
59
  if not file_settings.api.thinking_enabled:
@@ -94,6 +97,30 @@ def main(
94
97
  err=True,
95
98
  )
96
99
 
100
+ # Read image files into base64 attachments
101
+ image_attachments: list[dict[str, str]] | None = None
102
+ if image:
103
+ import base64
104
+ import mimetypes
105
+ from pathlib import Path
106
+
107
+ image_attachments = []
108
+ for img_path_str in image:
109
+ img_path = Path(img_path_str)
110
+ if not img_path.exists():
111
+ typer.echo(f"Error: image file not found: {img_path_str}", err=True)
112
+ raise typer.Exit(1)
113
+ if img_path.stat().st_size > 20 * 1024 * 1024:
114
+ typer.echo(f"Error: image file too large (>20MB): {img_path_str}", err=True)
115
+ raise typer.Exit(1)
116
+ media_type = mimetypes.guess_type(img_path_str)[0] or "image/png"
117
+ if not media_type.startswith("image/"):
118
+ typer.echo(f"Error: not an image file: {img_path_str}", err=True)
119
+ raise typer.Exit(1)
120
+ with open(img_path, "rb") as f:
121
+ data = base64.b64encode(f.read()).decode("ascii")
122
+ image_attachments.append({"media_type": media_type, "data": data})
123
+
97
124
  stdin_text = ""
98
125
  if not sys.stdin.isatty():
99
126
  stdin_text = sys.stdin.read().strip()
@@ -105,12 +132,12 @@ def main(
105
132
  raise typer.Exit(1)
106
133
 
107
134
  from crabcode_cli.pipe import run_pipe
108
- asyncio.run(run_pipe(text, settings=settings, cwd=work_dir))
135
+ asyncio.run(run_pipe(text, settings=settings, cwd=work_dir, images=image_attachments))
109
136
  return
110
137
 
111
138
  if prompt:
112
139
  from crabcode_cli.pipe import run_pipe
113
- asyncio.run(run_pipe(prompt, settings=settings, cwd=work_dir))
140
+ asyncio.run(run_pipe(prompt, settings=settings, cwd=work_dir, images=image_attachments))
114
141
  return
115
142
 
116
143
  resume_id: str | None = None
@@ -2,7 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import asyncio
5
+ import json
6
+ import os
6
7
  import sys
7
8
 
8
9
  from crabcode_core.events import CoreSession
@@ -21,16 +22,56 @@ from crabcode_core.types.event import (
21
22
  TurnCompleteEvent,
22
23
  )
23
24
 
25
+ _DEBUG_TOOL_PAYLOAD = os.getenv("CRABCODE_DEBUG_TOOL_PAYLOAD", "").strip().lower() in {
26
+ "1",
27
+ "true",
28
+ "yes",
29
+ "on",
30
+ }
31
+
32
+
33
+ def _format_token_count(tokens: int) -> str:
34
+ if tokens >= 1_000_000:
35
+ return f"{tokens / 1_000_000:.1f}M"
36
+ if tokens >= 1_000:
37
+ return f"{tokens // 1_000}k"
38
+ return str(tokens)
39
+
40
+
41
+ def _format_percent(percent: float) -> str:
42
+ rounded = round(percent)
43
+ if abs(percent - rounded) < 0.05:
44
+ return f"{rounded}%"
45
+ return f"{percent:.1f}%"
46
+
47
+
48
+ def _format_context_usage(event: TurnCompleteEvent) -> str | None:
49
+ used = max(0, int(getattr(event, "context_used_tokens", 0) or 0))
50
+ window = max(0, int(getattr(event, "context_window_tokens", 0) or 0))
51
+ if not used and not window:
52
+ return None
53
+ if not window:
54
+ return f"Context: {_format_token_count(used)} tokens used (window unknown)"
55
+
56
+ used_percent = max(0.0, float(getattr(event, "context_used_percent", 0.0) or 0.0))
57
+ remaining_percent = max(0.0, 100.0 - used_percent)
58
+ return (
59
+ f"Context: {_format_percent(used_percent)} used "
60
+ f"({_format_percent(remaining_percent)} remaining) · "
61
+ f"{_format_token_count(used)} tokens used of {_format_token_count(window)}"
62
+ )
63
+
24
64
 
25
65
  async def run_pipe(
26
66
  prompt: str,
27
67
  settings: CrabCodeSettings | None = None,
28
68
  cwd: str = ".",
69
+ images: list[dict[str, str]] | None = None,
29
70
  ) -> None:
30
71
  """Run a single prompt through the core and print the response."""
31
72
  session = CoreSession(cwd=cwd, settings=settings)
32
73
  try:
33
- async for event in session.send_message(prompt):
74
+ async for event in session.send_message(prompt, images=images):
34
75
  if isinstance(event, StreamTextEvent):
35
76
  sys.stdout.write(safe_utf8_str(event.text))
36
77
  sys.stdout.flush()
@@ -38,6 +79,9 @@ async def run_pipe(
38
79
  pass
39
80
  elif isinstance(event, ToolUseEvent):
40
81
  sys.stderr.write(f"\n[Tool: {event.tool_name}]\n")
82
+ if _DEBUG_TOOL_PAYLOAD:
83
+ pretty = json.dumps(event.tool_input, ensure_ascii=False, indent=2)
84
+ sys.stderr.write(f"{pretty}\n")
41
85
  sys.stderr.flush()
42
86
  elif isinstance(event, PermissionRequestEvent):
43
87
  await session.respond_permission(
@@ -64,7 +108,10 @@ async def run_pipe(
64
108
  if not event.recoverable:
65
109
  sys.exit(1)
66
110
  elif isinstance(event, TurnCompleteEvent):
67
- pass
111
+ usage_text = _format_context_usage(event)
112
+ if usage_text:
113
+ sys.stderr.write(f"\n{usage_text}\n")
114
+ sys.stderr.flush()
68
115
  finally:
69
116
  await session.close()
70
117
 
@@ -4,11 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import contextlib
7
+ import json
7
8
  import os
8
9
  import sys
9
10
  import time
10
11
  from datetime import datetime
11
12
  from pathlib import Path
13
+ from typing import Any
12
14
 
13
15
  from prompt_toolkit import PromptSession
14
16
  from prompt_toolkit.completion import Completer, Completion
@@ -66,6 +68,42 @@ _ANSI_ENABLED = _supports_ansi_output()
66
68
  console = Console(no_color=not _ANSI_ENABLED, force_terminal=_ANSI_ENABLED)
67
69
 
68
70
 
71
+ def _format_token_count(tokens: int) -> str:
72
+ if tokens >= 1_000_000:
73
+ return f"{tokens / 1_000_000:.1f}M"
74
+ if tokens >= 1_000:
75
+ return f"{tokens // 1_000}k"
76
+ return str(tokens)
77
+
78
+
79
+ def _format_percent(percent: float) -> str:
80
+ rounded = round(percent)
81
+ if abs(percent - rounded) < 0.05:
82
+ return f"{rounded}%"
83
+ return f"{percent:.1f}%"
84
+
85
+
86
+ def _render_context_usage(event: TurnCompleteEvent) -> None:
87
+ used = max(0, int(getattr(event, "context_used_tokens", 0) or 0))
88
+ window = max(0, int(getattr(event, "context_window_tokens", 0) or 0))
89
+ if not used and not window:
90
+ return
91
+
92
+ if window:
93
+ used_percent = max(0.0, float(getattr(event, "context_used_percent", 0.0) or 0.0))
94
+ remaining_percent = max(0.0, 100.0 - used_percent)
95
+ console.print(
96
+ " [dim]Context: "
97
+ f"{_format_percent(used_percent)} used "
98
+ f"({_format_percent(remaining_percent)} remaining) · "
99
+ f"{_format_token_count(used)} tokens used of {_format_token_count(window)}[/]"
100
+ )
101
+ else:
102
+ console.print(
103
+ f" [dim]Context: {_format_token_count(used)} tokens used (window unknown)[/]"
104
+ )
105
+
106
+
69
107
  # Slash commands with their arguments for auto-completion
70
108
  _SLASH_COMMANDS: dict[str, list[str]] = {
71
109
  "/help": [],
@@ -98,6 +136,7 @@ _SLASH_COMMANDS: dict[str, list[str]] = {
98
136
  "/resume": [], # Dynamic: session IDs
99
137
  "/exit": [],
100
138
  "/quit": [],
139
+ "/image": [], # Dynamic: file path
101
140
  }
102
141
 
103
142
 
@@ -244,6 +283,7 @@ class _CrabCodeCompleter(Completer):
244
283
  "/revert": "revert files + conversation to checkpoint",
245
284
  "/undo": "undo last checkpoint (revert files + conversation)",
246
285
  "/resume": "resume session",
286
+ "/image": "attach image to next message",
247
287
  "/exit": "exit CrabCode",
248
288
  "/quit": "exit CrabCode",
249
289
  }
@@ -516,6 +556,27 @@ def _tool_summary(name: str, inp: dict) -> str:
516
556
  return (raw[:200] + "…") if len(raw) > 200 else raw
517
557
 
518
558
 
559
+ def _debug_tool_payload_enabled() -> bool:
560
+ return os.getenv("CRABCODE_DEBUG_TOOL_PAYLOAD", "").strip().lower() in {
561
+ "1",
562
+ "true",
563
+ "yes",
564
+ "on",
565
+ }
566
+
567
+
568
+ def _render_full_tool_payload(tool_name: str, tool_input: dict) -> None:
569
+ payload = json.dumps(tool_input, ensure_ascii=False, indent=2)
570
+ console.print(
571
+ Panel(
572
+ Text(payload, style="dim"),
573
+ title=f"[bold yellow]Debug Payload: {tool_name}[/]",
574
+ border_style="yellow",
575
+ expand=False,
576
+ )
577
+ )
578
+
579
+
519
580
  def _render_saved_partial_reply(text: str) -> None:
520
581
  """Echo assistant text that was persisted after interrupt (visible in scrollback)."""
521
582
  body = text.rstrip()
@@ -558,6 +619,8 @@ def _render_tool_use(event: ToolUseEvent) -> None:
558
619
  expand=False,
559
620
  )
560
621
  )
622
+ if _debug_tool_payload_enabled():
623
+ _render_full_tool_payload(event.tool_name, event.tool_input)
561
624
 
562
625
 
563
626
  def _truncate_display(display: str, max_lines: int = 50, max_chars: int = 50_000) -> str:
@@ -699,8 +762,21 @@ def _render_session_history(messages: list[Message], max_messages: int = 50) ->
699
762
  async def _prompt_permission(
700
763
  event: PermissionRequestEvent,
701
764
  session: CoreSession,
765
+ batch_state: dict | None = None,
702
766
  ) -> None:
703
767
  """Prompt the user for tool permission and push response to session."""
768
+ # If a previous request in this batch was denied, auto-deny silently
769
+ if batch_state and batch_state.get("denied"):
770
+ await session.respond_permission(
771
+ PermissionResponseEvent(
772
+ tool_use_id=event.tool_use_id, allowed=False, agent_id=event.agent_id
773
+ )
774
+ )
775
+ console.print(
776
+ f" [dim red]✗ {event.tool_name} auto-denied (batch)[/]"
777
+ )
778
+ return
779
+
704
780
  summary = _tool_summary(event.tool_name, event.tool_input)
705
781
  if event.reason:
706
782
  summary = f"{summary}\n\nReason: {event.reason}"
@@ -713,17 +789,19 @@ async def _prompt_permission(
713
789
  )
714
790
  )
715
791
 
716
- loop = asyncio.get_event_loop()
792
+ perm_prompt_session: PromptSession[str] = PromptSession()
717
793
  while True:
718
794
  try:
719
- choice = await loop.run_in_executor(
720
- None,
721
- lambda: input(
722
- f" Allow {event.tool_name}? "
723
- "(y)es / (n)o / (a)lways allow: "
724
- ).strip().lower(),
795
+ choice = await perm_prompt_session.prompt_async(
796
+ HTML(
797
+ f" Allow <b>{event.tool_name}</b>? "
798
+ "(y)es / (n)o / (a)lways allow / (f)eedback: "
799
+ )
725
800
  )
801
+ choice = choice.strip().lower()
726
802
  except (EOFError, KeyboardInterrupt):
803
+ if batch_state is not None:
804
+ batch_state["denied"] = True
727
805
  await session.respond_permission(
728
806
  PermissionResponseEvent(
729
807
  tool_use_id=event.tool_use_id, allowed=False, agent_id=event.agent_id
@@ -739,6 +817,8 @@ async def _prompt_permission(
739
817
  )
740
818
  return
741
819
  elif choice in ("n", "no"):
820
+ if batch_state is not None:
821
+ batch_state["denied"] = True
742
822
  await session.respond_permission(
743
823
  PermissionResponseEvent(
744
824
  tool_use_id=event.tool_use_id, allowed=False, agent_id=event.agent_id
@@ -755,8 +835,28 @@ async def _prompt_permission(
755
835
  )
756
836
  )
757
837
  return
838
+ elif choice in ("f", "feedback"):
839
+ try:
840
+ feedback_session: PromptSession[str] = PromptSession()
841
+ feedback = await feedback_session.prompt_async(
842
+ HTML(" Feedback (what to do instead): ")
843
+ )
844
+ feedback = feedback.strip()
845
+ except (EOFError, KeyboardInterrupt):
846
+ feedback = ""
847
+ if batch_state is not None:
848
+ batch_state["denied"] = True
849
+ await session.respond_permission(
850
+ PermissionResponseEvent(
851
+ tool_use_id=event.tool_use_id,
852
+ allowed=False,
853
+ agent_id=event.agent_id,
854
+ feedback=feedback or None,
855
+ )
856
+ )
857
+ return
758
858
  else:
759
- console.print(" [dim]Please enter y, n, or a[/]")
859
+ console.print(" [dim]Please enter y, n, a, or f[/]")
760
860
 
761
861
 
762
862
  async def _interactive_select(question: str, options: list[str], multiple: bool = False) -> list[str]:
@@ -1058,6 +1158,7 @@ async def _run_plan_executor_with_runtime_events(
1058
1158
  producer = asyncio.create_task(_produce_plan_events())
1059
1159
  forwarder = asyncio.create_task(_forward_agent_events())
1060
1160
  active_stream_agent: str | None = None
1161
+ batch_state: dict = {"denied": False}
1061
1162
 
1062
1163
  try:
1063
1164
  while True:
@@ -1095,6 +1196,11 @@ async def _run_plan_executor_with_runtime_events(
1095
1196
  console.print(
1096
1197
  f" [{style}]agent {event.agent_id[:8]} · {event.status} · {event.title}[/]"
1097
1198
  )
1199
+ elif isinstance(event, StreamModeEvent):
1200
+ if event.mode == "tool-input":
1201
+ batch_state["denied"] = False
1202
+ elif isinstance(event, TurnCompleteEvent):
1203
+ batch_state["denied"] = False
1098
1204
  elif isinstance(event, ToolUseEvent):
1099
1205
  _render_tool_use(event)
1100
1206
  elif isinstance(event, ToolResultEvent):
@@ -1106,7 +1212,7 @@ async def _run_plan_executor_with_runtime_events(
1106
1212
  else:
1107
1213
  _render_tool_result(event)
1108
1214
  elif isinstance(event, PermissionRequestEvent):
1109
- await _prompt_permission(event, session)
1215
+ await _prompt_permission(event, session, batch_state)
1110
1216
  elif isinstance(event, ChoiceRequestEvent):
1111
1217
  await _prompt_choice(event, session)
1112
1218
  elif isinstance(event, ErrorEvent):
@@ -1165,6 +1271,9 @@ async def run_repl(
1165
1271
 
1166
1272
  session = CoreSession(cwd=cwd, settings=settings)
1167
1273
 
1274
+ # Pending image attachments for the next user message
1275
+ pending_images: list[dict[str, str]] = []
1276
+
1168
1277
  global _display_settings
1169
1278
  _display_settings = settings.display if settings else None
1170
1279
 
@@ -1227,6 +1336,8 @@ async def run_repl(
1227
1336
  mode = getattr(session, '_agent_mode', 'agent')
1228
1337
  if mode == "plan":
1229
1338
  prompt_html = HTML("<b><ansiblue>[plan]</ansiblue> <ansicyan>❯ </ansicyan></b>")
1339
+ elif pending_images:
1340
+ prompt_html = HTML("<b><ansicyan>❯ 📎 </ansicyan></b>")
1230
1341
  else:
1231
1342
  prompt_html = HTML("<b><ansicyan>❯ </ansicyan></b>")
1232
1343
  user_input = await prompt_session.prompt_async(prompt_html)
@@ -1270,7 +1381,7 @@ async def run_repl(
1270
1381
  ctrl_c_exit.clear()
1271
1382
 
1272
1383
  if user_input.startswith("/"):
1273
- result = await _handle_command(user_input, session, settings)
1384
+ result = await _handle_command(user_input, session, settings, pending_images)
1274
1385
  if result is False:
1275
1386
  break
1276
1387
  if isinstance(result, str):
@@ -1305,7 +1416,10 @@ async def run_repl(
1305
1416
 
1306
1417
  plan_pending = False
1307
1418
  try:
1308
- async for event in session.send_message(user_input):
1419
+ send_images = pending_images.copy() if pending_images else None
1420
+ pending_images.clear()
1421
+ batch_state: dict = {"denied": False}
1422
+ async for event in session.send_message(user_input, images=send_images):
1309
1423
  if isinstance(event, StreamModeEvent):
1310
1424
  if event.mode == "requesting":
1311
1425
  spinner.start()
@@ -1318,6 +1432,7 @@ async def run_repl(
1318
1432
  elif event.mode == "responding":
1319
1433
  await _stop_spinner_with_thinking()
1320
1434
  elif event.mode == "tool-input":
1435
+ batch_state["denied"] = False
1321
1436
  await _stop_spinner_with_thinking()
1322
1437
  if streamed_text:
1323
1438
  sys.stdout.write("\n")
@@ -1378,7 +1493,7 @@ async def run_repl(
1378
1493
  sys.stdout.write("\n")
1379
1494
  sys.stdout.flush()
1380
1495
  streamed_text = ""
1381
- await _prompt_permission(event, session)
1496
+ await _prompt_permission(event, session, batch_state)
1382
1497
 
1383
1498
  elif isinstance(event, ChoiceRequestEvent):
1384
1499
  await _stop_spinner_with_thinking()
@@ -1468,6 +1583,11 @@ async def run_repl(
1468
1583
 
1469
1584
  elif isinstance(event, TurnCompleteEvent):
1470
1585
  await _stop_spinner_with_thinking()
1586
+ if streamed_text:
1587
+ sys.stdout.write("\n")
1588
+ sys.stdout.flush()
1589
+ streamed_text = ""
1590
+ _render_context_usage(event)
1471
1591
  if getattr(session, '_current_plan', None) and session.agent_mode == "agent":
1472
1592
  plan_pending = True
1473
1593
 
@@ -1568,6 +1688,7 @@ async def _handle_command(
1568
1688
  command: str,
1569
1689
  session: CoreSession,
1570
1690
  settings: CrabCodeSettings | None,
1691
+ pending_images: list[dict[str, str]] | None = None,
1571
1692
  ) -> bool | str:
1572
1693
  """Handle slash commands.
1573
1694
 
@@ -1583,6 +1704,42 @@ async def _handle_command(
1583
1704
  # --- Built-in commands take priority over skill names ---
1584
1705
  skills = getattr(session, "skills", [])
1585
1706
 
1707
+ if cmd == "/image":
1708
+ if not arg:
1709
+ if pending_images is not None and pending_images:
1710
+ console.print(f"[dim]{len(pending_images)} image(s) attached for next message.[/]")
1711
+ else:
1712
+ console.print("[dim]Usage: /image <path> [path2 ...][/]")
1713
+ return True
1714
+ import base64
1715
+ import mimetypes
1716
+ from pathlib import Path
1717
+
1718
+ paths = arg.split()
1719
+ added = 0
1720
+ for p in paths:
1721
+ img_path = Path(p).expanduser()
1722
+ if not img_path.exists():
1723
+ console.print(f"[bold red]File not found: {p}[/]")
1724
+ continue
1725
+ if img_path.stat().st_size > 20 * 1024 * 1024:
1726
+ console.print(f"[bold red]File too large (>20MB): {p}[/]")
1727
+ continue
1728
+ media_type = mimetypes.guess_type(str(img_path))[0] or "image/png"
1729
+ if not media_type.startswith("image/"):
1730
+ console.print(f"[bold red]Not an image: {p} (detected: {media_type})[/]")
1731
+ continue
1732
+ with open(img_path, "rb") as f:
1733
+ data = base64.b64encode(f.read()).decode("ascii")
1734
+ if pending_images is not None:
1735
+ pending_images.append({"media_type": media_type, "data": data})
1736
+ added += 1
1737
+ size_kb = img_path.stat().st_size / 1024
1738
+ console.print(f" [green]✓[/] Attached: {p} ({media_type}, {size_kb:.0f}KB)")
1739
+ if added and pending_images is not None:
1740
+ console.print(f"[dim]{len(pending_images)} image(s) will be sent with your next message.[/]")
1741
+ return True
1742
+
1586
1743
  if cmd == "/help":
1587
1744
  skills_section = ""
1588
1745
  if skills:
@@ -1629,6 +1786,7 @@ async def _handle_command(
1629
1786
  "[bold]/revert <id|#>[/] — revert files + conversation to a checkpoint\n"
1630
1787
  "[bold]/undo[/] — revert last checkpoint (files + conversation)\n"
1631
1788
  "[bold]/resume <id>[/] — resume a previous session\n"
1789
+ "[bold]/image <path>[/] — attach image(s) to your next message\n"
1632
1790
  "[bold]/exit[/] — exit CrabCode\n"
1633
1791
  f"[bold]Ctrl+C[/] — interrupt; press again within {_CTRL_C_EXIT_WINDOW_S:.0f}s to exit\n"
1634
1792
  "\n"