git.lucas.co / cce-terminal
terminal emulator
git clone https://git.lucas.co/cce-terminal.git

commit3c3c0361e22a8a363443f6584e9b7a436f76f01f
parent2b1aa613eb
authorLucas Galante <[email protected]>
date2026-09-04 11:50
feat: tabs, managed from the corner menu

A tab is one shell on one pty with its own Term, parser, title, view
offset and bell (the new Tab struct); the window shows the active one.
Messages from the reader threads and the Term event proxies carry a
TabId rather than an index, since an earlier tab closing shifts the
indices, and a message for a tab already gone is dropped. Background
tabs keep parsing but never request a frame.

The corner menu lists the tabs as a radio group — the designer's
dock-tab language: every tab as a row, the shown one marked, clicking
the marked row a no-op — followed by New Tab and, past one tab, Close
Tab. A new tab starts in the active shell's directory (read from
/proc) and is inserted right after it. There is no tab bar; the window
title carries [i/n] while more than one tab is open. Rebindable chords
in the cce-terminal input.kdl domain: new_tab ctrl+shift+t, close_tab
ctrl+shift+w, next_tab ctrl+pagedown, prev_tab ctrl+pageup (the
gnome-terminal set; Ctrl+PageUp/Down no longer reach the shell).

The last tab is the window: closing it kills its shell and the reader
thread's PtyClosed exits the app the way a typed exit does, so the tab
list is never empty. Pointer gestures (selection drag, multi-click,
wheel remainders) stay on the app and drop on a switch; apps tracking
focus see a switch as focus leaving one tab and reaching the other.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/main.rs       | 531 ++++++++++++++++++++++++++++++++++++++++--------------
 src/plate_menu.rs |  51 ++++--
 src/pty.rs        |  18 +-
 3 files changed, 445 insertions(+), 155 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index b563724..b8c732a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -22,7 +22,13 @@
 //! The window plate carries the DE's corner control ([`plate_menu`]): the
 //! circular trigger on the top-right that cce-designer's panes wear, opening
 //! a menu of the actions a menubar-less terminal has nowhere else to put —
-//! copy/paste, text zoom, scrollback and terminal resets, a new window.
+//! copy/paste, text zoom, scrollback and terminal resets, a new window, and
+//! the tabs. A tab ([`Tab`]) is one shell on one pty with its own `Term`,
+//! title, view offset and bell; the window shows the active one and the
+//! menu lists them as a radio group (the designer's dock-tab language) with
+//! New Tab / Close Tab. There is no tab bar: the title carries `[i/n]` while
+//! more than one is open. Rebindable chords (`cce-terminal` domain in
+//! input.kdl): `new_tab`, `close_tab`, `next_tab`, `prev_tab`.
 //!
 //! Config (KDL, live-reloaded on file change): a `terminal { … }` section in
 //! the shared `~/.config/cce/config.kdl` or the per-app
@@ -41,6 +47,7 @@ mod plate_menu;
 mod pty;
 
 use std::io::{Read, Write};
+use std::path::{Path, PathBuf};
 use std::time::{Duration, Instant};
 
 use alacritty_terminal::event::{Event as TermEvent, EventListener, WindowSize};
@@ -139,6 +146,10 @@ struct Keys {
     paste: String,
     scroll_up: String,
     scroll_down: String,
+    new_tab: String,
+    close_tab: String,
+    next_tab: String,
+    prev_tab: String,
 }
 
 impl Keys {
@@ -149,15 +160,27 @@ impl Keys {
             paste: get("paste", "ctrl+shift+v"),
             scroll_up: get("scroll_up", "shift+pageup"),
             scroll_down: get("scroll_down", "shift+pagedown"),
+            // The gnome-terminal chords; Ctrl+PageUp/Down are taken from the
+            // shell (they would encode as CSI 5;5~ / 6;5~ otherwise).
+            new_tab: get("new_tab", "ctrl+shift+t"),
+            close_tab: get("close_tab", "ctrl+shift+w"),
+            next_tab: get("next_tab", "ctrl+pagedown"),
+            prev_tab: get("prev_tab", "ctrl+pageup"),
         }
     }
 }
 
+/// A tab's identity for its lifetime. Messages from the pty reader thread
+/// and the Term's event proxy are keyed on it rather than on a `Vec` index,
+/// which shifts when an earlier tab closes.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+struct TabId(u64);
+
 #[derive(Clone)]
 enum Msg {
-    Pty(Vec<u8>),
-    PtyClosed,
-    Term(TermEvent),
+    Pty(TabId, Vec<u8>),
+    PtyClosed(TabId),
+    Term(TabId, TermEvent),
 }
 
 /// The terminal's grid dimensions, for `Term::new`/`resize`.
@@ -179,36 +202,143 @@ impl Dimensions for TermDims {
     }
 }
 
-/// Forwards `Term`'s synthesized events (pty write-backs, title changes …)
-/// into the engine's message channel; they are handled in `update`.
-struct EventProxy(calloop::channel::Sender<Msg>);
+/// Forwards a tab's `Term` events (pty write-backs, title changes …) into
+/// the engine's message channel, tagged with the tab; handled in `update`.
+struct EventProxy {
+    sender: calloop::channel::Sender<Msg>,
+    id: TabId,
+}
 
 impl EventListener for EventProxy {
     fn send_event(&self, event: TermEvent) {
-        let _ = self.0.send(Msg::Term(event));
+        let _ = self.sender.send(Msg::Term(self.id, event));
     }
 }
 
-struct TerminalApp {
+/// One shell on one pty: the VT state and everything that is per-view
+/// rather than per-window. Gesture state (selection drag, multi-click,
+/// wheel remainders) stays on the app: it belongs to the pointer, and a
+/// tab switch simply drops it.
+struct Tab {
+    id: TabId,
     term: Term<EventProxy>,
     parser: Processor,
     pty: pty::Pty,
     writer: std::fs::File,
+    /// OSC title; the active tab's is polled by the engine through
+    /// `settings().title`, every tab's names its menu row.
+    title: Option<String>,
+    /// Scrollback view motion in LINE units: `y.pos()` is the float display
+    /// offset — a notch glides it, a trackpad tracks it 1:1 and its flick
+    /// coasts (cce-ui's ScrollMotion) — and each frame the Term is stepped to
+    /// its rounding. Drawing stays line-quantized; only the offset is smooth.
+    scroll_motion: ScrollMotion,
+    /// Bell flash intensity, 1.0 → 0 over ~a quarter second (decayed in tick).
+    bell: f32,
+    /// Cell of the last motion report — motion is per-cell, not per-pixel.
+    last_mouse_cell: Option<(usize, usize)>,
+}
+
+impl Tab {
+    /// Spawn a shell (or `command`) on a fresh pty sized to the grid, start
+    /// its reader thread, and build the Term that will parse it.
+    fn spawn(
+        id: TabId,
+        cols: u16,
+        rows: u16,
+        settings: &Settings,
+        sender: &calloop::channel::Sender<Msg>,
+        command: Option<&[String]>,
+        cwd: Option<&Path>,
+    ) -> std::io::Result<Tab> {
+        let pty = pty::spawn_shell(cols, rows, command, cwd)?;
+        let writer = pty.dup_handle()?;
+        let mut reader = pty.dup_handle()?;
+        let pty_sender = sender.clone();
+        std::thread::spawn(move || {
+            let mut buf = [0u8; 8192];
+            loop {
+                match reader.read(&mut buf) {
+                    Ok(0) => break,
+                    Ok(n) => {
+                        if pty_sender.send(Msg::Pty(id, buf[..n].to_vec())).is_err() {
+                            return;
+                        }
+                    }
+                    Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
+                    // EIO when the last slave fd closes: the normal exit path.
+                    Err(_) => break,
+                }
+            }
+            let _ = pty_sender.send(Msg::PtyClosed(id));
+        });
+
+        let config =
+            TermConfig { scrolling_history: settings.scrollback, ..TermConfig::default() };
+        let term = Term::new(
+            config,
+            &TermDims { cols, rows },
+            EventProxy { sender: sender.clone(), id },
+        );
+        Ok(Tab {
+            id,
+            term,
+            parser: Processor::new(),
+            pty,
+            writer,
+            title: None,
+            scroll_motion: ScrollMotion::new(),
+            bell: 0.0,
+            last_mouse_cell: None,
+        })
+    }
+
+    /// The shell's current directory (via /proc), so a new tab can start
+    /// where this one is. The shell itself, not its foreground job: that is
+    /// where the next prompt will be.
+    fn cwd(&self) -> Option<PathBuf> {
+        std::fs::read_link(format!("/proc/{}/cwd", self.pty.child.id())).ok()
+    }
+
+    /// The tab's menu row text: its title, or "Tab n" before one arrives,
+    /// clipped so a long OSC title does not stretch the menu.
+    fn label(&self, n: usize) -> String {
+        const MAX: usize = 32;
+        let title = match &self.title {
+            Some(t) if !t.trim().is_empty() => t.trim().to_string(),
+            _ => return format!("Tab {}", n + 1),
+        };
+        if title.chars().count() <= MAX {
+            title
+        } else {
+            let head: String = title.chars().take(MAX - 1).collect();
+            format!("{head}…")
+        }
+    }
+
+    /// Kill the shell and reap it. Closing a tab, not the shell exiting on
+    /// its own — that arrives as `Msg::PtyClosed` from the reader thread.
+    fn kill(&mut self) {
+        let _ = self.pty.child.kill();
+        let _ = self.pty.child.wait();
+    }
+}
+
+struct TerminalApp {
+    /// The engine's message channel, kept so new tabs can spawn onto it.
+    sender: calloop::channel::Sender<Msg>,
+    /// Never empty while the app runs: the last shell exiting exits the app.
+    tabs: Vec<Tab>,
+    active: usize,
+    next_tab_id: u64,
     font: String,
     pad: f32,
     cols: u16,
     rows: u16,
-    /// OSC title; polled by the engine through `settings().title`.
-    title: Option<String>,
     /// Fractional wheel remainder for the whole-line wheel protocols (mouse
     /// reports, alternate-scroll arrows), where a trackpad's pixel deltas
     /// have to add up to a line before anything is sent.
     scroll_accum: f32,
-    /// Scrollback view motion in LINE units: `y.pos()` is the float display
-    /// offset — a notch glides it, a trackpad tracks it 1:1 and its flick
-    /// coasts (cce-ui's ScrollMotion) — and each frame the Term is stepped to
-    /// its rounding. Drawing stays line-quantized; only the offset is smooth.
-    scroll_motion: ScrollMotion,
     focused: bool,
     /// Left button held: pointer moves extend the selection.
     selecting: bool,
@@ -220,8 +350,6 @@ struct TerminalApp {
     /// Config-file mtime at the last (re)load — tick polls it so palette and
     /// font-size edits apply live, per the DE's edit-the-file convention.
     config_stamp: Option<std::time::SystemTime>,
-    /// Bell flash intensity, 1.0 → 0 over ~a quarter second (decayed in tick).
-    bell: f32,
     /// Last pointer position (any state) — tick's autoscroll reads it while a
     /// selection drag holds the pointer in the frame's edge padding.
     last_pointer: LogicalPosition,
@@ -237,8 +365,6 @@ struct TerminalApp {
     alt_down: bool,
     /// Held buttons for drag-motion reports: bit 0 left, 1 middle, 2 right.
     mouse_buttons: u8,
-    /// Cell of the last motion report — motion is per-cell, not per-pixel.
-    last_mouse_cell: Option<(usize, usize)>,
     /// Corner-menu text zoom, in points over the configured font size.
     zoom_steps: i32,
     /// Rows of the OPEN corner menu (empty = not open); see [`plate_menu`].
@@ -246,12 +372,105 @@ struct TerminalApp {
 }
 
 impl TerminalApp {
+    fn tab(&self) -> &Tab {
+        &self.tabs[self.active]
+    }
+
+    fn tab_mut(&mut self) -> &mut Tab {
+        &mut self.tabs[self.active]
+    }
+
+    fn tab_index(&self, id: TabId) -> Option<usize> {
+        self.tabs.iter().position(|t| t.id == id)
+    }
+
+    /// Open a new tab after the active one, in the active shell's directory,
+    /// and switch to it.
+    fn new_tab(&mut self) {
+        let id = TabId(self.next_tab_id);
+        self.next_tab_id += 1;
+        let cwd = self.tab().cwd();
+        match Tab::spawn(id, self.cols, self.rows, &self.settings, &self.sender, None, cwd.as_deref()) {
+            Ok(tab) => {
+                let at = self.active + 1;
+                self.tabs.insert(at, tab);
+                self.show_tab(at);
+            }
+            Err(e) => log::warn!("cce-terminal: failed to spawn a new tab: {e}"),
+        }
+    }
+
+    /// Make tab `i` the visible one. Apps tracking focus (1004) in either
+    /// tab see the switch as focus leaving one and reaching the other.
+    fn show_tab(&mut self, i: usize) {
+        if i >= self.tabs.len() || i == self.active {
+            return;
+        }
+        if self.focused && self.tab().term.mode().contains(TermMode::FOCUS_IN_OUT) {
+            self.write_pty(b"\x1b[O");
+        }
+        self.active = i;
+        // Pointer gestures do not survive the switch: they were about the
+        // old tab's cells.
+        self.selecting = false;
+        self.last_click = None;
+        self.click_count = 0;
+        self.scroll_accum = 0.0;
+        self.autoscroll_accum = 0.0;
+        if self.focused && self.tab().term.mode().contains(TermMode::FOCUS_IN_OUT) {
+            self.write_pty(b"\x1b[I");
+        }
+    }
+
+    fn show_tab_by_id(&mut self, id: TabId) {
+        if let Some(i) = self.tab_index(id) {
+            self.show_tab(i);
+        }
+    }
+
+    /// Next/previous tab, wrapping.
+    fn cycle_tab(&mut self, delta: i32) {
+        let n = self.tabs.len() as i32;
+        if n > 1 {
+            self.show_tab(((self.active as i32 + delta).rem_euclid(n)) as usize);
+        }
+    }
+
+    /// Close the active tab. The last tab is the window: killing its shell
+    /// ends the pty, and the reader thread's `PtyClosed` exits the app the
+    /// way a typed `exit` would, so there is never a tabless window.
+    fn close_active_tab(&mut self) {
+        if self.tabs.len() == 1 {
+            self.tab_mut().kill();
+            return;
+        }
+        let mut tab = self.tabs.remove(self.active);
+        tab.kill();
+        let i = self.active.min(self.tabs.len() - 1);
+        // `show_tab` declines a same-index switch, so drop the gesture
+        // state here — the tab under the pointer changed either way.
+        self.active = i;
+        self.selecting = false;
+        self.last_click = None;
+        self.click_count = 0;
+    }
+
     fn grid_for(&self, w: f32, h: f32) -> (u16, u16) {
         grid_dims(w, h, self.pad, &self.settings)
     }
 
     fn write_pty(&mut self, bytes: &[u8]) {
-        let _ = self.writer.write_all(bytes);
+        let _ = self.tab_mut().writer.write_all(bytes);
+    }
+
+    /// Resize every tab's pty and Term to the current grid.
+    fn resize_tabs(&mut self, cols: u16, rows: u16) {
+        self.cols = cols;
+        self.rows = rows;
+        for tab in &mut self.tabs {
+            tab.pty.resize(cols, rows);
+            tab.term.resize(TermDims { cols, rows });
+        }
     }
 
     fn cell_rect(&self, row: usize, col: usize, width_cells: usize) -> Rect {
@@ -278,7 +497,7 @@ impl TerminalApp {
     fn grid_point(&self, pos: LogicalPosition) -> (Point, Side) {
         let (col, row) = self.viewport_cell(pos);
         let col_f = ((pos.x as f32 - self.pad) / self.settings.cell_w).max(0.0);
-        let line = Line(row as i32 - self.term.grid().display_offset() as i32);
+        let line = Line(row as i32 - self.tab().term.grid().display_offset() as i32);
         let side = if col_f.fract() > 0.5 { Side::Right } else { Side::Left };
         (Point::new(line, Column(col)), side)
     }
@@ -286,7 +505,7 @@ impl TerminalApp {
     /// Whether pointer events currently belong to the application rather than
     /// the selection machinery (Shift bypasses, per convention).
     fn mouse_reporting(&self) -> bool {
-        self.term.mode().intersects(TermMode::MOUSE_MODE) && !self.shift_down
+        self.tab().term.mode().intersects(TermMode::MOUSE_MODE) && !self.shift_down
     }
 
     /// Modifier bits added to every report's button code. Shift never
@@ -296,7 +515,7 @@ impl TerminalApp {
     }
 
     fn send_mouse_report(&mut self, code: u8, pressed: bool, col: usize, row: usize) {
-        if let Some(bytes) = mouse_report_bytes(*self.term.mode(), code, pressed, col, row) {
+        if let Some(bytes) = mouse_report_bytes(*self.tab().term.mode(), code, pressed, col, row) {
             self.write_pty(&bytes);
         }
     }
@@ -317,25 +536,27 @@ impl TerminalApp {
     /// snap to the bottom on a keypress or paste, new output pushing a held
     /// view up the history — so the motion resumes from where the view is.
     fn sync_scroll_motion(&mut self) {
-        let offset = self.term.grid().display_offset() as f32;
-        if self.scroll_motion.y.pos().round() != offset {
-            self.scroll_motion.y.jump_to(offset);
+        let tab = self.tab_mut();
+        let offset = tab.term.grid().display_offset() as f32;
+        if tab.scroll_motion.y.pos().round() != offset {
+            tab.scroll_motion.y.jump_to(offset);
         }
     }
 
     /// The scrollback offset's range: 0 (live bottom) ..= history length.
     fn scrollback_bounds(&self) -> Bounds {
-        Bounds::max(self.term.grid().history_size() as f32)
+        Bounds::max(self.tab().term.grid().history_size() as f32)
     }
 
     /// Step the Term to the motion's rounded offset; true if the view moved.
     fn apply_scroll_motion(&mut self) -> bool {
-        let target = self.scroll_motion.y.pos().round() as i32;
-        let current = self.term.grid().display_offset() as i32;
+        let tab = self.tab_mut();
+        let target = tab.scroll_motion.y.pos().round() as i32;
+        let current = tab.term.grid().display_offset() as i32;
         if target == current {
             return false;
         }
-        self.term.scroll_display(Scroll::Delta(target - current));
+        tab.term.scroll_display(Scroll::Delta(target - current));
         true
     }
 
@@ -348,21 +569,19 @@ impl TerminalApp {
         }
         let (cols, rows) = self.grid_for(self.win_w, self.win_h);
         if (cols, rows) != (self.cols, self.rows) {
-            self.cols = cols;
-            self.rows = rows;
-            self.pty.resize(cols, rows);
-            self.term.resize(TermDims { cols, rows });
+            self.resize_tabs(cols, rows);
         }
         true
     }
 
     /// Send pasted text to the pty and snap the view to the bottom.
     fn paste(&mut self, text: &str) {
-        let bracketed = self.term.mode().contains(TermMode::BRACKETED_PASTE);
+        let bracketed = self.tab().term.mode().contains(TermMode::BRACKETED_PASTE);
         let bytes = paste_bytes(text, bracketed);
         self.write_pty(&bytes);
-        if self.term.grid().display_offset() != 0 {
-            self.term.scroll_display(Scroll::Bottom);
+        let term = &mut self.tab_mut().term;
+        if term.grid().display_offset() != 0 {
+            term.scroll_display(Scroll::Bottom);
         }
     }
 }
@@ -548,45 +767,21 @@ impl Application for TerminalApp {
             None => None,
         };
 
-        let pty = pty::spawn_shell(cols, rows, command.as_deref())
+        // The first tab runs the command (later tabs are plain shells) and
+        // inherits the terminal's own directory.
+        let first = Tab::spawn(TabId(0), cols, rows, &settings, &sender, command.as_deref(), None)
             .expect("cce-terminal: failed to spawn shell on pty");
-        let writer = pty.dup_handle().expect("cce-terminal: pty dup failed");
-        let mut reader = pty.dup_handle().expect("cce-terminal: pty dup failed");
-        let pty_sender = sender.clone();
-        std::thread::spawn(move || {
-            let mut buf = [0u8; 8192];
-            loop {
-                match reader.read(&mut buf) {
-                    Ok(0) => break,
-                    Ok(n) => {
-                        if pty_sender.send(Msg::Pty(buf[..n].to_vec())).is_err() {
-                            return;
-                        }
-                    }
-                    Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
-                    // EIO when the last slave fd closes: the normal exit path.
-                    Err(_) => break,
-                }
-            }
-            let _ = pty_sender.send(Msg::PtyClosed);
-        });
-
-        let config =
-            TermConfig { scrolling_history: settings.scrollback, ..TermConfig::default() };
-        let term = Term::new(config, &TermDims { cols, rows }, EventProxy(sender));
 
         TerminalApp {
-            term,
-            parser: Processor::new(),
-            pty,
-            writer,
+            sender,
+            tabs: vec![first],
+            active: 0,
+            next_tab_id: 1,
             font,
             pad,
             cols,
             rows,
-            title: None,
             scroll_accum: 0.0,
-            scroll_motion: ScrollMotion::new(),
             focused: true,
             selecting: false,
             last_click: None,
@@ -594,7 +789,6 @@ impl Application for TerminalApp {
             settings,
             keys: Keys::load(),
             config_stamp,
-            bell: 0.0,
             last_pointer: LogicalPosition::new(0.0, 0.0),
             autoscroll_accum: 0.0,
             win_w: INIT_W as f32,
@@ -603,15 +797,20 @@ impl Application for TerminalApp {
             ctrl_down: false,
             alt_down: false,
             mouse_buttons: 0,
-            last_mouse_cell: None,
             zoom_steps: 0,
             plate_menu_actions: Vec::new(),
         }
     }
 
     fn settings(&self) -> WindowSettings {
+        // The active tab's title; with several tabs and no tab bar, the
+        // title is where "which one, of how many" shows.
+        let mut title = self.tab().title.clone().unwrap_or_else(|| "cce-terminal".to_string());
+        if self.tabs.len() > 1 {
+            title = format!("{title} [{}/{}]", self.active + 1, self.tabs.len());
+        }
         WindowSettings {
-            title: self.title.clone().unwrap_or_else(|| "cce-terminal".to_string()),
+            title,
             app_id: "cce-terminal".to_string(),
             width: INIT_W,
             height: INIT_H,
@@ -621,41 +820,69 @@ impl Application for TerminalApp {
     }
 
     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
+        // Every message names its tab; one from a tab already closed (its
+        // reader thread outlives the kill by a moment) is simply dropped.
+        // Background tabs keep parsing but never ask for a frame: nothing
+        // of theirs is on screen except their menu label and the title.
+        let (id, is_active) = match &msg {
+            Msg::Pty(id, _) | Msg::PtyClosed(id) | Msg::Term(id, _) => {
+                let Some(i) = self.tab_index(*id) else { return };
+                (i, i == self.active)
+            }
+        };
+        let window = WindowSize {
+            num_lines: self.rows,
+            num_cols: self.cols,
+            cell_width: self.settings.cell_w as u16,
+            cell_height: self.settings.line_h as u16,
+        };
+        let palette = self.settings.palette;
+        let tab = &mut self.tabs[id];
         match msg {
-            Msg::Pty(bytes) => {
-                self.parser.advance(&mut self.term, &bytes);
-                *needs_rebuild = true;
+            Msg::Pty(_, bytes) => {
+                tab.parser.advance(&mut tab.term, &bytes);
+                *needs_rebuild |= is_active;
             }
-            Msg::PtyClosed => {
-                let _ = self.pty.child.wait();
-                *exit = true;
+            Msg::PtyClosed(_) => {
+                let _ = tab.pty.child.wait();
+                if self.tabs.len() == 1 {
+                    // The last shell exiting is the window closing — the
+                    // tab stays in place so nothing observes an empty list.
+                    *exit = true;
+                    return;
+                }
+                self.tabs.remove(id);
+                if id < self.active {
+                    self.active -= 1;
+                } else if self.active >= self.tabs.len() {
+                    self.active = self.tabs.len() - 1;
+                }
+                if is_active {
+                    self.selecting = false;
+                    self.last_click = None;
+                    self.click_count = 0;
+                }
+                *needs_rebuild = true;
             }
-            Msg::Term(event) => match event {
-                TermEvent::PtyWrite(s) => self.write_pty(s.clone().as_bytes()),
+            Msg::Term(_, event) => match event {
+                TermEvent::PtyWrite(s) => {
+                    let _ = tab.writer.write_all(s.as_bytes());
+                }
                 TermEvent::Title(t) => {
-                    self.title = Some(t);
-                    *needs_rebuild = true;
+                    tab.title = Some(t);
+                    *needs_rebuild |= is_active;
                 }
                 TermEvent::ResetTitle => {
-                    self.title = None;
-                    *needs_rebuild = true;
+                    tab.title = None;
+                    *needs_rebuild |= is_active;
                 }
                 TermEvent::ColorRequest(index, format) => {
-                    let rgb = self
-                        .term
-                        .colors()[index]
-                        .unwrap_or_else(|| colors::default_color(index, &self.settings.palette));
-                    let response = format(rgb);
-                    self.write_pty(response.as_bytes());
+                    let rgb = tab.term.colors()[index]
+                        .unwrap_or_else(|| colors::default_color(index, &palette));
+                    let _ = tab.writer.write_all(format(rgb).as_bytes());
                 }
                 TermEvent::TextAreaSizeRequest(format) => {
-                    let response = format(WindowSize {
-                        num_lines: self.rows,
-                        num_cols: self.cols,
-                        cell_width: self.settings.cell_w as u16,
-                        cell_height: self.settings.line_h as u16,
-                    });
-                    self.write_pty(response.as_bytes());
+                    let _ = tab.writer.write_all(format(window).as_bytes());
                 }
                 // OSC 52: programs storing to (or, if enabled in the term
                 // config, reading from) the system clipboards.
@@ -671,12 +898,11 @@ impl Application for TerminalApp {
                         ClipboardType::Selection => clip::paste_primary(),
                     }
                     .unwrap_or_default();
-                    let response = format(&text);
-                    self.write_pty(response.as_bytes());
+                    let _ = tab.writer.write_all(format(&text).as_bytes());
                 }
                 TermEvent::Bell => {
-                    self.bell = 1.0;
-                    *needs_rebuild = true;
+                    tab.bell = 1.0;
+                    *needs_rebuild |= is_active;
                 }
                 // Wakeup/cursor-blink: nothing to do.
                 _ => {}
@@ -685,21 +911,24 @@ impl Application for TerminalApp {
     }
 
     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
-        // Bell flash decay.
-        if self.bell > 0.0 {
-            self.bell = (self.bell - dt * 4.0).max(0.0);
-            *needs_rebuild = true;
+        // Bell flash decay — every tab's, so a background tab's flash has
+        // faded by the time it is shown rather than greeting the switch.
+        for (i, tab) in self.tabs.iter_mut().enumerate() {
+            if tab.bell > 0.0 {
+                tab.bell = (tab.bell - dt * 4.0).max(0.0);
+                *needs_rebuild |= i == self.active;
+            }
         }
 
         // Wheel glide / trackpad coast through the scrollback: advance the
         // line-unit motion and step the Term to its rounded offset, asking
         // for frames while it is still moving.
-        if self.scroll_motion.is_animating() {
+        if self.tab().scroll_motion.is_animating() {
             self.sync_scroll_motion();
-            if self.scroll_motion.is_animating() {
+            if self.tab().scroll_motion.is_animating() {
                 let bounds = self.scrollback_bounds();
-                self.scroll_motion.tick(dt, Bounds::max(0.0), bounds);
-                if self.apply_scroll_motion() || self.scroll_motion.is_animating() {
+                self.tab_mut().scroll_motion.tick(dt, Bounds::max(0.0), bounds);
+                if self.apply_scroll_motion() || self.tab().scroll_motion.is_animating() {
                     *needs_rebuild = true;
                 }
             }
@@ -723,9 +952,9 @@ impl Application for TerminalApp {
                 let lines = self.autoscroll_accum as i32;
                 if lines != 0 {
                     self.autoscroll_accum -= lines as f32;
-                    self.term.scroll_display(Scroll::Delta(lines));
+                    self.tab_mut().term.scroll_display(Scroll::Delta(lines));
                     let (point, side) = self.grid_point(self.last_pointer);
-                    if let Some(selection) = &mut self.term.selection {
+                    if let Some(selection) = &mut self.tab_mut().term.selection {
                         selection.update(point, side);
                     }
                     *needs_rebuild = true;
@@ -756,10 +985,7 @@ impl Application for TerminalApp {
         self.win_h = height;
         let (cols, rows) = self.grid_for(width, height);
         if (cols, rows) != (self.cols, self.rows) {
-            self.cols = cols;
-            self.rows = rows;
-            self.pty.resize(cols, rows);
-            self.term.resize(TermDims { cols, rows });
+            self.resize_tabs(cols, rows);
         }
     }
 
@@ -787,7 +1013,9 @@ impl Application for TerminalApp {
         let font = self.font.clone();
         let bounds = [pad, 0.0, w - pad, h];
 
-        let content = self.term.renderable_content();
+        let tab = self.tab();
+        let bell = tab.bell;
+        let content = tab.term.renderable_content();
         let display_offset = content.display_offset as i32;
         let overrides = content.colors;
         let cfg_palette = self.settings.palette;
@@ -999,8 +1227,8 @@ impl Application for TerminalApp {
         }
 
         // Visual bell: a brief foreground-tinted wash over the frame.
-        if self.bell > 0.0 {
-            pc.quad(frame, quad_color(cfg_palette.foreground, 0.12 * self.bell));
+        if bell > 0.0 {
+            pc.quad(frame, quad_color(cfg_palette.foreground, 0.12 * bell));
         }
 
         // The corner control over the grid, and its menu over everything.
@@ -1022,7 +1250,7 @@ impl Application for TerminalApp {
             self.ctrl_down = false;
             self.alt_down = false;
             self.mouse_buttons = 0;
-            if self.term.mode().contains(TermMode::FOCUS_IN_OUT) {
+            if self.tab().term.mode().contains(TermMode::FOCUS_IN_OUT) {
                 self.write_pty(if focused { b"\x1b[I" } else { b"\x1b[O" });
             }
         }
@@ -1046,13 +1274,13 @@ impl Application for TerminalApp {
             *needs_rebuild = true;
         }
         if self.mouse_reporting() && !self.selecting {
-            let mode = *self.term.mode();
+            let mode = *self.tab().term.mode();
             let motion_wanted = mode.contains(TermMode::MOUSE_MOTION)
                 || (mode.contains(TermMode::MOUSE_DRAG) && self.mouse_buttons != 0);
             if motion_wanted {
                 let cell = self.viewport_cell(pos);
-                if self.last_mouse_cell != Some(cell) {
-                    self.last_mouse_cell = Some(cell);
+                if self.tab().last_mouse_cell != Some(cell) {
+                    self.tab_mut().last_mouse_cell = Some(cell);
                     // Lowest held button, or 3 (no button) for plain motion.
                     let button = (0..3).find(|b| self.mouse_buttons & (1 << b) != 0).unwrap_or(3);
                     let code = 32 + button + self.report_mods();
@@ -1066,7 +1294,7 @@ impl Application for TerminalApp {
         }
         // Edge overshoot scrolls on tick's autoscroll clock, not per event.
         let (point, side) = self.grid_point(pos);
-        if let Some(selection) = &mut self.term.selection {
+        if let Some(selection) = &mut self.tab_mut().term.selection {
             selection.update(point, side);
             *needs_rebuild = true;
         }
@@ -1086,8 +1314,8 @@ impl Application for TerminalApp {
                 state,
                 pos.x,
                 pos.y,
-                self.term.selection.is_some(),
-                self.term.mode(),
+                self.tab().term.selection.is_some(),
+                self.tab().term.mode(),
                 self.shift_down
             );
         }
@@ -1129,7 +1357,7 @@ impl Application for TerminalApp {
                 let code = code + self.report_mods();
                 self.send_mouse_report(code, state == ElementState::Pressed, col, row);
                 if state == ElementState::Pressed {
-                    self.last_mouse_cell = Some((col, row));
+                    self.tab_mut().last_mouse_cell = Some((col, row));
                 }
             }
             return None;
@@ -1149,8 +1377,8 @@ impl Application for TerminalApp {
                     3 => SelectionType::Lines,
                     _ => SelectionType::Simple,
                 };
-                let had_selection = self.term.selection.is_some();
-                self.term.selection = Some(Selection::new(ty, point, side));
+                let had_selection = self.tab().term.selection.is_some();
+                self.tab_mut().term.selection = Some(Selection::new(ty, point, side));
                 self.selecting = true;
                 // Semantic/Lines are non-empty immediately; a fresh Simple
                 // press only needs a repaint if it cleared an old highlight.
@@ -1160,10 +1388,10 @@ impl Application for TerminalApp {
                 self.selecting = false;
                 // Empty selections (a plain click) drop; real ones go to
                 // PRIMARY, per the select-then-middle-click convention.
-                match self.term.selection_to_string() {
+                match self.tab().term.selection_to_string() {
                     Some(text) if !text.is_empty() => clip::copy_primary(&text),
                     _ => {
-                        if self.term.selection.take().is_some() {
+                        if self.tab_mut().term.selection.take().is_some() {
                             *needs_rebuild = true;
                         }
                     }
@@ -1202,7 +1430,7 @@ impl Application for TerminalApp {
             return;
         }
 
-        let mode = *self.term.mode();
+        let mode = *self.tab().term.mode();
         if mode.contains(TermMode::ALT_SCREEN) && mode.contains(TermMode::ALTERNATE_SCROLL) {
             // Full-screen apps without mouse reporting get arrow keys.
             let Some(lines) = self.take_whole_lines(lines) else { return };
@@ -1220,11 +1448,12 @@ impl Application for TerminalApp {
         self.sync_scroll_motion();
         let bounds = self.scrollback_bounds();
         let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
-        let moved = self.scroll_motion.apply_px(0.0, lines, discrete, Bounds::max(0.0), bounds);
+        let moved =
+            self.tab_mut().scroll_motion.apply_px(0.0, lines, discrete, Bounds::max(0.0), bounds);
         if moved && self.apply_scroll_motion() {
             *needs_rebuild = true;
         }
-        if self.scroll_motion.is_animating() {
+        if self.tab().scroll_motion.is_animating() {
             // A notch only moved the target; tick glides the view there.
             *needs_rebuild = true;
         }
@@ -1256,13 +1485,34 @@ impl Application for TerminalApp {
         // a bare Ctrl+C must still reach the shell as SIGINT.
         use cce_ui::widget::match_key_shortcut;
         if match_key_shortcut(event, &self.keys.copy) {
-            if let Some(text) = self.term.selection_to_string() {
+            if let Some(text) = self.tab().term.selection_to_string() {
                 if !text.is_empty() {
                     cce_ui::widget::clipboard::copy_to_clipboard(&text);
                 }
             }
             return None;
         }
+        // Tabs: the same operations the corner menu offers, by chord.
+        if match_key_shortcut(event, &self.keys.new_tab) {
+            self.new_tab();
+            *needs_rebuild = true;
+            return None;
+        }
+        if match_key_shortcut(event, &self.keys.close_tab) {
+            self.close_active_tab();
+            *needs_rebuild = true;
+            return None;
+        }
+        if match_key_shortcut(event, &self.keys.next_tab) {
+            self.cycle_tab(1);
+            *needs_rebuild = true;
+            return None;
+        }
+        if match_key_shortcut(event, &self.keys.prev_tab) {
+            self.cycle_tab(-1);
+            *needs_rebuild = true;
+            return None;
+        }
         if match_key_shortcut(event, &self.keys.paste) {
             if let Some(text) = cce_ui::widget::clipboard::read_from_clipboard() {
                 if !text.is_empty() {
@@ -1272,23 +1522,24 @@ impl Application for TerminalApp {
             return None;
         }
         if match_key_shortcut(event, &self.keys.scroll_up) {
-            self.term.scroll_display(Scroll::PageUp);
+            self.tab_mut().term.scroll_display(Scroll::PageUp);
             *needs_rebuild = true;
             return None;
         }
         if match_key_shortcut(event, &self.keys.scroll_down) {
-            self.term.scroll_display(Scroll::PageDown);
+            self.tab_mut().term.scroll_display(Scroll::PageDown);
             *needs_rebuild = true;
             return None;
         }
 
-        if let Some(bytes) = encode_key(event, *self.term.mode()) {
+        if let Some(bytes) = encode_key(event, *self.tab().term.mode()) {
             self.write_pty(&bytes);
-            if self.term.selection.take().is_some() {
+            let term = &mut self.tab_mut().term;
+            if term.selection.take().is_some() {
                 *needs_rebuild = true;
             }
-            if self.term.grid().display_offset() != 0 {
-                self.term.scroll_display(Scroll::Bottom);
+            if term.grid().display_offset() != 0 {
+                term.scroll_display(Scroll::Bottom);
                 *needs_rebuild = true;
             }
         }
diff --git a/src/plate_menu.rs b/src/plate_menu.rs
index 37cb4ce..2b2f3fd 100644
--- a/src/plate_menu.rs
+++ b/src/plate_menu.rs
@@ -6,17 +6,20 @@
 //! The terminal has one plate — the window itself — so the toolkit's dock
 //! vocabulary (collapse, detach) does not apply; the rows are the actions a
 //! terminal without a menubar has nowhere else to put: the clipboard pair,
-//! text zoom, scrollback/state resets, and a new window. Geometry is the
-//! toolkit's (`corner_center` on the window rect), the menu is the shared
-//! `context_menu`, and the rows are dispatched here — the same
-//! `plate_menu_actions` + `handle_plate_menu_click` contract as the designer.
+//! text zoom, scrollback/state resets, the tabs, and a new window. The tabs
+//! borrow the designer's dock-tab language: every tab listed as a radio row
+//! (the shown one marked, so the list reads as state), then New Tab and
+//! Close Tab. Geometry is the toolkit's (`corner_center` on the window
+//! rect), the menu is the shared `context_menu`, and the rows are dispatched
+//! here — the same `plate_menu_actions` + `handle_plate_menu_click` contract
+//! as the designer.
 
 use alacritty_terminal::grid::Scroll;
 use alacritty_terminal::vte::ansi::Handler;
 use cce_ui::widget::plate_dock::{self, CORNER_R};
 use cce_ui::widget::{context_menu, ElementState, MouseButton, WidgetId};
 
-use crate::TerminalApp;
+use crate::{TabId, TerminalApp};
 
 /// What the corner menu can do to the terminal.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -32,8 +35,15 @@ pub enum PlateMenuAction {
     ResetTextSize,
     /// Drop the scrollback history (the viewport stays).
     ClearScrollback,
-    /// Full VT reset: modes, colors, tabs, the alternate screen.
+    /// Full VT reset: modes, colors, tab stops, the alternate screen.
     ResetTerminal,
+    /// Bring the named tab to the front. By id, not index: a tab whose
+    /// shell exits while the menu is open shifts the indices under it.
+    ShowTab(TabId),
+    /// A fresh shell in the active tab's directory, shown.
+    NewTab,
+    /// Close the active tab (its shell is killed).
+    CloseTab,
     /// Another cce-terminal, detached.
     NewWindow,
     /// A "-" row: engraved, inert — keeps the actions aligned with the option
@@ -74,7 +84,7 @@ impl TerminalApp {
             actions.push(action);
         };
 
-        if self.term.selection_to_string().is_some_and(|s| !s.is_empty()) {
+        if self.tab().term.selection_to_string().is_some_and(|s| !s.is_empty()) {
             row("Copy", PlateMenuAction::Copy);
         }
         row("Paste", PlateMenuAction::Paste);
@@ -88,6 +98,18 @@ impl TerminalApp {
         row("Clear Scrollback", PlateMenuAction::ClearScrollback);
         row("Reset Terminal", PlateMenuAction::ResetTerminal);
         row("-", PlateMenuAction::Separator);
+        // The tabs as a RADIO group: every one listed, the shown one marked.
+        // Clicking the marked row is a no-op (show_tab declines the active
+        // index), so the list reads as state, not just as actions.
+        for (i, tab) in self.tabs.iter().enumerate() {
+            let mark = if i == self.active { "●" } else { "○" };
+            row(&format!("{mark} {}", tab.label(i)), PlateMenuAction::ShowTab(tab.id));
+        }
+        row("New Tab", PlateMenuAction::NewTab);
+        if self.tabs.len() > 1 {
+            row("Close Tab", PlateMenuAction::CloseTab);
+        }
+        row("-", PlateMenuAction::Separator);
         row("New Window", PlateMenuAction::NewWindow);
 
         // The menu sizes itself from its labels on `show`, so place it once
@@ -137,7 +159,7 @@ impl TerminalApp {
     fn dispatch_plate_menu(&mut self, action: PlateMenuAction) {
         match action {
             PlateMenuAction::Copy => {
-                if let Some(text) = self.term.selection_to_string() {
+                if let Some(text) = self.tab().term.selection_to_string() {
                     if !text.is_empty() {
                         cce_ui::widget::clipboard::copy_to_clipboard(&text);
                     }
@@ -157,15 +179,20 @@ impl TerminalApp {
                 // Drop the view to the live screen first: a display offset
                 // into history that no longer exists is not a state the grid
                 // guards against.
-                self.term.scroll_display(Scroll::Bottom);
-                self.term.grid_mut().clear_history();
+                let term = &mut self.tab_mut().term;
+                term.scroll_display(Scroll::Bottom);
+                term.grid_mut().clear_history();
                 self.sync_scroll_motion();
             }
             PlateMenuAction::ResetTerminal => {
-                self.term.selection = None;
-                self.term.reset_state();
+                let term = &mut self.tab_mut().term;
+                term.selection = None;
+                term.reset_state();
                 self.sync_scroll_motion();
             }
+            PlateMenuAction::ShowTab(id) => self.show_tab_by_id(id),
+            PlateMenuAction::NewTab => self.new_tab(),
+            PlateMenuAction::CloseTab => self.close_active_tab(),
             PlateMenuAction::NewWindow => match std::env::current_exe() {
                 Ok(exe) => {
                     if let Err(e) = cce_ui::process::spawn_detached(std::process::Command::new(exe)) {
diff --git a/src/pty.rs b/src/pty.rs
index e318d0f..c3ebad2 100644
--- a/src/pty.rs
+++ b/src/pty.rs
@@ -15,8 +15,15 @@ pub struct Pty {
 /// Open a pty pair and spawn `command` (or `$SHELL`) on the slave side, in
 /// its own session with the slave as controlling terminal. The slave fd is
 /// fully handed to the child (stdin/stdout/stderr) and closed in the parent,
-/// so EOF on the master is the child-exit signal.
-pub fn spawn_shell(cols: u16, rows: u16, command: Option<&[String]>) -> io::Result<Pty> {
+/// so EOF on the master is the child-exit signal. `cwd` starts the child
+/// there (a new tab inherits the active one's directory); `None` inherits
+/// the terminal's own.
+pub fn spawn_shell(
+    cols: u16,
+    rows: u16,
+    command: Option<&[String]>,
+    cwd: Option<&std::path::Path>,
+) -> io::Result<Pty> {
     let mut master: libc::c_int = -1;
     let mut slave: libc::c_int = -1;
     let ws = libc::winsize { ws_row: rows, ws_col: cols, ws_xpixel: 0, ws_ypixel: 0 };
@@ -45,6 +52,11 @@ pub fn spawn_shell(cols: u16, rows: u16, command: Option<&[String]>) -> io::Resu
         .stdin(Stdio::from(slave.try_clone()?))
         .stdout(Stdio::from(slave.try_clone()?))
         .stderr(Stdio::from(slave));
+    // A directory that vanished since it was read is not worth failing the
+    // spawn over — the child just starts where the terminal did.
+    if let Some(dir) = cwd.filter(|d| d.is_dir()) {
+        cmd.current_dir(dir);
+    }
     unsafe {
         cmd.pre_exec(|| {
             if libc::setsid() < 0 {
@@ -89,7 +101,7 @@ mod tests {
     /// window (the GUI path is `handle_key_input` → the same master fd).
     #[test]
     fn shell_round_trip() {
-        let mut pty = spawn_shell(80, 24, None).expect("openpty/spawn");
+        let mut pty = spawn_shell(80, 24, None, None).expect("openpty/spawn");
         let mut writer = pty.dup_handle().unwrap();
         let mut reader = pty.dup_handle().unwrap();
         writer.write_all(b"printf 'RT-%s\\n' OK; exit\r").unwrap();