Compare commits

..

197 Commits

Author SHA1 Message Date
Matthew Breedlove e7411d6ee6 fix(git): move review and CI refreshes off render path
CI / Build (push) Blocked by required conditions
Publish / Publish to npm (push) Waiting to run
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
Serve PR and CI widgets from a versioned disk cache and refresh stale or missing data in a detached helper so slow GitHub CLI requests cannot block Claude Code's status line.

Request statusCheckRollup only when a CI widget needs it, deduplicate background refreshes with recoverable lock files, and share a single deadline across GitHub fallback attempts. Restrict metadata retries to CI-field compatibility failures.

Preserve upgrade compatibility with legacy raw JSON and empty negative-cache files, while marking new entries with whether CI checks were queried. Add regression coverage for migration, stale reads, cache upgrades, lock recovery, and timeout behavior.

Bump the package version to 2.2.25.
2026-07-20 18:37:53 -04:00
Matthew Breedlove 860df1763c Version bump and docs update
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
Publish / Publish to npm (push) Waiting to run
2026-07-20 02:39:55 -04:00
Zach Landquist 236cd79cb5 feat: add CacheTimer widget (continues #307) (#466)
* feat: add CacheTimer widget for prompt cache TTL countdown

Reads ~/.claude/state/cache-timer-{session_id}.json written by the
claude-cache-countdown hooks and displays live cache state:
  🔥 HOT  — agent active, cache being refreshed
  🟢 4:52 — countdown with green/yellow/red urgency colors
  ❄️ COLD — cache expired

Widget type: 'cache-timer', category: Session

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

* refactor(cache-timer): read transcript directly, no external deps

Instead of reading ~/.claude/state/cache-timer-{session_id}.json
(which required the claude-cache-countdown hooks to be installed),
the widget now reads the last assistant message timestamp directly
from the transcript_path provided by Claude Code.

No hooks, no external scripts — works out of the box with ccstatusline alone.

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

* fix(cache-timer): show HOT when Claude is actively working

When Claude is processing a request, the transcript's last entry is a
user message (the assistant response hasn't been written yet). The
previous code only searched for assistant timestamps, so it would use
the prior turn's timestamp — never showing HOT during active work.

Now detects the pending user message and displays 🔥 HOT immediately.

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

* refactor(cache-timer): adopt n/a empty-state and harden parsing

Builds on the widget from #307 and aligns it with the empty-state convention the Cache widgets follow: instead of returning null when there is no transcript or no assistant turn yet, render n/a gated behind a new (h)ide when empty toggle, so the widget stays visible unless the user opts out.

Also guard a malformed assistant timestamp so it can never reach the countdown as NaN, and document that a trailing user-role entry (a prompt or a tool result) is what drives the HOT state.

* test(cache-timer): cover preview, empty-state, countdown, and HOT

Sandboxed widget tests using temp transcript files: preview, n/a versus hidden empty-state, HOT during an in-flight turn, the green/yellow/red countdown buckets and COLD, the malformed-timestamp guard, and the hide-when-empty keybind and editor wiring.

* feat(cache-timer): customizable state glyphs and configurable TTL

The five state icons (HOT, fresh, draining, urgent, cold) become editable glyphs via the shared (g)lyph editor, declared as named SymbolSlots the same way the other symbol-aware widgets do, so nerd-font and ASCII users can replace the emoji with symbols that respect the widget color. A blanked glyph collapses its trailing space.

The cache TTL is now configurable: a (t)tl keybind cycles 5m/1h and any positive ttlSeconds can be set in settings.json, matching Claude Code writing both 5-minute and 1-hour cache breakpoints. The 5-minute default and existing output are unchanged.

* test(cache-timer): cover custom glyphs and configurable TTL

Adds cases for per-state glyph overrides (custom and blanked), the preview reflecting a custom glyph, a 1-hour TTL extending the countdown window, malformed-TTL fallback, the (t)tl cycle, and the editor TTL annotation; updates the keybind assertion for the new (t) and (g) binds.

* fix(cache-timer): ignore sidechain and API-error transcript rows

The transcript scan treated any trailing user or assistant row as the
session's cache state. Two kinds of rows broke that assumption:

- Sidechain (subagent) rows: a trailing sidechain user row reported HOT
  and a trailing sidechain assistant row restarted the countdown, even
  though subagent traffic runs against its own prompt prefix and never
  refreshes the main conversation's cache. Background agents can write
  these rows after the main turn has ended.
- Synthetic API-error rows (e.g. "You've hit your session limit"): these
  are written as type "assistant" with a timestamp and zero-token usage,
  so a failed request that refreshed nothing still produced a fresh
  countdown.

Skip rows with isSidechain: true or isApiErrorMessage: true so the scan
falls through to the newest main-chain row, matching the filtering
already done in jsonl-metrics.ts, jsonl-blocks.ts, and compaction.ts.

* fix(cache-timer): grow the tail read when the trailing record exceeds 32 KiB

The transcript scan read a fixed 32 KiB tail. When the newest relevant
record was itself larger than that (real transcripts contain tool-result
and pasted-prompt rows of several hundred KiB), the read started in the
middle of the record, the reverse scan could not parse the fragment, and
the widget rendered "n/a" instead of HOT or a countdown until a smaller
record was appended.

Retry the read with a doubled tail size whenever the scan finds no
relevant record and the read has not yet reached the start of the file,
capped at 1 MiB so a degenerate unparseable file bounds the work done
per render. readFileTail now also closes its file descriptor in a
finally block so a failed fstat/read no longer leaks it.

* fix(cache-timer): only reset the countdown on rows with cache activity

Every successful assistant row restarted the countdown, even when the
request reported zero cache-read and cache-creation tokens — as happens
when prompt caching is disabled (DISABLE_PROMPT_CACHING) or unsupported
by the provider/proxy. The widget then advertised a hot cache that did
not exist.

Inspect message.usage on assistant rows and skip those whose request
neither read nor wrote the cache, so the scan falls back to the newest
row with real cache activity, or reports n/a when caching never
happened. Rows without usage data cannot be classified and continue to
drive the countdown so older transcript formats keep working.

* fix(cache-timer): never report HOT for a finished turn without a cache anchor

Skipping a non-anchoring trailing assistant row (zero cache activity or
a synthetic API error) let the reverse scan fall through to the user row
that started that same turn, so a completed exchange was classified as
in-flight and the widget showed HOT indefinitely. The previous tests
missed this because they omitted the user row that precedes every
assistant response in a real transcript.

Track the two concerns separately: any main-chain assistant row now
finalizes the in-flight state (older user rows can no longer flip the
scan to HOT), while the countdown anchors on the newest assistant row
whose request actually read or wrote the cache. Non-anchoring rows fall
back to the prior real cache event, or to n/a when none exists.

A malformed anchor timestamp now also falls back to the prior cache
event instead of immediately reporting no data.

* fix(cache-timer): expand the tail read to the file start instead of capping

The tail expansion stopped at 1 MiB, so a valid trailing record larger
than that (a multi-megabyte pasted prompt or tool result) could never be
parsed and the widget rendered n/a permanently instead of HOT or a
countdown.

Keep doubling the read until the state resolves or the read reaches the
start of the file. The worst case — scanning a whole transcript that
contains no resolvable record — is bounded by file size, matching what
the token widgets already do by reading the full transcript on every
render; the common case still resolves within the initial 32 KiB tail.

---------

Co-authored-by: Tuan Son <tuan.dinh@interzero.de>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-07-20 02:26:03 -04:00
CC eecdf46b04 feat(widget): add Sandbox Status widget (#481)
* feat(widget): add Sandbox Status widget

Shows whether Claude Code's bash sandbox mode is enabled, read from the effective sandbox.enabled setting across the layered Claude config (project-local -> project -> user-local -> user). Because /sandbox persists its toggle to .claude/settings.local.json, the widget reflects runtime toggles on each status refresh, not just the configured default.

Display modes (cycle 'f'): glyph 'SB: dot' (default), text 'SB: ON/OFF', word 'Sandbox: ON/OFF', bare glyph-only; optional Nerd Font lock glyphs ('n'). Standard per-widget color picker; raw value on/off for composition.

Reuses the layered-settings candidate-path reader shared with getVoiceConfig.

* docs(widget): clarify sandbox status limitations

Add a second-line picker warning that the sandbox indicator is best effort when managed or CLI settings override local files or sandbox initialization fails.

Expand Sandbox Status coverage for bare Nerd Font glyphs and the current raw-output precedence so its display states are explicit ahead of follow-up behavior changes.

* fix(widgets): align raw and Nerd Font modes

Make Sandbox Status, Voice Status, and Remote Control Status follow the documented raw-value contract by removing display labels without replacing the configured value representation.

Expose and apply Nerd Font settings only while a configurable icon is visible across Sandbox Status, Voice Status, Remote Control Status, and Vim Mode. Hide the binding in text-only modes, guard direct actions, clear metadata when cycling away from icons, and ignore stale flags in rendering and editor labels.

Remove the redundant Sandbox bare format now that raw glyph mode provides glyph-only output, and cover all affected formats and state transitions.

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-07-20 01:24:37 -04:00
Casey Peters bd74e68c71 feat(widget): add github ci status (#492)
* feat(widget): add github ci status

* fix(git): preserve PR metadata without CI access

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-07-20 00:04:54 -04:00
Vishnu J a523195573 feat(renderer): add left/right option for Default Padding (#497)
* feat(renderer): add left/right option for default padding

The Default Padding setting always applied to both sides of a widget.
Add a defaultPaddingSide setting (both/left/right, default both) so
padding can be narrowed to one side without affecting the other,
matching the request in #482.

Both the standard and Powerline renderers honor the new option, and
alignment width accounting (calculateMaxWidthsFromPreRendered) counts
only the sides that are actually applied. Existing configs are
unaffected: defaultPaddingSide defaults to 'both', preserving current
rendering exactly.

* test(renderer): strip ANSI in padding-side assertions, cover merge + TUI cycle

Independent review found the padding-side tests asserted exact strings
against chalk-wrapped padding output, which breaks under FORCE_COLOR=1
since chalk.reset() reacts to ambient color support, not the Settings
colorLevel used elsewhere in the file. Strip SGR codes before every
assertion, matching the repo's existing convention (stripSgrCodes in
renderer-flex-width.test.ts and the separator-collapse tests). Verified
passing under both FORCE_COLOR=1 and NO_COLOR=1.

Also add the two coverage gaps called out in review:
- merge:'no-padding' regression tests (standard + Powerline) proving no
  double-pad and correct glue across a no-padding merge boundary for
  both the 'left' and 'right' padding sides.
- a GlobalOverridesMenu TUI test for the (d) cycle, mirroring the
  existing (m) minimalist-mode pattern.

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-07-19 23:34:56 -04:00
Treebird 98e3c8bbbc docs: add ccsessions / cc-session-num integration example (#483)
Adds ccsessions to Related Projects in README and a new Integration
Example section in USAGE.md showing how to wire the cc-session-num
companion script as a Custom Command widget to display the current
session rank (#1, #2, …) in the status line.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-07-19 23:24:49 -04:00
dependabot[bot] 1edc66516d chore(deps-dev): bump the dev-dependencies group with 5 updates (#500)
---
updated-dependencies:
- dependency-name: "@remotion/cli"
  dependency-version: 4.0.487
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: remotion
  dependency-version: 4.0.487
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typedoc
  dependency-version: 0.28.20
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.62.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: vitest
  dependency-version: 4.1.10
  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>
2026-07-19 22:44:32 -04:00
Karan Gourisaria b8ffa30b66 docs: Add ccsidekick to related projects (#512) 2026-07-19 22:42:20 -04:00
gwittebolle f22564bf51 docs: add claude-carbon to Related Projects (#514) 2026-07-19 22:41:21 -04:00
Nate 1b6e738cea chore: Update README with statuslin.es related project 2026-07-16 16:26:37 -04:00
Matthew Breedlove 1af051e207 Version bump and docs update
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
Publish / Publish to npm (push) Waiting to run
2026-07-10 17:49:05 -04:00
Zach Landquist 745299d819 feat(compaction): add a metric selector for composable sub-values (#457)
* feat(compaction): add a metric selector for composable sub-values

compaction-counter only emitted the full composite line. A new `metric` metadata selector (count|auto|manual|unknown|reclaimed) makes one instance emit a single raw value, so several can be composed with custom separators/symbols, following the existing skills `mode` / context-bar `display` metadata-mode precedent. Default `count` keeps the composite display unchanged; sub-metrics render a bare number (reclaimed via formatTokens) and respect hideZero on the selected value.

Closes #450

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

* fix(compaction): use reachable metric keybind

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-07-10 17:42:16 -04:00
dependabot[bot] 8c40ef0a83 chore(deps-dev): bump the dev-dependencies group with 6 updates (#496)
Bumps the dev-dependencies group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@remotion/cli](https://github.com/remotion-dev/remotion) | `4.0.481` | `4.0.484` |
| [eslint](https://github.com/eslint/eslint) | `10.5.0` | `10.6.0` |
| [eslint-plugin-import-x](https://github.com/un-ts/eslint-plugin-import-x) | `4.16.2` | `4.17.1` |
| [globals](https://github.com/sindresorhus/globals) | `17.6.0` | `17.7.0` |
| [remotion](https://github.com/remotion-dev/remotion) | `4.0.481` | `4.0.484` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.61.0` | `8.62.0` |


Updates `@remotion/cli` from 4.0.481 to 4.0.484
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](https://github.com/remotion-dev/remotion/compare/v4.0.481...v4.0.484)

Updates `eslint` from 10.5.0 to 10.6.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.5.0...v10.6.0)

Updates `eslint-plugin-import-x` from 4.16.2 to 4.17.1
- [Release notes](https://github.com/un-ts/eslint-plugin-import-x/releases)
- [Changelog](https://github.com/un-ts/eslint-plugin-import-x/blob/master/CHANGELOG.md)
- [Commits](https://github.com/un-ts/eslint-plugin-import-x/compare/v4.16.2...v4.17.1)

Updates `globals` from 17.6.0 to 17.7.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.6.0...v17.7.0)

Updates `remotion` from 4.0.481 to 4.0.484
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](https://github.com/remotion-dev/remotion/compare/v4.0.481...v4.0.484)

Updates `typescript-eslint` from 8.61.0 to 8.62.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.62.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@remotion/cli"
  dependency-version: 4.0.484
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: eslint
  dependency-version: 10.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: eslint-plugin-import-x
  dependency-version: 4.17.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: globals
  dependency-version: 17.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: remotion
  dependency-version: 4.0.484
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.62.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>
2026-07-10 16:24:54 -04:00
ekkoitac 460c2519d9 fix: invert plain usage percentages (#473)
* fix: invert plain usage percentages

* fix: clarify usage direction controls

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-07-10 16:23:28 -04:00
黄黄汪 4fefc51139 fix: silence stderr on best-effort package-manager probes (#491)
When bun is on PATH but its global directory was never initialized
(no `bun add -g` ever run), launching the config TUI prints
`error: No package.json was found for directory "~/.bun/install/global"`
into the terminal. The TUI probes `bun pm bin -g` (and `where`/`which`,
`npm prefix -g`, `npm root -g`) via execFileSync without an stdio
option, so the child's stderr is inherited by the parent terminal even
though the thrown error itself is caught and treated as "not found".

Add stdio: ['ignore', 'pipe', 'ignore'] to these best-effort probes
(stdout stays piped for the return value), matching the pattern already
used elsewhere in the codebase. Adds regression tests asserting every
probe call suppresses stderr and that a throwing bun probe degrades
gracefully.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:58:40 -04:00
Matthew Breedlove 44f49d3efb Adding related project 2026-07-08 01:29:39 -04:00
Matthew Breedlove c56849db42 Adding related project 2026-07-07 02:37:29 -04:00
CC d47b7bd902 feat(context): make the context-window fallback configurable (#480)
Motivated by #429. The context widgets already read the real window from Claude Code's context_window.context_window_size when present, and from a [1m]-style model-name hint otherwise; only the final fallback was a hard-coded 200k. On an older Claude Code that does not report the window size for a 1M-context model, that fallback made the bar read against 200k (e.g. 750k/200k, pinned full).

Demote the hard-coded 200k to the default of a configurable last-resort fallback, CCSTATUSLINE_CONTEXT_SIZE_FALLBACK (positive integer, ignored when unset or invalid), mirroring CCSTATUSLINE_WIDTH. The live status field and model-name hint still take precedence, so the override only applies when the window is otherwise unknown.
2026-06-30 13:22:34 -04:00
Tyler Hebenstreit 477164e605 fix: self-heal legacy untagged ccstatusline hooks on sync (#490)
* fix: self-heal legacy untagged ccstatusline hooks on sync

stripManagedHooks only removed entries tagged 'ccstatusline-managed', so
untagged hooks written by pre-tag versions survived every sync and piled up
beside the freshly-written managed hooks. Also strip untagged entries whose
command matches the ccstatusline --hook invocation, making hook sync idempotent
for legacy installs. Non-ccstatusline user hooks are untouched.

* fix(hooks): preserve user commands during legacy cleanup

Treat the ccstatusline-managed tag as whole-entry ownership, but handle legacy untagged ccstatusline hooks at the individual command level. This prevents sync and uninstall cleanup from dropping unrelated user commands that share the same Claude hook entry with an old ccstatusline --hook command.

Add regression coverage for mixed legacy hook entries in both syncWidgetHooks and removeManagedHooks so future cleanup changes keep user-managed hook commands intact.

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-30 13:14:12 -04:00
Bernát Gábor f7d7af7c32 feat(widgets): add maxWidth support to Git Branch and Git Root Dir (#488)
The maxWidth field already exists on every widget in the schema, but only
the Custom Command widget honored it. On a single-line status bar a long
branch or repo name pushes the model, context and other widgets off the
end, where they are truncated as `|...`.

Let Git Branch and Git Root Dir truncate their own visible text to a
configured maxWidth (ellipsis included), so the rest of the line survives.
For hyperlinked widgets only the visible label is truncated; the link
target stays intact. A shared max-width helper carries the truncation,
the `(w)idth` keybind, the editor and the `max:N` modifier display.

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-30 12:21:18 -04:00
Bernát Gábor c20e11145b feat(renderer): let widgets opt out of powerline auto-align (#489)
* feat(renderer): let widgets opt out of powerline auto-align

In powerline auto-align mode every widget is padded so its column lines
up with the widget above and below it. A single wide widget, such as a
long git branch, therefore stretches that column on every other line and
wastes horizontal space.

Add an `excludeFromAutoAlign` flag. A flagged widget, and everything
after it on the same line, stops contributing to the shared column widths
and stops receiving alignment padding, so it keeps its natural width
while the columns before it stay aligned. The items editor exposes an
`e(x)clude align` toggle and a `(no-align)` marker, shown only when
powerline auto-align is on.

* fix(renderer): constrain no-align to alignable widgets

Only allow excludeFromAutoAlign to be toggled from the items editor when powerline auto-align is active and the selected widget is not merged into a previous widget. This keeps the shortcut behavior aligned with the visible help text and prevents hidden exclusions from being persisted while the feature is unavailable.

Treat no-align as a merge-chain-head option in the UI, so widgets merged into a previous item no longer show an effective no-align marker. The renderer keeps merged-in widgets participating in their parent group width while still honoring exclusions on the first widget in the chain.

Add regression coverage for merged no-align width calculation and for the editor shortcut gate.

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-30 12:09:29 -04:00
CC cea4d3c9fd feat(widget): add optional glyph to Current Working Dir (#479)
Wire the shared symbol-override module into the Current Working Dir widget so it exposes the same (g)lyph option the Git and JJ widgets use. The default symbol is empty, so existing output is unchanged and no glyph is emitted unless the user sets one. The glyph also applies in raw value mode, so pairing it with raw value replaces the cwd: label with the chosen icon (e.g. a Nerd Font folder glyph).

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-30 11:48:26 -04:00
Matthew Breedlove aae5addb13 fix(usage): soften reset timer startup state
Suppress fallback usage API errors when reset-style timer widgets are only missing reset timestamps. This prevents transient rate-limit responses during Claude startup from surfacing as [Rate limited] before embedded rate_limits data is available.

Show labeled loading placeholders for reset timers while reset data is unavailable, preserving raw mode as just [Loading]. Add regression coverage for suppressed startup fetch errors and raw/non-raw loading output.
2026-06-30 11:37:12 -04:00
Bernát Gábor 08d3c7cc00 🐛 fix(usage): clear in-flight lock on a successful fetch (#487)
* fix(usage): drop in-flight lock on fetch success

The pre-fetch usage.lock is written as a 'timeout' guard before the API
call but is never removed on success, so it lingers for LOCK_MAX_AGE. A
cache miss in that window (e.g. an account switch invalidating the token
fingerprint) then returns getStaleUsageOrError('timeout', ...), showing a
spurious [Timeout] while the API is healthy.

Clear the lock after a successful cache write. Genuine error and
rate-limit backoff locks are untouched.

Fixes #486

* fix(usage): preserve throttle for incomplete usage fetches

Only clear the in-flight usage lock after a successful API response satisfies the fields requested by the caller. Aggregate-only responses that are missing per-model fields remain cached, but keep the short timeout lock so later renders do not refetch the API on every status line render.

Add regression coverage for a weekly Sonnet usage request receiving an aggregate-only 200 response, verifying that the second fetch is throttled instead of issuing another API request.

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-30 11:28:21 -04:00
dependabot[bot] 6793c75515 chore(deps): bump actions/checkout from 6 to 7 (#476)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 11:14:31 -04:00
dependabot[bot] 70ca56353e chore(deps-dev): bump the dev-dependencies group with 5 updates (#477)
Bumps the dev-dependencies group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@remotion/cli](https://github.com/remotion-dev/remotion) | `4.0.475` | `4.0.481` |
| [eslint](https://github.com/eslint/eslint) | `10.4.1` | `10.5.0` |
| [remotion](https://github.com/remotion-dev/remotion) | `4.0.475` | `4.0.481` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.1` | `8.61.0` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |


Updates `@remotion/cli` from 4.0.475 to 4.0.481
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](https://github.com/remotion-dev/remotion/compare/v4.0.475...v4.0.481)

Updates `eslint` from 10.4.1 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.1...v10.5.0)

Updates `remotion` from 4.0.475 to 4.0.481
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](https://github.com/remotion-dev/remotion/compare/v4.0.475...v4.0.481)

Updates `typescript-eslint` from 8.60.1 to 8.61.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.61.0/packages/typescript-eslint)

Updates `vitest` from 4.1.8 to 4.1.9
- [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/v4.1.9/packages/vitest)

---
updated-dependencies:
- dependency-name: "@remotion/cli"
  dependency-version: 4.0.481
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: remotion
  dependency-version: 4.0.481
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: vitest
  dependency-version: 4.1.9
  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>
2026-06-30 11:13:40 -04:00
CC 8398d91706 feat: print version and exit on --version (#463)
Running ccstatusline --version launched the TUI instead of reporting the version. Handle the flag at the top of main() before mode detection: print getPackageVersion() and exit. Closes #461.
2026-06-19 13:57:09 -04:00
Zach Landquist 151521ca6e feat(usage): invalidate the usage cache when the account token changes (#460)
* feat(usage): invalidate the usage cache when the account token changes

The usage cache was keyed only by CACHE_MAX_AGE, so a logout/login to a different account served the prior account stale usage until the 180s TTL. Fingerprint the token (truncated SHA-256, an identifier not the token) and persist it with the cache; fetchUsageData resolves the token first and gates the file-cache read on a fingerprint match, so a mismatch refetches immediately. No-token falls through to the existing path; pre-fingerprint caches refetch once on upgrade.

Closes #459

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

* fix(usage): respect token hash for stale cache fallbacks

Thread the current usage token fingerprint into stale-cache fallback handling so cached usage from a previous account is not returned when a lock is active or the API is unavailable.

This keeps account-switch invalidation consistent across fresh file-cache reads, active locks, rate-limit backoffs, API errors, and parse errors while preserving the existing no-token fallback behavior.

Add regression coverage for active-lock and rate-limited fallback paths with mismatched cached token hashes.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-16 21:33:28 -04:00
Zach Landquist 2bb0cdfc65 fix: stop refetching usage every render on accounts without rate-limit windows (#434)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-16 21:19:53 -04:00
CC c04247f2f4 feat(tui): warn on an invalid settings.json and confirm before overwriting it (#458)
* feat(tui): add config-load warning + save-guard helpers

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

* feat(tui): surface invalid settings.json with a banner and save-guard

* fix(tui): address adversarial review (accurate confirm reason, post-install re-sync, save-failure flash + screen reset, Ctrl+S re-entrancy guard)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-16 18:46:48 -04:00
Matthew Breedlove 30de97505f Version bump and docs update
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
Publish / Publish to npm (push) Waiting to run
2026-06-16 02:00:58 -04:00
Shawn Sorichetti cb2555d180 fix: honour flex-separator in powerline render path (#411)
* fix: honour flex-separator in powerline render path

renderPowerlineStatusLine filters out flex-separator widgets at the
start of the function and never restores their layout effect, so a
configured flex-separator silently has no effect when powerline mode
is enabled. The non-powerline render path correctly distributes
terminal-width minus content-width across each flex-separator
position; this commit teaches the powerline path to do the same.

How:

- Before filtering, compute a Set of filteredWidgets indices that are
  immediately followed by a flex-separator in the original widget
  array.
- During the render loop, when one of those indices is reached, emit
  a closing powerline cap (a triangle in the previous widget's bg
  colour with no bg of its own) followed by a sentinel string, and
  skip the regular between-widgets separator.
- After the render loop, if any flex positions were marked, split the
  result string on the sentinel, measure the visible width of each
  part, distribute the remaining terminal width evenly across the
  flex positions as spaces, and reassemble. When the terminal width
  is unknown the sentinels are stripped so they cannot leak.

Tests:

Four new tests in renderer-flex-width.test.ts cover
- single flex-separator in powerline mode
- multiple flex-separators in powerline mode
- sentinel cleanup when terminal width is unknown
- non-powerline path regression check

All 585 tests pass (581 existing + 4 new). Lint and tsc are clean.

* fix(powerline): honor flex separator segments

Preserve flex separators when enabling powerline mode and keep them available in the widget catalog while manual separators remain disabled.

Track powerline flex positions against rendered widgets so hidden or empty widgets do not move right-aligned segments. Reserve end caps before distributing flex space to avoid truncating visible content.

Advance start cap selection across flex-created segments and subsequent lines so each visible powerline segment uses the next configured cap.

Add regression coverage for hidden widgets before flex separators, end caps with flex spacing, powerline catalog/settings behavior, and multi-line start cap sequencing.

* fix(renderer): preserve powerline separator cycling across flex segments

Treat flex separators as boundaries between independent powerline segments instead of consuming configured separator glyphs as caps.

Each flex-delimited segment now receives the matching start and end cap, while intra-segment separators advance using the actual number of rendered separator slots. Separator glyph selection now cycles through the configured list instead of clamping to the last configured glyph, so multi-line layouts continue the expected sequence after flex splits.

Update separator index accounting to skip flex boundaries and count slots independently within each segment. Add regression coverage for segment caps, flex separator accounting, separator cycling across lines, and explicit separator widgets.

Tests: bun test

Tests: bun run lint

* fix(powerline): align separator and cap sequencing

* fix(renderer): respect separator merge boundaries

Treat explicit separators and flex separators as hard merge boundaries when calculating separator slots, powerline theme slots, and rendered powerline elements.

This prevents no-padding merges from crossing separators, keeps theme color advancement isolated on each side of a boundary, and keeps auto-alignment width groups from spanning flex separators.

When terminal width is unknown, render powerline flex separators as a single visible space instead of dropping the boundary entirely so adjacent segments remain separated.

Add regression coverage for separator indexing, powerline theme slot counting, powerline flex separator spacing, padding behavior, theme color advancement, and auto-align width grouping.

---------

Co-authored-by: Shawn Sorichetti <ssoriche@users.noreply.github.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-16 01:22:48 -04:00
Zach Landquist 50195e15ae test: make the suite pass on Windows hosts (#454)
Sixteen specs failed on a Windows host while passing on POSIX CI. The production code is already platform-aware; only the specs assumed a POSIX host. These changes are test-only.

terminal: pin process.platform for the probe specs via a defineProperty setter restored in afterEach (vi.spyOn on the getter did not re-apply reliably across specs); also consumes a leaked mockImplementationOnce. claude-settings: pin platform and stub getConfigPath to a literal POSIX path so the --config quoting specs avoid path.resolve drive-prefixing. usage-token: build the credentials path via path.join. config-dir: assert path.resolve of the input.

Closes #453

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 17:40:52 -04:00
Zach Landquist 7be789fa56 feat(compaction): make the reclaimed-tokens arrow an overridable glyph (#452)
The reclaimed-tokens suffix hardcoded its leading down-arrow. Route it through the existing symbol-override slot system (metadata slot symbolReclaimed) so it can be customized or cleared from the glyph editor, the same way the git widgets expose their symbols.

The default is unchanged, so existing configs render identically. An empty override drops the glyph but keeps the separating space.

Closes #448

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 17:22:30 -04:00
Zach Landquist e51b0027c6 fix(context-bar): render counts >= 1M as "1.0M" instead of "1000k" (#451)
ContextBar formatted used/total with Math.round(n / 1000) + 'k', so a 1M context window showed '1000k'. Add an optional decimals arg to formatTokens (default 1, behavior-preserving for all existing callers) and route the bar through it with decimals=0, so it keeps compact whole-number k and gains the existing M rollup.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 17:08:31 -04:00
Matthew Breedlove b20296c008 fix(config): preserve symlinked settings on save
Resolve symlinked settings paths before performing the atomic temp-file rename. This keeps dotfile-managed settings.json files and custom --config links intact while still updating the linked target.

Write the temporary file beside the resolved target so the final rename remains atomic and avoids cross-device replacement issues. Fall back to the configured settings path when the file does not exist yet.

Add a regression test that saves through a symlinked settings file, verifies the link survives, verifies the target JSON updates, and checks temp cleanup in both directories.
2026-06-15 16:16:25 -04:00
CC f3ecfe8254 fix(config): non-destructive recovery + loud warning for invalid settings.json (#447)
* fix(config): stop overwriting invalid settings.json on load

On a parse or validation failure, loadSettings now returns defaults in
memory and leaves the user's file untouched instead of backing it up and
overwriting it with defaults. The render path runs loadSettings on every
prompt, so the old behavior silently reset a malformed config. Removes the
now-redundant .bak backup (the preserved original is the backup).

Closes #393

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

* fix(config): write settings atomically via temp file + rename

Route every config write through a write-to-temp-then-rename, so a reader
(the render path runs on every prompt) never observes a partially written
settings.json. Mirrors the existing atomic-write idiom in git.ts. The temp
is cleaned up if the write fails.

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

* fix(config): show a loud warning when settings.json can't be loaded

When loadSettings falls back to defaults (invalid or unreadable settings.json),
the statusline now prepends a red "invalid config" badge so the failure is
visible instead of silent — previously the only signal was a stderr message the
piped statusline never surfaces. The detailed reason still goes to stderr.

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

* fix(config): validate a migration before persisting it

Previously a versioned config was migrated and written back to disk *before*
the final schema validation, so a faulty migration could overwrite the user's
original file with invalid output. Now the migrated result is validated first
and persisted only if it passes; on failure the original file is left untouched
and defaults are used in memory. This makes the non-destructive guarantee total
and lets the schema-fail path honestly report "file left unchanged".

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

* fix(config): don't overwrite an unreadable settings.json when saving install metadata

saveInstallationMetadata loads settings then writes them back to record the
installation method. If the existing file was unreadable, loadSettings returns
defaults in memory, so the write would discard the user's (recoverable) file —
the last path still violating the non-destructive contract. It now skips the
write when the existing config could not be read, leaving the file untouched
(metadata is non-critical and is persisted on the next clean save).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 15:35:00 -04:00
Zach Landquist 4f01094f21 feat: add per-widget dim styling, whole widget or parens-only (#433)
* feat: add per-widget dim styling, whole widget or parens-only

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

* fix(tui): scope dim styling in previews

Preserve parens dim styling when foreground gradients render in both regular and powerline paths.

Emit a combined intensity reset when restoring bold after dim so Ink preserves the dim reset in preview output.

Reset whole-widget dim before powerline separators and end caps, and render ColorMenu style indicators as one suffix to avoid badge wrapping.

Add regression coverage for gradient dim composition, powerline dim boundaries, Ink preview output, and ColorMenu indicator layout.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-15 15:05:40 -04:00
dependabot[bot] 2159379a3a chore(deps-dev): bump the dev-dependencies group with 4 updates (#446)
Bumps the dev-dependencies group with 4 updates: [@remotion/cli](https://github.com/remotion-dev/remotion), [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react), [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.472 to 4.0.475
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](https://github.com/remotion-dev/remotion/compare/v4.0.472...v4.0.475)

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

Updates `remotion` from 4.0.472 to 4.0.475
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](https://github.com/remotion-dev/remotion/compare/v4.0.472...v4.0.475)

Updates `typescript-eslint` from 8.60.0 to 8.60.1
- [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.60.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@remotion/cli"
  dependency-version: 4.0.475
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: remotion
  dependency-version: 4.0.475
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.60.1
  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>
2026-06-15 04:46:23 -04:00
Matthew Breedlove 6d4f10a273 docs(thinking-effort): clarify ultracode xhigh reporting 2026-06-15 04:20:27 -04:00
Matthew Breedlove 29895206d7 Version bump and docs update
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
Publish / Publish to npm (push) Waiting to run
2026-06-15 02:45:16 -04:00
CC bfe86ab904 fix(renderer): render 999950-999999 tokens as '1.0M' not '1000.0k' (#444)
Values in [999950, 999999] hit the thousands branch where (count/1000).toFixed(1) rounds up to '1000.0', producing '1000.0k'. Lower the millions threshold to that exact rounding boundary so they render as '1.0M'. Adds a direct unit test for formatTokens (previously exercised only indirectly via widget spies).

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-15 02:35:26 -04:00
CC 3c1f472598 feat(compaction): opt-in trigger split and tokens reclaimed (#445)
* refactor(compaction): compute stats struct (count, byTrigger, tokensReclaimed) from markers

Parse compactMetadata.{trigger,preTokens,postTokens} during the existing
compact_boundary scan. No widget behavior change yet.

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

* refactor(compaction): freeze zero stats, floor reclaimed at 0, clarify naming

Address review feedback on the data layer: freeze the shared
ZERO_COMPACTION_STATS (incl. nested byTrigger); floor each marker's reclaimed
contribution with Math.max(0, ...) so postTokens>preTokens can't go negative;
rename metaObj -> metaRecord; add unknown-trigger and negative-reclaim tests.

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

* feat(compaction): opt-in trigger split via (s) keybind

Adds a per-item showTriggers toggle that appends '(2 auto, 1 manual)';
unknown bucket shown only when > 0; omitted at count 0. Rendering now flows
through formatStats, shared by live render and preview.

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

* test(compaction): clarify zero-suffix test name; cover manual-only bucket

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

* feat(compaction): opt-in tokens reclaimed via (t) keybind

Adds a per-item showReclaimed toggle that appends the reclaimed token total
(e.g. 887.0k) via the shared formatTokens humanizer; omitted when 0. Stacks
independently with the trigger split.

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

* refactor: extract formatTokens to a leaf module to avoid a circular import

CompactionCounter importing formatTokens from renderer.ts created a cycle
(renderer -> widget registry -> CompactionCounter) that broke isolated runs of
its test file with a TDZ error. Move formatTokens into src/utils/format-tokens.ts;
renderer re-exports it so existing importers are unaffected.

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

* test(compaction): cover tokens reclaimed with a non-default format

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

* docs(compaction): document trigger-split and tokens-reclaimed toggles

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

* refactor(compaction): apply code-review feedback

- use shared isMetadataFlagEnabled/toggleMetadataFlag for the trigger/reclaimed
  flags instead of local copies
- freeze SAMPLE_STATS to match ZERO_COMPACTION_STATS
- guard tokensReclaimed against non-finite (overflow) marker differences
- docs: note the unknown trigger bucket and that reclaimed is floored/omitted at 0

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

* fix(compaction): carry the formatTokens rounding fix into the extracted leaf

The reclaimed display calls formatTokens; the extracted format-tokens.ts held the
pre-fix version, so a reclaimed total in [999950, 999999] rendered '1000.0k'
instead of '1.0M'. Apply the same threshold fix as PR #444 here, so the reclaimed
figure is correct and merging both PRs in either order cannot regress it.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 02:11:32 -04:00
dmsp 2fc4921b77 feat(widget): add Cache Hit / Read / Write widgets (#423)
* feat(widgets): add Cache Hit / Read / Write widgets

Adds a new "Cache" widget category that surfaces prompt-cache efficiency,
which previously was hidden: TokensCached summed cache_read and
cache_creation into one opaque number.

New widgets, each toggling between per-turn ("last action") and session
scope via the 't' keybind:
- Cache Hit   - read / (read + creation), %
- Cache Read  - cache_read tokens with context share, e.g. "88.0k (84.5%)"
- Cache Write - cache_creation tokens with context share, e.g. "12.0k (11.5%)"

No new data source: turn scope reads context_window.current_usage from the
live status JSON; session scope sums the transcript. TokenMetrics gains
optional cacheReadTokens / cacheCreationTokens (split of cachedTokens, which
is unchanged for backward compatibility).

Defaults: Cache Hit/Read green, Cache Write yellow (recolorable in the TUI).

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

* feat(cache): add hide option for empty values

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-14 04:12:44 -04:00
Sebastian Szewczyk 6126ff75f2 feat(widget): add cache-hit-rate widget (#409)
Adds a Cache Hit Rate widget that mirrors the Anthropic Console's
prompt-cache hit-rate formula: cache_read / (cache_read + cache_creation
+ fresh_input). Returns null on empty transcripts so it does not flash
0% before any tokens are recorded.

Splits cacheReadTokens and cacheCreationTokens out of the existing
cachedTokens roll-up on TokenMetrics (both new fields are optional and
cachedTokens is preserved unchanged), so downstream widgets and configs
are unaffected.

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-14 03:53:12 -04:00
CC dd820ea42f feat(custom-command): include terminal_width in stdin JSON (#396)
* feat(custom-command): include terminal_width in stdin JSON

Custom Command widgets receive context.data as JSON over stdin, but the
terminal width was not part of it, so scripts had no way to adapt their
output to the terminal (tput/stty/$COLUMNS all return 80 in the piped
context).

Populate context.terminalWidth once in the render pipeline via
getTerminalWidth() (which also lets the renderer reuse it instead of
re-probing per line) and add terminal_width to the JSON piped to custom
commands when a numeric width is known. The field name uses snake_case
to match the existing Claude Code payload (session_id, current_dir, ...).

Resolves the request in upstream issue #308.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(custom-command): document terminal_width in stdin JSON

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-14 03:44:17 -04:00
Martijn Riemers 7db0914856 feat: add Extra Usage Used widget showing spent overage budget (#417)
* fix: format extra usage in the currency reported by the usage API

The usage API reports the account's billing currency
(extra_usage.currency, e.g. "EUR"), but ExtraUsageRemaining always
formatted with a hardcoded dollar sign, showing non-USD budgets with
the wrong symbol.

Parse the currency field through the API response schema, file cache,
and prefetch merge, and format the remaining budget with
Intl-based currency formatting (falling back to USD when the field is
absent or invalid, preserving current output for USD accounts).

Fixes #415

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

* feat: add Extra Usage Used widget showing spent overage budget

The usage API's extra_usage.used_credits is already fetched and cached
(extraUsageUsed), but no widget could display it. Accounts with extra
usage enabled and no monthly limit configured get neither of the
existing widgets (both require limit-derived fields), so the spent
amount is the only displayable extra usage number for them.

Adds an extra-usage-used widget modeled on extra-usage-remaining,
including the hide-if-disabled toggle and raw value support.

Closes #414

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

* feat: format Extra Usage Used with the API-reported currency

Builds on the formatUsageCurrency helper from the currency fix so the
new widget shows the account's billing currency too.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-14 03:39:02 -04:00
Martijn Riemers 2d85849476 fix: format extra usage in the currency reported by the usage API (#418)
The usage API reports the account's billing currency
(extra_usage.currency, e.g. "EUR"), but ExtraUsageRemaining always
formatted with a hardcoded dollar sign, showing non-USD budgets with
the wrong symbol.

Parse the currency field through the API response schema, file cache,
and prefetch merge, and format the remaining budget with
Intl-based currency formatting (falling back to USD when the field is
absent or invalid, preserving current output for USD accounts).

Fixes #415

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-14 03:33:25 -04:00
CC df25207d18 fix(compaction): count compact_boundary markers instead of inferring from context-% drops (#425)
* feat(compaction): add marker-based compaction counting

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(compaction): count markers instead of inferring from context-% drops

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(compaction): remove obsolete context-%-drop heuristic and cache

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(compaction): pin explicit isSidechain:false; docs: marker-count wording

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-14 03:12:56 -04:00
Zach 8ae6481f25 feat: allow overriding widget symbols with a custom glyph (#431)
* feat: allow overriding widget symbols with a custom glyph

Extends the item.character override (already honored by Git
Staged/Unstaged/Untracked) to every widget with a hardcoded symbol and
makes it editable in the TUI.

- Add src/widgets/shared/symbol-override.tsx: shared (g)lyph keybind,
  symbol/prefix helpers, and a slot-based editor (type a character or
  emoji, Backspace renders without a symbol, choosing the default clears
  the override); single-symbol widgets store on item.character,
  multi-symbol widgets keep per-part metadata keys
- Wire it into Git Branch (⎇), Git Worktree (𖠰), Git Worktree Mode (⎇),
  Git Conflicts (⚠), JJ Bookmarks (🔖), JJ Workspace (◆), the three
  presence widgets that already supported item.character, and the
  multi-symbol widgets Git Ahead/Behind (↑/↓) and Git Status (!+*?)
- Add preview-path render coverage for default/override/suppressed plus
  helper unit tests; document the keybind in docs/USAGE.md

Co-authored-by: Claude <noreply@anthropic.com>

* fix: align glyph editor labels

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-14 02:56:45 -04:00
BWM0223 887f786875 fix: add missing metadata fields to package.json (#443) 2026-06-14 01:52:09 -04:00
Pugsin 3df04e168e fix: point package module to published build (#442)
* fix: point package module to published build

Signed-off-by: Pugsin <sergio.icdlf@gmail.com>

* fix: expose published package entrypoints

---------

Signed-off-by: Pugsin <sergio.icdlf@gmail.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-14 01:49:02 -04:00
Matthew Breedlove 28d98e320c Version bump and README update
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
Publish / Publish to npm (push) Waiting to run
2026-06-13 23:53:36 -04:00
Zach 04b4026f1d fix: prefer cumulative transcript metrics for input/output token widgets (#435)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-13 23:43:27 -04:00
Martijn Riemers bc34c3a97e fix: permanent [Timeout] in extra usage widgets when no monthly limit is set (#416)
* fix: treat enabled extra usage without a monthly limit as complete usage data

Accounts with extra usage enabled but no monthly limit configured
(monthly_limit: null) never receive extraUsageLimit/extraUsageUtilization
from the usage API. hasRequiredUsageField treated those absent fields as
incomplete data on every fetch - including successful ones - so cached
data was never accepted, the fetch loop re-fetched each cycle, and the
extra usage widgets surfaced the cached lock error as a permanent
[Timeout].

Extend the existing extraUsageEnabled === false special case to any
response where the extra usage state is known: once the API has told us
whether extra usage is enabled, absent detail fields are conclusive and
refetching cannot produce them.

Fixes #413

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

* test: drop currency from no-limit fixture to stay order-independent of #418

Once #418 teaches the parser about extra_usage.currency, a fixture
carrying the field would add extraUsageCurrency to the parsed result
and break this test's expectation depending on merge order. The field
is irrelevant to what this test covers (monthly_limit: null handling).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-13 20:24:14 -04:00
KorenKrita 96cb608294 fix: ignore transient 0% flashes in compaction counter (#370)
* fix: ignore transient 0% flashes in compaction counter

Ignore context percentage values below 1% to prevent false compaction
counts when Claude Code emits incomplete status JSON frames.

* test: clarify transient compaction glitches

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-13 20:16:21 -04:00
Zach 3058693a64 fix: keep usage-fetch tests from touching the real user cache (#432)
* fix: keep usage-fetch tests from touching the real user cache

The probe subprocesses overrode HOME but inherited the real USERPROFILE,
which os.homedir() prefers on Windows, so every test run read and wrote
the developer's live ~/.cache/ccstatusline: fixture data and stale locks
ended up in the running statusline, which showed [Timeout] until the
lock expired and fixture percentages until the cache aged out.

- Pin USERPROFILE alongside HOME in the probe environment
- Pin CLAUDE_CONFIG_DIR and both proxy variables so inherited developer
  settings never reach probes
- Report os.homedir() from every probe and assert it matches the sandbox
  home, so any future isolation escape fails loudly
- Make the lowercase https_proxy assertion platform-aware: Windows
  environment variables are case-insensitive, so it is indistinguishable
  from HTTPS_PROXY there

On Windows this takes the file from 0/11 passing to 11/11.

Co-authored-by: Claude <noreply@anthropic.com>

* fix(test): preserve lowercase proxy env in usage probes

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-13 20:05:18 -04:00
dependabot[bot] a221e8ab2c chore(deps-dev): bump the dev-dependencies group across 1 directory with 11 updates (#422)
Bumps the dev-dependencies group with 11 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@remotion/cli](https://github.com/remotion-dev/remotion) | `4.0.461` | `4.0.472` |
| [eslint](https://github.com/eslint/eslint) | `10.3.0` | `10.4.1` |
| [eslint-import-resolver-typescript](https://github.com/import-js/eslint-import-resolver-typescript) | `4.4.4` | `4.4.5` |
| [ink-gradient](https://github.com/sindresorhus/ink-gradient) | `4.0.0` | `4.0.1` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.6` | `19.2.7` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.14` | `19.2.16` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.6` | `19.2.7` |
| [remotion](https://github.com/remotion-dev/remotion) | `4.0.461` | `4.0.472` |
| [tinyglobby](https://github.com/SuperchupuDev/tinyglobby) | `0.2.16` | `0.2.17` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.3` | `8.60.0` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.6` | `4.1.8` |



Updates `@remotion/cli` from 4.0.461 to 4.0.472
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](https://github.com/remotion-dev/remotion/compare/v4.0.461...v4.0.472)

Updates `eslint` from 10.3.0 to 10.4.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.3.0...v10.4.1)

Updates `eslint-import-resolver-typescript` from 4.4.4 to 4.4.5
- [Release notes](https://github.com/import-js/eslint-import-resolver-typescript/releases)
- [Changelog](https://github.com/import-js/eslint-import-resolver-typescript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-import-resolver-typescript/compare/v4.4.4...v4.4.5)

Updates `ink-gradient` from 4.0.0 to 4.0.1
- [Release notes](https://github.com/sindresorhus/ink-gradient/releases)
- [Commits](https://github.com/sindresorhus/ink-gradient/compare/v4.0.0...v4.0.1)

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

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

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

Updates `remotion` from 4.0.461 to 4.0.472
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](https://github.com/remotion-dev/remotion/compare/v4.0.461...v4.0.472)

Updates `tinyglobby` from 0.2.16 to 0.2.17
- [Release notes](https://github.com/SuperchupuDev/tinyglobby/releases)
- [Changelog](https://github.com/SuperchupuDev/tinyglobby/blob/main/CHANGELOG.md)
- [Commits](https://github.com/SuperchupuDev/tinyglobby/compare/0.2.16...0.2.17)

Updates `typescript-eslint` from 8.59.3 to 8.60.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.60.0/packages/typescript-eslint)

Updates `vitest` from 4.1.6 to 4.1.8
- [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/v4.1.8/packages/vitest)

---
updated-dependencies:
- dependency-name: "@remotion/cli"
  dependency-version: 4.0.472
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: eslint
  dependency-version: 10.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: eslint-import-resolver-typescript
  dependency-version: 4.4.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: ink-gradient
  dependency-version: 4.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: react
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: react-dom
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: remotion
  dependency-version: 4.0.472
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: tinyglobby
  dependency-version: 0.2.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: vitest
  dependency-version: 4.1.8
  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>
2026-06-13 19:27:03 -04:00
Rayan Salhab 447e0e69eb fix: git-review detection with SSH host aliases (#424)
* Fix git review detection for SSH host aliases

* fix(git-review): preserve canonical SSH forge hosts

---------

Co-authored-by: cyphercodes <cyphercodes@users.noreply.github.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-13 19:25:10 -04:00
Cameron Sjo 97108fc1ea feat: Gradient color support and line-spanning foreground gradient overrides (#406)
* feat: line-spanning foreground gradients via overrideForegroundColor

Add a `gradient:<stop>,<stop>,...` form for `overrideForegroundColor` that
paints the whole status line with a continuous gradient — each visible
character is colored by its column position, so the gradient spans the line
rather than restarting per widget. Applies to standard (non-powerline) lines;
powerline separators derive their color from adjacent backgrounds, so a
foreground gradient is intentionally not applied there.

- src/utils/gradient.ts: parse hex stops (`hex:RRGGBB` / `#RRGGBB` / bare),
  interpolate in OKLab for perceptually even, non-muddy blends, and map to
  truecolor or the nearest ansi256 index.
- src/utils/ansi.ts: applyLineGradient walks the assembled line with the
  existing escape/cluster tokenizer, so SGR styling and OSC-8 hyperlinks pass
  through untouched and visible width is unchanged (flex layout unaffected).
- src/utils/renderer.ts: applied after assembly and before truncation in the
  standard path. `overrideForegroundColor` accepts the new `gradient:` form
  alongside the existing `hex:` / `ansi256:` / named tagged-string forms; a
  gradient spec is not treated as a per-widget solid color and degrades to a
  no-op at ansi16 (keeping widgets' own colors).

Tests cover spec parsing, OKLab sampling, ansi256 quantization,
width-invariance, OSC-8 passthrough, and a renderer integration case.

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

* feat: add per-widget gradients + presets + TUI picker on the line-span core

Converges the two same-day gradient PRs into one coherent feature. #406
added line-spanning gradients via overrideForegroundColor (OKLab, zero-dep);
#404 (@akkaz) added gradient as a per-widget color with named presets and a
ColorMenu picker. Both created src/utils/gradient.ts and would conflict, so
this meshes them onto a single shared OKLab engine:

- gradient.ts: GRADIENT_PRESETS (akkaz's stops, gradient-string MIT;
  rainbow/pastel re-expressed as multi-stop hue wheels for OKLab); unified
  parseGradientSpec accepting presets, dash (RRGGBB-RRGGBB), and comma
  (hex:..,..) forms; applyGradientToText for the per-widget sweep.
- colors.ts: per-widget gradient hook in applyColors; getColorAnsiCode
  collapses a gradient to its first stop (powerline / ansi16 degrade).
- ColorMenu.tsx: 'g' opens a gradient picker (preset list + custom hex),
  foreground-only, colorLevel >= 2.
- No new dependency (tinygradient dropped in favor of OKLab).

Precedence: overrideForegroundColor gradient (line-span) > widget.color
gradient (per-widget) > solid. Gradients self-degrade at render time, so
color-sanitize leaves them untouched at every level.

Builds on the per-widget design from #404 by @akkaz.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: akkaz <giomarco@cleversoft.it>

* refactor: polish gradient feature — fix truncation reset-leak, tighten, document

Pre-PR polish pass over the gradient feature.

Fix (correctness):
- renderStatusLine now applies the line-span gradient AFTER truncation, not
  before. truncateStyledText cuts from the right and appends a raw "..." with no
  trailing reset, so a gradient applied earlier had its closing \x1b[39m sliced
  off, leaking the last color past the status line. Gradient codes are zero-width,
  so the truncation measurement is unaffected by deferring. Regression test added.

Simplify:
- Add exported isGradientSpec(); reuse it across colors.ts and renderer.ts instead
  of duplicating the 'gradient:' startsWith check. Drop the redundant prefix guard
  in applyColors (parseGradientSpec already self-guards).

Docs:
- Document applyGradientToText's code-point (vs grapheme-cluster) iteration as a
  known limitation, with the ZWJ/variation-selector consequence and the
  circular-import reason it isn't unified with applyLineGradient yet.
- Note that hex:/# /bare stops are valid in both comma and dash parse forms.
- Note getColorAnsiCode's ansi16 branch intentionally emits a truecolor first-stop
  escape (caller-degraded; never reached at a true ansi16 terminal).
- README + docs/USAGE.md: gradient color options (both scopes, presets, forms).

bun test 1433 pass / 0 fail; lint + tsc clean.

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

* fix(gradient): preserve escape sequences in widget gradients

Keep ANSI and OSC control sequences byte-for-byte intact when applying per-widget gradients so hyperlinks and pre-styled widget output are not corrupted by inserted SGR codes.

Count and color only visible non-whitespace characters while passing escape sequences through unchanged. Add a regression covering styled OSC 8 link output.

* fix(colors): suppress gradient fallback at ansi16

Prevent stored gradient color specs from emitting 24-bit SGR codes when the active terminal color level has been reduced to Basic or No Color.

Leave gradient settings intact, but return no gradient ANSI fallback at ansi16 and cover the behavior in per-widget and raw-code regression tests.

* fix(renderer): detect truncated gradient lines visibly

Preserve truncation reporting after whole-line gradients insert SGR codes between the ellipsis characters.

Check the visible text for the truncation marker instead of the raw styled bytes, and add a regression where the rendered line no longer contains a literal contiguous ellipsis.

* feat(tui): add gradients to foreground overrides

Let Global Overrides assign a whole-line foreground gradient through the same preset/custom selector used by the color screen.

Move (g) to gradient selection, add (x) as the foreground override clear key, show gradient override values in the menu, and update usage docs for the new controls and Basic/No Color degradation behavior.

In Powerline mode, carry global gradient position continuously across widget text while leaving separators and caps under Powerline contrast rules. Surface an active foreground override warning on the Powerline Setup screen so theme masking is visible.

---------

Co-authored-by: Cameron Sjo <cameronsjo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: akkaz <giomarco@cleversoft.it>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-06-13 18:56:09 -04:00
Matthew Breedlove 54fce0903e fix: support Windows npm shim execution
Closes #401.
2026-06-02 09:53:25 -04:00
Kotob 9d4b6ee4f9 feat: add remote control status widget (#382)
* feat: add remote control status widget

Adds a widget that reads ~/.claude/sessions/<pid>.json and shows whether
the current session has a remote-control bridge attached, matched by
session_id from the Claude Code status payload.

Claude Code sets bridgeSessionId to null on disconnect and rewrites the
session file within ~1s, so the widget reflects connect/disconnect
transitions at the next status-line refresh.

* feat: add label-check format to remote control status widget

Renders "remote " when connected and "remote " when disconnected, so
the bar can be parsed by shape/color at a glance instead of reading "on"
or "off". Slots into the existing format cycle after word.

* feat: add label-mark format using text-mode check/cross

Renders "remote ✓" / "remote ✗" using U+2713 and U+2717. Unlike the
label-check emoji variant these are text-mode glyphs, so they stay
single-cell and pick up the widget color instead of drawing in the OS
emoji palette — less visually invasive while still parseable by shape.
2026-05-20 02:26:47 -04:00
dependabot[bot] cd2e41adc8 chore(deps-dev): bump the dev-dependencies group with 5 updates (#386)
Bumps the dev-dependencies group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@remotion/cli](https://github.com/remotion-dev/remotion) | `4.0.459` | `4.0.461` |
| [@types/bun](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/bun) | `1.3.13` | `1.3.14` |
| [remotion](https://github.com/remotion-dev/remotion) | `4.0.459` | `4.0.461` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.2` | `8.59.3` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.5` | `4.1.6` |


Updates `@remotion/cli` from 4.0.459 to 4.0.461
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](https://github.com/remotion-dev/remotion/compare/v4.0.459...v4.0.461)

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

Updates `remotion` from 4.0.459 to 4.0.461
- [Release notes](https://github.com/remotion-dev/remotion/releases)
- [Commits](https://github.com/remotion-dev/remotion/compare/v4.0.459...v4.0.461)

Updates `typescript-eslint` from 8.59.2 to 8.59.3
- [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.59.3/packages/typescript-eslint)

Updates `vitest` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/vitest)

---
updated-dependencies:
- dependency-name: "@remotion/cli"
  dependency-version: 4.0.461
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@types/bun"
  dependency-version: 1.3.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: remotion
  dependency-version: 4.0.461
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.59.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: vitest
  dependency-version: 4.1.6
  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>
2026-05-20 02:13:00 -04:00
Ronald E. Oribio R. 4b314d5fce fix(extra-usage): correct unit assumptions for usage API response (#388)
* fix(extra-usage): correct unit assumptions for usage API response

The Anthropic usage API returns `extra_usage.used_credits` in cents (not
dollars) and `extra_usage.utilization` as a percent 0-100 (not a 0-1
fraction). The current widget code assumed the inverse, which made:

- ExtraUsageUtilization always clamp to 100.0% (multiplying a percent
  by 100 and clamping to [0,100])
- ExtraUsageRemaining always show $0.00 (subtracting cents from dollars
  goes negative, clamped to 0)

Drop the `* 100` in ExtraUsageUtilization and divide `extraUsageUsed`
by 100 in ExtraUsageRemaining so both widgets render real values.

Verified locally against a live API response where util=6.74 and
used=27087/limit=400000 — now renders "Overage: 6.8% | Overage Left:
\$3,728.54" instead of "Overage: 100.0% | Overage Left: \$0.00".

* test(extra-usage): update fixtures for corrected unit assumptions

Adjust extraUsageUsed fixtures to cents and extraUsageUtilization
fixtures to 0-100 percentages so they exercise the corrected widget
math.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(extra-usage): align API unit fixtures

Update fetch and prefetch fixtures to reflect used credits as cents and extra usage utilization as a 0-100 percentage. Clarify the UsageData comments to match the widget contract.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-05-20 02:11:09 -04:00
Josi Aranda cc8e82f476 feat(weekly-reset): add weekday display mode for Weekly Reset Timer (#392)
Add a (w)eekday toggle to the Weekly Reset Timer widget in date mode.
When enabled, renders "Sun 11:00 PM GMT+9" instead of the full ISO date,
matching Claude web's weekly usage display style.

Co-authored-by: mm-aranda <aranda@macromill.com>
2026-05-20 01:49:42 -04:00
Matthew Breedlove 1b7cfb010b chore: bump version to 2.2.19
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
Publish / Publish to npm (push) Waiting to run
2026-05-17 03:07:31 -04:00
Daniel Naves de Carvalho ea97ddd69e feat(terminal): honor CCSTATUSLINE_WIDTH env var to override probe (#380)
* feat(terminal): honor CCSTATUSLINE_WIDTH env var to override probe

Provide an explicit width override so users can bypass the TTY probe
entirely when both ancestor-walk and `tput cols` fall through.

This is the fallback case the existing probe cannot solve on its own:
Claude Code >= 2.1.139 spawns statusline/hooks without terminal access,
and in some configurations (IDE integrations, nested shells, certain
agent-mode spawn paths) no ancestor process owns a TTY either. The
ancestor walk fails, `tput cols` returns 80, and the multi-line layout
truncates regardless of the actual iTerm2/terminal width.

PR #377 (`stty -F`/`stty -f`) covers the case where an ancestor does
hold a TTY but the legacy `< /dev/tty` form errors with ENOTTY. This
patch is complementary -- it handles the case where the ancestor walk
finds no TTY at all -- and gives users a knob today while upstream
work on passing `terminalWidth` via stdin JSON (#308) lands.

Change
------

`src/utils/terminal.ts` -- `probeTerminalWidth` now reads
`CCSTATUSLINE_WIDTH` before any platform check or probe. A valid
positive integer short-circuits with that value; anything else
(missing, empty, non-numeric, zero, negative) falls through to the
existing probe logic.

Usage
-----

Set the env var on the statusLine command in `~/.claude/settings.json`:

```json
"statusLine": {
  "type": "command",
  "command": "CCSTATUSLINE_WIDTH=200 ccstatusline"
}
```

Tests
-----

Four new cases in `src/utils/__tests__/terminal.test.ts`:

- override short-circuits probing entirely
- non-positive override (`0`) falls back to probing
- non-numeric override falls back to probing
- override applies on Windows where probing is otherwise disabled

`bunx vitest run src/utils/__tests__/terminal.test.ts` -> 12/12 pass.
Full-suite delta vs `main`: 4 new passing tests, zero new failures
(the pre-existing 105 env-specific failures in other suites are
unchanged on both branches).

`bun run lint` -> clean.

* docs: document terminal width override

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-05-17 03:01:58 -04:00
Matthew Breedlove 6a581e6c6a fix: cache git subprocess output for statusline
Add persistent git command caching under ~/.cache/ccstatusline/git-cache with command-only entries, cwd metadata, repo mtimes, and configurable TTL.

Expose Git Cache TTL in Configure Status Line, default it to 5s, and pass it through the real render context.

Make pinned global install the top/default option, clarify install wording, and add windowsHide to runtime child process options.

Closes #384.
2026-05-17 02:42:11 -04:00
Deepak Dewani 654802e4d6 fix(git): use compatible git commands for older git versions (#385)
* fix(git): use compatible git commands for older git versions

This commit updates git commands to be compatible with older versions of Git (e.g. 2.10.1). Specifically, it replaces branch --show-current with rev-parse --abbrev-ref HEAD and uses the GIT_OPTIONAL_LOCKS environment variable instead of the --no-optional-locks flag.

* fix(git): address review compatibility regressions

Use symbolic-ref --short HEAD for branch detection in the GitBranch widget and review cache so unborn branches keep rendering their branch name while detached HEAD still resolves as no branch.

Update git command assertions after replacing --no-optional-locks argv usage with GIT_OPTIONAL_LOCKS=0 in the exec environment, including shared coverage for widgets and git remote helpers.

Add a small test helper for asserting git exec options without depending on the full process environment.

Verification: bun test; bun run lint; git diff --check

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-05-17 01:33:09 -04:00
Matthew Breedlove cf5ab7be45 chore: Version bump and docs update
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
Publish / Publish to npm (push) Waiting to run
2026-05-15 09:42:38 -04:00
Bastian Staunstrup 861a2a0638 fix(git): always pass --no-optional-locks to prevent index.lock races (#381)
When the statusline refreshes while another git process is writing
.git/index.lock (e.g. a concurrent `git restore --staged`), commands
like `git diff --shortstat` fail with "Unable to create index.lock"
because diff tries to refresh the stat cache and races on the lock.

`runGitArgs` now prepends `--no-optional-locks` to every git
invocation so read-only commands cannot interfere with the user's
working git operations. The two call sites that already prefixed the
flag manually are simplified.
2026-05-15 09:38:40 -04:00
dependabot[bot] 4091574f52 chore(deps-dev): bump the dev-dependencies group across 1 directory with 4 updates (#368)
Bumps the dev-dependencies group with 4 updates in the / directory: [eslint](https://github.com/eslint/eslint), [globals](https://github.com/sindresorhus/globals), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [zod](https://github.com/colinhacks/zod).


Updates `eslint` from 10.2.1 to 10.3.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.2.1...v10.3.0)

Updates `globals` from 17.5.0 to 17.6.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.5.0...v17.6.0)

Updates `typescript-eslint` from 8.59.0 to 8.59.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.59.2/packages/typescript-eslint)

Updates `zod` from 4.3.6 to 4.4.3
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.3.6...v4.4.3)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: globals
  dependency-version: 17.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.59.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: zod
  dependency-version: 4.4.3
  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>
2026-05-14 09:52:19 -04:00
Ronald E. Oribio R. 196a370a2e feat: add extra usage widgets and fix null rate-limit buckets for pay-as-you-go plans (#375)
* feat: add extra usage widgets and fix null rate-limit buckets for pay-as-you-go plans

Add two new widgets for Anthropic pay-as-you-go (extra usage) plans:
- `extra-usage-utilization` — shows overage utilization as a percentage
- `extra-usage-remaining` — shows remaining monthly overage budget in dollars

Fix a Zod schema bug that caused all usage widgets to time out on PAYG plans.

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

* fix: handle disabled extra usage widgets

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-05-14 09:41:24 -04:00
Matthew Breedlove 29908c266a fix: usage API parsing for nullable cohort response buckets
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
Publish / Publish to npm (push) Waiting to run
2026-05-14 08:58:50 -04:00
Matthew Breedlove b01336b1a7 chore: scope publish workflow to this repository so forks don't try to push to the ccstatusline npm 2026-05-13 14:42:03 -04:00
Pragy Agarwal 9eccca279e fix(terminal): use stty -F/-f to read tty size in detached spawn (#377)
The TTY-width probe walked ancestor processes to find a controlling
PTY and ran `stty size < /dev/${tty}` to read its dimensions. That
form fails with ENOTTY on Linux when the calling process has no
controlling terminal — which is now the case under Claude Code
>= 2.1.139, whose changelog reads "hooks now run without terminal
access". The statusLine spawn is hardened the same way. Probe falls
back to `tput cols` (= 80), flexMode "full-minus-40" collapses to
40 columns, and the statusline truncates regardless of the real
terminal width.

GNU coreutils `stty -F <path>` and BSD `stty -f <path>` open the
device themselves (with O_NOCTTY semantics) and succeed regardless
of controlling-tty status. Try `-F` then `-f` then the historical
redirect form so we keep working on every stty variant.

Verified: on a Linux+ptyxis spawn under Claude Code 2.1.140 the probe
goes from returning null to returning the real width (159 cols here)
without restarting the session.

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-05-13 13:47:13 -04:00
Matthew Breedlove 9ab87c3f55 Adding publishing workflow for npm provenance attestation
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
Publish / Publish to npm (push) Waiting to run
Closes #369
2026-05-13 13:28:41 -04:00
Matthew Breedlove 1739572a96 fix: merge partial usage data for widgets
Use widget-specific requirements to fetch missing usage fields and merge rate_limits data with API results.

Preserve API errors alongside available usage data so widgets with fulfilled fields still render while missing fields surface fetch failures.
2026-05-13 13:09:37 -04:00
Matthew Breedlove b8821a7d83 fix: ignore bunx statusline shims
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
2026-05-12 02:24:39 -04:00
Matthew Breedlove ed3ce5ba0b feat: Version pinning install support
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
Closes #298
Closes #103
2026-05-12 02:14:02 -04:00
Matthew Breedlove ec283761d4 Update demo gif
Add remotion screens for making demo gif, update demo gif
2026-05-12 00:01:05 -04:00
Matthew Breedlove f198e72a62 Version bump and doc update
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
2026-05-11 03:28:05 -04:00
Sam Hopkins 29c47242a8 feat: add weekly Sonnet and Opus usage widgets (#361)
* feat: add weekly Sonnet and Opus usage widgets

* Fix per-model usage prefetch fallback

* Fix per-model usage cache handling

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-05-11 02:17:49 -04:00
Bentley(중길) 23b01a3877 docs: add AIWatch integration example (Custom Command + Related Projects) (#365)
Adds a "Integration Example: AIWatch" section to docs/USAGE.md mirroring
the existing ccusage section, plus a Related Projects link in README, per
the placement the maintainer chose in #362.

AIWatch (ai-watch.dev) monitors live status for 30+ AI APIs/apps; the
Custom Command one-liner surfaces degraded providers in the status line
and renders empty when everything's operational.

Co-authored-by: Bentley <bentley@naemomlab.com>
2026-05-11 01:48:40 -04:00
Thomas BENOIT 0ae20bea5e feat: add voice status widget showing Claude Code voice input state (#357)
* feat: add voice status widget showing Claude Code voice input state

* Fix voice status workspace config lookup

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-05-11 01:45:32 -04:00
Thomas BENOIT 456cc46de7 feat: short bar mode for timer widgets (#358) 2026-05-11 01:27:30 -04:00
dependabot[bot] a69e09a9a9 chore(deps-dev): bump the dev-dependencies group with 2 updates (#354)
* chore(deps-dev): bump the dev-dependencies group with 2 updates

Bumps the dev-dependencies group with 2 updates: [typescript](https://github.com/microsoft/TypeScript) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `typescript` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.2...v6.0.3)

Updates `typescript-eslint` from 8.58.2 to 8.59.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.59.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.59.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: satisfy updated lint rules

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-05-11 01:17:29 -04:00
Matthew Breedlove 0a749bb1ef fix(hooks): suppress no-op JSON output
Exit silently after hook side effects so Claude Code 2.1.117+ does not validate an empty JSON object.

Resolves #364
2026-05-11 00:57:25 -04:00
Sean Brandt bbd19313cc feat(jj): add Jujutsu VCS widgets (#205)
* feat(jj): add jj utility functions and shared hide-no-jj feature

Add jj VCS utility module mirroring the existing git utilities with
workspace detection, command execution, and diff stat parsing. Add
shared jj-no-jj toggle for hiding 'no jj' messages in jj widgets.

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

* feat(jj): add JjChange and JjBookmark widgets

Add two new Jujutsu VCS widgets mirroring the GitBranch pattern:
- JjChange: displays current jj change ID with `jj:` prefix
- JjBookmark: displays current jj bookmark name(s) with `@` prefix,
  showing `(none)` when no bookmarks are set
- Add `runJjRaw` utility to distinguish empty output from errors

Both widgets support raw value mode, hide-no-jj configuration,
and include full test coverage (17 tests).

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

* feat(jj): add JjChanges, JjInsertions, JjDeletions, JjRootDir, and JjDescription widgets

Add five new Jujutsu VCS widgets with full test coverage:
- JjChanges: combined insertions/deletions count (+ins,-del)
- JjInsertions: insertion count from jj diff --stat
- JjDeletions: deletion count from jj diff --stat
- JjRootDir: workspace root directory name extraction
- JjDescription: current change description via jj log

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

* feat(jj): add JjWorkspace widget for displaying current workspace name

Add getJjCurrentWorkspace() utility that parses `jj workspace list` output
to extract the current workspace name from the first line. Implement
JjWorkspaceWidget with blue color, W: prefix, raw value support, and
hide-no-jj toggle. Include tests for both the utility function and widget.

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

* feat(jj): register all jj widgets in manifest and add shared behavior tests

Add all 8 jj widgets (JjChange, JjBookmark, JjChanges, JjInsertions,
JjDeletions, JjRootDir, JjDescription, JjWorkspace) to the widget
exports and manifest registry. Add shared behavior test suite validating
hide-no-jj keybind, metadata toggling, editor display, and Jujutsu
category across all jj widgets.

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

* fix(jj): consolidate runJj/runJjRaw, fix double subprocess and hideNoJj invariant

- Merge runJjRaw into runJj with allowEmpty parameter to eliminate
  duplication while preserving semantic distinction between empty
  output and command failure
- Fix JjRootDir calling jj workspace root twice per render by
  removing redundant isInsideJjWorkspace guard
- Extract getRootDirName to module-level function (matches project
  convention of no private helper methods)
- Fix JjDescription ignoring hideNoJj flag when command fails after
  workspace check passes
- Fix JjDescription preview string inconsistency ('(no description
  set)' vs '(no description)')
- Add tests for allowEmpty behavior and JjDescription hideNoJj
  command failure case

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

* feat(git): add hideWhenJj toggle to suppress git widgets in jj workspaces

Add a new per-widget toggle that hides git widgets when a jj workspace
is detected, enabling clean colocated repo support. Users can press 'j'
in the TUI items editor to enable this on any git widget.

- Add git-hide-when-jj.ts shared module with metadata flag, keybind,
  and editor display helpers
- Update all 6 git widgets (Branch, Changes, Insertions, Deletions,
  RootDir, Worktree) with the new toggle
- Compose modifier text from both hideNoGit and hideWhenJj flags
- Chain action handlers via nullish coalescing
- Add shared behavior tests for toggle, keybind, and modifier display

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

* fix(jj): remove git widget jj hide keybind

* chore(git): restore git modifier helper usage

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-28 04:26:03 -04:00
Daniel Correia ad9519e1b9 feat: Jujutsu VCS (jj) widgets (#189)
* feat: add jujutsu widgets

* fix: better revset functions to improve usability

* Fix JJ widget command execution

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-28 03:45:39 -04:00
Matthew Breedlove af87d5594b Version bump and doc update
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
CI / Lint & Type Check (push) Waiting to run
2026-04-28 02:10:43 -04:00
黄黄汪 a1a47ab9c5 fix: context percentage labels reflect used/remaining state (#311)
Previously the Context % and Context % (usable) widgets always
rendered the static label "Ctx: " / "Ctx(u): ", so a user looking
at "Ctx: 9.3%" could not tell whether 9.3% was used or remaining
without opening the editor to check the inverse toggle.

Now the label reflects the current state:
- default (used):  "Ctx Used: X%"   /  "Ctx(u) Used: X%"
- inverse (left):  "Ctx Left: X%"   /  "Ctx(u) Left: X%"

The existing (u) keybind "(u)sed/remaining" and modifier text
"(remaining)" already named these states; this just surfaces the
same distinction in the rendered status line output. Tests updated
to match.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-28 02:06:03 -04:00
Matthew Breedlove fb0be05f53 fix(tui): allow more than three powerline caps 2026-04-28 01:53:30 -04:00
François-Pierre Bouchard de374741d0 feat: add time cursor to usage progress bars (#254)
* feat: add time cursor to usage progress bars

The old progress bar showed only how much of the usage limit had been
consumed. The new makeTimerProgressBar adds an optional cursor marker
that shows the elapsed time position within the current usage window.
Users can toggle the cursor on and off with the t keybind when in
progress display mode.

* fix: show time cursor in short usage bars

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-28 01:16:51 -04:00
Matthew Breedlove 1f26e52e80 Version bump and doc update
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
2026-04-28 00:06:52 -04:00
CJ 3b0a26e3a9 feat: add compaction-counter widget (#282)
* feat: add compaction-counter widget

Tracks context compaction events per session. When Claude Code compacts
the conversation, used_percentage drops; this widget detects the drop
and displays ↻N.

Detection: any drop in used_percentage > 2 points between consecutive
renders counts as one compaction. The threshold filters rounding noise
on both 200K and 1M context windows. State stored per-session in
~/.cache/ccstatusline/compaction/ as JSON.

Related: #92

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

* fix: sanitize session ID in cache path and handle write failures

- Sanitize session ID to prevent path traversal via crafted StatusJSON
- Wrap cache writes in try/catch — failures no longer crash status line
- Add persistence tests: round-trip, path traversal regression, write failure
- Clarify threshold doc (drop must exceed 2 points)

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

* style: align compaction files with project conventions

- Restore EOF newlines (eol-last flipped 'never' → 'always' in 9f779db)
- Use bare module names ('fs', 'os', 'path') with namespace imports
- Use safeParse for cache file parsing
- Refactor test to use a render(options) helper
- Strip @param/@returns from detectCompaction JSDoc
- Collapse trivial single-property object literals to one-line form
- Split guard clause across two lines in CompactionCounter.render

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

* fix(widget): harden compaction-counter against bad input and symlinks

Correctness:
- Pass raw used_percentage to detectCompaction; Math.round was missing
  real drops between rounded points (40.4 → 37.6 is a 2.8 drop but
  rounds to 40 → 38 = 2, failing the > 2 threshold).
- Sentinel prevCtxPct=-1 for fresh state so sessions starting at 0%
  are detected correctly (prior prevCtxPct>0 guard excluded that case).
- Guard detectCompaction against NaN/Infinity/negative input; NaN
  silently poisoned prevCtxPct and disabled detection forever.
- Always load the persisted count so the widget keeps showing the
  historical value when a status update has no used_percentage; only
  run detection+save when a current percentage is present.
- Skip the save when state is unchanged, avoiding a redundant fsync
  per refreshInterval tick.

Security:
- Atomic save via temp file + rename defeats a planted symlink at the
  cache path that would otherwise be written through. Orphan temp
  files are cleaned up if rename fails after write succeeded.
- Open with O_NOFOLLOW + fstat on the open fd: rejects symlinks and
  non-regular files on read, and closes the TOCTOU window that would
  otherwise let the path be swapped between stat and read.
- 4 KiB cache file size cap on read prevents DoS via oversized file.
- Hash session IDs that are empty or contain disallowed characters so
  distinct sessions can't collide in the same cache file.

Tests added for NaN/Infinity/negative input, non-integer drops, 0%
session start, sequential compactions, threshold 0, corrupted JSON,
oversized cache file, symlink rejection on read, atomic save replacing
a planted symlink, session-ID collision, and preview ignoring live data.
Symlink tests are skipped on Windows where fs.symlinkSync requires
elevated privileges.

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

* fix(compaction): harden counter detection and display

fix(compaction): derive usage from shared context percentage metrics when raw used_percentage is missing.

fix(compaction): reset persisted baselines when context window sizes change to avoid false compaction counts.

refactor(context): reuse shared percentage metrics in the context percentage widget.

feat(compaction-counter): add format cycling for icon-space, text label, and number-only displays.

feat(compaction-counter): add Nerd Font icon support only for the icon-space format.

feat(compaction-counter): show zero by default and add an opt-in hide-when-zero toggle.

test(compaction): cover window-size changes, derived percentages, display modes, Nerd Font behavior, and zero visibility.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-28 00:01:59 -04:00
Andrey Golanov bd20bbb265 fix: detect effort changes from /effort command in ThinkingEffort widget (#252)
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-27 23:18:35 -04:00
Matthew Breedlove 78836d74f3 fix: read thinking effort from status JSON 2026-04-27 23:09:52 -04:00
dependabot[bot] a95059ba2a chore(deps-dev): bump typescript from 5.9.3 to 6.0.2 (#342)
* chore(deps-dev): bump typescript from 5.9.3 to 6.0.2

Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Include Bun types for TypeScript 6

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-27 22:55:26 -04:00
dependabot[bot] cccf448518 chore(deps-dev): bump the dev-dependencies group with 3 updates (#341)
Bumps the dev-dependencies group with 3 updates: [@types/bun](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/bun), [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


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

Updates `eslint-plugin-react-hooks` from 7.0.1 to 7.1.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/eslint-plugin-react-hooks@7.1.1/packages/eslint-plugin-react-hooks)

Updates `vitest` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest)

---
updated-dependencies:
- dependency-name: "@types/bun"
  dependency-version: 1.3.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: eslint-plugin-react-hooks
  dependency-version: 7.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: vitest
  dependency-version: 4.1.5
  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>
2026-04-27 22:44:23 -04:00
CJ 89a0a78467 fix(renderer): collapse separators around empty widgets (#344)
In layouts with explicit `{type: "separator"}` items, when a widget
between two separators renders empty, both surrounding separators
still emit — producing "A |  | B" instead of "A | B". The walkback
in renderStatusLine that gates each separator was looking for ANY
prior widget with content, not the immediately-prior one. That's
correct for hiding a leading separator (nothing before it) but
wrong when the immediate-prior widget is empty in the middle of a
line.

Fix: stop the walkback at the first non-separator widget and emit
only when that widget actually rendered content. The leading-edge
case still hides correctly (no prior widget → no orphan), and a
merge:'no-padding' chain across a collapsed separator now reaches
the next visible widget as expected. Affects any hide-capable
widget (git-changes with hideNoGit, etc.). The auto-separator
(defaultSeparator) path already filters empty widgets out of its
element chain and isn't touched.

New tests cover:
- empty widget between two separators (the canonical case)
- consecutive empty widgets between content widgets
- leading empty widget (regression — already worked, locks behavior)
- merge:'no-padding' interaction across a collapsed separator
- defaultSeparator path independence (the fix doesn't couple to it)
- all-content happy path (regression guard)

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-27 22:43:45 -04:00
Matthew Breedlove 48bc4208fb Version bump and doc update
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
2026-04-27 02:03:30 -04:00
isseeeeey55 b4c19ec46f feat: support local/IANA timezone and locale selection in reset timers timestamp mode (#340)
* feat: support local/IANA timezone in reset timer date mode

Closes #337.

Adds an optional 'timezone' metadata key to BlockResetTimer and
WeeklyResetTimer. When provided, the reset timestamp (already exposed
in date mode via #220) is rendered through Intl.DateTimeFormat in the
specified timezone instead of UTC.

## Behavior

- 'timezone' unset or 'UTC': existing UTC behavior is preserved
- 'timezone' = 'local': uses the system timezone
- 'timezone' = an IANA name (e.g. 'Asia/Tokyo'): explicit override
- Invalid timezone names fall back to UTC

## Output examples

| timezone     | output (compact=false)        | output (compact=true) |
|--------------|-------------------------------|-----------------------|
| (none) / UTC | '2026-03-12 08:30 UTC'        | '03-12 08:30Z'        |
| Asia/Tokyo   | '2026-03-12 17:30 GMT+9'      | '03-12 17:30'         |
| local        | '2026-03-12 17:30 <tz>'       | '03-12 17:30'         |

## Implementation

- 'formatUsageResetAt' grows an optional 'timezone' parameter
- When set to a non-UTC value, uses 'Intl.DateTimeFormat' with
  'timeZone' option to render parts, plus 'timeZoneName: short' for
  the trailing label
- Widget render passes 'metadata.timezone' through via a new
  'getUsageTimezone(item)' helper
- Falls back to UTC on Intl errors so misconfiguration never blanks
  the widget

## Tests

- new cases in 'src/utils/__tests__/usage.test.ts' covering specific
  IANA TZ, local TZ, UTC backwards compat, and invalid TZ fallback

* feat: support 'locale' metadata for reset timer date mode

Adds an optional 'locale' metadata key alongside 'timezone' so users
can pick a locale that returns a friendlier short timezone label.

Background: 'Intl.DateTimeFormat' with the default 'en-CA' locale
returns 'GMT+9' for Asia/Tokyo. Switching to 'ja-JP' yields 'JST'.
Same for many other zones — 'en-US' tends to give EST/EDT etc.

## Behavior

- 'locale' omitted: uses 'en-CA' (current behavior, no change)
- 'locale' = an IANA / BCP 47 tag: passed to 'Intl.DateTimeFormat'
- If the supplied locale produces no usable output (rare), falls
  back to the default 'en-CA' formatter, then UTC

The numeric date parts come from 'formatToParts', so even with
'ja-JP' the output is still '2026-04-27 18:40 JST' — only the
trailing zone label changes.

## Sample config

{
  "type": "weekly-reset-timer",
  "metadata": {
    "absolute": "true",
    "timezone": "Asia/Tokyo",
    "locale": "ja-JP"
  }
}

## Tests

Added cases in src/utils/__tests__/usage.test.ts for ja-JP yielding
JST, en-CA yielding GMT+9 for the same instant, compact mode behavior,
and lenient locale handling.

* feat(widgets): add reset timer timezone controls

Add reusable timezone picker support for block and weekly reset timers.

Add a timestamp-mode 12/24hr toggle that defaults to 24hr.

* feat(widgets): add reset timer locale controls

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-27 01:57:54 -04:00
tejas 9c8b6c2781 feat: add git file status and enhanced git widgets (#208)
* feat: add 7 git status widgets (staged, unstaged, untracked, ahead/behind, conflicts, clean status, SHA)

Add granular git repository status widgets addressing issue #20:
- GitStagedFiles: staged file count (S:N format)
- GitUnstagedFiles: unstaged file count (M:N format)
- GitUntrackedFiles: untracked file count (A:N format)
- GitAheadBehind: upstream ahead/behind tracking (↑N ↓M format)
- GitMergeConflicts: merge conflict count (⚠N, hidden when 0)
- GitCleanStatus: clean/dirty indicator (✓/✗)
- GitSha: short commit SHA

All widgets follow existing patterns: Widget interface, git-no-git shared
helpers, hideNoGit support, rawValue support, and cross-platform git
commands via runGit(). Includes shared getGitFileStatusCounts() utility
for the file count widgets.

21 new tests across 7 test files + shared behavior test entries.

Closes #20

* fix: handle unstaged rename source paths

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-27 01:06:15 -04:00
myprogs c2a77c38d3 feat(widgets): add Context Window widget (#335)
Adds a new "context-window" widget that displays the total context
window size for the current model (e.g. 1.0M for Opus 1M, 200k for
Sonnet/Haiku). Resolves the size from runtime context_window_size,
falling back to per-model config when absent — same logic as
ContextBarWidget. Useful for users who want "used/total (pct)"
output without the progress bar from context-bar.

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-27 00:39:22 -04:00
Edison 316d727c0f feat: support absolute datetime for reset timers (#220)
* feat: add date mode for block and weekly reset timers

* fix(tui): avoid reset timer timestamp keybind conflict

Use t for timestamp mode so d remains the editor delete shortcut, and hide the weekly hours-only toggle while timestamp mode is active.

---------

Co-authored-by: Fan Bot <clawbot@Fandexuniji.local>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-27 00:31:29 -04:00
Vsevolod Kosarev 17938a98fc feat(widgets): add short bar display mode to percentage widgets (#333)
Add a compact progress bar (▓░, 10 chars, no brackets) as a new display
mode for all percentage-based widgets: SessionUsage, WeeklyUsage,
ContextPercentage, ContextPercentageUsable, and ContextBar. The mode
cycles via the existing 'p' keybind alongside the original progress bar
variants.

Renamed bar size labels for consistency: long bar (32 chars),
medium bar (16 chars), short bar (10 chars), short bar only (10 chars
without percentage text).

Added 11 unit tests covering makeSliderBar rendering, display mode
cycling with and without slider, and Context % slider render output.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-27 00:04:15 -04:00
Matthew Breedlove eb1f0d17f0 Version bump and doc update
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
2026-04-26 23:48:45 -04:00
CJ 6f875631da fix(widget): claude-account-email respects CLAUDE_CONFIG_DIR (#339)
* fix(widget): claude-account-email respects CLAUDE_CONFIG_DIR

Closes #317.

The widget computed the .claude.json path as ${configDir}/../.claude.json,
which only happens to work when configDir is the default ~/.claude (going
up one level lands at $HOME). When CLAUDE_CONFIG_DIR points elsewhere
(e.g. ~/.claude-work), the same .. heuristic still lands at $HOME, so
all profiles read the same .claude.json and display the same account
email — the symptom reported in the issue.

Fix: branch the path resolution on whether CLAUDE_CONFIG_DIR is set.
Claude Code stores .claude.json inside CLAUDE_CONFIG_DIR when that env
var is set, otherwise it lives at ~/.claude.json.

Also tightened the surrounding code while in the area:
- Drop redundant existsSync check (readFileSync ENOENT is already caught)
- Drop redundant path.resolve on already-absolute paths
- Tighten email field check from truthy (!email) to typeof+length, so a
  non-string oauthAccount.emailAddress can't render as Account: 12345

New tests cover:
- $CLAUDE_CONFIG_DIR/.claude.json is read when env var is set (regression)
- $HOME/.claude.json is NOT silently read when CLAUDE_CONFIG_DIR points
  to a dir without one (no profile leak)
- Non-string emailAddress returns null (typeof guard)

* chore: Centralize implementation of getClaudeJsonPath similar to getClaudeSettingsPath and getClaudeConfigDir

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-26 22:53:21 -04:00
Thomas BENOIT cef29e128a feat(tui): add k shortcut to clone selected widget in items editor (#328)
Pressing k on a selected widget inserts a copy just after it and moves
selection to the clone. In Powerline mode, the clone gets a fresh
background color to avoid adjacent duplicate bands.

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-24 00:10:19 -04:00
Justin Mecham afbbf66b3a feat: support GitLab in Git Branch and Git PR widgets (#330)
* feat(git-branch): support GitLab and self-hosted hosts for branch link URLs

Replace the GitHub-only parseGitHubBaseUrl helper with a forge-agnostic
buildBranchWebUrl built on the existing parseRemoteUrl + RemoteInfo
plumbing in git-remote.ts. GitBranch links now work for GitHub, GitLab,
and compatible self-hosted remotes that expose the standard
host/owner/repo path.

Uses a single /tree/<branch> suffix because GitLab redirects it to its
canonical /-/tree/<branch> form, so one format covers both forges.

parseGitHubBaseUrl and its helper were the only GitHub-specific URL
builders in hyperlink.ts; remove them and their tests now that GitBranch
is the only caller and has been migrated.

Also rename the metadata key linkToGitHub to linkToRepo to match the
naming used by GitOriginOwnerRepo / GitOriginRepo / GitUpstreamOwner.
Legacy linkToGitHub is preserved as a read-only fallback: toggling the
modifier strips both keys and writes only linkToRepo, so users who
interact with the feature get their settings quietly upgraded. Explicit
linkToRepo:false wins over legacy linkToGitHub:true.

* feat(git-pr): support GitLab merge requests via glab

Replace the GitHub-only gh-pr-cache with a forge-aware git-review-cache.
The widget now renders pull requests for GitHub (via `gh`) and merge
requests for GitLab (via `glab`), picking the CLI per-repo based on the
origin remote host:

- host contains `github` → `gh`
- host contains `gitlab` → `glab`
- unknown/self-hosted host → probe each CLI with
  `gh/glab auth status --hostname <h>` and use whichever is authenticated
  against that host; if neither is, stay quiet rather than fire wasted
  queries
- no origin remote → try both and let the CLI resolve the repo itself

For forks where the CLI would default-resolve to the parent repo, the
fetch falls back to `--repo <origin-url>` after an empty first query so
the user's fork PR/MR is still found.

GitPr.ts now records the provider on the cache entry and renders "MR #N"
for glab and "PR #N" for gh; raw mode stays `#N` for both. Widget display
name becomes "Git PR/MR".

Also rename the widget `type` from `git-pr` to `git-review` to match the
internal `git-review-cache` name. Legacy `git-pr` configs keep rendering
via a resolver in widgets.ts, and loadSettings silently rewrites them to
`git-review` in-memory so the canonical name lands on the next save —
same pattern as the linkToGitHub → linkToRepo rewrite in the previous
commit.

* docs(usage): describe GitHub and GitLab behavior for Git widgets

Update the Git-section intro to mention both forges and the CLI-selection
rules used for self-hosted hosts, and note on the Git PR keybind line
that the widget renders "MR" for GitLab origins.

* chore(gitlab-support): trim verbose comments to match repo style

Most of this repo's TS files carry zero or a handful of one-line comments;
the verbose rationale blocks added during the GitLab work (multi-paragraph
JSDocs on buildBranchWebUrl, getProviderCandidates, fetchFromProvider, etc.
and long inline explainers in the tests) stood out. Trim them to short
one-liners where the why is genuinely non-obvious, and drop the rest.

* fix(git-review): preserve self-hosted remote ports

* fix(tui): clamp status preview to terminal width

---------

Co-authored-by: jmecham <jmecham@foundrydigital.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-23 23:59:41 -04:00
Vsevolod Kosarev c5a209b687 feat(tui): add wrap-around navigation to all menus and move modes (#332)
Enable circular cursor movement across all TUI menus and lists —
pressing up at the first item wraps to the last, and pressing down
at the last wraps to the first. Also applies to move/reorder modes,
allowing items to be moved cyclically through the list boundaries.

Added 10 unit tests covering wrap-around boundaries for normal
navigation, move mode, picker categories, widgets, and top-level search.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 23:32:47 -04:00
Matthew Breedlove 5a08aeed69 Merge branch 'dependabot/bun/dev-dependencies-33a2694a2c' 2026-04-20 22:41:44 -04:00
Matthew Breedlove 9f779db141 chore(lint): Change annoying eol-last: never lint rule to always 2026-04-20 22:40:19 -04:00
Matthew Breedlove 933d1f7852 chore(ci): Fix failing tests 2026-04-20 22:37:39 -04:00
dependabot[bot] eef365945d chore(deps-dev): bump the dev-dependencies group across 1 directory with 2 updates
Bumps the dev-dependencies group with 2 updates in the / directory: [eslint](https://github.com/eslint/eslint) and [globals](https://github.com/sindresorhus/globals).


Updates `eslint` from 10.2.0 to 10.2.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)

Updates `globals` from 17.4.0 to 17.5.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.4.0...v17.5.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: globals
  dependency-version: 17.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 02:22:31 +00:00
Matthew Breedlove 8996d8aaf6 chore: Update eslint-plugin-import to eslint-plugin-import-x for ESLint 10 compatibility 2026-04-20 22:21:00 -04:00
Matthew Breedlove 1ecc240ce8 fix(git): harden git helper layer 2026-04-20 22:08:17 -04:00
Jaakko 298c6716e3 feat: add refreshInterval configuration for Claude Code status line (#297)
* feat: add refreshInterval configuration for Claude Code status line

Add a new "Configure Status Line" menu option (visible when ccstatusline
is installed) that lets users set the Claude Code statusLine
refreshInterval. The setting is written directly to Claude Code's
settings.json.

- Add refreshInterval to ClaudeSettings statusLine interface
- Add getRefreshInterval/setRefreshInterval utility functions
- Add Claude Code version detection (claude --version) with 5s timeout
- Gate refreshInterval behind Claude Code >=2.1.97 version check
- Default to 10s on fresh install, preserve existing value on re-install
- Show disabled state with version requirement message for older versions
- Add RefreshIntervalMenu TUI component with inline numeric input
- Add isKnownCommand path-boundary matching for local dev commands
- Add comprehensive tests for version detection, install flow, and validation

* fix: correct Claude status line state and local install detection

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-17 09:53:59 -04:00
dependabot[bot] efc7e8a06c chore(deps-dev): bump the dev-dependencies group across 1 directory with 3 updates (#316)
Bumps the dev-dependencies group with 3 updates in the / directory: [@types/bun](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/bun), [typedoc](https://github.com/TypeStrong/TypeDoc) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


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

Updates `typedoc` from 0.28.18 to 0.28.19
- [Release notes](https://github.com/TypeStrong/TypeDoc/releases)
- [Changelog](https://github.com/TypeStrong/typedoc/blob/master/CHANGELOG.md)
- [Commits](https://github.com/TypeStrong/TypeDoc/compare/v0.28.18...v0.28.19)

Updates `typescript-eslint` from 8.58.0 to 8.58.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.58.2/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@types/bun"
  dependency-version: 1.3.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typedoc
  dependency-version: 0.28.19
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.58.2
  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>
2026-04-17 09:21:32 -04:00
dependabot[bot] 49e5511f4a chore(deps-dev): bump the dev-dependencies group with 6 updates (#301)
Bumps the dev-dependencies group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [eslint](https://github.com/eslint/eslint) | `10.1.0` | `10.2.0` |
| [globals](https://github.com/sindresorhus/globals) | `17.3.0` | `17.4.0` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.4` | `19.2.5` |
| [tinyglobby](https://github.com/SuperchupuDev/tinyglobby) | `0.2.15` | `0.2.16` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.57.2` | `8.58.0` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.2` | `4.1.4` |


Updates `eslint` from 10.1.0 to 10.2.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.1.0...v10.2.0)

Updates `globals` from 17.3.0 to 17.4.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.3.0...v17.4.0)

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

Updates `tinyglobby` from 0.2.15 to 0.2.16
- [Release notes](https://github.com/SuperchupuDev/tinyglobby/releases)
- [Changelog](https://github.com/SuperchupuDev/tinyglobby/blob/main/CHANGELOG.md)
- [Commits](https://github.com/SuperchupuDev/tinyglobby/compare/0.2.15...0.2.16)

Updates `typescript-eslint` from 8.57.2 to 8.58.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.58.0/packages/typescript-eslint)

Updates `vitest` from 4.1.2 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: globals
  dependency-version: 17.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: react
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: tinyglobby
  dependency-version: 0.2.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.58.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: vitest
  dependency-version: 4.1.4
  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>
2026-04-17 09:18:22 -04:00
dependabot[bot] ad91b190b1 chore(deps-dev): bump https-proxy-agent from 7.0.6 to 8.0.0 (#302)
Bumps [https-proxy-agent](https://github.com/TooTallNate/proxy-agents/tree/HEAD/packages/https-proxy-agent) from 7.0.6 to 8.0.0.
- [Release notes](https://github.com/TooTallNate/proxy-agents/releases)
- [Changelog](https://github.com/TooTallNate/proxy-agents/blob/main/packages/https-proxy-agent/CHANGELOG.md)
- [Commits](https://github.com/TooTallNate/proxy-agents/commits/https-proxy-agent@8.0.0/packages/https-proxy-agent)

---
updated-dependencies:
- dependency-name: https-proxy-agent
  dependency-version: 8.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-17 09:17:58 -04:00
Spencer Smith 5e80b5834f feat: support xhigh thinking effort level (#314)
- Add xhigh to TranscriptThinkingEffort, ThinkingEffortLevel, and
  ClaudeSettings.effortLevel unions
- Accept xhigh and xHigh casings from /model stdout and settings.json
- Pass unknown-but-word-shaped effort values through with a trailing "?"
  marker (e.g. "super-max?"), so future Claude Code effort levels render
  gracefully without code changes
- Display "Thinking: default" when no effort signal is available from
  the transcript or Claude settings, replacing the prior silent medium
  fallback

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 09:01:23 -04:00
hgreene624 ccc180301d fix: Fix token overcounting from streaming duplicate JSONL entries (#278)
* Fix token overcounting from streaming duplicate JSONL entries

Claude Code writes multiple JSONL entries per API call during streaming:
intermediate entries have stop_reason: null, and only the final entry has
a string value like "end_turn" or "tool_use". The getTokenMetrics function
was summing all entries, inflating the total by ~2.5x.

Now only counts final entries (those with a truthy stop_reason). Falls
back to counting all entries when no stop_reason data is present, for
backward compatibility with older transcript formats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(jsonl): dedupe live streaming token metrics

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-08 13:50:43 -04:00
Chris 67151c61d4 fix: strip parenthetical suffix from model display name (#283)
Removes trailing parenthetical (e.g., "(1M context)", "(200K context)")
from model display names. "Opus 4.6 (1M context)" becomes "Opus 4.6".

Users who want the context window size displayed can add the existing
context-length widget to their layout.

Fixes #238

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-08 13:37:46 -04:00
Matthew Breedlove 497b30e0bf Version bump, README cleanup
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
2026-04-08 13:21:35 -04:00
Matthew Breedlove ad51a8762c fix: walk process tree for terminal width detection
Closes #262
2026-04-08 03:36:46 -04:00
hangie 70d1d5261f feat: add git status, git remote, worktree, and custom symbol widgets (#257)
* feat: add git status, git remote, worktree, and custom symbol widgets

Add 19 new widgets split out from PR #255 per reviewer request:

- Git status widgets (addresses #20): git-status, git-staged,
  git-unstaged, git-untracked, git-ahead-behind, git-conflicts, git-sha
- Git remote widgets: git-origin-owner, git-origin-repo,
  git-origin-owner-repo, git-upstream-owner, git-upstream-repo,
  git-upstream-owner-repo, git-is-fork
- Worktree widgets: worktree-mode, worktree-name, worktree-branch,
  worktree-original-branch
- Custom symbol widget

Supporting changes:
- Add git command cache and status functions to git.ts
- Add git-remote utilities for fork detection and URL parsing
- Add customSymbol, hide, getNumericValue to Widget types
- Add worktree field to StatusJSON
- Add gitData field to RenderContext

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: correct git status detection for unstaged changes and merge conflicts

Fixed three issues in git status parsing:

1. Unstaged pattern was incomplete - only detected M/D, missed merge conflicts
   (UU, AU, DU, AA, UA, UD) and other status codes (R, C, T)
2. Added -z flag for NUL-terminated output to properly handle filenames with
   special characters
3. Changed trim() to trimEnd() to preserve significant leading spaces in git
   porcelain format (e.g., ' M file.txt' for unstaged modifications)

Added comprehensive test coverage with 20 new test cases covering all merge
conflict scenarios, rename/copy/type-change detection, and edge cases.

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

* feat: add conflict detection to git status widget

Added '!' indicator to GitStatus widget to show merge conflicts with priority
ordering: !+*? (conflicts, staged, unstaged, untracked).

Conflicts are shown first as they're blocking - work cannot proceed until
resolved. The priority ordering reflects urgency: blocking issues, intentional
work, unsaved changes, then undecided files.

Added conflict detection for all merge conflict states: DD, AU, UD, UA, DU, AA, UU
Added test coverage for DD (both deleted) case and mixed status with conflicts

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

* fix(git-widgets): refine worktree naming and git parsing

Tighten the git widget plumbing and align the new worktree widgets with the Git naming scheme.

- parse nested namespace remotes correctly for both SCP-style and URL-based git remotes
- ignore rename and copy source-path entries when reading porcelain -z status output
- keep the git utility regression coverage aligned with the parser changes
- rename Worktree* widget files and classes to GitWorktree* and update display names
- preserve the existing worktree widget type ids while rewiring exports and manifest entries

* fix(git-widgets): correct conflict rendering and upstream resolution

Improve git widget behavior in preview and runtime rendering.

- treat text-presentation pictographs like the warning symbol as narrow unless explicitly emoji-style

- keep git conflicts visible at zero and return numeric raw values for the conflict count

- add regression coverage for glyph width handling and conflict widget rendering

- resolve git-upstream widgets through the branch tracking remote when no literal upstream remote exists

- cover tracked-upstream fallback behavior in git remote utility tests

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-08 03:20:42 -04:00
Matthew Breedlove dfe05312bd refactor(cache): move PR cache files to pr subdirectory 2026-04-08 00:28:37 -04:00
Forbes Avila e87ce2e815 feat(widgets): add Claude account email widget (#295)
* Add Claude account email widget for ccswitch integration

* feat(widgets): add Claude account email widget

Adds a new ClaudeAccountEmail widget in the Session category that displays
the user's email by reading from git config user.email or falling back to
common environment variables (GIT_AUTHOR_EMAIL, GIT_COMMITTER_EMAIL, EMAIL, USER_EMAIL).

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

* fix(widgets): read Claude account email from ~/.claude.json

Replaces the git config approach with reading oauthAccount.emailAddress
directly from ~/.claude.json — the actual Claude account credentials file.
Respects CLAUDE_CONFIG_DIR environment variable. No external tools required.

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

* chore: Remove unnecessary package-lock.json as this repo uses bun.lock

* fix(widgets): label claude account email output

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-08 00:19:44 -04:00
Matthew Breedlove 00ee862200 feat(powerline): continue theme colors across lines
Closes #288
2026-04-07 23:55:14 -04:00
Matthew Breedlove 315cd1128d docs: add localization section for community fork
Closes #281
2026-04-07 23:37:14 -04:00
Tien Nguyen Minh 23ac1f795c docs: remove invalid winget packages and replace with valid ones (#215) 2026-04-07 23:26:28 -04:00
Chris Tracey 88003245e7 feat: add a global minimalist mode that defaults widgets to raw mode (#227)
* feat: add a global minimalist mode that defaults widgets to raw mode

Adds the foundational plumbing for a global minimalist mode toggle:
- RenderContext.minimalist flag (optional boolean)
- Settings.minimalistMode field (default false, Zod-managed)
- Threads the flag from settings into RenderContext at render time in
  both piped mode (ccstatusline.ts) and TUI preview (StatusLinePreview.tsx)

Adds an (m) keybind to the Global Overrides TUI menu to toggle minimalist
mode on/off. The setting is persisted immediately via onUpdate, and the
preview reflects the change in real time via the RenderContext flag.

When minimalist mode is active, the renderer forces rawValue: true on all
widgets before calling render(), stripping decorative labels and prefixes
globally. No per-widget changes needed.

* fix: keep strict settings literals for minimalist mode

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-07 23:22:16 -04:00
dependabot[bot] 02c4eecca3 chore(deps-dev): bump globals from 14.0.0 to 17.3.0 (#240)
Bumps [globals](https://github.com/sindresorhus/globals) from 14.0.0 to 17.3.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v14.0.0...v17.3.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.3.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 23:01:09 -04:00
dependabot[bot] 1206479145 chore(deps-dev): bump the dev-dependencies group across 1 directory with 5 updates (#269)
* chore(deps-dev): bump the dev-dependencies group across 1 directory with 5 updates

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

| Package | From | To |
| --- | --- | --- |
| [@types/bun](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/bun) | `1.3.10` | `1.3.11` |
| [eslint](https://github.com/eslint/eslint) | `10.0.3` | `10.1.0` |
| [typedoc](https://github.com/TypeStrong/TypeDoc) | `0.28.17` | `0.28.18` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.57.0` | `8.57.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.0` | `4.1.2` |



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

Updates `eslint` from 10.0.3 to 10.1.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.0.3...v10.1.0)

Updates `typedoc` from 0.28.17 to 0.28.18
- [Release notes](https://github.com/TypeStrong/TypeDoc/releases)
- [Changelog](https://github.com/TypeStrong/typedoc/blob/master/CHANGELOG.md)
- [Commits](https://github.com/TypeStrong/TypeDoc/compare/v0.28.17...v0.28.18)

Updates `typescript-eslint` from 8.57.0 to 8.57.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.57.2/packages/typescript-eslint)

Updates `vitest` from 4.1.0 to 4.1.2
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/vitest)

---
updated-dependencies:
- dependency-name: "@types/bun"
  dependency-version: 1.3.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: eslint
  dependency-version: 10.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: typedoc
  dependency-version: 0.28.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.57.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: vitest
  dependency-version: 4.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: satisfy updated ColorMenu linting

Remove the redundant useState type argument in the color menu so the dependency bump branch passes the newer typescript-eslint rule set without changing runtime behavior.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-07 22:57:57 -04:00
YuLaiZ f0cffeb619 fix: prevent UserPromptSubmit regex from matching Unix paths as skill names (#274) 2026-04-07 22:49:34 -04:00
1K2S aed41dc410 feat: fzf when adding widgets (#293)
* feat: fzf when adding widgets

Adds fuzzy matching to the widget picker as a fallback when the query
has no exact substring match anywhere.

Characters must appear in order but need not be adjacent; matches are
scored by span, consecutive runs, and word boundaries, and ranked below
all exact matches.

Selection now resets to the top-ranked result on every keystroke instead
of staying pinned to the previously-selected widget type.

Matched characters are highlighted in the picker list: yellow-bold for
unselected rows, green-bold for the selected row.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: prefer widget picker initialisms over incidental matches

Give display-name initialism matches their own ranking path so abbreviation searches like tw, tc, ti, and to select the intended widget instead of description or type fallbacks. Keep picker highlighting aligned with the ranking logic and add regressions for the reported fuzzy-match cases.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-07 22:34:38 -04:00
vincent-k2026 4af5d780bc chore: Add codachi to Related Projects (#284) 2026-04-07 21:46:49 -04:00
Jonathan Yang 7a2f032037 feat(widgets): add git-pr widget (#250)
* feat(widgets): add git-pr widget

* fix git-pr cache invalidation and test isolation

* fix gh-pr cache test matcher

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-04-07 21:46:07 -04:00
Matthew Breedlove 8d8819af73 Version bump
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
2026-03-20 14:58:01 -04:00
Jaakko 3ff7cb7502 feat: use native rate_limits field from Claude Code 2.1.80 (#253)
* feat: use native rate_limits field from Claude Code 2.1.80

Claude Code 2.1.80 now sends rate_limits in the statusline JSON input
with five_hour and seven_day windows (used_percentage + resets_at).
When present, usage widgets consume this data directly instead of
fetching from the Anthropic API. Falls back to the API fetch for
older Claude Code versions that don't send rate_limits.

Also updates the example payload to match the real 2.1.80 format.

* fix: revert example payload to use placeholder values

* chore: align test values with example payload

* fix: Fallback to API usage query if any of the rate limit fields are missing

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-20 14:56:00 -04:00
Matthew Breedlove e31c7908cd chore: Version bump and README update
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
2026-03-15 02:42:43 -04:00
Ereli d916afc2ec feat: add hyperlink support to GitBranch and GitRootDir widgets (#224)
* feat: add hyperlink support to GitBranch and GitRootDir widgets

- GitBranch: toggle (l)ink wraps branch name in OSC 8 link to
  github.com/owner/repo/tree/<branch>; only activates for GitHub
  remotes, falls back to plain text otherwise
- GitRootDir: toggle (l)ink wraps project root name in OSC 8
  cursor://file/<path> link to open the directory in Cursor
- Add shared src/utils/hyperlink.ts with renderOsc8Link and
  parseGitHubBaseUrl helpers

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

* Improve git and IDE hyperlink handling

- encode GitHub branch refs so reserved characters do not break branch links
- accept ssh:// and credentialed GitHub remotes when building GitHub URLs
- replace the Cursor-only repo root toggle with IDE link modes for VS Code and Cursor
- build IDE file links from encoded paths so Windows, spaces, #, and UNC paths work
- reuse the shared OSC-8 hyperlink renderer from the Link widget
- add focused coverage for hyperlink parsing and the updated git widgets

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-15 01:44:56 -04:00
Matthew Breedlove e31a78add1 chore: Add vim widget to README
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
2026-03-15 01:22:57 -04:00
Matthew Breedlove 687de09eb4 chore: Version bump and README update 2026-03-15 01:05:56 -04:00
Jiabao Ji 823c45f054 feat: add vim-mode widget (#237)
* feat: add vim-mode widget

Adds a new status line widget that displays the current vim mode when
Claude Code's vim mode is enabled.

Claude Code includes a `vim` field in the status hook JSON when vim mode
is active. The widget reads this field and renders a compact indicator.
When vim mode is not enabled, the widget returns null and hides itself.

Five display formats are supported via metadata.format:
- icon-dash-letter (default): -N / -I
- icon-letter:  N /  I
- icon: icon only
- letter: N / I
- word: NORMAL / INSERT

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

* Fix vim mode defaults and editor toggles

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-15 00:59:02 -04:00
Matthew Breedlove 5b49868de7 Fix macOS usage token keychain fallback
Closes #236

Closes #218
2026-03-15 00:31:02 -04:00
Jaakko d1e35aa832 fix(timer): show days in formatUsageDuration for durations >= 24h (#228)
* fix(timer): show days in formatUsageDuration for durations >= 24h

Durations of 24+ hours now display a days component instead of
raw hours (e.g. "1d 12hr 30m" instead of "36hr 30m"). Applies to
both normal and compact formats. Fixes #210.

* refactor(timer): simplify formatUsageDuration and show 0m for zero duration

Unify compact/normal branches into a single code path differing only
in hour label and separator. Zero duration now displays "0m" instead
of "0hr"/"0h".

* Add weekly reset hours toggle

Default weekly reset timers to day-based formatting while adding an hours-only toggle and keeping preview text aligned with live rendering.

Also move custom keybind visibility back into widgets and clear disabled toggle metadata when those options become unavailable in the editor.

  Supersedes #235
  Closes #235

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-14 23:06:44 -04:00
Matthew Breedlove 1e69ae6898 Fixes for ESLint 10 2026-03-14 22:14:31 -04:00
Matthew Breedlove a9e4e3ea2d Merge remote-tracking branch 'origin/dependabot/bun/eslint/js-10.0.1' 2026-03-14 22:02:37 -04:00
Matthew Breedlove 01766b79e5 Merge remote-tracking branch 'origin/dependabot/bun/eslint-10.0.0' 2026-03-14 22:01:28 -04:00
Matthew Breedlove abb8038c12 Merge branch 'dependabot/bun/ink-gradient-4.0.0' 2026-03-14 21:57:34 -04:00
Matthew Breedlove fcc5734b8d fix: Fix broken ThinkingEffort widget
CI / Build (push) Blocked by required conditions
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
2026-03-10 02:45:34 -04:00
elliotllliu 49c1750c19 feat: add ThinkingEffort widget to display thinking effort level (#217)
Adds a new "thinking-effort" widget that shows the current
thinking effort level (low, medium, high) in the status bar.

Data source priority:
1. StatusJSON "thinking.effort" field (from Claude Code status hook)
2. "thinkingMode" key in ~/.claude/settings.json (user fallback)

Usage in config:
  { "type": "thinking-effort", "color": "magenta" }

Closes #209

Changes:
- src/types/StatusJSON.ts: add optional "thinking" field
- src/widgets/ThinkingEffort.ts: new widget implementation
- src/widgets/index.ts: export ThinkingEffortWidget
- src/utils/widget-manifest.ts: register "thinking-effort" type
- src/widgets/__tests__/ThinkingEffort.test.ts: 13 unit tests

Co-authored-by: GitHub User <user@example.com>
2026-03-10 02:09:28 -04:00
dependabot[bot] d1634c113c chore(deps-dev): bump ink-gradient from 3.0.0 to 4.0.0
Bumps [ink-gradient](https://github.com/sindresorhus/ink-gradient) from 3.0.0 to 4.0.0.
- [Release notes](https://github.com/sindresorhus/ink-gradient/releases)
- [Commits](https://github.com/sindresorhus/ink-gradient/compare/v3.0.0...v4.0.0)

---
updated-dependencies:
- dependency-name: ink-gradient
  dependency-version: 4.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 08:57:02 +00:00
dependabot[bot] 36a6169f4e chore(deps-dev): bump @eslint/js from 9.39.3 to 10.0.1
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.39.3 to 10.0.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 08:56:55 +00:00
dependabot[bot] 60bf9b8c50 chore(deps-dev): bump eslint from 9.39.3 to 10.0.0
Bumps [eslint](https://github.com/eslint/eslint) from 9.39.3 to 10.0.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.3...v10.0.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 08:56:49 +00:00
Matthew Breedlove dfa009e7df Handle usage API 429 backoff
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
Closes #204
2026-03-08 01:11:47 -05:00
Matthew Breedlove db950f3f7a fix: install menu escape navigation 2026-03-06 17:49:58 -05:00
j3r0lin fe9cc15818 feat(timer): add compact time format option for timer widgets (#203)
* feat(timer): add compact time format option for timer widgets

Add an optional compact display mode (s key) to BlockTimer, BlockResetTimer,
and WeeklyResetTimer that renders durations as '5h30m' instead of '5hr 30m'.

- Add `compact` boolean parameter to `formatUsageDuration`
- Add `isUsageCompact` / `toggleUsageCompact` helpers in usage-display.ts
- Add `toggle-compact` action and `s` keybind across all three timer widgets
- Preview text reflects compact format when flag is set
- Editor modifier text shows 'compact' when flag is enabled
- Tests cover compact format output, keybind exposure, and toggle behavior

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

* Fix compact timer widget editor state

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-06 17:24:52 -05:00
Jack Allen 34fa512778 feat(list): add custom list component (#60)
* feat(list): add custom list component

* feat(list): add sublabel and disabled options for list items

* Refactor TUI menu screens onto shared List component

Replace the remaining menu-style terminal and powerline screens with the shared List component so they share navigation, descriptions, back-row behavior, disabled states, and confirm dialog rendering.

This migrates TerminalOptionsMenu, TerminalWidthMenu, PowerlineSetup, PowerlineThemeSelector, and ConfirmDialog, and adds focused tests for the extracted menu builders and Ink-level regressions.

It also fixes two regressions uncovered during manual testing: PowerlineThemeSelector now memoizes theme data and List selection callbacks so live theme preview no longer triggers a maximum update depth loop, and TerminalWidthMenu now restores focus to the active full-until-compact row after returning from threshold entry instead of resetting to the first item.

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-06 02:19:29 -05:00
Matthew Breedlove e63591492a feat: add usage API HTTPS_PROXY support
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
2026-03-05 18:07:10 -05:00
Matthew Breedlove 7a0b979b01 test: isolate config test Claude config dir 2026-03-05 14:35:43 -05:00
Matthew Breedlove eaab442794 chore: standardize lint scripts and CI 2026-03-05 14:31:46 -05:00
Bap 78d6dcc28c feat: Add Skills widget with cross-platform hook framework (#201)
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
* feat: Add Skills widget with cross-platform hook framework

Replaces bash/jq hook with a built-in --hook CLI handler that works
on all platforms. Widgets can declare hooks via getHooks() which are
auto-synced to Claude settings when the statusline config is saved.

* feat: tighten hook lifecycle and upgrade skills widget

Notes:\n- Hook sync now follows saved-state semantics: installing statusline syncs hooks from saved ccstatusline settings on disk, and uninstall always removes all ccstatusline-managed hooks.\n- syncWidgetHooks always strips managed hooks first, including when statusline.command is missing, then persists cleanup.\n- Skills metrics storage path is now ~/.cache/ccstatusline/skills/skills-<sessionId>.jsonl.\n- Skills unique list ordering is now most-recent-first.\n- Skills widget updates: view toggle on (v), hide-when-empty toggle on (h), explicit empty outputs when not hidden, and new list-only (l)imit editor option where 0 means unlimited (default).\n- Added list-limit custom editor flow and list-only keybind visibility in ItemsEditor.\n- Maintained Node 14 compatibility by avoiding Array.prototype.at in skills metrics parsing.\n- Added/updated regression tests for hook sync/install/uninstall behavior, skills metrics path/order, Skills widget rendering/editing, and items-editor custom keybind handling.

* chore: bump version to 2.2.1 and update release notes

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-05 01:35:37 -05:00
Matthew Breedlove 53c8200c5a chore: sync available widgets list in README 2026-03-04 22:08:08 -05:00
olion500 7ca953400c feat: Add token speed widgets (InputSpeed, OutputSpeed, TotalSpeed) (#141)
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
* Add token speed widgets (InputSpeed, OutputSpeed, TotalSpeed)

- Add SpeedMetrics type for tracking token throughput
- Implement speed calculation utilities in speed-metrics.ts
- Add InputSpeed, OutputSpeed, TotalSpeed widgets
- Extend jsonl.ts to extract timing data for speed calculation
- Inject speedMetrics into RenderContext for widget access
- Add unit tests for speed widgets

* Add widget-gated subagent speed aggregation with parallel reads

* Fix subagent speed parsing for session-directory transcript layout

* Consolidate speed windows into core speed widgets

* Bump to v2.2.0 and document new speed widgets

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-04 20:03:36 -05:00
Matthew Breedlove 591cb60459 ci: drop invalid cache input from setup-bun v2
Remove unsupported 'cache' input from oven-sh/setup-bun@v2 steps to eliminate GitHub Actions warnings about unexpected inputs.
2026-03-04 18:15:10 -05:00
Kanon c4d0f4b5ad refactor: improve main manu option (#170)
* chore: resolve eslint error

Signed-off-by: ysknsid25 <kengo071225@gmail.com>

* refactor: improve menu value handing

Signed-off-by: ysknsid25 <kengo071225@gmail.com>

* refactor: keep minimal typed main-menu options

Co-authored-by: Kanon <kengo071225@gmail.com>

* refactor: minimize typed menu diff against main

Co-authored-by: Kanon <kengo071225@gmail.com>

---------

Signed-off-by: ysknsid25 <kengo071225@gmail.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-04 18:10:39 -05:00
dependabot[bot] 97d7491c12 chore(deps-dev): bump vitest from 3.2.4 to 4.0.18 (#198)
* chore(deps-dev): bump vitest from 3.2.4 to 4.0.18

Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 3.2.4 to 4.0.18.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.18/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.0.18
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: type console spy in config test for vitest 4 lint

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-04 13:17:20 -05:00
dependabot[bot] 8049e28667 chore(deps-dev): bump the dev-dependencies group with 14 updates (#196)
* chore(deps-dev): bump the dev-dependencies group with 14 updates

Bumps the dev-dependencies group with 14 updates:

| Package | From | To |
| --- | --- | --- |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.33.0` | `9.39.3` |
| [@stylistic/eslint-plugin](https://github.com/eslint-stylistic/eslint-stylistic/tree/HEAD/packages/eslint-plugin) | `5.2.3` | `5.9.0` |
| [chalk](https://github.com/chalk/chalk) | `5.5.0` | `5.6.2` |
| [eslint](https://github.com/eslint/eslint) | `9.33.0` | `9.39.3` |
| [eslint-plugin-import-newlines](https://github.com/SeinopSys/eslint-plugin-import-newlines) | `1.4.0` | `1.4.1` |
| [ink](https://github.com/vadimdemedes/ink) | `6.2.0` | `6.8.0` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.1.1` | `19.2.4` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.1.10` | `19.2.14` |
| [strip-ansi](https://github.com/chalk/strip-ansi) | `7.1.0` | `7.1.2` |
| [tinyglobby](https://github.com/SuperchupuDev/tinyglobby) | `0.2.14` | `0.2.15` |
| [typedoc](https://github.com/TypeStrong/TypeDoc) | `0.28.12` | `0.28.17` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.2` | `5.9.3` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.39.1` | `8.56.1` |
| [zod](https://github.com/colinhacks/zod) | `4.0.17` | `4.3.6` |


Updates `@eslint/js` from 9.33.0 to 9.39.3
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v9.39.3/packages/js)

Updates `@stylistic/eslint-plugin` from 5.2.3 to 5.9.0
- [Release notes](https://github.com/eslint-stylistic/eslint-stylistic/releases)
- [Changelog](https://github.com/eslint-stylistic/eslint-stylistic/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint-stylistic/eslint-stylistic/commits/v5.9.0/packages/eslint-plugin)

Updates `chalk` from 5.5.0 to 5.6.2
- [Release notes](https://github.com/chalk/chalk/releases)
- [Commits](https://github.com/chalk/chalk/compare/v5.5.0...v5.6.2)

Updates `eslint` from 9.33.0 to 9.39.3
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.33.0...v9.39.3)

Updates `eslint-plugin-import-newlines` from 1.4.0 to 1.4.1
- [Release notes](https://github.com/SeinopSys/eslint-plugin-import-newlines/releases)
- [Commits](https://github.com/SeinopSys/eslint-plugin-import-newlines/compare/v1.4.0...v1.4.1)

Updates `ink` from 6.2.0 to 6.8.0
- [Release notes](https://github.com/vadimdemedes/ink/releases)
- [Commits](https://github.com/vadimdemedes/ink/compare/v6.2.0...v6.8.0)

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

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

Updates `strip-ansi` from 7.1.0 to 7.1.2
- [Release notes](https://github.com/chalk/strip-ansi/releases)
- [Commits](https://github.com/chalk/strip-ansi/compare/v7.1.0...v7.1.2)

Updates `tinyglobby` from 0.2.14 to 0.2.15
- [Release notes](https://github.com/SuperchupuDev/tinyglobby/releases)
- [Changelog](https://github.com/SuperchupuDev/tinyglobby/blob/main/CHANGELOG.md)
- [Commits](https://github.com/SuperchupuDev/tinyglobby/compare/0.2.14...0.2.15)

Updates `typedoc` from 0.28.12 to 0.28.17
- [Release notes](https://github.com/TypeStrong/TypeDoc/releases)
- [Changelog](https://github.com/TypeStrong/typedoc/blob/master/CHANGELOG.md)
- [Commits](https://github.com/TypeStrong/TypeDoc/compare/v0.28.12...v0.28.17)

Updates `typescript` from 5.9.2 to 5.9.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.2...v5.9.3)

Updates `typescript-eslint` from 8.39.1 to 8.56.1
- [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.56.1/packages/typescript-eslint)

Updates `zod` from 4.0.17 to 4.3.6
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.0.17...v4.3.6)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.39.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@stylistic/eslint-plugin"
  dependency-version: 5.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: chalk
  dependency-version: 5.6.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: eslint
  dependency-version: 9.39.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: eslint-plugin-import-newlines
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: ink
  dependency-version: 6.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: react
  dependency-version: 19.2.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: strip-ansi
  dependency-version: 7.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: tinyglobby
  dependency-version: 0.2.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typedoc
  dependency-version: 0.28.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript
  dependency-version: 5.9.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.56.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: zod
  dependency-version: 4.3.6
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: pin ink 6.2.0 and fix lint formatting

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-04 13:10:40 -05:00
dependabot[bot] a056183a67 chore(deps-dev): bump eslint-plugin-react-hooks from 5.2.0 to 7.0.1 (#199)
Bumps [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) from 5.2.0 to 7.0.1.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/eslint-plugin-react-hooks)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-hooks
  dependency-version: 7.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 12:45:32 -05:00
dependabot[bot] fceab59ec6 chore(deps-dev): bump react-devtools-core from 6.1.5 to 7.0.1 (#197)
Bumps [react-devtools-core](https://github.com/facebook/react/tree/HEAD/packages/react-devtools-core) from 6.1.5 to 7.0.1.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/react-devtools-core)

---
updated-dependencies:
- dependency-name: react-devtools-core
  dependency-version: 7.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 12:44:28 -05:00
dependabot[bot] 19eb83fb24 chore(deps): bump actions/checkout from 4 to 6 (#195)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 12:37:23 -05:00
Joseph Mearman f2764362fd chore: Add Dependabot for npm and GitHub Actions (#152)
* Add Dependabot configuration for npm and GitHub Actions

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

* chore: use bun ecosystem and add Dependabot cooldowns

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-04 12:34:25 -05:00
Gary Norton 990f2703b1 fix: support bare repo worktrees in GitWorktree widget (#138)
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
* fix: support bare repo worktrees in GitWorktree widget

The GitWorktree widget now correctly detects worktrees from bare
repositories. Previously, it only checked for `.git/worktrees/` in
the path, but bare repos store worktree metadata at
`<bare-repo>/worktrees/<name>` without the `.git` prefix.

This adds an additional check for `/worktrees/` pattern when the
`.git/worktrees/` pattern isn't found.

Fixes detection for setups like:
- Bare repo at: /path/to/repo (contains objects/, refs/, HEAD, etc.)
- Worktree at: /path/to/repo/trees/feature-x
- Git dir: /path/to/repo/worktrees/feature-x

* chore: remove accidental npm lockfile from PR branch

* chore: bump version to 2.1.10 and update recent updates

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-04 11:47:59 -05:00
Matthew Breedlove 59d43589c9 test: stabilize CI by isolating cross-file mocks
Convert module-level mocks to scoped spies and restore global stubs between tests to prevent Bun/Vitest cross-file leakage and flaky Actions failures.
2026-03-04 11:26:34 -05:00
Joseph Mearman 78c7bc16f0 chore: Add CI (#151)
* Add GitHub CI workflow for lint, test, and build

Runs on push to main and PRs. Uses Bun with dependency caching.

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

* Remove branch filter on push trigger to run CI on all branches

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

* Use bun test in CI test job

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-04 10:46:01 -05:00
Rubén Varela 2956842911 Attempt to add backups (#123)
* Attempt to add backups

* fix: quiet invalid claude settings parse output in TUI

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-04 10:31:30 -05:00
Matthew Breedlove 5c0d211ddf test: isolate config-path behavior across suite 2026-03-04 09:59:30 -05:00
Matthew Breedlove 9c0f5feab6 chore: bump version to 2.1.9 and update release notes 2026-03-04 09:33:47 -05:00
Matthew Breedlove 7925ec3e9a fix: support Unicode code points >4 hex digits for separators (resolves #133) 2026-03-04 09:31:53 -05:00
Matthew Breedlove 87fac953aa fix: correct ANSI reset order in applyColors (resolves #79) 2026-03-04 09:29:33 -05:00
Stuart Corbishley d79eefb043 feat: Add --config flag for custom settings file path (#166)
* Add --config flag for custom settings file path

Allow users to specify an alternative settings file via `--config <path>`,
enabling distinct status line configs for different Claude Code instances.

- Add initConfigPath/getConfigPath/isCustomConfigPath to config.ts
- Fix backup path to always append .bak instead of replacing .json suffix
- Use path.dirname(SETTINGS_PATH) instead of CONFIG_DIR for mkdir
- Parse --config arg in ccstatusline.ts main() entry point
- Add tests for config path management

* Integrate --config flag with Claude Code install commands

When a custom config path is active, the install-to-Claude-Code feature
now appends --config <path> to the status line command. Also updates
isInstalled detection to recognize commands with the --config suffix.

- Add isKnownCommand() to deduplicate command-matching logic
- Add shell-safe path quoting for --config values with spaces/special chars
- Add buildCommand() to append --config when custom path is active
- Refactor isInstalled() to use isKnownCommand()
- Use isKnownCommand() in App.tsx install flow
- Add tests for isKnownCommand() and buildCommand() with CLAUDE_CONFIG_DIR safety net

* Show custom config path in TUI header

When a custom --config path is active, display it below the title bar
so users can see which settings file is being edited.

* fix: use platform-appropriate quoting for --config path

* chore: bump version to 2.1.8 and update recent updates

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-04 09:10:37 -05:00
Matthew Breedlove 79b8e4e2f5 refactor: implement phase 7 test/tooling simplification with tests 2026-03-04 01:39:48 -05:00
Matthew Breedlove fc09688879 refactor: implement phase 6 jsonl/usage modularization with async prefetch tests 2026-03-04 01:28:27 -05:00
Matthew Breedlove a34a632b82 refactor: implement phase 5 utility cleanup with tests 2026-03-04 00:43:05 -05:00
Matthew Breedlove 25b69228dd feat(usage): split reset timers into block and weekly widgets
Closes #194
2026-03-04 00:14:49 -05:00
Matthew Breedlove 7e2ad30f77 fix(context): support 1M model name formats in fallback parsing
Handle [1M], (1M), and optional context labels when inferring model context size; use model id + display_name in context widgets and add regression coverage for ctx, ctx(u), and context bar.

Closes #193
2026-03-03 21:49:49 -05:00
Matthew Breedlove b90840f68c refactor: implement phase 4 tui component simplification with tests 2026-03-03 20:14:30 -05:00
Matthew Breedlove b13ea14a8c fix: coerce status JSON numeric strings and bump version
Coerce numeric strings in StatusJSON parsing to keep status line rendering resilient with third-party payload tweaks.

Closes #192
2026-03-03 18:30:04 -05:00
Matthew Breedlove da7eaffe03 refactor: implement phase 3 widget pattern extraction with tests 2026-03-03 18:24:39 -05:00
Matthew Breedlove 9aea4c1265 refactor: implement phase 2 tui/settings simplification with tests 2026-03-03 12:53:21 -05:00
Matthew Breedlove 2d05b3b891 refactor: implement phase 1 terminal/render dedup with tests 2026-03-03 12:43:44 -05:00
Matthew Breedlove 6edc1b4277 feat: add git insertions and deletions widgets
Closes #115
2026-03-03 00:22:13 -05:00
355 changed files with 50242 additions and 5257 deletions
+32
View File
@@ -0,0 +1,32 @@
version: 2
updates:
- package-ecosystem: bun
directory: /
schedule:
interval: weekly
day: monday
ignore:
- dependency-name: "ink"
open-pull-requests-limit: 10
cooldown:
semver-major-days: 30
semver-minor-days: 7
semver-patch-days: 3
groups:
dev-dependencies:
dependency-type: development
update-types:
- minor
- patch
production-dependencies:
dependency-type: production
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
+36
View File
@@ -0,0 +1,36 @@
name: CI
on:
push:
pull_request:
branches: [main]
jobs:
lint:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun run lint
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun test
build:
name: Build
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun run build
- run: test -f dist/ccstatusline.js
+36
View File
@@ -0,0 +1,36 @@
name: Publish
on:
push:
tags:
- "v*"
permissions:
contents: write
id-token: write
jobs:
publish:
if: github.repository == 'sirmalloc/ccstatusline'
name: Publish to npm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v6
with:
node-version: 24
registry-url: "https://registry.npmjs.org"
package-manager-cache: false
- run: bun install
- run: bun run lint
- run: bun test
- run: npm publish
- name: Create GitHub release
run: >
gh release create "$GITHUB_REF_NAME"
--title "$GITHUB_REF_NAME"
--generate-notes
--notes "Published to npm: https://www.npmjs.com/package/ccstatusline"
env:
GH_TOKEN: ${{ github.token }}
+1 -1
View File
@@ -4,7 +4,7 @@ node_modules
# output
out
dist
docs
typedoc
*.tgz
# code coverage
+1
View File
@@ -21,6 +21,7 @@
"ccstatusline",
"Powerline",
"statusline",
"sublabel",
"Worktree",
"worktrees"
]
+7 -4
View File
@@ -33,7 +33,10 @@ bun test
bun test --watch
# Lint and type check
bun run lint # Runs TypeScript type checking and ESLint with auto-fix
bun run lint # Runs TypeScript type checking and ESLint without modifying files
# Apply ESLint auto-fixes intentionally
bun run lint:fix
```
## Architecture
@@ -96,8 +99,8 @@ All widgets must implement:
- `isKnownWidgetType()`: Validates if a type is registered
**Available Widgets:**
- Model, Version, OutputStyle - Claude Code metadata display
- GitBranch, GitChanges, GitWorktree - Git repository status
- Model, Version, OutputStyle, VoiceStatus - Claude Code metadata display
- GitBranch, GitChanges, GitInsertions, GitDeletions, GitWorktree - Git repository status
- TokensInput, TokensOutput, TokensCached, TokensTotal - Token usage metrics
- ContextLength, ContextPercentage, ContextPercentageUsable - Context window metrics (uses dynamic model-based context windows: 1M for Sonnet 4.5 with [1m] suffix, 200k for all other models)
- BlockTimer, SessionClock, SessionCost - Time and cost tracking
@@ -136,7 +139,7 @@ Default to using Bun instead of Node.js:
2. `postbuild`: Runs scripts/replace-version.ts to replace `__PACKAGE_VERSION__` placeholder with actual version from package.json
- **ESLint configuration**: Uses flat config format (eslint.config.js) with TypeScript and React plugins
- **Dependencies**: All runtime dependencies are bundled using `--packages=external` for npm package
- **Type checking and linting**: Only run via `bun run lint` command, never using `npx eslint` or `eslint` directly. Never run `tsx`, `bun tsc` or any other variation
- **Type checking and linting**: Run checks via `bun run lint` and use `bun run lint:fix` only when you intentionally want ESLint auto-fixes. Never use `npx eslint`, `eslint`, `tsx`, `bun tsc`, or any other variation directly
- **Lint rules**: Never disable a lint rule via a comment, no matter how benign the lint warning or error may seem
- **Testing**: Uses Vitest (via Bun) with 6 test files and ~40 test cases covering:
- Model context detection and token calculation (src/utils/__tests__/model-context.test.ts)
+167 -526
View File
@@ -28,30 +28,147 @@
![Demo](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/demo.gif)
</div>
<br />
## 📚 Table of Contents
- [Recent Updates](#-recent-updates)
- [Features](#-features)
- [Localizations](#-localizations)
- [Quick Start](#-quick-start)
- [Windows Support](#-windows-support)
- [Usage](#-usage)
- [API Documentation](#-api-documentation)
- [Development](#-development)
- [Windows Support](docs/WINDOWS.md)
- [Usage](docs/USAGE.md)
- [Development](docs/DEVELOPMENT.md)
- [Contributing](#-contributing)
- [License](#-license)
- [Related Projects](#-related-projects)
---
<br />
## 🆕 Recent Updates
### v2.1.0 - v2.1.3 - Usage widgets, links, and reliability fixes
### v2.2.22 - v2.2.24 - Powerline flex mode, cache/CI/sandbox visibility, layout controls, composable metrics, and safer config
- **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Reset Timer**, and **Context Bar** widgets.
![Powerline Flex Mode](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/powerline-flex.png)
- **⏳ Prompt cache timer** - Added a `Cache Timer` widget with live `HOT` state, TTL countdown, configurable 5-minute/1-hour windows, and customizable state glyphs.
- **✅ GitHub CI status** - Added a `Git CI Status` widget that summarizes failing, pending, and successful checks for the current branch's pull request.
- **🔒 Sandbox status** - Added a `Sandbox Status` widget with glyph, text, and Nerd Font formats that follows Claude Code's layered sandbox setting on each refresh.
- **↔️ One-sided default padding** - Default widget padding can now apply to both sides, the left only, or the right only in standard and Powerline layouts.
- **⚡ Powerline flex mode** - Flex separators now work in Powerline mode, letting Powerline status lines right-align content or absorb available width.
- **🌗 Per-widget dim styling** - The color editor can dim an entire widget or only parenthesized text, with reset and clear-all actions covering dim state.
- **🧯 Safer settings recovery** - Invalid `settings.json` files are left untouched, defaults render in memory, and the status line shows an invalid-config warning.
- **🧭 Selective Powerline alignment** - Press `x` in the line editor to let a widget and the rest of its line keep their natural widths while earlier Powerline columns stay auto-aligned.
- **📏 Git widget width limits** - `Git Branch` and `Git Root Dir` can cap their visible width with ellipsis-safe truncation while preserving hyperlink targets.
- **🔣 Current directory glyphs** - `Current Working Dir` can prepend an optional custom glyph, including in raw-value mode.
- **🧠 Configurable context fallback** - `CCSTATUSLINE_CONTEXT_SIZE_FALLBACK` overrides the default 200k last-resort context window when Claude Code and the model name do not report one.
- **🧩 Composable compaction metrics** - `Compaction Counter` can render count, auto, manual, unknown, or reclaimed-token values independently for custom layouts.
- **🏷️ CLI version flag** - `ccstatusline --version` now prints the installed package version and exits.
- **🛡️ Guarded invalid-config saves** - The TUI warns when it loaded defaults for an invalid settings file and requires confirmation before replacing that file.
- **🔄 Usage display and cache fixes** - Used/remaining direction now works in every percentage mode, account switches invalidate cached usage, and fetch locks no longer cause repeated requests or stale timeout output.
- **⏱️ Calmer reset timer startup** - Reset timers show labeled loading placeholders instead of transient rate-limit errors while Claude Code's embedded usage-window data is still arriving.
- **🔇 Quieter install detection** - Best-effort npm and Bun probes no longer leak expected package-manager errors into the TUI.
### v2.2.21 - Cache widgets, compaction details, extra usage currency, and package fixes
- **🔣 Custom widget glyphs** - Git and JJ symbol widgets can override or suppress their built-in glyphs from the TUI.
- **🔁 Compaction counter details** - `Compaction Counter` now counts explicit `compact_boundary` markers and can optionally show trigger splits plus tokens reclaimed.
- **💸 Extra usage improvements** - Added `Extra Usage Used` and formats extra usage amounts with the billing currency reported by the usage API.
- **📏 Custom command width context** - `Custom Command` widgets receive `terminal_width` in stdin JSON when ccstatusline can detect the terminal width.
- **🧠 Prompt cache widgets** - Added `Cache Hit Rate`, `Cache Read`, and `Cache Write` widgets with turn/session scopes and hide-when-empty behavior.
- **🔢 Token rounding fix** - Token counts from `999950` through `999999` now render as `1.0M` instead of `1000.0k`.
### v2.2.20 - Gradients, token accuracy, usage reliability, and Git PR/MR fixes
- **🌈 Gradient colors** - Added per-widget and whole-line foreground gradients with named presets, custom hex stops, TUI picker support, and Powerline-aware rendering. Press `g` on the Edit Colors screen for widget gradients, or press `g` for Override FG Color in Global Overrides for a line-wide gradient.
- **🎯 More accurate token counts** - `Tokens Input` and `Tokens Output` now prefer cumulative transcript metrics before falling back to context-window totals.
- **💸 Extra usage no-limit fix** - Extra usage widgets no longer get stuck on `[Timeout]` for accounts with overage enabled but no monthly limit configured.
- **🔁 Compaction glitch filtering** - `Compaction Counter` ignores transient below-1% context readings so incomplete status frames do not create false compaction counts.
- **🔀 SSH alias Git PR/MR detection** - Git PR/MR detection resolves SSH host aliases while preserving canonical GitHub and GitLab hosts for CLI selection and fallback repo links.
- **🧪 Usage test cache isolation** - Usage-fetch test probes now isolate `HOME`, `USERPROFILE`, `CLAUDE_CONFIG_DIR`, and proxy variables so local tests cannot touch the real ccstatusline cache.
- **📦 Dependency refresh** - Refreshed React/React DOM and Bun lockfile development-tooling resolutions for the release.
### v2.2.14 - v2.2.19 - Version pinning, npm provenance, usage overage widgets, and Git lock avoidance
- **📌 Version pinning support** - Added support for pinned global installs so Claude Code can keep running a specific ccstatusline version.
- **🔐 npm provenance attestations** - Published packages now use trusted publishing provenance so users can verify where releases were built while avoiding long-lived npm publish tokens.
- **🔄 Moving from auto-update installs** - If you currently use an auto-updating install, use the TUI uninstall option first, then reinstall to go through the version pinning flow. Your ccstatusline settings are preserved when uninstalling.
- **💸 Extra usage widgets** - Added Extra Usage Utilization and Extra Usage Remaining widgets for monthly pay-as-you-go overage limits, with null rate-limit buckets handled as zero usage.
- **🔒 Git lock avoidance** - Git helpers now pass `--no-optional-locks` so background status checks avoid creating `index.lock` races.
- **🧱 Older Git compatibility** - Git widgets avoid newer command forms so repository status works on older Git installations.
- **⚡ Persistent Git cache** - Git command output is cached under `~/.cache/ccstatusline/git-cache` with configurable TTL and `.git/HEAD`/`.git/index` mtime checks to reduce repeated subprocess work.
- **🧭 Install flow polish** - Pinned global install is now the default install option, with clearer wording for install and migration flows.
- **🪟 Hidden helper processes** - Runtime child processes set `windowsHide` so helper commands do not open extra windows on Windows.
- **📏 Terminal width override** - `CCSTATUSLINE_WIDTH` can provide an explicit terminal width when automatic probing is unavailable.
### v2.2.13 - Weekly model usage, voice status, hooks, and docs
- **📊 Weekly Sonnet/Opus usage widgets** - Added separate weekly usage widgets for Sonnet and Opus API buckets, matching Claude Code's `/usage` model split.
- **🎤 Voice Status widget** - Added a widget that shows whether Claude Code voice input is enabled, with icon, text, word, and optional Nerd Font display modes.
- **📉 Timer short bars** - Block Timer, Block Reset Timer, and Weekly Reset Timer now support compact short-bar progress displays.
- **🔕 Quieter hook output** - Hook handling now suppresses no-op JSON output so non-status updates stay silent.
### v2.2.9 - v2.2.12 - GitLab support, reset timers, context, compaction, and git widgets
- **🦊 GitLab PR/MR support** - `Git Branch` and `Git PR/MR` now support GitHub, GitLab, and compatible self-hosted remotes, using `gh` or `glab` as appropriate.
- **🔄 Status line refresh interval** - Installed configs can set Claude Code's `statusLine.refreshInterval` from the TUI when Claude Code >=2.1.97 supports it.
- **🧭 Wrap-around TUI navigation** - Menu/list navigation and move/reorder modes now wrap at the first and last items.
- **📋 Clone widget shortcut** - Press `k` in the item editor to duplicate the selected widget, with fresh Powerline background color for cloned Powerline items.
- **📊 Short bar display modes** - Context percentage, Context Bar, Session Usage, Weekly Usage, Block Timer, and reset timer widgets can use compact bar variants.
- **⏱️ Usage time cursor** - Session Usage and Weekly Usage progress bars can show the elapsed time position within the current usage window.
- **🕒 Reset timer timestamps** - Block and Weekly Reset Timer widgets can show exact reset timestamps with compact formatting, 12/24-hour display, IANA time zones, and locale selection.
- **🪟 Context Window widget** - Added a `Context Window` widget for total model window size, keeping `Context Length` focused on current context usage.
- **🔁 Compaction Counter widget** - Added a `Compaction Counter` widget that tracks session context compactions, with icon/text/number formats, optional Nerd Font icon, and hide-when-zero behavior.
- **🧮 Git file status widgets** - Added `Git Staged Files`, `Git Unstaged Files`, `Git Untracked Files`, and `Git Clean Status` for file counts and clean/dirty state.
- **🏷️ Clear context percentage labels** - `Context %` and `Context % (usable)` now label rendered values as used or left when toggling used/remaining mode.
- **⚡ More Powerline caps** - The Powerline separator editor now supports more than three start/end caps.
- **🧠 Thinking Effort updates** - Added `xhigh`, show `default` when no effort is set, mark unknown future effort levels with `?`, and track live status JSON plus `/effort` command changes. Claude Code reports Ultracode as `xhigh` in status line data.
- **🧮 More accurate token counts** - Streaming duplicate JSONL entries are deduped so token widgets do not overcount live Claude Code output.
- **🏷️ Cleaner model display** - The Model widget strips trailing context suffixes like `(1M context)`; use `Context Window` when you want the total window size shown.
- **🧹 Cleaner empty-widget separators** - Manual separators now collapse around widgets that render empty, avoiding dangling separators when hide-when-empty widgets disappear.
- **🧱 More resilient Git helpers** - Git widgets handle missing or unusual git command output more defensively.
### v2.2.8 - Git widgets, smarter picker search, and minimalist mode
- **🔀 New Git PR widget** - Added a `Git PR` widget with clickable PR links plus optional status and title display for the current branch.
- **🧰 Major Git widget expansion** - Added `Git Status`, `Git Staged`, `Git Unstaged`, `Git Untracked`, `Git Ahead/Behind`, `Git Conflicts`, `Git SHA`, `Git Origin Owner`, `Git Origin Repo`, `Git Origin Owner/Repo`, `Git Upstream Owner`, `Git Upstream Repo`, `Git Upstream Owner/Repo`, `Git Is Fork`, `Git Worktree Mode`, `Git Worktree Name`, `Git Worktree Branch`, `Git Worktree Original Branch`, and `Custom Symbol`.
- **👤 Claude Account Email widget** - Added a session widget that reads the signed-in Claude account email from `~/.claude.json` while respecting `CLAUDE_CONFIG_DIR`.
- **🧼 Global Minimalist Mode** - Added a global toggle in `Global Overrides` that forces widgets into raw-value mode for a cleaner, label-free status line.
- **🔎 Smarter widget picker search** - The add/change widget picker now supports substring, initialism, and fuzzy matching, with ranked results and live match highlighting.
- **📏 Better terminal width detection** - Flex separators and right-alignment now work more reliably when ccstatusline is launched through wrapper processes or nested PTYs.
- **🎨 Powerline theme continuity** - Built-in Powerline themes can now continue colors cleanly across multiple status lines instead of restarting each line.
<br />
<details>
<summary><b>Older updates (v2.2.6 and earlier)</b></summary>
### v2.2.0 - v2.2.6 - Speed, widgets, links, and reliability updates
- **🚀 New Token Speed widgets** - Added three widgets: **Input Speed**, **Output Speed**, and **Total Speed**.
- Each speed widget supports a configurable window of `0-120` seconds in the widget editor (`w` key).
- `0` disables window mode and uses a full-session average speed.
- `1-120` calculates recent speed over the selected rolling window.
- **🧩 New Skills widget controls (v2.2.1)** - Added configurable Skills modes (last/count/list), optional hide-when-empty behavior, and list-size limiting with most-recent-first ordering.
- **🌐 Usage API proxy support (v2.2.2)** - Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic.
- **🧠 New Thinking Effort widget (v2.2.4)** - Added a widget that shows the current Claude Code thinking effort level.
- **🍎 Better macOS usage lookup reliability (v2.2.5)** - Improved reliability when loading usage API tokens on macOS.
- **⌨️ New Vim Mode widget (v2.2.5)** - Added a widget that shows the current vim mode, with ASCII and optional Nerd Font icon display.
- **🔗 Git widget link modes (v2.2.6)** - `Git Branch` can render clickable GitHub branch links, and `Git Root Dir` can render clickable IDE links for VS Code and Cursor.
- **🤝 Better subagent-aware speed reporting** - Token speed calculations continue to include referenced subagent activity so displayed speeds better reflect actual concurrent work.
### v2.1.0 - v2.1.10 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes
- **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Block Reset Timer**, and **Context Bar** widgets.
- **📊 More accurate counts (v2.1.0)** - Usage/context widgets now use new statusline JSON metrics when available for more accurate token and context counts.
- **🪟 Windows empty file bug fix (v2.1.1)** - Fixed a Windows issue that could create an empty `c:\dev\null` file.
- **🔗 New Link widget (v2.1.3)** - Added a new **Link** widget with clickable OSC8 rendering, preview parity, and raw mode support.
- ** New Git Insertions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted insertions (e.g., `+42`).
- ** New Git Deletions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted deletions (e.g., `-10`).
- **🧠 Context format fallback fix (v2.1.6)** - When `context_window_size` is missing, context widgets now infer 1M models from long-context labels such as `[1m]` and `1M context` in model identifiers.
- **⏳ Weekly reset timer split (v2.1.7)** - Added a separate `Weekly Reset Timer` widget.
- **⚙️ Custom config file flag (v2.1.8)** - Added `--config <path>` support so ccstatusline can load/save settings from a custom file location.
- **🔣 Unicode separator hex input upgrade (v2.1.9)** - Powerline separator hex input now supports 4-6 digits (full Unicode code points up to `U+10FFFF`).
- **🌳 Bare repo worktree detection fix (v2.1.10)** - `Git Worktree` now correctly detects linked worktrees created from bare repositories.
### v2.0.26 - v2.0.29 - Performance, git internals, and workflow improvements
@@ -129,29 +246,39 @@
### v2.0.0 - Powerline Support & Enhanced Themes
- **⚡ Powerline Mode** - Beautiful Powerline-style status lines with arrow separators and customizable caps
- **🎨 Built-in Themes** - Multiple pre-configured themes that you can copy and customize
- **🌈 Advanced Color Support** - Basic (16), 256-color (with custom ANSI codes), and truecolor (with hex codes) modes
- **🌈 Advanced Color Support** - Basic (16), 256-color (with custom ANSI codes), and truecolor (with hex codes) modes, plus multi-stop **gradients** (per-widget or spanning the whole line)
- **🔗 Widget Merging** - Merge multiple widgets together with or without padding for seamless designs
- **📦 Easy Installation** - Install directly with `npx` or `bunx` - no global package needed
- **🔤 Custom Separators** - Add multiple Powerline separators with custom hex codes for font support
- **🚀 Auto Font Install** - Automatic Powerline font installation with user consent
---
</details>
<br />
## ✨ Features
- **📊 Real-time Metrics** - Display model name, git branch, token usage, session duration, block timer, and more
- **📊 Real-time Metrics** - Display model name, git branch, token usage, per-model weekly usage, extra usage limits, voice input state, session duration, compaction count, block timer, and more
- **🎨 Fully Customizable** - Choose what to display and customize colors for each element
- **⚡ Powerline Support** - Beautiful Powerline-style rendering with arrow separators, caps, and custom fonts
- **📐 Multi-line Support** - Configure multiple independent status lines
- **🖥️ Interactive TUI** - Built-in configuration interface using React/Ink
- **🔎 Fast Widget Picker** - Add/change widgets by category with search and ranked matching
- **⚙️ Global Options** - Apply consistent formatting across all widgets (padding, separators, bold, background)
- **⚙️ Global Options** - Apply consistent formatting across all widgets (padding, separators, bold, minimalist mode, and color overrides)
- **🚀 Cross-platform** - Works seamlessly with both Bun and Node.js
- **🔧 Flexible Configuration** - Supports custom Claude Code config directory via `CLAUDE_CONFIG_DIR` environment variable
- **📏 Smart Width Detection** - Automatically adapts to terminal width with flex separators
- **⚡ Zero Config** - Sensible defaults that work out of the box
---
<br />
## 🌐 Localizations
The localizations in this section are third-party forks maintained outside this repository. They are not maintained, reviewed, or endorsed by this repository, so review their code and releases before using them.
- 🌏 **中文版 (Chinese):** [ccstatusline-zh](https://github.com/huangguang1999/ccstatusline-zh)
<br />
## 🚀 Quick Start
@@ -165,13 +292,18 @@ npx -y ccstatusline@latest
bunx -y ccstatusline@latest
```
### Configure ccstatusline
Both commands launch the same TUI. During the initial setup flow, choose **Pinned global install** if you want Claude Code to stay on the ccstatusline version you are running instead of following `@latest`; the TUI will install that version globally with npm or Bun and write the pinned `ccstatusline` command to Claude Code settings. After a pinned install, you can run `ccstatusline` directly to launch the TUI in the future.
<br />
<details>
<summary><b>Configure ccstatusline</b></summary>
The interactive configuration tool provides a terminal UI where you can:
- Configure multiple separate status lines
- Add/remove/reorder status line widgets
- Customize colors for each widget
- Configure flex separator behavior
- Configure Claude Code status line refresh interval when supported
- Edit custom text widgets
- Install/uninstall to Claude Code settings
- Preview your status line in real-time
@@ -182,12 +314,16 @@ The interactive configuration tool provides a terminal UI where you can:
> ```bash
> # Linux/macOS
> export CLAUDE_CONFIG_DIR=/custom/path/to/.claude
>
> # Windows PowerShell
> $env:CLAUDE_CONFIG_DIR="C:\custom\path\.claude"
> ```
### Claude Code settings.json format
> 🌐 **Usage API proxy:** Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic.
> 🪟 **Windows Support:** PowerShell examples, installation notes, fonts, troubleshooting, WSL, and Windows Terminal configuration are in [docs/WINDOWS.md](docs/WINDOWS.md).
</details>
<details>
<summary><b>Claude Code settings.json format</b></summary>
When you install from the TUI, ccstatusline writes a `statusLine` command object to your Claude Code settings:
@@ -196,519 +332,22 @@ When you install from the TUI, ccstatusline writes a `statusLine` command object
"statusLine": {
"type": "command",
"command": "npx -y ccstatusline@latest",
"padding": 0
"padding": 0,
"refreshInterval": 10
}
}
```
`refreshInterval` is written only when your Claude Code version supports it (>=2.1.97). The TUI can set it to `1-60` seconds, or remove it by leaving the input empty.
Other supported command values are:
- `bunx -y ccstatusline@latest`
- `ccstatusline` (for self-managed/global installs)
---
## 🪟 Windows Support
ccstatusline works seamlessly on Windows with full feature compatibility across PowerShell (5.1+ and 7+), Command Prompt, and Windows Subsystem for Linux (WSL).
### Installation on Windows
#### Option 1: Using Bun (Recommended)
```powershell
# Install Bun for Windows
irm bun.sh/install.ps1 | iex
# Run ccstatusline
bunx -y ccstatusline@latest
```
#### Option 2: Using Node.js
```powershell
# Using npm
npx -y ccstatusline@latest
# Or with Yarn
yarn dlx ccstatusline@latest
# Or with pnpm
pnpm dlx ccstatusline@latest
```
### Windows-Specific Features
#### Powerline Font Support
For optimal Powerline rendering on Windows:
**Windows Terminal** (Recommended):
- Supports Powerline fonts natively
- Download from [Microsoft Store](https://aka.ms/terminal)
- Auto-detects compatible fonts
**PowerShell/Command Prompt**:
```powershell
# Install JetBrains Mono Nerd Font via winget
winget install DEVCOM.JetBrainsMonoNerdFont
# Alternative: Install base JetBrains Mono font
winget install "JetBrains.JetBrainsMono"
# Or download manually from: https://www.nerdfonts.com/font-downloads
```
#### Path Handling
ccstatusline automatically handles Windows-specific paths:
- Git repositories work with both `/` and `\` path separators
- Current Working Directory widget displays Windows-style paths correctly
- Full support for mapped network drives and UNC paths
- Handles Windows drive letters (C:, D:, etc.)
### Windows Troubleshooting
#### Common Issues & Solutions
**Issue**: Powerline symbols showing as question marks or boxes
```powershell
# Solution: Install a compatible Nerd Font
winget install JetBrainsMono.NerdFont
# Then set the font in your terminal settings
```
**Issue**: Git commands not recognized
```powershell
# Check if Git is installed and in PATH
git --version
# If not found, install Git:
winget install Git.Git
# Or download from: https://git-scm.com/download/win
```
**Issue**: Permission errors during installation
```powershell
# Use non-global installation (recommended)
npx -y ccstatusline@latest
# Or run PowerShell as Administrator for global install
```
**Issue**: "Execution Policy" errors in PowerShell
```powershell
# Temporarily allow script execution
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
```
**Issue**: Windows Defender blocking execution
```powershell
# If Windows Defender flags the binary:
# 1. Open Windows Security
# 2. Go to "Virus & threat protection"
# 3. Add exclusion for the ccstatusline binary location
# Or use temporary bypass (not recommended for production):
Add-MpPreference -ExclusionPath "$env:USERPROFILE\.bun\bin"
```
#### Windows Subsystem for Linux (WSL)
ccstatusline works perfectly in WSL environments:
```bash
# Install in WSL Ubuntu/Debian
curl -fsSL https://bun.sh/install | bash
source ~/.bashrc
bunx -y ccstatusline@latest
```
**WSL Benefits**:
- Native Unix-style path handling
- Better font rendering in WSL terminals
- Seamless integration with Linux development workflows
### Windows Terminal Configuration
For the best experience, configure Windows Terminal with these recommended settings:
#### Terminal Settings (settings.json)
```json
{
"profiles": {
"defaults": {
"font": {
"face": "JetBrainsMono Nerd Font",
"size": 12
},
"colorScheme": "One Half Dark"
}
}
}
```
#### Claude Code Integration
Configure ccstatusline in your Claude Code settings:
**Settings Location:**
- Default: `~/.claude/settings.json` (Windows: `%USERPROFILE%\.claude\settings.json`)
- Custom: Set `CLAUDE_CONFIG_DIR` environment variable to use a different directory
**For Bun users**:
```json
{
"statusLine": {
"type": "command",
"command": "bunx -y ccstatusline@latest",
"padding": 0
}
}
```
**For npm users**:
```json
{
"statusLine": {
"type": "command",
"command": "npx -y ccstatusline@latest",
"padding": 0
}
}
```
> 💡 **Custom Config Directory:** If you use a non-standard Claude Code configuration directory, set the `CLAUDE_CONFIG_DIR` environment variable before running ccstatusline. The tool will automatically detect and use your custom location.
### Performance on Windows
ccstatusline includes Windows-specific runtime behavior:
- **UTF-8 piped output fix**: In piped mode, it attempts to set code page `65001` for reliable symbol rendering
- **Path compatibility**: Git and CWD widgets handle both `/` and `\` separators
- **Block timer cache**: Cached block metrics reduce repeated JSONL scanning
### Windows-Specific Widget Behavior
Some widgets have Windows-specific optimizations:
- **Current Working Directory**: Displays Windows drive letters and UNC paths
- **Git Widgets**: Handle Windows line endings (CRLF) automatically
- **Custom Commands**: Support both PowerShell and cmd.exe commands
- **Block Timer**: Accounts for Windows timezone handling
---
## 📖 Usage
Once configured, ccstatusline automatically formats your Claude Code status line. The status line appears at the bottom of your terminal during Claude Code sessions.
### Runtime Modes
- **Interactive mode (TUI)**: Launches when there is no stdin input
- **Piped mode (renderer)**: Parses Claude Code status JSON from stdin and prints one or more formatted lines
```bash
# Interactive TUI
bun run start
# Piped mode with example payload
bun run example
```
### 📊 Available Widgets
- **Model Name** - Shows the current Claude model (e.g., "Claude 3.5 Sonnet")
- **Git Branch** - Displays current git branch name
- **Git Changes** - Shows uncommitted insertions/deletions (e.g., "+42,-10")
- **Git Root Dir** - Shows the git repository root directory name
- **Git Worktree** - Shows the name of the current git worktree
- **Session Clock** - Shows elapsed time since session start (e.g., "2hr 15m")
- **Session Usage** - Shows current 5-hour/session API usage percentage
- **Weekly Usage** - Shows rolling 7-day API usage percentage
- **Session Cost** - Shows total session cost in USD (e.g., "$1.23")
- **Session Name** - Shows the session name set via `/rename` command in Claude Code
- **Claude Session ID** - Shows the current Claude Code session ID from status JSON
- **Block Timer** - Shows time elapsed in current 5-hour block or progress bar
- **Reset Timer** - Shows time remaining until the current 5-hour block resets
- **Current Working Directory** - Shows current working directory with segment limit, fish-style abbreviation, and optional `~` home abbreviation
- **Version** - Shows Claude Code version
- **Output Style** - Shows the currently set output style in Claude Code
- **Tokens Input** - Shows input tokens used
- **Tokens Output** - Shows output tokens used
- **Tokens Cached** - Shows cached tokens used
- **Tokens Total** - Shows total tokens used
- **Context Length** - Shows current context length in tokens
- **Context Percentage** - Shows percentage of context limit used (dynamic: 1M for model IDs with `[1m]` suffix, 200k otherwise)
- **Context Percentage (usable)** - Shows percentage of usable context (dynamic: 800k for model IDs with `[1m]` suffix, 160k otherwise, accounting for auto-compact at 80%)
- **Context Bar** - Shows context usage as a progress bar with short/full display modes
- **Terminal Width** - Shows detected terminal width (for debugging)
- **Memory Usage** - Shows system memory usage (used/total, e.g., "Mem: 12.4G/16.0G")
- **Custom Text** - Add your own custom text to the status line
- **Custom Command** - Execute shell commands and display their output (refreshes whenever the statusline is updated by Claude Code)
- **Link** - Add clickable terminal hyperlinks (OSC 8) with configurable URL and display text
- **Separator** - Visual divider between widgets (customizable: |, -, comma, space; available when Powerline mode is off and no default separator is configured)
- **Flex Separator** - Expands to fill available space (available when Powerline mode is off)
---
### Terminal Width Options
These settings affect where long lines are truncated, and where right-alignment occurs when using flex separators:
- **Full width always** - Uses full terminal width (may wrap if auto-compact message appears or IDE integration adds text)
- **Full width minus 40** - Reserves 40 characters for auto-compact message to prevent wrapping (default)
- **Full width until compact** - Dynamically switches between full width and minus 40 based on context percentage threshold (configurable, default 60%)
---
### ⚙️ Global Options
Configure global formatting preferences that apply to all widgets:
![Global Options](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/global.png)
#### Default Padding & Separators
- **Default Padding** - Add consistent padding to the left and right of each widget
- **Default Separator** - Automatically insert a separator between all widgets
- Press **(p)** to edit padding
- Press **(s)** to edit separator
<details>
<summary><b>Global Formatting Options</b></summary>
- **Inherit Colors** - Default separators inherit foreground and background colors from the preceding widget
- Press **(i)** to toggle
- **Global Bold** - Apply bold formatting to all text regardless of individual widget settings
- Press **(o)** to toggle
- **Override Foreground Color** - Force all widgets to use the same text color
- Press **(f)** to cycle through colors
- Press **(g)** to clear override
- **Override Background Color** - Force all widgets to use the same background color
- Press **(b)** to cycle through colors
- Press **(c)** to clear override
For pinned installs, launch the TUI with `npx -y ccstatusline@latest` or `bunx -y ccstatusline@latest`, then choose **Pinned global install**. The TUI pins the active version by installing it globally and writing `"command": "ccstatusline"` to `settings.json`; afterward, you can run `ccstatusline` directly to open the TUI.
</details>
> 💡 **Note:** These settings are applied during rendering and don't add widgets to your widget list. They provide a consistent look across your entire status line without modifying individual widget configurations.
> ⚠️ **VSCode Users:** If colors appear incorrect in the VSCode integrated terminal, the "Terminal Integrated: Minimum Contrast Ratio" (`terminal.integrated.minimumContrastRatio`) setting is forcing a minimum contrast between foreground and background colors. You can adjust this setting to 1 to disable the contrast enforcement, or use a standalone terminal for accurate colors.
### ⏱️ Block Timer Widget
The Block Timer widget helps you track your progress through Claude Code's 5-hour conversation blocks:
![Block Timer](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/blockTimer.png)
**Display Modes:**
- **Time Display** - Shows elapsed time as "3hr 45m" (default)
- **Progress Bar** - Full width 32-character progress bar with percentage
- **Progress Bar (Short)** - Compact 16-character progress bar with percentage
**Features:**
- Automatically detects block boundaries from transcript timestamps
- Floors block start time to the hour for consistent tracking
- Shows "Block: 3hr 45m" in normal mode or just "3hr 45m" in raw value mode
- Progress bars show completion percentage (e.g., "[████████████████████████░░░░░░░░] 73.9%")
- Toggle between modes with the **(p)** key in the widgets editor
### 🔤 Raw Value Mode
Some widgets support "raw value" mode which displays just the value without a label:
- Normal: `Model: Claude 3.5 Sonnet` → Raw: `Claude 3.5 Sonnet`
- Normal: `Session: 2hr 15m` → Raw: `2hr 15m`
- Normal: `Block: 3hr 45m` → Raw: `3hr 45m`
- Normal: `Ctx: 18.6k` → Raw: `18.6k`
### ⌨️ Widget Editor Keybinds
Common controls in the line editor:
- `a` add widget
- `i` insert widget
- `Enter` enter/exit move mode
- `d` delete selected widget
- `r` toggle raw value (supported widgets)
- `m` cycle merge mode (`off``merge``merge no padding`)
Widget-specific shortcuts:
- **Git widgets**: `h` toggle hide `no git` output
- **Context % widgets**: `u` toggle used vs remaining display
- **Block Timer**: `p` cycle display mode (time/full bar/short bar)
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path
- **Custom Command**: `e` command, `w` max width, `t` timeout, `p` preserve ANSI colors
- **Link**: `u` URL, `e` link text
---
### 🔧 Custom Widgets
#### Custom Text Widget
Add static text to your status line. Perfect for:
- Project identifiers
- Environment indicators (dev/prod)
- Personal labels or reminders
#### Custom Command Widget
Execute shell commands and display their output dynamically:
- Refreshes whenever the statusline is updated by Claude Code
- Receives the full Claude Code JSON data via stdin (model info, session ID, transcript path, etc.)
- Displays command output inline in your status line
- Configurable timeout (default: 1000ms)
- Optional max-width truncation
- Optional ANSI color preservation (`preserve colors`)
- Examples:
- `pwd | xargs basename` - Show current directory name
- `node -v` - Display Node.js version
- `git rev-parse --short HEAD` - Show current commit hash
- `date +%H:%M` - Display current time
- `curl -s wttr.in?format="%t"` - Show current temperature
- `npx -y ccusage@latest statusline` - Display Claude usage metrics (set timeout: 5000ms)
> ⚠️ **Important:** Commands should complete quickly to avoid delays. Long-running commands will be killed after the configured timeout. If you're not seeing output from your custom command, try increasing the timeout value (press 't' in the editor).
> 💡 **Tip:** Custom commands can be other Claude Code compatible status line formatters! They receive the same JSON via stdin that ccstatusline receives from Claude Code, allowing you to chain or combine multiple status line tools.
#### Link Widget
Create clickable links in terminals that support OSC 8 hyperlinks:
- `metadata.url` - target URL (http/https)
- `metadata.text` - optional display text (defaults to URL)
- Falls back to plain text when URL is missing or unsupported
---
### 🔗 Integration Example: ccusage
[ccusage](https://github.com/ryoppippi/ccusage) is a tool that tracks and displays Claude Code usage metrics. You can integrate it directly into your status line:
1. Add a Custom Command widget
2. Set command: `npx -y ccusage@latest statusline`
3. Set timeout: `5000` (5 seconds for initial download)
4. Enable "preserve colors" to keep ccusage's color formatting
![ccusage integration](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/ccusage.png)
> 📄 **How it works:** The command receives Claude Code's JSON data via stdin, allowing ccusage to access session information, model details, and transcript data for accurate usage tracking.
### ✂️ Smart Truncation
When terminal width is detected, status lines automatically truncate with ellipsis (...) if they exceed the available width, preventing line wrapping.
Truncation is ANSI/OSC-aware, so preserved color output and OSC 8 hyperlinks remain well-formed.
---
## 📖 API Documentation
Complete API documentation is generated using TypeDoc and includes detailed information about:
- **Core Types**: Configuration interfaces, widget definitions, and render contexts
- **Widget System**: All available widgets and their customization options
- **Utility Functions**: Helper functions for rendering, configuration, and terminal handling
- **Status Line Rendering**: Core rendering engine and formatting options
### Generating Documentation
To generate the API documentation locally:
```bash
# Generate documentation
bun run docs
# Clean generated documentation
bun run docs:clean
```
The documentation will be generated in the `docs/` directory and can be viewed by opening `docs/index.html` in your web browser.
### Documentation Structure
- **Types**: Core TypeScript interfaces and type definitions
- **Widgets**: Individual widget implementations and their APIs
- **Utils**: Utility functions for configuration, rendering, and terminal operations
- **Main Module**: Primary entry point and orchestration functions
---
## 🛠️ Development
### Prerequisites
- [Bun](https://bun.sh) (v1.0+)
- Git
- Node.js 14+ (optional, for running the built `dist/ccstatusline.js` binary or npm publishing)
### Setup
```bash
# Clone the repository
git clone https://github.com/sirmalloc/ccstatusline.git
cd ccstatusline
# Install dependencies
bun install
```
### Development Commands
```bash
# Run in TUI mode
bun run start
# Test piped mode with example payload
bun run example
# Run tests
bun test
# Run typecheck + eslint autofix
bun run lint
# Build for distribution
bun run build
# Generate TypeDoc documentation
bun run docs
```
### Configuration Files
- `~/.config/ccstatusline/settings.json` - ccstatusline UI/render settings
- `~/.claude/settings.json` - Claude Code settings (`statusLine` command object)
- `~/.cache/ccstatusline/block-cache-*.json` - block timer cache (keyed by Claude config directory hash)
If you use a custom Claude config location, set `CLAUDE_CONFIG_DIR` and ccstatusline will read/write that path instead of `~/.claude`.
### Build Notes
- Build target is Node.js 14+ (`dist/ccstatusline.js`)
- During install, `ink@6.2.0` is patched to fix backspace handling on macOS terminals
### 📁 Project Structure
```
ccstatusline/
├── src/
│ ├── ccstatusline.ts # Main entry point
│ ├── tui/ # React/Ink configuration UI
│ │ ├── App.tsx # Root TUI component
│ │ ├── index.tsx # TUI entry point
│ │ └── components/ # UI components
│ │ ├── MainMenu.tsx
│ │ ├── LineSelector.tsx
│ │ ├── ItemsEditor.tsx
│ │ ├── ColorMenu.tsx
│ │ ├── PowerlineSetup.tsx
│ │ └── ...
│ ├── widgets/ # Status line widget implementations
│ │ ├── Model.ts
│ │ ├── GitBranch.ts
│ │ ├── TokensTotal.ts
│ │ ├── OutputStyle.ts
│ │ └── ...
│ ├── utils/ # Utility functions
│ │ ├── config.ts # Settings management
│ │ ├── renderer.ts # Core rendering logic
│ │ ├── powerline.ts # Powerline font utilities
│ │ ├── colors.ts # Color definitions
│ │ └── claude-settings.ts # Claude Code integration (supports CLAUDE_CONFIG_DIR)
│ └── types/ # TypeScript type definitions
│ ├── Settings.ts
│ ├── Widget.ts
│ ├── PowerlineConfig.ts
│ └── ...
├── dist/ # Built files (generated)
├── package.json
├── tsconfig.json
└── README.md
```
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
@@ -719,7 +358,6 @@ Contributions are welcome! Please feel free to submit a Pull Request.
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
---
## Support
@@ -727,13 +365,11 @@ If ccstatusline is useful to you, consider buying me a coffee:
<a href="https://www.buymeacoffee.com/sirmalloc" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
---
## 📄 License
[MIT](LICENSE) © Matthew Breedlove
---
## 👤 Author
@@ -741,14 +377,19 @@ If ccstatusline is useful to you, consider buying me a coffee:
- GitHub: [@sirmalloc](https://github.com/sirmalloc)
---
## 🔗 Related Projects
- [ccstatusline-editor](https://github.com/refinist/ccstatusline-editor) - A visual editor for building ccstatusline configurations — drag, drop, preview, ship.
- [tweakcc](https://github.com/Piebald-AI/tweakcc) - Customize Claude Code themes, thinking verbs, and more.
- [ccusage](https://github.com/ryoppippi/ccusage) - Track and display Claude Code usage metrics.
---
- [ccsidekick](https://ccsidekick.krayong.com/) - A Claude Code status-line with a reactive character plus cost, git, and usage widgets.
- [codachi](https://github.com/vincent-k2026/codachi) - A tamagotchi-style statusline pet that grows with your context window.
- [AIWatch](https://ai-watch.dev) - Live status monitor for 30+ AI APIs and apps; pairs with a Custom Command widget to surface provider outages in your status line.
- [ccsessions](https://github.com/treebird7/ccsessions) - CLI session manager for Claude Code; includes `cc-session-num`, a Custom Command widget that shows the current session's rank (`#1`, `#2`, …).
- [crispy-recall](https://github.com/TheSylvester/crispy-recall) - Searchable memory for your Claude Code and Codex sessions. Local, fast, no daemon.
- [statuslin.es](https://statuslin.es) - Community gallery of Claude Code status lines with live, sandbox-rendered previews.
- [claude-carbon](https://github.com/gwittebolle/claude-carbon) - Live CO2 estimate for your Claude Code sessions, next to the cost. Ships a `--segment` mode built to embed as a Custom Command widget.
## 🙏 Acknowledgments
@@ -756,7 +397,7 @@ If ccstatusline is useful to you, consider buying me a coffee:
- Powered by [Ink](https://github.com/vadimdemedes/ink) for the terminal UI
- Made with ❤️ for the Claude Code community
---
<br />
## Star History
+709 -241
View File
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
# Development
Development setup, project structure, and API documentation for `ccstatusline`.
If you want the main project overview, return to [README.md](../README.md).
## Prerequisites
- [Bun](https://bun.sh) (v1.0+)
- Git
- Node.js 14+ (optional, for running the built `dist/ccstatusline.js` binary or npm publishing)
## Setup
```bash
# Clone the repository
git clone https://github.com/sirmalloc/ccstatusline.git
cd ccstatusline
# Install dependencies
bun install
```
## Development Commands
```bash
# Run in TUI mode
bun run start
# Test piped mode with example payload
bun run example
# Run tests
bun test
# Run typecheck + eslint checks without modifying files
bun run lint
# Apply ESLint auto-fixes intentionally
bun run lint:fix
# Build for distribution
bun run build
# Generate TypeDoc documentation
bun run docs
```
## Configuration Files
- `~/.config/ccstatusline/settings.json` - ccstatusline UI/render settings
- `~/.claude/settings.json` - Claude Code settings (`statusLine` command object)
- `~/.cache/ccstatusline/block-cache-*.json` - block timer cache (keyed by Claude config directory hash)
- `~/.cache/ccstatusline/git-cache/git-*.json` - persistent git widget command cache
- `~/.cache/ccstatusline/git-review/git-review-*.json` - cached Git PR/MR lookup results
- `~/.cache/ccstatusline/usage.json` and `~/.cache/ccstatusline/usage.lock` - usage API data cache and fetch backoff lock
If you use a custom Claude config location, set `CLAUDE_CONFIG_DIR` and ccstatusline will read/write that path instead of `~/.claude`.
Settings saves are atomic and preserve symlinked `settings.json` files by writing through the resolved target. Invalid or unreadable settings are never overwritten during load; `loadSettings()` returns in-memory defaults, records `getConfigLoadError()`, and renderer paths surface that state with an invalid-config warning badge. The TUI captures that load error, keeps a visible warning active, and guards both save paths with an overwrite confirmation until a valid configuration is saved.
Usage-fetch tests spawn subprocess probes. Keep those probes sandboxed by setting `HOME`, `USERPROFILE`, `CLAUDE_CONFIG_DIR`, and proxy variables explicitly so tests cannot read or write a developer's live ccstatusline usage cache.
## Widget Data Sources
- **Cache Timer** reads the transcript tail directly on every render. It expands the read backward when a trailing JSONL record exceeds the initial window, ignores sidechain and synthetic API-error rows, and anchors the countdown only on assistant requests with cache activity. It does not create a separate cache file.
- **Git CI Status** extends the cached Git PR lookup with GitHub's `statusCheckRollup`. If the authenticated `gh` token cannot read checks, the lookup retries with PR metadata only so the Git PR widget still works.
- **Sandbox Status** reads `sandbox.enabled` from Claude Code's layered project-local, project, user-local, and user settings on every refresh. This reflects `/sandbox` file updates but remains a best-effort indicator when managed or CLI settings take precedence.
## Build Notes
- Build target is Node.js 14+ (`dist/ccstatusline.js`)
- `postbuild` replaces the bundled `__PACKAGE_VERSION__` placeholder from `package.json`; `ccstatusline --version` reads that value and exits before mode detection
- During install, `ink@6.2.0` is patched to fix backspace handling on macOS terminals
- React and React DOM are exact-version pins; dependency refreshes should update `package.json` and `bun.lock` together
## API Documentation
Complete API documentation is generated using TypeDoc and includes detailed information about:
- **Core Types**: Configuration interfaces, widget definitions, and render contexts
- **Widget System**: All available widgets and their customization options
- **Utility Functions**: Helper functions for rendering, configuration, and terminal handling
- **Status Line Rendering**: Core rendering engine and formatting options
### Generating Documentation
To generate the API documentation locally:
```bash
# Generate documentation
bun run docs
# Clean generated documentation
bun run docs:clean
```
The documentation will be generated in the `typedoc/` directory and can be viewed by opening `typedoc/index.html` in your web browser.
### Documentation Structure
- **Types**: Core TypeScript interfaces and type definitions
- **Widgets**: Individual widget implementations and their APIs
- **Utils**: Utility functions for configuration, rendering, and terminal operations
- **Main Module**: Primary entry point and orchestration functions
## Project Structure
```text
ccstatusline/
├── src/
│ ├── ccstatusline.ts # Main entry point
│ ├── tui/ # React/Ink configuration UI
│ │ ├── App.tsx # Root TUI component
│ │ ├── index.tsx # TUI entry point
│ │ └── components/ # UI components
│ │ ├── MainMenu.tsx
│ │ ├── LineSelector.tsx
│ │ ├── ItemsEditor.tsx
│ │ ├── ColorMenu.tsx
│ │ ├── PowerlineSetup.tsx
│ │ └── ...
│ ├── widgets/ # Status line widget implementations
│ │ ├── Model.ts
│ │ ├── GitBranch.ts
│ │ ├── TokensTotal.ts
│ │ ├── OutputStyle.ts
│ │ └── ...
│ ├── utils/ # Utility functions
│ │ ├── config.ts # Settings management
│ │ ├── renderer.ts # Core rendering logic
│ │ ├── powerline.ts # Powerline font utilities
│ │ ├── colors.ts # Color definitions
│ │ └── claude-settings.ts # Claude Code integration (supports CLAUDE_CONFIG_DIR)
│ └── types/ # TypeScript type definitions
│ ├── Settings.ts
│ ├── Widget.ts
│ ├── PowerlineConfig.ts
│ └── ...
├── dist/ # Built files (generated)
├── docs/ # Hand-written repository docs
├── typedoc/ # Generated API docs
├── package.json
├── tsconfig.json
└── README.md
```
+335
View File
@@ -0,0 +1,335 @@
# Usage
Usage documentation for `ccstatusline`.
If you want the main project overview, return to [README.md](../README.md).
Once configured, `ccstatusline` automatically formats your Claude Code status line. The status line appears at the bottom of your terminal during Claude Code sessions.
## Runtime Modes
- **Interactive mode (TUI)**: Launches when there is no stdin input
- **Piped mode (renderer)**: Parses Claude Code status JSON from stdin and prints one or more formatted lines
- **Version mode**: Prints the installed ccstatusline package version and exits when passed `--version`
```bash
# Interactive TUI
bun run start
# Piped mode with example payload
bun run example
# Print the installed package version
ccstatusline --version
```
## Available Widgets
### Claude & Session
- **Model** / **Output Style** / **Version** - Show the active Claude model, output style, and Claude Code CLI version. Model names omit trailing context suffixes like `(1M context)`; use **Context Window** when you want the total window size shown.
- **Claude Session ID** / **Session Name** / **Claude Account Email** - Show session identifiers plus the currently signed-in Claude account email.
- **Voice Status** - Show whether Claude Code voice input is enabled. It can render as an icon, icon plus text, plain text, or `voice on/off`, with optional Nerd Font microphone icons.
- **Sandbox Status** - Show the effective `sandbox.enabled` value from Claude Code's layered project and user settings. It can render as a glyph, `SB: ON/OFF`, or `Sandbox: ON/OFF`, with optional Nerd Font lock icons. The value is refreshed after `/sandbox` changes, but is best effort when managed or CLI settings override files or sandbox initialization fails.
- **Thinking Effort** / **Vim Mode** / **Skills** - Show Claude thinking effort, the current vim editing mode, and skill activity from hook data. Thinking Effort reads live status JSON first, then `/model` or `/effort` transcript output, then settings fallback; it supports `low`, `medium`, `high`, `xhigh`, and `max`, shows `default` when no effort is set, and marks unknown future values with `?`. Claude Code reports Ultracode as `xhigh` in status line data; it does not expose Ultracode as a separate effort level.
- **Session Clock** / **Session Cost** - Show elapsed session time and the current session cost in USD.
### Git
- **Git Branch** / **Git Root Dir** / **Git PR** - Show the current branch, repository root directory, and PR/MR details for the current branch with optional links. Git Branch and Git Root Dir can cap their visible labels to a per-widget maximum width; truncation keeps OSC 8 hyperlink targets intact. Works with GitHub (`gh`) and GitLab (`glab`); SSH remote aliases are resolved with `ssh -G` before provider detection, while canonical GitHub/GitLab remotes keep their original forge hosts. For self-hosted hosts whose name contains neither token, whichever CLI is authenticated against that host (`gh auth status --hostname <h>` / `glab auth status --hostname <h>`) is used.
- **Git CI Status** - Summarize GitHub checks for the current branch's pull request as failing (`✗`), pending (`●`), and successful (`✓`) counts. Raw-value mode renders `failing`, `pending`, or `passing`; `-` means no pull request or readable check rollup. This widget is GitHub-only and uses the same cached `gh` lookup as Git PR.
- **Git Changes** / **Git Insertions** / **Git Deletions** - Show aggregate file-change counts and dedicated insertion/deletion counts.
- **Git Status** / **Git Staged** / **Git Unstaged** / **Git Untracked** / **Git Ahead/Behind** / **Git Conflicts** / **Git SHA** - Show compact repo-state indicators, upstream divergence, merge-conflict count, and the current short commit SHA.
- **Git Staged Files** / **Git Unstaged Files** / **Git Untracked Files** / **Git Clean Status** - Show file-level status counts and clean/dirty state.
- **Git Origin Owner** / **Git Origin Repo** / **Git Origin Owner/Repo** - Show parsed `origin` remote metadata.
- **Git Upstream Owner** / **Git Upstream Repo** / **Git Upstream Owner/Repo** / **Git Is Fork** - Show upstream remote metadata and whether the current repo is a fork.
- **Git Worktree** / **Git Worktree Mode** / **Git Worktree Name** / **Git Worktree Branch** / **Git Worktree Original Branch** - Show worktree status plus the active worktree's name and branch metadata.
### Tokens, Usage & Context
- **Tokens Input** / **Tokens Output** / **Tokens Cached** / **Tokens Total** - Show current-session token counts. Input/output prefer cumulative transcript metrics and fall back to `context_window.total_input_tokens` / `context_window.total_output_tokens` when transcript metrics are unavailable; cached/total use transcript metrics.
- **Cache Hit Rate** / **Cache Read** / **Cache Write** - Show prompt-cache efficiency. Cache Hit Rate uses cache reads divided by cache reads plus cache writes; Cache Read and Cache Write include each value's share of prompt context. They default to the latest turn from `context_window.current_usage`, can switch to cumulative session totals, and can hide when empty.
- **Cache Timer** - Estimate time remaining before the current prompt-cache entry expires. It shows `HOT` while a main-chain turn is active, then counts down from the latest assistant request with cache activity and becomes `COLD` just before expiry. The default TTL is 5 minutes; it can switch to 1 hour, hide when no cache anchor is available, and customize the glyph for each state. Because Claude Code transcripts expose cache token activity rather than the actual expiry timestamp, the countdown is best effort.
- **Input Speed** / **Output Speed** / **Total Speed** - Show session-average token throughput with an optional per-widget rolling window (`0-120` seconds; `0` = full-session average).
- **Context Length** / **Context Window** / **Context %** / **Context % (usable)** / **Context Bar** - Show current context length, total context window size, used/remaining percentage, usable-window percentage, or a progress bar. The window size is taken from Claude Code's reported `context_window.context_window_size` when present, then from a model-name hint (e.g. a `[1m]` suffix), and finally from a fixed fallback. Set `CCSTATUSLINE_CONTEXT_SIZE_FALLBACK` to a positive integer to override that last-resort fallback (defaults to `200000`) — useful when an older Claude Code does not report the window size for a 1M-context model, so the bar would otherwise read against 200k.
- **Compaction Counter** - Show how many context compactions have been detected in the current session by scanning transcript compaction markers. It can render as icon plus number, text plus number, or number-only, and can hide while the count is zero. Two optional, independent per-item add-ons toggle extra detail: a trigger split (`↻ 3 (2 auto, 1 manual)`; a compaction whose trigger is missing or unrecognized is bucketed as `unknown`) and tokens reclaimed (`↻ 3 ↓887.0k`, each compaction's `preTokens - postTokens` floored at 0 and summed, shown only when greater than 0 — so very old transcripts predating the `postTokens` field display nothing). Its value selector can instead render the total count, one trigger count (`auto`, `manual`, or `unknown`), or reclaimed tokens as a standalone value; hide-when-zero applies to the selected value.
- **Session Usage** / **Weekly Usage** / **Weekly Sonnet Usage** / **Weekly Opus Usage** / **Extra Usage Utilization** / **Extra Usage Remaining** / **Extra Usage Used** / **Block Timer** / **Block Reset Timer** / **Weekly Reset Timer** - Show usage percentages, monthly pay-as-you-go overage usage, and current block/reset timing. The all-models weekly bar covers `seven_day` from the usage API; the per-model variants surface the `seven_day_sonnet` and `seven_day_opus` buckets that Claude Code's own `/usage` panel shows. Session Usage, the weekly percentage widgets, and Extra Usage Utilization can show either used or remaining percentage in every display mode. Session and weekly usage bars can also show a time cursor. Extra usage widgets accept known extra-usage state as complete when an account has no monthly limit configured, avoid repeated refetches and stale `[Timeout]` output, and format amounts with the API-reported billing currency when available. Reset timers can show remaining time, progress, or exact reset date/time with timezone and locale controls; while reset data is still arriving at startup, they show a labeled loading placeholder instead of a transient API error.
### Environment, Layout & Custom
- **Current Working Dir** / **Terminal Width** / **Memory Usage** - Show the current working directory, detected terminal width, and system memory usage. Current Working Dir can prepend an optional custom glyph, including when raw-value mode replaces the `cwd:` label.
- **Custom Text** / **Custom Symbol** / **Custom Command** / **Link** - Add user-defined text, a single symbol or emoji, custom command output, or a clickable OSC 8 hyperlink.
- **Separator** / **Flex Separator** - Add a manual divider or a width-filling flexible spacer. Manual separators are disabled in Powerline mode, but flex separators still work there as layout spacers.
## Terminal Width Options
These settings affect where long lines are truncated, and where right-alignment occurs when using flex separators:
- **Full width always** - Uses full terminal width (may wrap if auto-compact message appears or IDE integration adds text)
- **Full width minus 40** - Reserves 40 characters for auto-compact message to prevent wrapping (default)
- **Full width until compact** - Dynamically switches between full width and minus 40 based on context percentage threshold (configurable, default 60%)
Flex separators expand against the detected width in both regular and Powerline rendering. If width detection is unavailable, they render like normal separators until a terminal width is available.
If ccstatusline cannot detect your terminal width, set `CCSTATUSLINE_WIDTH` to a positive integer to override probing:
```bash
CCSTATUSLINE_WIDTH=160 ccstatusline
```
The override is checked before automatic width detection, so it also works in wrapper processes, IDE integrations, nested PTYs, and Windows environments where probing may be unavailable. Invalid values such as `0`, negative numbers, or non-numeric strings are ignored and ccstatusline falls back to normal detection.
## Powerline Auto-Alignment
Powerline Setup can align widgets into shared columns across multiple status lines; press `a` there to toggle **Align Widgets**. When auto-alignment makes a naturally wide value stretch later columns, select that widget in the line editor and press `x` (**exclude align**). The selected widget and everything after it on that line keep their natural widths, while earlier columns remain aligned. This control is available only when Powerline auto-alignment is enabled and the selected widget is not merged into the previous widget.
## Global Options
Configure global formatting preferences that apply to all widgets:
![Global Options](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/global.png)
### Default Padding & Separators
- **Default Padding** - Add consistent padding around each widget
- **Padding Side** - Choose whether default padding applies to **Both** sides (default), **Left only**, or **Right only**
- **Default Separator** - Automatically insert a separator between all widgets
- Press **(p)** to edit padding
- Press **(d)** to cycle padding side
- Press **(s)** to edit separator
- Manual separators collapse around widgets that render empty, so hide-when-empty widgets do not leave dangling dividers.
<details>
<summary><b>Global Formatting Options</b></summary>
- **Inherit Colors** - Default separators inherit foreground and background colors from the preceding widget
- Press **(i)** to toggle
- **Global Bold** - Apply bold formatting to all text regardless of individual widget settings
- Press **(o)** to toggle
- **Minimalist Mode** - Force widgets into raw-value rendering globally for a cleaner, label-free status line
- Press **(m)** to toggle
- **Override Foreground Color** - Force all widgets to use the same text color, or a whole-line **gradient** (see below)
- Press **(f)** to cycle through colors
- Press **(g)** to choose a gradient
- Press **(x)** to clear override
- **Override Background Color** - Force all widgets to use the same background color
- Press **(b)** to cycle through colors
- Press **(c)** to clear override
</details>
> 💡 **Note:** These settings are applied during rendering and don't add widgets to your widget list. They provide a consistent look across your entire status line without modifying individual widget configurations.
> ⚠️ **VSCode Users:** If colors appear incorrect in the VSCode integrated terminal, the "Terminal Integrated: Minimum Contrast Ratio" (`terminal.integrated.minimumContrastRatio`) setting is forcing a minimum contrast between foreground and background colors. You can adjust this setting to 1 to disable the contrast enforcement, or use a standalone terminal for accurate colors.
## Widget Styling
The color editor can adjust foreground color, background color, bold, dim, and gradients per widget:
- Use `←` / `→` to cycle the selected foreground or background color.
- Press `f` to switch between foreground and background editing.
- Press `b` to toggle bold.
- Press `d` to cycle dim styling: off → whole widget → parenthesized text only → off.
- Press `r` to reset styling on the selected widget, or `c` to clear styling on every widget in the line.
## Gradient Colors
A foreground color can be a multi-stop **gradient** instead of a solid. Colors interpolate in OKLab for perceptually even blends. A gradient value takes one of three forms, all prefixed `gradient:`:
- **Named preset** — `gradient:atlas` (case-insensitive). Built-in presets: `atlas`, `cristal`, `teen`, `mind`, `morning`, `vice`, `passion`, `fruit`, `instagram`, `retro`, `summer`, `rainbow`, `pastel`.
- **Dash stops** — `gradient:RRGGBB-RRGGBB[-RRGGBB...]` (two or more bare or `#`-prefixed hex stops).
- **Comma stops** — `gradient:hex:RRGGBB,#RRGGBB,RRGGBB` (two or more `hex:`/`#`/bare stops).
Gradients apply at **two scopes**:
- **Per-widget** — set a widget's color to a gradient so its text carries its own self-contained sweep. In the color menu (foreground, 256-color or truecolor mode), press **(g)** to open the gradient picker, then choose a preset or enter custom start/end hex stops.
- **Whole line** — set `overrideForegroundColor` to a gradient spec to paint the entire status line with one continuous sweep, each character colored by its column position. In the Global Overrides menu, press **(g)** on Override FG Color to open the same gradient picker, or author the value directly in `settings.json`.
Gradients self-degrade where they can't render: at Basic or No Color levels, gradient settings are preserved but render as plain text. In Powerline mode, global foreground gradients color widget text while separators and caps keep Powerline's normal foreground/background contrast rules; per-widget gradients collapse to their first stop when using 256-color or truecolor output.
## Claude Code Status Line Settings
When ccstatusline is installed in Claude Code, the main menu includes **Configure Status Line**. Claude Code versions >=2.1.97 support `statusLine.refreshInterval`; ccstatusline can set it to `1-60` seconds, defaults fresh supported installs to `10` seconds, and removes the setting when the input is left empty.
## Settings Recovery
If `settings.json` is unreadable or invalid, ccstatusline leaves the file unchanged, renders with built-in defaults for that run, and prepends an invalid-config warning badge to the status line. The TUI shows the same warning and asks for confirmation before either **Save & Exit** or `Ctrl+S` replaces the invalid file. Fix the JSON to preserve its contents, or confirm the save to replace it with the configuration currently shown in the TUI.
## Block Timer Widget
The Block Timer widget helps you track your progress through Claude Code's 5-hour conversation blocks:
![Block Timer](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/blockTimer.png)
**Display Modes:**
- **Time Display** - Shows elapsed time as "3hr 45m" (default)
- **Progress Bar** - Full width 32-character progress bar with percentage
- **Progress Bar (Short)** - Compact 16-character progress bar with percentage
**Features:**
- Automatically detects block boundaries from transcript timestamps
- Floors block start time to the hour for consistent tracking
- Shows "Block: 3hr 45m" in normal mode or just "3hr 45m" in raw value mode
- Progress bars show completion percentage (e.g., "[████████████████████████░░░░░░░░] 73.9%")
- Use **(p)** to cycle time/full bar/short bar, **(s)** for compact time mode, and **(v)** to invert fill in progress mode
## Raw Value Mode
Some widgets support "raw value" mode which displays just the value without a label:
- Normal: `Model: Claude 3.5 Sonnet` → Raw: `Claude 3.5 Sonnet`
- Normal: `Session: 2hr 15m` → Raw: `2hr 15m`
- Normal: `Block: 3hr 45m` → Raw: `3hr 45m`
- Normal: `Ctx: 18.6k` → Raw: `18.6k`
## Widget Editor Keybinds
Common controls in the line editor:
- `↑/↓` select widget
- `←/→` open the type picker for the selected widget
- navigation wraps at list boundaries, including move/reorder mode
- `a` add widget via the picker
- `i` insert widget via the picker
- `k` clone the selected widget
- `Enter` enter/exit move mode
- `d` delete selected widget
- `c` clear the current line
- `Space` cycle a manual separator character
- `r` toggle raw value (supported widgets)
- `m` cycle merge mode (`off``merge``merge no padding`)
- `x` exclude the selected widget and the rest of its line from shared Powerline column widths (shown only when Powerline auto-alignment is enabled)
- `Esc` go back
Widget picker:
- type to search categories and widgets
- supports substring, initialism, and fuzzy matching
- `↑/↓` change selection, `Enter` continue/apply, `Esc` clear search/back/cancel
The keybind footer in the TUI only shows shortcuts that apply to the currently selected widget.
Widget-specific shortcuts:
- **Git widgets with empty-state toggles**: `h` hide `no git` / empty output where supported
- **Glyph widgets** (Git Branch, Git Worktree, Git Worktree Mode, Git Staged, Git Unstaged, Git Untracked, Git Conflicts, Git Ahead/Behind, Git Status, JJ Bookmarks, JJ Workspace): `g` set custom glyphs for the widget's symbols; Backspace in the editor renders without one, and multi-symbol widgets (Ahead/Behind, Status) edit each part in one list
- **Git Branch**: `l` toggle clickable branch links (GitHub, GitLab, self-hosted), `w` set a maximum visible width (blank removes the limit)
- **Git Root Dir**: `l` cycle IDE links (`off``VS Code``Cursor`), `w` set a maximum visible width (blank removes the limit)
- **Git PR**: `h` hide empty/no-PR/MR output, `s` toggle review status, `t` toggle title (renders "MR" for GitLab origins)
- **Git remote widgets** (`Git Origin*` / `Git Upstream*`): `h` hide when no remote, `l` toggle clickable repo links
- **Git Origin Owner/Repo**: `o` show only the owner when the repo is a fork
- **Git Is Fork**: `h` hide when the repo is not a fork
- **Context % widgets**: `u` toggle used vs remaining display, `p` cycle percentage/short bar/short bar only
- **Session Usage / Weekly Usage / Weekly Sonnet Usage / Weekly Opus Usage / Extra Usage Utilization**: `p` cycle percentage/full bar/medium bar/short bar/short bar only and `u` switch between used and remaining percentage in every display mode. The editor row labels the current direction as `used` or `remaining`, while the `u` helper names the direction it will switch to. Session and weekly usage widgets use `t` to toggle the time cursor in bar modes; Extra Usage Utilization uses `h` to hide itself when extra usage is disabled.
- **Block Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time, `v` invert fill in progress mode
- **Block Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `h` toggle 12/24-hour display in date mode, `z` edit timezone in date mode, `l` edit locale in date mode, `v` invert fill in progress mode
- **Weekly Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `h` toggle hours-only in time mode or 12/24-hour display in date mode, `z` edit timezone in date mode, `l` edit locale in date mode, `v` invert fill in progress mode
- **Context Bar**: `p` cycle medium/full/short/short-only progress bar
- **Compaction Counter**: `v` cycle value (count/auto/manual/unknown/reclaimed), `f` cycle format, `n` toggle Nerd Font icon in icon mode, `s` toggle trigger split (auto/manual/unknown), `t` toggle tokens reclaimed, `h` hide when zero
- **Cache widgets** (Cache Hit Rate, Cache Read, Cache Write): `t` toggle turn/session scope, `h` hide when empty
- **Cache Timer**: `t` cycle 5-minute/1-hour TTL, `h` hide when no cache anchor is available, `g` customize the working/fresh/draining/urgent/cold glyphs
- **Sandbox Status**: `f` cycle glyph/text/word format, `n` toggle Nerd Font lock icons in glyph mode
- **Voice Status**: `f` cycle format, `n` toggle Nerd Font microphone icons
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path, `g` optional leading glyph (off by default; pair with raw value to replace the `cwd:` label with the glyph)
- **Skills**: `v` cycle view mode, `h` hide when empty, `l` edit list limit in list mode
- **Input Speed / Output Speed / Total Speed**: `w` edit the rolling window in seconds
- **Custom Text / Custom Symbol**: `e` edit text or symbol
- **Custom Command**: `e` command, `w` max width, `t` timeout, `p` preserve ANSI colors
- **Link**: `u` URL, `e` link text
- **Vim Mode**: `f` cycle format, `n` toggle Nerd Font icons
## Custom Widgets
### Custom Text Widget
Add static text to your status line. Perfect for:
- Project identifiers
- Environment indicators (dev/prod)
- Personal labels or reminders
### Custom Symbol Widget
Add a single symbol or emoji to your status line when you want a compact visual marker:
- Nerd Font or Powerline-friendly glyphs
- Status markers like `●`, `✓`, or `⚠`
- Emoji shorthand for environments, workflows, or attention cues
### Custom Command Widget
Execute shell commands and display their output dynamically:
- Refreshes whenever the statusline is updated by Claude Code
- Receives the full Claude Code JSON data via stdin (model info, session ID, transcript path, etc.)
- Also includes `terminal_width` — the detected terminal width in columns, added by ccstatusline (omitted when it can't be determined) — so scripts can adapt their output to the available space
- Displays command output inline in your status line
- Configurable timeout (default: 1000ms)
- Optional max-width truncation
- Optional ANSI color preservation (`preserve colors`)
- Examples:
- `pwd | xargs basename` - Show current directory name
- `node -v` - Display Node.js version
- `git rev-parse --short HEAD` - Show current commit hash
- `date +%H:%M` - Display current time
- `curl -s wttr.in?format="%t"` - Show current temperature
- `npx -y ccusage@latest statusline` - Display Claude usage metrics (set timeout: 5000ms)
- `cc-session-num` - Show current session rank (`#1`, `#2`, …) from [ccsessions](https://github.com/treebird7/ccsessions)
> ⚠️ **Important:** Commands should complete quickly to avoid delays. Long-running commands will be killed after the configured timeout. If you're not seeing output from your custom command, try increasing the timeout value (press 't' in the editor).
> 💡 **Tip:** Custom commands can be other Claude Code compatible status line formatters. They receive the same JSON via stdin that `ccstatusline` receives from Claude Code (augmented with a `terminal_width` field), allowing you to chain or combine multiple status line tools.
### Link Widget
Create clickable links in terminals that support OSC 8 hyperlinks:
- `metadata.url` - target URL (http/https)
- `metadata.text` - optional display text (defaults to URL)
- Falls back to plain text when URL is missing or unsupported
## Integration Example: ccusage
[ccusage](https://github.com/ryoppippi/ccusage) is a tool that tracks and displays Claude Code usage metrics. You can integrate it directly into your status line:
1. Add a Custom Command widget
2. Set command: `npx -y ccusage@latest statusline`
3. Set timeout: `5000` (5 seconds for initial download)
4. Enable "preserve colors" to keep ccusage's color formatting
![ccusage integration](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/ccusage.png)
> 📄 **How it works:** The command receives Claude Code's JSON data via stdin, allowing ccusage to access session information, model details, and transcript data for accurate usage tracking.
## Integration Example: AIWatch
[AIWatch](https://ai-watch.dev) monitors the live status of 30+ AI APIs and apps (Claude, GPT, Gemini, …). Surfacing it in your status line answers "is Claude slow because of me, or because the API is degraded?" without leaving the terminal.
1. Add a Custom Command widget
2. Set command:
```bash
( curl -sf --max-time 2 https://ai-watch.dev/api/status/cached | jq -r '[.services[] | select(.status != "operational") | "🔴 " + .name] | .[0:3] | join(" ")' ) 2>/dev/null || true
```
3. Set timeout: `2000` (the `curl` itself caps at 2s; the outer `|| true` keeps the widget silent on any failure)
4. Leave "preserve colors" off — the output is a plain emoji + name list
When every tracked service is operational the command prints nothing, so the widget renders empty and manual separators collapse around it. The endpoint is JSON, CORS-enabled, and served with a ~5-minute cache.
> 📄 **Variants:** See [ai-watch.dev/#statusline](https://ai-watch.dev/#statusline) for count-only, compact, provider-scoped, and clickable OSC 8 link presets.
## Integration Example: ccsessions
[ccsessions](https://github.com/treebird7/ccsessions) is a CLI session manager for Claude Code. Its companion script `cc-session-num` shows the current session's rank (`#1`, `#2`, …) matched against the same mtime-sorted list that `ccsessions` uses.
1. Install `cc-session-num`:
```bash
curl -fsSL https://raw.githubusercontent.com/treebird7/ccsessions/main/cc-session-num \
-o ~/.local/bin/cc-session-num && chmod +x ~/.local/bin/cc-session-num
```
2. Add a Custom Command widget
3. Set command: `cc-session-num`
4. Leave timeout at default (the script reads `~/.claude/projects/` locally and returns in milliseconds)
The widget renders nothing when the current session isn't found, so manual separators collapse around it cleanly.
> 📄 **How it works:** `cc-session-num` reads `CLAUDE_CODE_SESSION_ID` from the environment (set by Claude Code), then ranks `~/.claude/projects/*/*.jsonl` files by modification time — the same sort order `ccsessions` uses — and prints the matching position.
## Smart Truncation
When terminal width is detected, status lines automatically truncate with ellipsis (`...`) if they exceed the available width, preventing line wrapping.
Truncation is ANSI/OSC-aware, so preserved color output and OSC 8 hyperlinks remain well-formed.
+216
View File
@@ -0,0 +1,216 @@
# Windows Support
`ccstatusline` works on Windows across PowerShell (5.1+ and 7+), Command Prompt, and Windows Subsystem for Linux (WSL).
If you want the main project overview, return to [README.md](../README.md).
## Installation on Windows
### Option 1: Using Bun (Recommended)
```powershell
# Install Bun for Windows
irm bun.sh/install.ps1 | iex
# Run ccstatusline
bunx -y ccstatusline@latest
```
### Option 2: Using Node.js
```powershell
# Using npm
npx -y ccstatusline@latest
```
These commands launch the configuration TUI. To pin the install, start the TUI with `bunx -y ccstatusline@latest` or `npx -y ccstatusline@latest`, then choose **Pinned global install**. The TUI installs the active ccstatusline version globally with Bun or npm and configures Claude Code to run `ccstatusline`. After a pinned install, you can run `ccstatusline` directly to launch the TUI in the future.
## Claude Code Integration
Configure `ccstatusline` in your Claude Code settings:
**Settings location:**
- Default: `%USERPROFILE%\.claude\settings.json`
- Custom: set `CLAUDE_CONFIG_DIR` to use a different directory
**PowerShell custom config example:**
```powershell
$env:CLAUDE_CONFIG_DIR="C:\custom\path\.claude"
```
**For Bun users:**
```json
{
"statusLine": {
"type": "command",
"command": "bunx -y ccstatusline@latest",
"padding": 0,
"refreshInterval": 10
}
}
```
**For npm users:**
```json
{
"statusLine": {
"type": "command",
"command": "npx -y ccstatusline@latest",
"padding": 0,
"refreshInterval": 10
}
}
```
`refreshInterval` is optional and only supported by Claude Code >=2.1.97. You can configure it from ccstatusline's TUI after installation.
The `bunx` and `npx` examples above follow `@latest`. If you choose **Pinned global install** in the TUI, it writes `"command": "ccstatusline"` instead after installing the selected version globally. You can also run `ccstatusline` directly for future TUI launches.
## Windows-Specific Features
### GitHub CI Status
The **Git CI Status** widget requires the GitHub CLI to be installed and authenticated. It is GitHub-only and reads checks from the pull request associated with the current branch.
```powershell
winget install --id GitHub.cli --source winget
gh auth login
```
The widget displays `-` when the branch has no pull request, the pull request has no checks, or the authenticated token cannot read the check rollup.
### Powerline Font Support
For optimal Powerline rendering on Windows:
**Windows Terminal** (Recommended):
- Supports Powerline fonts natively
- Download from [Microsoft Store](https://aka.ms/terminal)
- Auto-detects compatible fonts
**PowerShell/Command Prompt**:
```powershell
# Install JetBrains Mono Nerd Font via winget
winget install DEVCOM.JetBrainsMonoNerdFont
# Or download manually from: https://www.nerdfonts.com/font-downloads
# Alternative: Download and install base JetBrains Mono font
# from [JetBrains](https://www.jetbrains.com/lp/mono/)
# or [GitHub](https://github.com/JetBrains/JetBrainsMono)
# or [Google Fonts](https://fonts.google.com/specimen/JetBrains+Mono)
```
### Path Handling
`ccstatusline` automatically handles Windows-specific paths:
- Git repositories work with both `/` and `\` path separators
- Current Working Directory widget displays Windows-style paths correctly
- Full support for mapped network drives and UNC paths
- Handles Windows drive letters (C:, D:, etc.)
## Windows Troubleshooting
### Common Issues & Solutions
**Issue**: Status lines wrap because terminal width cannot be detected
```powershell
# Set an explicit width before launching Claude Code
$env:CCSTATUSLINE_WIDTH="160"
claude
```
`CCSTATUSLINE_WIDTH` accepts a positive integer column width and is checked before automatic width detection. Set it in the same environment that starts Claude Code so the status line command inherits it. This is useful on Windows because native width probing is disabled when ccstatusline runs outside WSL.
**Issue**: Powerline symbols showing as question marks or boxes
```powershell
# Solution: Install a compatible Nerd Font
winget install DEVCOM.JetBrainsMonoNerdFont
# Then set the font in your terminal settings
```
**Issue**: Git commands not recognized
```powershell
# Check if Git is installed and in PATH
git --version
# If not found, install Git:
winget install Git.Git
# Or download from: https://git-scm.com/download/win
```
**Issue**: Permission errors during installation
```powershell
# Use non-global installation (recommended)
npx -y ccstatusline@latest
# Or run PowerShell as Administrator for global install
```
**Issue**: "Execution Policy" errors in PowerShell
```powershell
# Temporarily allow script execution
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
```
**Issue**: Windows Defender blocking execution
```powershell
# If Windows Defender flags the binary:
# 1. Open Windows Security
# 2. Go to "Virus & threat protection"
# 3. Add exclusion for the ccstatusline binary location
# Or use temporary bypass (not recommended for production):
Add-MpPreference -ExclusionPath "$env:USERPROFILE\.bun\bin"
```
## Windows Subsystem for Linux (WSL)
`ccstatusline` works well in WSL environments:
```bash
# Install in WSL Ubuntu/Debian
curl -fsSL https://bun.sh/install | bash
source ~/.bashrc
bunx -y ccstatusline@latest
```
**WSL benefits:**
- Native Unix-style path handling
- Better font rendering in WSL terminals
- Seamless integration with Linux development workflows
## Windows Terminal Configuration
For the best experience, configure Windows Terminal with these recommended settings:
### Terminal Settings (`settings.json`)
```json
{
"profiles": {
"defaults": {
"font": {
"face": "JetBrainsMono Nerd Font",
"size": 12
},
"colorScheme": "One Half Dark"
}
}
}
```
## Performance on Windows
`ccstatusline` includes Windows-specific runtime behavior:
- **UTF-8 piped output fix**: In piped mode, it attempts to set code page `65001` for reliable symbol rendering
- **Path compatibility**: Git and CWD widgets handle both `/` and `\` separators
+29 -41
View File
@@ -1,25 +1,42 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import stylistic from '@stylistic/eslint-plugin';
import importPlugin from 'eslint-plugin-import';
import { importX } from 'eslint-plugin-import-x';
import importNewlinesPlugin from 'eslint-plugin-import-newlines';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import globals from 'globals';
const importResolverSettings = {
'import-x/resolver': {
typescript: {
project: ['./tsconfig.json'],
alwaysTryTypes: true,
noWarnOnMultipleProjects: true
},
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
}
},
'import-x/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx']
},
'import-x/external-module-folders': ['node_modules', 'node_modules/@types']
};
export default ts.config([
{
files: ['**/*.ts', '**/*.tsx'],
plugins: {
stylistic,
importPlugin,
'import-x': importX,
'import-newlines': importNewlinesPlugin
},
extends: [
js.configs.recommended,
ts.configs.strictTypeChecked,
ts.configs.stylisticTypeChecked,
importPlugin.flatConfigs.recommended,
importX.flatConfigs.recommended,
stylistic.configs.customize({
quotes: 'single',
semi: true,
@@ -38,26 +55,12 @@ export default ts.config([
}
},
settings: {
'import/resolver': {
typescript: {
project: ['./tsconfig.json'],
alwaysTryTypes: true,
noWarnOnMultipleProjects: true
},
parcel2: {},
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
}
},
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx']
},
'import/external-module-folders': ['node_modules', 'node_modules/@types']
...importResolverSettings
},
rules: {
'no-control-regex': 'off', // We intentionally match ANSI escape sequences
'eqeqeq': 'error',
'import/order': ['error', {
'import-x/order': ['error', {
alphabetize: {
'order': 'asc',
'orderImportKind': 'asc',
@@ -103,13 +106,13 @@ export default ts.config([
'@stylistic/nonblock-statement-body-position': ['error', 'below'],
'@stylistic/object-curly-newline': ['error', { 'multiline': true }],
'@stylistic/switch-colon-spacing': 'error',
'@stylistic/eol-last': ['error', 'never'],
'@stylistic/eol-last': ['error', 'always'],
'@stylistic/jsx-quotes': ['error', 'prefer-single'],
'@stylistic/multiline-ternary': 'off',
'import/no-unresolved': ['error'],
'import/no-named-as-default': 'off',
'import/no-named-as-default-member': 'off',
'import/default': 'off'
'import-x/no-unresolved': ['error'],
'import-x/no-named-as-default': 'off',
'import-x/no-named-as-default-member': 'off',
'import-x/default': 'off'
}
},
{
@@ -119,22 +122,7 @@ export default ts.config([
'react-hooks': reactHooksPlugin
},
settings: {
...{
'import/resolver': {
typescript: {
project: ['./tsconfig.json'],
alwaysTryTypes: true,
noWarnOnMultipleProjects: true
},
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
}
},
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx']
},
'import/external-module-folders': ['node_modules', 'node_modules/@types']
},
...importResolverSettings,
react: {
version: 'detect'
}
@@ -156,4 +144,4 @@ export default ts.config([
'!eslint.config.js'
]
}
]);
]);
+38 -16
View File
@@ -1,11 +1,22 @@
{
"name": "ccstatusline",
"version": "2.1.3",
"version": "2.2.25",
"bugs": {
"url": "https://github.com/sirmalloc/ccstatusline/issues"
},
"description": "A customizable status line formatter for Claude Code CLI",
"module": "src/ccstatusline.ts",
"main": "./dist/ccstatusline.js",
"module": "./dist/ccstatusline.js",
"type": "module",
"bin": {
"ccstatusline": "dist/ccstatusline.js"
"ccstatusline": "./dist/ccstatusline.js"
},
"exports": {
".": {
"import": "./dist/ccstatusline.js",
"default": "./dist/ccstatusline.js"
},
"./package.json": "./package.json"
},
"files": [
"dist/"
@@ -15,36 +26,47 @@
"build": "rm -rf dist/* ; bun build src/ccstatusline.ts --target=node --outfile=dist/ccstatusline.js --target-version=14",
"postbuild": "bun run scripts/replace-version.ts",
"example": "cat scripts/payload.example.json | bun start",
"video:studio": "remotion studio remotion/index.ts",
"video:still": "remotion still remotion/index.ts ccstatusline-tui-demo out/ccstatusline-tui-demo.png --frame=700 --scale=0.5",
"video:render": "remotion render remotion/index.ts ccstatusline-tui-demo out/ccstatusline-tui-demo.mp4",
"video:gif": "bun run video:render && ffmpeg -y -i out/ccstatusline-tui-demo.mp4 -filter_complex \"fps=12,scale=1322:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=256[p];[s1][p]paletteuse=dither=none:diff_mode=rectangle\" out/ccstatusline-tui-demo-unoptimized.gif && gifsicle -O3 --lossy=30 out/ccstatusline-tui-demo-unoptimized.gif -o out/ccstatusline-tui-demo.gif",
"prepublishOnly": "bun run build",
"lint": "bun tsc --noEmit; eslint . --config eslint.config.js --max-warnings=999999 --fix",
"lint": "bun tsc --noEmit && eslint . --config eslint.config.js --max-warnings=0",
"lint:fix": "bun tsc --noEmit && eslint . --config eslint.config.js --max-warnings=0 --fix",
"docs": "typedoc",
"docs:clean": "rm -rf docs"
"docs:clean": "rm -rf typedoc"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@eslint/js": "^10.0.1",
"@remotion/cli": "^4.0.459",
"@stylistic/eslint-plugin": "^5.2.3",
"@types/bun": "latest",
"@types/pluralize": "^0.0.33",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.2.3",
"chalk": "^5.5.0",
"eslint": "^9.33.0",
"eslint": "^10.0.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-import-newlines": "^1.4.0",
"eslint-plugin-import-newlines": "^2.0.0",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"ink": "^6.2.0",
"ink-gradient": "^3.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.3.0",
"https-proxy-agent": "^8.0.0",
"ink": "6.2.0",
"ink-gradient": "^4.0.0",
"ink-select-input": "^6.2.0",
"pluralize": "^8.0.0",
"react": "^19.1.1",
"react-devtools-core": "^6.1.5",
"react": "19.2.7",
"react-devtools-core": "^7.0.1",
"react-dom": "19.2.7",
"remotion": "^4.0.459",
"strip-ansi": "^7.1.0",
"tinyglobby": "^0.2.14",
"typedoc": "^0.28.12",
"typescript": "^5.9.2",
"typescript": "^6.0.2",
"typescript-eslint": "^8.39.1",
"vitest": "^3.2.4",
"vitest": "^4.0.18",
"zod": "^4.0.17"
},
"keywords": [
+5
View File
@@ -0,0 +1,5 @@
import { registerRoot } from 'remotion';
import { RemotionRoot } from './root';
registerRoot(RemotionRoot);
+16
View File
@@ -0,0 +1,16 @@
import { Composition } from 'remotion';
import { TUIDemo } from './tuiDemo';
export const RemotionRoot = () => {
return (
<Composition
id='ccstatusline-tui-demo'
component={TUIDemo}
durationInFrames={1470}
fps={30}
width={1322}
height={862}
/>
);
};
+2724
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 MiB

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+32 -4
View File
@@ -4,14 +4,15 @@
"transcript_path": "/path/to/transcript.json",
"cwd": "/current/working/directory",
"model": {
"id": "claude-opus-4-1",
"display_name": "Opus"
"id": "claude-opus-4-6[1m]",
"display_name": "Opus 4.6 (1M context)"
},
"workspace": {
"current_dir": "/current/working/directory",
"project_dir": "/original/project/directory"
"project_dir": "/original/project/directory",
"added_dirs": []
},
"version": "1.0.80",
"version": "2.1.80",
"output_style": {
"name": "default"
},
@@ -21,5 +22,32 @@
"total_api_duration_ms": 2300,
"total_lines_added": 156,
"total_lines_removed": 23
},
"context_window": {
"total_input_tokens": 50113,
"total_output_tokens": 10462,
"context_window_size": 1000000,
"current_usage": {
"input_tokens": 8500,
"output_tokens": 1200,
"cache_creation_input_tokens": 5000,
"cache_read_input_tokens": 2000
},
"used_percentage": 8,
"remaining_percentage": 92
},
"exceeds_200k_tokens": false,
"rate_limits": {
"five_hour": {
"used_percentage": 42,
"resets_at": 1774020000
},
"seven_day": {
"used_percentage": 15,
"resets_at": 1774540000
}
},
"vim": {
"mode": "NORMAL"
}
}
+1 -1
View File
@@ -25,4 +25,4 @@ bundledContent = bundledContent.replace(/__PACKAGE_VERSION__/g, version);
// Write back the modified content
writeFileSync(bundledFilePath, bundledContent);
console.log(`✓ Replaced version placeholder with ${version}`);
console.log(`✓ Replaced version placeholder with ${version}`);
+165 -10
View File
@@ -2,25 +2,55 @@
import chalk from 'chalk';
import { runTUI } from './tui';
import type { TokenMetrics } from './types';
import type {
SkillsMetrics,
SpeedMetrics,
TokenMetrics
} from './types';
import type { RenderContext } from './types/RenderContext';
import type { StatusJSON } from './types/StatusJSON';
import { StatusJSONSchema } from './types/StatusJSON';
import { getVisibleText } from './utils/ansi';
import { updateColorMap } from './utils/colors';
import {
ZERO_COMPACTION_STATS,
getCompactionStats
} from './utils/compaction';
import {
getConfigLoadError,
initConfigPath,
loadSettings,
saveSettings
} from './utils/config';
import {
GIT_REVIEW_REFRESH_FLAG,
refreshGitReviewCacheFromCli
} from './utils/git-review-cache';
import { handleHookInput } from './utils/hook-handler';
import {
getSessionDuration,
getSpeedMetricsCollection,
getTokenMetrics
} from './utils/jsonl';
import { advanceGlobalPowerlineThemeIndex } from './utils/powerline-theme-index';
import {
buildConfigWarningBadge,
calculateMaxWidthsFromPreRendered,
countPowerlineStartCapSlots,
preRenderAllWidgets,
renderStatusLine
} from './utils/renderer';
import { advanceGlobalSeparatorIndex } from './utils/separator-index';
import { getSkillsMetrics } from './utils/skills';
import {
getWidgetSpeedWindowSeconds,
isWidgetSpeedWindowEnabled
} from './utils/speed-window';
import {
getPackageVersion,
getTerminalWidth
} from './utils/terminal';
import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch';
function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
const durationMs = data.cost?.total_duration_ms;
@@ -63,7 +93,7 @@ async function ensureWindowsUtf8CodePage() {
try {
const { execFileSync } = await import('child_process');
execFileSync('chcp.com', ['65001'], { stdio: 'ignore' });
execFileSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true });
} catch {
// Ignore failures to preserve statusline output even in restricted shells.
}
@@ -71,6 +101,7 @@ async function ensureWindowsUtf8CodePage() {
async function renderMultipleLines(data: StatusJSON) {
const settings = await loadSettings();
const configError = getConfigLoadError();
// Set global chalk level based on settings
chalk.level = settings.colorLevel;
@@ -84,6 +115,17 @@ async function renderMultipleLines(data: StatusJSON) {
// Check if session clock is needed
const hasSessionClock = lines.some(line => line.some(item => item.type === 'session-clock'));
const speedWidgetTypes = new Set(['output-speed', 'input-speed', 'total-speed']);
const hasSpeedItems = lines.some(line => line.some(item => speedWidgetTypes.has(item.type)));
const requestedSpeedWindows = new Set<number>();
for (const line of lines) {
for (const item of line) {
if (speedWidgetTypes.has(item.type) && isWidgetSpeedWindowEnabled(item)) {
requestedSpeedWindows.add(getWidgetSpeedWindowSeconds(item));
}
}
}
let tokenMetrics: TokenMetrics | null = null;
if (data.transcript_path) {
tokenMetrics = await getTokenMetrics(data.transcript_path);
@@ -94,12 +136,46 @@ async function renderMultipleLines(data: StatusJSON) {
sessionDuration = await getSessionDuration(data.transcript_path);
}
const usageData = await prefetchUsageDataIfNeeded(lines, data);
let speedMetrics: SpeedMetrics | null = null;
let windowedSpeedMetrics: Record<string, SpeedMetrics> | null = null;
if (hasSpeedItems && data.transcript_path) {
const speedMetricsCollection = await getSpeedMetricsCollection(data.transcript_path, {
includeSubagents: true,
windowSeconds: Array.from(requestedSpeedWindows)
});
speedMetrics = speedMetricsCollection.sessionAverage;
windowedSpeedMetrics = speedMetricsCollection.windowed;
}
let skillsMetrics: SkillsMetrics | null = null;
if (data.session_id) {
skillsMetrics = getSkillsMetrics(data.session_id);
}
// Compaction stats — parse compact_boundary markers in this session's transcript
const hasCompactionWidget = lines.some(line => line.some(item => item.type === 'compaction-counter'));
const compactionData = hasCompactionWidget
? (data.transcript_path ? await getCompactionStats(data.transcript_path) : ZERO_COMPACTION_STATS)
: null;
// Create render context
const context: RenderContext = {
data,
tokenMetrics,
speedMetrics,
windowedSpeedMetrics,
usageData,
sessionDuration,
isPreview: false
skillsMetrics,
compactionData,
terminalWidth: getTerminalWidth(),
isPreview: false,
minimalist: settings.minimalistMode,
gitCacheTtlSeconds: settings.gitCacheTtlSeconds,
gitReviewNeedsChecks: lines.some(line => line.some(item => item.type === 'git-ci-status'))
};
// Always pre-render all widgets once (for efficiency)
@@ -108,21 +184,31 @@ async function renderMultipleLines(data: StatusJSON) {
// Render each line using pre-rendered content
let globalSeparatorIndex = 0;
let globalPowerlineThemeIndex = 0;
let globalPowerlineStartCapIndex = 0;
let configBadgePrepended = false;
for (let i = 0; i < lines.length; i++) {
const lineItems = lines[i];
if (lineItems && lineItems.length > 0) {
const lineContext = { ...context, lineIndex: i, globalSeparatorIndex };
const preRenderedWidgets = preRenderedLines[i] ?? [];
const line = renderStatusLine(lineItems, settings, lineContext, preRenderedWidgets, preCalculatedMaxWidths);
const lineContext = {
...context,
lineIndex: i,
globalSeparatorIndex,
globalPowerlineThemeIndex,
globalPowerlineStartCapIndex
};
let line = renderStatusLine(lineItems, settings, lineContext, preRenderedWidgets, preCalculatedMaxWidths);
// Only output the line if it has content (not just ANSI codes)
// Strip ANSI codes to check if there's actual text
const strippedLine = getVisibleText(line).trim();
if (strippedLine.length > 0) {
// Count separators used in this line (widgets - 1, excluding merged widgets)
const nonMergedWidgets = lineItems.filter((_, idx) => idx === lineItems.length - 1 || !lineItems[idx]?.merge);
if (nonMergedWidgets.length > 1)
globalSeparatorIndex += nonMergedWidgets.length - 1;
if (configError && !configBadgePrepended) {
// On the error path settings are always inMemoryDefaults(), whose separators render as ' | '.
line = `${buildConfigWarningBadge(settings.colorLevel)} | ${line}`;
configBadgePrepended = true;
}
// Replace all spaces with non-breaking spaces to prevent VSCode trimming
let outputLine = line.replace(/ /g, '\u00A0');
@@ -130,10 +216,23 @@ async function renderMultipleLines(data: StatusJSON) {
// Add reset code at the beginning to override Claude Code's dim setting
outputLine = '\x1b[0m' + outputLine;
console.log(outputLine);
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems, preRenderedWidgets);
if (settings.powerline.enabled) {
globalPowerlineStartCapIndex += countPowerlineStartCapSlots(lineItems, preRenderedWidgets);
}
if (settings.powerline.enabled && settings.powerline.continueThemeAcrossLines) {
globalPowerlineThemeIndex = advanceGlobalPowerlineThemeIndex(globalPowerlineThemeIndex, preRenderedWidgets);
}
}
}
}
// Defensive fallback: if no content line was emitted, ensure the warning is not lost
if (configError && !configBadgePrepended) {
console.log('\x1b[0m' + buildConfigWarningBadge(settings.colorLevel).replace(/ /g, '\u00A0'));
}
// Check if there's an update message to display
if (settings.updatemessage?.message
&& settings.updatemessage.message.trim() !== ''
@@ -164,7 +263,63 @@ async function renderMultipleLines(data: StatusJSON) {
}
}
function parseConfigArg(): string | undefined {
const idx = process.argv.indexOf('--config');
if (idx === -1)
return undefined;
const configPath = process.argv[idx + 1];
if (!configPath || configPath.startsWith('--')) {
console.error('--config requires a file path argument');
process.exit(1);
}
process.argv.splice(idx, 2);
return configPath;
}
async function handleHook(): Promise<void> {
const input = await readStdin();
handleHookInput(input);
}
function handleGitReviewRefresh(): boolean {
const flagIndex = process.argv.indexOf(GIT_REVIEW_REFRESH_FLAG);
if (flagIndex === -1) {
return false;
}
const cwd = process.argv[flagIndex + 1];
const mode = process.argv[flagIndex + 2];
const lockPath = process.argv[flagIndex + 3];
if (!cwd || (mode !== 'metadata' && mode !== 'checks') || !lockPath) {
return true;
}
refreshGitReviewCacheFromCli(cwd, { includeChecks: mode === 'checks' }, lockPath);
return true;
}
async function main() {
// Detached cache refreshes re-enter this executable without reading stdin
// or loading user settings. This mode intentionally emits no output.
if (handleGitReviewRefresh()) {
return;
}
// Print version and exit (#461). Standard CLI behavior, runs before any other mode.
if (process.argv.includes('--version')) {
console.log(getPackageVersion());
process.exit(0);
}
// Parse --config before anything else
initConfigPath(parseConfigArg());
// Handle --hook mode (cross-platform hook handler for widgets)
if (process.argv.includes('--hook')) {
await handleHook();
return;
}
// Check if we're in a piped/non-TTY environment first
if (!process.stdin.isTTY) {
await ensureWindowsUtf8CodePage();
@@ -202,4 +357,4 @@ async function main() {
}
}
void main();
void main();
+977 -119
View File
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
import {
describe,
expect,
it,
vi
} from 'vitest';
import {
DEFAULT_SETTINGS,
type InstallationMetadata
} from '../../types/Settings';
import {
buildConfigLoadWarning,
buildInvalidConfigSaveConfirm,
clearInstallMenuSelection,
getConfirmCancelScreen,
getCurrentInstallation,
getPathInferredInstallation,
getPinnedVersionMismatch
} from '../App';
import {
buildMainMenuItems,
getMainMenuInstallSelectionIndex,
getMainMenuSelectionIndex
} from '../components/MainMenu';
import { buildManageInstallationItems } from '../components/ManageInstallationMenu';
function getMenuValues(
isClaudeInstalled: boolean,
hasChanges: boolean,
installation?: InstallationMetadata
): string[] {
return buildMainMenuItems(isClaudeInstalled, hasChanges, installation)
.map(item => item === '-' ? '-' : item.value);
}
describe('App confirm navigation helpers', () => {
it('defaults confirmation cancel navigation to the main menu', () => {
expect(getConfirmCancelScreen(null)).toBe('main');
expect(getConfirmCancelScreen({
message: 'Confirm install?',
action: () => Promise.resolve()
})).toBe('main');
});
it('returns to the install menu when the confirm dialog requests it', () => {
expect(getConfirmCancelScreen({
message: 'Confirm install?',
action: () => Promise.resolve(),
cancelScreen: 'install'
})).toBe('install');
});
it('clears saved install selection when leaving the install menu', () => {
expect(clearInstallMenuSelection({
main: 5,
install: 1,
installPackage: 1
})).toEqual({ main: 5 });
const menuSelections = { main: 5 };
expect(clearInstallMenuSelection(menuSelections)).toBe(menuSelections);
});
});
describe('Pinned version mismatch guard', () => {
it('uses saved pinned metadata while Claude status line is still loading', () => {
const installation: InstallationMetadata = {
method: 'pinned',
installedVersion: '2.2.13'
};
expect(getCurrentInstallation(true, null, {
...DEFAULT_SETTINGS,
installation
})).toEqual(installation);
});
it('does not block auto-update or matching pinned installs', () => {
expect(getPinnedVersionMismatch({
method: 'auto-update',
packageManager: 'bun'
}, '2.3.0', 'ccstatusline')).toBeNull();
expect(getPinnedVersionMismatch({
method: 'pinned',
packageManager: 'npm',
installedVersion: '2.3.0'
}, '2.3.0', 'ccstatusline')).toBeNull();
});
it('blocks when the running TUI is newer than the pinned global install', () => {
expect(getPinnedVersionMismatch({
method: 'pinned',
packageManager: 'bun',
installedVersion: '2.2.13'
}, '2.3.0', '/home/alice/.bun/bin/ccstatusline')).toEqual({
packageManager: 'bun',
installedVersion: '2.2.13',
runningVersion: '2.3.0',
relaunchCommand: '/home/alice/.bun/bin/ccstatusline',
canUpdateToRunningVersion: true
});
});
it('blocks without an update action when the running TUI is older than the pinned global install', () => {
expect(getPinnedVersionMismatch({
method: 'pinned',
packageManager: 'npm',
installedVersion: '2.3.0'
}, '2.2.13', '/usr/local/bin/ccstatusline')).toEqual({
packageManager: 'npm',
installedVersion: '2.3.0',
runningVersion: '2.2.13',
relaunchCommand: '/usr/local/bin/ccstatusline',
canUpdateToRunningVersion: false
});
});
it('infers pinned package manager from the active PATH match', () => {
expect(getPathInferredInstallation({
method: 'pinned',
installedVersion: '2.2.13'
}, {
packageManager: 'bun',
resolvedPath: '/Users/alice/.bun/bin/ccstatusline',
resolvedPaths: [
'/Users/alice/.bun/bin/ccstatusline',
'/Users/alice/.nvm/versions/node/v24.9.0/bin/ccstatusline'
],
binDir: '/Users/alice/.bun/bin',
version: null,
warning: null
})).toEqual({
method: 'pinned',
packageManager: 'bun',
installedVersion: '2.2.13'
});
});
it('uses the active PATH match version when available', () => {
expect(getPathInferredInstallation({
method: 'pinned',
installedVersion: '2.2.13'
}, {
packageManager: 'bun',
resolvedPath: '/Users/alice/.bun/bin/ccstatusline',
resolvedPaths: ['/Users/alice/.bun/bin/ccstatusline'],
binDir: '/Users/alice/.bun/bin',
version: '2.2.13',
warning: null
})).toEqual({
method: 'pinned',
packageManager: 'bun',
installedVersion: '2.2.13'
});
});
});
describe('Main menu structure', () => {
it('groups configure status line with terminal/global options when auto-update installed', () => {
expect(getMenuValues(true, false, {
method: 'auto-update',
packageManager: 'npm'
})).toEqual([
'lines',
'colors',
'powerline',
'-',
'terminalConfig',
'globalOverrides',
'configureStatusLine',
'-',
'install',
'-',
'exit',
'-',
'starGithub'
]);
});
it('keeps install in its own section when not installed', () => {
expect(getMenuValues(false, false)).toEqual([
'lines',
'colors',
'powerline',
'-',
'terminalConfig',
'globalOverrides',
'configureStatusLine',
'-',
'install',
'-',
'exit',
'-',
'starGithub'
]);
});
it('uses manage installation for pinned installs', () => {
const installation: InstallationMetadata = {
method: 'pinned',
installedVersion: '2.2.13'
};
expect(getMenuValues(true, false, installation)).toEqual([
'lines',
'colors',
'powerline',
'-',
'terminalConfig',
'globalOverrides',
'configureStatusLine',
'-',
'manageInstallation',
'-',
'exit',
'-',
'starGithub'
]);
const manageItem = buildMainMenuItems(true, false, installation)
.find(item => item !== '-' && item.value === 'manageInstallation');
expect(manageItem).toEqual(expect.objectContaining({ label: '🧰 Manage Installation' }));
});
it('uses a consistent update icon in manage installation and computes install selection indices', () => {
const configureItem = buildMainMenuItems(false, false)
.find(item => item !== '-' && item.value === 'configureStatusLine');
const autoInstallation: InstallationMetadata = {
method: 'auto-update',
packageManager: 'npm'
};
const pinnedInstallation: InstallationMetadata = {
method: 'pinned',
installedVersion: '2.2.13'
};
expect(configureItem).toEqual(expect.objectContaining({
disabled: true,
sublabel: '(install first)'
}));
expect(buildManageInstallationItems()[0]).toEqual(expect.objectContaining({ label: '🔄 Check for Updates' }));
expect(getMainMenuInstallSelectionIndex(false)).toBe(5);
expect(getMainMenuInstallSelectionIndex(true, autoInstallation)).toBe(6);
expect(getMainMenuInstallSelectionIndex(true, pinnedInstallation)).toBe(6);
expect(getMainMenuSelectionIndex(buildMainMenuItems(true, false, autoInstallation), 'install')).toBe(6);
expect(getMainMenuSelectionIndex(
buildMainMenuItems(true, false, pinnedInstallation),
'manageInstallation'
)).toBe(6);
});
});
describe('Invalid-config TUI guards', () => {
it('returns null when there is no config load error', () => {
expect(buildConfigLoadWarning(null)).toBeNull();
expect(buildInvalidConfigSaveConfirm(null, vi.fn())).toBeNull();
});
it('builds a banner that names the reason and warns about overwriting', () => {
const warning = buildConfigLoadWarning('settings.json is not valid JSON');
expect(warning).toContain('settings.json is not valid JSON');
expect(warning).toContain('overwrites the file');
});
it('builds a save-guard confirm dialog that returns to main on cancel', () => {
const guard = buildInvalidConfigSaveConfirm('settings.json could not be read', vi.fn());
expect(guard).not.toBeNull();
expect(guard?.cancelScreen).toBe('main');
expect(guard?.message).toContain('preserved');
expect(guard?.message).toContain('could not be read');
});
it('invokes the provided onConfirm when the guard action runs', async () => {
const onConfirm = vi.fn();
const guard = buildInvalidConfigSaveConfirm('settings.json is not valid JSON', onConfirm);
await guard?.action();
expect(onConfirm).toHaveBeenCalledOnce();
});
it('reflects the specific load-error reason in the save-guard message', () => {
expect(buildInvalidConfigSaveConfirm('settings.json is not valid JSON', vi.fn())?.message)
.toContain('settings.json is not valid JSON');
expect(buildInvalidConfigSaveConfirm('settings.json is not in a valid format', vi.fn())?.message)
.toContain('not in a valid format');
});
});
+69
View File
@@ -0,0 +1,69 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';
import { saveClaudeSettings } from '../../utils/claude-settings';
import { loadClaudeStatusLineState } from '../claude-status';
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
let testClaudeConfigDir = '';
beforeEach(() => {
testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-claude-status-'));
process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir;
});
afterEach(() => {
if (testClaudeConfigDir) {
fs.rmSync(testClaudeConfigDir, { recursive: true, force: true });
}
});
afterAll(() => {
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
}
});
describe('loadClaudeStatusLineState', () => {
it('loads both the installed command and refresh interval from Claude settings', async () => {
await saveClaudeSettings({
statusLine: {
type: 'command',
command: 'npx -y ccstatusline@latest',
padding: 0,
refreshInterval: 10
}
});
await expect(loadClaudeStatusLineState()).resolves.toEqual({
existingStatusLine: 'npx -y ccstatusline@latest',
refreshInterval: 10
});
});
it('returns null refreshInterval when Claude settings do not define one', async () => {
await saveClaudeSettings({
statusLine: {
type: 'command',
command: 'npx -y ccstatusline@latest',
padding: 0
}
});
await expect(loadClaudeStatusLineState()).resolves.toEqual({
existingStatusLine: 'npx -y ccstatusline@latest',
refreshInterval: null
});
});
});
+24
View File
@@ -0,0 +1,24 @@
import {
getExistingStatusLine,
getRefreshInterval
} from '../utils/claude-settings';
export interface ClaudeStatusLineState {
existingStatusLine: string | null;
refreshInterval: number | null;
}
export async function loadClaudeStatusLineState(): Promise<ClaudeStatusLineState> {
const [
existingStatusLine,
refreshInterval
] = await Promise.all([
getExistingStatusLine(),
getRefreshInterval()
]);
return {
existingStatusLine,
refreshInterval
};
}
+182 -99
View File
@@ -15,10 +15,19 @@ import {
getAvailableBackgroundColorsForUI,
getAvailableColorsForUI
} from '../../utils/colors';
import { GRADIENT_PRESET_NAMES } from '../../utils/gradient';
import { shouldInsertInput } from '../../utils/input-guards';
import { getWidget } from '../../utils/widgets';
import { ConfirmDialog } from './ConfirmDialog';
import {
clearAllWidgetStyling,
cycleWidgetColor,
cycleWidgetDim,
resetWidgetStyling,
setWidgetColor,
toggleWidgetBold
} from './color-menu/mutations';
export interface ColorMenuProps {
widgets: WidgetItem[];
@@ -35,6 +44,11 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
const [ansi256InputMode, setAnsi256InputMode] = useState(false);
const [ansi256Input, setAnsi256Input] = useState('');
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [gradientMode, setGradientMode] = useState(false);
const [gradientIndex, setGradientIndex] = useState(0);
const [gradientCustomStep, setGradientCustomStep] = useState<'start' | 'end' | null>(null);
const [gradientStartHex, setGradientStartHex] = useState('');
const [gradientHexInput, setGradientHexInput] = useState('');
const powerlineEnabled = settings.powerline.enabled;
@@ -48,7 +62,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
// Include unknown widgets (they might support colors, we just don't know)
return widgetInstance ? widgetInstance.supportsColors(widget) : true;
});
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(colorableWidgets[0]?.id ?? null);
const [highlightedItemId, setHighlightedItemId] = useState(colorableWidgets[0]?.id ?? null);
const [editingBackground, setEditingBackground] = useState(false);
// Handle keyboard input
@@ -80,17 +94,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
const hexColor = `hex:${hexInput}`;
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
// IMPORTANT: Update ALL items (not just colorableWidgets) to maintain proper indexing
const newItems = widgets.map((widget) => {
if (widget.id === highlightedItemId) {
if (editingBackground) {
return { ...widget, backgroundColor: hexColor };
} else {
return { ...widget, color: hexColor };
}
}
return widget;
});
const newItems = setWidgetColor(widgets, selectedWidget.id, hexColor, editingBackground);
onUpdate(newItems);
}
setHexInputMode(false);
@@ -126,17 +130,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
// IMPORTANT: Update ALL items (not just colorableWidgets) to maintain proper indexing
const newItems = widgets.map((widget) => {
if (widget.id === highlightedItemId) {
if (editingBackground) {
return { ...widget, backgroundColor: ansiColor };
} else {
return { ...widget, color: ansiColor };
}
}
return widget;
});
const newItems = setWidgetColor(widgets, selectedWidget.id, ansiColor, editingBackground);
onUpdate(newItems);
setAnsi256InputMode(false);
@@ -159,6 +153,69 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
return;
}
// Handle gradient selection mode
if (gradientMode) {
const exitGradient = () => {
setGradientMode(false);
setGradientCustomStep(null);
setGradientStartHex('');
setGradientHexInput('');
};
const applyGradientValue = (value: string) => {
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
onUpdate(setWidgetColor(widgets, selectedWidget.id, value, false));
}
exitGradient();
};
// Custom start/end hex entry
if (gradientCustomStep) {
if (key.escape) {
setGradientCustomStep(null);
setGradientHexInput('');
} else if (key.return) {
if (gradientHexInput.length === 6) {
if (gradientCustomStep === 'start') {
setGradientStartHex(gradientHexInput);
setGradientHexInput('');
setGradientCustomStep('end');
} else {
applyGradientValue(`gradient:${gradientStartHex}-${gradientHexInput}`);
}
}
} else if (key.backspace || key.delete) {
setGradientHexInput(gradientHexInput.slice(0, -1));
} else if (shouldInsertInput(input, key) && gradientHexInput.length < 6) {
const upperInput = input.toUpperCase();
if (/^[0-9A-F]$/.test(upperInput)) {
setGradientHexInput(gradientHexInput + upperInput);
}
}
return;
}
// Preset list navigation (last item is the "Custom" entry)
const total = GRADIENT_PRESET_NAMES.length + 1;
if (key.escape) {
exitGradient();
} else if (key.upArrow) {
setGradientIndex((gradientIndex - 1 + total) % total);
} else if (key.downArrow) {
setGradientIndex((gradientIndex + 1) % total);
} else if (key.return) {
if (gradientIndex < GRADIENT_PRESET_NAMES.length) {
applyGradientValue(`gradient:${GRADIENT_PRESET_NAMES[gradientIndex]}`);
} else {
setGradientStartHex('');
setGradientHexInput('');
setGradientCustomStep('start');
}
}
return;
}
// Ignore number keys to prevent SelectInput numerical navigation
if (input && /^[0-9]$/.test(input)) {
return;
@@ -183,6 +240,15 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
setAnsi256InputMode(true);
setAnsi256Input('');
}
} else if (input === 'g' || input === 'G') {
// Enter gradient selection mode (foreground only, needs a real color palette)
if (highlightedItemId && highlightedItemId !== 'back' && !editingBackground && settings.colorLevel >= 2) {
setGradientMode(true);
setGradientIndex(0);
setGradientCustomStep(null);
setGradientStartHex('');
setGradientHexInput('');
}
} else if ((input === 's' || input === 'S') && !key.ctrl) {
// Toggle show separators (only if not in powerline mode and no default separator)
if (!settings.powerline.enabled && !settings.defaultSeparator) {
@@ -199,12 +265,16 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
// Toggle bold for the highlighted item
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
const newItems = widgets.map((widget) => {
if (widget.id === selectedWidget.id) {
return { ...widget, bold: !widget.bold };
}
return widget;
});
const newItems = toggleWidgetBold(widgets, selectedWidget.id);
onUpdate(newItems);
}
}
} else if (input === 'd' || input === 'D') {
if (highlightedItemId && highlightedItemId !== 'back') {
// Cycle dim for the highlighted item: off -> whole -> parens -> off
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
const newItems = cycleWidgetDim(widgets, selectedWidget.id);
onUpdate(newItems);
}
}
@@ -213,17 +283,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
// Reset all styling (color, background, and bold) for the highlighted item
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
const newItems = widgets.map((widget) => {
if (widget.id === selectedWidget.id) {
// Remove color, backgroundColor, and bold properties
const { color, backgroundColor, bold, ...restWidget } = widget;
void color; // Intentionally unused
void backgroundColor; // Intentionally unused
void bold; // Intentionally unused
return restWidget;
}
return widget;
});
const newItems = resetWidgetStyling(widgets, selectedWidget.id);
onUpdate(newItems);
}
}
@@ -235,52 +295,13 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
if (highlightedItemId && highlightedItemId !== 'back') {
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
const newItems = widgets.map((widget) => {
if (widget.id === selectedWidget.id) {
if (editingBackground) {
const currentBgColor = widget.backgroundColor ?? ''; // Empty string for 'none'
let currentBgColorIndex = bgColors.indexOf(currentBgColor);
// If color not found, start from beginning
if (currentBgColorIndex === -1)
currentBgColorIndex = 0;
let nextBgColorIndex;
if (key.rightArrow) {
nextBgColorIndex = (currentBgColorIndex + 1) % bgColors.length;
} else {
nextBgColorIndex = currentBgColorIndex === 0 ? bgColors.length - 1 : currentBgColorIndex - 1;
}
const nextBgColor = bgColors[nextBgColorIndex];
return { ...widget, backgroundColor: nextBgColor === '' ? undefined : nextBgColor };
} else {
let defaultColor = 'white';
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
if (widgetImpl) {
defaultColor = widgetImpl.getDefaultColor();
}
}
let currentColor = widget.color ?? defaultColor;
// If color is 'dim', treat as if no color was set
if (currentColor === 'dim') {
currentColor = defaultColor;
}
let currentColorIndex = colors.indexOf(currentColor);
// If color not found, start from beginning
if (currentColorIndex === -1)
currentColorIndex = 0;
let nextColorIndex;
if (key.rightArrow) {
nextColorIndex = (currentColorIndex + 1) % colors.length;
} else {
nextColorIndex = currentColorIndex === 0 ? colors.length - 1 : currentColorIndex - 1;
}
const nextColor = colors[nextColorIndex];
return { ...widget, color: nextColor };
}
}
return widget;
const newItems = cycleWidgetColor({
widgets,
widgetId: selectedWidget.id,
direction: key.rightArrow ? 'right' : 'left',
editingBackground,
colors,
backgroundColors: bgColors
});
onUpdate(newItems);
}
@@ -336,7 +357,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
defaultColor = widgetImpl.getDefaultColor();
}
}
const styledLabel = applyColors(label, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level);
const styledLabel = applyColors(label, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level, widget.dim);
return {
label: styledLabel,
value: widget.id
@@ -403,6 +424,13 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
displayName = `ANSI ${currentColor.substring(8)}`;
} else if (currentColor.startsWith('hex:')) {
displayName = `#${currentColor.substring(4)}`;
} else if (currentColor.startsWith('gradient:')) {
const body = currentColor.substring(9);
if (GRADIENT_PRESET_NAMES.includes(body.toLowerCase())) {
displayName = `Gradient: ${body.toLowerCase()}`;
} else {
displayName = `Gradient: ${body}`;
}
} else {
const colorOption = colorOptions.find(c => c.value === currentColor);
displayName = colorOption ? colorOption.name : currentColor;
@@ -413,6 +441,68 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
colorDisplay = applyColors(displayName, currentColor, undefined, false, level);
}
}
const styleIndicators = [
selectedWidget?.bold ? '[BOLD]' : null,
selectedWidget?.dim === true ? '[DIM]' : null,
selectedWidget?.dim === 'parens' ? '[DIM ()]' : null
].filter(indicator => indicator !== null).join(' ');
// Gradient selection mode takes over the whole view
if (gradientMode) {
const level = getColorLevelString(settings.colorLevel);
const widgetName = selectedWidget ? getItemLabel(selectedWidget) : '';
if (gradientCustomStep) {
return (
<Box flexDirection='column'>
<Text bold>
Custom Gradient
{widgetName ? ` - ${widgetName}` : ''}
</Text>
<Box marginTop={1} flexDirection='column'>
<Text>{gradientCustomStep === 'start' ? 'Enter START hex color (without #):' : 'Enter END hex color (without #):'}</Text>
{gradientCustomStep === 'end' && (
<Text dimColor>
Start: #
{gradientStartHex}
</Text>
)}
<Text>
#
{gradientHexInput}
<Text dimColor>{gradientHexInput.length < 6 ? '_'.repeat(6 - gradientHexInput.length) : ''}</Text>
</Text>
<Text> </Text>
<Text dimColor>Press Enter when done, ESC to go back</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection='column'>
<Text bold>
Select Gradient
{widgetName ? ` - ${widgetName}` : ''}
</Text>
<Box marginTop={1}>
<Text dimColor> to select, Enter to apply, ESC to cancel</Text>
</Box>
<Box marginTop={1} flexDirection='column'>
{GRADIENT_PRESET_NAMES.map((name, idx) => (
<Text key={name}>
{idx === gradientIndex ? '▶ ' : ' '}
{applyColors(name, `gradient:${name}`, undefined, idx === gradientIndex, level)}
</Text>
))}
<Text key='custom'>
{gradientIndex === GRADIENT_PRESET_NAMES.length ? '▶ ' : ' '}
Custom (enter two hex stops)
</Text>
</Box>
</Box>
);
}
// Show confirmation dialog if clearing all colors
if (showClearConfirm) {
@@ -430,15 +520,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
<ConfirmDialog
inline={true}
onConfirm={() => {
// Clear all colors from all widgets
const newItems = widgets.map((widget) => {
// Remove color, backgroundColor, and bold properties
const { color, backgroundColor, bold, ...restWidget } = widget;
void color; // Intentionally unused
void backgroundColor; // Intentionally unused
void bold; // Intentionally unused
return restWidget;
});
const newItems = clearAllWidgetStyling(widgets);
onUpdate(newItems);
setShowClearConfirm(false);
}}
@@ -506,8 +588,9 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
to select, to cycle
{' '}
{editingBackground ? 'background' : 'foreground'}
, (f) to toggle bg/fg, (b)old,
, (f) to toggle bg/fg, (b)old, (d)im,
{settings.colorLevel === 3 ? ' (h)ex,' : settings.colorLevel === 2 ? ' (a)nsi256,' : ''}
{!editingBackground && settings.colorLevel >= 2 ? ' (g)radient,' : ''}
{' '}
(r)eset, (c)lear all, ESC to go back
</Text>
@@ -529,7 +612,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
):
{' '}
{colorDisplay}
{selectedWidget.bold && chalk.bold(' [BOLD]')}
{styleIndicators && ` ${styleIndicators}`}
</Text>
</Box>
) : (
@@ -579,4 +662,4 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
</Box>
</Box>
);
};
};
+46 -36
View File
@@ -3,7 +3,12 @@ import {
Text,
useInput
} from 'ink';
import React, { useState } from 'react';
import React from 'react';
import {
List,
type ListEntry
} from './List';
export interface ConfirmDialogProps {
message?: string;
@@ -12,53 +17,58 @@ export interface ConfirmDialogProps {
inline?: boolean;
}
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ message, onConfirm, onCancel, inline = false }) => {
const [selectedIndex, setSelectedIndex] = useState(0); // Default to "Yes"
const CONFIRM_OPTIONS: ListEntry<boolean>[] = [
{
label: 'Yes',
value: true
},
{
label: 'No',
value: false
}
];
useInput((input, key) => {
if (key.upArrow) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow) {
setSelectedIndex(Math.min(1, selectedIndex + 1));
} else if (key.return) {
if (selectedIndex === 0) {
onConfirm();
} else {
onCancel();
}
} else if (key.escape) {
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ message, onConfirm, onCancel, inline = false }) => {
useInput((_, key) => {
if (key.escape) {
onCancel();
}
});
const renderOptions = () => {
const yesStyle = selectedIndex === 0 ? { color: 'cyan' } : {};
const noStyle = selectedIndex === 1 ? { color: 'cyan' } : {};
return (
<Box flexDirection='column'>
<Text {...yesStyle}>
{selectedIndex === 0 ? '▶ ' : ' '}
Yes
</Text>
<Text {...noStyle}>
{selectedIndex === 1 ? '▶ ' : ' '}
No
</Text>
</Box>
);
};
if (inline) {
return renderOptions();
return (
<List
items={CONFIRM_OPTIONS}
onSelect={(confirmed) => {
if (confirmed) {
onConfirm();
return;
}
onCancel();
}}
color='cyan'
/>
);
}
return (
<Box flexDirection='column'>
<Text>{message}</Text>
<Box marginTop={1}>
{renderOptions()}
<List
items={CONFIRM_OPTIONS}
onSelect={(confirmed) => {
if (confirmed) {
onConfirm();
return;
}
onCancel();
}}
color='cyan'
/>
</Box>
</Box>
);
};
};
+173 -4
View File
@@ -5,12 +5,18 @@ import {
} from 'ink';
import React, { useState } from 'react';
import type { Settings } from '../../types/Settings';
import { getColorLevelString } from '../../types/ColorLevel';
import {
DefaultPaddingSideSchema,
type Settings
} from '../../types/Settings';
import {
COLOR_MAP,
applyColors,
getChalkColor,
getColorDisplayName
} from '../../utils/colors';
import { GRADIENT_PRESET_NAMES } from '../../utils/gradient';
import { shouldInsertInput } from '../../utils/input-guards';
import { ConfirmDialog } from './ConfirmDialog';
@@ -29,6 +35,12 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
const [separatorInput, setSeparatorInput] = useState(settings.defaultSeparator ?? '');
const [inheritColors, setInheritColors] = useState(settings.inheritSeparatorColors);
const [globalBold, setGlobalBold] = useState(settings.globalBold);
const [minimalistMode, setMinimalistMode] = useState(settings.minimalistMode);
const [gradientMode, setGradientMode] = useState(false);
const [gradientIndex, setGradientIndex] = useState(0);
const [gradientCustomStep, setGradientCustomStep] = useState<'start' | 'end' | null>(null);
const [gradientStartHex, setGradientStartHex] = useState('');
const [gradientHexInput, setGradientHexInput] = useState('');
const isPowerlineEnabled = settings.powerline.enabled;
// Check if there are any manual separators in the current configuration
@@ -93,6 +105,63 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
} else if (confirmingSeparator) {
// Skip input handling when confirmation is active - let ConfirmDialog handle it
return;
} else if (gradientMode) {
const exitGradient = () => {
setGradientMode(false);
setGradientCustomStep(null);
setGradientStartHex('');
setGradientHexInput('');
};
const applyGradientValue = (value: string) => {
onUpdate({
...settings,
overrideForegroundColor: value
});
exitGradient();
};
if (gradientCustomStep) {
if (key.escape) {
setGradientCustomStep(null);
setGradientHexInput('');
} else if (key.return) {
if (gradientHexInput.length === 6) {
if (gradientCustomStep === 'start') {
setGradientStartHex(gradientHexInput);
setGradientHexInput('');
setGradientCustomStep('end');
} else {
applyGradientValue(`gradient:${gradientStartHex}-${gradientHexInput}`);
}
}
} else if (key.backspace || key.delete) {
setGradientHexInput(gradientHexInput.slice(0, -1));
} else if (shouldInsertInput(input, key) && gradientHexInput.length < 6) {
const upperInput = input.toUpperCase();
if (/^[0-9A-F]$/.test(upperInput)) {
setGradientHexInput(gradientHexInput + upperInput);
}
}
return;
}
const total = GRADIENT_PRESET_NAMES.length + 1;
if (key.escape) {
exitGradient();
} else if (key.upArrow) {
setGradientIndex((gradientIndex - 1 + total) % total);
} else if (key.downArrow) {
setGradientIndex((gradientIndex + 1) % total);
} else if (key.return) {
if (gradientIndex < GRADIENT_PRESET_NAMES.length) {
applyGradientValue(`gradient:${GRADIENT_PRESET_NAMES[gradientIndex]}`);
} else {
setGradientStartHex('');
setGradientHexInput('');
setGradientCustomStep('start');
}
}
} else {
if (key.escape) {
onBack();
@@ -133,6 +202,15 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
globalBold: newGlobalBold
};
onUpdate(updatedSettings);
} else if (input === 'm' || input === 'M') {
// Toggle minimalist mode
const newMinimalistMode = !minimalistMode;
setMinimalistMode(newMinimalistMode);
const updatedSettings = {
...settings,
minimalistMode: newMinimalistMode
};
onUpdate(updatedSettings);
} else if (input === 'f' || input === 'F') {
// Cycle through foreground colors
const nextIndex = (currentFgIndex + 1) % fgColors.length;
@@ -143,16 +221,82 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
};
onUpdate(updatedSettings);
} else if (input === 'g' || input === 'G') {
// Enter gradient selection mode
setGradientMode(true);
setGradientIndex(0);
setGradientCustomStep(null);
setGradientStartHex('');
setGradientHexInput('');
} else if (input === 'x' || input === 'X') {
// Clear override foreground color
const updatedSettings = {
...settings,
overrideForegroundColor: undefined
};
onUpdate(updatedSettings);
} else if (input === 'd' || input === 'D') {
// Cycle through padding sides: both -> left -> right -> both
const paddingSides = DefaultPaddingSideSchema.options;
const currentIndex = paddingSides.indexOf(settings.defaultPaddingSide);
const nextSide = paddingSides[(currentIndex + 1) % paddingSides.length] ?? 'both';
const updatedSettings = {
...settings,
defaultPaddingSide: nextSide
};
onUpdate(updatedSettings);
}
}
});
if (gradientMode) {
const level = getColorLevelString(settings.colorLevel);
if (gradientCustomStep) {
return (
<Box flexDirection='column'>
<Text bold>Custom Gradient - Override FG Color</Text>
<Box marginTop={1} flexDirection='column'>
<Text>{gradientCustomStep === 'start' ? 'Enter START hex color (without #):' : 'Enter END hex color (without #):'}</Text>
{gradientCustomStep === 'end' && (
<Text dimColor>
Start: #
{gradientStartHex}
</Text>
)}
<Text>
#
{gradientHexInput}
<Text dimColor>{gradientHexInput.length < 6 ? '_'.repeat(6 - gradientHexInput.length) : ''}</Text>
</Text>
<Text> </Text>
<Text dimColor>Press Enter when done, ESC to go back</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection='column'>
<Text bold>Select Gradient - Override FG Color</Text>
<Box marginTop={1}>
<Text dimColor> to select, Enter to apply, ESC to cancel</Text>
</Box>
<Box marginTop={1} flexDirection='column'>
{GRADIENT_PRESET_NAMES.map((name, idx) => (
<Text key={name}>
{idx === gradientIndex ? '▶ ' : ' '}
{applyColors(name, `gradient:${name}`, undefined, idx === gradientIndex, level)}
</Text>
))}
<Text key='custom'>
{gradientIndex === GRADIENT_PRESET_NAMES.length ? '▶ ' : ' '}
Custom (enter two hex stops)
</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection='column'>
<Text bold>Global Overrides</Text>
@@ -167,7 +311,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
{editingPadding ? (
<Box flexDirection='column'>
<Box>
<Text>Enter default padding (applied to left and right of each widget): </Text>
<Text>Enter default padding (applied per the Padding Side setting): </Text>
<Text color='cyan'>{paddingInput ? `"${paddingInput}"` : '(empty)'}</Text>
</Box>
<Text dimColor>Press Enter to save, ESC to cancel</Text>
@@ -222,18 +366,37 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
<Text dimColor> - Press (o) to toggle</Text>
</Box>
<Box>
<Text> Minimalist Mode: </Text>
<Text color={minimalistMode ? 'green' : 'red'}>{minimalistMode ? '✓ Enabled' : '✗ Disabled'}</Text>
<Text dimColor> - Press (m) to toggle</Text>
</Box>
<Box>
<Text> Default Padding: </Text>
<Text color='cyan'>{settings.defaultPadding ? `"${settings.defaultPadding}"` : '(none)'}</Text>
<Text dimColor> - Press (p) to edit</Text>
</Box>
<Box>
<Text> Padding Side: </Text>
<Text color='cyan'>{settings.defaultPaddingSide === 'left' ? 'Left only' : settings.defaultPaddingSide === 'right' ? 'Right only' : 'Both'}</Text>
<Text dimColor> - Press (d) to cycle</Text>
</Box>
<Box>
<Text>Override FG Color: </Text>
{(() => {
const fgColor = settings.overrideForegroundColor ?? 'none';
if (fgColor === 'none') {
return <Text color='gray'>(none)</Text>;
} else if (fgColor.startsWith('gradient:')) {
const body = fgColor.substring(9);
const displayName = GRADIENT_PRESET_NAMES.includes(body.toLowerCase())
? `Gradient: ${body.toLowerCase()}`
: `Gradient: ${body}`;
const level = getColorLevelString(settings.colorLevel);
return <Text>{applyColors(displayName, fgColor, undefined, false, level)}</Text>;
} else {
const displayName = getColorDisplayName(fgColor);
const fgChalk = getChalkColor(fgColor, 'ansi16', false);
@@ -241,7 +404,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
return <Text>{display}</Text>;
}
})()}
<Text dimColor> - (f) cycle, (g) clear</Text>
<Text dimColor> - (f) cycle, (g) gradient, (x) clear</Text>
</Box>
<Box>
@@ -298,12 +461,18 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
<Text dimColor wrap='wrap'>
Note: These settings are applied during rendering and don't add widgets to your widget list.
</Text>
<Text dimColor wrap='wrap'>
• Padding Side: Choose whether default padding applies to both sides, left only, or right only
</Text>
<Text dimColor wrap='wrap'>
• Inherit colors: Separators will use colors from the preceding widget
</Text>
<Text dimColor wrap='wrap'>
• Global Bold: Makes all text bold regardless of individual settings
</Text>
<Text dimColor wrap='wrap'>
• Minimalist Mode: Strips decorative prefixes and labels from widgets
</Text>
<Text dimColor wrap='wrap'>
Override colors: All widgets will use these colors instead of their configured colors
</Text>
@@ -312,4 +481,4 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
)}
</Box>
);
};
};
+187 -59
View File
@@ -5,51 +5,158 @@ import {
} from 'ink';
import React, { useState } from 'react';
import { getClaudeSettingsPath } from '../../utils/claude-settings';
import type { InstallationMetadata } from '../../types/Settings';
import {
CCSTATUSLINE_COMMANDS,
PINNED_INSTALL_COMMANDS,
getClaudeSettingsPath,
type PackageCommandAvailability,
type StatusLineCommandMode
} from '../../utils/claude-settings';
import {
List,
type ListEntry
} from './List';
export type InstallUpdateStyle = 'auto-update' | 'pinned';
export type InstallPackageManager = 'npm' | 'bun';
export interface InstallSelection {
updateStyle: InstallUpdateStyle;
packageManager: InstallPackageManager;
commandMode: StatusLineCommandMode;
metadata: InstallationMetadata;
displayedCommand: string;
globalInstallCommand?: string;
}
export interface InstallMenuProps {
bunxAvailable: boolean;
commandAvailability: PackageCommandAvailability;
currentVersion: string;
existingStatusLine: string | null;
onSelectNpx: () => void;
onSelectBunx: () => void;
onSelect: (selection: InstallSelection) => void;
onCancel: () => void;
initialPackageSelection?: number;
}
type InstallStep = 'style' | 'manager';
const AUTO_UPDATE_DESCRIPTION = 'Runs `@latest` through npx/bunx. Stays current automatically, with a small startup cost when the package runner checks or resolves the package. Because it follows the latest published package, pinned install is available if you prefer explicit updates.';
function getPinnedDescription(currentVersion: string): string {
return `Installs \`ccstatusline@${currentVersion}\` globally and Claude Code runs \`ccstatusline\`. Fast on each render because Claude Code runs the installed ccstatusline binary directly. The version changes only when you update the global install.`;
}
function getStyleItems(currentVersion: string): ListEntry<InstallUpdateStyle>[] {
return [
{
label: 'Pinned global install',
value: 'pinned',
description: getPinnedDescription(currentVersion)
},
{
label: 'Auto-update',
value: 'auto-update',
description: AUTO_UPDATE_DESCRIPTION
}
];
}
function getManagerItems(
updateStyle: InstallUpdateStyle,
commandAvailability: PackageCommandAvailability,
currentVersion: string
): ListEntry<InstallPackageManager>[] {
if (updateStyle === 'auto-update') {
return [
{
label: CCSTATUSLINE_COMMANDS.AUTO_NPX,
value: 'npm',
disabled: !commandAvailability.npx,
sublabel: commandAvailability.npx ? undefined : '(npx not installed)'
},
{
label: CCSTATUSLINE_COMMANDS.AUTO_BUNX,
value: 'bun',
disabled: !commandAvailability.bunx,
sublabel: commandAvailability.bunx ? undefined : '(bunx not installed)'
}
];
}
return [
{
label: PINNED_INSTALL_COMMANDS.NPM(currentVersion),
value: 'npm',
disabled: !commandAvailability.npm,
sublabel: commandAvailability.npm ? undefined : '(npm not installed)'
},
{
label: PINNED_INSTALL_COMMANDS.BUN(currentVersion),
value: 'bun',
disabled: !commandAvailability.bun,
sublabel: commandAvailability.bun ? undefined : '(bun not installed)'
}
];
}
function buildSelection(
updateStyle: InstallUpdateStyle,
packageManager: InstallPackageManager,
currentVersion: string
): InstallSelection {
if (updateStyle === 'auto-update') {
return {
updateStyle,
packageManager,
commandMode: packageManager === 'bun' ? 'auto-bunx' : 'auto-npx',
displayedCommand: packageManager === 'bun'
? CCSTATUSLINE_COMMANDS.AUTO_BUNX
: CCSTATUSLINE_COMMANDS.AUTO_NPX,
metadata: {
method: 'auto-update',
packageManager
}
};
}
return {
updateStyle,
packageManager,
commandMode: 'global',
displayedCommand: packageManager === 'bun'
? PINNED_INSTALL_COMMANDS.BUN(currentVersion)
: PINNED_INSTALL_COMMANDS.NPM(currentVersion),
globalInstallCommand: packageManager === 'bun'
? PINNED_INSTALL_COMMANDS.BUN(currentVersion)
: PINNED_INSTALL_COMMANDS.NPM(currentVersion),
metadata: {
method: 'pinned',
installedVersion: currentVersion
}
};
}
export const InstallMenu: React.FC<InstallMenuProps> = ({
bunxAvailable,
commandAvailability,
currentVersion,
existingStatusLine,
onSelectNpx,
onSelectBunx,
onCancel
onSelect,
onCancel,
initialPackageSelection = 0
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const maxIndex = 2; // npx, bunx (if available), and back
const [step, setStep] = useState<InstallStep>('style');
const [updateStyle, setUpdateStyle] = useState<InstallUpdateStyle>('pinned');
useInput((input, key) => {
useInput((_, key) => {
if (key.escape) {
if (step === 'manager') {
setStep('style');
return;
}
onCancel();
} else if (key.upArrow) {
if (selectedIndex === 2) {
setSelectedIndex(bunxAvailable ? 1 : 0); // Skip bunx if not available
} else {
setSelectedIndex(Math.max(0, selectedIndex - 1));
}
} else if (key.downArrow) {
if (selectedIndex === 0) {
setSelectedIndex(bunxAvailable ? 1 : 2); // Skip bunx if not available
} else if (selectedIndex === 1 && bunxAvailable) {
setSelectedIndex(2);
} else {
setSelectedIndex(Math.min(maxIndex, selectedIndex + 1));
}
} else if (key.return) {
if (selectedIndex === 0) {
onSelectNpx();
} else if (selectedIndex === 1 && bunxAvailable) {
onSelectBunx();
} else if (selectedIndex === 2) {
onCancel();
}
}
});
@@ -67,33 +174,54 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
</Box>
)}
<Box>
<Text dimColor>Select package manager to use:</Text>
</Box>
{step === 'style' && (
<>
<Box>
<Text dimColor>Select update style:</Text>
</Box>
<Box marginTop={1} flexDirection='column'>
<Box>
<Text color={selectedIndex === 0 ? 'blue' : undefined}>
{selectedIndex === 0 ? '▶ ' : ' '}
npx - Node Package Execute
</Text>
</Box>
<List
color='blue'
marginTop={1}
items={getStyleItems(currentVersion)}
onSelect={(value) => {
if (value === 'back') {
onCancel();
return;
}
<Box>
<Text color={selectedIndex === 1 && bunxAvailable ? 'blue' : undefined} dimColor={!bunxAvailable}>
{selectedIndex === 1 && bunxAvailable ? '▶ ' : ' '}
bunx - Bun Package Execute
{!bunxAvailable && ' (not installed)'}
</Text>
</Box>
setUpdateStyle(value);
setStep('manager');
}}
initialSelection={0}
showBackButton={true}
/>
</>
)}
<Box marginTop={1}>
<Text color={selectedIndex === 2 ? 'blue' : undefined}>
{selectedIndex === 2 ? '▶ ' : ' '}
Back
</Text>
</Box>
</Box>
{step === 'manager' && (
<>
<Box>
<Text dimColor>Select package manager:</Text>
</Box>
<List
color='blue'
marginTop={1}
items={getManagerItems(updateStyle, commandAvailability, currentVersion)}
onSelect={(value) => {
if (value === 'back') {
setStep('style');
return;
}
onSelect(buildSelection(updateStyle, value, currentVersion));
}}
initialSelection={initialPackageSelection}
showBackButton={true}
/>
</>
)}
<Box marginTop={2}>
<Text dimColor>
@@ -104,8 +232,8 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
</Box>
<Box marginTop={1}>
<Text dimColor>Press Enter to select, ESC to cancel</Text>
<Text dimColor>Press Enter to select, ESC to go back</Text>
</Box>
</Box>
);
};
};
+110 -356
View File
@@ -17,12 +17,22 @@ import { generateGuid } from '../../utils/guid';
import { canDetectTerminalWidth } from '../../utils/terminal';
import {
filterWidgetCatalog,
getMatchSegments,
getWidget,
getWidgetCatalog,
getWidgetCatalogCategories
} from '../../utils/widgets';
import { ConfirmDialog } from './ConfirmDialog';
import {
handleMoveInputMode,
handleNormalInputMode,
handlePickerInputMode,
normalizePickerState,
type CustomEditorWidgetState,
type WidgetPickerAction,
type WidgetPickerState
} from './items-editor/input-handlers';
export interface ItemsEditorProps {
widgets: WidgetItem[];
@@ -32,22 +42,18 @@ export interface ItemsEditorProps {
settings: Settings;
}
type WidgetPickerAction = 'change' | 'add' | 'insert';
type WidgetPickerLevel = 'category' | 'widget';
function isMergedIntoPreviousWidget(widgets: WidgetItem[], index: number): boolean {
if (index <= 0) {
return false;
}
interface WidgetPickerState {
action: WidgetPickerAction;
level: WidgetPickerLevel;
selectedCategory: string | null;
categoryQuery: string;
widgetQuery: string;
selectedType: WidgetItemType | null;
return Boolean(widgets[index - 1]?.merge);
}
export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onBack, lineNumber, settings }) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [moveMode, setMoveMode] = useState(false);
const [customEditorWidget, setCustomEditorWidget] = useState<{ widget: WidgetItem; impl: Widget; action?: string } | null>(null);
const [customEditorWidget, setCustomEditorWidget] = useState<CustomEditorWidgetState | null>(null);
const [widgetPicker, setWidgetPicker] = useState<WidgetPickerState | null>(null);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const separatorChars = ['|', '-', ',', ' '];
@@ -97,47 +103,12 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
setCustomEditorWidget(null);
};
const getFilteredCategories = (query: string): string[] => {
void query;
return [...widgetCategories];
};
const normalizePickerState = (state: WidgetPickerState): WidgetPickerState => {
const filteredCategories = getFilteredCategories(state.categoryQuery);
const selectedCategory = state.selectedCategory && filteredCategories.includes(state.selectedCategory)
? state.selectedCategory
: (filteredCategories[0] ?? null);
const hasTopLevelSearch = state.level === 'category' && state.categoryQuery.trim().length > 0;
const effectiveCategory = hasTopLevelSearch ? 'All' : (selectedCategory ?? 'All');
const effectiveQuery = hasTopLevelSearch ? state.categoryQuery : state.widgetQuery;
const filteredWidgets = filterWidgetCatalog(widgetCatalog, effectiveCategory, effectiveQuery);
const hasSelectedType = state.selectedType
? filteredWidgets.some(entry => entry.type === state.selectedType)
: false;
return {
...state,
selectedCategory,
selectedType: hasSelectedType ? state.selectedType : (filteredWidgets[0]?.type ?? null)
};
};
const shouldShowCustomKeybind = (widget: WidgetItem, keybind: CustomKeybind): boolean => {
if (keybind.action !== 'toggle-invert') {
return true;
}
const mode = widget.metadata?.display;
return mode === 'progress' || mode === 'progress-short';
};
const getVisibleCustomKeybinds = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => {
const getCustomKeybindsForWidget = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => {
if (!widgetImpl.getCustomKeybinds) {
return [];
}
return widgetImpl.getCustomKeybinds().filter(keybind => shouldShowCustomKeybind(widget, keybind));
return widgetImpl.getCustomKeybinds(widget);
};
const openWidgetPicker = (action: WidgetPickerAction) => {
@@ -155,7 +126,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
categoryQuery: '',
widgetQuery: '',
selectedType
}));
}, widgetCatalog, widgetCategories));
};
const applyWidgetPickerSelection = (selectedType: WidgetItemType) => {
@@ -189,6 +160,30 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
setWidgetPicker(null);
};
const currentWidget = widgets[selectedIndex];
const isSeparator = currentWidget?.type === 'separator';
const isFlexSeparator = currentWidget?.type === 'flex-separator';
// Check if widget supports raw value using registry
let canToggleRaw = false;
let customKeybinds: CustomKeybind[] = [];
if (currentWidget && !isSeparator && !isFlexSeparator) {
const widgetImpl = getWidget(currentWidget.type);
if (widgetImpl) {
canToggleRaw = widgetImpl.supportsRawValue();
// Get custom keybinds from the widget
customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
} else {
canToggleRaw = false;
}
}
const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator;
const canExcludeAlign = Boolean(currentWidget) && !isSeparator && !isFlexSeparator
&& settings.powerline.enabled && settings.powerline.autoAlign
&& !isMergedIntoPreviousWidget(widgets, selectedIndex);
const hasWidgets = widgets.length > 0;
useInput((input, key) => {
// Skip input if custom editor is active
if (customEditorWidget) {
@@ -201,286 +196,47 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
}
if (widgetPicker) {
const filteredCategories = getFilteredCategories(widgetPicker.categoryQuery);
const selectedCategory = widgetPicker.selectedCategory && filteredCategories.includes(widgetPicker.selectedCategory)
? widgetPicker.selectedCategory
: (filteredCategories[0] ?? null);
const hasTopLevelSearch = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0;
const topLevelSearchEntries = hasTopLevelSearch
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
: [];
const topLevelSelectedEntry = topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0];
const filteredWidgets = filterWidgetCatalog(widgetCatalog, selectedCategory ?? 'All', widgetPicker.widgetQuery);
const selectedEntry = filteredWidgets.find(entry => entry.type === widgetPicker.selectedType) ?? filteredWidgets[0];
if (widgetPicker.level === 'category') {
if (key.escape) {
if (widgetPicker.categoryQuery.length > 0) {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
categoryQuery: ''
}) : prev);
} else {
setWidgetPicker(null);
}
} else if (key.return) {
if (hasTopLevelSearch) {
if (topLevelSelectedEntry) {
applyWidgetPickerSelection(topLevelSelectedEntry.type);
}
} else if (selectedCategory) {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
level: 'widget',
selectedCategory
}) : prev);
}
} else if (key.upArrow || key.downArrow) {
if (hasTopLevelSearch) {
if (topLevelSearchEntries.length === 0) {
return;
}
let currentIndex = topLevelSearchEntries.findIndex(entry => entry.type === widgetPicker.selectedType);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? Math.min(topLevelSearchEntries.length - 1, currentIndex + 1)
: Math.max(0, currentIndex - 1);
const nextType = topLevelSearchEntries[nextIndex]?.type ?? null;
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
selectedType: nextType
}) : prev);
} else {
if (filteredCategories.length === 0) {
return;
}
let currentIndex = filteredCategories.findIndex(category => category === selectedCategory);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? Math.min(filteredCategories.length - 1, currentIndex + 1)
: Math.max(0, currentIndex - 1);
const nextCategory = filteredCategories[nextIndex] ?? null;
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
selectedCategory: nextCategory
}) : prev);
}
} else if (key.backspace || key.delete) {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
categoryQuery: prev.categoryQuery.slice(0, -1)
}) : prev);
} else if (
input
&& !key.ctrl
&& !key.meta
&& !key.tab
) {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
categoryQuery: prev.categoryQuery + input
}) : prev);
}
} else {
if (key.escape) {
if (widgetPicker.widgetQuery.length > 0) {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
widgetQuery: ''
}) : prev);
} else {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
level: 'category'
}) : prev);
}
} else if (key.return) {
if (selectedEntry) {
applyWidgetPickerSelection(selectedEntry.type);
}
} else if (key.upArrow || key.downArrow) {
if (filteredWidgets.length === 0) {
return;
}
let currentIndex = filteredWidgets.findIndex(entry => entry.type === widgetPicker.selectedType);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? Math.min(filteredWidgets.length - 1, currentIndex + 1)
: Math.max(0, currentIndex - 1);
const nextType = filteredWidgets[nextIndex]?.type ?? null;
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
selectedType: nextType
}) : prev);
} else if (key.backspace || key.delete) {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
widgetQuery: prev.widgetQuery.slice(0, -1)
}) : prev);
} else if (
input
&& !key.ctrl
&& !key.meta
&& !key.tab
) {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
widgetQuery: prev.widgetQuery + input
}) : prev);
}
}
handlePickerInputMode({
input,
key,
widgetPicker,
widgetCatalog,
widgetCategories,
setWidgetPicker,
applyWidgetPickerSelection
});
return;
}
if (moveMode) {
// In move mode, use up/down to move the selected item
if (key.upArrow && selectedIndex > 0) {
const newWidgets = [...widgets];
const temp = newWidgets[selectedIndex];
const prev = newWidgets[selectedIndex - 1];
if (temp && prev) {
[newWidgets[selectedIndex], newWidgets[selectedIndex - 1]] = [prev, temp];
}
onUpdate(newWidgets);
setSelectedIndex(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < widgets.length - 1) {
const newWidgets = [...widgets];
const temp = newWidgets[selectedIndex];
const next = newWidgets[selectedIndex + 1];
if (temp && next) {
[newWidgets[selectedIndex], newWidgets[selectedIndex + 1]] = [next, temp];
}
onUpdate(newWidgets);
setSelectedIndex(selectedIndex + 1);
} else if (key.escape || key.return) {
// Exit move mode
setMoveMode(false);
}
} else {
// Normal mode
if (key.upArrow && widgets.length > 0) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow && widgets.length > 0) {
setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1));
} else if (key.leftArrow && widgets.length > 0) {
openWidgetPicker('change');
} else if (key.rightArrow && widgets.length > 0) {
openWidgetPicker('change');
} else if (key.return && widgets.length > 0) {
// Enter move mode
setMoveMode(true);
} else if (input === 'a') {
openWidgetPicker('add');
} else if (input === 'i') {
openWidgetPicker('insert');
} else if (input === 'd' && widgets.length > 0) {
// Delete selected item
const newWidgets = widgets.filter((_, i) => i !== selectedIndex);
onUpdate(newWidgets);
if (selectedIndex >= newWidgets.length && selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1);
}
} else if (input === 'c') {
if (widgets.length > 0) {
setShowClearConfirm(true);
}
} else if (input === ' ' && widgets.length > 0) {
// Space key - cycle separator character for separator types only (not flex)
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type === 'separator') {
const currentChar = currentWidget.character ?? '|';
const currentCharIndex = separatorChars.indexOf(currentChar);
const nextChar = separatorChars[(currentCharIndex + 1) % separatorChars.length];
const newWidgets = [...widgets];
newWidgets[selectedIndex] = { ...currentWidget, character: nextChar };
onUpdate(newWidgets);
}
} else if (input === 'r' && widgets.length > 0) {
// Toggle raw value for widgets that support it
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const widgetImpl = getWidget(currentWidget.type);
if (!widgetImpl?.supportsRawValue()) {
return;
}
const newWidgets = [...widgets];
newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue };
onUpdate(newWidgets);
}
} else if (input === 'm' && widgets.length > 0) {
// Cycle through merge states: undefined -> true -> 'no-padding' -> undefined
const currentWidget = widgets[selectedIndex];
// Don't allow merge on the last item or on separators
if (currentWidget && selectedIndex < widgets.length - 1
&& currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const newWidgets = [...widgets];
let nextMergeState: boolean | 'no-padding' | undefined;
if (currentWidget.merge === undefined) {
nextMergeState = true;
} else if (currentWidget.merge === true) {
nextMergeState = 'no-padding';
} else {
nextMergeState = undefined;
}
if (nextMergeState === undefined) {
const { merge, ...rest } = currentWidget;
void merge; // Intentionally unused
newWidgets[selectedIndex] = rest;
} else {
newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState };
}
onUpdate(newWidgets);
}
} else if (key.escape) {
onBack();
} else if (widgets.length > 0) {
// Check for custom widget keybinds
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const widgetImpl = getWidget(currentWidget.type);
if (widgetImpl) {
if (widgetImpl.getCustomKeybinds) {
const customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget);
const matchedKeybind = customKeybinds.find(kb => kb.key === input);
if (matchedKeybind && !key.ctrl) {
// Check if widget handles the action directly
if (widgetImpl.handleEditorAction) {
// Let the widget handle the action directly
const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget);
if (updatedWidget) {
const newWidgets = [...widgets];
newWidgets[selectedIndex] = updatedWidget;
onUpdate(newWidgets);
} else if (widgetImpl.renderEditor) {
// If handleEditorAction returned null, open the editor
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
}
} else if (widgetImpl.renderEditor) {
// Open the widget's custom editor with the action
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
}
}
}
}
}
}
handleMoveInputMode({
key,
widgets,
selectedIndex,
onUpdate,
setSelectedIndex,
setMoveMode
});
return;
}
handleNormalInputMode({
input,
key,
widgets,
selectedIndex,
canExcludeAlign,
separatorChars,
onBack,
onUpdate,
setSelectedIndex,
setMoveMode,
setShowClearConfirm,
openWidgetPicker,
getCustomKeybindsForWidget,
setCustomEditorWidget,
getUniqueBackgroundColor
});
});
const getWidgetDisplay = (widget: WidgetItem) => {
@@ -508,14 +264,14 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
const hasFlexSeparator = widgets.some(widget => widget.type === 'flex-separator');
const widthDetectionAvailable = canDetectTerminalWidth();
const pickerCategories = widgetPicker
? getFilteredCategories(widgetPicker.categoryQuery)
? [...widgetCategories]
: [];
const selectedPickerCategory = widgetPicker
? (widgetPicker.selectedCategory && pickerCategories.includes(widgetPicker.selectedCategory)
? widgetPicker.selectedCategory
: (pickerCategories[0] ?? null))
: null;
const topLevelSearchEntries = widgetPicker && widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0
const topLevelSearchEntries = widgetPicker?.level === 'category' && widgetPicker.categoryQuery.trim().length > 0
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
: [];
const selectedTopLevelSearchEntry = widgetPicker
@@ -528,28 +284,6 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
? (pickerEntries.find(entry => entry.type === widgetPicker.selectedType) ?? pickerEntries[0])
: null;
// Build dynamic help text based on selected item
const currentWidget = widgets[selectedIndex];
const isSeparator = currentWidget?.type === 'separator';
const isFlexSeparator = currentWidget?.type === 'flex-separator';
// Check if widget supports raw value using registry
let canToggleRaw = false;
let customKeybinds: CustomKeybind[] = [];
if (currentWidget && !isSeparator && !isFlexSeparator) {
const widgetImpl = getWidget(currentWidget.type);
if (widgetImpl) {
canToggleRaw = widgetImpl.supportsRawValue();
// Get custom keybinds from the widget
customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget);
} else {
canToggleRaw = false;
}
}
const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator;
const hasWidgets = widgets.length > 0;
// Build main help text (without custom keybinds)
let helpText = hasWidgets
? '↑↓ select, ←→ open type picker'
@@ -558,7 +292,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
helpText += ', Space edit separator';
}
if (hasWidgets) {
helpText += ', Enter to move, (a)dd via picker, (i)nsert via picker, (d)elete, (c)lear line';
helpText += ', Enter to move, (a)dd via picker, (i)nsert via picker, (k) clone, (d)elete, (c)lear line';
}
if (canToggleRaw) {
helpText += ', (r)aw value';
@@ -566,6 +300,9 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
if (canMerge) {
helpText += ', (m)erge';
}
if (canExcludeAlign) {
helpText += ', e(x)clude align';
}
helpText += ', ESC back';
// Build custom keybinds text
@@ -636,7 +373,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
{' '}
{settings.powerline.enabled
? 'Powerline mode active: separators controlled by powerline settings'
? 'Powerline mode active: manual separators disabled'
: 'Default separator active: manual separators disabled'}
</Text>
</Box>
@@ -699,6 +436,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
<>
{topLevelSearchEntries.map((entry, index) => {
const isSelected = entry.type === selectedTopLevelSearchEntry?.type;
const segments = getMatchSegments(entry.displayName, widgetPicker.categoryQuery);
return (
<Box key={entry.type} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
@@ -706,9 +444,16 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
{isSelected ? '▶ ' : ' '}
</Text>
</Box>
<Text color={isSelected ? 'green' : undefined}>
{`${index + 1}. ${entry.displayName}`}
</Text>
<Text color={isSelected ? 'green' : undefined}>{`${index + 1}. `}</Text>
{segments.map((seg, i) => (
<Text
key={i}
color={isSelected ? 'green' : seg.matched ? 'yellowBright' : undefined}
bold={isSelected ? true : seg.matched}
>
{seg.text}
</Text>
))}
</Box>
);
})}
@@ -754,6 +499,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
<>
{pickerEntries.map((entry, index) => {
const isSelected = entry.type === selectedPickerEntry?.type;
const segments = getMatchSegments(entry.displayName, widgetPicker.widgetQuery);
return (
<Box key={entry.type} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
@@ -761,9 +507,16 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
{isSelected ? '▶ ' : ' '}
</Text>
</Box>
<Text color={isSelected ? 'green' : undefined}>
{`${index + 1}. ${entry.displayName}`}
</Text>
<Text color={isSelected ? 'green' : undefined}>{`${index + 1}. `}</Text>
{segments.map((seg, i) => (
<Text
key={i}
color={isSelected ? 'green' : (seg.matched ? 'yellowBright' : undefined)}
bold={seg.matched}
>
{seg.text}
</Text>
))}
</Box>
);
})}
@@ -808,6 +561,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
{supportsRawValue && widget.rawValue && <Text dimColor> (raw value)</Text>}
{widget.merge === true && <Text dimColor> (merged)</Text>}
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad)</Text>}
{widget.excludeFromAutoAlign && settings.powerline.enabled && settings.powerline.autoAlign && !isMergedIntoPreviousWidget(widgets, index) && <Text dimColor> (no-align)</Text>}
</Box>
);
})}
@@ -834,4 +588,4 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
)}
</Box>
);
};
};
+81 -68
View File
@@ -14,6 +14,7 @@ import type { Settings } from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import { ConfirmDialog } from './ConfirmDialog';
import { List } from './List';
interface LineSelectorProps {
lines: WidgetItem[][];
@@ -47,6 +48,10 @@ const LineSelector: React.FC<LineSelectorProps> = ({
setLocalLines(lines);
}, [lines]);
useEffect(() => {
setSelectedIndex(initialSelection);
}, [initialSelection]);
const selectedLine = useMemo(
() => localLines[selectedIndex],
[localLines, selectedIndex]
@@ -60,7 +65,7 @@ const LineSelector: React.FC<LineSelectorProps> = ({
};
const deleteLine = (lineIndex: number) => {
// Don't allow deleting the last remaining line
// Don't allow deleting the last remaining line
if (localLines.length <= 1) {
return;
}
@@ -92,26 +97,28 @@ const LineSelector: React.FC<LineSelectorProps> = ({
}
if (moveMode) {
if (key.upArrow && selectedIndex > 0) {
if (key.upArrow && localLines.length > 1) {
const newLines = [...localLines];
const targetIndex = selectedIndex - 1 < 0 ? localLines.length - 1 : selectedIndex - 1;
const temp = newLines[selectedIndex];
const prev = newLines[selectedIndex - 1];
const prev = newLines[targetIndex];
if (temp && prev) {
[newLines[selectedIndex], newLines[selectedIndex - 1]] = [prev, temp];
[newLines[selectedIndex], newLines[targetIndex]] = [prev, temp];
}
setLocalLines(newLines);
onLinesUpdate(newLines);
setSelectedIndex(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < localLines.length - 1) {
setSelectedIndex(targetIndex);
} else if (key.downArrow && localLines.length > 1) {
const newLines = [...localLines];
const targetIndex = selectedIndex + 1 > localLines.length - 1 ? 0 : selectedIndex + 1;
const temp = newLines[selectedIndex];
const next = newLines[selectedIndex + 1];
const next = newLines[targetIndex];
if (temp && next) {
[newLines[selectedIndex], newLines[selectedIndex + 1]] = [next, temp];
[newLines[selectedIndex], newLines[targetIndex]] = [next, temp];
}
setLocalLines(newLines);
onLinesUpdate(newLines);
setSelectedIndex(selectedIndex + 1);
setSelectedIndex(targetIndex);
} else if (key.escape || key.return) {
setMoveMode(false);
}
@@ -119,35 +126,25 @@ const LineSelector: React.FC<LineSelectorProps> = ({
}
switch (input) {
case 'a':
if (allowEditing) {
appendLine();
}
return;
case 'd':
if (allowEditing && localLines.length > 1) {
setShowDeleteDialog(true);
}
return;
case 'm':
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
setMoveMode(true);
}
return;
case 'a':
if (allowEditing) {
appendLine();
}
return;
case 'd':
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
setShowDeleteDialog(true);
}
return;
case 'm':
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
setMoveMode(true);
}
return;
}
if (key.escape) {
onBack();
} else if (key.upArrow) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow) {
setSelectedIndex(Math.min(localLines.length, selectedIndex + 1));
} else if (key.return) {
if (selectedIndex === localLines.length) {
onBack();
} else {
onSelect(selectedIndex);
}
}
});
@@ -197,7 +194,6 @@ const LineSelector: React.FC<LineSelectorProps> = ({
<Text>
<Text>
Line
{' '}
{selectedIndex + 1}
</Text>
{' '}
@@ -228,6 +224,12 @@ const LineSelector: React.FC<LineSelectorProps> = ({
);
}
const lineItems = localLines.map((line, index) => ({
label: `☰ Line ${index + 1}`,
sublabel: `(${line.length > 0 ? pluralize('widget', line.length, true) : 'empty'})`,
value: index
}));
return (
<>
<Box flexDirection='column'>
@@ -253,47 +255,58 @@ const LineSelector: React.FC<LineSelectorProps> = ({
</Text>
)}
<Box marginTop={1} flexDirection='column'>
{localLines.map((line, index) => {
const isSelected = selectedIndex === index;
const suffix = line.length
? pluralize('widget', line.length, true)
: 'empty';
{moveMode ? (
<Box marginTop={1} flexDirection='column'>
{localLines.map((line, index) => {
const isSelected = selectedIndex === index;
const suffix = line.length
? pluralize('widget', line.length, true)
: 'empty';
return (
<Box key={index}>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
<Text>{isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '}</Text>
<Text>
return (
<Box key={index}>
<Text color={isSelected ? 'blue' : undefined}>
<Text>{isSelected ? '◆ ' : ' '}</Text>
<Text>
Line
<Text>
Line
{' '}
{index + 1}
</Text>
{' '}
{index + 1}
</Text>
{' '}
<Text dimColor={!isSelected}>
(
{suffix}
)
<Text dimColor={!isSelected}>
(
{suffix}
)
</Text>
</Text>
</Text>
</Text>
</Box>
);
})}
</Box>
);
})}
</Box>
) : (
<List
marginTop={1}
items={lineItems}
onSelect={(line) => {
if (line === 'back') {
onBack();
return;
}
{!moveMode && (
<Box marginTop={1}>
<Text color={selectedIndex === localLines.length ? 'green' : undefined}>
{selectedIndex === localLines.length ? '▶ ' : ' '}
Back
</Text>
</Box>
)}
</Box>
onSelect(line);
}}
onSelectionChange={(_, index) => {
setSelectedIndex(index);
}}
initialSelection={selectedIndex}
showBackButton={true}
/>
)}
</Box>
</>
);
};
export { LineSelector, type LineSelectorProps };
export { LineSelector, type LineSelectorProps };
+170
View File
@@ -0,0 +1,170 @@
import type { ForegroundColorName } from 'chalk';
import {
Box,
Text,
useInput,
type BoxProps
} from 'ink';
import {
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren
} from 'react';
export interface ListEntry<V = string | number> {
label: string;
sublabel?: string;
disabled?: boolean;
description?: string;
value: V;
props?: BoxProps;
}
interface ListProps<V = string | number> extends BoxProps {
items: (ListEntry<V> | '-')[];
onSelect: (value: V | 'back', index: number) => void;
onSelectionChange?: (value: V | 'back', index: number) => void;
initialSelection?: number;
showBackButton?: boolean;
color?: ForegroundColorName;
wrapNavigation?: boolean;
}
export function List<V = string | number>({
items,
onSelect,
onSelectionChange,
initialSelection = 0,
showBackButton,
color,
wrapNavigation = true,
...boxProps
}: ListProps<V>) {
const [selectedIndex, setSelectedIndex] = useState(initialSelection);
const latestOnSelectionChangeRef = useRef(onSelectionChange);
const _items = useMemo(() => {
if (showBackButton) {
return [...items, '-' as const, { label: '← Back', value: 'back' as V }];
}
return items;
}, [items, showBackButton]);
const selectableItems = _items.filter(item => item !== '-' && !item.disabled) as ListEntry<V>[];
const selectedItem = selectableItems[selectedIndex];
const selectedValue = selectedItem?.value;
const actualIndex = _items.findIndex(item => item === selectedItem);
useEffect(() => {
latestOnSelectionChangeRef.current = onSelectionChange;
}, [onSelectionChange]);
useEffect(() => {
const maxIndex = Math.max(selectableItems.length - 1, 0);
setSelectedIndex(Math.min(initialSelection, maxIndex));
}, [initialSelection, selectableItems.length]);
useEffect(() => {
if (selectedValue !== undefined) {
latestOnSelectionChangeRef.current?.(selectedValue, selectedIndex);
}
}, [selectedIndex, selectedValue]);
useInput((_, key) => {
if (key.upArrow) {
const prev = selectedIndex - 1;
const prevIndex = prev < 0
? (wrapNavigation ? selectableItems.length - 1 : 0)
: prev;
setSelectedIndex(prevIndex);
return;
}
if (key.downArrow) {
const next = selectedIndex + 1;
const nextIndex = next > selectableItems.length - 1
? (wrapNavigation ? 0 : selectableItems.length - 1)
: next;
setSelectedIndex(nextIndex);
return;
}
if (key.return && selectedItem) {
onSelect(selectedItem.value, selectedIndex);
return;
}
});
return (
<Box flexDirection='column' {...boxProps}>
{_items.map((item, index) => {
if (item === '-') {
return <ListSeparator key={index} />;
}
const isSelected = index === actualIndex;
return (
<ListItem
key={index}
isSelected={isSelected}
color={color}
disabled={item.disabled}
{...item.props}
>
<Text>
<Text>
{item.label}
</Text>
{item.sublabel && (
<Text dimColor={!isSelected}>
{' '}
{item.sublabel}
</Text>
)}
</Text>
</ListItem>
);
})}
{selectedItem?.description && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor wrap='wrap'>
{selectedItem.description}
</Text>
</Box>
)}
</Box>
);
}
interface ListItemProps extends PropsWithChildren, BoxProps {
isSelected: boolean;
color?: ForegroundColorName;
disabled?: boolean;
}
export function ListItem({
children,
isSelected,
color = 'green',
disabled,
...boxProps
}: ListItemProps) {
return (
<Box {...boxProps}>
<Text color={isSelected ? color : undefined} dimColor={disabled}>
<Text>{isSelected ? '▶ ' : ' '}</Text>
<Text>{children}</Text>
</Text>
</Box>
);
}
export function ListSeparator() {
return <Text> </Text>;
}
+199 -85
View File
@@ -1,127 +1,241 @@
import {
Box,
Text,
useInput
Text
} from 'ink';
import React, { useState } from 'react';
import React from 'react';
import type { Settings } from '../../types/Settings';
import type {
InstallationMetadata,
Settings
} from '../../types/Settings';
import { type PowerlineFontStatus } from '../../utils/powerline';
import { List } from './List';
export type MainMenuOption = 'lines'
| 'colors'
| 'powerline'
| 'terminalConfig'
| 'globalOverrides'
| 'install'
| 'manageInstallation'
| 'checkUpdates'
| 'configureStatusLine'
| 'starGithub'
| 'save'
| 'exit';
export interface MainMenuProps {
onSelect: (value: string) => void;
onSelect: (value: MainMenuOption, index: number) => void;
isClaudeInstalled: boolean;
hasChanges: boolean;
initialSelection?: number;
powerlineFontStatus: PowerlineFontStatus;
settings: Settings | null;
installation?: InstallationMetadata;
previewIsTruncated?: boolean;
}
export const MainMenu: React.FC<MainMenuProps> = ({ onSelect, isClaudeInstalled, hasChanges, initialSelection = 0, powerlineFontStatus, settings, previewIsTruncated }) => {
const [selectedIndex, setSelectedIndex] = useState(initialSelection);
interface MainMenuItem {
label: string;
sublabel?: string;
disabled?: boolean;
value: MainMenuOption;
description: string;
}
// Build menu structure with visual gaps
const menuItems = [
{ label: '📝 Edit Lines', value: 'lines', selectable: true },
{ label: '🎨 Edit Colors', value: 'colors', selectable: true },
{ label: '⚡ Powerline Setup', value: 'powerline', selectable: true },
{ label: '', value: '_gap1', selectable: false }, // Visual gap
{ label: '💻 Terminal Options', value: 'terminalConfig', selectable: true },
{ label: '🌐 Global Overrides', value: 'globalOverrides', selectable: true },
{ label: '', value: '_gap2', selectable: false }, // Visual gap
{ label: isClaudeInstalled ? '🔌 Uninstall from Claude Code' : '📦 Install to Claude Code', value: 'install', selectable: true }
export type MainMenuEntry = MainMenuItem | '-';
function usesManageInstallation(installation?: InstallationMetadata): boolean {
return installation?.method === 'pinned' || installation?.method === 'self-managed';
}
function getInstallationMenuItem(
isClaudeInstalled: boolean,
installation?: InstallationMetadata
): MainMenuItem {
if (!isClaudeInstalled) {
return {
label: '📦 Install to Claude Code',
value: 'install',
description: 'Add ccstatusline to your Claude Code settings for automatic status line rendering'
};
}
if (usesManageInstallation(installation)) {
return {
label: '🧰 Manage Installation',
value: 'manageInstallation',
description: 'Check pinned global package updates or uninstall ccstatusline'
};
}
return {
label: '🔌 Uninstall from Claude Code',
value: 'install',
description: 'Remove ccstatusline from your Claude Code settings'
};
}
export function buildMainMenuItems(
isClaudeInstalled: boolean,
hasChanges: boolean,
installation?: InstallationMetadata
): MainMenuEntry[] {
const menuItems: MainMenuEntry[] = [
{
label: '📝 Edit Lines',
value: 'lines',
description:
'Configure any number of status lines with various widgets like model info, git status, and token usage'
},
{
label: '🎨 Edit Colors',
value: 'colors',
description:
'Customize colors for each widget including foreground, background, and bold styling'
},
{
label: '⚡ Powerline Setup',
value: 'powerline',
description:
'Install Powerline fonts for enhanced visual separators and symbols in your status line'
},
'-',
{
label: '💻 Terminal Options',
value: 'terminalConfig',
description: 'Configure terminal-specific settings for optimal display'
},
{
label: '🌐 Global Overrides',
value: 'globalOverrides',
description:
'Set global padding, separators, and color overrides that apply to all widgets'
},
{
label: '🔧 Configure Status Line',
sublabel: isClaudeInstalled ? undefined : '(install first)',
disabled: !isClaudeInstalled,
value: 'configureStatusLine',
description: 'Configure Claude Code status line settings like refresh interval'
},
'-',
getInstallationMenuItem(isClaudeInstalled, installation)
];
if (hasChanges) {
menuItems.push(
{ label: '💾 Save & Exit', value: 'save', selectable: true },
{ label: '❌ Exit without saving', value: 'exit', selectable: true },
{ label: '', value: '_gap3', selectable: false }, // Visual gap
{ label: '⭐ Like ccstatusline? Star us on GitHub', value: 'starGithub', selectable: true }
'-',
{
label: '💾 Save & Exit',
value: 'save',
description: 'Save all changes and exit the configuration tool'
},
{
label: '❌ Exit without saving',
value: 'exit',
description: 'Exit without saving your changes'
},
'-',
{
label: '⭐ Like ccstatusline? Star us on GitHub',
value: 'starGithub',
description: 'Open the ccstatusline GitHub repository in your browser so you can star the project'
}
);
} else {
menuItems.push(
{ label: '🚪 Exit', value: 'exit', selectable: true },
{ label: '', value: '_gap3', selectable: false }, // Visual gap
{ label: '⭐ Like ccstatusline? Star us on GitHub', value: 'starGithub', selectable: true }
'-',
{
label: '🚪 Exit',
value: 'exit',
description: 'Exit the configuration tool'
},
'-',
{
label: '⭐ Like ccstatusline? Star us on GitHub',
value: 'starGithub',
description: 'Open the ccstatusline GitHub repository in your browser so you can star the project'
}
);
}
// Get only selectable items for navigation
const selectableItems = menuItems.filter(item => item.selectable);
return menuItems;
}
useInput((input, key) => {
if (key.upArrow) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow) {
setSelectedIndex(Math.min(selectableItems.length - 1, selectedIndex + 1));
} else if (key.return) {
const item = selectableItems[selectedIndex];
if (item) {
onSelect(item.value);
}
export function getMainMenuSelectionIndex(items: MainMenuEntry[], option: MainMenuOption): number {
let selectionIndex = 0;
for (const item of items) {
if (item === '-') {
continue;
}
});
// Get description for selected item
const getDescription = (value: string): string => {
const descriptions: Record<string, string> = {
lines: 'Configure any number of status lines with various widgets like model info, git status, and token usage',
colors: 'Customize colors for each widget including foreground, background, and bold styling',
powerline: 'Install Powerline fonts for enhanced visual separators and symbols in your status line',
globalOverrides: 'Set global padding, separators, and color overrides that apply to all widgets',
install: isClaudeInstalled
? 'Remove ccstatusline from your Claude Code settings'
: 'Add ccstatusline to your Claude Code settings for automatic status line rendering',
terminalConfig: 'Configure terminal-specific settings for optimal display',
starGithub: 'Open the ccstatusline GitHub repository in your browser so you can star the project',
save: 'Save all changes and exit the configuration tool',
exit: hasChanges
? 'Exit without saving your changes'
: 'Exit the configuration tool'
};
return descriptions[value] ?? '';
};
if (item.value === option) {
return selectionIndex;
}
const selectedItem = selectableItems[selectedIndex];
const description = selectedItem ? getDescription(selectedItem.value) : '';
if (!item.disabled) {
selectionIndex += 1;
}
}
return 0;
}
export function getMainMenuInstallSelectionIndex(
isClaudeInstalled: boolean,
installation?: InstallationMetadata
): number {
const option = isClaudeInstalled && usesManageInstallation(installation)
? 'manageInstallation'
: 'install';
return getMainMenuSelectionIndex(buildMainMenuItems(isClaudeInstalled, false, installation), option);
}
export const MainMenu: React.FC<MainMenuProps> = ({
onSelect,
isClaudeInstalled,
hasChanges,
initialSelection = 0,
powerlineFontStatus,
settings,
installation,
previewIsTruncated
}) => {
const menuItems = buildMainMenuItems(isClaudeInstalled, hasChanges, installation);
// Check if we should show the truncation warning
const showTruncationWarning = previewIsTruncated && settings?.flexMode === 'full-minus-40';
const showTruncationWarning
= previewIsTruncated && settings?.flexMode === 'full-minus-40';
return (
<Box flexDirection='column'>
{showTruncationWarning && (
<Box marginBottom={1}>
<Text color='yellow'> Some lines are truncated, see Terminal Options Terminal Width for info</Text>
<Text color='yellow'>
Some lines are truncated, see Terminal Options Terminal Width
for info
</Text>
</Box>
)}
<Text bold>Main Menu</Text>
<Box marginTop={1} flexDirection='column'>
{menuItems.map((item, idx) => {
if (!item.selectable && item.value.startsWith('_gap')) {
return <Text key={item.value}> </Text>;
}
const selectableIdx = selectableItems.indexOf(item);
const isSelected = selectableIdx === selectedIndex;
return (
<Text
key={item.value}
color={isSelected ? 'green' : undefined}
>
{isSelected ? '▶ ' : ' '}
{item.label}
</Text>
);
})}
</Box>
{description && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor wrap='wrap'>{description}</Text>
</Box>
)}
<Text bold>Main Menu</Text>
<List
items={menuItems}
marginTop={1}
onSelect={(value, index) => {
if (value === 'back') {
return;
}
onSelect(value, index);
}}
initialSelection={initialSelection}
/>
</Box>
);
};
};
@@ -0,0 +1,226 @@
import {
Box,
Text,
useInput
} from 'ink';
import React from 'react';
import type { ResolvedInstallationMetadata } from '../../types/Settings';
import type {
ActiveGlobalCommandResolution,
GlobalPackageInstallation,
GlobalPackageManager
} from '../../utils/global-package-manager';
import {
List,
type ListEntry
} from './List';
export type ManageInstallationAction = 'checkUpdates' | 'uninstall';
export interface UninstallSelection { packageManagers: GlobalPackageManager[] }
export interface ManageInstallationMenuProps {
installation: ResolvedInstallationMetadata;
activeCommand: ActiveGlobalCommandResolution | null;
onSelect: (action: ManageInstallationAction) => void;
onBack: () => void;
}
export interface UninstallMenuProps {
installations: GlobalPackageInstallation[];
onSelect: (selection: UninstallSelection) => void;
onBack: () => void;
}
function getInstallationLabel(installation: ResolvedInstallationMetadata): string {
if (installation.method === 'pinned') {
const version = installation.installedVersion
? ` ${installation.installedVersion}`
: '';
const manager = installation.packageManager === 'unknown'
? ''
: ` via ${installation.packageManager}`;
return `Pinned global install${manager}${version}`;
}
if (installation.method === 'self-managed') {
return 'Self-managed/global install';
}
if (installation.method === 'auto-update') {
return `Auto-update via ${installation.packageManager}`;
}
return 'Unknown installation';
}
function getActiveCommandLabel(activeCommand: ActiveGlobalCommandResolution | null): string | null {
if (!activeCommand?.resolvedPath) {
return null;
}
if (activeCommand.packageManager === 'unknown') {
return `Active PATH match: ${activeCommand.resolvedPath}`;
}
const version = activeCommand.version
? ` ${activeCommand.version}`
: '';
return `Active PATH match: ${activeCommand.packageManager} global${version} (${activeCommand.resolvedPath})`;
}
export function buildManageInstallationItems(): ListEntry<ManageInstallationAction>[] {
return [
{
label: '🔄 Check for Updates',
value: 'checkUpdates',
description: 'Check npm for the latest ccstatusline version and update the pinned global package'
},
{
label: '🔌 Uninstall',
value: 'uninstall',
description: 'Remove ccstatusline from Claude Code settings, optionally removing global npm/bun packages'
}
];
}
function formatPackageManagers(packageManagers: GlobalPackageManager[]): string {
return packageManagers.join(' + ');
}
export function buildUninstallItems(
installations: GlobalPackageInstallation[]
): ListEntry<UninstallSelection>[] {
const removableManagers = installations
.filter(installation => installation.installed && installation.available)
.map(installation => installation.packageManager);
const items: ListEntry<UninstallSelection>[] = [
{
label: 'Remove from Claude Code settings only',
value: { packageManagers: [] },
description: 'Leaves any global npm or bun ccstatusline packages installed'
}
];
for (const packageManager of removableManagers) {
items.push({
label: `Remove Claude settings and ${packageManager} global package`,
value: { packageManagers: [packageManager] },
description: `Runs ${packageManager === 'npm'
? 'npm uninstall -g ccstatusline'
: 'bun remove -g ccstatusline'} after removing Claude Code settings`
});
}
if (removableManagers.length > 1) {
items.push({
label: `Remove Claude settings and ${formatPackageManagers(removableManagers)} global packages`,
value: { packageManagers: removableManagers },
description: 'Removes every detected global ccstatusline package after removing Claude Code settings'
});
}
return items;
}
export const ManageInstallationMenu: React.FC<ManageInstallationMenuProps> = ({
installation,
activeCommand,
onSelect,
onBack
}) => {
const activeCommandLabel = getActiveCommandLabel(activeCommand);
useInput((_, key) => {
if (key.escape) {
onBack();
}
});
return (
<Box flexDirection='column'>
<Text bold>Manage Installation</Text>
<Box marginTop={1}>
<Text>
Current:
{' '}
{getInstallationLabel(installation)}
</Text>
</Box>
{activeCommandLabel && (
<Box>
<Text dimColor>{activeCommandLabel}</Text>
</Box>
)}
{activeCommand?.warning && (
<Box marginTop={1}>
<Text color='yellow' wrap='wrap'>{activeCommand.warning}</Text>
</Box>
)}
<List
marginTop={1}
items={buildManageInstallationItems()}
onSelect={(value) => {
if (value === 'back') {
onBack();
return;
}
onSelect(value);
}}
showBackButton={true}
/>
</Box>
);
};
export const UninstallMenu: React.FC<UninstallMenuProps> = ({
installations,
onSelect,
onBack
}) => {
const items = buildUninstallItems(installations);
const detectedManagers = installations
.filter(installation => installation.installed && installation.available)
.map(installation => installation.packageManager);
useInput((_, key) => {
if (key.escape) {
onBack();
}
});
return (
<Box flexDirection='column'>
<Text bold>Uninstall ccstatusline</Text>
<Box marginTop={1}>
<Text dimColor>
Choose what to remove from this machine.
</Text>
</Box>
{detectedManagers.length === 0 && (
<Box marginTop={1}>
<Text dimColor>No global npm or bun ccstatusline package was detected.</Text>
</Box>
)}
<List
marginTop={1}
items={items}
onSelect={(value) => {
if (value === 'back') {
onBack();
return;
}
onSelect(value);
}}
showBackButton={true}
/>
</Box>
);
};
+55 -51
View File
@@ -28,12 +28,12 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
// Get the appropriate array based on mode
const getItems = () => {
switch (mode) {
case 'separator':
return powerlineConfig.separators;
case 'startCap':
return powerlineConfig.startCaps;
case 'endCap':
return powerlineConfig.endCaps;
case 'separator':
return powerlineConfig.separators;
case 'startCap':
return powerlineConfig.startCaps;
case 'endCap':
return powerlineConfig.endCaps;
}
};
@@ -83,24 +83,25 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
const inversionText = mode === 'separator' && invertBg ? ' [Inverted]' : '';
return `${preset.char} - ${preset.name}${inversionText}`;
}
const hexCode = char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0');
return `${char} - Custom (\\u${hexCode})${invertBg ? ' [Inverted]' : ''}`;
const codePoint = char.codePointAt(0) ?? 0;
const hexCode = codePoint.toString(16).toUpperCase().padStart(4, '0');
return `${char} - Custom (U+${hexCode})${invertBg ? ' [Inverted]' : ''}`;
};
const updateSeparators = (newSeparators: string[], newInvertBgs?: boolean[]) => {
const updatedPowerline = { ...powerlineConfig };
switch (mode) {
case 'separator':
updatedPowerline.separators = newSeparators;
updatedPowerline.separatorInvertBackground = newInvertBgs ?? newSeparators.map((_, i) => invertBgs[i] ?? false);
break;
case 'startCap':
updatedPowerline.startCaps = newSeparators;
break;
case 'endCap':
updatedPowerline.endCaps = newSeparators;
break;
case 'separator':
updatedPowerline.separators = newSeparators;
updatedPowerline.separatorInvertBackground = newInvertBgs ?? newSeparators.map((_, i) => invertBgs[i] ?? false);
break;
case 'startCap':
updatedPowerline.startCaps = newSeparators;
break;
case 'endCap':
updatedPowerline.endCaps = newSeparators;
break;
}
onUpdate({
@@ -117,24 +118,27 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
setHexInput('');
setCursorPos(0);
} else if (key.return) {
if (hexInput.length === 4) {
const char = String.fromCharCode(parseInt(hexInput, 16));
const newSeparators = [...separators];
if (separators.length === 0) {
// Add new item if list is empty
newSeparators.push(char);
} else {
newSeparators[selectedIndex] = char;
if (hexInput.length >= 4 && hexInput.length <= 6) {
const codePoint = parseInt(hexInput, 16);
if (codePoint >= 0 && codePoint <= 0x10FFFF) {
const char = String.fromCodePoint(codePoint);
const newSeparators = [...separators];
if (separators.length === 0) {
// Add new item if list is empty
newSeparators.push(char);
} else {
newSeparators[selectedIndex] = char;
}
updateSeparators(newSeparators);
setHexInputMode(false);
setHexInput('');
setCursorPos(0);
}
updateSeparators(newSeparators);
setHexInputMode(false);
setHexInput('');
setCursorPos(0);
}
} else if (key.backspace && cursorPos > 0) {
setHexInput(hexInput.slice(0, cursorPos - 1) + hexInput.slice(cursorPos));
setCursorPos(cursorPos - 1);
} else if (shouldInsertInput(input, key) && /[0-9a-fA-F]/.test(input) && hexInput.length < 4) {
} else if (shouldInsertInput(input, key) && /[0-9a-fA-F]/.test(input) && hexInput.length < 6) {
setHexInput(hexInput.slice(0, cursorPos) + input.toUpperCase() + hexInput.slice(cursorPos));
setCursorPos(cursorPos + 1);
}
@@ -142,10 +146,10 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
// Normal mode
if (key.escape) {
onBack();
} else if (key.upArrow) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.upArrow && separators.length > 0) {
setSelectedIndex(selectedIndex - 1 < 0 ? separators.length - 1 : selectedIndex - 1);
} else if (key.downArrow && separators.length > 0) {
setSelectedIndex(Math.min(separators.length - 1, selectedIndex + 1));
setSelectedIndex(selectedIndex + 1 > separators.length - 1 ? 0 : selectedIndex + 1);
} else if ((key.leftArrow || key.rightArrow) && separators.length > 0) {
// Cycle through preset separators
const currentChar = separators[selectedIndex] ?? '\uE0B0';
@@ -180,8 +184,8 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
}
updateSeparators(newSeparators, mode === 'separator' ? newInvertBgs : undefined);
} else if ((input === 'a' || input === 'A') && (mode === 'separator' || separators.length < 3)) {
// Add after current (max 3 for caps)
} else if (input === 'a' || input === 'A') {
// Add after current
const newSeparators = [...separators];
const newInvertBgs = mode === 'separator' ? [...invertBgs] : [];
const defaultChar = presetSeparators[0]?.char ?? '\uE0B0';
@@ -203,8 +207,8 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
updateSeparators(newSeparators, newInvertBgs);
setSelectedIndex(selectedIndex + 1);
}
} else if ((input === 'i' || input === 'I') && (mode === 'separator' || separators.length < 3)) {
// Insert before current (max 3 for caps)
} else if (input === 'i' || input === 'I') {
// Insert before current
const newSeparators = [...separators];
const newInvertBgs = mode === 'separator' ? [...invertBgs] : [];
const defaultChar = presetSeparators[0]?.char ?? '\uE0B0';
@@ -257,16 +261,15 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
const getTitle = () => {
switch (mode) {
case 'separator':
return 'Powerline Separator Configuration';
case 'startCap':
return 'Powerline Start Cap Configuration';
case 'endCap':
return 'Powerline End Cap Configuration';
case 'separator':
return 'Powerline Separator Configuration';
case 'startCap':
return 'Powerline Start Cap Configuration';
case 'endCap':
return 'Powerline End Cap Configuration';
}
};
const canAdd = mode === 'separator' || separators.length < 3;
const canDelete = mode !== 'separator' || separators.length > 1;
return (
@@ -276,26 +279,27 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
{hexInputMode ? (
<Box marginTop={2} flexDirection='column'>
<Text>
Enter 4-digit hex code for
Enter hex code (4-6 digits) for
{' '}
{mode === 'separator' ? 'separator' : 'cap'}
{separators.length > 0 ? ` ${selectedIndex + 1}` : ''}
:
</Text>
<Text>
\u
U+
{hexInput.slice(0, cursorPos)}
<Text backgroundColor='gray' color='black'>{hexInput[cursorPos] ?? '_'}</Text>
{hexInput.slice(cursorPos + 1)}
{hexInput.length < 4 && hexInput.length === cursorPos && <Text dimColor>{'_'.repeat(4 - hexInput.length - 1)}</Text>}
{hexInput.length < 6 && hexInput.length === cursorPos && <Text dimColor>{'_'.repeat(6 - hexInput.length - 1)}</Text>}
</Text>
<Text dimColor>Enter 4 hex digits (0-9, A-F), then press Enter. ESC to cancel.</Text>
<Text dimColor>Enter 4-6 hex digits (0-9, A-F) for a Unicode code point, then press Enter. ESC to cancel.</Text>
<Text dimColor>Examples: E0B0 (powerline), 1F984 (🦄), 2764 ()</Text>
</Box>
) : (
<>
<Box>
<Text dimColor>
{`↑↓ select, ← → cycle${canAdd ? ', (a)dd, (i)nsert' : ''}${canDelete ? ', (d)elete' : ''}, (c)lear, (h)ex${mode === 'separator' ? ', (t)oggle invert' : ''}, ESC back`}
{`↑↓ select, ← → cycle, (a)dd, (i)nsert${canDelete ? ', (d)elete' : ''}, (c)lear, (h)ex${mode === 'separator' ? ', (t)oggle invert' : ''}, ESC back`}
</Text>
</Box>
@@ -317,4 +321,4 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
)}
</Box>
);
};
};
+202 -205
View File
@@ -6,14 +6,139 @@ import {
import * as os from 'os';
import React, { useState } from 'react';
import type { PowerlineConfig } from '../../types/PowerlineConfig';
import type { Settings } from '../../types/Settings';
import { getDefaultPowerlineTheme } from '../../utils/colors';
import { type PowerlineFontStatus } from '../../utils/powerline';
import { buildEnabledPowerlineSettings } from '../../utils/powerline-settings';
import { ConfirmDialog } from './ConfirmDialog';
import {
List,
type ListEntry
} from './List';
import { PowerlineSeparatorEditor } from './PowerlineSeparatorEditor';
import { PowerlineThemeSelector } from './PowerlineThemeSelector';
type PowerlineMenuValue = 'separator' | 'startCap' | 'endCap' | 'themes';
type Screen = 'menu' | PowerlineMenuValue;
const POWERLINE_MENU_LABEL_WIDTH = 11;
function formatPowerlineMenuLabel(label: string): string {
return label.padEnd(POWERLINE_MENU_LABEL_WIDTH, ' ');
}
export function getSeparatorDisplay(powerlineConfig: PowerlineConfig): string {
const seps = powerlineConfig.separators;
if (seps.length > 1) {
return 'multiple';
}
const sep = seps[0] ?? '\uE0B0';
const presets = [
{ char: '\uE0B0', name: 'Triangle Right' },
{ char: '\uE0B2', name: 'Triangle Left' },
{ char: '\uE0B4', name: 'Round Right' },
{ char: '\uE0B6', name: 'Round Left' }
];
const preset = presets.find(item => item.char === sep);
if (preset) {
return `${preset.char} - ${preset.name}`;
}
return `${sep} - Custom`;
}
export function getCapDisplay(
powerlineConfig: PowerlineConfig,
type: 'start' | 'end'
): string {
const caps = type === 'start'
? powerlineConfig.startCaps
: powerlineConfig.endCaps;
if (caps.length === 0) {
return 'none';
}
if (caps.length > 1) {
return 'multiple';
}
const cap = caps[0];
if (!cap) {
return 'none';
}
const presets = type === 'start' ? [
{ char: '\uE0B2', name: 'Triangle' },
{ char: '\uE0B6', name: 'Round' },
{ char: '\uE0BA', name: 'Lower Triangle' },
{ char: '\uE0BE', name: 'Diagonal' }
] : [
{ char: '\uE0B0', name: 'Triangle' },
{ char: '\uE0B4', name: 'Round' },
{ char: '\uE0B8', name: 'Lower Triangle' },
{ char: '\uE0BC', name: 'Diagonal' }
];
const preset = presets.find(item => item.char === cap);
if (preset) {
return `${preset.char} - ${preset.name}`;
}
return `${cap} - Custom`;
}
export function getThemeDisplay(powerlineConfig: PowerlineConfig): string {
const theme = powerlineConfig.theme;
if (!theme || theme === 'custom') {
return 'Custom';
}
return theme.charAt(0).toUpperCase() + theme.slice(1);
}
export function buildPowerlineSetupMenuItems(
powerlineConfig: PowerlineConfig
): ListEntry<PowerlineMenuValue>[] {
const disabled = !powerlineConfig.enabled;
return [
{
label: formatPowerlineMenuLabel('Separator'),
sublabel: `(${getSeparatorDisplay(powerlineConfig)})`,
value: 'separator',
disabled,
description: 'Choose the glyph used between powerline segments.'
},
{
label: formatPowerlineMenuLabel('Start Cap'),
sublabel: `(${getCapDisplay(powerlineConfig, 'start')})`,
value: 'startCap',
disabled,
description: 'Configure the cap glyph that appears at the start of each powerline line.'
},
{
label: formatPowerlineMenuLabel('End Cap'),
sublabel: `(${getCapDisplay(powerlineConfig, 'end')})`,
value: 'endCap',
disabled,
description: 'Configure the cap glyph that appears at the end of each powerline line.'
},
{
label: formatPowerlineMenuLabel('Themes'),
sublabel: `(${getThemeDisplay(powerlineConfig)})`,
value: 'themes',
disabled,
description: 'Preview built-in powerline themes or copy a theme into custom widget colors.'
}
];
}
export interface PowerlineSetupProps {
settings: Settings;
powerlineFontStatus: PowerlineFontStatus;
@@ -25,8 +150,6 @@ export interface PowerlineSetupProps {
onClearMessage: () => void;
}
type Screen = 'menu' | 'separator' | 'startCap' | 'endCap' | 'themes';
export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
settings,
powerlineFontStatus,
@@ -43,155 +166,65 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
const [confirmingEnable, setConfirmingEnable] = useState(false);
const [confirmingFontInstall, setConfirmingFontInstall] = useState(false);
// Check if there are any separators or flex-separators in the current configuration
const hasSeparatorItems = settings.lines.some(line => line.some(item => item.type === 'separator' || item.type === 'flex-separator'));
// Menu items for navigation
const menuItems = [
{ label: 'Separator', value: 'separator' },
{ label: 'Start Cap', value: 'startCap' },
{ label: 'End Cap', value: 'endCap' },
{ label: 'Themes', value: 'themes' },
{ label: '← Back', value: 'back' }
];
// Helper functions for display
const getSeparatorDisplay = (): string => {
const seps = powerlineConfig.separators;
if (seps.length > 1) {
return 'multiple';
}
const sep = seps[0] ?? '\uE0B0';
const presets = [
{ char: '\uE0B0', name: 'Triangle Right' },
{ char: '\uE0B2', name: 'Triangle Left' },
{ char: '\uE0B4', name: 'Round Right' },
{ char: '\uE0B6', name: 'Round Left' }
];
const preset = presets.find(p => p.char === sep);
if (preset) {
return `${preset.char} - ${preset.name}`;
}
return `${sep} - Custom`;
};
const getCapDisplay = (type: 'start' | 'end'): string => {
const caps = type === 'start'
? powerlineConfig.startCaps
: powerlineConfig.endCaps;
if (caps.length === 0)
return 'none';
if (caps.length > 1)
return 'multiple';
const cap = caps[0];
if (!cap)
return 'none';
const presets = type === 'start' ? [
{ char: '\uE0B2', name: 'Triangle' },
{ char: '\uE0B6', name: 'Round' },
{ char: '\uE0BA', name: 'Lower Triangle' },
{ char: '\uE0BE', name: 'Diagonal' }
] : [
{ char: '\uE0B0', name: 'Triangle' },
{ char: '\uE0B4', name: 'Round' },
{ char: '\uE0B8', name: 'Lower Triangle' },
{ char: '\uE0BC', name: 'Diagonal' }
];
const preset = presets.find(c => c.char === cap);
if (preset) {
return `${preset.char} - ${preset.name}`;
}
return `${cap} - Custom`;
};
const getThemeDisplay = (): string => {
const theme = powerlineConfig.theme;
if (!theme || theme === 'custom')
return 'Custom';
return theme.charAt(0).toUpperCase() + theme.slice(1);
};
const hasManualSeparatorItems = settings.lines.some(line => line.some(
item => item.type === 'separator'
));
const hasGlobalFgOverride = Boolean(settings.overrideForegroundColor && settings.overrideForegroundColor !== 'none');
const globalOverrideMessage = hasGlobalFgOverride ? '⚠ Global override for FG active' : null;
useInput((input, key) => {
// Block all input handling when font installation message is shown or installing
if (fontInstallMessage || installingFonts) {
// Only clear message on non-escape keys when message is shown
if (fontInstallMessage && !key.escape) {
onClearMessage();
}
// Always return early to prevent any other input handling
return;
}
// Skip input handling when confirmations are active - let ConfirmDialog handle it
if (confirmingFontInstall || confirmingEnable) {
return;
}
if (screen === 'menu') {
// Menu navigation mode
if (key.escape) {
onBack();
} else if (key.upArrow) {
setSelectedMenuItem(Math.max(0, selectedMenuItem - 1));
} else if (key.downArrow) {
setSelectedMenuItem(Math.min(menuItems.length - 1, selectedMenuItem + 1));
} else if (key.return) {
const selected = menuItems[selectedMenuItem];
if (selected) {
if (selected.value === 'back') {
onBack();
} else if (powerlineConfig.enabled) {
setScreen(selected.value as Screen);
}
}
} else if (input === 't' || input === 'T') {
// Toggle powerline mode
if (!powerlineConfig.enabled) {
// Only show confirmation when enabling if there are separators to remove
if (hasSeparatorItems) {
if (hasManualSeparatorItems) {
setConfirmingEnable(true);
} else {
// Set to nord theme if currently custom or undefined (first time enabling)
const theme = (!powerlineConfig.theme || powerlineConfig.theme === 'custom')
? getDefaultPowerlineTheme()
: powerlineConfig.theme;
// Enable directly without confirmation since there are no separators
const updatedSettings = {
...settings,
powerline: {
...powerlineConfig,
enabled: true,
theme,
// Separators are already initialized by Zod
separators: powerlineConfig.separators,
separatorInvertBackground: powerlineConfig.separatorInvertBackground
},
defaultPadding: ' ' // Set padding to space when enabling powerline
};
onUpdate(updatedSettings);
onUpdate(buildEnabledPowerlineSettings(settings, false));
}
} else {
// Disable without confirmation
const newConfig = { ...powerlineConfig, enabled: false };
onUpdate({ ...settings, powerline: newConfig });
onUpdate({
...settings,
powerline: {
...powerlineConfig,
enabled: false
}
});
}
} else if (input === 'i' || input === 'I') {
// Show font installation consent prompt
setConfirmingFontInstall(true);
} else if ((input === 'a' || input === 'A') && powerlineConfig.enabled) {
// Toggle autoAlign when powerline is enabled
const newConfig = { ...powerlineConfig, autoAlign: !powerlineConfig.autoAlign };
onUpdate({ ...settings, powerline: newConfig });
onUpdate({
...settings,
powerline: {
...powerlineConfig,
autoAlign: !powerlineConfig.autoAlign
}
});
} else if ((input === 'c' || input === 'C') && powerlineConfig.enabled) {
onUpdate({
...settings,
powerline: {
...powerlineConfig,
continueThemeAcrossLines: !powerlineConfig.continueThemeAcrossLines
}
});
}
}
});
// Render sub-screens
if (screen === 'separator') {
return (
<PowerlineSeparatorEditor
@@ -235,11 +268,18 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
);
}
// Main menu screen
return (
<Box flexDirection='column'>
{!confirmingFontInstall && !installingFonts && !fontInstallMessage && (
<Text bold>Powerline Setup</Text>
<Box>
<Text bold>Powerline Setup</Text>
{globalOverrideMessage && (
<Text color='yellow' dimColor>
{'. '}
{globalOverrideMessage}
</Text>
)}
</Box>
)}
{confirmingFontInstall ? (
@@ -307,45 +347,24 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
</Box>
) : confirmingEnable ? (
<Box flexDirection='column' marginTop={1}>
{hasSeparatorItems && (
{hasManualSeparatorItems && (
<>
<Box>
<Text color='yellow'> Warning: Enabling Powerline mode will remove all existing separators and flex-separators from your status lines.</Text>
<Text color='yellow'> Warning: Enabling Powerline mode will remove all existing manual separators from your status lines.</Text>
</Box>
<Box marginBottom={1}>
<Text dimColor>Powerline mode uses its own separator system and is incompatible with manual separators.</Text>
</Box>
</>
)}
<Box marginTop={hasSeparatorItems ? 1 : 0}>
<Box marginTop={hasManualSeparatorItems ? 1 : 0}>
<Text>Do you want to continue? </Text>
</Box>
<Box marginTop={1}>
<ConfirmDialog
inline={true}
onConfirm={() => {
// Set to nord theme if currently custom or undefined (first time enabling)
const theme = (!powerlineConfig.theme || powerlineConfig.theme === 'custom')
? getDefaultPowerlineTheme()
: powerlineConfig.theme;
// Remove all separators and flex-separators from lines
// Also set default padding to a space when enabling powerline
const updatedSettings = {
...settings,
powerline: {
...powerlineConfig,
enabled: true,
theme,
// Separators are already initialized by Zod
separators: powerlineConfig.separators,
separatorInvertBackground: powerlineConfig.separatorInvertBackground
},
defaultPadding: ' ', // Set padding to space when enabling powerline
lines: settings.lines.map(line => line.filter(item => item.type !== 'separator' && item.type !== 'flex-separator')
)
};
onUpdate(updatedSettings);
onUpdate(buildEnabledPowerlineSettings(settings, true));
setConfirmingEnable(false);
}}
onCancel={() => {
@@ -404,72 +423,50 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
<Text dimColor> - Press (a) to toggle</Text>
</Box>
<Box>
<Text> Continue Theme: </Text>
<Text color={powerlineConfig.continueThemeAcrossLines ? 'green' : 'red'}>
{powerlineConfig.continueThemeAcrossLines ? '✓ Enabled ' : '✗ Disabled '}
</Text>
<Text dimColor> - Press (c) to toggle</Text>
</Box>
<Box flexDirection='column' marginTop={1}>
<Text dimColor>
When enabled, global overrides are disabled and powerline separators are used
Powerline mode uses its own separator system
</Text>
<Text dimColor>
Continue Theme keeps the Powerline color sequence running across lines
</Text>
</Box>
</>
)}
<Box marginTop={1} flexDirection='column'>
{powerlineConfig.enabled ? (
<>
{menuItems.map((item, index) => {
const isSelected = index === selectedMenuItem;
let displayValue = '';
{!powerlineConfig.enabled && (
<Box marginTop={1}>
<Text dimColor>Enable Powerline mode to configure separators, caps, and themes.</Text>
</Box>
)}
switch (item.value) {
case 'separator':
displayValue = getSeparatorDisplay();
break;
case 'startCap':
displayValue = getCapDisplay('start');
break;
case 'endCap':
displayValue = getCapDisplay('end');
break;
case 'themes':
displayValue = getThemeDisplay();
break;
case 'back':
displayValue = '';
break;
}
<List
marginTop={1}
items={buildPowerlineSetupMenuItems(powerlineConfig)}
onSelect={(value) => {
if (value === 'back') {
onBack();
return;
}
if (item.value === 'back') {
return (
<Box key={item.value} marginTop={1}>
<Text color={isSelected ? 'green' : undefined}>
{isSelected ? '▶ ' : ' '}
{item.label}
</Text>
</Box>
);
}
return (
<Box key={item.value}>
<Text color={isSelected ? 'green' : undefined}>
{isSelected ? '▶ ' : ' '}
{item.label.padEnd(11, ' ')}
<Text dimColor>
{displayValue && `(${displayValue})`}
</Text>
</Text>
</Box>
);
})}
</>
) : (
// When powerline is disabled, show ESC to go back message
<Box marginTop={1}>
<Text dimColor>Press ESC to go back</Text>
</Box>
)}
</Box>
setScreen(value);
}}
onSelectionChange={(_, index) => {
setSelectedMenuItem(index);
}}
initialSelection={selectedMenuItem}
showBackButton={true}
/>
</>
)}
</Box>
);
};
};
+138 -129
View File
@@ -4,6 +4,8 @@ import {
useInput
} from 'ink';
import React, {
useEffect,
useMemo,
useRef,
useState
} from 'react';
@@ -16,6 +18,74 @@ import {
} from '../../utils/colors';
import { ConfirmDialog } from './ConfirmDialog';
import {
List,
type ListEntry
} from './List';
export function buildPowerlineThemeItems(
themes: string[],
originalTheme: string
): ListEntry<string>[] {
return themes.map((themeName) => {
const theme = getPowerlineTheme(themeName);
return {
label: theme?.name ?? themeName,
sublabel: themeName === originalTheme ? '(original)' : undefined,
value: themeName,
description: theme?.description ?? ''
};
});
}
export function applyCustomPowerlineTheme(
settings: Settings,
themeName: string
): Settings | null {
const theme = getPowerlineTheme(themeName);
if (!theme || themeName === 'custom') {
return null;
}
const colorLevel = getColorLevelString(settings.colorLevel);
const colorLevelKey = colorLevel === 'ansi16' ? '1' : colorLevel === 'ansi256' ? '2' : '3';
const themeColors = theme[colorLevelKey];
if (!themeColors) {
return null;
}
const lines = settings.lines.map((line) => {
let widgetColorIndex = 0;
return line.map((widget) => {
if (widget.type === 'separator' || widget.type === 'flex-separator') {
return widget;
}
const fgColor = themeColors.fg[widgetColorIndex % themeColors.fg.length];
const bgColor = themeColors.bg[widgetColorIndex % themeColors.bg.length];
widgetColorIndex++;
return {
...widget,
color: fgColor,
backgroundColor: bgColor
};
});
});
return {
...settings,
powerline: {
...settings.powerline,
theme: 'custom'
},
lines
};
}
export interface PowerlineThemeSelectorProps {
settings: Settings;
@@ -28,120 +98,63 @@ export const PowerlineThemeSelector: React.FC<PowerlineThemeSelectorProps> = ({
onUpdate,
onBack
}) => {
const themes = getPowerlineThemes();
const themes = useMemo(() => getPowerlineThemes(), []);
const currentTheme = settings.powerline.theme ?? 'custom';
const [selectedIndex, setSelectedIndex] = useState(Math.max(0, themes.indexOf(currentTheme)));
const [showCustomizeConfirm, setShowCustomizeConfirm] = useState(false);
const originalThemeRef = useRef(currentTheme);
const originalSettingsRef = useRef(settings);
const latestSettingsRef = useRef(settings);
const latestOnUpdateRef = useRef(onUpdate);
const didHandleInitialSelectionRef = useRef(false);
const applyTheme = (themeName: string) => {
// Simply change the theme setting, don't modify widget colors
const updatedSettings = {
...settings,
useEffect(() => {
latestSettingsRef.current = settings;
latestOnUpdateRef.current = onUpdate;
}, [settings, onUpdate]);
useEffect(() => {
const themeName = themes[selectedIndex];
if (!themeName) {
return;
}
if (!didHandleInitialSelectionRef.current) {
didHandleInitialSelectionRef.current = true;
return;
}
latestOnUpdateRef.current({
...latestSettingsRef.current,
powerline: {
...settings.powerline,
...latestSettingsRef.current.powerline,
theme: themeName
}
};
onUpdate(updatedSettings);
};
const customizeTheme = () => {
// Copy current theme's colors to widgets and switch to custom theme
const currentThemeName = themes[selectedIndex];
if (!currentThemeName) {
return;
}
const theme = getPowerlineTheme(currentThemeName);
if (!theme || currentThemeName === 'custom') {
// If already on custom, just go back
onBack();
return;
}
const colorLevel = getColorLevelString(settings.colorLevel);
const colorLevelKey = colorLevel === 'ansi16' ? '1' : colorLevel === 'ansi256' ? '2' : '3';
const themeColors = theme[colorLevelKey];
if (themeColors) {
// Apply theme colors to widgets
const newLines = settings.lines.map((line) => {
let widgetColorIndex = 0;
return line.map((widget) => {
// Skip separators
if (widget.type === 'separator' || widget.type === 'flex-separator') {
return widget;
}
const fgColor = themeColors.fg[widgetColorIndex % themeColors.fg.length];
const bgColor = themeColors.bg[widgetColorIndex % themeColors.bg.length];
widgetColorIndex++;
return {
...widget,
color: fgColor,
backgroundColor: bgColor
};
});
});
const updatedSettings = {
...settings,
powerline: {
...settings.powerline,
theme: 'custom'
},
lines: newLines
};
onUpdate(updatedSettings);
}
onBack();
};
});
}, [selectedIndex, themes]);
useInput((input, key) => {
// Skip input handling when confirmation is active - let ConfirmDialog handle it
if (showCustomizeConfirm) {
return;
}
{
// Normal input handling
if (key.escape) {
// Restore original settings completely when canceling
onUpdate(originalSettingsRef.current);
onBack();
} else if (key.upArrow) {
const newIndex = Math.max(0, selectedIndex - 1);
setSelectedIndex(newIndex);
const newTheme = themes[newIndex];
if (newTheme) {
applyTheme(newTheme);
}
} else if (key.downArrow) {
const newIndex = Math.min(themes.length - 1, selectedIndex + 1);
setSelectedIndex(newIndex);
const newTheme = themes[newIndex];
if (newTheme) {
applyTheme(newTheme);
}
} else if (key.return) {
// User confirmed their selection, so we keep the current theme
onBack();
} else if (input === 'c' || input === 'C') {
// Customize theme - copy theme colors to widgets
const currentThemeName = themes[selectedIndex];
if (currentThemeName && currentThemeName !== 'custom') {
setShowCustomizeConfirm(true);
}
if (key.escape) {
onUpdate(originalSettingsRef.current);
onBack();
} else if (input === 'c' || input === 'C') {
const currentThemeName = themes[selectedIndex];
if (currentThemeName && currentThemeName !== 'custom') {
setShowCustomizeConfirm(true);
}
}
});
const selectedThemeName = themes[selectedIndex];
const selectedTheme = selectedThemeName ? getPowerlineTheme(selectedThemeName) : undefined;
const themeItems = useMemo(
() => buildPowerlineThemeItems(themes, originalThemeRef.current),
[themes]
);
if (showCustomizeConfirm) {
return (
@@ -159,8 +172,14 @@ export const PowerlineThemeSelector: React.FC<PowerlineThemeSelectorProps> = ({
<ConfirmDialog
inline={true}
onConfirm={() => {
customizeTheme();
if (selectedThemeName) {
const updatedSettings = applyCustomPowerlineTheme(settings, selectedThemeName);
if (updatedSettings) {
onUpdate(updatedSettings);
}
}
setShowCustomizeConfirm(false);
onBack();
}}
onCancel={() => {
setShowCustomizeConfirm(false);
@@ -185,42 +204,32 @@ export const PowerlineThemeSelector: React.FC<PowerlineThemeSelectorProps> = ({
</Text>
</Box>
<Box marginTop={1} flexDirection='column'>
{themes.map((themeName, index) => {
const theme = getPowerlineTheme(themeName);
const isSelected = index === selectedIndex;
const isOriginal = themeName === originalThemeRef.current;
<List
marginTop={1}
items={themeItems}
onSelect={() => {
onBack();
}}
onSelectionChange={(themeName, index) => {
if (themeName === 'back') {
return;
}
return (
<Box key={themeName}>
<Text color={isSelected ? 'green' : undefined}>
{isSelected ? '▶ ' : ' '}
{theme?.name ?? themeName}
{isOriginal && <Text dimColor> (original)</Text>}
</Text>
</Box>
);
})}
</Box>
setSelectedIndex(index);
}}
initialSelection={selectedIndex}
/>
{selectedTheme && (
<Box marginTop={2} flexDirection='column'>
<Text dimColor>Description:</Text>
<Box marginLeft={2}>
<Text>{selectedTheme.description}</Text>
</Box>
{selectedThemeName && selectedThemeName !== 'custom' && (
<Box marginTop={1}>
<Text dimColor>Press (c) to customize this theme - copies colors to widgets</Text>
</Box>
)}
{settings.colorLevel === 1 && (
<Box>
<Text color='yellow'> 16 color mode themes have a very limited palette, we recommend switching color level in Terminal Options</Text>
</Box>
)}
{selectedThemeName && selectedThemeName !== 'custom' && (
<Box marginTop={1}>
<Text dimColor>Press (c) to customize this theme - copies colors to widgets</Text>
</Box>
)}
{settings.colorLevel === 1 && (
<Box marginTop={1}>
<Text color='yellow'> 16 color mode themes have a very limited palette, we recommend switching color level in Terminal Options</Text>
</Box>
)}
</Box>
);
};
};
+265
View File
@@ -0,0 +1,265 @@
import {
Box,
Text,
useInput
} from 'ink';
import React, { useState } from 'react';
import { shouldInsertInput } from '../../utils/input-guards';
import {
List,
type ListEntry
} from './List';
type ConfigureStatusLineValue = 'refreshInterval' | 'gitCacheTtl';
function getRefreshInputValue(interval: number | null): string {
return interval === null ? '' : String(interval);
}
function getRefreshIntervalSublabel(interval: number | null, supported: boolean): string {
if (!supported) {
return '(requires Claude Code >=2.1.97)';
}
if (interval === null) {
return '(not set)';
}
return `(${interval}s)`;
}
function getGitCacheTtlSublabel(ttlSeconds: number): string {
return ttlSeconds === 0
? '(mtime only)'
: `(${ttlSeconds}s)`;
}
export function buildConfigureStatusLineItems(
refreshInterval: number | null,
supportsRefreshInterval: boolean,
gitCacheTtlSeconds: number
): ListEntry<ConfigureStatusLineValue>[] {
return [
{
label: '🔄 Refresh Interval',
sublabel: getRefreshIntervalSublabel(refreshInterval, supportsRefreshInterval),
value: 'refreshInterval',
disabled: !supportsRefreshInterval,
description: supportsRefreshInterval
? 'How often Claude Code refreshes the status line by re-running the command. Enter value in seconds (1-60), or leave empty to remove.'
: 'This setting requires Claude Code version 2.1.97 or later. Please update Claude Code to use this feature.'
},
{
label: '🧮 Git Cache TTL',
sublabel: getGitCacheTtlSublabel(gitCacheTtlSeconds),
value: 'gitCacheTtl',
description: 'How long git widget subprocess output can be reused while .git/HEAD and .git/index are unchanged. Enter 0-60 seconds;\n0 disables age-based expiry, so cached output is reused until those git metadata mtimes change.'
}
];
}
export function validateRefreshIntervalInput(value: string): string | null {
if (value === '') {
return null;
}
const parsed = parseInt(value, 10);
if (isNaN(parsed)) {
return 'Please enter a valid number';
}
if (parsed < 1) {
return `Minimum interval is 1s (you entered ${parsed}s)`;
}
if (parsed > 60) {
return `Maximum interval is 60s (you entered ${parsed}s)`;
}
return null;
}
export function validateGitCacheTtlInput(value: string): string | null {
const parsed = parseInt(value, 10);
if (value === '' || isNaN(parsed)) {
return 'Please enter a valid number';
}
if (parsed < 0) {
return `Minimum Git cache TTL is 0s (you entered ${parsed}s)`;
}
if (parsed > 60) {
return `Maximum Git cache TTL is 60s (you entered ${parsed}s)`;
}
return null;
}
export interface RefreshIntervalMenuProps {
currentInterval: number | null;
supportsRefreshInterval: boolean;
gitCacheTtlSeconds: number;
onUpdate: (interval: number | null) => void;
onGitCacheTtlUpdate: (ttlSeconds: number) => void;
onBack: () => void;
}
export const RefreshIntervalMenu: React.FC<RefreshIntervalMenuProps> = ({
currentInterval,
supportsRefreshInterval,
gitCacheTtlSeconds,
onUpdate,
onGitCacheTtlUpdate,
onBack
}) => {
const [editingRefreshInterval, setEditingRefreshInterval] = useState(false);
const [editingGitCacheTtl, setEditingGitCacheTtl] = useState(false);
const [refreshInput, setRefreshInput] = useState(() => getRefreshInputValue(currentInterval));
const [gitCacheTtlInput, setGitCacheTtlInput] = useState(() => String(gitCacheTtlSeconds));
const [validationError, setValidationError] = useState<string | null>(null);
useInput((input, key) => {
if (editingRefreshInterval) {
if (key.return) {
if (refreshInput === '') {
onUpdate(null);
setEditingRefreshInterval(false);
setValidationError(null);
return;
}
const error = validateRefreshIntervalInput(refreshInput);
if (error) {
setValidationError(error);
} else {
const value = parseInt(refreshInput, 10);
onUpdate(value);
setEditingRefreshInterval(false);
setValidationError(null);
}
} else if (key.escape) {
setRefreshInput(getRefreshInputValue(currentInterval));
setEditingRefreshInterval(false);
setValidationError(null);
} else if (key.backspace) {
setRefreshInput(refreshInput.slice(0, -1));
setValidationError(null);
} else if (key.delete) {
// No cursor position in simple input
} else if (shouldInsertInput(input, key) && /\d/.test(input)) {
const newValue = refreshInput + input;
if (newValue.length <= 2) {
setRefreshInput(newValue);
setValidationError(null);
}
}
return;
}
if (editingGitCacheTtl) {
if (key.return) {
const error = validateGitCacheTtlInput(gitCacheTtlInput);
if (error) {
setValidationError(error);
} else {
const value = parseInt(gitCacheTtlInput, 10);
onGitCacheTtlUpdate(value);
setEditingGitCacheTtl(false);
setValidationError(null);
}
} else if (key.escape) {
setGitCacheTtlInput(String(gitCacheTtlSeconds));
setEditingGitCacheTtl(false);
setValidationError(null);
} else if (key.backspace) {
setGitCacheTtlInput(gitCacheTtlInput.slice(0, -1));
setValidationError(null);
} else if (key.delete) {
// No cursor position in simple input
} else if (shouldInsertInput(input, key) && /\d/.test(input)) {
const newValue = gitCacheTtlInput + input;
if (newValue.length <= 2) {
setGitCacheTtlInput(newValue);
setValidationError(null);
}
}
return;
}
if (key.escape) {
onBack();
}
});
return (
<Box flexDirection='column'>
<Text bold>Configure Status Line</Text>
<Text color='white'>Configure Claude Code status line settings</Text>
{editingRefreshInterval ? (
<Box marginTop={1} flexDirection='column'>
<Text>
Enter refresh interval in seconds (1-60):
{' '}
{refreshInput}
{refreshInput.length > 0 ? 's' : ''}
</Text>
{validationError ? (
<Text color='red'>{validationError}</Text>
) : (
<Text dimColor>Press Enter to confirm, ESC to cancel. Leave empty to remove.</Text>
)}
</Box>
) : editingGitCacheTtl ? (
<Box marginTop={1} flexDirection='column'>
<Text>
Enter Git cache TTL in seconds (0-60):
{' '}
{gitCacheTtlInput}
{gitCacheTtlInput.length > 0 ? 's' : ''}
</Text>
<Text> </Text>
<Text dimColor wrap='wrap'>
This affects how quickly git widgets notice unstaged and untracked working-tree changes.
</Text>
{validationError ? (
<Text color='red'>{validationError}</Text>
) : (
<Text dimColor>
0 disables age-based expiry; cache validity uses .git/HEAD and .git/index mtimes only.
</Text>
)}
<Text dimColor>Press Enter to confirm, ESC to cancel.</Text>
</Box>
) : (
<List
marginTop={1}
items={buildConfigureStatusLineItems(currentInterval, supportsRefreshInterval, gitCacheTtlSeconds)}
onSelect={(value) => {
if (value === 'back') {
onBack();
return;
}
if (value === 'refreshInterval') {
setRefreshInput(getRefreshInputValue(currentInterval));
setEditingRefreshInterval(true);
return;
}
setGitCacheTtlInput(String(gitCacheTtlSeconds));
setEditingGitCacheTtl(true);
}}
showBackButton={true}
/>
)}
</Box>
);
};
+53 -16
View File
@@ -8,14 +8,21 @@ import React from 'react';
import type { RenderContext } from '../../types/RenderContext';
import type { Settings } from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import {
getVisibleWidth,
stripOscCodes,
truncateStyledText
} from '../../utils/ansi';
import { advanceGlobalPowerlineThemeIndex } from '../../utils/powerline-theme-index';
import {
calculateMaxWidthsFromPreRendered,
countPowerlineStartCapSlots,
preRenderAllWidgets,
renderStatusLineWithInfo,
type PreRenderedWidget,
type RenderResult
} from '../../utils/renderer';
import { canDetectTerminalWidth } from '../../utils/terminal';
import { advanceGlobalSeparatorIndex } from '../../utils/separator-index';
export interface StatusLinePreviewProps {
lines: WidgetItem[][];
@@ -27,10 +34,11 @@ export interface StatusLinePreviewProps {
const renderSingleLine = (
widgets: WidgetItem[],
terminalWidth: number,
widthDetectionAvailable: boolean,
settings: Settings,
lineIndex: number,
globalSeparatorIndex: number,
globalPowerlineThemeIndex: number,
globalPowerlineStartCapIndex: number,
preRenderedWidgets: PreRenderedWidget[],
preCalculatedMaxWidths: number[]
): RenderResult => {
@@ -38,16 +46,26 @@ const renderSingleLine = (
const context: RenderContext = {
terminalWidth,
isPreview: true,
minimalist: settings.minimalistMode,
gitCacheTtlSeconds: settings.gitCacheTtlSeconds,
lineIndex,
globalSeparatorIndex
globalSeparatorIndex,
globalPowerlineThemeIndex,
globalPowerlineStartCapIndex
};
return renderStatusLineWithInfo(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
};
export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, terminalWidth, settings, onTruncationChange }) => {
const widthDetectionAvailable = React.useMemo(() => canDetectTerminalWidth(), []);
const PREVIEW_LINE_INDENT = ' ';
export function preparePreviewLineForTerminal(line: string, terminalWidth: number): string {
const printableLine = stripOscCodes(line);
const availableWidth = Math.max(0, terminalWidth - getVisibleWidth(PREVIEW_LINE_INDENT));
return truncateStyledText(printableLine, availableWidth, { ellipsis: true });
}
export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, terminalWidth, settings, onTruncationChange }) => {
// Render each configured line
// Pass the full terminal width - the renderer will handle preview adjustments
const { renderedLines, anyTruncated } = React.useMemo(() => {
@@ -55,10 +73,17 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
return { renderedLines: [], anyTruncated: false };
// Always pre-render all widgets once (for efficiency)
const preRenderedLines = preRenderAllWidgets(lines, settings, { terminalWidth, isPreview: true });
const preRenderedLines = preRenderAllWidgets(lines, settings, {
terminalWidth,
isPreview: true,
minimalist: settings.minimalistMode,
gitCacheTtlSeconds: settings.gitCacheTtlSeconds
});
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
let globalSeparatorIndex = 0;
let globalPowerlineThemeIndex = 0;
let globalPowerlineStartCapIndex = 0;
const result: string[] = [];
let truncated = false;
@@ -66,22 +91,34 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
const lineItems = lines[i];
if (lineItems && lineItems.length > 0) {
const preRenderedWidgets = preRenderedLines[i] ?? [];
const renderResult = renderSingleLine(lineItems, terminalWidth, widthDetectionAvailable, settings, i, globalSeparatorIndex, preRenderedWidgets, preCalculatedMaxWidths);
const renderResult = renderSingleLine(
lineItems,
terminalWidth,
settings,
i,
globalSeparatorIndex,
globalPowerlineThemeIndex,
globalPowerlineStartCapIndex,
preRenderedWidgets,
preCalculatedMaxWidths
);
result.push(renderResult.line);
if (renderResult.wasTruncated) {
truncated = true;
}
// Count separators used in this line (widgets - 1, excluding merged widgets)
const nonMergedWidgets = lineItems.filter((_, idx) => idx === lineItems.length - 1 || !lineItems[idx]?.merge);
if (nonMergedWidgets.length > 1) {
globalSeparatorIndex += nonMergedWidgets.length - 1;
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems, preRenderedWidgets);
if (settings.powerline.enabled) {
globalPowerlineStartCapIndex += countPowerlineStartCapSlots(lineItems, preRenderedWidgets);
}
if (settings.powerline.enabled && settings.powerline.continueThemeAcrossLines) {
globalPowerlineThemeIndex = advanceGlobalPowerlineThemeIndex(globalPowerlineThemeIndex, preRenderedWidgets);
}
}
}
return { renderedLines: result, anyTruncated: truncated };
}, [lines, terminalWidth, widthDetectionAvailable, settings]);
}, [lines, terminalWidth, settings]);
// Notify parent when truncation status changes
React.useEffect(() => {
@@ -97,12 +134,12 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
</Text>
</Box>
{renderedLines.map((line, index) => (
<Text key={index}>
{' '}
{line}
<Text key={index} wrap='truncate'>
{PREVIEW_LINE_INDENT}
{preparePreviewLineForTerminal(line, terminalWidth)}
{chalk.reset('')}
</Text>
))}
</Box>
);
};
};
+98 -162
View File
@@ -7,10 +7,56 @@ import {
import React, { useState } from 'react';
import type { Settings } from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import { getWidget } from '../../utils/widgets';
import {
hasCustomWidgetColors,
sanitizeLinesForColorLevel
} from '../../utils/color-sanitize';
import { ConfirmDialog } from './ConfirmDialog';
import {
List,
type ListEntry
} from './List';
type TerminalOptionsValue = 'width' | 'colorLevel';
export function getNextColorLevel(level: 0 | 1 | 2 | 3): 0 | 1 | 2 | 3 {
return ((level + 1) % 4) as 0 | 1 | 2 | 3;
}
export function shouldWarnOnColorLevelChange(
currentLevel: 0 | 1 | 2 | 3,
nextLevel: 0 | 1 | 2 | 3,
hasCustomColors: boolean
): boolean {
return hasCustomColors
&& ((currentLevel === 2 && nextLevel !== 2)
|| (currentLevel === 3 && nextLevel !== 3));
}
export function buildTerminalOptionsItems(
colorLevel: 0 | 1 | 2 | 3
): ListEntry<TerminalOptionsValue>[] {
return [
{
label: '◱ Terminal Width',
value: 'width',
description: 'Configure how the status line uses available terminal width and when it should compact.'
},
{
label: '▓ Color Level',
sublabel: `(${getColorLevelLabel(colorLevel)})`,
value: 'colorLevel',
description: [
'Color level affects how colors are rendered:',
'• Truecolor: Full 24-bit RGB colors (16.7M colors)',
'• 256 Color: Extended color palette (256 colors)',
'• Basic: Standard 16-color terminal palette',
'• No Color: Disables all color output'
].join('\n')
}
];
}
export interface TerminalOptionsMenuProps {
settings: Settings;
@@ -18,124 +64,51 @@ export interface TerminalOptionsMenuProps {
onBack: (target?: string) => void;
}
export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settings, onUpdate, onBack }) => {
export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({
settings,
onUpdate,
onBack
}) => {
const [showColorWarning, setShowColorWarning] = useState(false);
const [pendingColorLevel, setPendingColorLevel] = useState<0 | 1 | 2 | 3 | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const handleSelect = () => {
if (selectedIndex === 2) {
// Back button
const handleSelect = (value: TerminalOptionsValue | 'back') => {
if (value === 'back') {
onBack();
} else if (selectedIndex === 0) {
// Terminal Width Options
onBack('width');
} else if (selectedIndex === 1) {
// Color Level
// Check if there are any custom colors that would be lost
const hasCustomColors = settings.lines.some((line: WidgetItem[]) => line.some((widget: WidgetItem) => Boolean(widget.color && (widget.color.startsWith('ansi256:') || widget.color.startsWith('hex:')))
|| Boolean(widget.backgroundColor && (widget.backgroundColor.startsWith('ansi256:') || widget.backgroundColor.startsWith('hex:')))
)
);
const currentLevel = settings.colorLevel;
const nextLevel = ((currentLevel + 1) % 4) as 0 | 1 | 2 | 3;
// Warn if switching away from mode that supports custom colors
if (hasCustomColors
&& ((currentLevel === 2 && nextLevel !== 2) // Switching from 256 color mode
|| (currentLevel === 3 && nextLevel !== 3))) { // Switching from truecolor mode
setShowColorWarning(true);
setPendingColorLevel(nextLevel);
} else {
// Update chalk level immediately
chalk.level = nextLevel;
// Clean up incompatible custom colors even when no warning is shown
const cleanedLines = settings.lines.map(line => line.map((widget) => {
const newWidget = { ...widget };
// Remove custom colors incompatible with the new mode
if (nextLevel === 2) {
// Switching to 256 color mode - remove hex colors
if (widget.color?.startsWith('hex:')) {
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
if (widgetImpl) {
newWidget.color = widgetImpl.getDefaultColor();
}
}
}
if (widget.backgroundColor?.startsWith('hex:')) {
newWidget.backgroundColor = undefined;
}
} else if (nextLevel === 3) {
// Switching to truecolor mode - remove ansi256 colors
if (widget.color?.startsWith('ansi256:')) {
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
if (widgetImpl) {
newWidget.color = widgetImpl.getDefaultColor();
}
}
}
if (widget.backgroundColor?.startsWith('ansi256:')) {
newWidget.backgroundColor = undefined;
}
} else {
// Switching to 16 color mode - remove all custom colors
if (widget.color?.startsWith('ansi256:') || widget.color?.startsWith('hex:')) {
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
if (widgetImpl) {
newWidget.color = widgetImpl.getDefaultColor();
}
}
}
if (widget.backgroundColor?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('hex:')) {
newWidget.backgroundColor = undefined;
}
}
return newWidget;
})
);
onUpdate({
...settings,
lines: cleanedLines,
colorLevel: nextLevel
});
}
return;
}
if (value === 'width') {
onBack('width');
return;
}
const hasCustomColors = hasCustomWidgetColors(settings.lines);
const currentLevel = settings.colorLevel;
const nextLevel = getNextColorLevel(currentLevel);
if (shouldWarnOnColorLevelChange(currentLevel, nextLevel, hasCustomColors)) {
setShowColorWarning(true);
setPendingColorLevel(nextLevel);
return;
}
chalk.level = nextLevel;
const cleanedLines = sanitizeLinesForColorLevel(settings.lines, nextLevel);
onUpdate({
...settings,
lines: cleanedLines,
colorLevel: nextLevel
});
};
const handleColorConfirm = () => {
// Proceed with color level change and clean up custom colors
if (pendingColorLevel !== null) {
chalk.level = pendingColorLevel;
// Clean up custom colors if switching away from modes that support them
const cleanedLines = settings.lines.map(line => line.map((widget) => {
const newWidget = { ...widget };
// Remove custom colors if switching to a mode that doesn't support them
if ((pendingColorLevel !== 2 && pendingColorLevel !== 3)
|| (pendingColorLevel === 2 && (widget.color?.startsWith('hex:') || widget.backgroundColor?.startsWith('hex:')))
|| (pendingColorLevel === 3 && (widget.color?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('ansi256:')))) {
// Reset custom colors to defaults
if (widget.color?.startsWith('ansi256:') || widget.color?.startsWith('hex:')) {
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
if (widgetImpl) {
newWidget.color = widgetImpl.getDefaultColor();
}
}
}
if (widget.backgroundColor?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('hex:')) {
newWidget.backgroundColor = undefined;
}
}
return newWidget;
})
);
const cleanedLines = sanitizeLinesForColorLevel(settings.lines, pendingColorLevel);
onUpdate({
...settings,
@@ -152,19 +125,9 @@ export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settin
setPendingColorLevel(null);
};
useInput((input, key) => {
if (key.escape) {
if (!showColorWarning) {
onBack();
}
} else if (!showColorWarning) {
if (key.upArrow) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow) {
setSelectedIndex(Math.min(2, selectedIndex + 1));
} else if (key.return) {
handleSelect();
}
useInput((_, key) => {
if (key.escape && !showColorWarning) {
onBack();
}
});
@@ -187,39 +150,12 @@ export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settin
) : (
<>
<Text color='white'>Configure terminal-specific settings for optimal display</Text>
<Box marginTop={1} flexDirection='column'>
<Box>
<Text color={selectedIndex === 0 ? 'green' : undefined}>
{selectedIndex === 0 ? '▶ ' : ' '}
Terminal Width
</Text>
</Box>
<Box>
<Text color={selectedIndex === 1 ? 'green' : undefined}>
{selectedIndex === 1 ? '▶ ' : ' '}
Color Level:
{' '}
{getColorLevelLabel(settings.colorLevel)}
</Text>
</Box>
<Box marginTop={1}>
<Text color={selectedIndex === 2 ? 'green' : undefined}>
{selectedIndex === 2 ? '▶ ' : ' '}
Back
</Text>
</Box>
</Box>
{selectedIndex === 1 && (
<Box marginTop={1} flexDirection='column'>
<Text dimColor>Color level affects how colors are rendered:</Text>
<Text dimColor> Truecolor: Full 24-bit RGB colors (16.7M colors)</Text>
<Text dimColor> 256 Color: Extended color palette (256 colors)</Text>
<Text dimColor> Basic: Standard 16-color terminal palette</Text>
<Text dimColor> No Color: Disables all color output</Text>
</Box>
)}
<List
marginTop={1}
items={buildTerminalOptionsItems(settings.colorLevel)}
onSelect={handleSelect}
showBackButton={true}
/>
</>
)}
</Box>
@@ -228,11 +164,11 @@ export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settin
export const getColorLevelLabel = (level?: 0 | 1 | 2 | 3): string => {
switch (level) {
case 0: return 'No Color';
case 1: return 'Basic';
case 2:
case undefined: return '256 Color (default)';
case 3: return 'Truecolor';
default: return '256 Color (default)';
case 0: return 'No Color';
case 1: return 'Basic';
case 2:
case undefined: return '256 Color (default)';
case 3: return 'Truecolor';
default: return '256 Color (default)';
}
};
};
+94 -95
View File
@@ -9,38 +9,89 @@ import type { FlexMode } from '../../types/FlexMode';
import type { Settings } from '../../types/Settings';
import { shouldInsertInput } from '../../utils/input-guards';
import {
List,
type ListEntry
} from './List';
export const TERMINAL_WIDTH_OPTIONS: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact'];
export function getTerminalWidthSelectionIndex(selectedOption: FlexMode): number {
const selectedIndex = TERMINAL_WIDTH_OPTIONS.indexOf(selectedOption);
return selectedIndex >= 0 ? selectedIndex : 0;
}
export function validateCompactThresholdInput(value: string): string | null {
const parsedValue = parseInt(value, 10);
if (isNaN(parsedValue)) {
return 'Please enter a valid number';
}
if (parsedValue < 1 || parsedValue > 99) {
return `Value must be between 1 and 99 (you entered ${parsedValue})`;
}
return null;
}
export function buildTerminalWidthItems(
selectedOption: FlexMode,
compactThreshold: number
): ListEntry<FlexMode>[] {
return [
{
value: 'full',
label: 'Full width always',
sublabel: selectedOption === 'full' ? '(active)' : undefined,
description: 'Uses the full terminal width minus 4 characters for terminal padding. If the auto-compact message appears, it may cause the line to wrap.\n\nNOTE: If /ide integration is enabled, it is not recommended to use this mode.'
},
{
value: 'full-minus-40',
label: 'Full width minus 40',
sublabel: selectedOption === 'full-minus-40' ? '(active)' : '(default)',
description: 'Leaves a gap to the right of the status line to accommodate the auto-compact message. This prevents wrapping but may leave unused space. This limitation exists because we cannot detect when the message will appear.'
},
{
value: 'full-until-compact',
label: 'Full width until compact',
sublabel: selectedOption === 'full-until-compact'
? `(threshold ${compactThreshold}%, active)`
: `(threshold ${compactThreshold}%)`,
description: `Dynamically adjusts width based on context usage. When context reaches ${compactThreshold}%, it switches to leaving space for the auto-compact message.\n\nNOTE: If /ide integration is enabled, it is not recommended to use this mode.`
}
];
}
export interface TerminalWidthMenuProps {
settings: Settings;
onUpdate: (settings: Settings) => void;
onBack: () => void;
}
export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({ settings, onUpdate, onBack }) => {
export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({
settings,
onUpdate,
onBack
}) => {
const [selectedOption, setSelectedOption] = useState<FlexMode>(settings.flexMode);
const [compactThreshold, setCompactThreshold] = useState(settings.compactThreshold);
const [editingThreshold, setEditingThreshold] = useState(false);
const [thresholdInput, setThresholdInput] = useState(String(settings.compactThreshold));
const [validationError, setValidationError] = useState<string | null>(null);
// For manual navigation: 0-2 for options, 3 for back
const [selectedIndex, setSelectedIndex] = useState(() => {
const options: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact'];
return options.indexOf(settings.flexMode);
});
const options: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact'];
useInput((input, key) => {
if (editingThreshold) {
if (key.return) {
const value = parseInt(thresholdInput, 10);
if (isNaN(value)) {
setValidationError('Please enter a valid number');
} else if (value < 1 || value > 99) {
setValidationError(`Value must be between 1 and 99 (you entered ${value})`);
const error = validateCompactThresholdInput(thresholdInput);
if (error) {
setValidationError(error);
} else {
const value = parseInt(thresholdInput, 10);
setCompactThreshold(value);
// Update settings with both flexMode and the new threshold
const updatedSettings = {
...settings,
flexMode: selectedOption,
@@ -66,59 +117,14 @@ export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({ settings,
setValidationError(null);
}
}
} else {
if (key.escape) {
onBack();
} else if (key.upArrow) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow) {
setSelectedIndex(Math.min(3, selectedIndex + 1)); // 0-2 for options, 3 for back
} else if (key.return) {
if (selectedIndex === 3) {
onBack();
} else if (selectedIndex >= 0 && selectedIndex < options.length) {
const mode = options[selectedIndex];
if (mode) {
setSelectedOption(mode);
return;
}
// Update settings
const updatedSettings = {
...settings,
flexMode: mode,
compactThreshold: compactThreshold
};
onUpdate(updatedSettings);
if (mode === 'full-until-compact') {
// Prompt for threshold editing
setEditingThreshold(true);
}
}
}
}
if (key.escape) {
onBack();
}
});
const optionDetails = [
{
value: 'full' as FlexMode,
label: 'Full width always',
description: 'Uses the full terminal width minus 4 characters for terminal padding. If the auto-compact message appears, it may cause the line to wrap.\n\nNOTE: If /ide integration is enabled, it\'s not recommended to use this mode.'
},
{
value: 'full-minus-40' as FlexMode,
label: 'Full width minus 40 (default)',
description: 'Leaves a gap to the right of the status line to accommodate the auto-compact message. This prevents wrapping but may leave unused space. This limitation exists because we cannot detect when the message will appear.'
},
{
value: 'full-until-compact' as FlexMode,
label: 'Full width until compact',
description: `Dynamically adjusts width based on context usage. When context reaches ${compactThreshold}%, it switches to leaving space for the auto-compact message.\n\nNOTE: If /ide integration is enabled, it's not recommended to use this mode.`
}
];
const currentOption = selectedIndex < 3 ? optionDetails[selectedIndex] : null;
return (
<Box flexDirection='column'>
<Text bold>Terminal Width</Text>
@@ -140,39 +146,32 @@ export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({ settings,
)}
</Box>
) : (
<>
<Box marginTop={1} flexDirection='column'>
{optionDetails.map((opt, index) => (
<Box key={opt.value}>
<Text color={selectedIndex === index ? 'green' : undefined}>
{selectedIndex === index ? '▶ ' : ' '}
{opt.label}
{opt.value === selectedOption ? ' ✓' : ''}
</Text>
</Box>
))}
<List
marginTop={1}
items={buildTerminalWidthItems(selectedOption, compactThreshold)}
initialSelection={getTerminalWidthSelectionIndex(selectedOption)}
onSelect={(value) => {
if (value === 'back') {
onBack();
return;
}
<Box marginTop={1}>
<Text color={selectedIndex === 3 ? 'green' : undefined}>
{selectedIndex === 3 ? '▶ ' : ' '}
Back
</Text>
</Box>
</Box>
setSelectedOption(value);
{currentOption && (
<Box marginTop={1} marginBottom={1} borderStyle='round' borderColor='dim' paddingX={1}>
<Box flexDirection='column'>
<Text>
<Text color='yellow'>{currentOption.label}</Text>
{currentOption.value === 'full-until-compact' && ` | Current threshold: ${compactThreshold}%`}
</Text>
<Text dimColor wrap='wrap'>{currentOption.description}</Text>
</Box>
</Box>
)}
</>
const updatedSettings = {
...settings,
flexMode: value,
compactThreshold
};
onUpdate(updatedSettings);
if (value === 'full-until-compact') {
setEditingThreshold(true);
}
}}
showBackButton={true}
/>
)}
</Box>
);
};
};
+233
View File
@@ -0,0 +1,233 @@
import {
Box,
Text,
useInput
} from 'ink';
import React from 'react';
import type {
UpdateAction,
UpdateCheckResult
} from '../../utils/update-checker';
import {
List,
type ListEntry
} from './List';
export type UpdateCheckerState = { status: 'checking' } | UpdateCheckResult;
export interface UpdateCheckerMenuProps {
state: UpdateCheckerState;
onBack: () => void;
onRefresh: () => void;
onRunAction: (action: UpdateAction) => void;
}
type UpdateMenuAction = UpdateAction | 'refresh';
function getInstallationLabel(result: UpdateCheckResult): string {
const { installation } = result;
if (installation.method === 'auto-update') {
return `Auto-update via ${installation.packageManager}`;
}
if (installation.method === 'pinned') {
const version = installation.installedVersion
? ` ${installation.installedVersion}`
: '';
const manager = installation.packageManager === 'unknown'
? ''
: ` via ${installation.packageManager}`;
return `Pinned global install${manager}${version}`;
}
if (installation.method === 'self-managed') {
return 'Self-managed/global install';
}
return 'Unknown or not installed';
}
function getActionLabel(action: UpdateAction): string {
return `Run ${action.command}`;
}
function getActionSublabel(action: UpdateAction): string | undefined {
if (action.available) {
return undefined;
}
return action.packageManager === 'npm'
? '(npm not installed)'
: '(bun not installed)';
}
function getActionItems(actions: UpdateAction[]): ListEntry<UpdateMenuAction>[] {
return [
...actions.map((action): ListEntry<UpdateMenuAction> => ({
label: getActionLabel(action),
value: action,
disabled: !action.available,
sublabel: getActionSublabel(action)
})),
{
label: 'Check again',
value: 'refresh'
}
];
}
export const UpdateCheckerMenu: React.FC<UpdateCheckerMenuProps> = ({
state,
onBack,
onRefresh,
onRunAction
}) => {
useInput((_, key) => {
if (key.escape) {
onBack();
}
});
if (state.status === 'checking') {
return (
<Box flexDirection='column'>
<Text bold>Check for Updates</Text>
<Box marginTop={1}>
<Text dimColor>Checking npm registry...</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection='column'>
<Text bold>Check for Updates</Text>
<Box marginTop={1} flexDirection='column'>
<Text>
Current:
{' '}
{state.currentVersion}
</Text>
{state.status !== 'registry-failure' && (
<Text>
Latest:
{' '}
{state.latestVersion}
</Text>
)}
<Text>
Install:
{' '}
{getInstallationLabel(state)}
</Text>
</Box>
{state.status === 'registry-failure' && (
<>
<Box marginTop={1}>
<Text color='red'>
Registry check failed:
{' '}
{state.errorMessage}
</Text>
</Box>
<List
marginTop={1}
items={[{ label: 'Check again', value: 'refresh' }]}
onSelect={(value) => {
if (value === 'back') {
onBack();
return;
}
onRefresh();
}}
showBackButton={true}
/>
</>
)}
{state.status === 'up-to-date' && (
<>
<Box marginTop={1}>
<Text color='green'>ccstatusline is up to date.</Text>
</Box>
<List
marginTop={1}
items={[{ label: 'Check again', value: 'refresh' }]}
onSelect={(value) => {
if (value === 'back') {
onBack();
return;
}
onRefresh();
}}
showBackButton={true}
/>
</>
)}
{state.status === 'update-available' && (
<>
<Box marginTop={1}>
<Text color='yellow'>An update is available.</Text>
</Box>
{state.installation.method === 'auto-update' && (
<Box marginTop={1} flexDirection='column'>
<Text>No manual hook change is needed. Claude Code already runs @latest.</Text>
<Text>The next @latest invocation will resolve the latest package.</Text>
<Text>
Launch command for a fresh TUI:
{' '}
{state.autoUpdateLaunchCommand}
</Text>
</Box>
)}
{state.actions.length > 0 && (
<List
marginTop={1}
items={getActionItems(state.actions)}
onSelect={(value) => {
if (value === 'back') {
onBack();
return;
}
if (value === 'refresh') {
onRefresh();
return;
}
onRunAction(value);
}}
showBackButton={true}
/>
)}
{state.actions.length === 0 && (
<List
marginTop={1}
items={[{ label: 'Check again', value: 'refresh' }]}
onSelect={(value) => {
if (value === 'back') {
onBack();
return;
}
onRefresh();
}}
showBackButton={true}
/>
)}
</>
)}
</Box>
);
};
@@ -0,0 +1,122 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import {
describe,
expect,
it,
vi
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../../types/Settings';
import type { WidgetItem } from '../../../types/Widget';
import { ColorMenu } from '../ColorMenu';
class MockTtyStream extends PassThrough {
isTTY = true;
columns = 160;
rows = 40;
setRawMode() {
return this;
}
ref() {
return this;
}
unref() {
return this;
}
}
interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string }
function createMockStdin(): NodeJS.ReadStream {
return new MockTtyStream() as unknown as NodeJS.ReadStream;
}
function createMockStdout(): CapturedWriteStream {
const stream = new MockTtyStream();
const chunks: string[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(chunk.toString());
});
return Object.assign(stream as unknown as NodeJS.WriteStream, {
getOutput() {
return chunks.join('');
}
});
}
function flushInk() {
return new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
describe('ColorMenu', () => {
it('keeps bold and dim indicators on the current-style row', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const widgets: WidgetItem[] = [
{ id: '1', type: 'cache-hit-rate' },
{
id: '2',
type: 'cache-read',
color: 'hex:ABB2BF',
backgroundColor: 'bgBrightBlack',
bold: true,
dim: 'parens'
},
{ id: '3', type: 'cache-write' },
{ id: '4', type: 'tokens-cached' }
];
const instance = render(
React.createElement(ColorMenu, {
widgets,
settings: {
...DEFAULT_SETTINGS,
colorLevel: 3,
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: true
}
},
onUpdate: vi.fn(),
onBack: vi.fn()
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('\x1B[B');
await flushInk();
const latestFrame = stdout.getOutput().split('Configure Colors').at(-1) ?? '';
const currentStyleLine = latestFrame
.split('\n')
.find(line => line.includes('Current foreground')) ?? '';
expect(currentStyleLine).toContain('[BOLD] [DIM ()]');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
});
@@ -0,0 +1,377 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../../types/Settings';
import { GlobalOverridesMenu } from '../GlobalOverridesMenu';
class MockTtyStream extends PassThrough {
isTTY = true;
columns = 120;
rows = 40;
setRawMode() {
return this;
}
ref() {
return this;
}
unref() {
return this;
}
}
interface CapturedWriteStream extends NodeJS.WriteStream {
clearOutput: () => void;
getOutput: () => string;
}
function createMockStdin(): NodeJS.ReadStream {
return new MockTtyStream() as unknown as NodeJS.ReadStream;
}
function createMockStdout(): CapturedWriteStream {
const stream = new MockTtyStream();
const chunks: string[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(chunk.toString());
});
return Object.assign(stream as unknown as NodeJS.WriteStream, {
clearOutput() {
chunks.length = 0;
},
getOutput() {
return chunks.join('');
}
});
}
function flushInk() {
return new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
describe('GlobalOverridesMenu', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('displays minimalist mode as disabled by default', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();
const instance = render(
React.createElement(GlobalOverridesMenu, {
settings: DEFAULT_SETTINGS,
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
expect(stdout.getOutput()).toContain('Minimalist Mode:');
expect(stdout.getOutput()).toContain('✗ Disabled');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('toggles minimalist mode on when (m) is pressed', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();
const instance = render(
React.createElement(GlobalOverridesMenu, {
settings: { ...DEFAULT_SETTINGS, minimalistMode: false },
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('m');
await flushInk();
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ minimalistMode: true }));
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('toggles minimalist mode off when (m) is pressed while enabled', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();
const instance = render(
React.createElement(GlobalOverridesMenu, {
settings: { ...DEFAULT_SETTINGS, minimalistMode: true },
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('m');
await flushInk();
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ minimalistMode: false }));
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('displays padding side as "Both" by default', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();
const instance = render(
React.createElement(GlobalOverridesMenu, {
settings: DEFAULT_SETTINGS,
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
expect(stdout.getOutput()).toContain('Padding Side:');
expect(stdout.getOutput()).toContain('Both');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it.each([
{ starting: 'both' as const, expected: 'left' as const },
{ starting: 'left' as const, expected: 'right' as const },
{ starting: 'right' as const, expected: 'both' as const }
])('cycles padding side from "$starting" to "$expected" when (d) is pressed', async ({ starting, expected }) => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();
const instance = render(
React.createElement(GlobalOverridesMenu, {
settings: { ...DEFAULT_SETTINGS, defaultPaddingSide: starting },
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('d');
await flushInk();
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ defaultPaddingSide: expected }));
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('shows foreground override gradient and clear controls on the same line', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();
const instance = render(
React.createElement(GlobalOverridesMenu, {
settings: DEFAULT_SETTINGS,
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
const output = stdout.getOutput();
expect(output).toContain('Override FG Color:');
expect(output).toContain('(f) cycle, (g) gradient, (x) clear');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('applies a foreground override gradient from the preset selector', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();
const instance = render(
React.createElement(GlobalOverridesMenu, {
settings: { ...DEFAULT_SETTINGS, colorLevel: 3 },
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('g');
await flushInk();
expect(stdout.getOutput()).toContain('Select Gradient - Override FG Color');
stdin.write('\r');
await flushInk();
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ overrideForegroundColor: 'gradient:atlas' }));
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('clears the foreground override when (x) is pressed', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();
const instance = render(
React.createElement(GlobalOverridesMenu, {
settings: { ...DEFAULT_SETTINGS, overrideForegroundColor: 'gradient:atlas' },
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('x');
await flushInk();
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ overrideForegroundColor: undefined }));
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
});
@@ -0,0 +1,270 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import stripAnsi from 'strip-ansi';
import {
describe,
expect,
it,
vi
} from 'vitest';
import { InstallMenu } from '../InstallMenu';
const ALL_AVAILABLE = {
npm: true,
npx: true,
bun: true,
bunx: true
};
class MockTtyStream extends PassThrough {
isTTY = true;
columns = 120;
rows = 40;
setRawMode() {
return this;
}
ref() {
return this;
}
unref() {
return this;
}
}
interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string }
function createMockStdin(): NodeJS.ReadStream {
return new MockTtyStream() as unknown as NodeJS.ReadStream;
}
function createMockStdout(): CapturedWriteStream {
const stream = new MockTtyStream();
const chunks: string[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(chunk.toString());
});
return Object.assign(stream as unknown as NodeJS.WriteStream, {
getOutput() {
return stripAnsi(chunks.join(''));
}
});
}
function flushInk() {
return new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
describe('InstallMenu', () => {
it('calls onCancel when escape is pressed', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onCancel = vi.fn();
const instance = render(
React.createElement(InstallMenu, {
commandAvailability: ALL_AVAILABLE,
currentVersion: '2.2.13',
existingStatusLine: null,
onSelect: vi.fn(),
onCancel
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('\u001B');
await flushInk();
expect(onCancel).toHaveBeenCalledTimes(1);
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('renders both update styles without a recommendation label', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const instance = render(
React.createElement(InstallMenu, {
commandAvailability: ALL_AVAILABLE,
currentVersion: '2.2.13',
existingStatusLine: null,
onSelect: vi.fn(),
onCancel: vi.fn()
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
const output = stdout.getOutput();
expect(output).toContain('Auto-update');
expect(output).toContain('Pinned global install');
expect(output.toLowerCase()).not.toContain('recommended');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('shows pinned global install first and selected by default', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const instance = render(
React.createElement(InstallMenu, {
commandAvailability: ALL_AVAILABLE,
currentVersion: '2.2.13',
existingStatusLine: null,
onSelect: vi.fn(),
onCancel: vi.fn()
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
const output = stdout.getOutput();
expect(output.indexOf('Pinned global install')).toBeLessThan(output.indexOf('Auto-update'));
expect(output).toContain('▶ Pinned global install');
expect(output).not.toContain('▶ Auto-update');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('shows unavailable package managers as disabled in step two', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const instance = render(
React.createElement(InstallMenu, {
commandAvailability: {
npm: true,
npx: false,
bun: true,
bunx: false
},
currentVersion: '2.2.13',
existingStatusLine: null,
onSelect: vi.fn(),
onCancel: vi.fn()
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('\r');
await flushInk();
const output = stdout.getOutput();
expect(output).toContain('npm install -g ccstatusline@2.2.13');
expect(output).not.toContain('(npm not installed)');
expect(output).toContain('bun add -g ccstatusline@2.2.13');
expect(output).not.toContain('(bun not installed)');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('returns from package manager selection to update style on escape', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onCancel = vi.fn();
const instance = render(
React.createElement(InstallMenu, {
commandAvailability: ALL_AVAILABLE,
currentVersion: '2.2.13',
existingStatusLine: null,
onSelect: vi.fn(),
onCancel
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('\r');
await flushInk();
expect(stdout.getOutput()).toContain('Select package manager');
stdin.write('\u001B');
await flushInk();
expect(onCancel).not.toHaveBeenCalled();
expect(stdout.getOutput()).toContain('Select update style');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
});
@@ -0,0 +1,64 @@
import {
describe,
expect,
it
} from 'vitest';
import {
buildManageInstallationItems,
buildUninstallItems
} from '../ManageInstallationMenu';
describe('ManageInstallationMenu helpers', () => {
it('builds update and uninstall actions', () => {
expect(buildManageInstallationItems().map(item => item.value)).toEqual([
'checkUpdates',
'uninstall'
]);
expect(buildManageInstallationItems()[0]?.label).toBe('🔄 Check for Updates');
});
it('offers npm, bun, and combined package removal when both are installed', () => {
const items = buildUninstallItems([
{
packageManager: 'npm',
available: true,
installed: true,
binDir: '/usr/local/bin'
},
{
packageManager: 'bun',
available: true,
installed: true,
binDir: '/home/alice/.bun/bin'
}
]);
expect(items.map(item => item.value.packageManagers)).toEqual([
[],
['npm'],
['bun'],
['npm', 'bun']
]);
});
it('only offers Claude settings removal when no global package is detected', () => {
const items = buildUninstallItems([
{
packageManager: 'npm',
available: true,
installed: false,
binDir: '/usr/local/bin'
},
{
packageManager: 'bun',
available: false,
installed: false,
binDir: null
}
]);
expect(items).toHaveLength(1);
expect(items[0]?.value.packageManagers).toEqual([]);
});
});
@@ -0,0 +1,292 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../../types/Settings';
import {
PowerlineSeparatorEditor,
type PowerlineSeparatorEditorProps
} from '../PowerlineSeparatorEditor';
import {
PowerlineSetup,
buildPowerlineSetupMenuItems,
getCapDisplay,
getSeparatorDisplay,
getThemeDisplay,
type PowerlineSetupProps
} from '../PowerlineSetup';
class MockTtyStream extends PassThrough {
isTTY = true;
columns = 120;
rows = 40;
setRawMode() {
return this;
}
ref() {
return this;
}
unref() {
return this;
}
}
interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string }
function createMockStdin(): NodeJS.ReadStream {
return new MockTtyStream() as unknown as NodeJS.ReadStream;
}
function createMockStdout(): CapturedWriteStream {
const stream = new MockTtyStream();
const chunks: string[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(chunk.toString());
});
return Object.assign(stream as unknown as NodeJS.WriteStream, {
getOutput() {
return chunks.join('');
}
});
}
function flushInk() {
return new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
describe('PowerlineSetup helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('formats separator, cap, and theme display values', () => {
const config = {
...DEFAULT_SETTINGS.powerline,
enabled: true,
separators: ['\uE0B4'],
startCaps: ['\uE0B2'],
endCaps: ['\uE0B0'],
theme: 'gruvbox'
};
expect(getSeparatorDisplay(config)).toBe('\uE0B4 - Round Right');
expect(getCapDisplay(config, 'start')).toBe('\uE0B2 - Triangle');
expect(getCapDisplay(config, 'end')).toBe('\uE0B0 - Triangle');
expect(getThemeDisplay(config)).toBe('Gruvbox');
});
it('builds powerline setup items with disabled states and sublabels', () => {
const disabledItems = buildPowerlineSetupMenuItems({
...DEFAULT_SETTINGS.powerline,
enabled: false
});
expect(disabledItems.every(item => item.disabled)).toBe(true);
const enabledItems = buildPowerlineSetupMenuItems({
...DEFAULT_SETTINGS.powerline,
enabled: true,
separators: ['\uE0B0', '\uE0B4'],
startCaps: [],
endCaps: ['\uE0BC'],
theme: undefined
});
expect(enabledItems[0]).toMatchObject({
label: 'Separator ',
sublabel: '(multiple)',
disabled: false
});
expect(enabledItems[1]).toMatchObject({
label: 'Start Cap ',
sublabel: '(none)'
});
expect(enabledItems[2]).toMatchObject({
label: 'End Cap ',
sublabel: '(\uE0BC - Diagonal)'
});
expect(enabledItems[3]).toMatchObject({
label: 'Themes ',
sublabel: '(Custom)'
});
});
it('toggles continue theme across lines when (c) is pressed', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn<PowerlineSetupProps['onUpdate']>();
const onBack = vi.fn();
const onInstallFonts = vi.fn();
const onClearMessage = vi.fn();
const instance = render(
React.createElement(PowerlineSetup, {
settings: {
...DEFAULT_SETTINGS,
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: true,
continueThemeAcrossLines: false
}
},
powerlineFontStatus: { installed: true },
onUpdate,
onBack,
onInstallFonts,
installingFonts: false,
fontInstallMessage: null,
onClearMessage
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
expect(stdout.getOutput()).toContain('Continue Theme:');
stdin.write('c');
await flushInk();
const updatedSettings = onUpdate.mock.calls[0]?.[0];
expect(updatedSettings).toBeDefined();
expect(updatedSettings?.powerline.continueThemeAcrossLines).toBe(true);
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('warns when a global foreground override is active', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn<PowerlineSetupProps['onUpdate']>();
const onBack = vi.fn();
const onInstallFonts = vi.fn();
const onClearMessage = vi.fn();
const instance = render(
React.createElement(PowerlineSetup, {
settings: {
...DEFAULT_SETTINGS,
overrideForegroundColor: 'gradient:atlas',
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: true
}
},
powerlineFontStatus: { installed: true },
onUpdate,
onBack,
onInstallFonts,
installingFonts: false,
fontInstallMessage: null,
onClearMessage
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
expect(stdout.getOutput()).toContain('Powerline Setup');
expect(stdout.getOutput()).toContain('⚠ Global override for FG active');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
});
describe('PowerlineSeparatorEditor', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
['startCap', 'startCaps', '\uE0B2'],
['endCap', 'endCaps', '\uE0B0']
] as const)('allows adding more than 3 %s entries', async (mode, capKey, expectedDefaultCap) => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn<PowerlineSeparatorEditorProps['onUpdate']>();
const onBack = vi.fn();
const existingCaps = [expectedDefaultCap, expectedDefaultCap, expectedDefaultCap];
const instance = render(
React.createElement(PowerlineSeparatorEditor, {
settings: {
...DEFAULT_SETTINGS,
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: true,
[capKey]: existingCaps
}
},
mode,
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
expect(stdout.getOutput()).toContain('(a)dd');
stdin.write('a');
await flushInk();
const updatedSettings = onUpdate.mock.calls[0]?.[0];
expect(updatedSettings).toBeDefined();
expect(updatedSettings?.powerline[capKey]).toHaveLength(4);
expect(updatedSettings?.powerline[capKey][1]).toBe(expectedDefaultCap);
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
});
@@ -0,0 +1,160 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../../types/Settings';
import { getPowerlineThemes } from '../../../utils/colors';
import {
PowerlineThemeSelector,
applyCustomPowerlineTheme,
buildPowerlineThemeItems,
type PowerlineThemeSelectorProps
} from '../PowerlineThemeSelector';
class MockTtyStream extends PassThrough {
isTTY = true;
columns = 120;
rows = 40;
setRawMode() {
return this;
}
ref() {
return this;
}
unref() {
return this;
}
}
function createMockStdin(): NodeJS.ReadStream {
return new MockTtyStream() as unknown as NodeJS.ReadStream;
}
function createMockStdout(): NodeJS.WriteStream {
return new MockTtyStream() as unknown as NodeJS.WriteStream;
}
function flushInk() {
return new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
describe('PowerlineThemeSelector helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('builds powerline theme list items with original theme sublabels', () => {
const items = buildPowerlineThemeItems(['gruvbox', 'onedark'], 'onedark');
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
label: 'Gruvbox',
value: 'gruvbox'
});
expect(items[1]).toMatchObject({
label: 'One Dark',
sublabel: '(original)',
value: 'onedark'
});
});
it('copies a built-in theme into widget colors and switches to custom mode', () => {
const settings = {
...DEFAULT_SETTINGS,
colorLevel: 2 as const,
powerline: {
...DEFAULT_SETTINGS.powerline,
theme: 'gruvbox'
}
};
const updatedSettings = applyCustomPowerlineTheme(settings, 'gruvbox');
expect(updatedSettings).not.toBeNull();
expect(updatedSettings?.powerline.theme).toBe('custom');
expect(updatedSettings?.lines[0]?.[0]).toMatchObject({
color: 'ansi256:16',
backgroundColor: 'ansi256:167'
});
expect(updatedSettings?.lines[0]?.[1]).toEqual(settings.lines[0]?.[1]);
expect(updatedSettings?.lines[0]?.[2]).toMatchObject({
color: 'ansi256:235',
backgroundColor: 'ansi256:214'
});
});
it('returns null when the requested theme cannot be customized', () => {
expect(applyCustomPowerlineTheme(DEFAULT_SETTINGS, 'custom')).toBeNull();
expect(applyCustomPowerlineTheme(DEFAULT_SETTINGS, 'missing-theme')).toBeNull();
});
it('previews the highlighted theme once without triggering update-depth warnings', async () => {
const themes = getPowerlineThemes();
expect(themes.length).toBeGreaterThan(1);
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn<PowerlineThemeSelectorProps['onUpdate']>();
const onBack = vi.fn();
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const instance = render(
React.createElement(PowerlineThemeSelector, {
settings: {
...DEFAULT_SETTINGS,
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: true,
theme: themes[0]
}
},
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
expect(onUpdate).not.toHaveBeenCalled();
stdin.write('\u001B[B');
await flushInk();
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(onUpdate.mock.calls[0]?.[0]?.powerline.theme).toBe(themes[1]);
const maximumUpdateDepthWarnings = consoleErrorSpy.mock.calls.filter((call) => {
return call.some(arg => typeof arg === 'string' && arg.includes('Maximum update depth exceeded'));
});
expect(maximumUpdateDepthWarnings).toHaveLength(0);
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
});
@@ -0,0 +1,241 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import {
describe,
expect,
it,
vi
} from 'vitest';
import {
RefreshIntervalMenu,
buildConfigureStatusLineItems,
validateGitCacheTtlInput,
validateRefreshIntervalInput
} from '../RefreshIntervalMenu';
class MockTtyStream extends PassThrough {
isTTY = true;
columns = 120;
rows = 40;
setRawMode() {
return this;
}
ref() {
return this;
}
unref() {
return this;
}
}
interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string }
function createMockStdin(): NodeJS.ReadStream {
return new MockTtyStream() as unknown as NodeJS.ReadStream;
}
function createMockStdout(): CapturedWriteStream {
const stream = new MockTtyStream();
const chunks: string[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(chunk.toString());
});
return Object.assign(stream as unknown as NodeJS.WriteStream, {
getOutput() {
return chunks.join('');
}
});
}
function flushInk() {
return new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
describe('validateRefreshIntervalInput', () => {
it('should accept empty string (remove interval)', () => {
expect(validateRefreshIntervalInput('')).toBeNull();
});
it('should accept valid values within range', () => {
expect(validateRefreshIntervalInput('1')).toBeNull();
expect(validateRefreshIntervalInput('10')).toBeNull();
expect(validateRefreshIntervalInput('30')).toBeNull();
expect(validateRefreshIntervalInput('60')).toBeNull();
});
it('should reject values below minimum', () => {
expect(validateRefreshIntervalInput('0')).toContain('Minimum');
});
it('should reject values above maximum', () => {
expect(validateRefreshIntervalInput('61')).toContain('Maximum');
});
it('should reject non-numeric input', () => {
expect(validateRefreshIntervalInput('abc')).toContain('valid number');
});
});
describe('validateGitCacheTtlInput', () => {
it('should accept valid values within range', () => {
expect(validateGitCacheTtlInput('0')).toBeNull();
expect(validateGitCacheTtlInput('5')).toBeNull();
expect(validateGitCacheTtlInput('60')).toBeNull();
});
it('should reject values outside the range', () => {
expect(validateGitCacheTtlInput('-1')).toContain('Minimum');
expect(validateGitCacheTtlInput('61')).toContain('Maximum');
});
it('should reject empty and non-numeric input', () => {
expect(validateGitCacheTtlInput('')).toContain('valid number');
expect(validateGitCacheTtlInput('abc')).toContain('valid number');
});
});
describe('buildConfigureStatusLineItems', () => {
it('should show (not set) when interval is null and supported', () => {
const items = buildConfigureStatusLineItems(null, true, 5);
expect(items[0]?.sublabel).toBe('(not set)');
});
it('should show seconds for set intervals', () => {
const items = buildConfigureStatusLineItems(10, true, 5);
expect(items[0]?.sublabel).toBe('(10s)');
});
it('should show seconds for small values', () => {
const items = buildConfigureStatusLineItems(1, true, 5);
expect(items[0]?.sublabel).toBe('(1s)');
});
it('should show version requirement when not supported', () => {
const items = buildConfigureStatusLineItems(null, false, 5);
expect(items[0]?.sublabel).toContain('requires Claude Code');
expect(items[0]?.disabled).toBe(true);
});
it('should not be disabled when supported', () => {
const items = buildConfigureStatusLineItems(10, true, 5);
expect(items[0]?.disabled).toBeFalsy();
});
it('should show the configured Git cache TTL', () => {
const items = buildConfigureStatusLineItems(10, true, 5);
expect(items[1]?.label).toContain('Git Cache TTL');
expect(items[1]?.sublabel).toBe('(5s)');
});
it('should describe zero Git cache TTL as mtime-only', () => {
const items = buildConfigureStatusLineItems(10, true, 0);
expect(items[1]?.sublabel).toBe('(mtime only)');
});
});
describe('RefreshIntervalMenu', () => {
it('keeps an unset interval empty when reopening the editor', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();
const instance = render(
React.createElement(RefreshIntervalMenu, {
currentInterval: null,
supportsRefreshInterval: true,
gitCacheTtlSeconds: 5,
onUpdate,
onGitCacheTtlUpdate: vi.fn(),
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('\r');
await flushInk();
expect(stdout.getOutput()).toContain('Enter refresh interval in seconds (1-60):');
expect(stdout.getOutput()).not.toContain('10s');
stdin.write('\r');
await flushInk();
expect(onUpdate).toHaveBeenCalledWith(null);
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('shows helper text while editing Git cache TTL and saves updates', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onGitCacheTtlUpdate = vi.fn();
const onBack = vi.fn();
const instance = render(
React.createElement(RefreshIntervalMenu, {
currentInterval: 10,
supportsRefreshInterval: true,
gitCacheTtlSeconds: 0,
onUpdate,
onGitCacheTtlUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('\u001B[B');
await flushInk();
stdin.write('\r');
await flushInk();
expect(stdout.getOutput()).toContain('Enter Git cache TTL in seconds (0-60):');
expect(stdout.getOutput()).toContain('unstaged and untracked working-tree changes');
stdin.write('\r');
await flushInk();
expect(onGitCacheTtlUpdate).toHaveBeenCalledWith(0);
expect(onUpdate).not.toHaveBeenCalled();
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
});
@@ -0,0 +1,156 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import {
describe,
expect,
it
} from 'vitest';
import {
DEFAULT_SETTINGS,
type Settings
} from '../../../types/Settings';
import type { WidgetItem } from '../../../types/Widget';
import { getVisibleWidth } from '../../../utils/ansi';
import { renderOsc8Link } from '../../../utils/hyperlink';
import {
StatusLinePreview,
preparePreviewLineForTerminal
} from '../StatusLinePreview';
class MockTtyStream extends PassThrough {
isTTY = true;
columns = 160;
rows = 40;
setRawMode() {
return this;
}
ref() {
return this;
}
unref() {
return this;
}
}
interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string }
function createMockStdin(): NodeJS.ReadStream {
return new MockTtyStream() as unknown as NodeJS.ReadStream;
}
function createMockStdout(): CapturedWriteStream {
const stream = new MockTtyStream();
const chunks: string[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(chunk.toString());
});
return Object.assign(stream as unknown as NodeJS.WriteStream, {
getOutput() {
return chunks.join('');
}
});
}
function flushInk() {
return new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
describe('StatusLinePreview helpers', () => {
it('strips OSC links and clamps preview lines to the terminal width', () => {
const line = `${renderOsc8Link(
'https://github.com/owner/repo/pull/42',
'PR #42'
)} OPEN ${'Example PR title '.repeat(8)}`;
const prepared = preparePreviewLineForTerminal(line, 40);
expect(prepared).not.toContain('github.com');
expect(prepared.endsWith('...')).toBe(true);
expect(getVisibleWidth(` ${prepared}`)).toBeLessThanOrEqual(40);
});
it('keeps parens dim scoped in the Ink preview when global bold is active', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const settings: Settings = {
...DEFAULT_SETTINGS,
colorLevel: 3,
globalBold: true,
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: true,
theme: 'custom',
separators: ['\uE0B0'],
separatorInvertBackground: [false]
}
};
const lines: WidgetItem[][] = [[
{
id: 'w1',
type: 'custom-text',
customText: 'Cache Hit: 87.0%',
color: 'hex:282C34',
backgroundColor: 'hex:61AFEF'
},
{
id: 'w2',
type: 'custom-text',
customText: 'Cache Read: 12k (64.0%)',
color: 'hex:ABB2BF',
backgroundColor: 'hex:3E4452',
dim: 'parens'
},
{
id: 'w3',
type: 'custom-text',
customText: 'Cache Write: 3k (16.0%)',
color: 'hex:282C34',
backgroundColor: 'hex:98C379'
}
]];
const instance = render(
React.createElement(StatusLinePreview, {
lines,
terminalWidth: 160,
settings
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
const output = stdout.getOutput();
const dimIndex = output.indexOf('\x1b[2m(64.0%)');
const resetIndex = output.indexOf('\x1b[22;1m', dimIndex);
const nextWidgetIndex = output.indexOf('Cache Write');
expect(dimIndex).toBeGreaterThanOrEqual(0);
expect(resetIndex).toBeGreaterThan(dimIndex);
expect(resetIndex).toBeLessThan(nextWidgetIndex);
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
});
@@ -0,0 +1,44 @@
import {
describe,
expect,
it
} from 'vitest';
import {
buildTerminalOptionsItems,
getNextColorLevel,
shouldWarnOnColorLevelChange
} from '../TerminalOptionsMenu';
describe('TerminalOptionsMenu helpers', () => {
it('cycles color levels in order', () => {
expect(getNextColorLevel(0)).toBe(1);
expect(getNextColorLevel(1)).toBe(2);
expect(getNextColorLevel(2)).toBe(3);
expect(getNextColorLevel(3)).toBe(0);
});
it('warns only when custom colors would be lost', () => {
expect(shouldWarnOnColorLevelChange(2, 3, true)).toBe(true);
expect(shouldWarnOnColorLevelChange(3, 0, true)).toBe(true);
expect(shouldWarnOnColorLevelChange(2, 2, true)).toBe(false);
expect(shouldWarnOnColorLevelChange(1, 2, true)).toBe(false);
expect(shouldWarnOnColorLevelChange(3, 0, false)).toBe(false);
});
it('builds terminal options list items with the current color level label', () => {
const items = buildTerminalOptionsItems(2);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
label: '◱ Terminal Width',
value: 'width'
});
expect(items[1]).toMatchObject({
label: '▓ Color Level',
sublabel: '(256 Color (default))',
value: 'colorLevel'
});
expect(items[1]?.description).toContain('Truecolor');
});
});
@@ -0,0 +1,169 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../../types/Settings';
import {
TerminalWidthMenu,
buildTerminalWidthItems,
getTerminalWidthSelectionIndex,
validateCompactThresholdInput
} from '../TerminalWidthMenu';
class MockTtyStream extends PassThrough {
isTTY = true;
columns = 120;
rows = 40;
setRawMode() {
return this;
}
ref() {
return this;
}
unref() {
return this;
}
}
interface CapturedWriteStream extends NodeJS.WriteStream {
clearOutput: () => void;
getOutput: () => string;
}
function createMockStdin(): NodeJS.ReadStream {
return new MockTtyStream() as unknown as NodeJS.ReadStream;
}
function createMockStdout(): CapturedWriteStream {
const stream = new MockTtyStream();
const chunks: string[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(chunk.toString());
});
return Object.assign(stream as unknown as NodeJS.WriteStream, {
clearOutput() {
chunks.length = 0;
},
getOutput() {
return chunks.join('');
}
});
}
function flushInk() {
return new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
describe('TerminalWidthMenu helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('validates compact threshold input', () => {
expect(validateCompactThresholdInput('')).toBe('Please enter a valid number');
expect(validateCompactThresholdInput('0')).toBe('Value must be between 1 and 99 (you entered 0)');
expect(validateCompactThresholdInput('100')).toBe('Value must be between 1 and 99 (you entered 100)');
expect(validateCompactThresholdInput('42')).toBeNull();
});
it('builds terminal width menu items with active and threshold sublabels', () => {
const items = buildTerminalWidthItems('full-until-compact', 60);
expect(items).toHaveLength(3);
expect(items[0]).toMatchObject({
label: 'Full width always',
value: 'full'
});
expect(items[1]).toMatchObject({
label: 'Full width minus 40',
sublabel: '(default)',
value: 'full-minus-40'
});
expect(items[2]).toMatchObject({
label: 'Full width until compact',
sublabel: '(threshold 60%, active)',
value: 'full-until-compact'
});
expect(items[2]?.description).toContain('60%');
});
it('returns the current option index for list selection', () => {
expect(getTerminalWidthSelectionIndex('full')).toBe(0);
expect(getTerminalWidthSelectionIndex('full-minus-40')).toBe(1);
expect(getTerminalWidthSelectionIndex('full-until-compact')).toBe(2);
});
it('keeps full-until-compact selected after confirming the threshold prompt', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();
const instance = render(
React.createElement(TerminalWidthMenu, {
settings: {
...DEFAULT_SETTINGS,
flexMode: 'full',
compactThreshold: 60
},
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('\u001B[B');
await flushInk();
stdin.write('\u001B[B');
await flushInk();
stdin.write('\r');
await flushInk();
expect(stdout.getOutput()).toContain('Enter compact threshold (1-99):');
stdout.clearOutput();
stdin.write('\r');
await flushInk();
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({
flexMode: 'full-until-compact',
compactThreshold: 60
}));
const output = stdout.getOutput();
expect(output).toContain('▶ Full width until compact');
expect(output).not.toContain('▶ Full width always');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
});
@@ -0,0 +1,155 @@
import {
describe,
expect,
it
} from 'vitest';
import type { WidgetItem } from '../../../../types/Widget';
import {
clearAllWidgetStyling,
cycleWidgetColor,
cycleWidgetDim,
resetWidgetStyling,
toggleWidgetBold,
updateWidgetById
} from '../mutations';
describe('color-menu mutations', () => {
it('updateWidgetById only updates the matching widget', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input', color: 'blue' },
{ id: '2', type: 'tokens-output', color: 'white' }
];
const updated = updateWidgetById(widgets, '1', widget => ({
...widget,
color: 'red'
}));
expect(updated[0]?.color).toBe('red');
expect(updated[1]?.color).toBe('white');
});
it('toggleWidgetBold flips bold state for the selected widget only', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input', bold: true },
{ id: '2', type: 'tokens-output', bold: false }
];
const updated = toggleWidgetBold(widgets, '1');
expect(updated[0]?.bold).toBe(false);
expect(updated[1]?.bold).toBe(false);
});
it('cycleWidgetDim cycles off, whole widget, parens, then off for the selected widget only', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input' },
{ id: '2', type: 'tokens-output' }
];
const whole = cycleWidgetDim(widgets, '1');
const parens = cycleWidgetDim(whole, '1');
const off = cycleWidgetDim(parens, '1');
expect(whole[0]?.dim).toBe(true);
expect(parens[0]?.dim).toBe('parens');
expect(off[0]).toEqual({ id: '1', type: 'tokens-input' });
expect(whole[1]?.dim).toBeUndefined();
});
it('resetWidgetStyling removes color, backgroundColor, bold, and dim from one widget', () => {
const widgets: WidgetItem[] = [
{
id: '1',
type: 'tokens-input',
color: 'red',
backgroundColor: 'blue',
bold: true,
dim: 'parens'
},
{ id: '2', type: 'tokens-output', color: 'white', bold: true }
];
const updated = resetWidgetStyling(widgets, '1');
expect(updated[0]).toEqual({ id: '1', type: 'tokens-input' });
expect(updated[1]).toEqual({ id: '2', type: 'tokens-output', color: 'white', bold: true });
});
it('clearAllWidgetStyling strips styling fields from every widget', () => {
const widgets: WidgetItem[] = [
{
id: '1',
type: 'tokens-input',
color: 'red',
backgroundColor: 'blue',
bold: true,
dim: true
},
{ id: '2', type: 'tokens-output', color: 'white', bold: true, dim: 'parens' }
];
const updated = clearAllWidgetStyling(widgets);
expect(updated).toEqual([
{ id: '1', type: 'tokens-input' },
{ id: '2', type: 'tokens-output' }
]);
});
it('cycles background colors and maps empty background to undefined', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input', backgroundColor: 'bg:red' }
];
const right = cycleWidgetColor({
widgets,
widgetId: '1',
direction: 'right',
editingBackground: true,
colors: ['blue', 'red'],
backgroundColors: ['bg:red', '']
});
const left = cycleWidgetColor({
widgets: right,
widgetId: '1',
direction: 'left',
editingBackground: true,
colors: ['blue', 'red'],
backgroundColors: ['bg:red', '']
});
expect(right[0]?.backgroundColor).toBeUndefined();
expect(left[0]?.backgroundColor).toBe('bg:red');
});
it('cycles foreground colors from widget default and treats dim as default', () => {
const fromDefault: WidgetItem[] = [
{ id: '1', type: 'tokens-input' }
];
const fromDim: WidgetItem[] = [
{ id: '1', type: 'tokens-input', color: 'dim' }
];
const defaultCycle = cycleWidgetColor({
widgets: fromDefault,
widgetId: '1',
direction: 'right',
editingBackground: false,
colors: ['blue', 'red'],
backgroundColors: ['bg:red', '']
});
const dimCycle = cycleWidgetColor({
widgets: fromDim,
widgetId: '1',
direction: 'right',
editingBackground: false,
colors: ['blue', 'red'],
backgroundColors: ['bg:red', '']
});
expect(defaultCycle[0]?.color).toBe('red');
expect(dimCycle[0]?.color).toBe('red');
});
});
+175
View File
@@ -0,0 +1,175 @@
import type { WidgetItem } from '../../../types/Widget';
import { getWidget } from '../../../utils/widgets';
export function updateWidgetById(
widgets: WidgetItem[],
widgetId: string,
updater: (widget: WidgetItem) => WidgetItem
): WidgetItem[] {
return widgets.map(widget => widget.id === widgetId ? updater(widget) : widget);
}
export function setWidgetColor(
widgets: WidgetItem[],
widgetId: string,
color: string,
editingBackground: boolean
): WidgetItem[] {
return updateWidgetById(widgets, widgetId, (widget) => {
if (editingBackground) {
return {
...widget,
backgroundColor: color
};
}
return {
...widget,
color
};
});
}
export function toggleWidgetBold(widgets: WidgetItem[], widgetId: string): WidgetItem[] {
return updateWidgetById(widgets, widgetId, widget => ({
...widget,
bold: !widget.bold
}));
}
export function cycleWidgetDim(widgets: WidgetItem[], widgetId: string): WidgetItem[] {
return updateWidgetById(widgets, widgetId, (widget) => {
// Cycle: off -> whole widget -> (...) spans only -> off
if (widget.dim === true) {
return {
...widget,
dim: 'parens' as const
};
}
if (widget.dim === 'parens') {
const { dim, ...restWidget } = widget;
void dim; // Intentionally unused
return restWidget;
}
return {
...widget,
dim: true
};
});
}
export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] {
return updateWidgetById(widgets, widgetId, (widget) => {
const {
color,
backgroundColor,
bold,
dim,
...restWidget
} = widget;
void color; // Intentionally unused
void backgroundColor; // Intentionally unused
void bold; // Intentionally unused
void dim; // Intentionally unused
return restWidget;
});
}
export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] {
return widgets.map((widget) => {
const {
color,
backgroundColor,
bold,
dim,
...restWidget
} = widget;
void color; // Intentionally unused
void backgroundColor; // Intentionally unused
void bold; // Intentionally unused
void dim; // Intentionally unused
return restWidget;
});
}
function getDefaultForegroundColor(widget: WidgetItem): string {
if (widget.type === 'separator' || widget.type === 'flex-separator') {
return 'white';
}
const widgetImpl = getWidget(widget.type);
return widgetImpl ? widgetImpl.getDefaultColor() : 'white';
}
function getNextIndex(currentIndex: number, length: number, direction: 'left' | 'right'): number {
if (direction === 'right') {
return (currentIndex + 1) % length;
}
return currentIndex === 0 ? length - 1 : currentIndex - 1;
}
export interface CycleWidgetColorOptions {
widgets: WidgetItem[];
widgetId: string;
direction: 'left' | 'right';
editingBackground: boolean;
colors: string[];
backgroundColors: string[];
}
export function cycleWidgetColor({
widgets,
widgetId,
direction,
editingBackground,
colors,
backgroundColors
}: CycleWidgetColorOptions): WidgetItem[] {
return updateWidgetById(widgets, widgetId, (widget) => {
if (editingBackground) {
if (backgroundColors.length === 0) {
return widget;
}
const currentBgColor = widget.backgroundColor ?? '';
let currentBgColorIndex = backgroundColors.indexOf(currentBgColor);
if (currentBgColorIndex === -1) {
currentBgColorIndex = 0;
}
const nextBgColorIndex = getNextIndex(currentBgColorIndex, backgroundColors.length, direction);
const nextBgColor = backgroundColors[nextBgColorIndex];
return {
...widget,
backgroundColor: nextBgColor === '' ? undefined : nextBgColor
};
}
if (colors.length === 0) {
return widget;
}
const defaultColor = getDefaultForegroundColor(widget);
let currentColor = widget.color ?? defaultColor;
if (currentColor === 'dim') {
currentColor = defaultColor;
}
let currentColorIndex = colors.indexOf(currentColor);
if (currentColorIndex === -1) {
currentColorIndex = 0;
}
const nextColorIndex = getNextIndex(currentColorIndex, colors.length, direction);
const nextColor = colors[nextColorIndex];
return {
...widget,
color: nextColor
};
});
}
+4 -1
View File
@@ -6,7 +6,10 @@ export * from './InstallMenu';
export * from './ItemsEditor';
export * from './LineSelector';
export * from './MainMenu';
export * from './ManageInstallationMenu';
export * from './PowerlineSetup';
export * from './RefreshIntervalMenu';
export * from './StatusLinePreview';
export * from './TerminalOptionsMenu';
export * from './TerminalWidthMenu';
export * from './TerminalWidthMenu';
export * from './UpdateCheckerMenu';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,502 @@
import type {
CustomKeybind,
Widget,
WidgetItem,
WidgetItemType
} from '../../../types/Widget';
import { generateGuid } from '../../../utils/guid';
import {
filterWidgetCatalog,
getWidget,
type WidgetCatalogEntry
} from '../../../utils/widgets';
export type WidgetPickerAction = 'change' | 'add' | 'insert';
export type WidgetPickerLevel = 'category' | 'widget';
export interface WidgetPickerState {
action: WidgetPickerAction;
level: WidgetPickerLevel;
selectedCategory: string | null;
categoryQuery: string;
widgetQuery: string;
selectedType: WidgetItemType | null;
}
export interface CustomEditorWidgetState {
widget: WidgetItem;
impl: Widget;
action?: string;
}
export interface InputKey {
ctrl?: boolean;
meta?: boolean;
tab?: boolean;
shift?: boolean;
upArrow?: boolean;
downArrow?: boolean;
leftArrow?: boolean;
rightArrow?: boolean;
return?: boolean;
escape?: boolean;
backspace?: boolean;
delete?: boolean;
}
type Setter<T> = (value: T | ((prev: T) => T)) => void;
function setPickerState(
setWidgetPicker: Setter<WidgetPickerState | null>,
normalizeState: (state: WidgetPickerState) => WidgetPickerState,
updater: (prev: WidgetPickerState) => WidgetPickerState
): void {
setWidgetPicker((prev) => {
if (!prev) {
return prev;
}
return normalizeState(updater(prev));
});
}
function getPickerCategories(widgetCategories: string[]): string[] {
return [...widgetCategories];
}
export function normalizePickerState(
state: WidgetPickerState,
widgetCatalog: WidgetCatalogEntry[],
widgetCategories: string[]
): WidgetPickerState {
const filteredCategories = getPickerCategories(widgetCategories);
const selectedCategory = state.selectedCategory && filteredCategories.includes(state.selectedCategory)
? state.selectedCategory
: (filteredCategories[0] ?? null);
const hasTopLevelSearch = state.level === 'category' && state.categoryQuery.trim().length > 0;
const effectiveCategory = hasTopLevelSearch ? 'All' : (selectedCategory ?? 'All');
const effectiveQuery = hasTopLevelSearch ? state.categoryQuery : state.widgetQuery;
const filteredWidgets = filterWidgetCatalog(widgetCatalog, effectiveCategory, effectiveQuery);
const hasSelectedType = state.selectedType
? filteredWidgets.some(entry => entry.type === state.selectedType)
: false;
return {
...state,
selectedCategory,
selectedType: hasSelectedType ? state.selectedType : (filteredWidgets[0]?.type ?? null)
};
}
interface PickerViewState {
filteredCategories: string[];
selectedCategory: string | null;
hasTopLevelSearch: boolean;
topLevelSearchEntries: WidgetCatalogEntry[];
topLevelSelectedEntry: WidgetCatalogEntry | undefined;
filteredWidgets: WidgetCatalogEntry[];
selectedEntry: WidgetCatalogEntry | undefined;
}
function getPickerViewState(
widgetPicker: WidgetPickerState,
widgetCatalog: WidgetCatalogEntry[],
widgetCategories: string[]
): PickerViewState {
const filteredCategories = getPickerCategories(widgetCategories);
const selectedCategory = widgetPicker.selectedCategory && filteredCategories.includes(widgetPicker.selectedCategory)
? widgetPicker.selectedCategory
: (filteredCategories[0] ?? null);
const hasTopLevelSearch = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0;
const topLevelSearchEntries = hasTopLevelSearch
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
: [];
const topLevelSelectedEntry = topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0];
const filteredWidgets = filterWidgetCatalog(widgetCatalog, selectedCategory ?? 'All', widgetPicker.widgetQuery);
const selectedEntry = filteredWidgets.find(entry => entry.type === widgetPicker.selectedType) ?? filteredWidgets[0];
return {
filteredCategories,
selectedCategory,
hasTopLevelSearch,
topLevelSearchEntries,
topLevelSelectedEntry,
filteredWidgets,
selectedEntry
};
}
export interface HandlePickerInputModeArgs {
input: string;
key: InputKey;
widgetPicker: WidgetPickerState;
widgetCatalog: WidgetCatalogEntry[];
widgetCategories: string[];
setWidgetPicker: Setter<WidgetPickerState | null>;
applyWidgetPickerSelection: (selectedType: WidgetItemType) => void;
}
export function handlePickerInputMode({
input,
key,
widgetPicker,
widgetCatalog,
widgetCategories,
setWidgetPicker,
applyWidgetPickerSelection
}: HandlePickerInputModeArgs): void {
const normalizeState = (state: WidgetPickerState) => normalizePickerState(state, widgetCatalog, widgetCategories);
const {
filteredCategories,
selectedCategory,
hasTopLevelSearch,
topLevelSearchEntries,
topLevelSelectedEntry,
filteredWidgets,
selectedEntry
} = getPickerViewState(widgetPicker, widgetCatalog, widgetCategories);
if (widgetPicker.level === 'category') {
if (key.escape) {
if (widgetPicker.categoryQuery.length > 0) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
categoryQuery: ''
}));
} else {
setWidgetPicker(null);
}
} else if (key.return) {
if (hasTopLevelSearch) {
if (topLevelSelectedEntry) {
applyWidgetPickerSelection(topLevelSelectedEntry.type);
}
} else if (selectedCategory) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
level: 'widget',
selectedCategory
}));
}
} else if (key.upArrow || key.downArrow) {
if (hasTopLevelSearch) {
if (topLevelSearchEntries.length === 0) {
return;
}
let currentIndex = topLevelSearchEntries.findIndex(entry => entry.type === widgetPicker.selectedType);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? (currentIndex + 1 > topLevelSearchEntries.length - 1 ? 0 : currentIndex + 1)
: (currentIndex - 1 < 0 ? topLevelSearchEntries.length - 1 : currentIndex - 1);
const nextType = topLevelSearchEntries[nextIndex]?.type ?? null;
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
selectedType: nextType
}));
} else {
if (filteredCategories.length === 0) {
return;
}
let currentIndex = filteredCategories.findIndex(category => category === selectedCategory);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? (currentIndex + 1 > filteredCategories.length - 1 ? 0 : currentIndex + 1)
: (currentIndex - 1 < 0 ? filteredCategories.length - 1 : currentIndex - 1);
const nextCategory = filteredCategories[nextIndex] ?? null;
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
selectedCategory: nextCategory
}));
}
} else if (key.backspace || key.delete) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
categoryQuery: prev.categoryQuery.slice(0, -1),
selectedType: null
}));
} else if (
input
&& !key.ctrl
&& !key.meta
&& !key.tab
) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
categoryQuery: prev.categoryQuery + input,
selectedType: null
}));
}
} else {
if (key.escape) {
if (widgetPicker.widgetQuery.length > 0) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
widgetQuery: ''
}));
} else {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
level: 'category'
}));
}
} else if (key.return) {
if (selectedEntry) {
applyWidgetPickerSelection(selectedEntry.type);
}
} else if (key.upArrow || key.downArrow) {
if (filteredWidgets.length === 0) {
return;
}
let currentIndex = filteredWidgets.findIndex(entry => entry.type === widgetPicker.selectedType);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? (currentIndex + 1 > filteredWidgets.length - 1 ? 0 : currentIndex + 1)
: (currentIndex - 1 < 0 ? filteredWidgets.length - 1 : currentIndex - 1);
const nextType = filteredWidgets[nextIndex]?.type ?? null;
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
selectedType: nextType
}));
} else if (key.backspace || key.delete) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
widgetQuery: prev.widgetQuery.slice(0, -1),
selectedType: null
}));
} else if (
input
&& !key.ctrl
&& !key.meta
&& !key.tab
) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
widgetQuery: prev.widgetQuery + input,
selectedType: null
}));
}
}
}
export interface HandleMoveInputModeArgs {
key: InputKey;
widgets: WidgetItem[];
selectedIndex: number;
onUpdate: (widgets: WidgetItem[]) => void;
setSelectedIndex: (index: number) => void;
setMoveMode: (moveMode: boolean) => void;
}
export function handleMoveInputMode({
key,
widgets,
selectedIndex,
onUpdate,
setSelectedIndex,
setMoveMode
}: HandleMoveInputModeArgs): void {
if (key.upArrow && widgets.length > 1) {
const newWidgets = [...widgets];
const targetIndex = selectedIndex - 1 < 0 ? widgets.length - 1 : selectedIndex - 1;
const temp = newWidgets[selectedIndex];
const prev = newWidgets[targetIndex];
if (temp && prev) {
[newWidgets[selectedIndex], newWidgets[targetIndex]] = [prev, temp];
}
onUpdate(newWidgets);
setSelectedIndex(targetIndex);
} else if (key.downArrow && widgets.length > 1) {
const newWidgets = [...widgets];
const targetIndex = selectedIndex + 1 > widgets.length - 1 ? 0 : selectedIndex + 1;
const temp = newWidgets[selectedIndex];
const next = newWidgets[targetIndex];
if (temp && next) {
[newWidgets[selectedIndex], newWidgets[targetIndex]] = [next, temp];
}
onUpdate(newWidgets);
setSelectedIndex(targetIndex);
} else if (key.escape || key.return) {
setMoveMode(false);
}
}
export interface HandleNormalInputModeArgs {
input: string;
key: InputKey;
widgets: WidgetItem[];
selectedIndex: number;
canExcludeAlign?: boolean;
separatorChars: string[];
onBack: () => void;
onUpdate: (widgets: WidgetItem[]) => void;
setSelectedIndex: (index: number) => void;
setMoveMode: (moveMode: boolean) => void;
setShowClearConfirm: (show: boolean) => void;
openWidgetPicker: (action: WidgetPickerAction) => void;
getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[];
setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void;
getUniqueBackgroundColor?: (insertIndex: number) => string | undefined;
}
export function handleNormalInputMode({
input,
key,
widgets,
selectedIndex,
canExcludeAlign = false,
separatorChars,
onBack,
onUpdate,
setSelectedIndex,
setMoveMode,
setShowClearConfirm,
openWidgetPicker,
getCustomKeybindsForWidget,
setCustomEditorWidget,
getUniqueBackgroundColor
}: HandleNormalInputModeArgs): void {
if (key.upArrow && widgets.length > 0) {
setSelectedIndex(selectedIndex - 1 < 0 ? widgets.length - 1 : selectedIndex - 1);
} else if (key.downArrow && widgets.length > 0) {
setSelectedIndex(selectedIndex + 1 > widgets.length - 1 ? 0 : selectedIndex + 1);
} else if (key.leftArrow && widgets.length > 0) {
openWidgetPicker('change');
} else if (key.rightArrow && widgets.length > 0) {
openWidgetPicker('change');
} else if (key.return && widgets.length > 0) {
setMoveMode(true);
} else if (input === 'a') {
openWidgetPicker('add');
} else if (input === 'i') {
openWidgetPicker('insert');
} else if (input === 'd' && widgets.length > 0) {
const newWidgets = widgets.filter((_, i) => i !== selectedIndex);
onUpdate(newWidgets);
if (selectedIndex >= newWidgets.length && selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1);
}
} else if (input === 'k' && widgets.length > 0) {
const source = widgets[selectedIndex];
if (!source) {
return;
}
const insertIndex = selectedIndex + 1;
const newBg = getUniqueBackgroundColor?.(insertIndex);
const clone: WidgetItem = {
...source,
id: generateGuid(),
...(source.metadata && { metadata: { ...source.metadata } }),
...(newBg && { backgroundColor: newBg })
};
const newWidgets = [
...widgets.slice(0, insertIndex),
clone,
...widgets.slice(insertIndex)
];
onUpdate(newWidgets);
setSelectedIndex(insertIndex);
} else if (input === 'c') {
if (widgets.length > 0) {
setShowClearConfirm(true);
}
} else if (input === ' ' && widgets.length > 0) {
const currentWidget = widgets[selectedIndex];
if (currentWidget?.type === 'separator') {
const currentChar = currentWidget.character ?? '|';
const currentCharIndex = separatorChars.indexOf(currentChar);
const nextChar = separatorChars[(currentCharIndex + 1) % separatorChars.length];
const newWidgets = [...widgets];
newWidgets[selectedIndex] = { ...currentWidget, character: nextChar };
onUpdate(newWidgets);
}
} else if (input === 'r' && widgets.length > 0) {
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const widgetImpl = getWidget(currentWidget.type);
if (!widgetImpl?.supportsRawValue()) {
return;
}
const newWidgets = [...widgets];
newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue };
onUpdate(newWidgets);
}
} else if (input === 'm' && widgets.length > 0) {
const currentWidget = widgets[selectedIndex];
if (currentWidget && selectedIndex < widgets.length - 1
&& currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const newWidgets = [...widgets];
let nextMergeState: boolean | 'no-padding' | undefined;
if (currentWidget.merge === undefined) {
nextMergeState = true;
} else if (currentWidget.merge === true) {
nextMergeState = 'no-padding';
} else {
nextMergeState = undefined;
}
if (nextMergeState === undefined) {
const { merge, ...rest } = currentWidget;
void merge; // Intentionally unused
newWidgets[selectedIndex] = rest;
} else {
newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState };
}
onUpdate(newWidgets);
}
} else if (input === 'x' && widgets.length > 0) {
const currentWidget = widgets[selectedIndex];
if (canExcludeAlign && currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const newWidgets = [...widgets];
if (currentWidget.excludeFromAutoAlign) {
const { excludeFromAutoAlign, ...rest } = currentWidget;
void excludeFromAutoAlign; // Intentionally unused
newWidgets[selectedIndex] = rest;
} else {
newWidgets[selectedIndex] = { ...currentWidget, excludeFromAutoAlign: true };
}
onUpdate(newWidgets);
}
} else if (key.escape) {
onBack();
} else if (widgets.length > 0) {
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const widgetImpl = getWidget(currentWidget.type);
if (!widgetImpl?.getCustomKeybinds) {
return;
}
const customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
const matchedKeybind = customKeybinds.find(kb => kb.key === input);
if (matchedKeybind && !key.ctrl) {
if (widgetImpl.handleEditorAction) {
const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget);
if (updatedWidget) {
const newWidgets = [...widgets];
newWidgets[selectedIndex] = updatedWidget;
onUpdate(newWidgets);
} else if (widgetImpl.renderEditor) {
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
}
} else if (widgetImpl.renderEditor) {
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
}
}
}
}
}
+1 -1
View File
@@ -1 +1 @@
export { runTUI } from './App';
export { runTUI } from './App';
+1 -1
View File
@@ -1,4 +1,4 @@
export interface BlockMetrics {
startTime: Date;
lastActivity: Date;
}
}
+5 -1
View File
@@ -1,4 +1,7 @@
import type { TranscriptThinkingEffort } from '../utils/jsonl-metadata';
export interface ClaudeSettings {
effortLevel?: TranscriptThinkingEffort;
permissions?: {
allow?: string[];
deny?: string[];
@@ -7,6 +10,7 @@ export interface ClaudeSettings {
type: string;
command: string;
padding?: number;
refreshInterval?: number;
};
[key: string]: unknown;
}
}
+1 -1
View File
@@ -7,4 +7,4 @@ export interface ColorEntry {
ansi16: ChalkInstance;
ansi256: ChalkInstance;
truecolor: ChalkInstance;
}
}
+9 -9
View File
@@ -17,13 +17,13 @@ export type ColorLevelString = 'ansi16' | 'ansi256' | 'truecolor';
// Helper to get color level as string for chalk
export function getColorLevelString(level: ColorLevel | undefined): ColorLevelString {
switch (level) {
case 0:
case 1:
return 'ansi16';
case 3:
return 'truecolor';
case 2:
default:
return 'ansi256';
case 0:
case 1:
return 'ansi16';
case 3:
return 'truecolor';
case 2:
default:
return 'ansi256';
}
}
}
+1 -1
View File
@@ -4,4 +4,4 @@ import { z } from 'zod';
export const FlexModeSchema = z.enum(['full', 'full-minus-40', 'full-until-compact']);
// Inferred type from schema
export type FlexMode = z.infer<typeof FlexModeSchema>;
export type FlexMode = z.infer<typeof FlexModeSchema>;
+3 -2
View File
@@ -8,8 +8,9 @@ export const PowerlineConfigSchema = z.object({
startCaps: z.array(z.string()).default([]),
endCaps: z.array(z.string()).default([]),
theme: z.string().optional(),
autoAlign: z.boolean().default(false)
autoAlign: z.boolean().default(false),
continueThemeAcrossLines: z.boolean().default(false)
});
// Inferred type from schema
export type PowerlineConfig = z.infer<typeof PowerlineConfigSchema>;
export type PowerlineConfig = z.infer<typeof PowerlineConfigSchema>;
+1 -1
View File
@@ -1,4 +1,4 @@
export interface PowerlineFontStatus {
installed: boolean;
checkedSymbol?: string;
}
}
+46 -2
View File
@@ -1,15 +1,59 @@
import type { BlockMetrics } from '../types';
import type {
BlockMetrics,
SkillsMetrics
} from '../types';
import type { SpeedMetrics } from './SpeedMetrics';
import type { StatusJSON } from './StatusJSON';
import type { TokenMetrics } from './TokenMetrics';
export interface RenderUsageData {
sessionUsage?: number;
sessionResetAt?: string;
weeklyUsage?: number;
weeklyResetAt?: string;
weeklySonnetUsage?: number;
weeklySonnetResetAt?: string;
weeklyOpusUsage?: number;
weeklyOpusResetAt?: string;
extraUsageEnabled?: boolean;
extraUsageLimit?: number;
extraUsageUsed?: number;
extraUsageUtilization?: number;
extraUsageCurrency?: string;
error?: 'no-credentials' | 'timeout' | 'rate-limited' | 'api-error' | 'parse-error';
}
export interface CompactionData {
count: number;
byTrigger: { auto: number; manual: number; unknown: number };
tokensReclaimed: number;
}
export interface RenderContext {
data?: StatusJSON;
tokenMetrics?: TokenMetrics | null;
speedMetrics?: SpeedMetrics | null;
windowedSpeedMetrics?: Record<string, SpeedMetrics> | null;
usageData?: RenderUsageData | null;
sessionDuration?: string | null;
blockMetrics?: BlockMetrics | null;
skillsMetrics?: SkillsMetrics | null;
compactionData?: CompactionData | null;
terminalWidth?: number | null;
isPreview?: boolean;
minimalist?: boolean;
gitCacheTtlSeconds?: number;
gitReviewNeedsChecks?: boolean;
lineIndex?: number; // Index of the current line being rendered (for theme cycling)
globalSeparatorIndex?: number; // Global separator index that continues across lines
}
// For git widget thresholds
gitData?: {
changedFiles?: number;
insertions?: number;
deletions?: number;
};
globalPowerlineThemeIndex?: number; // Global powerline theme index that continues across lines
globalPowerlineStartCapIndex?: number; // Global start cap index across powerline flex segments and lines
}
+35 -3
View File
@@ -8,6 +8,29 @@ import { WidgetItemSchema } from './Widget';
// Current version - bump this when making breaking changes to the schema
export const CURRENT_VERSION = 3;
// Which side(s) of a widget the default padding is applied to
export const DefaultPaddingSideSchema = z.enum(['both', 'left', 'right']);
export type DefaultPaddingSide = z.infer<typeof DefaultPaddingSideSchema>;
export const InstallationMetadataSchema = z.discriminatedUnion('method', [
z.object({
method: z.literal('auto-update'),
packageManager: z.enum(['npm', 'bun'])
}),
z.object({
method: z.literal('pinned'),
installedVersion: z.string().optional()
}),
z.object({
method: z.literal('self-managed'),
packageManager: z.enum(['npm', 'bun', 'unknown']).default('unknown')
}),
z.object({
method: z.literal('unknown'),
packageManager: z.enum(['npm', 'bun', 'unknown']).default('unknown')
})
]);
// Schema for v1 settings (before version field was added)
export const SettingsSchema_v1 = z.object({
lines: z.array(z.array(WidgetItemSchema)).optional(),
@@ -45,10 +68,13 @@ export const SettingsSchema = z.object({
colorLevel: ColorLevelSchema.default(2),
defaultSeparator: z.string().optional(),
defaultPadding: z.string().optional(),
defaultPaddingSide: DefaultPaddingSideSchema.default('both'),
inheritSeparatorColors: z.boolean().default(false),
overrideBackgroundColor: z.string().optional(),
overrideForegroundColor: z.string().optional(),
globalBold: z.boolean().default(false),
gitCacheTtlSeconds: z.number().min(0).max(60).default(5),
minimalistMode: z.boolean().default(false),
powerline: PowerlineConfigSchema.default({
enabled: false,
separators: ['\uE0B0'],
@@ -56,16 +82,22 @@ export const SettingsSchema = z.object({
startCaps: [],
endCaps: [],
theme: undefined,
autoAlign: false
autoAlign: false,
continueThemeAcrossLines: false
}),
updatemessage: z.object({
message: z.string().nullable().optional(),
remaining: z.number().nullable().optional()
}).optional()
}).optional(),
installation: InstallationMetadataSchema.optional()
});
// Inferred type from schema
export type Settings = z.infer<typeof SettingsSchema>;
export type InstallationMetadata = z.infer<typeof InstallationMetadataSchema>;
export type ResolvedInstallationMetadata
= | Exclude<InstallationMetadata, { method: 'pinned' }>
| (Extract<InstallationMetadata, { method: 'pinned' }> & { packageManager: 'npm' | 'bun' | 'unknown' });
// Export a default settings constant for reference
export const DEFAULT_SETTINGS: Settings = SettingsSchema.parse({});
export const DEFAULT_SETTINGS: Settings = SettingsSchema.parse({});
+12
View File
@@ -0,0 +1,12 @@
export interface SkillInvocation {
timestamp: string;
session_id: string;
skill: string;
source: string;
}
export interface SkillsMetrics {
totalInvocations: number;
uniqueSkills: string[];
lastSkill: string | null;
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Speed metrics for calculating token processing rates.
* Provides time-based data needed for speed calculations.
*/
export interface SpeedMetrics {
/** Active processing duration in milliseconds (sum of user request → assistant response times) */
totalDurationMs: number;
/** Total input tokens across all requests */
inputTokens: number;
/** Total output tokens across all requests */
outputTokens: number;
/** Total tokens (input + output) */
totalTokens: number;
/** Number of assistant usage entries included in speed aggregation */
requestCount: number;
}
+50 -16
View File
@@ -1,5 +1,24 @@
import { z } from 'zod';
const CoercedNumberSchema = z.preprocess((value) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return value;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : value;
}, z.number());
const RateLimitPeriodSchema = z.object({
used_percentage: CoercedNumberSchema.nullable().optional(),
resets_at: CoercedNumberSchema.nullable().optional() // Unix epoch seconds
});
export const StatusJSONSchema = z.looseObject({
hook_event_name: z.string().optional(),
session_id: z.string().optional(),
@@ -18,29 +37,44 @@ export const StatusJSONSchema = z.looseObject({
}).optional(),
version: z.string().optional(),
output_style: z.object({ name: z.string().optional() }).optional(),
effort: z.object({ level: z.string().nullable().optional() }).nullable().optional(),
cost: z.object({
total_cost_usd: z.number().optional(),
total_duration_ms: z.number().optional(),
total_api_duration_ms: z.number().optional(),
total_lines_added: z.number().optional(),
total_lines_removed: z.number().optional()
total_cost_usd: CoercedNumberSchema.optional(),
total_duration_ms: CoercedNumberSchema.optional(),
total_api_duration_ms: CoercedNumberSchema.optional(),
total_lines_added: CoercedNumberSchema.optional(),
total_lines_removed: CoercedNumberSchema.optional()
}).optional(),
context_window: z.object({
context_window_size: z.number().nullable().optional(),
total_input_tokens: z.number().nullable().optional(),
total_output_tokens: z.number().nullable().optional(),
context_window_size: CoercedNumberSchema.nullable().optional(),
total_input_tokens: CoercedNumberSchema.nullable().optional(),
total_output_tokens: CoercedNumberSchema.nullable().optional(),
current_usage: z.union([
z.number(),
CoercedNumberSchema,
z.object({
input_tokens: z.number().optional(),
output_tokens: z.number().optional(),
cache_creation_input_tokens: z.number().optional(),
cache_read_input_tokens: z.number().optional()
input_tokens: CoercedNumberSchema.optional(),
output_tokens: CoercedNumberSchema.optional(),
cache_creation_input_tokens: CoercedNumberSchema.optional(),
cache_read_input_tokens: CoercedNumberSchema.optional()
})
]).nullable().optional(),
used_percentage: z.number().nullable().optional(),
remaining_percentage: z.number().nullable().optional()
used_percentage: CoercedNumberSchema.nullable().optional(),
remaining_percentage: CoercedNumberSchema.nullable().optional()
}).nullable().optional(),
vim: z.object({ mode: z.string().optional() }).nullable().optional(),
worktree: z.object({
name: z.string().optional(),
path: z.string().optional(),
branch: z.string().optional(),
original_cwd: z.string().optional(),
original_branch: z.string().optional()
}).nullable().optional(),
rate_limits: z.object({
five_hour: RateLimitPeriodSchema.optional(),
seven_day: RateLimitPeriodSchema.optional(),
seven_day_sonnet: RateLimitPeriodSchema.nullable().optional(),
seven_day_opus: RateLimitPeriodSchema.nullable().optional()
}).nullable().optional()
});
export type StatusJSON = z.infer<typeof StatusJSONSchema>;
export type StatusJSON = z.infer<typeof StatusJSONSchema>;
+7 -2
View File
@@ -6,16 +6,21 @@ export interface TokenUsage {
}
export interface TranscriptLine {
message?: { usage?: TokenUsage };
message?: { usage?: TokenUsage; stop_reason?: string | null };
isSidechain?: boolean;
timestamp?: string;
isApiErrorMessage?: boolean;
type?: 'user' | 'assistant' | 'system' | 'progress' | 'file-history-snapshot';
}
export interface TokenMetrics {
inputTokens: number;
outputTokens: number;
cachedTokens: number;
// Hot (cache read) and cold (cache creation) split of cachedTokens.
// Optional so existing TokenMetrics literals stay valid; getTokenMetrics always sets them.
cacheReadTokens?: number;
cacheCreationTokens?: number;
totalTokens: number;
contextLength: number;
}
}
+7 -2
View File
@@ -10,14 +10,18 @@ export const WidgetItemSchema = z.object({
color: z.string().optional(),
backgroundColor: z.string().optional(),
bold: z.boolean().optional(),
dim: z.union([z.boolean(), z.literal('parens')]).optional(),
character: z.string().optional(),
rawValue: z.boolean().optional(),
customText: z.string().optional(),
customSymbol: z.string().optional(),
commandPath: z.string().optional(),
maxWidth: z.number().optional(),
preserveColors: z.boolean().optional(),
timeout: z.number().optional(),
merge: z.union([z.boolean(), z.literal('no-padding')]).optional(),
hide: z.boolean().optional(),
excludeFromAutoAlign: z.boolean().optional(),
metadata: z.record(z.string(), z.string()).optional()
});
@@ -37,11 +41,12 @@ export interface Widget {
getCategory(): string;
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay;
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null;
getCustomKeybinds?(): CustomKeybind[];
getCustomKeybinds?(item?: WidgetItem): CustomKeybind[];
renderEditor?(props: WidgetEditorProps): React.ReactElement | null;
supportsRawValue(): boolean;
supportsColors(item: WidgetItem): boolean;
handleEditorAction?(action: string, item: WidgetItem): WidgetItem | null;
getNumericValue?(context: RenderContext, item: WidgetItem): number | null;
}
export interface WidgetEditorProps {
@@ -55,4 +60,4 @@ export interface CustomKeybind {
key: string;
label: string;
action: string;
}
}
+114
View File
@@ -0,0 +1,114 @@
import {
describe,
expect,
it
} from 'vitest';
import { StatusJSONSchema } from '../StatusJSON';
describe('StatusJSONSchema numeric coercion', () => {
it('coerces numeric strings to numbers', () => {
const result = StatusJSONSchema.safeParse({
cost: {
total_cost_usd: '1.25',
total_duration_ms: '12345',
total_api_duration_ms: '2345',
total_lines_added: '12',
total_lines_removed: '3'
},
context_window: {
context_window_size: '200000',
total_input_tokens: '1200',
total_output_tokens: '340',
current_usage: {
input_tokens: '100',
output_tokens: '50',
cache_creation_input_tokens: '20',
cache_read_input_tokens: '10'
},
used_percentage: '9.3',
remaining_percentage: '90.7'
}
});
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.cost?.total_duration_ms).toBe(12345);
expect(result.data.context_window?.context_window_size).toBe(200000);
expect(result.data.context_window?.current_usage).toEqual({
input_tokens: 100,
output_tokens: 50,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 10
});
expect(result.data.context_window?.used_percentage).toBe(9.3);
});
it('keeps invalid numeric strings rejected', () => {
const result = StatusJSONSchema.safeParse({ context_window: { context_window_size: 'not-a-number' } });
expect(result.success).toBe(false);
});
it('keeps empty numeric strings rejected', () => {
const result = StatusJSONSchema.safeParse({ context_window: { context_window_size: '' } });
expect(result.success).toBe(false);
});
it('accepts null vim payloads', () => {
const result = StatusJSONSchema.safeParse({ vim: null });
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.vim).toBeNull();
});
it('parses rate_limits with valid data', () => {
const result = StatusJSONSchema.safeParse({
rate_limits: {
five_hour: { used_percentage: 42, resets_at: 1774020000 },
seven_day: { used_percentage: 15, resets_at: 1774540000 }
}
});
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.rate_limits?.five_hour?.used_percentage).toBe(42);
expect(result.data.rate_limits?.five_hour?.resets_at).toBe(1774020000);
expect(result.data.rate_limits?.seven_day?.used_percentage).toBe(15);
expect(result.data.rate_limits?.seven_day?.resets_at).toBe(1774540000);
});
it('accepts null rate_limits', () => {
const result = StatusJSONSchema.safeParse({ rate_limits: null });
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.rate_limits).toBeNull();
});
it('coerces rate_limits string numbers', () => {
const result = StatusJSONSchema.safeParse({ rate_limits: { five_hour: { used_percentage: '42', resets_at: '1774020000' } } });
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.rate_limits?.five_hour?.used_percentage).toBe(42);
expect(result.data.rate_limits?.five_hour?.resets_at).toBe(1774020000);
});
});
+3 -1
View File
@@ -17,4 +17,6 @@ export type { RenderContext } from './RenderContext';
export type { PowerlineFontStatus } from './PowerlineFontStatus';
export type { ClaudeSettings } from './ClaudeSettings';
export type { ColorEntry } from './ColorEntry';
export type { BlockMetrics } from './BlockMetrics';
export type { BlockMetrics } from './BlockMetrics';
export type { SpeedMetrics } from './SpeedMetrics';
export type { SkillInvocation, SkillsMetrics } from './SkillsMetrics';
+1 -1
View File
@@ -288,4 +288,4 @@ describe('getCachedBlockMetrics integration', () => {
// Should return null because cache is fresh but scoped to a different profile
expect(result).toBeNull();
});
});
});
+925
View File
@@ -0,0 +1,925 @@
import * as childProcess from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../types/Settings';
import {
CCSTATUSLINE_COMMANDS,
buildStatusLineCommand,
classifyInstallation,
getClaudeCodeVersion,
getClaudeJsonPath,
getClaudeSettingsPath,
getExistingStatusLine,
getRefreshInterval,
getSandboxConfig,
getVoiceConfig,
installStatusLine,
isClaudeCodeVersionAtLeast,
isInstalled,
isKnownCommand,
loadClaudeSettings,
saveClaudeSettings,
setRefreshInterval,
uninstallStatusLine
} from '../claude-settings';
import * as config from '../config';
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
let testClaudeConfigDir = '';
function readInstalledCommand(): string {
const settingsPath = getClaudeSettingsPath();
const content = fs.readFileSync(settingsPath, 'utf-8');
const data = JSON.parse(content) as { statusLine?: { command?: string } };
return data.statusLine?.command ?? '';
}
function readInstalledRefreshInterval(): number | undefined {
const settingsPath = getClaudeSettingsPath();
const content = fs.readFileSync(settingsPath, 'utf-8');
const data = JSON.parse(content) as { statusLine?: { refreshInterval?: number } };
return data.statusLine?.refreshInterval;
}
function writeRawClaudeSettings(content: string): void {
const settingsPath = getClaudeSettingsPath();
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, content, 'utf-8');
}
beforeEach(() => {
testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-claude-settings-'));
process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir;
config.initConfigPath(path.join(testClaudeConfigDir, 'ccstatusline-settings.json'));
});
afterEach(() => {
vi.restoreAllMocks();
config.initConfigPath();
if (testClaudeConfigDir) {
fs.rmSync(testClaudeConfigDir, { recursive: true, force: true });
}
});
afterAll(() => {
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
}
});
describe('isKnownCommand', () => {
it('should match exact NPM command', () => {
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.NPM)).toBe(true);
});
it('should match exact BUNX command', () => {
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.BUNX)).toBe(true);
});
it('should match exact SELF_MANAGED command', () => {
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.SELF_MANAGED)).toBe(true);
});
it('should match NPM command with --config and simple path', () => {
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`)).toBe(true);
});
it('should match BUNX command with --config and quoted path with spaces', () => {
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.BUNX} --config '/my path/settings.json'`)).toBe(true);
});
it('should match command with --config and quoted path with parens', () => {
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my(path)/settings.json'`)).toBe(true);
});
it('should match command with --config and double-quoted Windows path', () => {
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config "C:\\Users\\Alice\\My Settings\\settings.json"`)).toBe(true);
});
it('should not match unknown commands', () => {
expect(isKnownCommand('some-other-command')).toBe(false);
});
it('should not match empty string', () => {
expect(isKnownCommand('')).toBe(false);
});
it('should not match partial prefix', () => {
expect(isKnownCommand('npx -y ccstatusline')).toBe(false);
});
it('should not match prefix that is a substring', () => {
expect(isKnownCommand('npx -y ccstatusline@latestFOO')).toBe(false);
});
it('should match command containing ccstatusline.ts', () => {
expect(isKnownCommand('bun run /home/user/ccstatusline/src/ccstatusline.ts')).toBe(true);
});
it('should match command containing a quoted ccstatusline.ts path', () => {
expect(isKnownCommand('bun run "/Users/Jane Doe/ccstatusline/src/ccstatusline.ts"')).toBe(true);
});
it('should match global command with --config', () => {
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.GLOBAL} --config /tmp/settings.json`)).toBe(true);
});
});
describe('classifyInstallation', () => {
it('classifies existing npx latest commands as auto-update npm', () => {
expect(classifyInstallation(CCSTATUSLINE_COMMANDS.NPM)).toEqual({
method: 'auto-update',
packageManager: 'npm'
});
});
it('classifies existing bunx latest commands as auto-update bun', () => {
expect(classifyInstallation(CCSTATUSLINE_COMMANDS.BUNX)).toEqual({
method: 'auto-update',
packageManager: 'bun'
});
});
it('classifies global commands without metadata as self-managed unknown', () => {
expect(classifyInstallation(CCSTATUSLINE_COMMANDS.GLOBAL)).toEqual({
method: 'self-managed',
packageManager: 'unknown'
});
});
it('uses pinned metadata for global commands', () => {
expect(classifyInstallation(CCSTATUSLINE_COMMANDS.GLOBAL, {
method: 'pinned',
installedVersion: '2.2.13'
})).toEqual({
method: 'pinned',
installedVersion: '2.2.13'
});
});
it('classifies local development commands as self-managed unknown', () => {
expect(classifyInstallation('bun run /repo/src/ccstatusline.ts')).toEqual({
method: 'self-managed',
packageManager: 'unknown'
});
});
});
describe('Claude config paths', () => {
it('should resolve .claude.json inside CLAUDE_CONFIG_DIR when configured', () => {
expect(getClaudeJsonPath()).toBe(path.join(testClaudeConfigDir, '.claude.json'));
});
it('should resolve .claude.json beside the default Claude config dir when CLAUDE_CONFIG_DIR is unset', () => {
delete process.env.CLAUDE_CONFIG_DIR;
expect(getClaudeJsonPath()).toBe(path.join(os.homedir(), '.claude.json'));
});
it('should use default .claude.json path when CLAUDE_CONFIG_DIR points to a file', () => {
const invalidConfigDir = path.join(testClaudeConfigDir, 'not-a-dir');
fs.writeFileSync(invalidConfigDir, 'not a directory', 'utf-8');
process.env.CLAUDE_CONFIG_DIR = invalidConfigDir;
expect(getClaudeJsonPath()).toBe(path.join(os.homedir(), '.claude.json'));
});
});
describe('buildCommand via installStatusLine', () => {
it('should use base command when no custom config path', async () => {
config.initConfigPath();
await installStatusLine({ commandMode: 'auto-npx' });
expect(readInstalledCommand()).toBe(CCSTATUSLINE_COMMANDS.NPM);
});
it('should append --config with simple path (no quoting needed)', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
vi.spyOn(config, 'getConfigPath').mockReturnValue('/tmp/settings.json');
await installStatusLine({ commandMode: 'auto-npx' });
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`);
});
it('should quote path with spaces', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
vi.spyOn(config, 'getConfigPath').mockReturnValue('/my path/settings.json');
await installStatusLine({ commandMode: 'auto-npx' });
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my path/settings.json'`);
});
it('should quote path with parentheses', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
vi.spyOn(config, 'getConfigPath').mockReturnValue('/my(path)/settings.json');
await installStatusLine({ commandMode: 'auto-npx' });
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my(path)/settings.json'`);
});
it('should escape embedded single quotes in path', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
vi.spyOn(config, 'getConfigPath').mockReturnValue('/my\'path/settings.json');
await installStatusLine({ commandMode: 'auto-npx' });
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my'\\''path/settings.json'`);
});
it('should use bunx command when commandMode is auto-bunx', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
vi.spyOn(config, 'getConfigPath').mockReturnValue('/my path/settings.json');
await installStatusLine({ commandMode: 'auto-bunx' });
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.BUNX} --config '/my path/settings.json'`);
});
it('should generate global command with custom config path', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
vi.spyOn(config, 'getConfigPath').mockReturnValue('/my path/settings.json');
expect(buildStatusLineCommand('global')).toBe(`${CCSTATUSLINE_COMMANDS.GLOBAL} --config '/my path/settings.json'`);
});
it('should install global command for pinned installs and save metadata', async () => {
const configPath = path.join(testClaudeConfigDir, 'pinned-settings.json');
config.initConfigPath(configPath);
await installStatusLine({
commandMode: 'global',
installationMetadata: {
method: 'pinned',
installedVersion: '2.2.13'
}
});
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.GLOBAL} --config ${configPath}`);
const savedSettings = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as { installation?: unknown };
expect(savedSettings.installation).toEqual({
method: 'pinned',
installedVersion: '2.2.13'
});
});
it('should sync hooks on install when settings include hook-enabled widgets', async () => {
const configPath = path.join(testClaudeConfigDir, 'ccstatusline-settings.json');
config.initConfigPath(configPath);
const settingsWithSkills = {
...DEFAULT_SETTINGS,
lines: [[{ id: 'skills-1', type: 'skills' }], [], []]
};
fs.writeFileSync(configPath, JSON.stringify(settingsWithSkills, null, 2), 'utf-8');
await installStatusLine({ commandMode: 'auto-npx' });
const installedCommand = `${CCSTATUSLINE_COMMANDS.NPM} --config ${configPath}`;
const claudeSettings = await loadClaudeSettings();
expect(claudeSettings.statusLine?.command).toBe(installedCommand);
const hooks = (claudeSettings.hooks ?? {}) as Record<string, unknown[]>;
expect(hooks.PreToolUse).toEqual([
{
_tag: 'ccstatusline-managed',
matcher: 'Skill',
hooks: [{ type: 'command', command: `${installedCommand} --hook` }]
}
]);
expect(hooks.UserPromptSubmit).toEqual([
{
_tag: 'ccstatusline-managed',
hooks: [{ type: 'command', command: `${installedCommand} --hook` }]
}
]);
});
it('should sync hooks from the final global statusline command', async () => {
const configPath = path.join(testClaudeConfigDir, 'global-settings.json');
config.initConfigPath(configPath);
fs.writeFileSync(configPath, JSON.stringify({
...DEFAULT_SETTINGS,
lines: [[{ id: 'skills-1', type: 'skills' }], [], []]
}, null, 2), 'utf-8');
await installStatusLine({
commandMode: 'global',
installationMetadata: {
method: 'pinned',
installedVersion: '2.2.13'
}
});
const installedCommand = `${CCSTATUSLINE_COMMANDS.GLOBAL} --config ${configPath}`;
const claudeSettings = await loadClaudeSettings();
expect(claudeSettings.statusLine?.command).toBe(installedCommand);
const hooks = (claudeSettings.hooks ?? {}) as Record<string, unknown[]>;
expect(hooks.PreToolUse).toEqual([
{
_tag: 'ccstatusline-managed',
matcher: 'Skill',
hooks: [{ type: 'command', command: `${installedCommand} --hook` }]
}
]);
});
});
describe('installStatusLine refreshInterval', () => {
it('should set refreshInterval to 10 when version is supported', async () => {
config.initConfigPath();
await installStatusLine({ commandMode: 'auto-npx', supportsRefreshInterval: true });
expect(readInstalledRefreshInterval()).toBe(10);
});
it('should not set refreshInterval when version is unsupported', async () => {
config.initConfigPath();
await installStatusLine({ commandMode: 'auto-npx', supportsRefreshInterval: false });
expect(readInstalledRefreshInterval()).toBeUndefined();
});
it('should preserve existing refreshInterval on re-install', async () => {
writeRawClaudeSettings(JSON.stringify({
statusLine: {
type: 'command',
command: CCSTATUSLINE_COMMANDS.NPM,
padding: 0,
refreshInterval: 5
}
}));
await installStatusLine({ commandMode: 'auto-npx', supportsRefreshInterval: true });
expect(readInstalledRefreshInterval()).toBe(5);
});
});
describe('refreshInterval', () => {
it('getRefreshInterval should return null when no settings exist', async () => {
await expect(getRefreshInterval()).resolves.toBeNull();
});
it('getRefreshInterval should return null when statusLine has no refreshInterval', async () => {
await saveClaudeSettings({
statusLine: {
type: 'command',
command: CCSTATUSLINE_COMMANDS.NPM,
padding: 0
}
});
await expect(getRefreshInterval()).resolves.toBeNull();
});
it('getRefreshInterval should return the configured value', async () => {
await saveClaudeSettings({
statusLine: {
type: 'command',
command: CCSTATUSLINE_COMMANDS.NPM,
padding: 0,
refreshInterval: 5
}
});
await expect(getRefreshInterval()).resolves.toBe(5);
});
it('setRefreshInterval should set the value on existing statusLine', async () => {
await saveClaudeSettings({
statusLine: {
type: 'command',
command: CCSTATUSLINE_COMMANDS.NPM,
padding: 0
}
});
await setRefreshInterval(15);
const settings = await loadClaudeSettings();
expect(settings.statusLine?.refreshInterval).toBe(15);
});
it('setRefreshInterval with null should remove refreshInterval', async () => {
await saveClaudeSettings({
statusLine: {
type: 'command',
command: CCSTATUSLINE_COMMANDS.NPM,
padding: 0,
refreshInterval: 10
}
});
await setRefreshInterval(null);
const settings = await loadClaudeSettings();
expect(settings.statusLine?.refreshInterval).toBeUndefined();
});
it('setRefreshInterval should do nothing when no statusLine exists', async () => {
await saveClaudeSettings({});
await setRefreshInterval(10);
const settings = await loadClaudeSettings();
expect(settings.statusLine).toBeUndefined();
});
});
describe('backup and error handling behavior', () => {
it('saveClaudeSettings should create .bak backup before overwrite', async () => {
writeRawClaudeSettings(JSON.stringify({
statusLine: {
type: 'command',
command: 'preexisting-command',
padding: 1
}
}));
await saveClaudeSettings({
statusLine: {
type: 'command',
command: CCSTATUSLINE_COMMANDS.NPM,
padding: 0
}
});
const settingsPath = getClaudeSettingsPath();
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { statusLine?: { command?: string } };
expect(saved.statusLine?.command).toBe(CCSTATUSLINE_COMMANDS.NPM);
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(true);
const backup = JSON.parse(fs.readFileSync(`${settingsPath}.bak`, 'utf-8')) as { statusLine?: { command?: string } };
expect(backup.statusLine?.command).toBe('preexisting-command');
});
it('installStatusLine should create .orig backup before updating settings', async () => {
writeRawClaudeSettings(JSON.stringify({
statusLine: {
type: 'command',
command: 'old-command',
padding: 1
}
}));
await installStatusLine({ commandMode: 'auto-npx' });
const settingsPath = getClaudeSettingsPath();
expect(fs.existsSync(`${settingsPath}.orig`)).toBe(true);
const orig = JSON.parse(fs.readFileSync(`${settingsPath}.orig`, 'utf-8')) as { statusLine?: { command?: string } };
expect(orig.statusLine?.command).toBe('old-command');
});
it('loadClaudeSettings should return empty object when settings file is missing', async () => {
await expect(loadClaudeSettings()).resolves.toEqual({});
});
it('loadClaudeSettings should log and throw when settings file is invalid JSON', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(loadClaudeSettings()).rejects.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to load Claude settings:',
expect.anything()
);
} finally {
consoleErrorSpy.mockRestore();
}
});
it('isInstalled should return false when settings cannot be loaded', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(isInstalled()).resolves.toBe(false);
expect(consoleErrorSpy).not.toHaveBeenCalled();
} finally {
consoleErrorSpy.mockRestore();
}
});
it('installStatusLine should warn and recover when existing settings are invalid', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await installStatusLine({ commandMode: 'auto-npx' });
const settingsPath = getClaudeSettingsPath();
const installed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { statusLine?: { command?: string; padding?: number } };
expect(installed.statusLine?.command).toBe(buildStatusLineCommand('auto-npx'));
expect(installed.statusLine?.padding).toBe(0);
expect(fs.existsSync(`${settingsPath}.orig`)).toBe(true);
expect(fs.readFileSync(`${settingsPath}.orig`, 'utf-8')).toBe('{ invalid json');
expect(consoleErrorSpy).toHaveBeenCalledWith(
`Warning: Could not read existing Claude settings. A backup exists at ${settingsPath}.orig.`
);
} finally {
consoleErrorSpy.mockRestore();
}
});
it('uninstallStatusLine should warn and return without modifying invalid settings', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await uninstallStatusLine();
const settingsPath = getClaudeSettingsPath();
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json');
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Warning: Could not read existing Claude settings.'
);
} finally {
consoleErrorSpy.mockRestore();
}
});
it('uninstallStatusLine should remove all managed hooks', async () => {
writeRawClaudeSettings(JSON.stringify({
statusLine: {
type: 'command',
command: CCSTATUSLINE_COMMANDS.NPM,
padding: 0
},
hooks: {
PreToolUse: [
{
_tag: 'ccstatusline-managed',
matcher: 'Skill',
hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }]
},
{
matcher: 'Other',
hooks: [{ type: 'command', command: 'keep-me' }]
}
],
UserPromptSubmit: [
{
_tag: 'ccstatusline-managed',
hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }]
}
]
}
}));
await uninstallStatusLine();
const settingsPath = getClaudeSettingsPath();
const updated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
statusLine?: unknown;
hooks?: Record<string, unknown[]>;
};
expect(updated.statusLine).toBeUndefined();
expect(updated.hooks).toEqual({
PreToolUse: [
{
matcher: 'Other',
hooks: [{ type: 'command', command: 'keep-me' }]
}
]
});
});
it('getExistingStatusLine should return null when settings cannot be loaded', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(getExistingStatusLine()).resolves.toBeNull();
expect(consoleErrorSpy).not.toHaveBeenCalled();
} finally {
consoleErrorSpy.mockRestore();
}
});
it('isInstalled should accept known commands with --config and undefined padding', async () => {
await saveClaudeSettings({
statusLine: {
type: 'command',
command: `${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`
}
});
await expect(isInstalled()).resolves.toBe(true);
});
it('isInstalled should accept quoted local development commands when padding is undefined', async () => {
await saveClaudeSettings({
statusLine: {
type: 'command',
command: 'bun run "/Users/Jane Doe/ccstatusline/src/ccstatusline.ts"'
}
});
await expect(isInstalled()).resolves.toBe(true);
});
});
describe('getClaudeCodeVersion', () => {
it('should parse version from claude --version output', () => {
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.1.97 (Claude Code)\n');
expect(getClaudeCodeVersion()).toBe('2.1.97');
});
it('should parse version without suffix text', () => {
vi.spyOn(childProcess, 'execSync').mockReturnValue('3.0.0\n');
expect(getClaudeCodeVersion()).toBe('3.0.0');
});
it('should return null when claude is not installed', () => {
vi.spyOn(childProcess, 'execSync').mockImplementation(() => { throw new Error('not found'); });
expect(getClaudeCodeVersion()).toBeNull();
});
it('should return null for unexpected output', () => {
vi.spyOn(childProcess, 'execSync').mockReturnValue('unknown output');
expect(getClaudeCodeVersion()).toBeNull();
});
});
describe('isClaudeCodeVersionAtLeast', () => {
it('should return true when version equals minimum', () => {
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.1.97 (Claude Code)\n');
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(true);
});
it('should return true when patch is higher', () => {
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.1.100 (Claude Code)\n');
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(true);
});
it('should return true when minor is higher', () => {
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.2.0 (Claude Code)\n');
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(true);
});
it('should return true when major is higher', () => {
vi.spyOn(childProcess, 'execSync').mockReturnValue('3.0.0 (Claude Code)\n');
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(true);
});
it('should return false when version is lower', () => {
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.1.96 (Claude Code)\n');
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(false);
});
it('should return false when minor is lower', () => {
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.0.100 (Claude Code)\n');
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(false);
});
it('should return false when claude is not installed', () => {
vi.spyOn(childProcess, 'execSync').mockImplementation(() => { throw new Error('not found'); });
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(false);
});
});
describe('getVoiceConfig', () => {
let testProjectDir = '';
function writeRawUserLocalSettings(content: string): void {
const settingsPath = path.join(testClaudeConfigDir, 'settings.local.json');
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, content, 'utf-8');
}
function writeRawProjectSettings(content: string): void {
const settingsPath = path.join(testProjectDir, '.claude', 'settings.json');
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, content, 'utf-8');
}
function writeRawProjectLocalSettings(content: string): void {
const settingsPath = path.join(testProjectDir, '.claude', 'settings.local.json');
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, content, 'utf-8');
}
beforeEach(() => {
testProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-voice-project-'));
});
afterEach(() => {
if (testProjectDir) {
fs.rmSync(testProjectDir, { recursive: true, force: true });
}
});
describe('user-global layer only', () => {
it('returns null when no candidate file exists', () => {
expect(getVoiceConfig(testProjectDir)).toBeNull();
});
it('returns { enabled: false } when settings.json has no voice field', () => {
writeRawClaudeSettings(JSON.stringify({ effortLevel: 'high' }));
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
});
it('returns { enabled: true } when voice.enabled is true', () => {
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true, mode: 'hold' } }));
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true });
});
it('returns { enabled: false } when voice.enabled is false', () => {
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: false, mode: 'hold' } }));
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
});
it('returns { enabled: false } when voice.enabled is missing but voice exists', () => {
writeRawClaudeSettings(JSON.stringify({ voice: { mode: 'hold' } }));
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
});
it('treats malformed JSON as "no override"', () => {
// Malformed file is silently skipped; with no other layers, no override is found
// and we fall back to the Claude Code default of `enabled: false`. The file's mere
// existence still flips the overall result away from `null`.
writeRawClaudeSettings('{ this is not json');
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
});
it('treats unexpected voice shape as "no override"', () => {
// voice is a string instead of an object — Zod schema fails, no override extracted.
writeRawClaudeSettings(JSON.stringify({ voice: 'enabled' }));
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
});
it('respects CLAUDE_CONFIG_DIR env var', () => {
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } }));
expect(getClaudeSettingsPath().startsWith(testClaudeConfigDir)).toBe(true);
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true });
});
});
describe('layer precedence', () => {
it('user-local overrides user-global', () => {
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } }));
writeRawUserLocalSettings(JSON.stringify({ voice: { enabled: false } }));
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
});
it('project overrides user-local', () => {
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: false } }));
writeRawUserLocalSettings(JSON.stringify({ voice: { enabled: false } }));
writeRawProjectSettings(JSON.stringify({ voice: { enabled: true } }));
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true });
});
it('project-local overrides project', () => {
writeRawProjectSettings(JSON.stringify({ voice: { enabled: true } }));
writeRawProjectLocalSettings(JSON.stringify({ voice: { enabled: false } }));
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
});
it('layer without voice.enabled does not override a lower layer', () => {
// user-global sets enabled:true, project layer has voice but no `enabled` field
// → project should NOT clobber the user-global value.
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } }));
writeRawProjectSettings(JSON.stringify({ voice: { mode: 'hold' } }));
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true });
});
it('malformed higher-priority layer does not clobber a lower layer', () => {
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } }));
writeRawProjectLocalSettings('{ corrupt');
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true });
});
it('returns { enabled: false } when only project layer exists with voice but no enabled', () => {
writeRawProjectSettings(JSON.stringify({ voice: { mode: 'hold' } }));
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
});
it('returns null when no candidate file exists in any layer', () => {
// testProjectDir is freshly created and empty, testClaudeConfigDir too
expect(getVoiceConfig(testProjectDir)).toBeNull();
});
it('full stack: project-local wins over all three lower layers', () => {
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } }));
writeRawUserLocalSettings(JSON.stringify({ voice: { enabled: false } }));
writeRawProjectSettings(JSON.stringify({ voice: { enabled: true } }));
writeRawProjectLocalSettings(JSON.stringify({ voice: { enabled: false } }));
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
});
it('falls through layers without voice.enabled until it finds a defined value', () => {
// user-global defines enabled:true; the three higher-priority layers exist but
// contribute nothing usable (no voice field, only mode, or unrelated keys).
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } }));
writeRawUserLocalSettings(JSON.stringify({ effortLevel: 'high' }));
writeRawProjectSettings(JSON.stringify({ voice: { mode: 'hold' } }));
writeRawProjectLocalSettings(JSON.stringify({ effortLevel: 'low' }));
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true });
});
});
});
describe('getSandboxConfig', () => {
let testSandboxProjectDir = '';
function writeRawUserLocalSettings(content: string): void {
const settingsPath = path.join(testClaudeConfigDir, 'settings.local.json');
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, content, 'utf-8');
}
function writeRawProjectSettings(content: string): void {
const settingsPath = path.join(testSandboxProjectDir, '.claude', 'settings.json');
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, content, 'utf-8');
}
function writeRawProjectLocalSettings(content: string): void {
const settingsPath = path.join(testSandboxProjectDir, '.claude', 'settings.local.json');
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, content, 'utf-8');
}
beforeEach(() => {
testSandboxProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-sandbox-project-'));
});
afterEach(() => {
if (testSandboxProjectDir) {
fs.rmSync(testSandboxProjectDir, { recursive: true, force: true });
}
});
it('returns null when no candidate file exists', () => {
expect(getSandboxConfig(testSandboxProjectDir)).toBeNull();
});
it('returns { enabled: false } when settings.json has no sandbox field', () => {
writeRawClaudeSettings(JSON.stringify({ effortLevel: 'high' }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
});
it('returns { enabled: true } when sandbox.enabled is true', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
});
it('returns { enabled: false } when sandbox.enabled is false', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: false } }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
});
it('returns { enabled: false } when sandbox exists but enabled is missing', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { network: { allowAll: true } } }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
});
it('treats malformed JSON as "no override"', () => {
writeRawClaudeSettings('{ not json');
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
});
it('treats an unexpected sandbox shape as "no override"', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: 'on' }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
});
it('respects CLAUDE_CONFIG_DIR env var', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
expect(getClaudeSettingsPath().startsWith(testClaudeConfigDir)).toBe(true);
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
});
it('project-local overrides project (where /sandbox writes)', () => {
writeRawProjectSettings(JSON.stringify({ sandbox: { enabled: false } }));
writeRawProjectLocalSettings(JSON.stringify({ sandbox: { enabled: true } }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
});
it('project overrides user-global', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: false } }));
writeRawProjectSettings(JSON.stringify({ sandbox: { enabled: true } }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
});
it('user-local overrides user-global', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
writeRawUserLocalSettings(JSON.stringify({ sandbox: { enabled: false } }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
});
it('a layer without sandbox.enabled does not clobber a lower layer', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
writeRawProjectSettings(JSON.stringify({ sandbox: { network: {} } }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
});
it('a malformed higher-priority layer does not clobber a defined lower layer', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
writeRawProjectLocalSettings('{ corrupt');
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
});
it('falls through layers without sandbox.enabled until it finds a defined value', () => {
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
writeRawUserLocalSettings(JSON.stringify({ effortLevel: 'high' }));
writeRawProjectSettings(JSON.stringify({ sandbox: { network: {} } }));
writeRawProjectLocalSettings(JSON.stringify({ effortLevel: 'low' }));
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
});
});
@@ -0,0 +1,40 @@
import {
describe,
expect,
it
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../types/Settings';
import { cloneSettings } from '../clone-settings';
describe('cloneSettings', () => {
it('creates a deep clone that is independent from source', () => {
const original = {
...DEFAULT_SETTINGS,
lines: [
[
{ id: '1', type: 'model', metadata: { key: 'value' } }
]
]
};
const cloned = cloneSettings(original);
const originalWidget = original.lines[0]?.[0];
const clonedWidget = cloned.lines[0]?.[0];
expect(originalWidget).toBeDefined();
expect(clonedWidget).toBeDefined();
if (!originalWidget || !clonedWidget) {
throw new Error('Expected cloned settings to include widget entries');
}
const originalMetadata = originalWidget.metadata as Record<string, string>;
const clonedMetadata = (clonedWidget.metadata ?? {});
clonedWidget.metadata = clonedMetadata;
clonedMetadata.key = 'changed';
expect(originalMetadata.key).toBe('value');
expect(clonedMetadata.key).toBe('changed');
});
});
@@ -0,0 +1,86 @@
import {
describe,
expect,
it
} from 'vitest';
import type { WidgetItem } from '../../types/Widget';
import {
hasCustomWidgetColors,
sanitizeLinesForColorLevel
} from '../color-sanitize';
describe('color sanitize helpers', () => {
it('detects custom ansi256/hex colors in foreground and background', () => {
const lines: WidgetItem[][] = [
[
{ id: '1', type: 'model', color: 'ansi256:120' }
],
[
{ id: '2', type: 'context-length', backgroundColor: 'hex:AA00BB' }
]
];
expect(hasCustomWidgetColors(lines)).toBe(true);
expect(hasCustomWidgetColors([[{ id: '3', type: 'model', color: 'cyan' }]])).toBe(false);
});
it('sanitizes hex colors when moving to ansi256 mode', () => {
const lines: WidgetItem[][] = [[
{ id: '1', type: 'model', color: 'hex:FF00AA', backgroundColor: 'hex:112233' },
{ id: '2', type: 'context-length', color: 'ansi256:111', backgroundColor: 'ansi256:24' }
]];
const sanitized = sanitizeLinesForColorLevel(lines, 2);
expect(sanitized[0]?.[0]?.color).toBe('cyan');
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
expect(sanitized[0]?.[1]?.color).toBe('ansi256:111');
expect(sanitized[0]?.[1]?.backgroundColor).toBe('ansi256:24');
});
it('sanitizes ansi256 colors when moving to truecolor mode', () => {
const lines: WidgetItem[][] = [[
{ id: '1', type: 'model', color: 'ansi256:120', backgroundColor: 'ansi256:244' },
{ id: '2', type: 'context-length', color: 'hex:AA11BB', backgroundColor: 'hex:112233' }
]];
const sanitized = sanitizeLinesForColorLevel(lines, 3);
expect(sanitized[0]?.[0]?.color).toBe('cyan');
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
expect(sanitized[0]?.[1]?.color).toBe('hex:AA11BB');
expect(sanitized[0]?.[1]?.backgroundColor).toBe('hex:112233');
});
it('sanitizes all custom colors when moving to basic/no-color modes', () => {
const lines: WidgetItem[][] = [[
{ id: '1', type: 'model', color: 'ansi256:99', backgroundColor: 'hex:123456' },
{ id: '2', type: 'separator', color: 'hex:ABCDEF', backgroundColor: 'ansi256:2' }
]];
const sanitized = sanitizeLinesForColorLevel(lines, 1);
expect(sanitized[0]?.[0]?.color).toBe('cyan');
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
// Preserve existing behavior: separator foreground is not reset by current logic.
expect(sanitized[0]?.[1]?.color).toBe('hex:ABCDEF');
expect(sanitized[0]?.[1]?.backgroundColor).toBeUndefined();
});
it('leaves gradient colors untouched at every color level (they self-degrade at render time)', () => {
const lines: WidgetItem[][] = [[
{ id: '1', type: 'model', color: 'gradient:retro' },
{ id: '2', type: 'context-length', color: 'gradient:FF0000-0000FF' }
]];
for (const level of [1, 2, 3] as const) {
const sanitized = sanitizeLinesForColorLevel(lines, level);
expect(sanitized[0]?.[0]?.color).toBe('gradient:retro');
expect(sanitized[0]?.[1]?.color).toBe('gradient:FF0000-0000FF');
}
// Gradients are not "custom colors" for sanitize purposes - they degrade at render.
expect(hasCustomWidgetColors(lines)).toBe(false);
});
});
@@ -0,0 +1,68 @@
import {
describe,
expect,
it
} from 'vitest';
import {
applyColors,
getColorAnsiCode
} from '../colors';
const TRUECOLOR_CODE = /\x1b\[38;2;\d+;\d+;\d+m/g;
const ANSI256_CODE = /\x1b\[38;5;\d+m/g;
function countMatches(text: string, pattern: RegExp): number {
return text.match(pattern)?.length ?? 0;
}
describe('applyColors with a per-widget gradient foreground', () => {
const gradient = 'gradient:FF0000-0000FF';
it('paints each visible character at truecolor and closes with a reset', () => {
const out = applyColors('abcd', gradient, undefined, false, 'truecolor');
expect(countMatches(out, TRUECOLOR_CODE)).toBe(4);
expect(out.endsWith('\x1b[39m')).toBe(true);
});
it('uses 256-color escapes at ansi256 level', () => {
const out = applyColors('abc', gradient, undefined, false, 'ansi256');
expect(countMatches(out, ANSI256_CODE)).toBe(3);
expect(countMatches(out, TRUECOLOR_CODE)).toBe(0);
});
it('emits no gradient color at ansi16', () => {
const out = applyColors('abcd', gradient, undefined, false, 'ansi16');
expect(out).toBe('abcd');
expect(countMatches(out, TRUECOLOR_CODE)).toBe(0);
expect(countMatches(out, ANSI256_CODE)).toBe(0);
});
it('preserves a resolvable named preset', () => {
const out = applyColors('hello', 'gradient:atlas', undefined, false, 'truecolor');
expect(countMatches(out, TRUECOLOR_CODE)).toBe(5);
});
});
describe('getColorAnsiCode gradient first-stop fallback (powerline / ansi16 path)', () => {
it('collapses a gradient to its first stop as a solid foreground', () => {
expect(getColorAnsiCode('gradient:FF0000-0000FF', 'truecolor', false)).toBe('\x1b[38;2;255;0;0m');
});
it('honors the background flag', () => {
expect(getColorAnsiCode('gradient:FF0000-0000FF', 'truecolor', true)).toBe('\x1b[48;2;255;0;0m');
});
it('maps the first stop into the 256-color palette at ansi256', () => {
expect(getColorAnsiCode('gradient:FF0000-0000FF', 'ansi256', false)).toBe('\x1b[38;5;196m');
});
it('returns empty at ansi16 so gradients do not leak higher color levels', () => {
expect(getColorAnsiCode('gradient:FF0000-0000FF', 'ansi16', false)).toBe('');
expect(getColorAnsiCode('gradient:FF0000-0000FF', 'ansi16', true)).toBe('');
});
it('returns empty for an unparseable gradient spec', () => {
expect(getColorAnsiCode('gradient:not-a-color', 'truecolor', false)).toBe('');
});
});
+159
View File
@@ -0,0 +1,159 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';
import {
ZERO_COMPACTION_STATS,
computeCompactionStats,
getCompactionStats
} from '../compaction';
describe('computeCompactionStats', () => {
it('returns zeroed stats for no compaction markers', () => {
const lines = [
JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } }),
JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 100 } } })
];
expect(computeCompactionStats(lines)).toEqual({
count: 0,
byTrigger: { auto: 0, manual: 0, unknown: 0 },
tokensReclaimed: 0
});
});
it('counts each compact_boundary system record', () => {
const lines = [
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto', preTokens: 179004 } }),
JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 20000 } } }),
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'manual', preTokens: 837327, postTokens: 25443 } })
];
expect(computeCompactionStats(lines).count).toBe(2);
});
it('counts exactly one compaction despite transient 0% context frames (supersedes #370)', () => {
const lines = [
JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 150000 } } }),
JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 0 } } }),
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto', preTokens: 150000, postTokens: 20000 } }),
JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 0 } } }),
JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 20000 } } })
];
expect(computeCompactionStats(lines).count).toBe(1);
});
it('splits counts by trigger and buckets missing/unknown trigger as unknown', () => {
const lines = [
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto', preTokens: 1 } }),
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'manual', preTokens: 1 } }),
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'manual', preTokens: 1 } }),
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { preTokens: 1 } }),
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'future-mode', preTokens: 1 } }),
JSON.stringify({ type: 'system', subtype: 'compact_boundary' })
];
const stats = computeCompactionStats(lines);
expect(stats.byTrigger).toEqual({ auto: 1, manual: 2, unknown: 3 });
expect(stats.count).toBe(stats.byTrigger.auto + stats.byTrigger.manual + stats.byTrigger.unknown);
});
it('sums tokensReclaimed only for markers with both pre and post tokens', () => {
const lines = [
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'manual', preTokens: 900000, postTokens: 20000 } }),
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto', preTokens: 100000, postTokens: 30000 } }),
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto', preTokens: 50000 } })
];
// (900000-20000) + (100000-30000) = 880000 + 70000 = 950000; third marker lacks postTokens -> contributes 0
expect(computeCompactionStats(lines).tokensReclaimed).toBe(950000);
});
it('reports tokensReclaimed 0 when no marker has both pre and post tokens', () => {
const lines = [
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto', preTokens: 50000 } })
];
expect(computeCompactionStats(lines).tokensReclaimed).toBe(0);
});
it('floors per-marker tokensReclaimed at 0 when postTokens exceeds preTokens', () => {
const lines = [
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto', preTokens: 10000, postTokens: 50000 } })
];
expect(computeCompactionStats(lines).tokensReclaimed).toBe(0);
});
it('ignores other system records and malformed lines', () => {
const lines = [
JSON.stringify({ type: 'system', subtype: 'something_else' }),
'{ this is not valid json',
'',
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto' } })
];
expect(computeCompactionStats(lines).count).toBe(1);
});
it('does not count a non-system record that merely has the subtype string', () => {
const lines = [
JSON.stringify({ type: 'user', subtype: 'compact_boundary' })
];
expect(computeCompactionStats(lines).count).toBe(0);
});
it('excludes sidechain (subagent) records from every stat', () => {
const lines = [
JSON.stringify({ type: 'system', subtype: 'compact_boundary', isSidechain: true, compactMetadata: { trigger: 'auto', preTokens: 50000, postTokens: 10000 } })
];
expect(computeCompactionStats(lines)).toEqual({
count: 0,
byTrigger: { auto: 0, manual: 0, unknown: 0 },
tokensReclaimed: 0
});
});
it('counts a compact_boundary record with explicit isSidechain false', () => {
const lines = [
JSON.stringify({ type: 'system', subtype: 'compact_boundary', isSidechain: false, compactMetadata: { trigger: 'manual', preTokens: 100000 } })
];
expect(computeCompactionStats(lines).count).toBe(1);
});
});
describe('getCompactionStats', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'compaction-stats-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('returns zeroed stats when the transcript file does not exist', async () => {
await expect(getCompactionStats(path.join(dir, 'missing.jsonl'))).resolves.toEqual(ZERO_COMPACTION_STATS);
});
it('computes stats from a real-shaped transcript', async () => {
const file = path.join(dir, 'session.jsonl');
const content = [
JSON.stringify({ type: 'user', message: { role: 'user', content: 'start' } }),
JSON.stringify({ type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted', compactMetadata: { trigger: 'manual', preTokens: 837327, postTokens: 25443 }, version: '2.1.161' }),
JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 25443 } } }),
JSON.stringify({ type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted', compactMetadata: { trigger: 'auto', preTokens: 912661, postTokens: 30026 }, version: '2.1.161' })
].join('\n') + '\n';
fs.writeFileSync(file, content);
await expect(getCompactionStats(file)).resolves.toEqual({
count: 2,
byTrigger: { auto: 1, manual: 1, unknown: 0 },
tokensReclaimed: (837327 - 25443) + (912661 - 30026)
});
});
it('returns zeroed stats when the transcript path is not a readable file', async () => {
await expect(getCompactionStats(dir)).resolves.toEqual(ZERO_COMPACTION_STATS);
});
});
+48
View File
@@ -0,0 +1,48 @@
import * as os from 'os';
import * as path from 'path';
import {
beforeEach,
describe,
expect,
it
} from 'vitest';
import {
getConfigPath,
initConfigPath,
isCustomConfigPath
} from '../config';
const DEFAULT_PATH = path.join(os.homedir(), '.config', 'ccstatusline', 'settings.json');
describe('initConfigPath / getConfigPath', () => {
beforeEach(() => {
initConfigPath();
});
it('should return the default settings path when no arg is provided', () => {
initConfigPath();
expect(getConfigPath()).toBe(DEFAULT_PATH);
expect(isCustomConfigPath()).toBe(false);
});
it('should return a custom settings path when a file path is provided', () => {
initConfigPath('/tmp/my-ccsl/settings.json');
expect(getConfigPath()).toBe(path.resolve('/tmp/my-ccsl/settings.json'));
expect(isCustomConfigPath()).toBe(true);
});
it('should resolve relative paths', () => {
initConfigPath('relative/settings.json');
expect(path.isAbsolute(getConfigPath())).toBe(true);
expect(getConfigPath()).toBe(path.resolve('relative/settings.json'));
});
it('should reset to default when called with undefined', () => {
initConfigPath('/tmp/custom.json');
expect(isCustomConfigPath()).toBe(true);
initConfigPath(undefined);
expect(getConfigPath()).toBe(DEFAULT_PATH);
expect(isCustomConfigPath()).toBe(false);
});
});
+414
View File
@@ -0,0 +1,414 @@
import * as fs from 'fs';
import path from 'path';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance
} from 'vitest';
import {
CURRENT_VERSION,
DEFAULT_SETTINGS,
type InstallationMetadata,
type Settings
} from '../../types/Settings';
const MOCK_HOME_DIR = '/tmp/ccstatusline-config-test-home';
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
let loadSettings: () => Promise<Settings>;
let saveSettings: (settings: Settings) => Promise<void>;
let initConfigPath: (filePath?: string) => void;
let getConfigLoadError: () => string | null;
let saveInstallationMetadata: (metadata: InstallationMetadata | undefined) => Promise<void>;
let consoleErrorSpy: MockInstance<typeof console.error>;
function getSettingsPaths(): { configDir: string; settingsPath: string; backupPath: string } {
const configDir = path.join(MOCK_HOME_DIR, '.config', 'ccstatusline');
return {
configDir,
settingsPath: path.join(configDir, 'settings.json'),
backupPath: path.join(configDir, 'settings.bak')
};
}
function getClaudeConfigDir(): string {
return path.join(MOCK_HOME_DIR, '.claude');
}
describe('config utilities', () => {
beforeAll(async () => {
const configModule = await import('../config');
loadSettings = configModule.loadSettings;
saveSettings = configModule.saveSettings;
initConfigPath = configModule.initConfigPath;
getConfigLoadError = configModule.getConfigLoadError;
saveInstallationMetadata = configModule.saveInstallationMetadata;
});
beforeEach(() => {
fs.rmSync(MOCK_HOME_DIR, { recursive: true, force: true });
process.env.CLAUDE_CONFIG_DIR = getClaudeConfigDir();
const { settingsPath } = getSettingsPaths();
initConfigPath(settingsPath);
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
afterAll(() => {
fs.rmSync(MOCK_HOME_DIR, { recursive: true, force: true });
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
}
initConfigPath();
});
it('writes defaults when settings file does not exist', async () => {
const { settingsPath } = getSettingsPaths();
const settings = await loadSettings();
expect(settings.version).toBe(CURRENT_VERSION);
expect(fs.existsSync(settingsPath)).toBe(true);
const onDisk = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
version?: number;
lines?: unknown[];
};
expect(onDisk.version).toBe(CURRENT_VERSION);
expect(Array.isArray(onDisk.lines)).toBe(true);
expect(settings.gitCacheTtlSeconds).toBe(5);
expect((onDisk as { gitCacheTtlSeconds?: number }).gitCacheTtlSeconds).toBe(5);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Default settings written to')
);
});
it('uses defaults in memory and preserves invalid JSON without overwriting', async () => {
const { settingsPath, backupPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(settingsPath, '{ invalid json', 'utf-8');
const settings = await loadSettings();
// Defaults are returned in memory.
expect(settings.version).toBe(CURRENT_VERSION);
// The invalid file is left exactly as the user wrote it (not overwritten).
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json');
// No backup is created: recovery is non-destructive, so the original is the backup.
expect(fs.existsSync(backupPath)).toBe(false);
// A diagnostic is still emitted.
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to parse settings.json')
);
});
it('uses defaults in memory and preserves an invalid v1 payload', async () => {
const { settingsPath, backupPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
const original = JSON.stringify({ flexMode: 123 });
fs.writeFileSync(settingsPath, original, 'utf-8');
const settings = await loadSettings();
expect(settings.version).toBe(CURRENT_VERSION);
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(original);
expect(fs.existsSync(backupPath)).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid v1 settings format'),
expect.anything()
);
});
it('uses defaults in memory when schema validation fails', async () => {
const { settingsPath, backupPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
// Has a version (skips v1 branch), version === CURRENT_VERSION (no migration),
// but `lines: 42` is not an array, so SettingsSchema validation fails.
const original = JSON.stringify({ version: CURRENT_VERSION, lines: 42 });
fs.writeFileSync(settingsPath, original, 'utf-8');
const settings = await loadSettings();
expect(settings.version).toBe(CURRENT_VERSION);
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(original);
expect(fs.existsSync(backupPath)).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to parse settings, using defaults'),
expect.anything()
);
});
it('uses defaults in memory when the settings file cannot be read', async () => {
const { settingsPath, backupPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
// Make settings.json a directory so readFile throws (EISDIR) -> outer catch path.
fs.mkdirSync(settingsPath, { recursive: true });
const settings = await loadSettings();
expect(settings.version).toBe(CURRENT_VERSION);
// The path is left as-is (still a directory) — nothing was written over it.
expect(fs.statSync(settingsPath).isDirectory()).toBe(true);
expect(fs.existsSync(backupPath)).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Error loading settings'),
expect.anything()
);
});
it('migrates older versioned settings and persists migrated result', async () => {
const { settingsPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
settingsPath,
JSON.stringify({
version: 2,
lines: [[{ id: 'widget-1', type: 'model' }]]
}),
'utf-8'
);
const settings = await loadSettings();
expect(settings.version).toBe(CURRENT_VERSION);
const migrated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
version?: number;
updatemessage?: { message?: string };
};
expect(migrated.version).toBe(CURRENT_VERSION);
expect(migrated.updatemessage?.message).toContain('v2.0.2');
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('does not overwrite the file when a migration produces an invalid result', async () => {
const { settingsPath, backupPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
// A v2 config whose migrated v3 form fails schema validation: the v2->v3 migration
// copies fields through, so `lines: 42` survives into the v3 result and fails the schema.
const original = JSON.stringify({ version: 2, lines: 42 });
fs.writeFileSync(settingsPath, original, 'utf-8');
const settings = await loadSettings();
// Falls back to defaults in memory.
expect(settings.version).toBe(CURRENT_VERSION);
// The original file is preserved — the invalid migration was NOT written over it.
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(original);
// No backup, and no temp residue from an aborted write.
expect(fs.existsSync(backupPath)).toBe(false);
expect(fs.readdirSync(configDir).filter(name => name.endsWith('.tmp'))).toEqual([]);
// The failure is recorded so the statusline can warn.
expect(getConfigLoadError()).not.toBeNull();
});
it('does not overwrite an unreadable settings.json when recording installation metadata', async () => {
const { settingsPath, backupPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
const original = '{ invalid json';
fs.writeFileSync(settingsPath, original, 'utf-8');
await saveInstallationMetadata({ method: 'pinned' });
// The unreadable file is preserved, not overwritten with defaults+metadata.
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(original);
expect(fs.existsSync(backupPath)).toBe(false);
});
it('records installation metadata when settings.json is valid', async () => {
const { settingsPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
settingsPath,
JSON.stringify({ version: CURRENT_VERSION, lines: [[], [], []] }),
'utf-8'
);
await saveInstallationMetadata({ method: 'pinned' });
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { installation?: { method?: string } };
expect(saved.installation?.method).toBe('pinned');
});
it('always saves current version in saveSettings', async () => {
const { settingsPath } = getSettingsPaths();
await saveSettings({
...DEFAULT_SETTINGS,
version: 1
});
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
expect(saved.version).toBe(CURRENT_VERSION);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('saves settings without leaving a temp file behind', async () => {
const { settingsPath, configDir } = getSettingsPaths();
await saveSettings({ ...DEFAULT_SETTINGS });
// Final file is complete and valid.
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
expect(saved.version).toBe(CURRENT_VERSION);
// No temporary write-file is left in the config directory.
const leftovers = fs.readdirSync(configDir).filter(name => name.endsWith('.tmp'));
expect(leftovers).toEqual([]);
});
it('saves through a symlinked settings file without replacing the link', async () => {
const { settingsPath, configDir } = getSettingsPaths();
const targetDir = path.join(MOCK_HOME_DIR, 'dotfiles', 'ccstatusline');
const targetPath = path.join(targetDir, 'settings.json');
fs.mkdirSync(configDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(
targetPath,
JSON.stringify({ version: CURRENT_VERSION, lines: [[], [], []], flexMode: 'full' }),
'utf-8'
);
fs.symlinkSync(targetPath, settingsPath);
await saveSettings({
...DEFAULT_SETTINGS,
flexMode: 'full-minus-40'
});
expect(fs.lstatSync(settingsPath).isSymbolicLink()).toBe(true);
expect(fs.realpathSync(settingsPath)).toBe(fs.realpathSync(targetPath));
const saved = JSON.parse(fs.readFileSync(targetPath, 'utf-8')) as {
flexMode?: string;
version?: number;
};
expect(saved.version).toBe(CURRENT_VERSION);
expect(saved.flexMode).toBe('full-minus-40');
expect(fs.readdirSync(configDir).filter(name => name.endsWith('.tmp'))).toEqual([]);
expect(fs.readdirSync(targetDir).filter(name => name.endsWith('.tmp'))).toEqual([]);
});
it('migration write-back leaves no temp file behind', async () => {
const { settingsPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
settingsPath,
JSON.stringify({
version: 2,
lines: [[{ id: 'widget-1', type: 'model' }]]
}),
'utf-8'
);
await loadSettings();
const migrated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
expect(migrated.version).toBe(CURRENT_VERSION);
const leftovers = fs.readdirSync(configDir).filter(name => name.endsWith('.tmp'));
expect(leftovers).toEqual([]);
});
it('cleans up the temp file and rethrows when the write cannot be renamed into place', async () => {
const { settingsPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
// Make the target a directory so the final rename always fails, exercising
// the cleanup-on-error path in writeSettingsJson.
fs.mkdirSync(settingsPath, { recursive: true });
await expect(saveSettings({ ...DEFAULT_SETTINGS })).rejects.toThrow();
// The target is untouched and no temp file is left behind.
expect(fs.statSync(settingsPath).isDirectory()).toBe(true);
const leftovers = fs.readdirSync(configDir).filter(name => name.endsWith('.tmp'));
expect(leftovers).toEqual([]);
});
it('sets getConfigLoadError when settings.json contains invalid JSON', async () => {
const { configDir, settingsPath } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(settingsPath, '{ bad json', 'utf-8');
await loadSettings();
const err = getConfigLoadError();
expect(err).not.toBeNull();
expect(err).toContain('settings.json');
});
it('sets getConfigLoadError when settings.json has invalid schema', async () => {
const { configDir, settingsPath } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify({ version: CURRENT_VERSION, lines: 42 }), 'utf-8');
await loadSettings();
expect(getConfigLoadError()).not.toBeNull();
});
it('clears getConfigLoadError after loading a valid current-version config', async () => {
const { configDir, settingsPath } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
// Write a valid config so we don't go through first-run path
fs.writeFileSync(settingsPath, JSON.stringify({ version: CURRENT_VERSION, lines: [[], [], []] }), 'utf-8');
await loadSettings();
expect(getConfigLoadError()).toBeNull();
});
it('leaves getConfigLoadError null on first-run (no file)', async () => {
// No file written — loadSettings triggers writeDefaultSettings
await loadSettings();
expect(getConfigLoadError()).toBeNull();
});
it('silently rewrites legacy git-pr widget type to git-review on load', async () => {
const { settingsPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
settingsPath,
JSON.stringify({
version: CURRENT_VERSION,
lines: [
[
{ id: 'widget-1', type: 'model' },
{ id: 'widget-2', type: 'git-pr' }
],
[],
[]
]
}),
'utf-8'
);
const settings = await loadSettings();
// In-memory rewrite: legacy string is gone.
const types = settings.lines[0]?.map(item => item.type);
expect(types).toEqual(['model', 'git-review']);
// Load does not eagerly persist; the rewrite lands on next save.
const onDiskBeforeSave = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { lines: { type: string }[][] };
expect(onDiskBeforeSave.lines[0]?.[1]?.type).toBe('git-pr');
await saveSettings(settings);
const onDiskAfterSave = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { lines: { type: string }[][] };
expect(onDiskAfterSave.lines[0]?.[1]?.type).toBe('git-review');
});
});
+115 -2
View File
@@ -5,7 +5,10 @@ import {
} from 'vitest';
import type { RenderContext } from '../../types';
import { calculateContextPercentage } from '../context-percentage';
import {
calculateContextPercentage,
calculateContextPercentageMetrics
} from '../context-percentage';
describe('calculateContextPercentage', () => {
describe('Status JSON context_window', () => {
@@ -31,6 +34,20 @@ describe('calculateContextPercentage', () => {
expect(percentage).toBe(12.5);
});
it('should infer the window size for raw percentage metrics when size is missing', () => {
const context: RenderContext = {
data: {
model: { id: 'claude-sonnet-4-5-20250929[1m]' },
context_window: { used_percentage: 4.2 }
}
};
expect(calculateContextPercentageMetrics(context)).toEqual({
usedPercentage: 4.2,
windowSize: 1000000
});
});
it('should derive percentage from current usage and window size when used_percentage is missing', () => {
const context: RenderContext = {
data: {
@@ -50,6 +67,27 @@ describe('calculateContextPercentage', () => {
expect(percentage).toBe(20);
});
it('should return derived percentage metrics from current usage when used_percentage is missing', () => {
const context: RenderContext = {
data: {
context_window: {
context_window_size: 200000,
current_usage: {
input_tokens: 20000,
output_tokens: 10000,
cache_creation_input_tokens: 5000,
cache_read_input_tokens: 5000
}
}
}
};
expect(calculateContextPercentageMetrics(context)).toEqual({
usedPercentage: 20,
windowSize: 200000
});
});
it('should use context_window_size as denominator when falling back to token metrics', () => {
const context: RenderContext = {
data: {
@@ -68,6 +106,27 @@ describe('calculateContextPercentage', () => {
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
it('should return token-metric fallback metrics with the denominator used', () => {
const context: RenderContext = {
data: {
model: { id: 'claude-3-5-sonnet-20241022' },
context_window: { context_window_size: 1000000 }
},
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
expect(calculateContextPercentageMetrics(context)).toEqual({
usedPercentage: 4.2,
windowSize: 1000000
});
});
});
describe('Sonnet 4.5 with 1M context window', () => {
@@ -102,6 +161,59 @@ describe('calculateContextPercentage', () => {
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(100);
});
it('should calculate percentage using 1M denominator with 1M context label', () => {
const context: RenderContext = {
data: { model: { id: 'Opus 4.6 (1M context)' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
it('should calculate percentage using 1M denominator with 1M in parentheses', () => {
const context: RenderContext = {
data: { model: { id: 'Opus 4.6 (1M)' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
it('should calculate percentage from display_name when model id lacks context size suffix', () => {
const context: RenderContext = {
data: {
model: {
id: 'claude-opus-4-6',
display_name: 'Opus 4.6 (1M context)'
}
},
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
});
describe('Older models with 200k context window', () => {
@@ -126,6 +238,7 @@ describe('calculateContextPercentage', () => {
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(0);
expect(calculateContextPercentageMetrics(context)).toBeNull();
});
it('should use default 200k context when model ID is undefined', () => {
@@ -143,4 +256,4 @@ describe('calculateContextPercentage', () => {
expect(percentage).toBe(21.0);
});
});
});
});
+1 -1
View File
@@ -72,4 +72,4 @@ describe('getContextWindowMetrics', () => {
totalTokens: 6000
});
});
});
});
+41
View File
@@ -0,0 +1,41 @@
import {
describe,
expect,
it
} from 'vitest';
import { computeCiRollup } from '../git-review-cache';
const pass = { status: 'COMPLETED', conclusion: 'SUCCESS' };
const fail = { status: 'COMPLETED', conclusion: 'FAILURE' };
const running = { status: 'IN_PROGRESS', conclusion: '' };
const neutral = { status: 'COMPLETED', conclusion: 'NEUTRAL' };
const skipped = { status: 'COMPLETED', conclusion: 'SKIPPED' };
const statusPass = { state: 'SUCCESS' };
const statusFail = { state: 'FAILURE' };
const statusPending = { state: 'PENDING' };
describe('computeCiRollup', () => {
it.each([
['all passing check runs', [pass, pass, pass], { state: 'passing', failing: 0, pending: 0, success: 3 }],
['a failure makes it failing', [pass, fail, pass], { state: 'failing', failing: 1, pending: 0, success: 2 }],
['a pending run makes it pending', [pass, running], { state: 'pending', failing: 0, pending: 1, success: 1 }],
['failure takes precedence over pending', [fail, running, pass], { state: 'failing', failing: 1, pending: 1, success: 1 }],
['neutral and skipped are ignored (not counted as success)', [pass, neutral, skipped], { state: 'passing', failing: 0, pending: 0, success: 1 }],
['StatusContext success counts as success', [statusPass, statusPass], { state: 'passing', failing: 0, pending: 0, success: 2 }],
['StatusContext failure counts as failing', [statusPass, statusFail], { state: 'failing', failing: 1, pending: 0, success: 1 }],
['StatusContext pending counts as pending', [statusPass, statusPending], { state: 'pending', failing: 0, pending: 1, success: 1 }],
['the screenshot mix (1 fail, 1 neutral, 1 pending, 2 skipped, 4 success)', [fail, neutral, running, skipped, skipped, pass, pass, pass, pass], { state: 'failing', failing: 1, pending: 1, success: 4 }]
])('%s', (_label, rollup, expected) => {
expect(computeCiRollup(rollup)).toEqual(expected);
});
it.each([
['empty array', []],
['non-array', null],
['undefined', undefined],
['string', 'nope']
])('returns null for %s', (_label, input) => {
expect(computeCiRollup(input)).toBeNull();
});
});
+412
View File
@@ -0,0 +1,412 @@
import { execFileSync } from 'child_process';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import type { RenderContext } from '../../types/RenderContext';
import { clearGitCache } from '../git';
import {
buildRepoWebUrl,
getForkStatus,
getRemoteInfo,
getUpstreamRemoteInfo,
listRemotes,
parseRemoteUrl
} from '../git-remote';
import { expectGitExecOptions } from './git-test-helpers';
vi.mock('child_process', () => ({
execSync: vi.fn(),
execFileSync: vi.fn(),
spawnSync: vi.fn()
}));
const mockExecFileSync = execFileSync as unknown as {
mock: { calls: unknown[][] };
mockImplementation: (impl: () => never) => void;
mockImplementationOnce: (impl: () => never) => void;
mockReturnValue: (value: string) => void;
mockReturnValueOnce: (value: string) => void;
};
describe('git-remote utils', () => {
beforeEach(() => {
vi.clearAllMocks();
clearGitCache();
});
describe('parseRemoteUrl', () => {
describe('SSH format (git@host:owner/repo)', () => {
it('parses github.com SSH URL', () => {
expect(parseRemoteUrl('git@github.com:owner/repo.git')).toEqual({
host: 'github.com',
owner: 'owner',
repo: 'repo'
});
});
it('parses GHES SSH URL', () => {
expect(parseRemoteUrl('git@github.service.anz:org/project.git')).toEqual({
host: 'github.service.anz',
owner: 'org',
repo: 'project'
});
});
it('parses SSH URL without .git suffix', () => {
expect(parseRemoteUrl('git@github.com:owner/repo')).toEqual({
host: 'github.com',
owner: 'owner',
repo: 'repo'
});
});
it('parses SSH URL with trailing slash', () => {
expect(parseRemoteUrl('git@github.com:owner/repo/')).toEqual({
host: 'github.com',
owner: 'owner',
repo: 'repo'
});
});
it('parses GitLab SSH URL', () => {
expect(parseRemoteUrl('git@gitlab.com:group/project.git')).toEqual({
host: 'gitlab.com',
owner: 'group',
repo: 'project'
});
});
it('parses nested GitLab SSH namespace', () => {
expect(parseRemoteUrl('git@gitlab.com:group/subgroup/project.git')).toEqual({
host: 'gitlab.com',
owner: 'group/subgroup',
repo: 'project'
});
});
});
describe('HTTPS format', () => {
it('parses github.com HTTPS URL', () => {
expect(parseRemoteUrl('https://github.com/owner/repo.git')).toEqual({
host: 'github.com',
owner: 'owner',
repo: 'repo'
});
});
it('parses GHES HTTPS URL', () => {
expect(parseRemoteUrl('https://github.service.anz/org/project.git')).toEqual({
host: 'github.service.anz',
owner: 'org',
repo: 'project'
});
});
it('parses HTTPS URL without .git suffix', () => {
expect(parseRemoteUrl('https://github.com/owner/repo')).toEqual({
host: 'github.com',
owner: 'owner',
repo: 'repo'
});
});
it('parses HTTP URL', () => {
expect(parseRemoteUrl('http://github.com/owner/repo.git')).toEqual({
host: 'github.com',
owner: 'owner',
repo: 'repo'
});
});
it('parses nested HTTPS namespace', () => {
expect(parseRemoteUrl('https://gitlab.com/group/subgroup/project.git')).toEqual({
host: 'gitlab.com',
owner: 'group/subgroup',
repo: 'project'
});
});
it('preserves non-default HTTPS ports', () => {
expect(parseRemoteUrl('https://git.example.com:8443/team/repo.git')).toEqual({
host: 'git.example.com:8443',
owner: 'team',
repo: 'repo'
});
});
});
describe('ssh:// protocol format', () => {
it('parses ssh:// URL', () => {
expect(parseRemoteUrl('ssh://git@github.com/owner/repo.git')).toEqual({
host: 'github.com',
owner: 'owner',
repo: 'repo'
});
});
it('parses ssh:// URL for GHES', () => {
expect(parseRemoteUrl('ssh://git@github.service.anz/org/project.git')).toEqual({
host: 'github.service.anz',
owner: 'org',
repo: 'project'
});
});
it('omits ssh:// transport ports from the parsed host', () => {
expect(parseRemoteUrl('ssh://git@git.example.com:2222/team/repo.git')).toEqual({
host: 'git.example.com',
owner: 'team',
repo: 'repo'
});
});
});
describe('git:// protocol format', () => {
it('parses git:// URL', () => {
expect(parseRemoteUrl('git://github.com/owner/repo.git')).toEqual({
host: 'github.com',
owner: 'owner',
repo: 'repo'
});
});
});
describe('edge cases', () => {
it('returns null for empty string', () => {
expect(parseRemoteUrl('')).toBeNull();
});
it('returns null for whitespace-only string', () => {
expect(parseRemoteUrl(' ')).toBeNull();
});
it('returns null for invalid URL', () => {
expect(parseRemoteUrl('not-a-url')).toBeNull();
});
it('returns null for URL with only owner (no repo)', () => {
expect(parseRemoteUrl('https://github.com/owner')).toBeNull();
});
it('returns null for unsupported protocol', () => {
expect(parseRemoteUrl('ftp://github.com/owner/repo.git')).toBeNull();
});
it('trims whitespace from URL', () => {
expect(parseRemoteUrl(' https://github.com/owner/repo.git ')).toEqual({
host: 'github.com',
owner: 'owner',
repo: 'repo'
});
});
});
});
describe('getRemoteInfo', () => {
it('returns remote info for valid remote', () => {
mockExecFileSync.mockReturnValue('https://github.com/hangie/ccstatusline.git\n');
const context: RenderContext = { data: { cwd: '/tmp/repo' } };
const result = getRemoteInfo('origin', context);
expect(result).toEqual({
name: 'origin',
url: 'https://github.com/hangie/ccstatusline.git',
host: 'github.com',
owner: 'hangie',
repo: 'ccstatusline'
});
});
it('passes remote name as a literal git argument', () => {
mockExecFileSync.mockReturnValue('https://github.com/hangie/ccstatusline.git\n');
const remoteName = 'foo$(touch /tmp/pwn)';
getRemoteInfo(remoteName, {});
expect(mockExecFileSync.mock.calls[0]?.[0]).toBe('git');
expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual(['remote', 'get-url', '--', remoteName]);
expectGitExecOptions(mockExecFileSync.mock.calls[0]?.[2]);
});
it('returns null when remote does not exist', () => {
mockExecFileSync.mockImplementation(() => { throw new Error('No such remote'); });
expect(getRemoteInfo('nonexistent', {})).toBeNull();
});
it('returns null when URL cannot be parsed', () => {
mockExecFileSync.mockReturnValue('invalid-url\n');
expect(getRemoteInfo('origin', {})).toBeNull();
});
});
describe('getUpstreamRemoteInfo', () => {
it('prefers a literal upstream remote when present', () => {
mockExecFileSync.mockReturnValueOnce('https://github.com/upstream-owner/repo.git\n');
expect(getUpstreamRemoteInfo({})).toEqual({
name: 'upstream',
url: 'https://github.com/upstream-owner/repo.git',
host: 'github.com',
owner: 'upstream-owner',
repo: 'repo'
});
});
it('falls back to the tracking remote when upstream is not a remote name', () => {
mockExecFileSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
mockExecFileSync.mockReturnValueOnce('hangie/feature/new-git-and-worktree-widgets\n');
mockExecFileSync.mockReturnValueOnce('origin\nhangie\n');
mockExecFileSync.mockReturnValueOnce('https://github.com/hangie/ccstatusline.git\n');
expect(getUpstreamRemoteInfo({})).toEqual({
name: 'hangie',
url: 'https://github.com/hangie/ccstatusline.git',
host: 'github.com',
owner: 'hangie',
repo: 'ccstatusline'
});
});
it('matches the longest remote prefix when remote names contain slashes', () => {
mockExecFileSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
mockExecFileSync.mockReturnValueOnce('team/upstream/feature/worktree\n');
mockExecFileSync.mockReturnValueOnce('origin\nteam\nteam/upstream\n');
mockExecFileSync.mockReturnValueOnce('https://github.com/team/upstream-repo.git\n');
expect(getUpstreamRemoteInfo({})).toEqual({
name: 'team/upstream',
url: 'https://github.com/team/upstream-repo.git',
host: 'github.com',
owner: 'team',
repo: 'upstream-repo'
});
});
it('returns null when the tracking remote cannot be resolved', () => {
mockExecFileSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
mockExecFileSync.mockReturnValueOnce('hangie/feature/new-git-and-worktree-widgets\n');
mockExecFileSync.mockReturnValueOnce('origin\n');
expect(getUpstreamRemoteInfo({})).toBeNull();
});
});
describe('getForkStatus', () => {
it('detects fork when origin and upstream differ', () => {
mockExecFileSync.mockReturnValueOnce('https://github.com/hangie/ccstatusline.git\n');
mockExecFileSync.mockReturnValueOnce('https://github.com/sirmalloc/ccstatusline.git\n');
const result = getForkStatus({});
expect(result.isFork).toBe(true);
expect(result.origin?.owner).toBe('hangie');
expect(result.upstream?.owner).toBe('sirmalloc');
});
it('detects fork when repos have different names', () => {
mockExecFileSync.mockReturnValueOnce('https://github.com/hangie/my-fork.git\n');
mockExecFileSync.mockReturnValueOnce('https://github.com/hangie/original.git\n');
const result = getForkStatus({});
expect(result.isFork).toBe(true);
});
it('returns not a fork when only origin exists', () => {
mockExecFileSync.mockReturnValueOnce('https://github.com/owner/repo.git\n');
mockExecFileSync.mockImplementation(() => { throw new Error('No such remote'); });
const result = getForkStatus({});
expect(result.isFork).toBe(false);
expect(result.origin).not.toBeNull();
expect(result.upstream).toBeNull();
});
it('returns not a fork when origin equals upstream', () => {
mockExecFileSync.mockReturnValueOnce('https://github.com/owner/repo.git\n');
mockExecFileSync.mockReturnValueOnce('https://github.com/owner/repo.git\n');
const result = getForkStatus({});
expect(result.isFork).toBe(false);
});
it('returns not a fork when no remotes exist', () => {
mockExecFileSync.mockImplementation(() => { throw new Error('No such remote'); });
const result = getForkStatus({});
expect(result.isFork).toBe(false);
expect(result.origin).toBeNull();
expect(result.upstream).toBeNull();
});
});
describe('listRemotes', () => {
it('returns list of remote names', () => {
mockExecFileSync.mockReturnValue('origin\nupstream\n');
expect(listRemotes({})).toEqual(['origin', 'upstream']);
});
it('returns empty array when no remotes', () => {
mockExecFileSync.mockImplementation(() => { throw new Error('Not a git repo'); });
expect(listRemotes({})).toEqual([]);
});
it('filters empty lines', () => {
mockExecFileSync.mockReturnValue('origin\n\nupstream\n\n');
expect(listRemotes({})).toEqual(['origin', 'upstream']);
});
});
describe('buildRepoWebUrl', () => {
it('builds URL for github.com', () => {
const remote = {
name: 'origin',
url: 'git@github.com:owner/repo.git',
host: 'github.com',
owner: 'owner',
repo: 'repo'
};
expect(buildRepoWebUrl(remote)).toBe('https://github.com/owner/repo');
});
it('builds URL for GHES', () => {
const remote = {
name: 'origin',
url: 'git@github.service.anz:org/project.git',
host: 'github.service.anz',
owner: 'org',
repo: 'project'
};
expect(buildRepoWebUrl(remote)).toBe('https://github.service.anz/org/project');
});
it('builds URL for nested namespaces', () => {
const remote = {
name: 'origin',
url: 'git@gitlab.com:group/subgroup/project.git',
host: 'gitlab.com',
owner: 'group/subgroup',
repo: 'project'
};
expect(buildRepoWebUrl(remote)).toBe('https://gitlab.com/group/subgroup/project');
});
});
});
@@ -0,0 +1,901 @@
import {
describe,
expect,
it
} from 'vitest';
import {
fetchGitReviewData,
getCachedGitReviewData,
refreshGitReviewCacheFromCli,
type GitReviewCacheDeps
} from '../git-review-cache';
interface FakeCacheFile {
content: string;
mtimeMs: number;
}
interface PrCacheHarness {
cacheFiles: Map<string, FakeCacheFile>;
deps: GitReviewCacheDeps;
execCalls: { args: string[]; cmd: string; cwd?: string }[];
ghDurations: number[];
ghResponses: (Error | string)[];
glabResponses: (Error | string)[];
spawnCalls: { args: string[]; command: string }[];
advanceNow: (milliseconds: number) => void;
setCurrentRef: (ref: string) => void;
setOriginRemoteUrl: (url: string) => void;
setGlabAvailable: (available: boolean) => void;
setCliAuthedForHost: (cli: 'gh' | 'glab', host: string, authed: boolean) => void;
setSshHostAlias: (host: string, hostname: string) => void;
}
function createHarness(): PrCacheHarness {
const cacheFiles = new Map<string, FakeCacheFile>();
const execCalls: { args: string[]; cmd: string; cwd?: string }[] = [];
const ghDurations: number[] = [];
const ghResponses: (Error | string)[] = [];
const glabResponses: (Error | string)[] = [];
const spawnCalls: { args: string[]; command: string }[] = [];
let now = 1_700_000_000_000;
let currentRef = 'feature/cache-a';
let originRemoteUrl: string | null = null;
let glabAvailable = false;
const authedHosts: Record<'gh' | 'glab', Set<string>> = {
gh: new Set(),
glab: new Set()
};
const sshHostAliases = new Map<string, string>();
const deps: GitReviewCacheDeps = {
closeSync: () => undefined,
execFileSync: ((cmd, args, options) => {
const commandArgs = Array.isArray(args)
? args.map(arg => String(arg))
: [];
execCalls.push({
args: commandArgs,
cmd,
cwd: typeof options === 'object' && 'cwd' in options
? String(options.cwd)
: undefined
});
if (cmd === 'git' && commandArgs[0] === 'remote') {
if (originRemoteUrl === null) {
throw new Error('no origin configured');
}
return `${originRemoteUrl}\n`;
}
if (cmd === 'git' && commandArgs[0] === 'symbolic-ref')
return `${currentRef}\n`;
if (cmd === 'git' && commandArgs[0] === 'rev-parse')
return 'abc123\n';
if (cmd === 'ssh' && commandArgs[0] === '-G') {
const host = commandArgs[1];
if (!host)
throw new Error('missing ssh host');
return `hostname ${sshHostAliases.get(host) ?? host}\n`;
}
if (cmd === 'gh' && commandArgs[0] === '--version')
return 'gh version 2.0.0\n';
if (cmd === 'gh' && commandArgs[0] === 'auth' && commandArgs[1] === 'status') {
const hostIdx = commandArgs.indexOf('--hostname');
const host = hostIdx >= 0 ? commandArgs[hostIdx + 1] : undefined;
if (!host || !authedHosts.gh.has(host))
throw new Error(`gh not authed for ${host ?? '<unspecified>'}`);
return '';
}
if (cmd === 'gh' && commandArgs[0] === 'pr') {
now += ghDurations.shift() ?? 0;
const response = ghResponses.shift();
if (response instanceof Error)
throw response;
return response ?? '';
}
if (cmd === 'glab' && commandArgs[0] === '--version') {
if (!glabAvailable)
throw new Error('glab not installed');
return 'glab 1.44.0\n';
}
if (cmd === 'glab' && commandArgs[0] === 'auth' && commandArgs[1] === 'status') {
if (!glabAvailable)
throw new Error('glab not installed');
const hostIdx = commandArgs.indexOf('--hostname');
const host = hostIdx >= 0 ? commandArgs[hostIdx + 1] : undefined;
if (!host || !authedHosts.glab.has(host))
throw new Error(`glab not authed for ${host ?? '<unspecified>'}`);
return '';
}
if (cmd === 'glab' && commandArgs[0] === 'mr') {
const response = glabResponses.shift();
if (response instanceof Error)
throw response;
return response ?? '';
}
throw new Error(`Unexpected command: ${cmd} ${commandArgs.join(' ')}`);
}) as GitReviewCacheDeps['execFileSync'],
existsSync: filePath => cacheFiles.has(String(filePath)),
getExecPath: () => '/usr/bin/node',
getHomedir: () => '/tmp/home',
mkdirSync: () => undefined,
openSync: (filePath) => {
const normalizedPath = String(filePath);
if (cacheFiles.has(normalizedPath)) {
throw new Error('EEXIST');
}
cacheFiles.set(normalizedPath, { content: '', mtimeMs: now });
return 42;
},
now: () => now,
readFileSync: (filePath => cacheFiles.get(String(filePath))?.content ?? '') as GitReviewCacheDeps['readFileSync'],
getScriptPath: () => '/app/ccstatusline.js',
spawn: ((command, args) => {
spawnCalls.push({
args: Array.isArray(args) ? args.map(arg => String(arg)) : [],
command
});
return { unref: () => undefined };
}) as GitReviewCacheDeps['spawn'],
statSync: (filePath => ({ mtimeMs: cacheFiles.get(String(filePath))?.mtimeMs ?? now })) as GitReviewCacheDeps['statSync'],
unlinkSync: (filePath) => {
if (!cacheFiles.delete(String(filePath))) {
throw new Error('ENOENT');
}
},
writeFileSync: (filePath, content) => {
const normalizedContent = typeof content === 'string'
? content
: Buffer.isBuffer(content)
? content.toString('utf8')
: '';
cacheFiles.set(String(filePath), {
content: normalizedContent,
mtimeMs: now
});
}
};
return {
cacheFiles,
deps,
execCalls,
ghDurations,
ghResponses,
glabResponses,
spawnCalls,
advanceNow: (milliseconds) => {
now += milliseconds;
},
setCurrentRef: (ref: string) => {
currentRef = ref;
},
setOriginRemoteUrl: (url: string) => {
originRemoteUrl = url;
},
setGlabAvailable: (available: boolean) => {
glabAvailable = available;
},
setCliAuthedForHost: (cli: 'gh' | 'glab', host: string, authed: boolean) => {
if (authed) {
authedHosts[cli].add(host);
} else {
authedHosts[cli].delete(host);
}
},
setSshHostAlias: (host: string, hostname: string) => {
sshHostAliases.set(host, hostname);
}
};
}
function prepareCachePath(harness: PrCacheHarness): string {
getCachedGitReviewData('/tmp/repo', {}, harness.deps);
const lockPath = [...harness.cacheFiles.keys()].find(filePath => filePath.endsWith('.lock'));
if (!lockPath) {
throw new Error('Expected a refresh lock');
}
harness.cacheFiles.delete(lockPath);
harness.spawnCalls.length = 0;
return lockPath.slice(0, -'.lock'.length);
}
describe('git-review-cache', () => {
it('negative-caches failed gh PR lookups', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git');
harness.ghResponses.push(new Error('no pull request found'));
harness.ghResponses.push(new Error('no pull request found'));
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toBeNull();
const ghCallsAfterFirstRender = harness.execCalls.filter(call => call.cmd === 'gh');
expect(ghCallsAfterFirstRender).toHaveLength(3);
const ghPrCalls = ghCallsAfterFirstRender.filter(call => call.args[0] === 'pr');
expect(ghPrCalls).toHaveLength(2);
expect(ghPrCalls[0]?.args).not.toContain('--repo');
expect(ghPrCalls[1]?.args).toContain('--repo');
expect(ghPrCalls.every(call => call.args.at(-1) === 'url,number,title,state,reviewDecision')).toBe(true);
const cachedMissEntry = [...harness.cacheFiles.values()].at(0);
expect(JSON.parse(cachedMissEntry?.content ?? '')).toEqual({
checksQueried: true,
data: null,
version: 1
});
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toBeNull();
const ghCallsAfterSecondRender = harness.execCalls.filter(call => call.cmd === 'gh');
expect(ghCallsAfterSecondRender).toHaveLength(3);
});
it('does not retry metadata-only for ordinary CI lookup failures', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git');
harness.ghResponses.push(new Error('no pull request found'));
harness.ghResponses.push(new Error('no pull request found'));
expect(fetchGitReviewData('/tmp/repo', harness.deps, { includeChecks: true })).toBeNull();
const ghPrCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'pr'
);
expect(ghPrCalls).toHaveLength(2);
expect(ghPrCalls[0]?.args).not.toContain('--repo');
expect(ghPrCalls[1]?.args).toContain('--repo');
expect(ghPrCalls.every(call => call.args.at(-1)?.includes('statusCheckRollup'))).toBe(true);
});
it('shares one deadline across unpinned and pinned CI lookups', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git');
harness.ghDurations.push(5_000);
harness.ghResponses.push(new Error('timed out'));
harness.ghResponses.push(JSON.stringify({
number: 42,
reviewDecision: '',
state: 'OPEN',
title: 'Too late',
url: 'https://github.com/example-owner/example-repo/pull/42'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps, { includeChecks: true })).toBeNull();
const ghPrCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'pr'
);
expect(ghPrCalls).toHaveLength(1);
});
it('returns immediately on a cache miss and schedules one metadata refresh', () => {
const harness = createHarness();
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)).toBeNull();
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)).toBeNull();
expect(harness.execCalls.filter(call => call.cmd === 'gh')).toHaveLength(0);
expect(harness.spawnCalls).toHaveLength(1);
expect(harness.spawnCalls[0]?.command).toBe('/usr/bin/node');
expect(harness.spawnCalls[0]?.args.slice(0, 4)).toEqual([
'/app/ccstatusline.js',
'--internal-refresh-git-review-cache',
'/tmp/repo',
'metadata'
]);
});
it('refreshes through the detached CLI mode and releases its lock', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git');
harness.ghResponses.push(JSON.stringify({
number: 42,
reviewDecision: '',
state: 'OPEN',
title: 'Background result',
url: 'https://github.com/example-owner/example-repo/pull/42'
}));
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)).toBeNull();
const lockPath = [...harness.cacheFiles.keys()].find(filePath => filePath.endsWith('.lock'));
expect(lockPath).toBeDefined();
refreshGitReviewCacheFromCli('/tmp/repo', {}, lockPath ?? '', harness.deps);
expect([...harness.cacheFiles.keys()].some(filePath => filePath.endsWith('.lock'))).toBe(false);
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)?.title).toBe('Background result');
expect(harness.spawnCalls).toHaveLength(1);
});
it('returns stale data while scheduling a refresh', () => {
const harness = createHarness();
harness.ghResponses.push(JSON.stringify({
number: 42,
reviewDecision: '',
state: 'OPEN',
title: 'Stale but useful',
url: 'https://github.com/example-owner/example-repo/pull/42'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps)?.title).toBe('Stale but useful');
harness.advanceNow(30_001);
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)?.title).toBe('Stale but useful');
expect(harness.spawnCalls).toHaveLength(1);
});
it('recovers a stale refresh lock', () => {
const harness = createHarness();
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)).toBeNull();
expect(harness.spawnCalls).toHaveLength(1);
harness.advanceNow(30_001);
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)).toBeNull();
expect(harness.spawnCalls).toHaveLength(2);
});
it('reads legacy metadata cache files and upgrades them when CI is requested', () => {
const harness = createHarness();
const cachePath = prepareCachePath(harness);
const legacyData = {
number: 42,
reviewDecision: '',
state: 'OPEN',
title: 'Legacy cache',
url: 'https://github.com/example-owner/example-repo/pull/42'
};
harness.cacheFiles.set(cachePath, {
content: JSON.stringify(legacyData),
mtimeMs: harness.deps.now()
});
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)).toEqual(legacyData);
expect(harness.spawnCalls).toHaveLength(0);
expect(getCachedGitReviewData('/tmp/repo', { includeChecks: true }, harness.deps)).toEqual(legacyData);
expect(harness.spawnCalls).toHaveLength(1);
expect(harness.spawnCalls[0]?.args[3]).toBe('checks');
});
it('accepts legacy empty negative-cache files without refreshing them while fresh', () => {
const harness = createHarness();
const cachePath = prepareCachePath(harness);
harness.cacheFiles.set(cachePath, { content: '', mtimeMs: harness.deps.now() });
expect(getCachedGitReviewData('/tmp/repo', { includeChecks: true }, harness.deps)).toBeNull();
expect(harness.spawnCalls).toHaveLength(0);
});
it('records an empty CI rollup as queried so it is not fetched repeatedly', () => {
const harness = createHarness();
harness.ghResponses.push(JSON.stringify({
number: 42,
reviewDecision: '',
state: 'OPEN',
statusCheckRollup: [],
title: 'No checks configured',
url: 'https://github.com/example-owner/example-repo/pull/42'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps, { includeChecks: true })?.checks).toBeUndefined();
expect(getCachedGitReviewData('/tmp/repo', { includeChecks: true }, harness.deps)?.title).toBe('No checks configured');
expect(harness.spawnCalls).toHaveLength(0);
});
it('uses a different cache entry for each checked-out branch', () => {
const harness = createHarness();
harness.ghResponses.push(JSON.stringify({
number: 123,
reviewDecision: '',
state: 'OPEN',
title: 'First PR',
url: 'https://github.com/owner/repo/pull/123'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
number: 123,
provider: 'gh',
reviewDecision: '',
state: 'OPEN',
title: 'First PR',
url: 'https://github.com/owner/repo/pull/123'
});
harness.setCurrentRef('feature/cache-b');
harness.ghResponses.push(JSON.stringify({
number: 456,
reviewDecision: 'APPROVED',
state: 'OPEN',
title: 'Second PR',
url: 'https://github.com/owner/repo/pull/456'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
number: 456,
provider: 'gh',
reviewDecision: 'APPROVED',
state: 'OPEN',
title: 'Second PR',
url: 'https://github.com/owner/repo/pull/456'
});
const writtenCachePaths = [...harness.cacheFiles.keys()];
expect(writtenCachePaths.length).toBe(2);
expect(writtenCachePaths[0]).not.toBe(writtenCachePaths[1]);
const normalize = (filePath: string): string => filePath.replace(/\\/g, '/');
expect(normalize(writtenCachePaths[0] ?? '')).toContain('/.cache/ccstatusline/git-review/git-review-');
expect(normalize(writtenCachePaths[1] ?? '')).toContain('/.cache/ccstatusline/git-review/git-review-');
harness.setCurrentRef('feature/cache-a');
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
number: 123,
provider: 'gh',
reviewDecision: '',
state: 'OPEN',
title: 'First PR',
url: 'https://github.com/owner/repo/pull/123'
});
expect(harness.cacheFiles.size).toBe(2);
const ghPrCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'pr'
);
expect(ghPrCalls).toHaveLength(2);
});
it('fetches merge request data from glab for GitLab remotes', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('git@gitlab.com:owner/repo.git');
harness.setGlabAvailable(true);
harness.glabResponses.push(JSON.stringify({
iid: 77,
state: 'opened',
title: 'GitLab MR',
web_url: 'https://gitlab.com/owner/repo/-/merge_requests/77'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
number: 77,
provider: 'glab',
reviewDecision: '',
state: 'OPEN',
title: 'GitLab MR',
url: 'https://gitlab.com/owner/repo/-/merge_requests/77'
});
const ghCalls = harness.execCalls.filter(call => call.cmd === 'gh');
expect(ghCalls).toHaveLength(0);
const glabMrCalls = harness.execCalls.filter(
call => call.cmd === 'glab' && call.args[0] === 'mr'
);
expect(glabMrCalls).toHaveLength(1);
});
it('maps glab merged state to MERGED', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('https://gitlab.example.com/owner/repo.git');
harness.setGlabAvailable(true);
harness.glabResponses.push(JSON.stringify({
iid: 12,
state: 'merged',
title: 'Done',
web_url: 'https://gitlab.example.com/owner/repo/-/merge_requests/12'
}));
const data = fetchGitReviewData('/tmp/repo', harness.deps);
expect(data?.state).toBe('MERGED');
});
it('uses gh\'s default repo resolution and includes CI checks in one query', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git');
harness.ghResponses.push(JSON.stringify({
number: 42,
reviewDecision: '',
state: 'OPEN',
statusCheckRollup: [
{ conclusion: 'SUCCESS', status: 'COMPLETED' },
{ conclusion: '', status: 'IN_PROGRESS' }
],
title: 'Standard PR',
url: 'https://github.com/example-owner/example-repo/pull/42'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps, { includeChecks: true })).toEqual({
checks: {
failing: 0,
pending: 1,
state: 'pending',
success: 1
},
number: 42,
provider: 'gh',
reviewDecision: '',
state: 'OPEN',
title: 'Standard PR',
url: 'https://github.com/example-owner/example-repo/pull/42'
});
const ghPrCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'pr'
);
expect(ghPrCalls).toHaveLength(1);
expect(ghPrCalls[0]?.args).not.toContain('--repo');
expect(ghPrCalls[0]?.args.at(-1)).toBe(
'url,number,title,state,reviewDecision,statusCheckRollup'
);
});
it('retries metadata-only when gh cannot query CI checks', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git');
harness.ghResponses.push(new Error('statusCheckRollup is unavailable'));
harness.ghResponses.push(JSON.stringify({
number: 42,
reviewDecision: 'APPROVED',
state: 'OPEN',
title: 'Restricted token PR',
url: 'https://github.com/example-owner/example-repo/pull/42'
}));
const expected = {
number: 42,
provider: 'gh' as const,
reviewDecision: 'APPROVED',
state: 'OPEN',
title: 'Restricted token PR',
url: 'https://github.com/example-owner/example-repo/pull/42'
};
expect(fetchGitReviewData('/tmp/repo', harness.deps, { includeChecks: true })).toEqual(expected);
const ghPrCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'pr'
);
expect(ghPrCalls).toHaveLength(2);
expect(ghPrCalls[0]?.args.slice(0, -2)).toEqual(ghPrCalls[1]?.args.slice(0, -2));
expect(ghPrCalls[0]?.args.at(-1)).toBe(
'url,number,title,state,reviewDecision,statusCheckRollup'
);
expect(ghPrCalls[1]?.args.at(-1)).toBe('url,number,title,state,reviewDecision');
const cachedEntry = [...harness.cacheFiles.values()].at(0);
expect(JSON.parse(cachedEntry?.content ?? '')).toEqual({
checksQueried: true,
data: expected,
version: 1
});
expect(fetchGitReviewData('/tmp/repo', harness.deps, { includeChecks: true })).toEqual(expected);
const cachedGhPrCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'pr'
);
expect(cachedGhPrCalls).toHaveLength(2);
});
it('falls back to --repo <origin> for forked GitHub repos when gh\'s default resolves elsewhere', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('https://github.com/fork-owner/example-repo.git');
harness.ghResponses.push('');
harness.ghResponses.push(JSON.stringify({
number: 1,
reviewDecision: '',
state: 'OPEN',
title: 'Forked PR',
url: 'https://github.com/fork-owner/example-repo/pull/1'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
number: 1,
provider: 'gh',
reviewDecision: '',
state: 'OPEN',
title: 'Forked PR',
url: 'https://github.com/fork-owner/example-repo/pull/1'
});
const ghPrCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'pr'
);
expect(ghPrCalls).toHaveLength(2);
expect(ghPrCalls[0]?.args).not.toContain('--repo');
expect(ghPrCalls[1]?.args).toContain('--repo');
expect(ghPrCalls[1]?.args).toContain('https://github.com/fork-owner/example-repo');
expect(ghPrCalls[1]?.args).toContain('feature/cache-a');
});
it('reuses the pinned PR target for the metadata-only compatibility retry', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('https://github.com/fork-owner/example-repo.git');
harness.ghResponses.push('');
harness.ghResponses.push(new Error('statusCheckRollup is unavailable'));
harness.ghResponses.push(JSON.stringify({
number: 1,
reviewDecision: '',
state: 'OPEN',
title: 'Forked PR',
url: 'https://github.com/fork-owner/example-repo/pull/1'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps, { includeChecks: true })).toEqual({
number: 1,
provider: 'gh',
reviewDecision: '',
state: 'OPEN',
title: 'Forked PR',
url: 'https://github.com/fork-owner/example-repo/pull/1'
});
const ghPrCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'pr'
);
expect(ghPrCalls).toHaveLength(3);
expect(ghPrCalls[1]?.args.slice(0, -2)).toEqual(ghPrCalls[2]?.args.slice(0, -2));
expect(ghPrCalls[1]?.args).toContain('feature/cache-a');
expect(ghPrCalls[1]?.args).toContain('--repo');
expect(ghPrCalls[1]?.args).toContain('https://github.com/fork-owner/example-repo');
expect(ghPrCalls[2]?.args.at(-1)).toBe('url,number,title,state,reviewDecision');
});
it('resolves SSH host aliases before selecting GitHub and pinning --repo', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('git@mygit:owner/repo.git');
harness.setSshHostAlias('mygit', 'github.com');
harness.ghResponses.push('');
harness.ghResponses.push(JSON.stringify({
number: 1485,
reviewDecision: '',
state: 'OPEN',
title: 'Alias PR',
url: 'https://github.com/owner/repo/pull/1485'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
number: 1485,
provider: 'gh',
reviewDecision: '',
state: 'OPEN',
title: 'Alias PR',
url: 'https://github.com/owner/repo/pull/1485'
});
const sshCalls = harness.execCalls.filter(call => call.cmd === 'ssh');
expect(sshCalls.length).toBeGreaterThan(0);
expect(sshCalls.every(call => call.args.join(' ') === '-G mygit')).toBe(true);
const ghAuthCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'auth'
);
expect(ghAuthCalls).toHaveLength(0);
const ghPrCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'pr'
);
expect(ghPrCalls).toHaveLength(2);
expect(ghPrCalls[0]?.args).not.toContain('--repo');
expect(ghPrCalls[1]?.args).toContain('--repo');
expect(ghPrCalls[1]?.args).toContain('https://github.com/owner/repo');
});
it('preserves canonical GitHub SSH hosts when SSH config points at a transport endpoint', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('git@github.com:owner/repo.git');
harness.setSshHostAlias('github.com', 'ssh.github.com');
harness.ghResponses.push('');
harness.ghResponses.push(JSON.stringify({
number: 1486,
reviewDecision: '',
state: 'OPEN',
title: 'Canonical GitHub PR',
url: 'https://github.com/owner/repo/pull/1486'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
number: 1486,
provider: 'gh',
reviewDecision: '',
state: 'OPEN',
title: 'Canonical GitHub PR',
url: 'https://github.com/owner/repo/pull/1486'
});
const sshCalls = harness.execCalls.filter(call => call.cmd === 'ssh');
expect(sshCalls).toHaveLength(0);
const ghPrCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'pr'
);
expect(ghPrCalls).toHaveLength(2);
expect(ghPrCalls[1]?.args).toContain('--repo');
expect(ghPrCalls[1]?.args).toContain('https://github.com/owner/repo');
expect(ghPrCalls[1]?.args).not.toContain('https://ssh.github.com/owner/repo');
});
it('falls back to --repo <origin> for forked GitLab repos when glab\'s default resolves elsewhere', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('git@gitlab.com:fork-owner/example-fork.git');
harness.setGlabAvailable(true);
harness.glabResponses.push('');
harness.glabResponses.push(JSON.stringify({
iid: 9,
state: 'opened',
title: 'Forked MR',
web_url: 'https://gitlab.com/fork-owner/example-fork/-/merge_requests/9'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
number: 9,
provider: 'glab',
reviewDecision: '',
state: 'OPEN',
title: 'Forked MR',
url: 'https://gitlab.com/fork-owner/example-fork/-/merge_requests/9'
});
const glabMrCalls = harness.execCalls.filter(
call => call.cmd === 'glab' && call.args[0] === 'mr'
);
expect(glabMrCalls).toHaveLength(2);
expect(glabMrCalls[0]?.args).not.toContain('--repo');
expect(glabMrCalls[1]?.args).toContain('--repo');
expect(glabMrCalls[1]?.args).toContain('https://gitlab.com/fork-owner/example-fork');
expect(glabMrCalls[1]?.args).toContain('feature/cache-a');
});
it('uses glab for unknown host when only glab is authed', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('git@git.self-hosted.example:team/repo.git');
harness.setGlabAvailable(true);
harness.setCliAuthedForHost('glab', 'git.self-hosted.example', true);
harness.glabResponses.push(JSON.stringify({
iid: 5,
state: 'opened',
title: 'Self-hosted MR',
web_url: 'https://git.self-hosted.example/team/repo/-/merge_requests/5'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
number: 5,
provider: 'glab',
reviewDecision: '',
state: 'OPEN',
title: 'Self-hosted MR',
url: 'https://git.self-hosted.example/team/repo/-/merge_requests/5'
});
const ghPrCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'pr'
);
expect(ghPrCalls).toHaveLength(0);
const glabMrCalls = harness.execCalls.filter(
call => call.cmd === 'glab' && call.args[0] === 'mr'
);
expect(glabMrCalls).toHaveLength(1);
});
it('preserves non-default ports when probing and pinning self-hosted GitLab repos', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('https://git.self-hosted.example:8443/team/repo.git');
harness.setGlabAvailable(true);
harness.setCliAuthedForHost('glab', 'git.self-hosted.example:8443', true);
harness.glabResponses.push('');
harness.glabResponses.push(JSON.stringify({
iid: 8,
state: 'opened',
title: 'Port-hosted MR',
web_url: 'https://git.self-hosted.example:8443/team/repo/-/merge_requests/8'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
number: 8,
provider: 'glab',
reviewDecision: '',
state: 'OPEN',
title: 'Port-hosted MR',
url: 'https://git.self-hosted.example:8443/team/repo/-/merge_requests/8'
});
const glabAuthCalls = harness.execCalls.filter(
call => call.cmd === 'glab' && call.args[0] === 'auth'
);
expect(glabAuthCalls[0]?.args).toEqual([
'auth',
'status',
'--hostname',
'git.self-hosted.example:8443'
]);
const glabMrCalls = harness.execCalls.filter(
call => call.cmd === 'glab' && call.args[0] === 'mr'
);
expect(glabMrCalls).toHaveLength(2);
expect(glabMrCalls[1]?.args).toContain('--repo');
expect(glabMrCalls[1]?.args).toContain('https://git.self-hosted.example:8443/team/repo');
});
it('uses gh for unknown host when only gh is authed (no wasted glab mr calls)', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('git@git.self-hosted.example:team/repo.git');
harness.setGlabAvailable(true);
harness.setCliAuthedForHost('gh', 'git.self-hosted.example', true);
harness.ghResponses.push(JSON.stringify({
number: 7,
reviewDecision: '',
state: 'OPEN',
title: 'Self-hosted GHE PR',
url: 'https://git.self-hosted.example/team/repo/pull/7'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
number: 7,
provider: 'gh',
reviewDecision: '',
state: 'OPEN',
title: 'Self-hosted GHE PR',
url: 'https://git.self-hosted.example/team/repo/pull/7'
});
const glabMrCalls = harness.execCalls.filter(
call => call.cmd === 'glab' && call.args[0] === 'mr'
);
expect(glabMrCalls).toHaveLength(0);
const ghPrCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'pr'
);
expect(ghPrCalls).toHaveLength(1);
});
it('returns null for unknown host when neither CLI is authed', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('git@git.self-hosted.example:team/repo.git');
harness.setGlabAvailable(true);
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toBeNull();
const glabMrCalls = harness.execCalls.filter(
call => call.cmd === 'glab' && call.args[0] === 'mr'
);
expect(glabMrCalls).toHaveLength(0);
const ghPrCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'pr'
);
expect(ghPrCalls).toHaveLength(0);
const cachedMissEntry = [...harness.cacheFiles.values()].at(0);
expect(JSON.parse(cachedMissEntry?.content ?? '')).toEqual({
checksQueried: true,
data: null,
version: 1
});
});
it('prefers glab over gh for unknown host when both CLIs are authed', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('git@git.self-hosted.example:team/repo.git');
harness.setGlabAvailable(true);
harness.setCliAuthedForHost('glab', 'git.self-hosted.example', true);
harness.setCliAuthedForHost('gh', 'git.self-hosted.example', true);
harness.glabResponses.push(JSON.stringify({
iid: 3,
state: 'opened',
title: 'Ambiguous MR',
web_url: 'https://git.self-hosted.example/team/repo/-/merge_requests/3'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
number: 3,
provider: 'glab',
reviewDecision: '',
state: 'OPEN',
title: 'Ambiguous MR',
url: 'https://git.self-hosted.example/team/repo/-/merge_requests/3'
});
const ghPrCalls = harness.execCalls.filter(
call => call.cmd === 'gh' && call.args[0] === 'pr'
);
expect(ghPrCalls).toHaveLength(0);
const glabMrCalls = harness.execCalls.filter(
call => call.cmd === 'glab' && call.args[0] === 'mr'
);
expect(glabMrCalls).toHaveLength(1);
});
});
+15
View File
@@ -0,0 +1,15 @@
import { expect } from 'vitest';
export function expectGitExecOptions(options: unknown, cwd?: string): void {
expect(options).toEqual(expect.objectContaining({
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
windowsHide: true,
...(cwd ? { cwd } : {})
}));
expect((options as { env?: Record<string, string | undefined> }).env?.GIT_OPTIONAL_LOCKS).toBe('0');
if (!cwd)
expect(options).not.toHaveProperty('cwd');
}
+548 -22
View File
@@ -1,5 +1,9 @@
import { execSync } from 'child_process';
import { execFileSync } from 'child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
afterEach,
beforeEach,
describe,
expect,
@@ -9,22 +13,100 @@ import {
import type { RenderContext } from '../../types/RenderContext';
import {
clearGitCache,
getGitChangeCounts,
getGitFileStatusCounts,
getGitStatus,
isInsideGitWorkTree,
resolveGitCwd,
runGit
} from '../git';
vi.mock('child_process', () => ({ execSync: vi.fn() }));
import { expectGitExecOptions } from './git-test-helpers';
const mockExecSync = execSync as unknown as {
vi.mock('child_process', () => ({
execSync: vi.fn(),
execFileSync: vi.fn(),
spawnSync: vi.fn()
}));
const mockExecFileSync = execFileSync as unknown as {
mock: { calls: unknown[][] };
mockImplementation: (impl: () => never) => void;
mockReturnValue: (value: string) => void;
mockReturnValueOnce: (value: string) => void;
};
const ORIGINAL_HOME = process.env.HOME;
const ORIGINAL_USERPROFILE = process.env.USERPROFILE;
const tempPaths: string[] = [];
function useTempHome(): string {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-git-home-'));
tempPaths.push(home);
process.env.HOME = home;
process.env.USERPROFILE = home;
vi.spyOn(os, 'homedir').mockReturnValue(home);
return home;
}
function createGitRepo(): { root: string; headPath: string; indexPath: string } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-git-repo-'));
tempPaths.push(root);
const gitDir = path.join(root, '.git');
fs.mkdirSync(gitDir, { recursive: true });
const headPath = path.join(gitDir, 'HEAD');
const indexPath = path.join(gitDir, 'index');
fs.writeFileSync(headPath, 'ref: refs/heads/main\n', 'utf-8');
fs.writeFileSync(indexPath, '', 'utf-8');
return { root, headPath, indexPath };
}
function touch(filePath: string, mtimeMs: number): void {
const date = new Date(mtimeMs);
fs.utimesSync(filePath, date, date);
}
function getOnlyGitCachePath(home: string): string {
const cacheDir = path.join(home, '.cache', 'ccstatusline', 'git-cache');
const files = fs.readdirSync(cacheDir).filter(file => /^git-[a-f0-9]+\.json$/.test(file));
expect(files).toHaveLength(1);
return path.join(cacheDir, files[0] ?? '');
}
function readGitCacheJson(home: string): { cwd?: unknown; entries?: Record<string, unknown> } {
return JSON.parse(fs.readFileSync(getOnlyGitCachePath(home), 'utf-8')) as {
cwd?: unknown;
entries?: Record<string, unknown>;
};
}
describe('git utils', () => {
beforeEach(() => {
vi.clearAllMocks();
clearGitCache();
});
afterEach(() => {
clearGitCache();
vi.restoreAllMocks();
if (ORIGINAL_HOME === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = ORIGINAL_HOME;
}
if (ORIGINAL_USERPROFILE === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = ORIGINAL_USERPROFILE;
}
while (tempPaths.length > 0) {
const tempPath = tempPaths.pop();
if (tempPath) {
fs.rmSync(tempPath, { recursive: true, force: true });
}
}
});
describe('resolveGitCwd', () => {
@@ -81,57 +163,501 @@ describe('git utils', () => {
});
describe('runGit', () => {
it('runs git command with resolved cwd and trims output', () => {
mockExecSync.mockReturnValue(' feature/worktree \n');
it('runs git command with resolved cwd and trims trailing whitespace', () => {
mockExecFileSync.mockReturnValueOnce('feature/worktree\n');
const context: RenderContext = { data: { cwd: '/tmp/repo' } };
const result = runGit('branch --show-current', context);
const result = runGit('symbolic-ref --short HEAD', context);
expect(result).toBe('feature/worktree');
expect(mockExecSync.mock.calls[0]?.[0]).toBe('git branch --show-current');
expect(mockExecSync.mock.calls[0]?.[1]).toEqual({
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
cwd: '/tmp/repo'
});
expect(mockExecFileSync.mock.calls[0]?.[0]).toBe('git');
expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual(['symbolic-ref', '--short', 'HEAD']);
expectGitExecOptions(mockExecFileSync.mock.calls[0]?.[2], '/tmp/repo');
});
it('runs git command without cwd when no context directory exists', () => {
mockExecSync.mockReturnValue('true\n');
mockExecFileSync.mockReturnValueOnce('true\n');
const result = runGit('rev-parse --is-inside-work-tree', {});
expect(result).toBe('true');
expect(mockExecSync.mock.calls[0]?.[1]).toEqual({
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
});
expectGitExecOptions(mockExecFileSync.mock.calls[0]?.[2]);
});
it('returns null when the command fails', () => {
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
expect(runGit('status --short', {})).toBeNull();
});
it('reuses in-process cache entries while repo mtimes and TTL remain valid', () => {
useTempHome();
const { root } = createGitRepo();
const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 5 };
mockExecFileSync.mockReturnValueOnce('feature/cache\n');
expect(runGit('symbolic-ref --short HEAD', context)).toBe('feature/cache');
expect(runGit('symbolic-ref --short HEAD', context)).toBe('feature/cache');
expect(mockExecFileSync.mock.calls).toHaveLength(1);
});
it('reuses valid persistent cache entries after in-process cache is cleared', () => {
vi.spyOn(Date, 'now').mockReturnValue(1000);
const home = useTempHome();
const { root } = createGitRepo();
const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 5 };
mockExecFileSync.mockReturnValueOnce('feature/persisted\n');
expect(runGit('symbolic-ref --short HEAD', context)).toBe('feature/persisted');
expect(fs.existsSync(getOnlyGitCachePath(home))).toBe(true);
clearGitCache();
expect(runGit('symbolic-ref --short HEAD', context)).toBe('feature/persisted');
expect(mockExecFileSync.mock.calls).toHaveLength(1);
});
it('stores cwd once and uses command-only persistent cache keys', () => {
vi.spyOn(Date, 'now').mockReturnValue(1000);
const home = useTempHome();
const { root } = createGitRepo();
const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 5 };
mockExecFileSync.mockReturnValueOnce('1 file changed, 2 insertions(+)');
expect(runGit('diff --cached --shortstat', context)).toBe('1 file changed, 2 insertions(+)');
const cache = readGitCacheJson(home);
expect(cache.cwd).toBe(root);
expect(Object.keys(cache.entries ?? {})).toEqual(['diff --cached --shortstat']);
});
it('expires persistent cache entries older than the configured TTL', () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
useTempHome();
const { root } = createGitRepo();
const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 5 };
mockExecFileSync.mockReturnValueOnce('old-value\n');
expect(runGit('symbolic-ref --short HEAD', context)).toBe('old-value');
clearGitCache();
nowSpy.mockReturnValue(7000);
mockExecFileSync.mockReturnValueOnce('new-value\n');
expect(runGit('symbolic-ref --short HEAD', context)).toBe('new-value');
expect(mockExecFileSync.mock.calls).toHaveLength(2);
});
it('keeps persistent cache entries when TTL is zero and repo mtimes match', () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
useTempHome();
const { root } = createGitRepo();
const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 0 };
mockExecFileSync.mockReturnValueOnce('old-value\n');
expect(runGit('symbolic-ref --short HEAD', context)).toBe('old-value');
clearGitCache();
nowSpy.mockReturnValue(600000);
expect(runGit('symbolic-ref --short HEAD', context)).toBe('old-value');
expect(mockExecFileSync.mock.calls).toHaveLength(1);
});
it('invalidates cached output when HEAD or index mtimes change', () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
useTempHome();
const {
root,
indexPath
} = createGitRepo();
const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 60 };
mockExecFileSync.mockReturnValueOnce('old-value\n');
expect(runGit('status --porcelain -z', context)).toBe('old-value');
clearGitCache();
touch(indexPath, Date.now() + 10000);
nowSpy.mockReturnValue(2000);
mockExecFileSync.mockReturnValueOnce('new-value\n');
expect(runGit('status --porcelain -z', context)).toBe('new-value');
expect(mockExecFileSync.mock.calls).toHaveLength(2);
});
it('falls back to git when the persistent cache file is malformed', () => {
vi.spyOn(Date, 'now').mockReturnValue(1000);
const home = useTempHome();
const { root } = createGitRepo();
const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 5 };
mockExecFileSync.mockReturnValueOnce('old-value\n');
expect(runGit('symbolic-ref --short HEAD', context)).toBe('old-value');
fs.writeFileSync(getOnlyGitCachePath(home), '{ malformed json', 'utf-8');
clearGitCache();
mockExecFileSync.mockReturnValueOnce('new-value\n');
expect(runGit('symbolic-ref --short HEAD', context)).toBe('new-value');
expect(mockExecFileSync.mock.calls).toHaveLength(2);
});
});
describe('isInsideGitWorkTree', () => {
it('returns true when git reports true', () => {
mockExecSync.mockReturnValue('true\n');
mockExecFileSync.mockReturnValueOnce('true\n');
expect(isInsideGitWorkTree({})).toBe(true);
});
it('returns false when git reports false', () => {
mockExecSync.mockReturnValue('false\n');
mockExecFileSync.mockReturnValueOnce('false\n');
expect(isInsideGitWorkTree({})).toBe(false);
});
it('returns false when git command fails', () => {
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
expect(isInsideGitWorkTree({})).toBe(false);
});
});
});
describe('getGitChangeCounts', () => {
it('sums staged and unstaged insertions/deletions', () => {
mockExecFileSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)');
mockExecFileSync.mockReturnValueOnce('1 file changed, 3 insertions(+), 4 deletions(-)');
expect(getGitChangeCounts({})).toEqual({
insertions: 5,
deletions: 5
});
});
it('handles singular insertion/deletion forms', () => {
mockExecFileSync.mockReturnValueOnce('1 file changed, 1 insertion(+), 1 deletion(-)');
mockExecFileSync.mockReturnValueOnce('');
expect(getGitChangeCounts({})).toEqual({
insertions: 1,
deletions: 1
});
});
it('returns zero counts when git diff commands fail', () => {
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
expect(getGitChangeCounts({})).toEqual({
insertions: 0,
deletions: 0
});
});
});
describe('getGitStatus', () => {
it('returns all false when no git output', () => {
mockExecFileSync.mockReturnValueOnce('');
expect(getGitStatus({})).toEqual({
staged: false,
unstaged: false,
untracked: false,
conflicts: false
});
});
it('detects staged modification', () => {
mockExecFileSync.mockReturnValueOnce('M file.txt');
const result = getGitStatus({});
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(false);
expect(result.conflicts).toBe(false);
});
it('detects unstaged modification', () => {
mockExecFileSync.mockReturnValueOnce(' M file.txt');
const result = getGitStatus({});
expect(result.staged).toBe(false);
expect(result.unstaged).toBe(true);
expect(result.conflicts).toBe(false);
});
it('detects both staged and unstaged modification', () => {
mockExecFileSync.mockReturnValueOnce('MM file.txt');
const result = getGitStatus({});
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(true);
expect(result.conflicts).toBe(false);
});
it('detects unstaged deletion', () => {
mockExecFileSync.mockReturnValueOnce(' D file.txt');
const result = getGitStatus({});
expect(result.staged).toBe(false);
expect(result.unstaged).toBe(true);
expect(result.conflicts).toBe(false);
});
it('detects staged deletion', () => {
mockExecFileSync.mockReturnValueOnce('D file.txt');
const result = getGitStatus({});
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(false);
expect(result.conflicts).toBe(false);
});
it('detects untracked files', () => {
mockExecFileSync.mockReturnValueOnce('?? newfile.txt');
const result = getGitStatus({});
expect(result.untracked).toBe(true);
expect(result.staged).toBe(false);
expect(result.unstaged).toBe(false);
expect(result.conflicts).toBe(false);
});
it('detects merge conflict: both modified (UU)', () => {
mockExecFileSync.mockReturnValueOnce('UU file.txt');
const result = getGitStatus({});
expect(result.conflicts).toBe(true);
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(true);
});
it('detects merge conflict: added by us (AU)', () => {
mockExecFileSync.mockReturnValueOnce('AU file.txt');
const result = getGitStatus({});
expect(result.conflicts).toBe(true);
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(true);
});
it('detects merge conflict: deleted by us (DU)', () => {
mockExecFileSync.mockReturnValueOnce('DU file.txt');
const result = getGitStatus({});
expect(result.conflicts).toBe(true);
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(true);
});
it('detects merge conflict: both added (AA)', () => {
mockExecFileSync.mockReturnValueOnce('AA file.txt');
const result = getGitStatus({});
expect(result.conflicts).toBe(true);
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(true);
});
it('detects merge conflict: added by them (UA)', () => {
mockExecFileSync.mockReturnValueOnce('UA file.txt');
const result = getGitStatus({});
expect(result.conflicts).toBe(true);
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(true);
});
it('detects merge conflict: deleted by them (UD)', () => {
mockExecFileSync.mockReturnValueOnce('UD file.txt');
const result = getGitStatus({});
expect(result.conflicts).toBe(true);
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(true);
});
it('detects merge conflict: both deleted (DD)', () => {
mockExecFileSync.mockReturnValueOnce('DD file.txt');
const result = getGitStatus({});
expect(result.conflicts).toBe(true);
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(true);
});
it('detects renamed file in index (staged)', () => {
mockExecFileSync.mockReturnValueOnce('R oldname.txt -> newname.txt');
const result = getGitStatus({});
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(false);
expect(result.conflicts).toBe(false);
});
it('detects copied file in index (staged)', () => {
mockExecFileSync.mockReturnValueOnce('C original.txt -> copy.txt');
const result = getGitStatus({});
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(false);
expect(result.conflicts).toBe(false);
});
it('ignores rename source path in porcelain -z output', () => {
mockExecFileSync.mockReturnValueOnce('R new-name.txt\0DUCK.txt\0');
const result = getGitStatus({});
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(false);
expect(result.conflicts).toBe(false);
});
it('ignores copy source path in porcelain -z output', () => {
mockExecFileSync.mockReturnValueOnce('C copy.txt\0MOUSE.txt\0');
const result = getGitStatus({});
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(false);
expect(result.conflicts).toBe(false);
});
it('ignores unstaged rename source path in porcelain -z output', () => {
mockExecFileSync.mockReturnValueOnce(' R new-name.txt\0ANT.txt\0');
const result = getGitStatus({});
expect(result.staged).toBe(false);
expect(result.unstaged).toBe(true);
expect(result.conflicts).toBe(false);
});
it('ignores unstaged copy source path in porcelain -z output', () => {
mockExecFileSync.mockReturnValueOnce(' C copy.txt\0MOUSE.txt\0');
const result = getGitStatus({});
expect(result.staged).toBe(false);
expect(result.unstaged).toBe(true);
expect(result.conflicts).toBe(false);
});
it('detects type changed file in index (staged)', () => {
mockExecFileSync.mockReturnValueOnce('T file.txt');
const result = getGitStatus({});
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(false);
expect(result.conflicts).toBe(false);
});
it('detects mixed status with multiple files', () => {
mockExecFileSync.mockReturnValueOnce('M staged.txt\0 M unstaged.txt\0?? untracked.txt');
const result = getGitStatus({});
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(true);
expect(result.untracked).toBe(true);
expect(result.conflicts).toBe(false);
});
it('detects mixed status with conflicts', () => {
mockExecFileSync.mockReturnValueOnce('UU conflict.txt\0M staged.txt\0 M unstaged.txt\0?? untracked.txt');
const result = getGitStatus({});
expect(result.conflicts).toBe(true);
expect(result.staged).toBe(true);
expect(result.unstaged).toBe(true);
expect(result.untracked).toBe(true);
});
it('handles git command failure', () => {
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
expect(getGitStatus({})).toEqual({
staged: false,
unstaged: false,
untracked: false,
conflicts: false
});
});
});
describe('getGitFileStatusCounts', () => {
it('counts staged, unstaged, and untracked files from porcelain status', () => {
mockExecFileSync.mockReturnValueOnce('M staged-a.ts\0A staged-b.ts\0 M unstaged-a.ts\0?? new-a.ts\0?? new-b.ts\0');
expect(getGitFileStatusCounts({})).toEqual({
staged: 2,
unstaged: 1,
untracked: 2
});
});
it('counts files with both staged and unstaged changes in both totals', () => {
mockExecFileSync.mockReturnValueOnce('MM file.ts');
expect(getGitFileStatusCounts({})).toEqual({
staged: 1,
unstaged: 1,
untracked: 0
});
});
it('returns zero counts when there are no matching files', () => {
mockExecFileSync.mockReturnValueOnce('');
expect(getGitFileStatusCounts({})).toEqual({
staged: 0,
unstaged: 0,
untracked: 0
});
});
it('ignores rename source paths in porcelain -z output', () => {
mockExecFileSync.mockReturnValueOnce('R new-name.ts\0old-name.ts\0?? new-file.ts\0');
expect(getGitFileStatusCounts({})).toEqual({
staged: 1,
unstaged: 0,
untracked: 1
});
});
it('ignores copy source paths in porcelain -z output', () => {
mockExecFileSync.mockReturnValueOnce('C copy.ts\0original.ts\0 M changed.ts');
expect(getGitFileStatusCounts({})).toEqual({
staged: 1,
unstaged: 1,
untracked: 0
});
});
it('ignores unstaged rename source paths in porcelain -z output', () => {
mockExecFileSync.mockReturnValueOnce(' R new-name.ts\0A-old-name.ts\0?? new-file.ts\0');
expect(getGitFileStatusCounts({})).toEqual({
staged: 0,
unstaged: 1,
untracked: 1
});
});
it('ignores unstaged copy source paths in porcelain -z output', () => {
mockExecFileSync.mockReturnValueOnce(' C copy.ts\0M-original.ts\0 M changed.ts');
expect(getGitFileStatusCounts({})).toEqual({
staged: 0,
unstaged: 2,
untracked: 0
});
});
it('returns zero counts when git commands fail', () => {
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
expect(getGitFileStatusCounts({})).toEqual({
staged: 0,
unstaged: 0,
untracked: 0
});
});
});
});
@@ -0,0 +1,157 @@
import * as childProcess from 'child_process';
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest';
import {
getCommandResolutionPaths,
inspectGlobalCommandResolution
} from '../global-command-resolution';
import {
getPackageManagerExecutable,
getPackageManagerShellOptions
} from '../package-manager-executable';
function mockExecFileSync(responses: Record<string, string>) {
return vi.spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => {
const key = `${command} ${(args as string[]).join(' ')}`;
const response = responses[key];
if (response === undefined) {
throw new Error(`Unexpected command: ${key}`);
}
return response;
});
}
describe('global command resolution', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('uses where on Windows and treats same-directory shims as one install', () => {
const execFileSyncSpy = mockExecFileSync({
'where ccstatusline': 'C:\\Users\\Alice\\AppData\\Roaming\\npm\\ccstatusline.cmd\r\nC:\\Users\\Alice\\AppData\\Roaming\\npm\\ccstatusline.ps1\r\n',
'npm.cmd prefix -g': 'C:\\Users\\Alice\\AppData\\Roaming\\npm\r\n'
});
const resolution = inspectGlobalCommandResolution('npm', { platform: 'win32' });
expect(resolution.resolvedPaths).toEqual([
'C:\\Users\\Alice\\AppData\\Roaming\\npm\\ccstatusline.cmd',
'C:\\Users\\Alice\\AppData\\Roaming\\npm\\ccstatusline.ps1'
]);
expect(resolution.warning).toBeNull();
expect(execFileSyncSpy).toHaveBeenCalledWith(
'npm.cmd',
['prefix', '-g'],
expect.objectContaining({ shell: true })
);
});
it('resolves the Windows npm executable shim for execFile calls', () => {
expect(getPackageManagerExecutable('npm', 'win32')).toBe('npm.cmd');
expect(getPackageManagerExecutable('npm', 'linux')).toBe('npm');
expect(getPackageManagerExecutable('bun', 'win32')).toBe('bun');
expect(getPackageManagerShellOptions('npm.cmd', 'win32')).toEqual({ shell: true });
expect(getPackageManagerShellOptions('npm', 'linux')).toEqual({});
expect(getPackageManagerShellOptions('bun', 'win32')).toEqual({});
});
it('uses which -a on POSIX/WSL', () => {
mockExecFileSync({ 'which -a ccstatusline': '/home/alice/.bun/bin/ccstatusline\n' });
expect(getCommandResolutionPaths('ccstatusline', { platform: 'linux' })).toEqual([
'/home/alice/.bun/bin/ccstatusline'
]);
});
it('silences child stderr on best-effort probes so failures cannot leak to the terminal', () => {
const execFileSyncSpy = mockExecFileSync({
'which -a ccstatusline': '/home/alice/.bun/bin/ccstatusline\n',
'bun pm bin -g': '/home/alice/.bun/bin\n'
});
inspectGlobalCommandResolution('bun', { platform: 'linux' });
expect(execFileSyncSpy).toHaveBeenCalled();
for (const call of execFileSyncSpy.mock.calls) {
const options = call[2] as { stdio?: string[] };
expect(options.stdio).toEqual(['ignore', 'pipe', 'ignore']);
}
});
it('treats a probe that throws with stderr output as not found without surfacing an error', () => {
vi.spyOn(childProcess, 'execFileSync').mockImplementation(() => {
throw new Error('error: No package.json was found for directory "C:\\Users\\alice\\.bun\\install\\global"');
});
const resolution = inspectGlobalCommandResolution('bun', { platform: 'win32' });
expect(resolution.resolvedPaths).toEqual([]);
expect(resolution.expectedBinDir).toBeNull();
});
it('warns when multiple PATH directories contain ccstatusline', () => {
mockExecFileSync({
'which -a ccstatusline': '/home/alice/.bun/bin/ccstatusline\n/usr/local/bin/ccstatusline\n',
'bun pm bin -g': '/home/alice/.bun/bin\n'
});
const resolution = inspectGlobalCommandResolution('bun', { platform: 'linux' });
expect(resolution.warning).toContain('Multiple ccstatusline binaries are on PATH');
expect(resolution.warning).toContain('/home/alice/.bun/bin/ccstatusline');
expect(resolution.warning).toContain('/usr/local/bin/ccstatusline');
});
it('ignores transient bunx status line shims when resolving global commands', () => {
mockExecFileSync({
'which -a ccstatusline': '/var/folders/demo/T/bunx-501-ccstatusline@latest/node_modules/.bin/ccstatusline\n/Users/alice/.bun/bin/ccstatusline\n',
'bun pm bin -g': '/Users/alice/.bun/bin\n'
});
const resolution = inspectGlobalCommandResolution('bun', { platform: 'darwin' });
expect(resolution.firstResolvedPath).toBe('/Users/alice/.bun/bin/ccstatusline');
expect(resolution.resolvedPaths).toEqual(['/Users/alice/.bun/bin/ccstatusline']);
expect(resolution.warning).toBeNull();
});
it('compares Windows npm prefixes with WSL /mnt paths', () => {
mockExecFileSync({
'which -a ccstatusline': '/mnt/c/Users/Alice/AppData/Roaming/npm/ccstatusline\n',
'npm prefix -g': 'C:\\Users\\Alice\\AppData\\Roaming\\npm\n'
});
const resolution = inspectGlobalCommandResolution('npm', { platform: 'linux' });
expect(resolution.expectedBinDir).toBe('C:\\Users\\Alice\\AppData\\Roaming\\npm');
expect(resolution.warning).toBeNull();
});
it('warns when the first resolved binary is outside the selected manager bin directory', () => {
mockExecFileSync({
'which -a ccstatusline': '/usr/local/bin/ccstatusline\n',
'bun pm bin -g': '/home/alice/.bun/bin\n'
});
const resolution = inspectGlobalCommandResolution('bun', { platform: 'linux' });
expect(resolution.warning).toContain('outside the bun global bin directory');
expect(resolution.warning).toContain('/usr/local/bin/ccstatusline');
});
it('warns when ccstatusline is not resolvable after a global install', () => {
mockExecFileSync({ 'npm prefix -g': '/usr/local\n' });
const resolution = inspectGlobalCommandResolution('npm', { platform: 'linux' });
expect(resolution.warning).toContain('not currently resolvable on PATH');
});
});
@@ -0,0 +1,194 @@
import * as childProcess from 'child_process';
import * as fs from 'fs';
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest';
import {
inspectActiveGlobalCommand,
inspectGlobalPackageInstallations,
runGlobalPackageUninstall
} from '../global-package-manager';
function mockExecFileSync(responses: Record<string, string>) {
return vi.spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => {
const key = `${command} ${(args as string[]).join(' ')}`;
const response = responses[key];
if (response === undefined) {
throw new Error(`Unexpected command: ${key}`);
}
return response;
});
}
describe('global package manager inspection', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('detects npm installs through WSL path variants', () => {
mockExecFileSync({
'which -a ccstatusline': '',
'npm prefix -g': 'C:\\Users\\Alice\\AppData\\Roaming\\npm\n'
});
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => (
filePath === '/mnt/c/Users/Alice/AppData/Roaming/npm/ccstatusline'
));
const installations = inspectGlobalPackageInstallations({
commandAvailability: {
npm: true,
bun: false
},
platform: 'linux'
});
expect(installations).toEqual([
{
packageManager: 'npm',
available: true,
installed: true,
binDir: 'C:\\Users\\Alice\\AppData\\Roaming\\npm'
},
{
packageManager: 'bun',
available: false,
installed: false,
binDir: null
}
]);
});
it('identifies the active package manager and version from the first PATH match', () => {
mockExecFileSync({
'which -a ccstatusline': '/Users/alice/.bun/bin/ccstatusline\n/Users/alice/.nvm/versions/node/v24.9.0/bin/ccstatusline\n',
'npm prefix -g': '/Users/alice/.nvm/versions/node/v24.9.0\n',
'bun pm bin -g': '/Users/alice/.bun/bin\n'
});
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => (
filePath === '/Users/alice/.bun/install/global/node_modules/ccstatusline/package.json'
));
vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
if (filePath === '/Users/alice/.bun/install/global/node_modules/ccstatusline/package.json') {
return '{"version":"2.2.13"}';
}
throw new Error(`Unexpected read: ${String(filePath)}`);
});
const activeCommand = inspectActiveGlobalCommand({
commandAvailability: {
npm: true,
bun: true
},
platform: 'darwin'
});
expect(activeCommand).toEqual({
packageManager: 'bun',
resolvedPath: '/Users/alice/.bun/bin/ccstatusline',
resolvedPaths: [
'/Users/alice/.bun/bin/ccstatusline',
'/Users/alice/.nvm/versions/node/v24.9.0/bin/ccstatusline'
],
binDir: '/Users/alice/.bun/bin',
version: '2.2.13',
warning: '⚠ Multiple ccstatusline binaries are on PATH. Claude Code will run the first match: /Users/alice/.bun/bin/ccstatusline.\nOther matches: /Users/alice/.nvm/versions/node/v24.9.0/bin/ccstatusline'
});
});
it('ignores transient bunx status line shims when identifying the active global command', () => {
mockExecFileSync({
'which -a ccstatusline': '/var/folders/demo/T/bunx-501-ccstatusline@latest/node_modules/.bin/ccstatusline\n/Users/alice/.bun/bin/ccstatusline\n',
'npm prefix -g': '/Users/alice/.nvm/versions/node/v24.9.0\n',
'bun pm bin -g': '/Users/alice/.bun/bin\n'
});
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => (
filePath === '/Users/alice/.bun/install/global/node_modules/ccstatusline/package.json'
));
vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
if (filePath === '/Users/alice/.bun/install/global/node_modules/ccstatusline/package.json') {
return '{"version":"2.2.14"}';
}
throw new Error(`Unexpected read: ${String(filePath)}`);
});
const activeCommand = inspectActiveGlobalCommand({
commandAvailability: {
npm: true,
bun: true
},
platform: 'darwin'
});
expect(activeCommand).toEqual({
packageManager: 'bun',
resolvedPath: '/Users/alice/.bun/bin/ccstatusline',
resolvedPaths: ['/Users/alice/.bun/bin/ccstatusline'],
binDir: '/Users/alice/.bun/bin',
version: '2.2.14',
warning: null
});
});
it('uses npm.cmd for Windows npm version lookup', () => {
const execFileSyncSpy = mockExecFileSync({
'where ccstatusline': 'C:\\Users\\Alice\\AppData\\Roaming\\npm\\ccstatusline.cmd\r\n',
'npm.cmd prefix -g': 'C:\\Users\\Alice\\AppData\\Roaming\\npm\r\n',
'npm.cmd root -g': 'C:\\Users\\Alice\\AppData\\Roaming\\npm\\node_modules\r\n'
});
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => (
filePath === 'C:\\Users\\Alice\\AppData\\Roaming\\npm\\node_modules\\ccstatusline\\package.json'
));
vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
if (filePath === 'C:\\Users\\Alice\\AppData\\Roaming\\npm\\node_modules\\ccstatusline\\package.json') {
return '{"version":"2.2.13"}';
}
throw new Error(`Unexpected read: ${String(filePath)}`);
});
const activeCommand = inspectActiveGlobalCommand({
commandAvailability: {
npm: true,
bun: false
},
platform: 'win32'
});
expect(activeCommand).toEqual({
packageManager: 'npm',
resolvedPath: 'C:\\Users\\Alice\\AppData\\Roaming\\npm\\ccstatusline.cmd',
resolvedPaths: ['C:\\Users\\Alice\\AppData\\Roaming\\npm\\ccstatusline.cmd'],
binDir: 'C:\\Users\\Alice\\AppData\\Roaming\\npm',
version: '2.2.13',
warning: null
});
expect(execFileSyncSpy).toHaveBeenCalledWith(
'npm.cmd',
['root', '-g'],
expect.objectContaining({ shell: true })
);
});
it('uses npm.cmd for Windows npm uninstalls', async () => {
const execFileSpy = vi.spyOn(childProcess, 'execFile').mockImplementation(((...args: unknown[]) => {
const callback = args[3] as (error: Error | null) => void;
callback(null);
return {};
}) as typeof childProcess.execFile);
await runGlobalPackageUninstall('npm', { platform: 'win32' });
expect(execFileSpy.mock.calls[0]?.[0]).toBe('npm.cmd');
expect(execFileSpy.mock.calls[0]?.[1]).toEqual(['uninstall', '-g', 'ccstatusline']);
expect(execFileSpy.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ shell: true }));
});
});
+352
View File
@@ -0,0 +1,352 @@
import {
describe,
expect,
it
} from 'vitest';
import type { RenderContext } from '../../types/RenderContext';
import {
DEFAULT_SETTINGS,
type Settings
} from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import {
applyLineGradient,
getVisibleText,
getVisibleWidth
} from '../ansi';
import {
GRADIENT_PRESET_NAMES,
applyGradientToText,
gradientCodeAt,
isGradientSpec,
parseGradientSpec,
rgbToAnsi256,
sampleGradient
} from '../gradient';
import {
calculateMaxWidthsFromPreRendered,
preRenderAllWidgets,
renderStatusLine,
renderStatusLineWithInfo
} from '../renderer';
const TRUECOLOR_CODE = /\x1b\[38;2;\d+;\d+;\d+m/g;
const ANSI256_CODE = /\x1b\[38;5;\d+m/g;
function countMatches(text: string, pattern: RegExp): number {
return text.match(pattern)?.length ?? 0;
}
describe('isGradientSpec', () => {
it('is true only for gradient: prefixed values', () => {
expect(isGradientSpec('gradient:atlas')).toBe(true);
expect(isGradientSpec('gradient:FF0000-0000FF')).toBe(true);
});
it('is false for solid colors, empty, and undefined', () => {
expect(isGradientSpec(undefined)).toBe(false);
expect(isGradientSpec('')).toBe(false);
expect(isGradientSpec('hex:FF0000')).toBe(false);
expect(isGradientSpec('ansi256:120')).toBe(false);
expect(isGradientSpec('cyan')).toBe(false);
});
});
describe('parseGradientSpec', () => {
it('returns null for non-gradient values', () => {
expect(parseGradientSpec(undefined)).toBeNull();
expect(parseGradientSpec('')).toBeNull();
expect(parseGradientSpec('hex:FF0000')).toBeNull();
expect(parseGradientSpec('cyan')).toBeNull();
});
it('returns null when fewer than two stops resolve', () => {
expect(parseGradientSpec('gradient:hex:FF0000')).toBeNull();
expect(parseGradientSpec('gradient:not-a-color,also-bad')).toBeNull();
});
it('parses hex, #hex, and bare hex stops (whitespace tolerant)', () => {
const stops = parseGradientSpec('gradient: hex:FF0000 , #00FF00 , 0000FF ');
expect(stops).toEqual([
{ r: 255, g: 0, b: 0 },
{ r: 0, g: 255, b: 0 },
{ r: 0, g: 0, b: 255 }
]);
});
it('parses dash-separated bare hex stops', () => {
const stops = parseGradientSpec('gradient:FF0000-0000FF');
expect(stops).toEqual([
{ r: 255, g: 0, b: 0 },
{ r: 0, g: 0, b: 255 }
]);
});
it('parses dash-separated #-prefixed stops', () => {
const stops = parseGradientSpec('gradient:#FF0000-#0000FF');
expect(stops).toEqual([
{ r: 255, g: 0, b: 0 },
{ r: 0, g: 0, b: 255 }
]);
});
it('resolves named presets (case-insensitive) to their stop list', () => {
const retro = parseGradientSpec('gradient:retro');
expect(retro).toHaveLength(9);
expect(parseGradientSpec('gradient:RAINBOW')).toHaveLength(7);
// every shipped preset resolves to >= 2 usable stops
for (const name of GRADIENT_PRESET_NAMES) {
expect((parseGradientSpec(`gradient:${name}`) ?? []).length).toBeGreaterThanOrEqual(2);
}
});
});
describe('applyGradientToText', () => {
const stops = [{ r: 255, g: 0, b: 0 }, { r: 0, g: 0, b: 255 }];
it('emits one code per non-whitespace character and leaves whitespace uncolored', () => {
const out = applyGradientToText('ab cd', stops, 'truecolor');
expect(countMatches(out, TRUECOLOR_CODE)).toBe(4);
// the space follows the visible char directly, with no color code in between
expect(out).toContain('b ');
});
it('emits no trailing reset (the caller appends it)', () => {
const out = applyGradientToText('abc', stops, 'truecolor');
expect(out.endsWith('\x1b[39m')).toBe(false);
});
it('restarts the sweep per call (first visible code identical for two calls)', () => {
const first = applyGradientToText('abc', stops, 'truecolor').match(TRUECOLOR_CODE)?.[0];
const second = applyGradientToText('xyz', stops, 'truecolor').match(TRUECOLOR_CODE)?.[0];
expect(first).toBe(second);
});
it('is a no-op for ansi16, empty, and blank-only text', () => {
expect(applyGradientToText('abc', stops, 'ansi16')).toBe('abc');
expect(applyGradientToText('', stops, 'truecolor')).toBe('');
expect(applyGradientToText(' ', stops, 'truecolor')).toBe(' ');
});
it('passes ANSI and OSC 8 escape sequences through untouched', () => {
const openLink = '\x1b]8;;https://example.com\x1b\\';
const closeLink = '\x1b]8;;\x1b\\';
const styledLink = `\x1b[1m${openLink}branch${closeLink}\x1b[22m`;
const out = applyGradientToText(styledLink, stops, 'truecolor');
expect(out).toContain('\x1b[1m');
expect(out).toContain(openLink);
expect(out).toContain(closeLink);
expect(out).toContain('\x1b[22m');
expect(countMatches(out, TRUECOLOR_CODE)).toBe('branch'.length);
});
});
describe('sampleGradient', () => {
it('returns the endpoints (within OKLab round-trip tolerance)', () => {
const stops = [{ r: 10, g: 20, b: 30 }, { r: 200, g: 150, b: 100 }];
const start = sampleGradient(stops, 0);
const end = sampleGradient(stops, 1);
expect(Math.abs(start.r - 10)).toBeLessThanOrEqual(2);
expect(Math.abs(end.b - 100)).toBeLessThanOrEqual(2);
});
it('produces a neutral mid-gray at the midpoint of black->white', () => {
const mid = sampleGradient([{ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 }], 0.5);
expect(mid.r).toBe(mid.g);
expect(mid.g).toBe(mid.b);
expect(mid.r).toBeGreaterThan(80);
expect(mid.r).toBeLessThan(180);
});
it('clamps out-of-range positions', () => {
const stops = [{ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 }];
expect(sampleGradient(stops, -1)).toEqual(sampleGradient(stops, 0));
expect(sampleGradient(stops, 2)).toEqual(sampleGradient(stops, 1));
});
it('lands on the interior stop of a 3-stop gradient at its position', () => {
// t=0.5 across three stops brackets exactly on the middle stop (green).
const mid = sampleGradient([{ r: 255, g: 0, b: 0 }, { r: 0, g: 255, b: 0 }, { r: 0, g: 0, b: 255 }], 0.5);
expect(Math.abs(mid.r - 0)).toBeLessThanOrEqual(2);
expect(Math.abs(mid.g - 255)).toBeLessThanOrEqual(2);
expect(Math.abs(mid.b - 0)).toBeLessThanOrEqual(2);
});
});
describe('rgbToAnsi256', () => {
it('maps pure colors to the expected palette indices', () => {
expect(rgbToAnsi256({ r: 0, g: 0, b: 0 })).toBe(16);
expect(rgbToAnsi256({ r: 255, g: 255, b: 255 })).toBe(231);
expect(rgbToAnsi256({ r: 255, g: 0, b: 0 })).toBe(196);
});
it('maps mid grays into the grayscale ramp', () => {
const index = rgbToAnsi256({ r: 128, g: 128, b: 128 });
expect(index).toBeGreaterThanOrEqual(232);
expect(index).toBeLessThanOrEqual(255);
});
});
describe('gradientCodeAt', () => {
const stops = [{ r: 255, g: 0, b: 0 }, { r: 0, g: 0, b: 255 }];
it('emits a truecolor escape at truecolor level', () => {
expect(gradientCodeAt(stops, 0, 'truecolor')).toMatch(/^\x1b\[38;2;\d+;\d+;\d+m$/);
});
it('emits a 256-color escape at ansi256 level', () => {
expect(gradientCodeAt(stops, 0.5, 'ansi256')).toMatch(/^\x1b\[38;5;\d+m$/);
});
it('falls back to the 256-color path defensively at ansi16', () => {
// Callers degrade before this, but if reached, ansi16 must not emit truecolor.
expect(gradientCodeAt(stops, 0.5, 'ansi16')).toMatch(/^\x1b\[38;5;\d+m$/);
});
});
describe('applyLineGradient', () => {
const stops = [{ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 }];
it('preserves visible width when coloring a styled line', () => {
const line = '\x1b[38;2;255;0;0mhello\x1b[39m world';
const out = applyLineGradient(line, stops, 'truecolor');
expect(getVisibleWidth(out)).toBe(getVisibleWidth(line));
});
it('emits one foreground code per visible cluster', () => {
const out = applyLineGradient('abcdef', stops, 'truecolor');
expect(countMatches(out, TRUECOLOR_CODE)).toBe(getVisibleWidth('abcdef'));
});
it('sweeps through distinct colors across the line', () => {
const out = applyLineGradient('abcdefghij', stops, 'truecolor');
const codes = out.match(TRUECOLOR_CODE) ?? [];
expect(new Set(codes).size).toBeGreaterThanOrEqual(2);
});
it('passes OSC 8 hyperlinks through untouched', () => {
const link = '\x1b]8;;https://example.com\x1b\\branch\x1b]8;;\x1b\\';
const out = applyLineGradient(`a${link}b`, stops, 'truecolor');
expect(out).toContain('https://example.com');
expect(out).toContain('\x1b]8;;\x1b\\');
expect(getVisibleWidth(out)).toBe(getVisibleWidth(`a${link}b`));
});
it('colors both ends of a two-cluster line (denominator of exactly 1)', () => {
// totalWidth 2 -> denominator 1: first cluster at t=0, second at t=1.
const out = applyLineGradient('ab', stops, 'truecolor');
const codes = out.match(TRUECOLOR_CODE) ?? [];
expect(codes.length).toBe(2);
expect(codes[0]).not.toBe(codes[1]);
expect(getVisibleWidth(out)).toBe(2);
});
it('uses 256-color escapes at ansi256 level', () => {
const out = applyLineGradient('abc', stops, 'ansi256');
expect(countMatches(out, ANSI256_CODE)).toBe(3);
expect(countMatches(out, TRUECOLOR_CODE)).toBe(0);
});
it('is a no-op for ansi16, single-character, and empty lines', () => {
expect(applyLineGradient('abc', stops, 'ansi16')).toBe('abc');
expect(applyLineGradient('x', stops, 'truecolor')).toBe('x');
expect(applyLineGradient('', stops, 'truecolor')).toBe('');
});
});
describe('renderStatusLine with a gradient override', () => {
function createSettings(overrides: Partial<Settings> = {}): Settings {
return {
...DEFAULT_SETTINGS,
flexMode: 'full',
colorLevel: 3,
...overrides,
powerline: {
...DEFAULT_SETTINGS.powerline,
...(overrides.powerline ?? {})
}
};
}
function renderLine(widgets: WidgetItem[], settingsOverrides: Partial<Settings> = {}, terminalWidth = 200): string {
const settings = createSettings(settingsOverrides);
const context: RenderContext = { isPreview: false, terminalWidth };
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
return renderStatusLine(widgets, settings, context, preRenderedLines[0] ?? [], preCalculatedMaxWidths);
}
function renderLineWithInfo(widgets: WidgetItem[], settingsOverrides: Partial<Settings> = {}, terminalWidth = 200) {
const settings = createSettings(settingsOverrides);
const context: RenderContext = { isPreview: false, terminalWidth };
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
return renderStatusLineWithInfo(widgets, settings, context, preRenderedLines[0] ?? [], preCalculatedMaxWidths);
}
const widgets: WidgetItem[] = [
{ id: 'a', type: 'custom-text', customText: 'model', color: 'hex:A89278' },
{ id: 'b', type: 'custom-text', customText: ' branch', color: 'hex:A89278' }
];
it('paints the whole line with a continuous gradient', () => {
const gradient = 'gradient:hex:dbbb6f,hex:c4808a,hex:9070d0,hex:b8cad4,hex:4a8a5e';
const line = renderLine(widgets, { overrideForegroundColor: gradient });
const codes = line.match(TRUECOLOR_CODE) ?? [];
expect(codes.length).toBe(getVisibleWidth(line));
expect(new Set(codes).size).toBeGreaterThanOrEqual(2);
});
it('leaves visible width unchanged versus the non-gradient render', () => {
const plain = renderLine(widgets);
const gradient = renderLine(widgets, { overrideForegroundColor: 'gradient:hex:dbbb6f,hex:4a8a5e' });
expect(getVisibleWidth(gradient)).toBe(getVisibleWidth(plain));
});
it('closes with a reset even when the line is truncated (no color leak)', () => {
// Regression: the gradient pass must run AFTER truncation. If it runs before,
// truncateStyledText cuts from the right and slices off the trailing \x1b[39m,
// so the last color leaks past the status line. A narrow width forces
// truncation; the rendered line must still end with the reset.
const gradient = 'gradient:hex:dbbb6f,hex:c4808a,hex:9070d0,hex:b8cad4,hex:4a8a5e';
const full = renderLine(widgets, { overrideForegroundColor: gradient });
const truncated = renderLine(widgets, { overrideForegroundColor: gradient }, 8);
// truncation actually happened
expect(getVisibleWidth(truncated)).toBeLessThan(getVisibleWidth(full));
// and the gradient's trailing reset survived
expect(truncated.endsWith('\x1b[39m')).toBe(true);
});
it('reports truncation after the gradient colors the ellipsis', () => {
const gradient = 'gradient:hex:dbbb6f,hex:c4808a,hex:9070d0,hex:b8cad4,hex:4a8a5e';
const result = renderLineWithInfo(widgets, { overrideForegroundColor: gradient }, 9);
expect(result.wasTruncated).toBe(true);
expect(result.line.includes('...')).toBe(false);
expect(getVisibleText(result.line)).toContain('...');
});
it('applies global foreground gradients to widget text in powerline mode', () => {
const gradient = 'gradient:FF0000-0000FF';
const powerlineWidgets: WidgetItem[] = [
{ id: 'a', type: 'custom-text', customText: 'abc' },
{ id: 'b', type: 'custom-text', customText: 'def' }
];
const line = renderLine(powerlineWidgets, {
overrideForegroundColor: gradient,
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: true
}
});
const codes = line.match(TRUECOLOR_CODE) ?? [];
expect(getVisibleText(line)).toContain('abc');
expect(getVisibleText(line)).toContain('def');
expect(codes).toHaveLength(6);
expect(codes[0]).not.toBe(codes[3]);
});
});
+85
View File
@@ -0,0 +1,85 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance
} from 'vitest';
import { handleHookInput } from '../hook-handler';
import { getSkillsFilePath } from '../skills';
let testHomeDir = '';
let consoleLogSpy: MockInstance<typeof console.log>;
function readSkillsLog(sessionId: string): Record<string, unknown>[] {
return fs.readFileSync(getSkillsFilePath(sessionId), 'utf-8')
.trim()
.split('\n')
.map(line => JSON.parse(line) as Record<string, unknown>);
}
describe('handleHookInput', () => {
beforeEach(() => {
testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-hook-handler-'));
vi.spyOn(os, 'homedir').mockReturnValue(testHomeDir);
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
if (testHomeDir) {
fs.rmSync(testHomeDir, { recursive: true, force: true });
}
});
it('does not write stdout for no-op hook inputs', () => {
handleHookInput(null);
handleHookInput('{ invalid json');
handleHookInput(JSON.stringify({ hook_event_name: 'PreToolUse' }));
handleHookInput(JSON.stringify({ session_id: 'session-1', hook_event_name: 'PreToolUse' }));
expect(consoleLogSpy).not.toHaveBeenCalled();
expect(fs.existsSync(path.join(testHomeDir, '.cache', 'ccstatusline'))).toBe(false);
});
it('records PreToolUse skill hooks without writing stdout', () => {
handleHookInput(JSON.stringify({
session_id: 'session-1',
hook_event_name: 'PreToolUse',
tool_name: 'Skill',
tool_input: { skill: 'review-pr' }
}));
expect(consoleLogSpy).not.toHaveBeenCalled();
expect(readSkillsLog('session-1')).toMatchObject([
{
session_id: 'session-1',
skill: 'review-pr',
source: 'PreToolUse'
}
]);
});
it('records slash command UserPromptSubmit hooks without writing stdout', () => {
handleHookInput(JSON.stringify({
session_id: 'session-1',
hook_event_name: 'UserPromptSubmit',
prompt: '/commit staged changes'
}));
expect(consoleLogSpy).not.toHaveBeenCalled();
expect(readSkillsLog('session-1')).toMatchObject([
{
session_id: 'session-1',
skill: 'commit',
source: 'UserPromptSubmit'
}
]);
});
});
+247
View File
@@ -0,0 +1,247 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';
import {
DEFAULT_SETTINGS,
SettingsSchema
} from '../../types/Settings';
import {
removeManagedHooks,
syncWidgetHooks
} from '../hooks';
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
let testClaudeConfigDir = '';
function getClaudeSettingsPath(): string {
return path.join(testClaudeConfigDir, 'settings.json');
}
describe('syncWidgetHooks', () => {
beforeEach(() => {
testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-hooks-'));
process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir;
});
afterEach(() => {
if (testClaudeConfigDir) {
fs.rmSync(testClaudeConfigDir, { recursive: true, force: true });
}
});
afterAll(() => {
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
}
});
it('removes managed hooks and persists cleanup when status line is unset', async () => {
const settingsPath = getClaudeSettingsPath();
fs.writeFileSync(settingsPath, JSON.stringify({
hooks: {
PreToolUse: [
{
_tag: 'ccstatusline-managed',
matcher: 'Skill',
hooks: [{ type: 'command', command: 'old-command --hook' }]
},
{
matcher: 'Other',
hooks: [{ type: 'command', command: 'keep-command' }]
}
],
UserPromptSubmit: [
{
_tag: 'ccstatusline-managed',
hooks: [{ type: 'command', command: 'old-command --hook' }]
}
]
}
}, null, 2), 'utf-8');
await syncWidgetHooks(DEFAULT_SETTINGS);
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { hooks?: Record<string, unknown[]> };
expect(saved.hooks).toEqual({
PreToolUse: [
{
matcher: 'Other',
hooks: [{ type: 'command', command: 'keep-command' }]
}
]
});
});
it('heals legacy untagged ccstatusline hooks instead of leaving duplicates', async () => {
const settingsPath = getClaudeSettingsPath();
const command = '/Users/test/.bun/bin/ccstatusline';
fs.writeFileSync(settingsPath, JSON.stringify({
statusLine: { type: 'command', command },
hooks: {
PreToolUse: [
{
matcher: 'Skill',
hooks: [{ type: 'command', command: 'bunx -y ccstatusline@latest --hook' }]
},
{
matcher: 'Other',
hooks: [{ type: 'command', command: 'keep-command' }]
}
],
UserPromptSubmit: [
{ hooks: [{ type: 'command', command: 'bunx -y ccstatusline@latest --hook' }] }
]
}
}, null, 2), 'utf-8');
const settings = SettingsSchema.parse({ lines: [[{ id: 'skills-1', type: 'skills' }]] });
await syncWidgetHooks(settings);
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { hooks?: Record<string, unknown[]> };
expect(saved.hooks).toEqual({
PreToolUse: [
{
matcher: 'Other',
hooks: [{ type: 'command', command: 'keep-command' }]
},
{
_tag: 'ccstatusline-managed',
matcher: 'Skill',
hooks: [{ type: 'command', command: `${command} --hook` }]
}
],
UserPromptSubmit: [
{
_tag: 'ccstatusline-managed',
hooks: [{ type: 'command', command: `${command} --hook` }]
}
]
});
});
it('preserves other commands in mixed legacy hook entries while syncing', async () => {
const settingsPath = getClaudeSettingsPath();
const command = '/Users/test/.bun/bin/ccstatusline';
fs.writeFileSync(settingsPath, JSON.stringify({
statusLine: { type: 'command', command },
hooks: {
PreToolUse: [
{
matcher: 'Skill',
hooks: [
{ type: 'command', command: 'npx ccstatusline --hook' },
{ type: 'command', command: 'keep-command' }
]
}
]
}
}, null, 2), 'utf-8');
const settings = SettingsSchema.parse({ lines: [[{ id: 'skills-1', type: 'skills' }]] });
await syncWidgetHooks(settings);
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { hooks?: Record<string, unknown[]> };
expect(saved.hooks).toEqual({
PreToolUse: [
{
matcher: 'Skill',
hooks: [{ type: 'command', command: 'keep-command' }]
},
{
_tag: 'ccstatusline-managed',
matcher: 'Skill',
hooks: [{ type: 'command', command: `${command} --hook` }]
}
],
UserPromptSubmit: [
{
_tag: 'ccstatusline-managed',
hooks: [{ type: 'command', command: `${command} --hook` }]
}
]
});
});
it('preserves other commands in mixed legacy hook entries while removing managed hooks', async () => {
const settingsPath = getClaudeSettingsPath();
fs.writeFileSync(settingsPath, JSON.stringify({
hooks: {
PreToolUse: [
{
matcher: 'Skill',
hooks: [
{ type: 'command', command: 'bunx -y ccstatusline@latest --hook' },
{ type: 'command', command: 'keep-command' }
]
},
{
matcher: 'LegacyOnly',
hooks: [{ type: 'command', command: 'npx ccstatusline --hook' }]
},
{
_tag: 'ccstatusline-managed',
matcher: 'Managed',
hooks: [{ type: 'command', command: '/Users/test/.bun/bin/ccstatusline --hook' }]
}
]
}
}, null, 2), 'utf-8');
await removeManagedHooks();
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { hooks?: Record<string, unknown[]> };
expect(saved.hooks).toEqual({
PreToolUse: [
{
matcher: 'Skill',
hooks: [{ type: 'command', command: 'keep-command' }]
}
]
});
});
it('is idempotent — repeated syncs heal legacy hooks without accumulating', async () => {
const settingsPath = getClaudeSettingsPath();
const command = '/Users/test/.bun/bin/ccstatusline';
// Seed a legacy untagged hook so the first sync exercises the heal (regex) path,
// not just the tagged-entry path.
fs.writeFileSync(settingsPath, JSON.stringify({
statusLine: { type: 'command', command },
hooks: {
PreToolUse: [
{
matcher: 'Skill',
hooks: [{ type: 'command', command: 'npx ccstatusline --hook' }]
}
]
}
}, null, 2), 'utf-8');
const settings = SettingsSchema.parse({ lines: [[{ id: 'skills-1', type: 'skills' }]] });
await syncWidgetHooks(settings);
const afterFirst = fs.readFileSync(settingsPath, 'utf-8');
await syncWidgetHooks(settings);
const afterSecond = fs.readFileSync(settingsPath, 'utf-8');
expect(afterSecond).toEqual(afterFirst);
const saved = JSON.parse(afterSecond) as { hooks?: Record<string, unknown[]> };
expect(saved.hooks?.PreToolUse).toHaveLength(1);
expect(saved.hooks?.UserPromptSubmit).toHaveLength(1);
});
});
+30
View File
@@ -0,0 +1,30 @@
import {
describe,
expect,
it
} from 'vitest';
import {
buildIdeFileUrl,
encodeGitRefForUrlPath
} from '../hyperlink';
describe('encodeGitRefForUrlPath', () => {
it('encodes reserved characters while preserving branch separators', () => {
expect(encodeGitRefForUrlPath('feature/issue#1')).toBe('feature/issue%231');
});
});
describe('buildIdeFileUrl', () => {
it('builds encoded IDE links for POSIX paths', () => {
expect(buildIdeFileUrl('/Users/example/my repo#1', 'cursor')).toBe('cursor://file/Users/example/my%20repo%231');
});
it('builds IDE links for Windows drive-letter paths', () => {
expect(buildIdeFileUrl('C:/Work/my repo#1', 'vscode')).toBe('vscode://file/C:/Work/my%20repo%231');
});
it('builds IDE links for UNC paths', () => {
expect(buildIdeFileUrl('\\\\server\\share\\my repo', 'cursor')).toBe('cursor://file//server/share/my%20repo');
});
});
+1 -1
View File
@@ -28,4 +28,4 @@ describe('shouldInsertInput', () => {
expect(shouldInsertInput('\u0013', {})).toBe(false);
expect(shouldInsertInput('🙂', {})).toBe(true);
});
});
});
+132
View File
@@ -0,0 +1,132 @@
import { execFileSync } from 'child_process';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import type { RenderContext } from '../../types/RenderContext';
import {
getJjChangeCounts,
isInsideJjRepo,
runJjArgs
} from '../jj';
vi.mock('child_process', () => ({ execFileSync: vi.fn() }));
const mockExecFileSync = execFileSync as unknown as {
mock: { calls: unknown[][] };
mockImplementation: (impl: () => never) => void;
mockReturnValue: (value: string) => void;
mockReturnValueOnce: (value: string) => void;
};
describe('jj utils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('runJjArgs', () => {
it('runs jj command with resolved cwd and trims trailing newlines', () => {
mockExecFileSync.mockReturnValue('some-output\n');
const context: RenderContext = { data: { cwd: '/tmp/repo' } };
const result = runJjArgs(['log', '--limit', '1'], context);
expect(result).toBe('some-output');
expect(mockExecFileSync.mock.calls[0]?.[0]).toBe('jj');
expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual(['log', '--limit', '1']);
expect(mockExecFileSync.mock.calls[0]?.[2]).toEqual({
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
windowsHide: true,
cwd: '/tmp/repo'
});
});
it('runs jj command without cwd when no context directory exists', () => {
mockExecFileSync.mockReturnValue('/tmp/repo\n');
const result = runJjArgs(['root'], {});
expect(result).toBe('/tmp/repo');
expect(mockExecFileSync.mock.calls[0]?.[2]).toEqual({
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
windowsHide: true
});
});
it('returns null when output is empty', () => {
mockExecFileSync.mockReturnValue('');
expect(runJjArgs(['root'], {})).toBeNull();
});
it('returns empty string when allowEmpty is true and output is empty', () => {
mockExecFileSync.mockReturnValue('');
expect(runJjArgs(['log'], {}, true)).toBe('');
});
it('returns null when the command fails', () => {
mockExecFileSync.mockImplementation(() => { throw new Error('jj failed'); });
expect(runJjArgs(['status'], {})).toBeNull();
});
});
describe('isInsideJjRepo', () => {
it('returns true when jj root succeeds', () => {
mockExecFileSync.mockReturnValue('/tmp/repo\n');
expect(isInsideJjRepo({})).toBe(true);
});
it('returns false when jj root fails', () => {
mockExecFileSync.mockImplementation(() => { throw new Error('jj failed'); });
expect(isInsideJjRepo({})).toBe(false);
});
});
describe('getJjChangeCounts', () => {
it('parses insertions and deletions from jj diff --stat', () => {
mockExecFileSync.mockReturnValue('2 files changed, 5 insertions(+), 3 deletions(-)');
expect(getJjChangeCounts({})).toEqual({
insertions: 5,
deletions: 3
});
});
it('handles singular insertion/deletion forms', () => {
mockExecFileSync.mockReturnValue('1 file changed, 1 insertion(+), 1 deletion(-)');
expect(getJjChangeCounts({})).toEqual({
insertions: 1,
deletions: 1
});
});
it('returns zero counts when jj diff --stat returns empty', () => {
mockExecFileSync.mockReturnValue('');
expect(getJjChangeCounts({})).toEqual({
insertions: 0,
deletions: 0
});
});
it('returns zero counts when jj diff command fails', () => {
mockExecFileSync.mockImplementation(() => { throw new Error('jj failed'); });
expect(getJjChangeCounts({})).toEqual({
insertions: 0,
deletions: 0
});
});
});
});
+88
View File
@@ -0,0 +1,88 @@
import * as fs from 'fs';
import os from 'os';
import path from 'path';
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';
import { getBlockMetrics } from '../jsonl';
function floorToHourUtc(timestamp: Date): Date {
const floored = new Date(timestamp);
floored.setUTCMinutes(0, 0, 0);
return floored;
}
function makeUsageLine(timestamp: Date): string {
return JSON.stringify({
timestamp: timestamp.toISOString(),
message: {
usage: {
input_tokens: 100,
output_tokens: 50
}
}
});
}
describe('jsonl block metrics integration', () => {
let tempClaudeDir: string;
let originalClaudeConfigDir: string | undefined;
beforeEach(() => {
tempClaudeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-blocks-'));
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = tempClaudeDir;
});
afterEach(() => {
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
}
fs.rmSync(tempClaudeDir, { recursive: true, force: true });
});
it('returns the current block start for recent activity after an older session gap', () => {
const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a');
fs.mkdirSync(projectsDir, { recursive: true });
const transcriptPath = path.join(projectsDir, 'session.jsonl');
const now = new Date();
const oldActivity = new Date(now.getTime() - (10 * 60 * 60 * 1000));
const currentBlockStartSource = new Date(now.getTime() - (2 * 60 * 60 * 1000) - (10 * 60 * 1000));
const recentActivity = new Date(now.getTime() - (40 * 60 * 1000));
fs.writeFileSync(transcriptPath, [
makeUsageLine(oldActivity),
makeUsageLine(currentBlockStartSource),
makeUsageLine(recentActivity)
].join('\n'));
const metrics = getBlockMetrics();
expect(metrics).not.toBeNull();
expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(currentBlockStartSource).toISOString());
expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString());
});
it('returns null when the most recent activity is older than the session window', () => {
const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a');
fs.mkdirSync(projectsDir, { recursive: true });
const transcriptPath = path.join(projectsDir, 'stale-session.jsonl');
const now = new Date();
const staleActivity = new Date(now.getTime() - (6 * 60 * 60 * 1000));
fs.writeFileSync(transcriptPath, makeUsageLine(staleActivity));
const metrics = getBlockMetrics();
expect(metrics).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -261,4 +261,4 @@ describe('Block Detection Algorithm', () => {
expect(result).toBeNull();
});
});
});
});

Some files were not shown because too many files have changed in this diff Show More