Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 93 additions & 29 deletions libs/code/deepagents_code/tui/widgets/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
RenderResult,
)
from textual.app import ComposeResult
from textual.css.types import PointerShape
from textual.events import MouseMove
from textual.timer import Timer
from textual.widget import Widget
Expand Down Expand Up @@ -128,6 +129,58 @@ def _mode_color(mode: str | None, widget_or_app: object | None = None) -> str:
return colors.primary


def _event_targets_rendered_text(event: MouseMove) -> bool:
"""Return whether the hovered cell was rendered from widget content.

Textual tags every content segment's style with a selection `offset`
(`Content.to_strip` via `Style.rich_style_with_offset`) and reads the same
key back in `Compositor.get_widget_and_offset_at` to map a screen cell to a
text position. Alignment padding and cells past the end of a line carry no
such meta, so the key doubles as a rendered-text hit test that agrees
exactly with what Textual can resolve to an offset.

`offset` and both of its producers are private Textual API. Re-verify these
names on every Textual bump: if the key moves, every cell reads as blank
and the text pointer silently stops appearing.

This tracks *rendered* text, not *selectable* text — the meta is attached
unconditionally, so a widget with `ALLOW_SELECT = False` still reports
`True` here.

Args:
event: The Textual mouse-move event to inspect.

Returns:
`True` when the hovered cell holds content-rendered text.
"""
return "offset" in event.style.meta


def _pointer_shape_for(event: MouseMove) -> PointerShape:
"""Return the pointer shape to show for the cell under the mouse.

Textual applies a widget's pointer across its whole rectangle, so a message
declaring `pointer: text` in CSS shows an I-beam over the blank space beside
its short lines. Resolving the shape per cell instead keeps the I-beam on
text the reader can actually select.

The result is only accurate as of the last mouse movement: content that
grows or scrolls under a stationary pointer leaves the previous shape in
place until the mouse moves again.

Args:
event: The Textual mouse-move event to inspect.

Returns:
`'pointer'` over links, `'text'` over rendered text, else `'default'`.
"""
if event_targets_link(event):
return "pointer"
if _event_targets_rendered_text(event):
return "text"
return "default"


@dataclass(frozen=True, slots=True)
class FormattedOutput:
"""Result of formatting tool output for display."""
Expand Down Expand Up @@ -457,7 +510,6 @@ def __init__(self, widget: UserMessage, expanded: bool) -> None:
margin: 0 0 1 0;
background: transparent;
border-left: wide $primary;
pointer: text;
/* The expand affordance carries `@click` meta, which Textual styles as
a link (underline, and bold on an accent block when hovered).
Neutralize both so the hint renders as plain inherited-colour dim
Expand Down Expand Up @@ -790,6 +842,14 @@ def render(self) -> Content:
self._append_highlighted_body(parts, collapse.tail, colors=colors)
return Content.assemble(*parts)

def on_mouse_move(self, event: MouseMove) -> None:
"""Match the pointer shape to the cell under the mouse."""
self.styles.pointer = _pointer_shape_for(event)

def on_leave(self) -> None:
"""Reset the pointer shape when the mouse leaves the message."""
self.styles.pointer = "default"


class QueuedUserMessage(Static):
"""Widget displaying a queued (pending) user message in grey.
Expand All @@ -805,7 +865,6 @@ class QueuedUserMessage(Static):
background: transparent;
border-left: wide $panel;
opacity: 0.6;
pointer: text;
}
"""
"""Dimmed border + reduced opacity to distinguish queued messages from sent ones."""
Expand Down Expand Up @@ -890,6 +949,14 @@ def render(self) -> Content:
content = _truncate_for_display(content)
return Content.assemble(prefix, (content, colors.muted))

def on_mouse_move(self, event: MouseMove) -> None:
"""Match the pointer shape to the cell under the mouse."""
self.styles.pointer = _pointer_shape_for(event)

def on_leave(self) -> None:
"""Reset the pointer shape when the mouse leaves the message."""
self.styles.pointer = "default"


def _strip_frontmatter(text: str) -> str:
"""Remove YAML frontmatter delimited by `---` markers.
Expand Down Expand Up @@ -1198,7 +1265,6 @@ class AssistantMessage(Vertical):
AssistantMessage Markdown {
padding: 0;
margin: 0;
pointer: text;
}

/* Markdown blocks carry a bottom margin for inter-block spacing; drop it
Expand Down Expand Up @@ -1258,21 +1324,12 @@ def on_mount(self) -> None:
self._markdown = self.query_one("#assistant-content", Markdown)

def on_mouse_move(self, event: MouseMove) -> None:
"""Show a pointer cursor over markdown links, text cursor elsewhere.

The pointer is set on the inner `Markdown` widget because it carries a
non-default (`text`) pointer in CSS, so the screen resolves its shape
before reaching this container.
"""
if self._markdown is not None:
self._markdown.styles.pointer = (
"pointer" if event_targets_link(event) else "text"
)
"""Match the pointer shape to the cell under the mouse."""
self.styles.pointer = _pointer_shape_for(event)

def on_leave(self) -> None:
"""Reset the markdown pointer shape when the mouse leaves the message."""
if self._markdown is not None:
self._markdown.styles.pointer = "text"
"""Reset the pointer shape when the mouse leaves the message."""
self.styles.pointer = "default"

async def on_markdown_link_clicked(self, event: Markdown.LinkClicked) -> None:
"""Open Markdown links with the same toast feedback as style links."""
Expand Down Expand Up @@ -4931,7 +4988,6 @@ class DiffMessage(Static):
margin: 0 0 1 0;
background: transparent;
border-left: wide $panel;
pointer: text;
}

DiffMessage .diff-header {
Expand Down Expand Up @@ -5119,6 +5175,14 @@ def on_mount(self) -> None:
colors = theme.get_theme_colors(self)
self.styles.border_left = ("ascii", colors.panel)

def on_mouse_move(self, event: MouseMove) -> None:
"""Match the pointer shape to the cell under the mouse."""
self.styles.pointer = _pointer_shape_for(event)

def on_leave(self) -> None:
"""Reset the pointer shape when the mouse leaves the message."""
self.styles.pointer = "default"


class ErrorMessage(Static):
"""Widget displaying an error message."""
Expand All @@ -5131,7 +5195,6 @@ class ErrorMessage(Static):
background: $error-muted;
color: white;
border-left: wide $error;
pointer: text;
}
"""
"""Tinted background + left border to visually separate errors from output."""
Expand Down Expand Up @@ -5170,6 +5233,14 @@ def on_click(self, event: Click) -> None: # noqa: PLR6301 # Textual event hand
if event.style.link:
open_style_link(event)

def on_mouse_move(self, event: MouseMove) -> None:
"""Match the pointer shape to the cell under the mouse."""
self.styles.pointer = _pointer_shape_for(event)

def on_leave(self) -> None:
"""Reset the pointer shape when the mouse leaves the message."""
self.styles.pointer = "default"


class _RubricResultToggle(Static):
"""Clickable summary or hint for a rubric result."""
Expand Down Expand Up @@ -5499,7 +5570,6 @@ class AppMessage(Static):
margin: 0 0 1 0;
color: $text-muted;
text-style: italic;
pointer: text;
}
"""

Expand Down Expand Up @@ -5592,17 +5662,12 @@ def on_click(self, event: Click) -> None: # noqa: PLR6301 # Textual event hand
open_style_link(event)

def on_mouse_move(self, event: MouseMove) -> None:
"""Show a pointer cursor over embedded links, text cursor elsewhere."""
self.styles.pointer = "pointer" if event_targets_link(event) else "text"
"""Match the pointer shape to the cell under the mouse."""
self.styles.pointer = _pointer_shape_for(event)

def on_leave(self) -> None:
"""Restore the pointer shape when the mouse leaves the message.

`"text"` restates this widget's CSS default rather than clearing the
inline style, so a subclass declaring a different `pointer` would be
forced back to `text` on leave.
"""
self.styles.pointer = "text"
"""Reset the pointer shape when the mouse leaves the message."""
self.styles.pointer = "default"


class SummarizationMessage(AppMessage):
Expand All @@ -5617,7 +5682,6 @@ class SummarizationMessage(AppMessage):
background: $surface;
border-left: wide $primary;
text-style: bold;
pointer: text;
}
"""

Expand Down
Loading