limbo-code 0.1.12__tar.gz → 0.1.14__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 (184) hide show
  1. {limbo_code-0.1.12 → limbo_code-0.1.14}/AGENTS.md +2 -0
  2. limbo_code-0.1.12/README.md → limbo_code-0.1.14/PKG-INFO +31 -0
  3. limbo_code-0.1.12/PKG-INFO → limbo_code-0.1.14/README.md +17 -14
  4. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/agent.py +5 -0
  5. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/app.py +18 -1
  6. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/config.py +34 -0
  7. limbo_code-0.1.14/src/limbo/integrations/__init__.py +43 -0
  8. limbo_code-0.1.14/src/limbo/integrations/base.py +87 -0
  9. limbo_code-0.1.14/src/limbo/integrations/herdr.py +143 -0
  10. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/screens/main.py +33 -0
  11. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_cli.py +77 -8
  12. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_config.py +31 -1
  13. limbo_code-0.1.14/tests/test_herdr.py +145 -0
  14. limbo_code-0.1.14/tests/test_integrations.py +60 -0
  15. {limbo_code-0.1.12 → limbo_code-0.1.14}/.agents/skills/grill-with-docs/SKILL.md +0 -0
  16. {limbo_code-0.1.12 → limbo_code-0.1.14}/.agents/skills/grill-with-docs/agents/openai.yaml +0 -0
  17. {limbo_code-0.1.12 → limbo_code-0.1.14}/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +0 -0
  18. {limbo_code-0.1.12 → limbo_code-0.1.14}/.agents/skills/improve-codebase-architecture/SKILL.md +0 -0
  19. {limbo_code-0.1.12 → limbo_code-0.1.14}/.agents/skills/improve-codebase-architecture/agents/openai.yaml +0 -0
  20. {limbo_code-0.1.12 → limbo_code-0.1.14}/.agents/skills/tdd/SKILL.md +0 -0
  21. {limbo_code-0.1.12 → limbo_code-0.1.14}/.agents/skills/tdd/agents/openai.yaml +0 -0
  22. {limbo_code-0.1.12 → limbo_code-0.1.14}/.agents/skills/tdd/mocking.md +0 -0
  23. {limbo_code-0.1.12 → limbo_code-0.1.14}/.agents/skills/tdd/tests.md +0 -0
  24. {limbo_code-0.1.12 → limbo_code-0.1.14}/.github/workflows/publish.yml +0 -0
  25. {limbo_code-0.1.12 → limbo_code-0.1.14}/.github/workflows/test.yml +0 -0
  26. {limbo_code-0.1.12 → limbo_code-0.1.14}/.gitignore +0 -0
  27. {limbo_code-0.1.12 → limbo_code-0.1.14}/CONTEXT.md +0 -0
  28. {limbo_code-0.1.12 → limbo_code-0.1.14}/Makefile +0 -0
  29. {limbo_code-0.1.12 → limbo_code-0.1.14}/design/confirm_view.html +0 -0
  30. {limbo_code-0.1.12 → limbo_code-0.1.14}/design/limbo-ui-minimal.md +0 -0
  31. {limbo_code-0.1.12 → limbo_code-0.1.14}/design/limbo-ui-redesign.md +0 -0
  32. {limbo_code-0.1.12 → limbo_code-0.1.14}/design/prototype-minimal-confirm.png +0 -0
  33. {limbo_code-0.1.12 → limbo_code-0.1.14}/design/prototype-minimal.html +0 -0
  34. {limbo_code-0.1.12 → limbo_code-0.1.14}/design/prototype-minimal.png +0 -0
  35. {limbo_code-0.1.12 → limbo_code-0.1.14}/design/prototype.html +0 -0
  36. {limbo_code-0.1.12 → limbo_code-0.1.14}/design/prototype.png +0 -0
  37. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/assets/limbo-current-ui.png +0 -0
  38. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/assets/limbo-new-ui.png +0 -0
  39. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-dark/1_idle.svg +0 -0
  40. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-dark/2_thinking_tool_running.svg +0 -0
  41. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-dark/3_tool_success.svg +0 -0
  42. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-dark/4_tool_error.svg +0 -0
  43. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-dark/5_llm_error.svg +0 -0
  44. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-dark/6_edit_diff.svg +0 -0
  45. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-light/1_idle.svg +0 -0
  46. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-light/2_thinking_tool_running.svg +0 -0
  47. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-light/3_tool_success.svg +0 -0
  48. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-light/4_tool_error.svg +0 -0
  49. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-light/5_llm_error.svg +0 -0
  50. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/assets/walkthrough/limbo-light/6_edit_diff.svg +0 -0
  51. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/session-management.md +0 -0
  52. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/skills.md +0 -0
  53. {limbo_code-0.1.12 → limbo_code-0.1.14}/docs/ui-redesign-proposal.md +0 -0
  54. {limbo_code-0.1.12 → limbo_code-0.1.14}/pyproject.toml +0 -0
  55. {limbo_code-0.1.12 → limbo_code-0.1.14}/scripts/check_contrast.py +0 -0
  56. {limbo_code-0.1.12 → limbo_code-0.1.14}/scripts/gen_banner.py +0 -0
  57. {limbo_code-0.1.12 → limbo_code-0.1.14}/scripts/ui_walkthrough.py +0 -0
  58. {limbo_code-0.1.12 → limbo_code-0.1.14}/skills-lock.json +0 -0
  59. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/__init__.py +0 -0
  60. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/__main__.py +0 -0
  61. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/attachments.py +0 -0
  62. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/btw.py +0 -0
  63. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/compaction.py +0 -0
  64. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/goal.py +0 -0
  65. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/goal_driver.py +0 -0
  66. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/history.py +0 -0
  67. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/llm/__init__.py +0 -0
  68. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/llm/anthropic_client.py +0 -0
  69. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/llm/catalog.py +0 -0
  70. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/llm/client.py +0 -0
  71. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/llm/factory.py +0 -0
  72. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/llm/openai_client.py +0 -0
  73. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/llm/responses_client.py +0 -0
  74. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/llm/retry.py +0 -0
  75. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/llm/scaffold.py +0 -0
  76. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/llm/sse.py +0 -0
  77. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/llm/usage.py +0 -0
  78. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/model_switch.py +0 -0
  79. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/models.py +0 -0
  80. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/prompt.py +0 -0
  81. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/sessions.py +0 -0
  82. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/skills.py +0 -0
  83. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/steer.py +0 -0
  84. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/tools/__init__.py +0 -0
  85. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/tools/base.py +0 -0
  86. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/tools/bash.py +0 -0
  87. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/tools/edit.py +0 -0
  88. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/tools/find.py +0 -0
  89. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/tools/grep.py +0 -0
  90. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/tools/ignore.py +0 -0
  91. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/tools/ls.py +0 -0
  92. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/tools/mutation_queue.py +0 -0
  93. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/tools/read.py +0 -0
  94. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/tools/registry.py +0 -0
  95. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/tools/write.py +0 -0
  96. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/trace.py +0 -0
  97. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/__init__.py +0 -0
  98. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/app.py +0 -0
  99. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/app.tcss +0 -0
  100. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/banner.py +0 -0
  101. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/clipboard.py +0 -0
  102. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/commands.py +0 -0
  103. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/contrast.py +0 -0
  104. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/screens/__init__.py +0 -0
  105. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/screens/btw.py +0 -0
  106. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/screens/game2048.py +0 -0
  107. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/screens/model_picker.py +0 -0
  108. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/screens/session_picker.py +0 -0
  109. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/screens/verify_picker.py +0 -0
  110. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/syntax.py +0 -0
  111. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/theme.py +0 -0
  112. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/widgets/__init__.py +0 -0
  113. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/widgets/chat.py +0 -0
  114. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/widgets/command_menu.py +0 -0
  115. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/widgets/input.py +0 -0
  116. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/widgets/status_bar.py +0 -0
  117. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/ui/widgets/tool_card.py +0 -0
  118. {limbo_code-0.1.12 → limbo_code-0.1.14}/src/limbo/user_paths.py +0 -0
  119. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/conftest.py +0 -0
  120. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_agent.py +0 -0
  121. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_anthropic_client.py +0 -0
  122. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_attachments.py +0 -0
  123. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_btw.py +0 -0
  124. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_catalog.py +0 -0
  125. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_clipboard.py +0 -0
  126. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_compaction.py +0 -0
  127. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_goal.py +0 -0
  128. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_goal_driver.py +0 -0
  129. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_history.py +0 -0
  130. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_integration.py +0 -0
  131. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_interrupt.py +0 -0
  132. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_llm_client.py +0 -0
  133. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_llm_scaffold.py +0 -0
  134. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_model_switch.py +0 -0
  135. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_models.py +0 -0
  136. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_prompt.py +0 -0
  137. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_responses_client.py +0 -0
  138. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_retry.py +0 -0
  139. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_sessions.py +0 -0
  140. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_skills.py +0 -0
  141. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_sse.py +0 -0
  142. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_steer.py +0 -0
  143. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_trace.py +0 -0
  144. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_usage.py +0 -0
  145. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/test_user_paths.py +0 -0
  146. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/tools/test_base.py +0 -0
  147. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/tools/test_bash.py +0 -0
  148. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/tools/test_edit.py +0 -0
  149. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/tools/test_find.py +0 -0
  150. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/tools/test_grep.py +0 -0
  151. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/tools/test_ignore.py +0 -0
  152. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/tools/test_ls.py +0 -0
  153. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/tools/test_mutation_queue.py +0 -0
  154. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/tools/test_read.py +0 -0
  155. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/tools/test_registry.py +0 -0
  156. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/tools/test_write.py +0 -0
  157. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/__snapshots__/test_snapshots/test_snapshot_idle.raw +0 -0
  158. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/__snapshots__/test_snapshots/test_snapshot_session_picker.raw +0 -0
  159. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/__snapshots__/test_snapshots/test_snapshot_thinking_and_running_tool.raw +0 -0
  160. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/__snapshots__/test_snapshots/test_snapshot_tool_error_and_error_message.raw +0 -0
  161. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_app_smoke.py +0 -0
  162. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_btw_ui.py +0 -0
  163. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_command_menu.py +0 -0
  164. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_commands.py +0 -0
  165. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_compact_ui.py +0 -0
  166. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_contrast.py +0 -0
  167. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_copy.py +0 -0
  168. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_game2048.py +0 -0
  169. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_goal_ui.py +0 -0
  170. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_input_attachments.py +0 -0
  171. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_input_history.py +0 -0
  172. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_input_paste.py +0 -0
  173. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_interrupt_ui.py +0 -0
  174. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_main_screen.py +0 -0
  175. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_model_picker.py +0 -0
  176. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_scroll_follow.py +0 -0
  177. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_sessions_ui.py +0 -0
  178. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_skills_ui.py +0 -0
  179. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_snapshots.py +0 -0
  180. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_startup_art.py +0 -0
  181. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_steer_ui.py +0 -0
  182. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_theme.py +0 -0
  183. {limbo_code-0.1.12 → limbo_code-0.1.14}/tests/ui/test_widgets.py +0 -0
  184. {limbo_code-0.1.12 → limbo_code-0.1.14}/uv.lock +0 -0
@@ -33,6 +33,8 @@ src/limbo/
33
33
  ├── goal_driver.py # /goal orchestrator (frontend-agnostic, non-UI): turn → verify → next round
34
34
  ├── sessions.py # Session JSONL save/load/list/export
35
35
  ├── trace.py # Append-only JSONL run log (sessions/traces/)
36
+ ├── integrations/ # External mux/orchestrator lifecycle reporters: base.py (protocol
37
+ │ # + CompositeReporter + AgentState), herdr.py; create_reporters() fans out
36
38
  ├── skills.py # SKILL.md discovery (user + project dirs)
37
39
  ├── user_paths.py # Fence grants from paths in user messages
38
40
  ├── llm/ # catalog.py (provider/model specs), factory.py (dialect→client),
@@ -1,3 +1,17 @@
1
+ Metadata-Version: 2.5
2
+ Name: limbo-code
3
+ Version: 0.1.14
4
+ Summary: A minimal terminal AI coding agent
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: openai>=1.30
8
+ Requires-Dist: pydantic>=2.0
9
+ Requires-Dist: pygments>=2.17
10
+ Requires-Dist: textual>=8.0
11
+ Requires-Dist: toml>=0.10
12
+ Requires-Dist: tomlkit>=0.13
13
+ Description-Content-Type: text/markdown
14
+
1
15
  # Limbo
2
16
 
3
17
  A minimal terminal AI coding agent.
@@ -169,6 +183,23 @@ Optional tool settings:
169
183
  bash_enabled = true
170
184
  ```
171
185
 
186
+ Optional UI settings:
187
+
188
+ ```toml
189
+ [ui]
190
+ kitty_keyboard = "auto" # auto (default) | enabled | disabled
191
+ ```
192
+
193
+ `kitty_keyboard` controls Textual's kitty keyboard protocol. The default
194
+ `auto` disables it only when Limbo runs inside the **herdr** multiplexer:
195
+ herdr mirrors a pane's `REPORT_ALL_KEYS` request to the host terminal
196
+ (e.g. Ghostty), and Ghostty then encodes IME commits as the physical commit
197
+ key (space) while dropping the composed text — typing Chinese turns into
198
+ spaces (pasting is unaffected). With the protocol disabled, herdr keeps
199
+ IME-compatible host flags and committed IME text arrives as raw UTF-8. Use
200
+ `enabled` to always keep the protocol (e.g. in a direct Ghostty/kitty
201
+ terminal), or `disabled` to force raw/legacy input everywhere.
202
+
172
203
  ### Session storage
173
204
 
174
205
  Conversations are saved as JSONL files in `~/.limbo/sessions/` so you can
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: limbo-code
3
- Version: 0.1.12
4
- Summary: A minimal terminal AI coding agent
5
- Requires-Python: >=3.11
6
- Requires-Dist: httpx>=0.27
7
- Requires-Dist: openai>=1.30
8
- Requires-Dist: pydantic>=2.0
9
- Requires-Dist: pygments>=2.17
10
- Requires-Dist: textual>=8.0
11
- Requires-Dist: toml>=0.10
12
- Requires-Dist: tomlkit>=0.13
13
- Description-Content-Type: text/markdown
14
-
15
1
  # Limbo
16
2
 
17
3
  A minimal terminal AI coding agent.
@@ -183,6 +169,23 @@ Optional tool settings:
183
169
  bash_enabled = true
184
170
  ```
185
171
 
172
+ Optional UI settings:
173
+
174
+ ```toml
175
+ [ui]
176
+ kitty_keyboard = "auto" # auto (default) | enabled | disabled
177
+ ```
178
+
179
+ `kitty_keyboard` controls Textual's kitty keyboard protocol. The default
180
+ `auto` disables it only when Limbo runs inside the **herdr** multiplexer:
181
+ herdr mirrors a pane's `REPORT_ALL_KEYS` request to the host terminal
182
+ (e.g. Ghostty), and Ghostty then encodes IME commits as the physical commit
183
+ key (space) while dropping the composed text — typing Chinese turns into
184
+ spaces (pasting is unaffected). With the protocol disabled, herdr keeps
185
+ IME-compatible host flags and committed IME text arrives as raw UTF-8. Use
186
+ `enabled` to always keep the protocol (e.g. in a direct Ghostty/kitty
187
+ terminal), or `disabled` to force raw/legacy input everywhere.
188
+
186
189
  ### Session storage
187
190
 
188
191
  Conversations are saved as JSONL files in `~/.limbo/sessions/` so you can
@@ -344,6 +344,11 @@ class Agent:
344
344
  """Stable id of the current session (the session file stem)."""
345
345
  return self._meta.id
346
346
 
347
+ @property
348
+ def session_path(self) -> Path:
349
+ """Path of the current session JSONL file."""
350
+ return self._session_file
351
+
347
352
  @property
348
353
  def session_meta(self) -> SessionMeta:
349
354
  """Metadata of the current session (title filled in on first save)."""
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import os
6
7
  import sys
7
8
  from pathlib import Path
8
9
 
@@ -15,11 +16,24 @@ from limbo.sessions import (
15
16
  latest_session,
16
17
  load_session,
17
18
  )
18
- from limbo.ui.app import LimboApp
19
19
 
20
20
  DEFAULT_SESSION_DIR = Path.home() / ".limbo" / "sessions"
21
21
 
22
22
 
23
+ def _apply_kitty_keyboard_workaround(config) -> None:
24
+ """Apply the `ui.kitty_keyboard` setting before Textual initializes.
25
+
26
+ Textual reads TEXTUAL_DISABLE_KITTY_KEY at import time, so this must run
27
+ before the first `textual` import. See KittyKeyboardMode for why typing
28
+ Chinese inside the herdr multiplexer turns into spaces when the kitty
29
+ keyboard protocol is enabled.
30
+ """
31
+ if config.ui.kitty_keyboard.disable_textual_kitty_key(
32
+ in_herdr=bool(os.environ.get("HERDR_ENV"))
33
+ ):
34
+ os.environ["TEXTUAL_DISABLE_KITTY_KEY"] = "1"
35
+
36
+
23
37
  def main() -> int:
24
38
  parser = argparse.ArgumentParser(description="Limbo TUI coding agent")
25
39
  parser.add_argument(
@@ -55,6 +69,7 @@ def main() -> int:
55
69
  args = parser.parse_args()
56
70
 
57
71
  config = load_config()
72
+ _apply_kitty_keyboard_workaround(config)
58
73
  if args.model:
59
74
  config.llm.model = args.model
60
75
  spec = resolve_model(config.llm.model)
@@ -97,6 +112,8 @@ def main() -> int:
97
112
  if meta.workdir and Path(meta.workdir).is_dir():
98
113
  workdir = Path(meta.workdir)
99
114
 
115
+ from limbo.ui.app import LimboApp # noqa: I001, PLC0415 - deferred so the kitty keyboard workaround runs first
116
+
100
117
  app = LimboApp(
101
118
  workdir=workdir,
102
119
  config=config,
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import warnings
6
+ from enum import Enum
6
7
  from pathlib import Path
7
8
  from typing import Any
8
9
 
@@ -132,12 +133,45 @@ class ProviderOverride(BaseModel):
132
133
  headers: dict[str, str] = Field(default_factory=dict)
133
134
 
134
135
 
136
+ class KittyKeyboardMode(str, Enum):
137
+ """How Limbo negotiates Textual's kitty keyboard protocol.
138
+
139
+ Textual 8.2.8 requests the kitty keyboard protocol with ``REPORT_ALL_KEYS``.
140
+ Inside the herdr multiplexer, herdr mirrors that request to the host
141
+ terminal (e.g. Ghostty), and Ghostty then encodes IME commits as the
142
+ physical commit key (space) while dropping the composed text — typing
143
+ Chinese turns into spaces (pasting is unaffected, it bypasses the key
144
+ encoder). Disabling the protocol makes herdr negotiate IME-compatible host
145
+ flags so committed IME text arrives as raw UTF-8.
146
+ """
147
+
148
+ # Disable only inside the herdr multiplexer (detected via HERDR_ENV);
149
+ # keep the protocol everywhere else. Safe default for CJK users.
150
+ AUTO = "auto"
151
+ # Always request the kitty keyboard protocol (Textual's default behavior).
152
+ ENABLED = "enabled"
153
+ # Never request it — forces raw/legacy terminal input everywhere.
154
+ DISABLED = "disabled"
155
+
156
+ def disable_textual_kitty_key(self, *, in_herdr: bool) -> bool:
157
+ """Whether Textual's kitty keyboard protocol should be disabled."""
158
+ return self is KittyKeyboardMode.DISABLED or (
159
+ self is KittyKeyboardMode.AUTO and in_herdr
160
+ )
161
+
162
+
135
163
  class UIConfig(BaseModel):
136
164
  # Theme name: built-in "limbo-dark" (default) / "limbo-light", or any
137
165
  # Textual built-in theme (e.g. "textual-dark", "dracula", "nord").
138
166
  theme: str | None = None
139
167
  # Show the startup ASCII-art banner on fresh sessions.
140
168
  show_banner: bool = True
169
+ # Kitty keyboard protocol handling (see KittyKeyboardMode):
170
+ # auto - enable normally, except inside the herdr multiplexer where
171
+ # it breaks IME/Chinese input (default)
172
+ # enabled - always request the kitty keyboard protocol
173
+ # disabled - never request it (forces raw/legacy terminal input)
174
+ kitty_keyboard: KittyKeyboardMode = KittyKeyboardMode.AUTO
141
175
 
142
176
 
143
177
  class SafetyConfig(BaseModel):
@@ -0,0 +1,43 @@
1
+ """Integrations with external terminal multiplexers / agent orchestrators.
2
+
3
+ Each integration lives in its own module (e.g. ``herdr``) and implements
4
+ the ``IntegrationReporter`` protocol from ``base``. Its ``from_env()``
5
+ returns ``None`` when Limbo is not running inside that tool, so the
6
+ integration is a strict no-op elsewhere. ``create_reporters()`` collects
7
+ every active integration into one ``CompositeReporter`` — the only object
8
+ call sites ever touch.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from collections.abc import Mapping
15
+
16
+ from limbo.integrations.base import (
17
+ AgentState,
18
+ CompositeReporter,
19
+ IntegrationReporter,
20
+ )
21
+ from limbo.integrations.herdr import HerdrReporter
22
+
23
+ __all__ = [
24
+ "AgentState",
25
+ "CompositeReporter",
26
+ "HerdrReporter",
27
+ "IntegrationReporter",
28
+ "create_reporters",
29
+ ]
30
+
31
+
32
+ def create_reporters(env: Mapping[str, str] | None = None) -> CompositeReporter:
33
+ """Detect active integrations from the environment.
34
+
35
+ Register new integrations here: append ``XReporter.from_env(env)`` when
36
+ it returns a reporter. Order is irrelevant; each reporter is
37
+ independent and fire-and-forget.
38
+ """
39
+ env = os.environ if env is None else env
40
+ reporters: list[IntegrationReporter] = []
41
+ if (herdr := HerdrReporter.from_env(env)) is not None:
42
+ reporters.append(herdr)
43
+ return CompositeReporter(reporters)
@@ -0,0 +1,87 @@
1
+ """Shared contract for terminal multiplexer / agent orchestrator integrations.
2
+
3
+ Every integration (Herdr, future *mux tools, ...) implements
4
+ ``IntegrationReporter``: it receives semantic lifecycle state
5
+ (``working``/``idle``/``blocked``) plus session identity, and maps that onto
6
+ whatever the specific tool understands. Call sites (UI, agent loop) only
7
+ ever talk to ``CompositeReporter`` — adding a new integration means writing
8
+ one module here and registering it in ``create_reporters()``, with zero
9
+ changes at call sites.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Iterable
15
+ from typing import Literal, Protocol
16
+
17
+ # Semantic lifecycle states every integration must be able to represent.
18
+ # ``blocked`` means the agent is waiting on a user decision.
19
+ AgentState = Literal["working", "idle", "blocked"]
20
+
21
+
22
+ class IntegrationReporter(Protocol):
23
+ """One external tool interested in Limbo's lifecycle.
24
+
25
+ Implementations must be fire-and-forget: never block, never raise.
26
+ """
27
+
28
+ def report(
29
+ self,
30
+ state: AgentState,
31
+ *,
32
+ message: str | None = None,
33
+ session_id: str | None = None,
34
+ session_path: str | None = None,
35
+ ) -> None:
36
+ """Report lifecycle state (optionally carrying session identity)."""
37
+ ...
38
+
39
+ def report_session(
40
+ self, session_id: str, session_path: str | None = None
41
+ ) -> None:
42
+ """Report session identity when it changes independently of state."""
43
+ ...
44
+
45
+ def release(self) -> None:
46
+ """Release any held authority/resources (on agent exit)."""
47
+ ...
48
+
49
+
50
+ class CompositeReporter:
51
+ """Fans lifecycle reports out to every active integration.
52
+
53
+ Always safe to call, even with zero reporters — call sites never need
54
+ to know which (if any) integrations are active.
55
+ """
56
+
57
+ def __init__(self, reporters: Iterable[IntegrationReporter] = ()): # noqa: D107
58
+ self._reporters = list(reporters)
59
+
60
+ def __bool__(self) -> bool:
61
+ return bool(self._reporters)
62
+
63
+ def report(
64
+ self,
65
+ state: AgentState,
66
+ *,
67
+ message: str | None = None,
68
+ session_id: str | None = None,
69
+ session_path: str | None = None,
70
+ ) -> None:
71
+ for reporter in self._reporters:
72
+ reporter.report(
73
+ state,
74
+ message=message,
75
+ session_id=session_id,
76
+ session_path=session_path,
77
+ )
78
+
79
+ def report_session(
80
+ self, session_id: str, session_path: str | None = None
81
+ ) -> None:
82
+ for reporter in self._reporters:
83
+ reporter.report_session(session_id, session_path)
84
+
85
+ def release(self) -> None:
86
+ for reporter in self._reporters:
87
+ reporter.release()
@@ -0,0 +1,143 @@
1
+ """Herdr terminal multiplexer integration.
2
+
3
+ When Limbo runs inside a Herdr pane, it inherits ``HERDR_ENV``,
4
+ ``HERDR_PANE_ID``, ``HERDR_BIN_PATH``, and ``HERDR_SOCKET_PATH``. This
5
+ reporter uses Herdr's CLI to report semantic lifecycle state
6
+ (``working``/``idle``/``blocked``) and native session identity, per
7
+ https://herdr.dev/docs/integrations/#integrate-your-own-agent
8
+
9
+ The integration is a strict no-op outside Herdr: ``from_env()`` returns
10
+ ``None`` unless ``HERDR_ENV=1`` and both the pane id and a usable ``herdr``
11
+ binary (``HERDR_BIN_PATH``, else PATH) are present.
12
+ Reports are fire-and-forget CLI invocations — they must never block or
13
+ break the UI, and a missing/broken ``herdr`` binary is silently ignored.
14
+
15
+ ``--source`` must stay stable and unique to this integration; ``--seq`` is
16
+ strictly increasing so Herdr can drop reports that arrive out of order
17
+ (turn-end and steer follow-up hand-offs race between workers).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+ from collections.abc import Mapping
26
+
27
+ from limbo.integrations.base import AgentState
28
+
29
+ SOURCE = "custom:limbo"
30
+ AGENT = "limbo"
31
+
32
+
33
+ class HerdrReporter:
34
+ """Fire-and-forget lifecycle reporter bound to one Herdr pane."""
35
+
36
+ def __init__(self, pane_id: str, bin_path: str):
37
+ self._pane_id = pane_id
38
+ self._bin_path = bin_path
39
+ self._seq = 0
40
+
41
+ @classmethod
42
+ def from_env(cls, env: Mapping[str, str] | None = None) -> HerdrReporter | None:
43
+ """Create a reporter when running inside a Herdr pane, else None.
44
+
45
+ ``HERDR_BIN_PATH`` is preferred (portable across PATH differences)
46
+ but is not guaranteed to be set — fall back to a PATH lookup.
47
+ """
48
+ env = os.environ if env is None else env
49
+ if env.get("HERDR_ENV") != "1":
50
+ return None
51
+ pane_id = env.get("HERDR_PANE_ID")
52
+ bin_path = env.get("HERDR_BIN_PATH") or shutil.which("herdr")
53
+ if not pane_id or not bin_path:
54
+ return None
55
+ return cls(pane_id, bin_path)
56
+
57
+ def report(
58
+ self,
59
+ state: AgentState,
60
+ *,
61
+ message: str | None = None,
62
+ session_id: str | None = None,
63
+ session_path: str | None = None,
64
+ ) -> None:
65
+ """Report lifecycle state (optionally with session identity)."""
66
+ cmd = [
67
+ self._bin_path,
68
+ "pane",
69
+ "report-agent",
70
+ self._pane_id,
71
+ "--source",
72
+ SOURCE,
73
+ "--agent",
74
+ AGENT,
75
+ "--state",
76
+ state,
77
+ "--seq",
78
+ str(self._next_seq()),
79
+ ]
80
+ if message:
81
+ cmd += ["--message", message]
82
+ if session_id:
83
+ cmd += ["--agent-session-id", session_id]
84
+ if session_path:
85
+ cmd += ["--agent-session-path", session_path]
86
+ self._run(cmd)
87
+
88
+ def report_session(
89
+ self, session_id: str, session_path: str | None = None
90
+ ) -> None:
91
+ """Report session identity when it changes independently of state
92
+ (``/new``, ``/sessions`` switch)."""
93
+ cmd = [
94
+ self._bin_path,
95
+ "pane",
96
+ "report-agent-session",
97
+ self._pane_id,
98
+ "--source",
99
+ SOURCE,
100
+ "--agent",
101
+ AGENT,
102
+ "--seq",
103
+ str(self._next_seq()),
104
+ "--agent-session-id",
105
+ session_id,
106
+ ]
107
+ if session_path:
108
+ cmd += ["--agent-session-path", session_path]
109
+ self._run(cmd)
110
+
111
+ def release(self) -> None:
112
+ """Release this source's lifecycle authority (on agent exit)."""
113
+ self._run(
114
+ [
115
+ self._bin_path,
116
+ "pane",
117
+ "release-agent",
118
+ self._pane_id,
119
+ "--source",
120
+ SOURCE,
121
+ "--agent",
122
+ AGENT,
123
+ ]
124
+ )
125
+
126
+ def _next_seq(self) -> int:
127
+ # Assigned synchronously before spawning, so sequence order matches
128
+ # call order even when deliveries race.
129
+ self._seq += 1
130
+ return self._seq
131
+
132
+ @staticmethod
133
+ def _run(cmd: list[str]) -> None:
134
+ try:
135
+ subprocess.Popen(
136
+ cmd,
137
+ stdin=subprocess.DEVNULL,
138
+ stdout=subprocess.DEVNULL,
139
+ stderr=subprocess.DEVNULL,
140
+ )
141
+ except OSError:
142
+ # A missing/broken herdr binary must never break the session.
143
+ pass
@@ -41,6 +41,7 @@ from limbo.goal_driver import (
41
41
  GoalVerifyResultEvent,
42
42
  GoalVerifyStarted,
43
43
  )
44
+ from limbo.integrations import AgentState, create_reporters
44
45
  from limbo.llm.client import LLMClient
45
46
  from limbo.llm.factory import create_llm_client
46
47
  from limbo.model_switch import (
@@ -111,6 +112,9 @@ class MainScreen(Screen[None]):
111
112
  self._compact_busy = False
112
113
  self._commands = SlashCommandRegistry()
113
114
  self._register_builtin_commands()
115
+ # External tool integrations (Herdr, ...): an empty composite
116
+ # outside any integrated environment, so all calls are no-ops.
117
+ self._integrations = create_reporters()
114
118
 
115
119
  @property
116
120
  def _agent_busy(self) -> bool:
@@ -140,6 +144,20 @@ class MainScreen(Screen[None]):
140
144
  resume=resume,
141
145
  )
142
146
 
147
+ # -- External tool integrations (no-op outside integrated environments) --
148
+
149
+ def _report_state(
150
+ self, state: AgentState, *, message: str | None = None
151
+ ) -> None:
152
+ """Report semantic lifecycle state to active integrations."""
153
+ self._integrations.report(state, message=message)
154
+
155
+ def _report_session(self) -> None:
156
+ """Report the current session identity (startup, /new, /sessions)."""
157
+ self._integrations.report_session(
158
+ self.agent.session_id, str(self.agent.session_path)
159
+ )
160
+
143
161
  def compose(self) -> ComposeResult:
144
162
  yield StatusBar(
145
163
  model=self.config.llm.model,
@@ -169,6 +187,8 @@ class MainScreen(Screen[None]):
169
187
  )
170
188
  self._refresh_goal_indicator() # D6: silent resume, badge only
171
189
  self.query_one("#input", InputWidget).focus()
190
+ self._report_session()
191
+ self._report_state("idle")
172
192
 
173
193
  # -- slash command menu ---------------------------------------------------
174
194
 
@@ -332,6 +352,7 @@ class MainScreen(Screen[None]):
332
352
  self._pending_proposals = parsed or []
333
353
  if not self._pending_proposals:
334
354
  chat.add_info("未能解析模型的验收提议,请手动输入或跳过")
355
+ self._report_state("blocked", message="等待确认 /goal 验收命令")
335
356
  self.app.push_screen(
336
357
  VerifyPicker(self._pending_proposals), self._on_verify_picked
337
358
  )
@@ -345,6 +366,7 @@ class MainScreen(Screen[None]):
345
366
  if choice is None:
346
367
  chat.add_info("已跳过自动验收(单轮模式);/goal clear 可退出 goal")
347
368
  self._refresh_goal_indicator()
369
+ self._report_state("idle")
348
370
  return
349
371
  if choice == VERIFY_EDIT:
350
372
  # Prefill the best proposal for editing; the next submission is
@@ -356,6 +378,7 @@ class MainScreen(Screen[None]):
356
378
  input_widget.focus()
357
379
  self._editing_verify = True
358
380
  chat.add_info("编辑验收命令后回车确认(清空回车则跳过)")
381
+ self._report_state("blocked", message="等待编辑 /goal 验收命令")
359
382
  return
360
383
  self._accept_verify(choice)
361
384
 
@@ -364,6 +387,7 @@ class MainScreen(Screen[None]):
364
387
  if is_dangerous(command, self.config.safety.dangerous_commands):
365
388
  chat.add_error(f"验收命令命中危险命令过滤,已拒绝:{command}")
366
389
  chat.add_info("goal 保持单轮模式;/goal clear 退出")
390
+ self._report_state("idle")
367
391
  return
368
392
  state = self.driver.set_verify(command)
369
393
  if state is None:
@@ -484,6 +508,7 @@ class MainScreen(Screen[None]):
484
508
  input_widget.disabled = True
485
509
  self._compact_busy = True
486
510
  statusbar.set_state("compacting…", "thinking")
511
+ self._report_state("working")
487
512
  try:
488
513
  async for event in self.agent.compact(trigger="manual"):
489
514
  await self._process_agent_event(event)
@@ -492,6 +517,7 @@ class MainScreen(Screen[None]):
492
517
  input_widget.disabled = False
493
518
  input_widget.focus()
494
519
  statusbar.set_state("idle")
520
+ self._report_state("idle")
495
521
 
496
522
  def _show_help(self) -> None:
497
523
  self.query_one("#chat", ChatWidget).add_info(self._commands.help_text())
@@ -549,6 +575,7 @@ class MainScreen(Screen[None]):
549
575
  self.agent = self._new_agent()
550
576
  chat.clear()
551
577
  chat.add_info(f"已开始新会话 {self.agent.session_id}")
578
+ self._report_session()
552
579
 
553
580
  def _find_skill(self, name: str) -> Skill | None:
554
581
  if self._commands.get(f"/{name}") is not None:
@@ -602,6 +629,7 @@ class MainScreen(Screen[None]):
602
629
  self._render_history()
603
630
  meta = self.agent.session_meta
604
631
  chat.add_info(f"已切换到会话 {meta.id} · {meta.title or '(无标题)'}")
632
+ self._report_session()
605
633
 
606
634
  def _render_history(self) -> None:
607
635
  """Render the agent's restored history into the chat flow.
@@ -684,6 +712,7 @@ class MainScreen(Screen[None]):
684
712
  async def on_unmount(self) -> None:
685
713
  """Close owned resources on shutdown: the LLM client's HTTP pool and
686
714
  the agent's trace file handle."""
715
+ self._integrations.release()
687
716
  self.agent.close()
688
717
  close = getattr(self.llm_client, "close", None)
689
718
  if close is not None:
@@ -707,6 +736,7 @@ class MainScreen(Screen[None]):
707
736
  self._editing_verify = False
708
737
  chat = self.query_one("#chat", ChatWidget)
709
738
  if text.startswith("/"):
739
+ self._report_state("idle")
710
740
  self._handle_command(text)
711
741
  return
712
742
  command = text.strip()
@@ -715,6 +745,7 @@ class MainScreen(Screen[None]):
715
745
  self._accept_verify(command)
716
746
  else:
717
747
  chat.add_info("已跳过自动验收(单轮模式);/goal clear 可退出 goal")
748
+ self._report_state("idle")
718
749
  return
719
750
  if text.startswith("/"):
720
751
  self._handle_command(text)
@@ -825,6 +856,7 @@ class MainScreen(Screen[None]):
825
856
  # go to the steer queue instead of being blocked. Single-flight is
826
857
  # owned by the goal driver (``running`` is set eagerly on call).
827
858
  statusbar.set_state("thinking…", "thinking")
859
+ self._report_state("working")
828
860
  # driver.run() sets ``running`` eagerly at call time, so the badge
829
861
  # refresh below already sees the loop as executing (rainbow on).
830
862
  events = self.driver.run(user_input, attachments)
@@ -858,6 +890,7 @@ class MainScreen(Screen[None]):
858
890
  # the user can keep typing without clicking.
859
891
  input_widget.focus()
860
892
  statusbar.set_state("idle")
893
+ self._report_state("idle")
861
894
  self._refresh_goal_indicator()
862
895
  # M2: a proposal round that ends without leftover steers pops
863
896
  # the verify-command confirmation picker.