Skip to content

Prefix Keys

A prefix key changes what the next key does, as tmux's Ctrl+B does: Ctrl+B, then c, opens a pane, while c on its own is typed into the focused one. Vim's modes and Emacs's chords are the same thing with more states. This one has panes that take typing, and Ctrl+B followed by c to open a pane, n to focus the next, or x to close one. Any other key after Ctrl+B is dropped.

@dataclass(frozen=True, slots=True)
class Pane:
    number: int
    handle: FocusHandle


def first_pane() -> tuple[Pane, ...]:
    return (Pane(number=1, handle=FocusHandle()),)


@component
def pane(number: int, handle: FocusHandle, prefix: InputHandler) -> Text:
    focus = use_focus(handle=handle, auto_focus=True)
    typed, set_typed = use_state("")

    def type_key(event: InputEvent, _: Receiver) -> InputResponse:
        match event:
            case KeyPressed(key=key) if len(key) == 1:
                set_typed(lambda typed: typed + key)
                return ()
            case _:
                return None

    return Text(
        content=f"{number}: {typed}",
        style=fill(1)
        | border
        | border_lightrounded
        | (border_color("amber", 400) if focus.is_focused else border_color("slate", 600)),
        on_input=first(prefix, type_key),
    )


@component
def root() -> Div:
    panes, set_panes = use_state(first_pane)
    is_armed = use_ref(False)
    shows_armed, set_shows_armed = use_state(False)

    def arm(value: bool) -> None:
        is_armed.current = value
        set_shows_armed(value)

    def prefixed(key: str, focused: FocusHandle | None) -> InputResponse:
        match key:
            case "c":
                handle = FocusHandle()
                set_panes(
                    lambda panes: (
                        *panes,
                        Pane(number=max((pane.number for pane in panes), default=0) + 1, handle=handle),
                    )
                )
                return FocusOn(handle)
            case "n":
                return FocusNext()
            case "x":
                set_panes(lambda panes: tuple(pane for pane in panes if pane.handle != focused))
                return FocusNext()
            case _:
                return ()

    def prefix(event: InputEvent, receiver: Receiver) -> InputResponse:
        match event:
            case KeyPressed(key=Key.ControlB) if not is_armed.current:
                arm(True)
                return ()
            case KeyPressed(key=key) if is_armed.current:
                arm(False)
                return prefixed(key, receiver.focus_handle)
            case _:
                return None

    return Div(
        style=col | full,
        on_input=prefix,
        children=(
            Div(
                style=row | grow(1),
                children=tuple(pane(p.number, p.handle, prefix).with_key(str(p.number)) for p in panes),
            ),
            Text(
                content=("PREFIX " if shows_armed else "") + "ctrl+b, then c: new pane, n: next pane, x: close pane",
                style=text_color("slate", 900) | text_bg("amber", 400 if shows_armed else 200),
            ),
        ),
    )

One pane, with "hi" typed into it

╭──────────────────────────────────────────────────────────╮
│1: hi                                                     │
│                                                          │
╰──────────────────────────────────────────────────────────╯
ctrl+b, then c: new pane, n: next pane, x: close pane       

After Ctrl+B, the status bar shows the prefix is waiting for its key:

The status bar showing PREFIX

╭──────────────────────────────────────────────────────────╮
│1: hi                                                     │
│                                                          │
╰──────────────────────────────────────────────────────────╯
PREFIX ctrl+b, then c: new pane, n: next pane, x: close pane

Then c opens a second pane and focuses it, so the next keys go there:

Two panes, the second focused with "yo" typed into it

╭────────────────────────────╮╭────────────────────────────╮
│1: hi                       ││2: yo                       │
│                            ││                            │
╰────────────────────────────╯╰────────────────────────────╯
ctrl+b, then c: new pane, n: next pane, x: close pane       

Keeping the mode in a ref

Whether the prefix is waiting for its key, is_armed, is a ref, not state, because a handler sees the state of the render that built it. A terminal often delivers Ctrl+B and the key after it in one read, such as when they're typed quickly or pasted, and Counterweight handles every event that has arrived before it renders again. If the mode were state, the handler for c would still see the prefix unarmed, and type c into the pane. A ref is one box shared by every call to the handler, so the c sees what the Ctrl+B just wrote.

Drawing the status bar does need state, since changing a ref doesn't render anything, and a component mustn't read a ref while rendering. So arm writes both: the ref for the handlers to read, and shows_armed for the status bar to draw.

Putting the prefix first

Keys go to the focused pane first and bubble up from there, so a prefix handler on an ancestor alone would never see the c: the pane would type it first. Each pane puts the prefix ahead of its own typing with first(prefix, type_key), so the prefix decides before the pane does. The root's element has the prefix too, for when no pane is focused, such as after the last one closes.

The prefix claims Ctrl+B and the key after it, returning () or a control, and passes on everything else, so a key it doesn't bind reaches the pane, and anything the pane doesn't take reaches the root.

Opening a pane focused

Opening a pane creates its focus handle in the handler, keeps it in state with the pane, and returns FocusOn with it. The pane doesn't exist until the next render, so FocusOn waits for that render, and the new pane is focused in the first frame that shows it. Keys that arrive in the same read as the c still go to the pane that was focused when they arrived.

The new pane's number comes from the latest panes, inside the update function passed to set_panes, rather than from the panes the render saw, so two panes opened in one read get different numbers, and different keys.