diff --git a/Cargo.lock b/Cargo.lock
index 69c3f731..026be7fd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3180,7 +3180,7 @@ dependencies = [
[[package]]
name = "gpui-component"
version = "0.5.2"
-source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#ff8af959b759a5c554a243fbae5ca78ce1cdf5e5"
+source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#3826fafacd3c854bbcddb596116a1768e24031b9"
dependencies = [
"aho-corasick",
"anyhow",
@@ -3220,7 +3220,40 @@ dependencies = [
"smol",
"tracing",
"tree-sitter",
+ "tree-sitter-astro-next",
+ "tree-sitter-bash",
+ "tree-sitter-c",
+ "tree-sitter-c-sharp",
+ "tree-sitter-cmake",
+ "tree-sitter-cpp",
+ "tree-sitter-css",
+ "tree-sitter-diff",
+ "tree-sitter-elixir",
+ "tree-sitter-embedded-template",
+ "tree-sitter-go",
+ "tree-sitter-graphql",
+ "tree-sitter-html",
+ "tree-sitter-java",
+ "tree-sitter-javascript",
+ "tree-sitter-jsdoc",
"tree-sitter-json",
+ "tree-sitter-kotlin-sg",
+ "tree-sitter-lua",
+ "tree-sitter-make",
+ "tree-sitter-md",
+ "tree-sitter-php",
+ "tree-sitter-proto",
+ "tree-sitter-python",
+ "tree-sitter-ruby",
+ "tree-sitter-rust",
+ "tree-sitter-scala",
+ "tree-sitter-sequel",
+ "tree-sitter-svelte-next",
+ "tree-sitter-swift",
+ "tree-sitter-toml-ng",
+ "tree-sitter-typescript",
+ "tree-sitter-yaml",
+ "tree-sitter-zig",
"unicode-segmentation",
"uuid",
"windows 0.58.0",
@@ -3230,7 +3263,7 @@ dependencies = [
[[package]]
name = "gpui-component-assets"
version = "0.5.1"
-source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#ff8af959b759a5c554a243fbae5ca78ce1cdf5e5"
+source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#3826fafacd3c854bbcddb596116a1768e24031b9"
dependencies = [
"anyhow",
"gpui",
@@ -3244,7 +3277,7 @@ dependencies = [
[[package]]
name = "gpui-component-macros"
version = "0.5.1"
-source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#ff8af959b759a5c554a243fbae5ca78ce1cdf5e5"
+source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#3826fafacd3c854bbcddb596116a1768e24031b9"
dependencies = [
"proc-macro2",
"quote",
@@ -9135,6 +9168,166 @@ dependencies = [
"tree-sitter-language",
]
+[[package]]
+name = "tree-sitter-astro-next"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "794a4a59fc2d88e49b4bc41fef9522d77184a36f4e68bbaf545cd1eb2364c46e"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-bash"
+version = "0.23.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-c"
+version = "0.24.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-c-sharp"
+version = "0.23.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-cmake"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "164e0c4f4236ec5ceff14824a5528615cf462e100467e49826442ff57d327061"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-cpp"
+version = "0.23.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-css"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-diff"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-elixir"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-embedded-template"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790063ef14e5b67556abc0b3be0ed863fb41d65ee791cf8c0b20eb42a1fa46af"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-go"
+version = "0.23.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-graphql"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "efedc4cac157161cc23a0adc4553a2cedc908e1cd754b6cd033a919bb81ce5d6"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-html"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-java"
+version = "0.23.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-javascript"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-jsdoc"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3862dfcb1038fc5e7812d7df14190afdeb7e1415288fd5f51f58395f8cb0faf"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
[[package]]
name = "tree-sitter-json"
version = "0.24.8"
@@ -9145,12 +9338,182 @@ dependencies = [
"tree-sitter-language",
]
+[[package]]
+name = "tree-sitter-kotlin-sg"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06ec43ae3c12165d4ac08afe4e1f5fc6757ffe274fa7bd5af9007ef11ba4319"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
[[package]]
name = "tree-sitter-language"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
+[[package]]
+name = "tree-sitter-lua"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea992f4164d83f371ef1239ae178c4d4596c296c09055e9a48bb02a2760403af"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-make"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-md"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-php"
+version = "0.24.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-proto"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e4360b434b5980fc397137ef29e1988619fef4159ac86fa7ac5777d459d3924"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-python"
+version = "0.23.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-ruby"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-rust"
+version = "0.24.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-scala"
+version = "0.23.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "efde5e68b4736e9eac17bfa296c6f104a26bffab363b365eb898c40a63c15d2f"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-sequel"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-svelte-next"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f88190d0743e897c3e148a7e241aba0a8844b8afe816943851426e3f7b9753"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-swift"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-toml-ng"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-typescript"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-yaml"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-zig"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
[[package]]
name = "triomphe"
version = "0.1.15"
@@ -9189,6 +9552,7 @@ dependencies = [
"gpui-component",
"gpui-component-assets",
"gpui_platform",
+ "ignore",
"image",
"jieba-rs",
"keyring",
@@ -9196,6 +9560,7 @@ dependencies = [
"libc",
"libgssapi",
"log",
+ "lsp-types",
"memchr",
"notify 8.2.0",
"notify-rust",
@@ -9207,6 +9572,7 @@ dependencies = [
"regex",
"reqwest_client",
"resvg",
+ "ropey",
"russh",
"russh-sftp",
"serde",
@@ -9217,6 +9583,7 @@ dependencies = [
"smol",
"tokio",
"tray-icon",
+ "url",
"uuid",
"windows-sys 0.59.0",
"winresource",
diff --git a/Cargo.toml b/Cargo.toml
index 5b1d5cb3..34cd6409 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -76,9 +76,21 @@ alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev
notify-rust = "4"
# Filesystem watcher for live config reload: watches `config.json` and reloads
-# `Config` + re-applies the theme without a restart.
+# `Config` + re-applies the theme without a restart. The code panel (file tree /
+# editor, `ui::code`) reuses it to refresh the tree and detect external edits.
notify = "8"
+# Code panel (file tree + editor, `ui::code`). `ropey` and `lsp-types` are pinned
+# to the exact versions gpui-component uses so its `InputState` rope / LSP
+# provider types unify with ours; `ignore` supplies the gitignore matcher chain
+# the file tree uses to dim ignored entries (same crate ripgrep uses).
+ropey = "=2.0.0-beta.1"
+lsp-types = { version = "0.97.0", features = ["proposed"] }
+ignore = "0.4"
+# `url` does the file-path ↔ `file://` URI conversion for the LSP client
+# (percent-encoding, Windows drive letters); already in the tree via gpui.
+url = "2"
+
# Cross-platform PTY for the daemon: a Unix pty on Unix, ConPTY on Windows, behind
# one blocking `Read`/`Write`/`resize` API. This is what lets `daemon::pane` share
# a single code path across platforms instead of hand-rolling fd/ioctl/signal code.
@@ -228,10 +240,16 @@ edition = "2024"
[workspace.dependencies]
# Our fork's `tty7` branch carries the local customizations tty7 relies on:
-# `PopupMenu::with_size` plus the 1px hairline menu separator. The exact commit
-# is still pinned by Cargo.lock. For co-developing the UI crate, point these
-# back at a sibling checkout: `path = "../gpui-component/crates/{ui,assets}"`.
-gpui-component = { git = "https://github.com/l0ng-ai/gpui-component", branch = "tty7", version = "0.5.2" }
+# `PopupMenu::with_size`, the 1px hairline menu separator, and the custom-button
+# label color the chrome tiles need. The exact commit is still pinned by
+# Cargo.lock. For co-developing the UI crate, point these back at a sibling
+# checkout: `path = "../gpui-component/crates/{ui,assets}"` — but never commit
+# that, since a path dependency can't resolve on CI.
+#
+# `tree-sitter-languages` compiles the grammars the code editor highlights with.
+gpui-component = { git = "https://github.com/l0ng-ai/gpui-component", branch = "tty7", version = "0.5.2", features = [
+ "tree-sitter-languages",
+] }
gpui-component-assets = { git = "https://github.com/l0ng-ai/gpui-component", branch = "tty7", version = "0.5.1" }
gpui = { git = "https://github.com/zed-industries/zed", rev = "1d217ee39d381ac101b7cf49d3d22451ac1093fe" }
diff --git a/assets/icons/info.svg b/assets/icons/info.svg
new file mode 100644
index 00000000..ec30e25e
--- /dev/null
+++ b/assets/icons/info.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/list.svg b/assets/icons/list.svg
new file mode 100644
index 00000000..981d0c33
--- /dev/null
+++ b/assets/icons/list.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/panel-left.svg b/assets/icons/panel-left.svg
new file mode 100644
index 00000000..2d61872f
--- /dev/null
+++ b/assets/icons/panel-left.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/panel-right.svg b/assets/icons/panel-right.svg
new file mode 100644
index 00000000..a975c9ec
--- /dev/null
+++ b/assets/icons/panel-right.svg
@@ -0,0 +1 @@
+
diff --git a/src/core/actions.rs b/src/core/actions.rs
index dd338417..724a42ab 100644
--- a/src/core/actions.rs
+++ b/src/core/actions.rs
@@ -55,10 +55,35 @@ actions!(
// Switch the tab bar between the horizontal title-bar strip and the
// vertical left-side sidebar (persists `tab_bar_position`).
ToggleTabSidebar,
+ // Collapse/expand the left tab sidebar in place (persists
+ // `sidebar_collapsed`). Unlike `ToggleTabSidebar` this does not switch
+ // the tab bar to the horizontal strip — the rail just goes away and
+ // comes back at the same width.
+ ToggleLeftPanel,
+ // Show/hide the right detail panel — session info, working-tree changes,
+ // and the file tree (persists `right_panel_visible`).
+ ToggleRightPanel,
+ // Jump straight to one of the right panel's tabs, opening the panel if
+ // it was closed. Unit actions rather than one parameterized action so
+ // config/Settings can bind them by name; unbound by default, since the
+ // panel's own tab row is the primary way in.
+ ShowRightPanelInfo,
+ ShowRightPanelOutline,
+ ShowRightPanelChanges,
+ ShowRightPanelFiles,
OpenSettings,
RestartDaemon,
// Toggle the SFTP file panel for the focused native-SSH pane (WS5).
ToggleSftp,
+ // Toggle the code panel: a full-body overlay of [file tree | editor]
+ // covering the terminal (settings-overlay style).
+ ToggleCodePanel,
+ // Save the editor panel's active file (⌘S).
+ EditorSave,
+ // LSP jump to the definition of the symbol at the editor cursor (F12).
+ EditorGotoDefinition,
+ // LSP list references to the symbol at the editor cursor (⇧F12).
+ EditorFindReferences,
// Open the SSH profile manager/editor full-window page (WS6, FR-P1).
OpenSshProfiles,
// Reconnect a dead native-SSH pane in place (WS6, FR-E4).
diff --git a/src/core/config.rs b/src/core/config.rs
index 1d63a1b8..897610fa 100644
--- a/src/core/config.rs
+++ b/src/core/config.rs
@@ -117,6 +117,25 @@ pub struct Config {
/// the live layout re-clamps it to `[180, window_width/2]`.
#[serde(default = "default_sidebar_width")]
pub sidebar_width: f32,
+ /// Whether the vertical tab sidebar is collapsed out of the layout (only
+ /// meaningful when `tab_bar_position` is `left`). Distinct from
+ /// `tab_bar_position`: collapsing hides the rail *without* falling back to
+ /// the horizontal title-bar strip, so the terminal gets the full width and
+ /// re-expanding restores the same rail. Toggled by `ToggleLeftPanel`.
+ #[serde(default)]
+ pub sidebar_collapsed: bool,
+ /// Whether the right detail panel (session info / changes / files) is
+ /// docked open. Toggled by `ToggleRightPanel`.
+ #[serde(default)]
+ pub right_panel_visible: bool,
+ /// Width (px) of the right detail panel. Re-clamped by the live layout the
+ /// same way `sidebar_width` is.
+ #[serde(default = "default_right_panel_width")]
+ pub right_panel_width: f32,
+ /// Which tab the right detail panel last had selected, so reopening it lands
+ /// where it was left.
+ #[serde(default, deserialize_with = "de_lenient")]
+ pub right_panel_tab: RightPanelTab,
/// How the vertical tab sidebar arranges its rows (only meaningful when
/// `tab_bar_position` is `left`): grouped under a header per git work tree
/// (`repo`, the default), or one flat list (`none`).
@@ -499,6 +518,10 @@ impl Default for Config {
// `left` opts into the vertical sidebar.
tab_bar_position: TabBarPosition::Top,
sidebar_width: default_sidebar_width(),
+ sidebar_collapsed: false,
+ right_panel_visible: false,
+ right_panel_width: default_right_panel_width(),
+ right_panel_tab: RightPanelTab::Info,
sidebar_grouping: SidebarGrouping::Repo,
notify_on_command_finish: NotifyMode::Unfocused,
// Opt-out, not opt-in: a stale terminal that never tells you it's
@@ -606,6 +629,10 @@ impl Config {
self.sidebar_width = default_sidebar_width();
}
self.sidebar_width = self.sidebar_width.clamp(100.0, 2000.0);
+ if !self.right_panel_width.is_finite() || self.right_panel_width <= 0.0 {
+ self.right_panel_width = default_right_panel_width();
+ }
+ self.right_panel_width = self.right_panel_width.clamp(100.0, 2000.0);
// An empty or whitespace-only file-open command means "no override"; the
// settings text field yields `""` when cleared, so fold it back to `None`
// rather than trying to run an empty command.
@@ -820,6 +847,27 @@ fn default_prefix() -> String {
"ctrl-b".to_string()
}
+/// Which tab the right detail panel shows.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RightPanelTab {
+ /// Session facts: cwd, shell, branch, agent.
+ #[default]
+ Info,
+ /// The pane's command history as a navigable outline (OSC 133 marks).
+ Outline,
+ /// The pane's working-tree diff.
+ Changes,
+ /// The file tree rooted at the pane's repository.
+ Files,
+}
+
+/// Serde default for [`Config::right_panel_width`]: wide enough for a file path
+/// plus its `+N −M` counts without the tree turning into an ellipsis parade.
+fn default_right_panel_width() -> f32 {
+ 260.
+}
+
/// Serde default for [`Config::sidebar_width`]: a comfortable rail width that
/// clears the tab labels without eating too much of the terminal.
fn default_sidebar_width() -> f32 {
diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs
index 01810c97..c83f7ac3 100644
--- a/src/daemon/mod.rs
+++ b/src/daemon/mod.rs
@@ -23,6 +23,7 @@
pub mod pane;
pub mod pidfile;
+pub mod procinfo;
pub mod protocol;
pub(crate) mod remote;
pub mod server;
diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs
index 23e50b15..161aee9f 100644
--- a/src/daemon/pane.rs
+++ b/src/daemon/pane.rs
@@ -1120,6 +1120,20 @@ impl DaemonPane {
}
}
+ /// The pane's process tree and listening ports, for the GUI's details panel
+ /// (`QueryProcs`). A native-SSH pane has no local process tree at all — its
+ /// commands run on the far side — so it answers empty rather than reporting
+ /// the daemon's own descendants.
+ pub fn procs(&self) -> crate::daemon::protocol::PaneProcs {
+ let Some(pty) = self.pty() else {
+ return Default::default();
+ };
+ let Some(shell_pid) = pty.shell_pid else {
+ return Default::default();
+ };
+ crate::daemon::procinfo::snapshot(shell_pid, pty_foreground_pgid(&pty.master))
+ }
+
/// The local-PTY backend, or `None` for a native-SSH pane. PTY-only
/// operations (resize via master, signal groups, foreground proc queries)
/// short-circuit when this is `None`.
diff --git a/src/daemon/procinfo.rs b/src/daemon/procinfo.rs
new file mode 100644
index 00000000..71635eff
--- /dev/null
+++ b/src/daemon/procinfo.rs
@@ -0,0 +1,413 @@
+//! What a pane is *running*: the process tree under its shell, and the TCP ports
+//! that tree is listening on. Feeds the GUI's details panel (`QueryProcs`).
+//!
+//! Everything here is best-effort and read-only. A pid can exit between the
+//! table walk and the name lookup, `lsof` may be missing, `/proc` may be
+//! unreadable — each of those degrades to a shorter list, never an error. The
+//! panel showing one fewer row is a non-event; a details query that can fail is
+//! a support burden.
+//!
+//! Called on demand from the details panel, not on a timer — see the doc on
+//! [`ClientMsg::QueryProcs`](crate::daemon::protocol::ClientMsg::QueryProcs) for
+//! why this is pull-based when `Cwd` and `Agent` are pushed.
+
+use std::collections::HashMap;
+
+use crate::daemon::protocol::{PaneProcs, PortEntry, ProcEntry};
+
+/// Depth cap on the process walk. Deep trees are real (a shell running `make`
+/// running a compiler driver running the compiler), but past a handful of hops
+/// the rows stop being information and start being noise in a 260px column.
+const MAX_DEPTH: u8 = 6;
+
+/// Hard cap on rows, so a pane that spawned a thousand workers can't turn a
+/// details query into a wire-format stress test.
+const MAX_PROCS: usize = 64;
+
+/// The process tree under `shell_pid` plus its listening ports. `fg_pgid` is the
+/// PTY's foreground process group, used to mark the row the user is looking at;
+/// pass `None` when it isn't known.
+pub fn snapshot(shell_pid: u32, fg_pgid: Option) -> PaneProcs {
+ let table = process_table();
+ let procs = walk(&table, shell_pid, fg_pgid);
+ let ports = listening_ports(&procs);
+ PaneProcs { procs, ports }
+}
+
+/// One row of the system process table, reduced to what the walk needs.
+struct Row {
+ ppid: u32,
+ pgid: u32,
+ name: String,
+}
+
+/// Depth-first from the shell, so the caller can render in order and indent by
+/// `depth` without rebuilding a hierarchy.
+fn walk(table: &HashMap, shell_pid: u32, fg_pgid: Option) -> Vec {
+ // Children by parent, so the descent is a lookup rather than a table scan
+ // per node. Sorted by pid: the process table's own order is unspecified, and
+ // a list that reshuffles between two refreshes reads as churn.
+ let mut children: HashMap> = HashMap::new();
+ for (pid, row) in table {
+ children.entry(row.ppid).or_default().push(*pid);
+ }
+ for kids in children.values_mut() {
+ kids.sort_unstable();
+ }
+
+ let mut out = Vec::new();
+ let mut stack = vec![(shell_pid, 0u8)];
+ while let Some((pid, depth)) = stack.pop() {
+ let Some(row) = table.get(&pid) else { continue };
+ if out.len() >= MAX_PROCS {
+ break;
+ }
+ out.push(ProcEntry {
+ pid,
+ name: row.name.clone(),
+ depth,
+ foreground: fg_pgid.is_some_and(|g| g as u32 == row.pgid),
+ });
+ if depth + 1 > MAX_DEPTH {
+ continue;
+ }
+ if let Some(kids) = children.get(&pid) {
+ // Pushed in reverse so the pop order stays ascending by pid.
+ for kid in kids.iter().rev() {
+ stack.push((*kid, depth + 1));
+ }
+ }
+ }
+ out
+}
+
+// ── Platform: the process table ─────────────────────────────────────────────
+
+/// macOS: one `proc_listallpids` sweep, then `PROC_PIDTBSDINFO` per pid for
+/// parent/group. Cheaper than shelling out to `ps`, and it can't be defeated by
+/// a user's `ps` alias or a locale-dependent column layout.
+#[cfg(target_os = "macos")]
+fn process_table() -> HashMap {
+ let mut table = HashMap::new();
+ // Ask for the count first, then read into a buffer sized from it (plus slack,
+ // since processes can appear between the two calls).
+ // SAFETY: the documented "how big a buffer do I need" form — null buffer,
+ // zero size — which only returns a byte count.
+ let bytes = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
+ if bytes <= 0 {
+ return table;
+ }
+ let cap = (bytes as usize / std::mem::size_of::()) + 64;
+ let mut pids = vec![0 as libc::c_int; cap];
+ // SAFETY: buffer and its true byte length; the call writes at most that many
+ // bytes and returns how many it wrote.
+ let written = unsafe {
+ libc::proc_listallpids(
+ pids.as_mut_ptr() as *mut libc::c_void,
+ (cap * std::mem::size_of::()) as libc::c_int,
+ )
+ };
+ if written <= 0 {
+ return table;
+ }
+ let n = written as usize / std::mem::size_of::();
+ for &pid in pids.iter().take(n.min(cap)) {
+ if pid <= 0 {
+ continue;
+ }
+ let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
+ let size = std::mem::size_of::() as libc::c_int;
+ // SAFETY: zeroed buffer of the expected type, real size passed; the
+ // result is read back only when the kernel filled exactly that many
+ // bytes (a short return means the pid died mid-walk).
+ let ret = unsafe {
+ libc::proc_pidinfo(
+ pid,
+ libc::PROC_PIDTBSDINFO,
+ 0,
+ &mut info as *mut _ as *mut libc::c_void,
+ size,
+ )
+ };
+ if ret != size {
+ continue;
+ }
+ // `pbi_comm` is the kernel's truncated name (16 bytes). Prefer the full
+ // executable basename, which is what the user typed.
+ let name = proc_name(pid).unwrap_or_else(|| cstr_field(&info.pbi_comm));
+ table.insert(
+ pid as u32,
+ Row {
+ ppid: info.pbi_ppid,
+ pgid: info.pbi_pgid,
+ name,
+ },
+ );
+ }
+ table
+}
+
+/// Read a fixed-size, NUL-padded C char array into a `String`.
+#[cfg(target_os = "macos")]
+fn cstr_field(buf: &[libc::c_char]) -> String {
+ let bytes: Vec = buf
+ .iter()
+ .take_while(|c| **c != 0)
+ .map(|c| *c as u8)
+ .collect();
+ String::from_utf8_lossy(&bytes).into_owned()
+}
+
+/// Linux: `/proc//stat` carries ppid and pgid in fixed positions. The
+/// comm field is parenthesized and may itself contain spaces and parens, so the
+/// fields after it are located from the *last* `)`, not by splitting the line.
+#[cfg(target_os = "linux")]
+fn process_table() -> HashMap {
+ let mut table = HashMap::new();
+ let Ok(dir) = std::fs::read_dir("/proc") else {
+ return table;
+ };
+ for entry in dir.flatten() {
+ let Some(pid) = entry
+ .file_name()
+ .to_str()
+ .and_then(|s| s.parse::().ok())
+ else {
+ continue;
+ };
+ let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
+ continue;
+ };
+ let Some(close) = stat.rfind(')') else {
+ continue;
+ };
+ let mut fields = stat[close + 1..].split_whitespace();
+ // After `)`: state, ppid, pgrp, …
+ let (Some(_state), Some(ppid), Some(pgid)) = (fields.next(), fields.next(), fields.next())
+ else {
+ continue;
+ };
+ let (Ok(ppid), Ok(pgid)) = (ppid.parse::(), pgid.parse::()) else {
+ continue;
+ };
+ let name = proc_name(pid as i32).unwrap_or_else(|| {
+ // Fall back to the parenthesized comm already in hand.
+ stat[..close]
+ .rfind('(')
+ .map_or_else(|| String::new(), |open| stat[open + 1..close].to_string())
+ });
+ table.insert(pid, Row { ppid, pgid, name });
+ }
+ table
+}
+
+/// Windows: reuse the existing toolhelp snapshot. It carries no process-group
+/// concept, so nothing is ever marked foreground — matching how `foreground_title`
+/// already treats the platform.
+#[cfg(windows)]
+fn process_table() -> HashMap {
+ crate::daemon::winproc::snapshot()
+ .into_iter()
+ .map(|p| {
+ (
+ p.pid,
+ Row {
+ // `winproc::Proc` names the parent link `parent`.
+ ppid: p.parent,
+ pgid: 0,
+ name: p.name,
+ },
+ )
+ })
+ .collect()
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
+fn process_table() -> HashMap {
+ HashMap::new()
+}
+
+/// Executable basename of `pid` (macOS).
+#[cfg(target_os = "macos")]
+fn proc_name(pid: i32) -> Option {
+ let mut buf = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
+ // SAFETY: valid, correctly-sized buffer; `proc_pidpath` writes at most
+ // `buf.len()` bytes and returns the count (<=0 on failure).
+ let ret =
+ unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32) };
+ if ret <= 0 {
+ return None;
+ }
+ let path = std::str::from_utf8(&buf[..ret as usize]).ok()?;
+ Some(path.rsplit('/').next().unwrap_or(path).to_string())
+}
+
+/// Executable basename of `pid` via `/proc//exe` (Linux). Unreadable for
+/// processes we don't own, hence the caller's `comm` fallback.
+#[cfg(target_os = "linux")]
+fn proc_name(pid: i32) -> Option {
+ let path = std::fs::read_link(format!("/proc/{pid}/exe")).ok()?;
+ let name = path.file_name()?.to_str()?;
+ let name = name.strip_suffix(" (deleted)").unwrap_or(name);
+ (!name.is_empty()).then(|| name.to_string())
+}
+
+// ── Platform: listening ports ───────────────────────────────────────────────
+
+/// TCP listeners owned by any pid in `procs`, via `lsof`.
+///
+/// Shelling out rather than reading the socket tables directly: on macOS the
+/// only supported route is a private `libproc` fd walk, and on Linux matching
+/// `/proc/net/tcp` inodes against every pid's fds costs more syscalls than the
+/// subprocess. `lsof` ships with macOS; where it's missing this returns empty,
+/// which just hides the row.
+#[cfg(unix)]
+fn listening_ports(procs: &[ProcEntry]) -> Vec {
+ use std::process::{Command, Stdio};
+
+ if procs.is_empty() {
+ return Vec::new();
+ }
+ let pid_list = procs
+ .iter()
+ .map(|p| p.pid.to_string())
+ .collect::>()
+ .join(",");
+ // `-Fpn`: machine-readable output, pid (`p…`) and name (`n…`) fields only,
+ // one per line. `-nP` skips DNS and /etc/services lookups — both can block.
+ let out = Command::new("lsof")
+ .args([
+ "-nP",
+ "-iTCP",
+ "-sTCP:LISTEN",
+ "-a",
+ "-p",
+ &pid_list,
+ "-Fpn",
+ ])
+ .stdin(Stdio::null())
+ .stderr(Stdio::null())
+ .output();
+ let Ok(out) = out else { return Vec::new() };
+ let text = String::from_utf8_lossy(&out.stdout);
+
+ let by_pid: HashMap = procs.iter().map(|p| (p.pid, p.name.as_str())).collect();
+ let mut ports: Vec = Vec::new();
+ let mut current = 0u32;
+ for line in text.lines() {
+ let Some((tag, rest)) = line.split_at_checked(1) else {
+ continue;
+ };
+ match tag {
+ "p" => current = rest.parse().unwrap_or(0),
+ "n" => {
+ let Some(port) = parse_listen_port(rest) else {
+ continue;
+ };
+ // One listener commonly binds both v4 and v6, or several
+ // addresses on the same port; the panel wants the port once.
+ if ports.iter().any(|e| e.port == port && e.pid == current) {
+ continue;
+ }
+ ports.push(PortEntry {
+ port,
+ pid: current,
+ name: by_pid
+ .get(¤t)
+ .copied()
+ .unwrap_or_default()
+ .to_string(),
+ });
+ }
+ _ => {}
+ }
+ }
+ ports.sort_by_key(|e| (e.port, e.pid));
+ ports
+}
+
+#[cfg(not(unix))]
+fn listening_ports(_procs: &[ProcEntry]) -> Vec {
+ Vec::new()
+}
+
+/// The port out of an `lsof -Fn` name field: `*:3000`, `127.0.0.1:8080`,
+/// `[::1]:5173`, sometimes with a trailing ` (LISTEN)` despite `-F`.
+fn parse_listen_port(name: &str) -> Option {
+ let name = name.split_whitespace().next()?;
+ // Split on the *last* colon: an IPv6 literal is full of them.
+ let (_, port) = name.rsplit_once(':')?;
+ port.parse().ok()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn row(ppid: u32, name: &str) -> Row {
+ Row {
+ ppid,
+ pgid: 0,
+ name: name.to_string(),
+ }
+ }
+
+ #[test]
+ fn walk_is_depth_first_from_the_shell() {
+ let table: HashMap = [
+ (100, row(1, "zsh")),
+ (200, row(100, "make")),
+ (300, row(200, "cc")),
+ (400, row(100, "vim")),
+ // A sibling process outside the shell's tree must not appear.
+ (500, row(1, "Finder")),
+ ]
+ .into_iter()
+ .collect();
+
+ let got = walk(&table, 100, None);
+ let names: Vec<_> = got.iter().map(|p| (p.name.as_str(), p.depth)).collect();
+ assert_eq!(
+ names,
+ vec![("zsh", 0), ("make", 1), ("cc", 2), ("vim", 1)],
+ "depth-first, ascending pid, shell's tree only"
+ );
+ }
+
+ #[test]
+ fn walk_marks_the_foreground_process_group() {
+ let mut table: HashMap = [(100, row(1, "zsh")), (200, row(100, "vim"))]
+ .into_iter()
+ .collect();
+ table.get_mut(&100).unwrap().pgid = 100;
+ table.get_mut(&200).unwrap().pgid = 200;
+
+ let got = walk(&table, 100, Some(200));
+ assert!(
+ !got[0].foreground,
+ "the shell is backgrounded while vim runs"
+ );
+ assert!(got[1].foreground, "vim's group owns the terminal");
+ }
+
+ #[test]
+ fn walk_survives_a_cycle_in_the_table() {
+ // Two processes claiming each other as parent — impossible on a live
+ // kernel, but the table is a non-atomic sweep of pids that can be reused
+ // mid-walk, so the descent must terminate regardless.
+ let table: HashMap = [(100, row(200, "a")), (200, row(100, "b"))]
+ .into_iter()
+ .collect();
+ let got = walk(&table, 100, None);
+ assert!(got.len() <= MAX_PROCS, "bounded, not infinite");
+ }
+
+ #[test]
+ fn parses_lsof_listen_addresses() {
+ assert_eq!(parse_listen_port("*:3000"), Some(3000));
+ assert_eq!(parse_listen_port("127.0.0.1:8080"), Some(8080));
+ assert_eq!(parse_listen_port("[::1]:5173"), Some(5173));
+ assert_eq!(parse_listen_port("*:5432 (LISTEN)"), Some(5432));
+ assert_eq!(parse_listen_port("/tmp/some.sock"), None);
+ }
+}
diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs
index aa533ee8..f6f3707f 100644
--- a/src/daemon/protocol.rs
+++ b/src/daemon/protocol.rs
@@ -473,6 +473,40 @@ pub struct ManagedForward {
pub status: ForwardStatus,
}
+/// One process running under a pane's shell, for the details panel's process
+/// list. `depth` is hops from the shell (the shell itself is 0), which is all the
+/// UI needs to indent the tree — sending the parent pid would make the client
+/// rebuild a hierarchy the daemon already walked.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ProcEntry {
+ pub pid: u32,
+ pub name: String,
+ pub depth: u8,
+ /// Whether this process (or its group) currently owns the terminal — the one
+ /// the user is actually looking at.
+ #[serde(default)]
+ pub foreground: bool,
+}
+
+/// A TCP port a pane's process tree is listening on. The pane that started a dev
+/// server is exactly the context in which "which port is this on?" gets asked, so
+/// the answer belongs next to the process list rather than in a global inspector.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PortEntry {
+ pub port: u16,
+ pub pid: u32,
+ pub name: String,
+}
+
+/// Reply to `QueryProcs`: what a pane is running, and what it's listening on.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneProcs {
+ /// Depth-first from the shell, so rendering in order gives a readable tree.
+ pub procs: Vec,
+ /// Ascending by port; deduped, since one listener can bind several addresses.
+ pub ports: Vec,
+}
+
fn default_term() -> String {
"xterm-256color".to_string()
}
@@ -815,6 +849,15 @@ pub enum ClientMsg {
/// Ask for the managed forwards attributed to `pane_id`. Control-connection
/// message; the daemon replies with a `ForwardList`.
ListForwards { pane_id: u64 },
+ /// One-shot query for a pane's process tree and listening ports, over a
+ /// short-lived control connection; the daemon replies with `PaneProcs`.
+ ///
+ /// Deliberately pull-based, unlike `Cwd`/`Agent` which the daemon pushes:
+ /// walking the process table and probing sockets costs far more than sniffing
+ /// an OSC sequence, and the answer is only ever looked at while the details
+ /// panel's Info tab is open. Pushing it on a timer would burn that cost for
+ /// every pane, forever, to feed a view that's usually closed.
+ QueryProcs { pane_id: u64 },
/// Ask which protocol version the daemon speaks (control connection); the
/// daemon replies `Version`. A daemon that predates versioning doesn't know
/// this kind and drops the connection instead of replying — the client
@@ -895,6 +938,8 @@ pub enum DaemonMsg {
/// Reply to `AddForward` / `RemoveForward` / `ListForwards`: the managed
/// forwards currently attributed to the requested pane (WS4).
ForwardList(Vec),
+ /// Reply to `QueryProcs`.
+ Procs(PaneProcs),
/// Reply to `Version`.
Version(DaemonVersion),
/// A request failed (e.g. `Attach` to an unknown/dead pane id).
@@ -950,6 +995,9 @@ mod kind {
/// `Version` — protocol-version handshake. 40 sits clear of every reserved
/// range above (WS3 16–19, WS4 20–24, SFTP 30–36).
pub const VERSION: u8 = 40;
+ /// `QueryProcs` — a pane's process tree + listening ports, for the details
+ /// panel. 50 sits clear of every range above and of `VERSION`.
+ pub const QUERY_PROCS: u8 = 50;
// Daemon -> client
pub const SPAWNED: u8 = 1;
@@ -985,6 +1033,8 @@ mod kind {
/// `Version` — reply to the client-space `VERSION` request (same value by
/// design; the spaces are independent).
pub const VERSION_REPLY: u8 = 40;
+ /// `Procs` — reply to the client-space `QUERY_PROCS` request.
+ pub const PROCS: u8 = 50;
}
/// Write one framed message: `[u32 LE len][u8 kind][payload]`.
@@ -1126,6 +1176,9 @@ impl ClientMsg {
pane_id,
forward_id,
} => write_frame(w, kind::REMOVE_FORWARD, &to_json(&(pane_id, forward_id))?),
+ ClientMsg::QueryProcs { pane_id } => {
+ write_frame(w, kind::QUERY_PROCS, &to_json(pane_id)?)
+ }
ClientMsg::ListForwards { pane_id } => {
write_frame(w, kind::LIST_FORWARDS, &to_json(pane_id)?)
}
@@ -1191,6 +1244,9 @@ impl ClientMsg {
kind::SFTP_TRANSFER_LIST => ClientMsg::SftpTransferList {
pane_id: from_json(&payload)?,
},
+ kind::QUERY_PROCS => ClientMsg::QueryProcs {
+ pane_id: from_json(&payload)?,
+ },
kind::ADD_FORWARD => {
let (pane_id, rule) = from_json(&payload)?;
ClientMsg::AddForward { pane_id, rule }
@@ -1269,6 +1325,7 @@ impl DaemonMsg {
write_frame(w, kind::SFTP_TRANSFER_PROGRESS, &to_json(jobs)?)
}
DaemonMsg::ForwardList(list) => write_frame(w, kind::FORWARD_LIST, &to_json(list)?),
+ DaemonMsg::Procs(procs) => write_frame(w, kind::PROCS, &to_json(procs)?),
DaemonMsg::Version(version) => write_frame(w, kind::VERSION_REPLY, &to_json(version)?),
DaemonMsg::Error(msg) => write_frame(w, kind::ERROR, &to_json(msg)?),
}
@@ -1316,6 +1373,7 @@ impl DaemonMsg {
},
kind::SFTP_TRANSFER_PROGRESS => DaemonMsg::SftpTransferProgress(from_json(&payload)?),
kind::FORWARD_LIST => DaemonMsg::ForwardList(from_json(&payload)?),
+ kind::PROCS => DaemonMsg::Procs(from_json(&payload)?),
kind::VERSION_REPLY => DaemonMsg::Version(from_json(&payload)?),
kind::ERROR => DaemonMsg::Error(from_json(&payload)?),
other => {
diff --git a/src/daemon/server.rs b/src/daemon/server.rs
index 96749f5b..d58d5795 100644
--- a/src/daemon/server.rs
+++ b/src/daemon/server.rs
@@ -509,6 +509,16 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> {
Ok(())
}
+ ClientMsg::QueryProcs { pane_id } => {
+ let mut w = write_stream;
+ // An unknown/dead pane answers empty rather than `Error`: the details
+ // panel polls while the user watches, and a pane closing mid-flight is
+ // ordinary, not a failure worth surfacing.
+ let procs = registry.get(pane_id).map(|p| p.procs()).unwrap_or_default();
+ DaemonMsg::Procs(procs).encode(&mut w)?;
+ Ok(())
+ }
+
ClientMsg::ListForwards { pane_id } => {
let mut w = write_stream;
let list = crate::daemon::ssh::SshManager::global().list_forwards(pane_id);
diff --git a/src/terminal/marks.rs b/src/terminal/marks.rs
new file mode 100644
index 00000000..9508a632
--- /dev/null
+++ b/src/terminal/marks.rs
@@ -0,0 +1,419 @@
+//! Command marks: where each shell prompt started in the scrollback, so the
+//! details panel's Outline can list a pane's commands and scroll back to one.
+//!
+//! Fed by the reader thread from OSC 133 (`A` prompt start, `C` command start,
+//! `D` command done — the same shell-integration marks the daemon sniffs for
+//! prompt state). The daemon reports only *whether* the shell is at its prompt;
+//! positions have to come from the client, because only the client holds the
+//! grid those positions are relative to.
+//!
+//! # Why a mark stores its text
+//!
+//! A grid row has no stable identity. Alacritty's `Line` is relative to the
+//! viewport, so anything recorded in those coordinates slides as output arrives.
+//! Converting to an absolute index from the top of history (`history_size -
+//! display_offset + line`) is stable — *until the scrollback fills*. After that
+//! alacritty discards the oldest row per new row, every surviving row's absolute
+//! index silently decreases, and the amount discarded is not observable from
+//! outside the emulator: `history_size` is pinned at the limit, and nothing else
+//! exposes the scroll count. (Counting it exactly would mean wrapping
+//! `vte::ansi::Handler` to intercept every line-producing sequence — 71 methods,
+//! all with no-op defaults, so a future `vte` upgrade that adds one would
+//! silently break rendering. Not worth it for this.)
+//!
+//! So the absolute index is treated as a *hint* and the row's text as the
+//! *truth*: each mark records what its row said when it was made, and a reader
+//! re-reads the row before trusting the position. A mark whose row no longer
+//! matches has drifted out from under us and is reported stale rather than
+//! silently scrolling somewhere wrong. Below the scrollback limit — which is
+//! where a pane spends most of its life — the hint is exact and the check always
+//! passes.
+
+use std::sync::{Arc, Mutex};
+
+/// Cap on retained marks. Deep scrollback holds far more prompts than a panel
+/// list is useful at, and the oldest are the likeliest to have drifted anyway.
+const MAX_MARKS: usize = 500;
+
+/// One shell prompt, and the command run from it.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct CommandMark {
+ /// Row index from the top of the scrollback at record time — the position
+ /// hint. See the module docs for when it stops being exact.
+ pub row: i64,
+ /// What the row said when the mark was made, used to detect drift. Empty
+ /// while the prompt has been printed but nothing has been typed yet.
+ pub text: String,
+ /// Exit code from `OSC 133;D`, once the command finishes.
+ pub exit: Option,
+ /// Whether the command has finished (a `D` mark arrived). Distinct from
+ /// `exit.is_some()`: a `D` without a code still means "done".
+ pub done: bool,
+}
+
+/// A pane's marks, shared between the reader thread (writer) and the UI (reader).
+#[derive(Clone, Default)]
+pub struct Marks(Arc>>);
+
+impl Marks {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Begin a mark at `row` (`OSC 133;A` — the shell is about to print a
+ /// prompt). `text` is the row's current content, which is normally empty at
+ /// this point and gets filled in by [`set_text`](Self::set_text) once the
+ /// command has been typed.
+ pub fn begin(&self, row: i64, text: String) {
+ let Ok(mut marks) = self.0.lock() else { return };
+ // A prompt redraw (a resize, a `clear`, zle repainting the line) re-emits
+ // `A` on the same row. Update in place rather than stacking duplicates.
+ if marks.last().is_some_and(|m| m.row == row && !m.done) {
+ if let Some(last) = marks.last_mut() {
+ last.text = text;
+ }
+ return;
+ }
+ marks.push(CommandMark {
+ row,
+ text,
+ exit: None,
+ done: false,
+ });
+ // Trim from the front: oldest marks age out of the scrollback first.
+ let overflow = marks.len().saturating_sub(MAX_MARKS);
+ if overflow > 0 {
+ marks.drain(..overflow);
+ }
+ }
+
+ /// Attach the command line to the open mark (`OSC 133;C` — the user hit
+ /// enter, so the prompt row now holds the command). Ignored when no mark is
+ /// open, which is what a `C` without a preceding `A` means.
+ pub fn set_text(&self, text: String) {
+ let Ok(mut marks) = self.0.lock() else { return };
+ if let Some(last) = marks.last_mut() {
+ if !last.done {
+ last.text = text;
+ }
+ }
+ }
+
+ /// Close the open mark (`OSC 133;D[;exit]`).
+ pub fn finish(&self, exit: Option) {
+ let Ok(mut marks) = self.0.lock() else { return };
+ if let Some(last) = marks.last_mut() {
+ last.done = true;
+ last.exit = exit;
+ }
+ }
+
+ /// Snapshot for rendering, newest last. Marks that never got a command are
+ /// dropped: a bare prompt the user typed nothing at is not an outline entry.
+ pub fn list(&self) -> Vec {
+ let Ok(marks) = self.0.lock() else {
+ return Vec::new();
+ };
+ marks
+ .iter()
+ .filter(|m| !m.text.trim().is_empty())
+ .cloned()
+ .collect()
+ }
+
+ /// Drop everything (the pane was cleared, so every position is meaningless).
+ pub fn clear(&self) {
+ if let Ok(mut marks) = self.0.lock() {
+ marks.clear();
+ }
+ }
+}
+
+/// Parse the exit code out of an `OSC 133;D` payload: `D`, `D;0`, `D;1`, and
+/// zsh's `D;aborted` all occur. Anything unparseable is "done, code unknown".
+pub fn parse_done_exit(payload: &[u8]) -> Option {
+ let rest = payload.strip_prefix(b"D")?;
+ let rest = rest.strip_prefix(b";")?;
+ std::str::from_utf8(rest).ok()?.trim().parse().ok()
+}
+
+/// What a recognized `OSC 133` mark means for the outline.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum MarkEvent {
+ /// `A` — the shell is about to print a prompt.
+ Prompt,
+ /// `C;` — the command was submitted and its output starts here. tty7's
+ /// own shell integration always includes the command line, so the outline
+ /// never has to guess it back out of the grid (where it would be tangled up
+ /// with the user's prompt string).
+ Command(String),
+ /// `D[;exit]` — the command finished.
+ Done(Option),
+}
+
+/// Finds `OSC 133` marks in the output stream and reports *where* each one lands
+/// — the byte offset just past the sequence — so the caller can advance the
+/// emulator up to exactly that point and read the grid position there.
+///
+/// Separate from [`OscTokenizer`](crate::core::osc::OscTokenizer), which reports
+/// payloads but not offsets. Carries its state across feeds, so a mark split over
+/// two socket reads is still recognized (and attributed to the batch its
+/// terminator lands in, which is the correct row either way).
+#[derive(Default)]
+pub struct MarkScanner {
+ state: ScanState,
+ /// Payload bytes collected so far, possibly spanning feeds. Bounded: a
+ /// "payload" that runs past any plausible command line is a desync, not a
+ /// mark, so it's abandoned rather than grown without limit.
+ payload: Vec,
+}
+
+#[derive(Default, PartialEq, Eq)]
+enum ScanState {
+ /// Ordinary output.
+ #[default]
+ Text,
+ /// Saw `ESC`, waiting to see whether `]` follows.
+ Esc,
+ /// Inside an OSC payload, collecting until BEL or ST.
+ Osc,
+ /// Saw `ESC` inside an OSC payload — an ST (`ESC \`) if `\` follows.
+ OscEsc,
+}
+
+/// Ceiling on a collected OSC payload. Long enough for any real command line,
+/// short enough that a stream that never terminates its OSC can't grow a buffer
+/// unboundedly.
+const MAX_PAYLOAD: usize = 64 * 1024;
+
+impl MarkScanner {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Feed one batch. `on_mark(offset, event)` fires for each recognized mark,
+ /// where `offset` is an index into `bytes` just past the mark's terminator.
+ pub fn feed(&mut self, bytes: &[u8], mut on_mark: impl FnMut(usize, MarkEvent)) {
+ for (i, &b) in bytes.iter().enumerate() {
+ match self.state {
+ ScanState::Text => {
+ if b == 0x1b {
+ self.state = ScanState::Esc;
+ }
+ }
+ ScanState::Esc => {
+ if b == b']' {
+ self.state = ScanState::Osc;
+ self.payload.clear();
+ } else {
+ // Some other escape sequence; `ESC ESC` restarts.
+ self.state = if b == 0x1b {
+ ScanState::Esc
+ } else {
+ ScanState::Text
+ };
+ }
+ }
+ ScanState::Osc => match b {
+ 0x07 => {
+ if let Some(ev) = self.take() {
+ on_mark(i + 1, ev);
+ }
+ self.state = ScanState::Text;
+ }
+ 0x1b => self.state = ScanState::OscEsc,
+ _ => {
+ if self.payload.len() < MAX_PAYLOAD {
+ self.payload.push(b);
+ } else {
+ // Runaway payload: give up on this sequence rather
+ // than buffer the rest of the stream into it.
+ self.state = ScanState::Text;
+ self.payload.clear();
+ }
+ }
+ },
+ ScanState::OscEsc => {
+ if b == b'\\' {
+ if let Some(ev) = self.take() {
+ on_mark(i + 1, ev);
+ }
+ self.state = ScanState::Text;
+ } else {
+ // Not an ST after all — the ESC was payload. Bounded like
+ // the ordinary payload byte below it: a stream of bare
+ // ESCs inside an unterminated OSC would otherwise grow the
+ // buffer a byte at a time, never reaching the check there.
+ if self.payload.len() < MAX_PAYLOAD {
+ self.payload.push(0x1b);
+ self.state = ScanState::Osc;
+ } else {
+ self.state = ScanState::Text;
+ self.payload.clear();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /// Interpret the collected payload, clearing it either way.
+ fn take(&mut self) -> Option {
+ let payload = std::mem::take(&mut self.payload);
+ let body = payload.strip_prefix(b"133;")?;
+ match body.first()? {
+ b'A' => Some(MarkEvent::Prompt),
+ b'C' => {
+ // `C` alone (no command) still marks output start; the shells
+ // that can't report the line send it bare.
+ let cmd = body
+ .strip_prefix(b"C;")
+ .map(|c| String::from_utf8_lossy(c).into_owned())
+ .unwrap_or_default();
+ Some(MarkEvent::Command(cmd))
+ }
+ b'D' => Some(MarkEvent::Done(parse_done_exit(body))),
+ // `B` (prompt end) and `V` (tty7's edit-mode extension) carry no
+ // position the outline cares about.
+ _ => None,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_prompt_with_no_command_is_not_an_entry() {
+ let marks = Marks::new();
+ marks.begin(10, String::new());
+ assert!(
+ marks.list().is_empty(),
+ "an empty prompt the user walked away from isn't a command"
+ );
+ marks.set_text("cargo build".into());
+ assert_eq!(marks.list().len(), 1);
+ }
+
+ #[test]
+ fn a_prompt_redraw_updates_in_place() {
+ let marks = Marks::new();
+ marks.begin(10, String::new());
+ marks.set_text("cargo t".into());
+ // zle repaints the prompt on the same row (a resize, a completion menu
+ // closing) and the shell re-emits `A`.
+ marks.begin(10, "cargo test".into());
+ let got = marks.list();
+ assert_eq!(got.len(), 1, "a redraw is the same prompt, not a new one");
+ assert_eq!(got[0].text, "cargo test");
+ }
+
+ #[test]
+ fn a_new_prompt_after_a_finished_command_is_a_new_entry() {
+ let marks = Marks::new();
+ marks.begin(10, String::new());
+ marks.set_text("ls".into());
+ marks.finish(Some(0));
+ // Same row is possible after a `clear`.
+ marks.begin(10, String::new());
+ marks.set_text("pwd".into());
+ let got = marks.list();
+ assert_eq!(got.len(), 2);
+ assert_eq!(got[0].exit, Some(0));
+ assert!(!got[1].done);
+ }
+
+ #[test]
+ fn marks_are_capped_from_the_front() {
+ let marks = Marks::new();
+ for i in 0..(MAX_MARKS + 10) {
+ marks.begin(i as i64, format!("cmd{i}"));
+ marks.finish(Some(0));
+ }
+ let got = marks.list();
+ assert_eq!(got.len(), MAX_MARKS);
+ assert_eq!(got[0].text, "cmd10", "the oldest aged out, not the newest");
+ }
+
+ /// Collect `(offset, event)` pairs from feeding `chunks` in order, so a test
+ /// can assert on a stream split at arbitrary boundaries.
+ fn scan(chunks: &[&[u8]]) -> Vec<(usize, MarkEvent)> {
+ let mut scanner = MarkScanner::new();
+ let mut out = Vec::new();
+ for chunk in chunks {
+ scanner.feed(chunk, |off, ev| out.push((off, ev)));
+ }
+ out
+ }
+
+ #[test]
+ fn reports_marks_just_past_their_terminator() {
+ let got = scan(&[b"ab\x1b]133;A\x07cd"]);
+ assert_eq!(got, vec![(10, MarkEvent::Prompt)]);
+ // The offset must point past the BEL, so advancing `bytes[..offset]`
+ // consumes the whole sequence and nothing of what follows.
+ assert_eq!(&b"ab\x1b]133;A\x07cd"[10..], b"cd");
+ }
+
+ #[test]
+ fn carries_a_mark_split_across_two_feeds() {
+ let got = scan(&[b"out\x1b]13", b"3;C;cargo build\x07more"]);
+ assert_eq!(
+ got,
+ vec![(16, MarkEvent::Command("cargo build".into()))],
+ "the mark is attributed to the batch its terminator lands in"
+ );
+ }
+
+ #[test]
+ fn accepts_st_terminated_marks() {
+ // `ESC \` instead of BEL — both are legal OSC terminators and the
+ // integrations use ST on some shells.
+ let got = scan(&[b"\x1b]133;D;130\x1b\\"]);
+ assert_eq!(got, vec![(13, MarkEvent::Done(Some(130)))]);
+ }
+
+ #[test]
+ fn ignores_other_osc_sequences() {
+ let got = scan(&[b"\x1b]0;a title\x07\x1b]7;file://h/x\x07\x1b]133;B\x07"]);
+ assert!(
+ got.is_empty(),
+ "titles, cwd reports and prompt-end carry nothing the outline wants"
+ );
+ }
+
+ #[test]
+ fn a_command_containing_semicolons_survives_intact() {
+ let got = scan(&[b"\x1b]133;C;for i in a b; do echo $i; done\x07"]);
+ assert_eq!(
+ got,
+ vec![(
+ 39,
+ MarkEvent::Command("for i in a b; do echo $i; done".into())
+ )],
+ "only the first two fields are structure; the rest is the command"
+ );
+ }
+
+ #[test]
+ fn an_unterminated_payload_cannot_grow_without_bound() {
+ let mut scanner = MarkScanner::new();
+ let mut fired = 0;
+ scanner.feed(b"\x1b]133;C;", |_, _| fired += 1);
+ for _ in 0..40 {
+ scanner.feed(&vec![b'x'; 4096], |_, _| fired += 1);
+ }
+ assert_eq!(fired, 0, "never terminated, so never reported");
+ assert!(scanner.payload.len() <= MAX_PAYLOAD);
+ }
+
+ #[test]
+ fn parses_done_payloads() {
+ assert_eq!(parse_done_exit(b"D;0"), Some(0));
+ assert_eq!(parse_done_exit(b"D;130"), Some(130));
+ assert_eq!(parse_done_exit(b"D"), None, "done, code unknown");
+ assert_eq!(parse_done_exit(b"D;aborted"), None);
+ assert_eq!(parse_done_exit(b"C"), None, "not a done mark at all");
+ }
+}
diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs
index f2294ce3..94bda0fe 100644
--- a/src/terminal/mod.rs
+++ b/src/terminal/mod.rs
@@ -28,6 +28,7 @@ mod history;
mod hold;
pub mod input;
mod loopback;
+pub(crate) mod marks;
pub mod palette;
mod remote;
mod reverse_search;
diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs
index d2a8627f..4c83f628 100644
--- a/src/terminal/remote.rs
+++ b/src/terminal/remote.rs
@@ -33,6 +33,8 @@ use alacritty_terminal::sync::FairMutex;
use alacritty_terminal::term::{Config, Term, TermMode};
use alacritty_terminal::vte::ansi::{self, CursorShape, CursorStyle};
+use crate::terminal::marks::{MarkEvent, MarkScanner};
+
use std::collections::VecDeque;
use crate::core::cli_agent::{AgentSessionState, CLIAgent};
@@ -41,8 +43,8 @@ use crate::core::osc::OscTokenizer;
use crate::daemon::protocol::{
AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId,
LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, LoopbackForwardRequest,
- ManagedForward, NativeSshSpec, RemoteContext, SftpEntry, SftpJobProgress, SftpOp, SftpOpResult,
- SftpTransferSpec, ShellSpec, SshForwardRule, SshPhase, WinSize,
+ ManagedForward, NativeSshSpec, PaneProcs, RemoteContext, SftpEntry, SftpJobProgress, SftpOp,
+ SftpOpResult, SftpTransferSpec, ShellSpec, SshForwardRule, SshPhase, WinSize,
};
use crate::daemon::transport::{self, Stream};
@@ -130,6 +132,8 @@ struct ReaderSignals {
/// Latest native-SSH spawn phase from `DaemonMsg::SshStatus`, for the status
/// line. `None` until the first status frame (a plain shell pane never sets it).
phase: Arc>>,
+ /// Command marks (OSC 133 prompt positions) for the details panel's Outline.
+ marks: crate::terminal::marks::Marks,
}
/// A terminal whose PTY lives in the daemon. Mirrors `backend::Terminal`'s public
@@ -208,6 +212,10 @@ pub struct RemoteTerminal {
/// session id), last reported by the daemon via `AgentStatus`. Drives the
/// status dot, "needs your input" notifications, and session resume.
agent_session: Arc>>,
+ /// Command marks recorded by the reader thread from OSC 133, for the details
+ /// panel's Outline. Positions are grid rows, so they can only be taken here
+ /// on the client — the daemon has no grid.
+ marks: crate::terminal::marks::Marks,
reader_thread: Option>,
}
@@ -333,6 +341,7 @@ impl RemoteTerminal {
let auth_prompts: Arc>> =
Arc::new(Mutex::new(VecDeque::new()));
let ssh_phase: Arc>> = Arc::new(Mutex::new(None));
+ let marks = crate::terminal::marks::Marks::new();
let reader_thread = Self::spawn_reader(
term.clone(),
@@ -350,6 +359,7 @@ impl RemoteTerminal {
shell_vi_mode: shell_vi_mode.clone(),
auth: auth_prompts.clone(),
phase: ssh_phase.clone(),
+ marks: marks.clone(),
},
);
@@ -374,6 +384,7 @@ impl RemoteTerminal {
auto_supplied_password: false,
agent,
agent_session,
+ marks,
reader_thread: Some(reader_thread),
})
}
@@ -416,6 +427,7 @@ impl RemoteTerminal {
shell_vi_mode,
auth,
phase,
+ marks,
} = signals;
// The client end of the visible-output path: keep it off the
// efficiency cores (see `core::threads`).
@@ -441,6 +453,10 @@ impl RemoteTerminal {
// zle is reading (see the `zle_reading` field docs). Historical
// Snapshot replays deliberately do not feed this tokenizer.
let mut zle_tok = OscTokenizer::new(&[b"133"]);
+ // Positional OSC 133 marks for the details panel's Outline. Unlike
+ // the tokenizers above this one reports byte *offsets*, because a
+ // mark's value is the grid row it lands on — see `terminal::marks`.
+ let mut mark_scan = MarkScanner::new();
// Bytes read but not yet framed, plus the recorded geometry
// waiting for its paired Snapshot: the attach replay is a
// `Size` → `Snapshot` pair per ring segment, and each pair
@@ -498,11 +514,29 @@ impl RemoteTerminal {
macro_rules! flush_batch {
() => {
if !out_batch.is_empty() {
+ // Where the batch's OSC 133 marks land, so the
+ // advance can stop at each one and read the grid
+ // row it fell on. Scanned before the lock (it's a
+ // pure byte pass) and normally empty — a batch
+ // with no marks takes the single-advance path
+ // below, exactly as before.
+ let mut cuts: Vec<(usize, MarkEvent)> = Vec::new();
+ mark_scan.feed(&out_batch, |off, ev| cuts.push((off, ev)));
{
let t0 = trace.then(std::time::Instant::now);
let mut term = term.lock();
let t1 = trace.then(std::time::Instant::now);
- processor.advance(&mut *term, &out_batch);
+ if cuts.is_empty() {
+ processor.advance(&mut *term, &out_batch);
+ } else {
+ let mut at = 0usize;
+ for (off, ev) in cuts {
+ processor.advance(&mut *term, &out_batch[at..off]);
+ at = off;
+ record_mark(&term, &marks, ev);
+ }
+ processor.advance(&mut *term, &out_batch[at..]);
+ }
if let (Some(t0), Some(t1)) = (t0, t1) {
tr_lock_t += t1 - t0;
tr_adv_t += t1.elapsed();
@@ -952,6 +986,12 @@ impl RemoteTerminal {
self.agent.lock().ok().and_then(|g| *g)
}
+ /// Command marks recorded from OSC 133, oldest first — the Outline's source.
+ /// Cheap clone of a shared handle; the caller snapshots via `Marks::list`.
+ pub fn marks(&self) -> crate::terminal::marks::Marks {
+ self.marks.clone()
+ }
+
/// The rich agent-session status (idle/working/waiting/done + native
/// session id), as last reported by the daemon, or `None` when no agent
/// session is live. Cheap cache read — sniffing runs daemon-side.
@@ -1352,6 +1392,42 @@ impl RemoteTerminal {
}
query(pane_id).unwrap_or_default()
}
+
+ /// A pane's process tree and listening ports, for the details panel. One-shot
+ /// over a short-lived control connection, like the forward queries — this is
+ /// polled only while the panel is open, so it never rides the pane's hot
+ /// output connection.
+ pub fn query_procs(pane_id: u64) -> PaneProcs {
+ fn query(pane_id: u64) -> anyhow::Result {
+ let mut stream = connect()?;
+ ClientMsg::QueryProcs { pane_id }.encode(&mut stream)?;
+ match DaemonMsg::read(&mut stream)? {
+ DaemonMsg::Procs(procs) => Ok(procs),
+ other => Err(anyhow::anyhow!("unexpected reply to QueryProcs: {other:?}")),
+ }
+ }
+ query(pane_id).unwrap_or_default()
+ }
+}
+
+/// Apply one OSC 133 mark at the emulator's current position.
+///
+/// Called with the terminal lock held and the parser advanced to exactly the
+/// mark's byte, so `cursor.point.line` is the row the mark fell on. That row is
+/// converted to an index from the top of the scrollback, which is stable as long
+/// as history hasn't saturated — see the `terminal::marks` module docs.
+fn record_mark(term: &Term, marks: &crate::terminal::marks::Marks, event: MarkEvent) {
+ use alacritty_terminal::grid::Dimensions as _;
+ match event {
+ MarkEvent::Prompt => {
+ let grid = term.grid();
+ let row = grid.history_size() as i64 - grid.display_offset() as i64
+ + i64::from(grid.cursor.point.line.0);
+ marks.begin(row, String::new());
+ }
+ MarkEvent::Command(cmd) => marks.set_text(cmd),
+ MarkEvent::Done(exit) => marks.finish(exit),
+ }
}
fn daemon_disconnected_before_spawn_reply(err: &anyhow::Error) -> bool {
@@ -2379,6 +2455,55 @@ mod tests {
assert!(poll(&|s| s.is_none()), "a None report clears the session");
}
+ /// End-to-end check of the Outline's data path: OSC 133 marks arriving in the
+ /// output stream must land in `Marks` with the *grid row they fell on*, not
+ /// the row at the end of the batch. This is the whole reason the reader
+ /// splits its advance at mark offsets, so it's worth an integration test —
+ /// a regression here looks fine (marks appear) but scrolls to the wrong place.
+ #[test]
+ fn marks_record_the_row_each_one_landed_on() {
+ let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
+ let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
+ let poll = |want: usize| {
+ for _ in 0..200 {
+ if term.marks().list().len() == want {
+ return true;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(5));
+ }
+ false
+ };
+
+ // Two full prompt cycles in ONE batch, separated by output lines. If the
+ // reader advanced the batch in a single pass and read the cursor after,
+ // both marks would report the same (final) row.
+ let mut stream = Vec::new();
+ stream.extend_from_slice(b"\x1b]133;A\x07"); // prompt 1 at row 0
+ stream.extend_from_slice(b"\x1b]133;C;echo one\x07");
+ stream.extend_from_slice(b"one\r\n");
+ stream.extend_from_slice(b"\x1b]133;D;0\x07");
+ stream.extend_from_slice(b"\x1b]133;A\x07"); // prompt 2, two rows down
+ stream.extend_from_slice(b"\x1b]133;C;false\x07");
+ stream.extend_from_slice(b"\r\n");
+ stream.extend_from_slice(b"\x1b]133;D;1\x07");
+ DaemonMsg::Output(stream).encode(&mut daemon_side).unwrap();
+ daemon_side.flush().unwrap();
+
+ assert!(poll(2), "both commands recorded");
+ let marks = term.marks().list();
+ assert_eq!(marks[0].text, "echo one");
+ assert_eq!(marks[0].exit, Some(0));
+ assert_eq!(marks[1].text, "false");
+ assert_eq!(marks[1].exit, Some(1), "a failure keeps its exit code");
+ assert!(
+ marks[1].row > marks[0].row,
+ "the second prompt is further down the scrollback ({} vs {}) — equal rows \
+ would mean the advance wasn't split at the marks",
+ marks[0].row,
+ marks[1].row
+ );
+ }
+
/// The typeahead wipe (^U) may only be written once zle actually reads the
/// keyboard; the client learns that from a *live* `133;B` (prompt end) in
/// the output stream. `133;D` (command done, but precmd hooks still running
diff --git a/src/terminal/view.rs b/src/terminal/view.rs
index c9e953c0..519e5f3d 100644
--- a/src/terminal/view.rs
+++ b/src/terminal/view.rs
@@ -2490,6 +2490,10 @@ impl TerminalView {
pub fn clear_scrollback(&mut self, cx: &mut Context) {
self.terminal.term.lock().grid_mut().clear_history();
self.scroll_frac = 0.;
+ // Every mark's row indexed into the history that just went away, so the
+ // Outline's positions are now meaningless. Drop them rather than leave
+ // rows that scroll somewhere arbitrary.
+ self.terminal.marks().clear();
self.terminal.write(vec![0x0c_u8]); // Ctrl+L
cx.notify();
}
@@ -4094,6 +4098,40 @@ impl TerminalView {
self.smooth_scroll(delta, cx);
}
+ /// The pane's OSC 133 command marks, newest last — the Outline's rows.
+ pub fn command_marks(&self) -> Vec {
+ self.terminal.marks().list()
+ }
+
+ /// Scroll so the command recorded at `row` sits near the top of the viewport.
+ /// Returns `false` when the mark has aged out of the scrollback, so the
+ /// caller can say so rather than leaving the user staring at an unchanged
+ /// screen wondering whether the click registered.
+ ///
+ /// `row` is an index from the top of history, which drifts once the
+ /// scrollback saturates (see the `terminal::marks` docs). A drifted mark
+ /// still scrolls *somewhere* — it just may not be the exact prompt — so the
+ /// only failure reported here is a row that has fallen off entirely.
+ pub fn scroll_to_mark(&mut self, row: i64, cx: &mut Context) -> bool {
+ use alacritty_terminal::grid::Dimensions as _;
+ let mut term = self.terminal.term.lock();
+ let history = term.grid().history_size() as i64;
+ if row < 0 || row > history + term.grid().screen_lines() as i64 {
+ return false;
+ }
+ // `display_offset` counts *up* from the bottom of history, so the offset
+ // that puts `row` at the viewport's top line is its distance from there.
+ let target = (history - row).max(0);
+ let current = term.grid().display_offset() as i64;
+ term.scroll_display(Scroll::Delta((target - current) as i32));
+ drop(term);
+ // A jump lands wherever it lands; the fractional offset is a smooth-scroll
+ // artifact and would otherwise shift the paint off the line boundary.
+ self.scroll_frac = 0.;
+ cx.notify();
+ true
+ }
+
/// Scroll the local scrollback by a possibly-fractional number of lines,
/// pixel-smooth: whole lines go to the emulator's `display_offset`, the
/// remainder stays in `scroll_frac` and shifts the paint. The position may
diff --git a/src/ui/app.rs b/src/ui/app.rs
index 66ec2dd4..6e7212c6 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -81,6 +81,45 @@ const RECORD_COMMIT_DELAY_MS: u64 = 650;
/// they all line up (and reach the very top of the window).
pub(crate) const TITLE_BAR_HEIGHT: f32 = 40.;
+/// The chrome tile rhythm: a 30px hit box around a 15px glyph, so the glyph sits
+/// [`TILE_PAD`] inside the box on every edge. Alignment is a property of what you
+/// can *see*, so anything lining a tile up with text or with the window edge
+/// subtracts `TILE_PAD` from the inset it wants — otherwise the invisible hit box
+/// lands on the line and the glyph reads 7.5px short of it.
+pub(crate) const TILE_SIZE: f32 = 30.;
+pub(crate) const TILE_GLYPH: f32 = 15.;
+pub(crate) const TILE_PAD: f32 = (TILE_SIZE - TILE_GLYPH) / 2.;
+
+/// The one content inset the whole window aligns to: the rail's text and icons,
+/// the title bar's chrome glyphs, and the side panels all start (or end) here, so
+/// every vertical edge in the chrome falls on one of two lines rather than the
+/// five slightly different ones each surface used to pick for itself.
+pub(crate) const CONTENT_INSET: f32 = 12.;
+
+/// What gpui-component's `TitleBar` already insets its content by, to clear the
+/// window controls: 80px on macOS (traffic lights on the left), 12px elsewhere
+/// (controls on the right). Anything laid out *inside* the bar therefore starts
+/// here, not at the window edge.
+pub(crate) const TITLE_BAR_LEAD: f32 = if cfg!(target_os = "macos") { 80. } else { 12. };
+
+/// Left offset for the tile group that sits beside the window controls.
+///
+/// On macOS the thing that can collide with the traffic lights is the tile's
+/// *hit box* — it paints a background on hover and when selected, 7.5px wider
+/// than the glyph on each side — so this aligns the box, not the glyph, and the
+/// bar's own 80px lead is already exactly the clearance macOS defines for that.
+/// Hence zero: pulling back into the reserve to "hug" the lights only made the
+/// hover capsule touch them. Off macOS the controls are on the right, nothing is
+/// there to clear, and the group aligns its glyph to the content inset like the
+/// rest of the chrome.
+pub(crate) fn title_bar_hug_offset() -> f32 {
+ if cfg!(target_os = "macos") {
+ 0.
+ } else {
+ CONTENT_INSET - TILE_PAD - TITLE_BAR_LEAD
+ }
+}
+
/// One tab: a split-pane tree plus an optional user-assigned name. Settings is
/// no longer a tab — it's a full-window overlay (`Tty7App::settings`), so every
/// tab is a real terminal tab.
@@ -101,6 +140,13 @@ pub struct Tab {
/// switching back restores it; closing the tab drops it. Only the active
/// tab's overlay is rendered. See [`crate::ui::diff_overlay`].
pub(crate) diff_overlay: Option,
+ /// This tab's code panel (file tree + editor overlay): open files, tree
+ /// roots/expansion, and visibility. Same per-tab contract as
+ /// `diff_overlay` — switching away hides it, switching back restores it,
+ /// closing the tab drops it. Shared caches (directory listings, gitignore
+ /// matchers, language servers, watchers) live on [`Tty7App`]. `None` until
+ /// the panel is first opened in this tab.
+ pub(crate) code: Option>,
/// The sidebar group this tab last *definitively* belonged to: the
/// repository home of its first pane's cwd — the main checkout's root, so
/// linked worktrees of one repo share a group (deliberately not the
@@ -114,6 +160,21 @@ pub struct Tab {
/// flickering through the Scratch group and back. A `RefCell` because the
/// sidebar refreshes it during render, which only has `&Tab`.
pub(crate) sidebar_group: std::cell::RefCell