Dynamic terminal tab title showing current task context

Resolved 💬 4 comments Opened Mar 13, 2026 by dakevinci Closed Mar 22, 2026

Feature Request

Current behavior: Terminal tab title shows static "Claude Code (node)" regardless of what's being worked on.

Desired behavior: Claude Code dynamically updates the terminal tab title to reflect the current working context — e.g., the project name, active file, or a short description of the current task.

Why this matters

When running multiple Claude Code sessions across different projects/terminals, they all look identical in the tab bar. You have to click into each one to figure out which is which. A dynamic title like Claude Code — qinnovate/blog or Claude Code — fixing BCI toggle would make multitasking significantly easier.

Security-hardened implementation proposal

Terminal title updates are a known attack surface. Malicious escape sequences injected into terminal titles have led to CVEs in terminal emulators (arbitrary code execution via crafted OSC sequences). The implementation must treat this as a security boundary.

Architecture

  1. Internal-only rendering. Title updates must go through Claude Code's own Node.js process via process.stdout.write() with a compile-time constant OSC prefix and suffix. Never shell out. Never use exec(), spawn({ shell: true }), or template literals with dynamic content near escape sequences.
  1. Strict allowlist sanitization. All title content — regardless of source — must pass through a sanitizer that:
  • Strips ALL control characters (U+0000–U+001F, U+007F, U+0080–U+009F) — prevents nested escape sequence injection
  • Strips ALL OSC/CSI/DCS/APC sequences (regex for \x1b followed by sequence initiators)
  • Allowlists only printable ASCII + common Unicode (letters, numbers, spaces, hyphens, dots, slashes)
  • Enforces a hard max length (e.g., 80 chars) to prevent buffer-based attacks on older terminal emulators
  • Returns a static fallback string ("Claude Code") if sanitization produces an empty result
  1. No user-controlled or file-derived content without sanitization. Even project directory names from the filesystem are untrusted — a malicious repo name could inject sequences if passed raw. Use path.basename() then sanitize.
  1. No model-generated content in title by default. If task summary is implemented:
  • Opt-in via settings (disabled by default)
  • Rate-limited (max 1 update per 5 seconds to prevent flickering attacks)
  • Treated as fully untrusted input (same sanitizer)
  • Truncated before sanitization to prevent DoS via large strings
  1. Terminal capability detection. Not all terminals support OSC. Detect via $TERM / $TERM_PROGRAM and only emit for known-safe terminals. Provide a setting to disable entirely.

Reference implementation (TypeScript)

import { basename } from 'node:path';

// Compile-time constants — never interpolated from user input
const OSC_SET_TITLE = '\x1b]0;';
const OSC_END = '\x07';
const MAX_TITLE_LENGTH = 80;
const FALLBACK_TITLE = 'Claude Code';

// Allowlist: printable ASCII + common Unicode letters/numbers
const ALLOWED_CHARS = /[^\p{L}\p{N}\s\-._\/\[\]()—:]/gu;

/**
 * Sanitize a string for safe use in terminal title.
 * Defense-in-depth: strip control chars, strip escape sequences,
 * allowlist remaining chars, enforce length.
 */
function sanitizeTitle(raw: string): string {
  let s = raw;

  // Layer 1: Strip all control characters (C0, DEL, C1)
  s = s.replace(/[\x00-\x1f\x7f\x80-\x9f]/g, '');

  // Layer 2: Strip any remaining ANSI/OSC/CSI sequences
  // (belt-and-suspenders after Layer 1 already removed \x1b)
  s = s.replace(/\x1b[\[\]PX^_].*?[\x07\x1b\\]/g, '');

  // Layer 3: Allowlist — remove anything not in the safe set
  s = s.replace(ALLOWED_CHARS, '');

  // Layer 4: Collapse whitespace, trim
  s = s.replace(/\s+/g, ' ').trim();

  // Layer 5: Enforce max length
  if (s.length > MAX_TITLE_LENGTH) {
    s = s.slice(0, MAX_TITLE_LENGTH);
  }

  return s || FALLBACK_TITLE;
}

/**
 * Set terminal title. Only called internally by Claude Code's
 * rendering layer — never exposed to plugins or user code.
 */
function setTerminalTitle(projectDir: string, branch?: string): void {
  const project = sanitizeTitle(basename(projectDir));
  const branchSafe = branch ? sanitizeTitle(branch) : '';

  let title = `Claude Code — ${project}`;
  if (branchSafe) {
    title += ` [${branchSafe}]`;
  }

  // Final sanitization of the composed string
  title = sanitizeTitle(title);

  // Write directly to stdout — no shell, no exec
  process.stdout.write(`${OSC_SET_TITLE}${title}${OSC_END}`);
}

Key security properties

| Layer | Threat | Mitigation |
|-------|--------|------------|
| Input | Malicious repo/branch names containing escape sequences | sanitizeTitle() strips all control chars and ANSI sequences |
| Input | Model-generated content with injection attempts | Same sanitizer, opt-in only, rate-limited |
| Composition | Nested sequences surviving individual sanitization | Final sanitization pass on composed title string |
| Output | Shell injection via exec()/spawn() | No shell involvement — direct process.stdout.write() only |
| Output | Buffer overflow on older terminals | Hard 80-char max length |
| Fallback | Empty/fully-stripped title | Returns static "Claude Code" |
| Scope | Function never exposed to plugins/hooks/user code | Internal rendering layer only |

Title content sources (in priority order)

  1. Project directory basename (always available, e.g., Claude Code — qinnovate)
  2. Git branch (if in a repo, e.g., Claude Code — qinnovate [main])
  3. Task summary (opt-in, model-generated, e.g., Claude Code — fixing BCI toggle)

Even just option 1 with the sanitization layer above would be a significant improvement over the static title.

Environment

  • Claude Code 2.1.74
  • macOS (iTerm2 + Terminal.app)

View original on GitHub ↗

This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗