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:
Mason Daugherty
2026-08-21 14:35:43 -04:00
committed by GitHub
parent d5005d3cc9
commit d711bc6130
2 changed files with 342 additions and 0 deletions
@@ -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.