docs/internal/input-calibration-wizard.md
The Input Calibration Wizard is a fail-safe feature designed for hostile terminal environments where standard keyboard input may be unreliable. This includes scenarios where:
Enter might send a newline instead of a submit signalEsc might be trapped by the browser or window managerCtrl key combinations might be intercepted by the terminal emulator or OSThe wizard uses standard lowercase ASCII characters (a, s, y, n, r) as control commands because they work on virtually every terminal since 1970.
Terminal emulators and environments vary widely in how they handle special keys:
Users in these environments often find that keys like Backspace, Delete, Home, End, or Ctrl+Arrow don't work as expected. The calibration wizard provides a way to remap these keys to whatever the terminal actually sends.
Fresh already enables the Kitty keyboard protocol (progressive enhancement) via crossterm's PushKeyboardEnhancementFlags:
// From main.rs
let keyboard_flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS;
When Kitty protocol is supported (Kitty, WezTerm, foot, etc.):
When Kitty protocol is NOT supported (legacy terminals, web terminals, some SSH clients):
The calibration wizard is the fallback for terminals that don't support the Kitty protocol. The translation layer sits after crossterm's parsing, so it works with both Kitty and legacy terminals.
The calibration system is a translation layer that sits between the terminal and the keymap:
┌─────────────┐ ┌──────────────────┐ ┌───────────────────┐ ┌────────┐
│ Terminal │ ──► │ KeyTranslator │ ──► │ KeybindingResolver│ ──► │ Action │
│ (raw input) │ │ (calibration) │ │ (keymap) │ │ │
└─────────────┘ └──────────────────┘ └───────────────────┘ └────────┘
│ │ │
│ │ │
"My terminal "Normalize to "Map key to
sends 0x7F" Backspace" action"
Option A (Rejected): Override actions directly
Raw Key → Action
0x7F → DeleteBackward
Problems:
Option B (Chosen): Translate keys, then use keymap
Raw Key → Normalized Key → Keymap → Action
0x7F → Backspace → (keymap lookup) → DeleteBackward
Benefits:
The calibration maps raw terminal events to expected key codes:
| Raw Event (what terminal sends) | Expected Key (what Fresh expects) |
|---|---|
Char('\x7f') | KeyCode::Backspace |
Char('\x08') | KeyCode::Backspace |
Alt('b') | KeyCode::Left + Alt modifier |
Esc, '[', '1', '~' | KeyCode::Home |
This translation happens before the KeybindingResolver sees the event.
During the wizard, specific lowercase letters are reserved commands:
| Key | Action | Description |
|---|---|---|
s | Skip | Skip current key calibration (keep default) |
g | Skip Group | Skip all remaining keys in current group |
a | Abort | Exit wizard, discard all changes |
y | Yes / Confirm | Save settings and exit (verification phase only) |
r | Retry | Restart the wizard from the beginning |
Why these keys are safe to reserve: The wizard calibrates control keys (Backspace, Delete, Home, End, Alt+Arrow) not alphanumeric keys. It is extremely unlikely that a physical "Home" key sends the letter "s".
The wizard calibrates the "problem children" - keys most likely to be broken in hostile terminals. Based on issue #219, these are organized into categories:
| # | Key | Action | Common Issues |
|---|---|---|---|
| 1 | BACKSPACE | Delete backward | Sends 0x7F, 0x08, Delete, or Ctrl+H |
| 2 | DELETE | Delete forward | Sometimes confused with Backspace |
| 3 | TAB | Indent / Next field | May be intercepted for completion |
| 4 | SHIFT + TAB | Dedent / Prev field | Modifier combo issues |
| # | Key | Action | Common Issues |
|---|---|---|---|
| 5 | HOME | Line start | Often not forwarded by terminals |
| 6 | END | Line end | Often not forwarded by terminals |
| 7 | SHIFT + HOME | Select to line start | Modifier combo issues |
| 8 | SHIFT + END | Select to line end | Modifier combo issues |
| # | Key | Action | Common Issues |
|---|---|---|---|
| 9 | ALT + LEFT | Word left | Produces special chars on intl keyboards |
| 10 | ALT + RIGHT | Word right | Produces special chars on intl keyboards |
| 11 | ALT + SHIFT + LEFT | Select word left | Modifier combo issues |
| 12 | ALT + SHIFT + RIGHT | Select word right | Modifier combo issues |
| 13 | CTRL + LEFT | Word left (alt) | Alternative for broken Alt |
| 14 | CTRL + RIGHT | Word right (alt) | Alternative for broken Alt |
| 15 | CTRL + SHIFT + LEFT | Select word left (alt) | Alternative for broken Alt |
| 16 | CTRL + SHIFT + RIGHT | Select word right (alt) | Alternative for broken Alt |
| # | Key | Action | Common Issues |
|---|---|---|---|
| 17 | PAGE UP | Page up | Sometimes intercepted |
| 18 | PAGE DOWN | Page down | Sometimes intercepted |
| 19 | CTRL + HOME | Document start | Modifier combo issues |
| 20 | CTRL + END | Document end | Modifier combo issues |
| # | Key | Action | Notes |
|---|---|---|---|
| 21 | CTRL + A | Line start | Emacs-style, useful when Home broken |
| 22 | CTRL + E | Line end | Emacs-style, useful when End broken |
| 23 | CTRL + K | Delete to line end | Emacs kill-line |
| 24 | CTRL + Y | Paste (yank) | Emacs yank, also used for redo |
Total: 24 keys across 5 groups.
Users can skip any group with g or any individual key with s.
The UI is stripped down - no fancy boxes that require specific rendering support:
[ FRESH EDITOR INPUT CALIBRATION ]
--------------------------------------------------
STEP 1 / 24 : Calibrating [ BACKSPACE ]
(Group 1: Basic Editing - 1 of 4)
Please press your physical BACKSPACE key now.
Controls:
[ s ] Skip this key (keep default)
[ g ] Skip entire group (Basic Editing)
[ a ] Abort wizard
--------------------------------------------------
waiting for input...
Logic:
key == 's' → Skip to next stepkey == 'g' → Skip all remaining keys in current group, advance to next groupkey == 'a' → Exit wizard, restore original statekey == 'y', 'n', or 'r' → Block with message: "Reserved key, please press the target key or [s] to skip"After capturing all keys, users verify their mappings work:
[ VERIFICATION MODE ]
--------------------------------------------------
We captured your keys. Let's test them.
== Group 1: Basic Editing ==
1. [ BACKSPACE ]: [ ] Waiting...
2. [ DELETE ]: [ OK ] Detected!
3. [ TAB ]: [SKIP] (using default)
4. [ SHIFT+TAB ]: [SKIP] (using default)
== Group 2: Line Navigation ==
5. [ HOME ]: [ OK ] Detected!
6. [ END ]: [ OK ] Detected!
7. [ SHIFT+HOME ]: [ ] Waiting...
8. [ SHIFT+END ]: [ ] Waiting...
== Group 3: Word Navigation ==
9. [ ALT+LEFT ]: [ OK ] Detected!
10. [ ALT+RIGHT ]: [ OK ] Detected!
... (8 more keys)
== Group 4: Document Navigation == (group skipped)
== Group 5: Emacs-Style == (group skipped)
--------------------------------------------------
COMMANDS:
[ y ] SAVE these settings and exit
[ r ] RETRY (restart wizard from beginning)
[ a ] ABORT (discard all changes)
Logic:
[ OK ] when the correct input is detectedy to save and exitfresh --calibrate
Launches the editor directly into the calibration wizard. This is useful for first-time setup or when the terminal is so broken that the user can't navigate to the menu.
Action::CalibrateInputAdd to the View menu (near Settings):
View
├── ...
├── Settings
├── Calibrate Input Keys...
├── ───────────
└── ...
use crossterm::event::{KeyCode, KeyModifiers, KeyEvent};
pub enum CalibrationStep {
/// Capturing key for a specific target
Capture {
group_idx: usize, // Index into CALIBRATION_GROUPS
key_idx: usize, // Index into group's key list
},
/// Verification phase
Verify,
}
/// What the user's key SHOULD produce (the expected/normalized key)
pub struct ExpectedKey {
pub code: KeyCode,
pub modifiers: KeyModifiers,
}
pub struct CalibrationTarget {
pub name: &'static str,
pub expected: ExpectedKey, // What Fresh expects to receive
}
pub struct CalibrationGroup {
pub name: &'static str,
pub targets: &'static [CalibrationTarget],
}
// 5 groups, 24 keys total
pub const CALIBRATION_GROUPS: &[CalibrationGroup] = &[
// Group 1: Basic Editing (4 keys)
CalibrationGroup {
name: "Basic Editing",
targets: &[
CalibrationTarget { name: "BACKSPACE", expected: ExpectedKey { code: KeyCode::Backspace, modifiers: KeyModifiers::NONE } },
CalibrationTarget { name: "DELETE", expected: ExpectedKey { code: KeyCode::Delete, modifiers: KeyModifiers::NONE } },
CalibrationTarget { name: "TAB", expected: ExpectedKey { code: KeyCode::Tab, modifiers: KeyModifiers::NONE } },
CalibrationTarget { name: "SHIFT+TAB", expected: ExpectedKey { code: KeyCode::BackTab, modifiers: KeyModifiers::SHIFT } },
],
},
// Group 2: Line Navigation (4 keys)
CalibrationGroup {
name: "Line Navigation",
targets: &[
CalibrationTarget { name: "HOME", expected: ExpectedKey { code: KeyCode::Home, modifiers: KeyModifiers::NONE } },
CalibrationTarget { name: "END", expected: ExpectedKey { code: KeyCode::End, modifiers: KeyModifiers::NONE } },
CalibrationTarget { name: "SHIFT+HOME", expected: ExpectedKey { code: KeyCode::Home, modifiers: KeyModifiers::SHIFT } },
CalibrationTarget { name: "SHIFT+END", expected: ExpectedKey { code: KeyCode::End, modifiers: KeyModifiers::SHIFT } },
],
},
// Group 3: Word Navigation (8 keys)
CalibrationGroup {
name: "Word Navigation",
targets: &[
CalibrationTarget { name: "ALT+LEFT", expected: ExpectedKey { code: KeyCode::Left, modifiers: KeyModifiers::ALT } },
CalibrationTarget { name: "ALT+RIGHT", expected: ExpectedKey { code: KeyCode::Right, modifiers: KeyModifiers::ALT } },
CalibrationTarget { name: "ALT+SHIFT+LEFT", expected: ExpectedKey { code: KeyCode::Left, modifiers: KeyModifiers::ALT | KeyModifiers::SHIFT } },
CalibrationTarget { name: "ALT+SHIFT+RIGHT", expected: ExpectedKey { code: KeyCode::Right, modifiers: KeyModifiers::ALT | KeyModifiers::SHIFT } },
CalibrationTarget { name: "CTRL+LEFT", expected: ExpectedKey { code: KeyCode::Left, modifiers: KeyModifiers::CONTROL } },
CalibrationTarget { name: "CTRL+RIGHT", expected: ExpectedKey { code: KeyCode::Right, modifiers: KeyModifiers::CONTROL } },
CalibrationTarget { name: "CTRL+SHIFT+LEFT", expected: ExpectedKey { code: KeyCode::Left, modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT } },
CalibrationTarget { name: "CTRL+SHIFT+RIGHT", expected: ExpectedKey { code: KeyCode::Right, modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT } },
],
},
// Group 4: Document Navigation (4 keys)
CalibrationGroup {
name: "Document Navigation",
targets: &[
CalibrationTarget { name: "PAGE UP", expected: ExpectedKey { code: KeyCode::PageUp, modifiers: KeyModifiers::NONE } },
CalibrationTarget { name: "PAGE DOWN", expected: ExpectedKey { code: KeyCode::PageDown, modifiers: KeyModifiers::NONE } },
CalibrationTarget { name: "CTRL+HOME", expected: ExpectedKey { code: KeyCode::Home, modifiers: KeyModifiers::CONTROL } },
CalibrationTarget { name: "CTRL+END", expected: ExpectedKey { code: KeyCode::End, modifiers: KeyModifiers::CONTROL } },
],
},
// Group 5: Emacs-Style Navigation (4 keys)
CalibrationGroup {
name: "Emacs-Style",
targets: &[
CalibrationTarget { name: "CTRL+A", expected: ExpectedKey { code: KeyCode::Char('a'), modifiers: KeyModifiers::CONTROL } },
CalibrationTarget { name: "CTRL+E", expected: ExpectedKey { code: KeyCode::Char('e'), modifiers: KeyModifiers::CONTROL } },
CalibrationTarget { name: "CTRL+K", expected: ExpectedKey { code: KeyCode::Char('k'), modifiers: KeyModifiers::CONTROL } },
CalibrationTarget { name: "CTRL+Y", expected: ExpectedKey { code: KeyCode::Char('y'), modifiers: KeyModifiers::CONTROL } },
],
},
];
pub struct CalibrationWizard {
/// Current step
step: CalibrationStep,
/// Translation table: raw terminal event → expected normalized event
/// Key: what the terminal actually sends
/// Value: what Fresh expects (the "correct" key)
pending_translations: HashMap<KeyEvent, KeyEvent>,
/// Which keys have been verified in the verification phase
verified: HashSet<usize>, // Index into flattened key list
/// Groups that were skipped entirely
skipped_groups: HashSet<usize>,
}
/// The key translator that applies calibration
pub struct KeyTranslator {
/// Translation table loaded from config
translations: HashMap<KeyEvent, KeyEvent>,
}
impl KeyTranslator {
/// Translate a raw terminal event to a normalized event
pub fn translate(&self, raw: KeyEvent) -> KeyEvent {
self.translations.get(&raw).cloned().unwrap_or(raw)
}
}
When the user presses y to save, mappings are written to ~/.config/fresh/key_calibration.json:
{
"_comment": "Generated by 'Calibrate Input Keys' wizard",
"_format": "raw_key → expected_key",
"translations": {
"Char(0x7f)": "Backspace",
"Char(0x08)": "Backspace",
"Alt(b)": "Alt+Left",
"Esc,[,1,~": "Home"
}
}
Keys not listed use default behavior (no translation).
The translation layer runs before keybinding resolution:
1. Terminal sends raw KeyEvent
2. KeyTranslator.translate(raw) → normalized KeyEvent ← CALIBRATION
3. KeybindingResolver.resolve(normalized) → Action ← KEYMAP
4. Editor executes Action
This means:
src/
├── input/
│ ├── keybindings.rs # Add CalibrateInput action
│ ├── key_translator.rs # NEW: KeyTranslator + load/save calibration
│ └── mod.rs # Wire translator into input pipeline
├── app/
│ ├── calibration_wizard.rs # NEW: Wizard state machine and logic
│ └── mod.rs # Integration with Editor
└── view/
└── ui/
└── calibration_ui.rs # NEW: Wizard UI rendering
Action::CalibrateInput to keybindings.rs--calibrate to main.rsKeyTranslator struct with translate() methodkey_calibration.jsonCalibrationWizard state machinea) always works and discards all changes