diff --git a/src/toad/goal_interaction.py b/src/toad/goal_interaction.py index c5ff2c469..a55e0958a 100644 --- a/src/toad/goal_interaction.py +++ b/src/toad/goal_interaction.py @@ -67,8 +67,7 @@ def caption(cls, display, collapsed): @classmethod async def apply(cls, session): - from toad.widgets.goal_bar import GoalBar - bar = session.view.query_one(GoalBar) + bar = session.view.goal_bar bar.collapsed = not bar.collapsed diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index c61e8a884..5499e2ad5 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -478,6 +478,7 @@ def mode_selection_available(self) -> bool: native_history_status: var[type[CursorStatus]] = var(lambda: HiddenCursorStatus) + goal_bar: GoalBar | None = None goal_display: var[GoalDisplay] = var(NoGoal()) @@ -761,6 +762,21 @@ def watch_agent(self, agent: AgentBase | None) -> None: if self.is_mounted: self.update_slash_commands() + @on(Prompt.DeliveryControlsChanged) + def _delivery_controls_changed(self, event: Prompt.DeliveryControlsChanged) -> None: + event.stop() + if self.goal_bar is not None: + self.goal_bar._queue_separator_update() + + def on_resize(self) -> None: + # The binding owns the goal presentation, including its retained size. + if self.goal_bar is not None: + self.goal_bar.update_document_height() + + def on_mouse_down(self, event: events.MouseDown) -> None: + if self.goal_bar is not None: + self.goal_bar.begin_separator_resize(event) + from toad.widget_actions import DeclaredWidgetActions from toad.conversation_actions import ConversationAction @@ -937,7 +953,8 @@ def compose(self) -> ComposeResult: status=Conversation.status, ) yield self.throbber - yield GoalBar(self, throbber=self.throbber, prompt=prompt) + self.goal_bar = GoalBar(self, throbber=self.throbber, prompt=prompt) + yield self.goal_bar yield prompt @property @@ -1810,16 +1827,8 @@ async def _settings_changed(self, message: CoreEventMessage) -> None: async def post_welcome(self) -> None: """Post any welcome content.""" - def on_resize(self) -> None: - # A goal can retain its own size while the surrounding viewport changes. - # Reapply its user-selected height against the new viewport bound. - if (goal_bar := self.query_one_optional(GoalBar)) is not None: - goal_bar.update_document_height() - def on_mouse_down(self, event: events.MouseDown) -> None: self._mouse_down_offset = event.screen_offset - if (goal_bar := self.query_one_optional(GoalBar)) is not None: - goal_bar.begin_separator_resize(event) def on_click(self, event: events.Click) -> None: from toad.screens.session_view import SessionView diff --git a/src/toad/widgets/goal_bar.py b/src/toad/widgets/goal_bar.py index 8159f00a7..c1360d5a8 100644 --- a/src/toad/widgets/goal_bar.py +++ b/src/toad/widgets/goal_bar.py @@ -28,19 +28,66 @@ class GoalControl(PrimaryActivation, CoreEventReceiver, Static, can_focus=True): GoalControl:hover, GoalControl:focus { text-style: underline; } """ + def __init__(self, interaction: type[GoalInteraction]): + super().__init__(interaction.label, id=interaction.control_id()) + self._interaction = interaction + @property def primary_activation(self): return self.action_activate def action_activate(self): if not self.disabled: - self.publish_core(GoalControlActivated(GoalInteraction.decode(self.id.removeprefix("goal-")))) + self.publish_core(GoalControlActivated(self._interaction)) + + def update_goal(self, display, collapsed: bool) -> None: + self.disabled = not self._interaction.enabled(display) + self.display = self._interaction.visible(display) + self.update(self._interaction.caption(display, collapsed)) def on_click(self, event: events.Click): event.stop() self.action_activate() +class GoalControlRow(HorizontalGroup): + """The declared goal actions own their controls; this row owns their layout.""" + + def __init__(self): + controls = tuple( + GoalControl(action) for action in GoalInteraction.members_with(GoalInteraction) + ) + super().__init__(*controls, classes="goal-controls") + self._controls = controls + + def update_goal(self, display, collapsed: bool) -> None: + for control in self._controls: + control.update_goal(display, collapsed) + + def fit(self, width: int, collapsed: bool, header_width: int) -> None: + widths = [ + cell_len(str(control.render())) + control.styles.margin.width + for control in self._controls if control.display + ] + inline = collapsed and sum(widths) + header_width <= width + self.styles.dock = "left" if inline else "top" if collapsed else "none" + self.styles.width = "auto" if inline else "1fr" + if sum(widths) <= width: + self.styles.layout = "horizontal" + self.styles.height = 1 + else: + # Keep every action reachable in narrow conversation panes. + columns = len(widths) + while columns > 1 and sum(max(widths[i::columns]) for i in range(columns)) > width: + columns -= 1 + self.styles.layout = "grid" + self.styles.grid_size_columns = columns + self.styles.grid_columns = " ".join( + str(max(widths[i::columns])) for i in range(columns) + ) + self.styles.height = (len(widths) + columns - 1) // columns + + class StandbyPulse(Animated, Static): """A goal's standby marker; its existing clock owns time and visibility.""" @@ -89,6 +136,18 @@ def __init__(self, source, *, throbber, prompt): self._source = ref(source) self._throbber = throbber self._prompt = prompt + self._header = Static("", markup=False, classes="goal-header") + self._standby_pulse = StandbyPulse() + self._execution_text = GoalText(classes="goal-execution") + self._execution_row = HorizontalGroup( + self._standby_pulse, self._execution_text, classes="goal-execution-row" + ) + self._summary = GoalText(classes="goal-summary") + self._progress = GoalText(classes="goal-progress") + self._document = VerticalScroll( + self._summary, self._progress, classes="goal-document" + ) + self._controls = GoalControlRow() @property def goal_display(self): @@ -119,13 +178,12 @@ def _queue_separator_update(self) -> None: def _update_separators(self) -> None: self._separator_update_pending = False throbber = self._throbber - controls = self._prompt.query_one_optional(".delivery-controls") loading = ( throbber.busy and throbber.display and throbber.visible ) - queue_visible = controls is not None and controls.display and controls.visible + queue_visible = self._prompt.delivery_controls_visible # Reuse the theme-resolved separator; only these local box edges change. self.styles.border_top = ("none", "transparent") if loading else None self.styles.border_bottom = ( @@ -134,16 +192,10 @@ def _update_separators(self) -> None: self._update_goal_text() def compose(self) -> ComposeResult: - yield Static("", markup=False, classes="goal-header") - with HorizontalGroup(classes="goal-execution-row"): - yield StandbyPulse() - yield GoalText(classes="goal-execution") - with VerticalScroll(classes="goal-document"): - yield GoalText(classes="goal-summary") - yield GoalText(classes="goal-progress") - with HorizontalGroup(classes="goal-controls"): - for action in GoalInteraction.members_with(GoalInteraction): - yield GoalControl(action.label, id=action.control_id()) + yield self._header + yield self._execution_row + yield self._document + yield self._controls def watch_collapsed(self) -> None: self.set_class(self.collapsed, "-collapsed") @@ -159,9 +211,8 @@ def watch_goal_display(self) -> None: self.display = self.goal_display.visible self._update_goal_text() if goal is not None and self.is_attached: - progress = self.query_one(".goal-progress", GoalText) - progress.update_goal_text(f"Progress: {goal.progress}" if goal.progress else "") - progress.display = bool(goal.progress) + self._progress.update_goal_text(f"Progress: {goal.progress}" if goal.progress else "") + self._progress.display = bool(goal.progress) self._update_control_layout() def watch_execution(self) -> None: @@ -170,26 +221,20 @@ def watch_execution(self) -> None: def _update_goal_text(self) -> None: if not self.is_attached: return - header = self.query_one(".goal-header", Static) state = self.goal_display goal = state.snapshot execution = self.execution - header.update(state.heading(execution)) - for action in GoalInteraction.members_with(GoalInteraction): - control = self.query_one(f"#{action.control_id()}", GoalControl) - control.disabled = not action.enabled(state) - control.display = action.visible(state) - control.update(action.caption(state, self.collapsed)) - self.query_one(".goal-document").display = goal is not None and not self.collapsed + self._header.update(state.heading(execution)) + self._controls.update_goal(state, self.collapsed) + self._document.display = goal is not None and not self.collapsed if goal is not None: - self.query_one(".goal-summary", GoalText).update_goal_text(f"Objective: {goal.text}") + self._summary.update_goal_text(f"Objective: {goal.text}") standby = state.standby(execution) - self.query_one(".goal-execution-row").display = standby and not self.collapsed - self.query_one(StandbyPulse).active = standby and not self.collapsed - status = self.query_one(".goal-execution", GoalText) - status.display = standby + self._execution_row.display = standby and not self.collapsed + self._standby_pulse.active = standby and not self.collapsed + self._execution_text.display = standby if standby: - status.update_goal_text(execution.presentation("").summary) + self._execution_text.update_goal_text(execution.presentation("").summary) self._update_control_layout() self.call_after_refresh(self.update_document_height) @@ -208,7 +253,7 @@ def begin_separator_resize(self, event: events.MouseDown) -> None: ) if own_edge or loading_edge: event.stop() - self._resize_origin = (event.screen_y, self.query_one(".goal-document").region.height) + self._resize_origin = (event.screen_y, self._document.region.height) self.set_class(True, "-resizing") self.capture_mouse() @@ -252,42 +297,21 @@ def _end_resize(self) -> None: def _update_control_layout(self) -> None: if not self.is_attached or not self.display or not self.content_size.width: return - controls = self.query_one(".goal-controls") - widths = [ - cell_len(str(control.render())) + control.styles.margin.width - for control in controls.query(GoalControl) if control.display - ] - width = self.content_size.width - header_width = cell_len(str(self.query_one(".goal-header", Static).render())) - inline = self.collapsed and sum(widths) + header_width <= width - controls.styles.dock = "left" if inline else "top" if self.collapsed else "none" - controls.styles.width = "auto" if inline else "1fr" - if sum(widths) <= width: - controls.styles.layout = "horizontal" - controls.styles.height = 1 - else: - # Keep every action reachable in narrow conversation panes. - columns = len(widths) - while columns > 1 and sum(max(widths[i::columns]) for i in range(columns)) > width: - columns -= 1 - controls.styles.layout = "grid" - controls.styles.grid_size_columns = columns - controls.styles.grid_columns = " ".join( - str(max(widths[i::columns])) for i in range(columns) - ) - controls.styles.height = (len(widths) + columns - 1) // columns + self._controls.fit( + self.content_size.width, self.collapsed, cell_len(str(self._header.render())) + ) def update_document_height(self) -> None: # Conversation resize also reaches an absent goal or an outage header # without a document. Querying that hidden document's geometry forces # a full compositor map after the visible viewport was already laid out. if self.is_attached and self.display and self.goal_display.snapshot is not None and not self.collapsed: - document = self.query_one(".goal-document", VerticalScroll) + document = self._document width = document.content_size.width height = ( sum( child.visual.get_height(child.styles, width) - for child in document.query(GoalText) + for child in (self._summary, self._progress) if child.display ) if width diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 34c123320..df95c5520 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -11,6 +11,7 @@ import asyncio from textual.reactive import var, Initialize from textual.app import ComposeResult +from textual.message import Message from textual.actions import SkipAction from textual.binding import Binding @@ -458,6 +459,8 @@ def watch_selection(self, previous_selection: Selection, selection: Selection) - class Prompt(containers.VerticalGroup): + class DeliveryControlsChanged(Message): + """The composing parent may need to update adjacent separators.""" DEFAULT_CSS = """ Prompt .queue-summary { display: none; height: auto; max-height: 3; color: $text-muted; } @@ -520,6 +523,12 @@ def __init__( self.simple_placeholder = placeholder self.turns = turns self.submissions = submissions + self._delivery_controls: containers.HorizontalGroup | None = None + + @property + def delivery_controls_visible(self) -> bool: + controls = self._delivery_controls + return controls is not None and controls.display and controls.visible @property def agent_busy(self) -> bool: @@ -637,9 +646,7 @@ def sync_turn(self) -> None: if (editor := self.query_one_optional(PromptTextArea)) is not None: self.set_class(SubmitNowAction().available(editor), "-queue-mode") editor.refresh_bindings() - from toad.widgets.goal_bar import GoalBar - for goal_bar in self.parent.query(GoalBar): - goal_bar._queue_separator_update() + self.post_message(self.DeliveryControlsChanged()) def sync_queue(self) -> None: if self.is_mounted: @@ -824,9 +831,11 @@ def compose(self) -> ComposeResult: yield popup if not self.simple_input: yield QueueSummary("", classes="queue-summary", markup=False) - with containers.HorizontalGroup(classes="delivery-controls"): - yield Label("Enter queues · ") - yield SendNow("Send now (Ctrl+Enter / Ctrl+Y)") + self._delivery_controls = containers.HorizontalGroup( + Label("Enter queues · "), SendNow("Send now (Ctrl+Enter / Ctrl+Y)"), + classes="delivery-controls", + ) + yield self._delivery_controls with PromptContainer(id="prompt-container"): yield Question() with containers.HorizontalGroup(id="text-prompt"):