Skip to content
Open
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 .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/target
.worktrees
.claude
docs/superpowers/
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added
- configurable keymap: override, add, or disable modifier shortcuts (Ctrl / Ctrl+Shift / Alt / ⌘-Super and `Ctrl+W` chords) from a new `[keybindings]` config table; `"binding" = "action-name"` (`"none"` disables a default); invalid entries are skipped, logged, and surfaced as a transient status-bar notice
- macOS Command (⌘) / Linux Super keyboard shortcuts: ⌘V paste, ⌘C copy, ⌘N/⌘T new tab, ⌘W close tab, ⌘1–⌘9 select tab, ⌘Q quit, ⌘, config, ⌘F search, ⌘K clear scrollback, ⌘+/⌘-/⌘= font size. Previously only Ctrl/Ctrl+Shift bindings were recognized, so ⌘V did not paste on macOS (these ⌘ defaults are now user-overridable via [keybindings])

### Changed
- reorganize `src/` flat files into module subdirectories: `config/`, `session/`, `theme/`, `input/` (+ motion, mouse), `ui/` (+ command_palette, statusbar, tabs), `renderer/` (+ views, render_ops, screenshot)
- move VT parsing to per-pane background threads with a 1 MB bounded channel; eliminates terminal freezes during heavy output (e.g. `find /`) and keeps Ctrl+C responsive regardless of backlog size

### Fixed
- `Ctrl+W` chord tails are now matched case-insensitively (except `R`), so shifted tails like `Ctrl+W V` / `Ctrl+W S` split panes again
- mouse selection highlight now tracks content when the viewport is scrolled during a drag
- `install.sh` quick install failed on systems with an older `gh` CLI (e.g. Debian 13, which ships gh 2.46 without the `attestation` subcommand): `gh attestation verify` errored with `unknown command "attestation"` and the script reported a misleading "provenance verification failed". The quick install no longer depends on `gh` at all — integrity is verified solely against the public `checksums-sha256.txt` (SHA-256), which needs no authentication

Expand Down
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,27 @@ size stays consistent across displays of any density. If you previously increase
| `Ctrl+Shift+K` | Clear scrollback |
| `Ctrl+Shift+L` | Toggle session logging for active pane |

#### macOS (⌘ Command)

On macOS the platform-standard ⌘ shortcuts are also available (Linux/Windows: the Super key):

| Binding | Action |
|---|---|
| `⌘V` | Paste |
| `⌘C` | Copy selection (Visual mode) |
| `⌘N` / `⌘T` | New tab |
| `⌘W` | Close tab |
| `⌘1`..`⌘9` | Jump to tab by position |
| `⌘Q` | Quit |
| `⌘,` | Open config panel |
| `⌘F` | Open scrollback search |
| `⌘K` | Clear scrollback |
| `⌘+` | Increase font size |
| `⌘-` | Decrease font size |
| `⌘=` / `⌘0` | Reset font size |

While ⌘/Super is held, an unmapped key is ignored (never sent to the shell). ⌘ shortcuts are inactive in passthrough mode (`Ctrl+B`).

### Modes

| Binding | Action |
Expand Down Expand Up @@ -334,8 +355,8 @@ Navigate freely to position the cursor, press `v` to set the selection anchor, t

| Binding | Action |
|---|---|
| `Ctrl+Shift+C` | Copy selection |
| `Ctrl+Shift+V` | Paste |
| `Ctrl+Shift+C` / `⌘C` | Copy selection |
| `Ctrl+Shift+V` / `⌘V` | Paste |

## Architecture

Expand Down
17 changes: 17 additions & 0 deletions assets/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,20 @@ palette = [
"#a3babf",
"#f8f8f2",
]

[keybindings]
# Override, add, or disable keyboard shortcuts. Format:
# "binding" = "action-name" ("none" disables a built-in default)
# Modifiers (order-insensitive, joined with '+'): ctrl shift alt cmd
# cmd resolves to Command (⌘) on macOS and Super on Linux/Windows.
# Key tokens: a single character (v , + =) or a named key
# (enter escape tab space backspace delete pageup pagedown home end
# arrowup arrowdown arrowleft arrowright f1..f12).
# Chord (Ctrl+W prefix): a space-separated tail key, e.g. "ctrl+w x".
# Bare unmodified characters are rejected in global scope (they would shadow typing).
# Examples (commented out — empty table = full built-in defaults):
# "cmd+v" = "paste"
# "cmd+k" = "none"
# "ctrl+e" = "new_tab"
# "ctrl+w x" = "close_pane"
# See doc/SPEC.md "Configurable Keymap" for the full action-name list.
25 changes: 20 additions & 5 deletions doc/LLMs.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ Full spec: `doc/SPEC.md`. This file is the dense implementation reference.
| `src/main.rs` | `App`; event loop; winit wiring; `wakeup_pending` |
| `src/app_state.rs` | `AppState`, `TabState`, `PaneEntry`, `AppEffect`; action dispatch |
| `src/drain.rs` | `ParseEffect` enum; `spawn_parser_thread`; `drain_effects` |
| `src/input/keybindings.rs` | `Action` enum; `handle_key`, `handle_ctrl_w` |
| `src/input/keybindings.rs` | `Action` enum; literal/PTY encoding + not-yet-tabled per-mode handlers (Insert/Normal/Visual/QuitSave/Screenshot) |
| `src/input/keymap.rs` | `KeyMap`, `BindingKey`, `Mods`, `KeyToken`, `ModeClass`, `default_keymap()`, binding parser, `action_from_name`/`name_of_action` registry — single source of truth for shortcut bindings |
| `src/input/mode.rs` | `InputMode` enum (Insert / Normal / Visual / QuitSave) |
| `src/pty/session.rs` | `PtySession` — fork PTY, spawn shell, read/write bytes |
| `src/terminal/grid.rs` | `Grid`, `Cell`, `Color` — VT cell grid + scrollback |
Expand Down Expand Up @@ -99,6 +100,18 @@ Constants in `src/ui/layout.rs`: `TAB_BAR_H = 22`, `STATUS_BAR_H = 22`.
- `App.hovered_url: Option<String>` — updated on `CursorMoved`; cursor switches to `Pointer` when hovering a link
- Left-click on a linked cell opens the URL via `xdg-open` (Linux) / `open` (macOS)

## Configurable Keymap (implemented)

- Single source of truth: `src/input/keymap.rs`. `default_keymap()` returns all built-in Global-scope shortcuts (Ctrl, Ctrl+Shift, Alt, ⌘/Super, `Ctrl+W` chords) AS DATA.
- `KeyMap::from_config(&KeybindingsConfig)` overlays the user's `[keybindings]` table onto a copy of the defaults: valid `binding=action` inserts/replaces; `binding="none"` removes; invalid entries are skipped + collected as `KeymapError` (Parse / UnknownAction / ShadowsInput).
- Dispatch: `handle_key` → `handle_key_modified(keymap, ...)` builds `(Mods, KeyToken)`, calls `keymap.lookup(ModeClass::Global, &bkey)` FIRST. Hit → `action_from_name(name, DispatchCtx { grid_rows, mode })`. Miss → if `cmd` held, swallow (`Action::None`); else fall through to `handle_key_inner` (encoding/per-mode fallback).
- `Ctrl+W` chords: `handle_ctrl_w(keymap, event)` → `handle_ctrl_w_keymap` looks up a `BindingKey { mods: ctrl, token: 'w', chord_tail: Some((no_mods, tail)) }`. Tail token is NOT lowercased, so `Ctrl+W R` (rotate backward) stays distinct from `Ctrl+W r` (forward).
- `cmd` token = ⌘ (macOS) / Super (Linux/Win); winit reports both via `super_key()` → the `cmd` flag.
- Map stores `&'static str` action names (not `Action` — several variants aren't `Clone` and some need runtime context). `intern_known_name` interns validated names; keep it in sync with `action_from_name`.
- `AppState` builds the merged `KeyMap` once in `new()` and stores `keymap`, `keymap_invalid_count`, `keymap_notice_until`. `render_ops` surfaces `keymap_notice()` into the status bar `right_text` (transient ~6 s).
- Config: `KeybindingsConfig(BTreeMap<String,String>)` newtype, `#[serde(default)]` field on `Config`; documented example block lives in `assets/config.toml`. No `F_*`/`Field` in `tui_config.rs` — a dynamic map has no Field representation; keybindings are file-configured.
- Out of scope (Phase 2): `normal:` / `visual:` modal remapping (rows not yet populated); arbitrary new chord prefixes.

## Core Types (abbreviated)

```rust
Expand Down Expand Up @@ -209,10 +222,12 @@ One line per entry, imperative mood, lowercase first letter. Example:
- regex search with copy-match and clear-scrollback actions
```

### 1. Keybinding
- Add variant to `Action` in `src/input/keybindings.rs`
- Return it from `handle_key` or `handle_ctrl_w`
- Handle it in the `match action` block(s) in `src/main.rs` (two blocks: `ctrl_w_pending` path and normal path)
### 1. Keybinding / Action
- Add a variant to `Action` in `src/input/keybindings.rs` (only if a new action type is needed)
- Add a `name ↔ Action` entry to the registry in `src/input/keymap.rs` (`action_from_name` + `name_of_action`)
- Add a default row to `default_keymap()` in `src/input/keymap.rs`
- Handle the `Action` in `AppState::dispatch_action` (`src/app_state.rs`) if not already handled
- Literal text / cursor / PTY-byte encoding is NOT a keymap binding — it stays in `handle_insert`/`cursor_seq`/`encode_*` (the fallback)

### 2. Config option
- Add field to the right `*Config` struct in `src/config.rs` with `#[serde(default = "fn_name")]`
Expand Down
76 changes: 76 additions & 0 deletions doc/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,60 @@ writing a file.

## Key Bindings Reference

### Configurable Keymap

mmterm's modifier shortcuts are a single data-driven table (`src/input/keymap.rs`).
`default_keymap()` defines all built-in Global-scope shortcuts as data; the user's
`[keybindings]` config overlays the same table.

**Config format**

```toml
[keybindings]
"cmd+v" = "paste" # override / add a binding
"cmd+k" = "none" # disable a built-in default
"ctrl+e" = "new_tab" # add a new shortcut
"ctrl+w x" = "close_pane" # chord (space-separated tail key)
```

**Grammar**
- Modifiers (order-insensitive, `+`-joined): `ctrl` `shift` `alt` `cmd`.
`cmd` resolves to Command (⌘) on macOS and Super on Linux/Windows.
- Key token: a single character (`v` `,` `+` `=`) or a named key
(`enter` `escape` `tab` `space` `backspace` `delete` `pageup` `pagedown`
`home` `end` `arrowup/down/left/right` `f1`..`f12`); letters are case-insensitive.
- Chord: a space-separated tail key after a prefix (today only `ctrl+w`).
- `"none"` disables the matching built-in (returns the key to its raw terminal meaning).

**Merge semantics**
- Defaults always load; a valid entry inserts/replaces; `"none"` removes; an empty
table = full defaults.
- Invalid entries are skipped, `log::warn!`-ed, counted, and surfaced as a transient
status-bar notice `"N keybindings invalid — see log"`. The app always starts.
- Validation rejects: unparseable bindings, unknown action names, and **bare
unmodified-character** bindings in Global scope (they would shadow literal typing).

**Bindable action names**

| Name | Action |
|---|---|
| `paste`, `copy` | clipboard |
| `new_tab`, `close_tab`, `next_tab`, `prev_tab`, `move_tab_left`, `move_tab_right`, `rename_tab` | tabs |
| `go_to_tab_1`..`go_to_tab_9` | select tab N |
| `split_horizontal`, `split_vertical`, `auto_split`, `close_pane` | splits |
| `focus_left/right/up/down`, `focus_next` | pane focus |
| `zoom_pane`, `rotate_panes_forward`, `rotate_panes_backward` | pane layout |
| `resize_pane_left/right/up/down` | pane resize |
| `scroll_page_up`, `scroll_page_down`, `scroll_to_top`, `scroll_to_bottom`, `clear_scrollback` | scroll |
| `search_open`, `search_next`, `search_prev` | search |
| `increase_font_size`, `decrease_font_size`, `reset_font_size` | font |
| `open_config`, `open_command_palette`, `toggle_fullscreen`, `toggle_log`, `toggle_passthrough`, `screenshot_open`, `quit` | app / ui |
| `cycle_mode`, `enter_normal_mode` | mode |
| `ctrl_w_prefix` | start a `Ctrl+W` chord |

Modal (`normal:` / `visual:`) remapping is a future phase; literal text and cursor/PTY
encoding are never bindable — they are the fallback when no binding matches.

### Global (all modes)

| Binding | Action |
Expand Down Expand Up @@ -482,6 +536,28 @@ writing a file.
| `Ctrl+\` | Enter Normal mode |
| `Ctrl+B` | Toggle passthrough mode (see below) |

### macOS Command (⌘) / Super

The platform-standard ⌘ shortcuts are routed when the Super modifier is held
(macOS Command; Linux/Windows Super key). They take priority over mode dispatch;
while Super is held an unmapped key is swallowed (never sent to the PTY). Inactive
in passthrough mode.

| Binding | Action |
|---|---|
| `⌘V` | Paste |
| `⌘C` | Copy selection (Visual mode) |
| `⌘N` / `⌘T` | New tab |
| `⌘W` | Close tab |
| `⌘1`..`⌘9` | Jump to tab by position |
| `⌘Q` | Quit |
| `⌘,` | Open config panel |
| `⌘F` | Open scrollback search |
| `⌘K` | Clear scrollback |
| `⌘+` | Increase font size |
| `⌘-` | Decrease font size |
| `⌘=` / `⌘0` | Reset font size |

### Pane Management (`Ctrl+W` prefix)

| Binding | Action |
Expand Down
4 changes: 3 additions & 1 deletion src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ impl App {
self.tab_mut().passthrough = false;
self.request_redraw();
} else {
// Passthrough bypasses the keymap entirely (raw PTY bytes).
let action = handle_key_passthrough(event, &self.modifiers, app_cursor);
if let crate::input::keybindings::Action::SendToPty(bytes) = action {
self.do_send_to_pty(bytes);
Expand Down Expand Up @@ -496,6 +497,7 @@ impl App {
}

let action = handle_key(
&self.state.keymap,
&event,
&self.modifiers,
&self.state.mode,
Expand Down Expand Up @@ -561,7 +563,7 @@ impl App {
}
if self.state.ctrl_w_pending {
self.state.ctrl_w_pending = false;
let action = handle_ctrl_w(event);
let action = handle_ctrl_w(&self.state.keymap, event);
self.execute_action(action, event_loop);
return true;
}
Expand Down
35 changes: 34 additions & 1 deletion src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ use arboard::Clipboard;
use crossbeam_channel::Receiver;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use std::time::{Duration, Instant};

use crate::config::Config;
use crate::config::tui_config::ConfigPanel;
use crate::dpi::Logical;
use crate::input::InputMode;
use crate::input::keybindings::Action;
use crate::input::keymap::KeyMap;
use crate::renderer::FontMetrics;
use crate::theme::ResolvedTheme;
use crate::ui::{Layout, Pane, SeparatorHandle, SplitDir};
Expand Down Expand Up @@ -100,10 +101,26 @@ pub struct AppState {
pub available_update: Option<crate::update::Version>,
/// An update was self-applied this session (Linux) — drives the "restart" badge.
pub update_applied: Option<crate::update::Version>,
/// Merged keymap (built once at startup from `config.keybindings`).
pub keymap: KeyMap,
/// Number of invalid keybinding entries skipped at load (drives the notice).
pub keymap_invalid_count: usize,
/// When set (and in the future), the status bar shows the invalid-keymap notice.
pub keymap_notice_until: Option<Instant>,
}

impl AppState {
pub fn new(config: Config, theme: ResolvedTheme) -> Self {
let (keymap, keymap_errors) = KeyMap::from_config(&config.keybindings);
for e in &keymap_errors {
log::warn!("invalid keybinding: {e:?}");
}
let keymap_invalid_count = keymap_errors.len();
let keymap_notice_until = if keymap_invalid_count > 0 {
Some(Instant::now() + Duration::from_secs(6))
} else {
None
};
Self {
tabs: Vec::new(),
active_tab: 0,
Expand All @@ -128,6 +145,22 @@ impl AppState {
drag_separator: None,
available_update: None,
update_applied: None,
keymap,
keymap_invalid_count,
keymap_notice_until,
}
}

/// The transient invalid-keymap notice text, if active.
pub fn keymap_notice(&self) -> Option<String> {
let until = self.keymap_notice_until?;
if Instant::now() < until && self.keymap_invalid_count > 0 {
Some(format!(
"{} keybindings invalid — see log",
self.keymap_invalid_count
))
} else {
None
}
}

Expand Down
28 changes: 28 additions & 0 deletions src/app_state_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1280,3 +1280,31 @@ fn search_history_clears_before_history_on_push() {
s.push_search_history("committed".to_string());
assert!(s.search_before_history.is_empty());
}

// ── Keymap wiring ───────────────────────────────────────────────────────────

#[test]
fn appstate_builds_default_keymap_with_clean_config() {
let cfg = Config::default();
let theme = crate::theme::default_theme();
let st = AppState::new(cfg, theme);
assert_eq!(st.keymap_invalid_count, 0);
assert!(st.keymap_notice().is_none());
// sanity: a known default is present.
assert!(!st.keymap.is_empty());
}

#[test]
fn appstate_counts_invalid_keybindings_and_sets_notice() {
let mut cfg = Config::default();
cfg.keybindings
.0
.insert("ctrl+".to_string(), "new_tab".to_string()); // parse error
cfg.keybindings
.0
.insert("e".to_string(), "new_tab".to_string()); // shadows input
let theme = crate::theme::default_theme();
let st = AppState::new(cfg, theme);
assert_eq!(st.keymap_invalid_count, 2);
assert!(st.keymap_notice().is_some());
}
39 changes: 39 additions & 0 deletions src/config/config_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,42 @@ fn general_update_defaults() {
assert!(cfg.general.auto_update_check); // daily check on by default
assert!(!cfg.general.auto_update_install); // silent self-replace opt-in (off)
}

#[test]
fn config_default_keybindings_is_empty() {
let cfg = Config::default();
assert!(cfg.keybindings.0.is_empty());
}

#[test]
fn config_deserializes_keybindings_table() {
let toml = r#"
[font]
family = "X"
size = 12.0

[keybindings]
"cmd+v" = "paste"
"ctrl+e" = "new_tab"
"#;
let cfg: Config = toml::from_str(toml).expect("parse");
assert_eq!(
cfg.keybindings.0.get("cmd+v").map(String::as_str),
Some("paste")
);
assert_eq!(
cfg.keybindings.0.get("ctrl+e").map(String::as_str),
Some("new_tab")
);
}

#[test]
fn config_missing_keybindings_defaults_empty() {
let toml = r#"
[font]
family = "X"
size = 12.0
"#;
let cfg: Config = toml::from_str(toml).expect("parse");
assert!(cfg.keybindings.0.is_empty());
}
Loading