Skip to content

feat: the terminal says which workspace a pane is - #358

Merged
blooop merged 7 commits into
mainfrom
tabname
Aug 22, 2026
Merged

feat: the terminal says which workspace a pane is#358
blooop merged 7 commits into
mainfrom
tabname

Conversation

@blooop

@blooop blooop commented Aug 22, 2026

Copy link
Copy Markdown
Owner

What

dl writes the workspace id to the terminal as an OSC 2 title just before the
session takes over, and aid starts claude with
CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 so that name is the one that stands.
DEVLAUNCH_NO_TITLE=1 turns the whole thing off.

In zellij that shows up as <session> | devlaunch-main-zovomobo; in a bare kitty
or xterm it is the window title; in tmux it is the pane title.

Why an escape sequence rather than zellij action rename-tab

Because the escape reaches every multiplexer at once and a command reaches one.
dl writes to the stream it was handed and whoever owns that pty parses it, so
zellij, tmux, byobu-on-tmux and a plain terminal are all served without dl
detecting which it is inside.

Two things were measured rather than assumed, against zellij 0.45:

  • A zellij tab name cannot be set by an escape sequence at all — only
    rename-tab or a plugin moves it. So the pane title is the only lever that
    reaches the outer tab bar, which is what this uses.
  • An OSC 2 written inside a pane does become the pane title and propagate,
    and zellij publishes it outward as <session> | <pane title>.

Why claude's own title has to be turned off

A terminal title has one value and the last writer sets it. claude writes one
continuously from its own read of what the session is doing, so the two are not
two signals but one contest that claude wins within a second. What claude is
doing is already on screen inside the pane; which workspace the pane is is not
otherwise anywhere.

Scoped to aid, which is what decided to start claude — a dl <ws> -- claude ...
somebody typed themselves is their command and not aid's to rewrite. It needed no
new plumbing: aid's agent table is already an env prefix on a payload that runs
under bash -lc, so this is one more entry beside the IS_SANDBOX=1 already
there, and no host variable is forwarded.

Two decisions worth review

  • stderr, not stdout. stdout is parsed by the completion machinery and by
    wf, so escapes there could corrupt what they read. The tty guard is on stderr
    for the same reason, which also means dl <ws> -- make test > log keeps its
    title.
  • The workspace id, not the spec. It exists for every launch where the spec
    does not — a bare owner/repo still has its branch unresolved at the handover,
    and ./some/dir is not a spec at all — and it is already the container
    hostname, so dl's title and the user@host a prompt paints over it are the same
    string. The cost is that it drops the owner and spends columns on the hash tail.

Tests

  • Unit: the OSC shape, both off-switches, control stripping (including DEL and
    the 8-bit String Terminator).
  • Ordering: the notice is said first at the handover, in front of the dotfiles
    refresh — after the session starts this process may not print for hours.
  • Rendering: this is the one notice with no line, so no escape can leak into a
    collected report.
  • On a real pty (aid/tests/interactive.rs): the escape really comes out of
    the shipped binary, and DEVLAUNCH_NO_TITLE=1 really silences it. The off-test
    was mutation-checked by breaking the guard — it fails, so it is not vacuous.

Self-review

Three follow-up commits, all from the adversarial pass: one dead clause in the
control filter (char::is_control already covers U+007F, verified by compiling
it), and two places where my own comments credited the wrong reason the title
stays short — the 47-character derivation bounds only the owner/repo@branch arm;
devpod's 48-character name limit bounds the rest.

Attacked and found solid: the background boot in aid (its stderr is a log file,
so no stray title), the notice-collecting say_launch path (only carries target
resolution, never a handover), and Host construction (one production call site,
so the feature cannot be silently off).

🤖 Generated with Claude Code

Summary by Sourcery

Identify each launched workspace in terminal and multiplexer titles while keeping the behavior safe, opt-outable, and stable during Claude sessions.

New Features:

  • Name launched terminals and multiplexer panes after their workspace IDs, with an environment variable to disable the behavior.
  • Prevent Claude Code from overwriting workspace titles when launched through aid.

Enhancements:

  • Emit titles safely to stderr only when attached to a terminal, sanitize control characters, preserve launch ordering, and keep titles out of rendered reports.

Build:

  • Bump the project version to 0.7.0.

Documentation:

  • Document terminal naming behavior, supported multiplexer limitations, title overrides, and the DEVLAUNCH_NO_TITLE setting.

Tests:

  • Add unit, ordering, rendering, and real-PTY coverage for terminal titles and their opt-out behavior.
  • Update agent command expectations to include Claude's terminal-title environment setting.

Release 0.7.0 is in this PR

rust/Cargo.toml goes 0.6.1 → 0.7.0 (minor: this adds a feature), with the
Cargo.lock refresh and the ## [0.7.0] changelog heading Auto-publish needs
to build release notes from. Merging this to main publishes 0.7.0 to PyPI
and conda and tags v0.7.0 — the workflow triggers on a version change between a
commit and its parent, so there is nothing further to run by hand.

The bump is its own commit (Release 0.7.0), so dropping it and cutting the
release separately is a one-commit revert if you would rather keep this PR to the
feature.

origin/main was merged in to clear a CHANGELOG.md conflict — main released
0.6.1 while this was in progress. Both entries survive; nothing was overwritten.

blooop added 4 commits August 22, 2026 17:12
dl writes the workspace id as an OSC 2 title just before the session takes
the terminal, and aid starts claude with CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1
so that name is the one that stands. DEVLAUNCH_NO_TITLE=1 turns it off.

One escape sequence to stderr rather than a multiplexer command, so zellij,
tmux, byobu and a bare terminal are all served without dl detecting which it
is in. stderr because stdout is parsed by the completion machinery and by wf,
and the tty guard is on the same stream it writes to.
`*ch != '\u{7f}'` was dead: char::is_control() is the Cc category, which
contains U+007F, verified by compiling it rather than by reading the docs.
No behaviour changes, so there is no failing test to show; the DEL and
U+009C cases the clause was nominally about are now asserted instead, so the
narrower filter is covered rather than believed.
…where

Both were wrong about *why* the code is safe, which is the kind of comment
that survives the thing it described.

The length note credited WorkspaceId's 47-character cap, but only the triple
arm derives its id there; a bare name and a path leaf arrive as the raw spec
and a directory basename, which this crate never shortens. What bounds those
is devpod refusing to create or report a name over 48 characters.

The sanitizer's note claimed the title is written before the refusal that
would catch an escaped spec. It is not: an unknown name is refused in stage
one, before the handover, so the injection it described is not reachable. The
filter is defence at the boundary the bytes are formed at, which is worth
having and worth not overselling.

Comments only, so there is no test that could go red on either.
Same defect as the code comments one commit back, in the two places a reader
would actually meet it: the 47-character derivation bounds only the
owner/repo@branch arm, and devpod's 48-character name limit is what bounds the
rest. Docs only.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @blooop, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements workspace-aware terminal titling for devlaunch sessions, including an OSC 2-based title written to stderr before handover, a host/env-driven opt-out switch, sanitization and tty guards, and adjusts aid’s Claude agent to stop competing terminal titles, with tests and docs updated accordingly.

Sequence diagram for workspace terminal titling at handover

sequenceDiagram
    participant User
    participant dl
    participant Saying
    participant Terminal
    participant Session

    User->>dl: launch workspace
    dl->>dl: TerminalTitle::from_host(host, workspace_id)
    dl->>Saying: say(TerminalTitle)
    alt stderr is a terminal and DEVLAUNCH_NO_TITLE is not enabled
        Saying->>Terminal: write OSC 2 to stderr
        Saying->>Terminal: flush()
        Terminal-->>Terminal: update pane or window title
    else disabled or stderr is not a terminal
        Saying-->>Terminal: no title bytes
    end
    dl->>Session: hand over terminal
Loading

Sequence diagram for aid disabling Claude terminal titles

sequenceDiagram
    participant aid
    participant Bash
    participant Claude
    participant Terminal

    aid->>aid: build_agent_command()
    aid->>Bash: run CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 claude
    Bash->>Claude: start Claude
    Claude-->>Terminal: render session without competing title updates
    Note over Terminal: workspace title written by dl remains visible
Loading

File-Level Changes

Change Details Files
Add workspace-based terminal title emission in devlaunch-core and dl, driven by host env and stderr TTY detection, with sanitization and notice plumbing.
  • Introduce DEVLAUNCH_NO_TITLE env constant and Host.no_title/stderr_tty fields to track opt-out and terminal availability.
  • Add TerminalTitle enum plus from_host/osc helpers and sanitize_title filter to form a safe OSC 2 sequence from workspace IDs.
  • Emit a LaunchNotice::TerminalTitle before attaching to a running workspace, regardless of on/off, so sinks decide whether to write.
  • Update dl’s Notices implementation (Saying) to handle TerminalTitle specially, writing raw OSC bytes to stderr without a newline and flushing them, while keeping launch_notice/launch_notices free of escapes.
  • Extend tests in devlaunch-core and dl to cover OSC shape, env semantics ("no" vocabulary), sanitization (controls, DEL, 8‑bit terminator), tty behavior, and ordering (title first in handover).
rust/devlaunch-core/src/flows/launch.rs
rust/dl/src/render.rs
rust/dl/src/cli.rs
rust/devlaunch-core/src/flows/launch.rs
Disable Claude’s own terminal title in aid so devlaunch’s workspace title remains visible, and reflect this in tests.
  • Extend agent env for the claude agent to include CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 alongside IS_SANDBOX=1.
  • Keep this env change scoped to aid-driven launches rather than generic dl commands, relying on the existing agent env prefix plumbing.
  • Update rewrite/interactive tests to expect the new env var in constructed devpod ssh commands and aid->dl logs.
rust/aid/src/rewrite.rs
rust/aid/tests/interactive.rs
rust/aid/tests/rewrite.rs
Document the new terminal naming behavior, env toggle, and multiplexer-specific considerations in README and CHANGELOG.
  • Add README section describing workspace-id-based OSC 2 titles, DEVLAUNCH_NO_TITLE=1, stderr vs stdout behavior, and overwriting behavior (shell prompts, Claude).
  • Note tmux configuration requirements, zellij tab vs pane name behavior, and GNU screen limitations.
  • Add CHANGELOG entry under Unreleased summarizing the terminal naming feature and Claude title disablement via aid.
  • Extend environment variable reference table with DEVLAUNCH_NO_TITLE in README and dl CLI help text.
CHANGELOG.md
README.md
rust/dl/src/cli.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.52066% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.03%. Comparing base (1e5c602) to head (8976829).

Files with missing lines Patch % Lines
rust/dl/src/render.rs 88.46% 3 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.40% <97.52%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 95.40% <97.52%> (+<0.01%) ⬆️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

TerminalTitle and the LaunchNotice variant carrying it are public surface, so
the committed snapshot no longer matched and the job failed as designed. The
snapshot is hand-written rather than regenerated: cargo-public-api needs a
nightly toolchain for its rustdoc-JSON backend and this container has only the
stable pin, so the job itself is what confirms the entries are right.
@blooop
blooop merged commit db3bb93 into main Aug 22, 2026
14 checks passed
@blooop
blooop deleted the tabname branch August 22, 2026 17:35

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This was generated by AI during review.

Retrospective wf-review. #358 and #371 are two iterations on one surface ~1h20m
apart, so they were reviewed together against current main (a5f7ed8) and the
consolidated report is on #371:
#371 (review)

Most of what #358 introduced was superseded or corrected by #371 and is not restated
here. Three things attributable to #358 still stand on main:

  • The escape-safety design holds. sanitize_title (rust/devlaunch-core/src/flows/launch.rs:1837)
    drops the whole Cc category, so ESC, BEL, DEL and the 8-bit ST cannot terminate the
    OSC early; the stderr-only + stderr_tty guard keeps the bytes out of piped output;
    and LaunchNotice::TerminalTitle correctly renders to no line
    (rust/dl/src/render.rs:1676) so nothing leaks into a collected report. Mutating the
    filter to a no-op reddens two tests, and mutating \x1b]2; to \x1b]0; reddens five
    unit tests plus the real-pty test. This was the likeliest place for a real defect and
    it is clean.

  • #358-specific, non-blocking: the title is never restored. attach_workspace
    (launch.rs:1903) says the notice unconditionally, ahead of workspace_ssh, and
    nothing writes a title on the way out. A refused session leaves the host terminal
    named after a workspace it never entered, and every one-shot dl <ws> -- cmd — which
    is every aid launch — leaves the tab reading the workspace name after dl exits.
    Fix: bracket the session with XTWINOPS, CSI 22;2t before the OSC and CSI 23;2t
    after; an empty OSC 2 would blank the name rather than restore it, which the code
    already correctly refuses to do.

  • #358-specific, non-blocking: TerminalTitle::Write(String) (launch.rs:1792) is
    a public tuple variant holding raw bytes that dl's sink writes verbatim to stderr
    (render.rs:1711). It is in the frozen public API
    (devlaunch-core/public-api.rest.txt:983-995), so the sanitising invariant is not
    enforced by the type. A private field plus a constructor would make it unskippable.

aid's CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 (rust/aid/src/rewrite.rs:73) survives
intact and interacts correctly with #371's PS1 half: a bash -lc one-shot never
matches case $- in *i*, so the two halves cannot contend.

Verdict: Comment. No blocking finding is attributable to #358 on current main;
the two blocking findings on this surface belong to #371 and are enumerated there.

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.

1 participant