T12: sessions: new (+, Cmd-T), close (row x, Cmd-W), select (press, Cmd-1 to 8, Cmd-9 last, Ctrl-Tab, Ctrl-Shift-Tab), Ghostty's keys off macOS; rows show the title and the folder with ~; an ended program closes its row, the last row the window, and the app quits; a bell marks a hidden row; new sessions follow the Shell and Start in settings
CI / platforms (windows-latest) (push) Failing after 7s
CI / macos (push) Failing after 8m29s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 18:07:39 -04:00
1 parent ccbea6a7a0
commit 5fd674e82a
6 files changed
+391 -52

No files matched your search

+2 -1
View File
@@ -21,7 +21,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
## Layout
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles and the title.
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the title, and the sessions' lifecycle. `src/sessions.rs`: a session and its row.
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`.
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance.
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and the mouse modes before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
@@ -60,4 +60,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- 2026-10-14: find opens on Cmd-F (Ctrl-Shift-F off macOS) beside the grid, outside the `Terminal` key context, so its keys never reach the program. Matches wash in Ely's `warning` color, the current one stronger; the newest is current first, Enter steps older and Shift-Enter newer. Shift-Enter binds under `TerminalFind > ElyInput`, the context of Ely's text input, whose own Shift-Enter binding takes the key. Find reads the grid as it shows: combining marks ride their cells, hidden cells are blank, wrapped rows join, words are Unicode; alacritty's own search reads no marks and knows only ASCII word edges. New output, a resize and a settings change search again; the current match stays the same occurrence: its line kept, grown or cut at its end, held under the same line above or in its place, before such a line elsewhere, nearest where scrolling moved it; else the nearest by line, then column. Searching and following take one pass over the text and the matches.
- 2026-10-14: the program hears focus in and out under mode 1004 when the terminal's focus or the window's activation changes. The window wears the program's title, the app's name while it names none. A bell is logged and makes no sound. A full reset, `ESC c`, empties alacritty's title and sends only `CursorBlinkingChange`, in the parser's order, synchronized updates too; on that event the view replays the title through `set_options`, which sends it.
- 2026-10-14: the title bar is transparent, as Ghostty's: the content runs under it, Ely's column headers drag the window, and the terminal's header shows the program's title. macOS draws its traffic lights at Ely's origin; elsewhere Ely draws the controls, so windows ask for client decorations, which only Linux reads, and Ely's `ResizeBorder` gives the edges. Cmd-B and Cmd-Alt-B (Ctrl-Shift-B and Ctrl-Shift-Alt-B off macOS) toggle the sessions and right columns, kept in the settings.
- 2026-10-14: session keys are Ghostty's: Cmd-T, Cmd-W, Cmd-1 to Cmd-8, Cmd-9 for the last, Ctrl-Tab and Ctrl-Shift-Tab; off macOS Ctrl-Shift-T, Ctrl-Shift-W and Alt with the digits. A digit past the last session goes on to the program. A new session runs the Shell setting in home or in the shown session's folder, as Start in says. A session whose program ends closes its row; the last row closes the window, and with no window left the app quits. A bell in a hidden session marks its row until it is shown. A row's title is the program's, or the app's name while it names none.
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
+7 -1
View File
@@ -143,7 +143,7 @@ mod typing {
};
/// US layout key codes and what each key types.
const KEYS: [(&str, u16, &str); 20] = [
const KEYS: [(&str, u16, &str); 26] = [
("a", 0, "a"),
("b", 11, "b"),
("c", 8, "c"),
@@ -152,6 +152,12 @@ mod typing {
("i", 34, "i"),
("s", 1, "s"),
("v", 9, "v"),
("t", 17, "t"),
("w", 13, "w"),
("1", 18, "1"),
("2", 19, "2"),
("5", 23, "5"),
("9", 25, "9"),
("[", 33, "["),
("space", 49, " "),
("tab", 48, "\t"),
+11 -6
View File
@@ -3,15 +3,15 @@ use std::borrow::Cow;
use anyhow::Context as _;
use ely_gpui_component::theme::ActiveTheme;
use gpui::{
App, AppContext, Bounds, Focusable, TitlebarOptions, WindowBounds, WindowDecorations,
WindowHandle, WindowOptions, px, size,
App, AppContext, Bounds, TitlebarOptions, WindowBounds, WindowDecorations, WindowHandle,
WindowOptions, px, size,
};
#[cfg(target_os = "macos")]
use crate::term::mac;
use crate::{
settings::SettingsStore,
term::{ICONS, Launch, MONO, TerminalView, config, input, shell::ShellIntegration, spawn},
term::{ICONS, Launch, MONO, config, input, shell::ShellIntegration, spawn},
themes,
workspace::{self, TITLE, Workspace},
};
@@ -41,6 +41,13 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
ShellIntegration::init(cx)?;
input::bind_keys(cx);
workspace::bind_keys(cx);
cx.on_window_closed(|cx, _| {
if cx.windows().is_empty() {
log::info!("app: no window left; quitting");
cx.quit();
}
})
.detach();
#[cfg(target_os = "macos")]
{
mac::repeat_held_keys();
@@ -74,9 +81,7 @@ pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result<WindowHandle<Workspa
window
.observe_window_appearance(|_, cx| themes::apply(cx))
.detach();
let terminal = cx.new(|cx| TerminalView::new(pty, events, window, cx));
window.focus(&terminal.focus_handle(cx), cx);
cx.new(|cx| Workspace::new(terminal, window, cx))
cx.new(|cx| Workspace::new(pty, events, launch.cwd, window, cx))
})?;
log::info!("app: main window open");
Ok(window)
+1
View File
@@ -1,4 +1,5 @@
pub mod app;
pub mod sessions;
pub mod settings;
pub mod term;
#[cfg(test)]
+112
View File
@@ -0,0 +1,112 @@
use std::path::{MAIN_SEPARATOR, Path, PathBuf};
use ely_gpui_component::{
buttons::{ButtonVariant, IconButton},
lists::ListItem,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize},
};
use gpui::{App, Entity, Subscription};
use crate::{
term::TerminalView,
workspace::{TITLE, Workspace},
};
/// A program on its own terminal, as the sessions column lists it.
pub struct Session {
pub terminal: Entity<TerminalView>,
/// The program's title; empty while it names none.
pub title: String,
/// The folder it reports, else the one it started in.
pub folder: PathBuf,
/// A bell rang while it was hidden.
pub bell: bool,
pub _events: Subscription,
}
/// `folder` as a row shows it: the home folder as `~`.
pub fn shown(folder: &Path, home: Option<&Path>) -> String {
match home.and_then(|home| folder.strip_prefix(home).ok()) {
Some(rest) if rest.as_os_str().is_empty() => "~".into(),
Some(rest) => format!("~{MAIN_SEPARATOR}{}", rest.display()),
None => folder.display().to_string(),
}
}
/// The session shown once session `closed` of `left + 1` is gone, `active` having been shown.
pub fn after_close(active: usize, closed: usize, left: usize) -> usize {
if closed < active || active == left {
active - 1
} else {
active
}
}
/// Session `index`'s row: its title and folder, a bell while one rang unseen, a button that closes it.
pub fn row(
workspace: &Entity<Workspace>,
index: usize,
session: &Session,
active: bool,
home: Option<&Path>,
cx: &App,
) -> ListItem {
let pid = session.terminal.read(cx).pid() as usize;
let title = if session.title.is_empty() {
TITLE
} else {
&session.title
};
let (select, close) = (workspace.clone(), workspace.clone());
let item = ListItem::new(("session", pid), title.to_string())
.description(shown(&session.folder, home))
.selected(active)
.strong(session.bell)
.on_click(move |_, window, cx| select.update(cx, |this, cx| this.select(index, window, cx)))
.trailing(
IconButton::new(("close-session", pid), IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Close session")
.on_click(move |_, window, cx| {
cx.stop_propagation();
close.update(cx, |this, cx| this.close(index, window, cx))
}),
);
if session.bell {
item.leading(Icon::new(IconName::Bell).color(cx.theme().colors.warning))
} else {
item
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn folders_under_home_read_from_a_tilde() {
let home = PathBuf::from("/Users/ely");
let shown = |folder: &str| shown(Path::new(folder), Some(&home));
assert_eq!(shown("/Users/ely"), "~");
assert_eq!(
shown("/Users/ely/src/app"),
format!("~{MAIN_SEPARATOR}src/app")
);
assert_eq!(
shown("/Users/elyse"),
"/Users/elyse",
"a sibling is no child"
);
assert_eq!(shown("/tmp"), "/tmp");
}
#[test]
fn closing_shows_the_next_session_or_keeps_the_shown_one() {
assert_eq!(after_close(2, 0, 3), 1, "one before it went");
assert_eq!(after_close(1, 1, 3), 1, "it went: the next one");
assert_eq!(after_close(3, 3, 3), 2, "the last went: the one before");
assert_eq!(after_close(1, 2, 3), 1, "one after it went");
}
}
+258 -44
View File
@@ -1,19 +1,45 @@
use std::path::PathBuf;
use ely_gpui_component::{
primitives::FocusScope,
buttons::{ButtonVariant, IconButton},
lists::List,
primitives::{FocusScope, IconName},
shell::{ColumnShell, ResizeBorder, ShellColumn},
theme::ActiveTheme,
theme::{ActiveTheme, ControlSize},
};
use gpui::{
App, Context, Entity, FocusHandle, InteractiveElement, IntoElement, KeyBinding, ParentElement,
Render, SharedString, Styled, Subscription, Window, actions, div,
Action, App, AppContext, Context, EntityId, FocusHandle, Focusable, InteractiveElement,
IntoElement, KeyBinding, ParentElement, Render, StatefulInteractiveElement, Styled,
Subscription, Window, actions, div,
};
use crate::{
settings::{Settings, SettingsStore},
term::{TerminalEvent, TerminalView},
sessions::{self, Session},
settings::{Settings, SettingsStore, StartIn},
term::{
Events, Pty, TerminalEvent, TerminalView, config,
shell::{ShellIntegration, launch},
spawn,
},
};
actions!(elyterminal, [ToggleSessions, TogglePanel]);
actions!(
elyterminal,
[
ToggleSessions,
TogglePanel,
NewSession,
CloseSession,
NextSession,
PreviousSession,
LastSession
]
);
/// Shows the session at this place, from 0; past the last one, the key goes on to the program.
#[derive(Clone, Debug, PartialEq, Action)]
#[action(namespace = elyterminal, no_json)]
pub struct SelectSession(pub usize);
/// The key context of the main window's content.
const CONTEXT: &str = "Workspace";
@@ -21,61 +47,203 @@ const CONTEXT: &str = "Workspace";
/// The window's title while the program names none.
pub const TITLE: &str = "ELYTerminal";
/// Cmd-B and Cmd-Alt-B toggle the side columns; Ctrl-Shift-B and Ctrl-Shift-Alt-B off macOS.
/// Ghostty's keys: Cmd on macOS; off it, Ctrl-Shift for keys a terminal takes, Alt for digits.
pub fn bind_keys(cx: &mut App) {
let (sessions, panel) = if cfg!(target_os = "macos") {
("cmd-b", "cmd-alt-b")
let (sessions, panel, new, close, digit) = if cfg!(target_os = "macos") {
("cmd-b", "cmd-alt-b", "cmd-t", "cmd-w", "cmd")
} else {
("ctrl-shift-b", "ctrl-shift-alt-b")
(
"ctrl-shift-b",
"ctrl-shift-alt-b",
"ctrl-shift-t",
"ctrl-shift-w",
"alt",
)
};
let context = Some(CONTEXT);
cx.bind_keys([
KeyBinding::new(sessions, ToggleSessions, Some(CONTEXT)),
KeyBinding::new(panel, TogglePanel, Some(CONTEXT)),
KeyBinding::new(sessions, ToggleSessions, context),
KeyBinding::new(panel, TogglePanel, context),
KeyBinding::new(new, NewSession, context),
KeyBinding::new(close, CloseSession, context),
KeyBinding::new("ctrl-tab", NextSession, context),
KeyBinding::new("ctrl-shift-tab", PreviousSession, context),
KeyBinding::new(&format!("{digit}-9"), LastSession, context),
]);
cx.bind_keys(
(1..=8).map(|n| KeyBinding::new(&format!("{digit}-{n}"), SelectSession(n - 1), context)),
);
}
/// The main window's content: sessions, the terminal, and Git or Files, in Ely's columns.
pub struct Workspace {
focus: FocusHandle,
terminal: Entity<TerminalView>,
/// The program's title, or the app's name while it names none.
title: SharedString,
_events: Subscription,
sessions: Vec<Session>,
/// The session shown.
active: usize,
_settings: Subscription,
}
impl Workspace {
/// The window's content with its first session, `pty` started in `folder`.
pub fn new(
terminal: Entity<TerminalView>,
pty: Pty,
events: Events,
folder: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
let mut workspace = Self {
focus: cx.focus_handle(),
title: TITLE.into(),
_events: cx.subscribe_in(&terminal, window, Self::terminal_event),
sessions: Vec::new(),
active: 0,
_settings: cx.observe_global::<SettingsStore>(|_, cx| cx.notify()),
};
workspace.add(pty, events, folder, window, cx);
workspace.select(0, window, cx);
workspace
}
fn add(
&mut self,
pty: Pty,
events: Events,
folder: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) {
let terminal = cx.new(|cx| TerminalView::new(pty, events, window, cx));
let id = terminal.entity_id();
let events = cx.subscribe_in(&terminal, window, move |this, _, event, window, cx| {
this.session_event(id, event, window, cx)
});
let pid = terminal.read(cx).pid();
log::info!("app: session {pid} opens in {}", folder.display());
self.sessions.push(Session {
terminal,
title: String::new(),
folder,
bell: false,
_events: events,
});
}
/// A session of the shell the settings name, in home or the shown session's folder.
fn new_session(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(home) = dirs::home_dir() else {
log::error!("app: no home folder, so no new session");
return;
};
let settings = SettingsStore::get(cx);
let folder = match settings.start_in {
StartIn::Home => home,
StartIn::Session => self
.sessions
.get(self.active)
.map_or(home, |session| session.folder.clone()),
};
let started = launch(
&settings.shell,
folder.clone(),
&cx.global::<ShellIntegration>().0,
)
.and_then(|launch| spawn(&launch, (80, 24), (8, 16), config(settings)));
match started {
Ok((pty, events)) => {
self.add(pty, events, folder, window, cx);
self.select(self.sessions.len() - 1, window, cx);
}
Err(error) => log::error!("app: no new session: {error:#}"),
}
}
/// The window and the terminal's header wear the program's title.
fn terminal_event(
/// Shows session `index`, clears its bell, and gives its terminal the keys.
pub fn select(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
let Some(session) = self.sessions.get_mut(index) else {
return;
};
session.bell = false;
window.focus(&session.terminal.focus_handle(cx), cx);
self.active = index;
log::debug!("app: session {} shown", index + 1);
self.retitle(window);
cx.notify();
}
/// Closes session `index`; its program hangs up. The last one closes the window.
pub fn close(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
if index >= self.sessions.len() {
return;
}
let session = self.sessions.remove(index);
log::info!("app: session {} closes", session.terminal.read(cx).pid());
if self.sessions.is_empty() {
log::info!("app: no session left; the window closes");
window.remove_window();
return;
}
let shown = index == self.active;
self.active = sessions::after_close(self.active, index, self.sessions.len());
if shown {
self.select(self.active, window, cx);
} else {
cx.notify();
}
}
/// The shown session's title, or the app's name while its program names none.
fn title(&self) -> &str {
self.sessions
.get(self.active)
.map(|session| session.title.as_str())
.filter(|title| !title.is_empty())
.unwrap_or(TITLE)
}
fn retitle(&self, window: &mut Window) {
let title = self.title();
if window.window_title() != title {
log::debug!("app: the title is {title:?}");
window.set_window_title(title);
}
}
fn session_event(
&mut self,
_: &Entity<TerminalView>,
id: EntityId,
event: &TerminalEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let TerminalEvent::Title(title) = event {
let title = if title.is_empty() { TITLE } else { title };
if self.title.as_ref() != title {
log::debug!("app: the title is {title:?}");
self.title = SharedString::from(title.to_string());
window.set_window_title(title);
cx.notify();
let Some(index) = self
.sessions
.iter()
.position(|session| session.terminal.entity_id() == id)
else {
return;
};
let session = &mut self.sessions[index];
match event {
TerminalEvent::Title(title) => session.title.clone_from(title),
TerminalEvent::Folder(folder) => session.folder.clone_from(folder),
TerminalEvent::Bell if index != self.active => {
if !session.bell {
log::debug!("app: a bell in hidden session {}", index + 1);
}
session.bell = true;
}
TerminalEvent::Bell => {}
TerminalEvent::Exited(status) => {
let pid = session.terminal.read(cx).pid();
log::info!("app: session {pid}'s program ended, {status}");
self.close(index, window, cx);
return;
}
}
if index == self.active {
self.retitle(window);
}
cx.notify();
}
/// Shows or hides a side column, as the settings keep it.
@@ -83,23 +251,23 @@ impl Workspace {
match SettingsStore::update(cx, flip) {
Ok(()) => {
let settings = SettingsStore::get(cx);
let shown = |on: bool| if on { "shown" } else { "hidden" };
log::info!(
"app: sessions {}, right column {}",
if settings.show_sessions {
"shown"
} else {
"hidden"
},
if settings.show_panel {
"shown"
} else {
"hidden"
}
shown(settings.show_sessions),
shown(settings.show_panel)
);
}
Err(error) => log::error!("app: the columns stay: {error:#}"),
}
}
/// Shows the next session, or the one before, round the ends.
fn step(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
let count = self.sessions.len();
let by = if forward { 1 } else { count - 1 };
self.select((self.active + by) % count, window, cx);
}
}
impl Render for Workspace {
@@ -108,12 +276,38 @@ impl Render for Workspace {
let width = cx.theme().sidebar_width(false);
let mut shell = ColumnShell::new("workspace");
if settings.show_sessions {
shell = shell.column(ShellColumn::new("sessions").width(width));
let (workspace, home) = (cx.entity(), dirs::home_dir());
let rows = self.sessions.iter().enumerate().map(|(index, session)| {
let active = index == self.active;
sessions::row(&workspace, index, session, active, home.as_deref(), cx)
});
let list = List::new().children(rows);
let new = IconButton::new("new-session", IconName::Plus)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("New session")
.on_click(move |_, window, cx| {
workspace.update(cx, |this, cx| this.new_session(window, cx))
});
shell = shell.column(
ShellColumn::new("sessions").width(width).action(new).child(
div()
.id("sessions")
.size_full()
.overflow_y_scroll()
.p_2()
.child(list),
),
);
}
let shown = self
.sessions
.get(self.active)
.map(|session| session.terminal.clone());
shell = shell.column(
ShellColumn::new("terminal")
.title(self.title.clone())
.child(self.terminal.clone()),
.title(self.title().to_string())
.children(shown),
);
if settings.show_panel {
shell = shell.column(ShellColumn::new("panel").width(width));
@@ -131,6 +325,26 @@ impl Render for Workspace {
.on_action(cx.listener(|this, _: &TogglePanel, _, cx| {
this.toggle(|settings| settings.show_panel = !settings.show_panel, cx)
}))
.on_action(cx.listener(|this, _: &NewSession, window, cx| this.new_session(window, cx)))
.on_action(
cx.listener(|this, _: &CloseSession, window, cx| {
this.close(this.active, window, cx)
}),
)
.on_action(cx.listener(|this, _: &NextSession, window, cx| this.step(true, window, cx)))
.on_action(
cx.listener(|this, _: &PreviousSession, window, cx| this.step(false, window, cx)),
)
.on_action(cx.listener(|this, _: &LastSession, window, cx| {
this.select(this.sessions.len().saturating_sub(1), window, cx)
}))
.on_action(cx.listener(|this, select: &SelectSession, window, cx| {
if select.0 < this.sessions.len() {
this.select(select.0, window, cx);
} else {
cx.propagate();
}
}))
.child(
ResizeBorder::new()
.child(FocusScope::new(&self.focus).root().size_full().child(shell)),