feat(code): add configurable chat cursor style (#4687)

Deep Agents Code now supports `block` and `underline` chat input cursor
styles through `[ui].cursor_style` or `DEEPAGENTS_CODE_CURSOR_STYLE`.

---

Deep Agents Code previously rendered the chat input cursor as a block
with no way to change its shape. Users can now choose an underlined
cursor while preserving the block cursor as the default.

The preference can be saved in `~/.deepagents/config.toml`:

    [ui]
    cursor_style = "underline"

It can also be set for a process with
`DEEPAGENTS_CODE_CURSOR_STYLE=underline`. The environment variable takes
precedence over the saved preference. Both sources accept `block` and
`underline`; invalid values fall through to the saved preference and
then the default block cursor.
This commit is contained in:
Mason Daugherty
2026-07-13 10:23:37 -04:00
committed by GitHub
parent 2c6b147676
commit a22484ba2e
7 changed files with 237 additions and 3 deletions
+7
View File
@@ -44,6 +44,13 @@ rather than forcing the default). Also settable via `[ui].collapse_pastes` in
config.toml.
"""
CURSOR_STYLE = "DEEPAGENTS_CODE_CURSOR_STYLE"
"""Chat input cursor style (`block` or `underline`).
Takes precedence over `[ui].cursor_style` in config.toml. Invalid values fall
through to the config file and then the default block cursor.
"""
DEBUG = "DEEPAGENTS_CODE_DEBUG"
"""Enable verbose debug logging and preserve the server subprocess log.
+32
View File
@@ -358,6 +358,7 @@ if TYPE_CHECKING:
from deepagents_code.client.launch.server import ServerProcess
from deepagents_code.client.remote_client import RemoteAgent
from deepagents_code.config import ModelResult
from deepagents_code.config_manifest import CursorStyle
from deepagents_code.event_bus import EventSource, ExternalEvent
from deepagents_code.mcp_tools import MCPServerInfo
from deepagents_code.model_config import MissingProviderPackageError
@@ -863,6 +864,33 @@ def _load_cursor_blink_preference() -> bool:
return _load_bool_ui_preference("cursor_blink", log_label="cursor blink")
def _load_cursor_style_preference() -> CursorStyle:
"""Resolve the chat input cursor style.
Precedence follows `resolve_scalar`: the `DEEPAGENTS_CODE_CURSOR_STYLE` env
var wins, then `[ui].cursor_style` in `~/.deepagents/config.toml`, falling
back to `"block"` when unset or invalid.
Returns:
The resolved cursor style.
"""
from deepagents_code.config_manifest import (
CURSOR_STYLE_DEFAULT,
get_option,
load_config_toml,
resolve_scalar,
)
option = get_option("display.cursor_style")
if option is None:
logger.warning(
"Unknown config option %r; using block cursor", "display.cursor_style"
)
return CURSOR_STYLE_DEFAULT
value, _ = resolve_scalar(option, toml_data=load_config_toml())
return cast("CursorStyle", value)
def _load_terminal_progress_preference() -> bool:
"""Load the `OSC 9;4` progress preference from `~/.deepagents/config.toml`.
@@ -2172,6 +2200,9 @@ class DeepAgentsApp(App):
boots with consistent colors before `/theme` runs.
"""
self._cursor_style = _load_cursor_style_preference()
"""Visual style used for the chat input cursor."""
self._cursor_blink_enabled = _load_cursor_blink_preference()
"""Whether the chat input cursor should blink (user preference)."""
@@ -3055,6 +3086,7 @@ class DeepAgentsApp(App):
self._sync_status_connection()
self._sync_status_queued()
self._sync_status_model()
self._chat_input.set_cursor_style(style=self._cursor_style)
self._chat_input.set_cursor_blink(blink=self._cursor_blink_enabled)
# Apply any skill commands discovered before the widget was mounted
+42 -3
View File
@@ -35,7 +35,7 @@ import os
from dataclasses import dataclass
from enum import Enum
from functools import lru_cache
from typing import TYPE_CHECKING, Any, assert_never, cast
from typing import TYPE_CHECKING, Any, Literal, assert_never, cast, get_args
from deepagents_code import _env_vars
from deepagents_code._env_vars import classify_env_bool
@@ -47,8 +47,8 @@ logger = logging.getLogger(__name__)
# --- Canonical typed defaults ----------------------------------------------
# These are the single source of truth for `[interpreter]` defaults. The
# `Settings` dataclass references them so the default is defined once.
# These are single sources of truth for defaults shared across the manifest and
# their runtime consumers.
INTERPRETER_ENABLE_DEFAULT = True
INTERPRETER_TIMEOUT_SECONDS_DEFAULT = 5.0
@@ -64,6 +64,13 @@ LANGSMITH_PROJECT_DEFAULT = "deepagents-code"
Single source of truth shared by the `tracing.langsmith_project` option and
`config.get_langsmith_project_name`."""
CursorStyle = Literal["block", "underline"]
"""Visual style for the chat input cursor (a block cell or an underline)."""
CURSOR_STYLE_DEFAULT: CursorStyle = "block"
VALID_CURSOR_STYLES: frozenset[str] = frozenset(get_args(CursorStyle))
"""Allowlist derived from `CursorStyle` so the two never drift."""
class OptionKind(Enum):
"""How an option's raw env/TOML value is coerced to a typed value.
@@ -105,6 +112,9 @@ class OptionKind(Enum):
PTC_DELEGATE = "ptc"
"""Delegates to `config._parse_interpreter_ptc`."""
CURSOR_STYLE_DELEGATE = "cursor_style"
"""Validates the `[ui].cursor_style` display allowlist."""
STARTUP_MODE_DELEGATE = "startup_mode"
"""Delegates to the `[startup].mode` runtime allowlist."""
@@ -125,6 +135,7 @@ _KIND_TYPE_LABEL: dict[OptionKind, str] = {
OptionKind.SHELL_LIST_DELEGATE: "list[str]",
OptionKind.SKILLS_DIRS_DELEGATE: "list[path]",
OptionKind.PTC_DELEGATE: "str | list[str]",
OptionKind.CURSOR_STYLE_DELEGATE: "str",
OptionKind.STARTUP_MODE_DELEGATE: "str",
OptionKind.THEME_DELEGATE: "theme",
OptionKind.STRUCTURED: "table",
@@ -146,6 +157,7 @@ _KIND_DEFAULT_TYPES: dict[OptionKind, tuple[type, ...]] = {
OptionKind.INT: (int,),
OptionKind.FLOAT: (int, float),
OptionKind.STR: (str,),
OptionKind.CURSOR_STYLE_DELEGATE: (str,),
OptionKind.STARTUP_MODE_DELEGATE: (str,),
}
@@ -430,6 +442,15 @@ def _coerce_env(option: ConfigOption, raw: str, name: str) -> object:
# Resolved upstream in `resolve_scalar` and never reaches here; the raw
# passthrough is a defensive fallback only.
return raw
if kind is OptionKind.CURSOR_STYLE_DELEGATE:
if raw in VALID_CURSOR_STYLES:
return raw
logger.warning(
"Ignoring %s=%r (expected 'block' or 'underline')",
name,
raw,
)
return _INVALID
if kind is OptionKind.PTC_DELEGATE or kind is OptionKind.STRUCTURED:
# Neither kind declares an `env_var`, so the `if option.env_var` guard in
# `resolve_scalar` means this is unreachable today. If a future option
@@ -497,6 +518,15 @@ def _coerce_toml(option: ConfigOption, raw: object) -> object:
except ValueError as exc:
logger.warning("Ignoring %s in config.toml: %s", label, exc)
return _INVALID
elif kind is OptionKind.CURSOR_STYLE_DELEGATE:
if isinstance(raw, str) and raw in VALID_CURSOR_STYLES:
return raw
logger.warning(
"Ignoring %s=%r in config.toml (expected 'block' or 'underline')",
label,
raw,
)
return _INVALID
elif kind is OptionKind.STARTUP_MODE_DELEGATE:
from deepagents_code.model_config import VALID_STARTUP_MODES
@@ -827,6 +857,15 @@ _STATIC_OPTIONS: tuple[ConfigOption, ...] = (
env_var=_env_vars.THEME,
toml_keys=("ui", "theme"),
),
ConfigOption(
key="display.cursor_style",
group="Display",
summary="Chat input cursor style ('block' or 'underline').",
kind=OptionKind.CURSOR_STYLE_DELEGATE,
default=CURSOR_STYLE_DEFAULT,
env_var=_env_vars.CURSOR_STYLE,
toml_keys=("ui", "cursor_style"),
),
ConfigOption(
key="display.show_header",
group="Display",
@@ -143,6 +143,7 @@ if TYPE_CHECKING:
from textual.events import Click
from textual.timer import Timer
from deepagents_code.config_manifest import CursorStyle
from deepagents_code.input import MediaTracker, ParsedPastedPathPayload
@@ -1668,6 +1669,12 @@ class ChatInput(Vertical):
padding: 0;
}
ChatInput ChatTextArea.cursor-underline .text-area--cursor {
background: transparent;
color: $text;
text-style: underline;
}
ChatInput ChatTextArea:focus {
border: none;
}
@@ -2993,6 +3000,15 @@ class ChatInput(Vertical):
if self._text_area:
self._text_area.set_app_focus(has_focus=active)
def set_cursor_style(self, *, style: CursorStyle) -> None:
"""Set the input cursor's visual style.
Args:
style: Whether to render a block or underlined character cell.
"""
if self._text_area is not None:
self._text_area.set_class(style == "underline", "cursor-underline")
def set_cursor_blink(self, *, blink: bool) -> None:
"""Toggle the input's cursor blink without changing focus.
@@ -21,6 +21,7 @@ from deepagents_code.client.commands.config import (
run_config_command,
)
from deepagents_code.config_manifest import (
CURSOR_STYLE_DEFAULT,
NON_OPTION_ENV_VARS,
ConfigOption,
OptionKind,
@@ -913,6 +914,64 @@ def test_resolve_bool_env_uses_truthy_semantics(monkeypatch) -> None:
assert resolve_scalar(opt, toml_data={})[0] is False
def test_cursor_style_option_definition() -> None:
"""Cursor style supports env and config.toml and defaults to a block."""
opt = get_option("display.cursor_style")
assert opt is not None
assert opt.kind is OptionKind.CURSOR_STYLE_DELEGATE
assert opt.default == CURSOR_STYLE_DEFAULT
assert opt.toml_keys == ("ui", "cursor_style")
assert opt.env_var == _env_vars.CURSOR_STYLE
def test_resolve_cursor_style_from_env(monkeypatch, caplog) -> None:
"""A valid env value wins; an invalid value falls through to config.toml."""
import logging
opt = get_option("display.cursor_style")
assert opt is not None
toml_data = {"ui": {"cursor_style": "underline"}}
monkeypatch.setenv(_env_vars.CURSOR_STYLE, "block")
assert resolve_scalar(opt, toml_data=toml_data) == (
"block",
f"env ({_env_vars.CURSOR_STYLE})",
)
monkeypatch.setenv(_env_vars.CURSOR_STYLE, "bar")
with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"):
assert resolve_scalar(opt, toml_data=toml_data) == (
"underline",
"config.toml",
)
assert any(
_env_vars.CURSOR_STYLE in record.getMessage() for record in caplog.records
)
def test_resolve_cursor_style_from_toml(caplog) -> None:
"""Only supported cursor styles are accepted from config.toml."""
import logging
opt = get_option("display.cursor_style")
assert opt is not None
assert resolve_scalar(opt, toml_data={"ui": {"cursor_style": "underline"}}) == (
"underline",
"config.toml",
)
for raw in ("bar", 1):
caplog.clear()
with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"):
value, source = resolve_scalar(opt, toml_data={"ui": {"cursor_style": raw}})
assert (value, source) == (CURSOR_STYLE_DEFAULT, "default")
assert any(
"[ui].cursor_style" in record.getMessage() for record in caplog.records
)
assert resolve_scalar(opt, toml_data={}) == (CURSOR_STYLE_DEFAULT, "default")
def test_collapse_pastes_default_enabled() -> None:
"""Paste collapsing is enabled by default when unset."""
opt = get_option("display.collapse_pastes")
@@ -0,0 +1,56 @@
"""Tests for chat input cursor-style preference loading."""
from __future__ import annotations
from typing import TYPE_CHECKING
from deepagents_code._env_vars import CURSOR_STYLE
from deepagents_code.app import DeepAgentsApp, _load_cursor_style_preference
if TYPE_CHECKING:
from pathlib import Path
import pytest
def test_cursor_style_defaults_to_block(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An absent config preserves the existing block cursor."""
monkeypatch.delenv(CURSOR_STYLE, raising=False)
monkeypatch.setattr(
"deepagents_code.model_config.DEFAULT_CONFIG_PATH",
tmp_path / "config.toml",
)
assert _load_cursor_style_preference() == "block"
def test_cursor_style_loads_underline(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The underline preference is read from the `[ui]` table."""
monkeypatch.delenv(CURSOR_STYLE, raising=False)
config = tmp_path / "config.toml"
config.write_text('[ui]\ncursor_style = "underline"\n', encoding="utf-8")
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
assert _load_cursor_style_preference() == "underline"
async def test_app_applies_underline_cursor_style(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The startup preference reaches the mounted chat text area."""
monkeypatch.delenv(CURSOR_STYLE, raising=False)
config = tmp_path / "config.toml"
config.write_text('[ui]\ncursor_style = "underline"\n', encoding="utf-8")
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_CONFIG_PATH", config)
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
assert app._chat_input is not None
assert app._chat_input._text_area is not None
assert app._chat_input._text_area.has_class("cursor-underline")
@@ -4233,6 +4233,31 @@ class TestScrollCursorVisibleDesync:
assert result == Offset(0, 0)
class TestSetCursorStyle:
"""`ChatInput.set_cursor_style` updates the rendered cursor component."""
async def test_switches_between_underline_and_block(self) -> None:
"""Underline adds its cursor class and block restores Textual's default."""
app = _ChatInputTestApp()
async with app.run_test() as pilot:
chat = app.query_one(ChatInput)
assert chat._text_area is not None
chat.set_cursor_style(style="underline")
await pilot.pause()
assert chat._text_area.has_class("cursor-underline")
underline = chat._text_area.get_component_rich_style("text-area--cursor")
assert underline.underline is True
chat.set_cursor_style(style="block")
await pilot.pause()
assert not chat._text_area.has_class("cursor-underline")
block = chat._text_area.get_component_rich_style("text-area--cursor")
assert block.underline is not True
class TestSetCursorBlink:
"""`ChatInput.set_cursor_blink` toggles cursor blink without changing focus."""