T44: round 1 fixes: under kitty's flags the legacy bytes a key falls back to keep gpui's character, so Option-Shift-[ with only events set sends ESC { again; a key that is down is known by its key code on macOS and its virtual key on Windows, so its release goes out, as a record where its press did, though Shift came up first; shot posts a press or a release alone with down: and up:
CI / platforms (windows-latest) (push) Failing after 11s
CI / platforms (ubuntu-24.04) (push) Successful in 9m23s
CI / macos (push) Successful in 24m8s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-18 04:21:52 -04:00
1 parent fc10c4d491
commit 6f8bfaeff8
8 files changed
+90 -47

No files matched your search

+2 -2
View File
@@ -16,7 +16,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- `cargo run` opens the app. `RUST_LOG=elyterminal=debug cargo run` logs more; `trace` logs each paint.
- `cargo run --example shot -- [--keys "SPECS"] <png> <program> [args…]` runs a program in the main window, plays SPECS on it, and writes a PNG of the window (macOS; the app it runs under needs Screen Recording, or the picture may hold the window's ground alone, and `shot` warns), naming the AppKit views under its content, an effect view with its blur radius, `none` when it draws no blur, and a capture of one window shows its alpha, not the blur behind it; it logs the menu bar, each item with its key. It shows its window without activating the app, so keystrokes or clicks meant for other apps stay there; gpui draws a window only while macOS shows it, so one on another Space or under a full-screen app keeps its last frame. Run it with `HOME` pointing at a temp folder to try settings without touching your own. SPECS, space-separated:
- keys as real AppKit events: `a`, `ctrl-c`, `lopt-f` and `ropt-f` (left and right Option), `cmd-v`, `repeat-up` (AppKit's repeat flag);
- keys as real AppKit events: `a`, `ctrl-c`, `lopt-f` and `ropt-f` (left and right Option), `cmd-v`, `repeat-up` (AppKit's repeat flag); `down:` or `up:` before a key posts only its press or its release, so a modifier may change between them;
- input method calls: `mark:é` composes, `commit:é` types;
- `wait` pauses a second, so a program can start before the keys after it;
- `activate` and `deactivate` the app, which takes real keystrokes while active: keep the two specs adjacent. macOS may delay or refuse the activation; each `shot: typed` line says whether the app was active;
@@ -104,4 +104,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- 2026-10-17: inside herdr, which passes no OSC 7501 on, herdr's agents are the session's records. The snapshot the follower asks for lists them; one in a state herdr knows, idle, working, blocked or done, is the record `herdr:PANE`, whose colon no OSC 7501 id holds, so a program's record of a name like it, `herdr/build` among them, stays its own; unknown and any state herdr adds later pass by. herdr names no kind and no progress; the app is the agent, the title its name, else the name herdr shows for it, and the message its terminal title less the spinner herdr strips, else the title its metadata sets; herdr's done is idle and not yet seen. A snapshot that lists other agents replaces them whole, so a full reset or a clear that dropped them sees them back at the next; they go when herdr leaves the foreground, and a snapshot that lands after the shell reported a folder is dropped, its agents with it. A change of their states is logged; a herdr whose snapshot lacks `agents` lists none.
- 2026-10-17: a session's row shows its status, which the owner asked for: one mark before its title, the most pressing state first: 3 by 3 dots in the state's color, which the owner asked for, moving, after the S1 orb of aicss.dev; the dots and their motion are ELYTerminal's own. Working ripples out from the centre to the edges, then the corners, once every 1.2 s; with progress the outer ring fills clockwise from the top, as Ely's `ProgressRing` did, and the next dot pulses, the centre last. Blocked, in the theme's warning color, stays lit and dips twice every 1.6 s, whatever its kind, which hover names. Error, a program's or a failed last command's, draws an X in the danger color; done a check in the success color; idle a dimmer ring in the muted color. A settled shape lights dot by dot, then holds still: one that kept moving would draw the window for as long as it stands. The dots outside a shape stay faint. Every mark steps on one clock, every 80 ms, so the marks move together, and the window draws again at each step only while a mark it shows moves: a working program costs 12.5 frames a second, not one at every refresh of the display, which kept the loader still before. Every mark takes a 24-pixel box, so the title stays put as the state changes; the dots fill 16 of it, an icon's room. Among the records in that state, the one reported last speaks. Hover over the mark lists each record, ten at most: its state, its app, its own or its nearest ancestor's as the spec asks, its title and its message, each on one line and cut at 120 characters, with C1 controls, direction controls and invisible formatting taken out, as the spec asks; joiners stay, since scripts and emoji need them. A failed last command's status follows. Hover reads the records as they stand at each frame, so an open one follows a change; it stays while the pointer is over it and scrolls within the window's height, so ten long records all show. A key the user types, a paste too, sees the terminal's done and error records, which then go, as the spec suggests; herdr's stay, since herdr marks them seen. The view notes the state its row shows after each change it hears, not once an output, and tells the workspace of each, and a hidden session whose mark turns to blocked sets its title strong until it is shown, though it went on within the same output, so one output marks as two would. A change of herdr's records draws the view again, so an open hover follows them though the state holds. The view logs each change of that state, and keeps its last command's status.
- 2026-10-17: a session posts system notifications through gpui's, which the owner asked for. At start `set_app_identity` names the app: on Windows that sets the process's AppUserModelID, which the taskbar groups its windows by too, so a pin made before may show apart, and the first notification writes the app's name under `HKCU\Software\Classes\AppUserModelId`, as gpui needs for an app without a package; on macOS gpui posts only from the app bundle, asks for leave at the first, and logs once, under the `gpui_macos` target, that it posts nothing outside one; on Linux it posts over D-Bus through notify-rust. Each terminal view posts its own, in the order its output asks, so OSC 99's answers keep their place before a DA1 sent after them: when its row's mark turns to waiting, done or error, at each turn, as the row's waiting mark is set, the hover's line for the record its body; when a command C started ends, `Command finished`, or `Command exited with N`, so a prompt drawn again, as macOS's bash 3.2 draws one with its D at a resize, posts nothing, and bash 3.2, which sends no C, posts no command's end, as Ghostty's integration, which skips it, posts none; and when a program asks through OSC 9, 777 or 99, its title and body the body, each line shown as text. It posts only while its window is not active or it is not the session shown, which the workspace tells it, one every 3 seconds at most, a later one logged and dropped; the title is the program's title, else the app's name, as the row names the session, which the tap reads in the output's order, so a title set before a notification in the same output names it. The session's own share a tag, so a new one takes the old one's place where the system allows, as an OSC 99 id does; OSC 9, 777 and OSC 99 without an id each take their own; a session keeps its last 64. A press goes to the window whose session posted it: it shows the window and the session, unless OSC 99 said `a=-focus`, and tells a program that asked `a=report`, `OSC 99 ; i=ID ;`; a press whose record went, a twin Linux stacked and pressed before or one past the 64 kept, still shows its session and tells no program. macOS brings the app forward at any press, `a=-focus` or not, and on Wayland gpui asks for the window with a serial the compositor likely refuses, so KWin and Mutter mark it as asking for attention instead. gpui hears of no close on any system, so `c=1` hears `p=close;untracked` once posted, and alive lists the ids posted and not yet pressed, closed or expired. On macOS and Windows a close takes the notification back, and `w` over 0 takes it back once it runs out, its session gone or not, unless a later post holds its tag; gpui on Linux closes and replaces none, so there a close and `w` pass by, logged, a closed one stays alive, and an update posts beside the old one. `o` passes by: each notification posts only while unseen, which is kitty's invisible, so the query answers `a=focus,report:o=invisible:p=title,body,close,?,alive:w=1`, on Linux `a=focus,report:o=invisible:p=title,body,?,alive`, never with the `c` that close events would need. Windows' toasts are silent, as macOS's are; on Linux the notification daemon decides, and reads the body as markup, so its `&`, `<` and `>` go as entities.
- 2026-10-18: herdr's shortcuts, which a user found dead, as kitty and Ghostty send them. herdr 0.9.5 asks for kitty's disambiguate, events and alternates on macOS and Linux, and takes `cmd+shift+ctrl+[` as `[` with Shift, Ctrl and Super, `CSI 91;14u`, the symbol as its alternate, `CSI 91:123;14u`, as Ghostty sends it; gpui named the key `{` and cleared Shift, so ELYTerminal sent `CSI 123;13u`, which herdr matches only to `cmd+ctrl+{`. Under kitty's flags a key Shift made a symbol of now goes as its own character with Shift, the symbol its alternate; legacy keys keep fixterms' symbol without Shift, as Ghostty's do. On macOS the key's own character comes from `UCKeyTranslate` on the event's key code, in the layout gpui reads: Command's while Command is down or while the plain one types no ASCII; AppKit's `charactersByApplyingModifiers:` ignores the layout for events built in-process. On Windows herdr asks for no kitty flags and reads the console's key records, and ConPTY asks every terminal for win32-input-mode, `CSI ? 9001 h`; so, as Windows Terminal does, each key that types no text goes as `CSI Vk ; Sc ; Uc ; Kd ; Cs ; Rc _`: the virtual key from gpui's name and the layout (`VkKeyScanW`), its scan code, what Windows types for it (`ToUnicode`, a dead key left as it is), down or up, and the modifier keys, locks and enhanced block from `GetKeyState`. kitty's flags go first, as in Windows Terminal; text stays text, so a release goes as a record only where its press did; a full reset ends the mode, as Windows Terminal's does. A console record holds no Windows key, so no Windows terminal passes herdr a `cmd` binding, and a keypad key goes as its main key, since gpui names it so. On Linux gpui keeps neither the key code nor its xkb state, and names a non-ASCII key by its place on a US keyboard, so a key's own character cannot be found: Shift with a symbol and Ctrl, Alt or Super goes as the symbol without Shift, which herdr matches to a binding on the symbol, `ctrl+{`.
- 2026-10-18: herdr's shortcuts, which a user found dead, as kitty and Ghostty send them. herdr 0.9.5 asks for kitty's disambiguate, events and alternates on macOS and Linux, and takes `cmd+shift+ctrl+[` as `[` with Shift, Ctrl and Super, `CSI 91;14u`, the symbol as its alternate, `CSI 91:123;14u`, as Ghostty sends it; gpui named the key `{` and cleared Shift, so ELYTerminal sent `CSI 123;13u`, which herdr matches only to `cmd+ctrl+{`. Under kitty's flags a key Shift made a symbol of now goes as its own character with Shift, the symbol its alternate; legacy keys keep fixterms' symbol without Shift, as Ghostty's do. On macOS the key's own character comes from `UCKeyTranslate` on the event's key code, in the layout gpui reads: Command's while Command is down or while the plain one types no ASCII; AppKit's `charactersByApplyingModifiers:` ignores the layout for events built in-process. On Windows herdr asks for no kitty flags and reads the console's key records, and ConPTY asks every terminal for win32-input-mode, `CSI ? 9001 h`; so, as Windows Terminal does, each key that types no text goes as `CSI Vk ; Sc ; Uc ; Kd ; Cs ; Rc _`: the virtual key from gpui's name and the layout (`VkKeyScanW`), its scan code, what Windows types for it (`ToUnicode`, a dead key left as it is), down or up, and the modifier keys, locks and enhanced block from `GetKeyState`. kitty's flags go first, as in Windows Terminal; text stays text, so a release goes as a record only where its press did; a full reset ends the mode, as Windows Terminal's does. A key that is down is known by its code, AppKit's key code on macOS and the virtual key on Windows, since gpui names a release by the modifiers left down: a release after Shift came up still goes out. Linux has only gpui's name. A console record holds no Windows key, so no Windows terminal passes herdr a `cmd` binding, and a keypad key goes as its main key, since gpui names it so. On Linux gpui keeps neither the key code nor its xkb state, and names a non-ASCII key by its place on a US keyboard, so a key's own character cannot be found: Shift with a symbol and Ctrl, Alt or Super goes as the symbol without Shift, which herdr matches to a binding on the symbol, `ctrl+{`.
+11 -2
View File
@@ -219,7 +219,11 @@ fn mouse(name: &str, flags: u64, number: u32) -> anyhow::Result<()> {
/// Posts one spec on window `number`.
pub fn post(spec: &str, number: u32) -> anyhow::Result<()> {
if let Some((call, text)) = spec.split_once(':') {
return compose(call, text, number);
return match call {
"down" => key(text, number, &[10]),
"up" => key(text, number, &[11]),
call => compose(call, text, number),
};
}
if let "activate" | "deactivate" = spec {
unsafe {
@@ -233,6 +237,11 @@ pub fn post(spec: &str, number: u32) -> anyhow::Result<()> {
log::info!("shot: {spec}");
return Ok(());
}
key(spec, number, &[10, 11])
}
/// Posts key `spec`, or a mouse gesture, as AppKit events of `kinds`: 10 down, 11 up.
fn key(spec: &str, number: u32, kinds: &[u64]) -> anyhow::Result<()> {
let mut parts: Vec<&str> = spec.split('-').collect();
let name = parts.pop().context("an empty key")?;
let mut flags: u64 = 0;
@@ -277,7 +286,7 @@ pub fn post(spec: &str, number: u32) -> anyhow::Result<()> {
}
_ => ignoring.clone(),
};
for kind in [10u64, 11] {
for &kind in kinds {
unsafe {
let event: *mut Object = msg_send![
class!(NSEvent),
+40 -14
View File
@@ -92,37 +92,43 @@ impl TerminalView {
if event.prefer_character_input {
return;
}
self.held.insert(event.keystroke.key.clone());
let facts = self.facts(&event.keystroke);
let action = if event.is_held || facts.repeat {
Action::Repeat
} else {
Action::Press
};
if self.send(&event.keystroke, action, facts, cx) {
let went = self.send(&event.keystroke, action, facts, false, cx);
self.held
.insert(held(&event.keystroke, facts), went == Some(true));
if went.is_some() {
cx.stop_propagation();
}
}
/// A release goes out only for a press this terminal saw: none for one a binding took.
pub(super) fn key_up(&mut self, event: &KeyUpEvent, _: &mut Window, cx: &mut Context<Self>) {
if !self.held.remove(&event.keystroke.key) {
return;
}
let facts = self.facts(&event.keystroke);
if self.send(&event.keystroke, Action::Release, facts, cx) {
let Some(record) = self.held.remove(&held(&event.keystroke, facts)) else {
return;
};
if self
.send(&event.keystroke, Action::Release, facts, record, cx)
.is_some()
{
cx.stop_propagation();
}
}
/// Sends what `keystroke` means to the program; false when it is text the input method types.
/// Sends what `keystroke` means to the program: none when it is text the input method types, else whether it went as Windows' record; `record` says that of its press.
fn send(
&mut self,
keystroke: &Keystroke,
action: Action,
facts: Facts,
record: bool,
cx: &mut Context<Self>,
) -> bool {
) -> Option<bool> {
let modifiers = keystroke.modifiers;
let alt = modifiers.alt && Self::alt_is_meta(facts, cx);
let text = match &keystroke.key_char {
@@ -150,12 +156,15 @@ impl TerminalView {
};
// As in Windows Terminal: kitty's flags first, then Windows' records; text stays text.
#[cfg(windows)]
if self.win32 && kitty == Kitty::default() && !keys::types(&key) {
if self.win32
&& kitty == Kitty::default()
&& (record || action != Action::Release && !keys::types(&key))
{
match super::win32::record(keystroke, action) {
Some(bytes) => {
log::trace!("terminal {}: {key:?} sends {bytes:?}", self.pty.pid);
self.input(bytes, cx);
return true;
return Some(true);
}
None => log::debug!(
"terminal {}: no virtual key for {:?}, so it goes as bytes",
@@ -164,12 +173,12 @@ impl TerminalView {
),
}
}
let Some(bytes) = keys::encode(&key, mode.contains(TermMode::APP_CURSOR), kitty) else {
return false;
};
#[cfg(not(windows))]
debug_assert!(!record, "only Windows sends records");
let bytes = keys::encode(&key, mode.contains(TermMode::APP_CURSOR), kitty)?;
log::trace!("terminal {}: {key:?} sends {bytes:?}", self.pty.pid);
self.input(bytes, cx);
true
Some(false)
}
/// AppKit's facts of the key; logged and none when unreadable.
@@ -182,6 +191,7 @@ impl TerminalView {
right_option: false,
repeat: false,
unshifted: None,
code: None,
}
})
}
@@ -197,6 +207,7 @@ impl TerminalView {
Facts {
repeat: false,
unshifted,
code: super::win32::virtual_key(&keystroke.key).map(|(key, _)| key),
}
}
@@ -233,6 +244,21 @@ type Facts = super::mac::KeyFacts;
struct Facts {
repeat: bool,
unshifted: Option<char>,
/// The key's virtual key, which Shift does not change; none off Windows.
code: Option<u16>,
}
/// A key while it is down: its code where the platform gives one, else gpui's name, which Shift may change.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(super) enum Held {
Code(u16),
Name(String),
}
fn held(keystroke: &Keystroke, facts: Facts) -> Held {
facts
.code
.map_or_else(|| Held::Name(keystroke.key.clone()), Held::Code)
}
impl EntityInputHandler for TerminalView {
+14 -23
View File
@@ -49,21 +49,7 @@ pub fn encode(key: &Key, app_cursor: bool, kitty: Kitty) -> Option<Vec<u8>> {
}
match functional(key.name) {
Some((number, end)) => named(key, number, end, app_cursor, kitty),
None => match key.unshifted {
// kitty names the key itself, with Shift, and what Shift made of it as its alternate.
Some(own) => {
let made = printable(key.name)?;
let mut name = [0; 4];
let key = Key {
name: own.encode_utf8(&mut name),
shift: true,
unshifted: None,
..*key
};
typed(&key, own, Some(made), kitty)
}
None => typed(key, printable(key.name)?, None, kitty),
},
None => typed(key, printable(key.name)?, kitty),
}
}
@@ -181,23 +167,28 @@ fn named(key: &Key, number: u32, end: char, app_cursor: bool, kitty: Kitty) -> O
Some(sequence.into_bytes())
}
/// A printable key: text, then legacy bytes, then CSI u once a flag has more to say; `made` is what Shift made of it.
fn typed(key: &Key, ch: char, made: Option<char>, kitty: Kitty) -> Option<Vec<u8>> {
/// A printable key: text, then legacy bytes, then CSI u once a flag has more to say.
fn typed(key: &Key, ch: char, kitty: Kitty) -> Option<Vec<u8>> {
let typing = types(key);
if typing && !kitty.all && key.action != Action::Release {
return None;
}
// gpui names what Shift made of a key and drops Shift; CSI u names the key, with Shift.
let (own, shift, made) = match key.unshifted {
Some(own) => (own, true, Some(ch).filter(|&made| made != own)),
None => (ch, key.shift, None),
};
let shifted = key
.text
.chars()
.next()
.filter(|&typed| typing && typed != ch && !typed.is_control())
.or(made.filter(|&made| made != ch))
.or_else(|| (key.shift && ch.is_ascii_lowercase()).then(|| ch.to_ascii_uppercase()))
.filter(|_| key.shift && kitty.alternates);
.filter(|&typed| typing && typed != own && !typed.is_control())
.or(made)
.or_else(|| (shift && own.is_ascii_lowercase()).then(|| own.to_ascii_uppercase()))
.filter(|_| shift && kitty.alternates);
let event = event(key, kitty);
let text = typing && kitty.text && key.action != Action::Release;
let mods = code(key);
let mods = code(&Key { shift, ..*key });
if shifted.is_none() && event.is_none() && !text {
if mods == 1 && !kitty.all {
return None;
@@ -210,7 +201,7 @@ fn typed(key: &Key, ch: char, made: Option<char>, kitty: Kitty) -> Option<Vec<u8
return Some(bytes);
}
}
let mut out = format!("\x1b[{}", ch.to_ascii_lowercase() as u32);
let mut out = format!("\x1b[{}", own.to_ascii_lowercase() as u32);
if let Some(shifted) = shifted {
out.push_str(&format!(":{}", shifted as u32));
}
+14
View File
@@ -341,6 +341,20 @@ fn kitty_reports_the_key_shift_turned_into_a_symbol() {
Some("\x1b[123;5u"),
"legacy keeps fixterms' shifted character"
);
let events = Kitty {
events: true,
..Kitty::default()
};
assert_eq!(
kitty(made("alt+{", "", Action::Press), events).as_deref(),
Some("\x1b{"),
"a flag that adds no field keeps the legacy bytes gpui's name gives"
);
let bang = Key {
unshifted: Some('1'),
..key("alt+!", "")
};
assert_eq!(kitty(bang, events).as_deref(), Some("\x1b!"));
let same = Key {
unshifted: Some('7'),
..key("ctrl+7", "")
+3
View File
@@ -58,6 +58,8 @@ pub struct KeyFacts {
pub repeat: bool,
/// The key's own character, where gpui named the one Shift made and dropped Shift.
pub unshifted: Option<char>,
/// AppKit's key code, which Shift does not change.
pub code: Option<u16>,
}
/// The facts of the key event AppKit is handling now, which gpui named `name`, with `shift` or not.
@@ -89,6 +91,7 @@ pub fn key_facts(name: &str, shift: bool) -> anyhow::Result<KeyFacts> {
right_option: flags & RIGHT_OPTION != 0,
repeat,
unshifted,
code: Some(code),
})
}
+5 -5
View File
@@ -1,4 +1,4 @@
use std::{collections::HashSet, path::PathBuf, process::ExitStatus, rc::Rc, time::Duration};
use std::{collections::HashMap, path::PathBuf, process::ExitStatus, rc::Rc, time::Duration};
use alacritty_terminal::{
event::Event,
@@ -21,7 +21,7 @@ use super::{
find::Finder,
frame::frame,
ink::Ink,
input::{self, Find, NextPrompt, PreviousPrompt},
input::{self, Find, Held, NextPrompt, PreviousPrompt},
marks::Mark,
mouse::MouseMode,
paint::{self, Look},
@@ -70,8 +70,8 @@ pub struct TerminalView {
/// Keys go as Windows' records, as ConPTY asks.
#[cfg(windows)]
pub(super) win32: bool,
/// Keys whose press this terminal saw, so their releases may go out.
pub(super) held: HashSet<String>,
/// Keys whose press this terminal saw, so their releases may go out; true where it went as Windows' record.
pub(super) held: HashMap<Held, bool>,
pub(super) finder: Option<Finder>,
pub(super) size: (usize, usize),
/// How many folders the program has reported.
@@ -135,7 +135,7 @@ impl TerminalView {
mouse: MouseMode::default(),
#[cfg(windows)]
win32: false,
held: HashSet::new(),
held: HashMap::new(),
finder: None,
size,
reports: 0,
+1 -1
View File
@@ -72,7 +72,7 @@ pub fn unshifted(made: &str) -> Option<char> {
}
/// The virtual key gpui's `name` stands for, and whether it sits in the enhanced block.
fn virtual_key(name: &str) -> Option<(u16, bool)> {
pub fn virtual_key(name: &str) -> Option<(u16, bool)> {
let named = match name {
"enter" => (VK_RETURN, false),
"tab" => (VK_TAB, false),