mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
fix(code): full-width chat messages, hide scrollbar, flush input bg (#4374)
Closes #3548 --- The chat surface had two nits: message blocks didn't line up with the input box (the chat container had horizontal padding the input border didn't), and the vertical scrollbar was always on unless the terminal was in ASCII mode — with no way for a user to hide it on a capable terminal. This makes the chat area flush with the input box and adds a user-toggleable scrollbar so people can choose whether the scrollbar is visible. - `#chat` drops its horizontal padding so message blocks span the same full width as the input box border below. - The input box and status bar switch from `$surface` to `$background` so they blend with the app background instead of reading as a separate band. - New `/scrollbar` slash command toggles the vertical scrollbar on or off for the current session and persists the choice to `[ui].show_scrollbar` (and `DEEPAGENTS_CODE_SHOW_SCROLLBAR` for an env-var override). It defaults to off, matching the look of other coding CLIs; wheel and keyboard scrolling still work when the scrollbar is hidden. ASCII-mode terminals keep the scrollbar hidden regardless of the preference, since they can't render the glyphs. ## Screenshots ### Before <img width="861" height="632" alt="Screenshot 2026-06-29 at 5 38 10 PM" src="https://github.com/user-attachments/assets/4add48dd-f761-4edf-a2a1-aefa9c6e4697" /> ### After <img width="862" height="634" alt="Screenshot 2026-06-29 at 5 36 48 PM" src="https://github.com/user-attachments/assets/d7ecb5b7-87b3-4122-8b28-bc61b248b670" /> <img width="857" height="634" alt="Screenshot 2026-06-29 at 5 37 19 PM" src="https://github.com/user-attachments/assets/2f656c5b-5509-4326-a68a-ad2a3a59fd6a" /> --------- Co-authored-by: Mason Daugherty <github@mdrxy.com> Co-authored-by: Mason Daugherty <mason@langchain.dev>
This commit is contained in:
committed by
GitHub
parent
83898ba608
commit
1f8e8dc942
@@ -8,7 +8,7 @@ Regenerate this file with `make commands-catalog` after changing command names,
|
||||
aliases, descriptions, visibility, or hidden-command metadata.
|
||||
|
||||
|
||||
## Public (30)
|
||||
## Public (31)
|
||||
|
||||
| Command | Aliases | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -34,6 +34,7 @@ aliases, descriptions, visibility, or hidden-command metadata.
|
||||
| `/remember` | | Save useful context to memory or skills |
|
||||
| `/restart` | | Restart the agent server |
|
||||
| `/rubric` | `/criteria` | Set explicit acceptance criteria for rubric grading |
|
||||
| `/scrollbar` | | Show or hide the chat scrollbar |
|
||||
| `/skill-creator` | | Create or refine agent skills |
|
||||
| `/theme` | | Change color theme |
|
||||
| `/threads` | | Browse and resume past threads |
|
||||
|
||||
@@ -206,6 +206,18 @@ Defaults to enabled; set to a falsy value (`0`, `false`, `no`, `off`, or empty)
|
||||
to hide replica tracing details from the splash while leaving tracing active.
|
||||
"""
|
||||
|
||||
SHOW_SCROLLBAR = "DEEPAGENTS_CODE_SHOW_SCROLLBAR"
|
||||
"""Show the vertical scrollbar in the chat area when enabled.
|
||||
|
||||
Off by default; use the `/scrollbar` slash command or `[ui].show_scrollbar` in
|
||||
config.toml to toggle. Parsed by `classify_env_bool` (an unrecognized or empty
|
||||
value falls through to the config value rather than forcing the default).
|
||||
|
||||
When set, this env var takes precedence over the persisted `[ui].show_scrollbar`
|
||||
config value on launch, so a `/scrollbar` toggle will not appear to "stick"
|
||||
across restarts while the env var remains set.
|
||||
"""
|
||||
|
||||
THEME = "DEEPAGENTS_CODE_THEME"
|
||||
"""Force the CLI to launch with this theme name when set."""
|
||||
|
||||
|
||||
@@ -57,12 +57,10 @@ from deepagents_code._session_stats import (
|
||||
format_token_count,
|
||||
)
|
||||
|
||||
# Only is_ascii_mode is needed before first paint (on_mount scrollbar config).
|
||||
# All other config imports — settings, create_model, detect_provider, etc. — are
|
||||
# deferred to local imports at their call sites since they are only accessed
|
||||
# after user interaction begins.
|
||||
# All config imports — settings, create_model, detect_provider, is_ascii_mode,
|
||||
# etc. — are deferred to local imports at their call sites since they are only
|
||||
# accessed after user interaction begins.
|
||||
from deepagents_code._version import CHANGELOG_URL, DOCS_URL
|
||||
from deepagents_code.config import is_ascii_mode
|
||||
from deepagents_code.formatting import format_message_timestamp
|
||||
from deepagents_code.iterm_cursor_guide import restore_iterm_cursor_guide
|
||||
from deepagents_code.notifications import (
|
||||
@@ -603,6 +601,55 @@ def _load_message_timestamps_visible() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _load_show_scrollbar() -> bool:
|
||||
"""Load the chat scrollbar visibility preference.
|
||||
|
||||
Reads `DEEPAGENTS_CODE_SHOW_SCROLLBAR` env var, falling back to
|
||||
`[ui].show_scrollbar` from `~/.deepagents/config.toml`, and finally `False`.
|
||||
|
||||
Returns:
|
||||
The resolved preference.
|
||||
"""
|
||||
from deepagents_code._env_vars import SHOW_SCROLLBAR, classify_env_bool
|
||||
|
||||
raw = os.environ.get(SHOW_SCROLLBAR)
|
||||
if raw is not None and raw.strip():
|
||||
env = classify_env_bool(raw)
|
||||
if env is not None:
|
||||
return env
|
||||
|
||||
import tomllib
|
||||
|
||||
from deepagents_code.model_config import DEFAULT_CONFIG_PATH
|
||||
|
||||
if not DEFAULT_CONFIG_PATH.exists():
|
||||
return False
|
||||
try:
|
||||
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
except (tomllib.TOMLDecodeError, PermissionError, OSError) as exc:
|
||||
logger.warning("Could not read config for scrollbar preference: %s", exc)
|
||||
return False
|
||||
|
||||
ui = data.get("ui", {})
|
||||
if not isinstance(ui, dict):
|
||||
logger.warning(
|
||||
"[ui] should be a table; got %s while loading scrollbar preference",
|
||||
type(ui).__name__,
|
||||
)
|
||||
return False
|
||||
|
||||
value = ui.get("show_scrollbar")
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is not None:
|
||||
logger.warning(
|
||||
"[ui].show_scrollbar should be a boolean; got %s",
|
||||
type(value).__name__,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _replace_malformed_ui(
|
||||
data: dict[str, object],
|
||||
) -> tuple[dict[str, object], str | None]:
|
||||
@@ -946,6 +993,65 @@ def _save_message_timestamps_visible_result(visible: bool) -> _ConfigWriteResult
|
||||
return _ConfigWriteResult(True, repair_message)
|
||||
|
||||
|
||||
def _save_show_scrollbar_result(visible: bool) -> _ConfigWriteResult:
|
||||
"""Persist the chat scrollbar visibility preference.
|
||||
|
||||
Writes `[ui].show_scrollbar` atomically (temp file +
|
||||
`Path.replace`). Mirrors `_save_message_timestamps_visible_result`.
|
||||
|
||||
Returns:
|
||||
Write status and a message suitable for a toast when the user needs to
|
||||
know about a repair or failure.
|
||||
"""
|
||||
import contextlib
|
||||
import tempfile
|
||||
import tomllib
|
||||
|
||||
try:
|
||||
import tomli_w
|
||||
|
||||
from deepagents_code.model_config import DEFAULT_CONFIG_PATH
|
||||
|
||||
DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _CONFIG_WRITE_LOCK:
|
||||
if DEFAULT_CONFIG_PATH.exists():
|
||||
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
else:
|
||||
data = {}
|
||||
|
||||
ui, repair_message = _replace_malformed_ui(data)
|
||||
ui["show_scrollbar"] = visible
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=DEFAULT_CONFIG_PATH.parent,
|
||||
suffix=".tmp",
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
Path(tmp_path).replace(DEFAULT_CONFIG_PATH)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
Path(tmp_path).unlink()
|
||||
raise
|
||||
except (
|
||||
OSError,
|
||||
tomllib.TOMLDecodeError,
|
||||
ImportError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
logger.exception("Could not save scrollbar preference")
|
||||
return _ConfigWriteResult(
|
||||
False,
|
||||
f"Scrollbar toggled for this session but could not be saved "
|
||||
f"({type(exc).__name__}).",
|
||||
"error",
|
||||
)
|
||||
return _ConfigWriteResult(True, repair_message)
|
||||
|
||||
|
||||
def _extract_model_params_flag(raw_arg: str) -> tuple[str, dict[str, Any] | None]:
|
||||
"""Extract `--model-params` and its JSON value from a `/model` arg string.
|
||||
|
||||
@@ -2143,6 +2249,13 @@ class DeepAgentsApp(App):
|
||||
Restored from `[ui].show_message_timestamps` and re-persisted on toggle.
|
||||
"""
|
||||
|
||||
self._show_scrollbar = _load_show_scrollbar()
|
||||
"""Whether the vertical scrollbar is shown in the chat area.
|
||||
|
||||
Restored from `DEEPAGENTS_CODE_SHOW_SCROLLBAR` env var or
|
||||
`[ui].show_scrollbar` and re-persisted on toggle. Off by default.
|
||||
"""
|
||||
|
||||
# Widget refs (populated in compose/on_mount)
|
||||
self._status_bar: StatusBar | None = None
|
||||
"""Status bar widget; populated in `on_mount`."""
|
||||
@@ -2622,8 +2735,7 @@ class DeepAgentsApp(App):
|
||||
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
chat.anchor()
|
||||
if is_ascii_mode():
|
||||
chat.styles.scrollbar_size_vertical = 0
|
||||
self._apply_scrollbar_visibility(chat)
|
||||
|
||||
self._status_bar = self.query_one("#status-bar", StatusBar)
|
||||
self._chat_input = self.query_one("#input-area", ChatInput)
|
||||
@@ -8749,7 +8861,8 @@ class DeepAgentsApp(App):
|
||||
"/mcp, /model [--model-params JSON] [--default], "
|
||||
"/notifications, /reload, /restart, /rubric, "
|
||||
"/skill:<name>, /remember, "
|
||||
"/skill-creator, /theme, /timestamps, /tokens, /threads, /trace, "
|
||||
"/skill-creator, /theme, /scrollbar, /timestamps, /tokens, "
|
||||
"/threads, /trace, "
|
||||
"/update, /auto-update, /install, /changelog, /docs, "
|
||||
"/feedback, /help\n\n"
|
||||
"Interactive Features:\n"
|
||||
@@ -8876,6 +8989,15 @@ class DeepAgentsApp(App):
|
||||
await self._handle_auto_update_toggle()
|
||||
elif cmd == "/install" or cmd.startswith("/install "):
|
||||
await self._handle_install_command(command)
|
||||
elif cmd == "/scrollbar":
|
||||
await self._toggle_scrollbar()
|
||||
label = "shown" if self._show_scrollbar else "hidden"
|
||||
self.notify(
|
||||
f"Chat scrollbar {label}.",
|
||||
severity="information",
|
||||
timeout=5,
|
||||
markup=False,
|
||||
)
|
||||
elif cmd == "/timestamps":
|
||||
await self._toggle_message_timestamp_footers()
|
||||
label = "shown" if self._message_timestamps_visible else "hidden"
|
||||
@@ -10245,6 +10367,59 @@ class DeepAgentsApp(App):
|
||||
"Failed to persist message timestamp preference",
|
||||
exc_info=True,
|
||||
)
|
||||
self.notify(
|
||||
"Timestamps toggled for this session but could not be saved.",
|
||||
severity="error",
|
||||
timeout=6,
|
||||
markup=False,
|
||||
)
|
||||
|
||||
def _apply_scrollbar_visibility(self, chat: VerticalScroll | None = None) -> None:
|
||||
"""Apply the current scrollbar visibility to the chat container.
|
||||
|
||||
Hides the scrollbar when the user preference is off or ASCII mode is
|
||||
active (ASCII terminals can't render the scrollbar glyphs).
|
||||
"""
|
||||
from deepagents_code.config import is_ascii_mode
|
||||
|
||||
if chat is None:
|
||||
try:
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
if self._show_scrollbar and not is_ascii_mode():
|
||||
chat.styles.scrollbar_size_vertical = 1
|
||||
else:
|
||||
chat.styles.scrollbar_size_vertical = 0
|
||||
|
||||
async def _toggle_scrollbar(self) -> None:
|
||||
"""Toggle chat scrollbar visibility and persist the preference."""
|
||||
self._show_scrollbar = not self._show_scrollbar
|
||||
self._apply_scrollbar_visibility()
|
||||
try:
|
||||
status = await asyncio.to_thread(
|
||||
_save_show_scrollbar_result,
|
||||
self._show_scrollbar,
|
||||
)
|
||||
if status.message is not None:
|
||||
self.notify(
|
||||
status.message,
|
||||
severity=status.severity,
|
||||
timeout=6,
|
||||
markup=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist scrollbar preference",
|
||||
exc_info=True,
|
||||
)
|
||||
self.notify(
|
||||
"Scrollbar toggled for this session but could not be saved.",
|
||||
severity="error",
|
||||
timeout=6,
|
||||
markup=False,
|
||||
)
|
||||
|
||||
async def _mount_message(
|
||||
self,
|
||||
|
||||
@@ -39,10 +39,13 @@ Screen {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Chat area - main scrollable messages area */
|
||||
/* Chat area - main scrollable messages area.
|
||||
No horizontal padding so message blocks span the same full width as the
|
||||
input box below (whose border is flush to both edges). The scrollbar is
|
||||
toggled at runtime via the /scrollbar command (off by default). */
|
||||
#chat {
|
||||
height: 1fr;
|
||||
padding: 1 2 0 2;
|
||||
padding: 1 0 0 0;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
|
||||
@@ -196,6 +196,12 @@ COMMANDS: tuple[SlashCommand, ...] = (
|
||||
bypass_tier=BypassTier.IMMEDIATE_UI,
|
||||
hidden_keywords="dark light color appearance",
|
||||
),
|
||||
SlashCommand(
|
||||
name="/scrollbar",
|
||||
description="Show or hide the chat scrollbar",
|
||||
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
|
||||
hidden_keywords="scroll scroller bar vertical",
|
||||
),
|
||||
SlashCommand(
|
||||
name="/timestamps",
|
||||
description="Show or hide message timestamps",
|
||||
|
||||
@@ -795,6 +795,15 @@ _STATIC_OPTIONS: tuple[ConfigOption, ...] = (
|
||||
kind=OptionKind.BOOL,
|
||||
env_var=_env_vars.KITTY_KEYBOARD,
|
||||
),
|
||||
ConfigOption(
|
||||
key="display.show_scrollbar",
|
||||
group="Display",
|
||||
summary="Show the vertical scrollbar in the chat area (off by default).",
|
||||
kind=OptionKind.BOOL,
|
||||
default=False,
|
||||
env_var=_env_vars.SHOW_SCROLLBAR,
|
||||
toml_keys=("ui", "show_scrollbar"),
|
||||
),
|
||||
ConfigOption(
|
||||
key="display.hide_cwd",
|
||||
group="Display",
|
||||
|
||||
@@ -1431,7 +1431,7 @@ class ChatInput(Vertical):
|
||||
min-height: 3;
|
||||
max-height: 25;
|
||||
padding: 0;
|
||||
background: $surface;
|
||||
background: $background;
|
||||
border: solid $primary;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ class StatusBar(Horizontal):
|
||||
StatusBar {
|
||||
height: 1;
|
||||
dock: bottom;
|
||||
background: $surface;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
StatusBar .status-mode {
|
||||
|
||||
@@ -7512,6 +7512,309 @@ class TestMessageTimestampsPersistence:
|
||||
assert data["ui"]["show_message_timestamps"] is True
|
||||
|
||||
|
||||
class TestScrollbarToggle:
|
||||
"""Tests for the toggleable chat scrollbar."""
|
||||
|
||||
def test_load_defaults_false_when_config_missing(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A missing config yields the hidden default."""
|
||||
from deepagents_code.app import _load_show_scrollbar
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config.DEFAULT_CONFIG_PATH",
|
||||
tmp_path / "config.toml",
|
||||
)
|
||||
assert _load_show_scrollbar() is False
|
||||
|
||||
def test_load_reads_saved_true(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An explicit `true` preference is read back."""
|
||||
from deepagents_code.app import _load_show_scrollbar
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("[ui]\nshow_scrollbar = true\n")
|
||||
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
|
||||
assert _load_show_scrollbar() is True
|
||||
|
||||
def test_load_ignores_non_boolean(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A non-boolean preference is ignored with a warning."""
|
||||
from deepagents_code.app import _load_show_scrollbar
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('[ui]\nshow_scrollbar = "yes"\n')
|
||||
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
|
||||
with caplog.at_level("WARNING", logger="deepagents_code.app"):
|
||||
assert _load_show_scrollbar() is False
|
||||
assert any("show_scrollbar" in record.getMessage() for record in caplog.records)
|
||||
|
||||
def test_env_var_overrides_config(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The env var takes priority over the config.toml value."""
|
||||
from deepagents_code.app import _load_show_scrollbar
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("[ui]\nshow_scrollbar = false\n")
|
||||
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_SHOW_SCROLLBAR", "1")
|
||||
assert _load_show_scrollbar() is True
|
||||
|
||||
def test_invalid_env_var_falls_back_to_config(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An unrecognized env var value does not mask the saved preference."""
|
||||
from deepagents_code.app import _load_show_scrollbar
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("[ui]\nshow_scrollbar = true\n")
|
||||
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_SHOW_SCROLLBAR", "maybe")
|
||||
assert _load_show_scrollbar() is True
|
||||
|
||||
def test_empty_env_var_falls_back_to_config(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An empty env var value does not mask the saved preference."""
|
||||
from deepagents_code.app import _load_show_scrollbar
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("[ui]\nshow_scrollbar = true\n")
|
||||
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_SHOW_SCROLLBAR", "")
|
||||
assert _load_show_scrollbar() is True
|
||||
|
||||
def test_save_round_trips(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Saving then loading returns the saved value, both directions."""
|
||||
from deepagents_code.app import (
|
||||
_load_show_scrollbar,
|
||||
_save_show_scrollbar_result,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config.DEFAULT_CONFIG_PATH",
|
||||
tmp_path / "config.toml",
|
||||
)
|
||||
assert _save_show_scrollbar_result(True).ok is True
|
||||
assert _load_show_scrollbar() is True
|
||||
assert _save_show_scrollbar_result(False).ok is True
|
||||
assert _load_show_scrollbar() is False
|
||||
|
||||
def test_save_preserves_other_ui_keys(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Persisting the toggle leaves unrelated `[ui]` keys intact."""
|
||||
import tomllib
|
||||
|
||||
from deepagents_code.app import _save_show_scrollbar_result
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('[ui]\ntheme = "langchain"\n')
|
||||
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
|
||||
assert _save_show_scrollbar_result(True).ok is True
|
||||
data = tomllib.loads(config.read_text())
|
||||
assert data["ui"]["theme"] == "langchain"
|
||||
assert data["ui"]["show_scrollbar"] is True
|
||||
|
||||
async def test_toggle_flips_preference_and_applies(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The `/scrollbar` toggle flips the flag and updates the chat widget."""
|
||||
from textual.containers import VerticalScroll
|
||||
|
||||
from deepagents_code.config import reset_glyphs_cache
|
||||
|
||||
# Force Unicode charset so the scrollbar can actually be shown —
|
||||
# in ASCII mode _apply_scrollbar_visibility keeps it at 0.
|
||||
monkeypatch.setenv("UI_CHARSET_MODE", "unicode")
|
||||
reset_glyphs_cache()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config.DEFAULT_CONFIG_PATH",
|
||||
tmp_path / "config.toml",
|
||||
)
|
||||
app = DeepAgentsApp()
|
||||
assert app._show_scrollbar is False
|
||||
|
||||
try:
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
chat = app.query_one("#chat", VerticalScroll)
|
||||
assert chat.styles.scrollbar_size_vertical == 0
|
||||
|
||||
await app._toggle_scrollbar()
|
||||
await pilot.pause()
|
||||
assert app._show_scrollbar is True
|
||||
assert chat.styles.scrollbar_size_vertical == 1
|
||||
|
||||
await app._toggle_scrollbar()
|
||||
await pilot.pause()
|
||||
assert app._show_scrollbar is False
|
||||
assert chat.styles.scrollbar_size_vertical == 0
|
||||
finally:
|
||||
# The glyph cache is process-global; clear the forced charset so the
|
||||
# Unicode result does not leak into later tests in this process.
|
||||
monkeypatch.delenv("UI_CHARSET_MODE", raising=False)
|
||||
reset_glyphs_cache()
|
||||
|
||||
async def test_ascii_mode_keeps_scrollbar_hidden(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""ASCII terminals never show the scrollbar, even when enabled."""
|
||||
from textual.containers import VerticalScroll
|
||||
|
||||
from deepagents_code.config import reset_glyphs_cache
|
||||
|
||||
# ASCII terminals can't render the scrollbar glyphs, so the visibility
|
||||
# helper must keep the width at 0 regardless of the user preference.
|
||||
monkeypatch.setenv("UI_CHARSET_MODE", "ascii")
|
||||
reset_glyphs_cache()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config.DEFAULT_CONFIG_PATH",
|
||||
tmp_path / "config.toml",
|
||||
)
|
||||
app = DeepAgentsApp()
|
||||
|
||||
try:
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
chat = app.query_one("#chat", VerticalScroll)
|
||||
|
||||
app._show_scrollbar = True
|
||||
app._apply_scrollbar_visibility()
|
||||
await pilot.pause()
|
||||
assert chat.styles.scrollbar_size_vertical == 0
|
||||
finally:
|
||||
monkeypatch.delenv("UI_CHARSET_MODE", raising=False)
|
||||
reset_glyphs_cache()
|
||||
|
||||
async def test_command_shows_toast_not_app_message(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""`/scrollbar` reports its new state via a toast, not a chat message."""
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config.DEFAULT_CONFIG_PATH",
|
||||
tmp_path / "config.toml",
|
||||
)
|
||||
app = DeepAgentsApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with patch.object(app, "notify") as notify_mock:
|
||||
await app._handle_command("/scrollbar")
|
||||
await pilot.pause()
|
||||
|
||||
notify_mock.assert_called_once()
|
||||
assert notify_mock.call_args.args[0] == "Chat scrollbar shown."
|
||||
assert notify_mock.call_args.kwargs.get("severity") == "information"
|
||||
assert notify_mock.call_args.kwargs.get("markup") is False
|
||||
assert not app.query(UserMessage)
|
||||
|
||||
# Toggling back reports the "hidden" state via the same toast path.
|
||||
with patch.object(app, "notify") as notify_mock:
|
||||
await app._handle_command("/scrollbar")
|
||||
await pilot.pause()
|
||||
|
||||
notify_mock.assert_called_once()
|
||||
assert notify_mock.call_args.args[0] == "Chat scrollbar hidden."
|
||||
assert notify_mock.call_args.kwargs.get("severity") == "information"
|
||||
assert notify_mock.call_args.kwargs.get("markup") is False
|
||||
assert not app.query(UserMessage)
|
||||
|
||||
def test_falsy_env_overrides_config(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A falsy env var overrides a `true` config value (precedence)."""
|
||||
from deepagents_code.app import _load_show_scrollbar
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("[ui]\nshow_scrollbar = true\n")
|
||||
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_SHOW_SCROLLBAR", "0")
|
||||
assert _load_show_scrollbar() is False
|
||||
|
||||
def test_load_handles_malformed_toml(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Unparsable config falls back to the hidden default with a warning."""
|
||||
from deepagents_code.app import _load_show_scrollbar
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("this is = = not valid toml [[[\n")
|
||||
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
|
||||
with caplog.at_level("WARNING", logger="deepagents_code.app"):
|
||||
assert _load_show_scrollbar() is False
|
||||
assert any("scrollbar" in record.getMessage() for record in caplog.records)
|
||||
|
||||
def test_load_ignores_non_table_ui(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A scalar `[ui]` value is ignored with a warning on load."""
|
||||
from deepagents_code.app import _load_show_scrollbar
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('ui = "oops"\n')
|
||||
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
|
||||
with caplog.at_level("WARNING", logger="deepagents_code.app"):
|
||||
assert _load_show_scrollbar() is False
|
||||
assert any("ui" in record.getMessage() for record in caplog.records)
|
||||
|
||||
def test_save_repairs_malformed_ui_table(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Saving over a scalar `[ui]` replaces it and reports the repair."""
|
||||
import tomllib
|
||||
|
||||
from deepagents_code.app import _save_show_scrollbar_result
|
||||
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('ui = "oops"\n')
|
||||
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
|
||||
|
||||
result = _save_show_scrollbar_result(True)
|
||||
assert result.ok is True
|
||||
assert result.message is not None
|
||||
assert "[ui]" in result.message
|
||||
data = tomllib.loads(config.read_text())
|
||||
assert data["ui"]["show_scrollbar"] is True
|
||||
|
||||
def test_save_failure_reports_error(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An unwritable target yields an error result instead of raising."""
|
||||
from deepagents_code.app import _save_show_scrollbar_result
|
||||
|
||||
# Point the config at a path whose parent is a regular file, so the
|
||||
# `mkdir` in the writer raises an OSError that must be converted into a
|
||||
# structured failure rather than propagating.
|
||||
blocker = tmp_path / "not-a-dir"
|
||||
blocker.write_text("")
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config.DEFAULT_CONFIG_PATH",
|
||||
blocker / "config.toml",
|
||||
)
|
||||
|
||||
result = _save_show_scrollbar_result(True)
|
||||
assert result.ok is False
|
||||
assert result.severity == "error"
|
||||
assert result.message is not None
|
||||
|
||||
|
||||
class TestAppBlurPausesCursorBlink:
|
||||
"""Test `on_app_blur` pauses cursor blink without changing widget focus."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user