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.
- {limbo_code-0.1.17 → limbo_code-0.2}/PKG-INFO +1 -1
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/agent.py +84 -6
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/sessions.py +53 -1
- limbo_code-0.2/src/limbo/trace.py +181 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/app.tcss +26 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/screens/main.py +36 -9
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/screens/session_picker.py +2 -0
- limbo_code-0.2/src/limbo/ui/widgets/chat.py +729 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/widgets/tool_card.py +50 -14
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_agent.py +135 -16
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_interrupt.py +13 -7
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_pump.py +1 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_sessions.py +37 -1
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_trace.py +50 -1
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_sessions_ui.py +75 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_snapshots.py +6 -0
- limbo_code-0.2/tests/ui/test_widgets.py +620 -0
- limbo_code-0.1.17/src/limbo/trace.py +0 -110
- limbo_code-0.1.17/src/limbo/ui/widgets/chat.py +0 -343
- limbo_code-0.1.17/tests/ui/test_widgets.py +0 -233
- {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/grill-with-docs/SKILL.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/grill-with-docs/agents/openai.yaml +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/improve-codebase-architecture/SKILL.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/improve-codebase-architecture/agents/openai.yaml +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/tdd/SKILL.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/tdd/agents/openai.yaml +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/tdd/mocking.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/.agents/skills/tdd/tests.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/.github/workflows/publish.yml +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/.github/workflows/test.yml +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/.gitignore +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/AGENTS.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/CONTEXT.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/Makefile +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/README.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/design/bang-verification.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/design/confirm_view.html +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/design/limbo-ui-minimal.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/design/limbo-ui-redesign.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/design/prototype-minimal-confirm.png +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/design/prototype-minimal.html +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/design/prototype-minimal.png +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/design/prototype.html +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/design/prototype.png +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/design/rfc-bang-command.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/design/rfc-headless-cli.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/limbo-current-ui.png +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/limbo-new-ui.png +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/1_idle.svg +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/2_thinking_tool_running.svg +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/3_tool_success.svg +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/4_tool_error.svg +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/5_llm_error.svg +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-dark/6_edit_diff.svg +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/1_idle.svg +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/2_thinking_tool_running.svg +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/3_tool_success.svg +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/4_tool_error.svg +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/5_llm_error.svg +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/assets/walkthrough/limbo-light/6_edit_diff.svg +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/session-management.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/skills.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/docs/ui-redesign-proposal.md +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/pyproject.toml +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/scripts/check_contrast.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/scripts/gen_banner.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/scripts/scroll_bench.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/scripts/ui_walkthrough.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/skills-lock.json +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/__init__.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/__main__.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/app.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/attachments.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/btw.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/code_mode.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/compaction.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/config.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/goal.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/headless.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/history.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/integrations/__init__.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/integrations/base.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/integrations/herdr.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/__init__.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/anthropic_client.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/catalog.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/client.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/factory.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/openai_client.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/responses_client.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/retry.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/scaffold.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/sse.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/llm/usage.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/model_switch.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/models.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/prompt.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/pump.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/skills.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/steer.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/__init__.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/base.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/bash.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/edit.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/find.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/grep.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/ignore.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/ls.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/mutation_queue.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/read.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/registry.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/run_code.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/tools/write.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/__init__.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/app.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/banner.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/clipboard.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/commands.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/contrast.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/key_fixes.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/paste_store.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/path_input.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/screens/__init__.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/screens/btw.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/screens/game2048.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/screens/model_picker.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/screens/verify_picker.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/syntax.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/theme.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/widgets/__init__.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/widgets/command_menu.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/widgets/input.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/src/limbo/ui/widgets/status_bar.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/conftest.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_anthropic_client.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_attachments.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_btw.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_catalog.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_cli.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_clipboard.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_code_mode.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_code_mode_agent.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_compaction.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_config.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_goal.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_headless.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_herdr.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_history.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_integration.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_integrations.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_llm_client.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_llm_scaffold.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_model_switch.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_models.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_prompt.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_responses_client.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_retry.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_skills.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_sse.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_steer.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/test_usage.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_base.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_bash.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_edit.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_find.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_grep.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_ignore.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_ls.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_mutation_queue.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_read.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_registry.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_run_code.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/tools/test_write.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/__snapshots__/test_snapshots/test_snapshot_idle.raw +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/__snapshots__/test_snapshots/test_snapshot_session_picker.raw +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/__snapshots__/test_snapshots/test_snapshot_thinking_and_running_tool.raw +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/__snapshots__/test_snapshots/test_snapshot_tool_error_and_error_message.raw +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_app_smoke.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_bang_ui.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_btw_ui.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_code_mode_ui.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_command_menu.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_commands.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_compact_ui.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_contrast.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_copy.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_game2048.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_goal_ui.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_input_attachments.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_input_history.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_input_paste.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_interrupt_ui.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_key_fixes.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_main_screen.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_model_picker.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_paste_store.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_path_input.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_scroll_follow.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_skills_ui.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_startup_art.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_steer_ui.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/tests/ui/test_theme.py +0 -0
- {limbo_code-0.1.17 → limbo_code-0.2}/uv.lock +0 -0
|
@@ -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
|
-
|
|
1298
|
-
|
|
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
|
-
#
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
|
698
|
-
|
|
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"
|
|
708
|
-
|
|
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
|
-
|
|
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(
|
|
742
|
+
export_markdown(self.agent.session_meta, self.agent.messages, out)
|
|
728
743
|
else:
|
|
729
744
|
export_jsonl(
|
|
730
|
-
|
|
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
|
-
|
|
735
|
-
|
|
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
|
|