Compare commits

..

150 Commits

Author SHA1 Message Date
Matthew Breedlove 497b30e0bf Version bump, README cleanup
CI / Lint & Type Check (push) Waiting to run
CI / Test (push) Waiting to run
CI / Build (push) Blocked by required conditions
2026-04-08 13:21:35 -04:00
Matthew Breedlove ad51a8762c fix: walk process tree for terminal width detection
Closes #262
2026-04-08 03:36:46 -04:00
hangie 70d1d5261f feat: add git status, git remote, worktree, and custom symbol widgets (#257)
* feat: add git status, git remote, worktree, and custom symbol widgets

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

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

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

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

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

Fixed three issues in git status parsing:

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

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

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

* feat: add conflict detection to git status widget

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

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

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

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

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

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

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

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

Improve git widget behavior in preview and runtime rendering.

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

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

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

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

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

---------

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

* feat(widgets): add Claude account email widget

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

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

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

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

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

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

* fix(widgets): label claude account email output

---------

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

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

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

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

* fix: keep strict settings literals for minimalist mode

---------

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

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

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

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

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



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

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

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

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

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

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

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

* fix: satisfy updated ColorMenu linting

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

---------

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

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

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

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

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

---------

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

* fix: prefer widget picker initialisms over incidental matches

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

---------

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

* fix git-pr cache invalidation and test isolation

* fix gh-pr cache test matcher

---------

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

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

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

* fix: revert example payload to use placeholder values

* chore: align test values with example payload

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

---------

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

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

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

* Improve git and IDE hyperlink handling

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

---------

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

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

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

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

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

* Fix vim mode defaults and editor toggles

---------

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

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

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

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

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

* Add weekly reset hours toggle

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

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

  Supersedes #235
  Closes #235

---------

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

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

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

Closes #209

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

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

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

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

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

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

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

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

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

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

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

* Fix compact timer widget editor state

---------

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

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

* Refactor TUI menu screens onto shared List component

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

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

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

---------

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

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

* feat: tighten hook lifecycle and upgrade skills widget

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

* chore: bump version to 2.2.1 and update release notes

---------

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

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

* Add widget-gated subagent speed aggregation with parallel reads

* Fix subagent speed parsing for session-directory transcript layout

* Consolidate speed windows into core speed widgets

* Bump to v2.2.0 and document new speed widgets

---------

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

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

* refactor: improve menu value handing

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

* refactor: keep minimal typed main-menu options

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

* refactor: minimize typed menu diff against main

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

---------

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

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

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

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

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

---------

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

Bumps the dev-dependencies group with 14 updates:

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: pin ink 6.2.0 and fix lint formatting

---------

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

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

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

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

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

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

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

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

* chore: use bun ecosystem and add Dependabot cooldowns

---------

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

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

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

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

* chore: remove accidental npm lockfile from PR branch

* chore: bump version to 2.1.10 and update recent updates

---------

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

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

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

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

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

* Use bun test in CI test job

---------

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

* fix: quiet invalid claude settings parse output in TUI

---------

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

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

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

* Integrate --config flag with Claude Code install commands

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

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

* Show custom config path in TUI header

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

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

* chore: bump version to 2.1.8 and update recent updates

---------

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

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

Closes #192
2026-03-03 18:30:04 -05:00
Matthew Breedlove da7eaffe03 refactor: implement phase 3 widget pattern extraction with tests 2026-03-03 18:24:39 -05:00
Matthew Breedlove 9aea4c1265 refactor: implement phase 2 tui/settings simplification with tests 2026-03-03 12:53:21 -05:00
Matthew Breedlove 2d05b3b891 refactor: implement phase 1 terminal/render dedup with tests 2026-03-03 12:43:44 -05:00
Matthew Breedlove 6edc1b4277 feat: add git insertions and deletions widgets
Closes #115
2026-03-03 00:22:13 -05:00
Matthew Breedlove 0c6bf4b02c feat: add Link widget and fix ANSI/OSC truncation calculation
- add a new Link widget with OSC8 rendering, preview parity, and raw mode behavior
- fix truncation and visible-width calculation to handle ANSI/OSC sequences safely
- bump package version to 2.1.3
- update README recent updates and widget documentation

Resolves #188
2026-03-02 23:59:14 -05:00
Matthew Breedlove eff030a9d8 Add 'star us on github' menu item, version bump to 2.1.2 2026-03-02 23:21:39 -05:00
Matthew Breedlove 9ddcb0003b Version bump and readme update 2026-03-02 20:32:10 -05:00
ItsDaRa 4459379038 fix: Avoid creating an empty c:\dev\null file on windows during terminal width detection. (#132)
On Windows, the Unix-specific commands (ps, stty, tput) and shell
redirects (2>/dev/null) don't work and cause issues:
- tput doesn't exist on Windows
- 2>/dev/null creates a literal file named "null" instead of
  redirecting stderr

This fix adds a platform check at the start of getTerminalWidth()
and canDetectTerminalWidth().  We cannot rely on process.stdout.columns when run as a subprocess of claude code to tell us the actual terminal width.  By simply returning that terminal width detection is unavailable on windows, we avoid the empty file being created, and disable explicit truncation behavior since we can't detect the width.


Fixes #117

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* fix: preserve legacy terminal width behavior on Windows

Return null/false for win32 in terminal width helpers to keep width detection disabled on Windows and avoid Unix-style fallback paths that can create C:\dev\null.

---------

Co-authored-by: ItsDaRa <ItsDaRa@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-02 20:28:11 -05:00
Matthew Breedlove 8b1ec580d9 Version bump and README update 2026-03-02 17:43:32 -05:00
Peter van Velzen a03b7c2238 feat: add API usage widgets (session, weekly, reset, context bar) (#168)
* feat: add API usage widgets (session, weekly, reset timer, context bar)

Add four new widgets that display Claude Code API usage data:
- session-usage: 5-hour session utilization with progress bar
- weekly-usage: 7-day utilization with progress bar
- reset-timer: countdown to session reset
- context-bar: context window usage with progress bar

Fetches data from /api/oauth/usage using existing OAuth credentials
(macOS Keychain + Linux file fallback). Includes 180s cache with
30s rate limiting to minimize API calls.

Also enhances ContextPercentage widget to use context_window data
from Claude Code's stdin JSON when available.

Closes #94

* fix(api-usage): zod-validate usage data and move cache to ~/.cache/ccstatusline

- parse credentials/cache/API response via zod with nullable field support\n- move usage cache+lock to ~/.cache/ccstatusline/usage.{json,lock}\n- add missing getCategory() methods on API usage widgets\n- resolve context percentage brace-style lint issue

* Refactor usage widgets and unify timer/usage display modes

Split API usage widgets into dedicated files and a shared usage utility, replacing the monolithic ApiUsage widget module.

Key changes:

- add src/utils/usage.ts for API fetch/caching, parsing, error mapping, progress-bar helpers, and shared 5-hour window math

- move widgets to standalone files: SessionUsage, WeeklyUsage, ResetTimer, ContextBar

- remove src/widgets/ApiUsage.tsx and update widget exports/registry wiring

- make BlockTimer usage-aware with usage API first and JSONL fallback via shared resolver

- make ResetTimer follow BlockTimer UX: time/progress/short-progress modes, raw value support, invert toggle, and JSONL fallback when usage timing is unavailable

- make SessionUsage and WeeklyUsage match timer interaction model: progress mode cycling, invert toggle, invert-clearing when returning to text mode, and raw-value support

- update ItemsEditor to hide/disable invert keybind when widget is in text mode

- remove eager block-metric parsing from ccstatusline render path so JSONL fallback is only invoked when usage timing is missing

- add comprehensive tests for usage window fallback behavior and widget mode/toggle/raw output behavior

Validation:

- bun test

- bun run lint

* Prefer status JSON context-window metrics with robust fallbacks

- add a shared context-window metrics utility to normalize context_window fields (window size, percentages, usage, and token snapshots)

- make context window size from status JSON authoritative for context calculations when present; derive usable limit as 80% of reported size

- keep model-id fallback behavior for missing context_window data, including case-insensitive [1m]/[1M] detection

- update Context %, Context % (usable), Context Length, and Context Bar to use status JSON first and fall back to JSONL token metrics when needed

- update Session Clock to use cost.total_duration_ms first with JSONL duration fallback

- keep Tokens Input/Output JSON-first, while reverting Tokens Cached/Total to cumulative session JSONL totals

- simplify render pipeline behavior to always parse current session JSONL token metrics when transcript_path exists

- add/expand tests for context-window parsing, denominator precedence, 1M-without-suffix scenarios, case-insensitive suffix fallback, token widget source behavior, context bar rendering, and session clock behavior

* Align Context Bar with Context Length semantics

- switch Context Bar usage basis from total current_usage (input+output+cache) to context-length usage (input+cache), matching Context Length widget

- keep statusline JSON-first behavior with JSONL/model fallbacks unchanged

- keep short-by-default progress mode and long/short toggle behavior

- update ContextBar tests for new token basis and progress-mode expectations

* usage: honor CLAUDE_CONFIG_DIR and use runtime-safe HTTPS API fetch

* Fix context bar overflow and preserve usage error states

* Group usage widgets under new Usage picker category

* Rename context remaining toggle to used/remaining shortcut

---------

Co-authored-by: Peter van Velzen <pvvelzen@emico.nl>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-03-02 17:31:12 -05:00
Matthew Breedlove 801c5f1b38 chore: Update README.md 2026-02-27 13:19:08 -05:00
Matthew Breedlove fc3934097b chore: Fix for test failures when testing with vitest, remove 'test' script from package.json, prefer running tests using 'bun test' 2026-02-27 13:19:08 -05:00
Matthew Breedlove a8122844ff Add Buy Me a Coffee funding and README support section 2026-02-24 14:50:47 -05:00
Anthony Vincent Caracappa 3d43476d6b Free Memory Widget (#155)
* FreeMemory widget:

  Description: Shows system memory usage (used/total) in a compact format like Mem: 12.4G/16.0G

  Features:
  - On macOS: Uses vm_stat to calculate memory like htop (Active + Wired pages)
  - On other platforms: Falls back to Node.js os.freemem()/os.totalmem()
  - Smart byte formatting (G, M, K, B)
  - Supports raw value mode (omits "Mem:" prefix)
  - Default color: cyan

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

* Version bump and README update

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-02-24 14:20:00 -05:00
Matthew Breedlove b6426bf707 Version bump 2026-02-21 16:12:52 -05:00
Jaakko ecd8d0b6cc perf: cache block timer metrics to reduce I/O (#161)
* perf: cache block timer metrics to reduce I/O

Cache block start time to ~/.cache/ccstatusline/block-cache.json to avoid
parsing all JSONL files on every status line render. Cache is invalidated
when the 5-hour block expires.

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

* fix: use hashed per-config block cache files

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-02-21 16:06:27 -05:00
Matthew Breedlove 3abc41f122 refactor git widgets to use shared git command helpers
Centralize cwd resolution and git command execution for git widgets, and expand widget-level and utility tests for failure and edge cases.

Closes #176
2026-02-21 14:49:46 -05:00
Matthew Breedlove 20691a70b0 Set Windows UTF-8 code page for piped statusline (closes #186) 2026-02-21 14:20:55 -05:00
Matthew Breedlove 2e0b321d41 Version bump and readme update 2026-02-21 12:07:00 -05:00
tim-watcha f83e7d2f6d Add Git Root Dir widget (#119)
* Add Git Root Dir widget

Add a new widget that displays the git repository root directory name.
- Shows directory name with 📁 icon (e.g., "📁 my-repo")
- Supports rawValue option for icon-free display
- Supports hideNoGit option to hide message when not in a git repo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Change Git Root Dir widget icon to text label

Replace 📁 emoji with "Repo:" text label for better compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Remove label from Git Root Dir widget

Display only the repository name without any prefix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix GitRootDir root path handling and raw mode

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-02-21 12:00:31 -05:00
Matthew Breedlove 35b4c0caa0 Version bump 2026-02-21 11:36:25 -05:00
Arjun Lall 62903d8183 Add Session Name widget (#142)
* Add Session Name widget

Adds a native widget to display the Claude Code session name set via the
/rename command. The widget reads the transcript file and extracts the
customTitle from custom-title entries.

- New SessionName widget with rawValue support
- 9 unit tests
- README documentation

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

* fix: add category metadata to session name widget

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-02-21 11:34:10 -05:00
Jaakko 0092c241a6 feat: add abbreviate home option to Current Working Directory widget (#144)
* feat: add abbreviate home option to Current Working Directory widget

Add a new 'abbreviate home' toggle (h key) that replaces the home
directory path with ~ (e.g., /Users/matt/Projects → ~/Projects).

- Can be combined with segments for paths like ~/.../Projects/my-project
- Mutually exclusive with fish-style abbreviation
- Includes tests for the new functionality

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

* fix: correct cwd home abbreviation edge cases

Preserve platform path separators when combining home abbreviation with segments.
Require exact home-directory boundary matching to avoid false substitutions.
Align one-segment abbreviateHome preview with runtime output.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-02-21 11:17:41 -05:00
Ani Aggarwal 7cf85dffb1 fix: recognize [1m] suffix on all models, not just Sonnet 4.5 (#172)
The getContextConfig function was hardcoded to only match
claude-sonnet-4-5 models with the [1m] suffix. Other models like
claude-opus-4-6[1m] also support 1M context but were falling back to
the 200k default, causing incorrect context percentage display.

The [1m] suffix itself is sufficient to indicate 1M context regardless
of model family.
2026-02-21 10:58:24 -05:00
Matthew Breedlove d025c707e6 fix: prevent shortcut keys from leaking into TUI editors
Closes #184
2026-02-21 10:44:12 -05:00
Matthew Breedlove 76d9af84a5 Improve TUI widget picker UX and clear-line safety
Closes #19
2026-02-21 10:35:47 -05:00
Matthew Breedlove 09e1d723ac Rename CLAUDE.md to AGENTS.md with symlink 2026-02-21 09:37:00 -05:00
Matthew Breedlove 3050aefc1c Version bump 2026-02-02 16:12:06 -05:00
Joseph Mearman 879255523b Add line reorder move mode and fix GitWorktree tests (#154)
* fix(test): replace vi.mocked with direct mock reference in GitWorktree tests

vi.mocked(execSync) was undefined because the vi.mock factory runs
before imports resolve. Use a top-level vi.fn() reference instead.

* feat(tui): add move mode to LineSelector for reordering lines

Press 'm' to enter move mode, use arrow keys to swap lines,
Enter/Escape to exit. Visual feedback mirrors ItemsEditor pattern
with blue diamond marker and [MOVE MODE] label.

* chore: update bun.lock

* Fix for model in statusLine JSON being passed as string vs object, closes #157, version bump

* fix(test): make GitWorktree execSync mock hoist-safe

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2026-02-02 16:03:52 -05:00
Matthew Breedlove d457e55913 Fix for model in statusLine JSON being passed as string vs object, closes #157, version bump 2026-02-02 14:32:33 -05:00
Matthew Breedlove 03d19692ab Lint fixes, version bump 2025-11-17 16:58:03 -05:00
Marty Martin 79a954c16e Add Claude Code Session ID widget (#111)
* feat: Add Claude Session ID widget

* Add ClaudeSessionIdWidget to widget registry, add label, support raw mode, fix cross-platform issues by using session ID directly from statusline JSON

---------

Co-authored-by: Marty <marty@croakingtoad.com>
Co-authored-by: Matthew Breedlove <matt@revenite.ai>
2025-11-17 16:56:59 -05:00
Matthew Breedlove bdcf6aac33 Version bump 2025-11-13 09:02:31 -05:00
Matthew Breedlove fa455a30e4 CLAUDE.md update, closes #108 2025-11-13 09:01:11 -05:00
Ethan Stark da325930ad Fix: Dynamic context window calculation for Sonnet 4.5 (1M tokens) (#107)
* Fix: Dynamic context window calculation for Sonnet 4.5 (1M tokens)

- Create model-context utility with dynamic context window mapping
- Sonnet 4.5 models now use 1M context (800k usable)
- Older models default to 200k context (160k usable)
- Update ContextPercentage and ContextPercentageUsable widgets
- Update renderer.ts flex mode calculations (2 locations)
- Add comprehensive test coverage (14 new tests, 36 total passing)

Fixes incorrect percentage display for Sonnet 4.5:
- Before: 42k tokens showed 21.0% (using 200k denominator)
- After: 42k tokens shows 4.2% (using 1M denominator)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Style: Apply ESLint auto-fixes for consistency

- Standardize indentation to 4 spaces
- Apply formatting rules from eslint.config.js
- No logic changes, formatting only

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Refactor: Code review fixes for context window changes

- Fix misleading test name (line 69 of context-percentage.test.ts)
- Strengthen model ID pattern matching (claude-sonnet-4-5 vs sonnet-4-5)
- Standardize export style (inline export for consistency)

All tests passing (19/19 for affected files)
TypeScript and ESLint checks passed

* Enhance CLAUDE.md: Update piped input example for Sonnet 4.5 and add test commands

- Changed piped input example to reflect the new model ID for Claude 4.5
- Added instructions for running tests and tests in watch mode
- Updated context window metrics description to include dynamic model-based context windows

Improves clarity and usability for developers working with the project.

* Fix: Require [1m] suffix for Sonnet 4.5 1M context window detection

Address PR feedback that Sonnet 4.5 only has 1M context on API usage
when the model ID includes the [1m] suffix (long context beta access).

Changes:
- Update getContextConfig() to check for [1m] suffix (case-insensitive)
- Models without suffix now safely default to 200K context
- Add comprehensive test coverage for suffix detection
- Update all test fixtures to use correct model ID format
- Document suffix requirement in CLAUDE.md

This ensures conservative behavior - only models with explicit [1m]
suffix receive 1M context allocation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-13 08:53:54 -05:00
Ramiro Rivera 8477dc5e6b REFactor: Simplify getBlockMetrics function and improve conditional checks in renderMultipleLines (#102) 2025-11-13 08:52:29 -05:00
Matthew Breedlove 929b004c22 Version bump 2025-10-07 23:39:27 -04:00
Ramiro Rivera 9f26a071a7 FEAT: Support CLAUDE_CONFIG_DIR (#99)
* chore: automatic change from linting

* feat: support CLAUDE_CONFIG_DIR on utilities

* feat: support CLAUDE_CONFIG_DIR from the main app UI

* fix: reduce duplication and handle installation using new constants

* feat: missed one place using hardcoded path

* chore: update CLAUDE.md

* docs: Update README
2025-10-07 23:36:13 -04:00
Matthew Breedlove 9feebf9a09 Version bump 2025-10-04 18:18:24 -04:00
Howard Zhang 1ad9304039 Fix bunx availability check on windows (#96) 2025-10-04 18:16:57 -04:00
Alexander Buyanov 055bf22308 Fix #97: context widgets showing 0% due to synthetic API error messages (#98)
Investigation revealed that Claude Code now appends synthetic error messages to transcript files when API errors occur (rate limits, authentication failures, etc.).
These synthetic messages have:
- model: "<synthetic>"
- isApiErrorMessage: true
- All token usage fields set to 0

The getTokenMetrics() function in src/utils/jsonl.ts calculates the current context length by finding the most recent main chain entry(isSidechain !== true) sorted by timestamp. When an API error occurs, the synthetic error message becomes the "most recent entry", causing contextLength to be calculated as 0.
2025-10-04 18:16:15 -04:00
Matthew Breedlove bb73dd63c0 Tabs -> Spaces 2025-10-03 15:37:41 -04:00
Matthew Breedlove 2053a5577d Version bump 2025-09-29 16:41:21 -04:00
Matthew Breedlove 8e6fcadbcd Merge branch 'NakiriYuuzu-bugfix/block-timer' 2025-09-29 16:39:07 -04:00
herrytsai 19a8724989 Fix Block-Timer not showing any values 2025-09-25 11:37:14 +08:00
Matthew Breedlove 36054e6b0b Version bump 2025-09-24 10:10:15 -04:00
Alexander Buyanov 7140c3faa5 Fix #88: Fix Block Timer showing incorrect elapsed time due to flawed block calculation (#90)
* revert 798b637

* Fix #88: Fix Block Timer showing incorrect elapsed time due to flawed block calculation

\## Problem

The Block Timer widget was displaying incorrect elapsed times when multiple work sessions occurred on the same day with gaps between them. For example, with work sessions at:
- Morning: 11:00-16:00 (first message at 11:42, last at 14:52)
- Evening: 17:00-22:00 (first message at 17:44)

At 20:10, the Block Timer showed **4hr 10m** instead of the expected **3hr 10m**.

\## Root Cause

The existing algorithm in `findMostRecentBlockStartTime()` had a fundamental flaw in how it calculated block start times:

1. It correctly identified continuous work by finding gaps ≥ 5 hours
2. It floored the continuous work start to the hour
3. **But then it used `Math.floor(totalWorkTime / sessionDurationMs)` to calculate the current block**

This division-based approach assumed continuous 5-hour blocks without gaps:
```
[Block 0: 11:00-16:00][Block 1: 16:00-21:00]...
```

In reality, blocks have gaps between them:
```
[Block 0: 11:00-16:00]--gap--[Block 1: 17:00-22:00]...
```

\### Incorrectly Identified Cases

The algorithm failed in scenarios where:
- **Multiple work sessions with small gaps** (< 5 hours): Treated them as one continuous session and calculated wrong block boundaries
- **Current time in a gap**: Could incorrectly show elapsed time from a previous block
- **Messages spanning multiple days**: Division logic ignored actual message timestamps and gaps

\### Example of the Bug

With messages at 11:42 and 17:44, gap of ~2h 52m:
1. No 5-hour gap found → continuous work from 11:00
2. At 20:10: `totalWorkTime = 9h 10m`
3. `completedBlocks = floor(9.17 / 5) = 1`
4. `blockStart = 11:00 + (1 × 5h) = 16:00`
5. Elapsed = 20:10 - 16:00 = **4h 10m** (wrong!)

Correct calculation should be:
- Block 1: 11:00-16:00 (ended)
- Gap: 16:00-17:44
- Block 2: 17:00-22:00 (current)
- Elapsed = 20:10 - 17:00 = **3h 10m** ✓

\## Solution

Replaced the flawed division-based calculation with a proper block-building algorithm:

1. **Find definitive boundary**: Look back for 5+ hour gap (or start of data)
2. **Build blocks forward from timestamps**:
- Sort timestamps chronologically
- For each timestamp after the boundary:
   - If no current block OR timestamp is after current block end: start new block (floor to hour, add 5h for end)
   - Otherwise: timestamp is within current block
3. **Find current block**: Check if `now` falls within any constructed block's timespan
4. **Return block start** if found and has activity, otherwise null

This correctly handles gaps of any size and builds the actual block structure from message timestamps.

\## Tests Added

Created comprehensive unit tests in `src/utils/__tests__/jsonl.test.ts` covering:

\### Real Scenarios
- **Morning and evening blocks with gap**: Messages at 11:42 and 17:44 → correctly identifies 17:00 block start at 20:10
- **Multiple messages in single block**: Messages within 5 hours → single block with floored start time
- **Multi-block scenario**: Messages at 00:13, 05:56, 06:01, 14:33, 20:01 → correctly identifies 20:00 block at 22:43

\### Edge Cases
- **Current time in gap between blocks**: Returns null (no active block)
- **No messages within 5 hours**: Returns null (session expired)
- **Block boundary timing**: Correctly handles time exactly at 5-hour boundary
- **5+ hour gap detection**: Properly detects boundary when gap ≥ 5 hours
- **Messages at exact hour boundaries**: Handles flooring when message is already at :00

\### Invalid Inputs
- **Empty content**: Returns null
- **Invalid JSON**: Returns null gracefully

All tests pass with the corrected algorithm, ensuring the Block Timer and Five Hour Moons widgets now display accurate elapsed times.

---------

Co-authored-by: alexander.buyanov <alexander.buyanov@konux.de>
2025-09-24 10:07:16 -04:00
Matthew Breedlove 5c4521d014 Version bump 2025-09-19 12:19:21 -04:00
Alexander Buyanov 798b637c58 Fix #86 Adjust block metrics gap threshold for short breaks (#87)
Detect block boundaries after 60 minutes of inactivity instead of requiring a 5-hour gap.
This prevents block start times from carrying over into short midday breaks and ensures elapsed time matches ccusage.

Co-authored-by: alexander.buyanov <alexander.buyanov@konux.de>
2025-09-19 12:18:48 -04:00
Matthew Breedlove d9ee110fc7 Version bump and README update 2025-09-19 01:30:23 -04:00
MIAO Jishuai d23af975b2 feat: Add fish-style path abbreviation to CurrentWorkingDir widget (#80)
* feat: Add fish-style path abbreviation to CurrentWorkingDir widget

- Added new toggle option for fish-style path abbreviation
- Keeps home directory as ~, first and last directories complete
- Abbreviates middle directories to first letter only
- Preserves dot for hidden directories (e.g. .config → .c)
- Maintains backward compatibility with segments feature
- Both options are mutually exclusive (fish-style takes precedence)

Example: ~/Documents/Projects/my-project → ~/D/P/my-project

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* Modify cwd widget fish-style code to use handleEditorAction instead of renderEditor, misc lint fixes

---------

Co-authored-by: Jishuai <aqianlikuaizaifeng@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2025-09-19 01:25:16 -04:00
Matthew Breedlove 0a5f0e58b9 Fix for menu item description text on main menu 2025-09-18 21:31:46 -04:00
Matthew Breedlove bf269e1f63 Version bump and README update 2025-09-18 21:27:21 -04:00
Alexander Buyanov 3010addb70 Fix #84: Block timer should reset after idle gap (#85)
Block windows were staying “active” after multiple hours with no token usage, so the block timer kept counting even though ccusage reported the session closed.
Aligning the activity detection with actual token usage prevents those phantom sessions and keeps the timer in sync with the upstream report.

Co-authored-by: alexander.buyanov <alexander.buyanov@konux.de>
2025-09-18 21:25:53 -04:00
Matthew Breedlove 2842f856e9 Version bump and README update 2025-09-17 22:38:54 -04:00
Jake Nelson 2ecaf023b7 feat: add remaining mode to context % widgets (#83) 2025-09-17 22:34:41 -04:00
Nuno Salvação 3bc8e225f4 Update documentation with Windows-specific details (#82) 2025-09-16 01:41:49 -04:00
Alex Newman 7bb53d11e5 TypeDoc Docs (#75)
* Initial plan

* Add TypeDoc documentation generation system

Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com>
2025-09-11 17:12:34 -04:00
Matthew Breedlove b36ac6c202 Fix SessionClock to show '0m' if we don't have a calculated session duration (empty jsonl) 2025-09-11 12:49:10 -04:00
Matthew Breedlove 5942afe563 Version bump 2025-09-09 00:00:50 -04:00
Matthew Breedlove c52e4d7042 Merge branch 'heromantf-fix/fix-win-cwd-segment' 2025-09-08 23:51:27 -04:00
Matthew Breedlove ab198be3c0 Merge branch 'MuserQuantity-fix/separator-rendering-logic' 2025-09-08 23:49:22 -04:00
Her0mAn 5b351548a4 Fix cwd segment setting not working as expected on Windows 2025-09-08 22:47:02 +08:00
muserquantity 66214c950c fix: improve separator rendering logic to look backwards for content
- Enhanced separator rendering to check all previous widgets for actual content
- Prevents separators from being skipped when there are widgets that don't render but others before them do
- Improves status line layout consistency by properly detecting content presence
2025-09-07 18:50:01 +08:00
Matthew Breedlove c106958f36 License fix file 2025-09-01 17:14:29 -04:00
Matthew Breedlove 679bdac86e Version bump and README update 2025-08-31 03:04:15 -04:00
Matthew Breedlove 0ee9f7f7e2 Fix for merged items when powerline mode is active with auto-alignment toggled on 2025-08-31 03:00:44 -04:00
Matthew Breedlove 17b2576cca Support emoji input for custom text 2025-08-31 02:47:02 -04:00
Matthew Breedlove dade396de1 Version bump and README update 2025-08-28 09:37:13 -04:00
Jack Allen d5b2da2a47 chore(settings): support more than 3 lines (#53)
* chore(settings): support more than 3 lines
* chore: add confirm dialog
* chore: update example script
* chore: rm config override
* Fix spacing issues, disallow deletion of last line, fix issue where deleting lines 2 and 3 would keep them in the list, cleanup onDelete and onAppend method locations
* Initialize default settings with two empty lines

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2025-08-28 09:30:26 -04:00
Matthew Breedlove 9e97e003d3 Merge commit '6cd32a5b82cd5ed27e698860f5ca8421a94548d9'. Closes #52 2025-08-27 14:35:02 -04:00
Jack Allen 6cd32a5b82 chore(no-git): Hide separators where the previous widget returns null 2025-08-27 17:19:32 +01:00
Matthew Breedlove 698bdb7f1a Updating version and README for 2.0.10 2025-08-27 11:08:48 -04:00
Matthew Breedlove 0a81688010 Added toggle to hide 'no git' message if not in a git repository. Fixes #48 2025-08-27 11:03:05 -04:00
Matthew Breedlove 8b5f6c7fb6 Fix for unicode characters messing with powerline auto-alignment width calculation 2025-08-27 10:45:51 -04:00
Jack Allen 7886e1f376 feat(git-worktree): Add git worktree widget (#49)
* feat(git-worktree): Add git worktree widget

* Minor cleanup

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
2025-08-27 10:22:43 -04:00
Matthew Breedlove 8c89b0271e Merge branch 'jackall3n-chore/use-bun-patch' 2025-08-27 09:10:21 -04:00
Jack Allen 9354dbc9a3 chore(patch-package): Migrate to using bun patch, instead of patch-package 2025-08-27 13:16:47 +01:00
Matthew Breedlove 9767fc6399 Gracefully handle unknown widgets in the settings.json in both the TUI and statusline rendering 2025-08-27 01:03:09 -04:00
Matthew Breedlove 077dc0f5eb Imports cleanup 2025-08-26 23:32:47 -04:00
Matthew Breedlove 19d739c7fd Add ClaudeLog badge to README 2025-08-25 03:52:37 -04:00
Matthew Breedlove c5d9243174 Merge pull request #46 from ProCreations-Official/patch-1
Update README.md to add username in setup command
2025-08-24 14:11:56 -04:00
ProCreations-Official 015c1f0b1c Update README.md 2025-08-24 14:07:46 -04:00
235 changed files with 25774 additions and 2978 deletions
+1
View File
@@ -0,0 +1 @@
buy_me_a_coffee: sirmalloc
+32
View File
@@ -0,0 +1,32 @@
version: 2
updates:
- package-ecosystem: bun
directory: /
schedule:
interval: weekly
day: monday
ignore:
- dependency-name: "ink"
open-pull-requests-limit: 10
cooldown:
semver-major-days: 30
semver-minor-days: 7
semver-patch-days: 3
groups:
dev-dependencies:
dependency-type: development
update-types:
- minor
- patch
production-dependencies:
dependency-type: production
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
+36
View File
@@ -0,0 +1,36 @@
name: CI
on:
push:
pull_request:
branches: [main]
jobs:
lint:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun run lint
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun test
build:
name: Build
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun run build
- run: test -f dist/ccstatusline.js
+1
View File
@@ -4,6 +4,7 @@ node_modules
# output
out
dist
typedoc
*.tgz
# code coverage
+1
View File
@@ -1,5 +1,6 @@
# Source files
src/
scripts/
*.ts
*.tsx
+9 -1
View File
@@ -16,5 +16,13 @@
},
"[javascript]": {
"files.trimTrailingWhitespace": true
}
},
"cSpell.words": [
"ccstatusline",
"Powerline",
"statusline",
"sublabel",
"Worktree",
"worktrees"
]
}
+151
View File
@@ -0,0 +1,151 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
ccstatusline is a customizable status line formatter for Claude Code CLI that displays model info, git branch, token usage, and other metrics. It functions as both:
1. A piped command processor for Claude Code status lines
2. An interactive TUI configuration tool when run without input
## Development Commands
```bash
# Install dependencies
bun install
# Run in interactive TUI mode
bun run start
# Test with piped input (use [1m] suffix for 1M context models)
echo '{"model":{"id":"claude-sonnet-4-5-20250929[1m]"},"transcript_path":"test.jsonl"}' | bun run src/ccstatusline.ts
# Or use example payload
bun run example
# Build for npm distribution
bun run build # Creates dist/ccstatusline.js with Node.js 14+ compatibility
# Run tests
bun test
# Run tests in watch mode
bun test --watch
# Lint and type check
bun run lint # Runs TypeScript type checking and ESLint without modifying files
# Apply ESLint auto-fixes intentionally
bun run lint:fix
```
## Architecture
The project has dual runtime compatibility - works with both Bun and Node.js:
### Core Structure
- **src/ccstatusline.ts**: Main entry point that detects piped vs interactive mode
- Piped mode: Parses JSON from stdin and renders formatted status line
- Interactive mode: Launches React/Ink TUI for configuration
### TUI Components (src/tui/)
- **index.tsx**: Main TUI entry point that handles React/Ink initialization
- **App.tsx**: Root component managing navigation and state
- **components/**: Modular UI components for different configuration screens
- MainMenu, LineSelector, ItemsEditor, ColorMenu, GlobalOverridesMenu
- PowerlineSetup, TerminalOptionsMenu, StatusLinePreview
### Utilities (src/utils/)
- **config.ts**: Settings management
- Loads from `~/.config/ccstatusline/settings.json`
- Handles migration from old settings format
- Default configuration if no settings exist
- **renderer.ts**: Core rendering logic for status lines
- Handles terminal width detection and truncation
- Applies colors, padding, and separators
- Manages flex separator expansion
- **powerline.ts**: Powerline font detection and installation
- **claude-settings.ts**: Integration with Claude Code settings.json
- Respects `CLAUDE_CONFIG_DIR` environment variable with fallback to `~/.claude`
- Provides installation command constants (NPM, BUNX, self-managed)
- Detects installation status and manages settings.json updates
- Validates config directory paths with proper error handling
- **colors.ts**: Color definitions and ANSI code mapping
- **model-context.ts**: Model-to-context-window mapping
- Maps model IDs to their context window sizes based on [1m] suffix
- Sonnet 4.5 WITH [1m] suffix: 1M tokens (800k usable at 80%) - requires long context beta access
- Sonnet 4.5 WITHOUT [1m] suffix: 200k tokens (160k usable at 80%)
- Legacy models: 200k tokens (160k usable at 80%)
### Widgets (src/widgets/)
Custom widgets implementing the Widget interface defined in src/types/Widget.ts:
**Widget Interface:**
All widgets must implement:
- `getDefaultColor()`: Default color for the widget
- `getDescription()`: Description shown in TUI
- `getDisplayName()`: Display name shown in TUI
- `getEditorDisplay()`: How the widget appears in the editor
- `render()`: Core rendering logic that produces the widget output
- `supportsRawValue()`: Whether widget supports raw value mode
- `supportsColors()`: Whether widget supports color customization
- Optional: `renderEditor()`, `getCustomKeybinds()`, `handleEditorAction()`
**Widget Registry Pattern:**
- Located in src/utils/widgets.ts
- Uses a Map-based registry (`widgetRegistry`) that maps widget type strings to widget instances
- `getWidget(type)`: Retrieves widget instance by type
- `getAllWidgetTypes()`: Returns all available widget types
- `isKnownWidgetType()`: Validates if a type is registered
**Available Widgets:**
- Model, Version, OutputStyle - Claude Code metadata display
- GitBranch, GitChanges, GitInsertions, GitDeletions, GitWorktree - Git repository status
- TokensInput, TokensOutput, TokensCached, TokensTotal - Token usage metrics
- ContextLength, ContextPercentage, ContextPercentageUsable - Context window metrics (uses dynamic model-based context windows: 1M for Sonnet 4.5 with [1m] suffix, 200k for all other models)
- BlockTimer, SessionClock, SessionCost - Time and cost tracking
- CurrentWorkingDir, TerminalWidth - Environment info
- CustomText, CustomCommand - User-defined widgets
## Key Implementation Details
- **Cross-platform stdin reading**: Detects Bun vs Node.js environment and uses appropriate stdin API
- **Token metrics**: Parses Claude Code transcript files (JSONL format) to calculate token usage
- **Git integration**: Uses child_process.execSync to get current branch and changes
- **Terminal width management**: Three modes for handling width (full, full-minus-40, full-until-compact)
- **Flex separators**: Special separator type that expands to fill available space
- **Powerline mode**: Optional Powerline-style rendering with arrow separators
- **Custom commands**: Execute shell commands and display output in status line
- **Mergeable items**: Items can be merged together with or without padding
## Bun Usage Preferences
Default to using Bun instead of Node.js:
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun install` instead of `npm install`
- Use `bun run <script>` instead of `npm run <script>`
- Use `bun build` with appropriate options for building
- Bun automatically loads .env, so don't use dotenv
## Important Notes
- **ink@6.2.0 patch**: The project uses a patch for ink@6.2.0 to fix backspace key handling on macOS
- Issue: ink treats `\x7f` (backspace on macOS) as delete key instead of backspace
- Fix: Patches `build/parse-keypress.js` to correctly map `\x7f` to backspace
- Applied automatically during `bun install` via `patchedDependencies` in package.json
- Patch file: `patches/ink@6.2.0.patch`
- **Build process**: Two-step build using `bun run build`
1. `bun build`: Bundles src/ccstatusline.ts into dist/ccstatusline.js targeting Node.js 14+
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**: 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)
- Context percentage calculations (src/utils/__tests__/context-percentage.test.ts)
- JSONL transcript parsing (src/utils/__tests__/jsonl.test.ts)
- Widget rendering (src/widgets/__tests__/*.test.ts)
- Run tests with `bun test` or `bun test --watch` for watch mode
- Test configuration: vitest.config.ts
- Manual testing also available via piped input and TUI interaction
+6
View File
@@ -0,0 +1,6 @@
Matthew Breedlove <https://github.com/sirmalloc>
---
Original author: Matthew Breedlove (https://github.com/sirmalloc)
Official repository: https://github.com/sirmalloc/ccstatusline
-99
View File
@@ -1,99 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
ccstatusline is a customizable status line formatter for Claude Code CLI that displays model info, git branch, token usage, and other metrics. It functions as both:
1. A piped command processor for Claude Code status lines
2. An interactive TUI configuration tool when run without input
## Development Commands
```bash
# Install dependencies
bun install
# Run with patch (TUI mode)
bun run start
# Run directly (TUI mode)
bun run statusline
# Test with piped input
echo '{"model":{"display_name":"Claude 3.5 Sonnet"},"transcript_path":"test.jsonl"}' | bun run src/ccstatusline.ts
# Build for npm distribution
bun run build # Creates dist/ccstatusline.js with Node.js 14+ compatibility
# Lint and type check
bun run lint # Runs TypeScript type checking and ESLint with auto-fix
```
## Architecture
The project has dual runtime compatibility - works with both Bun and Node.js:
### Core Structure
- **src/ccstatusline.ts**: Main entry point that detects piped vs interactive mode
- Piped mode: Parses JSON from stdin and renders formatted status line
- Interactive mode: Launches React/Ink TUI for configuration
### TUI Components (src/tui/)
- **index.tsx**: Main TUI entry point that handles React/Ink initialization
- **App.tsx**: Root component managing navigation and state
- **components/**: Modular UI components for different configuration screens
- MainMenu, LineSelector, ItemsEditor, ColorMenu, GlobalOverridesMenu
- PowerlineSetup, TerminalOptionsMenu, StatusLinePreview
### Utilities (src/utils/)
- **config.ts**: Settings management
- Loads from `~/.config/ccstatusline/settings.json`
- Handles migration from old settings format
- Default configuration if no settings exist
- **renderer.ts**: Core rendering logic for status lines
- Handles terminal width detection and truncation
- Applies colors, padding, and separators
- Manages flex separator expansion
- **powerline.ts**: Powerline font detection and installation
- **claude-settings.ts**: Integration with Claude Code settings.json
- **colors.ts**: Color definitions and ANSI code mapping
### Widgets (src/widgets/)
Custom widgets implementing the StatusItemWidget interface:
- Model, Version, OutputStyle - Claude Code metadata display
- GitBranch, GitChanges - Git repository status
- TokensInput, TokensOutput, TokensCached, TokensTotal - Token usage metrics
- ContextLength, ContextPercentage, ContextPercentageUsable - Context window metrics
- BlockTimer, SessionClock - Time tracking
- CurrentWorkingDir, TerminalWidth - Environment info
## Key Implementation Details
- **Cross-platform stdin reading**: Detects Bun vs Node.js environment and uses appropriate stdin API
- **Token metrics**: Parses Claude Code transcript files (JSONL format) to calculate token usage
- **Git integration**: Uses child_process.execSync to get current branch and changes
- **Terminal width management**: Three modes for handling width (full, full-minus-40, full-until-compact)
- **Flex separators**: Special separator type that expands to fill available space
- **Powerline mode**: Optional Powerline-style rendering with arrow separators
- **Custom commands**: Execute shell commands and display output in status line
- **Mergeable items**: Items can be merged together with or without padding
## Bun Usage Preferences
Default to using Bun instead of Node.js:
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun install` instead of `npm install`
- Use `bun run <script>` instead of `npm run <script>`
- Use `bun build` with appropriate options for building
- Bun automatically loads .env, so don't use dotenv
## Important Notes
- **patch-package**: The project uses patch-package to fix ink-gradient compatibility. Always run `bun run patch` before starting development
- **ESLint configuration**: Uses flat config format (eslint.config.js) with TypeScript and React plugins
- **Build target**: When building for distribution, target Node.js 14+ for maximum compatibility
- **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
- **Lint rules**: Never disable a lint rule via a comment, no matter how benign the lint warning or error may seem
- **Testing**: No test framework is currently configured. Manual testing is done via piped input and TUI interaction
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+1 -5
View File
@@ -18,8 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Original author: Matthew Breedlove (https://github.com/sirmalloc)
Official repository: https://github.com/sirmalloc/ccstatusline
SOFTWARE.
+142 -219
View File
@@ -22,26 +22,112 @@
[![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/sirmalloc/ccstatusline/graphs/commit-activity)
[![Mentioned in Awesome Claude Code](https://awesome.re/mentioned-badge.svg)](https://github.com/hesreallyhim/awesome-claude-code)
[![ClaudeLog - A comprehensive knowledge base for Claude](https://claudelog.com/img/claude_log_badge.svg)](https://claudelog.com/)
![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)
- [Usage](#-usage)
- [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.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.
- **📊 More accurate counts (v2.1.0)** - Usage/context widgets now use new statusline JSON metrics when available for more accurate token and context counts.
- **🪟 Windows empty file bug fix (v2.1.1)** - Fixed a Windows issue that could create an empty `c:\dev\null` file.
- **🔗 New Link widget (v2.1.3)** - Added a new **Link** widget with clickable OSC8 rendering, preview parity, and raw mode support.
- ** New Git Insertions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted insertions (e.g., `+42`).
- ** New Git Deletions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted deletions (e.g., `-10`).
- **🧠 Context format fallback fix (v2.1.6)** - When `context_window_size` is missing, context widgets now infer 1M models from long-context labels such as `[1m]` and `1M context` in model identifiers.
- **⏳ Weekly reset timer split (v2.1.7)** - Added a separate `Weekly Reset Timer` widget.
- **⚙️ Custom config file flag (v2.1.8)** - Added `--config <path>` support so ccstatusline can load/save settings from a custom file location.
- **🔣 Unicode separator hex input upgrade (v2.1.9)** - Powerline separator hex input now supports 4-6 digits (full Unicode code points up to `U+10FFFF`).
- **🌳 Bare repo worktree detection fix (v2.1.10)** - `Git Worktree` now correctly detects linked worktrees created from bare repositories.
### v2.0.26 - v2.0.29 - Performance, git internals, and workflow improvements
- **🧠 Memory Usage widget (v2.0.29)** - Added a new widget that shows current system memory usage (`Mem: used/total`).
- **⚡ Block timer cache (v2.0.28)** - Cache block timer metrics to reduce JSONL parsing on every render, with per-config hashed cache files and automatic 5-hour block invalidation.
- **🧱 Git widget command refactor (v2.0.28)** - Refactored git widgets to use shared git command helpers and expanded coverage for failure and edge-case tests.
- **🪟 Windows UTF-8 piped output fix (v2.0.28)** - Sets the Windows UTF-8 code page for piped status line rendering.
- **📁 Git Root Dir widget (v2.0.27)** - Added a new Git widget that shows the repository root directory name.
- **🏷️ Session Name widget (v2.0.26)** - Added a new widget that shows the current Claude Code session name from `/rename`.
- **🏠 Current Working Directory home abbreviation (v2.0.26)** - Added a `~` abbreviation option for CWD display in both preview and live rendering.
- **🧠 Context model suffix fix (v2.0.26)** - Context widgets now recognize the `[1m]` suffix across models, not just a single model path.
- **🧭 Widget picker UX updates (v2.0.26)** - Improved widget discovery/navigation and added clearer, safer clear-line behavior.
- **⌨️ TUI editor input fix (v2.0.26)** - Prevented shortcut/input leakage into widget editor flows.
- **📄 Repo docs update (v2.0.26)** - Migrated guidance from `CLAUDE.md` to `AGENTS.md` (with symlink compatibility).
### v2.0.16 - Add fish style path abbreviation toggle to Current Working Directory widget
### v2.0.15 - Block Timer calculation fixes
- Fix miscalculation in the block timer
### v2.0.14 - Add remaining mode toggle to Context Percentage widgets
- **Remaining Mode** - You can now toggle the Context Percentage widgets between usage percentage and remaining percentage when configuring them in the TUI by pressing the 'u' key.
### v2.0.12 - Custom Text widget now supports emojis
- **👾 Emoji Support** - You can now paste emoji into the custom text widget. You can also turn on the merge option to get emoji labels for your widgets like this:
![Emoji Support](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/emojiSupport.png)
### v2.0.11 - Unlimited Status Lines
- **🚀 No Line Limit** - Configure as many status lines as you need - the 3-line limitation has been removed
### v2.0.10 - Git Updates
- **🌳 Git Worktree widget** - Shows the active worktree name when working with git worktrees
- **👻 Hide 'no git' message toggle** - Git widgets now support hiding the 'no git' message when not in a repository (toggle with 'h' key while editing the widget)
### v2.0.8 - Powerline Auto-Alignment
![Powerline Auto-Alignment](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/autoAlign.png)
@@ -85,21 +171,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
- **🎨 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 up to 3 independent status lines
- **📐 Multi-line Support** - Configure multiple independent status lines
- **🖥️ Interactive TUI** - Built-in configuration interface using React/Ink
- **⚙️ Global Options** - Apply consistent formatting across all widgets (padding, separators, bold, background)
- **🔎 Fast Widget Picker** - Add/change widgets by category with search and ranked matching
- **⚙️ 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
@@ -107,16 +205,18 @@
```bash
# Run the configuration TUI with npm
npx ccstatusline@latest
npx -y ccstatusline@latest
# Or with Bun (faster)
bunx 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 up to 3 separate status lines
- Configure multiple separate status lines
- Add/remove/reorder status line widgets
- Customize colors for each widget
- Configure flex separator behavior
@@ -126,220 +226,39 @@ The interactive configuration tool provides a terminal UI where you can:
> 💡 **Tip:** Your settings are automatically saved to `~/.config/ccstatusline/settings.json`
---
> 🔧 **Custom Claude Config:** If your Claude Code configuration is in a non-standard location, set the `CLAUDE_CONFIG_DIR` environment variable:
> ```bash
> # Linux/macOS
> export CLAUDE_CONFIG_DIR=/custom/path/to/.claude
> ```
## 📖 Usage
> 🌐 **Usage API proxy:** Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic.
Once configured, ccstatusline automatically formats your Claude Code status line. The status line appears at the bottom of your terminal during Claude Code sessions.
### 📊 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")
- **Session Clock** - Shows elapsed time since session start (e.g., "2hr 15m")
- **Session Cost** - Shows total session cost in USD (e.g., "$1.23")
- **Block Timer** - Shows time elapsed in current 5-hour block or progress bar
- **Current Working Directory** - Shows current working directory with configurable path segments
- **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 (out of 200k)
- **Context Percentage (usable)** - Shows percentage of usable context (out of 160k, accounting for auto-compact at 80%)
- **Terminal Width** - Shows detected terminal width (for debugging)
- **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)
- **Separator** - Visual divider between widgets (customizable: |, -, comma, space)
- **Flex Separator** - Expands to fill available space
---
### 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
> 🪟 **Windows Support:** PowerShell examples, installation notes, fonts, troubleshooting, WSL, and Windows Terminal configuration are in [docs/WINDOWS.md](docs/WINDOWS.md).
</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.
<details>
<summary><b>Claude Code settings.json format</b></summary>
> ⚠️ **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.
When you install from the TUI, ccstatusline writes a `statusLine` command object to your Claude Code settings:
### ⏱️ 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`
---
### 🔧 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)
- 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.
---
### 🔗 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.
---
## 🛠️ Development
### Prerequisites
- [Bun](https://bun.sh) (v1.0+)
- Git
- Node.js 18+ (optional, for npm publishing)
### Setup
```bash
# Clone the repository
git clone https://github.com/yourusername/ccstatusline.git
cd ccstatusline
# Install dependencies
bun install
```json
{
"statusLine": {
"type": "command",
"command": "npx -y ccstatusline@latest",
"padding": 0
}
}
```
### Development Commands
Other supported command values are:
- `bunx -y ccstatusline@latest`
- `ccstatusline` (for self-managed/global installs)
```bash
# Run in TUI mode (configuration)
bun run src/ccstatusline.ts
</details>
# Build for distribution
bun run build
```
### 📁 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
│ └── 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.
@@ -350,13 +269,18 @@ 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
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
@@ -364,14 +288,13 @@ Contributions are welcome! Please feel free to submit a Pull Request.
- 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
@@ -379,7 +302,7 @@ Contributions are welcome! Please feel free to submit a Pull Request.
- Powered by [Ink](https://github.com/vadimdemedes/ink) for the terminal UI
- Made with ❤️ for the Claude Code community
---
<br />
## Star History
+348 -187
View File
@@ -1,30 +1,36 @@
{
"lockfileVersion": 1,
"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-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"ink": "^6.2.0",
"ink-gradient": "^3.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.3.0",
"https-proxy-agent": "^7.0.0",
"ink": "6.2.0",
"ink-gradient": "^4.0.0",
"ink-select-input": "^6.2.0",
"patch-package": "^8.0.0",
"pluralize": "^8.0.0",
"react": "^19.1.1",
"react-devtools-core": "^6.1.5",
"react-devtools-core": "^7.0.1",
"strip-ansi": "^7.1.0",
"tinyglobby": "^0.2.14",
"typedoc": "^0.28.12",
"typescript": "^5.9.2",
"typescript-eslint": "^8.39.1",
"vitest": "^4.0.18",
"zod": "^4.0.17",
},
},
@@ -32,90 +38,189 @@
"trustedDependencies": [
"unrs-resolver",
],
"patchedDependencies": {
"ink@6.2.0": "patches/ink@6.2.0.patch",
},
"packages": {
"@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="],
"@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=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.3.1", "", {}, "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@eslint/core": ["@eslint/core@0.15.2", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@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=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@eslint/js": ["@eslint/js@9.33.0", "", {}, "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="],
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@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.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
"@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.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.23.3", "", { "dependencies": { "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.5.3", "", { "dependencies": { "@eslint/core": "^1.1.1" } }, "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw=="],
"@eslint/core": ["@eslint/core@1.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ=="],
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.3", "", {}, "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.1", "", { "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ=="],
"@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=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@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=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q=="],
"@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=="],
"@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=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.9", "", { "os": "none", "cpu": "arm64" }, "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog=="],
"@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=="],
"@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=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "x64" }, "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="],
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.2.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/types": "^8.38.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-oY7GVkJGVMI5benlBDCaRrSC1qPasafyv5dOBLLv5MTilMGnErKhO6ziEfodDDIZbo5QxPUNW360VudJOFODMw=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
"@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
"@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="],
"@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
"@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@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.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@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=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@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/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="],
"@types/pluralize": ["@types/pluralize@0.0.33", "", {}, "sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/tinycolor2": ["@types/tinycolor2@1.4.6", "", {}, "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.39.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/type-utils": "8.39.1", "@typescript-eslint/utils": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.39.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.39.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.39.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.39.1", "@typescript-eslint/types": "^8.39.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1" } }, "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.2", "@typescript-eslint/types": "^8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.39.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2" } }, "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/utils": "8.39.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.39.1", "", {}, "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/utils": "8.57.2", "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-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.39.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.39.1", "@typescript-eslint/tsconfig-utils": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.2", "@typescript-eslint/tsconfig-utils": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "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-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
@@ -155,19 +260,33 @@
"@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=="],
"@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="],
"@vitest/expect": ["@vitest/expect@4.1.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"@vitest/mocker": ["@vitest/mocker@4.1.2", "", { "dependencies": { "@vitest/spy": "4.1.2", "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-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="],
"@vitest/runner": ["@vitest/runner@4.1.2", "", { "dependencies": { "@vitest/utils": "4.1.2", "pathe": "^2.0.3" } }, "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A=="],
"@vitest/spy": ["@vitest/spy@4.1.2", "", {}, "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA=="],
"@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="],
"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@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="],
"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-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -187,21 +306,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=="],
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="],
"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=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="],
"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.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"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=="],
@@ -209,11 +330,11 @@
"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.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="],
"chalk": ["chalk@5.5.0", "", {}, "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
@@ -223,17 +344,15 @@
"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=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
@@ -241,7 +360,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=="],
@@ -249,21 +368,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=="],
"emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
"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@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=="],
@@ -273,11 +400,13 @@
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
"es-toolkit": ["es-toolkit@1.39.8", "", {}, "sha512-A8QO9TfF+rltS8BXpdu8OS+rpGgEdnRhqIVxO/ZmNvnXBYgOdSsxukT55ELyP94gZIntWJ+Li9QRrT2u1Kitpg=="],
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.33.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.33.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "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-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA=="],
"eslint": ["eslint@10.1.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.3", "@eslint/core": "^1.1.1", "@eslint/plugin-kit": "^0.6.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-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA=="],
"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=="],
@@ -289,57 +418,51 @@
"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.0", "", { "peerDependencies": { "eslint": ">=6.0.0" }, "bin": { "import-linter": "lib/index.js" } }, "sha512-+Cz1x2xBLtI9gJbmuYEpvY7F8K75wskBmJ7rk4VRObIJo+jklUJaejFJgtnWeL0dCFWabGEkhausrikXaNbtoQ=="],
"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-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@5.2.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-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
"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-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=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="],
"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=="],
"fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -347,7 +470,11 @@
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
"get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"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.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=="],
@@ -355,28 +482,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=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"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.3.0", "", {}, "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw=="],
"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=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"gradient-string": ["gradient-string@2.0.2", "", { "dependencies": { "chalk": "^4.1.2", "tinygradient": "^1.1.5" } }, "sha512-rEDCuqUQ4tbD78TpzsMtt5OIf0cBCSDWSJtUDaF6JsAh+k0v9r++NzxNEG87oDZx9ZwGhD8DaezR2L/yrw0Jdw=="],
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
"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=="],
@@ -387,21 +506,21 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"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=="],
@@ -425,15 +544,13 @@
"is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
"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=="],
@@ -443,8 +560,6 @@
"is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
@@ -467,8 +582,6 @@
"is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@@ -477,54 +590,82 @@
"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=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify": ["json-stable-stringify@1.3.0", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" } }, "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg=="],
"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=="],
"jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="],
"jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="],
"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=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="],
"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=="],
"lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="],
"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.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=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
"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.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"napi-postinstall": ["napi-postinstall@0.3.3", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"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=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
@@ -541,51 +682,49 @@
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="],
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"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=="],
"patch-package": ["patch-package@8.0.0", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^9.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "rimraf": "^2.6.3", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.0.33", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"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=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="],
"react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
@@ -595,19 +734,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=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"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=="],
@@ -639,16 +772,22 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="],
"slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"slice-ansi": ["slice-ansi@7.1.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="],
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"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=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -663,29 +802,27 @@
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
"strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"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=="],
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinygradient": ["tinygradient@1.1.5", "", { "dependencies": { "@types/tinycolor2": "^1.4.0", "tinycolor2": "^1.0.0" } }, "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw=="],
"tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"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.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
"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=="],
@@ -703,20 +840,28 @@
"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=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"typedoc": ["typedoc@0.28.18", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.1.1", "minimatch": "^10.2.4", "yaml": "^2.8.2" }, "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-NTWTUOFRQ9+SGKKTuWKUioUkjxNwtS3JDRPVKZAXGHZy2wCA8bdv2iJiyeePn0xkmK+TCCqZFT0X7+2+FLjngA=="],
"typescript-eslint": ["typescript-eslint@8.39.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.39.1", "@typescript-eslint/parser": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/utils": "8.39.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-GDUv6/NDYngUlNvwaHM1RamYftxf782IyEDbdj3SeaIHHv8fNQVRC++fITT7kUJV/5rIA/tkoRSSskt6osEfqg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.57.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.2", "@typescript-eslint/parser": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/utils": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A=="],
"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=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"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=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"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.1.2", "", { "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", "@vitest/pretty-format": "4.1.2", "@vitest/runner": "4.1.2", "@vitest/snapshot": "4.1.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "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.2", "@vitest/browser-preview": "4.1.2", "@vitest/browser-webdriverio": "4.1.2", "@vitest/ui": "4.1.2", "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-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -725,74 +870,90 @@
"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=="],
"widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="],
"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=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"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=="],
"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=="],
"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.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"zod": ["zod@4.0.17", "", {}, "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"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=="],
"@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=="],
"@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/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="],
"@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="],
"@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="],
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="],
"@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.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="],
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="],
"@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-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=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"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-react/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"patch-package/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"patch-package/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"is-bun-module/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"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=="],
"react-reconciler/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.0.0", "", { "dependencies": { "get-east-asian-width": "^1.0.0" } }, "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA=="],
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"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=="],
"patch-package/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
}
}
+131
View File
@@ -0,0 +1,131 @@
# 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)
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
```
+224
View File
@@ -0,0 +1,224 @@
# 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.
- **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.
- **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 details for the current branch with optional links.
- **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 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 %** / **Context % (usable)** / **Context Bar** - Show model context size, usage percentage, usable-window percentage, or a progress bar.
- **Session Usage** / **Weekly Usage** / **Block Timer** / **Block Reset Timer** / **Weekly Reset Timer** - Show usage percentages plus current block/reset timing.
### 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
<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.
## 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
- `a` add widget via the picker
- `i` insert widget via the picker
- `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 GitHub branch links
- **Git Root Dir**: `l` cycle IDE links (`off``VS Code``Cursor`)
- **Git PR**: `h` hide empty/no-PR output, `s` toggle review status, `t` toggle title
- **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
- **Session Usage / Weekly Usage**: `p` cycle percentage/full bar/short bar, `v` invert fill in progress mode
- **Block Timer / Block Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time, `v` invert fill in progress mode
- **Weekly Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time, `h` toggle hours-only, `v` invert fill in progress mode
- **Context Bar**: `p` toggle full-width vs short progress bar
- **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.
+193
View File
@@ -0,0 +1,193 @@
# 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
}
}
```
**For npm users:**
```json
{
"statusLine": {
"type": "command",
"command": "npx -y ccstatusline@latest",
"padding": 0
}
}
```
## 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
+20 -32
View File
@@ -7,6 +7,23 @@ import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import globals from 'globals';
const importResolverSettings = {
'import/resolver': {
typescript: {
project: ['./tsconfig.json'],
alwaysTryTypes: true,
noWarnOnMultipleProjects: true
},
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
}
},
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx']
},
'import/external-module-folders': ['node_modules', 'node_modules/@types']
};
export default ts.config([
{
files: ['**/*.ts', '**/*.tsx'],
@@ -38,21 +55,7 @@ export default ts.config([
}
},
settings: {
'import/resolver': {
typescript: {
project: ['./tsconfig.json'],
alwaysTryTypes: true,
noWarnOnMultipleProjects: true
},
parcel2: {},
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
}
},
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx']
},
'import/external-module-folders': ['node_modules', 'node_modules/@types']
...importResolverSettings
},
rules: {
'no-control-regex': 'off', // We intentionally match ANSI escape sequences
@@ -119,22 +122,7 @@ export default ts.config([
'react-hooks': reactHooksPlugin
},
settings: {
...{
'import/resolver': {
typescript: {
project: ['./tsconfig.json'],
alwaysTryTypes: true,
noWarnOnMultipleProjects: true
},
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
}
},
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx']
},
'import/external-module-folders': ['node_modules', 'node_modules/@types']
},
...importResolverSettings,
react: {
version: 'detect'
}
@@ -156,4 +144,4 @@ export default ts.config([
'!eslint.config.js'
]
}
]);
]);
+25 -15
View File
@@ -1,6 +1,6 @@
{
"name": "ccstatusline",
"version": "2.0.9",
"version": "2.2.8",
"description": "A customizable status line formatter for Claude Code CLI",
"module": "src/ccstatusline.ts",
"type": "module",
@@ -11,36 +11,43 @@
"dist/"
],
"scripts": {
"start": "bun run patch && bun run src/ccstatusline.ts",
"statusline": "bun run src/ccstatusline.ts",
"patch": "patch-package",
"build": "bun run patch && rm -rf dist/* && bun build src/ccstatusline.ts --target=node --outfile=dist/ccstatusline.js --target-version=14",
"start": "bun run src/ccstatusline.ts",
"build": "rm -rf dist/* ; bun build src/ccstatusline.ts --target=node --outfile=dist/ccstatusline.js --target-version=14",
"postbuild": "bun run scripts/replace-version.ts",
"example": "cat scripts/payload.example.json | bun start",
"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 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-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"ink": "^6.2.0",
"ink-gradient": "^3.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.3.0",
"https-proxy-agent": "^7.0.0",
"ink": "6.2.0",
"ink-gradient": "^4.0.0",
"ink-select-input": "^6.2.0",
"patch-package": "^8.0.0",
"pluralize": "^8.0.0",
"react": "^19.1.1",
"react-devtools-core": "^6.1.5",
"react-devtools-core": "^7.0.1",
"strip-ansi": "^7.1.0",
"tinyglobby": "^0.2.14",
"typedoc": "^0.28.12",
"typescript": "^5.9.2",
"typescript-eslint": "^8.39.1",
"vitest": "^4.0.18",
"zod": "^4.0.17"
},
"keywords": [
@@ -61,5 +68,8 @@
},
"trustedDependencies": [
"unrs-resolver"
]
],
"patchedDependencies": {
"ink@6.2.0": "patches/ink@6.2.0.patch"
}
}
@@ -1,7 +1,7 @@
diff --git a/node_modules/ink/build/parse-keypress.js b/node_modules/ink/build/parse-keypress.js
index 1e13e81..e100611 100644
--- a/node_modules/ink/build/parse-keypress.js
+++ b/node_modules/ink/build/parse-keypress.js
diff --git a/build/parse-keypress.js b/build/parse-keypress.js
index 1e13e819f59d259a3476510db822695576a9d93c..e100611d8661187c4ca191e89621cd5e480fcacc 100644
--- a/build/parse-keypress.js
+++ b/build/parse-keypress.js
@@ -161,9 +161,9 @@ const parseKeypress = (s = '') => {
key.meta = s.charAt(0) === '\x1b';
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+2
View File
@@ -0,0 +1,2 @@
settings.json
payload.json
+53
View File
@@ -0,0 +1,53 @@
{
"hook_event_name": "Status",
"session_id": "abc123...",
"transcript_path": "/path/to/transcript.json",
"cwd": "/current/working/directory",
"model": {
"id": "claude-opus-4-6[1m]",
"display_name": "Opus 4.6 (1M context)"
},
"workspace": {
"current_dir": "/current/working/directory",
"project_dir": "/original/project/directory",
"added_dirs": []
},
"version": "2.1.80",
"output_style": {
"name": "default"
},
"cost": {
"total_cost_usd": 0.01234,
"total_duration_ms": 45000,
"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"
}
}
+164 -24
View File
@@ -3,27 +3,46 @@ import chalk from 'chalk';
import { runTUI } from './tui';
import type {
BlockMetrics,
SkillsMetrics,
SpeedMetrics,
TokenMetrics
} from './types';
import type { RenderContext } from './types/RenderContext';
import type { StatusJSON } from './types/StatusJSON';
import { StatusJSONSchema } from './types/StatusJSON';
import { getVisibleText } from './utils/ansi';
import { updateColorMap } from './utils/colors';
import {
initConfigPath,
loadSettings,
saveSettings
} from './utils/config';
import {
getBlockMetrics,
getSessionDuration,
getSpeedMetricsCollection,
getTokenMetrics
} from './utils/jsonl';
import { advanceGlobalPowerlineThemeIndex } from './utils/powerline-theme-index';
import {
calculateMaxWidthsFromPreRendered,
preRenderAllWidgets,
renderStatusLine,
type RenderContext,
type StatusJSON
renderStatusLine
} from './utils/renderer';
import { advanceGlobalSeparatorIndex } from './utils/separator-index';
import {
getSkillsFilePath,
getSkillsMetrics
} from './utils/skills';
import {
getWidgetSpeedWindowSeconds,
isWidgetSpeedWindowEnabled
} from './utils/speed-window';
import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch';
function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
const durationMs = data.cost?.total_duration_ms;
return typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0;
}
async function readStdin(): Promise<string | null> {
// Check if stdin is a TTY (terminal) - if it is, there's no piped data
@@ -54,45 +73,85 @@ async function readStdin(): Promise<string | null> {
}
}
async function ensureWindowsUtf8CodePage() {
if (process.platform !== 'win32') {
return;
}
try {
const { execFileSync } = await import('child_process');
execFileSync('chcp.com', ['65001'], { stdio: 'ignore' });
} catch {
// Ignore failures to preserve statusline output even in restricted shells.
}
}
async function renderMultipleLines(data: StatusJSON) {
const settings = await loadSettings();
// Set global chalk level based on settings
chalk.level = settings.colorLevel;
// Update color map after setting chalk level
updateColorMap();
// Get all lines to render
const lines = settings.lines;
// Get token metrics if needed (check all lines)
const hasTokenItems = lines.some(line => line.some(item => ['tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', 'context-percentage-usable'].includes(item.type)));
// Check if session clock is needed
const hasSessionClock = lines.some(line => line.some(item => item.type === 'session-clock'));
// Check if block timer is needed
const hasBlockTimer = lines.some(line => line.some(item => item.type === 'block-timer'));
const speedWidgetTypes = new Set(['output-speed', 'input-speed', 'total-speed']);
const hasSpeedItems = lines.some(line => line.some(item => speedWidgetTypes.has(item.type)));
const requestedSpeedWindows = new Set<number>();
for (const line of lines) {
for (const item of line) {
if (speedWidgetTypes.has(item.type) && isWidgetSpeedWindowEnabled(item)) {
requestedSpeedWindows.add(getWidgetSpeedWindowSeconds(item));
}
}
}
let tokenMetrics: TokenMetrics | null = null;
if (hasTokenItems && data.transcript_path)
if (data.transcript_path) {
tokenMetrics = await getTokenMetrics(data.transcript_path);
}
let sessionDuration: string | null = null;
if (hasSessionClock && data.transcript_path)
if (hasSessionClock && !hasSessionDurationInStatusJson(data) && data.transcript_path) {
sessionDuration = await getSessionDuration(data.transcript_path);
}
let blockMetrics: BlockMetrics | null = null;
if (hasBlockTimer && data.transcript_path)
blockMetrics = getBlockMetrics(data.transcript_path);
const usageData = await prefetchUsageDataIfNeeded(lines, data);
let speedMetrics: SpeedMetrics | null = null;
let windowedSpeedMetrics: Record<string, SpeedMetrics> | null = null;
if (hasSpeedItems && data.transcript_path) {
const speedMetricsCollection = await getSpeedMetricsCollection(data.transcript_path, {
includeSubagents: true,
windowSeconds: Array.from(requestedSpeedWindows)
});
speedMetrics = speedMetricsCollection.sessionAverage;
windowedSpeedMetrics = speedMetricsCollection.windowed;
}
let skillsMetrics: SkillsMetrics | null = null;
if (data.session_id) {
skillsMetrics = getSkillsMetrics(data.session_id);
}
// Create render context
const context: RenderContext = {
data,
tokenMetrics,
speedMetrics,
windowedSpeedMetrics,
usageData,
sessionDuration,
blockMetrics,
isPreview: false
skillsMetrics,
isPreview: false,
minimalist: settings.minimalistMode
};
// Always pre-render all widgets once (for efficiency)
@@ -101,28 +160,34 @@ 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)
// Strip ANSI codes to check if there's actual text
const strippedLine = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
const strippedLine = getVisibleText(line).trim();
if (strippedLine.length > 0) {
// Count separators used in this line (widgets - 1, excluding merged widgets)
const nonMergedWidgets = lineItems.filter((_, idx) => idx === lineItems.length - 1 || !lineItems[idx]?.merge);
if (nonMergedWidgets.length > 1)
globalSeparatorIndex += nonMergedWidgets.length - 1;
// Replace all spaces with non-breaking spaces to prevent VSCode trimming
let outputLine = line.replace(/ /g, '\u00A0');
// Add reset code at the beginning to override Claude Code's dim setting
outputLine = '\x1b[0m' + outputLine;
console.log(outputLine);
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems);
if (settings.powerline.enabled && settings.powerline.continueThemeAcrossLines) {
globalPowerlineThemeIndex = advanceGlobalPowerlineThemeIndex(globalPowerlineThemeIndex, preRenderedWidgets);
}
}
}
}
@@ -157,9 +222,84 @@ async function renderMultipleLines(data: StatusJSON) {
}
}
function parseConfigArg(): string | undefined {
const idx = process.argv.indexOf('--config');
if (idx === -1)
return undefined;
const configPath = process.argv[idx + 1];
if (!configPath || configPath.startsWith('--')) {
console.error('--config requires a file path argument');
process.exit(1);
}
process.argv.splice(idx, 2);
return configPath;
}
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();
// We're receiving piped input
const input = await readStdin();
if (input && input.trim() !== '') {
+186 -120
View File
@@ -8,6 +8,7 @@ import {
} from 'ink';
import Gradient from 'ink-gradient';
import React, {
useCallback,
useEffect,
useState
} from 'react';
@@ -15,16 +16,23 @@ import React, {
import type { Settings } from '../types/Settings';
import type { WidgetItem } from '../types/Widget';
import {
CCSTATUSLINE_COMMANDS,
getClaudeSettingsPath,
getExistingStatusLine,
installStatusLine,
isBunxAvailable,
isInstalled,
isKnownCommand,
uninstallStatusLine
} from '../utils/claude-settings';
import { cloneSettings } from '../utils/clone-settings';
import {
getConfigPath,
isCustomConfigPath,
loadSettings,
saveSettings
} from '../utils/config';
import { openExternalUrl } from '../utils/open-url';
import {
checkPowerlineFonts,
checkPowerlineFontsAsync,
@@ -44,25 +52,65 @@ import {
PowerlineSetup,
StatusLinePreview,
TerminalOptionsMenu,
TerminalWidthMenu
TerminalWidthMenu,
type MainMenuOption
} from './components';
const GITHUB_REPO_URL = 'https://github.com/sirmalloc/ccstatusline';
interface FlashMessage {
text: string;
color: 'green' | 'red';
}
type AppScreen = 'main'
| 'lines'
| 'items'
| 'colorLines'
| 'colors'
| 'terminalWidth'
| 'terminalConfig'
| 'globalOverrides'
| 'confirm'
| 'powerline'
| 'install';
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 });
const [installingFonts, setInstallingFonts] = useState(false);
const [fontInstallMessage, setFontInstallMessage] = useState<string | null>(null);
const [existingStatusLine, setExistingStatusLine] = useState<string | null>(null);
const [saveMessage, setSaveMessage] = useState<string | null>(null);
const [flashMessage, setFlashMessage] = useState<FlashMessage | null>(null);
const [previewIsTruncated, setPreviewIsTruncated] = useState(false);
useEffect(() => {
@@ -72,12 +120,8 @@ export const App: React.FC = () => {
void loadSettings().then((loadedSettings) => {
// Set global chalk level based on settings (default to 256 colors for compatibility)
chalk.level = loadedSettings.colorLevel;
// Ensure lines array has 3 slots
while (loadedSettings.lines.length < 3) {
loadedSettings.lines.push([]);
}
setSettings(loadedSettings);
setOriginalSettings(JSON.parse(JSON.stringify(loadedSettings)) as Settings); // Deep copy
setOriginalSettings(cloneSettings(loadedSettings));
});
void isInstalled().then(setIsClaudeInstalled);
@@ -108,15 +152,15 @@ export const App: React.FC = () => {
}
}, [settings, originalSettings]);
// Clear save message after 2 seconds
// Clear header message after 2 seconds
useEffect(() => {
if (saveMessage) {
if (flashMessage) {
const timer = setTimeout(() => {
setSaveMessage(null);
setFlashMessage(null);
}, 2000);
return () => { clearTimeout(timer); };
}
}, [saveMessage]);
}, [flashMessage]);
useInput((input, key) => {
if (key.ctrl && input === 'c') {
@@ -126,13 +170,59 @@ export const App: React.FC = () => {
if (key.ctrl && input === 's' && settings) {
void (async () => {
await saveSettings(settings);
setOriginalSettings(JSON.parse(JSON.stringify(settings)) as Settings);
setOriginalSettings(cloneSettings(settings));
setHasChanges(false);
setSaveMessage('✓ Configuration saved');
setFlashMessage({
text: '✓ Configuration saved',
color: 'green'
});
})();
}
});
const handleInstallSelection = useCallback((command: string, displayName: string, useBunx: boolean) => {
void getExistingStatusLine().then((existing) => {
const isAlreadyInstalled = isKnownCommand(existing ?? '');
let message: string;
if (existing && !isAlreadyInstalled) {
message = `This will modify ${getClaudeSettingsPath()}\n\nA status line is already configured: "${existing}"\nReplace it with ${command}?`;
} else if (isAlreadyInstalled) {
message = `ccstatusline is already installed in ${getClaudeSettingsPath()}\nUpdate it with ${command}?`;
} else {
message = `This will modify ${getClaudeSettingsPath()} to add ccstatusline with ${displayName}.\nContinue?`;
}
setConfirmDialog({
message,
cancelScreen: 'install',
action: async () => {
await installStatusLine(useBunx);
setIsClaudeInstalled(true);
setExistingStatusLine(command);
setScreen('main');
setConfirmDialog(null);
}
});
setScreen('confirm');
});
}, []);
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>;
}
@@ -141,7 +231,7 @@ export const App: React.FC = () => {
if (isClaudeInstalled) {
// Uninstall
setConfirmDialog({
message: 'This will remove ccstatusline from ~/.claude/settings.json. Continue?',
message: `This will remove ccstatusline from ${getClaudeSettingsPath()}. Continue?`,
action: async () => {
await uninstallStatusLine();
setIsClaudeInstalled(false);
@@ -157,35 +247,58 @@ export const App: React.FC = () => {
}
};
const handleMainMenuSelect = async (value: string) => {
const handleMainMenuSelect = async (value: MainMenuOption) => {
switch (value) {
case 'lines':
setScreen('lines');
break;
case 'colors':
setScreen('colorLines');
break;
case 'terminalConfig':
setScreen('terminalConfig');
break;
case 'globalOverrides':
setScreen('globalOverrides');
break;
case 'powerline':
setScreen('powerline');
break;
case 'install':
handleInstallUninstall();
break;
case 'save':
await saveSettings(settings);
setOriginalSettings(JSON.parse(JSON.stringify(settings)) as Settings); // Update original after save
setHasChanges(false);
exit();
break;
case 'exit':
exit();
break;
case 'lines':
setScreen('lines');
break;
case 'colors':
setScreen('colorLines');
break;
case 'terminalConfig':
setScreen('terminalConfig');
break;
case 'globalOverrides':
setScreen('globalOverrides');
break;
case 'powerline':
setScreen('powerline');
break;
case 'install':
handleInstallUninstall();
break;
case 'starGithub':
setConfirmDialog({
message: `Open the ccstatusline GitHub repository in your browser?\n\n${GITHUB_REPO_URL}`,
action: () => {
const result = openExternalUrl(GITHUB_REPO_URL);
if (result.success) {
setFlashMessage({
text: '✓ Opened GitHub repository in browser',
color: 'green'
});
} else {
setFlashMessage({
text: `✗ Could not open browser. Visit: ${GITHUB_REPO_URL}`,
color: 'red'
});
}
setScreen('main');
setConfirmDialog(null);
return Promise.resolve();
}
});
setScreen('confirm');
break;
case 'save':
await saveSettings(settings);
setOriginalSettings(cloneSettings(settings)); // Update original after save
setHasChanges(false);
exit();
break;
case 'exit':
exit();
break;
}
};
@@ -195,6 +308,10 @@ export const App: React.FC = () => {
setSettings({ ...settings, lines: newLines });
};
const updateLines = (newLines: WidgetItem[][]) => {
setSettings({ ...settings, lines: newLines });
};
const handleLineSelect = (lineIndex: number) => {
setSelectedLine(lineIndex);
setScreen('items');
@@ -211,12 +328,15 @@ export const App: React.FC = () => {
<Text bold>
{` | ${getPackageVersion() && `v${getPackageVersion()}`}`}
</Text>
{saveMessage && (
<Text color='green' bold>
{` ${saveMessage}`}
{flashMessage && (
<Text color={flashMessage.color} bold>
{` ${flashMessage.text}`}
</Text>
)}
</Box>
{isCustomConfigPath() && (
<Text dimColor>{`Config: ${getConfigPath()}`}</Text>
)}
<StatusLinePreview
lines={settings.lines}
@@ -228,19 +348,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
};
setMenuSelections({ ...menuSelections, main: menuMap[value] ?? 0 });
setMenuSelections(prev => ({ ...prev, main: index }));
}
void handleMainMenuSelect(value);
}}
isClaudeInstalled={isClaudeInstalled}
@@ -255,17 +368,19 @@ export const App: React.FC = () => {
<LineSelector
lines={settings.lines}
onSelect={(line) => {
setMenuSelections({ ...menuSelections, lines: line });
setMenuSelections(prev => ({ ...prev, lines: line }));
handleLineSelect(line);
}}
onLinesUpdate={updateLines}
onBack={() => {
// Save that we came from 'lines' menu (index 0)
// Clear the line selection so it resets next time we enter
setMenuSelections({ ...menuSelections, main: 0 });
setMenuSelections(prev => ({ ...prev, main: 0 }));
setScreen('main');
}}
initialSelection={menuSelections.lines}
title='Select Line to Edit Items'
allowEditing={true}
/>
)}
{screen === 'items' && (
@@ -274,7 +389,7 @@ export const App: React.FC = () => {
onUpdate={(widgets) => { updateLine(selectedLine, widgets); }}
onBack={() => {
// When going back to lines menu, preserve which line was selected
setMenuSelections({ ...menuSelections, lines: selectedLine });
setMenuSelections(prev => ({ ...prev, lines: selectedLine }));
setScreen('lines');
}}
lineNumber={selectedLine + 1}
@@ -284,20 +399,22 @@ export const App: React.FC = () => {
{screen === 'colorLines' && (
<LineSelector
lines={settings.lines}
onLinesUpdate={updateLines}
onSelect={(line) => {
setMenuSelections({ ...menuSelections, lines: line });
setMenuSelections(prev => ({ ...prev, lines: line }));
setSelectedLine(line);
setScreen('colors');
}}
onBack={() => {
// Save that we came from 'colors' menu (index 1)
setMenuSelections({ ...menuSelections, main: 1 });
setMenuSelections(prev => ({ ...prev, main: 1 }));
setScreen('main');
}}
initialSelection={menuSelections.lines}
title='Select Line to Edit Colors'
blockIfPowerlineActive={true}
settings={settings}
allowEditing={false}
/>
)}
{screen === 'colors' && (
@@ -328,7 +445,7 @@ export const App: React.FC = () => {
setScreen('terminalWidth');
} else {
// Save that we came from 'terminalConfig' menu (index 3)
setMenuSelections({ ...menuSelections, main: 3 });
setMenuSelections(prev => ({ ...prev, main: 3 }));
setScreen('main');
}
}}
@@ -353,7 +470,7 @@ export const App: React.FC = () => {
}}
onBack={() => {
// Save that we came from 'globalOverrides' menu (index 4)
setMenuSelections({ ...menuSelections, main: 4 });
setMenuSelections(prev => ({ ...prev, main: 4 }));
setScreen('main');
}}
/>
@@ -363,7 +480,7 @@ export const App: React.FC = () => {
message={confirmDialog.message}
onConfirm={() => void confirmDialog.action()}
onCancel={() => {
setScreen('main');
setScreen(getConfirmCancelScreen(confirmDialog));
setConfirmDialog(null);
}}
/>
@@ -372,61 +489,10 @@ export const App: React.FC = () => {
<InstallMenu
bunxAvailable={isBunxAvailable()}
existingStatusLine={existingStatusLine}
onSelectNpx={() => {
void getExistingStatusLine().then((existing) => {
const isAlreadyInstalled = ['npx -y ccstatusline@latest', 'bunx -y ccstatusline@latest'].includes(existing ?? '');
let message: string;
if (existing && !isAlreadyInstalled) {
message = `This will modify ~/.claude/settings.json\n\nA status line is already configured: "${existing}"\nReplace it with npx -y ccstatusline@latest?`;
} else if (isAlreadyInstalled) {
message = 'ccstatusline is already installed in ~/.claude/settings.json\nUpdate it with npx -y ccstatusline@latest?';
} else {
message = 'This will modify ~/.claude/settings.json to add ccstatusline with npx.\nContinue?';
}
setConfirmDialog({
message,
action: async () => {
await installStatusLine(false);
setIsClaudeInstalled(true);
setExistingStatusLine('npx -y ccstatusline@latest');
setScreen('main');
setConfirmDialog(null);
}
});
setScreen('confirm');
});
}}
onSelectBunx={() => {
void getExistingStatusLine().then((existing) => {
const isAlreadyInstalled = ['npx -y ccstatusline@latest', 'bunx -y ccstatusline@latest'].includes(existing ?? '');
let message: string;
if (existing && !isAlreadyInstalled) {
message = `This will modify ~/.claude/settings.json\n\nA status line is already configured: "${existing}"\nReplace it with bunx -y ccstatusline@latest?`;
} else if (isAlreadyInstalled) {
message = 'ccstatusline is already installed in ~/.claude/settings.json\nUpdate it with bunx -y ccstatusline@latest?';
} else {
message = 'This will modify ~/.claude/settings.json to add ccstatusline with bunx.\nContinue?';
}
setConfirmDialog({
message,
action: async () => {
await installStatusLine(true);
setIsClaudeInstalled(true);
setExistingStatusLine('bunx -y ccstatusline@latest');
setScreen('main');
setConfirmDialog(null);
}
});
setScreen('confirm');
});
}}
onCancel={() => {
setScreen('main');
}}
onSelectNpx={handleNpxInstall}
onSelectBunx={handleBunxInstall}
onCancel={handleInstallMenuCancel}
initialSelection={menuSelections.install}
/>
)}
{screen === 'powerline' && (
+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);
});
});
+31 -105
View File
@@ -15,9 +15,17 @@ import {
getAvailableBackgroundColorsForUI,
getAvailableColorsForUI
} from '../../utils/colors';
import { shouldInsertInput } from '../../utils/input-guards';
import { getWidget } from '../../utils/widgets';
import { ConfirmDialog } from './ConfirmDialog';
import {
clearAllWidgetStyling,
cycleWidgetColor,
resetWidgetStyling,
setWidgetColor,
toggleWidgetBold
} from './color-menu/mutations';
export interface ColorMenuProps {
widgets: WidgetItem[];
@@ -43,15 +51,11 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
return showSeparators;
}
// Use the widget's supportsColors method
try {
const widgetInstance = getWidget(widget.type);
return widgetInstance.supportsColors(widget);
} catch {
// If widget type is not found, exclude it
return false;
}
const widgetInstance = getWidget(widget.type);
// 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
@@ -83,17 +87,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
const hexColor = `hex:${hexInput}`;
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
// IMPORTANT: Update ALL items (not just colorableWidgets) to maintain proper indexing
const newItems = widgets.map((widget) => {
if (widget.id === highlightedItemId) {
if (editingBackground) {
return { ...widget, backgroundColor: hexColor };
} else {
return { ...widget, color: hexColor };
}
}
return widget;
});
const newItems = setWidgetColor(widgets, selectedWidget.id, hexColor, editingBackground);
onUpdate(newItems);
}
setHexInputMode(false);
@@ -101,7 +95,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
}
} else if (key.backspace || key.delete) {
setHexInput(hexInput.slice(0, -1));
} else if (input && hexInput.length < 6) {
} else if (shouldInsertInput(input, key) && hexInput.length < 6) {
// Only accept hex characters (0-9, A-F, a-f)
const upperInput = input.toUpperCase();
if (/^[0-9A-F]$/.test(upperInput)) {
@@ -129,17 +123,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
// IMPORTANT: Update ALL items (not just colorableWidgets) to maintain proper indexing
const newItems = widgets.map((widget) => {
if (widget.id === highlightedItemId) {
if (editingBackground) {
return { ...widget, backgroundColor: ansiColor };
} else {
return { ...widget, color: ansiColor };
}
}
return widget;
});
const newItems = setWidgetColor(widgets, selectedWidget.id, ansiColor, editingBackground);
onUpdate(newItems);
setAnsi256InputMode(false);
@@ -148,7 +132,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
}
} else if (key.backspace || key.delete) {
setAnsi256Input(ansi256Input.slice(0, -1));
} else if (input && ansi256Input.length < 3) {
} else if (shouldInsertInput(input, key) && ansi256Input.length < 3) {
// Only accept numeric characters (0-9)
if (/^[0-9]$/.test(input)) {
const newInput = ansi256Input + input;
@@ -202,12 +186,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
// Toggle bold for the highlighted item
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
const newItems = widgets.map((widget) => {
if (widget.id === selectedWidget.id) {
return { ...widget, bold: !widget.bold };
}
return widget;
});
const newItems = toggleWidgetBold(widgets, selectedWidget.id);
onUpdate(newItems);
}
}
@@ -216,17 +195,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
// Reset all styling (color, background, and bold) for the highlighted item
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
const newItems = widgets.map((widget) => {
if (widget.id === selectedWidget.id) {
// Remove color, backgroundColor, and bold properties
const { color, backgroundColor, bold, ...restWidget } = widget;
void color; // Intentionally unused
void backgroundColor; // Intentionally unused
void bold; // Intentionally unused
return restWidget;
}
return widget;
});
const newItems = resetWidgetStyling(widgets, selectedWidget.id);
onUpdate(newItems);
}
}
@@ -238,50 +207,13 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
if (highlightedItemId && highlightedItemId !== 'back') {
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
const newItems = widgets.map((widget) => {
if (widget.id === selectedWidget.id) {
if (editingBackground) {
const currentBgColor = widget.backgroundColor ?? ''; // Empty string for 'none'
let currentBgColorIndex = bgColors.indexOf(currentBgColor);
// If color not found, start from beginning
if (currentBgColorIndex === -1)
currentBgColorIndex = 0;
let nextBgColorIndex;
if (key.rightArrow) {
nextBgColorIndex = (currentBgColorIndex + 1) % bgColors.length;
} else {
nextBgColorIndex = currentBgColorIndex === 0 ? bgColors.length - 1 : currentBgColorIndex - 1;
}
const nextBgColor = bgColors[nextBgColorIndex];
return { ...widget, backgroundColor: nextBgColor === '' ? undefined : nextBgColor };
} else {
let defaultColor = 'white';
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
defaultColor = widgetImpl.getDefaultColor();
}
let currentColor = widget.color ?? defaultColor;
// If color is 'dim', treat as if no color was set
if (currentColor === 'dim') {
currentColor = defaultColor;
}
let currentColorIndex = colors.indexOf(currentColor);
// If color not found, start from beginning
if (currentColorIndex === -1)
currentColorIndex = 0;
let nextColorIndex;
if (key.rightArrow) {
nextColorIndex = (currentColorIndex + 1) % colors.length;
} else {
nextColorIndex = currentColorIndex === 0 ? colors.length - 1 : currentColorIndex - 1;
}
const nextColor = colors[nextColorIndex];
return { ...widget, color: nextColor };
}
}
return widget;
const newItems = cycleWidgetColor({
widgets,
widgetId: selectedWidget.id,
direction: key.rightArrow ? 'right' : 'left',
editingBackground,
colors,
backgroundColors: bgColors
});
onUpdate(newItems);
}
@@ -313,7 +245,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
}
const widgetImpl = getWidget(widget.type);
return widgetImpl.getDisplayName();
return widgetImpl ? widgetImpl.getDisplayName() : `Unknown: ${widget.type}`;
};
// Color list for cycling
@@ -333,7 +265,9 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
let defaultColor = 'white';
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
defaultColor = widgetImpl.getDefaultColor();
if (widgetImpl) {
defaultColor = widgetImpl.getDefaultColor();
}
}
const styledLabel = applyColors(label, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level);
return {
@@ -363,7 +297,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
: (selectedWidget ? (selectedWidget.color ?? (() => {
if (selectedWidget.type !== 'separator' && selectedWidget.type !== 'flex-separator') {
const widgetImpl = getWidget(selectedWidget.type);
return widgetImpl.getDefaultColor();
return widgetImpl ? widgetImpl.getDefaultColor() : 'white';
}
return 'white';
})()) : 'white');
@@ -429,15 +363,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
<ConfirmDialog
inline={true}
onConfirm={() => {
// Clear all colors from all widgets
const newItems = widgets.map((widget) => {
// Remove color, backgroundColor, and bold properties
const { color, backgroundColor, bold, ...restWidget } = widget;
void color; // Intentionally unused
void backgroundColor; // Intentionally unused
void bold; // Intentionally unused
return restWidget;
});
const newItems = clearAllWidgetStyling(widgets);
onUpdate(newItems);
setShowClearConfirm(false);
}}
+45 -35
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,52 +17,57 @@ 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>
);
+22 -2
View File
@@ -11,6 +11,7 @@ import {
getChalkColor,
getColorDisplayName
} from '../../utils/colors';
import { shouldInsertInput } from '../../utils/input-guards';
import { ConfirmDialog } from './ConfirmDialog';
@@ -28,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
@@ -57,7 +59,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
setPaddingInput(paddingInput.slice(0, -1));
} else if (key.delete) {
// For simple text inputs without cursor, forward delete does nothing
} else if (input) {
} else if (shouldInsertInput(input, key)) {
setPaddingInput(paddingInput + input);
}
} else if (editingSeparator) {
@@ -86,7 +88,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
setSeparatorInput(separatorInput.slice(0, -1));
} else if (key.delete) {
// For simple text inputs without cursor, forward delete does nothing
} else if (input) {
} else if (shouldInsertInput(input, key)) {
setSeparatorInput(separatorInput + input);
}
} else if (confirmingSeparator) {
@@ -132,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;
@@ -221,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>
@@ -303,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>
+55 -51
View File
@@ -3,7 +3,11 @@ 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;
@@ -11,6 +15,7 @@ export interface InstallMenuProps {
onSelectNpx: () => void;
onSelectBunx: () => void;
onCancel: () => void;
initialSelection?: number;
}
export const InstallMenu: React.FC<InstallMenuProps> = ({
@@ -18,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>
@@ -69,33 +79,27 @@ 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>
The selected command will be written to ~/.claude/settings.json
The selected command will be written to
{' '}
{getClaudeSettingsPath()}
</Text>
</Box>
+388 -280
View File
@@ -7,6 +7,7 @@ import React, { useState } from 'react';
import type { Settings } from '../../types/Settings';
import type {
CustomKeybind,
Widget,
WidgetItem,
WidgetItemType
@@ -15,10 +16,24 @@ import { getBackgroundColorsForPowerline } from '../../utils/colors';
import { generateGuid } from '../../utils/guid';
import { canDetectTerminalWidth } from '../../utils/terminal';
import {
getAllWidgetTypes,
getWidget
filterWidgetCatalog,
getMatchSegments,
getWidget,
getWidgetCatalog,
getWidgetCatalogCategories
} from '../../utils/widgets';
import { ConfirmDialog } from './ConfirmDialog';
import {
handleMoveInputMode,
handleNormalInputMode,
handlePickerInputMode,
normalizePickerState,
type CustomEditorWidgetState,
type WidgetPickerAction,
type WidgetPickerState
} from './items-editor/input-handlers';
export interface ItemsEditorProps {
widgets: WidgetItem[];
onUpdate: (widgets: WidgetItem[]) => void;
@@ -30,31 +45,13 @@ export interface ItemsEditorProps {
export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onBack, lineNumber, settings }) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [moveMode, setMoveMode] = useState(false);
const [customEditorWidget, setCustomEditorWidget] = useState<{ widget: WidgetItem; impl: Widget; action?: string } | null>(null);
const [customEditorWidget, setCustomEditorWidget] = useState<CustomEditorWidgetState | null>(null);
const [widgetPicker, setWidgetPicker] = useState<WidgetPickerState | null>(null);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const separatorChars = ['|', '-', ',', ' '];
// Determine which item types are allowed based on settings
const getAllowedTypes = (): WidgetItemType[] => {
let allowedTypes = getAllWidgetTypes(settings);
// Remove separator if default separator is set
if (settings.defaultSeparator) {
allowedTypes = allowedTypes.filter(t => t !== 'separator');
}
// Remove both separator and flex-separator if powerline mode is enabled
if (settings.powerline.enabled) {
allowedTypes = allowedTypes.filter(t => t !== 'separator' && t !== 'flex-separator');
}
return allowedTypes;
};
// Get the default type for new widgets (first non-separator type)
const getDefaultItemType = (): WidgetItemType => {
const allowedTypes = getAllowedTypes();
return allowedTypes.includes('model') ? 'model' : (allowedTypes[0] ?? 'model');
};
const widgetCatalog = getWidgetCatalog(settings);
const widgetCategories = ['All', ...getWidgetCatalogCategories(widgetCatalog)];
// Get a unique background color for powerline mode
const getUniqueBackgroundColor = (insertIndex: number): string | undefined => {
@@ -98,202 +95,114 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
setCustomEditorWidget(null);
};
const getCustomKeybindsForWidget = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => {
if (!widgetImpl.getCustomKeybinds) {
return [];
}
return widgetImpl.getCustomKeybinds(widget);
};
const openWidgetPicker = (action: WidgetPickerAction) => {
if (widgetCatalog.length === 0) {
return;
}
const currentType = widgets[selectedIndex]?.type;
const selectedType = action === 'change' ? currentType ?? null : null;
setWidgetPicker(normalizePickerState({
action,
level: 'category',
selectedCategory: 'All',
categoryQuery: '',
widgetQuery: '',
selectedType
}, widgetCatalog, widgetCategories));
};
const applyWidgetPickerSelection = (selectedType: WidgetItemType) => {
if (!widgetPicker) {
return;
}
if (widgetPicker.action === 'change') {
const currentWidget = widgets[selectedIndex];
if (currentWidget) {
const newWidgets = [...widgets];
newWidgets[selectedIndex] = { ...currentWidget, type: selectedType };
onUpdate(newWidgets);
}
} else {
const insertIndex = widgetPicker.action === 'add'
? (widgets.length > 0 ? selectedIndex + 1 : 0)
: selectedIndex;
const backgroundColor = getUniqueBackgroundColor(insertIndex);
const newWidget: WidgetItem = {
id: generateGuid(),
type: selectedType,
...(backgroundColor && { backgroundColor })
};
const newWidgets = [...widgets];
newWidgets.splice(insertIndex, 0, newWidget);
onUpdate(newWidgets);
setSelectedIndex(insertIndex);
}
setWidgetPicker(null);
};
useInput((input, key) => {
// Skip input if custom editor is active
if (customEditorWidget) {
return;
}
if (moveMode) {
// In move mode, use up/down to move the selected item
if (key.upArrow && selectedIndex > 0) {
const newWidgets = [...widgets];
const temp = newWidgets[selectedIndex];
const prev = newWidgets[selectedIndex - 1];
if (temp && prev) {
[newWidgets[selectedIndex], newWidgets[selectedIndex - 1]] = [prev, temp];
}
onUpdate(newWidgets);
setSelectedIndex(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < widgets.length - 1) {
const newWidgets = [...widgets];
const temp = newWidgets[selectedIndex];
const next = newWidgets[selectedIndex + 1];
if (temp && next) {
[newWidgets[selectedIndex], newWidgets[selectedIndex + 1]] = [next, temp];
}
onUpdate(newWidgets);
setSelectedIndex(selectedIndex + 1);
} else if (key.escape || key.return) {
// Exit move mode
setMoveMode(false);
}
} else {
// Normal mode
if (key.upArrow) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow) {
setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1));
} else if (key.leftArrow && widgets.length > 0) {
// Toggle item type backwards
const types = getAllowedTypes();
const currentWidget = widgets[selectedIndex];
if (currentWidget) {
const currentType = currentWidget.type;
let currentIndex = types.indexOf(currentType);
// If current type is not in allowed types (e.g., separator when disabled), find a valid type
if (currentIndex === -1) {
currentIndex = 0;
}
const prevIndex = currentIndex === 0 ? types.length - 1 : currentIndex - 1;
const newWidgets = [...widgets];
const prevType = types[prevIndex];
if (prevType) {
newWidgets[selectedIndex] = { ...currentWidget, type: prevType };
onUpdate(newWidgets);
}
}
} else if (key.rightArrow && widgets.length > 0) {
// Toggle item type forwards
const types = getAllowedTypes();
const currentWidget = widgets[selectedIndex];
if (currentWidget) {
const currentType = currentWidget.type;
let currentIndex = types.indexOf(currentType);
// If current type is not in allowed types (e.g., separator when disabled), find a valid type
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = (currentIndex + 1) % types.length;
const newWidgets = [...widgets];
const nextType = types[nextIndex];
if (nextType) {
newWidgets[selectedIndex] = { ...currentWidget, type: nextType };
onUpdate(newWidgets);
}
}
} else if (key.return && widgets.length > 0) {
// Enter move mode
setMoveMode(true);
} else if (input === 'a') {
// Add widget after selected
const insertIndex = widgets.length > 0 ? selectedIndex + 1 : 0;
const backgroundColor = getUniqueBackgroundColor(insertIndex);
const newWidget: WidgetItem = {
id: generateGuid(),
type: getDefaultItemType(),
...(backgroundColor && { backgroundColor })
};
const newWidgets = [...widgets];
newWidgets.splice(insertIndex, 0, newWidget);
onUpdate(newWidgets);
setSelectedIndex(insertIndex); // Move selection to new widget
} else if (input === 'i') {
// Insert item before selected
const insertIndex = selectedIndex;
const backgroundColor = getUniqueBackgroundColor(insertIndex);
const newWidget: WidgetItem = {
id: generateGuid(),
type: getDefaultItemType(),
...(backgroundColor && { backgroundColor })
};
const newWidgets = [...widgets];
newWidgets.splice(insertIndex, 0, newWidget);
onUpdate(newWidgets);
// Keep selection on the new widget (which is now at selectedIndex)
} else if (input === 'd' && widgets.length > 0) {
// Delete selected item
const newWidgets = widgets.filter((_, i) => i !== selectedIndex);
onUpdate(newWidgets);
if (selectedIndex >= newWidgets.length && selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1);
}
} else if (input === 'c') {
// Clear entire line
onUpdate([]);
setSelectedIndex(0);
} else if (input === ' ' && widgets.length > 0) {
// Space key - cycle separator character for separator types only (not flex)
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type === 'separator') {
const currentChar = currentWidget.character ?? '|';
const currentCharIndex = separatorChars.indexOf(currentChar);
const nextChar = separatorChars[(currentCharIndex + 1) % separatorChars.length];
const newWidgets = [...widgets];
newWidgets[selectedIndex] = { ...currentWidget, character: nextChar };
onUpdate(newWidgets);
}
} else if (input === 'r' && widgets.length > 0) {
// Toggle raw value for non-separator items
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator' && currentWidget.type !== 'custom-text') {
const newWidgets = [...widgets];
newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue };
onUpdate(newWidgets);
}
} else if (input === 'm' && widgets.length > 0) {
// Cycle through merge states: undefined -> true -> 'no-padding' -> undefined
const currentWidget = widgets[selectedIndex];
// Don't allow merge on the last item or on separators
if (currentWidget && selectedIndex < widgets.length - 1
&& currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const newWidgets = [...widgets];
let nextMergeState: boolean | 'no-padding' | undefined;
if (currentWidget.merge === undefined) {
nextMergeState = true;
} else if (currentWidget.merge === true) {
nextMergeState = 'no-padding';
} else {
nextMergeState = undefined;
}
if (nextMergeState === undefined) {
const { merge, ...rest } = currentWidget;
void merge; // Intentionally unused
newWidgets[selectedIndex] = rest;
} else {
newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState };
}
onUpdate(newWidgets);
}
} else if (key.escape) {
onBack();
} else if (widgets.length > 0) {
// Check for custom widget keybinds
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
try {
const widgetImpl = getWidget(currentWidget.type);
if (widgetImpl.getCustomKeybinds) {
const customKeybinds = widgetImpl.getCustomKeybinds();
const matchedKeybind = customKeybinds.find(kb => kb.key === input);
if (matchedKeybind && !key.ctrl) {
// Check if widget handles the action directly
if (widgetImpl.handleEditorAction) {
// Let the widget handle the action directly
const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget);
if (updatedWidget) {
const newWidgets = [...widgets];
newWidgets[selectedIndex] = updatedWidget;
onUpdate(newWidgets);
} else if (widgetImpl.renderEditor) {
// If handleEditorAction returned null, open the editor
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
}
} else if (widgetImpl.renderEditor) {
// Open the widget's custom editor with the action
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
}
}
}
} catch {
// Widget not found or doesn't support custom keybinds
}
}
}
// Skip input handling when clear confirmation is active - let ConfirmDialog handle it
if (showClearConfirm) {
return;
}
if (widgetPicker) {
handlePickerInputMode({
input,
key,
widgetPicker,
widgetCatalog,
widgetCategories,
setWidgetPicker,
applyWidgetPickerSelection
});
return;
}
if (moveMode) {
handleMoveInputMode({
key,
widgets,
selectedIndex,
onUpdate,
setSelectedIndex,
setMoveMode
});
return;
}
handleNormalInputMode({
input,
key,
widgets,
selectedIndex,
separatorChars,
onBack,
onUpdate,
setSelectedIndex,
setMoveMode,
setShowClearConfirm,
openWidgetPicker,
getCustomKeybindsForWidget,
setCustomEditorWidget
});
});
const getWidgetDisplay = (widget: WidgetItem) => {
@@ -309,14 +218,37 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
// Handle regular widgets - delegate to widget for display
const widgetImpl = getWidget(widget.type);
const { displayText, modifierText } = widgetImpl.getEditorDisplay(widget);
// Return plain text without colors
return displayText + (modifierText ? ` ${modifierText}` : '');
if (widgetImpl) {
const { displayText, modifierText } = widgetImpl.getEditorDisplay(widget);
// Return plain text without colors
return displayText + (modifierText ? ` ${modifierText}` : '');
}
// Unknown widget type
return `Unknown: ${widget.type}`;
};
const hasFlexSeparator = widgets.some(widget => widget.type === 'flex-separator');
const widthDetectionAvailable = canDetectTerminalWidth();
const pickerCategories = widgetPicker
? [...widgetCategories]
: [];
const selectedPickerCategory = widgetPicker
? (widgetPicker.selectedCategory && pickerCategories.includes(widgetPicker.selectedCategory)
? widgetPicker.selectedCategory
: (pickerCategories[0] ?? null))
: null;
const topLevelSearchEntries = widgetPicker?.level === 'category' && widgetPicker.categoryQuery.trim().length > 0
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
: [];
const selectedTopLevelSearchEntry = widgetPicker
? (topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0])
: null;
const pickerEntries = widgetPicker
? filterWidgetCatalog(widgetCatalog, selectedPickerCategory ?? 'All', widgetPicker.widgetQuery)
: [];
const selectedPickerEntry = widgetPicker
? (pickerEntries.find(entry => entry.type === widgetPicker.selectedType) ?? pickerEntries[0])
: null;
// Build dynamic help text based on selected item
const currentWidget = widgets[selectedIndex];
@@ -325,28 +257,31 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
// Check if widget supports raw value using registry
let canToggleRaw = false;
let customKeybinds: { key: string; label: string; action: string }[] = [];
let customKeybinds: CustomKeybind[] = [];
if (currentWidget && !isSeparator && !isFlexSeparator) {
try {
const widgetImpl = getWidget(currentWidget.type);
const widgetImpl = getWidget(currentWidget.type);
if (widgetImpl) {
canToggleRaw = widgetImpl.supportsRawValue();
// Get custom keybinds from the widget
if (widgetImpl.getCustomKeybinds) {
customKeybinds = widgetImpl.getCustomKeybinds();
}
} catch {
customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
} else {
canToggleRaw = false;
}
}
const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator;
const hasWidgets = widgets.length > 0;
// Build main help text (without custom keybinds)
let helpText = '↑↓ select, ←→ change type';
let helpText = hasWidgets
? '↑↓ select, ←→ open type picker'
: '(a)dd via picker, (i)nsert via picker';
if (isSeparator) {
helpText += ', Space edit separator';
}
helpText += ', Enter to move, (a)dd, (i)nsert, (d)elete, (c)lear line';
if (hasWidgets) {
helpText += ', Enter to move, (a)dd via picker, (i)nsert via picker, (d)elete, (c)lear line';
}
if (canToggleRaw) {
helpText += ', (r)aw value';
}
@@ -357,6 +292,11 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
// Build custom keybinds text
const customKeybindsText = customKeybinds.map(kb => kb.label).join(', ');
const pickerActionLabel = widgetPicker?.action === 'add'
? 'Add Widget'
: widgetPicker?.action === 'insert'
? 'Insert Widget'
: 'Change Widget Type';
// If custom editor is active, render it instead of the normal UI
if (customEditorWidget?.impl.renderEditor) {
@@ -368,6 +308,39 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
});
}
if (showClearConfirm) {
return (
<Box flexDirection='column'>
<Text bold color='yellow'> Confirm Clear Line</Text>
<Box marginTop={1} flexDirection='column'>
<Text>
This will remove all widgets from Line
{' '}
{lineNumber}
.
</Text>
<Text color='red'>This action cannot be undone!</Text>
</Box>
<Box marginTop={2}>
<Text>Continue?</Text>
</Box>
<Box marginTop={1}>
<ConfirmDialog
inline={true}
onConfirm={() => {
onUpdate([]);
setSelectedIndex(0);
setShowClearConfirm(false);
}}
onCancel={() => {
setShowClearConfirm(false);
}}
/>
</Box>
</Box>
);
}
return (
<Box flexDirection='column'>
<Box>
@@ -378,6 +351,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
{' '}
</Text>
{moveMode && <Text color='blue'>[MOVE MODE]</Text>}
{widgetPicker && <Text color='cyan'>{`[${pickerActionLabel.toUpperCase()}]`}</Text>}
{(settings.powerline.enabled || Boolean(settings.defaultSeparator)) && (
<Box marginLeft={2}>
<Text color='yellow'>
@@ -394,6 +368,37 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
<Box flexDirection='column' marginBottom={1}>
<Text dimColor> to move widget, ESC or Enter to exit move mode</Text>
</Box>
) : widgetPicker ? (
<Box flexDirection='column'>
{widgetPicker.level === 'category' ? (
<>
{widgetPicker.categoryQuery.trim().length > 0 ? (
<Text dimColor> select widget match, Enter apply, ESC clear/cancel</Text>
) : (
<Text dimColor> select category, type to search all widgets, Enter continue, ESC cancel</Text>
)}
<Box>
<Text dimColor>Search: </Text>
<Text color='cyan'>{widgetPicker.categoryQuery || '(none)'}</Text>
</Box>
</>
) : (
<>
<Text dimColor> select widget, type to search widgets, Enter apply, ESC back</Text>
<Box>
<Text dimColor>
Category:
{' '}
{selectedPickerCategory ?? '(none)'}
{' '}
| Search:
{' '}
</Text>
<Text color='cyan'>{widgetPicker.widgetQuery || '(none)'}</Text>
</Box>
</>
)}
</Box>
) : (
<Box flexDirection='column'>
<Text dimColor>{helpText}</Text>
@@ -406,62 +411,165 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
<Text dimColor> Flex separators will act as normal separators until width detection is available.</Text>
</Box>
)}
<Box marginTop={1} flexDirection='column'>
{widgets.length === 0 ? (
<Text dimColor>No widgets. Press 'a' to add one.</Text>
) : (
<>
{widgets.map((widget, index) => {
const isSelected = index === selectedIndex;
const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null;
const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) };
return (
<Box key={widget.id} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
{isSelected ? (moveMode ? '' : '▶ ') : ' '}
</Text>
</Box>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
{`${index + 1}. ${displayText || getWidgetDisplay(widget)}`}
</Text>
{modifierText && (
<Text dimColor>
{' '}
{modifierText}
</Text>
{widgetPicker && (
<Box marginTop={1} flexDirection='column'>
{widgetPicker.level === 'category' ? (
widgetPicker.categoryQuery.trim().length > 0 ? (
topLevelSearchEntries.length === 0 ? (
<Text dimColor>No widgets match the search.</Text>
) : (
<>
{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}>
<Text color={isSelected ? 'green' : undefined}>
{isSelected ? '▶ ' : ' '}
</Text>
</Box>
<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>
);
})}
{selectedTopLevelSearchEntry && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>{selectedTopLevelSearchEntry.description}</Text>
</Box>
)}
{widget.rawValue && <Text dimColor> (raw value)</Text>}
{widget.merge === true && <Text dimColor> (merged)</Text>}
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad)</Text>}
</Box>
);
})}
{/* Display description for selected widget */}
{currentWidget && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>
{(() => {
if (currentWidget.type === 'separator') {
return 'A separator character between status line widgets';
} else if (currentWidget.type === 'flex-separator') {
return 'Expands to fill available terminal width';
} else {
try {
</>
)
) : (
pickerCategories.length === 0 ? (
<Text dimColor>No categories available.</Text>
) : (
<>
{pickerCategories.map((category, index) => {
const isSelected = category === selectedPickerCategory;
return (
<Box key={category} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
<Text color={isSelected ? 'green' : undefined}>
{isSelected ? '▶ ' : ' '}
</Text>
</Box>
<Text color={isSelected ? 'green' : undefined}>
{`${index + 1}. ${category}`}
</Text>
</Box>
);
})}
{selectedPickerCategory === 'All' && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>Search across all widget categories.</Text>
</Box>
)}
</>
)
)
) : (
pickerEntries.length === 0 ? (
<Text dimColor>No widgets match the current category/search.</Text>
) : (
<>
{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}>
<Text color={isSelected ? 'green' : undefined}>
{isSelected ? '▶ ' : ' '}
</Text>
</Box>
<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>
);
})}
{selectedPickerEntry && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>{selectedPickerEntry.description}</Text>
</Box>
)}
</>
)
)}
</Box>
)}
{!widgetPicker && (
<Box marginTop={1} flexDirection='column'>
{widgets.length === 0 ? (
<Text dimColor>No widgets. Press 'a' to add one.</Text>
) : (
<>
{widgets.map((widget, index) => {
const isSelected = index === selectedIndex;
const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null;
const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) };
const supportsRawValue = widgetImpl?.supportsRawValue() ?? false;
return (
<Box key={widget.id} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
{isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '}
</Text>
</Box>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
{`${index + 1}. ${displayText || getWidgetDisplay(widget)}`}
</Text>
{modifierText && (
<Text dimColor>
{' '}
{modifierText}
</Text>
)}
{supportsRawValue && widget.rawValue && <Text dimColor> (raw value)</Text>}
{widget.merge === true && <Text dimColor> (merged)</Text>}
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad)</Text>}
</Box>
);
})}
{/* Display description for selected widget */}
{currentWidget && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>
{(() => {
if (currentWidget.type === 'separator') {
return 'A separator character between status line widgets';
} else if (currentWidget.type === 'flex-separator') {
return 'Expands to fill available terminal width';
} else {
const widgetImpl = getWidget(currentWidget.type);
return widgetImpl.getDescription();
} catch {
return 'Widget description not available';
return widgetImpl ? widgetImpl.getDescription() : 'Unknown widget type';
}
}
})()}
</Text>
</Box>
)}
</>
)}
</Box>
})()}
</Text>
</Box>
)}
</>
)}
</Box>
)}
</Box>
);
};
+238 -46
View File
@@ -3,49 +3,146 @@ import {
Text,
useInput
} from 'ink';
import React, { useState } from 'react';
import pluralize from 'pluralize';
import React, {
useEffect,
useMemo,
useState
} from 'react';
import type { Settings } from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import { ConfirmDialog } from './ConfirmDialog';
import { List } from './List';
interface LineSelectorProps {
lines: WidgetItem[][];
onSelect: (line: number) => void;
onBack: () => void;
onLinesUpdate: (lines: WidgetItem[][]) => void;
initialSelection?: number;
title?: string;
blockIfPowerlineActive?: boolean;
settings?: Settings;
allowEditing?: boolean;
}
const LineSelector: React.FC<LineSelectorProps> = ({ lines, onSelect, onBack, initialSelection = 0, title, blockIfPowerlineActive = false, settings }) => {
const LineSelector: React.FC<LineSelectorProps> = ({
lines,
onSelect,
onBack,
onLinesUpdate,
initialSelection = 0,
title,
blockIfPowerlineActive = false,
settings,
allowEditing = false
}) => {
const [selectedIndex, setSelectedIndex] = useState(initialSelection);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [moveMode, setMoveMode] = useState(false);
const [localLines, setLocalLines] = useState(lines);
useEffect(() => {
setLocalLines(lines);
}, [lines]);
useEffect(() => {
setSelectedIndex(initialSelection);
}, [initialSelection]);
const selectedLine = useMemo(
() => localLines[selectedIndex],
[localLines, selectedIndex]
);
const appendLine = () => {
const newLines = [...localLines, []];
setLocalLines(newLines);
onLinesUpdate(newLines);
setSelectedIndex(newLines.length - 1);
};
const deleteLine = (lineIndex: number) => {
// Don't allow deleting the last remaining line
if (localLines.length <= 1) {
return;
}
const newLines = [...localLines];
newLines.splice(lineIndex, 1);
setLocalLines(newLines);
onLinesUpdate(newLines);
};
// Check if powerline theme is managing colors
const powerlineEnabled = settings ? settings.powerline.enabled : false;
const powerlineTheme = settings ? settings.powerline.theme : undefined;
const isThemeManaged = blockIfPowerlineActive && powerlineEnabled && powerlineTheme && powerlineTheme !== 'custom';
const isThemeManaged
= blockIfPowerlineActive
&& powerlineEnabled
&& powerlineTheme
&& powerlineTheme !== 'custom';
// Handle keyboard input
useInput((input, key) => {
if (showDeleteDialog) {
return;
}
// If theme-managed and blocking is enabled, any key goes back
if (isThemeManaged) {
onBack();
return;
}
if (moveMode) {
if (key.upArrow && selectedIndex > 0) {
const newLines = [...localLines];
const temp = newLines[selectedIndex];
const prev = newLines[selectedIndex - 1];
if (temp && prev) {
[newLines[selectedIndex], newLines[selectedIndex - 1]] = [prev, temp];
}
setLocalLines(newLines);
onLinesUpdate(newLines);
setSelectedIndex(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < localLines.length - 1) {
const newLines = [...localLines];
const temp = newLines[selectedIndex];
const next = newLines[selectedIndex + 1];
if (temp && next) {
[newLines[selectedIndex], newLines[selectedIndex + 1]] = [next, temp];
}
setLocalLines(newLines);
onLinesUpdate(newLines);
setSelectedIndex(selectedIndex + 1);
} else if (key.escape || key.return) {
setMoveMode(false);
}
return;
}
switch (input) {
case 'a':
if (allowEditing) {
appendLine();
}
return;
case 'd':
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
setShowDeleteDialog(true);
}
return;
case 'm':
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
setMoveMode(true);
}
return;
}
if (key.escape) {
onBack();
} else if (key.upArrow) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow) {
setSelectedIndex(Math.min(3, selectedIndex + 1)); // 0-2 for lines, 3 for back
} else if (key.return) {
if (selectedIndex === 3) {
onBack();
} else {
onSelect(selectedIndex);
}
}
});
@@ -57,17 +154,23 @@ const LineSelector: React.FC<LineSelectorProps> = ({ lines, onSelect, onBack, in
<Box marginTop={1}>
<Text color='yellow'>
Colors are currently managed by the Powerline theme:
{' ' + powerlineTheme.charAt(0).toUpperCase() + powerlineTheme.slice(1)}
{' '
+ powerlineTheme.charAt(0).toUpperCase()
+ powerlineTheme.slice(1)}
</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>To customize colors, either:</Text>
</Box>
<Box marginLeft={2}>
<Text dimColor> Change to 'Custom' theme in Powerline Configuration Themes</Text>
<Text dimColor>
Change to 'Custom' theme in Powerline Configuration Themes
</Text>
</Box>
<Box marginLeft={2}>
<Text dimColor> Disable Powerline mode in Powerline Configuration</Text>
<Text dimColor>
Disable Powerline mode in Powerline Configuration
</Text>
</Box>
<Box marginTop={2}>
<Text>Press any key to go back...</Text>
@@ -76,42 +179,131 @@ const LineSelector: React.FC<LineSelectorProps> = ({ lines, onSelect, onBack, in
);
}
return (
<Box flexDirection='column'>
<Text bold>{title ?? 'Select Line to Edit'}</Text>
<Text dimColor>Choose which status line to configure (up to 3 lines supported)</Text>
<Text dimColor>Press ESC to go back</Text>
<Box marginTop={1} flexDirection='column'>
<Box>
<Text color={selectedIndex === 0 ? 'green' : undefined}>
{selectedIndex === 0 ? '▶ ' : ' '}
Line 1
{lines[0] && lines[0].length > 0 ? ` (${lines[0].length} widgets)` : ' (empty)'}
</Text>
</Box>
<Box>
<Text color={selectedIndex === 1 ? 'green' : undefined}>
{selectedIndex === 1 ? '▶ ' : ' '}
Line 2
{lines[1] && lines[1].length > 0 ? ` (${lines[1].length} widgets)` : ' (empty)'}
</Text>
</Box>
<Box>
<Text color={selectedIndex === 2 ? 'green' : undefined}>
{selectedIndex === 2 ? '▶ ' : ' '}
Line 3
{lines[2] && lines[2].length > 0 ? ` (${lines[2].length} widgets)` : ' (empty)'}
if (showDeleteDialog && selectedLine) {
const suffix
= selectedLine.length > 0
? pluralize('widget', selectedLine.length, true)
: 'empty';
return (
<Box flexDirection='column'>
<Box flexDirection='column' gap={1}>
<Text bold>
<Text>
<Text>
Line
{selectedIndex + 1}
</Text>
{' '}
<Text dimColor>
(
{suffix}
)
</Text>
</Text>
</Text>
<Text bold>Are you sure you want to delete line?</Text>
</Box>
<Box marginTop={1}>
<Text color={selectedIndex === 3 ? 'green' : undefined}>
{selectedIndex === 3 ? '▶ ' : ' '}
Back
</Text>
<ConfirmDialog
inline={true}
onConfirm={() => {
deleteLine(selectedIndex);
setSelectedIndex(Math.max(0, selectedIndex - 1));
setShowDeleteDialog(false);
}}
onCancel={() => {
setShowDeleteDialog(false);
}}
/>
</Box>
</Box>
</Box>
);
}
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'>
<Box>
<Text bold>
{title ?? 'Select Line to Edit'}
{' '}
</Text>
{moveMode && <Text color='blue'>[MOVE MODE]</Text>}
</Box>
<Text dimColor>
Choose which status line to configure
</Text>
{moveMode ? (
<Text dimColor> to move line, ESC or Enter to exit move mode</Text>
) : (
<Text dimColor>
{allowEditing ? (
localLines.length > 1
? '(a) to append new line, (d) to delete line, (m) to move line, ESC to go back'
: '(a) to append new line, ESC to go back'
) : 'ESC to go back'}
</Text>
)}
{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 ? 'blue' : undefined}>
<Text>{isSelected ? '◆ ' : ' '}</Text>
<Text>
<Text>
Line
{' '}
{index + 1}
</Text>
{' '}
<Text dimColor={!isSelected}>
(
{suffix}
)
</Text>
</Text>
</Text>
</Box>
);
})}
</Box>
) : (
<List
marginTop={1}
items={lineItems}
onSelect={(line) => {
if (line === 'back') {
onBack();
return;
}
onSelect(line);
}}
onSelectionChange={(_, index) => {
setSelectedIndex(index);
}}
initialSelection={selectedIndex}
showBackButton={true}
/>
)}
</Box>
</>
);
};
+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 = false,
...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>;
}
+118 -83
View File
@@ -1,15 +1,26 @@
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'
| 'starGithub'
| 'save'
| 'exit';
export interface MainMenuProps {
onSelect: (value: string) => void;
onSelect: (value: MainMenuOption, index: number) => void;
isClaudeInstalled: boolean;
hasChanges: boolean;
initialSelection?: number;
@@ -18,103 +29,127 @@ 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,
{
label: isClaudeInstalled
? '🔌 Uninstall from Claude Code'
: '📦 Install to Claude Code',
value: 'install',
description: isClaudeInstalled
? 'Remove ccstatusline from your Claude Code settings'
: '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: '💾 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 });
menuItems.push(
{
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) {
onSelect(item.value);
}
}
});
// Get description for selected item
const getDescription = (value: string): string => {
const descriptions: Record<string, string> = {
lines: 'Configure up to 3 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',
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>
);
};
+47 -41
View File
@@ -6,6 +6,7 @@ import {
import React, { useState } from 'react';
import type { Settings } from '../../types/Settings';
import { shouldInsertInput } from '../../utils/input-guards';
export type EditorMode = 'separator' | 'startCap' | 'endCap';
@@ -27,12 +28,12 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
// Get the appropriate array based on mode
const getItems = () => {
switch (mode) {
case 'separator':
return powerlineConfig.separators;
case 'startCap':
return powerlineConfig.startCaps;
case 'endCap':
return powerlineConfig.endCaps;
case 'separator':
return powerlineConfig.separators;
case 'startCap':
return powerlineConfig.startCaps;
case 'endCap':
return powerlineConfig.endCaps;
}
};
@@ -82,24 +83,25 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
const inversionText = mode === 'separator' && invertBg ? ' [Inverted]' : '';
return `${preset.char} - ${preset.name}${inversionText}`;
}
const hexCode = char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0');
return `${char} - Custom (\\u${hexCode})${invertBg ? ' [Inverted]' : ''}`;
const codePoint = char.codePointAt(0) ?? 0;
const hexCode = codePoint.toString(16).toUpperCase().padStart(4, '0');
return `${char} - Custom (U+${hexCode})${invertBg ? ' [Inverted]' : ''}`;
};
const updateSeparators = (newSeparators: string[], newInvertBgs?: boolean[]) => {
const updatedPowerline = { ...powerlineConfig };
switch (mode) {
case 'separator':
updatedPowerline.separators = newSeparators;
updatedPowerline.separatorInvertBackground = newInvertBgs ?? newSeparators.map((_, i) => invertBgs[i] ?? false);
break;
case 'startCap':
updatedPowerline.startCaps = newSeparators;
break;
case 'endCap':
updatedPowerline.endCaps = newSeparators;
break;
case 'separator':
updatedPowerline.separators = newSeparators;
updatedPowerline.separatorInvertBackground = newInvertBgs ?? newSeparators.map((_, i) => invertBgs[i] ?? false);
break;
case 'startCap':
updatedPowerline.startCaps = newSeparators;
break;
case 'endCap':
updatedPowerline.endCaps = newSeparators;
break;
}
onUpdate({
@@ -116,24 +118,27 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
setHexInput('');
setCursorPos(0);
} else if (key.return) {
if (hexInput.length === 4) {
const char = String.fromCharCode(parseInt(hexInput, 16));
const newSeparators = [...separators];
if (separators.length === 0) {
// Add new item if list is empty
newSeparators.push(char);
} else {
newSeparators[selectedIndex] = char;
if (hexInput.length >= 4 && hexInput.length <= 6) {
const codePoint = parseInt(hexInput, 16);
if (codePoint >= 0 && codePoint <= 0x10FFFF) {
const char = String.fromCodePoint(codePoint);
const newSeparators = [...separators];
if (separators.length === 0) {
// Add new item if list is empty
newSeparators.push(char);
} else {
newSeparators[selectedIndex] = char;
}
updateSeparators(newSeparators);
setHexInputMode(false);
setHexInput('');
setCursorPos(0);
}
updateSeparators(newSeparators);
setHexInputMode(false);
setHexInput('');
setCursorPos(0);
}
} else if (key.backspace && cursorPos > 0) {
setHexInput(hexInput.slice(0, cursorPos - 1) + hexInput.slice(cursorPos));
setCursorPos(cursorPos - 1);
} else if (input && /[0-9a-fA-F]/.test(input) && hexInput.length < 4) {
} else if (shouldInsertInput(input, key) && /[0-9a-fA-F]/.test(input) && hexInput.length < 6) {
setHexInput(hexInput.slice(0, cursorPos) + input.toUpperCase() + hexInput.slice(cursorPos));
setCursorPos(cursorPos + 1);
}
@@ -256,12 +261,12 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
const getTitle = () => {
switch (mode) {
case 'separator':
return 'Powerline Separator Configuration';
case 'startCap':
return 'Powerline Start Cap Configuration';
case 'endCap':
return 'Powerline End Cap Configuration';
case 'separator':
return 'Powerline Separator Configuration';
case 'startCap':
return 'Powerline Start Cap Configuration';
case 'endCap':
return 'Powerline End Cap Configuration';
}
};
@@ -275,20 +280,21 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
{hexInputMode ? (
<Box marginTop={2} flexDirection='column'>
<Text>
Enter 4-digit hex code for
Enter hex code (4-6 digits) for
{' '}
{mode === 'separator' ? 'separator' : 'cap'}
{separators.length > 0 ? ` ${selectedIndex + 1}` : ''}
:
</Text>
<Text>
\u
U+
{hexInput.slice(0, cursorPos)}
<Text backgroundColor='gray' color='black'>{hexInput[cursorPos] ?? '_'}</Text>
{hexInput.slice(cursorPos + 1)}
{hexInput.length < 4 && hexInput.length === cursorPos && <Text dimColor>{'_'.repeat(4 - hexInput.length - 1)}</Text>}
{hexInput.length < 6 && hexInput.length === cursorPos && <Text dimColor>{'_'.repeat(6 - hexInput.length - 1)}</Text>}
</Text>
<Text dimColor>Enter 4 hex digits (0-9, A-F), then press Enter. ESC to cancel.</Text>
<Text dimColor>Enter 4-6 hex digits (0-9, A-F) for a Unicode code point, then press Enter. ESC to cancel.</Text>
<Text dimColor>Examples: E0B0 (powerline), 1F984 (🦄), 2764 ()</Text>
</Box>
) : (
<>
+185 -198
View File
@@ -6,14 +6,139 @@ import {
import * as os from 'os';
import React, { useState } from 'react';
import type { PowerlineConfig } from '../../types/PowerlineConfig';
import type { Settings } from '../../types/Settings';
import { getDefaultPowerlineTheme } from '../../utils/colors';
import { type PowerlineFontStatus } from '../../utils/powerline';
import { buildEnabledPowerlineSettings } from '../../utils/powerline-settings';
import { ConfirmDialog } from './ConfirmDialog';
import {
List,
type ListEntry
} from './List';
import { PowerlineSeparatorEditor } from './PowerlineSeparatorEditor';
import { PowerlineThemeSelector } from './PowerlineThemeSelector';
type PowerlineMenuValue = 'separator' | 'startCap' | 'endCap' | 'themes';
type Screen = 'menu' | PowerlineMenuValue;
const POWERLINE_MENU_LABEL_WIDTH = 11;
function formatPowerlineMenuLabel(label: string): string {
return label.padEnd(POWERLINE_MENU_LABEL_WIDTH, ' ');
}
export function getSeparatorDisplay(powerlineConfig: PowerlineConfig): string {
const seps = powerlineConfig.separators;
if (seps.length > 1) {
return 'multiple';
}
const sep = seps[0] ?? '\uE0B0';
const presets = [
{ char: '\uE0B0', name: 'Triangle Right' },
{ char: '\uE0B2', name: 'Triangle Left' },
{ char: '\uE0B4', name: 'Round Right' },
{ char: '\uE0B6', name: 'Round Left' }
];
const preset = presets.find(item => item.char === sep);
if (preset) {
return `${preset.char} - ${preset.name}`;
}
return `${sep} - Custom`;
}
export function getCapDisplay(
powerlineConfig: PowerlineConfig,
type: 'start' | 'end'
): string {
const caps = type === 'start'
? powerlineConfig.startCaps
: powerlineConfig.endCaps;
if (caps.length === 0) {
return 'none';
}
if (caps.length > 1) {
return 'multiple';
}
const cap = caps[0];
if (!cap) {
return 'none';
}
const presets = type === 'start' ? [
{ char: '\uE0B2', name: 'Triangle' },
{ char: '\uE0B6', name: 'Round' },
{ char: '\uE0BA', name: 'Lower Triangle' },
{ char: '\uE0BE', name: 'Diagonal' }
] : [
{ char: '\uE0B0', name: 'Triangle' },
{ char: '\uE0B4', name: 'Round' },
{ char: '\uE0B8', name: 'Lower Triangle' },
{ char: '\uE0BC', name: 'Diagonal' }
];
const preset = presets.find(item => item.char === cap);
if (preset) {
return `${preset.char} - ${preset.name}`;
}
return `${cap} - Custom`;
}
export function getThemeDisplay(powerlineConfig: PowerlineConfig): string {
const theme = powerlineConfig.theme;
if (!theme || theme === 'custom') {
return 'Custom';
}
return theme.charAt(0).toUpperCase() + theme.slice(1);
}
export function buildPowerlineSetupMenuItems(
powerlineConfig: PowerlineConfig
): ListEntry<PowerlineMenuValue>[] {
const disabled = !powerlineConfig.enabled;
return [
{
label: formatPowerlineMenuLabel('Separator'),
sublabel: `(${getSeparatorDisplay(powerlineConfig)})`,
value: 'separator',
disabled,
description: 'Choose the glyph used between powerline segments.'
},
{
label: formatPowerlineMenuLabel('Start Cap'),
sublabel: `(${getCapDisplay(powerlineConfig, 'start')})`,
value: 'startCap',
disabled,
description: 'Configure the cap glyph that appears at the start of each powerline line.'
},
{
label: formatPowerlineMenuLabel('End Cap'),
sublabel: `(${getCapDisplay(powerlineConfig, 'end')})`,
value: 'endCap',
disabled,
description: 'Configure the cap glyph that appears at the end of each powerline line.'
},
{
label: formatPowerlineMenuLabel('Themes'),
sublabel: `(${getThemeDisplay(powerlineConfig)})`,
value: 'themes',
disabled,
description: 'Preview built-in powerline themes or copy a theme into custom widget colors.'
}
];
}
export interface PowerlineSetupProps {
settings: Settings;
powerlineFontStatus: PowerlineFontStatus;
@@ -25,8 +150,6 @@ export interface PowerlineSetupProps {
onClearMessage: () => void;
}
type Screen = 'menu' | 'separator' | 'startCap' | 'endCap' | 'themes';
export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
settings,
powerlineFontStatus,
@@ -43,155 +166,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 {
// Set to nord theme if currently custom or undefined (first time enabling)
const theme = (!powerlineConfig.theme || powerlineConfig.theme === 'custom')
? getDefaultPowerlineTheme()
: powerlineConfig.theme;
// Enable directly without confirmation since there are no separators
const updatedSettings = {
...settings,
powerline: {
...powerlineConfig,
enabled: true,
theme,
// Separators are already initialized by Zod
separators: powerlineConfig.separators,
separatorInvertBackground: powerlineConfig.separatorInvertBackground
},
defaultPadding: ' ' // Set padding to space when enabling powerline
};
onUpdate(updatedSettings);
onUpdate(buildEnabledPowerlineSettings(settings, false));
}
} else {
// Disable without confirmation
const newConfig = { ...powerlineConfig, enabled: false };
onUpdate({ ...settings, powerline: newConfig });
onUpdate({
...settings,
powerline: {
...powerlineConfig,
enabled: false
}
});
}
} else if (input === 'i' || input === 'I') {
// Show font installation consent prompt
setConfirmingFontInstall(true);
} else if ((input === 'a' || input === 'A') && powerlineConfig.enabled) {
// Toggle autoAlign when powerline is enabled
const newConfig = { ...powerlineConfig, autoAlign: !powerlineConfig.autoAlign };
onUpdate({ ...settings, powerline: newConfig });
onUpdate({
...settings,
powerline: {
...powerlineConfig,
autoAlign: !powerlineConfig.autoAlign
}
});
} else if ((input === 'c' || input === 'C') && powerlineConfig.enabled) {
onUpdate({
...settings,
powerline: {
...powerlineConfig,
continueThemeAcrossLines: !powerlineConfig.continueThemeAcrossLines
}
});
}
}
});
// Render sub-screens
if (screen === 'separator') {
return (
<PowerlineSeparatorEditor
@@ -235,7 +266,6 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
);
}
// Main menu screen
return (
<Box flexDirection='column'>
{!confirmingFontInstall && !installingFonts && !fontInstallMessage && (
@@ -324,28 +354,7 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
<ConfirmDialog
inline={true}
onConfirm={() => {
// Set to nord theme if currently custom or undefined (first time enabling)
const theme = (!powerlineConfig.theme || powerlineConfig.theme === 'custom')
? getDefaultPowerlineTheme()
: powerlineConfig.theme;
// Remove all separators and flex-separators from lines
// Also set default padding to a space when enabling powerline
const updatedSettings = {
...settings,
powerline: {
...powerlineConfig,
enabled: true,
theme,
// Separators are already initialized by Zod
separators: powerlineConfig.separators,
separatorInvertBackground: powerlineConfig.separatorInvertBackground
},
defaultPadding: ' ', // Set padding to space when enabling powerline
lines: settings.lines.map(line => line.filter(item => item.type !== 'separator' && item.type !== 'flex-separator')
)
};
onUpdate(updatedSettings);
onUpdate(buildEnabledPowerlineSettings(settings, true));
setConfirmingEnable(false);
}}
onCancel={() => {
@@ -404,70 +413,48 @@ 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>
+137 -128
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,40 +204,30 @@ 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>
+23 -13
View File
@@ -5,17 +5,18 @@ import {
} from 'ink';
import React from 'react';
import type { RenderContext } from '../../types/RenderContext';
import type { Settings } from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import { advanceGlobalPowerlineThemeIndex } from '../../utils/powerline-theme-index';
import {
calculateMaxWidthsFromPreRendered,
preRenderAllWidgets,
renderStatusLineWithInfo,
type PreRenderedWidget,
type RenderContext,
type RenderResult
} from '../../utils/renderer';
import { canDetectTerminalWidth } from '../../utils/terminal';
import { advanceGlobalSeparatorIndex } from '../../utils/separator-index';
export interface StatusLinePreviewProps {
lines: WidgetItem[][];
@@ -27,10 +28,10 @@ export interface StatusLinePreviewProps {
const renderSingleLine = (
widgets: WidgetItem[],
terminalWidth: number,
widthDetectionAvailable: boolean,
settings: Settings,
lineIndex: number,
globalSeparatorIndex: number,
globalPowerlineThemeIndex: number,
preRenderedWidgets: PreRenderedWidget[],
preCalculatedMaxWidths: number[]
): RenderResult => {
@@ -38,16 +39,16 @@ const renderSingleLine = (
const context: RenderContext = {
terminalWidth,
isPreview: true,
minimalist: settings.minimalistMode,
lineIndex,
globalSeparatorIndex
globalSeparatorIndex,
globalPowerlineThemeIndex
};
return renderStatusLineWithInfo(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
};
export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, terminalWidth, settings, onTruncationChange }) => {
const widthDetectionAvailable = React.useMemo(() => canDetectTerminalWidth(), []);
// Render each configured line
// Pass the full terminal width - the renderer will handle preview adjustments
const { renderedLines, anyTruncated } = React.useMemo(() => {
@@ -55,10 +56,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;
@@ -66,22 +68,30 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
const lineItems = lines[i];
if (lineItems && lineItems.length > 0) {
const preRenderedWidgets = preRenderedLines[i] ?? [];
const renderResult = renderSingleLine(lineItems, terminalWidth, widthDetectionAvailable, settings, i, globalSeparatorIndex, preRenderedWidgets, preCalculatedMaxWidths);
const renderResult = renderSingleLine(
lineItems,
terminalWidth,
settings,
i,
globalSeparatorIndex,
globalPowerlineThemeIndex,
preRenderedWidgets,
preCalculatedMaxWidths
);
result.push(renderResult.line);
if (renderResult.wasTruncated) {
truncated = true;
}
// Count separators used in this line (widgets - 1, excluding merged widgets)
const nonMergedWidgets = lineItems.filter((_, idx) => idx === lineItems.length - 1 || !lineItems[idx]?.merge);
if (nonMergedWidgets.length > 1) {
globalSeparatorIndex += nonMergedWidgets.length - 1;
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems);
if (settings.powerline.enabled && settings.powerline.continueThemeAcrossLines) {
globalPowerlineThemeIndex = advanceGlobalPowerlineThemeIndex(globalPowerlineThemeIndex, preRenderedWidgets);
}
}
}
return { renderedLines: result, anyTruncated: truncated };
}, [lines, terminalWidth, widthDetectionAvailable, settings]);
}, [lines, terminalWidth, settings]);
// Notify parent when truncation status changes
React.useEffect(() => {
+97 -153
View File
@@ -7,10 +7,56 @@ import {
import React, { useState } from 'react';
import type { Settings } from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import { getWidget } from '../../utils/widgets';
import {
hasCustomWidgetColors,
sanitizeLinesForColorLevel
} from '../../utils/color-sanitize';
import { ConfirmDialog } from './ConfirmDialog';
import {
List,
type ListEntry
} from './List';
type TerminalOptionsValue = 'width' | 'colorLevel';
export function getNextColorLevel(level: 0 | 1 | 2 | 3): 0 | 1 | 2 | 3 {
return ((level + 1) % 4) as 0 | 1 | 2 | 3;
}
export function shouldWarnOnColorLevelChange(
currentLevel: 0 | 1 | 2 | 3,
nextLevel: 0 | 1 | 2 | 3,
hasCustomColors: boolean
): boolean {
return hasCustomColors
&& ((currentLevel === 2 && nextLevel !== 2)
|| (currentLevel === 3 && nextLevel !== 3));
}
export function buildTerminalOptionsItems(
colorLevel: 0 | 1 | 2 | 3
): ListEntry<TerminalOptionsValue>[] {
return [
{
label: '◱ Terminal Width',
value: 'width',
description: 'Configure how the status line uses available terminal width and when it should compact.'
},
{
label: '▓ Color Level',
sublabel: `(${getColorLevelLabel(colorLevel)})`,
value: 'colorLevel',
description: [
'Color level affects how colors are rendered:',
'• Truecolor: Full 24-bit RGB colors (16.7M colors)',
'• 256 Color: Extended color palette (256 colors)',
'• Basic: Standard 16-color terminal palette',
'• No Color: Disables all color output'
].join('\n')
}
];
}
export interface TerminalOptionsMenuProps {
settings: Settings;
@@ -18,116 +64,51 @@ export interface TerminalOptionsMenuProps {
onBack: (target?: string) => void;
}
export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settings, onUpdate, onBack }) => {
export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({
settings,
onUpdate,
onBack
}) => {
const [showColorWarning, setShowColorWarning] = useState(false);
const [pendingColorLevel, setPendingColorLevel] = useState<0 | 1 | 2 | 3 | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const handleSelect = () => {
if (selectedIndex === 2) {
// Back button
const handleSelect = (value: TerminalOptionsValue | 'back') => {
if (value === 'back') {
onBack();
} else if (selectedIndex === 0) {
// Terminal Width Options
onBack('width');
} else if (selectedIndex === 1) {
// Color Level
// Check if there are any custom colors that would be lost
const hasCustomColors = settings.lines.some((line: WidgetItem[]) => line.some((widget: WidgetItem) => Boolean(widget.color && (widget.color.startsWith('ansi256:') || widget.color.startsWith('hex:')))
|| Boolean(widget.backgroundColor && (widget.backgroundColor.startsWith('ansi256:') || widget.backgroundColor.startsWith('hex:')))
)
);
const currentLevel = settings.colorLevel;
const nextLevel = ((currentLevel + 1) % 4) as 0 | 1 | 2 | 3;
// Warn if switching away from mode that supports custom colors
if (hasCustomColors
&& ((currentLevel === 2 && nextLevel !== 2) // Switching from 256 color mode
|| (currentLevel === 3 && nextLevel !== 3))) { // Switching from truecolor mode
setShowColorWarning(true);
setPendingColorLevel(nextLevel);
} else {
// Update chalk level immediately
chalk.level = nextLevel;
// Clean up incompatible custom colors even when no warning is shown
const cleanedLines = settings.lines.map(line => line.map((widget) => {
const newWidget = { ...widget };
// Remove custom colors incompatible with the new mode
if (nextLevel === 2) {
// Switching to 256 color mode - remove hex colors
if (widget.color?.startsWith('hex:')) {
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
newWidget.color = widgetImpl.getDefaultColor();
}
}
if (widget.backgroundColor?.startsWith('hex:')) {
newWidget.backgroundColor = undefined;
}
} else if (nextLevel === 3) {
// Switching to truecolor mode - remove ansi256 colors
if (widget.color?.startsWith('ansi256:')) {
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
newWidget.color = widgetImpl.getDefaultColor();
}
}
if (widget.backgroundColor?.startsWith('ansi256:')) {
newWidget.backgroundColor = undefined;
}
} else {
// Switching to 16 color mode - remove all custom colors
if (widget.color?.startsWith('ansi256:') || widget.color?.startsWith('hex:')) {
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
newWidget.color = widgetImpl.getDefaultColor();
}
}
if (widget.backgroundColor?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('hex:')) {
newWidget.backgroundColor = undefined;
}
}
return newWidget;
})
);
onUpdate({
...settings,
lines: cleanedLines,
colorLevel: nextLevel
});
}
return;
}
if (value === 'width') {
onBack('width');
return;
}
const hasCustomColors = hasCustomWidgetColors(settings.lines);
const currentLevel = settings.colorLevel;
const nextLevel = getNextColorLevel(currentLevel);
if (shouldWarnOnColorLevelChange(currentLevel, nextLevel, hasCustomColors)) {
setShowColorWarning(true);
setPendingColorLevel(nextLevel);
return;
}
chalk.level = nextLevel;
const cleanedLines = sanitizeLinesForColorLevel(settings.lines, nextLevel);
onUpdate({
...settings,
lines: cleanedLines,
colorLevel: nextLevel
});
};
const handleColorConfirm = () => {
// Proceed with color level change and clean up custom colors
if (pendingColorLevel !== null) {
chalk.level = pendingColorLevel;
// Clean up custom colors if switching away from modes that support them
const cleanedLines = settings.lines.map(line => line.map((widget) => {
const newWidget = { ...widget };
// Remove custom colors if switching to a mode that doesn't support them
if ((pendingColorLevel !== 2 && pendingColorLevel !== 3)
|| (pendingColorLevel === 2 && (widget.color?.startsWith('hex:') || widget.backgroundColor?.startsWith('hex:')))
|| (pendingColorLevel === 3 && (widget.color?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('ansi256:')))) {
// Reset custom colors to defaults
if (widget.color?.startsWith('ansi256:') || widget.color?.startsWith('hex:')) {
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
newWidget.color = widgetImpl.getDefaultColor();
}
}
if (widget.backgroundColor?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('hex:')) {
newWidget.backgroundColor = undefined;
}
}
return newWidget;
})
);
const cleanedLines = sanitizeLinesForColorLevel(settings.lines, pendingColorLevel);
onUpdate({
...settings,
@@ -144,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();
}
});
@@ -179,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>
@@ -220,11 +164,11 @@ export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settin
export const getColorLevelLabel = (level?: 0 | 1 | 2 | 3): string => {
switch (level) {
case 0: return 'No Color';
case 1: return 'Basic';
case 2:
case undefined: return '256 Color (default)';
case 3: return 'Truecolor';
default: return '256 Color (default)';
case 0: return 'No Color';
case 1: return 'Basic';
case 2:
case undefined: return '256 Color (default)';
case 3: return 'Truecolor';
default: return '256 Color (default)';
}
};
+95 -95
View File
@@ -7,6 +7,62 @@ import React, { useState } from 'react';
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;
@@ -14,32 +70,28 @@ export interface TerminalWidthMenuProps {
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,
@@ -58,66 +110,21 @@ export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({ settings,
setValidationError(null);
} else if (key.delete) {
// For simple number inputs, forward delete does nothing since there's no cursor position
} else if (input && /\d/.test(input)) {
} else if (shouldInsertInput(input, key) && /\d/.test(input)) {
const newValue = thresholdInput + input;
if (newValue.length <= 2) {
setThresholdInput(newValue);
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>
@@ -139,38 +146,31 @@ 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,178 @@
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 {
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();
}
});
});
@@ -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,44 @@
import {
describe,
expect,
it
} from 'vitest';
import {
buildTerminalOptionsItems,
getNextColorLevel,
shouldWarnOnColorLevelChange
} from '../TerminalOptionsMenu';
describe('TerminalOptionsMenu helpers', () => {
it('cycles color levels in order', () => {
expect(getNextColorLevel(0)).toBe(1);
expect(getNextColorLevel(1)).toBe(2);
expect(getNextColorLevel(2)).toBe(3);
expect(getNextColorLevel(3)).toBe(0);
});
it('warns only when custom colors would be lost', () => {
expect(shouldWarnOnColorLevelChange(2, 3, true)).toBe(true);
expect(shouldWarnOnColorLevelChange(3, 0, true)).toBe(true);
expect(shouldWarnOnColorLevelChange(2, 2, true)).toBe(false);
expect(shouldWarnOnColorLevelChange(1, 2, true)).toBe(false);
expect(shouldWarnOnColorLevelChange(3, 0, false)).toBe(false);
});
it('builds terminal options list items with the current color level label', () => {
const items = buildTerminalOptionsItems(2);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
label: '◱ Terminal Width',
value: 'width'
});
expect(items[1]).toMatchObject({
label: '▓ Color Level',
sublabel: '(256 Color (default))',
value: 'colorLevel'
});
expect(items[1]?.description).toContain('Truecolor');
});
});
@@ -0,0 +1,169 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../../types/Settings';
import {
TerminalWidthMenu,
buildTerminalWidthItems,
getTerminalWidthSelectionIndex,
validateCompactThresholdInput
} from '../TerminalWidthMenu';
class MockTtyStream extends PassThrough {
isTTY = true;
columns = 120;
rows = 40;
setRawMode() {
return this;
}
ref() {
return this;
}
unref() {
return this;
}
}
interface CapturedWriteStream extends NodeJS.WriteStream {
clearOutput: () => void;
getOutput: () => string;
}
function createMockStdin(): NodeJS.ReadStream {
return new MockTtyStream() as unknown as NodeJS.ReadStream;
}
function createMockStdout(): CapturedWriteStream {
const stream = new MockTtyStream();
const chunks: string[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(chunk.toString());
});
return Object.assign(stream as unknown as NodeJS.WriteStream, {
clearOutput() {
chunks.length = 0;
},
getOutput() {
return chunks.join('');
}
});
}
function flushInk() {
return new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
describe('TerminalWidthMenu helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('validates compact threshold input', () => {
expect(validateCompactThresholdInput('')).toBe('Please enter a valid number');
expect(validateCompactThresholdInput('0')).toBe('Value must be between 1 and 99 (you entered 0)');
expect(validateCompactThresholdInput('100')).toBe('Value must be between 1 and 99 (you entered 100)');
expect(validateCompactThresholdInput('42')).toBeNull();
});
it('builds terminal width menu items with active and threshold sublabels', () => {
const items = buildTerminalWidthItems('full-until-compact', 60);
expect(items).toHaveLength(3);
expect(items[0]).toMatchObject({
label: 'Full width always',
value: 'full'
});
expect(items[1]).toMatchObject({
label: 'Full width minus 40',
sublabel: '(default)',
value: 'full-minus-40'
});
expect(items[2]).toMatchObject({
label: 'Full width until compact',
sublabel: '(threshold 60%, active)',
value: 'full-until-compact'
});
expect(items[2]?.description).toContain('60%');
});
it('returns the current option index for list selection', () => {
expect(getTerminalWidthSelectionIndex('full')).toBe(0);
expect(getTerminalWidthSelectionIndex('full-minus-40')).toBe(1);
expect(getTerminalWidthSelectionIndex('full-until-compact')).toBe(2);
});
it('keeps full-until-compact selected after confirming the threshold prompt', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();
const instance = render(
React.createElement(TerminalWidthMenu, {
settings: {
...DEFAULT_SETTINGS,
flexMode: 'full',
compactThreshold: 60
},
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('\u001B[B');
await flushInk();
stdin.write('\u001B[B');
await flushInk();
stdin.write('\r');
await flushInk();
expect(stdout.getOutput()).toContain('Enter compact threshold (1-99):');
stdout.clearOutput();
stdin.write('\r');
await flushInk();
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({
flexMode: 'full-until-compact',
compactThreshold: 60
}));
const output = stdout.getOutput();
expect(output).toContain('▶ Full width until compact');
expect(output).not.toContain('▶ Full width always');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
});
@@ -0,0 +1,136 @@
import {
describe,
expect,
it
} from 'vitest';
import type { WidgetItem } from '../../../../types/Widget';
import {
clearAllWidgetStyling,
cycleWidgetColor,
resetWidgetStyling,
toggleWidgetBold,
updateWidgetById
} from '../mutations';
describe('color-menu mutations', () => {
it('updateWidgetById only updates the matching widget', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input', color: 'blue' },
{ id: '2', type: 'tokens-output', color: 'white' }
];
const updated = updateWidgetById(widgets, '1', widget => ({
...widget,
color: 'red'
}));
expect(updated[0]?.color).toBe('red');
expect(updated[1]?.color).toBe('white');
});
it('toggleWidgetBold flips bold state for the selected widget only', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input', bold: true },
{ id: '2', type: 'tokens-output', bold: false }
];
const updated = toggleWidgetBold(widgets, '1');
expect(updated[0]?.bold).toBe(false);
expect(updated[1]?.bold).toBe(false);
});
it('resetWidgetStyling removes color, backgroundColor, and bold from one widget', () => {
const widgets: WidgetItem[] = [
{
id: '1',
type: 'tokens-input',
color: 'red',
backgroundColor: 'blue',
bold: true
},
{ id: '2', type: 'tokens-output', color: 'white', bold: true }
];
const updated = resetWidgetStyling(widgets, '1');
expect(updated[0]).toEqual({ id: '1', type: 'tokens-input' });
expect(updated[1]).toEqual({ id: '2', type: 'tokens-output', color: 'white', bold: true });
});
it('clearAllWidgetStyling strips styling fields from every widget', () => {
const widgets: WidgetItem[] = [
{
id: '1',
type: 'tokens-input',
color: 'red',
backgroundColor: 'blue',
bold: true
},
{ id: '2', type: 'tokens-output', color: 'white', bold: true }
];
const updated = clearAllWidgetStyling(widgets);
expect(updated).toEqual([
{ id: '1', type: 'tokens-input' },
{ id: '2', type: 'tokens-output' }
]);
});
it('cycles background colors and maps empty background to undefined', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input', backgroundColor: 'bg:red' }
];
const right = cycleWidgetColor({
widgets,
widgetId: '1',
direction: 'right',
editingBackground: true,
colors: ['blue', 'red'],
backgroundColors: ['bg:red', '']
});
const left = cycleWidgetColor({
widgets: right,
widgetId: '1',
direction: 'left',
editingBackground: true,
colors: ['blue', 'red'],
backgroundColors: ['bg:red', '']
});
expect(right[0]?.backgroundColor).toBeUndefined();
expect(left[0]?.backgroundColor).toBe('bg:red');
});
it('cycles foreground colors from widget default and treats dim as default', () => {
const fromDefault: WidgetItem[] = [
{ id: '1', type: 'tokens-input' }
];
const fromDim: WidgetItem[] = [
{ id: '1', type: 'tokens-input', color: 'dim' }
];
const defaultCycle = cycleWidgetColor({
widgets: fromDefault,
widgetId: '1',
direction: 'right',
editingBackground: false,
colors: ['blue', 'red'],
backgroundColors: ['bg:red', '']
});
const dimCycle = cycleWidgetColor({
widgets: fromDim,
widgetId: '1',
direction: 'right',
editingBackground: false,
colors: ['blue', 'red'],
backgroundColors: ['bg:red', '']
});
expect(defaultCycle[0]?.color).toBe('red');
expect(dimCycle[0]?.color).toBe('red');
});
});
+148
View File
@@ -0,0 +1,148 @@
import type { WidgetItem } from '../../../types/Widget';
import { getWidget } from '../../../utils/widgets';
export function updateWidgetById(
widgets: WidgetItem[],
widgetId: string,
updater: (widget: WidgetItem) => WidgetItem
): WidgetItem[] {
return widgets.map(widget => widget.id === widgetId ? updater(widget) : widget);
}
export function setWidgetColor(
widgets: WidgetItem[],
widgetId: string,
color: string,
editingBackground: boolean
): WidgetItem[] {
return updateWidgetById(widgets, widgetId, (widget) => {
if (editingBackground) {
return {
...widget,
backgroundColor: color
};
}
return {
...widget,
color
};
});
}
export function toggleWidgetBold(widgets: WidgetItem[], widgetId: string): WidgetItem[] {
return updateWidgetById(widgets, widgetId, widget => ({
...widget,
bold: !widget.bold
}));
}
export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] {
return updateWidgetById(widgets, widgetId, (widget) => {
const {
color,
backgroundColor,
bold,
...restWidget
} = widget;
void color; // Intentionally unused
void backgroundColor; // Intentionally unused
void bold; // Intentionally unused
return restWidget;
});
}
export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] {
return widgets.map((widget) => {
const {
color,
backgroundColor,
bold,
...restWidget
} = widget;
void color; // Intentionally unused
void backgroundColor; // Intentionally unused
void bold; // Intentionally unused
return restWidget;
});
}
function getDefaultForegroundColor(widget: WidgetItem): string {
if (widget.type === 'separator' || widget.type === 'flex-separator') {
return 'white';
}
const widgetImpl = getWidget(widget.type);
return widgetImpl ? widgetImpl.getDefaultColor() : 'white';
}
function getNextIndex(currentIndex: number, length: number, direction: 'left' | 'right'): number {
if (direction === 'right') {
return (currentIndex + 1) % length;
}
return currentIndex === 0 ? length - 1 : currentIndex - 1;
}
export interface CycleWidgetColorOptions {
widgets: WidgetItem[];
widgetId: string;
direction: 'left' | 'right';
editingBackground: boolean;
colors: string[];
backgroundColors: string[];
}
export function cycleWidgetColor({
widgets,
widgetId,
direction,
editingBackground,
colors,
backgroundColors
}: CycleWidgetColorOptions): WidgetItem[] {
return updateWidgetById(widgets, widgetId, (widget) => {
if (editingBackground) {
if (backgroundColors.length === 0) {
return widget;
}
const currentBgColor = widget.backgroundColor ?? '';
let currentBgColorIndex = backgroundColors.indexOf(currentBgColor);
if (currentBgColorIndex === -1) {
currentBgColorIndex = 0;
}
const nextBgColorIndex = getNextIndex(currentBgColorIndex, backgroundColors.length, direction);
const nextBgColor = backgroundColors[nextBgColorIndex];
return {
...widget,
backgroundColor: nextBgColor === '' ? undefined : nextBgColor
};
}
if (colors.length === 0) {
return widget;
}
const defaultColor = getDefaultForegroundColor(widget);
let currentColor = widget.color ?? defaultColor;
if (currentColor === 'dim') {
currentColor = defaultColor;
}
let currentColorIndex = colors.indexOf(currentColor);
if (currentColorIndex === -1) {
currentColorIndex = 0;
}
const nextColorIndex = getNextIndex(currentColorIndex, colors.length, direction);
const nextColor = colors[nextColorIndex];
return {
...widget,
color: nextColor
};
});
}
@@ -0,0 +1,341 @@
import {
describe,
expect,
it,
vi
} from 'vitest';
import type { WidgetItem } from '../../../../types/Widget';
import type { WidgetCatalogEntry } from '../../../../utils/widgets';
import {
handleMoveInputMode,
handleNormalInputMode,
handlePickerInputMode,
normalizePickerState,
type WidgetPickerState
} from '../input-handlers';
function createStateSetter<T>(initial: T) {
let state = initial;
return {
get: () => state,
set: (value: T | ((prev: T) => T)) => {
state = typeof value === 'function'
? (value as (prev: T) => T)(state)
: value;
}
};
}
function requireState<T>(value: T | null): T {
if (!value) {
throw new Error('Expected state value');
}
return value;
}
function createCatalog(entries: (Partial<WidgetCatalogEntry> & Pick<WidgetCatalogEntry, 'type'>)[]): WidgetCatalogEntry[] {
return entries.map(entry => ({
type: entry.type,
displayName: entry.displayName ?? entry.type,
description: entry.description ?? entry.type,
category: entry.category ?? 'Other',
searchText: `${entry.displayName ?? entry.type} ${entry.description ?? entry.type} ${entry.type}`.toLowerCase()
}));
}
describe('items-editor input handlers', () => {
it('normalizes picker state with valid fallback category and selected type', () => {
const widgetCatalog = createCatalog([
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' }
]);
const widgetCategories = ['All', 'Git'];
const state: WidgetPickerState = {
action: 'change',
level: 'category',
selectedCategory: 'Missing',
categoryQuery: '',
widgetQuery: '',
selectedType: null
};
const normalized = normalizePickerState(state, widgetCatalog, widgetCategories);
expect(normalized.selectedCategory).toBe('All');
expect(normalized.selectedType).toBe('git-branch');
});
it('applies top-level category search selection on Enter', () => {
const widgetCatalog = createCatalog([
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' }
]);
const widgetCategories = ['All', 'Git'];
const pickerState = createStateSetter<WidgetPickerState | null>({
action: 'change',
level: 'category',
selectedCategory: 'All',
categoryQuery: 'git',
widgetQuery: '',
selectedType: 'git-branch'
});
const applySelection = vi.fn();
handlePickerInputMode({
input: '',
key: { return: true },
widgetPicker: requireState(pickerState.get()),
widgetCatalog,
widgetCategories,
setWidgetPicker: pickerState.set,
applyWidgetPickerSelection: applySelection
});
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' }
]);
const widgetCategories = ['All', 'Git'];
const pickerState = createStateSetter<WidgetPickerState | null>({
action: 'change',
level: 'widget',
selectedCategory: 'Git',
categoryQuery: '',
widgetQuery: '',
selectedType: 'git-branch'
});
handlePickerInputMode({
input: '',
key: { escape: true },
widgetPicker: requireState(pickerState.get()),
widgetCatalog,
widgetCategories,
setWidgetPicker: pickerState.set,
applyWidgetPickerSelection: vi.fn()
});
expect(pickerState.get()?.level).toBe('category');
});
it('moves selected widget up in move mode', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input' },
{ id: '2', type: 'tokens-output' }
];
const onUpdate = vi.fn();
const setSelectedIndex = vi.fn();
const setMoveMode = vi.fn();
handleMoveInputMode({
key: { upArrow: true },
widgets,
selectedIndex: 1,
onUpdate,
setSelectedIndex,
setMoveMode
});
expect(onUpdate).toHaveBeenCalledWith([
{ id: '2', type: 'tokens-output' },
{ id: '1', type: 'tokens-input' }
]);
expect(setSelectedIndex).toHaveBeenCalledWith(0);
expect(setMoveMode).not.toHaveBeenCalled();
});
it('toggles raw value in normal mode for supported widgets', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input' }
];
const onUpdate = vi.fn();
handleNormalInputMode({
input: 'r',
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]?.rawValue).toBe(true);
});
it('cycles separator character in normal mode', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'separator', character: '|' }
];
const onUpdate = vi.fn();
handleNormalInputMode({
input: ' ',
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]?.character).toBe('-');
});
it('applies custom widget keybind actions in normal mode', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'session-usage' }
];
const onUpdate = vi.fn();
handleNormalInputMode({
input: 'p',
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?.display).toBe('progress');
});
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');
});
});
@@ -0,0 +1,462 @@
import type {
CustomKeybind,
Widget,
WidgetItem,
WidgetItemType
} from '../../../types/Widget';
import {
filterWidgetCatalog,
getWidget,
type WidgetCatalogEntry
} from '../../../utils/widgets';
export type WidgetPickerAction = 'change' | 'add' | 'insert';
export type WidgetPickerLevel = 'category' | 'widget';
export interface WidgetPickerState {
action: WidgetPickerAction;
level: WidgetPickerLevel;
selectedCategory: string | null;
categoryQuery: string;
widgetQuery: string;
selectedType: WidgetItemType | null;
}
export interface CustomEditorWidgetState {
widget: WidgetItem;
impl: Widget;
action?: string;
}
export interface InputKey {
ctrl?: boolean;
meta?: boolean;
tab?: boolean;
shift?: boolean;
upArrow?: boolean;
downArrow?: boolean;
leftArrow?: boolean;
rightArrow?: boolean;
return?: boolean;
escape?: boolean;
backspace?: boolean;
delete?: boolean;
}
type Setter<T> = (value: T | ((prev: T) => T)) => void;
function setPickerState(
setWidgetPicker: Setter<WidgetPickerState | null>,
normalizeState: (state: WidgetPickerState) => WidgetPickerState,
updater: (prev: WidgetPickerState) => WidgetPickerState
): void {
setWidgetPicker((prev) => {
if (!prev) {
return prev;
}
return normalizeState(updater(prev));
});
}
function getPickerCategories(widgetCategories: string[]): string[] {
return [...widgetCategories];
}
export function normalizePickerState(
state: WidgetPickerState,
widgetCatalog: WidgetCatalogEntry[],
widgetCategories: string[]
): WidgetPickerState {
const filteredCategories = getPickerCategories(widgetCategories);
const selectedCategory = state.selectedCategory && filteredCategories.includes(state.selectedCategory)
? state.selectedCategory
: (filteredCategories[0] ?? null);
const hasTopLevelSearch = state.level === 'category' && state.categoryQuery.trim().length > 0;
const effectiveCategory = hasTopLevelSearch ? 'All' : (selectedCategory ?? 'All');
const effectiveQuery = hasTopLevelSearch ? state.categoryQuery : state.widgetQuery;
const filteredWidgets = filterWidgetCatalog(widgetCatalog, effectiveCategory, effectiveQuery);
const hasSelectedType = state.selectedType
? filteredWidgets.some(entry => entry.type === state.selectedType)
: false;
return {
...state,
selectedCategory,
selectedType: hasSelectedType ? state.selectedType : (filteredWidgets[0]?.type ?? null)
};
}
interface PickerViewState {
filteredCategories: string[];
selectedCategory: string | null;
hasTopLevelSearch: boolean;
topLevelSearchEntries: WidgetCatalogEntry[];
topLevelSelectedEntry: WidgetCatalogEntry | undefined;
filteredWidgets: WidgetCatalogEntry[];
selectedEntry: WidgetCatalogEntry | undefined;
}
function getPickerViewState(
widgetPicker: WidgetPickerState,
widgetCatalog: WidgetCatalogEntry[],
widgetCategories: string[]
): PickerViewState {
const filteredCategories = getPickerCategories(widgetCategories);
const selectedCategory = widgetPicker.selectedCategory && filteredCategories.includes(widgetPicker.selectedCategory)
? widgetPicker.selectedCategory
: (filteredCategories[0] ?? null);
const hasTopLevelSearch = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0;
const topLevelSearchEntries = hasTopLevelSearch
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
: [];
const topLevelSelectedEntry = topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0];
const filteredWidgets = filterWidgetCatalog(widgetCatalog, selectedCategory ?? 'All', widgetPicker.widgetQuery);
const selectedEntry = filteredWidgets.find(entry => entry.type === widgetPicker.selectedType) ?? filteredWidgets[0];
return {
filteredCategories,
selectedCategory,
hasTopLevelSearch,
topLevelSearchEntries,
topLevelSelectedEntry,
filteredWidgets,
selectedEntry
};
}
export interface HandlePickerInputModeArgs {
input: string;
key: InputKey;
widgetPicker: WidgetPickerState;
widgetCatalog: WidgetCatalogEntry[];
widgetCategories: string[];
setWidgetPicker: Setter<WidgetPickerState | null>;
applyWidgetPickerSelection: (selectedType: WidgetItemType) => void;
}
export function handlePickerInputMode({
input,
key,
widgetPicker,
widgetCatalog,
widgetCategories,
setWidgetPicker,
applyWidgetPickerSelection
}: HandlePickerInputModeArgs): void {
const normalizeState = (state: WidgetPickerState) => normalizePickerState(state, widgetCatalog, widgetCategories);
const {
filteredCategories,
selectedCategory,
hasTopLevelSearch,
topLevelSearchEntries,
topLevelSelectedEntry,
filteredWidgets,
selectedEntry
} = getPickerViewState(widgetPicker, widgetCatalog, widgetCategories);
if (widgetPicker.level === 'category') {
if (key.escape) {
if (widgetPicker.categoryQuery.length > 0) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
categoryQuery: ''
}));
} else {
setWidgetPicker(null);
}
} else if (key.return) {
if (hasTopLevelSearch) {
if (topLevelSelectedEntry) {
applyWidgetPickerSelection(topLevelSelectedEntry.type);
}
} else if (selectedCategory) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
level: 'widget',
selectedCategory
}));
}
} else if (key.upArrow || key.downArrow) {
if (hasTopLevelSearch) {
if (topLevelSearchEntries.length === 0) {
return;
}
let currentIndex = topLevelSearchEntries.findIndex(entry => entry.type === widgetPicker.selectedType);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? Math.min(topLevelSearchEntries.length - 1, currentIndex + 1)
: Math.max(0, currentIndex - 1);
const nextType = topLevelSearchEntries[nextIndex]?.type ?? null;
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
selectedType: nextType
}));
} else {
if (filteredCategories.length === 0) {
return;
}
let currentIndex = filteredCategories.findIndex(category => category === selectedCategory);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? Math.min(filteredCategories.length - 1, currentIndex + 1)
: Math.max(0, currentIndex - 1);
const nextCategory = filteredCategories[nextIndex] ?? null;
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
selectedCategory: nextCategory
}));
}
} else if (key.backspace || key.delete) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
categoryQuery: prev.categoryQuery.slice(0, -1),
selectedType: null
}));
} else if (
input
&& !key.ctrl
&& !key.meta
&& !key.tab
) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
categoryQuery: prev.categoryQuery + input,
selectedType: null
}));
}
} else {
if (key.escape) {
if (widgetPicker.widgetQuery.length > 0) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
widgetQuery: ''
}));
} else {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
level: 'category'
}));
}
} else if (key.return) {
if (selectedEntry) {
applyWidgetPickerSelection(selectedEntry.type);
}
} else if (key.upArrow || key.downArrow) {
if (filteredWidgets.length === 0) {
return;
}
let currentIndex = filteredWidgets.findIndex(entry => entry.type === widgetPicker.selectedType);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? Math.min(filteredWidgets.length - 1, currentIndex + 1)
: Math.max(0, currentIndex - 1);
const nextType = filteredWidgets[nextIndex]?.type ?? null;
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
selectedType: nextType
}));
} else if (key.backspace || key.delete) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
widgetQuery: prev.widgetQuery.slice(0, -1),
selectedType: null
}));
} else if (
input
&& !key.ctrl
&& !key.meta
&& !key.tab
) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
widgetQuery: prev.widgetQuery + input,
selectedType: null
}));
}
}
}
export interface HandleMoveInputModeArgs {
key: InputKey;
widgets: WidgetItem[];
selectedIndex: number;
onUpdate: (widgets: WidgetItem[]) => void;
setSelectedIndex: (index: number) => void;
setMoveMode: (moveMode: boolean) => void;
}
export function handleMoveInputMode({
key,
widgets,
selectedIndex,
onUpdate,
setSelectedIndex,
setMoveMode
}: HandleMoveInputModeArgs): void {
if (key.upArrow && selectedIndex > 0) {
const newWidgets = [...widgets];
const temp = newWidgets[selectedIndex];
const prev = newWidgets[selectedIndex - 1];
if (temp && prev) {
[newWidgets[selectedIndex], newWidgets[selectedIndex - 1]] = [prev, temp];
}
onUpdate(newWidgets);
setSelectedIndex(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < widgets.length - 1) {
const newWidgets = [...widgets];
const temp = newWidgets[selectedIndex];
const next = newWidgets[selectedIndex + 1];
if (temp && next) {
[newWidgets[selectedIndex], newWidgets[selectedIndex + 1]] = [next, temp];
}
onUpdate(newWidgets);
setSelectedIndex(selectedIndex + 1);
} else if (key.escape || key.return) {
setMoveMode(false);
}
}
export interface HandleNormalInputModeArgs {
input: string;
key: InputKey;
widgets: WidgetItem[];
selectedIndex: number;
separatorChars: string[];
onBack: () => void;
onUpdate: (widgets: WidgetItem[]) => void;
setSelectedIndex: (index: number) => void;
setMoveMode: (moveMode: boolean) => void;
setShowClearConfirm: (show: boolean) => void;
openWidgetPicker: (action: WidgetPickerAction) => void;
getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[];
setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void;
}
export function handleNormalInputMode({
input,
key,
widgets,
selectedIndex,
separatorChars,
onBack,
onUpdate,
setSelectedIndex,
setMoveMode,
setShowClearConfirm,
openWidgetPicker,
getCustomKeybindsForWidget,
setCustomEditorWidget
}: HandleNormalInputModeArgs): void {
if (key.upArrow && widgets.length > 0) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow && widgets.length > 0) {
setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1));
} else if (key.leftArrow && widgets.length > 0) {
openWidgetPicker('change');
} else if (key.rightArrow && widgets.length > 0) {
openWidgetPicker('change');
} else if (key.return && widgets.length > 0) {
setMoveMode(true);
} else if (input === 'a') {
openWidgetPicker('add');
} else if (input === 'i') {
openWidgetPicker('insert');
} else if (input === 'd' && widgets.length > 0) {
const newWidgets = widgets.filter((_, i) => i !== selectedIndex);
onUpdate(newWidgets);
if (selectedIndex >= newWidgets.length && selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1);
}
} else if (input === 'c') {
if (widgets.length > 0) {
setShowClearConfirm(true);
}
} else if (input === ' ' && widgets.length > 0) {
const currentWidget = widgets[selectedIndex];
if (currentWidget?.type === 'separator') {
const currentChar = currentWidget.character ?? '|';
const currentCharIndex = separatorChars.indexOf(currentChar);
const nextChar = separatorChars[(currentCharIndex + 1) % separatorChars.length];
const newWidgets = [...widgets];
newWidgets[selectedIndex] = { ...currentWidget, character: nextChar };
onUpdate(newWidgets);
}
} else if (input === 'r' && widgets.length > 0) {
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const widgetImpl = getWidget(currentWidget.type);
if (!widgetImpl?.supportsRawValue()) {
return;
}
const newWidgets = [...widgets];
newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue };
onUpdate(newWidgets);
}
} else if (input === 'm' && widgets.length > 0) {
const currentWidget = widgets[selectedIndex];
if (currentWidget && selectedIndex < widgets.length - 1
&& currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const newWidgets = [...widgets];
let nextMergeState: boolean | 'no-padding' | undefined;
if (currentWidget.merge === undefined) {
nextMergeState = true;
} else if (currentWidget.merge === true) {
nextMergeState = 'no-padding';
} else {
nextMergeState = undefined;
}
if (nextMergeState === undefined) {
const { merge, ...rest } = currentWidget;
void merge; // Intentionally unused
newWidgets[selectedIndex] = rest;
} else {
newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState };
}
onUpdate(newWidgets);
}
} else if (key.escape) {
onBack();
} else if (widgets.length > 0) {
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const widgetImpl = getWidget(currentWidget.type);
if (!widgetImpl?.getCustomKeybinds) {
return;
}
const customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
const matchedKeybind = customKeybinds.find(kb => kb.key === input);
if (matchedKeybind && !key.ctrl) {
if (widgetImpl.handleEditorAction) {
const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget);
if (updatedWidget) {
const newWidgets = [...widgets];
newWidgets[selectedIndex] = updatedWidget;
onUpdate(newWidgets);
} else if (widgetImpl.renderEditor) {
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
}
} else if (widgetImpl.renderEditor) {
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
}
}
}
}
}
+1
View File
@@ -1,4 +1,5 @@
export interface ClaudeSettings {
effortLevel?: 'low' | 'medium' | 'high' | 'max';
permissions?: {
allow?: string[];
deny?: string[];
+8 -8
View File
@@ -17,13 +17,13 @@ export type ColorLevelString = 'ansi16' | 'ansi256' | 'truecolor';
// Helper to get color level as string for chalk
export function getColorLevelString(level: ColorLevel | undefined): ColorLevelString {
switch (level) {
case 0:
case 1:
return 'ansi16';
case 3:
return 'truecolor';
case 2:
default:
return 'ansi256';
case 0:
case 1:
return 'ansi16';
case 3:
return 'truecolor';
case 2:
default:
return 'ansi256';
}
}
+2 -1
View File
@@ -8,7 +8,8 @@ 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
+30 -1
View File
@@ -1,15 +1,44 @@
import type { BlockMetrics } from '../types';
import type {
BlockMetrics,
SkillsMetrics
} from '../types';
import type { SpeedMetrics } from './SpeedMetrics';
import type { StatusJSON } from './StatusJSON';
import type { TokenMetrics } from './TokenMetrics';
export interface RenderUsageData {
sessionUsage?: number;
sessionResetAt?: string;
weeklyUsage?: number;
weeklyResetAt?: string;
extraUsageEnabled?: boolean;
extraUsageLimit?: number;
extraUsageUsed?: number;
extraUsageUtilization?: number;
error?: 'no-credentials' | 'timeout' | 'rate-limited' | 'api-error' | 'parse-error';
}
export interface RenderContext {
data?: StatusJSON;
tokenMetrics?: TokenMetrics | null;
speedMetrics?: SpeedMetrics | null;
windowedSpeedMetrics?: Record<string, SpeedMetrics> | null;
usageData?: RenderUsageData | null;
sessionDuration?: string | null;
blockMetrics?: BlockMetrics | null;
skillsMetrics?: SkillsMetrics | null;
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
}
+7 -5
View File
@@ -27,7 +27,6 @@ export const SettingsSchema = z.object({
version: z.number().default(CURRENT_VERSION),
lines: z.array(z.array(WidgetItemSchema))
.min(1)
.max(3)
.default([
[
{ id: '1', type: 'model', color: 'cyan' },
@@ -37,9 +36,10 @@ export const SettingsSchema = z.object({
{ id: '5', type: 'git-branch', color: 'magenta' },
{ id: '6', type: 'separator' },
{ id: '7', type: 'git-changes', color: 'yellow' }
]
])
.transform(lines => lines.slice(0, 3)), // Ensure max 3 lines
],
[],
[]
]), // Ensure max 3 lines
flexMode: FlexModeSchema.default('full-minus-40'),
compactThreshold: z.number().min(1).max(99).default(60),
colorLevel: ColorLevelSchema.default(2),
@@ -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(),
+12
View File
@@ -0,0 +1,12 @@
export interface SkillInvocation {
timestamp: string;
session_id: string;
skill: string;
source: string;
}
export interface SkillsMetrics {
totalInvocations: number;
uniqueSkills: string[];
lastSkill: string | null;
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Speed metrics for calculating token processing rates.
* Provides time-based data needed for speed calculations.
*/
export interface SpeedMetrics {
/** Active processing duration in milliseconds (sum of user request → assistant response times) */
totalDurationMs: number;
/** Total input tokens across all requests */
inputTokens: number;
/** Total output tokens across all requests */
outputTokens: number;
/** Total tokens (input + output) */
totalTokens: number;
/** Number of assistant usage entries included in speed aggregation */
requestCount: number;
}
+60 -10
View File
@@ -1,14 +1,36 @@
import { z } from 'zod';
const CoercedNumberSchema = z.preprocess((value) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return value;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : value;
}, z.number());
const RateLimitPeriodSchema = z.object({
used_percentage: CoercedNumberSchema.nullable().optional(),
resets_at: CoercedNumberSchema.nullable().optional() // Unix epoch seconds
});
export const StatusJSONSchema = z.looseObject({
hook_event_name: z.string().optional(),
session_id: z.string().optional(),
transcript_path: z.string().optional(),
cwd: z.string().optional(),
model: z.object({
id: z.string().optional(),
display_name: z.string().optional()
}).optional(),
model: z.union([
z.string(),
z.object({
id: z.string().optional(),
display_name: z.string().optional()
})
]).optional(),
workspace: z.object({
current_dir: z.string().optional(),
project_dir: z.string().optional()
@@ -16,12 +38,40 @@ export const StatusJSONSchema = z.looseObject({
version: z.string().optional(),
output_style: z.object({ name: z.string().optional() }).optional(),
cost: z.object({
total_cost_usd: z.number().optional(),
total_duration_ms: z.number().optional(),
total_api_duration_ms: z.number().optional(),
total_lines_added: z.number().optional(),
total_lines_removed: z.number().optional()
}).optional()
total_cost_usd: CoercedNumberSchema.optional(),
total_duration_ms: CoercedNumberSchema.optional(),
total_api_duration_ms: CoercedNumberSchema.optional(),
total_lines_added: CoercedNumberSchema.optional(),
total_lines_removed: CoercedNumberSchema.optional()
}).optional(),
context_window: z.object({
context_window_size: CoercedNumberSchema.nullable().optional(),
total_input_tokens: CoercedNumberSchema.nullable().optional(),
total_output_tokens: CoercedNumberSchema.nullable().optional(),
current_usage: z.union([
CoercedNumberSchema,
z.object({
input_tokens: CoercedNumberSchema.optional(),
output_tokens: CoercedNumberSchema.optional(),
cache_creation_input_tokens: CoercedNumberSchema.optional(),
cache_read_input_tokens: CoercedNumberSchema.optional()
})
]).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>;
+2
View File
@@ -9,6 +9,8 @@ export interface TranscriptLine {
message?: { usage?: TokenUsage };
isSidechain?: boolean;
timestamp?: string;
isApiErrorMessage?: boolean;
type?: 'user' | 'assistant' | 'system' | 'progress' | 'file-history-snapshot';
}
export interface TokenMetrics {
+5 -1
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()
});
@@ -34,13 +36,15 @@ export interface Widget {
getDefaultColor(): string;
getDescription(): string;
getDisplayName(): string;
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 {
+114
View File
@@ -0,0 +1,114 @@
import {
describe,
expect,
it
} from 'vitest';
import { StatusJSONSchema } from '../StatusJSON';
describe('StatusJSONSchema numeric coercion', () => {
it('coerces numeric strings to numbers', () => {
const result = StatusJSONSchema.safeParse({
cost: {
total_cost_usd: '1.25',
total_duration_ms: '12345',
total_api_duration_ms: '2345',
total_lines_added: '12',
total_lines_removed: '3'
},
context_window: {
context_window_size: '200000',
total_input_tokens: '1200',
total_output_tokens: '340',
current_usage: {
input_tokens: '100',
output_tokens: '50',
cache_creation_input_tokens: '20',
cache_read_input_tokens: '10'
},
used_percentage: '9.3',
remaining_percentage: '90.7'
}
});
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.cost?.total_duration_ms).toBe(12345);
expect(result.data.context_window?.context_window_size).toBe(200000);
expect(result.data.context_window?.current_usage).toEqual({
input_tokens: 100,
output_tokens: 50,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 10
});
expect(result.data.context_window?.used_percentage).toBe(9.3);
});
it('keeps invalid numeric strings rejected', () => {
const result = StatusJSONSchema.safeParse({ context_window: { context_window_size: 'not-a-number' } });
expect(result.success).toBe(false);
});
it('keeps empty numeric strings rejected', () => {
const result = StatusJSONSchema.safeParse({ context_window: { context_window_size: '' } });
expect(result.success).toBe(false);
});
it('accepts null vim payloads', () => {
const result = StatusJSONSchema.safeParse({ vim: null });
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.vim).toBeNull();
});
it('parses rate_limits with valid data', () => {
const result = StatusJSONSchema.safeParse({
rate_limits: {
five_hour: { used_percentage: 42, resets_at: 1774020000 },
seven_day: { used_percentage: 15, resets_at: 1774540000 }
}
});
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.rate_limits?.five_hour?.used_percentage).toBe(42);
expect(result.data.rate_limits?.five_hour?.resets_at).toBe(1774020000);
expect(result.data.rate_limits?.seven_day?.used_percentage).toBe(15);
expect(result.data.rate_limits?.seven_day?.resets_at).toBe(1774540000);
});
it('accepts null rate_limits', () => {
const result = StatusJSONSchema.safeParse({ rate_limits: null });
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.rate_limits).toBeNull();
});
it('coerces rate_limits string numbers', () => {
const result = StatusJSONSchema.safeParse({ rate_limits: { five_hour: { used_percentage: '42', resets_at: '1774020000' } } });
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.rate_limits?.five_hour?.used_percentage).toBe(42);
expect(result.data.rate_limits?.five_hour?.resets_at).toBe(1774020000);
});
});
+3 -1
View File
@@ -17,4 +17,6 @@ export type { RenderContext } from './RenderContext';
export type { PowerlineFontStatus } from './PowerlineFontStatus';
export type { ClaudeSettings } from './ClaudeSettings';
export type { ColorEntry } from './ColorEntry';
export type { BlockMetrics } from './BlockMetrics';
export type { BlockMetrics } from './BlockMetrics';
export type { SpeedMetrics } from './SpeedMetrics';
export type { SkillInvocation, SkillsMetrics } from './SkillsMetrics';
+291
View File
@@ -0,0 +1,291 @@
import * as fs from 'fs';
import { createHash } from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import {
getBlockCachePath,
readBlockCache,
writeBlockCache
} from '../jsonl';
function getExpectedCachePath(homeDir: string, configDir: string): string {
const normalizedConfigDir = path.resolve(configDir);
const configHash = createHash('sha256')
.update(normalizedConfigDir)
.digest('hex')
.slice(0, 16);
return path.join(homeDir, '.cache', 'ccstatusline', `block-cache-${configHash}.json`);
}
describe('Block Cache Functions', () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
beforeEach(() => {
// Create a temp directory for test isolation
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-test-'));
// Mock os.homedir to use temp directory
vi.spyOn(os, 'homedir').mockReturnValue(tempDir);
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude-default');
});
afterEach(() => {
vi.restoreAllMocks();
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
}
// Clean up temp directory
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('getBlockCachePath', () => {
it('should return the correct cache path', () => {
const cachePath = getBlockCachePath();
expect(cachePath).toBe(getExpectedCachePath(tempDir, path.join(tempDir, '.claude-default')));
});
it('should return different cache paths for different config directories', () => {
const profileA = path.join(tempDir, '.claude-profile-a');
const profileB = path.join(tempDir, '.claude-profile-b');
const pathA = getBlockCachePath(profileA);
const pathB = getBlockCachePath(profileB);
expect(pathA).toBe(getExpectedCachePath(tempDir, profileA));
expect(pathB).toBe(getExpectedCachePath(tempDir, profileB));
expect(pathA).not.toBe(pathB);
});
});
describe('readBlockCache', () => {
it('should return null when cache file does not exist', () => {
const result = readBlockCache();
expect(result).toBeNull();
});
it('should return cached date when cache file is valid', () => {
const testDate = new Date('2025-01-26T14:00:00.000Z');
const cachePath = getBlockCachePath();
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify({ startTime: testDate.toISOString() }));
const result = readBlockCache();
expect(result).toEqual(testDate);
});
it('should return cached date when configDir matches expected value', () => {
const testDate = new Date('2025-01-26T14:00:00.000Z');
const configDir = path.join(tempDir, '.claude-profile-a');
const cachePath = getBlockCachePath(configDir);
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(
cachePath,
JSON.stringify({ startTime: testDate.toISOString(), configDir })
);
const result = readBlockCache(configDir);
expect(result).toEqual(testDate);
});
it('should return null when cache configDir does not match expected value', () => {
const testDate = new Date('2025-01-26T14:00:00.000Z');
const expectedConfigDir = path.join(tempDir, '.claude-profile-b');
const cachePath = getBlockCachePath(expectedConfigDir);
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(
cachePath,
JSON.stringify({
startTime: testDate.toISOString(),
configDir: path.join(tempDir, '.claude-profile-a')
})
);
const result = readBlockCache(expectedConfigDir);
expect(result).toBeNull();
});
it('should return null when expected configDir is provided but cache has legacy schema', () => {
const testDate = new Date('2025-01-26T14:00:00.000Z');
const expectedConfigDir = path.join(tempDir, '.claude-profile-a');
const cachePath = getBlockCachePath(expectedConfigDir);
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify({ startTime: testDate.toISOString() }));
const result = readBlockCache(expectedConfigDir);
expect(result).toBeNull();
});
it('should return null when cache file has invalid JSON', () => {
const cachePath = getBlockCachePath();
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, 'not valid json');
const result = readBlockCache();
expect(result).toBeNull();
});
it('should return null when cache file has missing startTime', () => {
const cachePath = getBlockCachePath();
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify({}));
const result = readBlockCache();
expect(result).toBeNull();
});
it('should return null when startTime is not a string', () => {
const cachePath = getBlockCachePath();
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify({ startTime: 12345 }));
const result = readBlockCache();
expect(result).toBeNull();
});
it('should return null when startTime is invalid date string', () => {
const cachePath = getBlockCachePath();
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify({ startTime: 'not a date' }));
const result = readBlockCache();
expect(result).toBeNull();
});
});
describe('writeBlockCache', () => {
it('should create directory and write cache file', () => {
const testDate = new Date('2025-01-26T14:00:00.000Z');
const configDir = path.join(tempDir, '.claude-profile-a');
writeBlockCache(testDate, configDir);
const cachePath = getBlockCachePath(configDir);
expect(fs.existsSync(cachePath)).toBe(true);
const content = fs.readFileSync(cachePath, 'utf-8');
const parsed = JSON.parse(content) as { startTime: string; configDir: string };
expect(parsed.startTime).toBe(testDate.toISOString());
expect(parsed.configDir).toBe(path.resolve(configDir));
});
it('should overwrite existing cache file', () => {
const firstDate = new Date('2025-01-26T14:00:00.000Z');
const secondDate = new Date('2025-01-26T16:00:00.000Z');
const configDir = path.join(tempDir, '.claude-profile-a');
writeBlockCache(firstDate, configDir);
writeBlockCache(secondDate, configDir);
const cachePath = getBlockCachePath(configDir);
const content = fs.readFileSync(cachePath, 'utf-8');
const parsed = JSON.parse(content) as { startTime: string; configDir: string };
expect(parsed.startTime).toBe(secondDate.toISOString());
expect(parsed.configDir).toBe(path.resolve(configDir));
});
});
});
describe('getCachedBlockMetrics integration', () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-test-'));
vi.spyOn(os, 'homedir').mockReturnValue(tempDir);
// Mock CLAUDE_CONFIG_DIR to point to a non-existent directory
// This ensures getBlockMetrics returns null when cache is expired
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude-nonexistent');
});
afterEach(() => {
vi.restoreAllMocks();
// Restore original CLAUDE_CONFIG_DIR
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('should return cached result when cache is within block duration', async () => {
const { getCachedBlockMetrics, writeBlockCache, getBlockCachePath } = await import('../jsonl');
// Set up a cache with a start time 2 hours ago (within 5-hour block)
const testStartTime = new Date();
testStartTime.setHours(testStartTime.getHours() - 2);
writeBlockCache(testStartTime);
// Verify cache was written
expect(fs.existsSync(getBlockCachePath())).toBe(true);
const result = getCachedBlockMetrics();
expect(result).not.toBeNull();
expect(result?.startTime.getTime()).toBe(testStartTime.getTime());
});
it('should recalculate when cache is expired (beyond block duration)', async () => {
const { getCachedBlockMetrics, writeBlockCache } = await import('../jsonl');
// Set up a cache with a start time 6 hours ago (beyond 5-hour block)
const expiredStartTime = new Date();
expiredStartTime.setHours(expiredStartTime.getHours() - 6);
writeBlockCache(expiredStartTime);
const result = getCachedBlockMetrics();
// Should return null because cache is expired and no real JSONL files exist
expect(result).toBeNull();
});
it('should recalculate when no cache exists', async () => {
const { getCachedBlockMetrics } = await import('../jsonl');
const result = getCachedBlockMetrics();
// Should return null because no cache and no real JSONL files exist
expect(result).toBeNull();
});
it('should recalculate when cache belongs to a different config directory', async () => {
const { getCachedBlockMetrics, writeBlockCache } = await import('../jsonl');
const profileA = path.join(tempDir, '.claude-profile-a');
const profileB = path.join(tempDir, '.claude-profile-b');
const testStartTime = new Date();
testStartTime.setHours(testStartTime.getHours() - 2);
process.env.CLAUDE_CONFIG_DIR = profileA;
writeBlockCache(testStartTime);
process.env.CLAUDE_CONFIG_DIR = profileB;
const result = getCachedBlockMetrics();
// Should return null because cache is fresh but scoped to a different profile
expect(result).toBeNull();
});
});
+358
View File
@@ -0,0 +1,358 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../types/Settings';
import {
CCSTATUSLINE_COMMANDS,
getClaudeSettingsPath,
getExistingStatusLine,
installStatusLine,
isInstalled,
isKnownCommand,
loadClaudeSettings,
saveClaudeSettings,
uninstallStatusLine
} from '../claude-settings';
import { initConfigPath } from '../config';
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
let testClaudeConfigDir = '';
function readInstalledCommand(): string {
const settingsPath = getClaudeSettingsPath();
const content = fs.readFileSync(settingsPath, 'utf-8');
const data = JSON.parse(content) as { statusLine?: { command?: string } };
return data.statusLine?.command ?? '';
}
function writeRawClaudeSettings(content: string): void {
const settingsPath = getClaudeSettingsPath();
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, content, 'utf-8');
}
beforeEach(() => {
testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-claude-settings-'));
process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir;
initConfigPath();
});
afterEach(() => {
initConfigPath();
if (testClaudeConfigDir) {
fs.rmSync(testClaudeConfigDir, { recursive: true, force: true });
}
});
afterAll(() => {
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
}
});
describe('isKnownCommand', () => {
it('should match exact NPM command', () => {
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.NPM)).toBe(true);
});
it('should match exact BUNX command', () => {
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.BUNX)).toBe(true);
});
it('should match exact SELF_MANAGED command', () => {
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.SELF_MANAGED)).toBe(true);
});
it('should match NPM command with --config and simple path', () => {
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`)).toBe(true);
});
it('should match BUNX command with --config and quoted path with spaces', () => {
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.BUNX} --config '/my path/settings.json'`)).toBe(true);
});
it('should match command with --config and quoted path with parens', () => {
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my(path)/settings.json'`)).toBe(true);
});
it('should match command with --config and double-quoted Windows path', () => {
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config "C:\\Users\\Alice\\My Settings\\settings.json"`)).toBe(true);
});
it('should not match unknown commands', () => {
expect(isKnownCommand('some-other-command')).toBe(false);
});
it('should not match empty string', () => {
expect(isKnownCommand('')).toBe(false);
});
it('should not match partial prefix', () => {
expect(isKnownCommand('npx -y ccstatusline')).toBe(false);
});
it('should not match prefix that is a substring', () => {
expect(isKnownCommand('npx -y ccstatusline@latestFOO')).toBe(false);
});
});
describe('buildCommand via installStatusLine', () => {
it('should use base command when no custom config path', async () => {
initConfigPath();
await installStatusLine(false);
expect(readInstalledCommand()).toBe(CCSTATUSLINE_COMMANDS.NPM);
});
it('should append --config with simple path (no quoting needed)', async () => {
initConfigPath('/tmp/settings.json');
await installStatusLine(false);
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`);
});
it('should quote path with spaces', async () => {
initConfigPath('/my path/settings.json');
await installStatusLine(false);
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my path/settings.json'`);
});
it('should quote path with parentheses', async () => {
initConfigPath('/my(path)/settings.json');
await installStatusLine(false);
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my(path)/settings.json'`);
});
it('should escape embedded single quotes in path', async () => {
initConfigPath('/my\'path/settings.json');
await installStatusLine(false);
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my'\\''path/settings.json'`);
});
it('should use bunx command when useBunx is true', async () => {
initConfigPath('/my path/settings.json');
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('backup and error handling behavior', () => {
it('saveClaudeSettings should create .bak backup before overwrite', async () => {
writeRawClaudeSettings(JSON.stringify({
statusLine: {
type: 'command',
command: 'preexisting-command',
padding: 1
}
}));
await saveClaudeSettings({
statusLine: {
type: 'command',
command: CCSTATUSLINE_COMMANDS.NPM,
padding: 0
}
});
const settingsPath = getClaudeSettingsPath();
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { statusLine?: { command?: string } };
expect(saved.statusLine?.command).toBe(CCSTATUSLINE_COMMANDS.NPM);
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(true);
const backup = JSON.parse(fs.readFileSync(`${settingsPath}.bak`, 'utf-8')) as { statusLine?: { command?: string } };
expect(backup.statusLine?.command).toBe('preexisting-command');
});
it('installStatusLine should create .orig backup before updating settings', async () => {
writeRawClaudeSettings(JSON.stringify({
statusLine: {
type: 'command',
command: 'old-command',
padding: 1
}
}));
await installStatusLine(false);
const settingsPath = getClaudeSettingsPath();
expect(fs.existsSync(`${settingsPath}.orig`)).toBe(true);
const orig = JSON.parse(fs.readFileSync(`${settingsPath}.orig`, 'utf-8')) as { statusLine?: { command?: string } };
expect(orig.statusLine?.command).toBe('old-command');
});
it('loadClaudeSettings should return empty object when settings file is missing', async () => {
await expect(loadClaudeSettings()).resolves.toEqual({});
});
it('loadClaudeSettings should log and throw when settings file is invalid JSON', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(loadClaudeSettings()).rejects.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to load Claude settings:',
expect.anything()
);
} finally {
consoleErrorSpy.mockRestore();
}
});
it('isInstalled should return false when settings cannot be loaded', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(isInstalled()).resolves.toBe(false);
expect(consoleErrorSpy).not.toHaveBeenCalled();
} finally {
consoleErrorSpy.mockRestore();
}
});
it('installStatusLine should warn and recover when existing settings are invalid', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await installStatusLine(false);
const settingsPath = getClaudeSettingsPath();
const installed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { statusLine?: { command?: string; padding?: number } };
expect(installed.statusLine?.command).toBe(CCSTATUSLINE_COMMANDS.NPM);
expect(installed.statusLine?.padding).toBe(0);
expect(fs.existsSync(`${settingsPath}.orig`)).toBe(true);
expect(fs.readFileSync(`${settingsPath}.orig`, 'utf-8')).toBe('{ invalid json');
expect(consoleErrorSpy).toHaveBeenCalledWith(
`Warning: Could not read existing Claude settings. A backup exists at ${settingsPath}.orig.`
);
} finally {
consoleErrorSpy.mockRestore();
}
});
it('uninstallStatusLine should warn and return without modifying invalid settings', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await uninstallStatusLine();
const settingsPath = getClaudeSettingsPath();
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json');
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Warning: Could not read existing Claude settings.'
);
} finally {
consoleErrorSpy.mockRestore();
}
});
it('uninstallStatusLine should remove all managed hooks', async () => {
writeRawClaudeSettings(JSON.stringify({
statusLine: {
type: 'command',
command: CCSTATUSLINE_COMMANDS.NPM,
padding: 0
},
hooks: {
PreToolUse: [
{
_tag: 'ccstatusline-managed',
matcher: 'Skill',
hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }]
},
{
matcher: 'Other',
hooks: [{ type: 'command', command: 'keep-me' }]
}
],
UserPromptSubmit: [
{
_tag: 'ccstatusline-managed',
hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }]
}
]
}
}));
await uninstallStatusLine();
const settingsPath = getClaudeSettingsPath();
const updated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
statusLine?: unknown;
hooks?: Record<string, unknown[]>;
};
expect(updated.statusLine).toBeUndefined();
expect(updated.hooks).toEqual({
PreToolUse: [
{
matcher: 'Other',
hooks: [{ type: 'command', command: 'keep-me' }]
}
]
});
});
it('getExistingStatusLine should return null when settings cannot be loaded', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(getExistingStatusLine()).resolves.toBeNull();
expect(consoleErrorSpy).not.toHaveBeenCalled();
} finally {
consoleErrorSpy.mockRestore();
}
});
it('isInstalled should accept known commands with --config and undefined padding', async () => {
await saveClaudeSettings({
statusLine: {
type: 'command',
command: `${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`
}
});
await expect(isInstalled()).resolves.toBe(true);
});
});
@@ -0,0 +1,40 @@
import {
describe,
expect,
it
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../types/Settings';
import { cloneSettings } from '../clone-settings';
describe('cloneSettings', () => {
it('creates a deep clone that is independent from source', () => {
const original = {
...DEFAULT_SETTINGS,
lines: [
[
{ id: '1', type: 'model', metadata: { key: 'value' } }
]
]
};
const cloned = cloneSettings(original);
const originalWidget = original.lines[0]?.[0];
const clonedWidget = cloned.lines[0]?.[0];
expect(originalWidget).toBeDefined();
expect(clonedWidget).toBeDefined();
if (!originalWidget || !clonedWidget) {
throw new Error('Expected cloned settings to include widget entries');
}
const originalMetadata = originalWidget.metadata as Record<string, string>;
const clonedMetadata = (clonedWidget.metadata ?? {});
clonedWidget.metadata = clonedMetadata;
clonedMetadata.key = 'changed';
expect(originalMetadata.key).toBe('value');
expect(clonedMetadata.key).toBe('changed');
});
});
@@ -0,0 +1,70 @@
import {
describe,
expect,
it
} from 'vitest';
import type { WidgetItem } from '../../types/Widget';
import {
hasCustomWidgetColors,
sanitizeLinesForColorLevel
} from '../color-sanitize';
describe('color sanitize helpers', () => {
it('detects custom ansi256/hex colors in foreground and background', () => {
const lines: WidgetItem[][] = [
[
{ id: '1', type: 'model', color: 'ansi256:120' }
],
[
{ id: '2', type: 'context-length', backgroundColor: 'hex:AA00BB' }
]
];
expect(hasCustomWidgetColors(lines)).toBe(true);
expect(hasCustomWidgetColors([[{ id: '3', type: 'model', color: 'cyan' }]])).toBe(false);
});
it('sanitizes hex colors when moving to ansi256 mode', () => {
const lines: WidgetItem[][] = [[
{ id: '1', type: 'model', color: 'hex:FF00AA', backgroundColor: 'hex:112233' },
{ id: '2', type: 'context-length', color: 'ansi256:111', backgroundColor: 'ansi256:24' }
]];
const sanitized = sanitizeLinesForColorLevel(lines, 2);
expect(sanitized[0]?.[0]?.color).toBe('cyan');
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
expect(sanitized[0]?.[1]?.color).toBe('ansi256:111');
expect(sanitized[0]?.[1]?.backgroundColor).toBe('ansi256:24');
});
it('sanitizes ansi256 colors when moving to truecolor mode', () => {
const lines: WidgetItem[][] = [[
{ id: '1', type: 'model', color: 'ansi256:120', backgroundColor: 'ansi256:244' },
{ id: '2', type: 'context-length', color: 'hex:AA11BB', backgroundColor: 'hex:112233' }
]];
const sanitized = sanitizeLinesForColorLevel(lines, 3);
expect(sanitized[0]?.[0]?.color).toBe('cyan');
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
expect(sanitized[0]?.[1]?.color).toBe('hex:AA11BB');
expect(sanitized[0]?.[1]?.backgroundColor).toBe('hex:112233');
});
it('sanitizes all custom colors when moving to basic/no-color modes', () => {
const lines: WidgetItem[][] = [[
{ id: '1', type: 'model', color: 'ansi256:99', backgroundColor: 'hex:123456' },
{ id: '2', type: 'separator', color: 'hex:ABCDEF', backgroundColor: 'ansi256:2' }
]];
const sanitized = sanitizeLinesForColorLevel(lines, 1);
expect(sanitized[0]?.[0]?.color).toBe('cyan');
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
// Preserve existing behavior: separator foreground is not reset by current logic.
expect(sanitized[0]?.[1]?.color).toBe('hex:ABCDEF');
expect(sanitized[0]?.[1]?.backgroundColor).toBeUndefined();
});
});
+48
View File
@@ -0,0 +1,48 @@
import * as os from 'os';
import * as path from 'path';
import {
beforeEach,
describe,
expect,
it
} from 'vitest';
import {
getConfigPath,
initConfigPath,
isCustomConfigPath
} from '../config';
const DEFAULT_PATH = path.join(os.homedir(), '.config', 'ccstatusline', 'settings.json');
describe('initConfigPath / getConfigPath', () => {
beforeEach(() => {
initConfigPath();
});
it('should return the default settings path when no arg is provided', () => {
initConfigPath();
expect(getConfigPath()).toBe(DEFAULT_PATH);
expect(isCustomConfigPath()).toBe(false);
});
it('should return a custom settings path when a file path is provided', () => {
initConfigPath('/tmp/my-ccsl/settings.json');
expect(getConfigPath()).toBe('/tmp/my-ccsl/settings.json');
expect(isCustomConfigPath()).toBe(true);
});
it('should resolve relative paths', () => {
initConfigPath('relative/settings.json');
expect(path.isAbsolute(getConfigPath())).toBe(true);
expect(getConfigPath()).toBe(path.resolve('relative/settings.json'));
});
it('should reset to default when called with undefined', () => {
initConfigPath('/tmp/custom.json');
expect(isCustomConfigPath()).toBe(true);
initConfigPath(undefined);
expect(getConfigPath()).toBe(DEFAULT_PATH);
expect(isCustomConfigPath()).toBe(false);
});
});
+174
View File
@@ -0,0 +1,174 @@
import * as fs from 'fs';
import path from 'path';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance
} from 'vitest';
import {
CURRENT_VERSION,
DEFAULT_SETTINGS,
type Settings
} from '../../types/Settings';
const MOCK_HOME_DIR = '/tmp/ccstatusline-config-test-home';
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
let loadSettings: () => Promise<Settings>;
let saveSettings: (settings: Settings) => Promise<void>;
let initConfigPath: (filePath?: string) => void;
let consoleErrorSpy: MockInstance<typeof console.error>;
function getSettingsPaths(): { configDir: string; settingsPath: string; backupPath: string } {
const configDir = path.join(MOCK_HOME_DIR, '.config', 'ccstatusline');
return {
configDir,
settingsPath: path.join(configDir, 'settings.json'),
backupPath: path.join(configDir, 'settings.bak')
};
}
function getClaudeConfigDir(): string {
return path.join(MOCK_HOME_DIR, '.claude');
}
describe('config utilities', () => {
beforeAll(async () => {
const configModule = await import('../config');
loadSettings = configModule.loadSettings;
saveSettings = configModule.saveSettings;
initConfigPath = configModule.initConfigPath;
});
beforeEach(() => {
fs.rmSync(MOCK_HOME_DIR, { recursive: true, force: true });
process.env.CLAUDE_CONFIG_DIR = getClaudeConfigDir();
const { settingsPath } = getSettingsPaths();
initConfigPath(settingsPath);
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
afterAll(() => {
fs.rmSync(MOCK_HOME_DIR, { recursive: true, force: true });
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
}
initConfigPath();
});
it('writes defaults when settings file does not exist', async () => {
const { settingsPath } = getSettingsPaths();
const settings = await loadSettings();
expect(settings.version).toBe(CURRENT_VERSION);
expect(fs.existsSync(settingsPath)).toBe(true);
const onDisk = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
version?: number;
lines?: unknown[];
};
expect(onDisk.version).toBe(CURRENT_VERSION);
expect(Array.isArray(onDisk.lines)).toBe(true);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Default settings written to')
);
});
it('backs up invalid JSON and recovers with defaults', async () => {
const { settingsPath, backupPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(settingsPath, '{ invalid json', 'utf-8');
const settings = await loadSettings();
expect(settings.version).toBe(CURRENT_VERSION);
expect(fs.existsSync(backupPath)).toBe(true);
expect(fs.readFileSync(backupPath, 'utf-8')).toBe('{ invalid json');
const recovered = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
expect(recovered.version).toBe(CURRENT_VERSION);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to parse settings.json, backing up and using defaults'
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Bad settings backed up to')
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Default settings written to')
);
});
it('backs up invalid v1 payloads and recovers with defaults', async () => {
const { settingsPath, backupPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify({ flexMode: 123 }), 'utf-8');
const settings = await loadSettings();
expect(settings.version).toBe(CURRENT_VERSION);
expect(fs.existsSync(backupPath)).toBe(true);
const recovered = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
expect(recovered.version).toBe(CURRENT_VERSION);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Invalid v1 settings format:',
expect.anything()
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Bad settings backed up to')
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Default settings written to')
);
});
it('migrates older versioned settings and persists migrated result', async () => {
const { settingsPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
settingsPath,
JSON.stringify({
version: 2,
lines: [[{ id: 'widget-1', type: 'model' }]]
}),
'utf-8'
);
const settings = await loadSettings();
expect(settings.version).toBe(CURRENT_VERSION);
const migrated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
version?: number;
updatemessage?: { message?: string };
};
expect(migrated.version).toBe(CURRENT_VERSION);
expect(migrated.updatemessage?.message).toContain('v2.0.2');
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('always saves current version in saveSettings', async () => {
const { settingsPath } = getSettingsPaths();
await saveSettings({
...DEFAULT_SETTINGS,
version: 1
});
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
expect(saved.version).toBe(CURRENT_VERSION);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,199 @@
import {
describe,
expect,
it
} from 'vitest';
import type { RenderContext } from '../../types';
import { calculateContextPercentage } from '../context-percentage';
describe('calculateContextPercentage', () => {
describe('Status JSON context_window', () => {
it('should prefer context_window used_percentage over token metrics', () => {
const context: RenderContext = {
data: {
model: { id: 'claude-3-5-sonnet-20241022' },
context_window: {
context_window_size: 200000,
used_percentage: 12.5
}
},
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 100000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(12.5);
});
it('should derive percentage from current usage and window size 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
}
}
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(20);
});
it('should use context_window_size as denominator when falling back to token metrics', () => {
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
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
});
describe('Sonnet 4.5 with 1M context window', () => {
it('should calculate percentage using 1M denominator with [1m] suffix', () => {
const context: RenderContext = {
data: { model: { id: 'claude-sonnet-4-5-20250929[1m]' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
it('should cap at 100% with [1m] suffix', () => {
const context: RenderContext = {
data: { model: { id: 'claude-sonnet-4-5-20250929[1m]' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 2000000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(100);
});
it('should calculate percentage using 1M denominator with 1M context label', () => {
const context: RenderContext = {
data: { model: { id: 'Opus 4.6 (1M context)' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
it('should calculate percentage using 1M denominator with 1M in parentheses', () => {
const context: RenderContext = {
data: { model: { id: 'Opus 4.6 (1M)' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
it('should calculate percentage from display_name when model id lacks context size suffix', () => {
const context: RenderContext = {
data: {
model: {
id: 'claude-opus-4-6',
display_name: 'Opus 4.6 (1M context)'
}
},
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
});
describe('Older models with 200k context window', () => {
it('should calculate percentage using 200k denominator', () => {
const context: RenderContext = {
data: { model: { id: 'claude-3-5-sonnet-20241022' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(21.0);
});
it('should return 0 when no token metrics', () => {
const context: RenderContext = { data: { model: { id: 'claude-3-5-sonnet-20241022' } } };
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(0);
});
it('should use default 200k context when model ID is undefined', () => {
const context: RenderContext = {
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(21.0);
});
});
});
@@ -0,0 +1,75 @@
import {
describe,
expect,
it
} from 'vitest';
import { getContextWindowMetrics } from '../context-window';
describe('getContextWindowMetrics', () => {
it('returns null metrics when context_window is missing', () => {
expect(getContextWindowMetrics(undefined)).toEqual({
windowSize: null,
usedTokens: null,
contextLengthTokens: null,
usedPercentage: null,
remainingPercentage: null,
totalInputTokens: null,
totalOutputTokens: null,
cachedTokens: null,
totalTokens: null
});
});
it('extracts totals and usage from current_usage object', () => {
const metrics = getContextWindowMetrics({
context_window: {
context_window_size: 200000,
total_input_tokens: 4567,
total_output_tokens: 890,
current_usage: {
input_tokens: 1000,
output_tokens: 200,
cache_creation_input_tokens: 300,
cache_read_input_tokens: 400
}
}
});
expect(metrics).toEqual({
windowSize: 200000,
usedTokens: 1900,
contextLengthTokens: 1700,
usedPercentage: 0.95,
remainingPercentage: 99.05,
totalInputTokens: 4567,
totalOutputTokens: 890,
cachedTokens: 700,
totalTokens: 1900
});
});
it('derives token usage from used_percentage when current_usage is missing', () => {
const metrics = getContextWindowMetrics({
context_window: {
context_window_size: 200000,
used_percentage: 12.5,
remaining_percentage: 87.5,
total_input_tokens: 5000,
total_output_tokens: 1000
}
});
expect(metrics).toEqual({
windowSize: 200000,
usedTokens: 25000,
contextLengthTokens: 25000,
usedPercentage: 12.5,
remainingPercentage: 87.5,
totalInputTokens: 5000,
totalOutputTokens: 1000,
cachedTokens: null,
totalTokens: 6000
});
});
});
+165
View File
@@ -0,0 +1,165 @@
import {
describe,
expect,
it
} from 'vitest';
import {
fetchPrData,
type PrCacheDeps
} from '../gh-pr-cache';
interface FakeCacheFile {
content: string;
mtimeMs: number;
}
interface PrCacheHarness {
cacheFiles: Map<string, FakeCacheFile>;
deps: PrCacheDeps;
execCalls: { args: string[]; cmd: string; cwd?: string }[];
ghResponses: (Error | string)[];
setCurrentRef: (ref: string) => void;
}
function createHarness(): PrCacheHarness {
const cacheFiles = new Map<string, FakeCacheFile>();
const execCalls: { args: string[]; cmd: string; cwd?: string }[] = [];
const ghResponses: (Error | string)[] = [];
const now = 1_700_000_000_000;
let currentRef = 'feature/cache-a';
const deps: PrCacheDeps = {
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] === '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] === 'pr') {
const response = ghResponses.shift();
if (response instanceof Error)
throw response;
return response ?? '';
}
throw new Error(`Unexpected command: ${cmd} ${commandArgs.join(' ')}`);
}) as PrCacheDeps['execFileSync'],
existsSync: (filePath => cacheFiles.has(String(filePath))) as PrCacheDeps['existsSync'],
getHomedir: () => '/tmp/home',
mkdirSync: (() => undefined) as PrCacheDeps['mkdirSync'],
now: () => now,
readFileSync: (filePath => cacheFiles.get(String(filePath))?.content ?? '') as PrCacheDeps['readFileSync'],
statSync: (filePath => ({ mtimeMs: cacheFiles.get(String(filePath))?.mtimeMs ?? now })) as PrCacheDeps['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 PrCacheDeps['writeFileSync']
};
return {
cacheFiles,
deps,
execCalls,
ghResponses,
setCurrentRef: (ref: string) => {
currentRef = ref;
}
};
}
describe('gh-pr-cache', () => {
it('negative-caches failed gh PR lookups', () => {
const harness = createHarness();
harness.ghResponses.push(new Error('no pull request found'));
expect(fetchPrData('/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(fetchPrData('/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(fetchPrData('/tmp/repo', harness.deps)).toEqual({
number: 123,
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(fetchPrData('/tmp/repo', harness.deps)).toEqual({
number: 456,
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]);
expect(writtenCachePaths[0]).toContain('/.cache/ccstatusline/pr/pr-');
expect(writtenCachePaths[1]).toContain('/.cache/ccstatusline/pr/pr-');
harness.setCurrentRef('feature/cache-a');
expect(fetchPrData('/tmp/repo', harness.deps)).toEqual({
number: 123,
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);
});
});
+379
View File
@@ -0,0 +1,379 @@
import { execSync } 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() }));
const mockExecSync = execSync 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'
});
});
});
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'
});
});
});
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', () => {
mockExecSync.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('returns null when remote does not exist', () => {
mockExecSync.mockImplementation(() => { throw new Error('No such remote'); });
expect(getRemoteInfo('nonexistent', {})).toBeNull();
});
it('returns null when URL cannot be parsed', () => {
mockExecSync.mockReturnValue('invalid-url\n');
expect(getRemoteInfo('origin', {})).toBeNull();
});
});
describe('getUpstreamRemoteInfo', () => {
it('prefers a literal upstream remote when present', () => {
mockExecSync.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', () => {
mockExecSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
mockExecSync.mockReturnValueOnce('hangie/feature/new-git-and-worktree-widgets\n');
mockExecSync.mockReturnValueOnce('origin\nhangie\n');
mockExecSync.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', () => {
mockExecSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
mockExecSync.mockReturnValueOnce('team/upstream/feature/worktree\n');
mockExecSync.mockReturnValueOnce('origin\nteam\nteam/upstream\n');
mockExecSync.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', () => {
mockExecSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
mockExecSync.mockReturnValueOnce('hangie/feature/new-git-and-worktree-widgets\n');
mockExecSync.mockReturnValueOnce('origin\n');
expect(getUpstreamRemoteInfo({})).toBeNull();
});
});
describe('getForkStatus', () => {
it('detects fork when origin and upstream differ', () => {
mockExecSync.mockReturnValueOnce('https://github.com/hangie/ccstatusline.git\n');
mockExecSync.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', () => {
mockExecSync.mockReturnValueOnce('https://github.com/hangie/my-fork.git\n');
mockExecSync.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', () => {
mockExecSync.mockReturnValueOnce('https://github.com/owner/repo.git\n');
mockExecSync.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', () => {
mockExecSync.mockReturnValueOnce('https://github.com/owner/repo.git\n');
mockExecSync.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', () => {
mockExecSync.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', () => {
mockExecSync.mockReturnValue('origin\nupstream\n');
expect(listRemotes({})).toEqual(['origin', 'upstream']);
});
it('returns empty array when no remotes', () => {
mockExecSync.mockImplementation(() => { throw new Error('Not a git repo'); });
expect(listRemotes({})).toEqual([]);
});
it('filters empty lines', () => {
mockExecSync.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');
});
});
});
+380
View File
@@ -0,0 +1,380 @@
import { execSync } from 'child_process';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import type { RenderContext } from '../../types/RenderContext';
import {
clearGitCache,
getGitChangeCounts,
getGitStatus,
isInsideGitWorkTree,
resolveGitCwd,
runGit
} from '../git';
vi.mock('child_process', () => ({ execSync: vi.fn() }));
const mockExecSync = execSync as unknown as {
mock: { calls: unknown[][] };
mockImplementation: (impl: () => never) => void;
mockReturnValue: (value: string) => void;
mockReturnValueOnce: (value: string) => void;
};
describe('git utils', () => {
beforeEach(() => {
vi.clearAllMocks();
clearGitCache();
});
describe('resolveGitCwd', () => {
it('prefers context.data.cwd when available', () => {
const context: RenderContext = {
data: {
cwd: '/repo/from/cwd',
workspace: {
current_dir: '/repo/from/current-dir',
project_dir: '/repo/from/project-dir'
}
}
};
expect(resolveGitCwd(context)).toBe('/repo/from/cwd');
});
it('falls back to workspace.current_dir', () => {
const context: RenderContext = {
data: {
workspace: {
current_dir: '/repo/from/current-dir',
project_dir: '/repo/from/project-dir'
}
}
};
expect(resolveGitCwd(context)).toBe('/repo/from/current-dir');
});
it('falls back to workspace.project_dir', () => {
const context: RenderContext = { data: { workspace: { project_dir: '/repo/from/project-dir' } } };
expect(resolveGitCwd(context)).toBe('/repo/from/project-dir');
});
it('skips empty candidate values', () => {
const context: RenderContext = {
data: {
cwd: ' ',
workspace: {
current_dir: '',
project_dir: '/repo/from/project-dir'
}
}
};
expect(resolveGitCwd(context)).toBe('/repo/from/project-dir');
});
it('returns undefined when no candidates are available', () => {
expect(resolveGitCwd({})).toBeUndefined();
});
});
describe('runGit', () => {
it('runs git command with resolved cwd and trims trailing whitespace', () => {
mockExecSync.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({
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
cwd: '/tmp/repo'
});
});
it('runs git command without cwd when no context directory exists', () => {
mockExecSync.mockReturnValueOnce('true\n');
const result = runGit('rev-parse --is-inside-work-tree', {});
expect(result).toBe('true');
expect(mockExecSync.mock.calls[0]?.[1]).toEqual({
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
});
});
it('returns null when the command fails', () => {
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
expect(runGit('status --short', {})).toBeNull();
});
});
describe('isInsideGitWorkTree', () => {
it('returns true when git reports true', () => {
mockExecSync.mockReturnValueOnce('true\n');
expect(isInsideGitWorkTree({})).toBe(true);
});
it('returns false when git reports false', () => {
mockExecSync.mockReturnValueOnce('false\n');
expect(isInsideGitWorkTree({})).toBe(false);
});
it('returns false when git command fails', () => {
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
expect(isInsideGitWorkTree({})).toBe(false);
});
});
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(-)');
expect(getGitChangeCounts({})).toEqual({
insertions: 5,
deletions: 5
});
});
it('handles singular insertion/deletion forms', () => {
mockExecSync.mockReturnValueOnce('1 file changed, 1 insertion(+), 1 deletion(-)');
mockExecSync.mockReturnValueOnce('');
expect(getGitChangeCounts({})).toEqual({
insertions: 1,
deletions: 1
});
});
it('returns zero counts when git diff commands fail', () => {
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
expect(getGitChangeCounts({})).toEqual({
insertions: 0,
deletions: 0
});
});
});
describe('getGitStatus', () => {
it('returns all false when no git output', () => {
mockExecSync.mockReturnValueOnce('');
expect(getGitStatus({})).toEqual({
staged: false,
unstaged: false,
untracked: false,
conflicts: false
});
});
it('detects staged modification', () => {
mockExecSync.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', () => {
mockExecSync.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', () => {
mockExecSync.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', () => {
mockExecSync.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', () => {
mockExecSync.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', () => {
mockExecSync.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)', () => {
mockExecSync.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)', () => {
mockExecSync.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)', () => {
mockExecSync.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)', () => {
mockExecSync.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)', () => {
mockExecSync.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)', () => {
mockExecSync.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)', () => {
mockExecSync.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)', () => {
mockExecSync.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)', () => {
mockExecSync.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', () => {
mockExecSync.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', () => {
mockExecSync.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('detects type changed file in index (staged)', () => {
mockExecSync.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', () => {
mockExecSync.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', () => {
mockExecSync.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', () => {
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
expect(getGitStatus({})).toEqual({
staged: false,
unstaged: false,
untracked: false,
conflicts: false
});
});
});
});
+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' }]
}
]
});
});
});
+49
View File
@@ -0,0 +1,49 @@
import {
describe,
expect,
it
} from 'vitest';
import {
buildIdeFileUrl,
encodeGitRefForUrlPath,
parseGitHubBaseUrl
} from '../hyperlink';
describe('parseGitHubBaseUrl', () => {
it('supports scp-style SSH remotes', () => {
expect(parseGitHubBaseUrl('git@github.com:owner/repo.git')).toBe('https://github.com/owner/repo');
});
it('supports ssh URL remotes', () => {
expect(parseGitHubBaseUrl('ssh://git@github.com/owner/repo.git')).toBe('https://github.com/owner/repo');
});
it('supports credentialed HTTPS remotes', () => {
expect(parseGitHubBaseUrl('https://token@github.com/owner/repo.git')).toBe('https://github.com/owner/repo');
});
it('rejects non-GitHub remotes', () => {
expect(parseGitHubBaseUrl('https://gitlab.com/owner/repo.git')).toBeNull();
});
});
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');
});
});
+31
View File
@@ -0,0 +1,31 @@
import {
describe,
expect,
it
} from 'vitest';
import { shouldInsertInput } from '../input-guards';
describe('shouldInsertInput', () => {
it('allows regular printable input without modifiers', () => {
expect(shouldInsertInput('s', {})).toBe(true);
expect(shouldInsertInput('S', { shift: true })).toBe(true);
});
it('blocks ctrl chords', () => {
expect(shouldInsertInput('s', { ctrl: true })).toBe(false);
});
it('blocks meta chords', () => {
expect(shouldInsertInput('s', { meta: true })).toBe(false);
});
it('blocks tab-based input', () => {
expect(shouldInsertInput('\t', { tab: true })).toBe(false);
});
it('blocks control characters and allows unicode text', () => {
expect(shouldInsertInput('\u0013', {})).toBe(false);
expect(shouldInsertInput('🙂', {})).toBe(true);
});
});
+88
View File
@@ -0,0 +1,88 @@
import * as fs from 'fs';
import os from 'os';
import path from 'path';
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';
import { getBlockMetrics } from '../jsonl';
function floorToHourUtc(timestamp: Date): Date {
const floored = new Date(timestamp);
floored.setUTCMinutes(0, 0, 0);
return floored;
}
function makeUsageLine(timestamp: Date): string {
return JSON.stringify({
timestamp: timestamp.toISOString(),
message: {
usage: {
input_tokens: 100,
output_tokens: 50
}
}
});
}
describe('jsonl block metrics integration', () => {
let tempClaudeDir: string;
let originalClaudeConfigDir: string | undefined;
beforeEach(() => {
tempClaudeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-blocks-'));
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = tempClaudeDir;
});
afterEach(() => {
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
}
fs.rmSync(tempClaudeDir, { recursive: true, force: true });
});
it('returns the current block start for recent activity after an older session gap', () => {
const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a');
fs.mkdirSync(projectsDir, { recursive: true });
const transcriptPath = path.join(projectsDir, 'session.jsonl');
const now = new Date();
const oldActivity = new Date(now.getTime() - (10 * 60 * 60 * 1000));
const currentBlockStartSource = new Date(now.getTime() - (2 * 60 * 60 * 1000) - (10 * 60 * 1000));
const recentActivity = new Date(now.getTime() - (40 * 60 * 1000));
fs.writeFileSync(transcriptPath, [
makeUsageLine(oldActivity),
makeUsageLine(currentBlockStartSource),
makeUsageLine(recentActivity)
].join('\n'));
const metrics = getBlockMetrics();
expect(metrics).not.toBeNull();
expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(currentBlockStartSource).toISOString());
expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString());
});
it('returns null when the most recent activity is older than the session window', () => {
const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a');
fs.mkdirSync(projectsDir, { recursive: true });
const transcriptPath = path.join(projectsDir, 'stale-session.jsonl');
const now = new Date();
const staleActivity = new Date(now.getTime() - (6 * 60 * 60 * 1000));
fs.writeFileSync(transcriptPath, makeUsageLine(staleActivity));
const metrics = getBlockMetrics();
expect(metrics).toBeNull();
});
});
+886
View File
@@ -0,0 +1,886 @@
import * as fs from 'fs';
import os from 'os';
import path from 'path';
import {
afterEach,
describe,
expect,
it
} from 'vitest';
import {
getSessionDuration,
getSpeedMetrics,
getSpeedMetricsCollection,
getTokenMetrics
} from '../jsonl';
function makeUsageLine(params: {
timestamp: string;
input: number;
output: number;
cacheRead?: number;
cacheCreate?: number;
isSidechain?: boolean;
isApiErrorMessage?: boolean;
}): string {
return JSON.stringify({
timestamp: params.timestamp,
isSidechain: params.isSidechain,
isApiErrorMessage: params.isApiErrorMessage,
message: {
usage: {
input_tokens: params.input,
output_tokens: params.output,
cache_read_input_tokens: params.cacheRead,
cache_creation_input_tokens: params.cacheCreate
}
}
});
}
function makeTranscriptLine(params: {
timestamp: string;
type: 'user' | 'assistant';
input?: number;
output?: number;
isSidechain?: boolean;
isApiErrorMessage?: boolean;
}): string {
return JSON.stringify({
timestamp: params.timestamp,
type: params.type,
isSidechain: params.isSidechain,
isApiErrorMessage: params.isApiErrorMessage,
message: typeof params.input === 'number' || typeof params.output === 'number'
? {
usage: {
input_tokens: params.input ?? 0,
output_tokens: params.output ?? 0
}
}
: undefined
});
}
describe('jsonl transcript metrics', () => {
const tempRoots: string[] = [];
afterEach(() => {
while (tempRoots.length > 0) {
const root = tempRoots.pop();
if (root) {
fs.rmSync(root, { recursive: true, force: true });
}
}
});
it('formats session duration as <1m for sub-minute transcripts', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'short.jsonl');
fs.writeFileSync(transcriptPath, [
JSON.stringify({ timestamp: '2026-01-01T10:00:00.000Z' }),
JSON.stringify({ timestamp: '2026-01-01T10:00:30.000Z' })
].join('\n'));
const duration = await getSessionDuration(transcriptPath);
expect(duration).toBe('<1m');
});
it('formats multi-hour session durations', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'long.jsonl');
fs.writeFileSync(transcriptPath, [
JSON.stringify({ timestamp: '2026-01-01T10:00:00.000Z' }),
JSON.stringify({ timestamp: '2026-01-01T12:05:00.000Z' })
].join('\n'));
const duration = await getSessionDuration(transcriptPath);
expect(duration).toBe('2hr 5m');
});
it('returns null for missing transcript files', async () => {
const duration = await getSessionDuration('/tmp/ccstatusline-jsonl-metrics-missing.jsonl');
expect(duration).toBeNull();
});
it('aggregates token totals and computes context length from the latest main-chain non-error entry', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'tokens.jsonl');
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
}),
makeUsageLine({
timestamp: '2026-01-01T11:30:00.000Z',
input: 500,
output: 10,
cacheRead: 5,
cacheCreate: 5,
isSidechain: true
}),
makeUsageLine({
timestamp: '2026-01-01T11:45:00.000Z',
input: 999,
output: 1,
cacheRead: 1,
cacheCreate: 1,
isApiErrorMessage: true
})
];
fs.writeFileSync(transcriptPath, lines.join('\n'));
const metrics = await getTokenMetrics(transcriptPath);
expect(metrics).toEqual({
inputTokens: 1799,
outputTokens: 141,
cachedTokens: 92,
totalTokens: 2032,
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({
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 0
});
});
it('calculates speed metrics from user-to-assistant processing windows', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'speed.jsonl');
fs.writeFileSync(transcriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:05.000Z',
type: 'assistant',
input: 200,
output: 100
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:08.000Z',
type: 'assistant',
input: 100,
output: 50
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:01:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:01:04.000Z',
type: 'assistant',
input: 300,
output: 150
})
].join('\n'));
const metrics = await getSpeedMetrics(transcriptPath);
expect(metrics).toEqual({
totalDurationMs: 12000,
inputTokens: 600,
outputTokens: 300,
totalTokens: 900,
requestCount: 3
});
});
it('calculates windowed speed metrics from recent requests only', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'speed-window.jsonl');
fs.writeFileSync(transcriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:10.000Z',
type: 'assistant',
input: 100,
output: 50
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:01:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:01:10.000Z',
type: 'assistant',
input: 200,
output: 100
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:02:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:02:10.000Z',
type: 'assistant',
input: 300,
output: 150
})
].join('\n'));
const metrics = await getSpeedMetrics(transcriptPath, { windowSeconds: 70 });
expect(metrics).toEqual({
totalDurationMs: 20000,
inputTokens: 500,
outputTokens: 250,
totalTokens: 750,
requestCount: 2
});
});
it('returns session and windowed speed metrics in one collection call', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'speed-window-collection.jsonl');
fs.writeFileSync(transcriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:10.000Z',
type: 'assistant',
input: 100,
output: 50
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:40.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:50.000Z',
type: 'assistant',
input: 200,
output: 100
})
].join('\n'));
const metricsCollection = await getSpeedMetricsCollection(transcriptPath, { windowSeconds: [30, 90] });
expect(metricsCollection.sessionAverage).toEqual({
totalDurationMs: 20000,
inputTokens: 300,
outputTokens: 150,
totalTokens: 450,
requestCount: 2
});
expect(metricsCollection.windowed['30']).toEqual({
totalDurationMs: 10000,
inputTokens: 200,
outputTokens: 100,
totalTokens: 300,
requestCount: 1
});
expect(metricsCollection.windowed['90']).toEqual({
totalDurationMs: 20000,
inputTokens: 300,
outputTokens: 150,
totalTokens: 450,
requestCount: 2
});
});
it('ignores sidechain and API error entries in speed metrics', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'speed-filtering.jsonl');
fs.writeFileSync(transcriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:01.000Z',
type: 'assistant',
input: 999,
output: 999,
isSidechain: true
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:02.000Z',
type: 'assistant',
input: 500,
output: 500,
isApiErrorMessage: true
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:03.000Z',
type: 'assistant',
input: 100,
output: 50
})
].join('\n'));
const metrics = await getSpeedMetrics(transcriptPath);
expect(metrics).toEqual({
totalDurationMs: 3000,
inputTokens: 100,
outputTokens: 50,
totalTokens: 150,
requestCount: 1
});
});
it('does not parse subagent transcripts unless includeSubagents is enabled', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'speed-main.jsonl');
const subagentsDir = path.join(root, 'subagents');
const subagentTranscriptPath = path.join(subagentsDir, 'agent-1.jsonl');
fs.writeFileSync(transcriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:04.000Z',
type: 'assistant',
input: 10,
output: 20
}),
JSON.stringify({
type: 'progress',
data: { agentId: '1' }
})
].join('\n'));
fs.mkdirSync(subagentsDir, { recursive: true });
fs.writeFileSync(subagentTranscriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:01.000Z',
type: 'user',
isSidechain: true
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:11.000Z',
type: 'assistant',
input: 100,
output: 200,
isSidechain: true
})
].join('\n'));
const metrics = await getSpeedMetrics(transcriptPath);
expect(metrics).toEqual({
totalDurationMs: 4000,
inputTokens: 10,
outputTokens: 20,
totalTokens: 30,
requestCount: 1
});
});
it('aggregates subagent speed metrics with merged active windows when enabled', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'speed-main-with-subagents.jsonl');
const subagentsDir = path.join(root, 'subagents');
fs.writeFileSync(transcriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:10.000Z',
type: 'assistant',
input: 50,
output: 100
}),
JSON.stringify({
type: 'progress',
data: { agentId: 'a' }
}),
JSON.stringify({
type: 'progress',
data: { agentId: 'b' }
})
].join('\n'));
fs.mkdirSync(subagentsDir, { recursive: true });
fs.writeFileSync(path.join(subagentsDir, 'agent-a.jsonl'), [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:05.000Z',
type: 'user',
isSidechain: true
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:15.000Z',
type: 'assistant',
input: 150,
output: 300,
isSidechain: true
})
].join('\n'));
fs.writeFileSync(path.join(subagentsDir, 'agent-b.jsonl'), [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:20.000Z',
type: 'user',
isSidechain: true
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:25.000Z',
type: 'assistant',
input: 25,
output: 50,
isSidechain: true
})
].join('\n'));
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
expect(metrics).toEqual({
totalDurationMs: 20000,
inputTokens: 225,
outputTokens: 450,
totalTokens: 675,
requestCount: 3
});
});
it('applies window filtering to aggregated subagent speed metrics', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'speed-main-subagent-windowed.jsonl');
const subagentsDir = path.join(root, 'subagents');
fs.writeFileSync(transcriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:04.000Z',
type: 'assistant',
input: 10,
output: 20
}),
JSON.stringify({
type: 'progress',
data: { agentId: 'a' }
})
].join('\n'));
fs.mkdirSync(subagentsDir, { recursive: true });
fs.writeFileSync(path.join(subagentsDir, 'agent-a.jsonl'), [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:05.000Z',
type: 'user',
isSidechain: true
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:10.000Z',
type: 'assistant',
input: 30,
output: 60,
isSidechain: true
})
].join('\n'));
const metrics = await getSpeedMetrics(transcriptPath, {
includeSubagents: true,
windowSeconds: 4
});
expect(metrics).toEqual({
totalDurationMs: 4000,
inputTokens: 30,
outputTokens: 60,
totalTokens: 90,
requestCount: 1
});
});
it('includes only referenced subagent transcripts from the parent transcript', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'speed-main-referenced-subagents.jsonl');
const subagentsDir = path.join(root, 'subagents');
fs.writeFileSync(transcriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:05.000Z',
type: 'assistant',
input: 20,
output: 30
}),
JSON.stringify({
type: 'progress',
data: { agentId: 'referenced-agent' }
})
].join('\n'));
fs.mkdirSync(subagentsDir, { recursive: true });
fs.writeFileSync(path.join(subagentsDir, 'agent-referenced-agent.jsonl'), [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:06.000Z',
type: 'user',
isSidechain: true
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:08.000Z',
type: 'assistant',
input: 10,
output: 20,
isSidechain: true
})
].join('\n'));
fs.writeFileSync(path.join(subagentsDir, 'agent-unrelated-agent.jsonl'), [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:06.000Z',
type: 'user',
isSidechain: true
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:18.000Z',
type: 'assistant',
input: 500,
output: 900,
isSidechain: true
})
].join('\n'));
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
expect(metrics).toEqual({
totalDurationMs: 7000,
inputTokens: 30,
outputTokens: 50,
totalTokens: 80,
requestCount: 2
});
});
it('finds subagents in session-directory layout used by Claude transcripts', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const sessionId = 'session-123';
const transcriptPath = path.join(root, `${sessionId}.jsonl`);
const subagentsDir = path.join(root, sessionId, 'subagents');
fs.writeFileSync(transcriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:04.000Z',
type: 'assistant',
input: 10,
output: 20
}),
JSON.stringify({
type: 'progress',
data: { agentId: 'layout-agent' }
})
].join('\n'));
fs.mkdirSync(subagentsDir, { recursive: true });
fs.writeFileSync(path.join(subagentsDir, 'agent-layout-agent.jsonl'), [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:05.000Z',
type: 'user',
isSidechain: true
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:08.000Z',
type: 'assistant',
input: 15,
output: 25,
isSidechain: true
})
].join('\n'));
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
expect(metrics).toEqual({
totalDurationMs: 7000,
inputTokens: 25,
outputTokens: 45,
totalTokens: 70,
requestCount: 2
});
});
it('falls back to main transcript metrics when subagent folder cannot be listed', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'speed-main-discovery-failure.jsonl');
const subagentsPath = path.join(root, 'subagents');
fs.writeFileSync(transcriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:03.000Z',
type: 'assistant',
input: 30,
output: 60
}),
JSON.stringify({
type: 'progress',
data: { agentId: 'unreadable' }
})
].join('\n'));
// Create a regular file where the subagents directory is expected.
fs.writeFileSync(subagentsPath, 'not-a-directory');
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
expect(metrics).toEqual({
totalDurationMs: 3000,
inputTokens: 30,
outputTokens: 60,
totalTokens: 90,
requestCount: 1
});
});
it('ignores malformed subagent lines without failing', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'speed-main-malformed-subagent.jsonl');
const subagentsDir = path.join(root, 'subagents');
fs.writeFileSync(transcriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:02.000Z',
type: 'assistant',
input: 10,
output: 20
}),
JSON.stringify({
type: 'progress',
data: { agentId: 'malformed' }
})
].join('\n'));
fs.mkdirSync(subagentsDir, { recursive: true });
fs.writeFileSync(path.join(subagentsDir, 'agent-malformed.jsonl'), [
'not-json',
makeTranscriptLine({
timestamp: '2026-01-01T10:00:03.000Z',
type: 'user',
isSidechain: true
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:07.000Z',
type: 'assistant',
input: 5,
output: 15,
isSidechain: true
})
].join('\n'));
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
expect(metrics).toEqual({
totalDurationMs: 6000,
inputTokens: 15,
outputTokens: 35,
totalTokens: 50,
requestCount: 2
});
});
it('falls back to main transcript metrics when subagents directory is missing', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'speed-main-no-subagents.jsonl');
fs.writeFileSync(transcriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:03.000Z',
type: 'assistant',
input: 30,
output: 60
}),
JSON.stringify({
type: 'progress',
data: { agentId: 'unreadable' }
})
].join('\n'));
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
expect(metrics).toEqual({
totalDurationMs: 3000,
inputTokens: 30,
outputTokens: 60,
totalTokens: 90,
requestCount: 1
});
});
it('ignores unreadable subagent transcript files without failing', async () => {
if (process.platform === 'win32') {
expect(true).toBe(true);
return;
}
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'speed-main-unreadable-subagent.jsonl');
const subagentsDir = path.join(root, 'subagents');
const unreadableSubagentPath = path.join(subagentsDir, 'agent-unreadable.jsonl');
fs.writeFileSync(transcriptPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:00.000Z',
type: 'user'
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:03.000Z',
type: 'assistant',
input: 30,
output: 60
})
].join('\n'));
fs.mkdirSync(subagentsDir, { recursive: true });
fs.writeFileSync(unreadableSubagentPath, [
makeTranscriptLine({
timestamp: '2026-01-01T10:00:04.000Z',
type: 'user',
isSidechain: true
}),
makeTranscriptLine({
timestamp: '2026-01-01T10:00:06.000Z',
type: 'assistant',
input: 100,
output: 200,
isSidechain: true
})
].join('\n'));
fs.chmodSync(unreadableSubagentPath, 0o000);
const metrics = await (async () => {
try {
return await getSpeedMetrics(transcriptPath, { includeSubagents: true });
} finally {
fs.chmodSync(unreadableSubagentPath, 0o600);
}
})();
expect(metrics).toEqual({
totalDurationMs: 3000,
inputTokens: 30,
outputTokens: 60,
totalTokens: 90,
requestCount: 1
});
});
it('returns empty speed metrics when transcript path points to an unreadable directory', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'not-a-jsonl-file');
fs.mkdirSync(transcriptPath);
const metrics = await getSpeedMetrics(transcriptPath);
expect(metrics).toEqual({
totalDurationMs: 0,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
requestCount: 0
});
});
it('counts assistant tokens without timestamps while keeping active duration at zero', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'speed-missing-timestamps.jsonl');
fs.writeFileSync(transcriptPath, [
JSON.stringify({
type: 'assistant',
message: {
usage: {
input_tokens: 7,
output_tokens: 9
}
}
})
].join('\n'));
const metrics = await getSpeedMetrics(transcriptPath);
expect(metrics).toEqual({
totalDurationMs: 0,
inputTokens: 7,
outputTokens: 9,
totalTokens: 16,
requestCount: 1
});
});
it('returns empty speed metrics when transcript is missing', async () => {
const metrics = await getSpeedMetrics('/tmp/ccstatusline-jsonl-speed-missing.jsonl');
expect(metrics).toEqual({
totalDurationMs: 0,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
requestCount: 0
});
});
});
+264
View File
@@ -0,0 +1,264 @@
import {
describe,
expect,
it
} from 'vitest';
function createMockTimestamps(timestamps: string[]): string {
return timestamps.map(ts => JSON.stringify({
timestamp: ts,
message: {
usage: {
input_tokens: 100,
output_tokens: 50
}
}
})).join('\n');
}
function floorToHour(timestamp: Date): Date {
const floored = new Date(timestamp);
floored.setUTCMinutes(0, 0, 0);
return floored;
}
function getAllTimestampsFromContent(content: string): Date[] {
const timestamps: Date[] = [];
const lines = content.trim().split('\n').filter(line => line.length > 0);
for (const line of lines) {
try {
const json = JSON.parse(line) as {
timestamp?: string;
isSidechain?: boolean;
message?: { usage?: { input_tokens?: number; output_tokens?: number } };
};
const usage = json.message?.usage;
if (!usage)
continue;
const hasInputTokens = typeof usage.input_tokens === 'number';
const hasOutputTokens = typeof usage.output_tokens === 'number';
if (!hasInputTokens || !hasOutputTokens)
continue;
if (json.isSidechain === true)
continue;
const timestamp = json.timestamp;
if (typeof timestamp !== 'string')
continue;
const date = new Date(timestamp);
if (!Number.isNaN(date.getTime()))
timestamps.push(date);
} catch {
continue;
}
}
return timestamps;
}
function findBlockStartTime(
content: string,
currentTime: Date,
sessionDurationHours = 5
): Date | null {
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
const now = currentTime;
const timestamps = getAllTimestampsFromContent(content);
if (timestamps.length === 0)
return null;
timestamps.sort((a, b) => b.getTime() - a.getTime());
const mostRecentTimestamp = timestamps[0];
if (!mostRecentTimestamp)
return null;
const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime();
if (timeSinceLastActivity > sessionDurationMs) {
return null;
}
let continuousWorkStart = mostRecentTimestamp;
for (let i = 1; i < timestamps.length; i++) {
const currentTimestamp = timestamps[i];
const previousTimestamp = timestamps[i - 1];
if (!currentTimestamp || !previousTimestamp)
continue;
const gap = previousTimestamp.getTime() - currentTimestamp.getTime();
if (gap >= sessionDurationMs) {
break;
}
continuousWorkStart = currentTimestamp;
}
const blocks: { start: Date; end: Date }[] = [];
const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime());
let currentBlockStart: Date | null = null;
let currentBlockEnd: Date | null = null;
for (const timestamp of sortedTimestamps) {
if (timestamp.getTime() < continuousWorkStart.getTime())
continue;
if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) {
currentBlockStart = floorToHour(timestamp);
currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs);
blocks.push({ start: currentBlockStart, end: currentBlockEnd });
}
}
for (const block of blocks) {
if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) {
const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime()
&& t.getTime() <= block.end.getTime()
);
if (hasActivity) {
return block.start;
}
}
}
return null;
}
describe('Block Detection Algorithm', () => {
describe('Real scenario bug fix', () => {
it('should correctly handle morning and evening blocks with gap', () => {
const content = createMockTimestamps([
'2025-09-23T09:42:18.000Z',
'2025-09-23T12:52:31.000Z',
'2025-09-23T15:44:16.000Z',
'2025-09-23T16:57:24.000Z'
]);
const currentTime = new Date('2025-09-23T18:10:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).not.toBeNull();
expect(result?.toISOString()).toBe('2025-09-23T15:00:00.000Z');
});
});
describe('Multiple messages in single block', () => {
it('should create single block for messages within 5 hours', () => {
const content = createMockTimestamps([
'2025-09-23T08:15:00.000Z',
'2025-09-23T08:45:00.000Z',
'2025-09-23T09:30:00.000Z',
'2025-09-23T10:00:00.000Z'
]);
const currentTime = new Date('2025-09-23T11:30:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).not.toBeNull();
expect(result?.toISOString()).toBe('2025-09-23T08:00:00.000Z');
});
});
describe('Multiple blocks with gaps', () => {
it('should correctly identify current block in multi-block scenario', () => {
const content = createMockTimestamps([
'2025-09-22T22:13:00.000Z',
'2025-09-23T03:56:00.000Z',
'2025-09-23T04:01:00.000Z',
'2025-09-23T12:33:00.000Z',
'2025-09-23T18:01:00.000Z'
]);
const currentTime = new Date('2025-09-23T20:43:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).not.toBeNull();
expect(result?.toISOString()).toBe('2025-09-23T18:00:00.000Z');
});
});
describe('Edge cases', () => {
it('should return null when current time is in gap between blocks', () => {
const content = createMockTimestamps([
'2025-09-23T08:00:00.000Z',
'2025-09-23T10:00:00.000Z'
]);
const currentTime = new Date('2025-09-23T14:00:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).toBeNull();
});
it('should return null when no messages within 5 hours', () => {
const content = createMockTimestamps([
'2025-09-23T08:00:00.000Z'
]);
const currentTime = new Date('2025-09-23T14:00:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).toBeNull();
});
it('should handle block boundary correctly', () => {
const content = createMockTimestamps([
'2025-09-23T10:30:00.000Z'
]);
const currentTime = new Date('2025-09-23T15:00:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).not.toBeNull();
expect(result?.toISOString()).toBe('2025-09-23T10:00:00.000Z');
});
it('should detect 5+ hour gap as boundary', () => {
const content = createMockTimestamps([
'2025-09-23T08:00:00.000Z',
'2025-09-23T13:01:00.000Z'
]);
const currentTime = new Date('2025-09-23T15:00:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).not.toBeNull();
expect(result?.toISOString()).toBe('2025-09-23T13:00:00.000Z');
});
it('should handle messages at exact hour boundaries', () => {
const content = createMockTimestamps([
'2025-09-23T10:00:00.000Z',
'2025-09-23T12:00:00.000Z'
]);
const currentTime = new Date('2025-09-23T13:30:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).not.toBeNull();
expect(result?.toISOString()).toBe('2025-09-23T10:00:00.000Z');
});
});
describe('Invalid inputs', () => {
it('should return null for empty content', () => {
const result = findBlockStartTime('', new Date());
expect(result).toBeNull();
});
it('should return null for invalid JSON', () => {
const result = findBlockStartTime('not json', new Date());
expect(result).toBeNull();
});
});
});
+87
View File
@@ -0,0 +1,87 @@
import {
describe,
expect,
it
} from 'vitest';
import {
detectVersion,
migrateConfig,
needsMigration
} from '../migrations';
describe('migrations', () => {
it('detects version for unknown data and versioned objects', () => {
expect(detectVersion(null)).toBe(1);
expect(detectVersion('invalid')).toBe(1);
expect(detectVersion({})).toBe(1);
expect(detectVersion({ version: 2 })).toBe(2);
});
it('reports whether migration is needed', () => {
expect(needsMigration({ version: 2 }, 3)).toBe(true);
expect(needsMigration({ version: 3 }, 3)).toBe(false);
expect(needsMigration({}, 3)).toBe(true);
});
it('returns original value for non-record migration input', () => {
expect(migrateConfig('invalid', 3)).toBe('invalid');
expect(migrateConfig(123, 3)).toBe(123);
});
it('migrates v1 to v2 by copying known fields and assigning ids', () => {
const migrated = migrateConfig({
lines: [[
{ type: 'model', color: 'cyan' },
{ type: 'separator' },
{ type: 'git-branch' }
]],
flexMode: 'full',
compactThreshold: 70,
colorLevel: 3,
defaultSeparator: '|',
defaultPadding: ' ',
inheritSeparatorColors: true,
overrideBackgroundColor: 'black',
overrideForegroundColor: 'white',
globalBold: true,
unknownField: 'ignored'
}, 2) as Record<string, unknown>;
expect(migrated.version).toBe(2);
expect(migrated.flexMode).toBe('full');
expect(migrated.compactThreshold).toBe(70);
expect(migrated.colorLevel).toBe(3);
expect(migrated.defaultSeparator).toBe('|');
expect(migrated.defaultPadding).toBe(' ');
expect(migrated.inheritSeparatorColors).toBe(true);
expect(migrated.overrideBackgroundColor).toBe('black');
expect(migrated.overrideForegroundColor).toBe('white');
expect(migrated.globalBold).toBe(true);
expect(migrated.unknownField).toBeUndefined();
const lines = migrated.lines as Record<string, unknown>[][];
const firstLine = lines[0];
expect(Array.isArray(firstLine)).toBe(true);
expect(firstLine?.map(item => item.type)).toEqual(['model', 'git-branch']);
expect(typeof firstLine?.[0]?.id).toBe('string');
expect(typeof firstLine?.[1]?.id).toBe('string');
const updateMessage = migrated.updatemessage as { message?: string; remaining?: number };
expect(updateMessage.message).toContain('v2.0.0');
expect(updateMessage.remaining).toBe(12);
});
it('applies sequential migrations to reach target version', () => {
const migrated = migrateConfig({
lines: [[
{ type: 'model' }
]]
}, 3) as Record<string, unknown>;
expect(migrated.version).toBe(3);
const updateMessage = migrated.updatemessage as { message?: string; remaining?: number };
expect(updateMessage.message).toContain('v2.0.2');
expect(updateMessage.remaining).toBe(12);
});
});
+151
View File
@@ -0,0 +1,151 @@
import {
describe,
expect,
it
} from 'vitest';
import {
getContextConfig,
getModelContextIdentifier
} from '../model-context';
describe('getContextConfig', () => {
describe('Status JSON context window size override', () => {
it('should use context_window_size as max tokens when provided', () => {
const config = getContextConfig('claude-3-5-sonnet-20241022', 1000000);
expect(config.maxTokens).toBe(1000000);
expect(config.usableTokens).toBe(800000);
});
it('should prioritize context_window_size over [1m] model suffix', () => {
const config = getContextConfig('claude-sonnet-4-5-20250929[1m]', 200000);
expect(config.maxTokens).toBe(200000);
expect(config.usableTokens).toBe(160000);
});
});
describe('Models with [1m] suffix', () => {
it('should return 1M context window for claude-sonnet-4-5 with [1m] suffix', () => {
const config = getContextConfig('claude-sonnet-4-5-20250929[1m]');
expect(config.maxTokens).toBe(1000000);
expect(config.usableTokens).toBe(800000);
});
it('should return 1M context window for claude-opus-4-6 with [1m] suffix', () => {
const config = getContextConfig('claude-opus-4-6[1m]');
expect(config.maxTokens).toBe(1000000);
expect(config.usableTokens).toBe(800000);
});
it('should return 1M context window for AWS Bedrock format with [1m] suffix', () => {
const config = getContextConfig(
'us.anthropic.claude-sonnet-4-5-20250929-v1:0[1m]'
);
expect(config.maxTokens).toBe(1000000);
expect(config.usableTokens).toBe(800000);
});
it('should return 1M context window with uppercase [1M] suffix', () => {
const config = getContextConfig('claude-sonnet-4-5-20250929[1M]');
expect(config.maxTokens).toBe(1000000);
expect(config.usableTokens).toBe(800000);
});
it('should return 1M context window for model IDs with 1M context label', () => {
const config = getContextConfig('Opus 4.6 (1M context)');
expect(config.maxTokens).toBe(1000000);
expect(config.usableTokens).toBe(800000);
});
it('should return 1M context window for model IDs with 1M token context label', () => {
const config = getContextConfig('Claude Opus 4.6 - 1M token context');
expect(config.maxTokens).toBe(1000000);
expect(config.usableTokens).toBe(800000);
});
it('should return 1M context window for model IDs with 1M in parentheses', () => {
const config = getContextConfig('Opus 4.6 (1M)');
expect(config.maxTokens).toBe(1000000);
expect(config.usableTokens).toBe(800000);
});
it('should return 1M context window for model IDs with 1M in square brackets', () => {
const config = getContextConfig('Opus 4.5 [1M]');
expect(config.maxTokens).toBe(1000000);
expect(config.usableTokens).toBe(800000);
});
});
describe('Models without [1m] suffix', () => {
it('should return 200k context window for claude-sonnet-4-5 without [1m] suffix', () => {
const config = getContextConfig('claude-sonnet-4-5-20250929');
expect(config.maxTokens).toBe(200000);
expect(config.usableTokens).toBe(160000);
});
it('should return 200k context window for AWS Bedrock format without [1m] suffix', () => {
const config = getContextConfig(
'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
);
expect(config.maxTokens).toBe(200000);
expect(config.usableTokens).toBe(160000);
});
});
describe('Older/default models', () => {
it('should return 200k context window for older Sonnet 3.5 model', () => {
const config = getContextConfig('claude-3-5-sonnet-20241022');
expect(config.maxTokens).toBe(200000);
expect(config.usableTokens).toBe(160000);
});
it('should return 200k context window when model ID is undefined', () => {
const config = getContextConfig(undefined);
expect(config.maxTokens).toBe(200000);
expect(config.usableTokens).toBe(160000);
});
it('should return 200k context window for unknown model ID', () => {
const config = getContextConfig('claude-unknown-model');
expect(config.maxTokens).toBe(200000);
expect(config.usableTokens).toBe(160000);
});
});
});
describe('getModelContextIdentifier', () => {
it('returns string model identifier unchanged', () => {
expect(getModelContextIdentifier('claude-sonnet-4-5-20250929[1m]')).toBe('claude-sonnet-4-5-20250929[1m]');
});
it('prefers both id and display name when available', () => {
expect(getModelContextIdentifier({
id: 'claude-opus-4-6',
display_name: 'Opus 4.6 (1M context)'
})).toBe('claude-opus-4-6 Opus 4.6 (1M context)');
});
it('returns display name when id is missing', () => {
expect(getModelContextIdentifier({ display_name: 'Opus 4.6 (1M context)' })).toBe('Opus 4.6 (1M context)');
});
it('returns undefined when no model value exists', () => {
expect(getModelContextIdentifier(undefined)).toBeUndefined();
expect(getModelContextIdentifier({})).toBeUndefined();
});
});
+144
View File
@@ -0,0 +1,144 @@
import { spawnSync } from 'child_process';
import * as os from 'os';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { openExternalUrl } from '../open-url';
vi.mock('child_process', () => ({ spawnSync: vi.fn() }));
const mockSpawnSync = spawnSync as unknown as {
mock: { calls: unknown[][] };
mockReturnValue: (value: unknown) => void;
mockReturnValueOnce: (value: unknown) => void;
};
describe('openExternalUrl', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
it('uses open on macOS', () => {
vi.spyOn(os, 'platform').mockReturnValue('darwin');
mockSpawnSync.mockReturnValue({ status: 0 });
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
expect(result).toEqual({ success: true });
expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('open');
expect(mockSpawnSync.mock.calls[0]?.[1]).toEqual(['https://github.com/sirmalloc/ccstatusline']);
});
it('uses cmd start on Windows', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
mockSpawnSync.mockReturnValue({ status: 0 });
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
expect(result).toEqual({ success: true });
expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('cmd');
expect(mockSpawnSync.mock.calls[0]?.[1]).toEqual(['/c', 'start', '', 'https://github.com/sirmalloc/ccstatusline']);
});
it('uses xdg-open on Linux when available', () => {
vi.spyOn(os, 'platform').mockReturnValue('linux');
mockSpawnSync.mockReturnValue({ status: 0 });
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
expect(result).toEqual({ success: true });
expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('xdg-open');
expect(mockSpawnSync.mock.calls[0]?.[1]).toEqual(['https://github.com/sirmalloc/ccstatusline']);
});
it('falls back to gio open when xdg-open fails', () => {
vi.spyOn(os, 'platform').mockReturnValue('linux');
mockSpawnSync.mockReturnValueOnce({ status: 1 });
mockSpawnSync.mockReturnValueOnce({ status: 0 });
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
expect(result).toEqual({ success: true });
expect(mockSpawnSync.mock.calls.length).toBe(2);
expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('xdg-open');
expect(mockSpawnSync.mock.calls[1]?.[0]).toBe('gio');
expect(mockSpawnSync.mock.calls[1]?.[1]).toEqual(['open', 'https://github.com/sirmalloc/ccstatusline']);
});
it('returns failure when Linux openers fail', () => {
vi.spyOn(os, 'platform').mockReturnValue('linux');
mockSpawnSync.mockReturnValueOnce({ status: 1 });
mockSpawnSync.mockReturnValueOnce({ status: 2 });
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
expect(result.success).toBe(false);
expect(result.error).toContain('xdg-open failed');
expect(result.error).toContain('gio open failed');
});
it('rejects non-http URL protocols', () => {
const result = openExternalUrl('file:///tmp/ccstatusline');
expect(result).toEqual({
success: false,
error: 'Only http(s) URLs are supported'
});
expect(mockSpawnSync.mock.calls.length).toBe(0);
});
it('rejects malformed URLs', () => {
const result = openExternalUrl('not-a-valid-url');
expect(result).toEqual({
success: false,
error: 'Invalid URL'
});
expect(mockSpawnSync.mock.calls.length).toBe(0);
});
it('returns command spawn error details', () => {
vi.spyOn(os, 'platform').mockReturnValue('darwin');
mockSpawnSync.mockReturnValue({ error: new Error('spawn failed') });
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
expect(result).toEqual({
success: false,
error: 'spawn failed'
});
});
it('preserves status-based error formatting when signal is present', () => {
vi.spyOn(os, 'platform').mockReturnValue('darwin');
mockSpawnSync.mockReturnValue({
status: null,
signal: 'SIGTERM'
});
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
expect(result).toEqual({
success: false,
error: 'Command exited with status null'
});
});
it('returns unsupported platform error', () => {
vi.spyOn(os, 'platform').mockReturnValue('freebsd');
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
expect(result).toEqual({
success: false,
error: 'Unsupported platform: freebsd'
});
expect(mockSpawnSync.mock.calls.length).toBe(0);
});
});
@@ -0,0 +1,73 @@
import {
describe,
expect,
it
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import { buildEnabledPowerlineSettings } from '../powerline-settings';
describe('powerline settings helpers', () => {
it('enables powerline with default theme and default padding', () => {
const settings = {
...DEFAULT_SETTINGS,
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: false,
theme: undefined
}
};
const updated = buildEnabledPowerlineSettings(settings, false);
expect(updated.powerline.enabled).toBe(true);
expect(updated.powerline.theme).toBe('nord-aurora');
expect(updated.defaultPadding).toBe(' ');
});
it('preserves non-custom theme when enabling powerline', () => {
const settings = {
...DEFAULT_SETTINGS,
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: false,
theme: 'catppuccin'
}
};
const updated = buildEnabledPowerlineSettings(settings, false);
expect(updated.powerline.theme).toBe('catppuccin');
});
it('removes manual separators when requested', () => {
const line: WidgetItem[] = [
{ id: '1', type: 'model' },
{ id: '2', type: 'separator' },
{ id: '3', type: 'context-length' },
{ id: '4', type: 'flex-separator' }
];
const settings = {
...DEFAULT_SETTINGS,
lines: [line]
};
const updated = buildEnabledPowerlineSettings(settings, true);
expect(updated.lines[0]?.map(item => item.type)).toEqual(['model', 'context-length']);
});
it('keeps manual separators when removal is not requested', () => {
const line: WidgetItem[] = [
{ id: '1', type: 'model' },
{ id: '2', type: 'separator' },
{ id: '3', type: 'context-length' }
];
const settings = {
...DEFAULT_SETTINGS,
lines: [line]
};
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);
});
});
+230
View File
@@ -0,0 +1,230 @@
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 {
getVisibleText,
getVisibleWidth,
truncateStyledText
} from '../ansi';
import {
calculateMaxWidthsFromPreRendered,
preRenderAllWidgets,
renderStatusLine
} from '../renderer';
const OSC8_OPEN = '\x1b]8;;https://example.com/docs\x1b\\';
const OSC8_CLOSE = '\x1b]8;;\x1b\\';
const OSC8_OPEN_WITH_PARAMS = '\x1b]8;id=abc;https://example.com/docs\x1b\\';
const OSC8_CLOSE_WITH_PARAMS = '\x1b]8;id=abc;\x1b\\';
const BEL_OSC8_OPEN = '\x1b]8;;https://example.com/docs\x07';
const BEL_OSC8_CLOSE = '\x1b]8;;\x07';
function createSettings(overrides: Partial<Settings> = {}): Settings {
return {
...DEFAULT_SETTINGS,
flexMode: 'full',
...overrides,
powerline: {
...DEFAULT_SETTINGS.powerline,
...(overrides.powerline ?? {})
}
};
}
function renderLine(
widgets: WidgetItem[],
options: { settings?: Partial<Settings>; terminalWidth?: number } = {}
): string {
const settings = createSettings(options.settings);
const context: RenderContext = {
isPreview: false,
terminalWidth: options.terminalWidth
};
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
const preRenderedWidgets = preRenderedLines[0] ?? [];
return renderStatusLine(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
}
describe('renderer ANSI/OSC handling', () => {
it('treats OSC 8 wrappers as non-visible text for width calculations', () => {
const text = `A ${OSC8_OPEN}click${OSC8_CLOSE} B`;
expect(getVisibleText(text)).toBe('A click B');
expect(getVisibleWidth(text)).toBe(getVisibleWidth('A click B'));
});
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 });
expect(truncated.endsWith('...')).toBe(true);
expect(truncated).toContain(OSC8_CLOSE);
expect(getVisibleWidth(truncated)).toBeLessThanOrEqual(10);
});
it('keeps OSC 8 links well-formed in normal renderer truncation', () => {
const widgets: WidgetItem[] = [
{
id: 'w1',
type: 'custom-text',
customText: `${OSC8_OPEN}very-long-link-text${OSC8_CLOSE}`
}
];
const line = renderLine(widgets, { terminalWidth: 12 });
expect(line.endsWith('...')).toBe(true);
expect(line).toContain(OSC8_CLOSE);
expect(getVisibleWidth(line)).toBeLessThanOrEqual(12);
});
it('keeps OSC 8 links well-formed in powerline truncation', () => {
const widgets: WidgetItem[] = [
{
id: 'w1',
type: 'custom-text',
customText: `${OSC8_OPEN}very-long-link-text${OSC8_CLOSE}`,
color: 'white',
backgroundColor: 'bgBlue'
},
{
id: 'w2',
type: 'custom-text',
customText: 'tail',
color: 'white',
backgroundColor: 'bgGreen'
}
];
const line = renderLine(widgets, {
terminalWidth: 16,
settings: {
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: true,
separators: ['\uE0B0'],
separatorInvertBackground: [false]
}
}
});
expect(line).toContain(OSC8_CLOSE);
expect(getVisibleWidth(line)).toBeLessThanOrEqual(16);
});
it('truncates custom-command preserveColors output without breaking OSC 8', () => {
const widget: WidgetItem = {
id: 'cmd1',
type: 'custom-command',
preserveColors: true,
maxWidth: 6
};
const settings = createSettings();
const context: RenderContext = {
isPreview: false,
terminalWidth: 200
};
const content = `${OSC8_OPEN}abcdefghij${OSC8_CLOSE}`;
const preRenderedWidgets = [{
content,
plainLength: getVisibleWidth(content),
widget
}];
const line = renderStatusLine([widget], settings, context, preRenderedWidgets, []);
expect(line).toContain(OSC8_CLOSE);
expect(getVisibleWidth(line)).toBe(6);
});
it('uses visible width for flex separator alignment when OSC 8 text is present', () => {
const widgets: WidgetItem[] = [
{
id: 'left',
type: 'custom-text',
customText: `${OSC8_OPEN}A${OSC8_CLOSE}`
},
{
id: 'flex',
type: 'flex-separator'
},
{
id: 'right',
type: 'custom-text',
customText: 'B'
}
];
const line = renderLine(widgets, { terminalWidth: 26 });
const visible = getVisibleText(line);
expect(visible.startsWith('A')).toBe(true);
expect(visible.endsWith('B')).toBe(true);
expect(visible.includes('...')).toBe(false);
expect(getVisibleWidth(line)).toBe(20);
});
it('handles BEL-terminated OSC 8 sequences during truncation', () => {
const line = `${BEL_OSC8_OPEN}bel-link-text${BEL_OSC8_CLOSE}`;
const truncated = truncateStyledText(line, 8, { ellipsis: true });
expect(truncated.endsWith('...')).toBe(true);
expect(truncated).toContain(BEL_OSC8_CLOSE);
expect(getVisibleWidth(truncated)).toBeLessThanOrEqual(8);
});
it('handles OSC 8 sequences with params during truncation', () => {
const line = `${OSC8_OPEN_WITH_PARAMS}param-link-text${OSC8_CLOSE_WITH_PARAMS}`;
const truncated = truncateStyledText(line, 8, { ellipsis: true });
expect(truncated.endsWith('...')).toBe(true);
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');
});
});
@@ -0,0 +1,113 @@
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 { getVisibleWidth } from '../ansi';
import {
calculateMaxWidthsFromPreRendered,
preRenderAllWidgets,
renderStatusLine
} from '../renderer';
function createSettings(overrides: Partial<Settings> = {}): Settings {
return {
...DEFAULT_SETTINGS,
...overrides,
powerline: {
...DEFAULT_SETTINGS.powerline,
...(overrides.powerline ?? {})
}
};
}
function renderLine(
widgets: WidgetItem[],
settingsOverrides: Partial<Settings>,
contextOverrides: Partial<RenderContext> = {}
): string {
const settings = createSettings(settingsOverrides);
const context: RenderContext = {
isPreview: false,
terminalWidth: 50,
...contextOverrides
};
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
const preRenderedWidgets = preRenderedLines[0] ?? [];
return renderStatusLine(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
}
describe('renderer flex width behavior', () => {
const longTextWidget: WidgetItem = {
id: 'text',
type: 'custom-text',
customText: 'abcdefghijklmnopqrstuvwxyz1234567890'
};
it('uses full-minus-40 width in normal mode', () => {
const line = renderLine([longTextWidget], { flexMode: 'full-minus-40' });
expect(getVisibleWidth(line)).toBe(10);
expect(line.endsWith('...')).toBe(true);
});
it('uses full width in full-until-compact when under threshold', () => {
const line = renderLine([longTextWidget], {
flexMode: 'full-until-compact',
compactThreshold: 60
}, { data: { context_window: { used_percentage: 20 } } });
expect(getVisibleWidth(line)).toBe(longTextWidget.customText?.length);
expect(line.endsWith('...')).toBe(false);
});
it('uses compact width in full-until-compact when above threshold', () => {
const line = renderLine([longTextWidget], {
flexMode: 'full-until-compact',
compactThreshold: 60
}, { data: { context_window: { used_percentage: 80 } } });
expect(getVisibleWidth(line)).toBe(10);
expect(line.endsWith('...')).toBe(true);
});
it('always uses full preview width in full-until-compact preview mode', () => {
const line = renderLine([longTextWidget], {
flexMode: 'full-until-compact',
compactThreshold: 60
}, {
isPreview: true,
data: { context_window: { used_percentage: 99 } }
});
expect(getVisibleWidth(line)).toBe(longTextWidget.customText?.length);
expect(line.endsWith('...')).toBe(false);
});
it('applies the same width behavior in powerline mode', () => {
const line = renderLine([{
...longTextWidget,
backgroundColor: 'bgBlue',
color: 'white'
}], {
flexMode: 'full-minus-40',
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: true
}
});
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,68 @@
import {
describe,
expect,
it
} from 'vitest';
import type { WidgetItem } from '../../types/Widget';
import {
advanceGlobalSeparatorIndex,
countSeparatorSlots
} from '../separator-index';
describe('separator index utils', () => {
it('returns zero for empty and single-item lines', () => {
expect(countSeparatorSlots([])).toBe(0);
const single: WidgetItem[] = [{ id: '1', type: 'model' }];
expect(countSeparatorSlots(single)).toBe(0);
});
it('counts one separator slot between two non-merged items', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'model' },
{ id: '2', type: 'context-length' }
];
expect(countSeparatorSlots(widgets)).toBe(1);
});
it('does not count separator slots for merged items', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'model', merge: true },
{ id: '2', type: 'context-length' },
{ id: '3', type: 'version' }
];
expect(countSeparatorSlots(widgets)).toBe(1);
});
it('treats no-padding merge the same as merged', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'model', merge: 'no-padding' },
{ id: '2', type: 'context-length' },
{ id: '3', type: 'version' }
];
expect(countSeparatorSlots(widgets)).toBe(1);
});
it('advances a running global separator index', () => {
const firstLine: WidgetItem[] = [
{ id: '1', type: 'model' },
{ id: '2', type: 'context-length' },
{ id: '3', type: 'version' }
];
const secondLine: WidgetItem[] = [
{ id: '4', type: 'git-branch', merge: true },
{ id: '5', type: 'git-changes' },
{ id: '6', type: 'session-cost' }
];
const afterFirst = advanceGlobalSeparatorIndex(0, firstLine);
const afterSecond = advanceGlobalSeparatorIndex(afterFirst, secondLine);
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'
});
});
});
+74
View File
@@ -0,0 +1,74 @@
import {
describe,
expect,
it
} from 'vitest';
import type { SpeedMetrics } from '../../types/SpeedMetrics';
import {
calculateInputSpeed,
calculateOutputSpeed,
calculateTotalSpeed,
formatSpeed
} from '../speed-metrics';
function createMetrics(overrides: Partial<SpeedMetrics> = {}): SpeedMetrics {
return {
totalDurationMs: 10000,
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
requestCount: 5,
...overrides
};
}
describe('speed metrics calculations', () => {
it('calculateOutputSpeed returns null when duration is zero', () => {
const result = calculateOutputSpeed(createMetrics({ totalDurationMs: 0 }));
expect(result).toBeNull();
});
it('calculateOutputSpeed computes output tokens per second', () => {
const result = calculateOutputSpeed(createMetrics({ outputTokens: 750, totalDurationMs: 15000 }));
expect(result).toBe(50);
});
it('calculateInputSpeed returns null when duration is zero', () => {
const result = calculateInputSpeed(createMetrics({ totalDurationMs: 0 }));
expect(result).toBeNull();
});
it('calculateInputSpeed computes input tokens per second', () => {
const result = calculateInputSpeed(createMetrics({ inputTokens: 1200, totalDurationMs: 6000 }));
expect(result).toBe(200);
});
it('calculateTotalSpeed returns null when duration is zero', () => {
const result = calculateTotalSpeed(createMetrics({ totalDurationMs: 0 }));
expect(result).toBeNull();
});
it('calculateTotalSpeed computes total tokens per second from totalTokens', () => {
const result = calculateTotalSpeed(createMetrics({ totalTokens: 3000, totalDurationMs: 12000 }));
expect(result).toBe(250);
});
});
describe('formatSpeed', () => {
it('formats null as an em dash placeholder', () => {
expect(formatSpeed(null)).toBe('\u2014');
});
it('formats sub-1000 speeds with one decimal place', () => {
expect(formatSpeed(42.54)).toBe('42.5 t/s');
});
it('formats exact threshold values in k notation', () => {
expect(formatSpeed(1000)).toBe('1.0k t/s');
});
it('formats high speeds in k notation with one decimal place', () => {
expect(formatSpeed(1250)).toBe('1.3k t/s');
});
});
+55
View File
@@ -0,0 +1,55 @@
import {
describe,
expect,
it
} from 'vitest';
import type { WidgetItem } from '../../types/Widget';
import {
DEFAULT_SPEED_WINDOW_SECONDS,
clampSpeedWindowSeconds,
getWidgetSpeedWindowSeconds,
isWidgetSpeedWindowEnabled,
withWidgetSpeedWindowSeconds
} from '../speed-window';
function createWidget(metadata?: Record<string, string>): WidgetItem {
return {
id: 'speed-widget',
type: 'total-speed',
metadata
};
}
describe('speed-window helpers', () => {
it('clamps values to the supported range', () => {
expect(clampSpeedWindowSeconds(-1)).toBe(0);
expect(clampSpeedWindowSeconds(0)).toBe(0);
expect(clampSpeedWindowSeconds(90)).toBe(90);
expect(clampSpeedWindowSeconds(300)).toBe(120);
});
it('returns default window seconds when metadata is missing or invalid', () => {
expect(getWidgetSpeedWindowSeconds(createWidget())).toBe(DEFAULT_SPEED_WINDOW_SECONDS);
expect(getWidgetSpeedWindowSeconds(createWidget({ windowSeconds: 'abc' }))).toBe(DEFAULT_SPEED_WINDOW_SECONDS);
});
it('parses and clamps widget metadata window seconds', () => {
expect(getWidgetSpeedWindowSeconds(createWidget({ windowSeconds: '45' }))).toBe(45);
expect(getWidgetSpeedWindowSeconds(createWidget({ windowSeconds: '999' }))).toBe(120);
});
it('stores clamped window seconds in metadata while preserving existing keys', () => {
const updated = withWidgetSpeedWindowSeconds(createWidget({ keep: 'true' }), -3);
expect(updated.metadata).toEqual({
keep: 'true',
windowSeconds: '0'
});
});
it('treats zero as disabled and positive values as enabled', () => {
expect(isWidgetSpeedWindowEnabled(createWidget())).toBe(false);
expect(isWidgetSpeedWindowEnabled(createWidget({ windowSeconds: '0' }))).toBe(false);
expect(isWidgetSpeedWindowEnabled(createWidget({ windowSeconds: '30' }))).toBe(true);
});
});
+166
View File
@@ -0,0 +1,166 @@
import { execSync } from 'child_process';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import {
canDetectTerminalWidth,
getTerminalWidth
} from '../terminal';
vi.mock('child_process', () => ({ execSync: 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;
};
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
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.map(([command]) => command)).toEqual([
`ps -o ppid= -p ${process.pid}`,
'ps -o tty= -p 1234',
`stty size < /dev/ttys001 | awk '{print $2}'`
]);
});
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 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 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);
});
it('returns false for availability when all probes fail', () => {
mockExecSync.mockImplementationOnce(() => { throw new Error('tty unavailable'); });
mockExecSync.mockImplementationOnce(() => { throw new Error('tput unavailable'); });
expect(canDetectTerminalWidth()).toBe(false);
});
it('disables width detection on Windows', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
expect(getTerminalWidth()).toBeNull();
expect(canDetectTerminalWidth()).toBe(false);
expect(mockExecSync.mock.calls.length).toBe(0);
});
});
+598
View File
@@ -0,0 +1,598 @@
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { fileURLToPath } from 'url';
import {
describe,
expect,
it
} from 'vitest';
interface UsageProbeResult {
first: Record<string, unknown>;
second: Record<string, unknown>;
lockExists: boolean;
cacheExists: boolean;
requestCount: number;
proxyAgentConfigured: boolean;
requestHost: string | null;
lockContents: string | null;
}
interface TokenHome {
bin: string;
claudeConfig: string;
home: string;
}
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;
}
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));
const probeScript = `
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { createRequire } from 'module';
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 = {
headers: responseHeaders,
statusCode,
setEncoding() {},
on(event, handler) {
const existing = responseHandlers.get(event) || [];
existing.push(handler);
responseHandlers.set(event, existing);
return response;
}
};
const request = {
on(event, handler) {
const existing = requestHandlers.get(event) || [];
existing.push(handler);
requestHandlers.set(event, existing);
return request;
},
destroy() {},
end() {
if (mode === 'error') {
const handlers = requestHandlers.get('error') || [];
for (const handler of handlers) {
handler(new Error('mock request failure'));
}
return;
}
if (mode === 'unexpected') {
const handlers = requestHandlers.get('error') || [];
for (const handler of handlers) {
handler(new Error('unexpected request'));
}
return;
}
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();
}
}
};
return request;
};
const { fetchUsageData } = await import(${JSON.stringify(usageModulePath)});
const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock');
const cacheFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.json');
const nowMs = Number(process.env.TEST_NOW_MS || Date.now());
Date.now = () => nowMs;
const first = await fetchUsageData();
const second = await fetchUsageData();
process.stdout.write(JSON.stringify({
first,
second,
lockExists: fs.existsSync(lockFile),
cacheExists: fs.existsSync(cacheFile),
requestCount,
proxyAgentConfigured,
requestHost,
lockContents: fs.existsSync(lockFile) ? fs.readFileSync(lockFile, 'utf8') : null
}));
`;
fs.writeFileSync(probeScriptPath, probeScript);
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 = execFileSync(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
});
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 apiErrorResult = harness.runProbe({
claudeConfigDir: apiErrorHome.claudeConfig,
home: apiErrorHome.home,
mode: 'error',
nowMs,
pathDir: apiErrorHome.bin
});
expect(apiErrorResult.first).toEqual({ error: 'api-error' });
expect(apiErrorResult.second).toEqual({ error: 'api-error' });
expect(apiErrorResult.cacheExists).toBe(false);
expect(apiErrorResult.requestCount).toBe(1);
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
});
expect(successResult.first).toEqual({
sessionUsage: 42,
sessionResetAt: '2030-01-01T00:00:00.000Z',
weeklyUsage: 17,
weeklyResetAt: '2030-01-07T00:00:00.000Z'
});
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 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
});
expect(cachedSuccessResult.first).toEqual(successResult.first);
expect(cachedSuccessResult.second).toEqual(successResult.first);
expect(cachedSuccessResult.cacheExists).toBe(true);
expect(cachedSuccessResult.requestCount).toBe(0);
} finally {
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();
}
});
});
+246
View File
@@ -0,0 +1,246 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import type { WidgetItem } from '../../types/Widget';
import * as usage from '../usage';
import {
extractUsageDataFromRateLimits,
hasUsageDependentWidgets,
prefetchUsageDataIfNeeded
} from '../usage-prefetch';
import type { UsageData } from '../usage-types';
function makeLines(...lineItems: WidgetItem[][]): WidgetItem[][] {
return lineItems;
}
describe('usage prefetch', () => {
let mockFetchUsageData: {
mock: { calls: unknown[][] };
mockResolvedValue: (value: UsageData) => void;
};
beforeEach(() => {
vi.restoreAllMocks();
mockFetchUsageData = vi.spyOn(usage, 'fetchUsageData');
});
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
{
expected: true,
lines: makeLines(
[{ id: '1', type: 'model' }],
[{ id: '2', type: 'block-timer' }]
),
name: 'detects when usage widgets are present'
},
{
expected: false,
lines: makeLines(
[{ id: '1', type: 'model' }],
[{ id: '2', type: 'git-branch' }]
),
name: 'does not detect usage requirement for non-usage widgets'
}
])('$name', ({ expected, lines }) => {
expect(hasUsageDependentWidgets(lines)).toBe(expected);
});
it('fetches usage data once when at least one usage widget exists', async () => {
mockFetchUsageData.mockResolvedValue({ sessionUsage: 12.3 });
const lines = makeLines(
[{ id: '1', type: 'model' }],
[{ id: '2', type: 'session-usage' }, { id: '3', type: 'weekly-usage' }]
);
const usageData = await prefetchUsageDataIfNeeded(lines);
expect(usageData).toEqual({ sessionUsage: 12.3 });
expect(mockFetchUsageData.mock.calls.length).toBe(1);
});
it('does not fetch usage data when no usage widgets exist', async () => {
const lines = makeLines(
[{ id: '1', type: 'model' }],
[{ id: '2', type: 'git-branch' }]
);
const usageData = await prefetchUsageDataIfNeeded(lines);
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,72 @@
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', () => ({ execFileSync: 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);
});
});
+330
View File
@@ -0,0 +1,330 @@
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { fileURLToPath } from 'url';
import {
describe,
expect,
it
} from 'vitest';
import { parseMacKeychainCredentialCandidates } from '../usage-fetch';
interface TokenHome {
bin: string;
claudeConfig: string;
home: string;
logFile: string;
}
interface TokenProbeOptions {
candidatePayloads?: Record<string, string>;
dump?: string;
dumpPaddingLines?: number;
exactPayload?: string;
platform: NodeJS.Platform;
tokenHome: TokenHome;
}
interface TokenProbeResult {
first: string | null;
second: string | null;
securityLog: string[];
}
function makeTokenPayload(token: string): string {
return JSON.stringify({ claudeAiOauth: { accessToken: token } });
}
function encodeAsciiAsHex(value: string): string {
return Buffer.from(value, 'utf8').toString('hex');
}
function makeKeychainBlock(service: string, modifiedAt?: { raw?: string; quoted?: string }): string {
const lines = [
'keychain: "/Users/example/Library/Keychains/login.keychain-db"',
'version: 512',
'class: "genp"',
'attributes:',
` "svce"<blob>="${service}"`
];
if (modifiedAt?.raw && modifiedAt.quoted) {
lines.push(` "mdat"<timedate>=0x${modifiedAt.raw} "${modifiedAt.quoted}"`);
} else if (modifiedAt?.raw) {
lines.push(` "mdat"<timedate>=0x${modifiedAt.raw}`);
} else if (modifiedAt?.quoted) {
lines.push(` "mdat"<timedate>="${modifiedAt.quoted}"`);
}
return lines.join('\n');
}
function createTokenHarness() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-token-test-'));
const probeScriptPath = path.join(tempRoot, 'probe-token.mjs');
const usageFetchModulePath = fileURLToPath(new URL('../usage-fetch.ts', import.meta.url));
const probeScript = `
import * as fs from 'fs';
if (process.env.TEST_PLATFORM) {
Object.defineProperty(process, 'platform', {
configurable: true,
value: process.env.TEST_PLATFORM
});
}
const { getUsageToken } = await import(${JSON.stringify(usageFetchModulePath)});
const first = getUsageToken();
const second = getUsageToken();
const logFile = process.env.TEST_SECURITY_LOG_FILE;
process.stdout.write(JSON.stringify({
first,
second,
securityLog: logFile && fs.existsSync(logFile)
? fs.readFileSync(logFile, 'utf8').split(/\\r?\\n/).filter(Boolean)
: []
}));
`;
const securityScript = `#!/usr/bin/env node
const fs = require('fs');
const args = process.argv.slice(2);
const logFile = process.env.TEST_SECURITY_LOG_FILE;
if (logFile) {
fs.appendFileSync(logFile, args.join(' ') + '\\n');
}
if (args[0] === 'dump-keychain') {
const paddingLines = Number.parseInt(process.env.TEST_SECURITY_DUMP_PADDING_LINES || '0', 10);
let remainingPaddingLines = paddingLines;
while (remainingPaddingLines > 0) {
const chunkSize = Math.min(remainingPaddingLines, 1024);
fs.writeSync(process.stdout.fd, 'ignored\\n'.repeat(chunkSize));
remainingPaddingLines -= chunkSize;
}
fs.writeSync(process.stdout.fd, process.env.TEST_SECURITY_DUMP || '');
process.exit(0);
}
if (args[0] !== 'find-generic-password') {
process.exit(44);
}
const serviceIndex = args.indexOf('-s');
const service = serviceIndex >= 0 ? args[serviceIndex + 1] : '';
const exactPayload = process.env.TEST_SECURITY_EXACT_PAYLOAD;
const candidatePayloads = JSON.parse(process.env.TEST_SECURITY_CANDIDATE_PAYLOADS_JSON || '{}');
if (service === 'Claude Code-credentials') {
if (exactPayload === undefined || exactPayload === '__MISSING__') {
process.exit(44);
}
fs.writeSync(process.stdout.fd, exactPayload);
process.exit(0);
}
if (Object.prototype.hasOwnProperty.call(candidatePayloads, service)) {
fs.writeSync(process.stdout.fd, candidatePayloads[service]);
process.exit(0);
}
process.exit(44);
`;
fs.writeFileSync(probeScriptPath, probeScript);
function createTokenHome(name: string, fileToken?: string): TokenHome {
const home = path.join(tempRoot, `home-${name}`);
const bin = path.join(tempRoot, `bin-${name}`);
const claudeConfig = path.join(tempRoot, `claude-${name}`);
const logFile = path.join(tempRoot, `security-${name}.log`);
const securityPath = path.join(bin, 'security');
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(bin, { recursive: true });
fs.mkdirSync(claudeConfig, { recursive: true });
fs.writeFileSync(securityPath, securityScript);
fs.chmodSync(securityPath, 0o755);
if (fileToken) {
fs.writeFileSync(
path.join(claudeConfig, '.credentials.json'),
makeTokenPayload(fileToken)
);
}
return {
bin,
claudeConfig,
home,
logFile
};
}
function runTokenProbe(options: TokenProbeOptions): TokenProbeResult {
const output = execFileSync(process.execPath, [probeScriptPath], {
encoding: 'utf8',
env: {
...process.env,
CLAUDE_CONFIG_DIR: options.tokenHome.claudeConfig,
HOME: options.tokenHome.home,
PATH: `${options.tokenHome.bin}${path.delimiter}${process.env.PATH ?? ''}`,
TEST_PLATFORM: options.platform,
TEST_SECURITY_CANDIDATE_PAYLOADS_JSON: JSON.stringify(options.candidatePayloads ?? {}),
TEST_SECURITY_DUMP: options.dump ?? '',
TEST_SECURITY_DUMP_PADDING_LINES: String(options.dumpPaddingLines ?? 0),
TEST_SECURITY_EXACT_PAYLOAD: options.exactPayload ?? '__MISSING__',
TEST_SECURITY_LOG_FILE: options.tokenHome.logFile
}
});
return JSON.parse(output) as TokenProbeResult;
}
function cleanup(): void {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
return {
cleanup,
createTokenHome,
runTokenProbe
};
}
describe('parseMacKeychainCredentialCandidates', () => {
it('returns hashed macOS credential candidates sorted newest-first and excludes the exact service', () => {
const dump = [
makeKeychainBlock('Claude Code-credentials', { quoted: '20240101010101Z' }),
makeKeychainBlock('Claude Code-credentials-old', { quoted: '20240201010101Z' }),
makeKeychainBlock('Claude Code-credentials-new', { quoted: '20240301010101Z' })
].join('\n');
expect(parseMacKeychainCredentialCandidates(dump)).toEqual([
'Claude Code-credentials-new',
'Claude Code-credentials-old'
]);
});
it('uses discovered order when modified times are unavailable and parses hex-only timestamps when present', () => {
const dump = [
makeKeychainBlock('Claude Code-credentials-first'),
makeKeychainBlock('Claude Code-credentials-second', { raw: encodeAsciiAsHex('20240401010101Z\0') }),
makeKeychainBlock('Claude Code-credentials-third')
].join('\n');
expect(parseMacKeychainCredentialCandidates(dump)).toEqual([
'Claude Code-credentials-second',
'Claude Code-credentials-first',
'Claude Code-credentials-third'
]);
});
});
describe('getUsageToken', () => {
it('prefers the exact macOS keychain service over hashed fallbacks and files', () => {
const harness = createTokenHarness();
try {
const tokenHome = harness.createTokenHome('exact', 'file-token');
const result = harness.runTokenProbe({
exactPayload: makeTokenPayload('exact-token'),
platform: 'darwin',
tokenHome
});
expect(result.first).toBe('exact-token');
expect(result.second).toBe('exact-token');
expect(result.securityLog).toEqual([
'find-generic-password -s Claude Code-credentials -w',
'find-generic-password -s Claude Code-credentials -w'
]);
} finally {
harness.cleanup();
}
});
it('tries the newest hashed macOS keychain candidate after an exact miss', () => {
const harness = createTokenHarness();
try {
const tokenHome = harness.createTokenHome('hashed');
const dump = [
makeKeychainBlock('Claude Code-credentials-old', { quoted: '20240201010101Z' }),
makeKeychainBlock('Claude Code-credentials-new', { quoted: '20240301010101Z' })
].join('\n');
const result = harness.runTokenProbe({
candidatePayloads: { 'Claude Code-credentials-new': makeTokenPayload('hashed-token') },
dump,
platform: 'darwin',
tokenHome
});
expect(result.first).toBe('hashed-token');
expect(result.second).toBe('hashed-token');
expect(result.securityLog).toEqual([
'find-generic-password -s Claude Code-credentials -w',
'dump-keychain',
'find-generic-password -s Claude Code-credentials-new -w',
'find-generic-password -s Claude Code-credentials -w',
'dump-keychain',
'find-generic-password -s Claude Code-credentials-new -w'
]);
} finally {
harness.cleanup();
}
});
it('falls back to ~/.claude/.credentials.json on macOS when keychain lookups miss or parse invalid data', () => {
const harness = createTokenHarness();
try {
const tokenHome = harness.createTokenHome('file-fallback', 'file-token');
const dump = makeKeychainBlock('Claude Code-credentials-hashed', { quoted: '20240301010101Z' });
const result = harness.runTokenProbe({
candidatePayloads: { 'Claude Code-credentials-hashed': 'not-json' },
dump,
platform: 'darwin',
tokenHome
});
expect(result.first).toBe('file-token');
expect(result.second).toBe('file-token');
expect(result.securityLog).toEqual([
'find-generic-password -s Claude Code-credentials -w',
'dump-keychain',
'find-generic-password -s Claude Code-credentials-hashed -w',
'find-generic-password -s Claude Code-credentials -w',
'dump-keychain',
'find-generic-password -s Claude Code-credentials-hashed -w'
]);
} finally {
harness.cleanup();
}
});
it('uses the credentials file on non-macOS', () => {
const harness = createTokenHarness();
try {
const tokenHome = harness.createTokenHome('linux', 'linux-file-token');
const result = harness.runTokenProbe({
platform: 'linux',
tokenHome
});
expect(result.first).toBe('linux-file-token');
expect(result.second).toBe('linux-file-token');
expect(result.securityLog).toEqual([]);
} finally {
harness.cleanup();
}
});
});
+13
View File
@@ -0,0 +1,13 @@
import {
describe,
expect,
it
} from 'vitest';
import { getUsageErrorMessage } from '../usage-windows';
describe('getUsageErrorMessage', () => {
it('returns the rate-limited label', () => {
expect(getUsageErrorMessage('rate-limited')).toBe('[Rate limited]');
});
});
+173
View File
@@ -0,0 +1,173 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import type { BlockMetrics } from '../../types';
import * as jsonl from '../jsonl';
import {
FIVE_HOUR_BLOCK_MS,
SEVEN_DAY_WINDOW_MS
} from '../usage-types';
import {
formatUsageDuration,
getUsageWindowFromResetAt,
getWeeklyUsageWindowFromResetAt,
resolveUsageWindowWithFallback,
resolveWeeklyUsageWindow
} from '../usage-windows';
describe('usage window helpers', () => {
let mockGetCachedBlockMetrics: {
mock: { calls: unknown[][] };
mockReturnValue: (value: BlockMetrics | null) => void;
};
beforeEach(() => {
vi.restoreAllMocks();
mockGetCachedBlockMetrics = vi.spyOn(jsonl, 'getCachedBlockMetrics');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('parses usage reset timestamp into elapsed and remaining metrics', () => {
const nowMs = Date.parse('2026-03-02T20:00:00.000Z');
const resetAt = '2026-03-02T22:00:00.000Z';
const window = getUsageWindowFromResetAt(resetAt, nowMs);
expect(window).not.toBeNull();
expect(window?.elapsedMs).toBe(3 * 60 * 60 * 1000);
expect(window?.remainingMs).toBe(2 * 60 * 60 * 1000);
expect(window?.elapsedPercent).toBeCloseTo(60, 5);
expect(window?.remainingPercent).toBeCloseTo(40, 5);
expect(window?.sessionDurationMs).toBe(FIVE_HOUR_BLOCK_MS);
});
it('uses usage data first and does not parse JSONL when reset timestamp exists', () => {
const nowMs = Date.parse('2026-03-02T20:00:00.000Z');
const fallbackMetrics: BlockMetrics = {
startTime: new Date('2026-03-02T15:00:00.000Z'),
lastActivity: new Date('2026-03-02T20:00:00.000Z')
};
mockGetCachedBlockMetrics.mockReturnValue(fallbackMetrics);
const window = resolveUsageWindowWithFallback({ sessionResetAt: '2026-03-02T22:00:00.000Z' }, undefined, nowMs);
expect(window).not.toBeNull();
expect(window?.elapsedMs).toBe(3 * 60 * 60 * 1000);
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(0);
});
it('uses provided block metrics fallback without parsing JSONL', () => {
const nowMs = Date.parse('2026-03-02T18:30:00.000Z');
const providedMetrics: BlockMetrics = {
startTime: new Date('2026-03-02T15:00:00.000Z'),
lastActivity: new Date('2026-03-02T18:30:00.000Z')
};
const window = resolveUsageWindowWithFallback({}, providedMetrics, nowMs);
expect(window).not.toBeNull();
expect(window?.elapsedMs).toBe(3.5 * 60 * 60 * 1000);
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(0);
});
it('parses JSONL fallback only when usage reset data is missing', () => {
const nowMs = Date.parse('2026-03-02T18:00:00.000Z');
const fallbackMetrics: BlockMetrics = {
startTime: new Date('2026-03-02T15:00:00.000Z'),
lastActivity: new Date('2026-03-02T18:00:00.000Z')
};
mockGetCachedBlockMetrics.mockReturnValue(fallbackMetrics);
const window = resolveUsageWindowWithFallback({}, undefined, nowMs);
expect(window).not.toBeNull();
expect(window?.elapsedMs).toBe(3 * 60 * 60 * 1000);
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(1);
});
it('returns null when neither usage reset data nor JSONL fallback is available', () => {
mockGetCachedBlockMetrics.mockReturnValue(null);
const window = resolveUsageWindowWithFallback({}, undefined, Date.now());
expect(window).toBeNull();
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(1);
});
it('parses weekly reset timestamp into elapsed and remaining metrics', () => {
const nowMs = Date.parse('2026-03-04T20:00:00.000Z');
const resetAt = '2026-03-09T20:00:00.000Z';
const window = getWeeklyUsageWindowFromResetAt(resetAt, nowMs);
expect(window).not.toBeNull();
expect(window?.elapsedMs).toBe(2 * 24 * 60 * 60 * 1000);
expect(window?.remainingMs).toBe(5 * 24 * 60 * 60 * 1000);
expect(window?.elapsedPercent).toBeCloseTo((2 / 7) * 100, 5);
expect(window?.remainingPercent).toBeCloseTo((5 / 7) * 100, 5);
expect(window?.sessionDurationMs).toBe(SEVEN_DAY_WINDOW_MS);
});
it('returns null for missing or invalid weekly reset timestamps', () => {
expect(getWeeklyUsageWindowFromResetAt(undefined, Date.now())).toBeNull();
expect(getWeeklyUsageWindowFromResetAt('not-a-date', Date.now())).toBeNull();
});
it('resolves weekly window directly from usage data without JSONL fallback', () => {
const nowMs = Date.parse('2026-03-04T20:00:00.000Z');
const window = resolveWeeklyUsageWindow({ weeklyResetAt: '2026-03-09T20:00:00.000Z' }, nowMs);
expect(window).not.toBeNull();
expect(window?.remainingMs).toBe(5 * 24 * 60 * 60 * 1000);
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(0);
});
it('formats duration in block timer style', () => {
expect(formatUsageDuration(0)).toBe('0m');
expect(formatUsageDuration(3 * 60 * 60 * 1000)).toBe('3hr');
expect(formatUsageDuration(3.5 * 60 * 60 * 1000)).toBe('3hr 30m');
expect(formatUsageDuration(4 * 60 * 60 * 1000 + 5 * 60 * 1000)).toBe('4hr 5m');
});
it('formats duration with days when >= 24h', () => {
expect(formatUsageDuration(25 * 60 * 60 * 1000)).toBe('1d 1hr');
expect(formatUsageDuration(36.5 * 60 * 60 * 1000)).toBe('1d 12hr 30m');
expect(formatUsageDuration(168 * 60 * 60 * 1000)).toBe('7d');
});
it('formats duration in compact style', () => {
expect(formatUsageDuration(0, true)).toBe('0m');
expect(formatUsageDuration(3 * 60 * 60 * 1000, true)).toBe('3h');
expect(formatUsageDuration(3.5 * 60 * 60 * 1000, true)).toBe('3h30m');
expect(formatUsageDuration(4 * 60 * 60 * 1000 + 5 * 60 * 1000, true)).toBe('4h5m');
});
it('formats duration with days in compact style when >= 24h', () => {
expect(formatUsageDuration(25 * 60 * 60 * 1000, true)).toBe('1d1h');
expect(formatUsageDuration(36.5 * 60 * 60 * 1000, true)).toBe('1d12h30m');
expect(formatUsageDuration(168 * 60 * 60 * 1000, true)).toBe('7d');
});
it('formats duration without days when requested', () => {
expect(formatUsageDuration(25 * 60 * 60 * 1000, false, false)).toBe('25hr');
expect(formatUsageDuration(36.5 * 60 * 60 * 1000, false, false)).toBe('36hr 30m');
expect(formatUsageDuration(168 * 60 * 60 * 1000, false, false)).toBe('168hr');
});
it('formats duration without days in compact style when requested', () => {
expect(formatUsageDuration(25 * 60 * 60 * 1000, true, false)).toBe('25h');
expect(formatUsageDuration(36.5 * 60 * 60 * 1000, true, false)).toBe('36h30m');
expect(formatUsageDuration(168 * 60 * 60 * 1000, true, false)).toBe('168h');
});
});
+269
View File
@@ -0,0 +1,269 @@
import {
describe,
expect,
it
} from 'vitest';
import {
DEFAULT_SETTINGS,
type Settings
} from '../../types/Settings';
import type { WidgetItemType } from '../../types/Widget';
import {
filterWidgetCatalog,
getAllWidgetTypes,
getMatchSegments,
getWidget,
getWidgetCatalog,
getWidgetCatalogCategories,
isKnownWidgetType,
type WidgetCatalogEntry
} from '../widgets';
describe('widget catalog', () => {
const baseSettings: Settings = {
...DEFAULT_SETTINGS,
powerline: { ...DEFAULT_SETTINGS.powerline }
};
it('builds catalog entries with categories from widget definitions', () => {
const catalog = getWidgetCatalog(baseSettings);
const model = catalog.find(entry => entry.type === 'model');
const separator = catalog.find(entry => entry.type === 'separator');
const link = catalog.find(entry => entry.type === 'link');
const gitInsertions = catalog.find(entry => entry.type === 'git-insertions');
const gitDeletions = catalog.find(entry => entry.type === 'git-deletions');
const inputSpeed = catalog.find(entry => entry.type === 'input-speed');
const outputSpeed = catalog.find(entry => entry.type === 'output-speed');
const totalSpeed = catalog.find(entry => entry.type === 'total-speed');
const resetTimer = catalog.find(entry => entry.type === 'reset-timer');
const weeklyResetTimer = catalog.find(entry => entry.type === 'weekly-reset-timer');
expect(model?.displayName).toBe('Model');
expect(model?.category).toBe('Core');
expect(separator?.displayName).toBe('Separator');
expect(separator?.category).toBe('Layout');
expect(link?.displayName).toBe('Link');
expect(link?.category).toBe('Custom');
expect(gitInsertions?.displayName).toBe('Git Insertions');
expect(gitInsertions?.category).toBe('Git');
expect(gitDeletions?.displayName).toBe('Git Deletions');
expect(gitDeletions?.category).toBe('Git');
expect(inputSpeed?.displayName).toBe('Input Speed');
expect(inputSpeed?.category).toBe('Token Speed');
expect(outputSpeed?.displayName).toBe('Output Speed');
expect(outputSpeed?.category).toBe('Token Speed');
expect(totalSpeed?.displayName).toBe('Total Speed');
expect(totalSpeed?.category).toBe('Token Speed');
expect(resetTimer?.displayName).toBe('Block Reset Timer');
expect(resetTimer?.category).toBe('Usage');
expect(weeklyResetTimer?.displayName).toBe('Weekly Reset Timer');
expect(weeklyResetTimer?.category).toBe('Usage');
});
it('hides manual separator when default separator is configured', () => {
const catalog = getWidgetCatalog({
...baseSettings,
defaultSeparator: '|'
});
const types = new Set(catalog.map(entry => entry.type));
expect(types.has('separator')).toBe(false);
expect(types.has('flex-separator')).toBe(true);
});
it('hides both separator types in powerline mode', () => {
const catalog = getWidgetCatalog({
...baseSettings,
powerline: {
...baseSettings.powerline,
enabled: true
}
});
const types = new Set(catalog.map(entry => entry.type));
expect(types.has('separator')).toBe(false);
expect(types.has('flex-separator')).toBe(false);
});
it('returns unique categories in discovery order', () => {
const categories = getWidgetCatalogCategories(getWidgetCatalog(baseSettings));
expect(categories).toContain('Core');
expect(categories).toContain('Git');
expect(categories).toContain('Context');
expect(categories).toContain('Tokens');
expect(categories).toContain('Token Speed');
expect(categories).toContain('Session');
expect(categories).toContain('Usage');
expect(categories).toContain('Environment');
expect(categories).toContain('Custom');
expect(categories).toContain('Layout');
});
it('returns runtime widget instances for non-layout widget types', () => {
const runtimeTypes = getAllWidgetTypes(baseSettings).filter(
type => type !== 'separator' && type !== 'flex-separator'
);
for (const type of runtimeTypes) {
const widget = getWidget(type);
expect(widget).not.toBeNull();
expect(widget?.getDisplayName().length).toBeGreaterThan(0);
}
});
it('recognizes known widget and layout types', () => {
expect(isKnownWidgetType('model')).toBe(true);
expect(isKnownWidgetType('separator')).toBe(true);
expect(isKnownWidgetType('flex-separator')).toBe(true);
expect(isKnownWidgetType('unknown-widget-type')).toBe(false);
});
});
describe('widget catalog filtering', () => {
const catalog = getWidgetCatalog({
...DEFAULT_SETTINGS,
powerline: { ...DEFAULT_SETTINGS.powerline }
});
it('matches display name with case-insensitive partial search', () => {
const results = filterWidgetCatalog(catalog, 'All', 'gIt br');
expect(results[0]?.type).toBe('git-branch');
});
it('matches type string search such as git-worktree', () => {
const results = filterWidgetCatalog(catalog, 'All', 'git-worktree');
expect(results[0]?.type).toBe('git-worktree');
});
it('matches description search', () => {
const results = filterWidgetCatalog(catalog, 'All', 'working directory');
expect(results.some(entry => entry.type === 'current-working-dir')).toBe(true);
});
it('applies category and query filters together', () => {
const results = filterWidgetCatalog(catalog, 'Git', 'context');
expect(results).toHaveLength(0);
});
it('fuzzy-matches initials across word boundaries (gb → Git Branch)', () => {
const results = filterWidgetCatalog(catalog, 'All', 'gb');
expect(results[0]?.type).toBe('git-branch');
});
it('prioritizes display-name fuzzy matches over description substring hits', () => {
const results = filterWidgetCatalog(catalog, 'All', 'tw');
expect(results[0]?.type).toBe('terminal-width');
});
it('prioritizes word-initial fuzzy matches over incidental subsequence matches', () => {
expect(filterWidgetCatalog(catalog, 'All', 'tc')[0]?.type).toBe('tokens-cached');
expect(filterWidgetCatalog(catalog, 'All', 'ti')[0]?.type).toBe('tokens-input');
expect(filterWidgetCatalog(catalog, 'All', 'to')[0]?.type).toBe('tokens-output');
});
it('ranks exact substring matches above fuzzy matches', () => {
const rankingCatalog: WidgetCatalogEntry[] = [
{
type: 'exact-match' as WidgetItemType,
displayName: 'Git Branch',
description: 'Exact substring match',
category: 'Core',
searchText: 'git branch exact substring match exact-match'
},
{
type: 'fuzzy-match' as WidgetItemType,
displayName: 'Global Input Timer',
description: 'Fuzzy-only match',
category: 'Core',
searchText: 'global input timer fuzzy-only match fuzzy-match'
}
];
const results = filterWidgetCatalog(rankingCatalog, 'All', 'git');
expect(results.map(entry => entry.type)).toEqual(['exact-match', 'fuzzy-match']);
});
it('returns no results when query chars cannot form a subsequence in any entry', () => {
const results = filterWidgetCatalog(catalog, 'All', 'zzz');
expect(results).toHaveLength(0);
});
it('prioritizes name match before type and description matches', () => {
const rankingCatalog: WidgetCatalogEntry[] = [
{
type: 'alpha' as WidgetItemType,
displayName: 'Git Branch',
description: 'Primary match',
category: 'Core',
searchText: 'git branch primary match alpha'
},
{
type: 'git-type-only' as WidgetItemType,
displayName: 'Branch',
description: 'Type fallback match',
category: 'Core',
searchText: 'branch type fallback match git-type-only'
},
{
type: 'desc-only' as WidgetItemType,
displayName: 'Branch',
description: 'Description contains git',
category: 'Core',
searchText: 'branch description contains git desc-only'
}
];
const results = filterWidgetCatalog(rankingCatalog, 'All', 'git');
expect(results.map(entry => entry.type)).toEqual(['alpha', 'git-type-only', 'desc-only']);
});
});
describe('getMatchSegments', () => {
it('returns single unmatched segment when query is empty', () => {
expect(getMatchSegments('Git Branch', '')).toEqual([{ text: 'Git Branch', matched: false }]);
});
it('highlights exact substring match', () => {
const segments = getMatchSegments('Git Branch', 'git');
expect(segments).toEqual([
{ text: 'Git', matched: true },
{ text: ' Branch', matched: false }
]);
});
it('highlights exact substring in the middle', () => {
const segments = getMatchSegments('Git Branch', 'it B');
expect(segments).toEqual([
{ text: 'G', matched: false },
{ text: 'it B', matched: true },
{ text: 'ranch', matched: false }
]);
});
it('highlights fuzzy match positions when no substring match exists', () => {
const segments = getMatchSegments('Git Branch', 'gb');
const matched = segments.filter(s => s.matched).map(s => s.text).join('');
expect(matched.toLowerCase()).toBe('gb');
});
it('prefers word-initial fuzzy positions over incidental interior-letter matches', () => {
expect(getMatchSegments('Tokens Output', 'to')).toEqual([
{ text: 'T', matched: true },
{ text: 'okens ', matched: false },
{ text: 'O', matched: true },
{ text: 'utput', matched: false }
]);
});
it('returns unmatched segment when query chars cannot form a subsequence', () => {
expect(getMatchSegments('Git Branch', 'zzz')).toEqual([{ text: 'Git Branch', matched: false }]);
});
it('is case-insensitive but preserves original casing in output', () => {
const segments = getMatchSegments('Git Branch', 'GIT');
expect(segments[0]).toEqual({ text: 'Git', matched: true });
});
});
+448
View File
@@ -0,0 +1,448 @@
import stringWidth from 'string-width';
const ESC = '\x1b';
const BEL = '\x07';
const C1_CSI = '\x9b';
const C1_OSC = '\x9d';
const ST = '\x9c';
const ZERO_WIDTH_JOINER = 0x200d;
const COMBINING_ENCLOSING_KEYCAP = 0x20e3;
const VARIATION_SELECTOR_START = 0xfe00;
const VARIATION_SELECTOR_END = 0xfe0f;
const VARIATION_SELECTOR_SUPPLEMENT_START = 0xe0100;
const VARIATION_SELECTOR_SUPPLEMENT_END = 0xe01ef;
const REGIONAL_INDICATOR_START = 0x1f1e6;
const REGIONAL_INDICATOR_END = 0x1f1ff;
const SGR_REGEX = /\x1b\[[0-9;]*m/g;
const EXTENDED_PICTOGRAPHIC_REGEX = createUnicodePropertyRegex('\\p{Extended_Pictographic}');
const EMOJI_PRESENTATION_REGEX = createUnicodePropertyRegex('\\p{Emoji_Presentation}');
const EMOJI_MODIFIER_REGEX = createUnicodePropertyRegex('\\p{Emoji_Modifier}');
const COMBINING_MARK_REGEX = createUnicodePropertyRegex('\\p{Mark}');
type Osc8Action = 'open' | 'close';
type OscTerminator = 'bel' | 'st';
interface ParsedEscapeSequence {
nextIndex: number;
sequence: string;
osc8Action?: Osc8Action;
osc8Terminator?: OscTerminator;
}
interface DisplayCluster {
text: string;
nextIndex: number;
}
function createUnicodePropertyRegex(pattern: string): RegExp | null {
try {
return new RegExp(pattern, 'u');
} catch {
return null;
}
}
function matchesUnicodeProperty(character: string, regex: RegExp | null): boolean {
return regex?.test(character) ?? false;
}
function isVariationSelector(codePoint: number): boolean {
return (codePoint >= VARIATION_SELECTOR_START && codePoint <= VARIATION_SELECTOR_END)
|| (codePoint >= VARIATION_SELECTOR_SUPPLEMENT_START && codePoint <= VARIATION_SELECTOR_SUPPLEMENT_END);
}
function isRegionalIndicator(codePoint: number): boolean {
return codePoint >= REGIONAL_INDICATOR_START && codePoint <= REGIONAL_INDICATOR_END;
}
function consumeDisplayCluster(text: string, start: number): DisplayCluster | null {
const firstCodePoint = text.codePointAt(start);
if (firstCodePoint === undefined) {
return null;
}
const firstCharacter = String.fromCodePoint(firstCodePoint);
let cluster = firstCharacter;
let index = start + firstCharacter.length;
if (isRegionalIndicator(firstCodePoint)) {
const nextCodePoint = text.codePointAt(index);
if (nextCodePoint !== undefined && isRegionalIndicator(nextCodePoint)) {
const nextCharacter = String.fromCodePoint(nextCodePoint);
cluster += nextCharacter;
index += nextCharacter.length;
}
return {
text: cluster,
nextIndex: index
};
}
while (index < text.length) {
const nextCodePoint = text.codePointAt(index);
if (nextCodePoint === undefined) {
break;
}
const nextCharacter = String.fromCodePoint(nextCodePoint);
if (isVariationSelector(nextCodePoint)
|| nextCodePoint === COMBINING_ENCLOSING_KEYCAP
|| matchesUnicodeProperty(nextCharacter, COMBINING_MARK_REGEX)
|| matchesUnicodeProperty(nextCharacter, EMOJI_MODIFIER_REGEX)) {
cluster += nextCharacter;
index += nextCharacter.length;
continue;
}
if (nextCodePoint === ZERO_WIDTH_JOINER) {
cluster += nextCharacter;
index += nextCharacter.length;
const joinedCodePoint = text.codePointAt(index);
if (joinedCodePoint === undefined) {
break;
}
const joinedCharacter = String.fromCodePoint(joinedCodePoint);
cluster += joinedCharacter;
index += joinedCharacter.length;
continue;
}
break;
}
return {
text: cluster,
nextIndex: index
};
}
function isZeroWidthStandaloneCluster(cluster: string): boolean {
const characters = Array.from(cluster);
return characters.length > 0 && characters.every((character) => {
const codePoint = character.codePointAt(0);
if (codePoint === undefined) {
return false;
}
return codePoint === ZERO_WIDTH_JOINER
|| codePoint === COMBINING_ENCLOSING_KEYCAP
|| isVariationSelector(codePoint)
|| matchesUnicodeProperty(character, COMBINING_MARK_REGEX)
|| matchesUnicodeProperty(character, EMOJI_MODIFIER_REGEX);
});
}
function shouldTreatClusterAsNarrowTextPictograph(cluster: string): boolean {
if (stringWidth(cluster) <= 1) {
return false;
}
const characters = Array.from(cluster);
if (characters.length === 0) {
return false;
}
for (const character of characters) {
const codePoint = character.codePointAt(0);
if (codePoint === undefined) {
continue;
}
if (codePoint === ZERO_WIDTH_JOINER
|| codePoint === COMBINING_ENCLOSING_KEYCAP
|| isVariationSelector(codePoint)
|| isRegionalIndicator(codePoint)
|| matchesUnicodeProperty(character, EMOJI_PRESENTATION_REGEX)
|| matchesUnicodeProperty(character, EMOJI_MODIFIER_REGEX)) {
return false;
}
}
return characters.some(character => matchesUnicodeProperty(character, EXTENDED_PICTOGRAPHIC_REGEX));
}
function getClusterWidth(cluster: string): number {
if (cluster.length === 0 || isZeroWidthStandaloneCluster(cluster)) {
return 0;
}
if (shouldTreatClusterAsNarrowTextPictograph(cluster)) {
return 1;
}
return stringWidth(cluster);
}
function getTextDisplayWidth(text: string): number {
let width = 0;
let index = 0;
while (index < text.length) {
const cluster = consumeDisplayCluster(text, index);
if (!cluster) {
break;
}
width += getClusterWidth(cluster.text);
index = cluster.nextIndex;
}
return width;
}
function isCsiFinalByte(codePoint: number): boolean {
return codePoint >= 0x40 && codePoint <= 0x7e;
}
function parseCsi(input: string, start: number, bodyStart: number): ParsedEscapeSequence {
let index = bodyStart;
while (index < input.length) {
const codePoint = input.charCodeAt(index);
if (isCsiFinalByte(codePoint)) {
const end = index + 1;
return {
nextIndex: end,
sequence: input.slice(start, end)
};
}
index++;
}
return {
nextIndex: input.length,
sequence: input.slice(start)
};
}
function getOsc8Action(body: string): Osc8Action | undefined {
if (!body.startsWith('8;')) {
return undefined;
}
const urlStart = body.indexOf(';', 2);
if (urlStart === -1) {
return undefined;
}
const url = body.slice(urlStart + 1);
return url.length > 0 ? 'open' : 'close';
}
function parseOsc(
input: string,
start: number,
bodyStart: number
): ParsedEscapeSequence {
let index = bodyStart;
while (index < input.length) {
const current = input[index];
if (!current) {
break;
}
if (current === BEL) {
const end = index + 1;
const body = input.slice(bodyStart, index);
return {
nextIndex: end,
sequence: input.slice(start, end),
osc8Action: getOsc8Action(body),
osc8Terminator: 'bel'
};
}
if (current === ST) {
const end = index + 1;
const body = input.slice(bodyStart, index);
return {
nextIndex: end,
sequence: input.slice(start, end),
osc8Action: getOsc8Action(body),
osc8Terminator: 'st'
};
}
if (current === ESC && input[index + 1] === '\\') {
const end = index + 2;
const body = input.slice(bodyStart, index);
return {
nextIndex: end,
sequence: input.slice(start, end),
osc8Action: getOsc8Action(body),
osc8Terminator: 'st'
};
}
index++;
}
return {
nextIndex: input.length,
sequence: input.slice(start)
};
}
function parseEscapeSequence(input: string, index: number): ParsedEscapeSequence | null {
const current = input[index];
if (!current) {
return null;
}
if (current === ESC) {
const next = input[index + 1];
if (next === '[') {
return parseCsi(input, index, index + 2);
}
if (next === ']') {
return parseOsc(input, index, index + 2);
}
if (next) {
return {
nextIndex: index + 2,
sequence: input.slice(index, index + 2)
};
}
return {
nextIndex: input.length,
sequence: current
};
}
if (current === C1_CSI) {
return parseCsi(input, index, index + 1);
}
if (current === C1_OSC) {
return parseOsc(input, index, index + 1);
}
return null;
}
function getOsc8CloseSequence(terminator: OscTerminator): string {
if (terminator === 'bel') {
return `${ESC}]8;;${BEL}`;
}
return `${ESC}]8;;${ESC}\\`;
}
export function stripSgrCodes(text: string): string {
return text.replace(SGR_REGEX, '');
}
export function getVisibleText(text: string): string {
let result = '';
let index = 0;
while (index < text.length) {
const escape = parseEscapeSequence(text, index);
if (escape) {
index = escape.nextIndex;
continue;
}
const codePoint = text.codePointAt(index);
if (codePoint === undefined) {
break;
}
const character = String.fromCodePoint(codePoint);
result += character;
index += character.length;
}
return result;
}
export function getVisibleWidth(text: string): number {
return getTextDisplayWidth(getVisibleText(text));
}
interface TruncateOptions { ellipsis?: boolean }
export function truncateStyledText(
text: string,
maxWidth: number,
options: TruncateOptions = {}
): string {
if (maxWidth <= 0) {
return '';
}
if (getVisibleWidth(text) <= maxWidth) {
return text;
}
const addEllipsis = options.ellipsis ?? true;
const ellipsis = addEllipsis ? '...' : '';
const ellipsisWidth = addEllipsis ? stringWidth(ellipsis) : 0;
if (addEllipsis && maxWidth <= ellipsisWidth) {
return '.'.repeat(maxWidth);
}
const targetWidth = Math.max(0, maxWidth - ellipsisWidth);
let output = '';
let currentWidth = 0;
let index = 0;
let didTruncate = false;
let openOsc8Terminator: OscTerminator | null = null;
while (index < text.length) {
const escape = parseEscapeSequence(text, index);
if (escape) {
output += escape.sequence;
index = escape.nextIndex;
if (escape.osc8Action === 'open') {
openOsc8Terminator = escape.osc8Terminator ?? 'st';
} else if (escape.osc8Action === 'close') {
openOsc8Terminator = null;
}
continue;
}
let visibleSegmentEnd = index;
while (visibleSegmentEnd < text.length && !parseEscapeSequence(text, visibleSegmentEnd)) {
const codePoint = text.codePointAt(visibleSegmentEnd);
if (codePoint === undefined) {
break;
}
visibleSegmentEnd += String.fromCodePoint(codePoint).length;
}
const visibleSegment = text.slice(index, visibleSegmentEnd);
const cluster = consumeDisplayCluster(visibleSegment, 0);
if (!cluster) {
break;
}
const clusterWidth = getClusterWidth(cluster.text);
if (currentWidth + clusterWidth > targetWidth) {
didTruncate = true;
break;
}
output += cluster.text;
currentWidth += clusterWidth;
index += cluster.text.length;
}
if (!didTruncate) {
return text;
}
if (openOsc8Terminator) {
output += getOsc8CloseSequence(openOsc8Terminator);
}
return output + ellipsis;
}
+230 -22
View File
@@ -4,6 +4,15 @@ import * as os from 'os';
import * as path from 'path';
import type { ClaudeSettings } from '../types/ClaudeSettings';
import {
SettingsSchema,
type Settings
} from '../types/Settings';
import {
getConfigPath,
isCustomConfigPath
} from './config';
// Re-export for backward compatibility
export type { ClaudeSettings };
@@ -13,66 +22,265 @@ const readFile = fs.promises.readFile;
const writeFile = fs.promises.writeFile;
const mkdir = fs.promises.mkdir;
const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
export const CCSTATUSLINE_COMMANDS = {
NPM: 'npx -y ccstatusline@latest',
BUNX: 'bunx -y ccstatusline@latest',
SELF_MANAGED: 'ccstatusline'
};
export async function loadClaudeSettings(): Promise<ClaudeSettings> {
try {
if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) {
return {};
export function isKnownCommand(command: string): boolean {
const prefixes = [CCSTATUSLINE_COMMANDS.NPM, CCSTATUSLINE_COMMANDS.BUNX, CCSTATUSLINE_COMMANDS.SELF_MANAGED];
return prefixes.some(prefix => command === prefix || command.startsWith(`${prefix} --config `));
}
function needsQuoting(filePath: string): boolean {
if (process.platform === 'win32') {
// cmd.exe-safe set of characters that require quoting.
return /[\s&()<>|^"]/.test(filePath);
}
return /[\s()[\];&#|'"\\$`]/.test(filePath);
}
function quotePathIfNeeded(filePath: string): string {
if (!needsQuoting(filePath)) {
return filePath;
}
if (process.platform === 'win32') {
return `"${filePath.replace(/"/g, '""')}"`;
}
return `'${filePath.replace(/'/g, '\'\\\'\'')}'`;
}
/**
* Determines the Claude config directory, checking CLAUDE_CONFIG_DIR environment variable first,
* then falling back to the default ~/.claude directory.
*/
export function getClaudeConfigDir(): string {
const envConfigDir = process.env.CLAUDE_CONFIG_DIR;
if (envConfigDir) {
try {
// Validate that the path is absolute and reasonable
const resolvedPath = path.resolve(envConfigDir);
// Check if directory exists or can be created
if (fs.existsSync(resolvedPath)) {
const stats = fs.statSync(resolvedPath);
if (stats.isDirectory()) {
return resolvedPath;
}
} else {
// Directory doesn't exist yet, but we can try to use it
// (mkdir will be called later when saving)
return resolvedPath;
}
} catch {
// Fall through to default on any error
}
const content = await readFile(CLAUDE_SETTINGS_PATH, 'utf-8');
return JSON.parse(content) as ClaudeSettings;
} catch {
}
// Default fallback
return path.join(os.homedir(), '.claude');
}
/**
* Gets the full path to the Claude settings.json file.
*/
export function getClaudeSettingsPath(): string {
return path.join(getClaudeConfigDir(), 'settings.json');
}
/**
* Creates a backup of the current Claude settings file.
*/
async function backupClaudeSettings(suffix = '.bak'): Promise<string | null> {
const settingsPath = getClaudeSettingsPath();
const backupPath = settingsPath + suffix;
try {
if (fs.existsSync(settingsPath)) {
const content = await readFile(settingsPath, 'utf-8');
await writeFile(backupPath, content, 'utf-8');
return backupPath;
}
} catch (error) {
console.error('Failed to backup Claude settings:', error);
}
return null;
}
interface LoadClaudeSettingsOptions { logErrors?: boolean }
export function loadClaudeSettingsSync(options: LoadClaudeSettingsOptions = {}): ClaudeSettings {
const { logErrors = true } = options;
const settingsPath = getClaudeSettingsPath();
// File doesn't exist - return empty object
if (!fs.existsSync(settingsPath)) {
return {};
}
try {
const content = fs.readFileSync(settingsPath, 'utf-8');
return JSON.parse(content) as ClaudeSettings;
} catch (error) {
if (logErrors) {
console.error('Failed to load Claude settings:', error);
}
throw error;
}
}
export async function saveClaudeSettings(settings: ClaudeSettings): Promise<void> {
const dir = path.dirname(CLAUDE_SETTINGS_PATH);
export async function loadClaudeSettings(options: LoadClaudeSettingsOptions = {}): Promise<ClaudeSettings> {
const { logErrors = true } = options;
const settingsPath = getClaudeSettingsPath();
// File doesn't exist - return empty object
if (!fs.existsSync(settingsPath)) {
return {};
}
try {
const content = await readFile(settingsPath, 'utf-8');
return JSON.parse(content) as ClaudeSettings;
} catch (error) {
if (logErrors) {
console.error('Failed to load Claude settings:', error);
}
throw error;
}
}
export async function saveClaudeSettings(
settings: ClaudeSettings
): Promise<void> {
const settingsPath = getClaudeSettingsPath();
const dir = path.dirname(settingsPath);
// Backup settings before overwriting
await backupClaudeSettings();
await mkdir(dir, { recursive: true });
await writeFile(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf-8');
await writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
}
export async function isInstalled(): Promise<boolean> {
const settings = await loadClaudeSettings();
// Check if command is either npx or bunx version AND padding is 0 (or undefined for new installs)
const validCommands = ['npx -y ccstatusline@latest', 'bunx -y ccstatusline@latest'];
return validCommands.includes(settings.statusLine?.command ?? '')
&& (settings.statusLine?.padding === 0 || settings.statusLine?.padding === undefined);
let settings: ClaudeSettings;
try {
settings = await loadClaudeSettings({ logErrors: false });
} catch {
return false; // Can't determine if installed, assume not
}
const command = settings.statusLine?.command ?? '';
return (
isKnownCommand(command)
&& (settings.statusLine?.padding === 0
|| settings.statusLine?.padding === undefined)
);
}
export function isBunxAvailable(): boolean {
try {
execSync('which bunx', { stdio: 'ignore' });
// Use platform-appropriate command to check for bunx availability
const command = process.platform === 'win32' ? 'where bunx' : 'which bunx';
execSync(command, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
function buildCommand(baseCommand: string): string {
if (isCustomConfigPath()) {
return `${baseCommand} --config ${quotePathIfNeeded(getConfigPath())}`;
}
return baseCommand;
}
async function loadSavedSettingsForHookSync(): Promise<Settings | null> {
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
return null;
}
try {
const content = await readFile(configPath, 'utf-8');
const parsed = JSON.parse(content) as unknown;
const result = SettingsSchema.safeParse(parsed);
if (!result.success) {
return null;
}
return result.data;
} catch {
return null;
}
}
export async function installStatusLine(useBunx = false): Promise<void> {
const settings = await loadClaudeSettings();
let settings: ClaudeSettings;
const backupPath = await backupClaudeSettings('.orig');
try {
settings = await loadClaudeSettings({ logErrors: false });
} catch {
const fallbackBackupPath = `${getClaudeSettingsPath()}.orig`;
console.error(`Warning: Could not read existing Claude settings. A backup exists at ${backupPath ?? fallbackBackupPath}.`);
settings = {};
}
const baseCommand = useBunx
? CCSTATUSLINE_COMMANDS.BUNX
: CCSTATUSLINE_COMMANDS.NPM;
// Update settings with our status line (confirmation already handled in TUI)
settings.statusLine = {
type: 'command',
command: useBunx ? 'bunx -y ccstatusline@latest' : 'npx -y ccstatusline@latest',
command: buildCommand(baseCommand),
padding: 0
};
await saveClaudeSettings(settings);
const savedSettings = await loadSavedSettingsForHookSync();
if (savedSettings) {
const { syncWidgetHooks } = await import('./hooks');
await syncWidgetHooks(savedSettings);
}
}
export async function uninstallStatusLine(): Promise<void> {
const settings = await loadClaudeSettings();
let settings: ClaudeSettings;
try {
settings = await loadClaudeSettings({ logErrors: false });
} catch {
console.error('Warning: Could not read existing Claude settings.');
return; // if we can't read, return... what are we uninstalling?
}
if (settings.statusLine) {
delete settings.statusLine;
await saveClaudeSettings(settings);
}
try {
const { removeManagedHooks } = await import('./hooks');
await removeManagedHooks();
} catch {
// Ignore hook cleanup failures during uninstall
}
}
export async function getExistingStatusLine(): Promise<string | null> {
const settings = await loadClaudeSettings();
return settings.statusLine?.command ?? null;
try {
const settings = await loadClaudeSettings({ logErrors: false });
return settings.statusLine?.command ?? null;
} catch {
return null; // Can't read settings, return null
}
}

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