From 1f5f1c3d70b6efdd6ccc2918a6e01736f372a46b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 18:07:53 -0400 Subject: [PATCH] P55: New-session dialog takes model and thinking from the shared model selector The command dialog prepares catalog-backed fields through Core (TargetAction.prepare_fields) before opening, and renders each one as a CatalogField that opens the existing model search and provider connection. ModelSwitcher's search, ranking, recents and Connect row move into a ModelSelection base; the prompt's ModelSwitcher and the dialog's CatalogFieldSelection only declare their choices, current value, selection and provider-connection actions. Lifted from forms-migration's 1b9f89316 and 72ced63a4 on #670 without their scratch files or pins, and adapted to Core main: prepare_fields() takes no Comms, and the dialog picker is named CatalogFieldSelection so it does not shadow Core's CatalogSelection. Co-Authored-By: Claude Opus 5.5 --- src/toad/thread_actions.py | 19 ++- src/toad/widgets/comms_command_dialog.py | 187 +++++++++++++++++++++-- src/toad/widgets/model_switcher.py | 114 ++++++++++---- 3 files changed, 269 insertions(+), 51 deletions(-) diff --git a/src/toad/thread_actions.py b/src/toad/thread_actions.py index 3e9f3f17d..0b7d6bc87 100644 --- a/src/toad/thread_actions.py +++ b/src/toad/thread_actions.py @@ -134,9 +134,18 @@ def prepare(): except (OSError, ValueError) as error: ctx.app.notify(str(error), title=definition.label, severity='error') - if definition.editable_fields or definition.confirmation: - ctx.app.push_screen(CommandDialog(definition, ctx.title), accepted) - else: - async def prepare(): + async def prepare(): + if definition.editable_fields or definition.confirmation: + screen = ctx.app.screen + try: + fields = await ctx.app.preparation.run_thread( + lambda: asyncio.run(definition.prepare_fields())) + ctx.current() + except (OSError, ValueError, RuntimeError) as error: + ctx.app.notify(str(error), title=definition.label, severity='error') + return + if ctx.app.screen is screen: + ctx.app.push_screen(CommandDialog(definition, ctx.title, fields, ctx.comms), accepted) + else: await accepted({}) - ctx.app.run_worker(prepare) + ctx.app.run_worker(prepare, name='command-fields') diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 6a1e79bd9..b537fce3e 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -1,13 +1,121 @@ """Native parameter editing for one backend-declared operation.""" from __future__ import annotations -from agent_comms.cli_commands import TargetAction +import asyncio +from agent_comms.cli_commands import CatalogTargetField, TargetAction, TargetField +from agent_comms.login import run_login +from agent_comms.comms import Comms + +from textual import on, work from textual.app import ComposeResult +from textual.message import Message +from textual.reactive import var from textual.containers import Horizontal, Vertical, VerticalScroll from toad.screens.modal import ModalScreen from textual.widgets import Button, Checkbox, Input, Select, Static, TextArea +from toad.widgets.model_switcher import ModelSelection + + +class CatalogField(Button): + """One draft selection; the shared searchable picker presents its catalog.""" + value = var("") + + class Changed(Message): + def __init__(self, editor: CatalogField): + super().__init__() + self.editor = editor + + def __init__(self, parameter: CatalogTargetField): + self.parameter = parameter + super().__init__(name=parameter.name, + id='command-field-' + parameter.name.replace('_', '-')) + self.configure(parameter) + + def configure(self, parameter: CatalogTargetField): + self.parameter = parameter + self.value = (parameter.editor_default + if parameter.editor_default in {choice.value for choice in parameter.options} + else '') + self.update_label() + + def update_label(self): + self.label = next((choice.name for choice in self.parameter.options + if choice.value == self.value), 'Choose / connect a provider…') + + def watch_value(self): + self.update_label() + self.post_message(self.Changed(self)) + + def on_button_pressed(self, event: Button.Pressed): + event.stop() + self.choose() + + @work(exclusive=True) + async def choose(self): + value = await self.app.push_screen_wait(CatalogChoiceDialog(self)) + if value is not None and self.is_attached: + self.value = value + + +class CatalogFieldSelection(ModelSelection): + choice_name = "choices" + + def __init__(self, editor: CatalogField): + self.editor = editor + super().__init__() + + @property + def choices(self): + return self.editor.parameter.options + + @property + def current(self): + return self.editor.value + + @property + def selection_active(self): + return self.is_attached + + def compose(self): + yield from self.compose_content() + + def on_mount(self): + self.focus_content(False) + + def select_model(self, model_id: str): + self.screen.dismiss(model_id) + + def action_dismiss(self): + self.screen.dismiss(None) + + @work(exclusive=True) + async def connect_provider(self): + command = self.editor.query_ancestor(CommandDialog) + try: + with self.app.suspend(): + result = await asyncio.to_thread(run_login, comms=command.comms) + except (OSError, ValueError, RuntimeError) as error: + self.notify(str(error), title='Provider login', severity='error') + return + if result == 0 and self.is_attached: + await command.refresh_catalogs(command.arguments()) + self.configuration_changed() + elif self.is_attached: + self.notify(f'Provider login exited with status {result}', severity='error') + if self.is_attached: + self.search_input.focus() + + +class CatalogChoiceDialog(ModalScreen[str]): + DEFAULT_CSS = 'CatalogChoiceDialog { align: center middle; background: $background 40%; }' + + def __init__(self, editor: CatalogField): + super().__init__() + self.editor = editor + def compose(self): + yield CatalogFieldSelection(self.editor) class CommandDialog(ModalScreen[dict[str, str]]): DEFAULT_CSS = """ @@ -23,9 +131,11 @@ class CommandDialog(ModalScreen[dict[str, str]]): """ BINDINGS = [('escape', 'cancel', 'Cancel'), ('ctrl+enter', 'submit', 'Apply')] - def __init__(self, definition: TargetAction, target: str): + def __init__(self, definition: TargetAction, target: str, + fields: tuple[TargetField, ...], comms: Comms): super().__init__() self.definition, self.target = definition, target + self.fields, self.comms = fields, comms def compose(self) -> ComposeResult: with Vertical(id='command-box'): @@ -33,12 +143,17 @@ def compose(self) -> ComposeResult: with VerticalScroll(id='command-body'): yield Static('', markup=False, id='command-confirmation') with Vertical(id='command-fields'): - for parameter in self.definition.editable_fields: + for parameter in self.fields: key = parameter.name yield Static(parameter.description, markup=False) - if parameter.choices: - yield Select(parameter.choices, value=parameter.editor_default, - allow_blank=False, name=key, + if parameter.catalog is not None: + yield CatalogField(parameter) + elif (choices := parameter.choices) is not None: + value = parameter.editor_default + yield Select(choices, + value=value if value in {item[1] for item in choices} else Select.BLANK, + allow_blank=True, name=key, + disabled=not choices, id='command-field-' + key.replace('_', '-')) else: widget = TextArea if parameter.multiline else Input @@ -51,7 +166,7 @@ def compose(self) -> ComposeResult: def on_mount(self): self.update_confirmation() - fields = list(self.query('Input, TextArea, Select')) + fields = list(self.query('Input, TextArea, Select, CatalogField')) (fields[0] if fields else self.query_one('#command-cancel', Button)).focus() async def on_input_submitted(self, event: Input.Submitted): @@ -88,30 +203,74 @@ async def action_submit(self): def arguments(self): return {**{editor.name: editor.value for editor in self.query(Input)}, **{editor.name: editor.text for editor in self.query(TextArea)}, - **{editor.name: str(editor.value) for editor in self.query(Select)}} + **{editor.name: '' if editor.value is Select.BLANK else str(editor.value) + for editor in self.query(Select)}, + **{editor.name: editor.value for editor in self.query(CatalogField)}} + + async def refresh_catalogs(self, arguments): + requested = {item.name: arguments[item.name] for item in self.fields if item.catalog is not None} + def unchanged(): + current = self.arguments() + return all(current[name] == value for name, value in requested.items()) + self.query_one('#command-apply', Button).disabled = True + try: + fields = await self.app.preparation.run_thread( + lambda: asyncio.run(self.definition.edited(arguments).prepare_fields())) + except (OSError, ValueError, RuntimeError) as error: + if self.is_attached and unchanged(): + self.notify(str(error), title='Configuration choices', severity='error') + return + if not self.is_attached or not unchanged(): + return + self.fields = fields + with self.prevent(CatalogField.Changed): + for parameter in fields: + if parameter.catalog is not None: + editor = self.query_one('#command-field-' + parameter.name.replace('_', '-'), CatalogField) + editor.configure(parameter) + self.update_confirmation() def update_confirmation(self): confirmed = self.query_one('#command-confirmed', Checkbox) confirmed.value = False self.query_one('#command-apply', Button).disabled = True arguments = self.arguments() + missing_choice = next((editor.name for editor in self.query(Select) + if editor.value is Select.BLANK), None) + if missing_choice is None: + missing_choice = next((editor.name for editor in self.query(CatalogField) + if not editor.value), None) async def prepare(): def read(): try: - return self.definition.edited(arguments).confirmation - except (KeyError, ValueError, TypeError): - return self.definition.confirmation - confirmation = await self.app.preparation.run_thread(read) + if missing_choice is not None: + raise ValueError(f'Choose {missing_choice.replace("_", " ")}') + return self.definition.edited(arguments).confirmation, True + except (KeyError, ValueError, TypeError) as error: + return str(error), False + confirmation, valid = await self.app.preparation.run_thread(read) if not self.is_attached or arguments != self.arguments(): return self.query_one('#command-confirmation', Static).update(confirmation) - confirmed.display = bool(confirmation) - self.query_one('#command-apply', Button).disabled = False + confirmed.display = valid and bool(confirmation) + self.query_one('#command-apply', Button).disabled = not valid or any( + item.catalog is not None and item.editor_default != arguments[item.name] + for item in self.fields) self.run_worker(prepare, group='command-confirmation', exclusive=True) def on_select_changed(self, event: Select.Changed): self.update_confirmation() + @on(CatalogField.Changed) + def on_catalog_changed(self, event: CatalogField.Changed): + event.stop() + self.update_confirmation() + if event.editor.value and any( + item.name == event.editor.name and item.catalog is not None + and item.editor_default != event.editor.value for item in self.fields): + self.run_worker(self.refresh_catalogs(self.arguments()), + group='command-catalogs', exclusive=True) + def on_input_changed(self, event: Input.Changed): self.update_confirmation() diff --git a/src/toad/widgets/model_switcher.py b/src/toad/widgets/model_switcher.py index 90123ff43..ebebc6fb7 100644 --- a/src/toad/widgets/model_switcher.py +++ b/src/toad/widgets/model_switcher.py @@ -4,12 +4,15 @@ from toad.core import input_events from toad.core_event_carrier import CoreEventReceiver -from typing import TYPE_CHECKING, Self +from abc import abstractmethod +from collections.abc import Sequence +from typing import TYPE_CHECKING, Self, Protocol from textual import events, getters, on, work from textual.app import ComposeResult from textual.binding import Binding from textual.content import Content +from textual.containers import VerticalGroup from textual.reactive import var from textual.widgets import Input, OptionList, Static from textual.widget import PrimaryActivation @@ -35,8 +38,7 @@ def primary_activation(self): return self.action_connect def action_connect(self): - self.query_ancestor(ModelSwitcher).action_dismiss() - self.publish_core(input_events.ProviderLogin()) + self.query_ancestor(ModelSelection).connect_provider() def on_click(self, event: events.Click): event.stop() @@ -62,7 +64,19 @@ def match_score(query: str, candidate: str) -> float: return 10 + len(query) / max(1, previous - first + 1) -class ModelSwitcher(CoreEventReceiver, InfoPopup): +class ModelChoice(Protocol): + @property + def value(self) -> str: ... + + @property + def name(self) -> str: ... + + @property + def description(self) -> str | None: ... + + +class ModelSelection(CoreEventReceiver, VerticalGroup): + """Search, recents and provider connection shared by prompt and creation views.""" BINDING_GROUP_TITLE = "Model search" BINDINGS = [ Binding("up", "cursor_up", "Previous model", priority=True), @@ -72,9 +86,7 @@ class ModelSwitcher(CoreEventReceiver, InfoPopup): Binding("ctrl+u", "clear_search", "Clear search", priority=True, show=False), ] DEFAULT_CSS = """ - ModelSwitcher { - display: none; - overlay: screen; + ModelSelection { constrain: inside inflect; width: 80; max-width: 100%; @@ -85,19 +97,18 @@ class ModelSwitcher(CoreEventReceiver, InfoPopup): padding: 0; margin: 1 0; } - ModelSwitcher.-open { display: block; } - ModelSwitcher Input { height: 1; border: none; padding: 0 1; } - ModelSwitcher .model-count { height: 1; padding: 0 1; color: $text-muted; } - ModelSwitcher ConnectProvider { height: 1; padding: 0 1; color: $text-secondary; pointer: pointer; } - ModelSwitcher ConnectProvider:hover, ModelSwitcher ConnectProvider:focus { text-style: underline; } - ModelSwitcher OptionList { + ModelSelection Input { height: 1; border: none; padding: 0 1; } + ModelSelection .model-count { height: 1; padding: 0 1; color: $text-muted; } + ModelSelection ConnectProvider { height: 1; padding: 0 1; color: $text-secondary; pointer: pointer; } + ModelSelection ConnectProvider:hover, ModelSelection ConnectProvider:focus { text-style: underline; } + ModelSelection OptionList { height: auto; max-height: 45vh; border: none; padding: 0; & > .option-list--option { padding: 0 1; } } - ModelSwitcher OptionList > .option-list--option-highlighted { + ModelSelection OptionList > .option-list--option-highlighted { background: $block-cursor-background; color: $block-cursor-foreground; text-style: $block-cursor-text-style; @@ -107,6 +118,7 @@ class ModelSwitcher(CoreEventReceiver, InfoPopup): search_input = getters.query_one(Input) option_list = getters.query_one(OptionList) history_scope = var("") + choice_name = "models" def __init__(self): super().__init__() @@ -114,13 +126,26 @@ def __init__(self): self._open_generation = 0 self._selection_moved = False - @classmethod - def for_prompt(cls, prompt: Prompt) -> Self | None: - from toad.widgets.prompt import Prompt - return None if prompt.simple_input else cls().data_bind(history_scope=Prompt.model_history_scope) + @property + @abstractmethod + def choices(self) -> Sequence[ModelChoice]: ... + + @property + @abstractmethod + def current(self) -> str: ... + + @property + @abstractmethod + def selection_active(self) -> bool: ... + + @abstractmethod + def select_model(self, model_id: str) -> None: ... + + @abstractmethod + def connect_provider(self) -> None: ... def compose_content(self) -> ComposeResult: - yield Input(placeholder="Search models or providers…", compact=True) + yield Input(placeholder=f"Search {self.choice_name} or providers…", compact=True) yield Static("", classes="model-count", markup=False) options = SelectionOptionList() options.can_focus = False @@ -128,7 +153,7 @@ def compose_content(self) -> ComposeResult: yield ConnectProvider() def configuration_changed(self) -> None: - if self.is_mounted and self.is_open: + if self.is_mounted and self.selection_active: self.filter_models(preserve_selection=True) def focus_content(self, scroll_visible: bool) -> None: @@ -144,7 +169,7 @@ def focus_content(self, scroll_visible: bool) -> None: async def load_recents(self, generation: int, scope: str) -> None: recent = await DB().recent_models(scope) if ( - self.is_open + self.selection_active and generation == self._open_generation and scope == self.history_scope ): @@ -159,11 +184,7 @@ def filter_models(self, *, preserve_selection: bool = False) -> None: previous_id = highlighted.id if highlighted is not None else None terms = self.search_input.value.casefold().split() recent = {model_id: index for index, model_id in enumerate(self.recent_ids)} - from toad.widgets.prompt import Prompt - agent = self.query_ancestor(Prompt).agent - setting = agent.configuration.model if agent is not None else None - models = setting.choices if setting is not None else () - current = setting.current if setting is not None else "" + models, current = self.choices, self.current ranked = [] for model in models: fields = {model.name.casefold(), model.value.casefold()} @@ -202,9 +223,9 @@ def filter_models(self, *, preserve_selection: bool = False) -> None: else: self.option_list.highlighted = None self.query_one(".model-count", Static).update( - f"{len(options)} / {len(models)} models · {'best matches' if terms else 'recent first'}" + f"{len(options)} / {len(models)} {self.choice_name} · {'best matches' if terms else 'recent first'}" if options - else "No matching models" + else f"No matching {self.choice_name}" ) @on(Input.Changed) @@ -228,10 +249,6 @@ def on_option_selected(self, event: OptionList.OptionSelected): if event.option_id is not None: self.select_model(event.option_id) - def select_model(self, model_id: str): - self.publish_core(input_events.ChangeModel(model_id)) - self.action_dismiss() - def action_cursor_up(self): self._selection_moved = True self.option_list.action_cursor_up() @@ -251,3 +268,36 @@ def action_submit(self): def action_clear_search(self): self.search_input.clear() + + +class ModelSwitcher(ModelSelection, InfoPopup): + DEFAULT_CSS = """ + ModelSwitcher { display: none; overlay: screen; } + ModelSwitcher.-open { display: block; } + """ + + @classmethod + def for_prompt(cls, prompt: Prompt) -> Self | None: + return None if prompt.simple_input else cls().data_bind(history_scope=type(prompt).model_history_scope) + + @property + def choices(self): + agent = self.prompt.agent + return agent.configuration.model.choices if agent is not None else () + + @property + def current(self): + agent = self.prompt.agent + return agent.configuration.model.current if agent is not None else "" + + @property + def selection_active(self): + return self.is_open + + def select_model(self, model_id: str): + self.publish_core(input_events.ChangeModel(model_id)) + self.action_dismiss() + + def connect_provider(self): + self.action_dismiss() + self.publish_core(input_events.ProviderLogin())