Creative Commons Attribution-ShareAlike flurdy

Terminal workflow

Useful Kitty tab titles for Claude Code, Codex and Pi

Show which repository an agent is working in, what it is doing, and when it needs attention.

First published: June 2026. Last updated: 2nd September 2026.

I often have several terminal tabs running Claude Code and Codex. Default terminal titles do not make it obvious which agent owns a tab, which repository it is changing, or whether it is working, waiting for permission, or finished.

Claude Code and Codex expose lifecycle hooks, while Pi exposes an extension API. Each can drive a small title writer which uses Kitty remote control to update the current tab. A shell prompt hook restores the normal directory title after the agent exits.

The result

Portable baseline

C flurdy.com/docs ⚙ X dotfiles ❓ π agents/ABC-123 ✔

Extended setup in daily use

The later refinements replace the plain markers with Nerd Font repository glyphs, shorter names and richer task or state information.

Two real Kitty tabs using Nerd Font Git glyphs, short repository names and lifecycle emoji.

A compact title can contain:

  • the agent: C for Claude Code, X for Codex or π for Pi;
  • the Git repository, or current directory outside Git;
  • a shortened branch or task identifier; and
  • a state marker showing what the agent is doing.
State Marker
Session started🌱
Thinking or waiting for input💭
Using a tool⚙
Permission or answer required❓
Turn finished✔
Compacting context🧹

The exact labels and symbols are personal preference. The useful part is that the title changes with the agent lifecycle.

How it works

  1. Kitty listens on a local Unix socket for remote-control commands.
  2. Claude Code or Codex emits a lifecycle hook event.
  3. The hook passes JSON on standard input to a Bash script.
  4. The script derives a short repository and branch label.
  5. kitten @ set-tab-title updates the Kitty tab.
  6. The next Fish prompt restores the ordinary working-directory title.
Keep hook scripts quiet. Agent hooks may capture standard output. Redirect Kitty commands and diagnostics so title updates do not appear in the conversation.

Requirements

  • Kitty terminal;
  • Bash, Git and jq;
  • Claude Code, Codex, Pi, or any combination; and
  • optionally Fish for the shell-title reset shown below.

The example uses plain letters and standard Unicode symbols. My own titles also use Nerd Font Git and folder glyphs, but those should only be added when the configured terminal font supports them.

Configure Kitty remote control

Add a local socket to ~/.config/kitty/kitty.conf:

allow_remote_control socket-only
listen_on unix:@kitty

Restart Kitty after changing listen_on. Test the connection from a Kitty shell:

kitten @ set-tab-title "title test"

If that does not find the socket, specify it explicitly:

kitten @ --to unix:@kitty set-tab-title "title test"
Remote control is powerful. Use a local Unix socket and read Kitty's remote-control security documentation before allowing broader access.

Create a shared title script

Save this portable baseline as ~/.local/bin/kitty-agent-title:

Where should the script live? A single script in ~/.local/bin keeps the basic setup shared and avoids duplicating fixes. It also works naturally when that directory is already on PATH. Agent-owned locations are equally valid: ~/.claude/kitty-title.sh and ~/.codex/kitty-title.sh. Separate files become useful when Claude Code and Codex need different events or title logic.
#!/usr/bin/env bash

AGENT=${1:-agent}
PAYLOAD=$(cat)

json_value() {
  local key="$1"
  printf '%s' "$PAYLOAD" | jq -r --arg key "$key" \
    '.[$key] // empty' 2>/dev/null
}

EVENT=$(json_value hook_event_name)
CWD=$(json_value cwd)
[ -z "$CWD" ] && CWD="$PWD"

GIT_ROOT=$(git -C "$CWD" rev-parse --show-toplevel 2>/dev/null || true)
BRANCH=$(git -C "$CWD" branch --show-current 2>/dev/null || true)

if [ -n "$GIT_ROOT" ]; then
  REPO=$(basename "$GIT_ROOT")
else
  REPO=$(basename "$CWD")
fi

case "$BRANCH" in
  main|master|trunk|"") BRANCH="" ;;
  feature/*|fix/*|bugfix/*|chore/*) BRANCH=${BRANCH#*/} ;;
esac

case "$AGENT" in
  claude) LABEL="C $REPO" ;;
  codex)  LABEL="X $REPO" ;;
  *)      LABEL="$AGENT $REPO" ;;
esac

[ -n "$BRANCH" ] && LABEL="$LABEL/$BRANCH"

case "$EVENT" in
  SessionStart)                  MARK="🌱" ;;
  UserPromptSubmit|PostToolUse)  MARK="💭" ;;
  PreToolUse)                    MARK="⚙" ;;
  PermissionRequest)             MARK="❓" ;;
  Stop|SubagentStop)             MARK="✔" ;;
  PreCompact|PostCompact)        MARK="🧹" ;;
  *)                             MARK="" ;;
esac

TITLE="$LABEL"
[ -n "$MARK" ] && TITLE="$TITLE $MARK"

if command -v kitten >/dev/null 2>&1; then
  kitten @ set-tab-title "$TITLE" >/dev/null 2>&1 \
    || kitten @ --to unix:@kitty set-tab-title "$TITLE" >/dev/null 2>&1 \
    || true
fi

# Fallback to terminal title escape sequences.
(
  exec 2>/dev/null
  printf '\e]30;%s\a' "$TITLE" > /dev/tty || true
  printf '\e]2;%s\a' "$TITLE" > /dev/tty || true
) || true

Make it executable:

chmod +x ~/.local/bin/kitty-agent-title

If using separate agent scripts, make both executable and update the hook commands in the following sections:

chmod +x ~/.claude/kitty-title.sh ~/.codex/kitty-title.sh
Layout Hook command
Shared script for Claude Code ~/.local/bin/kitty-agent-title claude
Shared script for Codex ~/.local/bin/kitty-agent-title codex
Claude-specific script ~/.claude/kitty-title.sh
Codex-specific script ~/.codex/kitty-title.sh

The script deliberately ignores errors. A title failure must never interrupt an agent session.

Connect Claude Code hooks

Merge hooks like these into ~/.claude/settings.json. If that file already has a hooks object, add the event entries rather than replacing the whole file.

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.local/bin/kitty-agent-title claude"
          }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.local/bin/kitty-agent-title claude"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "~/.local/bin/kitty-agent-title claude"
          }
        ]
      }
    ],
    "PermissionRequest": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.local/bin/kitty-agent-title claude"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.local/bin/kitty-agent-title claude"
          }
        ]
      }
    ]
  }
}

Add PostToolUse and PreCompact in the same form if you want the finer state transitions shown earlier.

Connect Codex hooks

Codex reads user hooks from ~/.codex/hooks.json. Like Claude Code, it includes hook_event_name in the JSON payload, so each hook can call the same command:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "~/.local/bin/kitty-agent-title codex",
            "timeout": 5
          }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.local/bin/kitty-agent-title codex",
            "timeout": 5
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "~/.local/bin/kitty-agent-title codex",
            "timeout": 5
          }
        ]
      }
    ],
    "PermissionRequest": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "~/.local/bin/kitty-agent-title codex",
            "timeout": 5
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.local/bin/kitty-agent-title codex",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

Codex asks you to review new or changed command hooks. Use /hooks in the CLI to inspect and trust the definition. Matching hooks from different configuration layers are combined, so check for duplicates if the title updates more than expected.

Restore the shell title

Without a reset, the completed agent title remains after returning to the shell. Fish can restore a directory-based title whenever it displays a prompt.

Create ~/.config/fish/functions/update_kitty_tab_title.fish:

function update_kitty_tab_title \
    --description 'Reset Kitty tab title to the shell location' \
    --on-event fish_prompt

    status is-interactive; or return
    set -q KITTY_WINDOW_ID; or return

    set -l title (prompt_pwd)
    test -n "$title"; or set title (pwd)

    if type -q kitten
        kitten @ set-tab-title "" >/dev/null 2>&1
        or kitten @ --to unix:@kitty set-tab-title "" >/dev/null 2>&1
    end

    printf '\e]30;%s\a' "$title" >/dev/tty 2>/dev/null
    printf '\e]2;%s\a' "$title" >/dev/tty 2>/dev/null
end

Load it from ~/.config/fish/config.fish:

source ~/.config/fish/functions/update_kitty_tab_title.fish

Bash and Zsh can do the same from their prompt hooks. The important detail is that the shell should only reset the title when a prompt is shown, not while the foreground agent is running.

Clear the explicit tab title first. Calling set-tab-title with an empty title returns the tab to following the active terminal window title. Setting the directory itself as an explicit tab title looks identical locally, but prevents title escape sequences arriving over SSH from taking effect.

Further refinements

The separate Claude and Codex scripts this article grew from go further than the portable example. Each refinement below is independent, so add only the parts which match your workflow.

Nerd Fonts and emoji

The plain C and X agent markers work everywhere. My own titles use a Git branch glyph inside a repository and a folder glyph outside Git:

Example Kitty tabs with Git branch and folder Nerd Font glyphs, followed by start, thinking, tool, question, done, compact and subagent state symbols.
A browser-safe snapshot rendered from my local Nerd Font and emoji fonts.

The first two are private-use characters supplied by Nerd Fonts. You can use a fully patched programming font, or let Kitty map only the symbol code points to Symbols Nerd Font Mono. The bitmap above is used because a web browser cannot rely on Kitty's font mapping and may otherwise display empty boxes:

symbol_map U+E725 Symbols Nerd Font Mono
symbol_map U+F024B Symbols Nerd Font Mono

Nerd Font versions occasionally move glyphs between code points. Copy the actual symbols from a picker or your prompt configuration, and test them directly in Kitty before putting them in a hook.

I use ordinary Unicode emoji for the lifecycle states shown above. Emoji appearance depends on the fallback font and operating system, and some render wider than one terminal cell. If compact alignment matters more than colour, replace them with ASCII such as S, ..., *, ? and OK.

The richer repository label then starts like this:

GIT_MARK=$'\uE725'
FOLDER_MARK=$'\U000F024B'

if [ -n "$GIT_ROOT" ]; then
  MARK="$GIT_MARK"
else
  MARK="$FOLDER_MARK"
fi

case "$AGENT" in
  claude) LABEL="$MARK-$REPO" ;;
  codex)  LABEL="◈$MARK-$REPO" ;;
esac

Adjust the glyph baseline

Emoji and fallback glyphs may sit slightly low in the tab bar. Kitty can raise the font baseline globally:

font_size 12.0
modify_font baseline 1px

Reload Kitty after changing it. This affects text inside terminal windows as well as tab titles, so start with one pixel. Raising it further may centre the emoji while making ordinary text look too high. Tab-bar margins move the whole bar and do not correct the glyph baseline.

Use agent titles over SSH

A remote Claude Code or Codex process cannot connect to the Kitty Unix socket on the local machine. Ordinary terminal title escape sequences do cross an SSH pseudo-terminal, but Claude Code may immediately replace the window title with its own status.

Kitty remote-control commands can also travel inside the terminal stream. Permit only tab-title changes in the local kitty.conf:

allow_remote_control socket
remote_control_password "" set-tab-title
listen_on unix:@kitty

Reload Kitty, then create a new tab. Kitty applies a changed remote-control policy only to newly created terminal windows. The empty password is deliberately restricted to set-tab-title; do not replace it with unrestricted remote control.

Agent hooks may inherit SSH_TTY without having a controlling /dev/tty. In that case kitten @ fails even though the SSH terminal is known. Send the narrowly permitted command directly to $SSH_TTY:

set_kitty_tab_title() {
  if [ -n "$SSH_TTY" ]; then
    local command
    command=$(jq -cn --arg title "$TITLE" \
      '{cmd:"set-tab-title",
        version:[0,26,0],
        no_response:true,
        payload:{title:$title}}')
    printf '\eP@kitty-cmd%s\e\\' "$command" > "$SSH_TTY"
  elif command -v kitten >/dev/null 2>&1; then
    kitten @ set-tab-title "$TITLE" >/dev/null 2>&1 \
      || kitten @ --to unix:@kitty set-tab-title "$TITLE" \
           >/dev/null 2>&1
  fi
}

set_kitty_tab_title 2>/dev/null || true

The older protocol version is intentional: newer Kitty versions accept commands from older compatible clients. The no_response flag prevents the hook waiting for a reply on a terminal it does not read.

A small marker distinguishes remote agents without displaying long cloud hostnames. An optional alias is useful when several remote machines are open:

if [ -n "$SSH_TTY" ]; then
  REMOTE_PREFIX="🌐"
  [ -n "$KITTY_TITLE_HOST_ALIAS" ] \
    && REMOTE_PREFIX="$REMOTE_PREFIX$KITTY_TITLE_HOST_ALIAS/"
  LABEL="$REMOTE_PREFIX·$LABEL"
fi
🌐·GIT-letterbox·DONE
🌐rv/·GIT-letterbox·DONE

In Kitty, GIT and DONE can be the Nerd Font and emoji markers introduced above. Plain text is used here because private-use Nerd Font glyphs are not reliable in browsers.

Use a non-whitespace separator. My tab template displays {title.split()[0]}, so an ordinary space after the globe would leave only the globe visible. The middle dot matches the separator already used before the lifecycle state.

Use the repository name in Git worktrees

git rev-parse --show-toplevel returns the worktree directory. If worktrees have names such as worktree-fix-login, that is less useful than the shared repository name.

Claude Code can create an isolated worktree with claude -w. With no optional name, it generates a memorable but long name such as mellow-soaring-summit. You can supply a name with claude -w fix-login, but it still describes the worktree rather than the repository.

Source Example tab repository label
Worktree directory from bare claude -w mellow-soaring-summit
Named worktree from claude -w fix-login fix-login
Shared Git common directory flurdy.com-docs

Git's common directory points back to the main repository. Resolve it as an absolute path and use its parent directory:

GIT_ROOT=$(git -C "$CWD" rev-parse --show-toplevel 2>/dev/null || true)
GIT_COMMON_DIR=$(git -C "$CWD" \
  rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)

if [ -n "$GIT_ROOT" ]; then
  if [ -n "$GIT_COMMON_DIR" ]; then
    REPO=$(basename "$(dirname "$GIT_COMMON_DIR")")
  else
    REPO=$(basename "$GIT_ROOT")
  fi
else
  REPO=$(basename "$CWD")
fi

Shorten branch names

Full branch names quickly consume a narrow tab. I remove conventional prefixes, preserve useful pull-request and ticket IDs, and otherwise keep only the first part:

short_branch() {
  local branch="$1"
  branch="${branch#worktree-}"
  branch="${branch#feature/}"
  branch="${branch#fix/}"
  branch="${branch#bugfix/}"
  branch="${branch#chore/}"

  case "$branch" in
    main|master|trunk|"") printf '' ;;
    pr-[0-9]*|PR-[0-9]*)
      printf '%s' "$branch" | sed -E 's/^([Pp][Rr]-[0-9]+).*/\1/'
      ;;
    [A-Za-z][A-Za-z]-[0-9]*|[A-Za-z][A-Za-z][A-Za-z]-[0-9]*)
      printf '%s' "$branch" | sed -E 's/^([A-Za-z]+-[0-9]+).*/\1/'
      ;;
    *-*) printf '%s' "${branch%%-*}" ;;
    *) printf '%s' "$branch" ;;
  esac
}

BRANCH_SHORT=$(short_branch "$BRANCH")
[ -n "$BRANCH_SHORT" ] && LABEL="$LABEL/$BRANCH_SHORT"

Tune this to your own branch conventions. Aggressive shortening is only helpful when the retained prefix remains unambiguous.

Override awkward repository names

Compose wrappers, monorepos and checked-out customer projects may have directory names which are too long or misleading. An environment variable gives a session a deliberate display name:

Directory or repository name Useful alias
flurdy.com-compose flurdy.com
customer-platform-infrastructure-compose-development customer-platform
frontend-applications-monorepo frontend
REPO_DISPLAY=${KITTY_TITLE_REPO_ALIAS:-$REPO}
LABEL="$MARK-$REPO_DISPLAY"

For a persistent project-specific alias, add it to a direnv .envrc:

export KITTY_TITLE_REPO_ALIAS=flurdy.com

Approve the file once:

direnv allow

The variable is loaded when entering the directory and removed when leaving it. Claude Code and Codex then inherit the right alias without changing their launch commands.

For a one-off session, prefix the agent command directly:

KITTY_TITLE_REPO_ALIAS=flurdy.com codex
KITTY_TITLE_REPO_ALIAS=website claude

Add a persistent role to long-running sessions

Some agent sessions spend all day running a watch loop: monitoring releases, pull requests, CI or another recurring workflow. A repository title alone does not distinguish that tab from ordinary work in the same checkout. Keep the repository first, then add a short role before the lifecycle state:

-platform·🚢-releases·💭
-payments·🚢-releases·❔
-platform·👀-PRs·✅

The UserPromptSubmit payload contains the submitted prompt and a session ID. Match only deliberate command invocations, remember the role under /tmp, and read it again on every later hook event:

role_for_prompt() {
  case "$1" in
    /watch-release|/watch-release\ *|\$watch-release|\$watch-release\ *)
      printf '🚢-releases'
      ;;
    /watch-prs|/watch-prs\ *|\$watch-prs|\$watch-prs\ *)
      printf '👀-PRs'
      ;;
  esac
}

session_role() {
  [ -n "$SESSION_ID" ] || return

  local session_key role_file prompt role
  session_key=$(printf '%s' "$SESSION_ID" | tr -cd 'A-Za-z0-9._-')
  [ -n "$session_key" ] || return
  role_file="/tmp/kitty-role-$AGENT-$session_key"

  if [ "$EVENT" = "UserPromptSubmit" ]; then
    prompt=$(json_value prompt)
    role=$(role_for_prompt "$prompt")
    [ -n "$role" ] && printf '%s' "$role" > "$role_file"
  fi

  [ -r "$role_file" ] && sed -E 's/[[:space:]]+/-/g' "$role_file"
}

SESSION_ID=$(json_value session_id)
ROLE=$(session_role)
[ -n "$ROLE" ] && LABEL="$LABEL·$ROLE"

Put this after constructing the repository and branch label but before adding the event marker. Prefixing the state file with the agent name prevents Claude Code and Codex sessions from colliding. Exact command matching also avoids assigning a role merely because a normal prompt discusses /watch-release. Avoid whitespace in role labels when the tab template uses {title.split()[0]}, or the later lifecycle marker will be hidden.

These command names are only examples. Replace the mapping with the recurring commands or skills in your own setup. The important pattern is repository first, optional session role second, and transient lifecycle state last. My watch-release and watch-prs examples live in the public flurdy/agent-skills repository, but those skills are not required for this technique.

Show a Beads task on the main branch

A feature branch often supplies the task identifier. On main or master, my projects may instead use Beads, whose issue records live in .beads/issues.jsonl.

The advanced script looks for a task ID in the hook payload, then remembers one task per agent session under /tmp. If there is no explicit task, it uses the only issue currently in_progress. A closed task receives a leading ✓.

SESSION_ID=$(json_value session_id)
ISSUES_FILE="$GIT_ROOT/.beads/issues.jsonl"
STATE_FILE="/tmp/kitty-bead-session-$SESSION_ID"

evidence=$(printf '%s' "$PAYLOAD" | jq -r '
  [
    .prompt?,
    .tool_input.command?,
    .tool_input.args?,
    .tool_input.issue_id?,
    .tool_input.id?
  ] | map(select(type == "string")) | join("\n")
')

candidate=$(printf '%s' "$evidence" \
  | grep -Eo '[A-Za-z][A-Za-z0-9_-]*-[A-Za-z0-9]+' \
  | awk '!seen[$0]++' \
  | while read -r id; do
      jq -e --arg id "$id" 'select(.id == $id)' \
        "$ISSUES_FILE" >/dev/null && printf '%s\n' "$id"
    done \
  | head -1)

if [ -n "$candidate" ]; then
  printf '%s' "$candidate" > "$STATE_FILE"
elif [ -s "$STATE_FILE" ]; then
  candidate=$(cat "$STATE_FILE")
fi
This is deliberately optional. This part adds conventions specific to Beads and makes heavier use of the jq already used by the shared script. Validate candidate IDs against the issue file rather than displaying every ticket-shaped string found in a prompt or command.

Add more lifecycle states

The basic hooks cover the main working loop. Codex can also expose compaction and subagent activity. Add those events to ~/.codex/hooks.json using the same command handler, then extend the title mapping:

case "$EVENT" in
  PreCompact|PostCompact) TITLE="$LABEL·🧹" ;;
  SubagentStart)          TITLE="$LABEL·👥" ;;
  SubagentStop)           TITLE="$LABEL·✅" ;;
esac

Claude Code notifications can be noisy and may overwrite a more useful title after a turn. My Claude script ignores passive Notification events and distinguishes a direct question from a general permission request:

case "$EVENT" in
  Notification)
    exit 0
    ;;
  PermissionRequest)
    TOOL=$(json_value tool_name)
    case "$TOOL" in
      AskUserQuestion) TITLE="$LABEL·❔" ;;
      *)               TITLE="$LABEL·❓" ;;
    esac
    ;;
esac

Pi extension support

I also use these titles with Pi. That setup does not fit neatly into the Claude Code and Codex hook snippets above because Pi exposes an extension API instead of the same JSON hook files.

Rather than duplicate a longer Pi-specific implementation here, I keep the Pi Kitty tab-title extension with my other agent helpers. It listens to agent, turn and tool lifecycle events, derives the repository label in the same style, and sets a compact terminal title for the active session.

The extension also listens for the optional rpiv:ask-user:blocked event. While a structured ask_user_question questionnaire is waiting for human input, the title shows ❓; answering, cancelling, a tool failure or tool completion restores the normal lifecycle state. There is no hard dependency on the question package, so the title extension still works when that package is absent.

The question tool separately emits terminal BEL when it opens. Kitty may then prefix an unfocused tab with its configured bell_on_tab symbol. That is Kitty's attention marker, not corruption of the title or the Pi ❓ state.

Log hook events while tuning

Hook payloads and event timing are easiest to understand from a small append-only log. Keep it outside the repository and never let logging failure break the hook:

LOG_FILE=${KITTY_TITLE_LOG:-/tmp/kitty-agent-title.log}
TOOL=$(json_value tool_name)

{
  printf '%s agent=%s event=%s cwd=%s tool=%s\n' \
    "$(date +%Y-%m-%dT%H:%M:%S%z)" \
    "$AGENT" "$EVENT" "$CWD" "$TOOL"
} >> "$LOG_FILE" 2>/dev/null || true

Hook payloads can contain prompts, commands or file paths. Log only the fields you need, keep the file private, and remove or rotate it once the integration is stable.

My surrounding terminal configuration lives in flurdy/dotfiles. The title scripts there continue to evolve, while the shared script in this guide remains the easier starting point.

Troubleshooting

The title does not change
Test kitten @ --to unix:@kitty set-tab-title test, confirm the script is executable, then inspect the agent's hook configuration.
Codex says the hook needs review
Open /hooks and trust the exact command definition. Editing the hook changes its hash and requires another review.
The hook runs, but uses the wrong repository
Log the received cwd field. Agents may start from a subdirectory, and Git worktrees need extra handling if you want the shared repository name.
The agent prints control output
Redirect both standard output and standard error from every kitten invocation.
Symbols appear as boxes
Use plain ASCII markers or configure a font containing the selected Unicode or Nerd Font glyphs.
The title flashes briefly over SSH, then changes back
The agent is replacing the terminal window title. Use the restricted in-band Kitty command from the SSH refinement, and ensure the local shell reset clears rather than pins an explicit tab title.

References