Skip to content

fix: open Configure Status Line before an install, and sync hooks only from the config the status line loads - #807

Merged
sirmalloc merged 7 commits into
sirmalloc:mainfrom
eric-engberg:fix/statusline-command-detection
Nov 6, 2026
Merged

sirmalloc merged 7 commits into
sirmalloc:mainfrom
eric-engberg:fix/statusline-command-detection

Conversation

@eric-engberg

Copy link
Copy Markdown
Contributor

What

Two fixes to how the TUI uses Claude Code's status line command.

  • Configure Status Line opens before an install. Before, when the TUI didn't take the status line command for its own install, the menu was greyed out with "(install first)". That covers a wrapper script such as ~/.claude/ccstatusline-wrapper.sh, bunx ccstatusline, npx ccstatusline@latest without -y, and any padding other than 0. The menu now opens, so Git Cache TTL, Custom Command Cache TTL and Terminal Width Cache TTL can be edited. Only its Refresh Interval row still reads "(install first)" until ccstatusline is installed, because that value is written into Claude Code's own statusLine setting.
  • Saving a config the status line doesn't load leaves Claude Code's hooks alone. The managed Skills hooks now follow the config the installed status line command loads: the path after its --config, or the default config without one. Two kinds of save no longer remove hooks that status line needs, or add hooks it doesn't need:
    • a config opened with --config;
    • the default config, while the command names another --config.

Why

Configure Status Line locked

buildMainMenuItems disabled the item whenever isInstalled() was false. isInstalled() passes only in two cases:

  • statusLine.command is exactly npx -y ccstatusline@latest, bunx -y ccstatusline@latest or ccstatusline, optionally followed by --config …;
  • or it contains a ccstatusline.ts path, as a local development command does.

padding must also be 0 or unset.

The gate dates from when the menu held only the refresh interval: it was hidden without an install, and version pinning turned that into the greyed-out "(install first)" item. The three cache TTL rows were added to the menu later, and it is the only place in the TUI that edits them. They are ccstatusline's own settings, saved in its settings.json and read on every render, however Claude Code runs it. customCommandCacheTtlSeconds defaults to 0, which runs the command on every render, so with such a status line custom command caching couldn't be turned on from the TUI.

I wrote each command to a scratch CLAUDE_CONFIG_DIR/settings.json and called the real isInstalled() and buildMainMenuItems(). On main:

"~/.claude/ccstatusline-wrapper.sh" padding 0: isInstalled=false, Configure Status Line disabled=true sublabel=(install first)
"bunx ccstatusline" padding 0: isInstalled=false, Configure Status Line disabled=true sublabel=(install first)
"npx ccstatusline@latest" padding 0: isInstalled=false, Configure Status Line disabled=true sublabel=(install first)
"npx -y ccstatusline@latest" padding 1: isInstalled=false, Configure Status Line disabled=true sublabel=(install first)
"npx -y ccstatusline@latest" padding 0: isInstalled=true, Configure Status Line disabled=false sublabel=-

On this branch the first four print disabled=false. isInstalled() itself is unchanged.

Hooks rewritten from the wrong config

Every save (Ctrl+S and Save & Exit) runs saveSettings, which calls syncWidgetHooks with the config being saved. That function removed every ccstatusline-managed hook and added back the ones that config's widgets need, pointing at <statusLine.command> --hook. Its only check was that the command contains ccstatusline. It never looked at which config the command loads.

A script with a scratch HOME and CLAUDE_CONFIG_DIR calls initConfigPath() and then saveSettings(await loadSettings()), the calls the TUI makes when it opens a config and when Ctrl+S saves it. On main (<home> is the scratch home):

Case 1 save default (Skills), installed loads default -> PreToolUse: npx -y ccstatusline@latest --hook | UserPromptSubmit: npx -y ccstatusline@latest --hook
Case 2 save alt.json (no Skills), installed still loads default -> none
Case 3 save default (Skills), installed loads alt.json -> PreToolUse: ccstatusline --config <home>/alt.json --hook | UserPromptSubmit: ccstatusline --config <home>/alt.json --hook
Case 4 save alt.json (Skills), installed loads ~/alt.json -> PreToolUse: ccstatusline --config ~/alt.json --hook | UserPromptSubmit: ccstatusline --config ~/alt.json --hook
  • Case 2: The status line still loads the default config, which has Skills, but its hooks are gone. The --hook handler is the only thing that records skill use, so that status line's Skills widget shows none from then on.
  • Case 3: On every prompt and Skill call, Claude Code now runs ccstatusline --config alt.json --hook and waits for it. The status line it serves doesn't show Skills.

In both cases the TUI only said "✓ Configuration saved".

On this branch:

  • Case 2 keeps both hooks.
  • Case 3 adds none.
  • Case 4 still adds them. In case 4 the command names --config ~/alt.json, written with a tilde, and alt.json is the config being saved.

How

Configure Status Line (fix(tui): open Configure Status Line before ccstatusline is installed):

  • MainMenu.tsx: the item is always enabled. Its description now reads "Set ccstatusline's cache TTLs, and Claude Code's refresh interval once ccstatusline is installed".
  • RefreshIntervalMenu.tsx: a new isClaudeInstalled prop, also a trailing parameter of buildConfigureStatusLineItems. It's optional and defaults to true, the only state in which the menu used to open. When it's false, the Refresh Interval row is disabled with "(install first)". On an older Claude Code, "(requires Claude Code >=2.1.97)" still shows instead, since installing wouldn't help. With the row disabled, the cursor starts on Git Cache TTL.
  • App.tsx passes its install state to the menu.
  • Why the refresh interval keeps the gate: setRefreshInterval writes statusLine.refreshInterval into Claude Code's settings.json. Uninstall is likewise offered only for a command the TUI recognizes as its own, and Install replaces any other command with its own.
  • docs/USAGE.md describes the split.

Hooks (fix(hooks): sync widget hooks only from the config the status line loads):

  • New getStatusLineConfigPath(command) in claude-settings.ts, next to the code that writes --config into the command:
    • It splits the command into words the way a POSIX shell would, handling quotes and backslash escapes. This undoes quotePathIfNeeded. On Windows only double quotes group words, and backslashes stay as path separators.
    • It takes the word after the first --config, as parseConfigArg does, and expands a leading ~ and $HOME.
    • It resolves that path through a new resolveConfigPath, which initConfigPath now uses too.
    • It returns null when --config has no path, and the default config path when there's no --config.
  • syncWidgetHooks: after the existing "contains ccstatusline" check, it returns without writing settings.json when getStatusLineConfigPath(statusCommand) isn't getConfigPath(). Installing still syncs the hooks, because the command it writes loads getConfigPath().
  • docs/DEVELOPMENT.md describes the rule.

Left as is:

  • The Refresh Interval row still needs a recognized install. With a wrapper script, statusLine.refreshInterval has to be set in Claude Code's settings.json by hand, as before.
  • A wrapper script counts as loading the default config unless the status line command passes it --config. A wrapper that passes its arguments on (ccstatusline "$@") handles that. A script that names its config inside itself gets hooks only when the default config is saved.
  • A --config inside a nested shell string (bash -c '… ccstatusline --config …') isn't seen, so that command counts as loading the default config.
  • Paths compare as resolved strings:
    • A relative --config resolves against the TUI's working directory.
    • A symlinked path doesn't match its target.
    • Other shell expansions in the path, such as variables other than HOME or globs, aren't evaluated, so the path doesn't match and the hooks stay as they are.
    • A ~ or $HOME is expanded even inside single quotes, where the shell leaves it alone. So a config whose path contains a literal $HOME never matches, and neither Install nor a save syncs its hooks.
  • Saving a config the status line doesn't load still says only "Configuration saved". For a config opened with --config, the "Config:" line under the TUI's title shows which file is open.

Demo

Configure Status Line with a wrapper script

Claude Code's status line is ~/.claude/ccstatusline-wrapper.sh. The TUI runs in the top of a tmux split, and the shell below shows Claude's settings.json. Before: Configure Status Line reads "(install first)", and Down from Global Overrides jumps to Export Config. After: Down lands on Configure Status Line. Inside, Refresh Interval reads "(install first)" and the cache TTLs are editable. The Custom Command Cache TTL is set to 5s and saved, and the shell shows it in ccstatusline's settings.json.

Powerline

Before:

Configure Status Line with a wrapper script in Powerline mode before the fix

After:

Configure Status Line with a wrapper script in Powerline mode after the fix

Plain

Before:

Configure Status Line with a wrapper script in plain mode before the fix

After:

Configure Status Line with a wrapper script in plain mode after the fix

Saving a config the status line doesn't load

Claude Code's status line runs ccstatusline --config ~/work.json, a config with a Skills widget, and has the managed Skills hooks. The TUI edits the default config, which has no Skills widget. The shell below greps Claude's settings.json for its commands before and after Ctrl+S. Before: the save removes both Skills hooks. After: they stay.

Powerline

Before:

Saving another config in Powerline mode before the fix

After:

Saving another config in Powerline mode after the fix

Plain

Before:

Saving another config in plain mode before the fix

After:

Saving another config in plain mode after the fix

Testing

  • On main as of fix(powerline): keep the Custom theme when Powerline is enabled again #793 (a0bedc0): it merges after one conflict in config.ts next to fix(config): don't migrate a version-less settings.json with later settings as v1 #779's migration notice, resolved by keeping both. bun run lint is clean, and bun test gives 4155 pass, 0 fail (4147 on main). The built CLI under Bun 1.4.4 and Node 26.13.0 agrees across runtimes and matches main in plain and Powerline mode, and the TUI opens and exits cleanly under both. It adds no JavaScript or Node API newer than Node 14, the build's target.
  • 8 new tests plus one changed test, and all 9 fail on main. The 3 in claude-settings.test.ts fail there because getStatusLineConfigPath doesn't exist, so that file doesn't load:
    • App.test.ts, new Ink test with a wrapper-script status line and the real isInstalled():

      • Configure Status Line opens and Refresh Interval reads "(install first)".
      • Custom Command Cache TTL is set and saved to ccstatusline's settings.
      • Claude's statusLine is unchanged.

      On main, Down from Global Overrides lands on Export Config.

    • App.test.ts, changed: the main menu structure test now expects Configure Status Line enabled when not installed, and the install row's selection index to be 8 instead of 7.

    • RefreshIntervalMenu.test.ts, 1 new: without an install, Refresh Interval is disabled with "(install first)" and the three TTL rows stay enabled.

    • hooks.test.ts, 3 new:

      • saving a config the command doesn't load leaves settings.json byte for byte unchanged;
      • saving the default config while the command names another --config adds no hooks;
      • with --config ~/'my configs'/statusline.json, saving the default config adds nothing, and saving that file adds the hooks.
    • claude-settings.test.ts, 3 new for getStatusLineConfigPath:

      • default, ~, $HOME, escaped spaces, a missing path;
      • a round trip of the commands buildStatusLineCommand writes for a plain path and for paths with a space, a single quote, and ", $, backticks and backslashes;
      • a Windows path keeps its backslashes.
  • The 4 changed test files under vitest on Node 26.10.0: 172 pass, 11 fail. On main: 164 pass and the same 11 fail. The 11 are the existing getClaudeCodeVersion / isClaudeCodeVersionAtLeast tests whose child_process spies don't take under Node's ESM. All new tests pass under Node.

eric-engberg and others added 7 commits October 16, 2026 18:31
Configure Status Line was disabled with "(install first)" unless Claude
Code's statusLine command was one of the exact commands the installer
writes, with padding 0. A wrapper script, `bunx ccstatusline`,
`npx ccstatusline@latest` without -y, or any other padding all counted
as not installed.

That menu is the only place in the TUI to edit the Git, Custom Command
and Terminal Width cache TTLs. They are ccstatusline's own settings,
saved to its settings.json and read on every render however Claude Code
runs it. With such a status line they couldn't be changed, for example
to turn on custom command caching, which is off by default.

The menu now always opens. Only its Refresh Interval row needs the
install: Claude Code keeps that value in its statusLine setting, which
ccstatusline edits only for a status line it recognizes as its own.
Until then the row is disabled with "(install first)"; an older Claude
Code still shows its version requirement instead. The main menu's
description of the item says which part needs the install.
Saving a config rewrote Claude Code's ccstatusline-managed hooks from
that config's widgets, whichever config it was. The installed status
line command loads one config: the path after its --config, or the
default config without one. Saving any other config, such as one opened
with `ccstatusline --config <file>` to try something out, still rewrote
the hooks to match it:

- If the status line shows Skills and the saved config doesn't, the
  Skills hooks were removed and the status line's Skills widget stopped
  updating.
- In the other direction, hooks were added that make Claude Code run
  `<command> --hook` on every prompt and Skill call, for a status line
  that doesn't need them.

Either way the TUI only said "Configuration saved".

syncWidgetHooks now works out which config the status line command
loads and leaves the hooks as they are when that isn't the config being
saved. getStatusLineConfigPath splits the command into words the way a
POSIX shell passes them on (quotes and backslash escapes; on Windows
only double quotes group), expands a leading ~ and $HOME, and resolves
the path as the --config argument is resolved at startup, through
resolveConfigPath, which initConfigPath now uses too. A command without
--config, a wrapper script included, is taken to load the default
config. Installing still syncs the hooks, since the command it writes
names the config the TUI has open.
# Conflicts:
#	docs/DEVELOPMENT.md
#	docs/USAGE.md
Node 14, the build's target, has no logical assignment operators, and the
build passes them through unchanged.
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