Skip to content

feat(terminal): track, draw, and assert on the window title - #91

Open
aymanbagabas wants to merge 2 commits into
microsoft:mainfrom
aymanbagabas:feat/window-title
Open

feat(terminal): track, draw, and assert on the window title#91
aymanbagabas wants to merge 2 commits into
microsoft:mainfrom
aymanbagabas:feat/window-title

Conversation

@aymanbagabas

@aymanbagabas aymanbagabas commented Aug 5, 2026

Copy link
Copy Markdown
Member

A program announces what it is doing by setting the window title with OSC 0 or OSC 2, and shell-use threw that away. An agent driving a terminal could see what a program printed but not what it called itself — which is exactly where long-running tools report progress, and how a shell says which command is running.

The emulator now tracks the title, and it is reachable everywhere the other terminal properties already are.

Before / after

Same session, same command, screenshotted by the same screenshot --out:

before after
before after

The title is drawn centred in the title bar, clamped so it can never run under the window controls, and truncated with an ellipsis when it does not fit. No title leaves the bar exactly as it was, so every screenshot taken without one is byte-identical to before.

Surfaces

$ shell-use get title
{ "value": "vim: notes.md" }

$ shell-use expect title "vim"                  # exit 0
$ shell-use expect title "emacs"                # exit 1
$ shell-use wait title "build complete" --timeout 60000
$ shell-use expect title '^\w+: \S+\.md$' --regex
$ shell-use state | grep title
title: vim: notes.md

state.title, get title, expect title, and wait title, plus get_title / wait_title / expect_title in the Python and JS bindings. All four take --regex, --not, and --timeout where the sibling text commands do.

Widths are counted in columns, not characters

A terminal lays out by column. A CJK glyph is one char but two columns; a combining mark is one char but none. Counting characters drew the snapshot frame four columns out of true, and in the centred title bar it sized the title at half its real width so it spilled over the window controls at both ends.

cjk

╭─ 编辑:第三章笔记 ──────────────────╮      36 columns
│ok                                │      36 columns
╰──────────────────────────────────╯      36 columns

One shared truncate_to_columns in terminal/cell.rs serves both. This is the only reason unicode-width is now named in Cargo.toml: alacritty_terminal already pulls the identical 0.2.2, so declaring it added one line to Cargo.lock and zero new packages. Neither alacritty_terminal nor vte re-exports it, and hand-rolling East-Asian-width tables would be both reinvention and wrong on emoji and combining marks — the tests cover 🚀 build and e\u{301}clair for exactly that reason.

An empty title means reset

Programs clear the title by sending an empty one, so OSC 2; reports as None rather than as a title that happens to be blank. Keeping the two apart would only hand every caller the same special case to write.

Snapshots record the title only when asked

expect snapshot --include-title puts it in the frame's top border; the default leaves it out.

This is deliberate and worth a look during review. A stock Ubuntu .bashrc sets PS1='\[\e]0;\u@\h: \w\a\]…', so a normal shell writes its own title on every prompt. Recorded by default, that is:

╭─ ayman@Mac-91: /tmp/ttl ─────────╮

which pins the baseline to one username, hostname, and absolute path, and changes on cd while the screen stays identical. Off by default, a snapshot with no title is byte-identical to one taken before this PR, so every stored baseline keeps passing.

Free

The title stack (CSI 22 t push, CSI 23 t pop) works, because alacritty implements a pop as setting the title it popped and that routes through the same event. There is a test for it rather than any code.

Notes for review

  • The title is tracked from Event::Title / Event::ResetTitle in the existing CaptureProxy rather than read back off the terminal, because alacritty exposes no way to read it: Term.title and Term.colors are both private fields, but only colors has a public getter (pub fn colors(&self) -> &Colors). There is no Term::title(), and set_title is a Handler setter. Alacritty's own frontend consumes the same events for the same reason.
  • OSC 1 (icon name) is deliberately not supported: vte never dispatches it, and with a single drawn title there is nothing for a separate icon name to do.
  • The title is markup-escaped in the SVG like any other text; it is whatever the program chose to send, and there is a test that a title of </text><script> cannot inject an element.
  • Tests: 5 conformance cases (which run against every backend, so a future backend inherits them), 5 renderer tests, 4 snapshot tests, and 2 end-to-end tests through a real PTY.

aymanbagabas and others added 2 commits August 4, 2026 20:10
A program announces what it is doing by setting the window title with
`OSC 0` or `OSC 2`, and shell-use threw that away. An agent driving a
terminal could see what a program printed but not what it called itself,
which is exactly what long-running tools report progress through.

The emulator now tracks the title, and it is reachable everywhere the
other terminal properties are: `state`, `get title`, `expect title`, and
`wait title`, in the CLI and in both bindings. Screenshots draw it in the
title bar.

An empty title reports as none rather than as a blank one. Programs clear
the title that way on exit, so keeping the two apart would only give every
caller the same special case to write.

Widths are measured in terminal columns, not characters. A CJK title is
half as many characters as columns, so counting characters drew a snapshot
frame four columns out of true and, in the centred title bar, spilled the
title over the window controls at both ends.

Snapshots only record the title when asked with `--include-title`. A shell
prompt routinely sets it to a username, hostname, and absolute path, so
recording it by default would pin every stored baseline to one machine and
make it change on `cd` while the screen stayed the same.

The title stack (`CSI 22 t` / `CSI 23 t`) comes free with this, since
alacritty implements a pop as setting the title it popped; there is a test
rather than any code for it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Windows ConPTY gives a session the program's path as its window title as
soon as it opens, so asserting a fresh session reports none failed there
while the feature itself worked.

That a fresh emulator has no title, and that an empty one resets rather
than storing a blank, are claims about the emulator rather than about the
platform. Both are already pinned in the conformance suite, which runs
without a PTY and against every backend. The end-to-end test keeps to what
only it can prove: that a title set by a real program in a real shell
arrives intact, and that it replaced whatever the session started with.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
@aymanbagabas
aymanbagabas marked this pull request as ready for review August 5, 2026 00:44
@aymanbagabas

Copy link
Copy Markdown
Member Author

@cpendery ready for review when you have a moment. Branches off latest main, standalone (not part of the #84#85#86 stack).

GitHub will not let me request you as a reviewer directly (no push access here), hence the mention.

@cpendery cpendery left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we also update the bindings readmes with the new methods?

// rather than allowed to push the corner out of line.
let label = title.and_then(|title| {
let room = width.checked_sub(4).filter(|room| *room > 0)?;
Some(format!(" {} ", truncate_to_columns(title, room)))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we sure we want to truncate the title for the snapshot boxes? it might make more sense to expand the {view}\n{attributes} such that the attributes could be {title: "title", colors: {}, } and so on.

maybe we only drop into the attributes it the title would be truncated


use bitflags::bitflags;
use compact_str::CompactString;
use unicode_width::UnicodeWidthChar;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you'll run into some weird width calculation issues using this, maybe UnicodeWidthStr is a better option

// Off by default: a shell prompt routinely sets the title to a username,
// hostname, and absolute path, which would pin every baseline to one
// machine and make it change on `cd` while the screen stayed the same.
let title = include_title.then(|| title_of(session)).flatten();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: might have a weird race case here where we release the title lock on the session, the pty continues, and then we snapshot the grid. should flag & address in a follow up pr

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants