Skip to content

fix: report Uninstall and Refresh Interval failures when Claude's settings.json can't be changed - #103

Closed
eric-engberg wants to merge 99 commits into
developfrom
fix/claude-settings-write-failures
Closed

eric-engberg wants to merge 99 commits into
developfrom
fix/claude-settings-write-failures

Conversation

@eric-engberg

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

Copy link
Copy Markdown
Owner

Standalone, cut from main.

What

When the TUI can't change Claude Code's settings.json, it now reports a failure instead of claiming success:

  • Uninstall. If settings.json can't be read or parsed, Uninstall fails with "✗ Uninstall failed". The TUI stays installed and the global npm/bun package is left alone. Before this change, npm uninstall -g / bun remove -g ran anyway and the TUI flashed "✓ Uninstalled from Claude Code and removed global package", while statusLine, the managed --hook hooks and the installation metadata were all still there. Uninstall also fails now when statusLine is already gone but the managed --hook hooks are still in a settings.json that can't be written, for example a read-only one. That failure used to be ignored, so the package was removed and the hooks were left running it. A hook entry in a shape Claude Code doesn't expect, such as one written by hand, is left as it is and no longer stops the cleanup.
  • Refresh Interval. If settings.json can't be read or parsed, or no longer has a statusLine, Configure Status Line shows "✗ Failed to save refresh interval" and keeps the value that is still in effect. Before, it flashed "✓ Refresh interval updated" and showed the new value although nothing was written.

Why (evidence)

Both writers caught the failure and returned normally:

  • uninstallStatusLine printed "Warning: Could not read existing Claude settings." and returned. It also ignored any failure to remove the managed hooks, which is the only write to settings.json when statusLine is already gone.
  • setRefreshInterval returned without a word when the read failed and when there was no statusLine.

The TUI treats a normal return as success. It only takes its existing failure paths ("✗ Uninstall failed", "✗ Failed to save refresh interval") when the call rejects, so these cases never reached them.

The TUI reads settings.json once at startup, and only offers Uninstall and Configure Status Line if the file parsed and had our statusLine. So hitting this takes the file changing while the TUI is open, for example a hand edit saved with a trailing comma in another window. Reproduced on main, as an Ink test and in the demo below:

  • Uninstall (pinned npm install, trailing comma added with the confirm dialog open):
    • The warning lands on top of the TUI and npm uninstall -g ccstatusline runs.
    • The flash reads "✓ Uninstalled from Claude Code and removed global package" and the menu switches to "📦 Install to Claude Code".
    • settings.json is unchanged. Once its JSON is fixed, statusLine.command is still ccstatusline, the binary that was just removed, so Claude Code shows a blank status line. The --hook hooks point at it too.
    • The settings-only options said "✓ Uninstalled from Claude Code" while removing nothing.
  • Refresh Interval (refreshInterval: 10; the file given a trailing comma, or rewritten without statusLine): "✓ Refresh interval updated" and "(15s)", while settings.json still says 10.

installStatusLine already refuses to touch an unreadable settings.json (sirmalloc#627), and the TUI reports that as "Install Failed". Uninstall and Refresh Interval now do the same.

How

  • In claude-settings.ts, the read guard installStatusLine had inline moves into loadClaudeSettingsForUpdate(retry). It throws "Could not read existing Claude settings at (). The file was left unchanged; fix or remove it, then ." Install, uninstall and setRefreshInterval use it; install's message is unchanged.
  • uninstallStatusLine throws before removing anything, so the metadata and hooks stay in place. It now removes statusLine and the managed hooks in one write, before it clears the metadata, so a failure to save rejects with both still in the file and the metadata kept. With neither in the file it doesn't write at all, so Uninstall still goes ahead when statusLine is already gone and no managed hooks are left, even if the file can't be written, since nothing would be left pointing at the binary. The .bak copy now holds the file as it was before the uninstall.
  • The hook cleanup leaves alone what isn't ours: an event whose value isn't a list, an entry that isn't an object, or an entry whose hooks isn't a list. Before, such an entry threw a TypeError, which uninstall ignored.
  • setRefreshInterval also throws when there is no statusLine, including when clearing the interval: the TUI's idea that ccstatusline is installed is out of date in that state.
  • The TUI code is unchanged; its existing failure paths handle both cases. For Uninstall the catch runs before any settings were removed, so it skips the global package removal, shows "✗ Uninstall failed" and stays on the uninstall screen. For Refresh Interval the .catch puts the previous value back.
  • The stderr warning that used to land on top of the TUI is gone.

Demo

The TUI runs in the top of a tmux split. The shell below adds a trailing comma to ~/.claude/settings.json mid-session. npm is a stub that only records npm uninstall -g ccstatusline.

Uninstall. Before: the warning bleeds in, "✓ Uninstalled from Claude Code and removed global package", and the stub log shows npm ran. After: "✗ Uninstall failed", it stays on the uninstall screen, and npm never runs.

Powerline

Before:

Uninstall with a broken settings.json before the fix, Powerline

After:

Uninstall with a broken settings.json after the fix, Powerline

Plain

Before:

Uninstall with a broken settings.json before the fix, Plain

After:

Uninstall with a broken settings.json after the fix, Plain

Refresh Interval. Before: "✓ Refresh interval updated" and "(15s)" on reopening, while the file still says 10. After: "✗ Failed to save refresh interval", "(10s)" on reopening, and the file is unchanged.

Powerline

Before:

Refresh Interval with a broken settings.json before the fix, Powerline

After:

Refresh Interval with a broken settings.json after the fix, Powerline

Plain

Before:

Refresh Interval with a broken settings.json before the fix, Plain

After:

Refresh Interval with a broken settings.json after the fix, Plain

Testing

  • 11 tests fail on main and pass here:
    • claude-settings.test.ts: 3 changed, 4 new. Uninstall rejects and leaves the file and metadata alone, both when the file can't be parsed and when managed hooks are left in a read-only file. With neither statusLine nor managed hooks in a read-only file, Uninstall resolves, clears the metadata and doesn't try to rewrite the file. Uninstall keeps hook entries it doesn't recognize, and its .bak copy is the file from before the uninstall. setRefreshInterval rejects on an unreadable file and on a missing statusLine.
    • App.test.ts: 4 new Ink tests. Uninstall shows "✗ Uninstall failed", never calls the global uninstall, and leaves the file, the metadata and the installed state alone. That holds with a settings.json that no longer parses, and with a read-only one that has lost its statusLine but kept the managed hooks. Refresh Interval with an unparseable file or no statusLine shows the failure flash, keeps "(10s)" and leaves the file unchanged.
  • bun test: 3176 pass, 0 fail (178 files). bun run lint: clean.
  • Node vitest on the two changed files: 135 pass / 11 fail here, 127 / 11 on main. The same 11 tests fail on both (spying on node:child_process execSync isn't allowed under Node ESM). hooks.test.ts, whose source changed, passes 7 / 7 on both.
  • Built CLI under Bun 1.4.2 and Node v26.10.0: piped renders agree and match main in plain and Powerline. The TUI opens and exits cleanly on both.

uninstallStatusLine caught a load failure (invalid JSON such as a
trailing comma, or a permission error), printed a warning and returned
normally. The TUI's uninstall action treats a normal return as "Claude
settings removed", so it went on to run `npm uninstall -g` or
`bun remove -g`, switched the menu to not installed and flashed
"✓ Uninstalled from Claude Code and removed global package". Nothing
had been removed from settings.json: once the file was fixed,
statusLine still ran the global binary that had just been uninstalled,
which leaves a blank status line, and the managed --hook hooks and the
installation metadata were still there. The settings-only options
reported "✓ Uninstalled from Claude Code" the same way.

The TUI only offers Uninstall when settings.json was readable at
startup, so this takes the file breaking while the TUI is open, for
example a hand edit saved with a syntax error in another window.

uninstallStatusLine now throws on that failure, as installStatusLine
already does, with the same message naming the file and the reason
from a loader the two share. The TUI's existing failure branch then
runs: it shows "✗ Uninstall failed", keeps the installed state and
skips the global package removal. settings.json, the metadata and the
hooks are left as they were.
setRefreshInterval returned normally when Claude's settings.json could
not be read or parsed, and when it had no statusLine, without writing
anything. Configure Status Line sets the new value before the save and
only puts the old one back if the save rejects, so in both cases it
flashed "✓ Refresh interval updated" and kept showing the new value,
while Claude Code went on using the old one.

As with Uninstall, the menu is only enabled when settings.json was
readable and had our statusLine at startup, so this takes the file
changing while the TUI is open: a hand edit that leaves invalid JSON,
or another tool replacing the file.

setRefreshInterval now throws in both cases, the read failure with the
message install and uninstall use. The TUI's existing failure branch
then shows "✗ Failed to save refresh interval" and keeps the value
that is still in effect. The file is left unchanged.
uninstallStatusLine ignored any failure to remove the managed --hook
hooks. When statusLine was already gone from Claude's settings.json,
so nothing was written before that step, and the file couldn't be
written (read-only, for example), the hooks stayed in place and the
uninstall returned normally. The TUI then ran `npm uninstall -g` or
`bun remove -g` and flashed success, leaving hooks that run the binary
it had just removed. The installation metadata was cleared before the
hooks were tried, so it was gone as well.

The hook cleanup failure now rejects the uninstall, and the hooks are
removed before the metadata is cleared, so the TUI's existing failure
branch shows "✗ Uninstall failed", skips the global package removal
and leaves settings.json and the metadata as they were.

removeManagedHooks no longer rewrites settings.json when it holds none
of our hooks. An uninstall with neither statusLine nor managed hooks in
the file still goes ahead even when the file can't be written, since
nothing in it would be left pointing at the binary. It also stops
reformatting the file and refreshing its .bak copy for no change.
… read

Removing the managed hooks assumed every hook event held a list of
objects, each with a list of hooks. A hand-edited settings.json can
break that, for example a Stop hook written as an object, a null entry,
or "hooks" set to a string. The cleanup then threw a TypeError, even when
none of the hooks were ccstatusline's, and since the previous change
that error rejects the uninstall. statusLine had already been removed in
an earlier write, so the TUI showed "Uninstall failed" with statusLine
gone, and every retry failed the same way.

Entries without the expected shape are now left as they are, since they
can't be ours. Uninstall also removes statusLine and the managed hooks
in one write, so a failure to save leaves both in place, and the .bak
copy holds the file as it was before the uninstall rather than the
halfway state between the two writes.
@eric-engberg eric-engberg changed the title fix: report Uninstall and Refresh Interval failures when Claude's settings.json can't be read fix: report Uninstall and Refresh Interval failures when Claude's settings.json can't be changed Oct 16, 2026
dependabot Bot and others added 25 commits October 26, 2026 12:15
…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>
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>
dependabot Bot and others added 17 commits November 5, 2026 10:39
…alloc#795)

Bumps the dev-dependencies group with 3 updates: [@remotion/cli](https://github.com/remotion-dev/remotion), [remotion](https://github.com/remotion-dev/remotion) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


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

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

Updates `typescript-eslint` from 8.71.2 to 8.72.0
- [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.72.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@remotion/cli"
  dependency-version: 4.0.539
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: remotion
  dependency-version: 4.0.539
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.72.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat(context-bar): add a thin line display mode

Block glyphs (█░, ▓░) read heavy in light themes and plain-text layouts.
The new 'line' mode draws used context as ━ and the rest as ─, at the
same 16-cell width as the medium bar. It joins the end of the p cycle,
so existing configurations are unchanged.

* feat(widgets): extend line mode to all progress bars

---------

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

With no reset time from the status line payload or the usage API, Block
Reset Timer falls back to the 5-hour block found in the transcripts. In
timestamp mode it then formatted the missing sessionResetAt, got
nothing, and showed the remaining duration ("Reset: 3hr 45m") instead of
the reset time the user chose to see.

The block's end is known, so timestamp mode now formats that instant
when no reset time was reported.

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

* test(git): cover grapheme clusters in branch abbreviations

* test(git): cover grapheme clusters in worktree abbreviations

* fix(git): preserve complete graphemes in fish-style branch names

* fix(git): preserve complete graphemes in fish-style worktree names
…irmalloc#787)

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

The Terminal Width menu still marked "Full width minus 40" as
"(default)" after the settings default for flexMode moved to "full".
On a fresh install, or with any settings.json that has no flexMode,
the menu showed "Full width always (active)" with "Full width minus 40
(default)" below it. Picking the "(default)" entry to get back to the
default saved full-minus-40 instead.

"Full width always" now carries "(default)" while another mode is
active, following the rule the old marker used, and "Full width minus
40" shows only "(active)". A test checks the marker against
DEFAULT_SETTINGS, so a later change to the default can't leave the
menu behind again.

* fix(tui): say Full width always reserves 6 columns, not 4

The Terminal Width menu's description of "Full width always" says it
uses the full terminal width minus 4 characters. The renderer has
reserved 6 columns for that mode, in the preview and in the piped
status line, since the reserve changed from 4 to 6, and the menu text
wasn't updated with it. At 100 columns a long line truncates, and a
flex separator fills, to 94 columns rather than the 96 the menu
promises.

The description now says 6, matching the renderer. The renderer is
unchanged. The test renders a long line in that mode at 100 columns
and checks that the description names the number of columns the
renderer left free, so a later change to the reserve can't leave the
menu behind again.

* fix(tui): put the space back in the delete-line dialog's heading

Pressing d in Edit Lines opened a confirmation headed "☰ Line2 (2
widgets)", while the list and move mode both show "☰ Line 2". In the
dialog's JSX, "☰ Line" and the line number sit on separate lines, and
JSX drops whitespace that contains a line break, so nothing separated
them. The {' '} that used to sit between them was lost when the menus
moved to the shared List component.

The heading puts {' '} between the two again, as move mode does. The
existing delete test accepted the heading with or without the space;
it now expects "☰ Line 2 (empty)" and "☰ Line 1 (1 widget)", and waits
for the dialog before checking the second, since the list row behind
it has the same text.
…sts (sirmalloc#788)

The line editor and color menu tests slept a fixed 25ms after rendering
and after each key, then asserted. Ink redraws, and React re-attaches
Ink's input listener, on their own schedule. On a loaded machine the
assertion ran before the redraw, or the next key reached the listener
left from before the last redraw and was applied to the old state.
Under load these tests failed intermittently, the number style and
label tests most often.

Each wait now retries until the key's visible effect shows (the row's
new suffix, the label editor's draft, the picker's search text, the
moved highlight), then yields two setImmediate turns before the next
key, through the shared helper in src/tui/__tests__/helpers/wait-for-ink.ts.
Assertions that only restated a wait move into it; the rest are
unchanged.
…t left glyphs entered by hex (sirmalloc#786)

* fix(colors): clear separator colors a new Color Level can't show

Changing Color Level resets each widget's ansi256 or hex color that the
new level can't show to the widget's default, and clears such
backgrounds. A separator's foreground was skipped, because separators
have no widget default to reset to, so it kept its color. A separator
given a hex color at Truecolor still had it after cycling on to 256
Color, and the 256-color line printed a 24-bit escape for it
(\x1b[38;2;0;255;0m) next to the widgets' 38;5 codes.

The reset warning counts separator colors as well, so with only a
colored separator it promised a reset, and confirming it changed
nothing.

Clear a separator foreground the new level can't show, as backgrounds
already are. Without a color, a separator renders in the separator
default, or takes the previous widget's colors when separators inherit
colors.

* fix(powerline): invert a left-facing separator entered by hex code

Picking Triangle Left or Round Left with the arrow keys turns on the
separator's background inversion, which a left-facing glyph needs to
draw the next segment's color on the previous segment's background.
Entering the same glyph with (h), as E0B2 or E0B6, kept the slot's old
inversion instead. On the default separator that is off, so the arrow
was drawn reversed (\x1b[31m\x1b[44m between a red and a blue segment,
instead of \x1b[34m\x1b[41m), and the list showed no [Inverted] to hint
that (t) was needed. Typing E0B0 over an inverted Triangle Left
likewise left the right triangle inverted.

When (h) enters one of the preset glyphs, set its inversion as the
arrow keys do: on for the left-facing ones, off for the right-facing
ones. A custom code point keeps the slot's inversion, since its
direction is unknown, and (t) still toggles it.
…or (sirmalloc#785)

Ink 6.2.0's insertBeforeNode returns early when it inserts a node before
an existing sibling, so it never marks the parent text dirty the way
appendChildNode (and its own append branch) do. Yoga keeps the text's
old measured size, and Ink wraps the line at the old width onto rows
the layout never reserved, where the next row draws over it. It stays
that way until something re-measures the text, such as the next key.

The Custom Command and Link editors render the value as three sibling
texts: the part before the cursor, the cursor cell and the rest. With
the cursor at the start, as in an empty field, the part before it
renders nothing, so the next input inserts a new text ahead of the
cursor cell. Pasting "npx ccusage statusline" into an empty Custom
Command field showed only "npx ccusage", though Enter saved the whole
command. A URL or link text pasted into an empty Link field was cut the
same way, with its cut-off end showing at the end of the next row. A
key typed at the start of a command that was already the editor's
widest row hid the command's last word.

Add a hunk to the ink patch that marks the node dirty after both
branches, as Ink does upstream from 6.8.0.
… at the configured Color Level (sirmalloc#784)

* fix(tui): keep the main menu's highlight when a save changes its entries

Pressing Ctrl+S on the main menu after making changes moved the
highlight back to the entry that was last opened. Saving swaps Save &
Exit and Exit without saving for a single Exit, and List reset its
highlight to initialSelection whenever the number of items changed. The
main menu's initialSelection is the entry last opened, not the one
highlighted, so the cursor left the entry the user had moved to and the
next Enter opened a different screen.

List gets a keepHighlightOnItemsChange prop, and the main menu sets it.
With it, a new initialSelection still moves the highlight, but when just
the number of items changes, the highlight stays on the item it was on;
if that item is gone, it stays on the same row, inside the list. On the
main menu, Install stays Install after a save, and Save & Exit or Exit
without saving become the Exit that replaces them.

Other lists still move the highlight to initialSelection when their
item count changes. Line selection and Powerline Setup keep their
initialSelection on the highlight and set it to place the highlight:
appending a line highlights the new line, and turning Powerline on
highlights Separator.

* fix(tui): build the preview's color codes for the configured color level

In a terminal that chalk detects at fewer colors than the configured
color level, such as tmux with TERM=screen or ssh with TERM=xterm, the
TUI preview and color menus drew different colors from the real status
line. At the default 256 colors, cyan, magenta and bright black widgets
were drawn black; a truecolor setting previewed 256-color
approximations.

chalk works out a 256-color or hex style's escape code when the style
is created, at the level set at that moment. COLOR_MAP creates its
styles when colors.ts loads, so its codes follow the level chalk
detected for the terminal. The piped render sets chalk.level and then
calls updateColorMap() to rebuild them; the TUI set chalk.level on
load, on import and in Terminal Options, but never rebuilt the map.

The TUI now calls updateColorMap() each time it sets chalk.level, as
the piped render does. The test sandbox rebuilds the map when it puts
chalk's level back, so later test files get the codes they expect.

* docs(tui): say the main menu's initialSelection is the highlighted row

The main menu now records its highlighted row as it moves, so
initialSelection is that row's index rather than the entry last opened.
Keeping the highlight on its entry across a save is still needed: the
save swaps Save & Exit and Exit without saving for one Exit, which moves
the entries below, so the index alone would land on another entry. The
comments on MainMenu and List's keepHighlightOnItemsChange say so.

* test(tui): walk the main menu to an entry rather than through a fixed list

The main menu highlight tests pressed down through a hard-coded list of
entries, so an entry another change adds or makes selectable (Configure
Status Line opening before an install, say) broke them. They now press
down until the target entry is highlighted, waiting for the highlight to
move on each press.

* test(app): open Color Level directly, now Terminal Options' first row

With sirmalloc#765 Terminal Options lists only Color Level, so the steps that went
through Terminal Width land on Color Level when the menu opens.
… temp files (sirmalloc#783)

* fix(terminal): stop leaking temp files when the width cache write fails

writeCachedWidth wrote a temp file named after the process id and renamed
it over terminal-width.json inside a catch that swallowed every error.
When the rename failed (EPERM on Windows while a virus scanner or sync
client holds a handle on the file), the temp file stayed behind. Every
render is a new process with a new pid, so each failed write left one
more terminal-width.json.<pid>.tmp in ~/.cache/ccstatusline, and nothing
ever removed them. On Windows this write is routine: probing is disabled
there, so the no-width entry is rewritten whenever it passes the TTL, and
on every render while the rename keeps failing, since a failed write
leaves the old entry stale.

Handle it the way the git cache does since sirmalloc#564: remove the temp file
when the write or rename fails, and use one stable name
(terminal-width.json.tmp), so a cleanup blocked by the same handle leaves
at most one file, which the next write truncates. Writers use it under
the cache lock; one that proceeds unlocked can at worst leave malformed
JSON, which readCache already treats as a miss.

* fix(custom-command): stop leaking temp files when the cache write fails

writePersistentCache wrote a temp file named after the process id and
the current time, and renamed it over the cache file inside a catch that
swallowed every error. When the rename failed (EPERM on Windows while a
virus scanner or sync client holds a handle on the file), the temp file
stayed behind. With customCommandCacheTtlSeconds above 0, nothing reaches
the cache while the rename keeps failing, so every repaint misses, runs
the command again and leaves one more cmd-<hash>.json.<pid>.<ms>.tmp in
~/.cache/ccstatusline/custom-command-cache. Nothing ever removed them.

Handle it the way the git cache does since sirmalloc#564: remove the temp file
when the write or rename fails, and use one stable name
(cmd-<hash>.json.tmp), so a cleanup blocked by the same handle leaves at
most one file per cache file, which the next write truncates. The cost is
the one the git cache accepted: two sessions writing the same directory's
cache at the same moment can tear the shared temp file, and
readPersistentCache treats the malformed JSON as a miss.
…o picker fixes (sirmalloc#782)

* fix(usage): print es-ES's 12-hour day period as AM/PM, not "A M"

With the 12-hour clock in date mode, the Block and Weekly Reset Timers
turn the locale's day period into AM/PM by dropping its dots and
uppercasing it. That works for "am", "pm", "a.m." and "p.m.", but ICU
writes es-ES's as "a. m." and "p. m.", with a space after the first dot,
so the timers showed "10:30 A M CEST" in the status line and the preview.
Locales typed in by hand such as ca, bs and es-AR write it the same way.

Drop each dot together with the space after it, so "a. m." becomes "AM"
like the other forms, while a day period without dots keeps its spaces.

* fix(usage): keep reset dates Gregorian whatever the locale's calendar

In date mode, the Block and Weekly Reset Timers ask Intl for the year,
month and day in the chosen locale and fill them into a YYYY-MM-DD
layout, without saying which calendar to use. ar-SA, which the locale
picker offers, defaults to Gregorian in Node's ICU data but to the
Islamic (Umm al-Qura) calendar in Bun's, where the month comes back as a
name even though two digits were asked for. The same settings then
showed "٢٠٢٦-١٠-١٣ ١٦:٠٥" under npx and "١٤٤٨-جمادى الأولى-٠٢ ١٦:٠٥"
under bunx: a Hijri year with its era dropped and a month name inside
the numeric layout. th-TH (Buddhist year 2569) and fa-IR (Persian year
1405) typed in by hand gave non-Gregorian dates under both runtimes.

Ask for the Gregorian calendar, so the parts always fit the layout and
both runtimes print the same date. The locale keeps its own digits, as
it already does in its zone name and time.

* fix(usage): format reset timestamps in the chosen locale at UTC too

The Block and Weekly Reset Timers offer a locale in date mode whatever
the timezone, and the line editor shows it ("locale: de-DE"). But with
the timezone at its default, UTC, the formatter returned its fixed
English format before it looked at the locale, so the weekday names and
day period stayed English: "Sun 08:30 UTC" for de-DE and "8:30 AM UTC"
for ja-JP, while Etc/UTC, the same zone under another name, gave
"So 08:30 UTC" and "8:30 午前 UTC".

When a locale is set, format UTC through Intl in that locale, as every
other zone already is. The output keeps the UTC format's shape: every
locale names the zone "UTC", and compact mode still ends in "Z". Without
a locale the fixed format is unchanged, and en-US gives the same output
as no locale.

* fix(tui): let 'uk' and 'ca' pick en-GB and en-CA in the locale picker

The reset timers' locale picker searches each listed locale by a few
keywords, and en-GB's include 'uk' and en-CA's 'ca'. It also offers a
search that is a valid locale tag not in the list as a "Use <tag>"
custom row, placed first. 'uk' and 'ca' are valid tags too, for
Ukrainian and Catalan, so typing either put "Use uk" or "Use ca" above
en-GB or en-CA, and since the picker selects the first row after each
keystroke, Enter saved Ukrainian or Catalan formatting.

Put a locale whose keywords include the search ahead of the custom row.
The custom row still comes next, so a Ukrainian or Catalan tag is one
Down away, and any other tag the list doesn't offer, such as en-AU,
still comes first.

* fix(tui): open the timezone picker on a configured zone the runtime doesn't list

A reset timer's timezone picker should open on the configured zone, so
Enter keeps it. A valid zone that Intl doesn't list, such as Asia/Kolkata
(both Node and Bun list it as Asia/Calcutta) or Etc/GMT-5 picked under
Bun and reopened under Node, gets its own "Configured timezone" row
right after Local. The picker found the starting row's index in the
options as given, but shows them sorted, and the sort puts that row
after Africa/Abidjan. So the picker opened on Africa/Abidjan, and Enter
without moving saved it, switching the reset time to GMT.

Find the starting row in the list as the empty search shows it.

* fix(tui): keep 'uk' and 'ca' on en-GB and en-CA when either is the system or configured locale

The locale picker lists the system locale and the configured locale in
their own rows above the common ones, and drops the common row for the
same locale. Those rows carried only "system ..." or "configured ..."
search words, so when en-GB or en-CA was the system or configured
locale, the 'uk' and 'ca' keywords went with the dropped row. Typing
either put "Use uk" or "Use ca" first again and Enter saved Ukrainian or
Catalan; for 'uk', en-GB was not even listed. That is the case for a UK
or Canadian user under Node, which takes its default locale from LANG,
and for anyone reopening the picker after choosing en-GB or en-CA.

Give the System and Configured rows the keywords of the common locale
they replace, so they match and rank the same way it would.

The keyword tests now pin the runtime's locale: they passed under Bun,
which reports en-US whatever LANG says, and failed under Node with
LANG=en_GB.UTF-8 or en_CA.UTF-8.

* fix(usage): keep the UTC label on locale-formatted UTC reset timestamps

With a locale set and the timezone left at its default, UTC, the full
date-mode stamp now goes through Intl in that locale and takes the zone
name Intl gives. For most locales that is "UTC", but a few spell it
differently: el-GR writes "2026-03-15 08:30 Συντονισμένη Παγκόσμια Ώρα",
and sd, ku, sc and so give "م ع و", "SGH", "TCU" and "Waqtiga UTC", the
same under Bun and Node. Without a locale these stamps end in "UTC".

At UTC, the full form now ends in the fixed "UTC" label, as compact mode
already ends in a fixed "Z". The weekday, day period and digits still
follow the locale. An explicitly chosen IANA zone such as Etc/UTC keeps
Intl's zone name, as any other zone does.

* test(usage): cover the fixed UTC format when Intl rejects the locale

With a locale set and the timezone at UTC, a reset timestamp goes
through Intl in that locale, and keeps the fixed format when Intl can't
use the locale. Only the localized outcome had tests.

A POSIX-style tag such as "de_DE" is not a valid language tag, and Intl
throws on it under both Bun and Node. The stamp then reads
"Sun 08:30 UTC" in the full form and "Sun 08:30Z" in compact mode,
where de-DE gives "So 08:30 UTC" and "So 08:30Z".

* refactor(usage): format UTC reset timestamps in their own helper

formatUsageResetAt parsed the timestamp, split its overloaded
locale-or-hour12 argument, and then held both zone paths inline: the
UTC one, which tries the chosen locale, adds the compact "Z" and falls
back to the fixed format, and the IANA or local one with its en-US
fallback.

The UTC path moves into formatResetAtUtcInLocale, next to
formatResetAtUtc and formatResetAtInTimezone, the two formatters it
chooses between. formatUsageResetAt now reads as parse, normalize, then
format for the zone, and its UTC branch is a single call. The output is
the same for every timezone, locale and option.
…bs as one space (sirmalloc#781)

* fix(ansi): hide charset escapes and DCS/APC strings whole

With Preserve Colors off, Custom Command reduces its output to visible
text with getVisibleText before the renderer's sanitizer sees it. The
escape parser behind it read ESC and any character other than [ or ]
as a complete two-character sequence. That is right for ESC 7 or ESC c,
but not for ESC ( B, a character-set selection with an intermediate
byte, which `tput sgr0` prints before ESC [ m on xterm-256color. Its
final "B" was left as text, so a script printing
"$(tput setaf 2)OK$(tput sgr0) done" showed "OKB done". DCS, SOS, PM
and APC strings (ESC P ... ESC \) lost only their first two characters,
so their payload printed as text. By the time the sanitizer ran, these
were ordinary characters it had no reason to remove.

Read these forms the way terminal-sanitize.ts already does: ESC,
intermediate bytes (0x20-0x2F) and a final byte as one sequence, and a
DCS, SOS, PM or APC string up to ST or BEL, stopping before any other
ESC so an unterminated string doesn't hide the text after it. Visible
width goes through the same parser, so with Preserve Colors on these
sequences no longer count toward Max Width either.

* fix(ansi): recognize color codes written with colons

SGR parameters can be separated with colons, the ITU T.416 form:
ESC[38:5:196m, ESC[38:2::255:0:0m, or ESC[4:3m for a curly underline,
which `tput Smulx 3` prints on xterm-256color. The terminal sanitizer
keeps these, but the color pattern in ansi.ts only allowed digits and
semicolons, so everything downstream treated them as unknown:

- a Preserve Colors Custom Command cut by Max Width never got its
  closing reset, so its color or underline ran into the separators and
  widgets after it
- a global foreground override left the command's own colon-form color
  in place, where the semicolon form is recolored
- in Powerline mode, a colon-form reset such as ESC[0;38:5:196m didn't
  get the segment background re-applied after it

Allow colons in the pattern. When reading a sequence for a background
reset, an extended color written with colons holds its arguments in its
own parameter, so the following parameters are no longer skipped as if
they were its arguments: ESC[48:5:1;0m ends with the background cleared,
and ESC[0;38:2::0:0:0;41m keeps its red background.

* fix(render): print tabs as one space and measure them as one column

Widget output kept its tabs: the terminal sanitizer let them through,
and width measurement gave them no columns, as string-width does for
control characters. A terminal moves a tab on to the next tab stop, up
to 8 columns, so a line holding one printed wider than it was measured.
A Custom Command such as `git rev-list --left-right --count HEAD...@{u}`,
which prints "0<tab>3", beside a flex separator filled the line to its
width by count and then ran past it on screen, where it wraps or gets
cut off. Max Width and the line's own truncation let the extra columns
through the same way.

How far a tab moves depends on the column it lands in. That isn't known
while a widget is measured, before the line is laid out, and it changes
with where the host draws the line, so measuring tabs at their real
width can't be done reliably. Instead, the sanitizer prints each tab as
one space, and width measurement counts a tab as one column, so text
measured before it is sanitized, as Custom Command's Max Width is,
agrees with what is printed. A single space reads as the field
separator a tab usually is in one-line output, without spending up to
8 columns of the status line on it.

* test(ansi): cover an escape whose final byte never comes

An escape with intermediate bytes (ESC ( B) was only tested with its final
byte present. Pin down what happens when it is missing: at the end of the
text the escape is hidden up to where it stops, and a character that cannot
end it, here an emoji, stays visible after it.

* refactor(ansi): read escape bytes as code points and name the final-byte range

The scan for ESC, intermediate bytes and a final byte compared raw UTF-16
units and spelled the final-byte range inline in the slice call. It now reads
code points, as the grapheme-cluster scan in this file does, and tests the final
byte with isEscapeFinalByte next to isIntermediateByte, so the return reads as
"one past the final byte if there is one".

The parse is the same for every input: both ranges are ASCII, and code points
only differ from code units at a surrogate pair (a value of 0x10000 or more
instead of 0xD800 to 0xDBFF) and past the end of the text (undefined instead
of NaN), which neither range accepts either way.
…fter a sub-screen (sirmalloc#780)

* fix(tui): keep Merge highlighted after cancelling the shell-command confirmation

When an import adds a Custom Command, choosing Replace All or Merge
first asks "Apply this import? It adds a shell command...". Cancel says
"Go back to the preview without importing", but coming back from Merge
put the highlight on Replace All and switched the preview to the
Replace diff. The confirmation doesn't name the mode, so pressing Enter
again and confirming applied Replace All, which resets every setting
the file leaves out to its default.

The confirmation replaces the preview's List, and the List mounted
again at its first row, then reported that row as the new selection.
Mount it on the mode the preview was showing instead.

* fix(tui): let Escape leave Import Preview and its shell-command confirmation

Escape did nothing on Import Preview, on both the diff and the "Apply
this import? It adds a shell command..." confirmation. The only way out
was to arrow down to Cancel and press Enter. The Import Config path
dialog one step earlier, and the other menus, go back on Escape, and
docs/USAGE.md already says Escape returns from the confirmation to the
preview.

The dialog had no input handler of its own, and its List only handles
the arrows and Enter. Escape now steps back one screen: from the
confirmation to the preview, where the mode stays highlighted, and from
the preview out of the import, as Cancel does.

* fix(tui): keep Color Level highlighted after the custom-colors warning

In Terminal Options you press Enter on Color Level to cycle through the
levels. With an ansi256 or hex widget color, leaving 256 Color or
Truecolor first warns "Custom colors detected!". After Yes, No or
Escape the highlight was back on Terminal Width, so the next Enter
opened the Terminal Width screen instead of cycling on.

The warning replaces the List, and the List mounted again at its first
row. Remember the highlighted row, as Powerline Setup does around its
confirmations, and mount the List on it.

* fix(tui): keep the cancelled setting highlighted in Configure Status Line

Pressing Escape on a Refresh Interval or cache TTL prompt brought the
list back with the highlight on the first row, Refresh Interval (or Git
Cache TTL when Refresh Interval needs a newer Claude Code). Pressing
Enter again opened a different setting from the one just left. Saving
returns to the Main Menu, so only cancelling showed it.

Each prompt replaces the List, and the List mounted again at its first
row. Remember the highlighted row and mount the List on it.

* fix(tui): keep the chosen update style highlighted when going back in Install

In Install to Claude Code, going back from the package manager step
(Escape or "← Back") highlighted Pinned global install again, with its
description, even after choosing Auto-update. Pressing Enter then led
into the pinned global install commands instead of the npx/bunx ones.

The style List mounts again on the way back, and it always started on
its first row. Start it on the style still held from the first pick.

* test(import-preview): mark the command import with colorLevel instead of flexMode

flexMode is going away with the Terminal Width options; any non-default key
the import carries serves this fixture.

* test(terminal-options): start the warning tests on Color Level, now the first row

With sirmalloc#765 Terminal Options lists only Color Level, so the two tests start
there instead of stepping down from Terminal Width.

* fix(tui): drop the Terminal Options row change, which sirmalloc#765 made moot

With sirmalloc#765, Color Level is Terminal Options' only setting and the
custom-colors warning opens only from it, so the menu comes back on that
row without help.
…ttings as v1 (sirmalloc#779)

* fix(config): don't migrate a version-less settings.json with later settings as v1

A settings.json, or an Import Config file, with no "version" key was always
read as a v1 config, since v1 predates the field. loadSettings validated it
with the v1 schema, which ignores keys it doesn't know, and ran the v1->v2
migration, which rebuilds the config from `lines` and the nine v1 fields
only. Every later setting (powerline, defaultPaddingSide, minimalistMode,
gitCacheTtlSeconds, ...) went back to its default, the result was written
over the file with no copy kept, and the v2->v3 step added a "ccstatusline
updated to v2.0.2" message that then showed for 12 renders. Import ran the
same migration and took its merge keys from the migrated result, so Merge
applied nothing from such a file and Replace applied defaults, while the
TUI reported a successful import.

needsMigration now reads a version-less config as v1 only when it holds
none of the settings added after v1 (the keys SettingsSchema has beyond
SettingsSchema_v1). One that does is validated as the current version: if
it is valid it loads as written, and the file isn't rewritten just to add a
version; if it is invalid it takes the existing invalid-file path (defaults
in memory, the warning badge and TUI banner, file untouched). Genuine v1
files hold only v1 fields, so they still migrate.

Before a migration is written back, the original is now copied beside the
settings file as <file>.v<N>.bak, N being the version it was migrated
from, with the original's permissions. If that copy can't be written, the
migration isn't persisted: the file stays as it was and the load reports
the error, like any other file it can't use.

Import validation shares the check and takes its merge keys from the
file's own keys rather than the migrated result's, so Merge applies every
setting the file holds.

* fix(config): keep current settings a v1 import's migration drops

A merge import applies only the keys the file holds. For a version-less
v1 file, those keys were taken from the raw file, but the v1 migration
keeps only fields of the right type and drops a lines value that isn't
a list. The schema then filled the dropped keys with defaults, and the
merge wrote those defaults over the current values: a v1 file with
"flexMode": null reset Flex Mode to full, and one with a non-list lines
replaced the current lines with the default ones.

The merge now takes the raw keys that are still in the migrated result,
so a dropped value leaves the current setting alone.

* fix(config): back up settings.json byte for byte before a migration

The .v<N>.bak copy was written from the text decoded as UTF-8. Bytes
that aren't UTF-8, such as a Latin-1 é an editor saved in a custom
text, decode to U+FFFD and were written back as EF BF BD, so the backup
was not the original file. It is now written from the bytes read.

* fix(config): use a migration in memory when its backup can't be written

A migration is written back only after the original is copied to
settings.json.v<N>.bak. When that copy failed, for example because the
config folder is read-only, the load fell back to the built-in defaults
and reported the file as invalid: the status line rendered the defaults
behind the invalid-config badge, and the TUI showed the defaults and
offered to overwrite the file with them, although the file was valid and
only needed upgrading.

Now the migrated settings are used for that run without being written,
and every load tries the backup again, so the file is upgraded as soon
as the copy can be made. The status line renders the user's lines, with
no badge and nothing on stderr. The update message a migration adds is
held back until then, since counting it down would rewrite the file.

The TUI shows a notice instead of the invalid-config warning. A save
from it writes the backup first; if that still fails, the save fails
with the existing "Could not save configuration" message and the file
is left as it was. Recording installation metadata skips its write in
that case, as it does for an unreadable file.

* fix(config): don't replace a newer backup when saving over a held migration

When a load can't back up settings.json before migrating it, the
migrated settings are used from memory and the next save writes the
backup first. That save backed up the bytes the load had read, which in
the TUI can be minutes old. If the file was edited in the meantime, and
a status line render then backed up the edited file and migrated it,
the save deleted that backup, wrote the older bytes in its place and
wrote the TUI's settings over the file, so the edit was left nowhere.

The save now reads the file again before backing it up. If it still
needs migrating, the bytes it holds now are backed up, under their own
version. If it is already at the current version, another run has
migrated it and kept its own backup, and that backup is left as it is.

* test(config): mark kept settings with colorLevel instead of flexMode

flexMode and compactThreshold are going away with the Terminal Width
options, so these tests tell the user's settings from the defaults by a
non-default colorLevel (and defaultPadding for the second invalid v1 field).

* fix(config): preserve legacy hiding in versionless settings

---------

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

(a) in Edit Colors opens the whole 256-color palette as a grid (the 16
basic colors, the 6x6x6 cube and the 24 grays) instead of asking for a
number. The arrow keys move the cursor and the preview follows it, typing a
number jumps to that color, Enter keeps it and ESC restores the previous
one. Picks are still saved as ansi256:N, and (a) is now offered at
truecolor too.
…irmalloc#791)

With a tag named like the current branch, `git symbolic-ref --short HEAD`
prints "heads/<branch>". The refresh child keys the PR cache by the full
ref with "refs/heads/" stripped since sirmalloc#774, but the render's lookup
through the cached runGit (sirmalloc#639) still used --short. The two then hash
to different cache files: the child writes the PR to one, Git PR and Git
CI Status read the other, and the widgets show "(no PR)" on every render
while a refresh runs again each time its lock goes stale. The child also
never releases the lock it was handed, since that lock belongs to the
other cache file.

The render's lookup now reads `symbolic-ref HEAD` and strips the prefix
the same way, which is also the command Git Branch already runs, so the
cached runGit answers both.

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

Enabling Powerline replaced a theme of 'custom' with the default theme,
the same as a first enable with no theme set. 'custom' can only be set
from the Themes menu (picking Custom, or customizing a built-in theme
into widget colors), which is disabled while Powerline is off. So
turning Powerline off and on again switched a customized status line
to nord-aurora, and the widget colors stopped showing until Custom was
picked again.

Only a missing theme now falls back to the default.

Co-authored-by: effectustasi <216187407+effectustasi@users.noreply.github.com>
@eric-engberg
eric-engberg marked this pull request as ready for review November 5, 2026 23:29
eric-engberg and others added 9 commits November 6, 2026 09:19
…ode (sirmalloc#810)

- Shared bar rendering: Block Timer, Block Reset Timer, Weekly Reset Timer,
  Extra Usage Utilization and the usage-percent widgets each built their bars
  with the same blocks. formatUsageBar in usage-display.ts now builds every
  bar mode (block, line and slider, with or without the percentage) in one
  place and returns null outside them, so each widget only adds its label.
  The time cursor is still resolved only in bar modes.
- One class for the usage-percent widgets: Block, Weekly, Weekly Sonnet,
  Weekly Opus and Fable Weekly Usage were identical apart from their kind.
  UsagePercentWidget holds the class once and each widget names its kind.
  Spend Limit Usage extends it too and keeps only its color; the class takes
  the time cursor and the API-error state from the kind.
- A shared base for the Context % widgets: Context % and Context % (usable)
  differed only in name, color, description, label prefix, preview sample
  and how they measure the used share. ContextPercentageWidgetBase holds the
  rest.

No change in behavior.
…nd root dir widgets (sirmalloc#809)

* refactor(powerline): drop the font install's unreachable fallback return

After the clone, installPowerlineFonts either runs install.sh (macOS and
Linux) or copies the font files (Windows), and each branch returns its
result or throws. The "Platform-specific installation not implemented"
result after them could never be returned, and an unsupported platform
already returns its own message before the clone.

* refactor(renderer): step through Powerline auto-align padding with a while loop

The padding pass jumped its for-loop counter past each merge group from
inside the body, so the loop header no longer said how it stepped. A
while loop now sets the next widget explicitly: the following one, or
the one after a merge group once the group is padded.

The output is unchanged. Two new tests pin the cases that depend on the
stepping and had no test: an auto-align exclusion on a widget merged
into the previous one is ignored and the columns after the group are
still padded, and a line rendered without column widths keeps its
natural widths.

* refactor(guid): truncate the random hex digit with Math.trunc

generateGuid dropped the fraction of Math.random() * 16 with a bitwise
"| 0". Math.trunc says that directly. For the values this sees, [0, 16),
both give the same digit: they differ only past 2^31, for NaN and for
Infinity, none of which Math.random() * 16 can produce.

New tests pin the UUID v4 shape and the digits Math.random() at 0,
1/16, 1/2 and its largest value turn into, including the variant
digit's 8 to b range.

* refactor(widgets): drop the root dir widgets' redundant separator trim

Git Root Dir and JJ Root Dir stripped trailing separators from the root
path, then split it on separators and dropped the empty parts. The split
already drops the empty parts trailing separators leave, so the strip
changed nothing:
- With a name in the path, both give the same last part.
- With no name, as for "/", the strip left an empty string and the code
  fell back to the original path, which is what the fallback now uses
  directly.

The strip's pattern also rescanned a long run of separators from every
position in it: 50,000 slashes before a name took 1.6s under Bun and
4.0s under Node. git and jj never print such a path, but the split
alone does it in about a millisecond.

An exhaustive check over every string of up to 8 characters drawn from
"/", "\", "a", ":", " " and a newline gives the same name both ways. New
tests cover trailing Windows separators in both widgets, "/" for JJ Root
Dir and a path of separators alone for Git Root Dir.
…wide padding (sirmalloc#808)

* fix(widgets): edit Link and Custom Command text by whole characters

The Link text and URL editors and the Custom Command editor kept their
cursor as a UTF-16 index and moved or deleted one code unit at a time.
An emoji outside the Basic Multilingual Plane is two code units, so
Backspace after it, Delete before it, or an arrow key over it split the
pair:

- Backspace on "Docs 📘" saved "Docs \ud83d", which the status line
  draws as "🔗 Docs �".
- In the command "echo 🚀", Left then "x" saved "echo \ud83dx\ude80",
  so the shell received U+FFFD x U+FFFD instead of the rocket.

The cursor cell also drew value[cursor], a single code unit, so with the
cursor on an emoji a real terminal showed two replacement characters.

The Custom Text and label editors already work by grapheme through
text-cursor.ts. Its boundary lookup is now exported as
getAdjacentBoundaries, and both editors use it for Left, Right,
Backspace and Delete and to draw the whole grapheme under the cursor.
Joined, skin-toned and flag emoji now move and delete as one character,
as they do in Custom Text. Typing and pasting are unchanged.

* fix(tui): keep joined emoji whole in Edit Lines and Global Overrides rows

Each row in the line editor was a run of sibling Texts: the widget name
and its text, then one for each tag such as "(merged→)", "(label: …)",
the number style or "(no-align)". Ink lays a sibling out at the width
string-width gives the text before it, which counts a joined, skin-toned
or flag emoji (👩‍💻, 👍🏽, 🇺🇸) as two columns, but it writes every code
point of that emoji into its own cells. The text ran past its box and the
next tag overwrote the end. A Custom Text of 👩‍💻 merged into the next
widget showed as "1. Custom Text (👩‍ (merged→)", a plain 👩 with no
closing paren, and a 👍🏽 label lost its closing quote. Only the editor
list was wrong; the saved config and the status line were fine.

Global Overrides draws its Default Padding and Default Separator rows the
same way: the quoted value, then a sibling " - Press (p) to edit" hint.
A 👍🏽 padding showed as `Default Padding: "👍🏽 - Press (p) to edit`,
without its closing quote, and a 👩‍💻 padding lost its 💻 as well.

The row's name and tags are now nested Texts inside one Text, the way
the label editor already draws its value, so they print as one string
and nothing overwrites the emoji. The two Global Overrides rows nest
their value and hint the same way. The styling of each part is unchanged.

* fix(renderer): size auto-aligned columns by the padding's display width

With Powerline Align Widgets on, the shared column widths added the
default padding's string length to each widget's display width. The
renderer then pads every cell by measuring its padded text in columns.
The two only agree when every padding character is one UTF-16 unit and
one column wide.

With the ideographic space U+3000 as padding (what the space bar types
under a full-width Japanese or Chinese IME), the columns came out 2
narrower than the padded cells. On lines [A, B] and [CCCCC, D] the first
separator landed at column 7 on one line and 9 on the other. A padding
glyph from outside the Basic Multilingual Plane went the other way and
added spaces to every column.

The padding is now measured with getVisibleWidth, the same measure used
for the cells.
…y from the config the status line loads (sirmalloc#807)

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

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.

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

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.

* fix(claude-settings): start a quoted word without ??=

Node 14, the build's target, has no logical assignment operators, and the
build passes them through unchanged.

* fix: preserve config identity when syncing widget hooks

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…and re-check (sirmalloc#806)

* fix(update): bound the registry check with an overall deadline

Check for Updates is meant to give up on the npm registry after five
seconds, but its only timer was the request's socket timeout. That timer
starts once the socket has connected, and Bun's doesn't fire while it
connects at all. On a network that silently drops outbound HTTPS (a
firewall that expects a proxy, a captive portal), the Updates screen sat
on "Checking npm registry..." until the OS gave up on the connection:
about 75 seconds on macOS under Bun, and the same on Node 18 and older,
whose global agent has no default timeout. Node 19+ only stopped at five
seconds because its global agent's own socket timeout happens to match.

The check now arms one timer for the whole request, as the usage and
status-page requests already do. When it fires, it destroys the request
and fails the check with "npm registry request timed out after 5000ms".
The socket timeout is gone, since the deadline always fires first. The
request function can be passed in, so tests can stand in for the
network.

* fix(update): send the registry check through HTTPS_PROXY

The usage and Claude Status requests go through the proxy named by
HTTPS_PROXY unless NO_PROXY excludes the host, but Check for Updates
called https.request with no agent, and neither Node nor Bun reads the
proxy variables on its own. On a network where only the proxy gets out,
every other network widget worked while the Updates screen failed with
"npm registry request timed out after 5000ms" (and under Bun waited
about 75 seconds for the OS to give up first).

The registry request now uses the same helpers: createProxyAgent for
HTTPS_PROXY, skipped when isExcludedFromProxy matches registry.npmjs.org.
At the deadline it also closes the agent's connection to a proxy that
never answers CONNECT, which would otherwise keep the process alive. A
HTTPS_PROXY that isn't a URL fails the check with a message that says
so. The proxy docs now list the update check, and the test helper that
clears the proxy variables is shared by both suites that need it.

* fix(update): compare a self-managed global install's own version

With Claude Code's status line set to the global `ccstatusline` command
and no pinned metadata, Check for Updates compared the latest release
with the version of the TUI that was running. The README starts the TUI
with npx or bunx, so that is often a different copy from the global
package Claude Code runs. Manage Installation showed "Active PATH match:
npm global 2.2.20", yet Check for Updates said "Current: 2.2.32 ...
ccstatusline is up to date" and offered nothing, and a global already
at the latest release got an offer to reinstall it whenever the TUI was
older.

Pinned installs already use the version found on PATH. Self-managed
installs now do too when that version is known, and fall back to the
running TUI's version otherwise. The offered command already targeted
that global package's manager, so the version shown now matches what it
would update.

* fix(update): check again after a global update from the Updates screen

After "Run npm install -g ccstatusline@X" succeeded, the TUI flashed
"Global package updated" (or showed the Update Complete notice) and went
back to the Updates screen, which still held the result from before the
update: the old Current version, "An update is available." and the same
install command, which reinstalled X if picked again. Only Check again
showed that the install was up to date.

A successful update now runs the check again. It runs from an effect
once the new installation metadata has reached `settings`: calling the
check from the update's own callback would read the settings from
before the update, so a self-managed install would still show as one,
and a pinned install whose PATH version can't be read would compare
the old version. Both the flash path and the PATH-warning notice path
land on the fresh result.

* fix(update): compare a checkout status line with its own version, not PATH's

A status line that runs a local checkout (any command running
ccstatusline.ts) is classified as self-managed, the same as the bare global
`ccstatusline` command. Check for Updates then compared the latest release
with the version of whatever global `ccstatusline` sits first on PATH, a copy
Claude Code never runs for that status line. A checkout at the latest release
with an old global on PATH was told an update is available and offered
`npm install -g`; a checkout behind the latest release read as up to date
when the global was current.

The PATH version now applies only when the status line command is the global
`ccstatusline` (with or without --config). Every other self-managed command
compares this TUI's own version again, as before the self-managed change.

* fix(proxy): read an HTTPS_PROXY with no scheme as an HTTP proxy

A proxy given as host:port, such as `proxy.corp:3128` (a form curl
accepts as http://), went to HttpsProxyAgent as it was. `new URL()`
parses it without complaint but takes `proxy.corp:` as the scheme and
leaves the host empty, so the agent connected to localhost:80. Check
for Updates, which now goes through HTTPS_PROXY, then failed with only
"Unable to query npm registry", where it used to connect directly and
work wherever direct connections were allowed. The invalid-URL check
missed this form, since nothing threw.

createProxyAgent now adds `http://` when the value has no `scheme://`,
as curl does, so the request goes through the named proxy. Values that
still aren't URLs, like one with spaces, fail as before. The usage and
Claude Status requests build their agents with the same helper, so they
read the variable the same way.

* fix: close failed registry requests before clearing deadline

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
The "link to repo" option on Git Branch and the Git Origin/Upstream
widgets builds its link with buildRepoWebUrl, which always wrote
https://. For http and https remotes parseRemoteUrl keeps the port, as
the forge's web port, so an origin of
http://git.example.lan:3000/team/app.git (a Gitea or GitLab served over
plain HTTP on a LAN) linked to https://git.example.lan:3000/team/app
and .../tree/<branch>. That port speaks no TLS, so clicking the link
failed.

Take the scheme from the remote: an http:// remote links over http, and
https, ssh, git and scp-style remotes still link over https. An
http://github.com remote now links to http://github.com/..., which
GitHub redirects to https.
…s in fish mode (sirmalloc#803)

* fix(cwd): keep whole characters in fish-style segments

Fish style shortens each middle directory to its first character, but it
took that character with part[0] (and part[1] after a leading dot), which
index UTF-16 code units. A folder whose name starts with an emoji or other
character outside the Basic Multilingual Plane was cut in half, leaving a
lone surrogate that the terminal prints as U+FFFD: "~/🚀 rockets/sub/last"
showed as "~/�/s/last". A decomposed accented name such as "Über" lost its
combining mark, and a joined emoji such as "👨‍👩‍👧" printed as U+FFFD
too.

Take the first grapheme instead, using the getGraphemes helper the symbol
editors already use, so the abbreviation keeps one whole user-visible
character, including after the dot of a hidden directory.

* fix(cwd): keep a UNC path's root in fish style

Fish style splits the path on separators, drops the empty pieces and then
rebuilds it, putting a single separator back only when the path started
with '/'. A Windows UNC path lost its leading '\\' entirely, so
"\\server\share\team\repo" showed as the relative-looking
"server\s\t\repo", and "//server/share/team/repo" kept only one of its two
slashes.

Keep the path's leading separators as they were and join the shortened
parts after them. POSIX paths keep their '/', home and drive-letter paths
have no leading separators and come out as before.

* fix(cwd): turn fish style off when a segment count is saved

Fish style and the segment count are mutually exclusive: turning fish
style on clears the segment count, and turning on the home ~ option turns
fish style off. The (s)egments editor broke that rule. It is offered while
fish style is on, but on Enter it kept fish style and stored the count
next to it. Fish style wins when both are set, so the item still read
"(fish-style)" and the preview and status line didn't change, as if the
edit had been dropped. The hidden count then cut the path the moment fish
style was turned off.

Saving a segment count now turns fish style off, the same way the home ~
option does, so the count takes effect as soon as it is entered and the
item shows it. A blank entry still only clears the count.

Settings saved before this fix, or edited by hand, can still hold both.
Turning fish style off now drops the segment count as well, so the full
path comes back instead of a count that was never shown. Since turning
fish style on already clears the count, it can only sit next to fish
style through the old editor or a hand edit.
…ked PATH dir once (sirmalloc#804)

* fix(install): find package managers and ccstatusline on PATH without 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`.

* fix(install): count a PATH directory reached through a symlink once

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.

* fix(install): read PATH from process.env when the PATH search runs

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.

* fix(install): list each other install once in the multiple-binaries warning

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.

* fix(install): resolve PATH directories' letter case the same under Node 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.

* test(install): keep the PATH search for when which is missing, not failing

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.

* test(install): check that PATH directories resolve through the native 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.

* refactor(install): build the default PATH search in a named helper

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.
@sonarqubecloud

sonarqubecloud Bot commented Nov 6, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
1 New Critical Issues (required ≤ 0)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@eric-engberg

Copy link
Copy Markdown
Owner Author

Merged upstream as sirmalloc#805.

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.