mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user