fix(ui,openai): reduce TUI flicker and make timeout configurable

This commit is contained in:
tjb-tech
2026-04-11 14:31:19 +00:00
parent bd4df81f63
commit 327c6ed2ff
7 changed files with 202 additions and 33 deletions
+23 -3
View File
@@ -6,6 +6,7 @@ import asyncio
import json
import logging
from typing import Any, AsyncIterator
from urllib.parse import urlsplit, urlunsplit
from openai import AsyncOpenAI
@@ -207,6 +208,22 @@ def _parse_assistant_response(response: Any) -> ConversationMessage:
return ConversationMessage(role="assistant", content=content)
def _normalize_openai_base_url(base_url: str | None) -> str | None:
"""Normalize custom OpenAI-compatible base URLs without dropping API path segments."""
if not base_url:
return None
trimmed = base_url.strip()
if not trimmed:
return None
parts = urlsplit(trimmed)
if not parts.scheme or not parts.netloc:
return trimmed.rstrip("/")
path = parts.path.rstrip("/")
if not path:
path = "/v1"
return urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment))
class OpenAICompatibleClient:
"""Client for OpenAI-compatible APIs (DashScope, GitHub Models, etc.).
@@ -214,10 +231,13 @@ class OpenAICompatibleClient:
so it can be used as a drop-in replacement in the agent loop.
"""
def __init__(self, api_key: str, *, base_url: str | None = None) -> None:
def __init__(self, api_key: str, *, base_url: str | None = None, timeout: float | None = None) -> None:
kwargs: dict[str, Any] = {"api_key": api_key}
if base_url:
kwargs["base_url"] = base_url
normalized_base_url = _normalize_openai_base_url(base_url)
if normalized_base_url:
kwargs["base_url"] = normalized_base_url
if timeout is not None:
kwargs["timeout"] = timeout
self._client = AsyncOpenAI(**kwargs)
async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
+5
View File
@@ -415,6 +415,7 @@ class Settings(BaseModel):
model: str = "claude-sonnet-4-6"
max_tokens: int = 16384
base_url: str | None = None
timeout: float = 30.0
api_format: str = "anthropic" # "anthropic", "openai", or "copilot"
provider: str = ""
active_profile: str = "claude-api"
@@ -714,6 +715,10 @@ def _apply_env_overrides(settings: Settings) -> Settings:
if max_tokens:
updates["max_tokens"] = int(max_tokens)
timeout = os.environ.get("OPENHARNESS_TIMEOUT")
if timeout:
updates["timeout"] = float(timeout)
max_turns = os.environ.get("OPENHARNESS_MAX_TURNS")
if max_turns:
updates["max_turns"] = int(max_turns)
+1
View File
@@ -151,6 +151,7 @@ def _resolve_api_client_from_settings(settings) -> SupportsStreamingMessages:
return OpenAICompatibleClient(
api_key=auth.value,
base_url=settings.base_url,
timeout=settings.timeout,
)
auth = _safe_resolve_auth()
return AnthropicApiClient(
+63 -30
View File
@@ -225,6 +225,10 @@ class OpenHarnessTerminalApp(App[None]):
self._assistant_buffer = ""
self._busy = False
self.transcript_lines: list[str] = []
self._last_status_snapshot: tuple[object, ...] | None = None
self._last_tasks_snapshot: tuple[tuple[str, str, object, object], ...] | None = None
self._last_mcp_summary: str | None = None
self._last_current_response: str | None = None
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
@@ -253,8 +257,7 @@ class OpenHarnessTerminalApp(App[None]):
)
await start_runtime(self._bundle)
self.query_one("#composer", Input).focus()
self._refresh_sidebars()
self.set_interval(1.0, self._refresh_sidebars)
self._refresh_sidebars(force=True)
if self._config.prompt:
self.call_later(lambda: asyncio.create_task(self._process_line(self._config.prompt or "")))
@@ -382,7 +385,7 @@ class OpenHarnessTerminalApp(App[None]):
self._refresh_sidebars()
def action_refresh_sidebars(self) -> None:
self._refresh_sidebars()
self._refresh_sidebars(force=True)
def action_toggle_vim(self) -> None:
if self._bundle is None:
@@ -416,38 +419,68 @@ class OpenHarnessTerminalApp(App[None]):
self.transcript_lines.clear()
def _set_current_response(self, message: str) -> None:
if message == self._last_current_response:
return
self.query_one("#current-response", Static).update(message)
self._last_current_response = message
def _refresh_sidebars(self) -> None:
def _refresh_sidebars(self, *, force: bool = False) -> None:
if self._bundle is None:
return
state = self._bundle.app_state.get()
usage = self._bundle.engine.total_usage
status_lines = [
"[b]Status[/b]",
f"model: {state.model}",
f"permissions: {state.permission_mode}",
f"fast: {'on' if state.fast_mode else 'off'}",
f"style: {state.output_style}",
f"vim: {'on' if state.vim_enabled else 'off'}",
f"voice: {'on' if state.voice_enabled else 'off'}",
f"tokens: {usage.total_tokens}",
f"messages: {len(self._bundle.engine.messages)}",
]
self.query_one("#status-bar", Static).update("\n".join(status_lines))
status_snapshot = (
state.model,
state.permission_mode,
state.fast_mode,
state.output_style,
state.vim_enabled,
state.voice_enabled,
usage.total_tokens,
len(self._bundle.engine.messages),
)
if force or status_snapshot != self._last_status_snapshot:
status_lines = [
"[b]Status[/b]",
f"model: {state.model}",
f"permissions: {state.permission_mode}",
f"fast: {'on' if state.fast_mode else 'off'}",
f"style: {state.output_style}",
f"vim: {'on' if state.vim_enabled else 'off'}",
f"voice: {'on' if state.voice_enabled else 'off'}",
f"tokens: {usage.total_tokens}",
f"messages: {len(self._bundle.engine.messages)}",
]
self.query_one("#status-bar", Static).update("\n".join(status_lines))
self._last_status_snapshot = status_snapshot
tasks = get_task_manager().list_tasks()
if tasks:
task_lines = ["[b]Tasks[/b]"]
for task in tasks[:10]:
suffix: list[str] = []
if task.metadata.get("progress"):
suffix.append(f"{task.metadata['progress']}%")
if task.metadata.get("status_note"):
suffix.append(task.metadata["status_note"])
detail = f" ({' | '.join(suffix)})" if suffix else ""
task_lines.append(f"{task.id} {task.status} {task.description}{detail}")
else:
task_lines = ["[b]Tasks[/b]", "No background tasks."]
self.query_one("#tasks-panel", Static).update("\n".join(task_lines))
self.query_one("#mcp-panel", Static).update(self._bundle.mcp_summary())
tasks_snapshot = tuple(
(
task.id,
task.status,
task.metadata.get("progress"),
task.metadata.get("status_note"),
)
for task in tasks[:10]
)
if force or tasks_snapshot != self._last_tasks_snapshot:
if tasks:
task_lines = ["[b]Tasks[/b]"]
for task in tasks[:10]:
suffix: list[str] = []
if task.metadata.get("progress"):
suffix.append(f"{task.metadata['progress']}%")
if task.metadata.get("status_note"):
suffix.append(task.metadata["status_note"])
detail = f" ({' | '.join(suffix)})" if suffix else ""
task_lines.append(f"{task.id} {task.status} {task.description}{detail}")
else:
task_lines = ["[b]Tasks[/b]", "No background tasks."]
self.query_one("#tasks-panel", Static).update("\n".join(task_lines))
self._last_tasks_snapshot = tasks_snapshot
mcp_summary = self._bundle.mcp_summary()
if force or mcp_summary != self._last_mcp_summary:
self.query_one("#mcp-panel", Static).update(mcp_summary)
self._last_mcp_summary = mcp_summary
+39
View File
@@ -11,6 +11,7 @@ from openharness.api.openai_client import (
OpenAICompatibleClient,
_convert_messages_to_openai,
_convert_tools_to_openai,
_normalize_openai_base_url,
_token_limit_param_for_model,
)
from openharness.engine.messages import (
@@ -194,6 +195,17 @@ class TestConvertMessagesToOpenai:
assert result[1]["tool_call_id"] == "c2"
class TestNormalizeOpenAIBaseUrl:
def test_preserves_explicit_v1_path(self):
assert _normalize_openai_base_url("https://jarodfund.xyz/openai/v1") == "https://jarodfund.xyz/openai/v1"
def test_adds_default_v1_when_path_missing(self):
assert _normalize_openai_base_url("https://api.example.com") == "https://api.example.com/v1"
def test_strips_trailing_slash_without_dropping_path(self):
assert _normalize_openai_base_url("https://api.example.com/openai/v1/") == "https://api.example.com/openai/v1"
class TestTokenLimitParams:
def test_gpt5_uses_max_completion_tokens(self):
assert _token_limit_param_for_model("gpt-5.4", 4096) == {"max_completion_tokens": 4096}
@@ -236,6 +248,33 @@ class _FakeOpenAIClient:
self.chat = _FakeChat()
def test_openai_client_init_normalizes_base_url(monkeypatch):
captured: dict[str, object] = {}
class _StubAsyncOpenAI:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr("openharness.api.openai_client.AsyncOpenAI", _StubAsyncOpenAI)
OpenAICompatibleClient(api_key="test-key", base_url="https://jarodfund.xyz/openai/v1/")
assert captured["base_url"] == "https://jarodfund.xyz/openai/v1"
def test_openai_client_init_passes_timeout(monkeypatch):
captured: dict[str, object] = {}
class _StubAsyncOpenAI:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr("openharness.api.openai_client.AsyncOpenAI", _StubAsyncOpenAI)
OpenAICompatibleClient(api_key="test-key", timeout=45.0)
assert captured["timeout"] == 45.0
class TestStreamMessageTokenParams:
@pytest.mark.asyncio
async def test_gpt5_stream_uses_max_completion_tokens(self):
+3
View File
@@ -26,6 +26,7 @@ class TestSettings:
assert s.api_key == ""
assert s.model == "claude-sonnet-4-6"
assert s.max_tokens == 16384
assert s.timeout == 30.0
assert s.max_turns == 200
assert s.fast_mode is False
assert s.permission.mode == "default"
@@ -382,6 +383,7 @@ def test_normalize_anthropic_model_name_matches_hermes_behavior():
path.write_text(json.dumps({"model": "from-file", "base_url": "https://file.example"}))
monkeypatch.setenv("ANTHROPIC_MODEL", "from-env-model")
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://env.example/anthropic")
monkeypatch.setenv("OPENHARNESS_TIMEOUT", "42.5")
monkeypatch.setenv("OPENHARNESS_MAX_TURNS", "42")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-env-override")
monkeypatch.setenv("OPENHARNESS_SANDBOX_ENABLED", "true")
@@ -391,6 +393,7 @@ def test_normalize_anthropic_model_name_matches_hermes_behavior():
assert s.model == "from-env-model"
assert s.base_url == "https://env.example/anthropic"
assert s.timeout == 42.5
assert s.max_turns == 42
assert s.api_key == "sk-env-override"
assert s.sandbox.enabled is True
+68
View File
@@ -114,3 +114,71 @@ async def test_textual_app_handles_ask_user_tool(tmp_path, monkeypatch):
assert any("tool-result> ask_user_question: green" in line for line in app.transcript_lines)
assert any("assistant> chosen green" in line for line in app.transcript_lines)
@pytest.mark.asyncio
async def test_textual_sidebar_refresh_is_snapshot_based(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
app = OpenHarnessTerminalApp(api_client=StaticApiClient("hello"))
async with app.run_test():
status_bar = app.query_one("#status-bar")
tasks_panel = app.query_one("#tasks-panel")
status_updates: list[str] = []
task_updates: list[str] = []
original_status_update = status_bar.update
original_task_update = tasks_panel.update
def _tracked_status_update(renderable):
status_updates.append(str(renderable))
return original_status_update(renderable)
def _tracked_task_update(renderable):
task_updates.append(str(renderable))
return original_task_update(renderable)
monkeypatch.setattr(status_bar, "update", _tracked_status_update)
monkeypatch.setattr(tasks_panel, "update", _tracked_task_update)
app._refresh_sidebars()
app._refresh_sidebars()
assert status_updates == []
assert task_updates == []
app._bundle.app_state.set(model="gpt-5.4")
app._refresh_sidebars()
assert len(status_updates) == 1
assert task_updates == []
app._refresh_sidebars()
assert len(status_updates) == 1
assert task_updates == []
@pytest.mark.asyncio
async def test_textual_current_response_update_is_deduplicated(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
app = OpenHarnessTerminalApp(api_client=StaticApiClient("hello"))
async with app.run_test():
current_response = app.query_one("#current-response")
updates: list[str] = []
original_update = current_response.update
def _tracked_update(renderable):
updates.append(str(renderable))
return original_update(renderable)
monkeypatch.setattr(current_response, "update", _tracked_update)
app._set_current_response("same message")
app._set_current_response("same message")
app._set_current_response("next message")
assert updates == ["same message", "next message"]