Compare commits

..

77 Commits

Author SHA1 Message Date
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
269 changed files with 18810 additions and 2376 deletions
+1 -2
View File
@@ -13,8 +13,7 @@ jobs:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun tsc --noEmit
- run: bunx eslint . --config eslint.config.js --max-warnings=999999
- run: bun run lint
test:
name: Test
+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"
]
+5 -2
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
@@ -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)
+79 -529
View File
@@ -28,32 +28,73 @@
![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.2.0 - New Token Speed widgets with optional windows
### 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.
- **🧮 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.
### 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.
<br />
<details>
<summary><b>Older updates (v2.1.10 and earlier)</b></summary>
### 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.
@@ -150,23 +191,33 @@
- **🔤 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, 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
@@ -180,13 +231,16 @@ npx -y ccstatusline@latest
bunx -y ccstatusline@latest
```
### Configure ccstatusline
<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
@@ -197,12 +251,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:
@@ -211,524 +269,20 @@ 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 Insertions** - Shows uncommitted insertions only (e.g., "+42")
- **Git Deletions** - Shows uncommitted deletions only (e.g., "-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
- **Block Reset Timer** - Shows time remaining until the current 5-hour block resets
- **Weekly Reset Timer** - Shows time remaining until the weekly usage window 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 long-context labels like `[1m]` or `1M context`, 200k otherwise)
- **Context Percentage (usable)** - Shows percentage of usable context (dynamic: 800k for model IDs with long-context labels like `[1m]` or `1M context`, 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
</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)
- **Block Reset Timer**: `p` cycle display mode (time/full bar/short bar)
- **Weekly Reset 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.
@@ -739,7 +293,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
@@ -747,13 +300,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
@@ -761,14 +312,13 @@ If ccstatusline is useful to you, consider buying me a coffee:
- GitHub: [@sirmalloc](https://github.com/sirmalloc)
---
## 🔗 Related Projects
- [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.
- [codachi](https://github.com/vincent-k2026/codachi) - A tamagotchi-style statusline pet that grows with your context window.
---
## 🙏 Acknowledgments
@@ -776,7 +326,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
+187 -223
View File
@@ -1,24 +1,26 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"configVersion": 1,
"workspaces": {
"": {
"name": "ccstatusline",
"devDependencies": {
"@eslint/js": "^9.33.0",
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.2.3",
"@types/bun": "latest",
"@types/pluralize": "^0.0.33",
"@types/react": "^19.1.10",
"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": "^7.0.1",
"globals": "^17.3.0",
"https-proxy-agent": "^8.0.0",
"ink": "6.2.0",
"ink-gradient": "^3.0.0",
"ink-gradient": "^4.0.0",
"ink-select-input": "^6.2.0",
"pluralize": "^8.0.0",
"react": "^19.1.1",
@@ -26,7 +28,7 @@
"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": "^4.0.18",
"zod": "^4.0.17",
@@ -74,87 +76,33 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
"@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
"@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.9", "", { "os": "aix", "cpu": "ppc64" }, "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.9", "", { "os": "android", "cpu": "arm" }, "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.9", "", { "os": "android", "cpu": "arm64" }, "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.9", "", { "os": "android", "cpu": "x64" }, "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.9", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.9", "", { "os": "linux", "cpu": "arm" }, "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.9", "", { "os": "linux", "cpu": "ia32" }, "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.9", "", { "os": "linux", "cpu": "none" }, "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.9", "", { "os": "linux", "cpu": "none" }, "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.9", "", { "os": "linux", "cpu": "none" }, "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.9", "", { "os": "linux", "cpu": "x64" }, "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.9", "", { "os": "none", "cpu": "arm64" }, "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.9", "", { "os": "none", "cpu": "x64" }, "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.9", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.9", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.9", "", { "os": "none", "cpu": "arm64" }, "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.9", "", { "os": "sunos", "cpu": "x64" }, "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.9", "", { "os": "win32", "cpu": "ia32" }, "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.9", "", { "os": "win32", "cpu": "x64" }, "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
"@eslint/js": ["@eslint/js@9.39.3", "", {}, "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
"@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
@@ -172,45 +120,43 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-cqPpZdKUSQYRtLLr6R4X3sD4jCBO1zUmeo3qrWBCqYIeH8Q3KRL4F3V7XJ2Rm8/RJOQBZuqzQGWPjjvFUcYa/w=="],
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.49.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-99kMMSMQT7got6iYX3yyIiJfFndpojBmkHfTc1rIje8VbjhmqBXE+nb7ZZP3A5skLyujvT0eIUCUsxAe6NjWbw=="],
"@package-json/types": ["@package-json/types@0.0.12", "", {}, "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.49.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-y8cXoD3wdWUDpjOLMKLx6l+NFz3NlkWKcBCBfttUn+VGSfgsQ5o/yDUGtzE9HvsodkP0+16N0P4Ty1VuhtRUGg=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.49.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3mY5Pr7qv4GS4ZvWoSP8zha8YoiqrU+e0ViPvB549jvliBbdNLrg2ywPGkgLC3cmvN8ya3za+Q2xVyT6z+vZqA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.49.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-C9KzzOAQU5gU4kG8DTk+tjdKjpWhVWd5uVkinCwwFub2m7cDYLOdtXoMrExfeBmeRy9kBQMkiyJ+HULyF1yj9w=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.49.0", "", { "os": "linux", "cpu": "arm" }, "sha512-OVSQgEZDVLnTbMq5NBs6xkmz3AADByCWI4RdKSFNlDsYXdFtlxS59J+w+LippJe8KcmeSSM3ba+GlsM9+WwC1w=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.49.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ZnfSFA7fDUHNa4P3VwAcfaBLakCbYaxCk0jUnS3dTou9P95kwoOLAMlT3WmEJDBCSrOEFFV0Y1HXiwfLYJuLlA=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm" }, "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.49.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z81u+gfrobVK2iV7GqZCBfEB1y6+I61AH466lNK+xy1jfqFLiQ9Qv716WUM5fxFrYxwC7ziVdZRU9qvGHkYIJg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.49.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zoAwS0KCXSnTp9NH/h9aamBAIve0DXeYpll85shf9NJ0URjSTzzS+Z9evmolN+ICfD3v8skKUPyk2PO0uGdFqg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg=="],
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.49.0", "", { "os": "linux", "cpu": "none" }, "sha512-2QyUyQQ1ZtwZGiq0nvODL+vLJBtciItC3/5cYN8ncDQcv5avrt2MbKt1XU/vFAJlLta5KujqyHdYtdag4YEjYQ=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.49.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-k9aEmOWt+mrMuD3skjVJSSxHckJp+SiFzFG+v8JLXbc/xi9hv2icSkR3U7uQzqy+/QbbYY7iNB9eDTwrELo14g=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.49.0", "", { "os": "linux", "cpu": "none" }, "sha512-rDKRFFIWJ/zJn6uk2IdYLc09Z7zkE5IFIOWqpuU0o6ZpHcdniAyWkwSUWE/Z25N/wNDmFHHMzin84qW7Wzkjsw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.49.0", "", { "os": "linux", "cpu": "none" }, "sha512-FkkhIY/hYFVnOzz1WeV3S9Bd1h0hda/gRqvZCMpHWDHdiIHn6pqsY3b5eSbvGccWHMQ1uUzgZTKS4oGpykf8Tw=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.49.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-gRf5c+A7QiOG3UwLyOOtyJMD31JJhMjBvpfhAitPAoqZFcOeK3Kc1Veg1z/trmt+2P6F/biT02fU19GGTS529A=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.9", "", { "os": "none", "cpu": "arm64" }, "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.49.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BR7+blScdLW1h/2hB/2oXM+dhTmpW3rQt1DeSiCP9mc2NMMkqVgjIN3DDsNpKmezffGC9R8XKVOLmBkRUcK/sA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.49.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hDMOAe+6nX3V5ei1I7Au3wcr9h3ktKzDvF2ne5ovX8RZiAHEtX1A5SNNk4zt1Qt77CmnbqT+upb/umzoPMWiPg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.49.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-wkNRzfiIGaElC9kXUT+HLx17z7D0jl+9tGYRKwd8r7cUqTL7GYAvgUY++U2hK6Ar7z5Z6IRRoWC8kQxpmM7TDA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "x64" }, "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.49.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-gq5aW/SyNpjp71AAzroH37DtINDcX1Qw2iv9Chyz49ZgdOP3NV8QCyKZUrGsYX9Yyggj5soFiRCgsL3HwD8TdA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.49.0", "", { "os": "win32", "cpu": "x64" }, "sha512-gEtqFbzmZLFk2xKh7g0Rlo8xzho8KrEFEkzvHbfUGkrgXOpZ4XagQ6n+wIZFNh1nTb8UD16J4nFSFKXYgnbdBg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="],
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
@@ -226,16 +172,18 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.9.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-FqqSkvDMYJReydrMhlugc71M76yLLQWNfmGq+SIlLa7N3kHp8Qq8i2PyWrVNAfjOyOIY+xv9XaaYwvVW7vroMA=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.10.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="],
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/gradient-string": ["@types/gradient-string@1.1.6", "", { "dependencies": { "@types/tinycolor2": "*" } }, "sha512-LkaYxluY4G5wR1M4AKQUal2q61Di1yVVCw42ImFTuaIoQVgmV0WP1xUaLB8zwb47mp82vWTpePI9JmrjEnJ7nQ=="],
@@ -246,7 +194,7 @@
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
"@types/node": ["@types/node@24.2.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw=="],
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/pluralize": ["@types/pluralize@0.0.33", "", {}, "sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg=="],
@@ -256,25 +204,25 @@
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/type-utils": "8.58.2", "@typescript-eslint/utils": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.56.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.1", "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2" } }, "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/utils": "8.58.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.1", "@typescript-eslint/tsconfig-utils": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
@@ -314,25 +262,27 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
"@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="],
"@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="],
"@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="],
"@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="],
"@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="],
"@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="],
"@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="],
"@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="],
"@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="],
"@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"agent-base": ["agent-base@8.0.0", "", {}, "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
@@ -358,21 +308,23 @@
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
@@ -380,9 +332,7 @@
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
@@ -396,9 +346,7 @@
"code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"comment-parser": ["comment-parser@1.4.6", "", {}, "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
@@ -416,7 +364,7 @@
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -424,27 +372,29 @@
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
"electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
"emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="],
"es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-iterator-helpers": ["es-iterator-helpers@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.0.3", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.6", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.4", "safe-array-concat": "^1.1.3" } }, "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w=="],
"es-iterator-helpers": ["es-iterator-helpers@1.3.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0", "safe-array-concat": "^1.1.3" } }, "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
@@ -456,13 +406,11 @@
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
"esbuild": ["esbuild@0.25.9", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.9", "@esbuild/android-arm": "0.25.9", "@esbuild/android-arm64": "0.25.9", "@esbuild/android-x64": "0.25.9", "@esbuild/darwin-arm64": "0.25.9", "@esbuild/darwin-x64": "0.25.9", "@esbuild/freebsd-arm64": "0.25.9", "@esbuild/freebsd-x64": "0.25.9", "@esbuild/linux-arm": "0.25.9", "@esbuild/linux-arm64": "0.25.9", "@esbuild/linux-ia32": "0.25.9", "@esbuild/linux-loong64": "0.25.9", "@esbuild/linux-mips64el": "0.25.9", "@esbuild/linux-ppc64": "0.25.9", "@esbuild/linux-riscv64": "0.25.9", "@esbuild/linux-s390x": "0.25.9", "@esbuild/linux-x64": "0.25.9", "@esbuild/netbsd-arm64": "0.25.9", "@esbuild/netbsd-x64": "0.25.9", "@esbuild/openbsd-arm64": "0.25.9", "@esbuild/openbsd-x64": "0.25.9", "@esbuild/openharmony-arm64": "0.25.9", "@esbuild/sunos-x64": "0.25.9", "@esbuild/win32-arm64": "0.25.9", "@esbuild/win32-ia32": "0.25.9", "@esbuild/win32-x64": "0.25.9" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg=="],
"eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="],
"eslint-import-context": ["eslint-import-context@0.1.9", "", { "dependencies": { "get-tsconfig": "^4.10.1", "stable-hash-x": "^0.2.0" }, "peerDependencies": { "unrs-resolver": "^1.0.0" }, "optionalPeers": ["unrs-resolver"] }, "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg=="],
@@ -474,19 +422,21 @@
"eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="],
"eslint-plugin-import-newlines": ["eslint-plugin-import-newlines@1.4.1", "", { "peerDependencies": { "eslint": ">=6.0.0 < 10.0.0" }, "bin": { "import-linter": "lib/index.js" } }, "sha512-C9PQEJ4jS5tKoE9k0yY/5j4l1bxkxmVjrWvAFc1EToCnuQuwZGl1kS1JBlqYNF8svp0pnXLlkSdjByYa3JALcg=="],
"eslint-plugin-import-newlines": ["eslint-plugin-import-newlines@2.0.0", "", { "peerDependencies": { "eslint": ">=10.0.0" }, "bin": { "import-linter": "lib/index.js" } }, "sha512-xKcuSkpQvkAHWCvAysqCk8GAD+rabLokiK4rmeJjCB+CQtGn6Ptgs909miphvN51JyZxOJWz4reGMsoHSbjbIg=="],
"eslint-plugin-import-x": ["eslint-plugin-import-x@4.16.2", "", { "dependencies": { "@package-json/types": "^0.0.12", "@typescript-eslint/types": "^8.56.0", "comment-parser": "^1.4.1", "debug": "^4.4.1", "eslint-import-context": "^0.1.9", "is-glob": "^4.0.3", "minimatch": "^9.0.3 || ^10.1.2", "semver": "^7.7.2", "stable-hash-x": "^0.2.0", "unrs-resolver": "^1.9.2" }, "peerDependencies": { "@typescript-eslint/utils": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint-import-resolver-node": "*" }, "optionalPeers": ["@typescript-eslint/utils", "eslint-import-resolver-node"] }, "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw=="],
"eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
@@ -496,7 +446,7 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -514,7 +464,7 @@
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"flatted": ["flatted@3.4.1", "", {}, "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ=="],
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
@@ -526,9 +476,11 @@
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -536,22 +488,20 @@
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
"get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"gradient-string": ["gradient-string@2.0.2", "", { "dependencies": { "chalk": "^4.1.2", "tinygradient": "^1.1.5" } }, "sha512-rEDCuqUQ4tbD78TpzsMtt5OIf0cBCSDWSJtUDaF6JsAh+k0v9r++NzxNEG87oDZx9ZwGhD8DaezR2L/yrw0Jdw=="],
"gradient-string": ["gradient-string@3.0.0", "", { "dependencies": { "chalk": "^5.3.0", "tinygradient": "^1.1.5" } }, "sha512-frdKI4Qi8Ihp4C6wZNB565de/THpIaw3DjP5ku87M+N9rNSGmPTjfkq61SdRXB7eCaL8O1hkKDvf6CDMtOzIAg=="],
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
"has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="],
@@ -566,9 +516,9 @@
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"https-proxy-agent": ["https-proxy-agent@8.0.0", "", { "dependencies": { "agent-base": "8.0.0", "debug": "^4.3.4" } }, "sha512-YYeW+iCnAS3xhvj2dvVoWgsbca3RfQy/IlaNHHOtDmU0jMqPI9euIq3Y9BJETdxk16h9NHHCKqp/KB9nIMStCQ=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
@@ -576,7 +526,7 @@
"ink": ["ink@6.2.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^4.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.22.0", "indent-string": "^5.0.0", "is-in-ci": "^1.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.32.0", "scheduler": "^0.23.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^7.2.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": "^4.19.1" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-NQbNokT11cuxlIcCDfBMk1vEwaqc/cjTSqc4R4JugBO4BpWVe2B2A6ElC2koZQ9Vj91z0C40zid/jxOF2hJL9A=="],
"ink-gradient": ["ink-gradient@3.0.0", "", { "dependencies": { "@types/gradient-string": "^1.1.2", "gradient-string": "^2.0.2", "prop-types": "^15.8.1", "strip-ansi": "^7.1.0" }, "peerDependencies": { "ink": ">=4" } }, "sha512-OVyPBovBxE1tFcBhSamb+P1puqDP6pG3xFe2W9NiLgwUZd9RbcjBeR7twLbliUT9navrUstEf1ZcPKKvx71BsQ=="],
"ink-gradient": ["ink-gradient@4.0.0", "", { "dependencies": { "@types/gradient-string": "^1.1.6", "gradient-string": "^3.0.0", "strip-ansi": "^7.1.2" }, "peerDependencies": { "ink": ">=6" } }, "sha512-Yx227CStr4DaXVkRAQPbBufSUTqe4a4FLOPVoypXZyae5h3A5jWyqZpTmAIbm7iiiqNYCkKIFBUPJM6nSICfxA=="],
"ink-select-input": ["ink-select-input@6.2.0", "", { "dependencies": { "figures": "^6.1.0", "to-rotated": "^1.0.0" }, "peerDependencies": { "ink": ">=5.0.0", "react": ">=18.0.0" } }, "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ=="],
@@ -606,7 +556,7 @@
"is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
"is-generator-function": ["is-generator-function@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="],
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
@@ -646,8 +596,6 @@
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
@@ -656,7 +604,7 @@
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
@@ -664,12 +612,34 @@
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -678,7 +648,7 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="],
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -686,7 +656,7 @@
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -694,10 +664,12 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"napi-postinstall": ["napi-postinstall@0.3.3", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow=="],
"napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -728,8 +700,6 @@
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
@@ -748,7 +718,7 @@
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -758,7 +728,7 @@
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
@@ -770,15 +740,13 @@
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
"rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
@@ -788,7 +756,7 @@
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
@@ -824,7 +792,7 @@
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
@@ -844,27 +812,23 @@
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinygradient": ["tinygradient@1.1.5", "", { "dependencies": { "@types/tinycolor2": "^1.4.0", "tinycolor2": "^1.0.0" } }, "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw=="],
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"to-rotated": ["to-rotated@1.0.0", "", {}, "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q=="],
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
@@ -882,17 +846,17 @@
"typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
"typedoc": ["typedoc@0.28.17", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.17.0", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", "yaml": "^2.8.1" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-ZkJ2G7mZrbxrKxinTQMjFqsCoYY6a5Luwv2GKbTnBCEgV2ihYm5CflA9JnJAwH0pZWavqfYxmDkFHPt4yx2oDQ=="],
"typedoc": ["typedoc@0.28.19", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.1.1", "minimatch": "^10.2.5", "yaml": "^2.8.3" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
"typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="],
"typescript-eslint": ["typescript-eslint@8.58.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.58.2", "@typescript-eslint/parser": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/utils": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ=="],
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="],
@@ -900,9 +864,9 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"vite": ["vite@7.1.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw=="],
"vite": ["vite@8.0.0", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q=="],
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
"vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -912,7 +876,7 @@
"which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
"which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
@@ -920,13 +884,13 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -936,51 +900,61 @@
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
"@eslint/config-array/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"@typescript-eslint/eslint-plugin/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
"@typescript-eslint/project-service/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
"@typescript-eslint/type-utils/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
"@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-import-resolver-node/resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
"eslint-import-resolver-node/resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"eslint-import-resolver-typescript/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"eslint-import-resolver-typescript/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-plugin-react-hooks/zod": ["zod@4.0.17", "", {}, "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="],
"eslint-plugin-import/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"gradient-string/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"ink-gradient/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"eslint-plugin-import-x/@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"eslint-plugin-react/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
@@ -990,32 +964,22 @@
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"typedoc/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"vite/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"vitest/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"eslint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"gradient-string/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"eslint-plugin-import/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"ink-gradient/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
"string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"typedoc/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
}
}
+132
View File
@@ -0,0 +1,132 @@
# 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/compaction/compaction-*.json` - per-session compaction counter state
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
## 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
```
+235
View File
@@ -0,0 +1,235 @@
# 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
```bash
# Interactive TUI
bun run start
# Piped mode with example payload
bun run example
```
## 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.
- **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 `?`.
- **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. Works with GitHub (`gh`) and GitLab (`glab`); 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 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 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.
- **Compaction Counter** - Show how many context compactions have been detected in the current session. It can render as icon plus number, text plus number, or number-only, and can hide while the count is zero.
- **Session Usage** / **Weekly Usage** / **Block Timer** / **Block Reset Timer** / **Weekly Reset Timer** - Show usage percentages plus current block/reset timing. Session and weekly usage bars can show a time cursor; reset timers can show remaining time, progress, or exact reset date/time with timezone and locale controls.
### Environment, Layout & Custom
- **Current Working Dir** / **Terminal Width** / **Memory Usage** - Show the current working directory, detected terminal width, and system memory usage.
- **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 (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
- 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
- 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
</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.
## 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.
## 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`)
- `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
- **Git Branch**: `l` toggle clickable branch links (GitHub, GitLab, self-hosted)
- **Git Root Dir**: `l` cycle IDE links (`off``VS Code``Cursor`)
- **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**: `p` cycle percentage/full bar/medium bar/short bar/short bar only, `v` invert fill in progress mode, `t` toggle the time cursor in bar modes
- **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**: `f` cycle format, `n` toggle Nerd Font icon in icon mode, `h` hide when zero
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path
- **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.)
- 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.
+197
View File
@@ -0,0 +1,197 @@
# 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
# Or with Yarn
yarn dlx ccstatusline@latest
# Or with pnpm
pnpm dlx ccstatusline@latest
```
## 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.
## 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
# 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**: 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
+12 -12
View File
@@ -1,14 +1,14 @@
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/resolver': {
'import-x/resolver': {
typescript: {
project: ['./tsconfig.json'],
alwaysTryTypes: true,
@@ -18,10 +18,10 @@ const importResolverSettings = {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
}
},
'import/parsers': {
'import-x/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx']
},
'import/external-module-folders': ['node_modules', 'node_modules/@types']
'import-x/external-module-folders': ['node_modules', 'node_modules/@types']
};
export default ts.config([
@@ -29,14 +29,14 @@ 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,
@@ -60,7 +60,7 @@ export default ts.config([
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',
@@ -106,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'
}
},
{
+12 -9
View File
@@ -1,6 +1,6 @@
{
"name": "ccstatusline",
"version": "2.2.0",
"version": "2.2.12",
"description": "A customizable status line formatter for Claude Code CLI",
"module": "src/ccstatusline.ts",
"type": "module",
@@ -16,25 +16,28 @@
"postbuild": "bun run scripts/replace-version.ts",
"example": "cat scripts/payload.example.json | bun start",
"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",
"@stylistic/eslint-plugin": "^5.2.3",
"@types/bun": "latest",
"@types/pluralize": "^0.0.33",
"@types/react": "^19.1.10",
"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": "^7.0.1",
"globals": "^17.3.0",
"https-proxy-agent": "^8.0.0",
"ink": "6.2.0",
"ink-gradient": "^3.0.0",
"ink-gradient": "^4.0.0",
"ink-select-input": "^6.2.0",
"pluralize": "^8.0.0",
"react": "^19.1.1",
@@ -42,7 +45,7 @@
"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": "^4.0.18",
"zod": "^4.0.17"
+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}`);
+110 -4
View File
@@ -3,6 +3,7 @@ import chalk from 'chalk';
import { runTUI } from './tui';
import type {
SkillsMetrics,
SpeedMetrics,
TokenMetrics
} from './types';
@@ -11,22 +12,33 @@ import type { StatusJSON } from './types/StatusJSON';
import { StatusJSONSchema } from './types/StatusJSON';
import { getVisibleText } from './utils/ansi';
import { updateColorMap } from './utils/colors';
import {
detectCompaction,
loadCompactionState,
saveCompactionState
} from './utils/compaction';
import {
initConfigPath,
loadSettings,
saveSettings
} from './utils/config';
import { calculateContextPercentageMetrics } from './utils/context-percentage';
import {
getSessionDuration,
getSpeedMetricsCollection,
getTokenMetrics
} from './utils/jsonl';
import { advanceGlobalPowerlineThemeIndex } from './utils/powerline-theme-index';
import {
calculateMaxWidthsFromPreRendered,
preRenderAllWidgets,
renderStatusLine
} from './utils/renderer';
import { advanceGlobalSeparatorIndex } from './utils/separator-index';
import {
getSkillsFilePath,
getSkillsMetrics
} from './utils/skills';
import {
getWidgetSpeedWindowSeconds,
isWidgetSpeedWindowEnabled
@@ -116,7 +128,7 @@ async function renderMultipleLines(data: StatusJSON) {
sessionDuration = await getSessionDuration(data.transcript_path);
}
const usageData = await prefetchUsageDataIfNeeded(lines);
const usageData = await prefetchUsageDataIfNeeded(lines, data);
let speedMetrics: SpeedMetrics | null = null;
let windowedSpeedMetrics: Record<string, SpeedMetrics> | null = null;
@@ -130,6 +142,31 @@ async function renderMultipleLines(data: StatusJSON) {
windowedSpeedMetrics = speedMetricsCollection.windowed;
}
let skillsMetrics: SkillsMetrics | null = null;
if (data.session_id) {
skillsMetrics = getSkillsMetrics(data.session_id);
}
// Compaction detection — track context percentage drops between renders
let compactionCount = 0;
const hasCompactionWidget = lines.some(line => line.some(item => item.type === 'compaction-counter'));
if (hasCompactionWidget && data.session_id) {
const prevState = loadCompactionState(data.session_id);
compactionCount = prevState.count;
const contextPercentageMetrics = calculateContextPercentageMetrics({ data, tokenMetrics });
if (contextPercentageMetrics !== null) {
const newState = detectCompaction(contextPercentageMetrics.usedPercentage, prevState, { windowSize: contextPercentageMetrics.windowSize });
if (
newState.count !== prevState.count
|| newState.prevCtxPct !== prevState.prevCtxPct
|| newState.prevWindowSize !== prevState.prevWindowSize
) {
saveCompactionState(data.session_id, newState);
}
compactionCount = newState.count;
}
}
// Create render context
const context: RenderContext = {
data,
@@ -138,7 +175,10 @@ async function renderMultipleLines(data: StatusJSON) {
windowedSpeedMetrics,
usageData,
sessionDuration,
isPreview: false
skillsMetrics,
compactionData: hasCompactionWidget ? { count: compactionCount } : null,
isPreview: false,
minimalist: settings.minimalistMode
};
// Always pre-render all widgets once (for efficiency)
@@ -147,11 +187,17 @@ async function renderMultipleLines(data: StatusJSON) {
// Render each line using pre-rendered content
let globalSeparatorIndex = 0;
let globalPowerlineThemeIndex = 0;
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 lineContext = {
...context,
lineIndex: i,
globalSeparatorIndex,
globalPowerlineThemeIndex
};
const line = renderStatusLine(lineItems, settings, lineContext, preRenderedWidgets, preCalculatedMaxWidths);
// Only output the line if it has content (not just ANSI codes)
@@ -166,6 +212,9 @@ async function renderMultipleLines(data: StatusJSON) {
console.log(outputLine);
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems);
if (settings.powerline.enabled && settings.powerline.continueThemeAcrossLines) {
globalPowerlineThemeIndex = advanceGlobalPowerlineThemeIndex(globalPowerlineThemeIndex, preRenderedWidgets);
}
}
}
}
@@ -213,10 +262,67 @@ function parseConfigArg(): string | undefined {
return configPath;
}
interface HookInput {
session_id?: string;
hook_event_name?: string;
tool_name?: string;
tool_input?: { skill?: string };
prompt?: string;
}
async function handleHook(): Promise<void> {
const input = await readStdin();
if (!input) {
console.log('{}');
return;
}
try {
const data = JSON.parse(input) as HookInput;
const sessionId = data.session_id;
if (!sessionId) {
console.log('{}');
return;
}
let skillName = '';
if (data.hook_event_name === 'PreToolUse' && data.tool_name === 'Skill') {
skillName = data.tool_input?.skill ?? '';
} else if (data.hook_event_name === 'UserPromptSubmit') {
const match = /^\/([a-zA-Z0-9_:-]+)(?:\s|$)/.exec(data.prompt ?? '');
if (match) {
skillName = match[1] ?? '';
}
}
if (!skillName) {
console.log('{}');
return;
}
const filePath = getSkillsFilePath(sessionId);
const fs = await import('fs');
const path = await import('path');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const entry = JSON.stringify({
timestamp: new Date().toISOString(),
session_id: sessionId,
skill: skillName,
source: data.hook_event_name
});
fs.appendFileSync(filePath, entry + '\n');
} catch { /* ignore parse errors */ }
console.log('{}');
}
async function main() {
// 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();
@@ -254,4 +360,4 @@ async function main() {
}
}
void main();
void main();
+95 -22
View File
@@ -21,8 +21,10 @@ import {
getExistingStatusLine,
installStatusLine,
isBunxAvailable,
isClaudeCodeVersionAtLeast,
isInstalled,
isKnownCommand,
setRefreshInterval,
uninstallStatusLine
} from '../utils/claude-settings';
import { cloneSettings } from '../utils/clone-settings';
@@ -41,6 +43,7 @@ import {
} from '../utils/powerline';
import { getPackageVersion } from '../utils/terminal';
import { loadClaudeStatusLineState } from './claude-status';
import {
ColorMenu,
ConfirmDialog,
@@ -50,6 +53,7 @@ import {
LineSelector,
MainMenu,
PowerlineSetup,
RefreshIntervalMenu,
StatusLinePreview,
TerminalOptionsMenu,
TerminalWidthMenu,
@@ -63,15 +67,48 @@ interface FlashMessage {
color: 'green' | 'red';
}
type AppScreen = 'main'
| 'lines'
| 'items'
| 'colorLines'
| 'colors'
| 'terminalWidth'
| 'terminalConfig'
| 'globalOverrides'
| 'confirm'
| 'powerline'
| 'install'
| 'refreshInterval';
interface ConfirmDialogState {
message: string;
action: () => Promise<void>;
cancelScreen?: Exclude<AppScreen, 'confirm'>;
}
export function getConfirmCancelScreen(confirmDialog: ConfirmDialogState | null): Exclude<AppScreen, 'confirm'> {
return confirmDialog?.cancelScreen ?? 'main';
}
export function clearInstallMenuSelection(menuSelections: Record<string, number>): Record<string, number> {
if (menuSelections.install === undefined) {
return menuSelections;
}
const next = { ...menuSelections };
delete next.install;
return next;
}
export const App: React.FC = () => {
const { exit } = useApp();
const [settings, setSettings] = useState<Settings | null>(null);
const [originalSettings, setOriginalSettings] = useState<Settings | null>(null);
const [hasChanges, setHasChanges] = useState(false);
const [screen, setScreen] = useState<'main' | 'lines' | 'items' | 'colorLines' | 'colors' | 'terminalWidth' | 'terminalConfig' | 'globalOverrides' | 'confirm' | 'powerline' | 'install'>('main');
const [screen, setScreen] = useState<AppScreen>('main');
const [selectedLine, setSelectedLine] = useState(0);
const [menuSelections, setMenuSelections] = useState<Record<string, number>>({});
const [confirmDialog, setConfirmDialog] = useState<{ message: string; action: () => Promise<void> } | null>(null);
const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogState | null>(null);
const [isClaudeInstalled, setIsClaudeInstalled] = useState(false);
const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80);
const [powerlineFontStatus, setPowerlineFontStatus] = useState<PowerlineFontStatus>({ installed: false });
@@ -80,11 +117,14 @@ export const App: React.FC = () => {
const [existingStatusLine, setExistingStatusLine] = useState<string | null>(null);
const [flashMessage, setFlashMessage] = useState<FlashMessage | null>(null);
const [previewIsTruncated, setPreviewIsTruncated] = useState(false);
const [currentRefreshInterval, setCurrentRefreshInterval] = useState<number | null>(null);
const [supportsRefreshInterval] = useState(() => isClaudeCodeVersionAtLeast('2.1.97'));
useEffect(() => {
// Load existing status line
void getExistingStatusLine().then(setExistingStatusLine);
void loadClaudeStatusLineState().then((statusLineState) => {
setExistingStatusLine(statusLineState.existingStatusLine);
setCurrentRefreshInterval(statusLineState.refreshInterval);
});
void loadSettings().then((loadedSettings) => {
// Set global chalk level based on settings (default to 256 colors for compatibility)
chalk.level = loadedSettings.colorLevel;
@@ -163,26 +203,36 @@ export const App: React.FC = () => {
setConfirmDialog({
message,
cancelScreen: 'install',
action: async () => {
await installStatusLine(useBunx);
await installStatusLine(useBunx, supportsRefreshInterval);
const installedStatusLineState = await loadClaudeStatusLineState();
setIsClaudeInstalled(true);
setExistingStatusLine(command);
setExistingStatusLine(installedStatusLineState.existingStatusLine ?? command);
setCurrentRefreshInterval(installedStatusLineState.refreshInterval);
setScreen('main');
setConfirmDialog(null);
}
});
setScreen('confirm');
});
}, []);
}, [supportsRefreshInterval]);
const handleNpxInstall = useCallback(() => {
setMenuSelections(prev => ({ ...prev, install: 0 }));
handleInstallSelection(CCSTATUSLINE_COMMANDS.NPM, 'npx', false);
}, [handleInstallSelection]);
const handleBunxInstall = useCallback(() => {
setMenuSelections(prev => ({ ...prev, install: 1 }));
handleInstallSelection(CCSTATUSLINE_COMMANDS.BUNX, 'bunx', true);
}, [handleInstallSelection]);
const handleInstallMenuCancel = useCallback(() => {
setMenuSelections(clearInstallMenuSelection);
setScreen('main');
}, []);
if (!settings) {
return <Text>Loading settings...</Text>;
}
@@ -196,6 +246,7 @@ export const App: React.FC = () => {
await uninstallStatusLine();
setIsClaudeInstalled(false);
setExistingStatusLine(null);
setCurrentRefreshInterval(null);
setScreen('main');
setConfirmDialog(null);
}
@@ -227,6 +278,9 @@ export const App: React.FC = () => {
case 'install':
handleInstallUninstall();
break;
case 'configureStatusLine':
setScreen('refreshInterval');
break;
case 'starGithub':
setConfirmDialog({
message: `Open the ccstatusline GitHub repository in your browser?\n\n${GITHUB_REPO_URL}`,
@@ -308,20 +362,12 @@ export const App: React.FC = () => {
<Box marginTop={1}>
{screen === 'main' && (
<MainMenu
onSelect={(value) => {
onSelect={(value, index) => {
// Only persist menu selection if not exiting
if (value !== 'save' && value !== 'exit') {
const menuMap: Record<string, number> = {
lines: 0,
colors: 1,
powerline: 2,
terminalConfig: 3,
globalOverrides: 4,
install: 5,
starGithub: hasChanges ? 8 : 7
};
setMenuSelections(prev => ({ ...prev, main: menuMap[value] ?? 0 }));
setMenuSelections(prev => ({ ...prev, main: index }));
}
void handleMainMenuSelect(value);
}}
isClaudeInstalled={isClaudeInstalled}
@@ -448,7 +494,7 @@ export const App: React.FC = () => {
message={confirmDialog.message}
onConfirm={() => void confirmDialog.action()}
onCancel={() => {
setScreen('main');
setScreen(getConfirmCancelScreen(confirmDialog));
setConfirmDialog(null);
}}
/>
@@ -459,7 +505,34 @@ export const App: React.FC = () => {
existingStatusLine={existingStatusLine}
onSelectNpx={handleNpxInstall}
onSelectBunx={handleBunxInstall}
onCancel={() => {
onCancel={handleInstallMenuCancel}
initialSelection={menuSelections.install}
/>
)}
{screen === 'refreshInterval' && (
<RefreshIntervalMenu
currentInterval={currentRefreshInterval}
supportsRefreshInterval={supportsRefreshInterval}
onUpdate={(interval) => {
const previous = currentRefreshInterval;
setCurrentRefreshInterval(interval);
void setRefreshInterval(interval)
.then(() => {
setFlashMessage({
text: '✓ Refresh interval updated',
color: 'green'
});
})
.catch(() => {
setCurrentRefreshInterval(previous);
setFlashMessage({
text: '✗ Failed to save refresh interval',
color: 'red'
});
});
setScreen('main');
}}
onBack={() => {
setScreen('main');
}}
/>
@@ -503,4 +576,4 @@ export function runTUI() {
// Clear the terminal before starting the TUI
process.stdout.write('\x1b[2J\x1b[H');
render(<App />);
}
}
+39
View File
@@ -0,0 +1,39 @@
import {
describe,
expect,
it
} from 'vitest';
import {
clearInstallMenuSelection,
getConfirmCancelScreen
} from '../App';
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
})).toEqual({ main: 5 });
const menuSelections = { main: 5 };
expect(clearInstallMenuSelection(menuSelections)).toBe(menuSelections);
});
});
+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
};
}
+2 -2
View File
@@ -55,7 +55,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
@@ -504,4 +504,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>
);
};
};
+20 -1
View File
@@ -29,6 +29,7 @@ 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 isPowerlineEnabled = settings.powerline.enabled;
// Check if there are any manual separators in the current configuration
@@ -133,6 +134,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;
@@ -222,6 +232,12 @@ 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>
@@ -304,6 +320,9 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
<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 +331,4 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
)}
</Box>
);
};
};
+51 -51
View File
@@ -3,16 +3,19 @@ import {
Text,
useInput
} from 'ink';
import React, { useState } from 'react';
import React from 'react';
import { getClaudeSettingsPath } from '../../utils/claude-settings';
import { List } from './List';
export interface InstallMenuProps {
bunxAvailable: boolean;
existingStatusLine: string | null;
onSelectNpx: () => void;
onSelectBunx: () => void;
onCancel: () => void;
initialSelection?: number;
}
export const InstallMenu: React.FC<InstallMenuProps> = ({
@@ -20,39 +23,44 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
existingStatusLine,
onSelectNpx,
onSelectBunx,
onCancel
onCancel,
initialSelection = 0
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const maxIndex = 2; // npx, bunx (if available), and back
useInput((input, key) => {
useInput((_, key) => {
if (key.escape) {
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();
}
}
});
function onSelect(value: string) {
switch (value) {
case 'npx':
onSelectNpx();
break;
case 'bunx':
if (bunxAvailable) {
onSelectBunx();
}
break;
case 'back':
onCancel();
break;
}
}
const listItems = [
{
label: 'npx - Node Package Execute',
value: 'npx'
},
{
label: 'bunx - Bun Package Execute',
sublabel: bunxAvailable ? undefined : '(not installed)',
value: 'bunx',
disabled: !bunxAvailable
}
];
return (
<Box flexDirection='column'>
<Text bold>Install ccstatusline to Claude Code</Text>
@@ -71,29 +79,21 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
<Text dimColor>Select package manager to use:</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={listItems}
onSelect={(line) => {
if (line === '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>
<Box marginTop={1}>
<Text color={selectedIndex === 2 ? 'blue' : undefined}>
{selectedIndex === 2 ? '▶ ' : ' '}
Back
</Text>
</Box>
</Box>
onSelect(line);
}}
initialSelection={initialSelection}
showBackButton={true}
/>
<Box marginTop={2}>
<Text dimColor>
@@ -108,4 +108,4 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
</Box>
</Box>
);
};
};
+31 -22
View File
@@ -17,6 +17,7 @@ import { generateGuid } from '../../utils/guid';
import { canDetectTerminalWidth } from '../../utils/terminal';
import {
filterWidgetCatalog,
getMatchSegments,
getWidget,
getWidgetCatalog,
getWidgetCatalogCategories
@@ -94,21 +95,12 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
setCustomEditorWidget(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) => {
@@ -208,8 +200,9 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
setMoveMode,
setShowClearConfirm,
openWidgetPicker,
getVisibleCustomKeybinds,
setCustomEditorWidget
getCustomKeybindsForWidget,
setCustomEditorWidget,
getUniqueBackgroundColor
});
});
@@ -271,7 +264,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
if (widgetImpl) {
canToggleRaw = widgetImpl.supportsRawValue();
// Get custom keybinds from the widget
customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget);
customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
} else {
canToggleRaw = false;
}
@@ -288,7 +281,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';
@@ -429,6 +422,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}>
@@ -436,9 +430,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>
);
})}
@@ -484,6 +485,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}>
@@ -491,9 +493,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>
);
})}
@@ -564,4 +573,4 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
)}
</Box>
);
};
};
+67 -54
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);
}
@@ -125,7 +132,7 @@ const LineSelector: React.FC<LineSelectorProps> = ({
}
return;
case 'd':
if (allowEditing && localLines.length > 1) {
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
setShowDeleteDialog(true);
}
return;
@@ -138,16 +145,6 @@ const LineSelector: React.FC<LineSelectorProps> = ({
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>;
}
+120 -90
View File
@@ -1,25 +1,27 @@
import {
Box,
Text,
useInput
Text
} from 'ink';
import React, { useState } from 'react';
import React from 'react';
import type { Settings } from '../../types/Settings';
import { type PowerlineFontStatus } from '../../utils/powerline';
import { List } from './List';
export type MainMenuOption = 'lines'
| 'colors'
| 'powerline'
| 'terminalConfig'
| 'globalOverrides'
| 'install'
| 'configureStatusLine'
| 'starGithub'
| 'save'
| 'exit';
export interface MainMenuProps {
onSelect: (value: MainMenuOption) => void;
onSelect: (value: MainMenuOption, index: number) => void;
isClaudeInstalled: boolean;
hasChanges: boolean;
initialSelection?: number;
@@ -28,111 +30,139 @@ export interface MainMenuProps {
previewIsTruncated?: boolean;
}
export const MainMenu: React.FC<MainMenuProps> = ({ onSelect, isClaudeInstalled, hasChanges, initialSelection = 0, powerlineFontStatus, settings, previewIsTruncated }) => {
const [selectedIndex, setSelectedIndex] = useState(initialSelection);
export const MainMenu: React.FC<MainMenuProps> = ({
onSelect,
isClaudeInstalled,
hasChanges,
initialSelection = 0,
powerlineFontStatus,
settings,
previewIsTruncated
}) => {
// 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 }
const menuItems: ({
label: string;
value: MainMenuOption;
description: string;
} | '-')[] = [
{
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'
},
'-' as const,
{
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'
},
'-' as const,
...(isClaudeInstalled
? [
{
label: '🔧 Configure Status Line',
value: 'configureStatusLine' as MainMenuOption,
description: 'Configure Claude Code status line settings like refresh interval'
},
{
label: '🔌 Uninstall from Claude Code',
value: 'install' as MainMenuOption,
description: 'Remove ccstatusline from your Claude Code settings'
}
]
: [
{
label: '📦 Install to Claude Code',
value: 'install' as MainMenuOption,
description: 'Add ccstatusline to your Claude Code settings for automatic status line rendering'
}
]
)
];
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'
},
'-' as const,
{
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'
},
'-' as const,
{
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);
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) {
// Since we filtered by selectable: true, value is guaranteed to be MainMenuOption
onSelect(item.value as MainMenuOption);
}
}
});
// 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] ?? '';
};
const selectedItem = selectableItems[selectedIndex];
const description = selectedItem ? getDescription(selectedItem.value) : '';
// 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>
);
};
};
@@ -146,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';
@@ -184,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';
@@ -207,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';
@@ -270,7 +270,6 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
}
};
const canAdd = mode === 'separator' || separators.length < 3;
const canDelete = mode !== 'separator' || separators.length > 1;
return (
@@ -300,7 +299,7 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
<>
<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>
@@ -322,4 +321,4 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
)}
</Box>
);
};
};
+183 -158
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 { 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,138 +166,63 @@ 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 hasSeparatorItems = settings.lines.some(line => line.some(
item => item.type === 'separator' || item.type === 'flex-separator'
));
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) {
setConfirmingEnable(true);
} else {
// Enable directly without confirmation since there are no separators.
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
@@ -218,7 +266,6 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
);
}
// Main menu screen
return (
<Box flexDirection='column'>
{!confirmingFontInstall && !installingFonts && !fontInstallMessage && (
@@ -366,72 +413,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
</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>
);
};
};
+170
View File
@@ -0,0 +1,170 @@
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';
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)`;
}
export function buildConfigureStatusLineItems(
refreshInterval: number | null,
supportsRefreshInterval: boolean
): 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.'
}
];
}
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 interface RefreshIntervalMenuProps {
currentInterval: number | null;
supportsRefreshInterval: boolean;
onUpdate: (interval: number | null) => void;
onBack: () => void;
}
export const RefreshIntervalMenu: React.FC<RefreshIntervalMenuProps> = ({
currentInterval,
supportsRefreshInterval,
onUpdate,
onBack
}) => {
const [editingRefreshInterval, setEditingRefreshInterval] = useState(false);
const [refreshInput, setRefreshInput] = useState(() => getRefreshInputValue(currentInterval));
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 (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>
) : (
<List
marginTop={1}
items={buildConfigureStatusLineItems(currentInterval, supportsRefreshInterval)}
onSelect={(value) => {
if (value === 'back') {
onBack();
return;
}
setRefreshInput(getRefreshInputValue(currentInterval));
setEditingRefreshInterval(true);
}}
showBackButton={true}
/>
)}
</Box>
);
};
+37 -7
View File
@@ -8,6 +8,12 @@ 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,
preRenderAllWidgets,
@@ -30,6 +36,7 @@ const renderSingleLine = (
settings: Settings,
lineIndex: number,
globalSeparatorIndex: number,
globalPowerlineThemeIndex: number,
preRenderedWidgets: PreRenderedWidget[],
preCalculatedMaxWidths: number[]
): RenderResult => {
@@ -37,13 +44,23 @@ const renderSingleLine = (
const context: RenderContext = {
terminalWidth,
isPreview: true,
minimalist: settings.minimalistMode,
lineIndex,
globalSeparatorIndex
globalSeparatorIndex,
globalPowerlineThemeIndex
};
return renderStatusLineWithInfo(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
};
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
@@ -52,10 +69,11 @@ 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 });
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
let globalSeparatorIndex = 0;
let globalPowerlineThemeIndex = 0;
const result: string[] = [];
let truncated = false;
@@ -63,13 +81,25 @@ 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, settings, i, globalSeparatorIndex, preRenderedWidgets, preCalculatedMaxWidths);
const renderResult = renderSingleLine(
lineItems,
terminalWidth,
settings,
i,
globalSeparatorIndex,
globalPowerlineThemeIndex,
preRenderedWidgets,
preCalculatedMaxWidths
);
result.push(renderResult.line);
if (renderResult.wasTruncated) {
truncated = true;
}
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems);
if (settings.powerline.enabled && settings.powerline.continueThemeAcrossLines) {
globalPowerlineThemeIndex = advanceGlobalPowerlineThemeIndex(globalPowerlineThemeIndex, preRenderedWidgets);
}
}
}
@@ -90,12 +120,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>
);
};
};
+87 -82
View File
@@ -13,6 +13,50 @@ import {
} 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;
@@ -20,49 +64,47 @@ 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 = hasCustomWidgetColors(settings.lines);
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;
const cleanedLines = sanitizeLinesForColorLevel(settings.lines, nextLevel);
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;
@@ -83,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();
}
});
@@ -118,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>
@@ -166,4 +171,4 @@ export const getColorLevelLabel = (level?: 0 | 1 | 2 | 3): string => {
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>
);
};
};
@@ -0,0 +1,182 @@
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();
}
});
});
@@ -0,0 +1,135 @@
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';
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, {
bunxAvailable: true,
existingStatusLine: null,
onSelectNpx: vi.fn(),
onSelectBunx: 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('respects the provided initial selection', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const instance = render(
React.createElement(InstallMenu, {
bunxAvailable: true,
existingStatusLine: null,
onSelectNpx: vi.fn(),
onSelectBunx: vi.fn(),
onCancel: vi.fn(),
initialSelection: 1
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
expect(stdout.getOutput()).toContain('▶ bunx - Bun Package Execute');
expect(stdout.getOutput()).not.toContain('▶ npx - Node Package Execute');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
});
@@ -0,0 +1,242 @@
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();
}
});
});
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,159 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import {
describe,
expect,
it,
vi
} from 'vitest';
import {
RefreshIntervalMenu,
buildConfigureStatusLineItems,
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('buildConfigureStatusLineItems', () => {
it('should show (not set) when interval is null and supported', () => {
const items = buildConfigureStatusLineItems(null, true);
expect(items[0]?.sublabel).toBe('(not set)');
});
it('should show seconds for set intervals', () => {
const items = buildConfigureStatusLineItems(10, true);
expect(items[0]?.sublabel).toBe('(10s)');
});
it('should show seconds for small values', () => {
const items = buildConfigureStatusLineItems(1, true);
expect(items[0]?.sublabel).toBe('(1s)');
});
it('should show version requirement when not supported', () => {
const items = buildConfigureStatusLineItems(null, false);
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);
expect(items[0]?.disabled).toBeFalsy();
});
});
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,
onUpdate,
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();
}
});
});
@@ -0,0 +1,24 @@
import {
describe,
expect,
it
} from 'vitest';
import { getVisibleWidth } from '../../../utils/ansi';
import { renderOsc8Link } from '../../../utils/hyperlink';
import { preparePreviewLineForTerminal } from '../StatusLinePreview';
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);
});
});
@@ -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();
}
});
});
@@ -133,4 +133,4 @@ describe('color-menu mutations', () => {
expect(defaultCycle[0]?.color).toBe('red');
expect(dimCycle[0]?.color).toBe('red');
});
});
});
+1 -1
View File
@@ -145,4 +145,4 @@ export function cycleWidgetColor({
color: nextColor
};
});
}
}
+2 -1
View File
@@ -7,6 +7,7 @@ export * from './ItemsEditor';
export * from './LineSelector';
export * from './MainMenu';
export * from './PowerlineSetup';
export * from './RefreshIntervalMenu';
export * from './StatusLinePreview';
export * from './TerminalOptionsMenu';
export * from './TerminalWidthMenu';
export * from './TerminalWidthMenu';
@@ -95,6 +95,62 @@ describe('items-editor input handlers', () => {
expect(applySelection).toHaveBeenCalledWith('git-branch');
});
it('resets selection to best match when typing in category search', () => {
const widgetCatalog = createCatalog([
{ type: 'vim-mode', displayName: 'Vim Mode', category: 'Core' },
{ type: 'git-branch', displayName: 'Git Branch', category: 'Core' }
]);
const widgetCategories = ['All', 'Core'];
const pickerState = createStateSetter<WidgetPickerState | null>({
action: 'change',
level: 'category',
selectedCategory: 'All',
categoryQuery: '',
widgetQuery: '',
selectedType: 'git-branch'
});
handlePickerInputMode({
input: 'v',
key: {},
widgetPicker: requireState(pickerState.get()),
widgetCatalog,
widgetCategories,
setWidgetPicker: pickerState.set,
applyWidgetPickerSelection: vi.fn()
});
expect(pickerState.get()?.selectedType).toBe('vim-mode');
});
it('resets selection to best match when typing in widget search', () => {
const widgetCatalog = createCatalog([
{ type: 'vim-mode', displayName: 'Vim Mode', category: 'Core' },
{ type: 'git-branch', displayName: 'Git Branch', category: 'Core' }
]);
const widgetCategories = ['All', 'Core'];
const pickerState = createStateSetter<WidgetPickerState | null>({
action: 'change',
level: 'widget',
selectedCategory: 'Core',
categoryQuery: '',
widgetQuery: '',
selectedType: 'git-branch'
});
handlePickerInputMode({
input: 'v',
key: {},
widgetPicker: requireState(pickerState.get()),
widgetCatalog,
widgetCategories,
setWidgetPicker: pickerState.set,
applyWidgetPickerSelection: vi.fn()
});
expect(pickerState.get()?.selectedType).toBe('vim-mode');
});
it('returns to category level from widget picker on escape when widget query is empty', () => {
const widgetCatalog = createCatalog([
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' }
@@ -122,6 +178,176 @@ describe('items-editor input handlers', () => {
expect(pickerState.get()?.level).toBe('category');
});
it('wraps to last category when pressing up at first category', () => {
const widgetCatalog = createCatalog([
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
{ type: 'tokens-input', displayName: 'Tokens Input', category: 'Tokens' }
]);
const widgetCategories = ['All', 'Git', 'Tokens'];
const pickerState = createStateSetter<WidgetPickerState | null>({
action: 'change',
level: 'category',
selectedCategory: 'All',
categoryQuery: '',
widgetQuery: '',
selectedType: null
});
handlePickerInputMode({
input: '',
key: { upArrow: true },
widgetPicker: requireState(pickerState.get()),
widgetCatalog,
widgetCategories,
setWidgetPicker: pickerState.set,
applyWidgetPickerSelection: vi.fn()
});
expect(pickerState.get()?.selectedCategory).toBe('Tokens');
});
it('wraps to first category when pressing down at last category', () => {
const widgetCatalog = createCatalog([
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
{ type: 'tokens-input', displayName: 'Tokens Input', category: 'Tokens' }
]);
const widgetCategories = ['All', 'Git', 'Tokens'];
const pickerState = createStateSetter<WidgetPickerState | null>({
action: 'change',
level: 'category',
selectedCategory: 'Tokens',
categoryQuery: '',
widgetQuery: '',
selectedType: null
});
handlePickerInputMode({
input: '',
key: { downArrow: true },
widgetPicker: requireState(pickerState.get()),
widgetCatalog,
widgetCategories,
setWidgetPicker: pickerState.set,
applyWidgetPickerSelection: vi.fn()
});
expect(pickerState.get()?.selectedCategory).toBe('All');
});
it('wraps to last widget when pressing up at first widget in picker', () => {
const widgetCatalog = createCatalog([
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
{ type: 'git-changes', displayName: 'Git Changes', category: 'Git' },
{ type: 'git-insertions', displayName: 'Git Insertions', category: 'Git' }
]);
const widgetCategories = ['All', 'Git'];
const pickerState = createStateSetter<WidgetPickerState | null>({
action: 'change',
level: 'widget',
selectedCategory: 'Git',
categoryQuery: '',
widgetQuery: '',
selectedType: 'git-branch'
});
handlePickerInputMode({
input: '',
key: { upArrow: true },
widgetPicker: requireState(pickerState.get()),
widgetCatalog,
widgetCategories,
setWidgetPicker: pickerState.set,
applyWidgetPickerSelection: vi.fn()
});
expect(pickerState.get()?.selectedType).toBe('git-insertions');
});
it('wraps to first widget when pressing down at last widget in picker', () => {
const widgetCatalog = createCatalog([
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
{ type: 'git-changes', displayName: 'Git Changes', category: 'Git' },
{ type: 'git-insertions', displayName: 'Git Insertions', category: 'Git' }
]);
const widgetCategories = ['All', 'Git'];
const pickerState = createStateSetter<WidgetPickerState | null>({
action: 'change',
level: 'widget',
selectedCategory: 'Git',
categoryQuery: '',
widgetQuery: '',
selectedType: 'git-insertions'
});
handlePickerInputMode({
input: '',
key: { downArrow: true },
widgetPicker: requireState(pickerState.get()),
widgetCatalog,
widgetCategories,
setWidgetPicker: pickerState.set,
applyWidgetPickerSelection: vi.fn()
});
expect(pickerState.get()?.selectedType).toBe('git-branch');
});
it('wraps to last search result when pressing up at first in top-level search', () => {
const widgetCatalog = createCatalog([
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
{ type: 'git-changes', displayName: 'Git Changes', category: 'Git' }
]);
const widgetCategories = ['All', 'Git'];
const pickerState = createStateSetter<WidgetPickerState | null>({
action: 'change',
level: 'category',
selectedCategory: 'All',
categoryQuery: 'git',
widgetQuery: '',
selectedType: 'git-branch'
});
handlePickerInputMode({
input: '',
key: { upArrow: true },
widgetPicker: requireState(pickerState.get()),
widgetCatalog,
widgetCategories,
setWidgetPicker: pickerState.set,
applyWidgetPickerSelection: vi.fn()
});
expect(pickerState.get()?.selectedType).toBe('git-changes');
});
it('wraps to first search result when pressing down at last in top-level search', () => {
const widgetCatalog = createCatalog([
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
{ type: 'git-changes', displayName: 'Git Changes', category: 'Git' }
]);
const widgetCategories = ['All', 'Git'];
const pickerState = createStateSetter<WidgetPickerState | null>({
action: 'change',
level: 'category',
selectedCategory: 'All',
categoryQuery: 'git',
widgetQuery: '',
selectedType: 'git-changes'
});
handlePickerInputMode({
input: '',
key: { downArrow: true },
widgetPicker: requireState(pickerState.get()),
widgetCatalog,
widgetCategories,
setWidgetPicker: pickerState.set,
applyWidgetPickerSelection: vi.fn()
});
expect(pickerState.get()?.selectedType).toBe('git-branch');
});
it('moves selected widget up in move mode', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input' },
@@ -148,6 +374,112 @@ describe('items-editor input handlers', () => {
expect(setMoveMode).not.toHaveBeenCalled();
});
it('wraps to last widget when pressing up at first position in normal mode', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input' },
{ id: '2', type: 'tokens-output' },
{ id: '3', type: 'git-branch' }
];
const setSelectedIndex = vi.fn();
handleNormalInputMode({
input: '',
key: { upArrow: true },
widgets,
selectedIndex: 0,
separatorChars: ['|'],
onBack: vi.fn(),
onUpdate: vi.fn(),
setSelectedIndex,
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
setCustomEditorWidget: vi.fn()
});
expect(setSelectedIndex).toHaveBeenCalledWith(2);
});
it('wraps to first widget when pressing down at last position in normal mode', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input' },
{ id: '2', type: 'tokens-output' },
{ id: '3', type: 'git-branch' }
];
const setSelectedIndex = vi.fn();
handleNormalInputMode({
input: '',
key: { downArrow: true },
widgets,
selectedIndex: 2,
separatorChars: ['|'],
onBack: vi.fn(),
onUpdate: vi.fn(),
setSelectedIndex,
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
setCustomEditorWidget: vi.fn()
});
expect(setSelectedIndex).toHaveBeenCalledWith(0);
});
it('wraps to last position when moving widget up from first position', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input' },
{ id: '2', type: 'tokens-output' },
{ id: '3', type: 'git-branch' }
];
const onUpdate = vi.fn();
const setSelectedIndex = vi.fn();
handleMoveInputMode({
key: { upArrow: true },
widgets,
selectedIndex: 0,
onUpdate,
setSelectedIndex,
setMoveMode: vi.fn()
});
expect(onUpdate).toHaveBeenCalledWith([
{ id: '3', type: 'git-branch' },
{ id: '2', type: 'tokens-output' },
{ id: '1', type: 'tokens-input' }
]);
expect(setSelectedIndex).toHaveBeenCalledWith(2);
});
it('wraps to first position when moving widget down from last position', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input' },
{ id: '2', type: 'tokens-output' },
{ id: '3', type: 'git-branch' }
];
const onUpdate = vi.fn();
const setSelectedIndex = vi.fn();
handleMoveInputMode({
key: { downArrow: true },
widgets,
selectedIndex: 2,
onUpdate,
setSelectedIndex,
setMoveMode: vi.fn()
});
expect(onUpdate).toHaveBeenCalledWith([
{ id: '3', type: 'git-branch' },
{ id: '2', type: 'tokens-output' },
{ id: '1', type: 'tokens-input' }
]);
expect(setSelectedIndex).toHaveBeenCalledWith(0);
});
it('toggles raw value in normal mode for supported widgets', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input' }
@@ -166,7 +498,7 @@ describe('items-editor input handlers', () => {
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
setCustomEditorWidget: vi.fn()
});
@@ -192,7 +524,7 @@ describe('items-editor input handlers', () => {
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
setCustomEditorWidget: vi.fn()
});
@@ -218,11 +550,378 @@ describe('items-editor input handlers', () => {
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
setCustomEditorWidget: vi.fn()
});
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
expect(updated?.[0]?.metadata?.display).toBe('progress');
});
});
it('uses t to toggle reset timer date mode in normal mode', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'reset-timer' }
];
const onUpdate = vi.fn();
handleNormalInputMode({
input: 't',
key: {},
widgets,
selectedIndex: 0,
separatorChars: ['|', '-'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex: vi.fn(),
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
setCustomEditorWidget: vi.fn()
});
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
expect(updated?.[0]?.metadata?.absolute).toBe('true');
});
it('uses h to toggle reset timer hour format in timestamp mode', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'reset-timer', metadata: { absolute: 'true' } }
];
const onUpdate = vi.fn();
handleNormalInputMode({
input: 'h',
key: {},
widgets,
selectedIndex: 0,
separatorChars: ['|', '-'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex: vi.fn(),
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
setCustomEditorWidget: vi.fn()
});
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
expect(updated?.[0]?.metadata?.hour12).toBe('true');
});
it('opens custom editor for reset timer timezone action', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'reset-timer', metadata: { absolute: 'true' } }
];
const onUpdate = vi.fn();
const setCustomEditorWidget = vi.fn();
handleNormalInputMode({
input: 'z',
key: {},
widgets,
selectedIndex: 0,
separatorChars: ['|', '-'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex: vi.fn(),
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
setCustomEditorWidget
});
expect(onUpdate).not.toHaveBeenCalled();
const customEditorState = setCustomEditorWidget.mock.calls[0]?.[0] as
| { action?: string; widget?: WidgetItem }
| undefined;
expect(customEditorState?.action).toBe('edit-timezone');
expect(customEditorState?.widget?.type).toBe('reset-timer');
});
it('opens custom editor for reset timer locale action', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'reset-timer', metadata: { absolute: 'true' } }
];
const onUpdate = vi.fn();
const setCustomEditorWidget = vi.fn();
handleNormalInputMode({
input: 'l',
key: {},
widgets,
selectedIndex: 0,
separatorChars: ['|', '-'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex: vi.fn(),
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
setCustomEditorWidget
});
expect(onUpdate).not.toHaveBeenCalled();
const customEditorState = setCustomEditorWidget.mock.calls[0]?.[0] as
| { action?: string; widget?: WidgetItem }
| undefined;
expect(customEditorState?.action).toBe('edit-locale');
expect(customEditorState?.widget?.type).toBe('reset-timer');
});
it('uses v to cycle skills widget mode', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'skills' }
];
const onUpdate = vi.fn();
handleNormalInputMode({
input: 'v',
key: {},
widgets,
selectedIndex: 0,
separatorChars: ['|', '-'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex: vi.fn(),
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
setCustomEditorWidget: vi.fn()
});
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
expect(updated?.[0]?.metadata?.mode).toBe('count');
});
it('opens custom editor for skills list limit action', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'skills', metadata: { mode: 'list' } }
];
const onUpdate = vi.fn();
const setCustomEditorWidget = vi.fn();
handleNormalInputMode({
input: 'l',
key: {},
widgets,
selectedIndex: 0,
separatorChars: ['|', '-'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex: vi.fn(),
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
setCustomEditorWidget
});
expect(onUpdate).not.toHaveBeenCalled();
const customEditorState = setCustomEditorWidget.mock.calls[0]?.[0] as
| { action?: string; widget?: WidgetItem }
| undefined;
expect(customEditorState?.action).toBe('edit-list-limit');
expect(customEditorState?.widget?.type).toBe('skills');
});
describe('k shortcut - clone widget', () => {
it('inserts clone after source and moves selection to clone', () => {
const widgets: WidgetItem[] = [
{ id: 'a', type: 'tokens-input' },
{ id: 'b', type: 'tokens-output' }
];
const onUpdate = vi.fn();
const setSelectedIndex = vi.fn();
handleNormalInputMode({
input: 'k',
key: {},
widgets,
selectedIndex: 0,
separatorChars: ['|', '-'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex,
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
setCustomEditorWidget: vi.fn()
});
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
expect(updated).toHaveLength(3);
expect(updated[0]?.id).toBe('a');
expect(updated[1]?.type).toBe('tokens-input');
expect(updated[2]?.id).toBe('b');
expect(setSelectedIndex).toHaveBeenCalledWith(1);
});
it('copies all primitive properties of source to clone', () => {
const widgets: WidgetItem[] = [
{ id: 'src', type: 'tokens-input', color: 'green', bold: true, rawValue: true, backgroundColor: 'blue' }
];
const onUpdate = vi.fn();
handleNormalInputMode({
input: 'k',
key: {},
widgets,
selectedIndex: 0,
separatorChars: ['|', '-'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex: vi.fn(),
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
setCustomEditorWidget: vi.fn()
});
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
const clone = updated[1];
expect(clone?.color).toBe('green');
expect(clone?.bold).toBe(true);
expect(clone?.rawValue).toBe(true);
});
it('generates a different id for the clone', () => {
const widgets: WidgetItem[] = [{ id: 'src', type: 'tokens-input' }];
const onUpdate = vi.fn();
handleNormalInputMode({
input: 'k',
key: {},
widgets,
selectedIndex: 0,
separatorChars: ['|', '-'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex: vi.fn(),
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
setCustomEditorWidget: vi.fn()
});
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
expect(updated[1]?.id).not.toBe('src');
expect(typeof updated[1]?.id).toBe('string');
expect(updated[1]?.id.length).toBeGreaterThan(0);
});
it('shallow-clones metadata so mutating clone does not affect source', () => {
const sourceMeta = { display: 'progress' };
const widgets: WidgetItem[] = [{ id: 'src', type: 'session-usage', metadata: sourceMeta }];
const onUpdate = vi.fn();
handleNormalInputMode({
input: 'k',
key: {},
widgets,
selectedIndex: 0,
separatorChars: ['|', '-'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex: vi.fn(),
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
setCustomEditorWidget: vi.fn()
});
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
const cloneMeta = updated[1]?.metadata as Record<string, unknown> | undefined;
expect(cloneMeta).toBeDefined();
expect(cloneMeta).not.toBe(sourceMeta);
expect(cloneMeta?.display).toBe('progress');
if (cloneMeta) {
cloneMeta.display = 'changed';
}
expect(sourceMeta.display).toBe('progress');
});
it('uses getUniqueBackgroundColor result as backgroundColor in powerline mode', () => {
const widgets: WidgetItem[] = [{ id: 'src', type: 'tokens-input', backgroundColor: 'red' }];
const onUpdate = vi.fn();
handleNormalInputMode({
input: 'k',
key: {},
widgets,
selectedIndex: 0,
separatorChars: ['|', '-'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex: vi.fn(),
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
setCustomEditorWidget: vi.fn(),
getUniqueBackgroundColor: () => 'cyan'
});
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
expect(updated[1]?.backgroundColor).toBe('cyan');
});
it('preserves source backgroundColor when getUniqueBackgroundColor returns undefined', () => {
const widgets: WidgetItem[] = [{ id: 'src', type: 'tokens-input', backgroundColor: 'red' }];
const onUpdate = vi.fn();
handleNormalInputMode({
input: 'k',
key: {},
widgets,
selectedIndex: 0,
separatorChars: ['|', '-'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex: vi.fn(),
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
setCustomEditorWidget: vi.fn(),
getUniqueBackgroundColor: () => undefined
});
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
expect(updated[1]?.backgroundColor).toBe('red');
});
it('does nothing when widget list is empty', () => {
const onUpdate = vi.fn();
const setSelectedIndex = vi.fn();
handleNormalInputMode({
input: 'k',
key: {},
widgets: [],
selectedIndex: 0,
separatorChars: ['|', '-'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex,
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
setCustomEditorWidget: vi.fn()
});
expect(onUpdate).not.toHaveBeenCalled();
expect(setSelectedIndex).not.toHaveBeenCalled();
});
});
});
@@ -4,6 +4,7 @@ import type {
WidgetItem,
WidgetItemType
} from '../../../types/Widget';
import { generateGuid } from '../../../utils/guid';
import {
filterWidgetCatalog,
getWidget,
@@ -190,8 +191,8 @@ export function handlePickerInputMode({
}
const nextIndex = key.downArrow
? Math.min(topLevelSearchEntries.length - 1, currentIndex + 1)
: Math.max(0, currentIndex - 1);
? (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,
@@ -208,8 +209,8 @@ export function handlePickerInputMode({
}
const nextIndex = key.downArrow
? Math.min(filteredCategories.length - 1, currentIndex + 1)
: Math.max(0, currentIndex - 1);
? (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,
@@ -219,7 +220,8 @@ export function handlePickerInputMode({
} else if (key.backspace || key.delete) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
categoryQuery: prev.categoryQuery.slice(0, -1)
categoryQuery: prev.categoryQuery.slice(0, -1),
selectedType: null
}));
} else if (
input
@@ -229,7 +231,8 @@ export function handlePickerInputMode({
) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
categoryQuery: prev.categoryQuery + input
categoryQuery: prev.categoryQuery + input,
selectedType: null
}));
}
} else {
@@ -260,8 +263,8 @@ export function handlePickerInputMode({
}
const nextIndex = key.downArrow
? Math.min(filteredWidgets.length - 1, currentIndex + 1)
: Math.max(0, currentIndex - 1);
? (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,
@@ -270,7 +273,8 @@ export function handlePickerInputMode({
} else if (key.backspace || key.delete) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
widgetQuery: prev.widgetQuery.slice(0, -1)
widgetQuery: prev.widgetQuery.slice(0, -1),
selectedType: null
}));
} else if (
input
@@ -280,7 +284,8 @@ export function handlePickerInputMode({
) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
widgetQuery: prev.widgetQuery + input
widgetQuery: prev.widgetQuery + input,
selectedType: null
}));
}
}
@@ -303,24 +308,26 @@ export function handleMoveInputMode({
setSelectedIndex,
setMoveMode
}: HandleMoveInputModeArgs): void {
if (key.upArrow && selectedIndex > 0) {
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[selectedIndex - 1];
const prev = newWidgets[targetIndex];
if (temp && prev) {
[newWidgets[selectedIndex], newWidgets[selectedIndex - 1]] = [prev, temp];
[newWidgets[selectedIndex], newWidgets[targetIndex]] = [prev, temp];
}
onUpdate(newWidgets);
setSelectedIndex(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < widgets.length - 1) {
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[selectedIndex + 1];
const next = newWidgets[targetIndex];
if (temp && next) {
[newWidgets[selectedIndex], newWidgets[selectedIndex + 1]] = [next, temp];
[newWidgets[selectedIndex], newWidgets[targetIndex]] = [next, temp];
}
onUpdate(newWidgets);
setSelectedIndex(selectedIndex + 1);
setSelectedIndex(targetIndex);
} else if (key.escape || key.return) {
setMoveMode(false);
}
@@ -338,8 +345,9 @@ export interface HandleNormalInputModeArgs {
setMoveMode: (moveMode: boolean) => void;
setShowClearConfirm: (show: boolean) => void;
openWidgetPicker: (action: WidgetPickerAction) => void;
getVisibleCustomKeybinds: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[];
getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[];
setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void;
getUniqueBackgroundColor?: (insertIndex: number) => string | undefined;
}
export function handleNormalInputMode({
@@ -354,13 +362,14 @@ export function handleNormalInputMode({
setMoveMode,
setShowClearConfirm,
openWidgetPicker,
getVisibleCustomKeybinds,
setCustomEditorWidget
getCustomKeybindsForWidget,
setCustomEditorWidget,
getUniqueBackgroundColor
}: HandleNormalInputModeArgs): void {
if (key.upArrow && widgets.length > 0) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
setSelectedIndex(selectedIndex - 1 < 0 ? widgets.length - 1 : selectedIndex - 1);
} else if (key.downArrow && widgets.length > 0) {
setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1));
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) {
@@ -377,6 +386,26 @@ export function handleNormalInputMode({
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);
@@ -436,7 +465,7 @@ export function handleNormalInputMode({
return;
}
const customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget);
const customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
const matchedKeybind = customKeybinds.find(kb => kb.key === input);
if (matchedKeybind && !key.ctrl) {
@@ -455,4 +484,4 @@ export function handleNormalInputMode({
}
}
}
}
}
+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;
}
}
+1 -1
View File
@@ -26,4 +26,4 @@ export function getColorLevelString(level: ColorLevel | undefined): ColorLevelSt
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;
}
}
+19 -3
View File
@@ -1,4 +1,7 @@
import type { BlockMetrics } from '../types';
import type {
BlockMetrics,
SkillsMetrics
} from '../types';
import type { SpeedMetrics } from './SpeedMetrics';
import type { StatusJSON } from './StatusJSON';
@@ -13,9 +16,11 @@ export interface RenderUsageData {
extraUsageLimit?: number;
extraUsageUsed?: number;
extraUsageUtilization?: number;
error?: 'no-credentials' | 'timeout' | 'api-error' | 'parse-error';
error?: 'no-credentials' | 'timeout' | 'rate-limited' | 'api-error' | 'parse-error';
}
export interface CompactionData { count: number }
export interface RenderContext {
data?: StatusJSON;
tokenMetrics?: TokenMetrics | null;
@@ -24,8 +29,19 @@ export interface RenderContext {
usageData?: RenderUsageData | null;
sessionDuration?: string | null;
blockMetrics?: BlockMetrics | null;
skillsMetrics?: SkillsMetrics | null;
compactionData?: CompactionData | null;
terminalWidth?: number | null;
isPreview?: boolean;
minimalist?: 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
}
+4 -2
View File
@@ -49,6 +49,7 @@ export const SettingsSchema = z.object({
overrideBackgroundColor: z.string().optional(),
overrideForegroundColor: z.string().optional(),
globalBold: z.boolean().default(false),
minimalistMode: z.boolean().default(false),
powerline: PowerlineConfigSchema.default({
enabled: false,
separators: ['\uE0B0'],
@@ -56,7 +57,8 @@ export const SettingsSchema = z.object({
startCaps: [],
endCaps: [],
theme: undefined,
autoAlign: false
autoAlign: false,
continueThemeAcrossLines: false
}),
updatemessage: z.object({
message: z.string().nullable().optional(),
@@ -68,4 +70,4 @@ export const SettingsSchema = z.object({
export type Settings = z.infer<typeof SettingsSchema>;
// 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;
}
+1 -1
View File
@@ -17,4 +17,4 @@ export interface SpeedMetrics {
/** Number of assistant usage entries included in speed aggregation */
requestCount: number;
}
}
+19 -1
View File
@@ -14,6 +14,11 @@ const CoercedNumberSchema = z.preprocess((value) => {
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(),
@@ -32,6 +37,7 @@ 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: CoercedNumberSchema.optional(),
total_duration_ms: CoercedNumberSchema.optional(),
@@ -54,7 +60,19 @@ export const StatusJSONSchema = z.looseObject({
]).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()
}).nullable().optional()
});
export type StatusJSON = z.infer<typeof StatusJSONSchema>;
export type StatusJSON = z.infer<typeof StatusJSONSchema>;
+2 -2
View File
@@ -6,7 +6,7 @@ export interface TokenUsage {
}
export interface TranscriptLine {
message?: { usage?: TokenUsage };
message?: { usage?: TokenUsage; stop_reason?: string | null };
isSidechain?: boolean;
timestamp?: string;
isApiErrorMessage?: boolean;
@@ -19,4 +19,4 @@ export interface TokenMetrics {
cachedTokens: number;
totalTokens: number;
contextLength: number;
}
}
+5 -2
View File
@@ -13,11 +13,13 @@ export const WidgetItemSchema = z.object({
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(),
metadata: z.record(z.string(), z.string()).optional()
});
@@ -37,11 +39,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 +58,4 @@ export interface CustomKeybind {
key: string;
label: string;
action: string;
}
}
+54 -1
View File
@@ -58,4 +58,57 @@ describe('StatusJSONSchema numeric coercion', () => {
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);
});
});
+2 -1
View File
@@ -18,4 +18,5 @@ export type { PowerlineFontStatus } from './PowerlineFontStatus';
export type { ClaudeSettings } from './ClaudeSettings';
export type { ColorEntry } from './ColorEntry';
export type { BlockMetrics } from './BlockMetrics';
export type { SpeedMetrics } from './SpeedMetrics';
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();
});
});
});
+285 -1
View File
@@ -1,3 +1,4 @@
import * as childProcess from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -11,15 +12,21 @@ import {
vi
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../types/Settings';
import {
CCSTATUSLINE_COMMANDS,
getClaudeCodeVersion,
getClaudeJsonPath,
getClaudeSettingsPath,
getExistingStatusLine,
getRefreshInterval,
installStatusLine,
isClaudeCodeVersionAtLeast,
isInstalled,
isKnownCommand,
loadClaudeSettings,
saveClaudeSettings,
setRefreshInterval,
uninstallStatusLine
} from '../claude-settings';
import { initConfigPath } from '../config';
@@ -34,6 +41,13 @@ function readInstalledCommand(): 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 });
@@ -105,6 +119,34 @@ describe('isKnownCommand', () => {
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);
});
});
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', () => {
@@ -143,6 +185,132 @@ describe('buildCommand via installStatusLine', () => {
await installStatusLine(true);
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.BUNX} --config '/my path/settings.json'`);
});
it('should sync hooks on install when settings include hook-enabled widgets', async () => {
const configPath = path.join(testClaudeConfigDir, 'ccstatusline-settings.json');
initConfigPath(configPath);
const settingsWithSkills = {
...DEFAULT_SETTINGS,
lines: [[{ id: 'skills-1', type: 'skills' }], [], []]
};
fs.writeFileSync(configPath, JSON.stringify(settingsWithSkills, null, 2), 'utf-8');
await installStatusLine(false);
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` }]
}
]);
});
});
describe('installStatusLine refreshInterval', () => {
it('should set refreshInterval to 10 when version is supported', async () => {
initConfigPath();
await installStatusLine(false, true);
expect(readInstalledRefreshInterval()).toBe(10);
});
it('should not set refreshInterval when version is unsupported', async () => {
initConfigPath();
await installStatusLine(false, 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(false, 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', () => {
@@ -257,6 +425,52 @@ describe('backup and error handling behavior', () => {
}
});
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);
@@ -278,4 +492,74 @@ describe('backup and error handling behavior', () => {
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);
});
});
+1 -1
View File
@@ -37,4 +37,4 @@ describe('cloneSettings', () => {
expect(originalMetadata.key).toBe('value');
expect(clonedMetadata.key).toBe('changed');
});
});
});
+1 -1
View File
@@ -67,4 +67,4 @@ describe('color sanitize helpers', () => {
expect(sanitized[0]?.[1]?.color).toBe('hex:ABCDEF');
expect(sanitized[0]?.[1]?.backgroundColor).toBeUndefined();
});
});
});
+294
View File
@@ -0,0 +1,294 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import {
detectCompaction,
loadCompactionState,
saveCompactionState,
type CompactionState
} from '../compaction';
const fresh: CompactionState = { count: 0, prevCtxPct: -1 };
describe('detectCompaction', () => {
it('does not detect on first render (sentinel prevCtxPct)', () => {
const result = detectCompaction(40, fresh);
expect(result.count).toBe(0);
expect(result.prevCtxPct).toBe(40);
});
it('detects compaction when ctx drops by more than 2 points', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
const result = detectCompaction(30, prev);
expect(result.count).toBe(1);
});
it('does not detect when ctx drops by exactly 2 points', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
const result = detectCompaction(38, prev);
expect(result.count).toBe(0);
});
it('does not detect when ctx drops by 1 point (rounding noise)', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 8 };
const result = detectCompaction(7, prev);
expect(result.count).toBe(0);
});
it('does not detect when ctx increases', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
const result = detectCompaction(45, prev);
expect(result.count).toBe(0);
});
it('does not detect when ctx stays the same', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
const result = detectCompaction(40, prev);
expect(result.count).toBe(0);
});
it('detects 3-point drop on 1M window', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 8 };
const result = detectCompaction(5, prev);
expect(result.count).toBe(1);
});
it('detects large compaction on 200K window', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 85 };
const result = detectCompaction(30, prev);
expect(result.count).toBe(1);
});
it('increments existing count', () => {
const prev: CompactionState = { count: 3, prevCtxPct: 70 };
const result = detectCompaction(40, prev);
expect(result.count).toBe(4);
});
it('updates prevCtxPct regardless of detection', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
const result = detectCompaction(45, prev);
expect(result.prevCtxPct).toBe(45);
});
it('accepts custom threshold', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 10 };
const result = detectCompaction(8, prev, 1);
expect(result.count).toBe(1);
});
it('stores the current context window size when provided', () => {
const result = detectCompaction(40, fresh, { windowSize: 200000 });
expect(result).toEqual({ count: 0, prevCtxPct: 40, prevWindowSize: 200000 });
});
it('detects compaction when the context window size is unchanged', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40, prevWindowSize: 200000 };
const result = detectCompaction(30, prev, { windowSize: 200000 });
expect(result.count).toBe(1);
expect(result.prevWindowSize).toBe(200000);
});
it('resets the baseline without incrementing when the context window size changes', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40, prevWindowSize: 200000 };
const result = detectCompaction(8, prev, { windowSize: 1000000 });
expect(result).toEqual({ count: 0, prevCtxPct: 8, prevWindowSize: 1000000 });
});
it('learns the context window size for legacy state without incrementing', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
const result = detectCompaction(8, prev, { windowSize: 1000000 });
expect(result).toEqual({ count: 0, prevCtxPct: 8, prevWindowSize: 1000000 });
});
it('accepts custom threshold in options', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 10, prevWindowSize: 200000 };
const result = detectCompaction(8, prev, { dropThreshold: 1, windowSize: 200000 });
expect(result.count).toBe(1);
});
it('returns state unchanged for NaN input (no poison)', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
expect(detectCompaction(NaN, prev)).toEqual(prev);
});
it('returns state unchanged for Infinity input', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
expect(detectCompaction(Infinity, prev)).toEqual(prev);
});
it('returns state unchanged for negative input', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
expect(detectCompaction(-1, prev)).toEqual(prev);
});
it('detects drops using non-integer percentages', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40.4 };
// 2.8-point drop, exceeds default threshold of 2
const result = detectCompaction(37.6, prev);
expect(result.count).toBe(1);
});
it('handles a session that starts at 0% (sentinel guards first render)', () => {
// sequence: -1 (fresh) -> 0 -> 5 -> 30 -> 10
// First three transitions: no detection. Fourth (30 -> 10) is a real drop.
let state = fresh;
state = detectCompaction(0, state);
expect(state).toEqual({ count: 0, prevCtxPct: 0 });
state = detectCompaction(5, state);
expect(state.count).toBe(0);
state = detectCompaction(30, state);
expect(state.count).toBe(0);
state = detectCompaction(10, state);
expect(state.count).toBe(1);
});
it('detects multiple sequential compactions', () => {
// sequence: -1 (fresh) -> 40 -> 10 -> 50 -> 20
let state = fresh;
state = detectCompaction(40, state);
state = detectCompaction(10, state);
expect(state.count).toBe(1);
state = detectCompaction(50, state);
state = detectCompaction(20, state);
expect(state.count).toBe(2);
});
it('with threshold 0, every strict drop counts', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 10 };
expect(detectCompaction(9.5, prev, 0).count).toBe(1);
});
});
describe('persistence', () => {
let testHome: string;
beforeEach(() => {
testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'compaction-test-'));
vi.spyOn(os, 'homedir').mockReturnValue(testHome);
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(testHome, { recursive: true, force: true });
});
it('round-trips state through save and load', () => {
const state: CompactionState = { count: 5, prevCtxPct: 42, prevWindowSize: 200000 };
saveCompactionState('test-session', state);
const loaded = loadCompactionState('test-session');
expect(loaded).toEqual(state);
});
it('returns fresh state for unknown session', () => {
const loaded = loadCompactionState('nonexistent');
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
});
it('sanitizes path traversal in session ID', () => {
const malicious = '../../../../../../tmp/pwn';
saveCompactionState(malicious, { count: 1, prevCtxPct: 50 });
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
const files = fs.existsSync(cacheDir) ? fs.readdirSync(cacheDir) : [];
expect(files.length).toBe(1);
expect(files[0]).toMatch(/^compaction-[a-zA-Z0-9_-]+\.json$/);
expect(fs.existsSync('/tmp/pwn.json')).toBe(false);
});
it('hashes session IDs that contain only illegal characters to avoid collision', () => {
// Without hashing, both '....' and '!!!!' would sanitize to '____' and collide.
saveCompactionState('....', { count: 1, prevCtxPct: 10 });
saveCompactionState('!!!!', { count: 2, prevCtxPct: 20 });
expect(loadCompactionState('....').count).toBe(1);
expect(loadCompactionState('!!!!').count).toBe(2);
});
it('hashes empty session ID to avoid blank filename leaf', () => {
saveCompactionState('', { count: 1, prevCtxPct: 10 });
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
const files = fs.readdirSync(cacheDir);
expect(files.length).toBe(1);
expect(files[0]).not.toBe('compaction-.json');
expect(files[0]).toMatch(/^compaction-[a-f0-9]{32}\.json$/);
});
it('does not throw on write failure', () => {
vi.spyOn(os, 'homedir').mockReturnValue('/nonexistent/readonly/path');
expect(() => {
saveCompactionState('test', { count: 1, prevCtxPct: 50 });
}).not.toThrow();
});
it('returns fresh state when cache file has corrupted JSON', () => {
saveCompactionState('corrupt-test', { count: 5, prevCtxPct: 50 });
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
const cacheFile = path.join(cacheDir, fs.readdirSync(cacheDir)[0] ?? '');
fs.writeFileSync(cacheFile, '{ this is not valid json');
const loaded = loadCompactionState('corrupt-test');
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
});
it('returns fresh state when cache file exceeds size cap', () => {
saveCompactionState('big-test', { count: 5, prevCtxPct: 50 });
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
const cacheFile = path.join(cacheDir, fs.readdirSync(cacheDir)[0] ?? '');
fs.writeFileSync(cacheFile, 'a'.repeat(8192));
const loaded = loadCompactionState('big-test');
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
});
it.skipIf(process.platform === 'win32')('returns fresh state when cache path is a symlink', () => {
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
fs.mkdirSync(cacheDir, { recursive: true });
const realPath = path.join(testHome, 'real.json');
fs.writeFileSync(realPath, JSON.stringify({ count: 99, prevCtxPct: 50 }));
// sessionId 'symlink-test' has only legal chars, so the cache filename
// is deterministically compaction-symlink-test.json
const sessionId = 'symlink-test';
const symlinkPath = path.join(cacheDir, `compaction-${sessionId}.json`);
fs.symlinkSync(realPath, symlinkPath);
const loaded = loadCompactionState(sessionId);
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
});
it('uses zod defaults for missing fields in cache file', () => {
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, 'compaction-partial.json');
fs.writeFileSync(cacheFile, JSON.stringify({}));
const loaded = loadCompactionState('partial');
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
});
it.skipIf(process.platform === 'win32')('atomic save replaces a planted symlink rather than writing through it', () => {
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
fs.mkdirSync(cacheDir, { recursive: true });
const sessionId = 'rename-test';
const targetPath = path.join(cacheDir, `compaction-${sessionId}.json`);
const decoyTarget = path.join(testHome, 'decoy.txt');
fs.writeFileSync(decoyTarget, 'do not overwrite me');
fs.symlinkSync(decoyTarget, targetPath);
saveCompactionState(sessionId, { count: 1, prevCtxPct: 30 });
// The decoy must be untouched; the cache path is now a regular file.
expect(fs.readFileSync(decoyTarget, 'utf-8')).toBe('do not overwrite me');
expect(fs.lstatSync(targetPath).isFile()).toBe(true);
});
});
+1 -1
View File
@@ -45,4 +45,4 @@ describe('initConfigPath / getConfigPath', () => {
expect(getConfigPath()).toBe(DEFAULT_PATH);
expect(isCustomConfigPath()).toBe(false);
});
});
});
+47 -1
View File
@@ -19,6 +19,7 @@ import {
} 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>;
@@ -34,6 +35,10 @@ function getSettingsPaths(): { configDir: string; settingsPath: string; backupPa
};
}
function getClaudeConfigDir(): string {
return path.join(MOCK_HOME_DIR, '.claude');
}
describe('config utilities', () => {
beforeAll(async () => {
const configModule = await import('../config');
@@ -44,6 +49,7 @@ describe('config utilities', () => {
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);
@@ -55,6 +61,11 @@ describe('config utilities', () => {
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();
});
@@ -160,4 +171,39 @@ describe('config utilities', () => {
expect(saved.version).toBe(CURRENT_VERSION);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});
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');
});
});
+62 -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', () => {
@@ -179,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', () => {
@@ -196,4 +256,4 @@ describe('calculateContextPercentage', () => {
expect(percentage).toBe(21.0);
});
});
});
});
+1 -1
View File
@@ -72,4 +72,4 @@ describe('getContextWindowMetrics', () => {
totalTokens: 6000
});
});
});
});
+409
View File
@@ -0,0 +1,409 @@
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';
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]);
});
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,513 @@
import {
describe,
expect,
it
} from 'vitest';
import {
fetchGitReviewData,
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 }[];
ghResponses: (Error | string)[];
glabResponses: (Error | string)[];
setCurrentRef: (ref: string) => void;
setOriginRemoteUrl: (url: string) => void;
setGlabAvailable: (available: boolean) => void;
setCliAuthedForHost: (cli: 'gh' | 'glab', host: string, authed: boolean) => void;
}
function createHarness(): PrCacheHarness {
const cacheFiles = new Map<string, FakeCacheFile>();
const execCalls: { args: string[]; cmd: string; cwd?: string }[] = [];
const ghResponses: (Error | string)[] = [];
const glabResponses: (Error | string)[] = [];
const 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 deps: GitReviewCacheDeps = {
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] === 'branch')
return `${currentRef}\n`;
if (cmd === 'git' && commandArgs[0] === 'rev-parse')
return 'abc123\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') {
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))) as GitReviewCacheDeps['existsSync'],
getHomedir: () => '/tmp/home',
mkdirSync: (() => undefined) as GitReviewCacheDeps['mkdirSync'],
now: () => now,
readFileSync: (filePath => cacheFiles.get(String(filePath))?.content ?? '') as GitReviewCacheDeps['readFileSync'],
statSync: (filePath => ({ mtimeMs: cacheFiles.get(String(filePath))?.mtimeMs ?? now })) as GitReviewCacheDeps['statSync'],
writeFileSync: ((filePath, content) => {
const normalizedContent = typeof content === 'string'
? content
: Buffer.isBuffer(content)
? content.toString('utf8')
: '';
cacheFiles.set(String(filePath), {
content: normalizedContent,
mtimeMs: now
});
}) as GitReviewCacheDeps['writeFileSync']
};
return {
cacheFiles,
deps,
execCalls,
ghResponses,
glabResponses,
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);
}
}
};
}
describe('git-review-cache', () => {
it('negative-caches failed gh PR lookups', () => {
const harness = createHarness();
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(2);
const cachedMissEntry = [...harness.cacheFiles.values()].at(0);
expect(cachedMissEntry?.content).toBe('');
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toBeNull();
const ghCallsAfterSecondRender = harness.execCalls.filter(call => call.cmd === 'gh');
expect(ghCallsAfterSecondRender).toHaveLength(2);
});
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 when it succeeds (no --repo pin needed)', () => {
const harness = createHarness();
harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git');
harness.ghResponses.push(JSON.stringify({
number: 42,
reviewDecision: '',
state: 'OPEN',
title: 'Standard PR',
url: 'https://github.com/example-owner/example-repo/pull/42'
}));
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
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');
});
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('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(cachedMissEntry?.content).toBe('');
});
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);
});
});
+335 -19
View File
@@ -1,4 +1,4 @@
import { execSync } from 'child_process';
import { execFileSync } from 'child_process';
import {
beforeEach,
describe,
@@ -9,15 +9,22 @@ import {
import type { RenderContext } from '../../types/RenderContext';
import {
clearGitCache,
getGitChangeCounts,
getGitFileStatusCounts,
getGitStatus,
isInsideGitWorkTree,
resolveGitCwd,
runGit
} from '../git';
vi.mock('child_process', () => ({ execSync: vi.fn() }));
vi.mock('child_process', () => ({
execSync: vi.fn(),
execFileSync: vi.fn(),
spawnSync: vi.fn()
}));
const mockExecSync = execSync as unknown as {
const mockExecFileSync = execFileSync as unknown as {
mock: { calls: unknown[][] };
mockImplementation: (impl: () => never) => void;
mockReturnValue: (value: string) => void;
@@ -27,6 +34,7 @@ const mockExecSync = execSync as unknown as {
describe('git utils', () => {
beforeEach(() => {
vi.clearAllMocks();
clearGitCache();
});
describe('resolveGitCwd', () => {
@@ -83,15 +91,16 @@ 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);
expect(result).toBe('feature/worktree');
expect(mockExecSync.mock.calls[0]?.[0]).toBe('git branch --show-current');
expect(mockExecSync.mock.calls[0]?.[1]).toEqual({
expect(mockExecFileSync.mock.calls[0]?.[0]).toBe('git');
expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual(['branch', '--show-current']);
expect(mockExecFileSync.mock.calls[0]?.[2]).toEqual({
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
cwd: '/tmp/repo'
@@ -99,19 +108,19 @@ describe('git utils', () => {
});
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({
expect(mockExecFileSync.mock.calls[0]?.[2]).toEqual({
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
});
});
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();
});
@@ -119,19 +128,19 @@ describe('git utils', () => {
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);
});
@@ -139,8 +148,8 @@ describe('git utils', () => {
describe('getGitChangeCounts', () => {
it('sums staged and unstaged insertions/deletions', () => {
mockExecSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)');
mockExecSync.mockReturnValueOnce('1 file changed, 3 insertions(+), 4 deletions(-)');
mockExecFileSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)');
mockExecFileSync.mockReturnValueOnce('1 file changed, 3 insertions(+), 4 deletions(-)');
expect(getGitChangeCounts({})).toEqual({
insertions: 5,
@@ -149,8 +158,8 @@ describe('git utils', () => {
});
it('handles singular insertion/deletion forms', () => {
mockExecSync.mockReturnValueOnce('1 file changed, 1 insertion(+), 1 deletion(-)');
mockExecSync.mockReturnValueOnce('');
mockExecFileSync.mockReturnValueOnce('1 file changed, 1 insertion(+), 1 deletion(-)');
mockExecFileSync.mockReturnValueOnce('');
expect(getGitChangeCounts({})).toEqual({
insertions: 1,
@@ -159,7 +168,7 @@ describe('git utils', () => {
});
it('returns zero counts when git diff commands fail', () => {
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
expect(getGitChangeCounts({})).toEqual({
insertions: 0,
@@ -167,4 +176,311 @@ describe('git utils', () => {
});
});
});
});
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
});
});
});
});
+79
View File
@@ -0,0 +1,79 @@
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 } from '../../types/Settings';
import { 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' }]
}
]
});
});
});
+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);
});
});
});
+1 -1
View File
@@ -85,4 +85,4 @@ describe('jsonl block metrics integration', () => {
expect(metrics).toBeNull();
});
});
});
+204 -1
View File
@@ -23,12 +23,14 @@ function makeUsageLine(params: {
cacheCreate?: number;
isSidechain?: boolean;
isApiErrorMessage?: boolean;
stopReason?: string | null;
}): string {
return JSON.stringify({
timestamp: params.timestamp,
isSidechain: params.isSidechain,
isApiErrorMessage: params.isApiErrorMessage,
message: {
stop_reason: params.stopReason,
usage: {
input_tokens: params.input,
output_tokens: params.output,
@@ -159,6 +161,207 @@ describe('jsonl transcript metrics', () => {
});
});
it('skips intermediate streaming entries and only counts final entries per API call', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'streaming.jsonl');
// Simulate two API calls, each with intermediate streaming entries (stop_reason: null)
// and a final entry (stop_reason: "tool_use" or "end_turn")
const lines = [
// API call 1: two intermediates + one final
makeUsageLine({
timestamp: '2026-01-01T10:00:00.000Z',
input: 1,
output: 30,
cacheRead: 12000,
cacheCreate: 11000,
stopReason: null
}),
makeUsageLine({
timestamp: '2026-01-01T10:00:00.000Z',
input: 1,
output: 30,
cacheRead: 12000,
cacheCreate: 11000,
stopReason: null
}),
makeUsageLine({
timestamp: '2026-01-01T10:00:01.000Z',
input: 1,
output: 150,
cacheRead: 12000,
cacheCreate: 11000,
stopReason: 'tool_use'
}),
// API call 2: one intermediate + one final
makeUsageLine({
timestamp: '2026-01-01T10:00:02.000Z',
input: 1,
output: 25,
cacheRead: 23000,
cacheCreate: 500,
stopReason: null
}),
makeUsageLine({
timestamp: '2026-01-01T10:00:03.000Z',
input: 1,
output: 400,
cacheRead: 23000,
cacheCreate: 500,
stopReason: 'end_turn'
})
];
fs.writeFileSync(transcriptPath, lines.join('\n'));
const metrics = await getTokenMetrics(transcriptPath);
// Should only count the two final entries, not the three intermediates
expect(metrics).toEqual({
inputTokens: 2, // 1 + 1
outputTokens: 550, // 150 + 400
cachedTokens: 46500, // (12000 + 11000) + (23000 + 500)
totalTokens: 47052, // 2 + 550 + 46500
contextLength: 23501 // last main-chain final entry: 1 + 23000 + 500
});
});
it('counts the latest in-progress streaming entry once when no finalized row exists yet', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'streaming-in-progress.jsonl');
const lines = [
makeUsageLine({
timestamp: '2026-01-01T10:00:00.000Z',
input: 4,
output: 40,
cacheRead: 1000,
cacheCreate: 200,
stopReason: null
}),
makeUsageLine({
timestamp: '2026-01-01T10:00:01.000Z',
input: 4,
output: 90,
cacheRead: 1000,
cacheCreate: 200,
stopReason: null
}),
makeUsageLine({
timestamp: '2026-01-01T10:00:02.000Z',
input: 4,
output: 140,
cacheRead: 1000,
cacheCreate: 200,
stopReason: null
})
];
fs.writeFileSync(transcriptPath, lines.join('\n'));
const metrics = await getTokenMetrics(transcriptPath);
expect(metrics).toEqual({
inputTokens: 4,
outputTokens: 140,
cachedTokens: 1200,
totalTokens: 1344,
contextLength: 1204
});
});
it('counts finalized streaming entries plus the latest unfinished one during live updates', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'streaming-live-update.jsonl');
const lines = [
makeUsageLine({
timestamp: '2026-01-01T10:00:00.000Z',
input: 2,
output: 25,
cacheRead: 100,
cacheCreate: 50,
stopReason: null
}),
makeUsageLine({
timestamp: '2026-01-01T10:00:01.000Z',
input: 2,
output: 80,
cacheRead: 100,
cacheCreate: 50,
stopReason: 'end_turn'
}),
makeUsageLine({
timestamp: '2026-01-01T10:00:02.000Z',
input: 3,
output: 30,
cacheRead: 200,
cacheCreate: 25,
stopReason: null
}),
makeUsageLine({
timestamp: '2026-01-01T10:00:03.000Z',
input: 3,
output: 120,
cacheRead: 200,
cacheCreate: 25,
stopReason: null
})
];
fs.writeFileSync(transcriptPath, lines.join('\n'));
const metrics = await getTokenMetrics(transcriptPath);
expect(metrics).toEqual({
inputTokens: 5,
outputTokens: 200,
cachedTokens: 375,
totalTokens: 580,
contextLength: 228
});
});
it('falls back to counting all entries when no stop_reason data is present', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'legacy.jsonl');
// Older transcript format without stop_reason
const lines = [
makeUsageLine({
timestamp: '2026-01-01T10:00:00.000Z',
input: 100,
output: 50,
cacheRead: 20,
cacheCreate: 10
}),
makeUsageLine({
timestamp: '2026-01-01T11:00:00.000Z',
input: 200,
output: 80,
cacheRead: 30,
cacheCreate: 20
})
];
fs.writeFileSync(transcriptPath, lines.join('\n'));
const metrics = await getTokenMetrics(transcriptPath);
// Should count all entries since none have stop_reason
expect(metrics).toEqual({
inputTokens: 300,
outputTokens: 130,
cachedTokens: 80,
totalTokens: 510,
contextLength: 250
});
});
it('returns zeroed token metrics when file is missing', async () => {
const metrics = await getTokenMetrics('/tmp/ccstatusline-jsonl-metrics-missing.jsonl');
expect(metrics).toEqual({
@@ -883,4 +1086,4 @@ describe('jsonl transcript metrics', () => {
requestCount: 0
});
});
});
});
+1 -1
View File
@@ -261,4 +261,4 @@ describe('Block Detection Algorithm', () => {
expect(result).toBeNull();
});
});
});
});
+83
View File
@@ -0,0 +1,83 @@
import {
describe,
expect,
it
} from 'vitest';
import {
DEFAULT_RESET_LOCALE,
canonicalizeLocale,
filterLocaleOptions,
getLocaleMatchSegments,
getLocaleOptions,
getSystemLocale,
isValidLocale
} from '../locales';
describe('locale helpers', () => {
it('includes the default locale first and curated common locales', () => {
const options = getLocaleOptions();
expect(options[0]?.value).toBe(DEFAULT_RESET_LOCALE);
expect(options.some(option => option.value === 'ja-JP')).toBe(true);
expect(options.some(option => option.value === 'en-US')).toBe(true);
expect(options.some(option => option.value === 'en-CA')).toBe(true);
});
it('includes the system locale when it differs from the default', () => {
const systemLocale = getSystemLocale();
const options = getLocaleOptions();
expect(systemLocale === null || options.some(option => option.value === systemLocale)).toBe(true);
});
it('includes the configured locale when it is valid and uncommon', () => {
const configuredLocale = canonicalizeLocale('en-AU');
if (!configuredLocale) {
return;
}
const options = getLocaleOptions(configuredLocale);
expect(options.some(option => option.value === configuredLocale && option.description === 'Configured locale')).toBe(true);
});
it('filters locale options with fuzzy matching', () => {
const matches = filterLocaleOptions(getLocaleOptions(), 'japan');
expect(matches[0]?.value).toBe('ja-JP');
});
it('adds a custom locale option for valid typed locales outside the curated list', () => {
const customLocale = canonicalizeLocale('en-AU');
if (!customLocale) {
return;
}
const matches = filterLocaleOptions(getLocaleOptions(), 'en-au');
expect(matches[0]).toMatchObject({
value: customLocale,
displayName: `Use ${customLocale}`,
description: 'Custom locale'
});
});
it('does not add a custom locale option for invalid typed locales', () => {
const matches = filterLocaleOptions(getLocaleOptions(), 'not-a-locale');
expect(matches.some(option => option.description === 'Custom locale')).toBe(false);
});
it('highlights locale match segments', () => {
const segments = getLocaleMatchSegments('ja-JP', 'ja');
expect(segments.some(segment => segment.text === 'ja' && segment.matched)).toBe(true);
});
it('canonicalizes and validates BCP 47 locale tags', () => {
expect(canonicalizeLocale('ja-jp')).toBe('ja-JP');
expect(isValidLocale('fr-ca')).toBe(true);
expect(isValidLocale('not-a-locale')).toBe(false);
});
});
+1 -1
View File
@@ -84,4 +84,4 @@ describe('migrations', () => {
expect(updateMessage.message).toContain('v2.0.2');
expect(updateMessage.remaining).toBe(12);
});
});
});
+1 -1
View File
@@ -148,4 +148,4 @@ describe('getModelContextIdentifier', () => {
expect(getModelContextIdentifier(undefined)).toBeUndefined();
expect(getModelContextIdentifier({})).toBeUndefined();
});
});
});
+6 -2
View File
@@ -10,7 +10,11 @@ import {
import { openExternalUrl } from '../open-url';
vi.mock('child_process', () => ({ spawnSync: vi.fn() }));
vi.mock('child_process', () => ({
execSync: vi.fn(),
execFileSync: vi.fn(),
spawnSync: vi.fn()
}));
const mockSpawnSync = spawnSync as unknown as {
mock: { calls: unknown[][] };
@@ -141,4 +145,4 @@ describe('openExternalUrl', () => {
});
expect(mockSpawnSync.mock.calls.length).toBe(0);
});
});
});
@@ -70,4 +70,4 @@ describe('powerline settings helpers', () => {
const updated = buildEnabledPowerlineSettings(settings, false);
expect(updated.lines[0]?.map(item => item.type)).toEqual(['model', 'separator', 'context-length']);
});
});
});
@@ -0,0 +1,61 @@
import {
describe,
expect,
it
} from 'vitest';
import type { WidgetItem } from '../../types/Widget';
import {
advanceGlobalPowerlineThemeIndex,
countPowerlineThemeSlots,
type PowerlineThemeSlotEntry
} from '../powerline-theme-index';
function entry(widget: WidgetItem, content = 'x'): PowerlineThemeSlotEntry {
return { widget, content };
}
describe('powerline theme index utils', () => {
it('counts visible powerline color groups across merged widgets', () => {
const entries: PowerlineThemeSlotEntry[] = [
entry({ id: '1', type: 'model', merge: true }),
entry({ id: '2', type: 'context-length' }),
entry({ id: '3', type: 'git-branch' }),
entry({ id: '4', type: 'git-changes', merge: 'no-padding' }),
entry({ id: '5', type: 'session-cost' })
];
expect(countPowerlineThemeSlots(entries)).toBe(3);
});
it('skips separators and widgets that rendered no content', () => {
const entries: PowerlineThemeSlotEntry[] = [
entry({ id: '1', type: 'model', merge: true }, ''),
entry({ id: '2', type: 'separator' }),
entry({ id: '3', type: 'context-length' }, ''),
entry({ id: '4', type: 'git-branch' }),
entry({ id: '5', type: 'flex-separator' }),
entry({ id: '6', type: 'git-changes' })
];
expect(countPowerlineThemeSlots(entries)).toBe(2);
});
it('advances a running global theme index', () => {
const firstLine: PowerlineThemeSlotEntry[] = [
entry({ id: '1', type: 'model' }),
entry({ id: '2', type: 'context-length' })
];
const secondLine: PowerlineThemeSlotEntry[] = [
entry({ id: '3', type: 'git-branch', merge: true }),
entry({ id: '4', type: 'git-changes' }),
entry({ id: '5', type: 'session-cost' })
];
const afterFirst = advanceGlobalPowerlineThemeIndex(0, firstLine);
const afterSecond = advanceGlobalPowerlineThemeIndex(afterFirst, secondLine);
expect(afterFirst).toBe(2);
expect(afterSecond).toBe(4);
});
});
+44 -1
View File
@@ -13,6 +13,7 @@ import type { WidgetItem } from '../../types/Widget';
import {
getVisibleText,
getVisibleWidth,
stripOscCodes,
truncateStyledText
} from '../ansi';
import {
@@ -64,6 +65,17 @@ describe('renderer ANSI/OSC handling', () => {
expect(getVisibleWidth(text)).toBe(getVisibleWidth('A click B'));
});
it('strips OSC controls while preserving SGR styling', () => {
const text = `\x1b[31m${OSC8_OPEN}click${OSC8_CLOSE}\x1b[39m`;
expect(stripOscCodes(text)).toBe('\x1b[31mclick\x1b[39m');
});
it('treats text-presentation pictographs as narrow unless explicitly emoji-style', () => {
expect(getVisibleWidth('⚠ 2')).toBe(3);
expect(getVisibleWidth('⚠️ 2')).toBe(4);
expect(getVisibleWidth('😀 2')).toBe(4);
});
it('closes open OSC 8 hyperlinks when truncating styled text', () => {
const text = `${OSC8_OPEN}very-long-link-text${OSC8_CLOSE}`;
const truncated = truncateStyledText(text, 10, { ellipsis: true });
@@ -190,4 +202,35 @@ describe('renderer ANSI/OSC handling', () => {
expect(truncated).toContain(OSC8_CLOSE);
expect(getVisibleWidth(truncated)).toBeLessThanOrEqual(8);
});
});
});
describe('renderer minimalist mode', () => {
it('renders widget as raw value when minimalist mode is enabled', () => {
const widgets: WidgetItem[] = [{ id: 'model1', type: 'model' }];
const settings = createSettings();
const context: RenderContext = {
isPreview: true,
minimalist: true
};
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
const content = preRenderedLines[0]?.[0]?.content;
// With minimalist mode, model widget should render raw value ('Claude') not 'Model: Claude'
expect(content).toBe('Claude');
});
it('renders widget with label when minimalist mode is disabled', () => {
const widgets: WidgetItem[] = [{ id: 'model1', type: 'model' }];
const settings = createSettings();
const context: RenderContext = {
isPreview: true,
minimalist: false
};
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
const content = preRenderedLines[0]?.[0]?.content;
expect(content).toBe('Model: Claude');
});
});
@@ -110,4 +110,4 @@ describe('renderer flex width behavior', () => {
expect(getVisibleWidth(line)).toBe(10);
expect(line.endsWith('...')).toBe(true);
});
});
});
@@ -0,0 +1,65 @@
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 { getColorAnsiCode } from '../colors';
import {
calculateMaxWidthsFromPreRendered,
preRenderAllWidgets,
renderStatusLine
} from '../renderer';
function createSettings(continueThemeAcrossLines: boolean): Settings {
return {
...DEFAULT_SETTINGS,
colorLevel: 3,
defaultPadding: '',
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: true,
theme: 'nord-aurora',
continueThemeAcrossLines
}
};
}
function renderLine(settings: Settings, globalPowerlineThemeIndex: number): string {
const widgets: WidgetItem[] = [{
id: 'w1',
type: 'custom-text',
customText: 'tail'
}];
const context: RenderContext = {
isPreview: false,
lineIndex: 1,
globalPowerlineThemeIndex
};
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
const preRenderedWidgets = preRenderedLines[0] ?? [];
return renderStatusLine(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
}
describe('renderer powerline theme carry-over', () => {
it('continues theme colors across lines when enabled', () => {
const line = renderLine(createSettings(true), 2);
expect(line).toContain(getColorAnsiCode('hex:5E81AC', 'truecolor', true));
expect(line).not.toContain(getColorAnsiCode('hex:BF616A', 'truecolor', true));
});
it('restarts theme colors on each line when disabled', () => {
const line = renderLine(createSettings(false), 2);
expect(line).toContain(getColorAnsiCode('hex:BF616A', 'truecolor', true));
});
});
@@ -0,0 +1,150 @@
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 { renderStatusLine } from '../renderer';
interface PreRenderedWidget {
content: string;
plainLength: number;
widget: WidgetItem;
}
function createSettings(overrides: Partial<Settings> = {}): Settings {
return {
...DEFAULT_SETTINGS,
...overrides,
powerline: {
...DEFAULT_SETTINGS.powerline,
...(overrides.powerline ?? {})
}
};
}
function makePreRendered(widgets: WidgetItem[], contentByIndex: Record<number, string>): PreRenderedWidget[] {
return widgets.map((widget, i) => {
const content = contentByIndex[i] ?? '';
return { content, plainLength: content.length, widget };
});
}
function render(
widgets: WidgetItem[],
contentByIndex: Record<number, string>,
settingsOverrides: Partial<Settings> = {}
): string {
const settings = createSettings({ colorLevel: 0, ...settingsOverrides });
const context: RenderContext = { isPreview: false, terminalWidth: 200 };
const preRenderedWidgets = makePreRendered(widgets, contentByIndex);
return renderStatusLine(widgets, settings, context, preRenderedWidgets, []);
}
const T = (id: string): WidgetItem => ({ id, type: 'custom-text' });
const SEP: WidgetItem = { id: 'sep', type: 'separator' };
describe('renderer separator collapse around empty widgets', () => {
it('emits exactly one separator between content widgets (regression baseline)', () => {
const widgets = [T('a'), SEP, T('b')];
const out = render(widgets, { 0: 'A', 2: 'B' });
expect((out.match(/\|/g) ?? []).length).toBe(1);
});
it('drops the trailing separator when the widget after it renders empty', () => {
const widgets = [T('a'), SEP, T('b'), SEP, T('c')];
// 'b' renders empty; the separator after 'b' (before 'c') must be dropped
const out = render(widgets, { 0: 'A', 2: '', 4: 'C' });
expect((out.match(/\|/g) ?? []).length).toBe(1);
expect(out).toContain('A');
expect(out).toContain('C');
});
it('collapses multiple consecutive empty widgets into a single separator', () => {
const widgets = [T('a'), SEP, T('b'), SEP, T('c'), SEP, T('d')];
// both 'b' and 'c' render empty
const out = render(widgets, { 0: 'A', 2: '', 4: '', 6: 'D' });
expect((out.match(/\|/g) ?? []).length).toBe(1);
});
it('suppresses a leading separator when no prior widget has rendered (existing behavior)', () => {
const widgets = [T('a'), SEP, T('b')];
// 'a' renders empty; the separator after it should not emit
const out = render(widgets, { 0: '', 2: 'B' });
expect(out).not.toMatch(/\|/);
});
it('drops separators that follow a sequence of leading empty widgets', () => {
const widgets = [T('a'), SEP, T('b'), SEP, T('c')];
// both 'a' and 'b' render empty; only 'c' has content
const out = render(widgets, { 0: '', 2: '', 4: 'C' });
expect(out).not.toMatch(/\|/);
expect(out).toContain('C');
});
it('removes trailing separator when the last widget renders empty', () => {
const widgets = [T('a'), SEP, T('b')];
// 'b' renders empty
const out = render(widgets, { 0: 'A', 2: '' });
expect(out).not.toMatch(/\|/);
expect(out).toContain('A');
});
it('keeps separators between widgets that all rendered content', () => {
const widgets = [T('a'), SEP, T('b'), SEP, T('c')];
const out = render(widgets, { 0: 'A', 2: 'B', 4: 'C' });
expect((out.match(/\|/g) ?? []).length).toBe(2);
});
it('does not affect the auto-separator (defaultSeparator) path', () => {
// The auto-separator path operates on the already-filtered `elements`
// array (line ~778) — empty widgets never reach it. This test locks in
// that the fix doesn't accidentally couple to the auto-separator logic.
const widgets = [T('a'), T('b'), T('c')];
const out = render(widgets, { 0: 'A', 1: '', 2: 'C' }, { defaultSeparator: '·' });
// With B empty, exactly one auto-added '·' should appear between A and C.
expect((out.match(/·/g) ?? []).length).toBe(1);
expect(out).toContain('A');
expect(out).toContain('C');
});
it('lets a merge:no-padding widget glue to the next visible widget across an empty middle widget', () => {
// Layout: [A(merge:no-padding), B(empty), SEP, C].
//
// Without the fix, the SEP between B and C would emit (its walkback
// would skip past B's empty content and find A with content), so
// `elements` would be [A, SEP, C] and A's merge would not reach C
// (the separator sits between them in the element chain).
//
// With the fix, the SEP is suppressed because B (the immediate-prior
// non-separator) is empty. `elements` becomes [A, C] and the
// omitLeadingPadding check at the C step sees prevElem=A with
// merge:'no-padding' — A glues directly to C with no padding or
// separator between them.
const widgets: WidgetItem[] = [
{ id: 'a', type: 'custom-text', merge: 'no-padding' },
{ id: 'b', type: 'custom-text' },
SEP,
{ id: 'c', type: 'custom-text' }
];
const out = render(widgets, { 0: 'A', 1: '', 3: 'C' });
// No separator visible.
expect(out).not.toMatch(/\|/);
// A and C are present and adjacent (only intra-widget content between
// them after stripping ANSI), confirming the merge took effect.
const stripped = out.replace(/\[[0-9;]*m/g, '');
expect(stripped).toContain('A');
expect(stripped).toContain('C');
// No whitespace separator between A and C — they are directly adjacent.
expect(stripped).toMatch(/A\s*C/);
expect(stripped).not.toMatch(/A\s+\S+\s+C/);
});
});
+1 -1
View File
@@ -65,4 +65,4 @@ describe('separator index utils', () => {
expect(afterFirst).toBe(2);
expect(afterSecond).toBe(3);
});
});
});
+59
View File
@@ -0,0 +1,59 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import {
getSkillsFilePath,
getSkillsMetrics
} from '../skills';
let testHomeDir = '';
function writeSkillsLog(sessionId: string, lines: string[]): void {
const skillsPath = getSkillsFilePath(sessionId);
fs.mkdirSync(path.dirname(skillsPath), { recursive: true });
fs.writeFileSync(skillsPath, lines.join('\n'), 'utf-8');
}
describe('skills metrics', () => {
beforeEach(() => {
testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-home-'));
vi.spyOn(os, 'homedir').mockReturnValue(testHomeDir);
});
afterEach(() => {
vi.restoreAllMocks();
if (testHomeDir) {
fs.rmSync(testHomeDir, { recursive: true, force: true });
}
});
it('uses ~/.cache/ccstatusline/skills path for skill logs', () => {
expect(getSkillsFilePath('session-1')).toBe(
path.join(testHomeDir, '.cache', 'ccstatusline', 'skills', 'skills-session-1.jsonl')
);
});
it('returns total, unique (most-recent-first), and last skill from a valid log', () => {
writeSkillsLog('session-1', [
JSON.stringify({ skill: 'commit', session_id: 'session-1' }),
JSON.stringify({ skill: 'review-pr', session_id: 'session-1' }),
JSON.stringify({ skill: 'lint', session_id: 'session-1' }),
JSON.stringify({ skill: 'commit', session_id: 'session-1' })
]);
expect(getSkillsMetrics('session-1')).toEqual({
totalInvocations: 4,
uniqueSkills: ['commit', 'lint', 'review-pr'],
lastSkill: 'commit'
});
});
});
+1 -1
View File
@@ -71,4 +71,4 @@ describe('formatSpeed', () => {
it('formats high speeds in k notation with one decimal place', () => {
expect(formatSpeed(1250)).toBe('1.3k t/s');
});
});
});
+1 -1
View File
@@ -52,4 +52,4 @@ describe('speed-window helpers', () => {
expect(isWidgetSpeedWindowEnabled(createWidget({ windowSeconds: '0' }))).toBe(false);
expect(isWidgetSpeedWindowEnabled(createWidget({ windowSeconds: '30' }))).toBe(true);
});
});
});
+106 -16
View File
@@ -13,11 +13,16 @@ import {
getTerminalWidth
} from '../terminal';
vi.mock('child_process', () => ({ execSync: vi.fn() }));
vi.mock('child_process', () => ({
execSync: vi.fn(),
execFileSync: vi.fn(),
spawnSync: vi.fn()
}));
describe('terminal utils', () => {
const mockExecSync = execSync as unknown as {
mock: { calls: unknown[][] };
mockImplementation: (impl: (command: string) => string) => void;
mockImplementationOnce: (impl: () => never) => void;
mockReturnValueOnce: (value: string) => void;
};
@@ -31,34 +36,119 @@ describe('terminal utils', () => {
vi.restoreAllMocks();
});
it('returns width from tty probe when available', () => {
mockExecSync.mockReturnValueOnce('ttys001\n');
mockExecSync.mockReturnValueOnce('120\n');
it('returns width from the immediate parent tty when available', () => {
mockExecSync.mockImplementation((command: string) => {
if (command === `ps -o ppid= -p ${process.pid}`) {
return '1234\n';
}
if (command === 'ps -o tty= -p 1234') {
return 'ttys001\n';
}
if (command === `stty size < /dev/ttys001 | awk '{print $2}'`) {
return '120\n';
}
throw new Error(`Unexpected command: ${command}`);
});
expect(getTerminalWidth()).toBe(120);
expect(mockExecSync.mock.calls[0]?.[0]).toContain('ps -o tty=');
expect(mockExecSync.mock.calls[1]?.[0]).toContain('stty size < /dev/ttys001');
expect(mockExecSync.mock.calls.map(([command]) => command)).toEqual([
`ps -o ppid= -p ${process.pid}`,
'ps -o tty= -p 1234',
`stty size < /dev/ttys001 | awk '{print $2}'`
]);
});
it('falls back to tput cols when tty probe fails', () => {
mockExecSync.mockImplementationOnce(() => { throw new Error('tty unavailable'); });
it('walks ancestor processes until it finds a valid tty', () => {
mockExecSync.mockImplementation((command: string) => {
if (command === `ps -o ppid= -p ${process.pid}`) {
return '1234\n';
}
if (command === 'ps -o tty= -p 1234') {
return '??\n';
}
if (command === 'ps -o ppid= -p 1234') {
return '5678\n';
}
if (command === 'ps -o tty= -p 5678') {
return ' ttys009 \n';
}
if (command === `stty size < /dev/ttys009 | awk '{print $2}'`) {
return '104\n';
}
throw new Error(`Unexpected command: ${command}`);
});
expect(getTerminalWidth()).toBe(104);
});
it('falls back to tput cols when ancestor probing fails', () => {
mockExecSync.mockImplementationOnce(() => { throw new Error('ps unavailable'); });
mockExecSync.mockReturnValueOnce('90\n');
expect(getTerminalWidth()).toBe(90);
expect(mockExecSync.mock.calls[1]?.[0]).toBe('tput cols 2>/dev/null');
});
it('returns null when width probes fail', () => {
mockExecSync.mockReturnValueOnce('ttys001\n');
mockExecSync.mockReturnValueOnce('not-a-number\n');
mockExecSync.mockImplementationOnce(() => { throw new Error('tput unavailable'); });
it('returns null when ancestor and fallback probes fail', () => {
mockExecSync.mockImplementation((command: string) => {
if (command === `ps -o ppid= -p ${process.pid}`) {
return '1234\n';
}
if (command === 'ps -o tty= -p 1234') {
return 'ttys001\n';
}
if (command === `stty size < /dev/ttys001 | awk '{print $2}'`) {
return 'not-a-number\n';
}
if (command === 'ps -o ppid= -p 1234') {
return '0\n';
}
if (command === 'tput cols 2>/dev/null') {
throw new Error('tput unavailable');
}
throw new Error(`Unexpected command: ${command}`);
});
expect(getTerminalWidth()).toBeNull();
});
it('detects availability when tty probe succeeds', () => {
mockExecSync.mockReturnValueOnce('ttys001\n');
mockExecSync.mockReturnValueOnce('80\n');
it('detects availability when an ancestor tty probe succeeds', () => {
mockExecSync.mockImplementation((command: string) => {
if (command === `ps -o ppid= -p ${process.pid}`) {
return '1234\n';
}
if (command === 'ps -o tty= -p 1234') {
return '??\n';
}
if (command === 'ps -o ppid= -p 1234') {
return '5678\n';
}
if (command === 'ps -o tty= -p 5678') {
return 'ttys010\n';
}
if (command === `stty size < /dev/ttys010 | awk '{print $2}'`) {
return '80\n';
}
throw new Error(`Unexpected command: ${command}`);
});
expect(canDetectTerminalWidth()).toBe(true);
});
@@ -77,4 +167,4 @@ describe('terminal utils', () => {
expect(canDetectTerminalWidth()).toBe(false);
expect(mockExecSync.mock.calls.length).toBe(0);
});
});
});
+61
View File
@@ -0,0 +1,61 @@
import {
describe,
expect,
it
} from 'vitest';
import {
filterTimezoneOptions,
getLocalTimezone,
getTimezoneMatchSegments,
getTimezoneOptions,
isValidTimezone
} from '../timezones';
describe('timezone helpers', () => {
it('includes default UTC and local timezone options first', () => {
const options = getTimezoneOptions();
expect(options[0]?.value).toBe('UTC');
expect(options[1]?.value).toBe('local');
expect(options[1]?.displayName).toContain('Local');
});
it('lists native IANA timezones when available', () => {
const options = getTimezoneOptions();
const hasNativeTimezoneList = typeof Intl.supportedValuesOf === 'function';
if (hasNativeTimezoneList) {
expect(options.some(option => option.value === 'Asia/Tokyo')).toBe(true);
}
});
it('filters timezone options with fuzzy matching', () => {
const options = getTimezoneOptions();
const matches = filterTimezoneOptions(options, 'tokyo');
if (typeof Intl.supportedValuesOf === 'function') {
expect(matches[0]?.value).toBe('Asia/Tokyo');
} else {
expect(matches).toHaveLength(0);
}
});
it('highlights timezone match segments', () => {
const segments = getTimezoneMatchSegments('America/New_York', 'ny');
expect(segments.some(segment => segment.text === 'N' && segment.matched)).toBe(true);
expect(segments.some(segment => segment.text === 'Y' && segment.matched)).toBe(true);
});
it('validates IANA timezone names', () => {
expect(isValidTimezone('Asia/Tokyo')).toBe(true);
expect(isValidTimezone('Not/A_Real_Zone')).toBe(false);
});
it('returns the system local timezone when available', () => {
const timezone = getLocalTimezone();
expect(timezone === null || timezone.length > 0).toBe(true);
});
});
+474 -108
View File
@@ -1,5 +1,6 @@
import { execFileSync } from 'child_process';
import type * as childProcess from 'child_process';
import * as fs from 'fs';
import { createRequire } from 'module';
import * as os from 'os';
import * as path from 'path';
import { fileURLToPath } from 'url';
@@ -9,58 +10,45 @@ import {
it
} from 'vitest';
const require = createRequire(import.meta.url);
const { execFileSync: realExecFileSync } = require('node:child_process') as { execFileSync: typeof childProcess.execFileSync };
interface UsageProbeResult {
first: Record<string, unknown>;
second: Record<string, unknown>;
lockExists: boolean;
cacheExists: boolean;
requestCount: number;
proxyAgentConfigured: boolean;
requestHost: string | null;
lockContents: string | null;
}
describe('fetchUsageData error handling', () => {
it('preserves root errors within lock window and avoids locking on no-credentials', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-usage-test-'));
const probeScriptPath = path.join(tempRoot, 'probe-usage.mjs');
const usageModulePath = fileURLToPath(new URL('../usage.ts', import.meta.url));
interface TokenHome {
bin: string;
claudeConfig: string;
home: string;
}
const noCredentialsHome = path.join(tempRoot, 'home-no-credentials');
const apiErrorHome = path.join(tempRoot, 'home-api-error');
const apiErrorBin = path.join(tempRoot, 'bin-api-error');
const apiErrorClaudeConfig = path.join(tempRoot, 'claude-api-error');
const successHome = path.join(tempRoot, 'home-success');
const successBin = path.join(tempRoot, 'bin-success');
const successClaudeConfig = path.join(tempRoot, 'claude-success');
const securityScript = path.join(apiErrorBin, 'security');
const successSecurityScript = path.join(successBin, 'security');
const credentialsFile = path.join(apiErrorClaudeConfig, '.credentials.json');
const successCredentialsFile = path.join(successClaudeConfig, '.credentials.json');
const successResponseBody = JSON.stringify({
five_hour: {
utilization: 42,
resets_at: '2030-01-01T00:00:00.000Z'
},
seven_day: {
utilization: 17,
resets_at: '2030-01-07T00:00:00.000Z'
}
});
interface ProbeOptions {
claudeConfigDir?: string;
home: string;
httpsProxy?: string;
lowercaseHttpsProxy?: string;
mode?: 'error' | 'status' | 'success' | 'unexpected';
nowMs: number;
pathDir?: string;
responseBody?: string;
responseHeaders?: Record<string, string>;
statusCode?: number;
}
fs.mkdirSync(noCredentialsHome, { recursive: true });
fs.mkdirSync(apiErrorHome, { recursive: true });
fs.mkdirSync(apiErrorBin, { recursive: true });
fs.mkdirSync(apiErrorClaudeConfig, { recursive: true });
fs.mkdirSync(successHome, { recursive: true });
fs.mkdirSync(successBin, { recursive: true });
fs.mkdirSync(successClaudeConfig, { recursive: true });
function createProbeHarness() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-usage-test-'));
const probeScriptPath = path.join(tempRoot, 'probe-usage.mjs');
const usageModulePath = fileURLToPath(new URL('../usage.ts', import.meta.url));
fs.writeFileSync(securityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n');
fs.chmodSync(securityScript, 0o755);
fs.writeFileSync(credentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } }));
fs.writeFileSync(successSecurityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n');
fs.chmodSync(successSecurityScript, 0o755);
fs.writeFileSync(successCredentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } }));
const probeScript = `
const probeScript = `
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -70,16 +58,24 @@ const require = createRequire(import.meta.url);
const https = require('https');
const mode = process.env.TEST_REQUEST_MODE || 'success';
const responseBody = process.env.TEST_RESPONSE_BODY || '';
const responseHeaders = JSON.parse(process.env.TEST_RESPONSE_HEADERS_JSON || '{}');
const statusCode = Number(process.env.TEST_STATUS_CODE || (mode === 'success' ? '200' : '500'));
let requestCount = 0;
let proxyAgentConfigured = false;
let requestHost = null;
https.request = (...args) => {
requestCount += 1;
const callback = args.find(value => typeof value === 'function');
const options = args.find(value => value && typeof value === 'object' && !Buffer.isBuffer(value));
proxyAgentConfigured = Boolean(options?.agent);
requestHost = options?.hostname ?? null;
const requestHandlers = new Map();
const responseHandlers = new Map();
const response = {
statusCode: mode === 'success' ? 200 : 500,
headers: responseHeaders,
statusCode,
setEncoding() {},
on(event, handler) {
const existing = responseHandlers.get(event) || [];
@@ -106,24 +102,28 @@ https.request = (...args) => {
return;
}
if (mode === 'success') {
if (callback) {
callback(response);
}
const dataHandlers = responseHandlers.get('data') || [];
for (const handler of dataHandlers) {
handler(Buffer.from(responseBody));
}
const endHandlers = responseHandlers.get('end') || [];
for (const handler of endHandlers) {
handler();
if (mode === 'unexpected') {
const handlers = requestHandlers.get('error') || [];
for (const handler of handlers) {
handler(new Error('unexpected request'));
}
return;
}
const handlers = requestHandlers.get('error') || [];
for (const handler of handlers) {
handler(new Error('unexpected request'));
if (callback) {
callback(response);
}
if (responseBody !== '') {
const dataHandlers = responseHandlers.get('data') || [];
for (const handler of dataHandlers) {
handler(responseBody);
}
}
const endHandlers = responseHandlers.get('end') || [];
for (const handler of endHandlers) {
handler();
}
}
};
@@ -145,63 +145,174 @@ process.stdout.write(JSON.stringify({
second,
lockExists: fs.existsSync(lockFile),
cacheExists: fs.existsSync(cacheFile),
requestCount
requestCount,
proxyAgentConfigured,
requestHost,
lockContents: fs.existsSync(lockFile) ? fs.readFileSync(lockFile, 'utf8') : null
}));
`;
try {
fs.writeFileSync(probeScriptPath, probeScript);
fs.writeFileSync(probeScriptPath, probeScript);
const noCredentialsOutput = execFileSync(process.execPath, [probeScriptPath], {
encoding: 'utf8',
env: {
...process.env,
HOME: noCredentialsHome,
PATH: '/nonexistent',
TEST_REQUEST_MODE: 'unexpected',
TEST_NOW_MS: '2200000000000'
}
function createEmptyHome(name: string): { home: string } {
const home = path.join(tempRoot, `home-${name}`);
fs.mkdirSync(home, { recursive: true });
return { home };
}
function createTokenHome(name: string): TokenHome {
const home = path.join(tempRoot, `home-${name}`);
const bin = path.join(tempRoot, `bin-${name}`);
const claudeConfig = path.join(tempRoot, `claude-${name}`);
const securityScript = path.join(bin, 'security');
const credentialsFile = path.join(claudeConfig, '.credentials.json');
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(bin, { recursive: true });
fs.mkdirSync(claudeConfig, { recursive: true });
fs.writeFileSync(securityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n');
fs.chmodSync(securityScript, 0o755);
fs.writeFileSync(credentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } }));
return {
bin,
claudeConfig,
home
};
}
function runProbe(options: ProbeOptions): UsageProbeResult {
const output = realExecFileSync(process.execPath, [probeScriptPath], {
encoding: 'utf8',
env: {
...process.env,
HOME: options.home,
PATH: options.pathDir ?? '/nonexistent',
TEST_NOW_MS: String(options.nowMs),
TEST_REQUEST_MODE: options.mode ?? 'success',
TEST_RESPONSE_BODY: options.responseBody ?? '',
TEST_RESPONSE_HEADERS_JSON: JSON.stringify(options.responseHeaders ?? {}),
TEST_STATUS_CODE: String(options.statusCode ?? (options.mode === 'success' ? 200 : 500)),
...(options.claudeConfigDir ? { CLAUDE_CONFIG_DIR: options.claudeConfigDir } : {}),
...(options.httpsProxy !== undefined ? { HTTPS_PROXY: options.httpsProxy } : {}),
...(options.lowercaseHttpsProxy !== undefined ? { https_proxy: options.lowercaseHttpsProxy } : {})
}
});
return JSON.parse(output) as UsageProbeResult;
}
function cleanup(): void {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
return {
cleanup,
createEmptyHome,
createTokenHome,
runProbe
};
}
function parseLockContents(lockContents: string | null): { blockedUntil: number; error?: string } | null {
return lockContents ? JSON.parse(lockContents) as { blockedUntil: number; error?: string } : null;
}
describe('fetchUsageData error handling', () => {
const nowMs = 2200000000000;
const successResponseBody = JSON.stringify({
five_hour: {
utilization: 42,
resets_at: '2030-01-01T00:00:00.000Z'
},
seven_day: {
utilization: 17,
resets_at: '2030-01-07T00:00:00.000Z'
}
});
const updatedSuccessResponseBody = JSON.stringify({
five_hour: {
utilization: 55,
resets_at: '2030-01-02T00:00:00.000Z'
},
seven_day: {
utilization: 21,
resets_at: '2030-01-08T00:00:00.000Z'
}
});
const rateLimitedResponseBody = JSON.stringify({
error: {
message: 'Rate limited. Please try again later.',
type: 'rate_limit_error'
}
});
it('preserves root errors within a process and keeps existing proxy and cache behavior', () => {
const harness = createProbeHarness();
try {
const noCredentialsHome = harness.createEmptyHome('no-credentials');
const apiErrorHome = harness.createTokenHome('api-error');
const successHome = harness.createTokenHome('success');
const invalidProxyHome = harness.createTokenHome('invalid-proxy');
const proxyHome = harness.createTokenHome('proxy');
const blankProxyHome = harness.createTokenHome('blank-proxy');
const lowercaseProxyHome = harness.createTokenHome('lowercase-proxy');
const noCredentialsResult = harness.runProbe({
home: noCredentialsHome.home,
mode: 'unexpected',
nowMs
});
const noCredentialsResult = JSON.parse(noCredentialsOutput) as UsageProbeResult;
expect(noCredentialsResult.first).toEqual({ error: 'no-credentials' });
expect(noCredentialsResult.second).toEqual({ error: 'no-credentials' });
expect(noCredentialsResult.lockExists).toBe(false);
expect(noCredentialsResult.cacheExists).toBe(false);
expect(noCredentialsResult.requestCount).toBe(0);
expect(noCredentialsResult.proxyAgentConfigured).toBe(false);
const apiErrorOutput = execFileSync(process.execPath, [probeScriptPath], {
encoding: 'utf8',
env: {
...process.env,
HOME: apiErrorHome,
PATH: apiErrorBin,
CLAUDE_CONFIG_DIR: apiErrorClaudeConfig,
TEST_REQUEST_MODE: 'error',
TEST_NOW_MS: '2200000000000'
}
const apiErrorResult = harness.runProbe({
claudeConfigDir: apiErrorHome.claudeConfig,
home: apiErrorHome.home,
mode: 'error',
nowMs,
pathDir: apiErrorHome.bin
});
const apiErrorResult = JSON.parse(apiErrorOutput) as UsageProbeResult;
expect(apiErrorResult.first).toEqual({ error: 'api-error' });
expect(apiErrorResult.second).toEqual({ error: 'api-error' });
expect(apiErrorResult.cacheExists).toBe(false);
expect(apiErrorResult.requestCount).toBe(1);
const successOutput = execFileSync(process.execPath, [probeScriptPath], {
encoding: 'utf8',
env: {
...process.env,
HOME: successHome,
PATH: successBin,
CLAUDE_CONFIG_DIR: successClaudeConfig,
TEST_REQUEST_MODE: 'success',
TEST_RESPONSE_BODY: successResponseBody,
TEST_NOW_MS: '2200000000000'
}
expect(apiErrorResult.proxyAgentConfigured).toBe(false);
expect(apiErrorResult.requestHost).toBe('api.anthropic.com');
expect(parseLockContents(apiErrorResult.lockContents)).toEqual({
blockedUntil: Math.floor(nowMs / 1000) + 30,
error: 'timeout'
});
const genericLockResult = harness.runProbe({
claudeConfigDir: apiErrorHome.claudeConfig,
home: apiErrorHome.home,
mode: 'unexpected',
nowMs,
pathDir: apiErrorHome.bin
});
expect(genericLockResult.first).toEqual({ error: 'timeout' });
expect(genericLockResult.second).toEqual({ error: 'timeout' });
expect(genericLockResult.requestCount).toBe(0);
const successResult = harness.runProbe({
claudeConfigDir: successHome.claudeConfig,
home: successHome.home,
mode: 'success',
nowMs,
pathDir: successHome.bin,
responseBody: successResponseBody
});
const successResult = JSON.parse(successOutput) as UsageProbeResult;
expect(successResult.first).toEqual({
sessionUsage: 42,
sessionResetAt: '2030-01-01T00:00:00.000Z',
@@ -211,26 +322,281 @@ process.stdout.write(JSON.stringify({
expect(successResult.second).toEqual(successResult.first);
expect(successResult.cacheExists).toBe(true);
expect(successResult.requestCount).toBe(1);
expect(successResult.proxyAgentConfigured).toBe(false);
expect(successResult.requestHost).toBe('api.anthropic.com');
const cachedSuccessOutput = execFileSync(process.execPath, [probeScriptPath], {
encoding: 'utf8',
env: {
...process.env,
HOME: successHome,
PATH: successBin,
CLAUDE_CONFIG_DIR: successClaudeConfig,
TEST_REQUEST_MODE: 'unexpected',
TEST_NOW_MS: '2200000000000'
}
const httpsProxyResult = harness.runProbe({
claudeConfigDir: proxyHome.claudeConfig,
home: proxyHome.home,
httpsProxy: 'http://proxy.local:8080',
mode: 'success',
nowMs,
pathDir: proxyHome.bin,
responseBody: successResponseBody
});
expect(httpsProxyResult.first).toEqual(successResult.first);
expect(httpsProxyResult.second).toEqual(successResult.first);
expect(httpsProxyResult.requestCount).toBe(1);
expect(httpsProxyResult.proxyAgentConfigured).toBe(true);
expect(httpsProxyResult.requestHost).toBe('api.anthropic.com');
const lowercaseProxyResult = harness.runProbe({
claudeConfigDir: lowercaseProxyHome.claudeConfig,
home: lowercaseProxyHome.home,
lowercaseHttpsProxy: 'http://proxy.local:8080',
mode: 'success',
nowMs,
pathDir: lowercaseProxyHome.bin,
responseBody: successResponseBody
});
expect(lowercaseProxyResult.first).toEqual(successResult.first);
expect(lowercaseProxyResult.second).toEqual(successResult.first);
expect(lowercaseProxyResult.requestCount).toBe(1);
expect(lowercaseProxyResult.proxyAgentConfigured).toBe(false);
const blankProxyResult = harness.runProbe({
claudeConfigDir: blankProxyHome.claudeConfig,
home: blankProxyHome.home,
httpsProxy: ' ',
mode: 'success',
nowMs,
pathDir: blankProxyHome.bin,
responseBody: successResponseBody
});
expect(blankProxyResult.first).toEqual(successResult.first);
expect(blankProxyResult.second).toEqual(successResult.first);
expect(blankProxyResult.requestCount).toBe(1);
expect(blankProxyResult.proxyAgentConfigured).toBe(false);
const invalidProxyResult = harness.runProbe({
claudeConfigDir: invalidProxyHome.claudeConfig,
home: invalidProxyHome.home,
httpsProxy: '://bad-proxy',
mode: 'success',
nowMs,
pathDir: invalidProxyHome.bin,
responseBody: successResponseBody
});
expect(invalidProxyResult.first).toEqual({ error: 'api-error' });
expect(invalidProxyResult.second).toEqual({ error: 'api-error' });
expect(invalidProxyResult.requestCount).toBe(0);
expect(invalidProxyResult.proxyAgentConfigured).toBe(false);
const staleProxyResult = harness.runProbe({
claudeConfigDir: successHome.claudeConfig,
home: successHome.home,
httpsProxy: '://bad-proxy',
mode: 'success',
nowMs: nowMs + 181000,
pathDir: successHome.bin,
responseBody: successResponseBody
});
expect(staleProxyResult.first).toEqual(successResult.first);
expect(staleProxyResult.second).toEqual(successResult.first);
expect(staleProxyResult.requestCount).toBe(0);
expect(staleProxyResult.proxyAgentConfigured).toBe(false);
const cachedSuccessResult = harness.runProbe({
claudeConfigDir: successHome.claudeConfig,
home: successHome.home,
mode: 'unexpected',
nowMs,
pathDir: successHome.bin
});
const cachedSuccessResult = JSON.parse(cachedSuccessOutput) as UsageProbeResult;
expect(cachedSuccessResult.first).toEqual(successResult.first);
expect(cachedSuccessResult.second).toEqual(successResult.first);
expect(cachedSuccessResult.cacheExists).toBe(true);
expect(cachedSuccessResult.requestCount).toBe(1);
expect(cachedSuccessResult.requestCount).toBe(0);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
harness.cleanup();
}
});
});
it('reuses stale cached data during a numeric Retry-After backoff and retries after expiry', () => {
const harness = createProbeHarness();
try {
const home = harness.createTokenHome('rate-limited-with-cache');
const rateLimitNowMs = nowMs + 31000;
const successResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'success',
nowMs,
pathDir: home.bin,
responseBody: successResponseBody
});
const rateLimitedResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'status',
nowMs: rateLimitNowMs,
pathDir: home.bin,
responseBody: rateLimitedResponseBody,
responseHeaders: { 'retry-after': '3600' },
statusCode: 429
});
expect(rateLimitedResult.first).toEqual(successResult.first);
expect(rateLimitedResult.second).toEqual(successResult.first);
expect(rateLimitedResult.requestCount).toBe(1);
expect(parseLockContents(rateLimitedResult.lockContents)).toEqual({
blockedUntil: Math.floor(rateLimitNowMs / 1000) + 3600,
error: 'rate-limited'
});
const activeBackoffResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs: rateLimitNowMs + 600000,
pathDir: home.bin
});
expect(activeBackoffResult.first).toEqual(successResult.first);
expect(activeBackoffResult.second).toEqual(successResult.first);
expect(activeBackoffResult.requestCount).toBe(0);
const postBackoffResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'success',
nowMs: rateLimitNowMs + 3601000,
pathDir: home.bin,
responseBody: updatedSuccessResponseBody
});
expect(postBackoffResult.first).toEqual({
sessionUsage: 55,
sessionResetAt: '2030-01-02T00:00:00.000Z',
weeklyUsage: 21,
weeklyResetAt: '2030-01-08T00:00:00.000Z'
});
expect(postBackoffResult.second).toEqual(postBackoffResult.first);
expect(postBackoffResult.requestCount).toBe(1);
} finally {
harness.cleanup();
}
});
it('returns rate-limited without stale cache and falls back to the default backoff when Retry-After is invalid', () => {
const harness = createProbeHarness();
try {
const home = harness.createTokenHome('rate-limited-no-cache');
const firstRateLimitedResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'status',
nowMs,
pathDir: home.bin,
responseBody: rateLimitedResponseBody,
responseHeaders: { 'retry-after': 'not-a-number' },
statusCode: 429
});
expect(firstRateLimitedResult.first).toEqual({ error: 'rate-limited' });
expect(firstRateLimitedResult.second).toEqual({ error: 'rate-limited' });
expect(firstRateLimitedResult.requestCount).toBe(1);
expect(parseLockContents(firstRateLimitedResult.lockContents)).toEqual({
blockedUntil: Math.floor(nowMs / 1000) + 300,
error: 'rate-limited'
});
const activeBackoffResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs: nowMs + 299000,
pathDir: home.bin
});
expect(activeBackoffResult.first).toEqual({ error: 'rate-limited' });
expect(activeBackoffResult.second).toEqual({ error: 'rate-limited' });
expect(activeBackoffResult.requestCount).toBe(0);
const postBackoffResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'success',
nowMs: nowMs + 301000,
pathDir: home.bin,
responseBody: successResponseBody
});
expect(postBackoffResult.first).toEqual({
sessionUsage: 42,
sessionResetAt: '2030-01-01T00:00:00.000Z',
weeklyUsage: 17,
weeklyResetAt: '2030-01-07T00:00:00.000Z'
});
expect(postBackoffResult.second).toEqual(postBackoffResult.first);
expect(postBackoffResult.requestCount).toBe(1);
} finally {
harness.cleanup();
}
});
it('parses HTTP-date Retry-After headers', () => {
const harness = createProbeHarness();
try {
const home = harness.createTokenHome('rate-limited-http-date');
const retryAt = new Date(nowMs + 900000).toUTCString();
const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'status',
nowMs,
pathDir: home.bin,
responseBody: rateLimitedResponseBody,
responseHeaders: { 'retry-after': retryAt },
statusCode: 429
});
expect(result.first).toEqual({ error: 'rate-limited' });
expect(result.second).toEqual({ error: 'rate-limited' });
expect(parseLockContents(result.lockContents)).toEqual({
blockedUntil: Math.floor((nowMs + 900000) / 1000),
error: 'rate-limited'
});
} finally {
harness.cleanup();
}
});
it('supports the legacy empty lock file fallback', () => {
const harness = createProbeHarness();
try {
const home = harness.createTokenHome('legacy-lock');
const lockDir = path.join(home.home, '.cache', 'ccstatusline');
const lockFile = path.join(lockDir, 'usage.lock');
fs.mkdirSync(lockDir, { recursive: true });
fs.writeFileSync(lockFile, '');
fs.utimesSync(lockFile, new Date(nowMs), new Date(nowMs));
const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs,
pathDir: home.bin
});
expect(result.first).toEqual({ error: 'timeout' });
expect(result.second).toEqual({ error: 'timeout' });
expect(result.requestCount).toBe(0);
} finally {
harness.cleanup();
}
});
});
+164 -1
View File
@@ -10,6 +10,7 @@ import {
import type { WidgetItem } from '../../types/Widget';
import * as usage from '../usage';
import {
extractUsageDataFromRateLimits,
hasUsageDependentWidgets,
prefetchUsageDataIfNeeded
} from '../usage-prefetch';
@@ -80,4 +81,166 @@ describe('usage prefetch', () => {
expect(usageData).toBeNull();
expect(mockFetchUsageData.mock.calls.length).toBe(0);
});
});
it('uses rate_limits from StatusJSON instead of fetching from API', async () => {
mockFetchUsageData.mockResolvedValue({ sessionUsage: 99 });
const lines = makeLines(
[{ id: '1', type: 'session-usage' }]
);
const usageData = await prefetchUsageDataIfNeeded(lines, {
rate_limits: {
five_hour: { used_percentage: 42, resets_at: 1774020000 },
seven_day: { used_percentage: 15, resets_at: 1774540000 }
}
});
expect(usageData?.sessionUsage).toBe(42);
expect(usageData?.weeklyUsage).toBe(15);
expect(mockFetchUsageData.mock.calls.length).toBe(0);
});
it('falls back to API fetch when rate_limits is absent', async () => {
mockFetchUsageData.mockResolvedValue({ sessionUsage: 42 });
const lines = makeLines(
[{ id: '1', type: 'session-usage' }]
);
const usageData = await prefetchUsageDataIfNeeded(lines, {});
expect(usageData).toEqual({ sessionUsage: 42 });
expect(mockFetchUsageData.mock.calls.length).toBe(1);
});
it('falls back to API fetch when rate_limits has no usable percentages', async () => {
mockFetchUsageData.mockResolvedValue({ sessionUsage: 42 });
const lines = makeLines(
[{ id: '1', type: 'session-usage' }]
);
const usageData = await prefetchUsageDataIfNeeded(lines, { rate_limits: { five_hour: { resets_at: 1774020000 } } });
expect(usageData).toEqual({ sessionUsage: 42 });
expect(mockFetchUsageData.mock.calls.length).toBe(1);
});
it('falls back to API fetch when seven_day is absent from rate_limits', async () => {
mockFetchUsageData.mockResolvedValue({
sessionUsage: 50,
sessionResetAt: '2026-03-20T12:00:00.000Z',
weeklyUsage: 10,
weeklyResetAt: '2026-03-27T12:00:00.000Z'
});
const lines = makeLines(
[{ id: '1', type: 'weekly-usage' }]
);
const usageData = await prefetchUsageDataIfNeeded(lines, { rate_limits: { five_hour: { used_percentage: 50, resets_at: 1774020000 } } });
expect(usageData).toEqual({
sessionUsage: 50,
sessionResetAt: '2026-03-20T12:00:00.000Z',
weeklyUsage: 10,
weeklyResetAt: '2026-03-27T12:00:00.000Z'
});
expect(mockFetchUsageData.mock.calls.length).toBe(1);
});
it('falls back to API fetch when sessionResetAt is missing from rate_limits', async () => {
mockFetchUsageData.mockResolvedValue({
sessionUsage: 42,
sessionResetAt: '2026-03-20T12:00:00.000Z',
weeklyUsage: 15,
weeklyResetAt: '2026-03-27T12:00:00.000Z'
});
const lines = makeLines(
[{ id: '1', type: 'reset-timer' }]
);
const usageData = await prefetchUsageDataIfNeeded(lines, {
rate_limits: {
five_hour: { used_percentage: 42 },
seven_day: { used_percentage: 15, resets_at: 1774540000 }
}
});
expect(usageData).toEqual({
sessionUsage: 42,
sessionResetAt: '2026-03-20T12:00:00.000Z',
weeklyUsage: 15,
weeklyResetAt: '2026-03-27T12:00:00.000Z'
});
expect(mockFetchUsageData.mock.calls.length).toBe(1);
});
});
describe('extractUsageDataFromRateLimits', () => {
it('extracts session and weekly usage from rate_limits', () => {
const result = extractUsageDataFromRateLimits({
five_hour: { used_percentage: 42, resets_at: 1774020000 },
seven_day: { used_percentage: 15, resets_at: 1774540000 }
});
expect(result).not.toBeNull();
expect(result?.sessionUsage).toBe(42);
expect(result?.weeklyUsage).toBe(15);
});
it('converts epoch seconds to ISO strings for resets_at', () => {
const result = extractUsageDataFromRateLimits({
five_hour: { used_percentage: 42, resets_at: 1774020000 },
seven_day: { used_percentage: 15, resets_at: 1774540000 }
});
expect(result?.sessionResetAt).toBe(new Date(1774020000 * 1000).toISOString());
expect(result?.weeklyResetAt).toBe(new Date(1774540000 * 1000).toISOString());
});
it('returns null when rate_limits is null', () => {
expect(extractUsageDataFromRateLimits(null)).toBeNull();
});
it('returns null when rate_limits is undefined', () => {
expect(extractUsageDataFromRateLimits(undefined)).toBeNull();
});
it('returns null when both used_percentage values are missing', () => {
const result = extractUsageDataFromRateLimits({ five_hour: { resets_at: 1774020000 } });
expect(result).toBeNull();
});
it('extracts partial data when only five_hour is present', () => {
const result = extractUsageDataFromRateLimits({ five_hour: { used_percentage: 50, resets_at: 1774020000 } });
expect(result).not.toBeNull();
expect(result?.sessionUsage).toBe(50);
expect(result?.weeklyUsage).toBeUndefined();
});
it('extracts partial data when only seven_day is present', () => {
const result = extractUsageDataFromRateLimits({ seven_day: { used_percentage: 10, resets_at: 1774540000 } });
expect(result).not.toBeNull();
expect(result?.sessionUsage).toBeUndefined();
expect(result?.weeklyUsage).toBe(10);
});
it('treats used_percentage of 0 as valid data', () => {
const result = extractUsageDataFromRateLimits({ five_hour: { used_percentage: 0, resets_at: 1774020000 } });
expect(result).not.toBeNull();
expect(result?.sessionUsage).toBe(0);
});
it('treats null used_percentage as missing and falls back', () => {
const result = extractUsageDataFromRateLimits({ five_hour: { used_percentage: null, resets_at: 1774020000 } });
expect(result).toBeNull();
});
});
@@ -0,0 +1,76 @@
import * as childProcess from 'child_process';
import { createRequire } from 'module';
import type { Mock } from 'vitest';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { getUsageToken } from '../usage-fetch';
vi.mock('child_process', () => ({
execSync: vi.fn(),
execFileSync: vi.fn(),
spawnSync: vi.fn()
}));
const require = createRequire(import.meta.url);
const { execFileSync: realExecFileSync } = require('node:child_process') as { execFileSync: typeof childProcess.execFileSync };
const mockedExecFileSync = childProcess.execFileSync as Mock;
describe('getUsageToken dump-keychain behavior', () => {
beforeEach(() => {
mockedExecFileSync.mockReset();
mockedExecFileSync.mockImplementation(realExecFileSync);
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
});
afterEach(() => {
vi.restoreAllMocks();
mockedExecFileSync.mockReset();
mockedExecFileSync.mockImplementation(realExecFileSync);
});
it('uses an expanded maxBuffer when dumping keychains for hashed fallbacks', () => {
let dumpMaxBuffer: number | undefined;
mockedExecFileSync.mockImplementation((command: string, args?: string[], options?: { maxBuffer?: number }) => {
if (command !== 'security') {
return realExecFileSync(command, args, options);
}
if (!args) {
throw new Error('Expected security arguments');
}
if (args[0] === 'find-generic-password' && args[2] === 'Claude Code-credentials') {
throw new Error('missing exact credential');
}
if (args[0] === 'dump-keychain') {
dumpMaxBuffer = options?.maxBuffer;
return [
'keychain: "/Users/example/Library/Keychains/login.keychain-db"',
'version: 512',
'class: "genp"',
'attributes:',
' "svce"<blob>="Claude Code-credentials-hashed"',
' "mdat"<timedate>="20240301010101Z"'
].join('\n');
}
if (args[0] === 'find-generic-password' && args[2] === 'Claude Code-credentials-hashed') {
return JSON.stringify({ claudeAiOauth: { accessToken: 'hashed-token' } });
}
throw new Error(`Unexpected security args: ${args.join(' ')}`);
});
expect(getUsageToken()).toBe('hashed-token');
expect(dumpMaxBuffer).toBe(8 * 1024 * 1024);
});
});

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