mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
fix(code): keep footer branch visible and ellipsized instead of hiding when narrow (#4506)
The status-bar footer now keeps the git branch visible at any terminal width, truncating it with a trailing ellipsis (`…`) instead of hard-clipping it or hiding it outright. --- Previously, in `libs/code`: - The branch rendered in an auto-width widget that overflowed its collapsible container and was hard-clipped with no visual cue. - `on_resize` hid the branch entirely below 100 cols (70 with `HIDE_CWD`), so on a narrow-ish terminal the branch name simply disappeared. Now: - `.status-branch` uses a flexible width with `text-overflow: ellipsis`, so a too-long branch truncates with a trailing `…` while short names still render in full. - The width-based hide is removed: the branch stays visible and ellipsizes down as the terminal narrows. The `HIDE_GIT_BRANCH` env override still hides it entirely, and the cwd still hides below 70 cols to reclaim space (model > cwd priority is unchanged). Made by [Open SWE](https://openswe.vercel.app/agents/c0006e5f-a4dc-52aa-2fbf-f7979af72ffb) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -130,6 +130,54 @@ class ModelLabel(Widget):
|
||||
return Content("\u2026")
|
||||
|
||||
|
||||
class BranchLabel(Widget):
|
||||
"""A label that displays the git branch with glyph-aware truncation.
|
||||
|
||||
Unlike CSS `text-overflow: ellipsis` (which always uses the Unicode
|
||||
ellipsis character), this widget truncates manually in :meth:`render` using
|
||||
:func:`get_glyphs` so ASCII mode (`DEEPAGENTS_CODE_UI_CHARSET_MODE=ascii`)
|
||||
gets `"..."` instead of `"…"`.
|
||||
"""
|
||||
|
||||
branch: reactive[str] = reactive("", layout=True)
|
||||
|
||||
def get_content_width(self, container: Size, viewport: Size) -> int: # noqa: ARG002
|
||||
"""Return the intrinsic width so the widget participates in flex layout.
|
||||
|
||||
Args:
|
||||
container: Size of the container.
|
||||
viewport: Size of the viewport.
|
||||
|
||||
Returns:
|
||||
Character length of the full branch string (icon + space + name),
|
||||
or `0` when the branch is empty.
|
||||
"""
|
||||
if not self.branch:
|
||||
return 0
|
||||
icon = get_glyphs().git_branch
|
||||
return len(icon) + 1 + len(self.branch)
|
||||
|
||||
def render(self) -> RenderResult:
|
||||
"""Render the branch label, truncating with the configured glyph.
|
||||
|
||||
Returns:
|
||||
Branch text (icon + name) truncated from the right with
|
||||
:func:`get_glyphs`'s ellipsis when it overflows the available
|
||||
width, or an empty string when no branch is set.
|
||||
"""
|
||||
width = self.content_size.width
|
||||
if not self.branch or width <= 0:
|
||||
return ""
|
||||
icon = get_glyphs().git_branch
|
||||
full = f"{icon} {self.branch}"
|
||||
if len(full) <= width:
|
||||
return full
|
||||
ellipsis = get_glyphs().ellipsis
|
||||
if width <= len(ellipsis):
|
||||
return full[:width]
|
||||
return full[: width - len(ellipsis)] + ellipsis
|
||||
|
||||
|
||||
class StatusBar(Horizontal):
|
||||
"""Status bar showing mode, auto-approve, cwd, git branch, tokens, and model."""
|
||||
|
||||
@@ -202,12 +250,16 @@ class StatusBar(Horizontal):
|
||||
width: auto;
|
||||
text-align: right;
|
||||
color: $text-muted;
|
||||
/* Right padding only, so the cwd/branch gap collapses with the cwd
|
||||
when it hides (a left pad on the branch would ghost in its place). */
|
||||
padding: 0 1 0 0;
|
||||
}
|
||||
|
||||
StatusBar .status-branch {
|
||||
width: auto;
|
||||
color: $text-muted;
|
||||
padding: 0 1;
|
||||
width: 1fr;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
StatusBar .status-left-collapsible {
|
||||
@@ -236,6 +288,13 @@ class StatusBar(Horizontal):
|
||||
color: $text-muted;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
StatusBar BranchLabel {
|
||||
color: $text-muted;
|
||||
/* No left pad: the separating gap is owned by the cwd's right pad (or
|
||||
the message's) so nothing lingers where the cwd was once it hides. */
|
||||
padding: 0 1 0 0;
|
||||
}
|
||||
"""
|
||||
"""Mode badges and auto-approve pills use distinct colors for at-a-glance status."""
|
||||
|
||||
@@ -282,31 +341,22 @@ class StatusBar(Horizontal):
|
||||
yield Static("", classes="status-connection", id="connection-indicator")
|
||||
yield Static("", classes="status-message", id="status-message")
|
||||
yield Static("", classes="status-cwd", id="cwd-display")
|
||||
yield Static("", classes="status-branch", id="branch-display")
|
||||
yield BranchLabel(classes="status-branch", id="branch-display")
|
||||
yield Static("", classes="status-rubric", id="rubric-display")
|
||||
yield Static("", classes="status-tokens", id="tokens-display")
|
||||
yield ModelLabel(id="model-display")
|
||||
|
||||
_BRANCH_WIDTH_THRESHOLD = 100
|
||||
"""Hide git branch display below this terminal width."""
|
||||
_CWD_WIDTH_THRESHOLD = 70
|
||||
"""Hide cwd display below this terminal width."""
|
||||
|
||||
def on_resize(self, event: events.Resize) -> None:
|
||||
"""Manage visibility of status items based on terminal width.
|
||||
"""Hide the cwd on very narrow terminals.
|
||||
|
||||
Priority (highest first): model, cwd, git branch.
|
||||
The git branch stays visible at any width (unless disabled via
|
||||
`HIDE_GIT_BRANCH`) and ellipsizes to fit; only the cwd is dropped
|
||||
outright to reclaim space when the terminal gets narrow.
|
||||
"""
|
||||
width = event.size.width
|
||||
branch_threshold = (
|
||||
self._CWD_WIDTH_THRESHOLD
|
||||
if self._hide_cwd
|
||||
else self._BRANCH_WIDTH_THRESHOLD
|
||||
)
|
||||
with suppress(NoMatches):
|
||||
self.query_one("#branch-display", Static).display = (
|
||||
not self._hide_git_branch and width >= branch_threshold
|
||||
)
|
||||
with suppress(NoMatches):
|
||||
self.query_one("#cwd-display", Static).display = (
|
||||
not self._hide_cwd and width >= self._CWD_WIDTH_THRESHOLD
|
||||
@@ -326,7 +376,7 @@ class StatusBar(Horizontal):
|
||||
self.query_one("#cwd-display", Static).display = False
|
||||
if self._hide_git_branch:
|
||||
with suppress(NoMatches):
|
||||
self.query_one("#branch-display", Static).display = False
|
||||
self.query_one("#branch-display", BranchLabel).display = False
|
||||
# Set initial model display
|
||||
label = self.query_one("#model-display", ModelLabel)
|
||||
label.provider = settings.model_provider or ""
|
||||
@@ -336,6 +386,11 @@ class StatusBar(Horizontal):
|
||||
# Reactives are `init=False`, so the connection watcher never fires on
|
||||
# mount; render once to hide the empty indicator (and its padding).
|
||||
self._render_connection()
|
||||
# Same reasoning for the message and token slots: both start empty, so
|
||||
# hide them on mount so their padding doesn't reserve a blank gap.
|
||||
self.watch_status_message(self.status_message)
|
||||
with suppress(NoMatches):
|
||||
self.query_one("#tokens-display", Static).display = False
|
||||
|
||||
def watch_mode(self, mode: str) -> None:
|
||||
"""Update mode indicator when mode changes."""
|
||||
@@ -384,11 +439,10 @@ class StatusBar(Horizontal):
|
||||
def watch_branch(self, new_value: str) -> None:
|
||||
"""Update branch display when it changes."""
|
||||
try:
|
||||
display = self.query_one("#branch-display", Static)
|
||||
display = self.query_one("#branch-display", BranchLabel)
|
||||
except NoMatches:
|
||||
return
|
||||
icon = get_glyphs().git_branch
|
||||
display.update(f"{icon} {new_value}" if new_value else "")
|
||||
display.branch = new_value
|
||||
|
||||
def watch_status_message(self, new_value: str) -> None:
|
||||
"""Update status message display."""
|
||||
@@ -402,6 +456,9 @@ class StatusBar(Horizontal):
|
||||
return
|
||||
|
||||
msg_widget.remove_class("thinking")
|
||||
# Hide when empty so the widget's padding doesn't reserve a blank gap
|
||||
# in the footer (mirrors the connection indicator).
|
||||
msg_widget.display = bool(new_value)
|
||||
if new_value:
|
||||
msg_widget.update(new_value)
|
||||
if "thinking" in new_value.lower() or "executing" in new_value.lower():
|
||||
@@ -491,6 +548,7 @@ class StatusBar(Horizontal):
|
||||
except NoMatches:
|
||||
return
|
||||
widget.remove_class("thinking")
|
||||
widget.display = True
|
||||
frame = self._spinner.current_frame()
|
||||
widget.update(Content.assemble(frame, " ", Content(self._busy_message)))
|
||||
|
||||
@@ -610,6 +668,8 @@ class StatusBar(Horizontal):
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
# Hide when empty so the widget's padding doesn't reserve a blank gap.
|
||||
display.display = count > 0
|
||||
if count > 0:
|
||||
suffix = "+" if approximate else ""
|
||||
# Format with K suffix for thousands
|
||||
|
||||
@@ -9,7 +9,7 @@ from textual.geometry import Size
|
||||
from textual.widgets import Static
|
||||
|
||||
from deepagents_code._env_vars import HIDE_CWD, HIDE_GIT_BRANCH
|
||||
from deepagents_code.widgets.status import ModelLabel, StatusBar
|
||||
from deepagents_code.widgets.status import BranchLabel, ModelLabel, StatusBar
|
||||
|
||||
|
||||
class StatusBarApp(App):
|
||||
@@ -106,6 +106,99 @@ class TestBranchDisplay:
|
||||
display = pilot.app.query_one("#branch-display")
|
||||
assert display.render() == ""
|
||||
|
||||
@staticmethod
|
||||
def _visible_branch_text(display: BranchLabel) -> str:
|
||||
"""Return the branch text as actually rendered to the terminal line."""
|
||||
from rich.segment import Segment
|
||||
|
||||
return "".join(
|
||||
seg.text for seg in display.render_line(0) if isinstance(seg, Segment)
|
||||
)
|
||||
|
||||
async def test_long_branch_name_truncates_with_ellipsis(self) -> None:
|
||||
"""A branch too long for the footer should render a trailing ellipsis.
|
||||
|
||||
The branch widget's width is pinned directly (rather than relying on
|
||||
whole-status-bar layout arithmetic) so truncation is deterministic and
|
||||
independent of the other status items' sizes.
|
||||
"""
|
||||
long_branch = "feature/some-really-long-descriptive-branch-name-here"
|
||||
async with StatusBarApp().run_test(size=(110, 24)) as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.branch = long_branch
|
||||
display = pilot.app.query_one("#branch-display", BranchLabel)
|
||||
# Force a box far narrower than the branch text so overflow applies.
|
||||
display.styles.width = 20
|
||||
await pilot.pause()
|
||||
visible = self._visible_branch_text(display)
|
||||
# A *trailing* ellipsis (glyph-aware truncation), not a leading
|
||||
# one, with the head of the name preserved.
|
||||
assert visible.rstrip().endswith("\u2026")
|
||||
assert "feature/" in visible
|
||||
|
||||
async def test_long_branch_truncates_with_ellipsis_when_cwd_hidden(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Truncation still applies in the cwd-hidden layout.
|
||||
|
||||
With `HIDE_CWD` set, the branch is shown at a lower width threshold
|
||||
and fills the collapsible region alone; a too-long name must still
|
||||
ellipsize rather than hard-clip.
|
||||
"""
|
||||
monkeypatch.setenv(HIDE_CWD, "1")
|
||||
long_branch = "feature/some-really-long-descriptive-branch-name-here"
|
||||
async with StatusBarApp().run_test(size=(90, 24)) as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.branch = long_branch
|
||||
display = pilot.app.query_one("#branch-display", BranchLabel)
|
||||
display.styles.width = 20
|
||||
await pilot.pause()
|
||||
assert display.display is True
|
||||
visible = self._visible_branch_text(display)
|
||||
assert visible.rstrip().endswith("\u2026")
|
||||
assert "feature/" in visible
|
||||
|
||||
async def test_short_branch_name_not_truncated(self) -> None:
|
||||
"""A branch that fits should render in full with no ellipsis."""
|
||||
async with StatusBarApp().run_test(size=(150, 24)) as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.branch = "main"
|
||||
await pilot.pause()
|
||||
display = pilot.app.query_one("#branch-display", BranchLabel)
|
||||
visible = self._visible_branch_text(display)
|
||||
assert "\u2026" not in visible
|
||||
assert "main" in visible
|
||||
|
||||
async def test_long_branch_truncates_with_ascii_ellipsis(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""In ASCII charset mode, truncation uses `"..."` not `"…"`.
|
||||
|
||||
CSS `text-overflow: ellipsis` always emits the Unicode ellipsis
|
||||
character; `BranchLabel` truncates manually via `get_glyphs` so
|
||||
the configured glyph (ASCII `"..."` in ascii mode) is used instead.
|
||||
"""
|
||||
from deepagents_code.config import reset_glyphs_cache
|
||||
|
||||
monkeypatch.setenv("UI_CHARSET_MODE", "ascii")
|
||||
reset_glyphs_cache()
|
||||
long_branch = "feature/some-really-long-descriptive-branch-name-here"
|
||||
try:
|
||||
async with StatusBarApp().run_test(size=(110, 24)) as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.branch = long_branch
|
||||
display = pilot.app.query_one("#branch-display", BranchLabel)
|
||||
display.styles.width = 20
|
||||
await pilot.pause()
|
||||
visible = self._visible_branch_text(display)
|
||||
# ASCII ellipsis is three dots, not the Unicode character.
|
||||
assert visible.rstrip().endswith("...")
|
||||
assert "\u2026" not in visible
|
||||
assert "feature/" in visible
|
||||
finally:
|
||||
monkeypatch.delenv("UI_CHARSET_MODE", raising=False)
|
||||
reset_glyphs_cache()
|
||||
|
||||
async def test_branch_display_contains_git_icon(self) -> None:
|
||||
"""Branch display should include the git branch glyph prefix."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
@@ -120,19 +213,28 @@ class TestBranchDisplay:
|
||||
|
||||
|
||||
class TestResizePriority:
|
||||
"""Branch hides before cwd, cwd hides before model."""
|
||||
"""The cwd hides on narrow terminals; the branch truncates but never hides."""
|
||||
|
||||
async def test_branch_hidden_on_narrow_terminal(self) -> None:
|
||||
"""Branch display should be hidden when terminal width < 100."""
|
||||
async def test_branch_stays_visible_on_narrow_terminal(self) -> None:
|
||||
"""The branch truncates rather than hiding on a narrow terminal."""
|
||||
async with StatusBarApp().run_test(size=(80, 24)) as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.branch = "main"
|
||||
await pilot.pause()
|
||||
branch = pilot.app.query_one("#branch-display")
|
||||
assert branch.display is False
|
||||
assert branch.display is True
|
||||
|
||||
async def test_branch_stays_visible_below_cwd_threshold(self) -> None:
|
||||
"""Even below the cwd hide threshold the branch stays visible."""
|
||||
async with StatusBarApp().run_test(size=(50, 24)) as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.branch = "main"
|
||||
await pilot.pause()
|
||||
branch = pilot.app.query_one("#branch-display")
|
||||
assert branch.display is True
|
||||
|
||||
async def test_branch_visible_on_wide_terminal(self) -> None:
|
||||
"""Branch display should be visible when terminal width >= 100."""
|
||||
"""Branch display should be visible on a wide terminal."""
|
||||
async with StatusBarApp().run_test(size=(120, 24)) as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.branch = "main"
|
||||
@@ -146,8 +248,8 @@ class TestResizePriority:
|
||||
cwd = pilot.app.query_one("#cwd-display")
|
||||
assert cwd.display is False
|
||||
|
||||
async def test_cwd_visible_branch_hidden_at_medium_width(self) -> None:
|
||||
"""Between 70-99 cols: cwd visible, branch hidden."""
|
||||
async def test_cwd_and_branch_visible_at_medium_width(self) -> None:
|
||||
"""Between 70-99 cols: cwd visible and branch visible (truncating)."""
|
||||
async with StatusBarApp().run_test(size=(85, 24)) as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.branch = "main"
|
||||
@@ -155,19 +257,22 @@ class TestResizePriority:
|
||||
cwd = pilot.app.query_one("#cwd-display")
|
||||
branch = pilot.app.query_one("#branch-display")
|
||||
assert cwd.display is True
|
||||
assert branch.display is False
|
||||
assert branch.display is True
|
||||
|
||||
async def test_resize_restores_branch_visibility(self) -> None:
|
||||
"""Widening terminal should restore branch display."""
|
||||
async def test_resize_never_hides_branch(self) -> None:
|
||||
"""Resizing must never toggle the branch off; it only truncates."""
|
||||
async with StatusBarApp().run_test(size=(80, 24)) as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.branch = "main"
|
||||
await pilot.pause()
|
||||
branch = pilot.app.query_one("#branch-display")
|
||||
assert branch.display is False
|
||||
assert branch.display is True
|
||||
await pilot.resize_terminal(120, 24)
|
||||
await pilot.pause()
|
||||
assert branch.display is True
|
||||
await pilot.resize_terminal(50, 24)
|
||||
await pilot.pause()
|
||||
assert branch.display is True
|
||||
|
||||
async def test_model_visible_at_narrow_width(self) -> None:
|
||||
"""Model display should remain visible even at very narrow widths."""
|
||||
@@ -286,6 +391,58 @@ class TestTokenDisplay:
|
||||
assert "8.0K" in rendered
|
||||
assert "+" not in rendered
|
||||
|
||||
async def test_empty_tokens_hidden_on_mount(self) -> None:
|
||||
"""An empty token slot is hidden so its padding adds no gap."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
display = pilot.app.query_one("#tokens-display")
|
||||
assert display.display is False
|
||||
|
||||
async def test_set_tokens_shows_then_zero_hides(self) -> None:
|
||||
"""A positive count reveals the token slot; zeroing hides it again."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.set_tokens(5000)
|
||||
await pilot.pause()
|
||||
display = pilot.app.query_one("#tokens-display")
|
||||
assert display.display is True
|
||||
bar.set_tokens(0)
|
||||
await pilot.pause()
|
||||
assert display.display is False
|
||||
|
||||
|
||||
class TestStatusMessageVisibility:
|
||||
"""The status-message slot hides when empty so its padding adds no gap."""
|
||||
|
||||
async def test_empty_message_hidden_on_mount(self) -> None:
|
||||
"""The status-message slot starts empty and is hidden on mount."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
msg = pilot.app.query_one("#status-message")
|
||||
assert msg.display is False
|
||||
|
||||
async def test_setting_message_shows_then_clearing_hides(self) -> None:
|
||||
"""Setting a message reveals the slot; clearing it hides it again."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.set_status_message("Thinking")
|
||||
await pilot.pause()
|
||||
msg = pilot.app.query_one("#status-message")
|
||||
assert msg.display is True
|
||||
bar.set_status_message("")
|
||||
await pilot.pause()
|
||||
assert msg.display is False
|
||||
|
||||
async def test_busy_shows_slot_and_clearing_hides(self) -> None:
|
||||
"""A busy indicator reveals the slot; clearing busy (no message) hides it."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.set_busy("Switching model")
|
||||
await pilot.pause()
|
||||
msg = pilot.app.query_one("#status-message")
|
||||
assert msg.display is True
|
||||
bar.set_busy("")
|
||||
await pilot.pause()
|
||||
assert msg.display is False
|
||||
|
||||
|
||||
class TestModeIndicator:
|
||||
"""Tests for the input-mode indicator in the status bar."""
|
||||
|
||||
Reference in New Issue
Block a user