mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-27 10:51:26 -04:00
feat(code): highlight shell commands in the chat input (#5675)
Shell command modes now highlight Bash syntax as users type, including incognito mode. --- Inspired by Toad’s shell-mode UX, this is independently implemented with Textual’s public highlighter API. Made by [Open SWE](https://openswe.vercel.app/agents/e838fab2-9289-5b71-92ca-85585bcbbe0b) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -5,18 +5,22 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, assert_never
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
from textual.app import NoScreen
|
||||
from textual.color import Color
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.content import Content
|
||||
from textual.css.query import NoMatches
|
||||
from textual.geometry import Offset, Size
|
||||
from textual.highlight import highlight
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.strip import Strip
|
||||
@@ -606,6 +610,128 @@ class ChatTextArea(PasteBurstTextArea):
|
||||
# `_REFOCUS_CLICK_SUPPRESS_WINDOW_SECONDS`.
|
||||
self._app_blurred = False
|
||||
self._refocus_time: float | None = None
|
||||
self._shell_highlighting = False
|
||||
self._highlighted_source = ""
|
||||
self._highlighted_lines: list[Content] | None = None
|
||||
|
||||
def set_shell_highlighting(self, *, enabled: bool) -> None:
|
||||
"""Enable or disable shell syntax highlighting for this text area.
|
||||
|
||||
Args:
|
||||
enabled: Whether to style input as shell syntax.
|
||||
"""
|
||||
if self._shell_highlighting == enabled:
|
||||
return
|
||||
self._shell_highlighting = enabled
|
||||
self._highlighted_source = ""
|
||||
self._highlighted_lines = None
|
||||
self._line_cache.clear()
|
||||
self.refresh()
|
||||
|
||||
def _render_line(self, y: int) -> Strip:
|
||||
"""Render a line, keeping shell token colors visible on the cursor line.
|
||||
|
||||
`TextArea._render_line` stylizes the whole cursor line with
|
||||
`theme.cursor_line_style`, which under the default `css` theme resolves
|
||||
to a style carrying the widget's text color. That foreground is painted
|
||||
after (and on top of) the shell syntax spans produced by `get_line`,
|
||||
flattening every token on the cursor line to a single color. Clear just
|
||||
the foreground while shell highlighting is active so the cursor-line
|
||||
background tint - and any text styles such as bold - still apply over
|
||||
the token colors.
|
||||
|
||||
Mutating the theme in place is safe because `TextArea._set_theme` keeps
|
||||
a per-widget copy (`dataclasses.replace`), so this never touches the
|
||||
shared builtin theme or another `TextArea`. Rendering is synchronous
|
||||
and `apply_css` runs outside this window, so the `finally` restore is
|
||||
sufficient.
|
||||
|
||||
Args:
|
||||
y: Y coordinate of the line relative to the widget region.
|
||||
|
||||
Returns:
|
||||
The rendered line.
|
||||
"""
|
||||
theme = self._theme
|
||||
cursor_line_style = theme.cursor_line_style if theme else None
|
||||
if (
|
||||
not self._shell_highlighting
|
||||
or cursor_line_style is None
|
||||
or cursor_line_style.color is None
|
||||
):
|
||||
return super()._render_line(y)
|
||||
|
||||
# Restore on the exception path too; otherwise the widget keeps a
|
||||
# foreground-less cursor line for the rest of the session.
|
||||
theme.cursor_line_style = cursor_line_style.without_color + Style(
|
||||
bgcolor=cursor_line_style.bgcolor
|
||||
)
|
||||
try:
|
||||
return super()._render_line(y)
|
||||
finally:
|
||||
theme.cursor_line_style = cursor_line_style
|
||||
|
||||
def get_line(self, line_index: int) -> Text:
|
||||
"""Return one input line, with shell syntax styles when enabled.
|
||||
|
||||
Args:
|
||||
line_index: Index of the line to return.
|
||||
|
||||
Returns:
|
||||
The line as Rich text, styled per shell token when highlighting is
|
||||
active. Falls back to the unstyled base implementation if
|
||||
highlighting fails, so the text shown always matches the document.
|
||||
"""
|
||||
if not self._shell_highlighting:
|
||||
return super().get_line(line_index)
|
||||
|
||||
source = self.text
|
||||
lines = self._highlighted_lines
|
||||
if lines is None or source != self._highlighted_source:
|
||||
language = "batch" if sys.platform == "win32" else "sh"
|
||||
try:
|
||||
# `tab_size=1` keeps span offsets aligned with the document's
|
||||
# raw character offsets: Pygments expands tabs (default 8),
|
||||
# while `_render_line` does its own tab expansion downstream.
|
||||
highlighted = highlight(source, language=language, tab_size=1)
|
||||
lines = list(highlighted.split("\n", allow_blank=True))
|
||||
except Exception:
|
||||
# This runs inside the render loop, where an uncaught exception
|
||||
# tears down the whole app. Degrade to unhighlighted text and
|
||||
# stop retrying every frame.
|
||||
logger.exception(
|
||||
"Shell highlighting failed for a %d-character draft; "
|
||||
"falling back to unhighlighted text",
|
||||
len(source),
|
||||
)
|
||||
self._shell_highlighting = False
|
||||
self._highlighted_source = ""
|
||||
self._highlighted_lines = None
|
||||
return super().get_line(line_index)
|
||||
# `highlight()` normalizes via `"\n".join(code.splitlines())`, which
|
||||
# drops the trailing empty line that `Document` keeps. Pad so line
|
||||
# indices stay in step with the document.
|
||||
lines.extend([Content("")] * max(0, self.document.line_count - len(lines)))
|
||||
# Only commit the cache marker once both fallible steps succeeded;
|
||||
# advancing it earlier would serve the previous draft's lines
|
||||
# forever on the cache-hit path.
|
||||
self._highlighted_source = source
|
||||
self._highlighted_lines = lines
|
||||
|
||||
if not 0 <= line_index < len(lines):
|
||||
logger.warning(
|
||||
"Shell highlight covers %d lines, not line %d (document has "
|
||||
"%d); rendering it unhighlighted",
|
||||
len(lines),
|
||||
line_index,
|
||||
self.document.line_count,
|
||||
)
|
||||
return super().get_line(line_index)
|
||||
|
||||
line = Text(end="", no_wrap=True)
|
||||
for segment in lines[line_index].render_segments(self.visual_style):
|
||||
line.append(segment.text, segment.style)
|
||||
return line
|
||||
|
||||
def render_line(self, y: int) -> Strip:
|
||||
"""Render a single line, appending any argument hint at line end.
|
||||
@@ -2234,6 +2360,9 @@ class ChatInput(Vertical):
|
||||
self._text_area = self.query_one("#chat-input", ChatTextArea)
|
||||
self._popup = self.query_one("#completion-popup", CompletionPopup)
|
||||
self._text_area._chat_input_owner = self
|
||||
self._text_area.set_shell_highlighting(
|
||||
enabled=self.mode in {"shell", "shell_incognito"}
|
||||
)
|
||||
|
||||
# Both controllers implement the CompletionController protocol but have
|
||||
# different concrete types; the list-item warning is a false positive.
|
||||
@@ -3390,6 +3519,10 @@ class ChatInput(Vertical):
|
||||
# Keep inline argument hints in sync for mode-only transitions
|
||||
# (for example, exiting command mode via Escape or backspace).
|
||||
self._update_argument_hint()
|
||||
if self._text_area is not None:
|
||||
self._text_area.set_shell_highlighting(
|
||||
enabled=mode in {"shell", "shell_incognito"}
|
||||
)
|
||||
|
||||
glyph = MODE_DISPLAY_GLYPHS.get(mode)
|
||||
if not glyph and mode != "normal":
|
||||
|
||||
@@ -1549,6 +1549,215 @@ class TestPromptIndicator:
|
||||
assert any(m.mode == "shell" for m in messages)
|
||||
|
||||
|
||||
class TestShellSyntaxHighlighting:
|
||||
"""Shell command modes should render native shell styles in the chat input."""
|
||||
|
||||
@pytest.mark.parametrize("mode", ["shell", "shell_incognito"])
|
||||
async def test_shell_modes_highlight_command(self, mode: str) -> None:
|
||||
"""Shell and incognito shell modes should style command tokens."""
|
||||
app = _ChatInputTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
command = 'FOO="bar" echo "$FOO"'
|
||||
text_area.text = command
|
||||
|
||||
chat_input.mode = mode
|
||||
await pilot.pause()
|
||||
|
||||
line = text_area.get_line(0)
|
||||
assert line.plain == command
|
||||
assert len({span.style for span in line.spans}) > 1
|
||||
|
||||
async def test_windows_shell_mode_uses_batch_lexer(self) -> None:
|
||||
"""Windows shell commands should use `cmd.exe` batch syntax styles."""
|
||||
from unittest.mock import patch
|
||||
|
||||
app = _ChatInputTestApp()
|
||||
with (
|
||||
patch.object(chat_input_module, "sys") as mock_sys,
|
||||
patch.object(
|
||||
chat_input_module,
|
||||
"highlight",
|
||||
wraps=chat_input_module.highlight,
|
||||
) as mock_highlight,
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
command = "if exist %TEMP% echo %PATH%"
|
||||
text_area.text = command
|
||||
|
||||
chat_input.mode = "shell"
|
||||
await pilot.pause()
|
||||
|
||||
assert text_area.get_line(0).plain == command
|
||||
mock_highlight.assert_called_once_with(
|
||||
command,
|
||||
language="batch",
|
||||
tab_size=1,
|
||||
)
|
||||
|
||||
async def test_posix_shell_mode_uses_bash_lexer(self) -> None:
|
||||
"""Non-Windows shell commands should use Bash syntax styles.
|
||||
|
||||
Asserts a Bash-specific outcome rather than only the lexer name: Bash
|
||||
expands `$FOO` inside a double-quoted string, so the expansion carries
|
||||
a different style from the quotes around it. A non-shell grammar (or a
|
||||
shell one applied at the wrong offsets) styles the whole string
|
||||
uniformly and fails here.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
app = _ChatInputTestApp()
|
||||
with patch.object(chat_input_module.sys, "platform", "linux"):
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
command = 'FOO="bar" echo "$FOO"'
|
||||
text_area.text = command
|
||||
|
||||
chat_input.mode = "shell"
|
||||
await pilot.pause()
|
||||
|
||||
line = text_area.get_line(0)
|
||||
assert line.plain == command
|
||||
style_by_start = {span.start: span.style for span in line.spans}
|
||||
quote_start = command.index('"$FOO"')
|
||||
variable_start = command.index("$FOO")
|
||||
command_start = command.index("echo")
|
||||
# `$FOO` is styled apart from its enclosing quotes.
|
||||
assert style_by_start[variable_start] != style_by_start[quote_start]
|
||||
# `echo` is a command word, not plain text like the quotes.
|
||||
assert style_by_start[command_start] != style_by_start[quote_start]
|
||||
|
||||
async def test_highlight_failure_never_shows_stale_text(self) -> None:
|
||||
"""A failed highlight must fall back to the document, not a stale draft.
|
||||
|
||||
The rendered text has to match the buffer that Enter would submit. If
|
||||
the cache marker were committed before `highlight()` ran, a failure
|
||||
would leave the marker on the new text and the cached lines on the old,
|
||||
so every later call would take the cache-hit path and render the
|
||||
previous draft indefinitely.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
app = _ChatInputTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.text = "echo first"
|
||||
chat_input.mode = "shell"
|
||||
await pilot.pause()
|
||||
assert text_area.get_line(0).plain == "echo first"
|
||||
|
||||
text_area.text = "echo second"
|
||||
with patch.object(
|
||||
chat_input_module,
|
||||
"highlight",
|
||||
side_effect=RuntimeError("lexer exploded"),
|
||||
):
|
||||
line = text_area.get_line(0)
|
||||
|
||||
assert line.plain == "echo second"
|
||||
# Degradation persists rather than re-raising every frame, and the
|
||||
# text stays correct once the patch is lifted.
|
||||
assert text_area.get_line(0).plain == "echo second"
|
||||
|
||||
async def test_leaving_shell_mode_removes_highlighting(self) -> None:
|
||||
"""Returning to normal input should clear cached shell styles."""
|
||||
app = _ChatInputTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.text = 'echo "$HOME"'
|
||||
|
||||
chat_input.mode = "shell"
|
||||
await pilot.pause()
|
||||
assert text_area.get_line(0).spans
|
||||
|
||||
chat_input.mode = "normal"
|
||||
await pilot.pause()
|
||||
line = text_area.get_line(0)
|
||||
assert line.plain == 'echo "$HOME"'
|
||||
assert not line.spans
|
||||
|
||||
async def test_shell_highlighting_tracks_multiline_edits(self) -> None:
|
||||
"""Editing a shell draft should invalidate all cached highlighted lines."""
|
||||
app = _ChatInputTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
chat_input.mode = "shell"
|
||||
text_area.text = 'echo "first"'
|
||||
await pilot.pause()
|
||||
assert text_area.get_line(0).plain == 'echo "first"'
|
||||
|
||||
text_area.text = 'FOO="bar"\nprintf "%s" "$FOO"'
|
||||
await pilot.pause()
|
||||
|
||||
assert text_area.get_line(0).plain == 'FOO="bar"'
|
||||
second_line = text_area.get_line(1)
|
||||
assert second_line.plain == 'printf "%s" "$FOO"'
|
||||
assert second_line.spans
|
||||
|
||||
async def test_tab_keeps_shell_highlight_spans_aligned(self) -> None:
|
||||
"""Tabs should not shift the styles applied to later shell tokens."""
|
||||
app = _ChatInputTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
command = 'echo\t"$HOME"'
|
||||
text_area.text = command
|
||||
chat_input.mode = "shell"
|
||||
await pilot.pause()
|
||||
|
||||
line = text_area.get_line(0)
|
||||
variable_start = command.index("$HOME")
|
||||
assert line.plain == command
|
||||
assert any(
|
||||
span.start == variable_start
|
||||
and span.end == variable_start + len("$HOME")
|
||||
for span in line.spans
|
||||
)
|
||||
|
||||
async def test_cursor_line_keeps_shell_highlight_colors(self) -> None:
|
||||
"""Rendered strip on the cursor line should keep token colors.
|
||||
|
||||
Regression test: `TextArea._render_line` stylizes the whole cursor line
|
||||
with `cursor_line_style`, which carries the widget text color. Without
|
||||
the foreground strip in `ChatTextArea._render_line`, that paints over
|
||||
the syntax spans and every rendered token collapses to one color. The
|
||||
other tests in this class only assert on `get_line()`, which runs
|
||||
before the cursor-line style is applied.
|
||||
"""
|
||||
app = _ChatInputTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.text = 'FOO="bar" echo "$FOO"'
|
||||
chat_input.mode = "shell"
|
||||
await pilot.pause()
|
||||
|
||||
# Put the cursor on the line being rendered, but at end-of-line.
|
||||
# The block cursor inverts the cell it sits on, which contributes a
|
||||
# second color on its own - enough to satisfy the assertion below
|
||||
# even with the token colors flattened. Parking it past the last
|
||||
# character puts it on trailing padding, which the `.strip()`
|
||||
# filter drops, so only real token colors are counted.
|
||||
text_area.move_cursor((0, len(text_area.text)))
|
||||
strip = text_area.render_line(0)
|
||||
colors = {
|
||||
segment.style.color.triplet
|
||||
for segment in strip
|
||||
if segment.text.strip() and segment.style and segment.style.color
|
||||
}
|
||||
# Distinct syntax colors must survive to the rendered strip, not
|
||||
# flatten to the single cursor-line text color.
|
||||
assert len(colors) > 1
|
||||
|
||||
|
||||
class TestModeSwitchNoJitter:
|
||||
"""Regression tests: mode glyph and completion popup update atomically.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user