Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0221a90
Opt prompt editing into native modal capabilities
trissim Oct 15, 2026
656d30a
Use declared action availability for Vim navigation aliases
trissim Oct 15, 2026
70de518
Expose existing row tab and link effects to native hints
trissim Oct 15, 2026
0c70eae
Merge remote-tracking branch 'origin/main' into feat/vim-navigation
trissim Oct 15, 2026
d9be7f4
Use native workspace mode for session navigation eligibility
trissim Oct 15, 2026
1806ea7
Pin publication-admitted native hint lifecycle
trissim Oct 15, 2026
10c70f5
Separate native modal register paste from explicit external paste
trissim Oct 15, 2026
150afd7
Merge remote-tracking branch 'origin/main' into feat/vim-navigation
trissim Oct 15, 2026
5a35c6b
Pin exact displayed-label completion for native hints
trissim Oct 15, 2026
1733c73
Pin shared native visual ranges and modal command lifecycle
trissim Oct 15, 2026
287a22c
Expose existing primary effects across visible custom controls
trissim Oct 15, 2026
1f38691
Share opt-in modal hint bindings through the native screen owner
trissim Oct 15, 2026
056a0db
Merge remote-tracking branch 'origin/main' into feat/vim-navigation
trissim Oct 15, 2026
d36613b
Merge remote-tracking branch 'origin/main' into feat/vim-navigation
trissim Oct 15, 2026
d3a997a
Preserve native copy selection and modal shell-entry eligibility
trissim Oct 15, 2026
4a374eb
Let the Ctrl-C action arbitrate native copy before draft clearing
trissim Oct 15, 2026
dd25131
Merge remote-tracking branch 'origin/main' into feat/vim-navigation
trissim Oct 15, 2026
8c264fb
Pin hint custody-before-mount correction
trissim Oct 15, 2026
a20d3d4
Integrate opt-in Vim editing and shared hint actions
trissim Oct 16, 2026
c6a5482
Pin single-owner hint lifecycle on the integrated Vim pair
trissim Oct 16, 2026
321eab0
Merge remote-tracking branch 'origin/main' into feat/vim-navigation
trissim Oct 16, 2026
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ members = ["toad"]
[tool.uv.sources]
ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "5e58d8b5907cec4dbc8810a9329aaf902f69f675" }
agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "47f9856b6762553ca10ef94e26de85b2ba069c0d" }
textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "17a3183b8d5d3a6af950b01ca95f4cac6f2a67e7" }
textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "18f4e7a67403d56846ef5abf55c93e3e0639be33" }
textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }

[project.scripts]
Expand Down
49 changes: 15 additions & 34 deletions src/toad/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
from agent_comms.acp_extension import CoordinationChangedUpdate, TranscriptChangedUpdate
from textual._time import after_input
from textual import events, on, work
from textual.actions import ActionError, parse as parse_action
from textual.app import App
from textual.await_complete import AwaitComplete
from textual.content import Content
Expand Down Expand Up @@ -54,7 +53,8 @@
from toad.session_navigation import SessionNavigation
from toad.terminal_attention import TerminalAttention
from toad.textual_colours import ThemedApp
from toad.application_actions import ApplicationAction, ApplicationCommands, KeyboundAction
from toad.application_actions import ApplicationAction, ApplicationCommands
from toad.widget_actions import DeclaredWidgetActions
from toad.application_lifetime import ApplicationLifetime

if TYPE_CHECKING:
Expand Down Expand Up @@ -247,14 +247,14 @@ class ApplicationNotRunningError(RuntimeError):
"""The application has no event loop to accept cross-thread work."""


class ToadApp(CoreEventReceiver, WorkspaceSessionShutdown, ThemedApp, App, inherit_bindings=False):
class ToadApp(CoreEventReceiver, DeclaredWidgetActions, WorkspaceSessionShutdown, ThemedApp, App, inherit_bindings=False):
"""The top level app."""

CSS_PATH = ["toad.tcss", "screens/comms.tcss"]
COMMANDS = {ApplicationCommands}
MODES = {"store": get_store_screen, "workspace": get_workspace_screen}
BINDING_GROUP_TITLE = "System"
BINDINGS = [member.binding() for member in ApplicationAction.members_with(KeyboundAction)]
ACTIONS = ApplicationAction
ALLOW_IN_MAXIMIZED_VIEW = ""

project_dir = var(Path)
Expand Down Expand Up @@ -371,8 +371,8 @@ async def get_db(self) -> DB:
def session_tracker(self) -> SessionTracker:
return self._session_tracker

def copy_to_clipboard(self, text: str) -> None:
self.clipboard_transport = self.clipboard_transport.copy(self, text)
def publish_clipboard(self, text: str) -> None:
self.clipboard_transport = self.clipboard_transport.publish(self, text)


def on_notify(self, event: Notify) -> None:
Expand Down Expand Up @@ -612,17 +612,6 @@ def history_displayed(self) -> None:
async def on_workspace_session_request(self, event: CoreEventMessage) -> None:
await event.event.apply(self.session_navigation)

async def _dispatch_action(self, namespace, action_name: str, params) -> bool:
if namespace is self:
try:
declaration = ApplicationAction.decode(action_name)
except ValueError:
pass # Remaining names belong to Textual's own action contract.
else:
await declaration.parse(params).apply(self)
return True
return await super()._dispatch_action(namespace, action_name, params)

@on(events.TextSelected)
async def on_text_selected(self) -> None:
if self.settings.ui.auto_copy:
Expand All @@ -633,27 +622,19 @@ async def on_text_selected(self) -> None:
title="Automatic copy",
)

async def _broker_event(self, event_name, event, default_namespace) -> bool:
"""Offer copy-path on right-click without activating a file link."""
if event_name == "click" and isinstance(event, events.Click) and event.button == 3:
async def dispatch_style_action(self, handler, event, default_namespace) -> bool:
"""Right-click adds its menu to the original decoded link action."""
if isinstance(event, events.Click) and event.button == 3:
from toad.line_blocks import LineLink
action = event.style.meta.get("@click")
link = None
if isinstance(action, tuple):
match action:
case ("open", (LineLink() as target,)):
link = target
elif isinstance(action, str):
try:
parsed = parse_action(action)
except ActionError:
return await super()._broker_event(event_name, event, default_namespace)
match parsed:
case ("", "link", (str() as href,)) if href.startswith("toad-link:"):
link = LineLink.from_href(href)
match handler.action:
case ("", "open", (LineLink() as target,)):
link = target
case ("", "link", (str() as href,)) if href.startswith("toad-link:"):
link = LineLink.from_href(href)
if link is not None and await link.copy_menu(default_namespace, event):
return True
return await super()._broker_event(event_name, event, default_namespace)
return await super().dispatch_style_action(handler, event, default_namespace)

def _set_mouse_over(self, widget, hover_widget) -> None:
# Textual normally clears and reapplies the *same* :hover styles on
Expand Down
122 changes: 114 additions & 8 deletions src/toad/application_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class KeyboundAction:

@classmethod
def binding(cls) -> Binding:
return Binding(cls.key, FieldCodec.encode(cls), cls.description, tooltip=cls.tooltip,
return Binding(cls.key, cls.binding_name(), cls.description, tooltip=cls.tooltip,
show=cls.show, priority=cls.priority, system=cls.system,
group=cls.group, key_display=cls.key_display)

Expand All @@ -51,6 +51,12 @@ def label(self, app: ToadApp) -> str: ...
class NativeAction(Command, Generic[Context]):
"""Only the Textual boundary decodes an external action name."""

binding_prefix: ClassVar[str] = ""

@classmethod
def binding_name(cls) -> str:
return cls.binding_prefix + FieldCodec.encode(cls)

@classmethod
def parse(cls, parameters: tuple[object, ...]):
if parameters:
Expand All @@ -69,10 +75,12 @@ async def apply(self, context: Context) -> None: ...


class ApplicationAction(NativeAction, DeclaredFamily, affix="Action"):
"""Application action membership is owned by its declarations."""
"""Application action membership and native namespace belong here."""

binding_prefix = "app."

class SettingsAction(ApplicationAction, KeyboundAction, PaletteAction):

class SettingsAction(KeyboundAction, ApplicationAction, PaletteAction):
key = "f2,ctrl+comma"
description = "Settings"
tooltip = "Settings screen"
Expand Down Expand Up @@ -118,7 +126,7 @@ async def apply(self, app: ToadApp) -> None:
await SetFooterAction(not app.settings.ui.footer).apply(app)


class QuitAction(ApplicationAction, KeyboundAction, PaletteAction):
class QuitAction(KeyboundAction, ApplicationAction, PaletteAction):
key = "ctrl+q"
description = "Quit"
tooltip = "Quit the app and return to the command prompt."
Expand All @@ -132,15 +140,15 @@ async def apply(self, app: ToadApp) -> None:
app.application.quit()


class HelpQuitAction(ApplicationAction, KeyboundAction):
class HelpQuitAction(KeyboundAction, ApplicationAction):
key = "ctrl+c"
system = True

async def apply(self, app: ToadApp) -> None:
app.application.confirm_quit()


class ToggleHelpPanelAction(ApplicationAction, KeyboundAction, PaletteAction):
class ToggleHelpPanelAction(KeyboundAction, ApplicationAction, PaletteAction):
key = "f1"
description = "Help"
priority = True
Expand All @@ -156,7 +164,7 @@ async def apply(self, app: ToadApp) -> None:
app.action_show_help_panel()


class SessionsAction(ApplicationAction, KeyboundAction, PaletteAction):
class SessionsAction(KeyboundAction, ApplicationAction, PaletteAction):
key = "ctrl+s"
description = "Channels"
show = True
Expand All @@ -169,7 +177,7 @@ async def apply(self, app: ToadApp) -> None:
app.run_worker(app.session_navigation.reveal)


class DirectMessagesAction(ApplicationAction, KeyboundAction, PaletteAction):
class DirectMessagesAction(KeyboundAction, ApplicationAction, PaletteAction):
"""Reach the pairwise message view without changing thread-row navigation."""

key = "ctrl+shift+d"
Expand All @@ -184,6 +192,104 @@ async def apply(self, app: ToadApp) -> None:
app.session_navigation.direct_messages()


class VimApplicationAction(KeyboundAction, ApplicationAction):
def available(self, app):
return app.settings.ui.vim_keys and app.current_mode == "workspace"


class VimNavigationAction(VimApplicationAction):
"""Aliases act on the selected view; focus and native input retain custody."""

@staticmethod
def conversation(app):
from toad.widgets.conversation import Conversation
view = app.selected_session
return None if view is None else view.query_one_optional(Conversation)

def available(self, app):
return super().available(app) and self.conversation(app) is not None


class VimScrollDownAction(VimNavigationAction):
key = "j"

async def apply(self, app):
self.conversation(app).window.action_scroll_down()


class VimScrollUpAction(VimNavigationAction):
key = "k"

async def apply(self, app):
self.conversation(app).window.action_scroll_up()


class VimScrollLeftAction(VimNavigationAction):
key = "h"

async def apply(self, app):
self.conversation(app).window.action_scroll_left()


class VimScrollRightAction(VimNavigationAction):
key = "l"

async def apply(self, app):
self.conversation(app).window.action_scroll_right()


class VimPageDownAction(VimNavigationAction):
key = "ctrl+d"

async def apply(self, app):
self.conversation(app).window.action_page_down()


class VimPageUpAction(VimNavigationAction):
key = "ctrl+u"

async def apply(self, app):
self.conversation(app).window.action_page_up()


class VimLatestAction(VimNavigationAction):
key = "G"

async def apply(self, app):
self.conversation(app).jump_to_latest()


class VimFocusPromptAction(VimNavigationAction):
key = "i"

async def apply(self, app):
from textual.widgets.text_area import VimInsert
conversation = self.conversation(app)
conversation.prompt.prompt_text_area.editing_mode = VimInsert()
conversation.focus_prompt(reset_cursor=False, scroll_end=False)


class VimHintsAction(VimApplicationAction):
key = "f"

async def apply(self, app):
await app.screen.action_show_hints()


class VimNextTabAction(VimNavigationAction):
key = "J"

async def apply(self, app):
app.selected_session.action_session_next()


class VimPreviousTabAction(VimNavigationAction):
key = "K"

async def apply(self, app):
app.selected_session.action_session_previous()


class ApplicationCommands(Provider):
"""Discovery and search derive the same capability view; no command roster."""

Expand Down
19 changes: 7 additions & 12 deletions src/toad/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from agent_comms.declared_family import DeclaredFamily
from pyperclip import PyperclipException
from textual.app import App
from textual._clipboard import ClipboardContent


class Clipboard(DeclaredFamily, affix="Clipboard"):
Expand All @@ -21,19 +22,13 @@ def for_platform(cls) -> Clipboard:
else TerminalClipboard()
)

def copy(self, app: App, text: str) -> Clipboard:
transport = self.publish(app, text)
# This is Textual's existing local-value authority, not another cache.
app._clipboard = text
return transport

@abstractmethod
def publish(self, app: App, text: str) -> Clipboard: ...

def read(self, app: App) -> str:
return app.clipboard
def read(self, app: App) -> ClipboardContent:
return app.clipboard_content

async def paste(self, app: App) -> str:
async def paste(self, app: App) -> ClipboardContent:
return await asyncio.to_thread(self.read, app)


Expand All @@ -51,14 +46,14 @@ def publish(self, app: App, text: str) -> Clipboard:
return TerminalClipboard().publish(app, text)
return self

def read(self, app: App) -> str:
def read(self, app: App) -> ClipboardContent:
try:
return self.native_paste()
return ClipboardContent(self.native_paste())
except OSError, PyperclipException:
return super().read(app)


class TerminalClipboard(Clipboard):
def publish(self, app: App, text: str) -> Clipboard:
App.copy_to_clipboard(app, text)
App.publish_clipboard(app, text)
return self
11 changes: 10 additions & 1 deletion src/toad/conversation_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,21 @@ class CancelAction(KeyboundAction, ConversationAction):
description = "Cancel"
tooltip = "Cancel agent's turn"
show = True
vim_profile = False

def available(self, conversation):
if conversation.app.settings.ui.vim_keys != self.vim_profile:
return False
return True if conversation.agent and conversation.turns.owner.can_cancel else None

async def apply(self, conversation):
conversation.cancel_turn()
conversation.cancel_turn(self.key_display or self.key)


class VimCancelAction(CancelAction):
key = "alt+ctrl+x"
key_display = "Ctrl+Alt+X"
vim_profile = True


class ExpandBlockAction(KeyboundAction, ConversationAction):
Expand Down
5 changes: 5 additions & 0 deletions src/toad/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ class UiSettings(AnnotatedDataclassValidationMixin):
theme: Annotated[str, SchemeName(), effects.theme] = "wal"
"""wal follows pywal's palette in true colour. ANSI themes inherit your terminal palette."""

vim_keys: Annotated[bool, FieldLabel("Vim editing and navigation"), effects.vim_keys] = field(
default=False, metadata=saved_as("vim-keys")
)
"""Opt in to modal prompts. Escape leaves Insert; Normal Enter never sends. Ctrl+Alt+X cancels a turn."""

prompt_message: Annotated[str, FieldLabel("Prompt message")] = "How can I help you today?"
"""Text shown as placeholder in prompt text area"""

Expand Down
Loading
Loading