limbo-code 0.1.17__tar.gz → 0.2__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 (204) hide show
  1. {limbo_code-0.1.17 → limbo_code-0.2}/PKG-INFO +1 -1
  2. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/agent.py +84 -6
  3. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/sessions.py +53 -1
  4. limbo_code-0.2/src/limbo/trace.py +181 -0
  5. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/app.tcss +26 -0
  6. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/screens/main.py +36 -9
  7. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/screens/session_picker.py +2 -0
  8. limbo_code-0.2/src/limbo/ui/widgets/chat.py +729 -0
  9. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/widgets/tool_card.py +50 -14
  10. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_agent.py +135 -16
  11. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_interrupt.py +13 -7
  12. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_pump.py +1 -0
  13. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_sessions.py +37 -1
  14. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_trace.py +50 -1
  15. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_sessions_ui.py +75 -0
  16. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_snapshots.py +6 -0
  17. limbo_code-0.2/tests/ui/test_widgets.py +620 -0
  18. limbo_code-0.1.17/src/limbo/trace.py +0 -110
  19. limbo_code-0.1.17/src/limbo/ui/widgets/chat.py +0 -343
  20. limbo_code-0.1.17/tests/ui/test_widgets.py +0 -233
  21. {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/grill-with-docs/SKILL.md +0 -0
  22. {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/grill-with-docs/agents/openai.yaml +0 -0
  23. {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +0 -0
  24. {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/improve-codebase-architecture/SKILL.md +0 -0
  25. {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/improve-codebase-architecture/agents/openai.yaml +0 -0
  26. {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/tdd/SKILL.md +0 -0
  27. {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/tdd/agents/openai.yaml +0 -0
  28. {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/tdd/mocking.md +0 -0
  29. {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/tdd/tests.md +0 -0
  30. {limbo_code-0.1.17 → limbo_code-0.2}/.github/workflows/publish.yml +0 -0
  31. {limbo_code-0.1.17 → limbo_code-0.2}/.github/workflows/test.yml +0 -0
  32. {limbo_code-0.1.17 → limbo_code-0.2}/.gitignore +0 -0
  33. {limbo_code-0.1.17 → limbo_code-0.2}/AGENTS.md +0 -0
  34. {limbo_code-0.1.17 → limbo_code-0.2}/CONTEXT.md +0 -0
  35. {limbo_code-0.1.17 → limbo_code-0.2}/Makefile +0 -0
  36. {limbo_code-0.1.17 → limbo_code-0.2}/README.md +0 -0
  37. {limbo_code-0.1.17 → limbo_code-0.2}/design/bang-verification.md +0 -0
  38. {limbo_code-0.1.17 → limbo_code-0.2}/design/confirm_view.html +0 -0
  39. {limbo_code-0.1.17 → limbo_code-0.2}/design/limbo-ui-minimal.md +0 -0
  40. {limbo_code-0.1.17 → limbo_code-0.2}/design/limbo-ui-redesign.md +0 -0
  41. {limbo_code-0.1.17 → limbo_code-0.2}/design/prototype-minimal-confirm.png +0 -0
  42. {limbo_code-0.1.17 → limbo_code-0.2}/design/prototype-minimal.html +0 -0
  43. {limbo_code-0.1.17 → limbo_code-0.2}/design/prototype-minimal.png +0 -0
  44. {limbo_code-0.1.17 → limbo_code-0.2}/design/prototype.html +0 -0
  45. {limbo_code-0.1.17 → limbo_code-0.2}/design/prototype.png +0 -0
  46. {limbo_code-0.1.17 → limbo_code-0.2}/design/rfc-bang-command.md +0 -0
  47. {limbo_code-0.1.17 → limbo_code-0.2}/design/rfc-headless-cli.md +0 -0
  48. {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/limbo-current-ui.png +0 -0
  49. {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/limbo-new-ui.png +0 -0
  50. {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/1_idle.svg +0 -0
  51. {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/2_thinking_tool_running.svg +0 -0
  52. {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/3_tool_success.svg +0 -0
  53. {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/4_tool_error.svg +0 -0
  54. {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/5_llm_error.svg +0 -0
  55. {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/6_edit_diff.svg +0 -0
  56. {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/1_idle.svg +0 -0
  57. {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/2_thinking_tool_running.svg +0 -0
  58. {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/3_tool_success.svg +0 -0
  59. {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/4_tool_error.svg +0 -0
  60. {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/5_llm_error.svg +0 -0
  61. {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/6_edit_diff.svg +0 -0
  62. {limbo_code-0.1.17 → limbo_code-0.2}/docs/session-management.md +0 -0
  63. {limbo_code-0.1.17 → limbo_code-0.2}/docs/skills.md +0 -0
  64. {limbo_code-0.1.17 → limbo_code-0.2}/docs/ui-redesign-proposal.md +0 -0
  65. {limbo_code-0.1.17 → limbo_code-0.2}/pyproject.toml +0 -0
  66. {limbo_code-0.1.17 → limbo_code-0.2}/scripts/check_contrast.py +0 -0
  67. {limbo_code-0.1.17 → limbo_code-0.2}/scripts/gen_banner.py +0 -0
  68. {limbo_code-0.1.17 → limbo_code-0.2}/scripts/scroll_bench.py +0 -0
  69. {limbo_code-0.1.17 → limbo_code-0.2}/scripts/ui_walkthrough.py +0 -0
  70. {limbo_code-0.1.17 → limbo_code-0.2}/skills-lock.json +0 -0
  71. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/__init__.py +0 -0
  72. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/__main__.py +0 -0
  73. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/app.py +0 -0
  74. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/attachments.py +0 -0
  75. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/btw.py +0 -0
  76. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/code_mode.py +0 -0
  77. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/compaction.py +0 -0
  78. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/config.py +0 -0
  79. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/goal.py +0 -0
  80. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/headless.py +0 -0
  81. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/history.py +0 -0
  82. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/integrations/__init__.py +0 -0
  83. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/integrations/base.py +0 -0
  84. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/integrations/herdr.py +0 -0
  85. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/__init__.py +0 -0
  86. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/anthropic_client.py +0 -0
  87. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/catalog.py +0 -0
  88. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/client.py +0 -0
  89. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/factory.py +0 -0
  90. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/openai_client.py +0 -0
  91. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/responses_client.py +0 -0
  92. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/retry.py +0 -0
  93. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/scaffold.py +0 -0
  94. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/sse.py +0 -0
  95. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/usage.py +0 -0
  96. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/model_switch.py +0 -0
  97. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/models.py +0 -0
  98. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/prompt.py +0 -0
  99. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/pump.py +0 -0
  100. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/skills.py +0 -0
  101. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/steer.py +0 -0
  102. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/__init__.py +0 -0
  103. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/base.py +0 -0
  104. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/bash.py +0 -0
  105. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/edit.py +0 -0
  106. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/find.py +0 -0
  107. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/grep.py +0 -0
  108. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/ignore.py +0 -0
  109. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/ls.py +0 -0
  110. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/mutation_queue.py +0 -0
  111. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/read.py +0 -0
  112. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/registry.py +0 -0
  113. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/run_code.py +0 -0
  114. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/write.py +0 -0
  115. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/__init__.py +0 -0
  116. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/app.py +0 -0
  117. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/banner.py +0 -0
  118. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/clipboard.py +0 -0
  119. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/commands.py +0 -0
  120. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/contrast.py +0 -0
  121. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/key_fixes.py +0 -0
  122. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/paste_store.py +0 -0
  123. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/path_input.py +0 -0
  124. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/screens/__init__.py +0 -0
  125. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/screens/btw.py +0 -0
  126. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/screens/game2048.py +0 -0
  127. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/screens/model_picker.py +0 -0
  128. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/screens/verify_picker.py +0 -0
  129. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/syntax.py +0 -0
  130. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/theme.py +0 -0
  131. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/widgets/__init__.py +0 -0
  132. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/widgets/command_menu.py +0 -0
  133. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/widgets/input.py +0 -0
  134. {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/widgets/status_bar.py +0 -0
  135. {limbo_code-0.1.17 → limbo_code-0.2}/tests/conftest.py +0 -0
  136. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_anthropic_client.py +0 -0
  137. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_attachments.py +0 -0
  138. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_btw.py +0 -0
  139. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_catalog.py +0 -0
  140. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_cli.py +0 -0
  141. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_clipboard.py +0 -0
  142. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_code_mode.py +0 -0
  143. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_code_mode_agent.py +0 -0
  144. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_compaction.py +0 -0
  145. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_config.py +0 -0
  146. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_goal.py +0 -0
  147. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_headless.py +0 -0
  148. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_herdr.py +0 -0
  149. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_history.py +0 -0
  150. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_integration.py +0 -0
  151. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_integrations.py +0 -0
  152. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_llm_client.py +0 -0
  153. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_llm_scaffold.py +0 -0
  154. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_model_switch.py +0 -0
  155. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_models.py +0 -0
  156. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_prompt.py +0 -0
  157. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_responses_client.py +0 -0
  158. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_retry.py +0 -0
  159. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_skills.py +0 -0
  160. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_sse.py +0 -0
  161. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_steer.py +0 -0
  162. {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_usage.py +0 -0
  163. {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_base.py +0 -0
  164. {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_bash.py +0 -0
  165. {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_edit.py +0 -0
  166. {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_find.py +0 -0
  167. {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_grep.py +0 -0
  168. {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_ignore.py +0 -0
  169. {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_ls.py +0 -0
  170. {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_mutation_queue.py +0 -0
  171. {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_read.py +0 -0
  172. {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_registry.py +0 -0
  173. {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_run_code.py +0 -0
  174. {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_write.py +0 -0
  175. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/__snapshots__/test_snapshots/test_snapshot_idle.raw +0 -0
  176. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/__snapshots__/test_snapshots/test_snapshot_session_picker.raw +0 -0
  177. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/__snapshots__/test_snapshots/test_snapshot_thinking_and_running_tool.raw +0 -0
  178. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/__snapshots__/test_snapshots/test_snapshot_tool_error_and_error_message.raw +0 -0
  179. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_app_smoke.py +0 -0
  180. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_bang_ui.py +0 -0
  181. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_btw_ui.py +0 -0
  182. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_code_mode_ui.py +0 -0
  183. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_command_menu.py +0 -0
  184. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_commands.py +0 -0
  185. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_compact_ui.py +0 -0
  186. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_contrast.py +0 -0
  187. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_copy.py +0 -0
  188. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_game2048.py +0 -0
  189. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_goal_ui.py +0 -0
  190. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_input_attachments.py +0 -0
  191. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_input_history.py +0 -0
  192. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_input_paste.py +0 -0
  193. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_interrupt_ui.py +0 -0
  194. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_key_fixes.py +0 -0
  195. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_main_screen.py +0 -0
  196. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_model_picker.py +0 -0
  197. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_paste_store.py +0 -0
  198. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_path_input.py +0 -0
  199. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_scroll_follow.py +0 -0
  200. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_skills_ui.py +0 -0
  201. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_startup_art.py +0 -0
  202. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_steer_ui.py +0 -0
  203. {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_theme.py +0 -0
  204. {limbo_code-0.1.17 → limbo_code-0.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: limbo-code
3
- Version: 0.1.17
3
+ Version: 0.2
4
4
  Summary: A minimal terminal AI coding agent
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import json
6
7
  import os
7
8
  import platform
8
9
  import secrets
@@ -44,6 +45,7 @@ from limbo.prompt import build_system_prompt
44
45
  from limbo.sessions import (
45
46
  CompactionRecord,
46
47
  SessionMeta,
48
+ append_session_messages,
47
49
  derive_title,
48
50
  load_session,
49
51
  save_session,
@@ -218,6 +220,13 @@ class Agent:
218
220
  self._prompt_estimator = PromptSizeEstimator()
219
221
  self._compactions: list[CompactionRecord] = []
220
222
  self._previous_summary: str | None = None
223
+ # Incremental session persistence: how many messages are already on
224
+ # disk (append-only fast path), and whether the next save must do a
225
+ # full rewrite instead (history reshaped by compaction, or a resume
226
+ # whose repair() inserted placeholder tool messages).
227
+ self._session_saved_count = 0
228
+ self._session_saved_meta: str | None = None
229
+ self._session_full_rewrite_needed = False
221
230
  self._auto_compaction_failed_this_turn = False
222
231
  self._auto_compact_noop_notified = False
223
232
 
@@ -226,6 +235,12 @@ class Agent:
226
235
  self._meta, history, self._compactions = load_session(resume)
227
236
  self._session_file = resume
228
237
  self.messages.extend(repair_history(history))
238
+ # repair_history may have inserted placeholder tool messages, and
239
+ # load_session re-applied compaction: the in-memory history no
240
+ # longer matches the file byte-for-byte, so the first save after
241
+ # resume is a full rewrite (then the append path takes over).
242
+ self._session_saved_count = len(self.messages)
243
+ self._session_full_rewrite_needed = True
229
244
  # Restore the presentation mode: repair_history dropped the
230
245
  # loaded system message, so rebuild the fresh one in code mode
231
246
  # when the session was saved with it on.
@@ -1254,6 +1269,10 @@ class Agent:
1254
1269
  system_msg = self.messages[0]
1255
1270
  kept = self.messages[split:]
1256
1271
  self.messages = [system_msg, make_summary_message(summary), *kept]
1272
+ # History changed shape (old messages dropped): the append-only
1273
+ # fast path would leave the summarized-away messages on disk, so the
1274
+ # next save must fully rewrite (shrinking the file).
1275
+ self._session_full_rewrite_needed = True
1257
1276
  after = estimate_tokens(self.messages)
1258
1277
  record = CompactionRecord(
1259
1278
  id=secrets.token_hex(8),
@@ -1294,13 +1313,72 @@ class Agent:
1294
1313
  )
1295
1314
 
1296
1315
  def _save_session_sync(self) -> None:
1297
- # Rewrite the whole session on every save. It is cheap for MVP-sized
1298
- # conversations.
1316
+ """Persist the session: incremental append by default, full rewrite
1317
+ when the on-disk state changes shape.
1318
+
1319
+ Full rewrite happens when the file does not exist yet (first save),
1320
+ when compaction reshaped the history, when a resumed session's
1321
+ repaired history must be re-synced, or when the meta record changed
1322
+ (title, code_mode, goal, ...) — the meta line is only written by a
1323
+ full rewrite, so any meta drift forces one. The meta snapshot
1324
+ comparison replaces ad-hoc dirty flags, so future meta fields (e.g.
1325
+ the LIM-40 goal state mutated by TurnPump._save_state) are covered
1326
+ automatically. Otherwise only the messages appended since the last
1327
+ save are written — long sessions no longer rewrite megabytes every
1328
+ turn.
1329
+ """
1330
+ # Keep the presentation mode in sync before snapshotting the meta.
1331
+ self._meta.code_mode = self.code_mode
1299
1332
  if not self._meta.title:
1300
1333
  self._meta.title = derive_title(self.messages)
1301
- # Persist the presentation mode so a resumed session restores it.
1302
- self._meta.code_mode = self.code_mode
1303
- save_session(self._session_file, self._meta, self.messages, self._compactions)
1334
+ # updated_at is stamped by save_session (full rewrite) and refreshed
1335
+ # in-memory on the append path; exclude it so the comparison below
1336
+ # compares real meta content, not the always-moving timestamp.
1337
+ meta_snapshot = json.dumps(
1338
+ self._meta.model_dump(exclude={"path", "updated_at"}),
1339
+ ensure_ascii=False,
1340
+ sort_keys=True,
1341
+ )
1342
+ if (
1343
+ self._session_full_rewrite_needed
1344
+ or meta_snapshot != self._session_saved_meta
1345
+ or not self._session_file.exists()
1346
+ ):
1347
+ save_session(
1348
+ self._session_file,
1349
+ self._meta,
1350
+ self.messages,
1351
+ self._compactions,
1352
+ )
1353
+ self._session_saved_count = len(self.messages)
1354
+ self._session_saved_meta = meta_snapshot
1355
+ self._session_full_rewrite_needed = False
1356
+ else:
1357
+ new_messages = self.messages[self._session_saved_count :]
1358
+ if new_messages:
1359
+ try:
1360
+ append_session_messages(self._session_file, new_messages)
1361
+ except OSError:
1362
+ # Partial or failed append: some of the new messages may
1363
+ # already be on disk, and re-appending next time would
1364
+ # duplicate them. Force a full rewrite on the next save,
1365
+ # which rebuilds the file and drops the duplicates.
1366
+ self._session_full_rewrite_needed = True
1367
+ raise
1368
+ self._session_saved_count = len(self.messages)
1369
+ # Keep the in-memory timestamp fresh even though the meta line
1370
+ # is not rewritten (export headers read it).
1371
+ self._meta.updated_at = datetime.now(timezone.utc).isoformat(
1372
+ timespec="seconds"
1373
+ )
1304
1374
 
1305
1375
  async def _save_session(self) -> None:
1306
- await asyncio.to_thread(self._save_session_sync)
1376
+ # Trace and session are separate files written independently; the
1377
+ # flush barrier belongs here (turn end), so by the time run()
1378
+ # returns, every trace record is on disk. Both run off the event
1379
+ # loop so the UI never blocks on either file.
1380
+ def _sync() -> None:
1381
+ self.trace.flush()
1382
+ self._save_session_sync()
1383
+
1384
+ await asyncio.to_thread(_sync)
@@ -104,7 +104,14 @@ def save_session(
104
104
  messages: list[Message],
105
105
  compactions: Sequence[CompactionRecord] = (),
106
106
  ) -> None:
107
- """Atomically write the whole session (meta + compactions + messages)."""
107
+ """Atomically (re)write the whole session: meta + compactions + messages.
108
+
109
+ The full rewrite is the heavyweight path — used on session creation and
110
+ whenever the on-disk history changes shape (compaction) or the meta
111
+ line must be refreshed (title/code_mode). Steady-state persistence goes
112
+ through :func:`append_session_messages`, which only appends new
113
+ messages, so long sessions do not rewrite megabytes per turn.
114
+ """
108
115
  path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
109
116
  meta.updated_at = _utc_now_iso()
110
117
  meta_line = json.dumps(
@@ -130,6 +137,29 @@ def save_session(
130
137
  path.chmod(0o600)
131
138
 
132
139
 
140
+ def append_session_messages(path: Path, messages: list[Message]) -> None:
141
+ """Append new messages to an existing session file (incremental save).
142
+
143
+ The cheap steady-state write: the meta line and all previously saved
144
+ messages stay untouched, only ``messages`` are appended. Requires the
145
+ file to already exist with a meta first line (created by
146
+ :func:`save_session`) — a missing file raises instead of silently
147
+ creating a meta-less session, so the full-rewrite-then-append contract
148
+ stays explicit. Permissions are preserved (no re-chmod). A crash
149
+ during the append can leave a torn last line (skipped on load) and
150
+ loses at most the in-flight increment — narrower than the atomic
151
+ rewrite, which is why callers fall back to a full rewrite after a
152
+ failed append.
153
+ """
154
+ if not path.exists():
155
+ raise FileNotFoundError(
156
+ f"Cannot append to missing session file: {path}"
157
+ )
158
+ with path.open("a", encoding="utf-8") as f:
159
+ for msg in messages:
160
+ f.write(msg.model_dump_json() + "\n")
161
+
162
+
133
163
  def _parse_meta(raw: dict[str, Any], fallback_id: str, path: Path) -> SessionMeta:
134
164
  data = {k: v for k, v in raw.items() if k != "type"}
135
165
  data.setdefault("id", fallback_id)
@@ -192,6 +222,12 @@ def load_session(
192
222
  try:
193
223
  record = json.loads(line)
194
224
  except json.JSONDecodeError:
225
+ # Torn tail from a crashed incremental append: skip, but say
226
+ # so — a silently dropped message is hard to diagnose.
227
+ warnings.warn(
228
+ f"Skipping malformed line in session {path}: {line[:60]!r}",
229
+ stacklevel=2,
230
+ )
195
231
  continue
196
232
  if record.get("type") == META_TYPE:
197
233
  meta = _parse_meta(record, path.stem, path)
@@ -255,6 +291,22 @@ def list_sessions(
255
291
  if wanted is not None and meta.workdir != wanted:
256
292
  continue
257
293
  sessions.append(meta)
294
+ # Surface the file mtime as updated_at: incremental saves append to the
295
+ # file without rewriting the meta line, so the meta record's own
296
+ # updated_at goes stale (it still carries the creation / last-full-
297
+ # rewrite time), while the file's mtime is exactly "when this session
298
+ # was last written". Callers (picker, sorting) see fresh values without
299
+ # knowing the incremental-save detail.
300
+ for meta in sessions:
301
+ meta_path = meta.path
302
+ if meta_path is None:
303
+ continue
304
+ try:
305
+ meta.updated_at = datetime.fromtimestamp(
306
+ meta_path.stat().st_mtime, tz=timezone.utc
307
+ ).isoformat(timespec="seconds")
308
+ except OSError:
309
+ pass # keep the stale meta value; the file vanished mid-list
258
310
  sessions.sort(key=lambda m: m.updated_at, reverse=True)
259
311
  return sessions
260
312
 
@@ -0,0 +1,181 @@
1
+ """Append-only JSONL trace log for analyzing complete agent runs.
2
+
3
+ The trace lives in a ``traces/`` subdirectory of the session directory
4
+ (``traces/<session>.trace.jsonl``) and records everything the message
5
+ history cannot reconstruct after the fact: full LLM request bodies,
6
+ token usage, tool execution timing, and errors/interruptions.
7
+
8
+ Full fidelity is the contract — every record (including the entire LLM
9
+ request body, which can be hundreds of KB to MBs on long sessions) is
10
+ persisted, never sampled or truncated. What IS optimized is the write
11
+ path: :meth:`TraceLogger.log` only enqueues the record (microseconds),
12
+ while JSON serialization and the file write+flush run on a dedicated
13
+ background writer thread, so a huge ``llm_request`` body never blocks
14
+ the asyncio event loop (which is also the UI thread). Ordering is
15
+ preserved (single FIFO consumer); ``flush()`` provides a synchronous
16
+ barrier for readers (export, turn-end save); ``close()`` drains and
17
+ joins the writer before releasing the handle. Tracing must never break
18
+ the agent: all write failures degrade to a warning.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import queue
25
+ import threading
26
+ import warnings
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ TRACE_SUFFIX = ".trace.jsonl"
32
+
33
+ # How long close() waits for the writer thread to drain the queue before
34
+ # giving up and closing the handle anyway (a stuck OS write must not hang
35
+ # session teardown / app exit).
36
+ _CLOSE_DRAIN_TIMEOUT = 10.0
37
+
38
+
39
+ def trace_path_for(session_file: Path) -> Path:
40
+ """Return the trace file path belonging to a session file.
41
+
42
+ Traces live in a ``traces/`` subdirectory so session listing/globbing
43
+ (``*.jsonl``) never picks them up.
44
+ """
45
+ return session_file.parent / "traces" / f"{session_file.stem}{TRACE_SUFFIX}"
46
+
47
+
48
+ def _utc_now_iso() -> str:
49
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
50
+
51
+
52
+ class TraceLogger:
53
+ """Thread-safe, append-only JSONL event log (one record per line).
54
+
55
+ Enqueue-fast / write-later: ``log()`` assembles the record dict and
56
+ hands it to a single background writer thread (daemon) that owns all
57
+ serialization and file I/O. Callers never block on disk. Use
58
+ :meth:`flush` at points where a reader must see everything logged so
59
+ far (e.g. before exporting, or at the turn-end save), and :meth:`close`
60
+ to drain and release the file handle.
61
+ """
62
+
63
+ def __init__(self, path: Path):
64
+ self.path = path
65
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
66
+ file_existed = self.path.exists()
67
+ self._file = self.path.open("a", encoding="utf-8")
68
+ if not file_existed:
69
+ self.path.chmod(0o600)
70
+ self._queue: "queue.Queue[dict[str, Any] | None]" = queue.Queue()
71
+ self._failed = False
72
+ self._closed = False
73
+ self._thread = threading.Thread(
74
+ target=self._writer,
75
+ name="limbo-trace-writer",
76
+ daemon=True,
77
+ )
78
+ self._thread.start()
79
+
80
+ # -- writer thread --------------------------------------------------------
81
+
82
+ def _writer(self) -> None:
83
+ """Consume the FIFO queue: serialize + write + flush each record.
84
+
85
+ Every record is flushed individually, preserving the original
86
+ crash-safety guarantee (a crash loses at most the records still in
87
+ the queue). The sentinel ``None`` (pushed by :meth:`close`) stops
88
+ the loop after all earlier records were written.
89
+
90
+ ``task_done`` runs AFTER the file write: :meth:`flush` blocks on
91
+ ``queue.join()``, so it must only return once the record is really
92
+ on disk (barrier semantics for export / turn-end save).
93
+ """
94
+ while True:
95
+ record = self._queue.get()
96
+ try:
97
+ if record is None:
98
+ return
99
+ try:
100
+ line = json.dumps(record, ensure_ascii=False, default=str)
101
+ except (TypeError, ValueError) as e:
102
+ # Extremely defensive: `default=str` should catch
103
+ # everything.
104
+ line = json.dumps(
105
+ {
106
+ "ts": record.get("ts") if record else None,
107
+ "type": record.get("type") if record else "?",
108
+ "serialization_error": str(e),
109
+ },
110
+ ensure_ascii=False,
111
+ )
112
+ try:
113
+ self._file.write(line + "\n")
114
+ self._file.flush()
115
+ except (OSError, ValueError) as e:
116
+ # ValueError covers writes on a file handle closed by
117
+ # close() after its drain timeout — the writer must
118
+ # degrade to a warning, never die with a traceback.
119
+ if not self._failed:
120
+ self._failed = True
121
+ warnings.warn(f"Trace log write failed: {e}", stacklevel=2)
122
+ finally:
123
+ self._queue.task_done()
124
+
125
+ # -- public API -----------------------------------------------------------
126
+
127
+ def log(self, event_type: str, **fields: Any) -> None:
128
+ """Queue one event record. Never raises, never blocks on disk."""
129
+ if self._closed:
130
+ return
131
+ record = {"ts": _utc_now_iso(), "type": event_type, **fields}
132
+ self._queue.put(record)
133
+
134
+ def flush(self) -> None:
135
+ """Block until every record logged so far is on disk.
136
+
137
+ Safe to call from any thread (used inside ``asyncio.to_thread`` at
138
+ the turn-end save, so the UI never waits on it). No-op after
139
+ :meth:`close`.
140
+ """
141
+ if self._closed:
142
+ return
143
+ self._queue.join()
144
+
145
+ def close(self) -> None:
146
+ """Drain the queue, stop the writer, release the file handle.
147
+
148
+ Idempotent: safe to call from Agent.close() at every replacement
149
+ point even when the UI tears down several agents in sequence.
150
+ Records logged after close are dropped silently (the handle is
151
+ gone), same as before.
152
+ """
153
+ if self._closed:
154
+ return
155
+ self._closed = True
156
+ self._queue.put(None) # sentinel: FIFO keeps earlier records first
157
+ self._thread.join(timeout=_CLOSE_DRAIN_TIMEOUT)
158
+ try:
159
+ self._file.close()
160
+ except OSError:
161
+ pass
162
+
163
+
164
+ def read_trace(path: Path) -> list[dict[str, Any]]:
165
+ """Read all trace records, skipping malformed lines."""
166
+ records: list[dict[str, Any]] = []
167
+ try:
168
+ with path.open("r", encoding="utf-8") as f:
169
+ for line in f:
170
+ line = line.strip()
171
+ if not line:
172
+ continue
173
+ try:
174
+ record = json.loads(line)
175
+ except json.JSONDecodeError:
176
+ continue
177
+ if isinstance(record, dict):
178
+ records.append(record)
179
+ except OSError:
180
+ pass
181
+ return records
@@ -45,10 +45,25 @@ ChatWidget {
45
45
  padding: 0;
46
46
  }
47
47
  .thinking-message {
48
+ /* Vertical defaults to height: 1fr — without this a thinking block eats
49
+ all free space when the conversation is shorter than the viewport
50
+ (fresh session, just after resume), leaving a huge blank gap. */
51
+ height: auto;
48
52
  color: $text-tertiary;
49
53
  text-style: italic;
50
54
  margin-top: 1;
51
55
  }
56
+ .thinking-summary {
57
+ color: $text-tertiary;
58
+ text-style: italic;
59
+ }
60
+ .thinking-summary:hover {
61
+ text-style: bold italic;
62
+ }
63
+ .thinking-body {
64
+ color: $text-tertiary;
65
+ text-style: italic;
66
+ }
52
67
  .error-message {
53
68
  width: 1fr;
54
69
  background: $error-bg;
@@ -97,6 +112,17 @@ ChatWidget {
97
112
  color: $text-secondary;
98
113
  margin-top: 1;
99
114
  }
115
+ #load-more {
116
+ height: 1;
117
+ background: $panel;
118
+ color: $accent;
119
+ text-style: bold;
120
+ text-align: center;
121
+ margin-bottom: 1;
122
+ }
123
+ #load-more:hover {
124
+ color: $primary;
125
+ }
100
126
  #back-to-bottom {
101
127
  dock: bottom;
102
128
  height: 1;
@@ -694,8 +694,9 @@ class MainScreen(Screen[None]):
694
694
  def _render_history(self) -> None:
695
695
  """Render the agent's restored history into the chat flow.
696
696
 
697
- User/assistant text is rendered as-is; raw tool outputs are summarized
698
- (tool cards are not rebuilt for history).
697
+ User/assistant text is rendered as-is; reasoning is restored as a
698
+ collapsed thinking block; raw tool outputs are summarized (tool cards
699
+ are not rebuilt for history).
699
700
  """
700
701
  chat = self.query_one("#chat", ChatWidget)
701
702
  skipped_tools = 0
@@ -704,8 +705,11 @@ class MainScreen(Screen[None]):
704
705
  chat.add_info("(此前对话已压缩为摘要)")
705
706
  elif msg.role == "user" and msg.content:
706
707
  chat.add_user_message(msg.content, msg.attachments)
707
- elif msg.role == "assistant" and msg.content:
708
- chat.add_assistant_message(msg.content)
708
+ elif msg.role == "assistant":
709
+ if msg.reasoning:
710
+ chat.add_thinking_message(msg.reasoning)
711
+ if msg.content:
712
+ chat.add_assistant_message(msg.content)
709
713
  elif msg.role == "tool":
710
714
  skipped_tools += 1
711
715
  if skipped_tools:
@@ -722,17 +726,35 @@ class MainScreen(Screen[None]):
722
726
  out = (
723
727
  Path.home() / ".limbo" / "exports" / f"{self.agent.session_id}.jsonl"
724
728
  )
725
- try:
729
+ # flush + export run off the event loop: flush() is a queue.join()
730
+ # barrier that can block for the whole writer backlog on a long
731
+ # session, and export_jsonl re-reads the whole trace — neither
732
+ # should freeze the UI.
733
+ self.run_worker(self._export_worker(chat, out), name="export-session")
734
+
735
+ async def _export_worker(self, chat: ChatWidget, out: Path) -> None:
736
+ """Perform the export off the event loop, then report."""
737
+ def _sync() -> None:
738
+ # The export reads the trace file; flush the background
739
+ # writer first so every record lands in the export.
740
+ self.agent.trace.flush()
726
741
  if out.suffix == ".md":
727
- export_markdown(meta, self.agent.messages, out)
742
+ export_markdown(self.agent.session_meta, self.agent.messages, out)
728
743
  else:
729
744
  export_jsonl(
730
- meta, self.agent.messages, out, trace_path=self.agent.trace.path
745
+ self.agent.session_meta,
746
+ self.agent.messages,
747
+ out,
748
+ trace_path=self.agent.trace.path,
731
749
  )
750
+
751
+ try:
752
+ await asyncio.to_thread(_sync)
732
753
  except OSError as e:
733
754
  chat.add_error(f"导出失败:{e}")
734
- return
735
- chat.add_info(f"已导出到 {out}")
755
+ else:
756
+ chat.add_info(f"已导出到 {out}")
757
+
736
758
 
737
759
  def action_toggle_tools(self) -> None:
738
760
  self.query_one("#chat", ChatWidget).toggle_tool_bodies()
@@ -983,6 +1005,11 @@ class MainScreen(Screen[None]):
983
1005
  # swallowing it the way the old finally-drain did.
984
1006
  self.query_one("#chat", ChatWidget).add_error(f"内部错误:{e}")
985
1007
  finally:
1008
+ # Flush any buffered streamed text (assistant/thinking) so the
1009
+ # final chunks are rendered even if the last flush tick had not
1010
+ # fired yet — otherwise the transcript/display loses the tail.
1011
+ chat = self.query_one("#chat", ChatWidget)
1012
+ await chat.flush_stream()
986
1013
  self._update_queue_status()
987
1014
  # Disabling the input mid-turn moves focus away; give it back so
988
1015
  # the user can keep typing without clicking.
@@ -47,6 +47,8 @@ class SessionPicker(Screen[Path | None]):
47
47
  @staticmethod
48
48
  def _format(meta: SessionMeta) -> str:
49
49
  title = meta.title or "(无标题)"
50
+ # list_sessions surfaces the file mtime as updated_at, so this shows
51
+ # when the session was last written even under incremental saves.
50
52
  updated = meta.updated_at[:16].replace("T", " ")
51
53
  return f"{title} · {updated} · {meta.id}"
52
54