diff options
| author | David Heinemeier Hansson <david@hey.com> | 2026-08-11 02:04:12 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-11 03:04:12 +0300 |
| commit | 350f001320ec4a119f09c38157973126962a6121 (patch) | |
| tree | 408ddcf37955f9b1258fa1b0f1fa5f10e504944b | |
| parent | 42789c8e1d86a3d5ea529b1b047172bfd6979a21 (diff) | |
feat: keep the outer terminal window title in sync with the session (#2627)
* feat: keep the outer terminal window title in sync with the session
Herdr emulates the terminals in its panes, so an OSC 0/OSC 2 title
written inside a pane stops at Herdr and never reaches the terminal
Herdr itself runs in. That outer title is what window managers read for
title bars, tab bars, and group bars, so it kept showing whatever the
shell or ssh happened to leave behind.
Add `ui.window_title`, rendered from {hostname}, {workspace}, {tab},
{pane}, and {terminal_title}, and push it to the foreground client
whenever it changes. It renders on the server, so {hostname} names the
machine the panes actually run on rather than the machine a thin remote
client runs on, and it is gated on a pending render so an idle loop
never pays for it.
A title is only remembered as delivered once a foreground client takes
it, so the first client to attach is written to rather than skipped.
`client.window_title.set` still wins over the configured title, and
clearing it now hands the title back to `ui.window_title` instead of
only "herdr".
* fix: deliver the outer window title to a newly attached client
ClientConnected assigns the foreground client directly rather than going
through promote_client_to_foreground, so clearing the sent-title cache
there missed the case that matters most: attaching a second terminal to a
running session. The title was usually unchanged, so the sync returned
early and the new terminal kept whatever its shell or ssh had left.
Key the cache on the client that received the title instead of relying on
every foreground assignment to invalidate it.
* perf: keep hidden pane output off the window title path
Output from a hidden or background pane sets needs_render without setting
needs_full_render, and the retained render plan then skips presentation
for it entirely. Syncing the title on needs_render meant every coalesced
hidden-output tick still formatted, sanitized, and allocated a title that
could not have changed, against the hidden-source early exit AGENTS.md
requires.
Every input to the title is app state, which always requests a full
render, so gate on that instead. The one exception is the focused pane's
own terminal title, which arrives through PTY parsing, so ask for a full
render when that changes and the configured title uses it.
* fix: only cache a window title a client writer received
* fix: carry an api set window title across a live handoff
* fix: make outer window title updates event-driven
---------
Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
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. |
