Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a3f6a09
Move grid and workspace keys onto existing declaration owners
trissim Oct 29, 2026
9440565
Derive permission and sidebar navigation from the shared key family
trissim Oct 29, 2026
a02a7d6
Type the shared keymap declaration contexts
trissim Oct 29, 2026
4e568c8
fix: publish captured block menus with their paired Textual source
Oct 30, 2026
eba46da
fix: derive Menu catalog claims from their native scope owner
Oct 30, 2026
61fbdde
Merge remote-tracking branch 'origin/main' into work/toad-keymap--cen…
trissim Oct 30, 2026
7e28260
Declare keyed Menu defaults and close navigation imports
trissim Oct 30, 2026
8077e06
Merge remote-tracking branch 'origin/main' into work/toad-keymap--cen…
trissim Oct 30, 2026
bf1ba2a
Use the native declaration class contract and inherited table navigation
trissim Oct 30, 2026
69be87d
Merge remote-tracking branch 'origin/main' into claude-board/keymap-1338
trissim Oct 30, 2026
6c1731e
Migrate the block-menu test to MenuCommand and share ExclusiveKeys fo…
trissim Oct 30, 2026
fdb59c5
Merge remote-tracking branch 'origin/main' into claude-board/keymap-1338
trissim Oct 30, 2026
591f133
Give block-menu commands declared contracts instead of checker-only i…
trissim Oct 30, 2026
0735d06
Pass the key mode itself to the menu's key lookup, not its metaclass
trissim Oct 30, 2026
e772494
Keep the menu target's members on the session binding, not on Convers…
trissim Oct 30, 2026
74b9af8
Merge remote-tracking branch 'origin/main' into claude-board/keymap-1338
trissim Oct 30, 2026
61614d9
Declare contents on the session binding that now reads it
trissim Oct 30, 2026
d0469d6
Fold the copied grid cursor walks into one move, without None branches
trissim Oct 30, 2026
e2e88b4
Type the grid cursor leave message as its constructor
trissim Oct 30, 2026
657e57b
Name the grid cursor leave constructor with Self, not a string
trissim Oct 30, 2026
011fdcf
Give the menu contract plain names and count only string-found actions
trissim Oct 30, 2026
d77f0f3
Match the block export where the render may be absent
trissim Oct 30, 2026
2133e5f
Restore permission j/k and delete what the menu move left dead
trissim Oct 31, 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
32 changes: 30 additions & 2 deletions src/toad/block_content.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
"""Declared block capabilities shared by scene and original source consumers."""
from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass
from functools import cached_property

from textual.widgets import _markdown as native_markdown

from toad.menus import MenuItem
from toad.menus import KeyedMenuCommand, MenuCommand, MenuConversation
from toad.widget_actions import ExclusiveKeys


class BlockContent:
Expand All @@ -17,7 +21,7 @@ def block_cursor(self):

return AtomicBlockCursor(self)

def get_block_menu(self) -> Iterable[MenuItem]:
def get_block_menu(self) -> Iterable[MenuCommand]:
return ()

def get_clipboard_text(self) -> str | None:
Expand All @@ -39,6 +43,30 @@ def collapse_block(self) -> None:
pass


class ExpansionCommand(ExclusiveKeys, KeyedMenuCommand, declared_name=None):
"""Collapse and expand alternate on one block, so they share their key."""


@dataclass(frozen=True)
class CollapseCommand(ExpansionCommand):
block: BlockContent
key = "x"
description = "Collapse"

def apply(self, conversation: MenuConversation) -> None:
self.block.collapse_block()


@dataclass(frozen=True)
class ExpandCommand(ExpansionCommand):
block: BlockContent
key = "x"
description = "Expand"

def apply(self, conversation: MenuConversation) -> None:
self.block.expand_block()


class MarkdownBlockContent(BlockContent):
"""Declared native blocks share source-copy and menu capabilities."""

Expand Down
8 changes: 4 additions & 4 deletions src/toad/block_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ def show_clicked(self, window: HistoryWindow) -> None:
animate=False, immediate=True,
)

def export_render(self):
return None
def export_to_svg(self, conversation: Conversation) -> None:
"""Only a cursor resting on one drawn block has something to export."""


class NoBlockCursor(BlockCursor):
Expand Down Expand Up @@ -199,12 +199,12 @@ def visible_region(self):
def show_moved_to(self, window: HistoryWindow):
window.scroll_to_center(self.block, immediate=True)

def export_render(self):
def export_to_svg(self, conversation: Conversation) -> None:
from textual._compositor import Compositor

compositor = Compositor()
compositor.reflow(self.block, self.block.outer_size)
return self.block.outer_size, compositor.render_full_update()
conversation.open_render_as_svg(self.block.outer_size, compositor.render_full_update())


class FragmentBlockCursor(BlockCursor):
Expand Down
3 changes: 1 addition & 2 deletions src/toad/conversation_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ def available(self, conversation: Conversation) -> bool:
return bool(conversation.terminal_action_state)

async def apply(self, conversation: Conversation) -> None:
await conversation.shell.interrupt()
conversation.feedback.flash("Command interrupted", style="success")
await conversation.interrupt_shell()



Expand Down
37 changes: 22 additions & 15 deletions src/toad/keymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@
from agent_comms.declared_family import DeclaredFamily
from agent_comms.field_codec import NominalSpelling, NominalText
from objectstate import mark_ui_hidden_config
from textual.binding import Binding, BindingsMap
from textual.binding import KeymapClaim
from textual.dom import DOMNode
from textual.keys import KeyInvocation
from textual.screen import ModalScreen

import toad
from toad.key_modes import KeyMode
from toad.menus import MenuCommand
from toad.setting_declarations import Effect, FamilyChoices
from toad.widget_actions import ApplicationAction

Expand Down Expand Up @@ -97,37 +98,44 @@ class KeymapCatalog:
"""A detached view of the same concrete bindings the native scopes dispatch."""

invocations: Mapping[KeyInvocationId, KeyInvocation]
scopes: Mapping[type[KeyMode], tuple[tuple[Binding, ...], ...]]
scopes: Mapping[type[KeyMode], tuple[tuple[KeymapClaim, ...], ...]]
default_keys: Mapping[tuple[KeyInvocationId, type[KeyMode]], tuple[str, ...]]

@classmethod
def capture(cls, modes: tuple[type[KeyMode], ...]) -> KeymapCatalog:
discover()
invocations: dict[KeyInvocationId, KeyInvocation] = {}
scopes: dict[type[KeyMode], tuple[tuple[Binding, ...], ...]] = {}
scopes: dict[type[KeyMode], tuple[tuple[KeymapClaim, ...], ...]] = {}
keys: dict[tuple[KeyInvocationId, type[KeyMode]], dict[str, None]] = {}
for mode in modes:
native_mode = mode().binding_mode
application = tuple(binding for declaration in ApplicationAction.declarations()
for binding in declaration.exclusive_bindings(native_mode))
application = tuple(KeymapClaim.from_bindings(
binding for declaration in ApplicationAction.declarations()
for binding in declaration.exclusive_bindings(native_mode)
))
owned = [application]
for owner in DOMNode.contexts():
defaults = tuple(binding for _, binding in owner.binding_defaults(native_mode))
defaults = owner.keymap_claims(native_mode)
if not issubclass(owner, ModalScreen):
defaults = (*defaults, *application)
owned.append(defaults)
scopes[mode] = tuple(owned)
for defaults in owned:
for key, binding in BindingsMap(defaults):
for invocation in binding.invocations():
reference = KeyInvocationId(invocation.key_identity())
invocations.setdefault(reference, invocation)
for claim in defaults:
reference = KeyInvocationId(claim.invocation.key_identity())
invocations.setdefault(reference, claim.invocation)
for key in claim.keys:
keys.setdefault((reference, mode), {})[key] = None
return cls(MappingProxyType(invocations), MappingProxyType(scopes), MappingProxyType({
(reference, mode): tuple(keys.get((reference, mode), ()))
for reference in invocations for mode in modes
}))

@staticmethod
def menu_keys(command: MenuCommand, mode: type[KeyMode], keymap: Keymap) -> tuple[str, ...]:
declaration = type(command)
return keymap.keys_for(KeymapRow.of(declaration, mode), declaration.default_keys())

def require(self, reference: KeyInvocationId) -> KeyInvocation:
try:
return self.invocations[reference]
Expand Down Expand Up @@ -190,11 +198,10 @@ def conflicts(self, mode: type[KeyMode]) -> tuple[KeyConflict, ...]:
for defaults in catalog.scopes[mode]:
# Inspect before Textual resolves clashes by replacing a conflicting binding.
claims: dict[str, set[str]] = {}
for default_key, binding in BindingsMap(defaults):
if binding.id in rows:
keys = self.keys_for(rows[binding.id], (default_key,))
for key in keys:
claims.setdefault(key, set()).add(binding.id)
for claim in defaults:
row = KeymapRow.of(claim.invocation, mode)
for key in self.keys_for(row, claim.keys):
claims.setdefault(key, set()).add(row.binding_id)
for key, identities in claims.items():
claimed = tuple(sorted(identities))
for index, first in enumerate(claimed):
Expand Down
203 changes: 198 additions & 5 deletions src/toad/menus.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,202 @@
from typing import NamedTuple
"""Block-menu declarations and their captured native operations."""

from __future__ import annotations

class MenuItem(NamedTuple):
"""An entry in a Menu."""
import inspect
from abc import abstractmethod
from dataclasses import dataclass
from typing import ClassVar

from agent_comms.declared_family import DeclaredFamily
from textual._callback import invoke
from textual.binding import ModeBinding
from textual.document._editing import EditingMode
from textual.keys import KeyDeclarationType, KeyInvocation
from textual.widget import Widget

from toad.line_blocks import LineLink
from toad.widget_actions import KeyFamilyMeta


class MenuConversation:
"""What a block-menu command may ask of the conversation it was opened in."""

@abstractmethod
def accepts_menu_owner(self, owner: Widget) -> bool: ...

@abstractmethod
def copy_block_to_clipboard(self) -> None: ...

@abstractmethod
def copy_block_to_prompt(self) -> None: ...

@abstractmethod
def export_block_to_svg(self) -> None: ...

@abstractmethod
def maximize_block(self) -> None: ...

@abstractmethod
async def interrupt_shell(self) -> None: ...

@abstractmethod
def open_line_link(self, link: LineLink) -> None: ...


class MenuOwner:
"""The control a command was captured by, and where its effect lands."""

conversation: MenuConversation
block: Widget

@abstractmethod
def activate_command(self, command: MenuCommand) -> None: ...


class MenuDeclaration(KeyFamilyMeta, KeyInvocation):
"""The declaration, not captured instance parameters, owns keyboard identity."""

description: str
action: str | None
key: str | None = None

def key_identity(cls) -> str:
return KeyInvocation.qualified(cls)

def key_title(cls) -> str:
return cls.description

def key_summary(cls) -> str:
return inspect.cleandoc(cls.__doc__ or "")

def key_system(cls) -> bool:
return False

def permits_invocation_overlap(cls, other: KeyInvocation) -> bool:
return other.overlaps_declaration(cls)

def overlaps_declaration(cls, other: KeyDeclarationType) -> bool:
return cls.permits_key_overlap(other)


class MenuBinding(ModeBinding):
action: MenuCommand

@property
def invocation(self) -> KeyInvocation:
return type(self.action)


class MenuCommand(DeclaredFamily, metaclass=MenuDeclaration, affix="Command"):
"""Every block operation checks the captured menu context before acting."""

description: ClassVar[str]

@classmethod
def default_keys(cls) -> tuple[str, ...]:
return ()

def bindings(self, mode: type[EditingMode]) -> tuple[ModeBinding, ...]:
return tuple(
binding
for key in type(self).default_keys()
for binding in MenuBinding(
key=key,
action=self,
mode=EditingMode,
description=self.description,
show=False,
).for_mode(mode)
)

def __call__(self, menu: MenuOwner) -> None:
menu.activate_command(self)

async def run(self, owner: MenuOwner) -> None:
conversation = owner.conversation
if conversation.accepts_menu_owner(owner.block):
await invoke(self.apply, conversation)

@abstractmethod
def apply(self, conversation: MenuConversation) -> object: ...


class KeyedMenuCommand(MenuCommand, declared_name=None):
"""A command with a declared default key supplies it through inheritance."""

key: ClassVar[str]

@classmethod
def default_keys(cls) -> tuple[str, ...]:
return (cls.key,)


class CopyClipboardCommand(KeyedMenuCommand):
key = "c"
description = "[u]C[/]opy to clipboard"

def apply(self, conversation: MenuConversation) -> None:
conversation.copy_block_to_clipboard()


class CopyPromptCommand(KeyedMenuCommand):
key = "p"
description = "Co[u]p[/u]y to prompt"

def apply(self, conversation: MenuConversation) -> None:
conversation.copy_block_to_prompt()


class ExportSvgCommand(KeyedMenuCommand):
key = "v"
description = "Open as S[u]V[/]G"

def apply(self, conversation: MenuConversation) -> None:
conversation.export_block_to_svg()


class MaximizeBlockCommand(KeyedMenuCommand):
key = "m"
description = "[u]M[/u]aximize"

def apply(self, conversation: MenuConversation) -> None:
conversation.maximize_block()


class CopyThinkingCommand(CopyClipboardCommand):
key = "y"
description = "Copy thinking"


@dataclass(frozen=True)
class FocusBlockCommand(KeyedMenuCommand):
block: Widget
key = "f"
description = "Focus"

def apply(self, conversation: MenuConversation) -> None:
self.block.focus()


class InterruptCommand(KeyedMenuCommand):
key = "i"
description = "Interrupt"

async def apply(self, conversation: MenuConversation) -> None:
await conversation.interrupt_shell()


@dataclass(frozen=True)
class OpenLinkCommand(MenuCommand, declared_name=None):
link: LineLink

def apply(self, conversation: MenuConversation) -> None:
conversation.open_line_link(self.link)


class OpenSenderCommand(OpenLinkCommand, KeyedMenuCommand):
key = "s"
description = "Open [u]s[/u]ender"


class OpenDestinationCommand(OpenLinkCommand, KeyedMenuCommand):
key = "d"
description = "Open [u]d[/u]estination"
Loading
Loading