codex-agent-framework 0.1.55__tar.gz → 0.1.56__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.
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/CHANGELOG.md +10 -0
- {codex_agent_framework-0.1.55/codex_agent_framework.egg-info → codex_agent_framework-0.1.56}/PKG-INFO +1 -1
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/client.py +34 -4
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/server/app.py +26 -3
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/tui/app.py +72 -45
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/tui/chat.py +4 -1
- codex_agent_framework-0.1.56/codex_agent/tui/perf.py +73 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/tui/state.py +34 -2
- codex_agent_framework-0.1.56/codex_agent/tui/stream_debug.py +84 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/tui/style.py +49 -0
- codex_agent_framework-0.1.56/codex_agent/tui/widgets.py +248 -0
- codex_agent_framework-0.1.56/codex_agent/version.py +1 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56/codex_agent_framework.egg-info}/PKG-INFO +1 -1
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent_framework.egg-info/SOURCES.txt +3 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/pyproject.toml +1 -1
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_chat.py +47 -58
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_server.py +50 -0
- codex_agent_framework-0.1.55/codex_agent/version.py +0 -1
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/LICENSE +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/MANIFEST.in +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/README.md +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/__main__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/agent.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/agent_runtime.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/ai.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/browser.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_commands.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/bash/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/bash/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/browser/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/browser/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/content/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/content/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/content/system.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/content/vision.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/context/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/desktop/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/desktop/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/environment/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/environment/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/files/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/files/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/files/tools.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/image_generation/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/image_generation/image_generation_system_prompt.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/image_generation/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/interface/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/interface/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/memory/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/memory/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/planner/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/planner/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/python/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/python/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/realtime/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/realtime/audio.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/realtime/bridge.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/realtime/manager.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/realtime/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/realtime/session.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/scheduler/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/scheduler/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/subagents/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/subagents/profiles/explorer.json +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/subagents/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/tts/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/tts/requirements.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/builtin_plugins/web_search/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/cli/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/cli/headless.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/cli/main.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/cli/output.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/cli/root.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/cli/runner.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/command.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/config.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/context.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/desktop.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/event.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/get_text/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/get_text/default_gitignore +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/get_text/get_text.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/get_text/simpler_get_text.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/hooks.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/image.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/latex.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/mainloop.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/message.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/plugin.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/prompts/system_prompt.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/provider.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/runtime.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/scheduler.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/scripts/install-system-dependencies.sh +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/server/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/server/core.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/service.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/sessions.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/status.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/stream.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/stream_utils.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/tool.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/tray.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/tui/__init__.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/tui/lifecycle.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/tui/log.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/tui/status_bar.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/tui/transcript.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/utils.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent/worker.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent_framework.egg-info/dependency_links.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent_framework.egg-info/entry_points.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent_framework.egg-info/requires.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/codex_agent_framework.egg-info/top_level.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/dependencies.txt +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/setup.cfg +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_audio_hooks.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_commands.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_context.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_desktop.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_environment.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_image_generation.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_observe.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_plugins.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_providers_config.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_realtime.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_response.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_runtime.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_sessions.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_subagents.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_tools.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_agent_turns.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_ai.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_browser.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_builtin_config.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_cli.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_cli_headless.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_cli_root.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_client.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_events.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_get_text_browser.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_hooks.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_image_message.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_local_desktop.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_memory.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_messages.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_planner.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_scheduler.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_service.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_tui.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_utils.py +0 -0
- {codex_agent_framework-0.1.55 → codex_agent_framework-0.1.56}/tests/test_worker.py +0 -0
|
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
This project loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and uses semantic versioning where practical.
|
|
6
6
|
|
|
7
|
+
## [0.1.56] - 2026-05-17
|
|
8
|
+
### Changed
|
|
9
|
+
- Replace the TUI chat surface with a widget-based timeline inspired by Textual chat patterns so live assistant output updates an active message widget instead of mutating a monolithic transcript buffer.
|
|
10
|
+
- Add compact TUI event streams that truncate verbose tool/slash result payloads for live UI subscribers while preserving full replay/API data outside compact mode.
|
|
11
|
+
- Add persistent TUI stream diagnostics in `~/.codex-agent/tui-stream-debug.jsonl` and lightweight perf snapshots in `~/.codex-agent/tui-perf.json` to capture intermittent live-stream stalls after the fact.
|
|
12
|
+
- Preserve the previous sparse terminal look with role headers, compact tool/info/error rows, a right-aligned step marker, and sticky-only autoscroll.
|
|
13
|
+
|
|
14
|
+
### Tests
|
|
15
|
+
- Validate the TUI/server/client changes with targeted chat/client/server coverage and the full offline CI suite (570 passed).
|
|
16
|
+
|
|
7
17
|
## [0.1.55] - 2026-05-17
|
|
8
18
|
### Fixed
|
|
9
19
|
- Decouple SSE event-stream consumption from local client/TUI event handling so slow or blocked UI work no longer stalls event intake during long-running or heavy tool activity.
|
|
@@ -14,11 +14,12 @@ from .utils import snake_case_type
|
|
|
14
14
|
class AgentClient:
|
|
15
15
|
"""Small HTTP/SSE client for a codex-agent server."""
|
|
16
16
|
|
|
17
|
-
def __init__(self, base_url="http://127.0.0.1:8765", timeout=10, event_read_timeout=15.0, event_stale_after=5.0):
|
|
17
|
+
def __init__(self, base_url="http://127.0.0.1:8765", timeout=10, event_read_timeout=15.0, event_stale_after=5.0, compact_events=False):
|
|
18
18
|
self.base_url = base_url.rstrip("/")
|
|
19
19
|
self.timeout = timeout
|
|
20
20
|
self.event_read_timeout = event_read_timeout
|
|
21
21
|
self.event_stale_after = event_stale_after
|
|
22
|
+
self.compact_events = compact_events
|
|
22
23
|
self.client_pid = os.getpid()
|
|
23
24
|
self._event_handlers = {}
|
|
24
25
|
self._event_thread = None
|
|
@@ -36,6 +37,16 @@ class AgentClient:
|
|
|
36
37
|
self.health_interval = 1.0
|
|
37
38
|
self.health_failure_threshold = 3
|
|
38
39
|
self._health_failures = 0
|
|
40
|
+
self.stream_debug = None
|
|
41
|
+
|
|
42
|
+
def debug_stream(self, stage, event=None, **fields):
|
|
43
|
+
logger = getattr(self, "stream_debug", None)
|
|
44
|
+
if logger is None:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
return logger.record(stage, event, **fields)
|
|
48
|
+
except Exception:
|
|
49
|
+
return None
|
|
39
50
|
|
|
40
51
|
def on(self, event_type, handler=None):
|
|
41
52
|
if handler is None:
|
|
@@ -73,9 +84,13 @@ class AgentClient:
|
|
|
73
84
|
seen.add(handler)
|
|
74
85
|
handlers.append(handler)
|
|
75
86
|
for handler in handlers:
|
|
87
|
+
handler_name = getattr(handler, "__name__", handler.__class__.__name__)
|
|
88
|
+
self.debug_stream("handler_begin", event, handler=handler_name)
|
|
76
89
|
try:
|
|
77
90
|
handler(event)
|
|
91
|
+
self.debug_stream("handler_done", event, handler=handler_name)
|
|
78
92
|
except Exception as exc:
|
|
93
|
+
self.debug_stream("handler_error", event, handler=handler_name, error=str(exc))
|
|
79
94
|
if event_type != "client_event_error":
|
|
80
95
|
self.emit_local({"type": "client_event_error", "error": str(exc), "event": event_type})
|
|
81
96
|
return event
|
|
@@ -89,7 +104,9 @@ class AgentClient:
|
|
|
89
104
|
|
|
90
105
|
def _enqueue_local(self, event):
|
|
91
106
|
self._start_dispatch_thread()
|
|
92
|
-
|
|
107
|
+
event = modict(event)
|
|
108
|
+
self.debug_stream("queue_put", event, queue_size=self._local_event_queue.qsize())
|
|
109
|
+
self._local_event_queue.put(event)
|
|
93
110
|
return event
|
|
94
111
|
|
|
95
112
|
def _dispatch_loop(self):
|
|
@@ -102,7 +119,13 @@ class AgentClient:
|
|
|
102
119
|
continue
|
|
103
120
|
if event is None:
|
|
104
121
|
break
|
|
105
|
-
self.
|
|
122
|
+
self.debug_stream("dispatch_begin", event, queue_size=self._local_event_queue.qsize())
|
|
123
|
+
try:
|
|
124
|
+
self.emit_local(event)
|
|
125
|
+
self.debug_stream("dispatch_done", event, queue_size=self._local_event_queue.qsize())
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
self.debug_stream("dispatch_error", event, error=str(exc), queue_size=self._local_event_queue.qsize())
|
|
128
|
+
raise
|
|
106
129
|
|
|
107
130
|
def start_events(self):
|
|
108
131
|
if self._event_thread and self._event_thread.is_alive():
|
|
@@ -226,11 +249,14 @@ class AgentClient:
|
|
|
226
249
|
self._set_server_connected()
|
|
227
250
|
stream_ended = True
|
|
228
251
|
try:
|
|
229
|
-
for
|
|
252
|
+
# Keep SSE latency low for interactive streams without
|
|
253
|
+
# falling back to the old byte-by-byte reader.
|
|
254
|
+
for event in self.iter_sse(response.iter_lines(chunk_size=16, decode_unicode=True)):
|
|
230
255
|
if self._event_stop.is_set():
|
|
231
256
|
stream_ended = False
|
|
232
257
|
break
|
|
233
258
|
self._record_event_stream_activity()
|
|
259
|
+
self.debug_stream("sse_received", event)
|
|
234
260
|
self.remember_event_sequence(event)
|
|
235
261
|
self._enqueue_local(event)
|
|
236
262
|
finally:
|
|
@@ -324,6 +350,8 @@ class AgentClient:
|
|
|
324
350
|
self._last_event_sequence = sequence
|
|
325
351
|
def events_url(self):
|
|
326
352
|
params = {"client_pid": int(self.client_pid)}
|
|
353
|
+
if self.compact_events:
|
|
354
|
+
params["compact"] = 1
|
|
327
355
|
if self._last_event_sequence is not None:
|
|
328
356
|
params["after_sequence"] = int(self._last_event_sequence)
|
|
329
357
|
return self.url(f"/events?{urlencode(params)}")
|
|
@@ -437,6 +465,8 @@ class AgentClient:
|
|
|
437
465
|
def replay_events(self, before_sequence=None, session_id=None):
|
|
438
466
|
path = "/events/replay"
|
|
439
467
|
params = {}
|
|
468
|
+
if self.compact_events:
|
|
469
|
+
params["compact"] = 1
|
|
440
470
|
if before_sequence is not None:
|
|
441
471
|
params["before_sequence"] = int(before_sequence)
|
|
442
472
|
if session_id:
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
|
|
3
|
+
from modict import modict
|
|
4
|
+
|
|
3
5
|
from ..agent import Agent
|
|
4
6
|
from ..version import __version__
|
|
5
7
|
from ..agent_runtime import InProcessAgentRuntime, ProcessAgentRuntime
|
|
@@ -61,6 +63,22 @@ def create_app(agent: Agent | None = None, start_agent=False, process_agent=Fals
|
|
|
61
63
|
def jsonable(value):
|
|
62
64
|
return json.loads(json.dumps(value, ensure_ascii=False, default=str))
|
|
63
65
|
|
|
66
|
+
def compact_event_payload(payload, *, max_content_chars=2_000):
|
|
67
|
+
payload = modict(jsonable(payload))
|
|
68
|
+
event_type = payload.get("type")
|
|
69
|
+
if event_type in {"tool_call_done_event", "slash_command_done_event"}:
|
|
70
|
+
content_key = "content" if "content" in payload else "result" if "result" in payload else None
|
|
71
|
+
if content_key is not None:
|
|
72
|
+
content = "" if payload.get(content_key) is None else str(payload.get(content_key))
|
|
73
|
+
if len(content) > max_content_chars:
|
|
74
|
+
payload[f"{content_key}_truncated"] = True
|
|
75
|
+
payload[f"{content_key}_full_length"] = len(content)
|
|
76
|
+
payload[content_key] = content[:max_content_chars] + "\n… truncated for compact event stream …"
|
|
77
|
+
return payload
|
|
78
|
+
|
|
79
|
+
def event_payload(payload, compact=False):
|
|
80
|
+
return compact_event_payload(payload) if compact else payload
|
|
81
|
+
|
|
64
82
|
def busy_guard(call):
|
|
65
83
|
try:
|
|
66
84
|
return call()
|
|
@@ -162,7 +180,7 @@ def create_app(agent: Agent | None = None, start_agent=False, process_agent=Fals
|
|
|
162
180
|
return jsonable(service.interrupt(request.reason))
|
|
163
181
|
|
|
164
182
|
@app.get("/events")
|
|
165
|
-
def events(limit: int | None = None, after_sequence: int | None = None, client_pid: int | None = None):
|
|
183
|
+
def events(limit: int | None = None, after_sequence: int | None = None, client_pid: int | None = None, compact: bool = False):
|
|
166
184
|
try:
|
|
167
185
|
subscription = service.subscribe(after_sequence=after_sequence, client_pid=client_pid)
|
|
168
186
|
except RuntimeError as exc:
|
|
@@ -171,6 +189,7 @@ def create_app(agent: Agent | None = None, start_agent=False, process_agent=Fals
|
|
|
171
189
|
def stream():
|
|
172
190
|
count = 0
|
|
173
191
|
for payload in subscription:
|
|
192
|
+
payload = event_payload(payload, compact=compact)
|
|
174
193
|
lines = []
|
|
175
194
|
if payload.get("id"):
|
|
176
195
|
lines.append(f"id: {payload.id}")
|
|
@@ -183,8 +202,12 @@ def create_app(agent: Agent | None = None, start_agent=False, process_agent=Fals
|
|
|
183
202
|
return StreamingResponse(stream(), media_type="text/event-stream")
|
|
184
203
|
|
|
185
204
|
@app.get("/events/replay")
|
|
186
|
-
def replay_events(before_sequence: int | None = None, session_id: str | None = None):
|
|
187
|
-
|
|
205
|
+
def replay_events(before_sequence: int | None = None, session_id: str | None = None, compact: bool = False):
|
|
206
|
+
replay = service.replay_events(before_sequence=before_sequence, session_id=session_id)
|
|
207
|
+
if compact:
|
|
208
|
+
replay = modict(replay)
|
|
209
|
+
replay.events = [compact_event_payload(event) for event in replay.get("events") or []]
|
|
210
|
+
return jsonable(replay)
|
|
188
211
|
|
|
189
212
|
return app
|
|
190
213
|
|
|
@@ -6,9 +6,10 @@ from textual.events import Resize
|
|
|
6
6
|
from textual.widgets import Footer, Static, TextArea
|
|
7
7
|
|
|
8
8
|
from .lifecycle import clear_tui, install_tui_cleanup_handlers
|
|
9
|
-
from .
|
|
9
|
+
from .perf import TuiPerfMonitor
|
|
10
|
+
from .stream_debug import TuiStreamDebugLog
|
|
10
11
|
from .style import CHAT_CSS
|
|
11
|
-
from .
|
|
12
|
+
from .widgets import ConversationTimeline
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def composer_height(text):
|
|
@@ -33,20 +34,13 @@ class AgentTextualApp(App):
|
|
|
33
34
|
super().__init__(**kwargs)
|
|
34
35
|
self.chat = chat
|
|
35
36
|
self.mouse_mode_hint = mouse_mode_hint
|
|
36
|
-
self.transcript_renderer = None
|
|
37
37
|
self._live_delta_flush_scheduled = False
|
|
38
|
+
self.perf = TuiPerfMonitor()
|
|
39
|
+
self.stream_debug = TuiStreamDebugLog()
|
|
38
40
|
|
|
39
41
|
def compose(self) -> ComposeResult:
|
|
40
42
|
with Vertical(id="main"):
|
|
41
|
-
yield
|
|
42
|
-
id="conversation",
|
|
43
|
-
wrap=True,
|
|
44
|
-
highlight=False,
|
|
45
|
-
markup=True,
|
|
46
|
-
on_scroll_top=self.load_older_transcript,
|
|
47
|
-
on_resize=lambda: self.handle_terminal_resize(log_resized=True),
|
|
48
|
-
right_padding=6,
|
|
49
|
-
)
|
|
43
|
+
yield ConversationTimeline(id="conversation")
|
|
50
44
|
yield Static("", id="status")
|
|
51
45
|
yield TextArea(
|
|
52
46
|
"",
|
|
@@ -59,29 +53,26 @@ class AgentTextualApp(App):
|
|
|
59
53
|
|
|
60
54
|
def on_mount(self):
|
|
61
55
|
self.chat.app = self
|
|
62
|
-
self.
|
|
56
|
+
self.chat.client.stream_debug = self.stream_debug
|
|
57
|
+
self.stream_debug.record("tui_mount")
|
|
63
58
|
install_tui_cleanup_handlers()
|
|
64
59
|
self.chat.attach()
|
|
65
60
|
self.chat.client.start_events()
|
|
66
61
|
self.refresh_status()
|
|
67
62
|
self.resize_input()
|
|
68
63
|
self.query_one("#input", TextArea).focus()
|
|
64
|
+
self.set_interval(1.0, self.write_perf_snapshot)
|
|
69
65
|
self.write_info(f"Codex Agent client ready - {self.mouse_mode_hint}")
|
|
70
66
|
|
|
71
67
|
def on_resize(self, event: Resize):
|
|
72
68
|
self.handle_terminal_resize()
|
|
73
69
|
|
|
74
70
|
def handle_terminal_resize(self, *, log_resized=False):
|
|
75
|
-
if getattr(self, "transcript_renderer", None) is None:
|
|
76
|
-
return
|
|
77
|
-
log = self.query_one("#conversation", SelectableRichLog)
|
|
78
|
-
if not log_resized:
|
|
79
|
-
log.invalidate_layout()
|
|
80
|
-
self.render_transcript(keep_scroll=True)
|
|
81
71
|
self.resize_input()
|
|
82
72
|
self.refresh_status()
|
|
83
73
|
|
|
84
74
|
def on_unmount(self):
|
|
75
|
+
self.stream_debug.record("tui_unmount")
|
|
85
76
|
clear_tui(pid=os.getpid())
|
|
86
77
|
|
|
87
78
|
def action_quit(self):
|
|
@@ -113,7 +104,7 @@ class AgentTextualApp(App):
|
|
|
113
104
|
|
|
114
105
|
def action_clear(self):
|
|
115
106
|
self.chat.transcript.clear()
|
|
116
|
-
self.
|
|
107
|
+
self.conversation.clear_messages()
|
|
117
108
|
self.write_info("screen cleared - session history preserved")
|
|
118
109
|
self.refresh_status()
|
|
119
110
|
|
|
@@ -150,77 +141,113 @@ class AgentTextualApp(App):
|
|
|
150
141
|
finally:
|
|
151
142
|
self.call_from_thread(self.refresh_status)
|
|
152
143
|
|
|
144
|
+
@property
|
|
145
|
+
def conversation(self):
|
|
146
|
+
return self.query_one("#conversation", ConversationTimeline)
|
|
147
|
+
|
|
153
148
|
def render_transcript(self, *, keep_scroll=False):
|
|
154
|
-
|
|
149
|
+
# The widget timeline is updated incrementally. This method remains as a
|
|
150
|
+
# compatibility shim for older call sites during the migration.
|
|
151
|
+
return None
|
|
155
152
|
|
|
156
153
|
def begin_transcript_batch(self):
|
|
157
|
-
|
|
154
|
+
return None
|
|
158
155
|
|
|
159
156
|
def end_transcript_batch(self):
|
|
160
|
-
|
|
157
|
+
return None
|
|
161
158
|
|
|
162
159
|
def load_older_transcript(self):
|
|
163
|
-
|
|
160
|
+
return None
|
|
164
161
|
|
|
165
162
|
def write_user(self, prompt):
|
|
166
163
|
self.chat.transcript.append_user(prompt, self.chat.username())
|
|
167
|
-
self.
|
|
164
|
+
self.conversation.add_user(prompt, title=self.chat.username())
|
|
168
165
|
|
|
169
166
|
def ensure_agent_role(self, name=None):
|
|
170
|
-
|
|
171
|
-
|
|
167
|
+
self.chat.transcript.ensure_agent(name or self.chat.agent_name())
|
|
168
|
+
return self.conversation.ensure_active_assistant(title=name or self.chat.agent_name())
|
|
172
169
|
|
|
173
170
|
def write_agent_start(self, name):
|
|
174
171
|
self.chat.begin_turn_rendering()
|
|
175
172
|
|
|
176
173
|
def start_agent_step(self):
|
|
177
174
|
self.chat.transcript.begin_step()
|
|
178
|
-
self.render_transcript()
|
|
179
175
|
|
|
180
176
|
def write_agent_delta(self, delta):
|
|
177
|
+
self.stream_debug.record("ui_render_delta_begin", delta_len=len(str(delta or "")))
|
|
178
|
+
self.perf.count("write_agent_delta")
|
|
179
|
+
self.perf.count("write_agent_delta_chars", len(str(delta or "")))
|
|
180
|
+
with self.perf.timed("write_agent_delta"):
|
|
181
|
+
self.ensure_agent_role()
|
|
182
|
+
self.chat.transcript.apply_agent_delta(delta)
|
|
183
|
+
self.chat.transcript.step_had_content = True
|
|
184
|
+
self.conversation.append_assistant_delta(delta, title=self.chat.agent_name())
|
|
185
|
+
self.stream_debug.record("ui_render_delta_done", delta_len=len(str(delta or "")), live_chars=len(self.conversation._active_assistant_text))
|
|
186
|
+
|
|
187
|
+
def write_agent_message(self, content):
|
|
181
188
|
self.ensure_agent_role()
|
|
182
|
-
|
|
183
|
-
self.chat.transcript.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
self.render_transcript()
|
|
189
|
+
self.chat.transcript.agent_buffer = str(content or "")
|
|
190
|
+
if self.chat.transcript.entries and self.chat.transcript.entries[-1].kind == "agent":
|
|
191
|
+
self.chat.transcript.entries[-1].text = self.chat.transcript.agent_buffer
|
|
192
|
+
self.conversation.add_assistant_message(content, title=self.chat.agent_name())
|
|
187
193
|
|
|
188
194
|
def schedule_agent_delta_flush(self):
|
|
195
|
+
self.perf.count("schedule_agent_delta_flush")
|
|
189
196
|
if self._live_delta_flush_scheduled:
|
|
190
197
|
return
|
|
191
198
|
self._live_delta_flush_scheduled = True
|
|
192
199
|
self.set_timer(self.chat.live_delta.interval, self.flush_agent_delta)
|
|
193
200
|
|
|
194
201
|
def flush_agent_delta(self):
|
|
195
|
-
self.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
self.
|
|
202
|
+
self.perf.count("flush_agent_delta")
|
|
203
|
+
with self.perf.timed("flush_agent_delta"):
|
|
204
|
+
self._live_delta_flush_scheduled = False
|
|
205
|
+
delta = self.chat.live_delta.pop()
|
|
206
|
+
if delta:
|
|
207
|
+
self.write_agent_delta(delta)
|
|
199
208
|
|
|
200
209
|
def write_tool(self, name, call_id=None, status=None):
|
|
201
210
|
self.flush_agent_delta()
|
|
202
|
-
self.
|
|
211
|
+
self.conversation.commit_active_assistant_preview()
|
|
203
212
|
self.chat.transcript.append_tool(name, call_id=call_id, status=status)
|
|
204
|
-
self.
|
|
213
|
+
self.conversation.add_tool(name, call_id=call_id, status=status)
|
|
205
214
|
|
|
206
215
|
def write_slash_command(self, name, args="", status=None):
|
|
207
216
|
self.flush_agent_delta()
|
|
208
|
-
self.
|
|
217
|
+
self.conversation.commit_active_assistant_preview()
|
|
209
218
|
self.chat.transcript.append_slash_command(name, args=args, status=status)
|
|
210
|
-
self.
|
|
219
|
+
self.conversation.add_slash_command(name, args=args, status=status)
|
|
211
220
|
|
|
212
221
|
def end_agent_step(self):
|
|
213
222
|
self.flush_agent_delta()
|
|
223
|
+
self.conversation.commit_active_assistant_preview()
|
|
214
224
|
self.chat.transcript.end_step()
|
|
215
|
-
self.
|
|
225
|
+
self.conversation.add_step_separator()
|
|
216
226
|
|
|
217
227
|
def write_info(self, text):
|
|
218
228
|
self.chat.transcript.append_info(text)
|
|
219
|
-
self.
|
|
229
|
+
self.conversation.add_info(text)
|
|
220
230
|
|
|
221
231
|
def write_error(self, text):
|
|
222
232
|
self.chat.transcript.append_error(text)
|
|
223
|
-
self.
|
|
233
|
+
self.conversation.add_error(text)
|
|
224
234
|
|
|
225
235
|
def refresh_status(self):
|
|
226
|
-
self.
|
|
236
|
+
self.perf.count("refresh_status")
|
|
237
|
+
with self.perf.timed("refresh_status"):
|
|
238
|
+
self.query_one("#status", Static).update(self.chat.status_renderable())
|
|
239
|
+
|
|
240
|
+
def write_perf_snapshot(self):
|
|
241
|
+
client = self.chat.client
|
|
242
|
+
queue_obj = getattr(client, "_local_event_queue", None)
|
|
243
|
+
extra = {
|
|
244
|
+
"entries": len(self.chat.transcript.entries),
|
|
245
|
+
"live_buffer_chars": len(self.conversation._active_assistant_text),
|
|
246
|
+
"pending_live_delta_chars": len(self.chat.live_delta.text),
|
|
247
|
+
"last_event_sequence": getattr(client, "_last_event_sequence", None),
|
|
248
|
+
"event_queue_size": queue_obj.qsize() if queue_obj is not None else None,
|
|
249
|
+
"event_stream_connected": getattr(client, "_event_stream_connected", None),
|
|
250
|
+
"live_delta_flush_scheduled": self._live_delta_flush_scheduled,
|
|
251
|
+
}
|
|
252
|
+
self.perf.write_snapshot(extra=extra)
|
|
253
|
+
self.stream_debug.record("tui_heartbeat", **extra)
|
|
@@ -47,7 +47,7 @@ class Chat:
|
|
|
47
47
|
"""Textual-based chat UI client for a codex-agent server."""
|
|
48
48
|
|
|
49
49
|
def __init__(self, client=None, base_url="http://127.0.0.1:8765"):
|
|
50
|
-
self.client = client or AgentClient(base_url=base_url)
|
|
50
|
+
self.client = client or AgentClient(base_url=base_url, compact_events=True)
|
|
51
51
|
self.app = None
|
|
52
52
|
self.view = TuiAppBridge(self)
|
|
53
53
|
self.deduper = DisplayDeduper()
|
|
@@ -233,6 +233,9 @@ class Chat:
|
|
|
233
233
|
message = event.get("message") or {}
|
|
234
234
|
if message.get("type") == "user_message":
|
|
235
235
|
self.view.write_user(str(message.get("content") or ""))
|
|
236
|
+
elif message.get("type") == "assistant_message":
|
|
237
|
+
self._flush_live_delta()
|
|
238
|
+
self.view.write_agent_message(str(message.get("content") or ""))
|
|
236
239
|
self.view.refresh_status()
|
|
237
240
|
|
|
238
241
|
def _on_assistant_turn_start(self, event):
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TuiPerfMonitor:
|
|
9
|
+
"""Tiny in-process profiler for diagnosing TUI stream stalls.
|
|
10
|
+
|
|
11
|
+
The monitor keeps only counters and cumulative timings in memory, then writes
|
|
12
|
+
an atomic JSON snapshot. It is intentionally small enough to leave enabled
|
|
13
|
+
during local debugging without needing ptrace/py-spy access.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, path=None):
|
|
17
|
+
self.path = Path(path or os.path.expanduser("~/.codex-agent/tui-perf.json"))
|
|
18
|
+
self.started_at = time.monotonic()
|
|
19
|
+
self.reset_window()
|
|
20
|
+
|
|
21
|
+
def reset_window(self):
|
|
22
|
+
self.window_started_at = time.monotonic()
|
|
23
|
+
self.counts = {}
|
|
24
|
+
self.timings = {}
|
|
25
|
+
self.last = {}
|
|
26
|
+
|
|
27
|
+
def count(self, name, value=1):
|
|
28
|
+
self.counts[name] = self.counts.get(name, 0) + value
|
|
29
|
+
|
|
30
|
+
def set_last(self, name, value):
|
|
31
|
+
self.last[name] = value
|
|
32
|
+
|
|
33
|
+
@contextmanager
|
|
34
|
+
def timed(self, name):
|
|
35
|
+
start = time.perf_counter()
|
|
36
|
+
try:
|
|
37
|
+
yield
|
|
38
|
+
finally:
|
|
39
|
+
elapsed = time.perf_counter() - start
|
|
40
|
+
total, count, max_elapsed = self.timings.get(name, (0.0, 0, 0.0))
|
|
41
|
+
self.timings[name] = (total + elapsed, count + 1, max(max_elapsed, elapsed))
|
|
42
|
+
|
|
43
|
+
def snapshot(self, extra=None):
|
|
44
|
+
now = time.monotonic()
|
|
45
|
+
window_seconds = max(1e-9, now - self.window_started_at)
|
|
46
|
+
timings = {}
|
|
47
|
+
for name, (total, count, max_elapsed) in self.timings.items():
|
|
48
|
+
timings[name] = {
|
|
49
|
+
"count": count,
|
|
50
|
+
"total_ms": round(total * 1000, 3),
|
|
51
|
+
"avg_ms": round((total / count) * 1000, 3) if count else 0.0,
|
|
52
|
+
"max_ms": round(max_elapsed * 1000, 3),
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
"pid": os.getpid(),
|
|
56
|
+
"uptime_seconds": round(now - self.started_at, 3),
|
|
57
|
+
"window_seconds": round(window_seconds, 3),
|
|
58
|
+
"rates_per_second": {key: round(value / window_seconds, 3) for key, value in self.counts.items()},
|
|
59
|
+
"counts": dict(self.counts),
|
|
60
|
+
"timings": timings,
|
|
61
|
+
"last": dict(self.last),
|
|
62
|
+
"extra": dict(extra or {}),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def write_snapshot(self, extra=None, *, reset=True):
|
|
66
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
payload = self.snapshot(extra=extra)
|
|
68
|
+
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
69
|
+
tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
|
|
70
|
+
tmp_path.replace(self.path)
|
|
71
|
+
if reset:
|
|
72
|
+
self.reset_window()
|
|
73
|
+
return payload
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
class LiveDeltaBuffer:
|
|
2
|
-
|
|
2
|
+
"""Coalesce live text deltas before touching the Textual render loop.
|
|
3
|
+
|
|
4
|
+
Human-readable terminal text does not benefit from display-refresh-rate
|
|
5
|
+
updates, and high-frequency flushes can overwhelm Textual during bursty
|
|
6
|
+
model streams. Twenty frames per second keeps the UI feeling live while
|
|
7
|
+
leaving enough breathing room for input, health checks, and repaint work.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, interval=1 / 20):
|
|
3
11
|
self.interval = interval
|
|
4
12
|
self.text = ""
|
|
5
13
|
self.flush_requested = False
|
|
@@ -59,8 +67,29 @@ class TuiAppBridge:
|
|
|
59
67
|
return None
|
|
60
68
|
if self.app is None:
|
|
61
69
|
return None
|
|
70
|
+
perf = getattr(self.app, "perf", None)
|
|
71
|
+
if perf is not None:
|
|
72
|
+
perf.count("bridge_call_from_thread")
|
|
73
|
+
perf.count(f"bridge_call_from_thread.{method}")
|
|
74
|
+
stream_debug = getattr(self.app, "stream_debug", None)
|
|
75
|
+
if stream_debug is not None:
|
|
76
|
+
stream_debug.record("bridge_schedule", method=method, args_count=len(args))
|
|
62
77
|
callback = getattr(self.app, method)
|
|
63
|
-
|
|
78
|
+
|
|
79
|
+
def traced_callback(*callback_args):
|
|
80
|
+
if stream_debug is not None:
|
|
81
|
+
stream_debug.record("ui_callback_begin", method=method, args_count=len(callback_args))
|
|
82
|
+
try:
|
|
83
|
+
result = callback(*callback_args)
|
|
84
|
+
if stream_debug is not None:
|
|
85
|
+
stream_debug.record("ui_callback_done", method=method, args_count=len(callback_args))
|
|
86
|
+
return result
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
if stream_debug is not None:
|
|
89
|
+
stream_debug.record("ui_callback_error", method=method, error=str(exc), args_count=len(callback_args))
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
return self.app.call_from_thread(traced_callback, *args)
|
|
64
93
|
|
|
65
94
|
def refresh_status(self):
|
|
66
95
|
return self.call("refresh_status", skip_during_replay=True)
|
|
@@ -92,6 +121,9 @@ class TuiAppBridge:
|
|
|
92
121
|
def write_agent_delta(self, delta):
|
|
93
122
|
return self.call("write_agent_delta", delta)
|
|
94
123
|
|
|
124
|
+
def write_agent_message(self, content):
|
|
125
|
+
return self.call("write_agent_message", content)
|
|
126
|
+
|
|
95
127
|
def write_tool(self, name, call_id=None, status=None):
|
|
96
128
|
return self.call("write_tool", name, call_id, status)
|
|
97
129
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TuiStreamDebugLog:
|
|
11
|
+
"""Append-only black-box log for intermittent TUI stream stalls.
|
|
12
|
+
|
|
13
|
+
The file is intentionally outside the Textual render path and is flushed on
|
|
14
|
+
every record. When the UI stops updating but the process stays alive, the
|
|
15
|
+
last records should reveal whether events stopped at SSE receive, queue,
|
|
16
|
+
dispatch, bridge scheduling, or actual widget rendering.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, path=None, *, max_bytes=5_000_000):
|
|
20
|
+
self.path = Path(path or os.path.expanduser("~/.codex-agent/tui-stream-debug.jsonl"))
|
|
21
|
+
self.max_bytes = int(max_bytes or 0)
|
|
22
|
+
self._lock = threading.Lock()
|
|
23
|
+
self._started_at = time.monotonic()
|
|
24
|
+
|
|
25
|
+
def record(self, stage, event=None, **fields):
|
|
26
|
+
payload = {
|
|
27
|
+
"ts": round(time.time(), 6),
|
|
28
|
+
"monotonic": round(time.monotonic(), 6),
|
|
29
|
+
"uptime": round(time.monotonic() - self._started_at, 6),
|
|
30
|
+
"pid": os.getpid(),
|
|
31
|
+
"thread": threading.current_thread().name,
|
|
32
|
+
"stage": str(stage),
|
|
33
|
+
}
|
|
34
|
+
payload.update(self.event_fields(event))
|
|
35
|
+
payload.update(fields)
|
|
36
|
+
line = json.dumps(payload, ensure_ascii=False, default=str, sort_keys=True)
|
|
37
|
+
with self._lock:
|
|
38
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
self._trim_if_needed()
|
|
40
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
41
|
+
handle.write(line + "\n")
|
|
42
|
+
handle.flush()
|
|
43
|
+
return payload
|
|
44
|
+
|
|
45
|
+
def event_fields(self, event):
|
|
46
|
+
if not isinstance(event, dict):
|
|
47
|
+
return {}
|
|
48
|
+
payload = {
|
|
49
|
+
"event_type": event.get("type"),
|
|
50
|
+
"event_id": event.get("id"),
|
|
51
|
+
"sequence": event.get("sequence"),
|
|
52
|
+
}
|
|
53
|
+
if "delta" in event:
|
|
54
|
+
payload["delta_len"] = len(str(event.get("delta") or ""))
|
|
55
|
+
if "content" in event:
|
|
56
|
+
payload["content_len"] = len(str(event.get("content") or ""))
|
|
57
|
+
payload["content_truncated"] = bool(event.get("content_truncated"))
|
|
58
|
+
payload["content_full_length"] = event.get("content_full_length")
|
|
59
|
+
message = event.get("message")
|
|
60
|
+
if isinstance(message, dict):
|
|
61
|
+
payload["message_type"] = message.get("type")
|
|
62
|
+
payload["message_content_len"] = len(str(message.get("content") or ""))
|
|
63
|
+
tool_call = event.get("tool_call")
|
|
64
|
+
if isinstance(tool_call, dict):
|
|
65
|
+
payload["tool_name"] = tool_call.get("name")
|
|
66
|
+
payload["tool_call_id"] = tool_call.get("call_id")
|
|
67
|
+
if "error" in event:
|
|
68
|
+
payload["error"] = str(event.get("error") or "")[:500]
|
|
69
|
+
return payload
|
|
70
|
+
|
|
71
|
+
def _trim_if_needed(self):
|
|
72
|
+
if self.max_bytes <= 0 or not self.path.exists():
|
|
73
|
+
return
|
|
74
|
+
try:
|
|
75
|
+
if self.path.stat().st_size <= self.max_bytes:
|
|
76
|
+
return
|
|
77
|
+
data = self.path.read_bytes()[-self.max_bytes // 2:]
|
|
78
|
+
first_newline = data.find(b"\n")
|
|
79
|
+
if first_newline >= 0:
|
|
80
|
+
data = data[first_newline + 1:]
|
|
81
|
+
self.path.write_bytes(data)
|
|
82
|
+
except Exception:
|
|
83
|
+
# Debug logging must never break the TUI.
|
|
84
|
+
return
|