aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx2
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx15
-rw-r--r--docs/next/website/src/content/docs/ja/cli-reference.mdx2
-rw-r--r--docs/next/website/src/content/docs/ja/configuration.mdx15
-rw-r--r--docs/next/website/src/content/docs/ja/socket-api.mdx2
-rw-r--r--docs/next/website/src/content/docs/socket-api.mdx2
-rw-r--r--docs/next/website/src/content/docs/zh-cn/cli-reference.mdx2
-rw-r--r--docs/next/website/src/content/docs/zh-cn/configuration.mdx15
-rw-r--r--docs/next/website/src/content/docs/zh-cn/socket-api.mdx2
-rw-r--r--docs/next/website/src/data/config-reference.json6
-rw-r--r--src/app/api.rs2
-rw-r--r--src/app/mod.rs30
-rw-r--r--src/app/terminal_titles.rs143
-rw-r--r--src/app/window_title.rs171
-rw-r--r--src/client/mod.rs28
-rw-r--r--src/config.rs4
-rw-r--r--src/config/model.rs4
-rw-r--r--src/config/window_title.rs205
-rw-r--r--src/main.rs8
-rw-r--r--src/pane.rs10
-rw-r--r--src/pane/osc.rs10
-rw-r--r--src/pane/terminal.rs32
-rw-r--r--src/render_signal.rs38
-rw-r--r--src/server/handoff.rs58
-rw-r--r--src/server/headless.rs447
-rw-r--r--src/terminal_effects.rs21
-rw-r--r--tests/client_mode.rs283
28 files changed, 1455 insertions, 103 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index a912c438..52dd9f0a 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -3,6 +3,7 @@
## Unreleased
### Added
+- Herdr now keeps the outer terminal window title in sync with the session through `ui.window_title`, so window managers and terminal tab bars show the active workspace and the host the panes actually run on.
- The desktop tab bar now has configurable right-aligned status entries for zoom state, hostname, date/time, literal text, and asynchronously refreshed command output.
- Optional `keys.move_tab_previous` and `keys.move_tab_next` bindings now reorder the active tab in place, wrapping at either end.
- Optional `keys.resize_pane_left`, `keys.resize_pane_down`, `keys.resize_pane_up`, and `keys.resize_pane_right` bindings now resize the focused pane in one keystroke without entering resize mode.
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index 7ce6c314..a8d859b4 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -341,7 +341,7 @@ terminal, or agent target. It prints newline-delimited JSON `terminal.frame`
records with base64-encoded ANSI bytes, then a `terminal.closed` record when
the server closes the stream. Multiple observers can watch the same terminal
without taking input, resize, scroll, or takeover authority.
-`terminal title clear` restores Herdr's default outer terminal window title.
+`terminal title clear` hands the outer terminal window title back to `ui.window_title`.
## Output waits
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index 59ce7b2e..90614c53 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -289,6 +289,21 @@ Command entries run immediately and then at `interval_seconds` without blocking
Separators appear only between visible entries. Set `tab_bar_right_separator = ""` for direct concatenation. On a narrow tab row, the complete status area yields to the tabs and their controls.
+### Outer terminal window title
+
+Herdr emulates the terminals in its panes, so an `OSC 0`/`OSC 2` title written inside a pane stops at Herdr. Herdr writes its own title to the terminal it runs in, which is what window managers and terminal tab bars read:
+
+```toml
+[ui]
+window_title = "{hostname}: {workspace}"
+```
+
+Tokens are `{hostname}`, `{workspace}`, `{tab}`, `{pane}` (the focused pane's manual name), and `{terminal_title}` (the focused pane's own terminal title with spinner frames stripped). Write `{{` and `}}` for literal braces. A token with no value renders empty.
+
+The title renders on the Herdr server, so `{hostname}` names the machine the panes run on, including when you attach with `herdr --remote` or run `herdr` over SSH. Set `window_title = ""` to leave the outer terminal title alone.
+
+`client.window_title.set` overrides the configured title until `client.window_title.clear` hands it back.
+
Agent status uses compact colored dots by default. To distinguish blocked, working, done, idle, and unknown states by shape as well as color, choose **distinct symbols** in Settings or configure:
```toml
diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx
index 3cc1e13b..1521d593 100644
--- a/docs/next/website/src/content/docs/ja/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx
@@ -308,7 +308,7 @@ herdr terminal title clear
```
ダイレクトアタッチからは `ctrl+b q` でデタッチします。リテラルの `ctrl+b` は `ctrl+b ctrl+b` で送ります。
-`terminal title clear` は Herdr のデフォルトの外側ターミナルウィンドウタイトルを復元します。
+`terminal title clear` は外側ターミナルウィンドウタイトルを `ui.window_title` に戻します。
## 出力待機
diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx
index 9f7c0e5e..a8f3570f 100644
--- a/docs/next/website/src/content/docs/ja/configuration.mdx
+++ b/docs/next/website/src/content/docs/ja/configuration.mdx
@@ -255,6 +255,21 @@ yellow = "#f9e2af"
サイドバーは Herdr のメインダッシュボードです。サイズ、折りたたみモード、Agent パネルの並び順、マウスの挙動、ペインの境界線、その他の表示設定については、[設定リファレンス](/docs/config-reference/)で `ui.` を検索してください。
+### 外側のターミナルのウィンドウタイトル
+
+Herdr はペイン内のターミナルをエミュレートするため、ペイン内で書き込まれた `OSC 0`/`OSC 2` のタイトルは Herdr で止まります。Herdr は自身が動作しているターミナルに独自のタイトルを書き込み、ウィンドウマネージャーやターミナルのタブバーはこれを読み取ります:
+
+```toml
+[ui]
+window_title = "{hostname}: {workspace}"
+```
+
+トークンは `{hostname}`、`{workspace}`、`{tab}`、`{pane}`(フォーカス中のペインの手動名)、`{terminal_title}`(フォーカス中のペイン自身のターミナルタイトルからスピナーを取り除いたもの)です。波括弧そのものを書くには `{{` と `}}` を使います。値のないトークンは空になります。
+
+タイトルは Herdr サーバー上で生成されるため、`herdr --remote` で接続した場合や SSH 越しに `herdr` を実行した場合でも、`{hostname}` はペインが動作しているマシンを指します。外側のターミナルのタイトルに触れないようにするには `window_title = ""` を設定します。
+
+`client.window_title.set` は設定されたタイトルを上書きし、`client.window_title.clear` がそれを戻します。
+
### サイドバーの行レイアウト
展開されたデスクトップサイドバーでは、`rows` の各内側の配列が 1 行として描画されます。完全なデフォルトレイアウトは次のとおりです:
diff --git a/docs/next/website/src/content/docs/ja/socket-api.mdx b/docs/next/website/src/content/docs/ja/socket-api.mdx
index 7b9ee4cc..f5ef6f75 100644
--- a/docs/next/website/src/content/docs/ja/socket-api.mdx
+++ b/docs/next/website/src/content/docs/ja/socket-api.mdx
@@ -273,7 +273,7 @@ reason の候補は `shown`、`disabled`、`rate_limited`、`no_foreground_clien
{"id":"req_title_clear","method":"client.window_title.clear","params":{}}
```
-`client.window_title.clear` は Herdr のデフォルトタイトルを復元します。レスポンスは `type: "client_window_title"` で、`changed` と、`set`、`cleared`、`no_foreground_client` のいずれかの reason を含みます。
+`client.window_title.clear` はタイトルを `ui.window_title` に戻します。レスポンスは `type: "client_window_title"` で、`changed` と、`set`、`cleared`、`no_foreground_client` のいずれかの reason を含みます。
Worktree メソッドは Git チェックアウトを Herdr ワークスペースとして管理します。`worktree.create` はチェックアウトを作成し、新しい `workspace`、`tab`、`root_pane`、`worktree` のレコードを返します。要求されたブランチがローカルに既存ならそれをチェックアウトし、なければ要求されたベースまたは `HEAD` からブランチを作成します。`worktree.open` は既存のチェックアウトを開くか、すでに開いているワークスペースを返します。`worktree.remove` はリンクされた子ワークスペースに対して `git worktree remove` を実行し、ブランチは決して削除しません。
diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx
index 86551c03..430cf4bf 100644
--- a/docs/next/website/src/content/docs/socket-api.mdx
+++ b/docs/next/website/src/content/docs/socket-api.mdx
@@ -375,7 +375,7 @@ Set or clear the foreground client's outer terminal window title:
{"id":"req_title_clear","method":"client.window_title.clear","params":{}}
```
-`client.window_title.clear` restores Herdr's default title. The response is
+`client.window_title.clear` hands the title back to `ui.window_title`. The response is
`type: "client_window_title"` with `changed` and reason `set`, `cleared`, or
`no_foreground_client`.
diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx
index baebfcc8..b5468021 100644
--- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx
@@ -308,7 +308,7 @@ herdr terminal title clear
```
从直接附加中用 `ctrl+b q` 分离。用 `ctrl+b ctrl+b` 发送字面的 `ctrl+b`。
-`terminal title clear` 恢复 Herdr 默认的外层终端窗口标题。
+`terminal title clear` 把外层终端窗口标题交还给 `ui.window_title`。
## 输出等待
diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx
index 0cb9ce42..b83d10ca 100644
--- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx
+++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx
@@ -255,6 +255,21 @@ yellow = "#f9e2af"
侧边栏是 Herdr 的主仪表盘。在[配置参考](/docs/config-reference/)中搜索 `ui.`,可查看尺寸、折叠模式、Agent 面板排序、鼠标行为、窗格边框和其他显示设置。
+### 外层终端窗口标题
+
+Herdr 会模拟各窗格中的终端,因此窗格内写入的 `OSC 0`/`OSC 2` 标题只会停留在 Herdr。Herdr 会向自己所运行的终端写入自己的标题,窗口管理器和终端标签栏读取的正是这个标题:
+
+```toml
+[ui]
+window_title = "{hostname}: {workspace}"
+```
+
+可用记号为 `{hostname}`、`{workspace}`、`{tab}`、`{pane}`(聚焦窗格的手动名称)和 `{terminal_title}`(聚焦窗格自身的终端标题,已去除加载动画字符)。用 `{{` 和 `}}` 表示字面花括号。没有值的记号会渲染为空。
+
+标题在 Herdr 服务器上生成,因此即使通过 `herdr --remote` 连接或经由 SSH 运行 `herdr`,`{hostname}` 仍然指向运行窗格的那台机器。设置 `window_title = ""` 可以不改动外层终端标题。
+
+`client.window_title.set` 会覆盖配置的标题,直到 `client.window_title.clear` 将其交还。
+
### 侧边栏行布局
展开的桌面侧边栏会将 `rows` 中的每个内层数组渲染为一行。以下是完整的默认布局:
diff --git a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx
index 269e6a3d..255c33e2 100644
--- a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx
+++ b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx
@@ -273,7 +273,7 @@ herdr notification show "build failed" --body "api workspace" --position top-lef
{"id":"req_title_clear","method":"client.window_title.clear","params":{}}
```
-`client.window_title.clear` 恢复 Herdr 的默认标题。响应是 `type: "client_window_title"`,带 `changed` 和 `set`、`cleared` 或 `no_foreground_client` 之一的 reason。
+`client.window_title.clear` 把标题交还给 `ui.window_title`。响应是 `type: "client_window_title"`,带 `changed` 和 `set`、`cleared` 或 `no_foreground_client` 之一的 reason。
Worktree 方法把 Git 检出作为 Herdr 工作区管理。`worktree.create` 创建检出,并返回新的 `workspace`、`tab`、`root_pane` 和 `worktree` 记录。请求的分支已在本地存在时检出它;否则从请求的 base 或 `HEAD` 创建分支。`worktree.open` 打开已有检出,或返回已打开的工作区。`worktree.remove` 对关联的子工作区运行 `git worktree remove`,从不删除分支。
diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json
index 6024aaab..bcc3f02e 100644
--- a/docs/next/website/src/data/config-reference.json
+++ b/docs/next/website/src/data/config-reference.json
@@ -754,6 +754,12 @@
"description": "Text inserted between visible right-aligned tab bar entries."
},
{
+ "key": "ui.window_title",
+ "type": "string",
+ "default": "\"{hostname}: {workspace}\"",
+ "description": "Title Herdr writes to the terminal it runs in. Tokens: {hostname}, {workspace}, {tab}, {pane}, {terminal_title}. Empty leaves the outer title alone."
+ },
+ {
"key": "ui.agent_panel_sort",
"type": "enum",
"default": "\"spaces\"",
diff --git a/src/app/api.rs b/src/app/api.rs
index 379369ae..18471f57 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -923,7 +923,7 @@ impl App {
&mut self,
request: crate::api::schema::Request,
) -> String {
- self.sync_terminal_titles();
+ self.sync_pending_terminal_titles();
use crate::api::schema::{
ErrorBody, ErrorResponse, Method, ResponseResult, SuccessResponse,
};
diff --git a/src/app/mod.rs b/src/app/mod.rs
index cca530ce..d3d79954 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -26,6 +26,7 @@ mod tab_bar_status;
mod terminal_targets;
mod terminal_titles;
mod theme_sync;
+mod window_title;
mod worktrees;
use std::collections::{HashMap, HashSet};
@@ -144,6 +145,8 @@ pub struct App {
tab_bar_datetimes: Vec<tab_bar_status::TabBarDatetimeRuntime>,
tab_bar_commands: Vec<tab_bar_status::TabBarCommandRuntime>,
next_tab_bar_datetime_refresh: Option<Instant>,
+ /// Parsed `ui.window_title` plus the hostname resolved when it was applied.
+ window_title_template: Option<(crate::config::WindowTitleTemplate, String)>,
pub(crate) persist_pane_history: bool,
pub(crate) last_render_at: Option<Instant>,
pub(crate) input_leases: input::InputLeaseTable,
@@ -773,6 +776,7 @@ impl App {
tab_bar_datetimes: Vec::new(),
tab_bar_commands: Vec::new(),
next_tab_bar_datetime_refresh: None,
+ window_title_template: None,
selection_autoscroll_deadline: None,
selection_highlight_clear_deadline: None,
persist_pane_history: config.experimental.pane_history,
@@ -794,6 +798,7 @@ impl App {
prefix_input_source: Box::new(crate::platform::RealPrefixInputSource::default()),
};
app.configure_tab_bar_status(&config.ui.tab_bar_right, &config.ui.tab_bar_right_separator);
+ app.configure_window_title(&config.ui.window_title);
app
}
@@ -928,6 +933,7 @@ impl App {
self.query_host_terminal_theme();
let mut needs_render = true;
+ let mut sent_window_title: Option<Option<String>> = None;
let mut host_mouse_capture_active = self.state.mouse_capture;
let mut host_keyboard_report_all_active = false;
@@ -936,11 +942,6 @@ impl App {
if self.render_dirty.is_pending() {
needs_render = true;
}
- let terminal_title_changed = self.sync_terminal_titles();
- if terminal_title_changed && self.terminal_title_sidebar_configured() {
- needs_render = true;
- }
-
// Drain a bounded internal-event batch for responsiveness. API handlers
// perform an exhaustive drain before reading pane/runtime state.
if self.drain_internal_events() {
@@ -1062,7 +1063,20 @@ impl App {
self.sync_host_keyboard_report_all(&mut host_keyboard_report_all_active)?;
if needs_render && self.can_render_now(now) {
- let _ = self.render_dirty.take();
+ let render_request = self.render_dirty.take();
+ self.sync_terminal_titles(&render_request.terminal_title_sources);
+ if self.window_title_configured() {
+ let title = self
+ .window_title()
+ .and_then(|title| crate::config::sanitize_window_title_text(&title));
+ if sent_window_title.as_ref() != Some(&title) {
+ crate::terminal_effects::write_window_title(
+ &mut std::io::stdout(),
+ title.as_deref(),
+ )?;
+ sent_window_title = Some(title);
+ }
+ }
let _sync_output = SyncOutputGuard::begin()?;
let kitty_graphics_enabled = self.state.kitty_graphics_enabled;
if self.full_redraw_pending {
@@ -1437,6 +1451,9 @@ impl App {
diagnostics.extend(crate::config::tab_bar_right_diagnostics(
&config.ui.tab_bar_right,
));
+ diagnostics.extend(crate::config::window_title_diagnostics(
+ &config.ui.window_title,
+ ));
self.state.default_sidebar_width = config.ui.sidebar_width;
if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault {
@@ -1480,6 +1497,7 @@ impl App {
&config.ui.tab_bar_right,
&config.ui.tab_bar_right_separator,
);
+ self.configure_window_title(&config.ui.window_title);
self.state.agent_panel_sort =
agent_panel_sort_from_config(config.ui.agent_panel_sort);
self.state.status_indicators = config.ui.status_indicators;
diff --git a/src/app/terminal_titles.rs b/src/app/terminal_titles.rs
index a371d2dc..0ed2a0f7 100644
--- a/src/app/terminal_titles.rs
+++ b/src/app/terminal_titles.rs
@@ -1,48 +1,69 @@
+use std::collections::HashSet;
+
use super::App;
+use crate::layout::PaneId;
+
+#[derive(Debug, Default, PartialEq, Eq)]
+pub(crate) struct TerminalTitleChanges {
+ pub(crate) raw_changed: bool,
+ pub(crate) stripped_changed: bool,
+}
impl App {
- pub(crate) fn terminal_title_sidebar_configured(&self) -> bool {
+ pub(crate) fn terminal_title_sidebar_changed(&self, changes: &TerminalTitleChanges) -> bool {
let config = &self.state.sidebar_agents;
std::iter::once(&config.rows)
.chain(config.rows_by_agent.values())
.flatten()
.flatten()
- .any(|token| {
- matches!(
- token.parts().0,
- crate::config::AgentSidebarToken::TerminalTitle
- | crate::config::AgentSidebarToken::TerminalTitleStripped
- )
+ .any(|token| match token.parts().0 {
+ crate::config::AgentSidebarToken::TerminalTitle => changes.raw_changed,
+ crate::config::AgentSidebarToken::TerminalTitleStripped => changes.stripped_changed,
+ _ => false,
})
}
- pub(crate) fn sync_terminal_titles(&mut self) -> bool {
- let mut observations = Vec::new();
- for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() {
- for tab in &workspace.tabs {
- for (pane_id, pane) in &tab.panes {
- let terminal_id = &pane.attached_terminal_id;
- let Some(runtime) = self.terminal_runtimes.get(terminal_id) else {
- continue;
- };
- observations.push((
- ws_idx,
- *pane_id,
- terminal_id.clone(),
- runtime.terminal_title(),
- ));
- }
- }
+ pub(crate) fn sync_pending_terminal_titles(&mut self) -> TerminalTitleChanges {
+ let sources = self.render_dirty.pending_terminal_title_sources();
+ let changes = self.sync_terminal_titles(&sources);
+ if self.terminal_title_sidebar_changed(&changes) {
+ self.render_dirty.request_generic();
+ self.render_notify.notify_one();
+ }
+ changes
+ }
+
+ pub(crate) fn sync_terminal_titles(
+ &mut self,
+ sources: &HashSet<PaneId>,
+ ) -> TerminalTitleChanges {
+ if sources.is_empty() {
+ return TerminalTitleChanges::default();
+ }
+
+ let mut observations = Vec::with_capacity(sources.len());
+ for pane_id in sources {
+ let Some((ws_idx, terminal_id)) = self
+ .find_pane(*pane_id)
+ .map(|(ws_idx, pane)| (ws_idx, pane.attached_terminal_id.clone()))
+ else {
+ continue;
+ };
+ let Some(runtime) = self.terminal_runtimes.get(&terminal_id) else {
+ continue;
+ };
+ observations.push((ws_idx, *pane_id, terminal_id, runtime.terminal_title()));
}
- let mut raw_changed = false;
+ let mut changes = TerminalTitleChanges::default();
let mut publish = Vec::new();
for (ws_idx, pane_id, terminal_id, title) in observations {
let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
continue;
};
let change = terminal.set_terminal_title(title);
- raw_changed |= change.raw_changed;
+ changes.raw_changed |= change.raw_changed;
+ changes.stripped_changed |= change.stripped_changed;
if change.stripped_changed {
publish.push((ws_idx, pane_id));
}
@@ -52,7 +73,7 @@ impl App {
self.emit_pane_updated(ws_idx, pane_id);
}
- raw_changed
+ changes
}
}
@@ -69,6 +90,7 @@ mod tests {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
app.state.workspaces = vec![Workspace::test_new("one")];
+ app.state.active = Some(0);
app.state.ensure_test_terminals();
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
@@ -80,8 +102,15 @@ mod tests {
let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
runtime.test_process_pty_bytes("\x1b]0;⠋ 修复🙂标题\x07".as_bytes());
app.terminal_runtimes.insert(terminal_id.clone(), runtime);
+ let sources = HashSet::from([pane_id]);
- assert!(app.sync_terminal_titles());
+ assert_eq!(
+ app.sync_terminal_titles(&sources),
+ TerminalTitleChanges {
+ raw_changed: true,
+ stripped_changed: true,
+ }
+ );
let pane = app.pane_info(0, pane_id).unwrap();
assert_eq!(pane.terminal_title.as_deref(), Some("⠋ 修复🙂标题"));
assert_eq!(pane.terminal_title_stripped.as_deref(), Some("修复🙂标题"));
@@ -96,7 +125,13 @@ mod tests {
.get(&terminal_id)
.unwrap()
.test_process_pty_bytes("\x1b]2;⠙ 修复🙂标题\x1b\\".as_bytes());
- assert!(app.sync_terminal_titles());
+ assert_eq!(
+ app.sync_terminal_titles(&sources),
+ TerminalTitleChanges {
+ raw_changed: true,
+ stripped_changed: false,
+ }
+ );
let pane = app.pane_info(0, pane_id).unwrap();
assert_eq!(pane.terminal_title.as_deref(), Some("⠙ 修复🙂标题"));
assert_eq!(pane.terminal_title_stripped.as_deref(), Some("修复🙂标题"));
@@ -107,14 +142,14 @@ mod tests {
.get(&terminal_id)
.unwrap()
.test_process_pty_bytes(b"\x1b]0;Done reviewing\x07");
- assert!(app.sync_terminal_titles());
+ assert!(app.sync_terminal_titles(&sources).stripped_changed);
assert_eq!(pane_updated_events(&event_hub), 2);
app.terminal_runtimes
.get(&terminal_id)
.unwrap()
.test_process_pty_bytes(b"\x1b]0;\x07");
- assert!(app.sync_terminal_titles());
+ assert!(app.sync_terminal_titles(&sources).stripped_changed);
let pane = app.pane_info(0, pane_id).unwrap();
assert_eq!(pane.terminal_title, None);
assert_eq!(pane.terminal_title_stripped, None);
@@ -122,8 +157,36 @@ mod tests {
assert_eq!(pane_updated_events(&event_hub), 3);
}
+ #[tokio::test]
+ async fn syncing_pending_titles_preserves_sidebar_render_impact() {
+ let event_hub = crate::api::EventHub::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub);
+ app.state.workspaces = vec![Workspace::test_new("one")];
+ app.state.active = Some(0);
+ app.state.ensure_test_terminals();
+ app.state.sidebar_agents.rows = vec![vec![
+ crate::config::AgentSidebarToken::TerminalTitleStripped,
+ ]];
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0]
+ .terminal_id(pane_id)
+ .unwrap()
+ .clone();
+ let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
+ runtime.test_process_pty_bytes(b"\x1b]0;building\x07");
+ app.terminal_runtimes.insert(terminal_id, runtime);
+ app.render_dirty.request_terminal_title(pane_id);
+
+ let changes = app.sync_pending_terminal_titles();
+
+ assert!(changes.stripped_changed);
+ let render_request = app.render_dirty.take();
+ assert!(render_request.generic);
+ }
+
#[test]
- fn override_only_terminal_title_token_requests_sidebar_redraws() {
+ fn sidebar_redraws_only_for_the_configured_title_form() {
let event_hub = crate::api::EventHub::default();
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub);
@@ -135,7 +198,21 @@ mod tests {
]],
);
- assert!(app.terminal_title_sidebar_configured());
+ let spinner_only = TerminalTitleChanges {
+ raw_changed: true,
+ ..TerminalTitleChanges::default()
+ };
+ assert!(!app.terminal_title_sidebar_changed(&spinner_only));
+ assert!(app.terminal_title_sidebar_changed(&TerminalTitleChanges {
+ stripped_changed: true,
+ ..TerminalTitleChanges::default()
+ }));
+
+ app.state.sidebar_agents.rows_by_agent.insert(
+ "claude".into(),
+ vec![vec![crate::config::AgentSidebarToken::TerminalTitle]],
+ );
+ assert!(app.terminal_title_sidebar_changed(&spinner_only));
}
fn pane_updated_events(event_hub: &crate::api::EventHub) -> usize {
diff --git a/src/app/window_title.rs b/src/app/window_title.rs
new file mode 100644
index 00000000..841c6a92
--- /dev/null
+++ b/src/app/window_title.rs
@@ -0,0 +1,171 @@
+//! Outer terminal window title.
+//!
+//! Herdr is a terminal emulator, so `OSC 0`/`OSC 2` written by a pane stops at
+//! Herdr and never reaches the terminal Herdr itself runs in. Without this the
+//! host window title keeps whatever the shell or `ssh` left behind, which is
+//! what window managers show in tab and group bars.
+//!
+//! The title is rendered on the server so `{hostname}` names the host the panes
+//! actually live on, not the machine a thin remote client runs on. The server
+//! pushes the result to the foreground client, which writes the `OSC 0`.
+
+use super::App;
+use crate::config::{WindowTitlePart, WindowTitleTemplate, WindowTitleToken};
+
+impl App {
+ pub(crate) fn configure_window_title(&mut self, template: &str) {
+ self.window_title_template =
+ WindowTitleTemplate::parse(template)
+ .ok()
+ .flatten()
+ .map(|template| {
+ // Resolve the hostname once here rather than per render.
+ let hostname = if template.uses(WindowTitleToken::Hostname) {
+ crate::platform::hostname().unwrap_or_default()
+ } else {
+ String::new()
+ };
+ (template, hostname)
+ });
+ }
+
+ /// Whether `ui.window_title` asks Herdr to own the outer terminal title at
+ /// all. When it does not, Herdr leaves whatever the shell or `ssh` set.
+ pub(crate) fn window_title_configured(&self) -> bool {
+ self.window_title_template.is_some()
+ }
+
+ /// Whether the title depends on the focused pane's own terminal title, which
+ /// is the one input that arrives through PTY parsing rather than app state.
+ pub(crate) fn window_title_uses_terminal_title(&self) -> bool {
+ self.window_title_template
+ .as_ref()
+ .is_some_and(|(template, _)| template.uses(WindowTitleToken::TerminalTitle))
+ }
+
+ /// Renders the configured outer window title, or `None` when window titles
+ /// are disabled or every token resolved empty.
+ pub(crate) fn window_title(&self) -> Option<String> {
+ let (template, hostname) = self.window_title_template.as_ref()?;
+ let workspace = self
+ .state
+ .active
+ .and_then(|ws_idx| self.state.workspaces.get(ws_idx));
+
+ let mut title = String::new();
+ for part in template.parts() {
+ match part {
+ WindowTitlePart::Literal(literal) => title.push_str(literal),
+ WindowTitlePart::Token(WindowTitleToken::Hostname) => title.push_str(hostname),
+ WindowTitlePart::Token(WindowTitleToken::Workspace) => {
+ if let Some(workspace) = workspace {
+ title.push_str(
+ &workspace.display_name_from_terminals(&self.state.terminals),
+ );
+ }
+ }
+ WindowTitlePart::Token(WindowTitleToken::Tab) => {
+ if let Some(name) = workspace.and_then(|ws| ws.active_tab_display_name()) {
+ title.push_str(&name);
+ }
+ }
+ WindowTitlePart::Token(WindowTitleToken::Pane) => {
+ if let Some(label) = self
+ .focused_terminal_state()
+ .and_then(|terminal| terminal.manual_label.as_deref())
+ {
+ title.push_str(label);
+ }
+ }
+ WindowTitlePart::Token(WindowTitleToken::TerminalTitle) => {
+ if let Some(terminal_title) = self
+ .focused_terminal_state()
+ .and_then(|terminal| terminal.terminal_title_stripped())
+ {
+ title.push_str(&terminal_title);
+ }
+ }
+ }
+ }
+
+ Some(title)
+ }
+
+ fn focused_terminal_state(&self) -> Option<&crate::terminal::TerminalState> {
+ let workspace = self.state.workspaces.get(self.state.active?)?;
+ let terminal_id = workspace.terminal_id(workspace.focused_pane_id()?)?;
+ self.state.terminals.get(terminal_id)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::app::App;
+ use crate::config::Config;
+ use crate::workspace::Workspace;
+
+ fn test_app() -> App {
+ let event_hub = crate::api::EventHub::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub);
+ app.state.workspaces = vec![Workspace::test_new("herd")];
+ app.state.active = Some(0);
+ app.state.ensure_test_terminals();
+ app
+ }
+
+ #[test]
+ fn renders_workspace_and_tab_names() {
+ let mut app = test_app();
+ app.configure_window_title("{workspace}/{tab}");
+
+ assert_eq!(app.window_title().as_deref(), Some("herd/1"));
+
+ app.state.workspaces[0].tabs[0].custom_name = Some("build".into());
+ assert_eq!(app.window_title().as_deref(), Some("herd/build"));
+ }
+
+ #[test]
+ fn renders_focused_pane_label_and_terminal_title() {
+ let mut app = test_app();
+ app.configure_window_title("{pane}|{terminal_title}");
+
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let terminal = app
+ .state
+ .terminals
+ .get_mut(&terminal_id)
+ .expect("focused terminal");
+ terminal.manual_label = Some("api".into());
+ terminal.set_terminal_title(Some("⠋ building".into()));
+
+ assert_eq!(app.window_title().as_deref(), Some("api|building"));
+ }
+
+ #[test]
+ fn empty_template_disables_window_titles() {
+ let mut app = test_app();
+ app.configure_window_title("");
+
+ assert_eq!(app.window_title(), None);
+ }
+
+ #[test]
+ fn invalid_template_disables_window_titles() {
+ let mut app = test_app();
+ app.configure_window_title("{nope}");
+
+ assert_eq!(app.window_title(), None);
+ }
+
+ #[test]
+ fn unset_tokens_render_empty() {
+ let mut app = test_app();
+ app.configure_window_title("[{pane}]");
+
+ assert_eq!(app.window_title().as_deref(), Some("[]"));
+ }
+}
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 0d28b47a..e19c47ca 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -1808,8 +1808,10 @@ async fn run_client_loop(
let _ = io::stdout().flush();
}
ServerMessage::WindowTitle { title } => {
- write_window_title(title.as_deref());
- let _ = io::stdout().flush();
+ let _ = crate::terminal_effects::write_window_title(
+ &mut io::stdout(),
+ title.as_deref(),
+ );
}
ServerMessage::ReloadSoundConfig => {
reload_local_client_config(
@@ -2302,19 +2304,6 @@ fn forward_clipboard(data: &str) {
crate::selection::write_osc52_bytes(&bytes);
}
-fn window_title_osc(title: Option<&str>) -> Vec<u8> {
- let title = title.unwrap_or("herdr");
- let safe_title = title
- .chars()
- .filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}'))
- .collect::<String>();
- format!("\x1b]0;{safe_title}\x07").into_bytes()
-}
-
-fn write_window_title(title: Option<&str>) {
- let _ = io::stdout().write_all(&window_title_osc(title));
-}
-
// ---------------------------------------------------------------------------
// Frame output
// ---------------------------------------------------------------------------
@@ -3569,13 +3558,4 @@ mod tests {
std::env::remove_var("SSH_CONNECTION");
}
}
-
- #[test]
- fn window_title_osc_strips_terminators_and_defaults_to_herdr() {
- assert_eq!(
- window_title_osc(Some("herdr\x1b api\u{7}\u{9c}")),
- b"\x1b]0;herdr api\x07"
- );
- assert_eq!(window_title_osc(None), b"\x1b]0;herdr\x07");
- }
}
diff --git a/src/config.rs b/src/config.rs
index 88645061..7e7aecd2 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -7,6 +7,7 @@ mod sidebar;
mod sound;
mod tab_bar;
mod theme;
+mod window_title;
pub use self::{
io::{
@@ -33,6 +34,7 @@ pub use self::{
sound::SoundConfig,
tab_bar::TabBarRightEntryConfig,
theme::{parse_color, CustomThemeColors, ThemeConfig, THEME_NAMES},
+ window_title::{WindowTitlePart, WindowTitleTemplate, WindowTitleToken},
};
pub(crate) use self::keybinds::parse_key_combo;
@@ -44,6 +46,7 @@ pub(crate) use self::{
MAX_TAB_BAR_RIGHT_ENTRIES,
},
theme::canonical_theme_name,
+ window_title::{sanitize_window_title_text, window_title_diagnostics},
};
pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH";
@@ -85,6 +88,7 @@ impl Config {
.chain(self.theme.diagnostics())
.chain(self.ui.sound.diagnostics())
.chain(tab_bar_right_diagnostics(&self.ui.tab_bar_right))
+ .chain(window_title_diagnostics(&self.ui.window_title))
.chain(self.invalid_sidebar_bounds_diagnostic())
.collect()
}
diff --git a/src/config/model.rs b/src/config/model.rs
index 0b237376..9b5904fb 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -890,6 +890,9 @@ pub struct UiConfig {
pub tab_bar_right: Vec<TabBarRightEntryConfig>,
/// Text inserted between visible right-side tab bar entries. Default: one space.
pub tab_bar_right_separator: String,
+ /// Format for the outer terminal window title. Empty leaves the title alone.
+ /// Default: "{hostname}: {workspace}".
+ pub window_title: String,
/// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces".
pub agent_panel_sort: AgentPanelSortConfig,
/// Retired setting that Herdr wrote before the workspace filter was removed.
@@ -1108,6 +1111,7 @@ impl Default for UiConfig {
tab_bar_position: TabBarPositionConfig::Top,
tab_bar_right: Vec::new(),
tab_bar_right_separator: " ".into(),
+ window_title: super::window_title::default_window_title(),
agent_panel_sort: AgentPanelSortConfig::Spaces,
_legacy_agent_panel_scope: None,
status_indicators: StatusIndicatorStyle::Dots,
diff --git a/src/config/window_title.rs b/src/config/window_title.rs
new file mode 100644
index 00000000..d7598f51
--- /dev/null
+++ b/src/config/window_title.rs
@@ -0,0 +1,205 @@
+/// Maximum characters in a rendered outer terminal window title.
+pub(crate) const MAX_WINDOW_TITLE_CHARS: usize = 200;
+
+pub(crate) fn default_window_title() -> String {
+ "{hostname}: {workspace}".to_string()
+}
+
+pub(crate) fn sanitize_window_title_text(value: &str) -> Option<String> {
+ let sanitized = value
+ .chars()
+ .filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}') && !ch.is_control())
+ .take(MAX_WINDOW_TITLE_CHARS)
+ .collect::<String>()
+ .trim()
+ .to_string();
+ (!sanitized.is_empty()).then_some(sanitized)
+}
+
+/// A value the server can substitute into `ui.window_title`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WindowTitleToken {
+ /// Host the Herdr server runs on.
+ Hostname,
+ /// Active workspace display name.
+ Workspace,
+ /// Active tab display name.
+ Tab,
+ /// Manual name of the focused pane.
+ Pane,
+ /// Focused pane's terminal title, with spinner frames stripped.
+ TerminalTitle,
+}
+
+impl WindowTitleToken {
+ fn parse(name: &str) -> Option<Self> {
+ match name {
+ "hostname" => Some(Self::Hostname),
+ "workspace" => Some(Self::Workspace),
+ "tab" => Some(Self::Tab),
+ "pane" => Some(Self::Pane),
+ "terminal_title" => Some(Self::TerminalTitle),
+ _ => None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum WindowTitlePart {
+ Literal(String),
+ Token(WindowTitleToken),
+}
+
+/// Parsed `ui.window_title` format string.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WindowTitleTemplate {
+ parts: Vec<WindowTitlePart>,
+}
+
+impl WindowTitleTemplate {
+ /// Parses a window title format. An empty format disables outer window
+ /// titles and parses to `Ok(None)`.
+ pub fn parse(template: &str) -> Result<Option<Self>, String> {
+ if template.is_empty() {
+ return Ok(None);
+ }
+
+ let mut parts = Vec::new();
+ let mut literal = String::new();
+ let mut chars = template.chars().peekable();
+ while let Some(ch) = chars.next() {
+ match ch {
+ '{' if chars.peek() == Some(&'{') => {
+ chars.next();
+ literal.push('{');
+ }
+ '}' if chars.peek() == Some(&'}') => {
+ chars.next();
+ literal.push('}');
+ }
+ '{' => {
+ let mut name = String::new();
+ let mut closed = false;
+ for ch in chars.by_ref() {
+ if ch == '}' {
+ closed = true;
+ break;
+ }
+ name.push(ch);
+ }
+ if !closed {
+ return Err("has an unclosed '{'".to_string());
+ }
+ let Some(token) = WindowTitleToken::parse(name.trim()) else {
+ return Err(format!("has unknown token '{{{name}}}'"));
+ };
+ if !literal.is_empty() {
+ parts.push(WindowTitlePart::Literal(std::mem::take(&mut literal)));
+ }
+ parts.push(WindowTitlePart::Token(token));
+ }
+ '}' => return Err("has an unmatched '}'".to_string()),
+ _ => literal.push(ch),
+ }
+ }
+ if !literal.is_empty() {
+ parts.push(WindowTitlePart::Literal(literal));
+ }
+
+ Ok((!parts.is_empty()).then_some(Self { parts }))
+ }
+
+ pub fn parts(&self) -> &[WindowTitlePart] {
+ &self.parts
+ }
+
+ pub fn uses(&self, token: WindowTitleToken) -> bool {
+ self.parts
+ .iter()
+ .any(|part| matches!(part, WindowTitlePart::Token(other) if *other == token))
+ }
+}
+
+pub(crate) fn window_title_diagnostics(template: &str) -> Option<String> {
+ WindowTitleTemplate::parse(template)
+ .err()
+ .map(|err| format!("ui.window_title {err}; leaving the outer terminal title alone"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_tokens_literals_and_escapes() {
+ let template = WindowTitleTemplate::parse("{hostname}: {workspace} {{a}}")
+ .expect("parse")
+ .expect("template");
+
+ assert_eq!(
+ template.parts(),
+ [
+ WindowTitlePart::Token(WindowTitleToken::Hostname),
+ WindowTitlePart::Literal(": ".into()),
+ WindowTitlePart::Token(WindowTitleToken::Workspace),
+ WindowTitlePart::Literal(" {a}".into()),
+ ]
+ );
+ assert!(template.uses(WindowTitleToken::Hostname));
+ assert!(!template.uses(WindowTitleToken::TerminalTitle));
+ }
+
+ #[test]
+ fn default_template_parses() {
+ assert!(WindowTitleTemplate::parse(&default_window_title())
+ .expect("parse")
+ .is_some());
+ }
+
+ #[test]
+ fn empty_template_disables_window_titles() {
+ assert_eq!(WindowTitleTemplate::parse(""), Ok(None));
+ assert!(window_title_diagnostics("").is_none());
+ }
+
+ #[test]
+ fn invalid_templates_report_diagnostics() {
+ assert!(window_title_diagnostics("{hostname")
+ .expect("diagnostic")
+ .contains("unclosed"));
+ assert!(window_title_diagnostics("a } b")
+ .expect("diagnostic")
+ .contains("unmatched"));
+ assert!(window_title_diagnostics("{session}")
+ .expect("diagnostic")
+ .contains("unknown token '{session}'"));
+ }
+
+ #[test]
+ fn sanitizes_and_bounds_rendered_titles() {
+ assert_eq!(
+ sanitize_window_title_text(" herdr\u{1b} api\u{7}\n ").as_deref(),
+ Some("herdr api")
+ );
+ assert_eq!(sanitize_window_title_text("\u{7}\n"), None);
+ assert_eq!(
+ sanitize_window_title_text(&"x".repeat(MAX_WINDOW_TITLE_CHARS + 1))
+ .expect("title")
+ .chars()
+ .count(),
+ MAX_WINDOW_TITLE_CHARS
+ );
+ }
+
+ #[test]
+ fn token_names_tolerate_surrounding_whitespace() {
+ let template = WindowTitleTemplate::parse("{ tab }")
+ .expect("parse")
+ .expect("template");
+
+ assert_eq!(
+ template.parts(),
+ [WindowTitlePart::Token(WindowTitleToken::Tab)]
+ );
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index c32cfe53..4d915158 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -340,6 +340,14 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# tab_bar_right = []
# tab_bar_right_separator = " "
+# Title Herdr writes to the terminal it runs in, which is what window managers
+# show in title, tab, and group bars. Tokens are {hostname}, {workspace}, {tab},
+# {pane}, and {terminal_title}; {{ and }} are literal braces.
+# The title renders on the Herdr server, so {hostname} names the host the panes
+# run on even when attaching from a remote client.
+# Set to "" to leave the outer terminal title alone.
+# window_title = "{hostname}: {workspace}"
+
# Agent panel ordering: "spaces" (grouped by space) or "priority" (attention queue).
# "workspaces" is accepted as an alias for "spaces".
# agent_panel_sort = "spaces"
diff --git a/src/pane.rs b/src/pane.rs
index 130c75e5..1ade096e 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -1899,7 +1899,10 @@ impl PaneRuntime {
terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer);
publish_terminal_bells(pane_id, result.terminal_bells, &read_events);
observe_detection_content_change(bytes, &detection_content_seq);
- if result.request_render && render_dirty.request_pty(pane_id) {
+ let title_requested =
+ result.terminal_title_changed && render_dirty.request_terminal_title(pane_id);
+ let render_requested = result.request_render && render_dirty.request_pty(pane_id);
+ if title_requested || render_requested {
render_notify.notify_one();
}
if let Some(delay) = result.render_delay {
@@ -2062,7 +2065,10 @@ impl PaneRuntime {
if agent_detection == AgentDetection::Enabled {
observe_detection_content_change(bytes, &detection_content_seq);
}
- if result.request_render && render_dirty.request_pty(pane_id) {
+ let title_requested =
+ result.terminal_title_changed && render_dirty.request_terminal_title(pane_id);
+ let render_requested = result.request_render && render_dirty.request_pty(pane_id);
+ if title_requested || render_requested {
render_notify.notify_one();
}
if let Some(delay) = result.render_delay {
diff --git a/src/pane/osc.rs b/src/pane/osc.rs
index 16ee8fee..a281af77 100644
--- a/src/pane/osc.rs
+++ b/src/pane/osc.rs
@@ -464,13 +464,14 @@ pub(super) struct AgentOscStateTracker {
}
impl AgentOscStateTracker {
- pub(super) fn observe(&mut self, bytes: &[u8]) {
+ pub(super) fn observe(&mut self, bytes: &[u8]) -> bool {
let (collector, latest_title, terminal_title, latest_progress) = (
&mut self.collector,
&mut self.latest_title,
&mut self.terminal_title,
&mut self.latest_progress,
);
+ let mut terminal_title_changed = false;
collector.observe(bytes, |body| {
let Some((command, payload)) = parse_agent_osc_body(body) else {
return;
@@ -478,8 +479,10 @@ impl AgentOscStateTracker {
match command {
b"0" | b"2" => {
let title = sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS);
- *terminal_title = (!title.is_empty()).then_some(title.clone());
- *latest_title = (!title.is_empty()).then_some(title);
+ let title = (!title.is_empty()).then_some(title);
+ terminal_title_changed |= *terminal_title != title;
+ *terminal_title = title.clone();
+ *latest_title = title;
}
b"9" => {
*latest_progress =
@@ -488,6 +491,7 @@ impl AgentOscStateTracker {
_ => {}
}
});
+ terminal_title_changed
}
pub(super) fn terminal_title(&self) -> Option<&str> {
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index 9575ad6f..3524df21 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -146,6 +146,7 @@ impl InputState {
pub(crate) struct ProcessBytesResult {
pub request_render: bool,
pub render_delay: Option<Duration>,
+ pub terminal_title_changed: bool,
pub terminal_bells: u16,
pub clipboard_writes: Vec<Vec<u8>>,
pub reported_cwd: Option<std::path::PathBuf>,
@@ -1201,6 +1202,7 @@ impl GhosttyPaneTerminal {
return ProcessBytesResult {
request_render: false,
render_delay: None,
+ terminal_title_changed: false,
terminal_bells: 0,
clipboard_writes: Vec::new(),
reported_cwd: None,
@@ -1233,7 +1235,7 @@ impl GhosttyPaneTerminal {
"agent OSC evidence observed"
);
}
- core.agent_osc_state.observe(bytes);
+ let terminal_title_changed = core.agent_osc_state.observe(bytes);
let alternate_screen = core
.terminal
@@ -1335,6 +1337,7 @@ impl GhosttyPaneTerminal {
ProcessBytesResult {
request_render,
render_delay,
+ terminal_title_changed,
terminal_bells,
clipboard_writes,
reported_cwd,
@@ -3777,6 +3780,33 @@ mod tests {
}
#[test]
+ fn process_pty_bytes_reports_only_completed_title_changes() {
+ let (tx, _rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(80, 24, 100).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+
+ assert!(
+ !pane
+ .process_pty_bytes(pane_id, 0, b"\x1b]0;buil", &tx)
+ .terminal_title_changed
+ );
+ assert!(
+ pane.process_pty_bytes(pane_id, 0, b"ding\x07", &tx)
+ .terminal_title_changed
+ );
+ assert!(
+ !pane
+ .process_pty_bytes(pane_id, 0, b"\x1b]2;building\x07", &tx)
+ .terminal_title_changed
+ );
+ assert!(
+ pane.process_pty_bytes(pane_id, 0, b"\x1b]2;done\x07", &tx)
+ .terminal_title_changed
+ );
+ }
+
+ #[test]
fn process_pty_bytes_surfaces_clipboard_writes_without_other_results() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 100).unwrap();
diff --git a/src/render_signal.rs b/src/render_signal.rs
index d4544e5f..2f6d9cb8 100644
--- a/src/render_signal.rs
+++ b/src/render_signal.rs
@@ -8,6 +8,7 @@ use crate::layout::PaneId;
pub(crate) struct RenderRequest {
pub(crate) generic: bool,
pub(crate) pty_sources: HashSet<PaneId>,
+ pub(crate) terminal_title_sources: HashSet<PaneId>,
}
/// Coalesces render requests while retaining enough origin information for the
@@ -46,6 +47,25 @@ impl RenderSignal {
!self.pending.swap(true, Ordering::AcqRel)
}
+ /// Coalesces terminal-title changes separately from ordinary PTY damage so
+ /// consumers can update metadata without inspecting every pane.
+ pub(crate) fn request_terminal_title(&self, pane_id: PaneId) -> bool {
+ let mut request = self
+ .request
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ request.terminal_title_sources.insert(pane_id);
+ !self.pending.swap(true, Ordering::AcqRel)
+ }
+
+ pub(crate) fn pending_terminal_title_sources(&self) -> HashSet<PaneId> {
+ self.request
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .terminal_title_sources
+ .clone()
+ }
+
pub(crate) fn take(&self) -> RenderRequest {
let mut request = self
.request
@@ -73,10 +93,28 @@ mod tests {
let request = signal.take();
assert!(!request.generic);
assert_eq!(request.pty_sources, HashSet::from([first, second]));
+ assert!(request.terminal_title_sources.is_empty());
assert!(!signal.is_pending());
}
#[test]
+ fn coalesces_terminal_title_sources_without_making_them_pty_damage() {
+ let signal = RenderSignal::new();
+ let pane_id = PaneId::from_raw(10);
+
+ assert!(signal.request_terminal_title(pane_id));
+ assert!(!signal.request_terminal_title(pane_id));
+ assert_eq!(
+ signal.pending_terminal_title_sources(),
+ HashSet::from([pane_id])
+ );
+
+ let request = signal.take();
+ assert!(request.pty_sources.is_empty());
+ assert_eq!(request.terminal_title_sources, HashSet::from([pane_id]));
+ }
+
+ #[test]
fn keeps_generic_and_pty_requests_distinct() {
let signal = RenderSignal::new();
let pane_id = PaneId::from_raw(10);
diff --git a/src/server/handoff.rs b/src/server/handoff.rs
index d17bfa91..2cd17144 100644
--- a/src/server/handoff.rs
+++ b/src/server/handoff.rs
@@ -39,6 +39,11 @@ pub(crate) struct HandoffManifest {
pub expected_protocol: Option<u32>,
pub snapshot: crate::persist::SessionSnapshot,
pub panes: Vec<crate::handoff_runtime::HandoffRuntimeState>,
+ /// An outer window title set over the API outlives the server that took the
+ /// call, so a handoff carries it rather than falling back to the config.
+ /// Absent from manifests written before this field existed.
+ #[serde(default)]
+ pub api_window_title: Option<String>,
}
#[cfg(unix)]
@@ -304,6 +309,7 @@ pub(crate) fn manifest_for(
panes: Vec<crate::handoff_runtime::HandoffRuntimeState>,
expected_protocol: Option<u32>,
expected_version: Option<String>,
+ api_window_title: Option<String>,
) -> HandoffManifest {
HandoffManifest {
version: HANDOFF_VERSION,
@@ -313,6 +319,7 @@ pub(crate) fn manifest_for(
expected_protocol,
snapshot,
panes,
+ api_window_title,
}
}
@@ -464,3 +471,54 @@ fn recv_fds(stream: &UnixStream, expected: usize) -> io::Result<Vec<RawFd>> {
pub(crate) fn log_import_result(panes: usize) {
info!(panes, "handoff import ready");
}
+
+#[cfg(all(test, unix))]
+mod tests {
+ use super::*;
+
+ fn empty_snapshot() -> crate::persist::SessionSnapshot {
+ crate::persist::SessionSnapshot {
+ version: 0,
+ workspaces: Vec::new(),
+ active: None,
+ selected: 0,
+ sidebar_width: None,
+ sidebar_section_split: None,
+ collapsed_space_keys: Default::default(),
+ }
+ }
+
+ #[test]
+ fn a_handoff_carries_an_api_set_window_title() {
+ let manifest = manifest_for(
+ empty_snapshot(),
+ Vec::new(),
+ None,
+ None,
+ Some("deploying".to_string()),
+ );
+
+ assert_eq!(manifest.api_window_title.as_deref(), Some("deploying"));
+ }
+
+ #[test]
+ fn a_manifest_written_before_the_title_field_still_loads() {
+ let manifest = manifest_for(
+ empty_snapshot(),
+ Vec::new(),
+ None,
+ None,
+ Some("deploying".to_string()),
+ );
+ let mut value = serde_json::to_value(&manifest).expect("manifest should serialize");
+ value
+ .as_object_mut()
+ .expect("manifest should be a json object")
+ .remove("api_window_title");
+
+ let older: HandoffManifest =
+ serde_json::from_value(value).expect("an older manifest should still load");
+
+ assert!(older.api_window_title.is_none());
+ }
+}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 0632861c..3425225c 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -303,6 +303,14 @@ pub struct HeadlessServer {
next_client_id: u64,
/// The client currently driving the shared pane runtime size, theme, and input keybindings.
foreground_client_id: Option<u64>,
+ /// Outer window title last pushed, paired with the client that received it.
+ /// Keying on the client means a newly attached terminal is written to even
+ /// when the title itself has not changed, without every code path that
+ /// changes the foreground client having to remember to invalidate this.
+ sent_window_title: Option<(u64, Option<String>)>,
+ /// Window title set through `client.window_title.set`. While present it wins
+ /// over the configured `ui.window_title` until the API clears it again.
+ api_window_title: Option<String>,
/// Server-owned keybindings, restored when foreground clients use server mode.
server_keybindings: crate::config::LiveKeybindConfig,
/// Full server config warning shown to clients that use server keybindings.
@@ -503,6 +511,8 @@ impl HeadlessServer {
#[cfg(unix)]
next_client_id: 1,
foreground_client_id: None,
+ sent_window_title: None,
+ api_window_title: None,
server_keybindings,
server_config_diagnostic,
server_config_diagnostic_without_keybindings,
@@ -572,13 +582,6 @@ impl HeadlessServer {
needs_render = true;
crate::render_prof::event("render.request.signal");
}
- let terminal_title_changed = self.app.sync_terminal_titles();
- if terminal_title_changed && self.app.terminal_title_sidebar_configured() {
- needs_render = true;
- needs_full_render = true;
- crate::render_prof::event("full_render_cause.terminal_title");
- }
-
// 2. Drain a bounded internal-event batch. API handlers perform an
// exhaustive forwarding-aware drain before reading pane/runtime state.
if self.drain_internal_events_with_forwarding() {
@@ -710,6 +713,22 @@ impl HeadlessServer {
needs_full_render = true;
crate::render_prof::event("full_render_cause.generic_dirty");
}
+ let (sidebar_title_changed, outer_title_synced) =
+ self.sync_terminal_title_sources(&render_request.terminal_title_sources);
+ if sidebar_title_changed {
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.terminal_title_sidebar");
+ }
+ if needs_full_render && !outer_title_synced {
+ self.sync_window_title();
+ }
+ if !needs_full_render && !needs_graphics_render && !pty_dirty {
+ // A synchronized-output OSC title can be the only pending work.
+ // Its deferred PTY repaint has its own signal; do not manufacture
+ // a full UI render for this client-local side effect.
+ needs_render = false;
+ continue;
+ }
if needs_full_render {
crate::render_prof::event("retained_gate.needs_full_render");
} else if !pty_dirty {
@@ -1278,6 +1297,7 @@ impl HeadlessServer {
panes,
params.expected_protocol,
params.expected_version,
+ self.api_window_title.clone(),
);
let mut import_child = match crate::server::handoff::spawn_handoff_import(
import_exe.as_deref(),
@@ -2122,11 +2142,93 @@ impl HeadlessServer {
.unwrap_or_else(|_| "{}".to_string())
}
+ /// Pulls only titles reported dirty by the PTY parser. A focused pane title
+ /// is forwarded as an independent client side effect; only sidebar title
+ /// tokens require a UI render.
+ fn sync_terminal_title_sources(
+ &mut self,
+ sources: &HashSet<crate::layout::PaneId>,
+ ) -> (bool, bool) {
+ let focused_source = self
+ .app
+ .state
+ .active
+ .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
+ .and_then(|workspace| workspace.focused_pane_id())
+ .is_some_and(|pane_id| sources.contains(&pane_id));
+ let changes = self.app.sync_terminal_titles(sources);
+ let outer_title_synced = focused_source && self.app.window_title_uses_terminal_title();
+ if outer_title_synced {
+ self.sync_window_title();
+ }
+ (
+ self.app.terminal_title_sidebar_changed(&changes),
+ outer_title_synced,
+ )
+ }
+
+ /// Renders `ui.window_title` against current session state. `None` means
+ /// window titles are disabled or every token resolved empty, which leaves
+ /// the client on Herdr's default title.
+ fn configured_window_title(&self) -> Option<String> {
+ self.app
+ .window_title()
+ .and_then(|title| crate::config::sanitize_window_title_text(&title))
+ }
+
+ /// Pushes the configured outer window title to the foreground client when it
+ /// changed. Herdr consumes each pane's own `OSC 0`/`OSC 2`, so without this
+ /// the host terminal title never follows the session — which is what window
+ /// managers read for tab and group bar labels.
+ fn sync_window_title(&mut self) {
+ let title = match &self.api_window_title {
+ Some(title) => Some(title.clone()),
+ None if self.app.window_title_configured() => self.configured_window_title(),
+ None => return,
+ };
+ if let (Some(client_id), Some((sent_client_id, sent_title))) =
+ (self.foreground_client_id, self.sent_window_title.as_ref())
+ {
+ if *sent_client_id == client_id && *sent_title == title {
+ return;
+ }
+ }
+ self.send_window_title(title);
+ }
+
+ /// Sends a window title and remembers it only when a foreground client took
+ /// it, so the next client to attach is written to rather than skipped.
+ fn send_window_title(&mut self, title: Option<String>) -> bool {
+ let Some(client_id) = self.foreground_client_id else {
+ self.sent_window_title = None;
+ return false;
+ };
+ // A detached client keeps its entry with no writer, and a targeted send
+ // to one reports success without queuing anything. Caching the title
+ // against that client would skip the send once it attaches again.
+ if self
+ .clients
+ .get(&client_id)
+ .is_none_or(|client| client.writer.is_none())
+ {
+ self.sent_window_title = None;
+ return false;
+ }
+ let sent = self.send_to_client(
+ client_id,
+ ServerMessage::WindowTitle {
+ title: title.clone(),
+ },
+ );
+ self.sent_window_title = sent.then_some((client_id, title));
+ sent
+ }
+
fn handle_client_window_title_api(&mut self, id: String, title: Option<String>) -> String {
use api::schema::{ClientWindowTitleReason, ResponseResult};
let title = match title {
- Some(title) => match sanitize_window_title_text(&title, 200) {
+ Some(title) => match crate::config::sanitize_window_title_text(&title) {
Some(title) => Some(title),
None => {
return serde_json::to_string(&api::schema::ErrorResponse {
@@ -2142,7 +2244,11 @@ impl HeadlessServer {
None => None,
};
let set_title = title.is_some();
- let changed = self.send_to_foreground_client(ServerMessage::WindowTitle { title });
+ // An explicit title suppresses `ui.window_title` until it is cleared,
+ // and clearing restores the configured title rather than only "herdr".
+ self.api_window_title = title.clone();
+ let title = title.or_else(|| self.configured_window_title());
+ let changed = self.send_window_title(title);
let reason = match (changed, set_title) {
(true, true) => ClientWindowTitleReason::Set,
(true, false) => ClientWindowTitleReason::Cleared,
@@ -4828,17 +4934,6 @@ fn sanitize_notification_text(value: &str, max_chars: usize) -> Option<String> {
(!sanitized.is_empty()).then_some(sanitized)
}
-fn sanitize_window_title_text(value: &str, max_chars: usize) -> Option<String> {
- let sanitized = value
- .chars()
- .filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}') && !ch.is_control())
- .take(max_chars)
- .collect::<String>()
- .trim()
- .to_string();
- (!sanitized.is_empty()).then_some(sanitized)
-}
-
fn server_config_diagnostic_summaries(diagnostics: &[String]) -> (Option<String>, Option<String>) {
let without_keybindings = diagnostics
.iter()
@@ -5045,6 +5140,9 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()>
Some(api_server),
should_quit,
)?;
+ // Carried across before any client attaches, so the first title sent is
+ // the override rather than the configured one it replaced.
+ server.api_window_title = received.manifest.api_window_title.take();
crate::server::handoff::report_ready(&mut received.stream)?;
crate::server::handoff::wait_committed(&mut received.stream)?;
server.app.assume_handoff_ownership();
@@ -5207,6 +5305,8 @@ mod tests {
#[cfg(unix)]
next_client_id: 1,
foreground_client_id: None,
+ sent_window_title: None,
+ api_window_title: None,
server_keybindings,
server_config_diagnostic: None,
server_config_diagnostic_without_keybindings: None,
@@ -5294,6 +5394,7 @@ mod tests {
.app
.terminal_runtimes
.insert(terminal_id.clone(), runtime);
+ server.app.render_dirty.request_terminal_title(pane_id);
let first = headless_pane_list(&mut server).pop().unwrap();
assert_eq!(first.terminal_title.as_deref(), Some("⠋ task"));
@@ -5319,6 +5420,7 @@ mod tests {
.get(&terminal_id)
.unwrap()
.test_process_pty_bytes(b"\x1b]2;\xe2\xa0\x99 task\x1b\\");
+ server.app.render_dirty.request_terminal_title(pane_id);
let second = headless_pane_list(&mut server).pop().unwrap();
assert_eq!(second.terminal_title.as_deref(), Some("⠙ task"));
assert_eq!(second.terminal_title_stripped.as_deref(), Some("task"));
@@ -5473,6 +5575,311 @@ mod tests {
shutdown_test_runtimes(&mut server);
}
+ fn window_title_test_server() -> (HeadlessServer, std::sync::mpsc::Receiver<Vec<u8>>) {
+ let mut server = test_headless_server();
+ server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+
+ let (client_tx, control_rx, _render_rx) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ None,
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.promote_client_to_foreground(1);
+ drain_window_titles(&control_rx);
+ (server, control_rx)
+ }
+
+ /// The test client writer drains its queue on a background thread, so
+ /// reading a pushed message needs a timeout rather than `try_recv`.
+ fn next_window_title(
+ control_rx: &std::sync::mpsc::Receiver<Vec<u8>>,
+ ) -> Option<Option<String>> {
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
+ let Ok(bytes) = control_rx.recv_timeout(remaining) else {
+ return None;
+ };
+ if let ServerMessage::WindowTitle { title } = read_server_message(bytes) {
+ return Some(title);
+ }
+ }
+ None
+ }
+
+ fn drain_window_titles(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) {
+ while control_rx.recv_timeout(Duration::from_millis(50)).is_ok() {}
+ }
+
+ fn no_window_title(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) -> bool {
+ while let Ok(bytes) = control_rx.recv_timeout(Duration::from_millis(200)) {
+ if let ServerMessage::WindowTitle { .. } = read_server_message(bytes) {
+ return false;
+ }
+ }
+ true
+ }
+
+ #[test]
+ fn window_title_waits_for_a_foreground_client_to_exist() {
+ let mut server = test_headless_server();
+ server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")];
+ server.app.state.active = Some(0);
+ server.app.configure_window_title("{workspace}");
+
+ // The server renders before the first client attaches. Nothing was
+ // delivered, so nothing may be recorded as delivered either.
+ server.sync_window_title();
+ assert_eq!(server.sent_window_title, None);
+
+ let (client_tx, control_rx, _render_rx) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ None,
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.promote_client_to_foreground(1);
+ server.sync_window_title();
+
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herd".to_string()))
+ );
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[test]
+ fn an_attaching_client_gets_the_title_even_when_it_has_not_changed() {
+ let (mut server, first_control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}");
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&first_control_rx),
+ Some(Some("herd".to_string()))
+ );
+
+ // ClientConnected assigns the foreground client directly rather than
+ // going through promote_client_to_foreground, so the cache must notice
+ // the new client on its own.
+ let (client_tx, second_control_rx, _render_rx) = test_client_writer();
+ server.clients.insert(
+ 2,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ None,
+ 2,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.foreground_client_id = Some(2);
+ server.sync_window_title();
+
+ assert_eq!(
+ next_window_title(&second_control_rx),
+ Some(Some("herd".to_string()))
+ );
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[test]
+ fn configured_window_title_reaches_the_foreground_client_once_per_change() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}/{tab}");
+
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herd/1".to_string()))
+ );
+
+ // An unchanged title must not re-emit an OSC on every render.
+ server.sync_window_title();
+ assert!(no_window_title(&control_rx));
+
+ server.app.state.workspaces[0].tabs[0].custom_name = Some("build".into());
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herd/build".to_string()))
+ );
+
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[tokio::test]
+ async fn focused_terminal_title_syncs_without_requesting_a_sidebar_render() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("{terminal_title}");
+ server.app.state.ensure_test_terminals();
+ let pane_id = server.app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = server.app.state.workspaces[0]
+ .terminal_id(pane_id)
+ .expect("terminal")
+ .clone();
+ let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
+ runtime.test_process_pty_bytes("\x1b]0;⠋ building\x07".as_bytes());
+ server
+ .app
+ .terminal_runtimes
+ .insert(terminal_id.clone(), runtime);
+
+ assert_eq!(
+ server.sync_terminal_title_sources(&HashSet::from([pane_id])),
+ (false, true)
+ );
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("building".to_string()))
+ );
+
+ server
+ .app
+ .terminal_runtimes
+ .get(&terminal_id)
+ .expect("runtime")
+ .test_process_pty_bytes("\x1b]0;⠙ building\x07".as_bytes());
+ assert_eq!(
+ server.sync_terminal_title_sources(&HashSet::from([pane_id])),
+ (false, true)
+ );
+ assert!(no_window_title(&control_rx));
+
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[test]
+ fn a_foreground_client_without_a_writer_does_not_cache_the_window_title() {
+ let (mut server, _control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}");
+
+ // A detached client keeps its entry but loses its writer, so nothing
+ // reaches a terminal even though the targeted send reports success.
+ if let Some(client) = server.clients.get_mut(&1) {
+ client.writer = None;
+ }
+ server.sync_window_title();
+ assert!(server.sent_window_title.is_none());
+
+ // Attaching again has to deliver the title rather than skip it as sent.
+ let (client_tx, control_rx, _render_rx) = test_client_writer();
+ if let Some(client) = server.clients.get_mut(&1) {
+ client.writer = Some(client_tx);
+ }
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herd".to_string()))
+ );
+
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[test]
+ fn empty_window_title_config_leaves_the_outer_title_alone() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("");
+
+ server.sync_window_title();
+
+ assert!(no_window_title(&control_rx));
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[test]
+ fn api_window_title_wins_until_it_is_cleared() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}");
+
+ server.handle_client_window_title_api("set".into(), Some("herdr api".into()));
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herdr api".to_string()))
+ );
+
+ server.app.state.workspaces[0].custom_name = Some("ops".into());
+ server.sync_window_title();
+ assert!(no_window_title(&control_rx));
+
+ // Clearing hands the title back to ui.window_title, not to "herdr".
+ server.handle_client_window_title_api("clear".into(), None);
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("ops".to_string()))
+ );
+
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[test]
+ fn clearing_the_api_title_falls_back_to_herdr_when_window_titles_are_disabled() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("");
+
+ server.handle_client_window_title_api("set".into(), Some("herdr api".into()));
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herdr api".to_string()))
+ );
+
+ server.handle_client_window_title_api("clear".into(), None);
+ assert_eq!(next_window_title(&control_rx), Some(None));
+
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[test]
+ fn a_newly_promoted_client_gets_the_window_title_again() {
+ let (mut server, first_control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}");
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&first_control_rx),
+ Some(Some("herd".to_string()))
+ );
+
+ // A second terminal starts on whatever its shell or ssh left behind.
+ let (client_tx, second_control_rx, _render_rx) = test_client_writer();
+ server.clients.insert(
+ 2,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ None,
+ 2,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.promote_client_to_foreground(2);
+ server.sync_window_title();
+
+ assert_eq!(
+ next_window_title(&second_control_rx),
+ Some(Some("herd".to_string()))
+ );
+ shutdown_test_runtimes(&mut server);
+ }
+
fn test_client_writer() -> (
ClientWriter,
std::sync::mpsc::Receiver<Vec<u8>>,
diff --git a/src/terminal_effects.rs b/src/terminal_effects.rs
index 88fcf55f..91d7b363 100644
--- a/src/terminal_effects.rs
+++ b/src/terminal_effects.rs
@@ -12,6 +12,16 @@ pub(crate) fn write_terminal_bells<W: Write>(writer: &mut W, count: u16) -> io::
writer.flush()
}
+pub(crate) fn write_window_title<W: Write>(writer: &mut W, title: Option<&str>) -> io::Result<()> {
+ let title = title.unwrap_or("herdr");
+ let safe_title = title
+ .chars()
+ .filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}'))
+ .collect::<String>();
+ write!(writer, "\x1b]0;{safe_title}\x07")?;
+ writer.flush()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -24,4 +34,15 @@ mod tests {
assert_eq!(output, vec![b'\x07'; 130]);
}
+
+ #[test]
+ fn window_title_strips_terminators_and_defaults_to_herdr() {
+ let mut output = Vec::new();
+ write_window_title(&mut output, Some("herdr\x1b api\u{7}\u{9c}")).unwrap();
+ assert_eq!(output, b"\x1b]0;herdr api\x07");
+
+ output.clear();
+ write_window_title(&mut output, None).unwrap();
+ assert_eq!(output, b"\x1b]0;herdr\x07");
+ }
}
diff --git a/tests/client_mode.rs b/tests/client_mode.rs
index c92a62d1..e79375cf 100644
--- a/tests/client_mode.rs
+++ b/tests/client_mode.rs
@@ -113,20 +113,72 @@ fn spawn_client_process(
}
}
+fn spawn_no_session_process(config_home: &PathBuf, runtime_dir: &PathBuf) -> SpawnedHerdr {
+ fs::create_dir_all(config_home.join(app_dir_name())).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join(app_dir_name()).join("config.toml"),
+ "onboarding = false\n[ui]\nwindow_title = \"monolithic\"\n",
+ )
+ .unwrap();
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("--no-session");
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+ cmd.env_remove("HERDR_SOCKET_PATH");
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env_remove("HERDR_SESSION");
+ cmd.env_remove("HERDR_WORKSPACE_ID");
+ cmd.env_remove("HERDR_TAB_ID");
+ cmd.env_remove("HERDR_PANE_ID");
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: Some(pair.master),
+ child,
+ }
+}
+
fn spawn_server(
config_home: &PathBuf,
runtime_dir: &PathBuf,
api_socket_path: &PathBuf,
+ client_socket_path: &PathBuf,
+) -> SpawnedHerdr {
+ spawn_server_with_config(
+ config_home,
+ runtime_dir,
+ api_socket_path,
+ client_socket_path,
+ "onboarding = false\n",
+ )
+}
+
+fn spawn_server_with_config(
+ config_home: &PathBuf,
+ runtime_dir: &PathBuf,
+ api_socket_path: &PathBuf,
_client_socket_path: &PathBuf,
+ config: &str,
) -> SpawnedHerdr {
- fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::create_dir_all(config_home.join(app_dir_name())).unwrap();
fs::create_dir_all(runtime_dir).unwrap();
register_runtime_dir(runtime_dir);
- fs::write(
- config_home.join("herdr/config.toml"),
- "onboarding = false\n",
- )
- .unwrap();
+ fs::write(config_home.join(app_dir_name()).join("config.toml"), config).unwrap();
let pair = native_pty_system()
.openpty(PtySize {
@@ -647,7 +699,24 @@ fn attach_thin_client(
api_socket: &PathBuf,
client_socket: &PathBuf,
) -> (SpawnedHerdr, SpawnedHerdr, SharedOutput) {
- let spawned_server = spawn_server(config_home, runtime_dir, api_socket, client_socket);
+ attach_thin_client_with_config(
+ config_home,
+ runtime_dir,
+ api_socket,
+ client_socket,
+ "onboarding = false\n",
+ )
+}
+
+fn attach_thin_client_with_config(
+ config_home: &PathBuf,
+ runtime_dir: &PathBuf,
+ api_socket: &PathBuf,
+ client_socket: &PathBuf,
+ config: &str,
+) -> (SpawnedHerdr, SpawnedHerdr, SharedOutput) {
+ let spawned_server =
+ spawn_server_with_config(config_home, runtime_dir, api_socket, client_socket, config);
wait_for_socket(api_socket, Duration::from_secs(10));
wait_for_socket(client_socket, Duration::from_secs(10));
@@ -686,6 +755,206 @@ fn attach_thin_client(
(spawned_server, thin_client, output)
}
+fn captured_window_titles(output: &SharedOutput) -> Vec<String> {
+ read_output(output)
+ .split("\x1b]0;")
+ .skip(1)
+ .filter_map(|suffix| {
+ suffix
+ .split_once('\x07')
+ .map(|(title, _)| title.to_string())
+ })
+ .collect()
+}
+
+fn wait_for_window_title(output: &SharedOutput, expected_suffix: &str) -> String {
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline {
+ if let Some(title) = captured_window_titles(output)
+ .into_iter()
+ .find(|title| title.ends_with(expected_suffix))
+ {
+ return title;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+ panic!(
+ "outer window title ending in {expected_suffix:?} was not emitted; titles: {:?}; output: {:?}",
+ captured_window_titles(output),
+ read_output(output)
+ );
+}
+
+fn wait_for_pane_terminal_title(socket_path: &PathBuf, pane_id: &str, expected: &str) {
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline {
+ let request = serde_json::json!({
+ "id": "window-title-pane-get",
+ "method": "pane.get",
+ "params": {"pane_id": pane_id},
+ });
+ let response = send_json_request(socket_path, &request.to_string());
+ if response["result"]["pane"]["terminal_title"].as_str() == Some(expected) {
+ return;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+ panic!("pane {pane_id} did not report terminal title {expected:?}");
+}
+
+fn send_pane_shell_command(socket_path: &PathBuf, pane_id: &str, command: &str) {
+ let request = serde_json::json!({
+ "id": "window-title-command",
+ "method": "pane.send_input",
+ "params": {
+ "pane_id": pane_id,
+ "text": command,
+ "keys": ["Enter"],
+ }
+ });
+ let response = send_json_request(socket_path, &request.to_string());
+ assert_eq!(response["result"]["type"], "ok", "{response}");
+}
+
+#[test]
+fn configured_window_title_is_emitted_in_no_session_mode() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let no_session = spawn_no_session_process(&config_home, &runtime_dir);
+ let reader = no_session
+ ._master
+ .as_ref()
+ .expect("no-session PTY master")
+ .try_clone_reader()
+ .expect("clone no-session PTY reader");
+ let output = spawn_pty_drain(reader);
+
+ wait_for_window_title(&output, "monolithic");
+
+ cleanup_spawned_herdr(no_session, base);
+}
+
+#[test]
+fn configured_window_title_tracks_all_tokens_and_focused_osc_only() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+ let (server, client, output) = attach_thin_client_with_config(
+ &config_home,
+ &runtime_dir,
+ &api_socket,
+ &client_socket,
+ "onboarding = false\n[ui]\nwindow_title = \"H={hostname}|W={workspace}|T={tab}|P={pane}|O={terminal_title}\"\n",
+ );
+
+ let created = send_json_request(
+ &api_socket,
+ &serde_json::json!({
+ "id": "create-workspace",
+ "method": "workspace.create",
+ "params": {"cwd": base, "focus": true},
+ })
+ .to_string(),
+ );
+ assert_eq!(created["result"]["type"], "workspace_created", "{created}");
+ let workspace_id = created["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .expect("workspace id")
+ .to_string();
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .expect("pane id")
+ .to_string();
+ let tab_id = created["result"]["tab"]["tab_id"]
+ .as_str()
+ .expect("tab id")
+ .to_string();
+
+ for request in [
+ serde_json::json!({
+ "id": "rename-workspace",
+ "method": "workspace.rename",
+ "params": {"workspace_id": workspace_id, "label": "space-a"},
+ }),
+ serde_json::json!({
+ "id": "rename-tab",
+ "method": "tab.rename",
+ "params": {"tab_id": tab_id, "label": "tab-a"},
+ }),
+ serde_json::json!({
+ "id": "rename-pane",
+ "method": "pane.rename",
+ "params": {"pane_id": pane_id, "label": "pane-a"},
+ }),
+ ] {
+ let response = send_json_request(&api_socket, &request.to_string());
+ assert!(response.get("result").is_some(), "{response}");
+ }
+
+ let renamed = wait_for_window_title(&output, "|W=space-a|T=tab-a|P=pane-a|O=");
+ assert!(renamed.starts_with("H="));
+ assert!(
+ !renamed.starts_with("H=|"),
+ "hostname token was empty: {renamed}"
+ );
+
+ send_pane_shell_command(&api_socket, &pane_id, r"printf '\033]0;⠋ building\007'");
+ wait_for_window_title(&output, "|W=space-a|T=tab-a|P=pane-a|O=building");
+
+ let second_tab = send_json_request(
+ &api_socket,
+ &serde_json::json!({
+ "id": "second-tab",
+ "method": "tab.create",
+ "params": {"workspace_id": workspace_id, "focus": true},
+ })
+ .to_string(),
+ );
+ assert_eq!(second_tab["result"]["type"], "tab_created", "{second_tab}");
+ let second_pane_id = second_tab["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .expect("second pane id")
+ .to_string();
+ wait_for_window_title(&output, "|W=space-a|T=2|P=|O=");
+ let titles_before_hidden_update = captured_window_titles(&output).len();
+ send_pane_shell_command(&api_socket, &pane_id, r"printf '\033]0;hidden update\007'");
+ // Intentionally consume the AppState title through a read-only request
+ // before the queued source is handled.
+ wait_for_pane_terminal_title(&api_socket, &pane_id, "hidden update");
+ send_pane_shell_command(
+ &api_socket,
+ &second_pane_id,
+ r"printf '\033]0;foreground marker\007'",
+ );
+ wait_for_window_title(&output, "|W=space-a|T=2|P=|O=foreground marker");
+ assert!(
+ captured_window_titles(&output)[titles_before_hidden_update..]
+ .iter()
+ .all(|title| !title.ends_with("|O=hidden update")),
+ "a hidden pane title reached the outer terminal"
+ );
+
+ let focused = send_json_request(
+ &api_socket,
+ &serde_json::json!({
+ "id": "focus-first-tab",
+ "method": "tab.focus",
+ "params": {"tab_id": tab_id},
+ })
+ .to_string(),
+ );
+ assert_eq!(focused["result"]["tab"]["focused"], true, "{focused}");
+ wait_for_window_title(&output, "|W=space-a|T=tab-a|P=pane-a|O=hidden update");
+
+ drop(server);
+ cleanup_spawned_herdr(client, base);
+}
+
/// Polls until the client exits, then returns only the output captured after
/// the `since` byte watermark. Panics if the client does not exit within the
/// deadline.