Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ If you're looking for a friendlier GUI-based controller remapper, search "Game C
- **Button combos** — hold a modifier button to change what other buttons do (e.g., X+dpad_up)
- **Per-app profiles** — automatic profile switching based on the frontmost application
- **Modes** — multiple binding sets per profile, switchable via picker, cycling, or direct jump
- **Automatic modes** — let an external program pick the mode, e.g. the app in your terminal's focused pane ([herdr](https://herdr.dev/) plugin [available](https://github.com/vgreg/herdr-padio))
- **Custom menus** — define popup menus with labeled items that trigger any action
- **Haptic feedback** — rumble on system beep, notifications, or on-demand from any binding
- **Media keys** — play/pause, track skip, volume, brightness (no Accessibility permission needed)
Expand Down
14 changes: 14 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,21 @@
"LB": { "type": "prev_mode" },
"RB": { "type": "next_mode" }
},
"context_modes": {
"claude": "agent",
"nvim": "nvim",
"zsh": "shell",
"ssh": "shell"
},
"hidden_modes": ["agent"],
"modes": {
"agent": {
"A": { "type": "keystroke", "key": "return" },
"B": { "type": "keystroke", "key": "escape" },
"X": { "type": "keystroke", "key": "`continue`" },
"dpad_up": { "type": "keystroke", "key": "up" },
"dpad_down": { "type": "keystroke", "key": "down" }
},
"shell": {
"A": { "type": "keystroke", "key": "return" },
"B": { "type": "keystroke", "key": "c", "modifiers": ["ctrl"] },
Expand Down
130 changes: 130 additions & 0 deletions docs/configuration/automatic-modes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Automatic Modes

PadIO can switch mode by itself based on what you are actually doing, instead of you picking a mode by hand every time. An external program reports a short **context token** (usually the name of the app you are working in), and each profile maps tokens to modes.

The motivating case is a terminal multiplexer: focus a Claude Code pane and the controller is in agent mode, move to a shell pane and it is in shell mode, launch `nvim` and it becomes vim mode. But nothing about the mechanism is terminal-specific. Any program that can write a file can drive it.

## How it works

PadIO watches `~/.config/padio/context`, a plain text file holding one token:

```
claude
```

When the token **changes**, PadIO looks it up in the active profile's `context_modes` and switches mode if there is a match.

```
producer PadIO
──────── ─────
write ~/.config/padio/context ──> file watch → context_modes → mode
```

## Configuration

Add `context_modes` to a profile. Keep bindings that should always be available in the profile's `global`, so they survive every mode change:

```json
"profiles": {
"terminal": {
"apps": ["com.mitchellh.ghostty"],
"default_mode": "shell",
"global": {
"options": { "type": "mode_select" }
},
"context_modes": {
"claude": "agent",
"nvim": "nvim",
"zsh": "shell",
"ssh": "shell"
},
"hidden_modes": ["agent", "nvim"],
"modes": {
"shell": { },
"agent": { },
"nvim": { }
}
}
}
```

| Field | Type | Description |
|-----------------|--------|-------------|
| `context_modes` | object | Maps a context token to a mode name. Matching is exact. |

## Resolution rules

Four rules govern automatic switching. They exist to keep it from fighting you.

**1. It fires only when the token changes.** A producer rewriting the same token repeatedly does nothing.

**2. No match leaves the mode alone.** A token with no `context_modes` entry keeps the current mode. It does *not* fall back to `default_mode`. This is what makes it practical to add entries only for the apps you care about, and to ignore everything else.

**3. A mode you picked by hand sticks.** Because switching happens only on token *change*, a mode chosen from the picker survives until you move to a different app. Pick `media` by hand inside a Claude pane and it stays `media`; move to an `nvim` pane and it becomes `nvim`.

**4. An unknown mode name is ignored.** If `context_modes` names a mode that exists in neither the profile's `modes` nor `shared_modes`, PadIO logs it and does nothing.

Only the active profile is consulted, so a token means whatever that profile says it means, and nothing at all in a profile with no `context_modes`.

Returning to a profile re-applies the current token rather than snapping back to `default_mode`, so switching away from your terminal and back keeps the app-driven mode.

## Hidden modes

Automatic switching tends to produce one mode per app, which makes the mode picker unwieldy when only a couple of modes are ever chosen by hand. `hidden_modes` keeps them out of the way:

```json
"hidden_modes": ["agent", "nvim"]
```

Hidden modes are excluded from the [`mode_select`](actions.md#mode_select) picker and from [`prev_mode` / `next_mode`](actions.md#prev_mode-next_mode) cycling. They remain fully reachable via `context_modes`, [`mode:<name>`](actions.md#modename), and `default_mode`.

The key can go on a profile, or at the top level for modes shared across profiles. The two lists are combined.

```json
{
"hidden_modes": ["media"],
"profiles": {
"terminal": {
"hidden_modes": ["agent", "nvim"]
}
}
}
```

If hiding would leave nothing to show, PadIO shows every mode instead, so the picker can never become an empty panel you cannot escape.

!!! note
Cycling with `prev_mode` / `next_mode` while a hidden mode is active moves you into the visible set, since the current mode is not part of the cycle.

## The context file

The file is a general-purpose extension point. PadIO does not care what writes it.

| Property | Behaviour |
|----------|-----------|
| Path | `~/.config/padio/context` |
| Content | A single token. Surrounding whitespace and newlines are stripped. |
| Empty or missing | Treated as no context. Not an error, and never a mode change. |
| Updates | Picked up immediately, whether written in place or replaced atomically. |

Producers should write atomically, to a temp file in the same directory followed by a rename, so PadIO never reads a half-written token. PadIO ignores a rewrite of an unchanged token, so a producer that cannot easily detect changes may simply write every time.

A minimal producer is a one-liner:

```bash
printf 'nvim' > ~/.config/padio/context.tmp && mv ~/.config/padio/context.tmp ~/.config/padio/context
```

Which means anything can drive it: a shell hook on directory change, a window manager, a build script that switches you into a "watching CI" mode, or your editor.

## herdr integration

[herdr](https://herdr.dev/) is a terminal multiplexer for running coding agents, with workspaces, tabs and panes. The [herdr-padio](https://github.com/vgreg/herdr-padio) plugin is a ready-made producer for it:

```
herdr plugin install vgreg/herdr-padio
```

It watches the focused pane and writes the app running in it. Tokens are herdr's own agent name when it has detected one (`claude`), and otherwise the program driving the pane (`zsh`, `nvim`, `ssh`). Wrapped programs that report an unhelpful name can be renamed with substring rules; see that project's README.

With herdr, put the workspace, tab and pane navigation bindings in the profile's `global` so they work in every mode, and let `context_modes` handle the app in the pane.
17 changes: 11 additions & 6 deletions docs/configuration/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ A profile is a named set of bindings that applies when specific apps are in the
}
```

| Field | Type | Description |
|----------------|----------|-------------|
| `apps` | string[] | Bundle IDs this profile applies to. Empty list = default catch-all profile. |
| `default_mode` | string | Mode to activate when the profile is first entered. |
| `global` | object | Bindings that apply in all modes of this profile. Overridden by top-level `global`. |
| `modes` | object | Named modes, each containing button bindings. |
| Field | Type | Description |
|-----------------|----------|-------------|
| `apps` | string[] | Bundle IDs this profile applies to. Empty list = default catch-all profile. |
| `default_mode` | string | Mode to activate when the profile is first entered. |
| `global` | object | Bindings that apply in all modes of this profile. Overridden by top-level `global`. |
| `modes` | object | Named modes, each containing button bindings. |
| `context_modes` | object | Optional. Maps an external context token to a mode, for [automatic mode switching](automatic-modes.md). |
| `hidden_modes` | string[] | Optional. Modes to [hide from the picker](automatic-modes.md#hidden-modes) and from mode cycling. |

### Profile resolution order

Expand Down Expand Up @@ -78,6 +80,9 @@ Each profile can have multiple modes. Only one mode is active at a time. Switch
- [`mode_select`](actions.md#mode_select) — opens a picker overlay
- [`prev_mode` / `next_mode`](actions.md#prev_mode-next_mode) — cycle through modes
- [`mode:<name>`](actions.md#modename) — jump directly to a named mode
- [automatic switching](automatic-modes.md) — let an external program pick the mode from what you are working in

Modes that are only ever selected automatically can be kept out of the picker and the cycle with [`hidden_modes`](automatic-modes.md#hidden-modes).

## Button combos

Expand Down
16 changes: 16 additions & 0 deletions docs/example-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,21 @@ A complete annotated config demonstrating profiles, modes, sequences, custom men
"LB": { "type": "prev_mode" },
"RB": { "type": "next_mode" }
},
"context_modes": {
"claude": "agent",
"nvim": "nvim",
"zsh": "shell",
"ssh": "shell"
},
"hidden_modes": ["agent"],
"modes": {
"agent": {
"A": { "type": "keystroke", "key": "return" },
"B": { "type": "keystroke", "key": "escape" },
"X": { "type": "keystroke", "key": "`continue`" },
"dpad_up": { "type": "keystroke", "key": "up" },
"dpad_down": { "type": "keystroke", "key": "down" }
},
"shell": {
"A": { "type": "keystroke", "key": "return" },
"B": { "type": "keystroke", "key": "c", "modifiers": ["ctrl"] },
Expand Down Expand Up @@ -140,4 +154,6 @@ This config:
- **shell mode**: return, ctrl-c, ctrl-l, git menu on Y (using `"type": "menu", "name": "git"` syntax)
- **nvim mode**: vim-style hjkl navigation, with X+dpad combos for ctrl-j/ctrl-k (half-page scroll)
- **tmux mode**: prefix sequences (ctrl-a + key) for pane navigation
- **agent mode**: return, escape, and `continue` as injected text — reached only via `context_modes`
- **Automatic modes**: `context_modes` switches mode from an external token (see [Automatic Modes](configuration/automatic-modes.md)), and `hidden_modes` keeps `agent` out of the picker since it is never chosen by hand
- **Git menu**: quick terminal commands accessible via Y button in shell mode
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ nav:
- Configuration:
- Overview: configuration/index.md
- Profiles & Modes: configuration/profiles.md
- Automatic Modes: configuration/automatic-modes.md
- Actions: configuration/actions.md
- Custom Menus: configuration/menus.md
- Haptics: configuration/haptics.md
Expand Down