Skip to content

Make Claude Code terminal title more useful#134

Draft
samestep wants to merge 1 commit into
mainfrom
claude-title
Draft

Make Claude Code terminal title more useful#134
samestep wants to merge 1 commit into
mainfrom
claude-title

Conversation

@samestep

Copy link
Copy Markdown
Owner

Not tested yet.

Expand to see an /export of the Claude Code conversation that generated this PR.
╭─── Claude Code v2.1.143 ─────────────────────────────────────────────────────╮
│                                             │ Tips for getting started       │
│              Welcome back Sam!              │ Run /init to create a CLAUDE.… │
│                                             │ ────────────────────────────── │
│                   ▐▛███▜▌                   │ What's new                     │
│                  ▝▜█████▛▘                  │ Fixed the Bash tool returning… │
│                    ▘▘ ▝▝                    │ Pinned background sessions (`… │
│                                             │ Renamed `/simplify` to `/code… │
│   Opus 4.7 · Claude Team · sunshine-group   │ /release-notes for more        │
│            ~/github/openai/codex            │                                │
╰──────────────────────────────────────────────────────────────────────────────╯

❯ I'm switching over from Codex to Claude Code. Not long before switching, one  
  thing I grew to appreciate about Codex is that, if I have a lot of tabs in    
  Ghostty, it's very easy to tell at a glance which Codex tabs are still        
  working, and which are done: the working ones use Unicode characters to do a  
  sort of classic spin animation, and the waiting ones just show the title of   
  the directory. This can also be customized using Codex's `tui.terminal_title` 
   config setting: the specific one for this "working/waiting" indicator is     
  called `spinner`.                                                             
                                                                                
  At first I thought Claude Code had a similar feature: when I'm in a           
  conversation, if I look at the terminal/tab title, I see that it shows an     
  asterisk-looking character (I assume meant to approximate the Anthropic logo) 
   when it's not doing work, and toggles between two different dot characters   
  (one left, one right) when it is doing work. Great, same effect as Codex.     
  Except, apparently not really? Like, usually there's that distinction, but    
  also, sometimes Claude finishes doing _part_ of its work, and then the rest   
  of the work is just waiting for a background shell process to finish running. 
   In that case the icon just switches back to the asterisk-looking "done"      
  icon. And then later, when the process finishes, Claude switches it back to   
  the dots!                                                                     
                                                                                
  This is extremely annoying, because it means I can't actually use the         
  terminal/tab title to see at a glance which terminals need my attention in a  
  sea of different terminals in Ghostty. Is there any way to configure Claude   
  Code to get the behavior I actually want, i.e. the behavior Codex has where   
  it only gets rid of the spinner when it's really actually done and isn't      
  still doing more stuff?                                                       
                                                                                
  (Also, as an aside, I've noticed that when Claude is showing the dots that    
  flip back and forth between left and right, it _only_ does that when it's the 
   focused terminal tab. When a different terminal tab has focused, it stops    
  updating the terminal title, and instead it just stays on whichever dot it    
  was on before. This is in contrast to Codex which shows the spinner spinning  
  regardless.)                                                                  

● claude-code-guide(Claude Code terminal title behavior)
  ⎿  Done (8 tool uses · 33.6k tokens · 46s)
  (ctrl+o to expand)

● Short answer: no, Claude Code has no first-class config for this — there's
  nothing like Codex's tui.terminal_title. The "asterisk vs. dots" behavior is
  hardcoded, and it tracks the foreground turn state, not background shells.
  Your observation that the dots freeze when the tab is unfocused is also
  hardcoded (terminal title updates only fire on the focused TTY). GitHub issue
  #13847 (https://github.com/anthropics/claude-code/issues/13847) asked for
  related visibility and was closed as not planned.

  The least-bad workaround is to write your own title from hooks using OSC
  escape sequences. Claude Code's hook schema supports a terminalSequence field
  (added in v2.1.141+), and you can also just emit printf '\033]0;…\007' from a
  regular hook command. The shape would be:

  - PostToolUse on Bash with run_in_background: true → write a "working (bg)"
  title and stash the shell ID in a state file.
  - Stop (fires when Claude's turn ends) → check the state file; if any tracked
  background shells are still alive (ps -p $pid), keep the "working" title
  instead of letting Claude's idle title win. The hook is what runs last, so its
   OSC sequence sticks.
  - A small poller (or asyncRewake) to refresh the title when those PIDs exit.

  This still won't fix the unfocused-tab freeze, since that's a property of when
   the harness writes to the TTY at all — but if your hook itself periodically
  writes via OSC from a background script, the title will keep updating
  regardless of focus. The catch is that Ghostty (like most terminals) only
  renders the title of the active tab's most recent OSC write, so updates from
  an unfocused tab do land, they just don't animate visually until you switch.

  If you'd like, I can sketch out a concrete settings.json hook block plus the
  small shell helper that tracks background shells and rewrites the title — say
  the word and I'll wire it up.

✻ Cogitated for 1m 24s

❯ Hmm OK. This mostly makes sense, except for one sentence you wrote:           
                                                                                
  > The catch is that Ghostty (like most terminals) only renders the title of   
  the active tab's most recent OSC write, so updates from an unfocused tab do   
  land, they just don't animate visually until you switch.                      
                                                                                
  I don't understand this at all. What the heck does this mean? I've already    
  told you that with Codex, I see no difference in terminal tab titles          
  continuously updating regardless of focus. Could you clarify?                 
                                                                                
  But anyways yes, I'd love to see an implementation of your idea. You can find 
   my whole config in `~/github/samestep/env`; probably a good place to start   
  would be `~/github/samestep/env/claude/yolo.json` which is what               
  `~/.claude/settings.json` is a symlink to in this VM.                         

● You're right — that sentence was wrong, ignore it. Terminals (Ghostty
  included) render OSC title updates from any tab live, regardless of focus;
  that's exactly why Codex's spinner keeps spinning in unfocused tabs. The
  unfocused freeze you see in Claude Code is just Claude Code itself choosing
  not to emit OSC writes when its TTY isn't focused. If a hook (or a sidecar
  shell loop) emits the OSC sequences, those will update unfocused tabs the same
   way Codex's do.

  Let me look at your config to wire this up.

  Listed 1 directory (ctrl+o to expand)

● Bash(ls -la /nix/store/jys880wsw2l3m2m3y5qpqn6d8blj0i3z-home-manager-files/.cl
      aude/settings.json 2>&1; echo "---"; readlink -f ~/.claude/settings.json)
  ⎿  lrwxrwxrwx 1 root root 56 Jan  1  1970 /nix/store/jys880wsw2l3m2m3y5qp
     qn6d8blj0i3z-home-manager-files/.claude/settings.json -> /nix/store/n6
     sik2rdgd9v4kn5df3d0f0mr3x2kcgy-hm_yolo.json
     … +2 lines (ctrl+o to expand)

  Read 2 files (ctrl+o to expand)

● Good, your config is minimal — that makes this easy.

  Quick correction first: my "Ghostty only renders the active tab's title" claim
   was just wrong. Terminals render OSC title updates from any PTY live; that's
  how Codex's spinner stays animated in unfocused tabs. The Claude Code freeze
  is just Claude Code itself electing not to emit OSC writes from an unfocused
  TTY. If we drive the title from a sidecar that doesn't care about focus, the
  unfocused-tab problem disappears too.

  So here's the plan I'm going to wire up:

  1. A small daemon shell script that loops at ~10 Hz, decides "working vs.
  idle," and emits an OSC \033]0;...\007 to your terminal each tick.
  2. Three hooks that flip a busy flag the daemon reads:
    - SessionStart spawns the daemon and stashes the session's TTY path + Claude
   Code PID
    - UserPromptSubmit sets busy
    - Stop clears busy
    - SessionEnd kills the daemon
  3. The daemon also independently scans /proc for live bash/sh descendants of
  the Claude Code process — that's how it keeps "working" displayed while a
  run_in_background shell is still alive, even though Stop already fired.

  Let me check a couple of environment details before writing, and verify Claude
   Code's hook event names match what I think they are.

● Bash(echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"; ls -ld
      "${XDG_RUNTIME_DIR:-/tmp}" 2>/dev/null; echo "---"; tty; echo "---";
      readlink /proc/$PPID/fd/0; readlink /proc/$…)
  ⎿  XDG_RUNTIME_DIR=/run/user/1000
     drwx------ 4 agent-amd64 agent-amd64 160 May 22 23:51 /run/user/1000
     ---
     … +8 lines (ctrl+o to expand)

  Read 1 file, listed 2 directories (ctrl+o to expand)

● Bash(readlink -f /home/agent-amd64/github/samestep/env/result 2>/dev/null;
      echo "---"; nix-store --query --references
      /nix/store/n6sik2rdgd9v4kn5df3d0f0mr3x2kcgy-hm_…)
  ⎿  /nix/store/ywamk27jzb3mbzpjsjig31yvrq2z8aij-home-manager-generation
     ---
     ---
     … +7 lines (ctrl+o to expand)

● Bash(cat /nix/store/n6sik2rdgd9v4kn5df3d0f0mr3x2kcgy-hm_yolo.json 2>/dev/null;
       echo "---"; diff <(cat
      /nix/store/n6sik2rdgd9v4kn5df3d0f0mr3x2kcgy-hm_yolo.json) /home…)
  ⎿  {
       "permissions": {
         "defaultMode": "bypassPermissions"
     … +6 lines (ctrl+o to expand)

● Bash(readlink -f ~/.claude/settings.json; echo "---"; ls -la
      /nix/store/n6sik2rdgd9v4kn5df3d0f0mr3x2kcgy-hm_yolo.json)
  ⎿  /home/agent-amd64/github/samestep/env/claude/yolo.json
     ---
     lrwxrwxrwx 1 root root 54 Jan  1  1970 /nix/store/n6sik2rdgd9v4kn5df3d
     … +2 lines (ctrl+o to expand)

● Bash(which claude && readlink -f "$(which claude)"; echo "---"; ps -ef | grep
      -i claude | grep -v grep)
  ⎿  /home/agent-amd64/.nix-profile/bin/claude
     /nix/store/mhbxbfsjn9hz21244ckgxpd3sg0lg37v-claude-code-2.1.143/bin/cl
     aude
     … +2 lines (ctrl+o to expand)

● Bash(cat /proc/141692/comm; echo "---"; tr '\0' ' ' < /proc/141692/cmdline;
      echo; echo "---"; ls /proc/141692/fd/0 /proc/141692/fd/1 /proc/141692/fd/2
       -la; echo "---…)
  ⎿  .claude-wrapped
     ---
     claude
     … +19 lines (ctrl+o to expand)

● Quick aside before I write anything: I notice your env repo already has
  bin/title.py. Let me peek at it to see if I'm building on top of something you
   have, and also confirm how paths flow into the json config across platforms.

  Read 1 file, listed 1 directory (ctrl+o to expand)

● Plan: write a single Python helper claude-title (matches your title.py style)
  with two modes — daemon <state-dir> runs the spinner loop, hook <event> is
  what yolo.json calls. I'll also patch util.nix to install it and yolo.json to
  register the hooks.

  The bg-shell detection works by snapshotting Claude Code's main PID at
  SessionStart and having the daemon scan /proc for shell descendants of that
  PID — when a run_in_background Bash leaves a bash alive after Stop, the daemon
   sees it and keeps spinning.
● Write(~/github/samestep/env/bin/claude-title.py)
  ⎿  Wrote 287 lines to ../../samestep/env/bin/claude-title.py
      1 import argparse
      2 import json
      3 import os
      4 import pathlib
      5 import signal
      6 import subprocess
      7 import sys
      8 import time
      9 
     … +283 lines (ctrl+o to expand)

● Update(~/github/samestep/env/util.nix)
  ⎿  Added 1 line
      24        name = "samestep";
      25        paths = [
      26          (pkgs.writeTextDir "template.nix" (builtins.readFile ./
          template.nix))
      27 +        (pkgs.writers.writePython3Bin "claude-title" { } ./bin/
         +claude-title.py)                                               
      28          (pkgs.writers.writePython3Bin "codex" {
      29            makeWrapperArgs = [
      30              "--prefix"

● Write(~/github/samestep/env/claude/yolo.json)
  ⎿  Added 31 lines, removed 1 line
       3      "defaultMode": "bypassPermissions"
       4    },
       5    "autoMemoryEnabled": false,
       6 -  "skipDangerousModePermissionPrompt": true                    
       6 +  "skipDangerousModePermissionPrompt": true,                   
       7 +  "hooks": {                                                   
       8 +    "SessionStart": [                                          
       9 +      {                                                        
      10 +        "hooks": [                                             
      11 +          { "type": "command", "command": "claude-title hook se
         +ssion-start" }                                                 
      12 +        ]                                                      
      13 +      }                                                        
      14 +    ],                                                         
      15 +    "UserPromptSubmit": [                                      
      16 +      {                                                        
      17 +        "hooks": [                                             
      18 +          { "type": "command", "command": "claude-title hook us
         +er-prompt-submit" }                                            
      19 +        ]                                                      
      20 +      }                                                        
      21 +    ],                                                         
      22 +    "Stop": [                                                  
      23 +      {                                                        
      24 +        "hooks": [                                             
      25 +          { "type": "command", "command": "claude-title hook st
         +op" }                                                          
      26 +        ]                                                      
      27 +      }                                                        
      28 +    ],                                                         
      29 +    "SessionEnd": [                                            
      30 +      {                                                        
      31 +        "hooks": [                                             
      32 +          { "type": "command", "command": "claude-title hook se
         +ssion-end" }                                                   
      33 +        ]                                                      
      34 +      }                                                        
      35 +    ]                                                          
      36 +  }                                                            
      37  }

● Let me sanity-check the script and confirm it parses, plus quickly verify the
  proc-walking logic works against the live Claude Code process.

● Bash(python3 /home/agent-amd64/github/samestep/env/bin/claude-title.py --help
      && echo OK)
  ⎿  usage: claude-title.py [-h] {daemon,hook} ...

     Claude Code terminal title spinner
     … +7 lines (ctrl+o to expand)

● Bash(python3 - <<'EOF'
      import sys…)
  ⎿  find_claude_pid (from our PPID): 141692
     tty for 141692: /dev/pts/0
     descendants_have_shell(141692, {}): True
     … +2 lines (ctrl+o to expand)

  Read 1 file (ctrl+o to expand)

● All wired up. Files created/changed:

  - bin/claude-title.py — single helper with two modes:
    - claude-title daemon <state-dir> — runs the spinner loop, writes OSC
  \033]0;...\007 to your TTY every 80ms (matching Codex's dots cadence). Busy
  iff claude_busy flag exists or the Claude Code process has any shell
  descendant alive.
    - claude-title hook {session-start,user-prompt-submit,stop,session-end} —
  reads the hook JSON from stdin, flips state, spawns/kills the daemon.
  - util.nix — adds (pkgs.writers.writePython3Bin "claude-title" {} 
  ./bin/claude-title.py) so it lands on PATH.
  - claude/yolo.json — registers the four hooks.

  How the bg-shell case is handled: Stop clears claude_busy, but the daemon also
   scans /proc for bash/sh/zsh/etc. descendants of the captured Claude Code PID.
   A run_in_background: true Bash leaves its bash running after Stop, so the
  daemon keeps spinning until that shell exits. Verified against your live
  claude process (PID 141692) — find_claude_pid and find_tty_for_pid both find
  it correctly.

  To activate: run your hm rebuild (so claude-title lands on PATH), then start a
   new Claude Code session — SessionStart will spawn the daemon and begin
  driving the title. Existing sessions won't pick it up until restarted.

  Caveats to test for:
  1. Flicker. Claude Code still emits its own OSC writes; my daemon writes at
  12.5 Hz so it dominates but you may see brief glitches. If it's noticeable,
  bump TICK_SECONDS down (e.g. 0.05) or we can dig for a way to silence Claude's
   writes.
  2. End-of-turn flicker. The Stop hook itself is a bash process that's briefly
  a descendant of Claude. There's a ~one-tick window where the daemon may see it
   and show "working" for 80ms after Stop. Probably imperceptible.
  3. Unclean exits. If Claude Code is killed without firing SessionEnd, the
  daemon detects via kill(claude_pid, 0) and self-exits within one tick; state
  dir is left behind in $XDG_RUNTIME_DIR/claude-title/<session-id>/ and gets
  reaped on reboot.

  Want me to also wire it into safe.json, or leave that alone for now?

● Ran 1 stop hook (ctrl+o to expand)
  ⎿  Stop hook error: Failed with non-blocking status code: /bin/sh: 1:
  claude-title: not found

✻ Churned for 8m 12s

※ recap: Goal: make Claude Code's terminal title stay "working" while background shells are still alive, like Codex does. Wrote a `claude-title` Python daemon plus hooks in `yolo.json` and `util.nix`. Next: run your `hm` rebuild and start a fresh Claude session to test. (disable recaps in /config)
  ⎿  SessionStart:resume hook error                                                                                                                    ⎿  Failed with non-blocking status code: /bin/sh: 1: claude-title: not found

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