oterm/tests/modals/test_chat_edit.py
Yiorgis Gozadinos 3ba494fcf6
Keep the model list visible in the chat edit screen on short terminals
#top-row and #bottom-row split the screen 1fr/1fr. The left column of
the top row needs 12 rows for the provider select, the model input and
three models, so below about 40 rows the model list got only its
border and showed no models (#344).

Give #top-row a min-height of 12 and #bottom-row a min-height of 9
(three parameter rows), and let #chat-edit-screen scroll when they do
not fit. Between 30 and 34 rows Textual sizes both 1fr rows to the
viewport, so the screen scrolls further than the content needs. From
35 rows up nothing scrolls.

Also add the missing 0.25.0 heading to the CHANGELOG. Everything
under [Unreleased] except Go to chat and the ctrl+tab focus fix
shipped in 0.25.0.
2026-10-12 10:40:12 +03:00

619 lines
20 KiB
Python

import pytest
from textual.app import App
from textual.widgets import Button, Checkbox, Input, OptionList, TextArea
from oterm.app.chat_edit import ChatEdit
from oterm.app.widgets.model_select import ModelSelect
from oterm.types import ChatModel
@pytest.fixture(autouse=True)
def stub_providers(monkeypatch):
"""Keep ChatEdit.on_mount from actually calling Ollama or any SDK."""
from oterm.providers import ollama as ollama_mod
class _M:
model = "llama3"
def __getitem__(self, key):
return 1_000_000_000 if key == "size" else None
class _Resp:
models = [_M()]
monkeypatch.setattr(ollama_mod, "list_models", lambda: _Resp())
class _Show(dict):
modelfile = ""
@property
def parameters(self):
return ""
monkeypatch.setattr(ollama_mod, "show_model", lambda m: _Show())
return _Resp
class _Host(App):
pass
async def test_compose_renders_core_widgets(app_config):
app = _Host()
async with app.run_test() as pilot:
app.push_screen(ChatEdit())
await pilot.pause()
screen = app.screen
assert screen.query_one(ModelSelect)
assert screen.query_one(".system", TextArea)
assert screen.query_one("#temperature-input", Input)
assert screen.query_one("#top-p-input", Input)
assert screen.query_one("#max-tokens-input", Input)
assert screen.query_one("#seed-input", Input)
assert screen.query_one("#thinking-checkbox", Checkbox)
assert screen.query_one("#save-btn", Button)
async def test_compose_renders_param_inputs_for_anthropic(app_config, monkeypatch):
"""Common sampling fields are surfaced for every supported provider."""
import oterm.providers as prov
monkeypatch.setattr(prov, "list_models", lambda p: ["claude-sonnet-4"])
monkeypatch.setattr(
prov, "get_available_providers", lambda: ["ollama", "anthropic"]
)
app = _Host()
async with app.run_test() as pilot:
chat_model = ChatModel(model="claude-sonnet-4", provider="anthropic")
app.push_screen(ChatEdit(chat_model=chat_model))
await pilot.pause()
screen = app.screen
for input_id in (
"#temperature-input",
"#top-p-input",
"#max-tokens-input",
"#seed-input",
):
assert screen.query_one(input_id, Input)
async def test_escape_dismisses_with_none(app_config):
app = _Host()
async with app.run_test() as pilot:
received: list[ChatModel | None] = []
app.push_screen(ChatEdit(), lambda r: received.append(r))
await pilot.pause()
await pilot.press("escape")
await pilot.pause()
assert received == [None]
async def test_save_dismisses_with_chat_model_json(app_config):
app = _Host()
async with app.run_test() as pilot:
received: list[ChatModel | None] = []
chat_model = ChatModel(model="llama3", provider="ollama")
screen = ChatEdit(chat_model=chat_model, edit_mode=True)
app.push_screen(screen, lambda r: received.append(r))
await pilot.pause()
screen.query_one("#temperature-input", Input).value = "0.5"
screen.query_one("#top-p-input", Input).value = ""
screen.query_one("#max-tokens-input", Input).value = ""
await pilot.pause()
screen._return_chat_meta()
await pilot.pause()
assert received and received[0]
payload = received[0].model_dump()
assert payload["model"] == "llama3"
assert payload["parameters"]["temperature"] == 0.5
async def test_missing_model_notifies_and_does_not_dismiss(app_config):
app = _Host()
async with app.run_test() as pilot:
received: list[ChatModel | None] = []
screen = ChatEdit()
app.push_screen(screen, lambda r: received.append(r))
await pilot.pause()
screen.query_one(ModelSelect).set_value("")
await pilot.pause()
screen._return_chat_meta()
await pilot.pause()
assert received == []
assert any("model name" in n.message.lower() for n in list(app._notifications))
@pytest.mark.parametrize(
"field,value,message_fragment",
[
("#temperature-input", "nope", "Invalid Temperature"),
("#temperature-input", "3.0", "Temperature must be"),
("#top-p-input", "nope", "Invalid Top P"),
("#top-p-input", "2.0", "Top P must be"),
("#max-tokens-input", "nope", "Invalid Max Tokens"),
("#max-tokens-input", "-1", "Max Tokens must be"),
("#seed-input", "nope", "Invalid Seed"),
("#seed-input", "1.5", "Invalid Seed"),
],
)
async def test_parameter_validation_errors(app_config, field, value, message_fragment):
app = _Host()
async with app.run_test() as pilot:
received: list[ChatModel | None] = []
chat_model = ChatModel(model="llama3", provider="ollama")
screen = ChatEdit(chat_model=chat_model, edit_mode=True)
app.push_screen(screen, lambda r: received.append(r))
await pilot.pause()
screen.query_one(field, Input).value = value
await pilot.pause()
screen._return_chat_meta()
await pilot.pause()
assert received == []
assert any(
message_fragment.lower() in n.message.lower()
for n in list(app._notifications)
)
async def test_valid_parameters_are_persisted(app_config):
app = _Host()
async with app.run_test() as pilot:
received: list[ChatModel | None] = []
chat_model = ChatModel(model="llama3", provider="ollama")
screen = ChatEdit(chat_model=chat_model, edit_mode=True)
app.push_screen(screen, lambda r: received.append(r))
await pilot.pause()
screen.query_one("#temperature-input", Input).value = "0.3"
screen.query_one("#top-p-input", Input).value = "0.9"
screen.query_one("#max-tokens-input", Input).value = "512"
screen.query_one("#seed-input", Input).value = "1234"
await pilot.pause()
screen._return_chat_meta()
await pilot.pause()
assert received and received[0]
payload = received[0].model_dump()
assert payload["parameters"] == {
"temperature": 0.3,
"top_p": 0.9,
"max_tokens": 512,
"seed": 1234,
}
async def test_save_button_triggers_return(app_config):
app = _Host()
async with app.run_test() as pilot:
received: list[ChatModel | None] = []
chat_model = ChatModel(model="llama3", provider="ollama")
screen = ChatEdit(chat_model=chat_model, edit_mode=True)
app.push_screen(screen, lambda r: received.append(r))
await pilot.pause()
save_btn = screen.query_one("#save-btn", Button)
screen.on_button_pressed(Button.Pressed(save_btn))
await pilot.pause()
assert received and received[0]
async def test_cancel_button_dismisses_with_none(app_config):
app = _Host()
async with app.run_test() as pilot:
received: list[ChatModel | None] = []
screen = ChatEdit()
app.push_screen(screen, lambda r: received.append(r))
await pilot.pause()
cancel_btn = next(b for b in screen.query(Button) if b.name == "cancel")
screen.on_button_pressed(Button.Pressed(cancel_btn))
await pilot.pause()
assert received == [None]
async def test_save_action_triggers_return(app_config):
app = _Host()
async with app.run_test() as pilot:
received: list[ChatModel | None] = []
chat_model = ChatModel(model="llama3", provider="ollama")
screen = ChatEdit(chat_model=chat_model, edit_mode=True)
app.push_screen(screen, lambda r: received.append(r))
await pilot.pause()
screen.action_save()
await pilot.pause()
assert received and received[0]
async def test_loading_a_model_populates_inputs(app_config, monkeypatch):
"""Selecting a model via ModelSelect loads its capabilities and system prompt."""
import oterm.app.chat_edit as ce
class _Show(dict):
parameters = "temperature 0.7\ntop_p 0.95"
modelfile = 'SYSTEM "default system"'
def get(self, key, default=""):
if key == "capabilities":
return ["tools", "thinking", "vision", "completion"]
if key == "system":
return "default system"
return default
monkeypatch.setattr(ce.ollama, "show_model", lambda m: _Show())
app = _Host()
async with app.run_test() as pilot:
screen = ChatEdit()
app.push_screen(screen)
await pilot.pause()
await screen._load_model_info("some-model")
await pilot.pause()
assert screen.query_one(".system", TextArea).text == ""
assert screen.query_one("#temperature-input", Input).value == "0.7"
assert screen.query_one("#top-p-input", Input).value == "0.95"
assert screen.query_one("#save-btn", Button).disabled is False
async def test_saved_parameters_take_precedence_over_modelfile(app_config, monkeypatch):
"""Saved chat parameters win over Modelfile defaults when both are present."""
import oterm.app.chat_edit as ce
class _Show(dict):
parameters = "temperature 0.7\ntop_p 0.95"
modelfile = ""
def get(self, key, default=""):
if key == "capabilities":
return ["tools", "completion"]
if key == "system":
return ""
return default
monkeypatch.setattr(ce.ollama, "show_model", lambda m: _Show())
app = _Host()
async with app.run_test() as pilot:
chat_model = ChatModel(
model="some-model",
provider="ollama",
parameters={"temperature": 0.2},
)
screen = ChatEdit(chat_model=chat_model, edit_mode=True)
app.push_screen(screen)
await pilot.pause()
assert screen.query_one("#temperature-input", Input).value == "0.2"
assert screen.query_one("#top-p-input", Input).value == ""
async def test_chat_with_legacy_ollama_keys_loads(app_config):
"""A chat persisted with Ollama-native keys (num_ctx) must still open."""
app = _Host()
async with app.run_test() as pilot:
chat_model = ChatModel(
id=1,
model="llama3",
provider="ollama",
parameters={"num_ctx": 8192, "temperature": 0.4},
)
screen = ChatEdit(chat_model=chat_model, edit_mode=True)
app.push_screen(screen)
await pilot.pause()
assert screen.query_one("#temperature-input", Input).value == "0.4"
assert screen.parameters["num_ctx"] == 8192
async def test_load_model_info_show_failure_notifies(app_config, monkeypatch):
import oterm.app.chat_edit as ce
def boom(m):
raise RuntimeError("ollama down")
monkeypatch.setattr(ce.ollama, "show_model", boom)
app = _Host()
async with app.run_test() as pilot:
screen = ChatEdit()
app.push_screen(screen)
await pilot.pause()
await screen._load_model_info("m")
await pilot.pause()
assert any(
"Failed to load model" in n.message for n in list(app._notifications)
)
async def test_provider_change_resets_model(app_config, monkeypatch):
"""Switching provider clears model + reloads the model list for the new provider."""
import oterm.providers as prov
monkeypatch.setattr(prov, "list_models", lambda p: ["gpt-4o", "gpt-5"])
monkeypatch.setattr(
prov, "get_available_providers", lambda: ["ollama", "openai-chat"]
)
app = _Host()
async with app.run_test() as pilot:
chat_model = ChatModel(model="llama3", provider="ollama")
screen = ChatEdit(chat_model=chat_model)
app.push_screen(screen)
await pilot.pause()
from textual.widgets import Select
event = Select.Changed(
screen.query_one("#provider-select", Select), "openai-chat"
)
await screen.on_select_changed(event)
await pilot.pause()
assert screen.provider == "openai-chat"
assert screen.model_name == ""
async def test_edit_chat_with_unconfigured_openai_compat(app_config, monkeypatch):
"""Opening an existing chat whose openai-compat endpoint is no longer
configured must not crash. The provider stays on its saved value (the
Select is disabled in edit mode anyway) so saving preserves the original.
"""
import oterm.providers as prov
# Endpoint is no longer in the available list.
monkeypatch.setattr(prov, "get_available_providers", lambda: ["ollama"])
app = _Host()
async with app.run_test() as pilot:
chat_model = ChatModel(
id=1, name="x", model="some-model", provider="openai-compat/gone"
)
screen = ChatEdit(chat_model=chat_model, edit_mode=True)
app.push_screen(screen)
await pilot.pause()
# No InvalidSelectValueError; provider preserved.
assert screen.provider == "openai-compat/gone"
assert any(
"not currently available" in n.message for n in list(app._notifications)
)
async def test_load_models_failure_notifies(app_config, monkeypatch):
import oterm.app.chat_edit as ce
def boom():
raise RuntimeError("list failed")
monkeypatch.setattr(ce.ollama, "list_models", boom)
app = _Host()
async with app.run_test() as pilot:
screen = ChatEdit()
app.push_screen(screen)
await pilot.pause()
await screen._load_models_for_provider("ollama")
await pilot.pause()
assert any(
"Failed to load models" in n.message for n in list(app._notifications)
)
async def test_load_model_info_skips_when_already_loaded(app_config, monkeypatch):
"""A second call with the same model name short-circuits without re-fetching."""
import oterm.app.chat_edit as ce
calls: list[str] = []
def show(m):
calls.append(m)
class _Show(dict):
parameters = ""
modelfile = ""
def get(self, key, default=""):
return default
return _Show()
monkeypatch.setattr(ce.ollama, "show_model", show)
app = _Host()
async with app.run_test() as pilot:
screen = ChatEdit()
app.push_screen(screen)
await pilot.pause()
await screen._load_model_info("dup-model")
await screen._load_model_info("dup-model")
await pilot.pause()
assert calls == ["dup-model"]
async def test_load_model_info_uses_cached_meta(app_config, monkeypatch):
"""Cached models_info entries skip the ollama.show_model fetch."""
import oterm.app.chat_edit as ce
calls: list[str] = []
def show(m):
calls.append(m)
raise AssertionError("show_model should not be called when cached")
monkeypatch.setattr(ce.ollama, "show_model", show)
class _Cached(dict):
parameters = ""
modelfile = ""
def __bool__(self) -> bool:
return True
def get(self, key, default=""):
if key == "capabilities":
return ["tools"]
return default
app = _Host()
async with app.run_test() as pilot:
screen = ChatEdit()
app.push_screen(screen)
await pilot.pause()
ChatEdit.models_info["cached-model"] = _Cached() # ty: ignore[invalid-assignment]
try:
await screen._load_model_info("cached-model")
await pilot.pause()
assert calls == []
finally:
ChatEdit.models_info.pop("cached-model", None)
async def test_load_model_info_non_ollama_populates_caps(app_config, monkeypatch):
"""For non-ollama providers, caps come from get_capabilities."""
import oterm.app.chat_edit as ce
from oterm.providers.capabilities import ModelCapabilities
monkeypatch.setattr(
ce,
"get_capabilities",
lambda provider, model: ModelCapabilities(
supports_tools=True, supports_thinking=True, supports_vision=True
),
)
app = _Host()
async with app.run_test() as pilot:
chat_model = ChatModel(model="claude-sonnet", provider="anthropic")
screen = ChatEdit(chat_model=chat_model)
screen.provider = "anthropic"
app.push_screen(screen)
await pilot.pause()
await screen._load_model_info("claude-sonnet")
await pilot.pause()
from oterm.app.widgets.caps import Capabilities
caps_widget = screen.query_one(".caps", Capabilities)
assert set(caps_widget.caps) == {"tools", "thinking", "vision"}
async def test_load_model_info_non_ollama_no_caps(app_config, monkeypatch):
"""A non-ollama provider with no capabilities renders an empty caps strip."""
import oterm.app.chat_edit as ce
from oterm.providers.capabilities import ModelCapabilities
monkeypatch.setattr(
ce,
"get_capabilities",
lambda provider, model: ModelCapabilities(),
)
app = _Host()
async with app.run_test() as pilot:
chat_model = ChatModel(model="basic", provider="anthropic")
screen = ChatEdit(chat_model=chat_model)
screen.provider = "anthropic"
app.push_screen(screen)
await pilot.pause()
await screen._load_model_info("basic")
await pilot.pause()
from oterm.app.widgets.caps import Capabilities
caps_widget = screen.query_one(".caps", Capabilities)
assert list(caps_widget.caps) == []
async def test_on_model_submitted_loads_model(app_config, monkeypatch):
import oterm.app.chat_edit as ce
loaded: list[str] = []
async def fake_load(self, model):
loaded.append(model)
monkeypatch.setattr(ce.ChatEdit, "_load_model_info", fake_load)
app = _Host()
async with app.run_test() as pilot:
screen = ChatEdit()
app.push_screen(screen)
await pilot.pause()
await screen.on_model_submitted(ModelSelect.Submitted("picked"))
await pilot.pause()
assert loaded == ["picked"]
async def test_on_select_changed_ignores_other_selects(app_config):
"""Select.Changed for non-provider selects is a no-op."""
from textual.widgets import Select
app = _Host()
async with app.run_test() as pilot:
screen = ChatEdit()
app.push_screen(screen)
await pilot.pause()
# Construct a fresh Select with no id; the handler must short-circuit
# because event.select.id != "provider-select".
fake_select: Select = Select([("a", "a")], id="other-select")
ev = Select.Changed(fake_select, "ollama")
original_provider = screen.provider
await screen.on_select_changed(ev)
await pilot.pause()
assert screen.provider == original_provider
async def test_on_select_changed_same_provider_is_noop(app_config):
"""Selecting the already-active provider does not reset state."""
from textual.widgets import Select
app = _Host()
async with app.run_test() as pilot:
chat_model = ChatModel(model="llama3", provider="ollama")
screen = ChatEdit(chat_model=chat_model)
app.push_screen(screen)
await pilot.pause()
screen.model_name = "llama3"
provider_select = screen.query_one("#provider-select", Select)
ev = Select.Changed(provider_select, "ollama")
await screen.on_select_changed(ev)
await pilot.pause()
# No reset: model_name is preserved.
assert screen.model_name == "llama3"
class _StyledHost(App):
CSS_PATH = "../../src/oterm/app/oterm.tcss"
async def test_model_list_stays_visible_on_short_terminal(app_config):
app = _StyledHost()
async with app.run_test(size=(120, 24)) as pilot:
app.push_screen(ChatEdit())
await pilot.pause()
options = app.screen.query_one("#model-select-options", OptionList)
assert options.content_region.height >= 3