From 0f896c5f03a75b0aefc9e1ca45617f5fdfa70261 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 01:38:37 -0400 Subject: [PATCH 1/3] Carry saved arrow preferences into the native navigation declarations --- tools/cutover/carry_toad_arrow_keys.py | 114 +++++++++++++++++++++++++ tools/cutover/carry_toad_key_mode.py | 31 +++++-- 2 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 tools/cutover/carry_toad_arrow_keys.py diff --git a/tools/cutover/carry_toad_arrow_keys.py b/tools/cutover/carry_toad_arrow_keys.py new file mode 100644 index 0000000000..239e361a11 --- /dev/null +++ b/tools/cutover/carry_toad_arrow_keys.py @@ -0,0 +1,114 @@ +"""Carry retired per-control arrow identities into the native navigation declarations. + +Only installer input accepts these names. The current settings decoder and runtime +key routing do not retain aliases for them. +""" + +import re +from dataclasses import dataclass +from typing import ClassVar + +from pydantic import JsonValue +from textual.keys import DownKey, LeftKey, NavigationKey, RightKey, UpKey + +from agent_comms.declared_family import DeclaredFamily +from agent_comms.errors import RelationViolationError +from agent_comms.field_codec import FieldCodec, NominalSpelling, NominalText +from cutover.carry_toad_key_mode import ToadKeyMode, ToadSettingsCarry +from toad.key_modes import KeyMode +from toad.keymap import KeyInvocationId, Keymap, KeyRemap +from toad.preferences import ToadSettings, UiSettings + + +class PreviousInvocationId(NominalText): + """A foreign saved identity, not yet admitted by the current catalog.""" + + +class PreviousInvocationSpelling(NominalSpelling[PreviousInvocationId]): + spelling = re.compile(r".+") + + +class PreviousArrow(DeclaredFamily, affix="Arrow"): + previous: ClassVar[tuple[PreviousInvocationId, ...]] + current: ClassVar[type[NavigationKey]] + + @classmethod + def replacements(cls) -> dict[PreviousInvocationId, KeyInvocationId]: + return {previous: KeyInvocationId(member.current().key_identity()) + for member in cls.members_with(cls) for previous in member.previous} + + +class PreviousUpArrow(PreviousArrow): + previous = (PreviousInvocationId("textual.widgets._text_area.TextArea.action_cursor_up"), + PreviousInvocationId("toad.widget_actions.CursorUpAction")) + current = UpKey + + +class PreviousDownArrow(PreviousArrow): + previous = (PreviousInvocationId("textual.widgets._text_area.TextArea.action_cursor_down"), + PreviousInvocationId("toad.widget_actions.CursorDownAction")) + current = DownKey + + +class PreviousLeftArrow(PreviousArrow): + previous = (PreviousInvocationId("textual.widgets._text_area.TextArea.action_cursor_left"), + PreviousInvocationId("textual.widgets._input.Input.action_cursor_left"), + PreviousInvocationId("toad.widget_actions.CursorLeftAction")) + current = LeftKey + + +class PreviousRightArrow(PreviousArrow): + previous = (PreviousInvocationId("textual.widgets._text_area.TextArea.action_cursor_right"), + PreviousInvocationId("textual.widgets._input.Input.action_cursor_right"), + PreviousInvocationId("toad.widget_actions.CursorRightAction")) + current = RightKey + + +@dataclass(frozen=True) +class PreviousKeyRemap: + declaration: PreviousInvocationId + mode: type[KeyMode] + keys: tuple[str, ...] + + def carried(self, replacements: dict[PreviousInvocationId, KeyInvocationId]) -> KeyRemap: + reference = replacements.get(self.declaration, KeyInvocationId(str(self.declaration))) + return KeyRemap(reference, self.mode, self.keys) + + +class ToadArrowKeys(ToadSettingsCarry): + # The older step validates complete settings when changing its own retired field. + # Retired arrow references must already have crossed their boundary by then. + precedes = (ToadKeyMode,) + + def rewrite(self, document: dict[str, JsonValue]) -> int: + ui_key = FieldCodec.record_key(ToadSettings, "ui") + ui = FieldCodec.decode(dict[str, JsonValue], document.get(ui_key, {})) + keymap_key = FieldCodec.record_key(UiSettings, "keymap") + keymap = FieldCodec.decode(dict[str, JsonValue], ui.get(keymap_key, {})) + bindings_key = FieldCodec.record_key(Keymap, "bindings") + previous = FieldCodec.decode(tuple[PreviousKeyRemap, ...], keymap.get(bindings_key, [])) + replacements = PreviousArrow.replacements() + changed = sum(row.declaration in replacements for row in previous) + if not changed: + return 0 + destinations = frozenset(replacements.values()) + arrows: dict[tuple[KeyInvocationId, type[KeyMode]], KeyRemap] = {} + carried: list[KeyRemap] = [] + for row in previous: + candidate = row.carried(replacements) + if candidate.declaration in destinations: + identity = candidate.declaration, candidate.mode + existing = arrows.get(identity) + if existing is not None: + if existing.keys != candidate.keys: + raise RelationViolationError( + f"Conflicting saved arrow choices for {candidate.declaration} " + f"in {candidate.mode.declared_name}: {existing.keys!r} vs {candidate.keys!r}" + ) + continue + arrows[identity] = candidate + carried.append(candidate) + keymap[bindings_key] = FieldCodec.encode(tuple(carried), tuple[KeyRemap, ...]) + ui[keymap_key] = keymap + document[ui_key] = ui + return changed diff --git a/tools/cutover/carry_toad_key_mode.py b/tools/cutover/carry_toad_key_mode.py index 42a7d3ae17..4842d00dff 100644 --- a/tools/cutover/carry_toad_key_mode.py +++ b/tools/cutover/carry_toad_key_mode.py @@ -1,5 +1,6 @@ """Carry the saved Vim preference before the editable keymap replaces its wire field.""" +from abc import abstractmethod from dataclasses import dataclass from typing import Annotated, ClassVar @@ -25,10 +26,16 @@ def mode(self) -> type[KeyMode]: return VimNormal if self.vim_keys else Standard -class ToadKeyMode(Carry): +class ToadSettingsCarry(Carry): + """One confined saved-settings write, shared by its retired-input carries.""" + opened_files: ClassVar[tuple[type[RootFile], ...]] = () writer_owned = (SavedSettings,) + @abstractmethod + def rewrite(self, document: dict[str, JsonValue]) -> int: + """Rewrite this member's retired input in memory, or report no change.""" + def carry(self, *, apply: bool) -> Carried: resource, = SavedSettings.resources(self.files) path = self.files.location(resource) @@ -36,19 +43,27 @@ def carry(self, *, apply: bool) -> Carried: if not path.exists(): return Carried(0) document = FieldCodec.decode(dict[str, JsonValue], FieldCodec.parse(path.read_text())) + changed = self.rewrite(document) + if not changed: + # A later carry may still own retired fields in this document. + return Carried(0) + FieldCodec.decode(ToadSettings, document) + if apply: + AtomicFile(path).write_text(FieldCodec.compact(document, allow_nan=False)) + return Carried(changed, rewritten=(resource.path,) if apply else ()) + + +class ToadKeyMode(ToadSettingsCarry): + def rewrite(self, document: dict[str, JsonValue]) -> int: ui_key = FieldCodec.record_key(ToadSettings, "ui") ui = FieldCodec.decode(dict[str, JsonValue], document.get(ui_key, {})) previous_key = FieldCodec.record_key(PreviousVimPreference, "vim_keys") if previous_key not in ui: - FieldCodec.decode(ToadSettings, document) - return Carried(0) + return 0 mode_key = FieldCodec.record_key(UiSettings, "key_mode") if mode_key in ui: - raise RelationViolationError(f"{path}: both old and current keyboard preferences exist") + raise RelationViolationError("Both old and current keyboard preferences exist") previous = FieldCodec.decode(PreviousVimPreference, {previous_key: ui.pop(previous_key)}) ui[mode_key] = FieldCodec.encode(previous.mode(), type[KeyMode]) document[ui_key] = ui - FieldCodec.decode(ToadSettings, document) - if apply: - AtomicFile(path).write_text(FieldCodec.compact(document, allow_nan=False)) - return Carried(1, rewritten=(resource.path,) if apply else ()) + return 1 From 1013b069222de401b9da72380e301181f74a2d65 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 01:48:44 -0400 Subject: [PATCH 2/3] Convert all retired settings before validating and publishing once --- tools/cutover/carry_toad_arrow_keys.py | 6 +----- tools/cutover/carry_toad_key_mode.py | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/tools/cutover/carry_toad_arrow_keys.py b/tools/cutover/carry_toad_arrow_keys.py index 239e361a11..b7507c6551 100644 --- a/tools/cutover/carry_toad_arrow_keys.py +++ b/tools/cutover/carry_toad_arrow_keys.py @@ -14,7 +14,7 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.errors import RelationViolationError from agent_comms.field_codec import FieldCodec, NominalSpelling, NominalText -from cutover.carry_toad_key_mode import ToadKeyMode, ToadSettingsCarry +from cutover.carry_toad_key_mode import ToadSettingsCarry from toad.key_modes import KeyMode from toad.keymap import KeyInvocationId, Keymap, KeyRemap from toad.preferences import ToadSettings, UiSettings @@ -76,10 +76,6 @@ def carried(self, replacements: dict[PreviousInvocationId, KeyInvocationId]) -> class ToadArrowKeys(ToadSettingsCarry): - # The older step validates complete settings when changing its own retired field. - # Retired arrow references must already have crossed their boundary by then. - precedes = (ToadKeyMode,) - def rewrite(self, document: dict[str, JsonValue]) -> int: ui_key = FieldCodec.record_key(ToadSettings, "ui") ui = FieldCodec.decode(dict[str, JsonValue], document.get(ui_key, {})) diff --git a/tools/cutover/carry_toad_key_mode.py b/tools/cutover/carry_toad_key_mode.py index 4842d00dff..3136137fb5 100644 --- a/tools/cutover/carry_toad_key_mode.py +++ b/tools/cutover/carry_toad_key_mode.py @@ -43,9 +43,9 @@ def carry(self, *, apply: bool) -> Carried: if not path.exists(): return Carried(0) document = FieldCodec.decode(dict[str, JsonValue], FieldCodec.parse(path.read_text())) - changed = self.rewrite(document) + changed = sum(member(self.files, self.spill_dir).rewrite(document) + for member in ToadSettingsCarry.members_with(ToadSettingsCarry)) if not changed: - # A later carry may still own retired fields in this document. return Carried(0) FieldCodec.decode(ToadSettings, document) if apply: From 3aae0ae8cec96062354ddf804dadee3fa40c0067 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 02:00:05 -0400 Subject: [PATCH 3/3] Use direct duplicate-key lookup in retired arrow conversion --- tools/cutover/carry_toad_arrow_keys.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/cutover/carry_toad_arrow_keys.py b/tools/cutover/carry_toad_arrow_keys.py index b7507c6551..f7a2f47ba0 100644 --- a/tools/cutover/carry_toad_arrow_keys.py +++ b/tools/cutover/carry_toad_arrow_keys.py @@ -21,7 +21,7 @@ class PreviousInvocationId(NominalText): - """A foreign saved identity, not yet admitted by the current catalog.""" + """An old saved action name accepted only by this installer input decoder.""" class PreviousInvocationSpelling(NominalSpelling[PreviousInvocationId]): @@ -94,8 +94,8 @@ def rewrite(self, document: dict[str, JsonValue]) -> int: candidate = row.carried(replacements) if candidate.declaration in destinations: identity = candidate.declaration, candidate.mode - existing = arrows.get(identity) - if existing is not None: + if identity in arrows: + existing = arrows[identity] if existing.keys != candidate.keys: raise RelationViolationError( f"Conflicting saved arrow choices for {candidate.declaration} "