Skip to content

fix(install): find programs on PATH without which, and count a symlinked PATH dir once - #102

Closed
eric-engberg wants to merge 56 commits into
developfrom
fix/install-path-detection
Closed

eric-engberg wants to merge 56 commits into
developfrom
fix/install-path-detection

Conversation

@eric-engberg

@eric-engberg eric-engberg commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Standalone, cut from main.

What

Two fixes to how the TUI looks for programs on PATH:

  • No which binary: on systems without one, the TUI no longer reports npm, npx, bun and bunx as missing or says ccstatusline isn't on PATH.
  • Symlinked PATH directories: one ccstatusline reached through two PATH entries that are the same directory (/bin and /usr/bin on merged-/usr Linux, or bin and Bin on a case-insensitive macOS volume under Node) no longer triggers "Multiple ccstatusline binaries are on PATH". When there is a real second install, "Other matches" lists it once instead of also naming the first match's other spelling.

Why (evidence)

No which. The Install screen asks which <tool> whether npm, npx, bun and bunx exist, and Manage Installation asks which -a ccstatusline where the command resolves. which is its own package, and minimal Fedora/RHEL/UBI images and a base Arch install don't include it. When it's missing, the lookup fails exactly as if the tool were missing:

  • On a PATH holding node, npm, npx, bun and bunx but no which, the availability check returns {"npm":false,"npx":false,"bun":false,"bunx":false} under both Bun and Node.
  • The Install screen lists npm install -g ccstatusline@2.2.32 (npm not installed), bun add -g … (bun not installed), npx -y ccstatusline@latest (npx not installed) and bunx … (bunx not installed), and none of them can be selected. The TUI can't install at all, even though the user just started it with npx.
  • Update actions are disabled for the same reason, and Manage Installation says "ccstatusline is not currently resolvable on PATH".
  • On the same PATH, sh -c 'which npm' exits 127 (which: command not found) while command -v npm finds npm.

Symlinked PATH directories. The multiple-binaries warning counts the directories which -a lists, comparing them as text. On Ubuntu 22.04+ and Debian 12, /bin is a symlink to usr/bin and the default PATH lists both. With npm's global prefix at /usr (NodeSource's Node, for example), which -a ccstatusline prints /usr/bin/ccstatusline and /bin/ccstatusline, which are the same file. Manage Installation and the install/update notices then show "⚠ Multiple ccstatusline binaries are on PATH … Other matches: /bin/ccstatusline". Reproduced with a usr/bin directory and a bin -> usr/bin symlink both on PATH. With a real second install listed after those two, "Other matches" named /bin/ccstatusline as well as the other install, because the list took every path after the first. On macOS, Node's fs.realpathSync keeps the letter case it is given while Bun's returns the case on disk. With one scratch directory on PATH as both .../bin and .../Bin, which which -a lists twice, the check behind the install and update notices warned under Node v26.10.0 and not under Bun 1.4.2.

How

  • PATH search fallback. When which can't be started (ENOENT), getCommandResolutionPaths searches PATH itself through the new findAllOnPosixPath in executable-path.ts: each file the user can run, in PATH order, as which -a lists them. Empty and relative PATH entries are skipped: they name directories relative to wherever the TUI runs, which says nothing about what Claude Code runs, and the Windows lookup in the same file already counts only absolute entries.
  • Why not command -v. It could answer "is npm installed" but can't list every match, which the multiple-binaries warning needs, so both checks share one PATH search.
  • One lookup for both checks. On POSIX the availability check now goes through the same lookup as the ccstatusline resolution, so the two can't disagree.
  • Unchanged elsewhere. Where which exists nothing changes (a normal "not found" still returns nothing), and Windows keeps using where.
  • Duplicate check. Both duplicate checks now compare each directory with its symlinks resolved (getRealDirectory, which falls back to the listed path when it can't be resolved). It calls fs.realpathSync.native, which returns the on-disk letter case under both runtimes. "Other matches" lists the first path in each other directory, so each other install appears once. The warning text keeps the paths as PATH spells them.
  • Left as is: two checks still compare paths as text: the "outside the npm global bin directory" check in the install and update notices, and the check Manage Installation uses to pick the active package manager. They misfire whenever PATH lists a symlinked spelling of the global bin directory before its real one. On Arch, /usr/sbin is a symlink to bin and npm's global prefix is /usr. With Docker's default PATH (/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin), a root npm i -g ccstatusline therefore resolves first as /usr/sbin/ccstatusline. On that layout this branch drops the false "Multiple ccstatusline binaries" warning but shows "ccstatusline resolves to /usr/sbin/ccstatusline, which is outside the npm global bin directory (/usr/bin)" instead. Manage Installation detects no package manager, as on main, and says the binary is "outside the detected npm and bun global bin directories". Reproduced with a fake root of that layout under Bun and Node. Ubuntu and Debian aren't affected: /usr/sbin is a real directory there, and their default PATH lists /usr/bin before /bin.

Demo

Install to Claude Code with no which on PATH. Before, every package manager reads "not installed" and can't be picked; after, they're listed normally.

Powerline

Before:

Install screen without which before the fix, Powerline

After:

Install screen without which after the fix, Powerline

Plain

Before:

Install screen without which before the fix, Plain

After:

Install screen without which after the fix, Plain

Manage Installation with one binary reached through usr/bin and bin -> usr/bin. Before, a false "Multiple ccstatusline binaries" warning; after, none.

Powerline

Before:

Manage Installation on merged /usr before the fix, Powerline

After:

Manage Installation on merged /usr after the fix, Powerline

Plain

Before:

Manage Installation on merged /usr before the fix, Plain

After:

Manage Installation on merged /usr after the fix, Plain

Testing

  • bun test: 3183 pass, 0 fail (main: 3168). There are 15 new tests:

    • the PATH search's order, de-duplication, skipped empty and relative entries, missing PATH, reading a process.env replaced after load, and the real permission check (executable file, non-executable file, directory, symlink);
    • the resolution fallback when which is missing, and no fallback when which runs and fails;
    • package-manager availability without which;
    • the merged-/usr layout for both duplicate checks, alone and with a second install after it (listed once in "Other matches");
    • a directory spelled in another letter case resolving to one path, through the native realpath.

    The behaviour tests fail on main, except the failing-which case: it keeps what main already does and fails once the ENOENT check is dropped. The letter-case test fails before its fix only under Node, since Bun's realpathSync already returned the on-disk case, so a second test checks that the native call is made; it fails under bun test when the call is reverted.

  • bun run lint: clean.

  • Node (vitest), changed test files: the only failures are the existing "Cannot spy on export … Module namespace is not configurable in ESM" kind (7 more than main, all new mock-based tests failing the same way); the new PATH search tests and the letter-case tests pass under Node.

  • Built CLI under Bun 1.4.2 and Node v26.10.0: piped plain and Powerline renders are identical across runtimes and match main, and the TUI opens and exits cleanly.

  • End to end with a PATH of node, bun, npm, npx and bunx but no which, under both runtimes: main shows all four managers as not installed; this branch lists them and finds ccstatusline.

  • End to end with a merged-/usr layout under both runtimes: main shows the false warning; this branch doesn't.

  • End to end on macOS with one scratch directory on PATH as .../bin and .../Bin (real which -a): before the letter-case fix Node warned and Bun didn't; now neither does.

eric-engberg and others added 30 commits October 16, 2026 01:22
…which

The TUI asked `which` whether npm, npx, bun and bunx are installed and
where ccstatusline resolves. `which` is a separate package that minimal
Linux images (Fedora, RHEL/UBI) and a base Arch install leave out.
Without it every lookup failed the way a missing tool does: the Install
screen marked all four package managers "not installed" and disabled
every option, update actions were disabled too, and Manage Installation
said ccstatusline is not resolvable on PATH.

When `which` can't be started, the lookup now searches PATH itself and
lists what `which -a` would: each file the user can run, in PATH order.
`command -v` could answer "is npm installed" but can't list every match,
which the multiple-binaries warning needs, so both checks share this one
search. The availability check now goes through the same lookup as the
ccstatusline resolution, so the two can't disagree. Where `which` exists
nothing changes, and Windows still uses `where`.
The "Multiple ccstatusline binaries are on PATH" warning counts the
directories `which -a` lists ccstatusline in, comparing them as text.
On merged-/usr Linux (Ubuntu 22.04+, Debian 12) /bin is a symlink to
usr/bin and the default PATH lists both, so with npm's global prefix at
/usr one installed binary shows up as /usr/bin/ccstatusline and
/bin/ccstatusline. Manage Installation and the install and update
notices then warned about a second install that doesn't exist.

Both duplicate checks now compare each directory with its symlinks
resolved, and fall back to the path as listed when it can't be
resolved. The warning still shows the paths as PATH spells them.
The PATH search used when there is no `which` kept the process.env object
that existed when its module loaded. Code that replaces process.env with a
new object afterwards (a test restoring a saved environment does) left the
search reading the old object, so a PATH set on the new one was never seen.
The default search now reads process.env each time it runs.
…arning

The warning counts installs by directory, with symlinks resolved, but
built its "Other matches" list from every path `which -a` printed after
the first. With a real second install on a merged-/usr system, where
/bin links to usr/bin, PATH order gives /usr/bin/ccstatusline,
/bin/ccstatusline and then the other one, so the list named
/bin/ccstatusline as another binary although it is the first match
reached a second way.

The list now takes the first path listed in each directory and leaves
out the first match's directory, so each other install appears once,
spelled as PATH lists it.
…de and Bun

The duplicate-binary checks resolve each PATH directory with
fs.realpathSync before comparing. Where the file system ignores letter
case (macOS by default), Bun's realpathSync returns the case stored on
disk but Node's returns the case it was given. A PATH that spells one
directory two ways, such as .../bin and .../Bin, therefore showed
"Multiple ccstatusline binaries are on PATH" when the TUI ran under
Node and no warning under Bun.

The lookup now uses fs.realpathSync.native, which asks the operating
system and returns the on-disk case under both runtimes. A directory
that can't be resolved still falls back to the path as listed.
…iling

The lookup searches PATH itself only when `which` can't be started
(ENOENT). When `which` runs and exits non-zero, it found nothing, and
that answer stands. No test covered this: with the ENOENT check removed,
every failed `which -a ccstatusline` would fall through to the PATH
search, and the tests still passed.

The new case has `which -a` exit with status 1 while PATH holds an
executable ccstatusline, and expects no matches.
… realpath

The letter-case test passes under Bun with or without the native call,
because Bun's plain realpathSync already reports the on-disk case, and
it is skipped where the file system is case-sensitive. A second test
checks the call itself, so a revert fails under bun test on any system.
findAllOnPosixPath spelled its default search as an object literal inside
the parameter list, which made the signature hard to read at a glance. The
default now comes from defaultPosixPathSearch(), next to the isExecutableFile
check it uses.

A default parameter is evaluated on every call that leaves it out, so the
helper still builds a fresh search from process.env as it is when the search
runs: an env object swapped in after the module loaded is still seen.
…ith 9 updates (sirmalloc#745)

Bumps the dev-dependencies group with 9 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@remotion/cli](https://github.com/remotion-dev/remotion) | `4.0.534` | `4.0.538` |
| [@types/bun](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/bun) | `1.4.2` | `1.4.4` |
| [eslint](https://github.com/eslint/eslint) | `10.12.0` | `10.13.0` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.3.0` | `19.3.1` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.3.0` | `19.3.1` |
| [remotion](https://github.com/remotion-dev/remotion) | `4.0.534` | `4.0.538` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.71.1` | `8.71.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.3` | `5.0.5` |
| [zod](https://github.com/colinhacks/zod) | `4.6.5` | `4.6.7` |



Updates `@remotion/cli` from 4.0.534 to 4.0.538
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](remotion-dev/remotion@v4.0.534...v4.0.538)

Updates `@types/bun` from 1.4.2 to 1.4.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/bun)

Updates `eslint` from 10.12.0 to 10.13.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](eslint/eslint@v10.12.0...v10.13.0)

Updates `react` from 19.3.0 to 19.3.1
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.1/packages/react)

Updates `react-dom` from 19.3.0 to 19.3.1
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.1/packages/react-dom)

Updates `remotion` from 4.0.534 to 4.0.538
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](remotion-dev/remotion@v4.0.534...v4.0.538)

Updates `typescript-eslint` from 8.71.1 to 8.71.2
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/typescript-eslint)

Updates `vitest` from 5.0.3 to 5.0.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.5/packages/vitest)

Updates `zod` from 4.6.5 to 4.6.7
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](colinhacks/zod@v4.6.5...v4.6.7)

---
updated-dependencies:
- dependency-name: "@remotion/cli"
  dependency-version: 4.0.538
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@types/bun"
  dependency-version: 1.4.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: eslint
  dependency-version: 10.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: react
  dependency-version: 19.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: react-dom
  dependency-version: 19.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: remotion
  dependency-version: 4.0.538
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.71.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: vitest
  dependency-version: 5.0.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: zod
  dependency-version: 4.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…00% (sirmalloc#697)

* feat(usage): let Block Reset Timer hide until session usage hits 100%

* feat(usage): fetch the session percent for Block Reset Timer's under-limit state

* test(usage): compare Block Reset Timer's preview with the state off, not a fixed sample

* fix(usage): read Block Reset Timer's under-limit percent from the usage API

The under-limit hide state read the session percent from the status
payload's rate_limits whenever it had one. That percent comes from the
session's own last API response, so it stops moving while the session
sits idle: with several sessions open, one left idle while another used
up the 5-hour limit kept its timer hidden at the limit. After a reset it
could also keep showing a timer from the old window's 100%.

The state now fetches the usage API's percent even when the payload has
one, and keeps it apart from the merged data as apiSessionUsage, so
Session Usage and every other widget still show the payload's percent.
The API's answer is the account's, cached for 3 minutes and shared by
every session, so this costs at most one request per 3 minutes. When the
fetch fails, the payload's percent stands in, without an error.

* docs(usage): say "block" for the 5-hour window in the under-limit state

The under-limit state's checklist label read "when session usage is
under 100%", but ccstatusline also says "session" for the Claude Code
conversation (Session Clock, Session Cost), and the timers for the
5-hour window say "block". The label now reads "when the 5-hour block is
under 100%", and the docs, comments and test names say the block's
percent too. "Session" stays where it means a Claude Code session, such
as a session left idle. Code names that follow the usage data's
`sessionUsage` field keep it.

* docs(usage): refer to the usage percent without Session Usage's name

Another change renames Session Usage to Block Usage. Say "usage
percent" where the under-limit state's docs and two test comments named
it, so they read right with either name.

* fix(usage): ignore a cached block percent once its block has reset

The usage API's answer is cached for 3 minutes, and kept for as long as
the API keeps failing. Across a reset, under-limit took that answer's
100% for the new block's, so the reset countdown showed at the start of
a fresh block (at 40%, say) until a fetch succeeded. The API's percent
now counts only while its block hasn't reset; otherwise the payload's
percent stands in, as when the fetch fails.

* fix(usage): don't let a stale usage cache decide the under-limit state

While the usage API can't be reached (a 429 backoff, a failed request,
no credentials), fetchUsageData serves the last good cache however old
it is, and nothing tells it apart from a fresh answer: unlike a failed
fetch it carries no error. Block Reset Timer's under-limit state took
that cache's 5-hour percent as the API's current answer, so the
payload's percent never stood in. Through a backoff, which a Retry-After
can stretch to an hour, a cache written at 97% kept the reset countdown
hidden after the payload had reached 100% in the same block.

fetchUsageData now marks the stale fallback it returns, including when a
later call serves it again from memory. The prefetch takes the block's
API percent only from the API or a current cache, so a stale fallback
counts as the API being unreachable, like a failed fetch, and the
payload's percent decides, as the under-limit state's docs describe.

Only a cache that has outlived a refresh is marked: one older than the
3-minute cache lifetime plus the 30-second lock a fetch holds while in
flight. Another session's ordinary refresh serves the cache through
that lock too, seconds past its 3 minutes and as current as the shared
cache ever is, and marking it would let an idle session's frozen
payload percent override the API's for that render.

* fix(usage): keep rejected API percentages out of reset timer fallback

The under-limit hide state preferred apiSessionUsage and fell back to the
merged sessionUsage field. When the status payload omitted its percentage,
that merged field retained the API value even if prefetch rejected it as
stale or from a block that had already reset. An old cached 100% could
therefore show a new block's countdown from a reset-only payload.

Read the fallback percentage directly from the original status payload in
the render context. Continue preferring the API percentage accepted by
prefetch, and treat missing or null payload percentages as unknown so the
timer stays hidden. Keep general usage merging and other widgets unchanged.

Add prefetch-to-render regression coverage for expired and stale cached
100% readings with missing, null, zero, 97%, and 100% payload percentages,
plus an entirely absent payload. Verify that merged usage is preserved
while rejected API values cannot decide visibility. Update widget fixtures
to supply their fallback percentages through the original payload.

Validation:
- Both new regression cases reproduced the erroneous countdown before the fix.
- Targeted prefetch and Block Reset Timer tests: 96 passed.
- bun run lint: passed (TypeScript and ESLint).
- bun test: 3,191 passed; two local proxy listener tests failed because the
  sandbox blocked binding to 127.0.0.1. Both passed outside the sandbox
  (the status-service file: 30 passed; the usage proxy timeout test: 1 passed).

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
sirmalloc#698)

* fix(renderer): end a merge at a hidden widget that isn't merged onward

A merged widget joined the next widget that rendered anything, skipping over
widgets that rendered nothing. So with Session Usage merged into a widget that
hides itself, the hidden widget took the merge with it to whatever followed:
the separator vanished and, in Powerline, every later segment shifted one
theme color. A merge now carries past an empty widget only when that widget is
merged onward too; otherwise it ends there and the usual boundary returns. The
Powerline, plain and auto-align paths all follow the same rule.

* fix(renderer): end a merge at a hidden widget in the next-line counters too

The Powerline renderer now ends a merge at a widget that renders nothing
and isn't merged onward: A (merged), an empty unmerged widget, then B
draws a separator between A and B and gives B its own theme color. The
per-line counters that tell the next line where to pick up the separator
and theme-color cycles still followed the old rule and carried the merge
past every empty widget, so they counted one separator and one color
fewer than the line drew.

On a multi-line Powerline status line that put the next line one step
behind: its first separator repeated the previous glyph and, with
Continue Theme Across Lines on, its first segment reused the color of
the segment before it. Git Branch merged with Git Ahead/Behind hits this
whenever the branch is in sync. The TUI preview uses the same counters,
so it showed the same.

countSeparatorSlots and assignPowerlineThemeSlots now drop the pending
merge when they skip an empty widget that isn't merged onward, matching
the renderer. A merge still carries past empty widgets that are merged
onward themselves.

* refactor(renderer): count separator slots one widget at a time

countSeparatorSlots had grown a branch per kind of widget inside its
loop. Each widget's effect on the count now lives in
advanceSeparatorSlots, which keeps the loop to one call and puts the
rules for separators, flex separators, hidden widgets and shown widgets
side by side. No change in behavior.
…c#700)

* feat(tui): opt-in dev reload that restarts the TUI in place

Picking up a code change meant quitting the TUI and redoing any unsaved
setup and navigation by hand.

With CCSTATUSLINE_DEV_RELOAD=1 the interactive TUI runs under a small
supervisor process. ctrl+r writes a snapshot (unsaved and original
settings, current screen, selected line, menu positions, line editor
cursor) and exits with code 75; the supervisor starts a fresh process on
the latest code, which resumes from the snapshot. Screens that carry
their own flow state (confirm dialogs, install/update, import/export)
resume on the main menu. If the new code fails to start, the supervisor
keeps the snapshot and offers Enter to retry once it's fixed, or q to
quit.

Without the variable nothing changes: no supervisor, no key, and the
module is only loaded through a dynamic import in the interactive
branch, so the status line render path is unaffected.

* test(tui): wait for the cursor test's frame and callback

The dev reload cursor test checked the first frame and the cursor callback
after fixed 25 ms delays, which a busy machine can outrun. It now retries the
assertions until they pass (up to 3 s) with a waitFor helper. Once they pass,
waitFor gives React two setImmediate turns: React draws a frame, then attaches
the new input listener in a scheduled effect, so a key sent the moment the
frame shows could reach no listener.

* test: render the line selector with settings, as the App does

The App always passes the line selector its settings. The hotkey test now
does too, so it matches real use and keeps compiling if they become
required.

* refactor(dev-reload): import Node built-ins with the node: prefix

* test(dev-reload): cover the retry prompt, the relaunch and the TUI's resume

- The retry prompt takes the terminal and the supervisor takes the
  function that runs each child, so both run in tests: Enter retries, q
  gives up, other keys wait, and the supervisor relaunches the same command
  with a snapshot path and removes the snapshot at the end.
- A test renders the App as a reloaded child: it opens on the snapshot's
  line editor with the unsaved settings, and ctrl+r writes a new snapshot
  of where it is. Startup checks on Claude Code, package managers and
  fonts are stubbed.

* fix(dev-reload): keep each run's snapshot in a fresh private directory

The supervisor kept its snapshot at os.tmpdir()/ccstatusline-dev-reload-
<pid>.json and removed it only when it returned normally. The TUI it starts
restores whatever is at that path, so:

- a snapshot left by a supervisor that was killed came back in a later run
  that got the same PID, as current unsaved settings, and ctrl+s then wrote
  it over settings.json;
- on a shared /tmp another local user could plant the file, e.g. with a
  Custom Command widget, and have it restored the same way.

Each run now creates its own directory with mkdtemp (new, mode 0700) and
keeps the snapshot inside, so the first child can only find what this run
wrote. The whole directory is removed when the supervisor ends.

* fix(dev-reload): pass stop signals on to the TUI instead of orphaning it

The supervisor ran the TUI with spawnSync and handled no signals. SIGTERM,
SIGINT or SIGHUP sent to the supervisor alone (e.g. kill <pid>) ended it
but not the TUI, which kept running with PPID 1, out of the foreground;
under Bun it later printed an EIO stack trace over the shell prompt. The
snapshot was left behind too, also when closing the terminal ended both.

The TUI now runs as an asynchronous child, so the supervisor can handle
signals while it runs. On any of the three it passes the signal on, waits
for the TUI to exit (so the shell gets the terminal back after the TUI has
restored it), removes the snapshot directory and exits with 128 + the
signal number. A TUI that ends this way is not a crash, so no retry prompt
is shown. At the prompt there is no TUI to wait for, so the supervisor
cleans up and exits straight away.

The supervisor's process (prompt terminal, signals, exit) is a parameter
defaulting to process, like the child launcher, so tests can drive it.

* test(tui): use the shared waitFor helper in the line editor tests

main now has the same helper in src/tui/__tests__/helpers/wait-for-ink.ts,
so the file's own copy of waitFor and letReactCatchUp goes.

* fix(dev-reload): drop the snapshot once the settings file is written

The supervisor hands every TUI it starts the same snapshot path, including
the one it relaunches when Enter is pressed at "Reload failed". The snapshot
was only ever written by ctrl+r and never refreshed or removed afterwards,
so it outlived any save made after that ctrl+r.

So after ctrl+r, a save, and then a crash, the retry restored the settings
from before the save, paired with the original from before the save. The
TUI showed widgets that had been deleted and saved, offered Save & Exit for
changes that weren't there, and the next save wrote the older settings back
over the newer file.

Each change to originalSettings after the first load follows a write to the
settings file (ctrl+s, Save & Exit, or an install, update or uninstall
recording itself), so the TUI now deletes the snapshot when that happens. A
retry after such a write loads the file. A crash before any write, the case
the retry is for, still resumes from the snapshot in place.

* fix(tui): guard dev reload and preserve live menu selections

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…malloc#703)

* fix(hooks): tell the user when a save can't add a widget's hooks

Skills reads skill activity that Claude Code hooks record. Since hooks
are only added when the status line command contains `ccstatusline`, a
status line run through a wrapper such as `bash ~/.claude/status.sh`
gets none, and saving says "✓ Configuration saved" while Skills keeps
showing "none" with nothing to explain why.

syncWidgetHooks now returns the hooks it left out: the status line
command and the names of the widgets that needed them. It reports
nothing when every hook was added, when no widget needs one, or when no
status line is installed yet (installing ccstatusline adds them).
saveSettings passes that on.

When a save leaves hooks out, Ctrl+S and Save & Exit show a notice
instead of the usual confirmation: which widgets lost their hooks, the
status line command, and how a wrapper script gets them (put
"ccstatusline" in its name and pass its arguments on). Continue returns
to the screen Ctrl+S was pressed on, or exits after Save & Exit.

* docs: explain why Skills shows none behind a wrapper script

Add a troubleshooting note under Claude Code Status Line Settings, and a
README pointer next to the other status line notes: Skills needs hooks,
which are only added when the status line command contains
`ccstatusline`, and how a wrapper script gets them.

* refactor(tui): give the flow notice only the props it draws

FlowNotice took every field of FlowNoticeState, continueScreen
included, but never read it: App decides where Continue leads, in the
onContinue it passes. Now that continueScreen can also be 'exit',
which only App can act on, leave it out of the notice's props.
…oc#705)

* fix(git-branch): read a jj repo's detached HEAD as no branch

Since Git Branch started showing a detached HEAD's commit, it shows one
in every colocated jj repo: jj keeps git's HEAD detached on the working
copy's parent, so the widget printed "⎇ (4227868)" where it used to
show "no git", or hide with "hide no git". That commit is jj's
bookkeeping, not something the user checked out, and users who pair Git
Branch with the jj widgets and hide it outside git repos saw it come
back in every jj repo.

On a detached HEAD, check whether the directory is in a jj repo first.
If it is, there's no branch: "no git", or hidden, as before. A plain git
repo's detached HEAD (rebase, bisect, tag checkout) still shows its
commit. The jj check only runs on a detached HEAD.

* fix(git-branch): only treat a colocated jj repo's detached HEAD as no branch

On a detached HEAD, Git Branch took any successful `jj root` to mean the
repo was a colocated jj repo, where jj keeps git's HEAD detached. But jj
finds its workspace by walking up to the nearest .jj, and doesn't stop at
a git repo's own root. So in a plain git repo anywhere under a jj
workspace (a home or dotfiles tree managed by jj, a git checkout inside a
jj monorepo), `jj root` succeeds too, and a rebase, bisect or tag
checkout there showed "no git", or hid the widget, instead of the
detached commit.

Now the repo counts as colocated only when git's top-level directory
holds the .jj. A git repo nested under a jj workspace shows its detached
commit again, while a colocated repo, and a jj workspace with its own git
worktree, still read as no branch. The extra git call only runs on a
detached HEAD where `jj root` has already succeeded.
…malloc#614)

* feat(widget): fish-style abbreviation for Git Branch

Add a fishStyle metadata option (editor keybind (f)) to Git Branch:
every branch segment but the last collapses to its first character,
so 'main' renders as 'm' and 'fix/np/story' as 'f/p/story'. Mirrors
the existing fish-style mode of Current Working Dir. Link targets
keep the full branch name; the TUI preview reflects the mode.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* feat(widget): fish-style abbreviation for Git Worktree

Same fishStyle option as Git Branch (editor keybind (f)): every
worktree name segment but the last collapses to its first character,
so 'main' renders as 'm' and 'dir/wt' as 'd/wt'.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(git): preserve Unicode code points in fish-style names

---------

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…tory (sirmalloc#717)

The install tests reset the ccstatusline config path to the real ~/.config location and, after one test deletes CLAUDE_CONFIG_DIR without restoring it, write the real ~/.claude/settings.json. Under mutation testing a broken settings parse materialised defaults over a live config and pointed statusLine at a file inside the deleted Stryker sandbox (issue sirmalloc#716).

os.homedir() is mocked into the temp directory for the whole file and the default settings path is resolved on every call instead of once at import, so no test can reach the real home even when the parse is broken. afterEach restores CLAUDE_CONFIG_DIR for tests that delete it.

Verified: claude-settings + config tests 138 pass; full suite 3112 pass; mtime guards on both real config files show them untouched during a rerun.

Closes sirmalloc#716

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
…Timer (sirmalloc#747)

At the 5-hour limit, the block's percent says nothing new: it's 100% until
the reset, and the countdown is what matters. Block Usage gets an "at-limit"
hide state, the mirror of Block Reset Timer's "under-limit": with both on,
the reset countdown takes the percent's place once the block is used up.

Both states read one shared check, isAtSessionLimit, which reads the
percent the way Block Reset Timer's under-limit state does: the usage API's
raw percent when prefetch accepted one, so 99.7% shown as "100%" isn't the
limit yet and a lagging payload doesn't decide, and otherwise the payload's
own percent, never an API percent prefetch rejected as stale. The two
widgets therefore always swap in the same render, never both or neither.
The prefetch asks the usage API for the block's percent for either state,
through the same cache, so a line with both makes one request.
…line (sirmalloc#749)

While npx runs the TUI (the README's route to a pinned install), npm puts
the bin dir of its cached copy, <npm cache>/_npx/<hash>/node_modules/.bin,
first on PATH. The PATH lookup behind Install Complete, Manage
Installation and the pinned-version guard skipped bunx's temporary copy
but not npx's, so under npx it took the cached copy for the active
binary:

- Install Complete and Manage Installation warned that multiple
  ccstatusline binaries are on PATH and that Claude Code would run the
  npx copy, which isn't on Claude Code's PATH at all
- Manage Installation showed the pinned install without its package
  manager or version
- the pinned-version guard never fired, so a newer TUI launched through
  npx could save settings the older pinned install may not support

npx cache bins are now skipped the same way as bunx's: ccstatusline, or
its .cmd/.ps1 shim, in node_modules/.bin under an _npx/<hash> directory,
with either path separator. A custom npm cache (npm_config_cache) only
moves the part before _npx, so it's covered too.
…c#750)

The git command cache timed each entry from the moment the lookup
began, before git ran. A command that takes as long as the TTL (5
seconds by default), such as `git status` in a large repository or any
command cut off by the 5-second timeout, was therefore stored already
expired. Every widget sharing that command missed the cache and ran it
again: Git Status, Clean Status and the staged, unstaged and untracked
flags and file counts all read `status --porcelain -z`, and Insertions,
Deletions and Changes all read the two `diff --shortstat` commands. A
line with several of them waited on the same slow command once per
widget, long enough for the status line to stay blank or stale.

Stamp the entry once git returns, after a success or a failure alike,
and store that one entry in both the in-process and the on-disk cache.
Lookups still compare against the time they began. The custom command
cache already works this way.
…irmalloc#751)

* fix(tui): stop editor keys pausing on a global ccstatusline install

When Claude Code runs the global `ccstatusline` command (a pinned
install, which is the install menu's default, or a self-managed one),
App looked that command up in its render body. The lookup runs
`which -a`, `npm prefix -g` and `bun pm bin -g` synchronously, and a
pinned install runs `which` and the bin-dir lookup again for the
relaunch path. The label editor's live preview and every widget edit
redraw App, so each of those keys blocked the TUI for a quarter to half
a second on macOS. Keys pressed meanwhile reached Ink as one read,
which it parses as a single key or none: of six quick backspaces, two
or three took effect.

Run the lookups in a memo keyed on what they depend on: the saved
installation metadata, Claude's status line command and the install
state. They now run once loading finishes, and again after an install,
update or uninstall from the TUI, since each changes one of those. The
screens show the same thing. Ctrl+S and Check for Updates still look
the command up when pressed.

* refactor(tui): let App choose how to classify the installation

getCurrentInstallation took an isClaudeInstalled flag that switched
between two behaviours: with ccstatusline installed in Claude Code, the
saved metadata stands in until the status line command is read;
otherwise the command alone is classified. Each caller already holds
that state, so each now picks: classifyInstalledStatusLine for the
installed case, classifyInstallation otherwise.

Check for Updates passes the saved metadata as settings?.installation
instead of special-casing missing settings, since no saved metadata
already classifies the command alone.
…block (sirmalloc#752)

* fix(blocks): find transcripts when the config dir name has glob characters

The transcript fallback for Block Timer, Block Reset Timer and Block %
globbed `<config dir>/projects/**/*.jsonl` with the absolute config
directory written into the pattern. Characters a directory name can
contain, such as ( ) [ ] { } * ?, are glob syntax inside a pattern, so
with CLAUDE_CONFIG_DIR at a path like "~/claude (work)/.claude" the
pattern never matched the literal path. Every scan found no
transcripts, so Block Timer stayed at "0hr 0m" and Block Reset Timer at
[Loading] for as long as that directory was in use.

The glob already runs with the config directory as its cwd, so the
pattern is now the relative `projects/**/*.jsonl`. The directory is
then only ever a path, never part of the pattern.

* fix(blocks): don't count rejected requests as block activity

When a request is rejected (a usage limit, an expired login, an API
outage), Claude Code writes an assistant row with isApiErrorMessage set,
model "<synthetic>" and every usage field 0. The block scan only checked
that input_tokens and output_tokens were numbers, so these rows passed
as activity even though nothing was served:

- With only rejected requests in the last 5 hours, Block Timer and Block
  Reset Timer showed a running block that doesn't exist, e.g.
  "Block: 3hr 1m" / "Reset: 1hr 58m" instead of "0hr 0m" / [Loading].
- When rejected requests came before real work, the block started at
  the hour of the first rejection, so the reset was reported hours
  early.

Either wrong start was then cached for 5 hours.

The scan now skips rows flagged isApiErrorMessage, as the token and
context metrics and Cache Timer already do.

* fix(blocks): don't end the block chain at a gap a skipped transcript may fill

To find the current 5-hour block, the transcript fallback walks back from
the newest activity to the first gap of 5 hours or more, then chains
blocks forward from there. It reads in widening passes (10h, 20h, 48h),
and each pass reads only the transcripts last written after its cutoff.
But it keeps every row in the transcripts it reads, so a session left
open since yesterday brings in rows from long before the cutoff. A gap
between those rows and today's was taken as the session boundary even
when another transcript, skipped only because it was last written
before the cutoff, has rows inside it. The wider passes never ran, the
chain started at the wrong row, and the block start came out hours
early: Block Timer read 4hr 57m instead of 2hr 57m, the reset was
predicted 2 hours early, and the wrong start was cached until then.

A skipped transcript can only hold rows from before the cutoff. So a gap
is now a boundary only if the part of it after the cutoff spans a
session by itself; otherwise the next, wider pass reads the skipped
transcripts first. The last pass still takes any gap, as before.

* fix(blocks): don't trust block cache entries dated ahead of the clock

The block cache keeps either the current block's start or, after a scan
that found no block, an instant until which scans are skipped (one
minute ahead). Neither had an upper bound. When the system clock runs
ahead for a render (a dual-boot clock mismatch, a manual change) and is
then set back:

- a "no block" entry stood for the whole skew plus a minute, so Block
  Timer showed "0hr 0m" and Block Reset Timer [Loading] for that long
  (a day, after a day's skew), even with fresh activity in the
  transcripts;
- a block start cached under the fast clock lies in the future, and
  still counted as valid, so elapsed time stayed at 0 and Block Reset
  Timer at 5hr for the skew plus 5 hours.

A "no block" entry is now trusted only while it ends within one
interval, and a cached start only once it has begun; otherwise the
transcripts are scanned again.

* test(blocks): cover the transcript rows the block scan skips

The block scan counts only served requests as activity. Besides rejected
requests, it skips lines that don't parse, rows without both input and
output token counts (such as user messages), sidechain rows, and rows
whose timestamp is missing, not a string, or not a valid date. Only the
rejected-request case had a test against the real scan; this one puts a
row of each other kind after a real request and checks that the block
and its last activity come from that request alone.

The row with the invalid date comes first so that, if it were kept, it
would sort ahead of the real request and show up as the last activity.

* refactor(blocks): move the activity-row check into its own function

getAllTimestampsFromFile both read the transcript and decided which rows
count as block activity, so the six reasons to skip a row sat as a run of
continue statements inside the line loop. getActivityTimestamp now
answers that question for one row, returning its timestamp or null with
early returns, and the loop only reads lines and keeps what it returns.
The row shape both functions use gets a name, TranscriptRow.

No change in which rows count.

* test(blocks): add an output-only row to the skipped-rows test

The block scan counts a row as activity only when it has both an input
and an output token count. The skipped-rows test had a row with input
tokens and no output tokens, but none with output tokens and no input
tokens, so a scan that checked only the output count still passed it.
This adds that row, so the test now fails if either count stops being
required.

* test: skip invalid Windows glob directory names

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…e mock (sirmalloc#753)

ThinkingEffort.test.ts replaced ../../utils/claude-settings with a
vi.mock factory that exports only a vi.fn() loadClaudeSettingsSync.
Under Bun's test runner that mock isn't scoped to the file: test files
that run later in the same `bun test` process import the mocked module
too. When claude-settings.test.ts runs after it, its
loadClaudeSettingsSync tests get the stub's {} instead of the settings
file they wrote, and "should parse the settings file" and both
invalid-JSON tests fail. Whether they run in that order depends only on
the order the runner picks for the files: a CI run on Ubuntu hit it, and
running just these two files reproduces it on main.

The stub is now vi.spyOn on the module namespace, set in beforeEach and
restored in afterEach, the way VoiceStatus.test.ts stubs getVoiceConfig
and terminal.test.ts explains for terminal-width-cache. The tests check
the same things as before.
* feat(widgets): add a Fast Mode widget

Claude Code reports whether fast mode is on in the status line payload
(fast_mode), but nothing showed it. Fast Mode renders it like Voice
Status: icon plus text (⚡ on / ⚡ off) by default, an icon with a state
dot, plain text, or "fast on/off", with optional Nerd Font flash icons.
It can hide while fast mode is off, and renders nothing when the payload
has no fast_mode (older Claude Code).

A malformed fast_mode is dropped instead of failing the payload, so the
rest of the status line still renders.

* refactor(fast-mode): pick the icon in a helper instead of nested ternaries
…oc#755)

* feat(tui): preview the highlighted widget live while picking

Seeing what a widget looks like used to mean adding it, looking, and
removing it again. While the add/insert/change picker highlights a
widget, the status line preview now shows the line exactly as Enter
would leave it; ESC or browsing categories restores the line.

The new widget's id and powerline background are fixed when the picker
opens, so the preview doesn't change color on every keypress and Enter
adds exactly what was shown. Preview and apply share one placement
helper. A type change drops the widget's label there, as the label
editor (sirmalloc#601) does, so the preview shows the line without the old
label, as Enter applies it.

The picker rendered every entry, so the All category (~90 widgets) or a
broad search ran past the bottom of the terminal and scrolled the
preview off the top. Picker lists now show only the entries around the
selection, sized to the terminal height, the number of status lines and
the list's tallest description (wrapped at the terminal width as Ink
wraps it), with "↑ N more" / "↓ N more" markers. Entry numbers stay
absolute, so position in the full list is still visible.

* refactor(tui): find the highlighted picker category with indexOf

The category list holds plain strings, so finding the highlighted one
needs no callback; a picker with no categories has none highlighted.
* feat: add an Extra Usage Limit widget

The Extra Usage widgets show the amount spent, what's left and the
percentage used, but never the monthly limit itself, so a line could
read "Spend Used: $123.45" with no way to show "of $500". Remaining
and Utilization only imply it.

Extra Usage Limit shows the monthly limit from the usage API, in the
reported currency and the cost number format, labeled like the other
Extra Usage widgets ("Overage Limit", or "Spend Limit" on accounts
without plan limits). When extra usage is on without a monthly limit
it shows "none", and when it's off "n/a", with the same hide options as
its siblings. It needs only the extra usage state and the limit, which
come from the fetch the other Extra Usage widgets already make.

* fix: enable Extra Usage Limit label editing

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…imer) (sirmalloc#699)

* feat(usage): forecast the 5-hour window from the recent pace

* feat(usage): keep a per-profile history of session usage readings

* feat(usage): record session usage and forecast once per render

* feat(usage): add the Session Forecast widget

* feat(usage): add the Session Limit Timer widget

* docs: describe Session Forecast and Session Limit Timer

* test(usage): load Session Limit Timer's test the way the reset timer tests do

* fix(usage): ignore a lower session percent from a stale session

* test(usage): check the forecast's place mid-line, and document separate placement

* test(usage): check the TUI preview never records a forecast reading

* refactor(usage): call the forecast widgets Block Forecast and Block Limit Timer

ccstatusline uses "session" for two things: the 5-hour usage window and
the Claude Code conversation (Session Clock, Session Cost, Session Name).
The timers for the 5-hour window already say "block" (Block Timer, Block
Reset Timer), so these two say it too: Session Forecast becomes Block
Forecast (`block-forecast`) and Session Limit Timer becomes Block Limit
Timer (`block-limit-timer`), with their files, classes, descriptions and
docs. Neither has been released, so no saved setting needs migrating.

The helpers that record and forecast the window keep their names
(session-forecast.ts, session-usage-history.ts, the history file): they
follow the usage data's `sessionUsage` field, which isn't user-facing.

* test(block-forecast): check the line without the usage widget's label

The test placed Block Forecast after Session Usage and matched the line
against "Session: 42.0%", so it pinned that widget's label rather than
the forecast's place on the line. Show the usage widget's raw value
instead; the test checks the same thing whatever the label is.

* docs(block-forecast): refer to the 5-hour usage widget without its name

The docs said the forecast widgets sit right after Session Usage, which
another change renames Block Usage. Describe it as the 5-hour usage
widget instead, so the text reads right with either name; two test
titles follow.

* fix(block-forecast): label the limit timer as a countdown

Block Limit Timer printed `Limit: 1hr 13m`, which reads as the size of
the limit rather than the time until it's reached. It now prints
`Limit in: 1hr 13m` (`Limit in: 1h13m` short). Raw value mode is
unchanged.

* fix(usage): stop the forecast history's temp files piling up

The 5-hour usage history is saved by writing a temp file named after the
process id and renaming it over session-usage-history.json. When the
rename failed, for example with EPERM while a Windows virus scanner or
sync client held the new file, the error was ignored and the temp file
stayed. Every status line render is a new process with a new temp name,
so ~/.cache/ccstatusline gained another session-usage-history.json.<pid>.tmp
about once a minute, and nothing ever removed them.

A failed write now unlinks its temp file, ignoring a failed unlink. The
handle that broke the rename can refuse that unlink too, and earlier
builds have already left orphans behind, so each write also removes the
history's other <pid>.tmp files that are a minute or more old. The age
keeps it off a temp file another render is still writing. While a handle
stays held, only the last minute's temp files remain, and those from
earlier builds go on the next write.

Each render keeps its own temp name, so two renders writing at once
never write into the same file.

* fix(usage): forecast an idle session's block from the latest reading

Sessions of one Claude config directory share the 5-hour history. An
idle session's payload keeps the percent from its own last response, so
while another session took the account from 30% to 50%, the idle one
still said 30%. The history already ignores that lower percent, but the
forecast still put it at now, after the 50% reading. The rise over the
last 30 minutes came out negative or too small, so in the idle session
Block Forecast and Block Limit Timer showed nothing, or a projection and
limit time well behind the active session's for the same account.

Usage never falls within a block, so the forecast now goes from the
higher of the session's percent and the latest reading already seen. It
hands that percent on, and Block Forecast compares its projection with
it rather than with the session's own when deciding whether the
projection only repeats the current value, so both sessions hide the
same near-flat projection.

* fix(usage): keep another login's 5-hour block apart in the forecast history

The forecast history is kept per Claude config directory, and a reading
joined any window of it whose reset time was within an hour. After
/login to another account in the same config directory, that account's
block resets at a different time, and when that was within the hour of
the first account's reset, its readings landed in the first account's
window. Being lower than that account's last reading, they were all
dropped as a stale session's, and Block Forecast and Block Limit Timer
showed nothing, then too little once the new account passed the old
one's percent, until the old block reset.

One block's reset only differs between the usage API and the status
payload by fractions of a second, so a reading now joins a window only
when its reset is within a minute. Another login's block gets a window
of its own, unless both blocks reset at the same moment. The 5-hour
resets the usage API has sent so far fall on a whole 10-minute mark; if
blocks are rounded that way, two unrelated ones can share a reset
exactly, and no tolerance tells those apart.

* fix(usage): stop an idle session's refresh renders from flattening the forecast

Claude Code's refreshInterval, 10 seconds on a default install,
re-renders an idle session with the 5-hour percent from its last
response. An unchanged percent moved its reading's last-seen time to now
once a minute, so a session idle from 12:50 to 14:30 recorded "20% until
14:29". When usage on claude.ai or another machine meanwhile took the
account to 60%, the next response's reading made the whole climb look
like it happened in the last 30 minutes, instead of being spread over
the gap as a stretch without renders is: Block Limit Timer said "Limit
in: 30m" rather than 1hr 40m, and at 40% Block Forecast said →100.0%
with a limit time instead of →70.0%.

A percent from the payload now moves its reading's last-seen time no
later than the session's last API response: the timestamp of the
transcript's last assistant message, leaving out the ones Claude Code
writes itself, such as an API error's. The transcript's write time
wouldn't do: a prompt is written before its response arrives, and so is
a local command's output, while the renders they trigger still carry the
old percent. When the transcript holds no response, as a new one after
/clear doesn't until its first, the percent is from before it began, and
the reading's last sighting stays where it was. An idle stretch
therefore stays a gap. A percent from the usage API, used when the
payload has none, is current and still moves the reading to now. The
transcript is only read once a reading's last sighting is a minute old.

* fix(usage): keep a carried-over bar off the forecast widgets' editor rows

Changing a widget's type in the line editor keeps its metadata. Coming
from Block Usage or a reset timer in a bar mode, Block Limit Timer's row
read "(long bar, inverted)" while it rendered "Limit in: 1hr 13m", and
Block Forecast's "(long bar, remaining)" while it rendered "→16.8%".
Neither widget draws a bar or offers (p) to clear one, and the bar mode
also hid "compact" on Block Limit Timer, which still applied it.

Both rows now go through a shared helper for text-only usage widgets
that leaves out the bar mode, and inverting its fill unless the widget
shows used or remaining, before naming the modifiers.

* fix(usage): date a cached usage API percent by when the API sent it

When the payload carries no 5-hour percent, the forecast history took
the usage API's as seen at the render. fetchUsageData serves its cache,
though: up to three minutes old normally, and during a rate limit or an
API failure, whatever the cache holds, which can be hours old. Each
render then moved that old reading's last sighting to now. The history
showed the percent unchanged until fetching recovered, which flattened
the forecast meanwhile, and the climb since then looked like it happened
in the last few minutes, which exaggerated the pace afterwards.

fetchUsageData now keeps when the API sent the values it returns: now
for a fresh answer, the cache file's write time for a cached one.
getUsageFetchedAtMs exposes it, and the history dates an API percent by
it, as it already dates a payload percent by the transcript's last
response. The stretch since then stays a gap that a later change is
spread across.

A new percent is now also dated by when it was seen rather than when it
is first written, from either source: a profile can find another
profile's fetch in the shared usage cache, say. It is never dated before
the last reading's last sighting.

* fix(usage): lock the forecast history while a render rewrites it

The renders of every session and profile share
session-usage-history.json. A render read the whole file, added its
reading and renamed a new file over it, so when two renders overlapped,
the later rename put back what it had read: the other render's new
reading was lost, or a whole profile it had just added. Unique temp file
names only kept the JSON from tearing.

A render with something to write now creates
session-usage-history.json.lock exclusively, reads the history again
under it, writes, and removes the lock. A render that finds the lock
held doesn't wait: it forecasts from what it read and leaves the write
to its next render, which records the same percent with the same time,
since a reading is dated by when its percent was seen. A lock more than
10 seconds old, or dated that far ahead, was left by a render that died
holding it, and is taken over. Renders with nothing to write don't take
the lock.

* fix(usage): keep each login's 5-hour block apart in the forecast history

The forecast history was kept per Claude config directory, and a reading
joined the window whose reset was within a minute of its own. After
/login to another account in the same directory, that account's block
can reset at the same moment as the last one's, since the usage API's
resets fall on whole 10-minute marks. Its readings then joined the
first account's window, where a lower percent counted as a stale
session's and was dropped, and the forecast went from the first
account's percent: switching from an account at 100% to one at 10% hid
Block Forecast and Block Limit Timer until the reset.

The history now goes by config directory and login, the login being the
usage cache's fingerprint of its refresh token (sirmalloc#536). That stays the
same when Claude Code reissues the access token every few hours, and
changes with /login. getUsageLoginFingerprint reads it through the usage
widgets' credential lookup, including its 30-second backoff after
finding no login. Without one (a token from the environment, say), the
history goes by the config directory alone, as before.

On macOS that is one keychain read per render that has a forecast widget
and a 5-hour percent; a render whose percent comes from the payload
didn't read the keychain before.

* docs(block-forecast): drop the advice against merging Block Usage into it

The docs said to add Block Forecast and Block Limit Timer as separate
widgets, since a merge carried on across a widget that rendered nothing:
a hidden forecast joined Block Usage to whatever came next. Since sirmalloc#698,
a merge ends at a hidden widget that isn't merged onward, so merging
Block Usage into the forecast (with the forecast's label set to ` →`,
say) reads as one value while the forecast shows and leaves the line as
it was while it's hidden.

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…alloc#709)

* fix(git): preserve exact filenames when counting conflicts

* test(git): isolate real-index conflict cache in temporary homes

Spy on os.homedir using the existing useTempHome pattern and restore spies after each case. Verify the real helper cache uses the temporary home and that fixture directories are removed.

Agent-Signature: provider=codex model=gpt-6.1-sol agent=ccstatusline/codex session=oc-wisp-s9dpgh task=occ-x9f6

* test: adapt conflict checks to Git hardening and isolate real Git probes

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
vishnujayvel and others added 26 commits October 29, 2026 12:23
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
… off (sirmalloc#713)

Enabling Powerline mode called buildEnabledPowerlineSettings(settings, true),
which filtered every separator item out of every line. Disabling only flipped
powerline.enabled back to false and never restored anything, because the
separators were already gone from settings.lines by then. A user who tried
Powerline and backed out permanently lost that part of their configuration.

renderer.ts already treats flex-separator widgets as inert-but-present during
Powerline rendering: filtered from output, still used as merge-run boundaries
(isSeparatorBoundary). Extend that existing, shipped treatment to separator
widgets instead of deleting them, so manual separators survive an enable/
disable round trip exactly as flex separators already do. This is not a new
renderer behavior, just applying the renderer's existing symmetry to a
sibling widget type.

- buildEnabledPowerlineSettings no longer takes removeManualSeparators; lines
  are never filtered, so the parameter had no remaining use.
- Update both PowerlineSetup.tsx call sites to the new signature.
- The enable confirmation dialog's warning said enabling would delete manual
  separators, which is no longer true. Reword it to describe what actually
  happens: separators stay in the configuration but aren't rendered while
  Powerline is enabled.

Fixes sirmalloc#531.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ne (sirmalloc#532)

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…g token authentication (sirmalloc#266)

The usage cache (usage.json) and lock file (usage.lock) were shared
  across all Claude Code profiles, causing session-usage, weekly-usage,
  reset-timer, and weekly-reset-timer widgets to display data from the
  wrong account when CLAUDE_CONFIG_DIR pointed to a non-default profile.

  Replace the hardcoded file paths with per-profile paths using a
  SHA-256 hash of the resolved CLAUDE_CONFIG_DIR, matching the existing
  pattern used by getBlockCachePath() in jsonl-cache.ts.

  Claude Code stores per-profile credentials in the macOS keychain using the service
  name Claude Code-credentials-{sha256(configDir)[:8]}. For ~/.claude-boost, that's
  Claude Code-credentials-ac4e02fa. The old code never looked up this specific entry —
  it only tried the generic Claude Code-credentials service, then sorted all candidates by mtime.

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…irmalloc#724)

* fix(account-email): show the account the live credentials belong to

The Claude Account Email widget read oauthAccount.emailAddress from
.claude.json. Claude Code writes that record at login and does not
rewrite it when the credentials change underneath, so after an account
switch the widget could keep naming the previous account, next to usage
widgets that read the live credentials and report on the new one.

The email now comes from GET /api/oauth/profile, requested with the
usage credentials and the usage request's headers, proxy rules and
deadline. Answers are cached in ~/.cache/ccstatusline/account-email.json
(owner-only) under the usage cache's login fingerprint, so an
access-token refresh reuses them and only a login change triggers
another request. Up to ten logins are kept, so profiles used side by
side do not evict each other. A failed lookup backs off for that login
only: 30 seconds, or after a 429 the server's Retry-After. Without an
OAuth login, or until a lookup succeeds, the widget falls back to
.claude.json as before.

Fixes sirmalloc#538

* Fix concurrent account-email lookups and cache updates

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…oc#641)

* fix(hooks): dispatch wrapped hook payloads without rendering

When a configured statusLine command is wrapped (e.g. bash -c '...'),
syncWidgetHooks() appends --hook outside the wrapper's quotes, so the
inner ccstatusline never sees the flag. The hook JSON then arrives on
the piped-stdin path and renders as status output.

Detect supported hook envelopes (PreToolUse / UserPromptSubmit with a
non-empty session_id) before StatusJSONSchema validation and route them
through the existing handleHookInput, without rendering. Explicit --hook
mode is unchanged.

Refs sirmalloc#623

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(hooks): type parsed hook payload as unknown

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
… icon (sirmalloc#605)

The Nerd Font "off" glyph was U+F6AC, a Nerd Fonts v2 codepoint from the
Material Design range that v3 moved to the supplementary plane. Every v3
font renders it as a missing-glyph box, so `remote-control-status` showed
tofu whenever remote control was off while the "on" icon (nf-fa-wifi,
U+F1EB) rendered fine.

Use nf-md-wifi_off (U+F05AA), the v3 location of the same icon and the
natural pair for nf-fa-wifi. Checked against glyphnames.json from Nerd
Fonts 3.5.1: U+F05AA is md-wifi_off and U+F6AC has no glyph.

Fixes sirmalloc#597

Co-authored-by: D-L-Narayana <249577150+D-L-Narayana@users.noreply.github.com>
…loc#731)

* feat(remote-control-status): editable Nerd Font on/off glyphs

The Nerd Font icons are fixed per state, so users whose font lacks a glyph
or who want a matching pair (e.g. nf-md-wifi / nf-md-wifi_off) cannot change
them. Add On/Off symbol slots edited with `g`, offered only while the Nerd
Font icons are visible. Defaults are unchanged; a blank glyph collapses the
icon-text spacing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyaChaGiwULZ4ARcDzPGUP

* refactor(remote-control-status): drop comments to match the widget's style

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyaChaGiwULZ4ARcDzPGUP

* Fix remote control glyph spacing and support editable status labels

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…irmalloc#537)

installStatusLine captures the existing statusLine.refreshInterval before
replacing the statusLine object, but only re-applies it when
supportsRefreshInterval is true.

getClaudeCodeVersion() returns null - and isClaudeCodeVersionAtLeast()
therefore false - whenever `claude --version` cannot be run at all: not on
PATH, the 5s timeout elapses, the output does not match the version regex,
or the process exits non-zero. In those cases a re-install silently drops a
refreshInterval the user had set, even when the installed Claude Code does
support the setting.

Keep the captured value in that branch and only apply the default of 10 on
supported versions, matching the behaviour documented in sirmalloc#297 ("preserve
existing value on re-install").

Add the missing case to the install matrix: existing value + unsupported.
The three existing tests cover supported/unsupported on a fresh install and
supported on re-install, so this regression had no test to catch it.
…alloc#730)

When the status JSON has current_usage but no used_percentage, the used
percentage was worked out from input + output + cache tokens. Output
tokens aren't part of the context the next turn reads, and the Context
Length and Context Bar widgets already leave them out, so Context % read
higher than those widgets for the same data. Work it out from the
context length (input + cache creation + cache read) instead.

Co-authored-by: effectustasi <216187407+effectustasi@users.noreply.github.com>
…c#735)

* fix(speed): count each API call once in token speed widgets

Claude Code can write one API call as several transcript entries that
share a message.id and repeat its usage. The speed collector added every
entry as its own request, so Input, Output and Total Speed summed the
call's tokens once per entry. Entries sharing the previous request's
message.id now update that request instead.

* Fix Node.js 14 compatibility in speed metric deduplication

---------

Co-authored-by: effectustasi <216187407+effectustasi@users.noreply.github.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
Claude Code writes one JSONL entry per content block of a single API
call, all sharing one message.id. The cumulative token path summed
every entry, inflating Tokens Input/Output/Total by up to ~1.84x
(2.02x in the independent report on sirmalloc#549). Group consecutive counted
entries sharing an id: the prompt side counts once, output_tokens as
the running max.

- transcripts without duplicate-id entries are unchanged (existing tests)
- streaming partials resolve to the finalized value, counted once
- sidechain rows keep their treatment in sums and recency; grouped by
  their own id so one subagent call counts once per content block

Closes sirmalloc#549

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…oc#622)

* fix(speed): count each API response once when it is split across content blocks

A response is logged as one record per content block (thinking / text /
tool_use) sharing a message id, so summing usage over every record counted one
response once per block. Main transcripts repeat the same usage on each record;
subagent transcripts repeat the input and zero the output until it settles.
Across 41 real sessions this inflated output tokens 2.47x and input tokens
2.92x, over-reporting throughput by the same factor (408.2 t/s against a true
154.7 t/s on a 39 MB session).

Count a message id's usage once, from its last record, which is the one
carrying the response's final usage in both shapes. Earlier records keep
contributing their intervals, so the active duration is unchanged. requestCount
now counts responses rather than records.

Records without a message id cannot be grouped and keep the previous
one-record-per-request behaviour, so older transcripts are unaffected.

The token widgets share this duplication through collectTokenMetricRecord;
left for a separate change.

* fix(tokens): count each API response once in token totals

The token metrics carried the same duplication as the speed metrics: a response
is logged once per content block with its usage repeated on every record, and
collectTokenMetricRecord accumulated each one.

Its stop_reason guard is meant to skip intermediate streaming entries, but those
do not exist in main transcripts - Claude Code writes the records after the
response settled, so 4474 of 4477 records already carry the final truthy
stop_reason and the guard never fires. README.md has promised the deduped result
since v2.2.9 ("Streaming duplicate JSONL entries are deduped so token widgets do
not overcount").

Count a message id once instead, replacing an earlier record's share when a later
one arrives, since the record written last holds the response's final usage. That
map is reset together with the totals it describes, so no share can outlive them.
Entries without a message id keep the previous behaviour.

Across the 42 main transcripts this removes 2.79x of output tokens, 2.90x of
input tokens and 2.53x of cached tokens, while contextLength is untouched.

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…hift+Tab (sirmalloc#593)

Blanking a multi-symbol widget to save width on the status line was one
Backspace per row, five of them on Cache Timer. Shift+Tab now blanks every
slot, and blanks back to the defaults, so the key is reversible.

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…loc#594)

`gray` is not a COLOR_MAP entry, and getColorAnsiCode returns '' on a
lookup miss, so every site that fell back to it rendered with no color:
the default separator path in the renderer and the default color of the
Git SHA, Terminal Width and Version widgets. Use `brightBlack`, the
neutral entry the map actually has.

The Ink `<Text color='gray'>` usages in the TUI are untouched; Ink resolves
that name itself.

Closes sirmalloc#540

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…loc#736)

* fix(usage): drop stale cached windows that have already reset

When the usage API fails (429, timeout, network), ccstatusline falls back
to the last cached response however old it is, including windows whose
reset time has passed. After a night away, a 429 on the first fetch kept
"Session: 95%" on screen next to a 0m reset timer, for as long as the
Retry-After backoff lasted, although that window had reset hours ago.

A stale window whose resets_at has passed is now left out, as Claude Code
does in rate_limits, so the widget reports the fetch error (or falls back
to transcript blocks for the reset timer) instead of the old usage.

* fix(usage): preserve valid fields in partial stale fallbacks

---------

Co-authored-by: effectustasi <216187407+effectustasi@users.noreply.github.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…malloc#696)

formatBytes picked the unit from the raw value and rounded afterwards, so
values just under a boundary rendered as the boundary itself in the smaller
unit: 1023.6 MiB used showed "1024M" instead of "1.0G", and 1023.6 KiB
showed "1024K" instead of "1M". Promote once the value would round up at
the displayed precision, as formatTokens and formatSpeed already do.

Co-authored-by: effectustasi <216187407+effectustasi@users.noreply.github.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
sirmalloc#576)

* fix(cache-timer): use Claude Code's reported cache expiry when present

The widget inferred the countdown from the newest assistant row's timestamp
plus a configured TTL, because - as its comment said - the expiry was never
exposed. Claude Code now ships a prompt_cache object in status JSON carrying
warm, caching_observed, ttl and the real expires_at, so the guess is no longer
necessary.

Two things the inference gets wrong, both fixed by reading the reported value:

  - A resumed session shows HOT while a user row sits at the tail, even when
    the cache lapsed hours ago. That is exactly backwards from what the widget
    is for: the moment you most need to know the cache is cold is when you come
    back to an idle session.
  - A turn that read an entry written much earlier restarts the countdown from
    the wrong anchor.

Verified against real payloads with the same settings:

  warm    before: Cache: HOT     after: Cache: 34:46
  cold    before: Cache: HOT     after: Cache: COLD
  legacy  before: Cache: HOT     after: Cache: HOT

The third case has no prompt_cache field and falls through to the existing
transcript logic unchanged, so older Claude Code versions keep working.

StatusJSON gains a permissive prompt_cache schema; every field is optional and
nullable, and caching_observed === false is treated as no signal rather than as
cold.

Assisted-by: claude:claude-opus-5

* fix(cache-timer): use reported TTL for state glyphs

* docs(cache-timer): explain reported state and TTL fallback

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…irmalloc#762)

With the time cursor on, the usage bars always placed it at the share
of the window that had elapsed, even when the bar showed the remaining
percentage. The remaining bar shrinks as the window runs out while the
cursor moved the other way, so the two crossed mid-window whatever the
pace: at 30% used with 20% of the block gone, the bar showed 70% left
with the cursor at 20%, inside the fill.

In remaining mode the cursor now marks the share of the window left,
as Block Timer already does for its inverted bar, so the fill falling
short of the cursor means the quota is running out faster than time.

Co-authored-by: effectustasi <216187407+effectustasi@users.noreply.github.com>
…irmalloc#761)

* fix(tui): mark Full width always as the default terminal width mode

New settings default flexMode to "full" since sirmalloc#590, and the README and
USAGE docs call Full width always the default. The Terminal Width menu
still tagged "Full width minus 40" with "(default)" whenever it was not
the active mode, and gave "Full width always" no tag at all, so the
editor pointed users at the old default.

Take the tag from DEFAULT_SETTINGS.flexMode instead of hardcoding it,
so the menu shows "(default)" next to Full width always and can't drift
from the schema again. The active mode still shows "(active)".

* fix(tui): state the 6 columns Full width always reserves

The Terminal Width menu described "Full width always" as using the full
terminal width minus 4 characters, but the renderer reserves 6 columns
in that mode, both in the preview and in the status line itself. Say 6
in the description.

The test renders a flex line in full mode and checks that the menu text
names the number of columns the renderer left unused.

---------

Co-authored-by: effectustasi <216187407+effectustasi@users.noreply.github.com>
…oc#757)

* fix(session-name): use automatic titles as a fallback

* test(session-name): cover title precedence in both readers

* docs(session-name): describe automatic title fallback

* docs: explain session title precedence
* feat: add clickable current directory links

* fix(links): preserve literal backslashes in POSIX IDE paths

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
# Conflicts:
#	src/utils/__tests__/global-package-manager.test.ts
@sonarqubecloud

Copy link
Copy Markdown

@eric-engberg
eric-engberg marked this pull request as ready for review November 5, 2026 23:29
@eric-engberg

Copy link
Copy Markdown
Owner Author

Merged upstream as sirmalloc#804.

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.