Compare commits

..

38 Commits

Author SHA1 Message Date
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
151 changed files with 12066 additions and 3947 deletions
+32
View File
@@ -0,0 +1,32 @@
version: 2
updates:
- package-ecosystem: bun
directory: /
schedule:
interval: weekly
day: monday
ignore:
- dependency-name: "ink"
open-pull-requests-limit: 10
cooldown:
semver-major-days: 30
semver-minor-days: 7
semver-patch-days: 3
groups:
dev-dependencies:
dependency-type: development
update-types:
- minor
- patch
production-dependencies:
dependency-type: production
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
+36
View File
@@ -0,0 +1,36 @@
name: CI
on:
push:
pull_request:
branches: [main]
jobs:
lint:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@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
@@ -21,6 +21,7 @@
"ccstatusline",
"Powerline",
"statusline",
"sublabel",
"Worktree",
"worktrees"
]
+6 -3
View File
@@ -33,7 +33,10 @@ bun test
bun test --watch
# Lint and type check
bun run lint # Runs TypeScript type checking and ESLint with auto-fix
bun run lint # Runs TypeScript type checking and ESLint without modifying files
# Apply ESLint auto-fixes intentionally
bun run lint:fix
```
## Architecture
@@ -97,7 +100,7 @@ All widgets must implement:
**Available Widgets:**
- Model, Version, OutputStyle - Claude Code metadata display
- GitBranch, GitChanges, GitWorktree - Git repository status
- GitBranch, GitChanges, GitInsertions, GitDeletions, GitWorktree - Git repository status
- TokensInput, TokensOutput, TokensCached, TokensTotal - Token usage metrics
- ContextLength, ContextPercentage, ContextPercentageUsable - Context window metrics (uses dynamic model-based context windows: 1M for Sonnet 4.5 with [1m] suffix, 200k for all other models)
- BlockTimer, SessionClock, SessionCost - Time and cost tracking
@@ -136,7 +139,7 @@ Default to using Bun instead of Node.js:
2. `postbuild`: Runs scripts/replace-version.ts to replace `__PACKAGE_VERSION__` placeholder with actual version from package.json
- **ESLint configuration**: Uses flat config format (eslint.config.js) with TypeScript and React plugins
- **Dependencies**: All runtime dependencies are bundled using `--packages=external` for npm package
- **Type checking and linting**: Only run via `bun run lint` command, never using `npx eslint` or `eslint` directly. Never run `tsx`, `bun tsc` or any other variation
- **Type checking and linting**: Run checks via `bun run lint` and use `bun run lint:fix` only when you intentionally want ESLint auto-fixes. Never use `npx eslint`, `eslint`, `tsx`, `bun tsc`, or any other variation directly
- **Lint rules**: Never disable a lint rule via a comment, no matter how benign the lint warning or error may seem
- **Testing**: Uses Vitest (via Bun) with 6 test files and ~40 test cases covering:
- Model context detection and token calculation (src/utils/__tests__/model-context.test.ts)
+61 -30
View File
@@ -46,12 +46,29 @@
## 🆕 Recent Updates
### v2.1.0 - v2.1.3 - Usage widgets, links, and reliability fixes
### v2.2.0 - v2.2.2 - Token Speed + Skills widget updates
- **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Reset Timer**, and **Context Bar** widgets.
- **🚀 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.
- **🤝 Better subagent-aware speed reporting** - Token speed calculations continue to include referenced subagent activity so displayed speeds better reflect actual concurrent work.
### v2.1.0 - v2.1.10 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes
- **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Block Reset Timer**, and **Context Bar** widgets.
- **📊 More accurate counts (v2.1.0)** - Usage/context widgets now use new statusline JSON metrics when available for more accurate token and context counts.
- **🪟 Windows empty file bug fix (v2.1.1)** - Fixed a Windows issue that could create an empty `c:\dev\null` file.
- **🔗 New Link widget (v2.1.3)** - Added a new **Link** widget with clickable OSC8 rendering, preview parity, and raw mode support.
- ** New Git Insertions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted insertions (e.g., `+42`).
- ** New Git Deletions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted deletions (e.g., `-10`).
- **🧠 Context format fallback fix (v2.1.6)** - When `context_window_size` is missing, context widgets now infer 1M models from long-context labels such as `[1m]` and `1M context` in model identifiers.
- **⏳ Weekly reset timer split (v2.1.7)** - Added a separate `Weekly Reset Timer` widget.
- **⚙️ Custom config file flag (v2.1.8)** - Added `--config <path>` support so ccstatusline can load/save settings from a custom file location.
- **🔣 Unicode separator hex input upgrade (v2.1.9)** - Powerline separator hex input now supports 4-6 digits (full Unicode code points up to `U+10FFFF`).
- **🌳 Bare repo worktree detection fix (v2.1.10)** - `Git Worktree` now correctly detects linked worktrees created from bare repositories.
### v2.0.26 - v2.0.29 - Performance, git internals, and workflow improvements
@@ -187,6 +204,8 @@ The interactive configuration tool provides a terminal UI where you can:
> $env:CLAUDE_CONFIG_DIR="C:\custom\path\.claude"
> ```
> 🌐 **Usage API proxy:** Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic.
### Claude Code settings.json format
When you install from the TUI, ccstatusline writes a `statusLine` command object to your Claude Code settings:
@@ -409,36 +428,43 @@ bun run example
### 📊 Available Widgets
- **Model Name** - Shows the current Claude model (e.g., "Claude 3.5 Sonnet")
- **Git Branch** - Displays current git branch name
- **Git Changes** - Shows uncommitted insertions/deletions (e.g., "+42,-10")
- **Model** - Displays the Claude model name (e.g., "Claude 3.5 Sonnet")
- **Output Style** - Shows the current Claude Code output style
- **Git Branch** - Shows the current git branch name
- **Git Changes** - Shows git changes count (`+insertions`, `-deletions`)
- **Git Insertions** - Shows git insertions count
- **Git Deletions** - Shows git deletions count
- **Git Root Dir** - Shows the git repository root directory name
- **Git Worktree** - Shows the name of the current git worktree
- **Session Clock** - Shows elapsed time since session start (e.g., "2hr 15m")
- **Session Usage** - Shows current 5-hour/session API usage percentage
- **Weekly Usage** - Shows rolling 7-day API usage percentage
- **Session Cost** - Shows total session cost in USD (e.g., "$1.23")
- **Session Name** - Shows the session name set via `/rename` command in Claude Code
- **Git Worktree** - Shows the current git worktree name
- **Current Working Dir** - Shows current working directory with segment limit, fish-style abbreviation, and optional `~` home abbreviation
- **Tokens Input** - Shows input token count for the current session
- **Tokens Output** - Shows output token count for the current session
- **Tokens Cached** - Shows cached token count for the current session
- **Tokens Total** - Shows total token count (`input + output + cache`) for the current session
- **Input Speed** - Shows session-average input token speed (`tokens/sec`) with optional per-widget window (`0-120` seconds; `0` = full-session average)
- **Output Speed** - Shows session-average output token speed (`tokens/sec`) with optional per-widget window (`0-120` seconds; `0` = full-session average)
- **Total Speed** - Shows session-average total token speed (`tokens/sec`) with optional per-widget window (`0-120` seconds; `0` = full-session average)
- **Context Length** - Shows the current context window size in tokens
- **Context %** - Shows percentage of context window used or remaining
- **Context % (usable)** - Shows percentage of usable context used or remaining (80% of max before auto-compact)
- **Session Clock** - Shows elapsed time since current session started
- **Session Cost** - Shows the total session cost in USD
- **Block Timer** - Shows current 5-hour block elapsed time or progress
- **Terminal Width** - Shows current terminal width in columns
- **Version** - Shows Claude Code CLI version number
- **Custom Text** - Displays user-defined custom text
- **Custom Command** - Executes a custom shell command and displays output (refreshes whenever Claude Code updates the status line)
- **Link** - Displays a clickable terminal hyperlink using OSC 8
- **Claude Session ID** - Shows the current Claude Code session ID from status JSON
- **Block Timer** - Shows time elapsed in current 5-hour block or progress bar
- **Reset Timer** - Shows time remaining until the current 5-hour block resets
- **Current Working Directory** - Shows current working directory with segment limit, fish-style abbreviation, and optional `~` home abbreviation
- **Version** - Shows Claude Code version
- **Output Style** - Shows the currently set output style in Claude Code
- **Tokens Input** - Shows input tokens used
- **Tokens Output** - Shows output tokens used
- **Tokens Cached** - Shows cached tokens used
- **Tokens Total** - Shows total tokens used
- **Context Length** - Shows current context length in tokens
- **Context Percentage** - Shows percentage of context limit used (dynamic: 1M for model IDs with `[1m]` suffix, 200k otherwise)
- **Context Percentage (usable)** - Shows percentage of usable context (dynamic: 800k for model IDs with `[1m]` suffix, 160k otherwise, accounting for auto-compact at 80%)
- **Session Name** - Shows the session name set via `/rename` in Claude Code
- **Memory Usage** - Shows system memory usage (used/total)
- **Session Usage** - Shows daily/session API usage percentage
- **Weekly Usage** - Shows weekly API usage percentage
- **Block Reset Timer** - Shows time remaining until current 5-hour block reset window
- **Weekly Reset Timer** - Shows time remaining until weekly usage reset
- **Context Bar** - Shows context usage as a progress bar with short/full display modes
- **Terminal Width** - Shows detected terminal width (for debugging)
- **Memory Usage** - Shows system memory usage (used/total, e.g., "Mem: 12.4G/16.0G")
- **Custom Text** - Add your own custom text to the status line
- **Custom Command** - Execute shell commands and display their output (refreshes whenever the statusline is updated by Claude Code)
- **Link** - Add clickable terminal hyperlinks (OSC 8) with configurable URL and display text
- **Separator** - Visual divider between widgets (customizable: |, -, comma, space; available when Powerline mode is off and no default separator is configured)
- **Skills** - Shows skill activity as last used, total count, or unique list (with optional list limit and hide-when-empty toggle)
- **Separator** - Visual divider between widgets (available when Powerline mode is off and no default separator is configured)
- **Flex Separator** - Expands to fill available space (available when Powerline mode is off)
---
@@ -523,6 +549,8 @@ Widget-specific shortcuts:
- **Git widgets**: `h` toggle hide `no git` output
- **Context % widgets**: `u` toggle used vs remaining display
- **Block Timer**: `p` cycle display mode (time/full bar/short bar)
- **Block Reset Timer**: `p` cycle display mode (time/full bar/short bar)
- **Weekly Reset Timer**: `p` cycle display mode (time/full bar/short bar)
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path
- **Custom Command**: `e` command, `w` max width, `t` timeout, `p` preserve ANSI colors
- **Link**: `u` URL, `e` link text
@@ -648,9 +676,12 @@ bun run example
# Run tests
bun test
# Run typecheck + eslint autofix
# 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
+178 -115
View File
@@ -16,19 +16,20 @@
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-import-newlines": "^1.4.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"ink": "^6.2.0",
"eslint-plugin-react-hooks": "^7.0.1",
"https-proxy-agent": "^7.0.0",
"ink": "6.2.0",
"ink-gradient": "^3.0.0",
"ink-select-input": "^6.2.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": "^3.2.4",
"vitest": "^4.0.18",
"zod": "^4.0.17",
},
},
@@ -42,6 +43,38 @@
"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=="],
"@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=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@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=="],
"@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=="],
"@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=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@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=="],
"@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=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@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.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
"@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
@@ -100,25 +133,25 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.9", "", { "os": "win32", "cpu": "x64" }, "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ=="],
"@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=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
"@eslint/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=="],
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.3.1", "", {}, "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/core@0.15.2", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
"@eslint/js": ["@eslint/js@9.33.0", "", {}, "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A=="],
"@eslint/js": ["@eslint/js@9.39.3", "", {}, "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.12.2", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.12.2", "@shikijs/langs": "^3.12.2", "@shikijs/themes": "^3.12.2", "@shikijs/types": "^3.12.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HKZPmO8OSSAAo20H2B3xgJdxZaLTwtlMwxg0967scnrDlPwe6j5+ULGHyIqwgTbFCn9yv/ff8CmfWZLE9YKBzA=="],
"@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=="],
@@ -128,16 +161,18 @@
"@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=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@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=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-cqPpZdKUSQYRtLLr6R4X3sD4jCBO1zUmeo3qrWBCqYIeH8Q3KRL4F3V7XJ2Rm8/RJOQBZuqzQGWPjjvFUcYa/w=="],
@@ -180,17 +215,19 @@
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.12.2", "", { "dependencies": { "@shikijs/types": "3.12.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hozwnFHsLvujK4/CPVHNo3Bcg2EsnG8krI/ZQ2FlBlCRpPZW4XAEQmEwqegJsypsTAN9ehu2tEYe30lYKSZW/w=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],
"@shikijs/langs": ["@shikijs/langs@3.12.2", "", { "dependencies": { "@shikijs/types": "3.12.2" } }, "sha512-bVx5PfuZHDSHoBal+KzJZGheFuyH4qwwcwG/n+MsWno5cTlKmaNtTsGzJpHYQ8YPbB5BdEdKU1rga5/6JGY8ww=="],
"@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
"@shikijs/themes": ["@shikijs/themes@3.12.2", "", { "dependencies": { "@shikijs/types": "3.12.2" } }, "sha512-fTR3QAgnwYpfGczpIbzPjlRnxyONJOerguQv1iwpyQZ9QXX4qy/XFQqXlf17XTsorxnHoJGbH/LXBvwtqDsF5A=="],
"@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
"@shikijs/types": ["@shikijs/types@3.12.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-K5UIBzxCyv0YoxN3LMrKB9zuhp1bV+LgewxuVwHdl4Gz5oePoUFrr9EfgJlGlDeXCU1b/yhdnXeuRvAnz8HN8Q=="],
"@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=="],
"@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=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.9.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-FqqSkvDMYJReydrMhlugc71M76yLLQWNfmGq+SIlLa7N3kHp8Qq8i2PyWrVNAfjOyOIY+xv9XaaYwvVW7vroMA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
@@ -214,31 +251,31 @@
"@types/pluralize": ["@types/pluralize@0.0.33", "", {}, "sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg=="],
"@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="],
"@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=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@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=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="],
"@typescript-eslint/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/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="],
"@typescript-eslint/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/project-service": ["@typescript-eslint/project-service@8.56.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.1", "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ=="],
"@typescript-eslint/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/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.39.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ=="],
"@typescript-eslint/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/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.39.1", "", {}, "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="],
"@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/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.1", "@typescript-eslint/tsconfig-utils": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg=="],
"@typescript-eslint/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/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="],
"@typescript-eslint/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/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
@@ -278,31 +315,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=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
"@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="],
"@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="],
"@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
"@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="],
"@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
"@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="],
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="],
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"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=="],
"ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -322,8 +361,6 @@
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
@@ -332,14 +369,14 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"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=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@@ -348,11 +385,11 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="],
"chalk": ["chalk@5.5.0", "", {}, "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
@@ -368,11 +405,13 @@
"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=="],
@@ -382,8 +421,6 @@
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
@@ -394,6 +431,8 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
"emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
@@ -418,13 +457,15 @@
"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=="],
"esbuild": ["esbuild@0.25.9", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.9", "@esbuild/android-arm": "0.25.9", "@esbuild/android-arm64": "0.25.9", "@esbuild/android-x64": "0.25.9", "@esbuild/darwin-arm64": "0.25.9", "@esbuild/darwin-x64": "0.25.9", "@esbuild/freebsd-arm64": "0.25.9", "@esbuild/freebsd-x64": "0.25.9", "@esbuild/linux-arm": "0.25.9", "@esbuild/linux-arm64": "0.25.9", "@esbuild/linux-ia32": "0.25.9", "@esbuild/linux-loong64": "0.25.9", "@esbuild/linux-mips64el": "0.25.9", "@esbuild/linux-ppc64": "0.25.9", "@esbuild/linux-riscv64": "0.25.9", "@esbuild/linux-s390x": "0.25.9", "@esbuild/linux-x64": "0.25.9", "@esbuild/netbsd-arm64": "0.25.9", "@esbuild/netbsd-x64": "0.25.9", "@esbuild/openbsd-arm64": "0.25.9", "@esbuild/openbsd-x64": "0.25.9", "@esbuild/openharmony-arm64": "0.25.9", "@esbuild/sunos-x64": "0.25.9", "@esbuild/win32-arm64": "0.25.9", "@esbuild/win32-ia32": "0.25.9", "@esbuild/win32-x64": "0.25.9" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.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@9.39.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg=="],
"eslint-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=="],
@@ -436,11 +477,11 @@
"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@1.4.1", "", { "peerDependencies": { "eslint": ">=6.0.0 < 10.0.0" }, "bin": { "import-linter": "lib/index.js" } }, "sha512-C9PQEJ4jS5tKoE9k0yY/5j4l1bxkxmVjrWvAFc1EToCnuQuwZGl1kS1JBlqYNF8svp0pnXLlkSdjByYa3JALcg=="],
"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=="],
@@ -462,22 +503,16 @@
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"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-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=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
@@ -494,6 +529,8 @@
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"get-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=="],
@@ -514,8 +551,6 @@
"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=="],
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
@@ -530,6 +565,12 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"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=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -580,8 +621,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=="],
@@ -614,6 +653,8 @@
"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=="],
@@ -636,11 +677,11 @@
"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=="],
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"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.18", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="],
@@ -648,10 +689,6 @@
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
"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=="],
"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=="],
@@ -666,6 +703,8 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"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=="],
@@ -682,6 +721,8 @@
"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=="],
"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=="],
"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=="],
@@ -704,8 +745,6 @@
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -724,11 +763,9 @@
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
"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=="],
@@ -746,12 +783,8 @@
"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=="],
"rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"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=="],
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
@@ -786,7 +819,7 @@
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"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=="],
"slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -796,7 +829,7 @@
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
@@ -812,14 +845,12 @@
"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=="],
"strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="],
"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=="],
@@ -828,23 +859,17 @@
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"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=="],
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"tinyspy": ["tinyspy@4.0.3", "", {}, "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"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=="],
@@ -862,11 +887,11 @@
"typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
"typedoc": ["typedoc@0.28.12", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.12.0", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", "yaml": "^2.8.1" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-H5ODu4f7N+myG4MfuSp2Vh6wV+WLoZaEYxKPt2y8hmmqNEMVrH69DAjjdmYivF4tP/C2jrIZCZhPalZlTU/ipA=="],
"typedoc": ["typedoc@0.28.17", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.17.0", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", "yaml": "^2.8.1" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-ZkJ2G7mZrbxrKxinTQMjFqsCoYY6a5Luwv2GKbTnBCEgV2ihYm5CflA9JnJAwH0pZWavqfYxmDkFHPt4yx2oDQ=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"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-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="],
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
@@ -876,13 +901,13 @@
"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@7.1.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw=="],
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -904,23 +929,41 @@
"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=="],
"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=="],
"@typescript-eslint/eslint-plugin/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@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/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@typescript-eslint/project-service/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@typescript-eslint/type-utils/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@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=="],
@@ -930,36 +973,56 @@
"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-typescript/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"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-react-hooks/zod": ["zod@4.0.17", "", {}, "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="],
"gradient-string/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"ink-gradient/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"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=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"typedoc/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"eslint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"gradient-string/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"ink-gradient/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
"string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"typedoc/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
}
}
+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'
]
}
]);
]);
+8 -6
View File
@@ -1,6 +1,6 @@
{
"name": "ccstatusline",
"version": "2.1.3",
"version": "2.2.3",
"description": "A customizable status line formatter for Claude Code CLI",
"module": "src/ccstatusline.ts",
"type": "module",
@@ -16,7 +16,8 @@
"postbuild": "bun run scripts/replace-version.ts",
"example": "cat scripts/payload.example.json | bun start",
"prepublishOnly": "bun run build",
"lint": "bun tsc --noEmit; eslint . --config eslint.config.js --max-warnings=999999 --fix",
"lint": "bun tsc --noEmit && eslint . --config eslint.config.js --max-warnings=0",
"lint:fix": "bun tsc --noEmit && eslint . --config eslint.config.js --max-warnings=0 --fix",
"docs": "typedoc",
"docs:clean": "rm -rf docs"
},
@@ -32,19 +33,20 @@
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-import-newlines": "^1.4.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"ink": "^6.2.0",
"eslint-plugin-react-hooks": "^7.0.1",
"https-proxy-agent": "^7.0.0",
"ink": "6.2.0",
"ink-gradient": "^3.0.0",
"ink-select-input": "^6.2.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": "^3.2.4",
"vitest": "^4.0.18",
"zod": "^4.0.17"
},
"keywords": [
+126 -6
View File
@@ -2,18 +2,24 @@
import chalk from 'chalk';
import { runTUI } from './tui';
import type { TokenMetrics } from './types';
import type {
SkillsMetrics,
SpeedMetrics,
TokenMetrics
} from './types';
import type { RenderContext } from './types/RenderContext';
import type { StatusJSON } from './types/StatusJSON';
import { StatusJSONSchema } from './types/StatusJSON';
import { getVisibleText } from './utils/ansi';
import { updateColorMap } from './utils/colors';
import {
initConfigPath,
loadSettings,
saveSettings
} from './utils/config';
import {
getSessionDuration,
getSpeedMetricsCollection,
getTokenMetrics
} from './utils/jsonl';
import {
@@ -21,6 +27,16 @@ import {
preRenderAllWidgets,
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;
@@ -84,6 +100,17 @@ async function renderMultipleLines(data: StatusJSON) {
// Check if session clock is needed
const hasSessionClock = lines.some(line => line.some(item => item.type === 'session-clock'));
const speedWidgetTypes = new Set(['output-speed', 'input-speed', 'total-speed']);
const hasSpeedItems = lines.some(line => line.some(item => speedWidgetTypes.has(item.type)));
const requestedSpeedWindows = new Set<number>();
for (const line of lines) {
for (const item of line) {
if (speedWidgetTypes.has(item.type) && isWidgetSpeedWindowEnabled(item)) {
requestedSpeedWindows.add(getWidgetSpeedWindowSeconds(item));
}
}
}
let tokenMetrics: TokenMetrics | null = null;
if (data.transcript_path) {
tokenMetrics = await getTokenMetrics(data.transcript_path);
@@ -94,11 +121,34 @@ async function renderMultipleLines(data: StatusJSON) {
sessionDuration = await getSessionDuration(data.transcript_path);
}
const usageData = await prefetchUsageDataIfNeeded(lines);
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,
skillsMetrics,
isPreview: false
};
@@ -119,17 +169,14 @@ async function renderMultipleLines(data: StatusJSON) {
// Strip ANSI codes to check if there's actual text
const strippedLine = getVisibleText(line).trim();
if (strippedLine.length > 0) {
// Count separators used in this line (widgets - 1, excluding merged widgets)
const nonMergedWidgets = lineItems.filter((_, idx) => idx === lineItems.length - 1 || !lineItems[idx]?.merge);
if (nonMergedWidgets.length > 1)
globalSeparatorIndex += nonMergedWidgets.length - 1;
// 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);
}
}
}
@@ -164,7 +211,80 @@ 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_:-]+)/.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();
+117 -78
View File
@@ -22,9 +22,13 @@ import {
installStatusLine,
isBunxAvailable,
isInstalled,
isKnownCommand,
uninstallStatusLine
} from '../utils/claude-settings';
import { cloneSettings } from '../utils/clone-settings';
import {
getConfigPath,
isCustomConfigPath,
loadSettings,
saveSettings
} from '../utils/config';
@@ -48,7 +52,8 @@ import {
PowerlineSetup,
StatusLinePreview,
TerminalOptionsMenu,
TerminalWidthMenu
TerminalWidthMenu,
type MainMenuOption
} from './components';
const GITHUB_REPO_URL = 'https://github.com/sirmalloc/ccstatusline';
@@ -58,15 +63,47 @@ interface FlashMessage {
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 });
@@ -84,7 +121,7 @@ export const App: React.FC = () => {
// Set global chalk level based on settings (default to 256 colors for compatibility)
chalk.level = loadedSettings.colorLevel;
setSettings(loadedSettings);
setOriginalSettings(JSON.parse(JSON.stringify(loadedSettings)) as Settings); // Deep copy
setOriginalSettings(cloneSettings(loadedSettings));
});
void isInstalled().then(setIsClaudeInstalled);
@@ -133,7 +170,7 @@ 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);
setFlashMessage({
text: '✓ Configuration saved',
@@ -145,7 +182,7 @@ export const App: React.FC = () => {
const handleInstallSelection = useCallback((command: string, displayName: string, useBunx: boolean) => {
void getExistingStatusLine().then((existing) => {
const isAlreadyInstalled = [CCSTATUSLINE_COMMANDS.NPM, CCSTATUSLINE_COMMANDS.BUNX, CCSTATUSLINE_COMMANDS.SELF_MANAGED].includes(existing ?? '');
const isAlreadyInstalled = isKnownCommand(existing ?? '');
let message: string;
if (existing && !isAlreadyInstalled) {
@@ -158,6 +195,7 @@ export const App: React.FC = () => {
setConfirmDialog({
message,
cancelScreen: 'install',
action: async () => {
await installStatusLine(useBunx);
setIsClaudeInstalled(true);
@@ -171,13 +209,20 @@ export const App: React.FC = () => {
}, []);
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>;
}
@@ -202,58 +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 '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'
});
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('main');
setConfirmDialog(null);
return Promise.resolve();
}
});
setScreen('confirm');
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;
});
setScreen('confirm');
break;
case 'save':
await saveSettings(settings);
setOriginalSettings(cloneSettings(settings)); // Update original after save
setHasChanges(false);
exit();
break;
case 'exit':
exit();
break;
}
};
@@ -289,6 +334,9 @@ export const App: React.FC = () => {
</Text>
)}
</Box>
{isCustomConfigPath() && (
<Text dimColor>{`Config: ${getConfigPath()}`}</Text>
)}
<StatusLinePreview
lines={settings.lines}
@@ -300,20 +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,
starGithub: hasChanges ? 8 : 7
};
setMenuSelections({ ...menuSelections, main: menuMap[value] ?? 0 });
setMenuSelections(prev => ({ ...prev, main: index }));
}
void handleMainMenuSelect(value);
}}
isClaudeInstalled={isClaudeInstalled}
@@ -328,14 +368,14 @@ 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}
@@ -349,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}
@@ -361,13 +401,13 @@ export const App: React.FC = () => {
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}
@@ -405,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');
}
}}
@@ -430,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');
}}
/>
@@ -440,7 +480,7 @@ export const App: React.FC = () => {
message={confirmDialog.message}
onConfirm={() => void confirmDialog.action()}
onCancel={() => {
setScreen('main');
setScreen(getConfirmCancelScreen(confirmDialog));
setConfirmDialog(null);
}}
/>
@@ -451,9 +491,8 @@ export const App: React.FC = () => {
existingStatusLine={existingStatusLine}
onSelectNpx={handleNpxInstall}
onSelectBunx={handleBunxInstall}
onCancel={() => {
setScreen('main');
}}
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);
});
});
+19 -94
View File
@@ -19,6 +19,13 @@ 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[];
@@ -80,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);
@@ -126,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);
@@ -199,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);
}
}
@@ -213,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);
}
}
@@ -235,52 +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);
if (widgetImpl) {
defaultColor = widgetImpl.getDefaultColor();
}
}
let currentColor = widget.color ?? defaultColor;
// If color is 'dim', treat as if no color was set
if (currentColor === 'dim') {
currentColor = defaultColor;
}
let currentColorIndex = colors.indexOf(currentColor);
// If color not found, start from beginning
if (currentColorIndex === -1)
currentColorIndex = 0;
let nextColorIndex;
if (key.rightArrow) {
nextColorIndex = (currentColorIndex + 1) % colors.length;
} else {
nextColorIndex = currentColorIndex === 0 ? colors.length - 1 : currentColorIndex - 1;
}
const nextColor = colors[nextColorIndex];
return { ...widget, color: nextColor };
}
}
return widget;
const newItems = cycleWidgetColor({
widgets,
widgetId: selectedWidget.id,
direction: key.rightArrow ? 'right' : 'left',
editingBackground,
colors,
backgroundColors: bgColors
});
onUpdate(newItems);
}
@@ -430,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>
);
+50 -50
View File
@@ -3,16 +3,19 @@ import {
Text,
useInput
} from 'ink';
import React, { useState } from 'react';
import React from 'react';
import { getClaudeSettingsPath } from '../../utils/claude-settings';
import { List } from './List';
export interface InstallMenuProps {
bunxAvailable: boolean;
existingStatusLine: string | null;
onSelectNpx: () => void;
onSelectBunx: () => void;
onCancel: () => void;
initialSelection?: number;
}
export const InstallMenu: React.FC<InstallMenuProps> = ({
@@ -20,39 +23,44 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
existingStatusLine,
onSelectNpx,
onSelectBunx,
onCancel
onCancel,
initialSelection = 0
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const maxIndex = 2; // npx, bunx (if available), and back
useInput((input, key) => {
useInput((_, key) => {
if (key.escape) {
onCancel();
} else if (key.upArrow) {
if (selectedIndex === 2) {
setSelectedIndex(bunxAvailable ? 1 : 0); // Skip bunx if not available
} else {
setSelectedIndex(Math.max(0, selectedIndex - 1));
}
} else if (key.downArrow) {
if (selectedIndex === 0) {
setSelectedIndex(bunxAvailable ? 1 : 2); // Skip bunx if not available
} else if (selectedIndex === 1 && bunxAvailable) {
setSelectedIndex(2);
} else {
setSelectedIndex(Math.min(maxIndex, selectedIndex + 1));
}
} else if (key.return) {
if (selectedIndex === 0) {
onSelectNpx();
} else if (selectedIndex === 1 && bunxAvailable) {
onSelectBunx();
} else if (selectedIndex === 2) {
onCancel();
}
}
});
function onSelect(value: string) {
switch (value) {
case 'npx':
onSelectNpx();
break;
case 'bunx':
if (bunxAvailable) {
onSelectBunx();
}
break;
case 'back':
onCancel();
break;
}
}
const listItems = [
{
label: 'npx - Node Package Execute',
value: 'npx'
},
{
label: 'bunx - Bun Package Execute',
sublabel: bunxAvailable ? undefined : '(not installed)',
value: 'bunx',
disabled: !bunxAvailable
}
];
return (
<Box flexDirection='column'>
<Text bold>Install ccstatusline to Claude Code</Text>
@@ -71,29 +79,21 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
<Text dimColor>Select package manager to use:</Text>
</Box>
<Box marginTop={1} flexDirection='column'>
<Box>
<Text color={selectedIndex === 0 ? 'blue' : undefined}>
{selectedIndex === 0 ? '▶ ' : ' '}
npx - Node Package Execute
</Text>
</Box>
<List
color='blue'
marginTop={1}
items={listItems}
onSelect={(line) => {
if (line === 'back') {
onCancel();
return;
}
<Box>
<Text color={selectedIndex === 1 && bunxAvailable ? 'blue' : undefined} dimColor={!bunxAvailable}>
{selectedIndex === 1 && bunxAvailable ? '▶ ' : ' '}
bunx - Bun Package Execute
{!bunxAvailable && ' (not installed)'}
</Text>
</Box>
<Box marginTop={1}>
<Text color={selectedIndex === 2 ? 'blue' : undefined}>
{selectedIndex === 2 ? '▶ ' : ' '}
Back
</Text>
</Box>
</Box>
onSelect(line);
}}
initialSelection={initialSelection}
showBackButton={true}
/>
<Box marginTop={2}>
<Text dimColor>
+48 -326
View File
@@ -23,6 +23,16 @@ import {
} from '../../utils/widgets';
import { ConfirmDialog } from './ConfirmDialog';
import {
handleMoveInputMode,
handleNormalInputMode,
handlePickerInputMode,
normalizePickerState,
type CustomEditorWidgetState,
type WidgetPickerAction,
type WidgetPickerState
} from './items-editor/input-handlers';
import { shouldShowCustomKeybind } from './items-editor/keybind-visibility';
export interface ItemsEditorProps {
widgets: WidgetItem[];
@@ -32,22 +42,10 @@ export interface ItemsEditorProps {
settings: Settings;
}
type WidgetPickerAction = 'change' | 'add' | 'insert';
type WidgetPickerLevel = 'category' | 'widget';
interface WidgetPickerState {
action: WidgetPickerAction;
level: WidgetPickerLevel;
selectedCategory: string | null;
categoryQuery: string;
widgetQuery: string;
selectedType: WidgetItemType | null;
}
export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onBack, lineNumber, settings }) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [moveMode, setMoveMode] = useState(false);
const [customEditorWidget, setCustomEditorWidget] = useState<{ widget: WidgetItem; impl: Widget; action?: string } | null>(null);
const [customEditorWidget, setCustomEditorWidget] = useState<CustomEditorWidgetState | null>(null);
const [widgetPicker, setWidgetPicker] = useState<WidgetPickerState | null>(null);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const separatorChars = ['|', '-', ',', ' '];
@@ -97,41 +95,6 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
setCustomEditorWidget(null);
};
const getFilteredCategories = (query: string): string[] => {
void query;
return [...widgetCategories];
};
const normalizePickerState = (state: WidgetPickerState): WidgetPickerState => {
const filteredCategories = getFilteredCategories(state.categoryQuery);
const selectedCategory = state.selectedCategory && filteredCategories.includes(state.selectedCategory)
? state.selectedCategory
: (filteredCategories[0] ?? null);
const hasTopLevelSearch = state.level === 'category' && state.categoryQuery.trim().length > 0;
const effectiveCategory = hasTopLevelSearch ? 'All' : (selectedCategory ?? 'All');
const effectiveQuery = hasTopLevelSearch ? state.categoryQuery : state.widgetQuery;
const filteredWidgets = filterWidgetCatalog(widgetCatalog, effectiveCategory, effectiveQuery);
const hasSelectedType = state.selectedType
? filteredWidgets.some(entry => entry.type === state.selectedType)
: false;
return {
...state,
selectedCategory,
selectedType: hasSelectedType ? state.selectedType : (filteredWidgets[0]?.type ?? null)
};
};
const shouldShowCustomKeybind = (widget: WidgetItem, keybind: CustomKeybind): boolean => {
if (keybind.action !== 'toggle-invert') {
return true;
}
const mode = widget.metadata?.display;
return mode === 'progress' || mode === 'progress-short';
};
const getVisibleCustomKeybinds = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => {
if (!widgetImpl.getCustomKeybinds) {
return [];
@@ -155,7 +118,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
categoryQuery: '',
widgetQuery: '',
selectedType
}));
}, widgetCatalog, widgetCategories));
};
const applyWidgetPickerSelection = (selectedType: WidgetItemType) => {
@@ -201,286 +164,45 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
}
if (widgetPicker) {
const filteredCategories = getFilteredCategories(widgetPicker.categoryQuery);
const selectedCategory = widgetPicker.selectedCategory && filteredCategories.includes(widgetPicker.selectedCategory)
? widgetPicker.selectedCategory
: (filteredCategories[0] ?? null);
const hasTopLevelSearch = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0;
const topLevelSearchEntries = hasTopLevelSearch
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
: [];
const topLevelSelectedEntry = topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0];
const filteredWidgets = filterWidgetCatalog(widgetCatalog, selectedCategory ?? 'All', widgetPicker.widgetQuery);
const selectedEntry = filteredWidgets.find(entry => entry.type === widgetPicker.selectedType) ?? filteredWidgets[0];
if (widgetPicker.level === 'category') {
if (key.escape) {
if (widgetPicker.categoryQuery.length > 0) {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
categoryQuery: ''
}) : prev);
} else {
setWidgetPicker(null);
}
} else if (key.return) {
if (hasTopLevelSearch) {
if (topLevelSelectedEntry) {
applyWidgetPickerSelection(topLevelSelectedEntry.type);
}
} else if (selectedCategory) {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
level: 'widget',
selectedCategory
}) : prev);
}
} else if (key.upArrow || key.downArrow) {
if (hasTopLevelSearch) {
if (topLevelSearchEntries.length === 0) {
return;
}
let currentIndex = topLevelSearchEntries.findIndex(entry => entry.type === widgetPicker.selectedType);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? Math.min(topLevelSearchEntries.length - 1, currentIndex + 1)
: Math.max(0, currentIndex - 1);
const nextType = topLevelSearchEntries[nextIndex]?.type ?? null;
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
selectedType: nextType
}) : prev);
} else {
if (filteredCategories.length === 0) {
return;
}
let currentIndex = filteredCategories.findIndex(category => category === selectedCategory);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? Math.min(filteredCategories.length - 1, currentIndex + 1)
: Math.max(0, currentIndex - 1);
const nextCategory = filteredCategories[nextIndex] ?? null;
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
selectedCategory: nextCategory
}) : prev);
}
} else if (key.backspace || key.delete) {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
categoryQuery: prev.categoryQuery.slice(0, -1)
}) : prev);
} else if (
input
&& !key.ctrl
&& !key.meta
&& !key.tab
) {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
categoryQuery: prev.categoryQuery + input
}) : prev);
}
} else {
if (key.escape) {
if (widgetPicker.widgetQuery.length > 0) {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
widgetQuery: ''
}) : prev);
} else {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
level: 'category'
}) : prev);
}
} else if (key.return) {
if (selectedEntry) {
applyWidgetPickerSelection(selectedEntry.type);
}
} else if (key.upArrow || key.downArrow) {
if (filteredWidgets.length === 0) {
return;
}
let currentIndex = filteredWidgets.findIndex(entry => entry.type === widgetPicker.selectedType);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? Math.min(filteredWidgets.length - 1, currentIndex + 1)
: Math.max(0, currentIndex - 1);
const nextType = filteredWidgets[nextIndex]?.type ?? null;
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
selectedType: nextType
}) : prev);
} else if (key.backspace || key.delete) {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
widgetQuery: prev.widgetQuery.slice(0, -1)
}) : prev);
} else if (
input
&& !key.ctrl
&& !key.meta
&& !key.tab
) {
setWidgetPicker(prev => prev ? normalizePickerState({
...prev,
widgetQuery: prev.widgetQuery + input
}) : prev);
}
}
handlePickerInputMode({
input,
key,
widgetPicker,
widgetCatalog,
widgetCategories,
setWidgetPicker,
applyWidgetPickerSelection
});
return;
}
if (moveMode) {
// In move mode, use up/down to move the selected item
if (key.upArrow && selectedIndex > 0) {
const newWidgets = [...widgets];
const temp = newWidgets[selectedIndex];
const prev = newWidgets[selectedIndex - 1];
if (temp && prev) {
[newWidgets[selectedIndex], newWidgets[selectedIndex - 1]] = [prev, temp];
}
onUpdate(newWidgets);
setSelectedIndex(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < widgets.length - 1) {
const newWidgets = [...widgets];
const temp = newWidgets[selectedIndex];
const next = newWidgets[selectedIndex + 1];
if (temp && next) {
[newWidgets[selectedIndex], newWidgets[selectedIndex + 1]] = [next, temp];
}
onUpdate(newWidgets);
setSelectedIndex(selectedIndex + 1);
} else if (key.escape || key.return) {
// Exit move mode
setMoveMode(false);
}
} else {
// Normal mode
if (key.upArrow && widgets.length > 0) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow && widgets.length > 0) {
setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1));
} else if (key.leftArrow && widgets.length > 0) {
openWidgetPicker('change');
} else if (key.rightArrow && widgets.length > 0) {
openWidgetPicker('change');
} else if (key.return && widgets.length > 0) {
// Enter move mode
setMoveMode(true);
} else if (input === 'a') {
openWidgetPicker('add');
} else if (input === 'i') {
openWidgetPicker('insert');
} else if (input === 'd' && widgets.length > 0) {
// Delete selected item
const newWidgets = widgets.filter((_, i) => i !== selectedIndex);
onUpdate(newWidgets);
if (selectedIndex >= newWidgets.length && selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1);
}
} else if (input === 'c') {
if (widgets.length > 0) {
setShowClearConfirm(true);
}
} else if (input === ' ' && widgets.length > 0) {
// Space key - cycle separator character for separator types only (not flex)
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type === 'separator') {
const currentChar = currentWidget.character ?? '|';
const currentCharIndex = separatorChars.indexOf(currentChar);
const nextChar = separatorChars[(currentCharIndex + 1) % separatorChars.length];
const newWidgets = [...widgets];
newWidgets[selectedIndex] = { ...currentWidget, character: nextChar };
onUpdate(newWidgets);
}
} else if (input === 'r' && widgets.length > 0) {
// Toggle raw value for widgets that support it
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const widgetImpl = getWidget(currentWidget.type);
if (!widgetImpl?.supportsRawValue()) {
return;
}
const newWidgets = [...widgets];
newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue };
onUpdate(newWidgets);
}
} else if (input === 'm' && widgets.length > 0) {
// Cycle through merge states: undefined -> true -> 'no-padding' -> undefined
const currentWidget = widgets[selectedIndex];
// Don't allow merge on the last item or on separators
if (currentWidget && selectedIndex < widgets.length - 1
&& currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const newWidgets = [...widgets];
let nextMergeState: boolean | 'no-padding' | undefined;
if (currentWidget.merge === undefined) {
nextMergeState = true;
} else if (currentWidget.merge === true) {
nextMergeState = 'no-padding';
} else {
nextMergeState = undefined;
}
if (nextMergeState === undefined) {
const { merge, ...rest } = currentWidget;
void merge; // Intentionally unused
newWidgets[selectedIndex] = rest;
} else {
newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState };
}
onUpdate(newWidgets);
}
} else if (key.escape) {
onBack();
} else if (widgets.length > 0) {
// Check for custom widget keybinds
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const widgetImpl = getWidget(currentWidget.type);
if (widgetImpl) {
if (widgetImpl.getCustomKeybinds) {
const customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget);
const matchedKeybind = customKeybinds.find(kb => kb.key === input);
if (matchedKeybind && !key.ctrl) {
// Check if widget handles the action directly
if (widgetImpl.handleEditorAction) {
// Let the widget handle the action directly
const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget);
if (updatedWidget) {
const newWidgets = [...widgets];
newWidgets[selectedIndex] = updatedWidget;
onUpdate(newWidgets);
} else if (widgetImpl.renderEditor) {
// If handleEditorAction returned null, open the editor
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
}
} else if (widgetImpl.renderEditor) {
// Open the widget's custom editor with the action
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
}
}
}
}
}
}
handleMoveInputMode({
key,
widgets,
selectedIndex,
onUpdate,
setSelectedIndex,
setMoveMode
});
return;
}
handleNormalInputMode({
input,
key,
widgets,
selectedIndex,
separatorChars,
onBack,
onUpdate,
setSelectedIndex,
setMoveMode,
setShowClearConfirm,
openWidgetPicker,
getVisibleCustomKeybinds,
setCustomEditorWidget
});
});
const getWidgetDisplay = (widget: WidgetItem) => {
@@ -508,14 +230,14 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
const hasFlexSeparator = widgets.some(widget => widget.type === 'flex-separator');
const widthDetectionAvailable = canDetectTerminalWidth();
const pickerCategories = widgetPicker
? getFilteredCategories(widgetPicker.categoryQuery)
? [...widgetCategories]
: [];
const selectedPickerCategory = widgetPicker
? (widgetPicker.selectedCategory && pickerCategories.includes(widgetPicker.selectedCategory)
? widgetPicker.selectedCategory
: (pickerCategories[0] ?? null))
: null;
const topLevelSearchEntries = widgetPicker && widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0
const topLevelSearchEntries = widgetPicker?.level === 'category' && widgetPicker.categoryQuery.trim().length > 0
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
: [];
const selectedTopLevelSearchEntry = widgetPicker
+70 -59
View File
@@ -14,6 +14,7 @@ import type { Settings } from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import { ConfirmDialog } from './ConfirmDialog';
import { List } from './List';
interface LineSelectorProps {
lines: WidgetItem[][];
@@ -47,6 +48,10 @@ const LineSelector: React.FC<LineSelectorProps> = ({
setLocalLines(lines);
}, [lines]);
useEffect(() => {
setSelectedIndex(initialSelection);
}, [initialSelection]);
const selectedLine = useMemo(
() => localLines[selectedIndex],
[localLines, selectedIndex]
@@ -60,7 +65,7 @@ const LineSelector: React.FC<LineSelectorProps> = ({
};
const deleteLine = (lineIndex: number) => {
// Don't allow deleting the last remaining line
// Don't allow deleting the last remaining line
if (localLines.length <= 1) {
return;
}
@@ -119,35 +124,25 @@ const LineSelector: React.FC<LineSelectorProps> = ({
}
switch (input) {
case 'a':
if (allowEditing) {
appendLine();
}
return;
case 'd':
if (allowEditing && localLines.length > 1) {
setShowDeleteDialog(true);
}
return;
case 'm':
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
setMoveMode(true);
}
return;
case 'a':
if (allowEditing) {
appendLine();
}
return;
case 'd':
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
setShowDeleteDialog(true);
}
return;
case 'm':
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
setMoveMode(true);
}
return;
}
if (key.escape) {
onBack();
} else if (key.upArrow) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow) {
setSelectedIndex(Math.min(localLines.length, selectedIndex + 1));
} else if (key.return) {
if (selectedIndex === localLines.length) {
onBack();
} else {
onSelect(selectedIndex);
}
}
});
@@ -197,7 +192,6 @@ const LineSelector: React.FC<LineSelectorProps> = ({
<Text>
<Text>
Line
{' '}
{selectedIndex + 1}
</Text>
{' '}
@@ -228,6 +222,12 @@ const LineSelector: React.FC<LineSelectorProps> = ({
);
}
const lineItems = localLines.map((line, index) => ({
label: `☰ Line ${index + 1}`,
sublabel: `(${line.length > 0 ? pluralize('widget', line.length, true) : 'empty'})`,
value: index
}));
return (
<>
<Box flexDirection='column'>
@@ -253,44 +253,55 @@ const LineSelector: React.FC<LineSelectorProps> = ({
</Text>
)}
<Box marginTop={1} flexDirection='column'>
{localLines.map((line, index) => {
const isSelected = selectedIndex === index;
const suffix = line.length
? pluralize('widget', line.length, true)
: 'empty';
{moveMode ? (
<Box marginTop={1} flexDirection='column'>
{localLines.map((line, index) => {
const isSelected = selectedIndex === index;
const suffix = line.length
? pluralize('widget', line.length, true)
: 'empty';
return (
<Box key={index}>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
<Text>{isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '}</Text>
<Text>
return (
<Box key={index}>
<Text color={isSelected ? 'blue' : undefined}>
<Text>{isSelected ? '◆ ' : ' '}</Text>
<Text>
Line
<Text>
Line
{' '}
{index + 1}
</Text>
{' '}
{index + 1}
</Text>
{' '}
<Text dimColor={!isSelected}>
(
{suffix}
)
<Text dimColor={!isSelected}>
(
{suffix}
)
</Text>
</Text>
</Text>
</Text>
</Box>
);
})}
</Box>
);
})}
</Box>
) : (
<List
marginTop={1}
items={lineItems}
onSelect={(line) => {
if (line === 'back') {
onBack();
return;
}
{!moveMode && (
<Box marginTop={1}>
<Text color={selectedIndex === localLines.length ? 'green' : undefined}>
{selectedIndex === localLines.length ? '▶ ' : ' '}
Back
</Text>
</Box>
)}
</Box>
onSelect(line);
}}
onSelectionChange={(_, index) => {
setSelectedIndex(index);
}}
initialSelection={selectedIndex}
showBackButton={true}
/>
)}
</Box>
</>
);
+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>;
}
+116 -88
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,110 +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: '', value: '_gap3', selectable: false }, // Visual gap
{ label: '⭐ Like ccstatusline? Star us on GitHub', value: 'starGithub', selectable: true }
{
label: '💾 Save & Exit',
value: 'save',
description: 'Save all changes and exit the configuration tool'
},
{
label: '❌ Exit without saving',
value: 'exit',
description: 'Exit without saving your changes'
},
'-' as const,
{
label: '⭐ Like ccstatusline? Star us on GitHub',
value: 'starGithub',
description: 'Open the ccstatusline GitHub repository in your browser so you can star the project'
}
);
} else {
menuItems.push(
{ label: '🚪 Exit', value: 'exit', selectable: true },
{ label: '', value: '_gap3', selectable: false }, // Visual gap
{ label: '⭐ Like ccstatusline? Star us on GitHub', value: 'starGithub', selectable: true }
{
label: '🚪 Exit',
value: 'exit',
description: 'Exit the configuration tool'
},
'-' as const,
{
label: '⭐ Like ccstatusline? Star us on GitHub',
value: 'starGithub',
description: 'Open the ccstatusline GitHub repository in your browser so you can star the project'
}
);
}
// Get only selectable items for navigation
const selectableItems = menuItems.filter(item => item.selectable);
useInput((input, key) => {
if (key.upArrow) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow) {
setSelectedIndex(Math.min(selectableItems.length - 1, selectedIndex + 1));
} else if (key.return) {
const item = selectableItems[selectedIndex];
if (item) {
onSelect(item.value);
}
}
});
// Get description for selected item
const getDescription = (value: string): string => {
const descriptions: Record<string, string> = {
lines: 'Configure any number of status lines with various widgets like model info, git status, and token usage',
colors: 'Customize colors for each widget including foreground, background, and bold styling',
powerline: 'Install Powerline fonts for enhanced visual separators and symbols in your status line',
globalOverrides: 'Set global padding, separators, and color overrides that apply to all widgets',
install: isClaudeInstalled
? 'Remove ccstatusline from your Claude Code settings'
: 'Add ccstatusline to your Claude Code settings for automatic status line rendering',
terminalConfig: 'Configure terminal-specific settings for optimal display',
starGithub: 'Open the ccstatusline GitHub repository in your browser so you can star the project',
save: 'Save all changes and exit the configuration tool',
exit: hasChanges
? 'Exit without saving your changes'
: 'Exit the configuration tool'
};
return descriptions[value] ?? '';
};
const selectedItem = selectableItems[selectedIndex];
const description = selectedItem ? getDescription(selectedItem.value) : '';
// Check if we should show the truncation warning
const showTruncationWarning = previewIsTruncated && settings?.flexMode === 'full-minus-40';
const showTruncationWarning
= previewIsTruncated && settings?.flexMode === 'full-minus-40';
return (
<Box flexDirection='column'>
{showTruncationWarning && (
<Box marginBottom={1}>
<Text color='yellow'> Some lines are truncated, see Terminal Options Terminal Width for info</Text>
<Text color='yellow'>
Some lines are truncated, see Terminal Options Terminal Width
for info
</Text>
</Box>
)}
<Text bold>Main Menu</Text>
<Box marginTop={1} flexDirection='column'>
{menuItems.map((item, idx) => {
if (!item.selectable && item.value.startsWith('_gap')) {
return <Text key={item.value}> </Text>;
}
const selectableIdx = selectableItems.indexOf(item);
const isSelected = selectableIdx === selectedIndex;
return (
<Text
key={item.value}
color={isSelected ? 'green' : undefined}
>
{isSelected ? '▶ ' : ' '}
{item.label}
</Text>
);
})}
</Box>
{description && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor wrap='wrap'>{description}</Text>
</Box>
)}
<Text bold>Main Menu</Text>
<List
items={menuItems}
marginTop={1}
onSelect={(value, index) => {
if (value === 'back') {
return;
}
onSelect(value, index);
}}
initialSelection={initialSelection}
/>
</Box>
);
};
+46 -41
View File
@@ -28,12 +28,12 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
// Get the appropriate array based on mode
const getItems = () => {
switch (mode) {
case 'separator':
return powerlineConfig.separators;
case 'startCap':
return powerlineConfig.startCaps;
case 'endCap':
return powerlineConfig.endCaps;
case 'separator':
return powerlineConfig.separators;
case 'startCap':
return powerlineConfig.startCaps;
case 'endCap':
return powerlineConfig.endCaps;
}
};
@@ -83,24 +83,25 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
const inversionText = mode === 'separator' && invertBg ? ' [Inverted]' : '';
return `${preset.char} - ${preset.name}${inversionText}`;
}
const hexCode = char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0');
return `${char} - Custom (\\u${hexCode})${invertBg ? ' [Inverted]' : ''}`;
const codePoint = char.codePointAt(0) ?? 0;
const hexCode = codePoint.toString(16).toUpperCase().padStart(4, '0');
return `${char} - Custom (U+${hexCode})${invertBg ? ' [Inverted]' : ''}`;
};
const updateSeparators = (newSeparators: string[], newInvertBgs?: boolean[]) => {
const updatedPowerline = { ...powerlineConfig };
switch (mode) {
case 'separator':
updatedPowerline.separators = newSeparators;
updatedPowerline.separatorInvertBackground = newInvertBgs ?? newSeparators.map((_, i) => invertBgs[i] ?? false);
break;
case 'startCap':
updatedPowerline.startCaps = newSeparators;
break;
case 'endCap':
updatedPowerline.endCaps = newSeparators;
break;
case 'separator':
updatedPowerline.separators = newSeparators;
updatedPowerline.separatorInvertBackground = newInvertBgs ?? newSeparators.map((_, i) => invertBgs[i] ?? false);
break;
case 'startCap':
updatedPowerline.startCaps = newSeparators;
break;
case 'endCap':
updatedPowerline.endCaps = newSeparators;
break;
}
onUpdate({
@@ -117,24 +118,27 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
setHexInput('');
setCursorPos(0);
} else if (key.return) {
if (hexInput.length === 4) {
const char = String.fromCharCode(parseInt(hexInput, 16));
const newSeparators = [...separators];
if (separators.length === 0) {
// Add new item if list is empty
newSeparators.push(char);
} else {
newSeparators[selectedIndex] = char;
if (hexInput.length >= 4 && hexInput.length <= 6) {
const codePoint = parseInt(hexInput, 16);
if (codePoint >= 0 && codePoint <= 0x10FFFF) {
const char = String.fromCodePoint(codePoint);
const newSeparators = [...separators];
if (separators.length === 0) {
// Add new item if list is empty
newSeparators.push(char);
} else {
newSeparators[selectedIndex] = char;
}
updateSeparators(newSeparators);
setHexInputMode(false);
setHexInput('');
setCursorPos(0);
}
updateSeparators(newSeparators);
setHexInputMode(false);
setHexInput('');
setCursorPos(0);
}
} else if (key.backspace && cursorPos > 0) {
setHexInput(hexInput.slice(0, cursorPos - 1) + hexInput.slice(cursorPos));
setCursorPos(cursorPos - 1);
} else if (shouldInsertInput(input, key) && /[0-9a-fA-F]/.test(input) && hexInput.length < 4) {
} else if (shouldInsertInput(input, key) && /[0-9a-fA-F]/.test(input) && hexInput.length < 6) {
setHexInput(hexInput.slice(0, cursorPos) + input.toUpperCase() + hexInput.slice(cursorPos));
setCursorPos(cursorPos + 1);
}
@@ -257,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';
}
};
@@ -276,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>
) : (
<>
+166 -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,55 @@ 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
}
});
}
}
});
// Render sub-screens
if (screen === 'separator') {
return (
<PowerlineSeparatorEditor
@@ -235,7 +258,6 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
);
}
// Main menu screen
return (
<Box flexDirection='column'>
{!confirmingFontInstall && !installingFonts && !fontInstallMessage && (
@@ -324,28 +346,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={() => {
@@ -412,62 +413,29 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
</>
)}
<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>
+4 -11
View File
@@ -15,7 +15,7 @@ import {
type PreRenderedWidget,
type RenderResult
} from '../../utils/renderer';
import { canDetectTerminalWidth } from '../../utils/terminal';
import { advanceGlobalSeparatorIndex } from '../../utils/separator-index';
export interface StatusLinePreviewProps {
lines: WidgetItem[][];
@@ -27,7 +27,6 @@ export interface StatusLinePreviewProps {
const renderSingleLine = (
widgets: WidgetItem[],
terminalWidth: number,
widthDetectionAvailable: boolean,
settings: Settings,
lineIndex: number,
globalSeparatorIndex: number,
@@ -46,8 +45,6 @@ const renderSingleLine = (
};
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(() => {
@@ -66,22 +63,18 @@ 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, 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);
}
}
return { renderedLines: result, anyTruncated: truncated };
}, [lines, terminalWidth, widthDetectionAvailable, settings]);
}, [lines, terminalWidth, settings]);
// Notify parent when truncation status changes
React.useEffect(() => {
+97 -161
View File
@@ -7,10 +7,56 @@ import {
import React, { useState } from 'react';
import type { Settings } from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import { getWidget } from '../../utils/widgets';
import {
hasCustomWidgetColors,
sanitizeLinesForColorLevel
} from '../../utils/color-sanitize';
import { ConfirmDialog } from './ConfirmDialog';
import {
List,
type ListEntry
} from './List';
type TerminalOptionsValue = 'width' | 'colorLevel';
export function getNextColorLevel(level: 0 | 1 | 2 | 3): 0 | 1 | 2 | 3 {
return ((level + 1) % 4) as 0 | 1 | 2 | 3;
}
export function shouldWarnOnColorLevelChange(
currentLevel: 0 | 1 | 2 | 3,
nextLevel: 0 | 1 | 2 | 3,
hasCustomColors: boolean
): boolean {
return hasCustomColors
&& ((currentLevel === 2 && nextLevel !== 2)
|| (currentLevel === 3 && nextLevel !== 3));
}
export function buildTerminalOptionsItems(
colorLevel: 0 | 1 | 2 | 3
): ListEntry<TerminalOptionsValue>[] {
return [
{
label: '◱ Terminal Width',
value: 'width',
description: 'Configure how the status line uses available terminal width and when it should compact.'
},
{
label: '▓ Color Level',
sublabel: `(${getColorLevelLabel(colorLevel)})`,
value: 'colorLevel',
description: [
'Color level affects how colors are rendered:',
'• Truecolor: Full 24-bit RGB colors (16.7M colors)',
'• 256 Color: Extended color palette (256 colors)',
'• Basic: Standard 16-color terminal palette',
'• No Color: Disables all color output'
].join('\n')
}
];
}
export interface TerminalOptionsMenuProps {
settings: Settings;
@@ -18,124 +64,51 @@ export interface TerminalOptionsMenuProps {
onBack: (target?: string) => void;
}
export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settings, onUpdate, onBack }) => {
export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({
settings,
onUpdate,
onBack
}) => {
const [showColorWarning, setShowColorWarning] = useState(false);
const [pendingColorLevel, setPendingColorLevel] = useState<0 | 1 | 2 | 3 | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const handleSelect = () => {
if (selectedIndex === 2) {
// Back button
const handleSelect = (value: TerminalOptionsValue | 'back') => {
if (value === 'back') {
onBack();
} else if (selectedIndex === 0) {
// Terminal Width Options
onBack('width');
} else if (selectedIndex === 1) {
// Color Level
// Check if there are any custom colors that would be lost
const hasCustomColors = settings.lines.some((line: WidgetItem[]) => line.some((widget: WidgetItem) => Boolean(widget.color && (widget.color.startsWith('ansi256:') || widget.color.startsWith('hex:')))
|| Boolean(widget.backgroundColor && (widget.backgroundColor.startsWith('ansi256:') || widget.backgroundColor.startsWith('hex:')))
)
);
const currentLevel = settings.colorLevel;
const nextLevel = ((currentLevel + 1) % 4) as 0 | 1 | 2 | 3;
// Warn if switching away from mode that supports custom colors
if (hasCustomColors
&& ((currentLevel === 2 && nextLevel !== 2) // Switching from 256 color mode
|| (currentLevel === 3 && nextLevel !== 3))) { // Switching from truecolor mode
setShowColorWarning(true);
setPendingColorLevel(nextLevel);
} else {
// Update chalk level immediately
chalk.level = nextLevel;
// Clean up incompatible custom colors even when no warning is shown
const cleanedLines = settings.lines.map(line => line.map((widget) => {
const newWidget = { ...widget };
// Remove custom colors incompatible with the new mode
if (nextLevel === 2) {
// Switching to 256 color mode - remove hex colors
if (widget.color?.startsWith('hex:')) {
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
if (widgetImpl) {
newWidget.color = widgetImpl.getDefaultColor();
}
}
}
if (widget.backgroundColor?.startsWith('hex:')) {
newWidget.backgroundColor = undefined;
}
} else if (nextLevel === 3) {
// Switching to truecolor mode - remove ansi256 colors
if (widget.color?.startsWith('ansi256:')) {
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
if (widgetImpl) {
newWidget.color = widgetImpl.getDefaultColor();
}
}
}
if (widget.backgroundColor?.startsWith('ansi256:')) {
newWidget.backgroundColor = undefined;
}
} else {
// Switching to 16 color mode - remove all custom colors
if (widget.color?.startsWith('ansi256:') || widget.color?.startsWith('hex:')) {
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
if (widgetImpl) {
newWidget.color = widgetImpl.getDefaultColor();
}
}
}
if (widget.backgroundColor?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('hex:')) {
newWidget.backgroundColor = undefined;
}
}
return newWidget;
})
);
onUpdate({
...settings,
lines: cleanedLines,
colorLevel: nextLevel
});
}
return;
}
if (value === 'width') {
onBack('width');
return;
}
const hasCustomColors = hasCustomWidgetColors(settings.lines);
const currentLevel = settings.colorLevel;
const nextLevel = getNextColorLevel(currentLevel);
if (shouldWarnOnColorLevelChange(currentLevel, nextLevel, hasCustomColors)) {
setShowColorWarning(true);
setPendingColorLevel(nextLevel);
return;
}
chalk.level = nextLevel;
const cleanedLines = sanitizeLinesForColorLevel(settings.lines, nextLevel);
onUpdate({
...settings,
lines: cleanedLines,
colorLevel: nextLevel
});
};
const handleColorConfirm = () => {
// Proceed with color level change and clean up custom colors
if (pendingColorLevel !== null) {
chalk.level = pendingColorLevel;
// Clean up custom colors if switching away from modes that support them
const cleanedLines = settings.lines.map(line => line.map((widget) => {
const newWidget = { ...widget };
// Remove custom colors if switching to a mode that doesn't support them
if ((pendingColorLevel !== 2 && pendingColorLevel !== 3)
|| (pendingColorLevel === 2 && (widget.color?.startsWith('hex:') || widget.backgroundColor?.startsWith('hex:')))
|| (pendingColorLevel === 3 && (widget.color?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('ansi256:')))) {
// Reset custom colors to defaults
if (widget.color?.startsWith('ansi256:') || widget.color?.startsWith('hex:')) {
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
if (widgetImpl) {
newWidget.color = widgetImpl.getDefaultColor();
}
}
}
if (widget.backgroundColor?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('hex:')) {
newWidget.backgroundColor = undefined;
}
}
return newWidget;
})
);
const cleanedLines = sanitizeLinesForColorLevel(settings.lines, pendingColorLevel);
onUpdate({
...settings,
@@ -152,19 +125,9 @@ export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settin
setPendingColorLevel(null);
};
useInput((input, key) => {
if (key.escape) {
if (!showColorWarning) {
onBack();
}
} else if (!showColorWarning) {
if (key.upArrow) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow) {
setSelectedIndex(Math.min(2, selectedIndex + 1));
} else if (key.return) {
handleSelect();
}
useInput((_, key) => {
if (key.escape && !showColorWarning) {
onBack();
}
});
@@ -187,39 +150,12 @@ export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settin
) : (
<>
<Text color='white'>Configure terminal-specific settings for optimal display</Text>
<Box marginTop={1} flexDirection='column'>
<Box>
<Text color={selectedIndex === 0 ? 'green' : undefined}>
{selectedIndex === 0 ? '▶ ' : ' '}
Terminal Width
</Text>
</Box>
<Box>
<Text color={selectedIndex === 1 ? 'green' : undefined}>
{selectedIndex === 1 ? '▶ ' : ' '}
Color Level:
{' '}
{getColorLevelLabel(settings.colorLevel)}
</Text>
</Box>
<Box marginTop={1}>
<Text color={selectedIndex === 2 ? 'green' : undefined}>
{selectedIndex === 2 ? '▶ ' : ' '}
Back
</Text>
</Box>
</Box>
{selectedIndex === 1 && (
<Box marginTop={1} flexDirection='column'>
<Text dimColor>Color level affects how colors are rendered:</Text>
<Text dimColor> Truecolor: Full 24-bit RGB colors (16.7M colors)</Text>
<Text dimColor> 256 Color: Extended color palette (256 colors)</Text>
<Text dimColor> Basic: Standard 16-color terminal palette</Text>
<Text dimColor> No Color: Disables all color output</Text>
</Box>
)}
<List
marginTop={1}
items={buildTerminalOptionsItems(settings.colorLevel)}
onSelect={handleSelect}
showBackButton={true}
/>
</>
)}
</Box>
@@ -228,11 +164,11 @@ export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settin
export const getColorLevelLabel = (level?: 0 | 1 | 2 | 3): string => {
switch (level) {
case 0: return 'No Color';
case 1: return 'Basic';
case 2:
case undefined: return '256 Color (default)';
case 3: return 'Truecolor';
default: return '256 Color (default)';
case 0: return 'No Color';
case 1: return 'Basic';
case 2:
case undefined: return '256 Color (default)';
case 3: return 'Truecolor';
default: return '256 Color (default)';
}
};
+93 -94
View File
@@ -9,38 +9,89 @@ import type { FlexMode } from '../../types/FlexMode';
import type { Settings } from '../../types/Settings';
import { shouldInsertInput } from '../../utils/input-guards';
import {
List,
type ListEntry
} from './List';
export const TERMINAL_WIDTH_OPTIONS: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact'];
export function getTerminalWidthSelectionIndex(selectedOption: FlexMode): number {
const selectedIndex = TERMINAL_WIDTH_OPTIONS.indexOf(selectedOption);
return selectedIndex >= 0 ? selectedIndex : 0;
}
export function validateCompactThresholdInput(value: string): string | null {
const parsedValue = parseInt(value, 10);
if (isNaN(parsedValue)) {
return 'Please enter a valid number';
}
if (parsedValue < 1 || parsedValue > 99) {
return `Value must be between 1 and 99 (you entered ${parsedValue})`;
}
return null;
}
export function buildTerminalWidthItems(
selectedOption: FlexMode,
compactThreshold: number
): ListEntry<FlexMode>[] {
return [
{
value: 'full',
label: 'Full width always',
sublabel: selectedOption === 'full' ? '(active)' : undefined,
description: 'Uses the full terminal width minus 4 characters for terminal padding. If the auto-compact message appears, it may cause the line to wrap.\n\nNOTE: If /ide integration is enabled, it is not recommended to use this mode.'
},
{
value: 'full-minus-40',
label: 'Full width minus 40',
sublabel: selectedOption === 'full-minus-40' ? '(active)' : '(default)',
description: 'Leaves a gap to the right of the status line to accommodate the auto-compact message. This prevents wrapping but may leave unused space. This limitation exists because we cannot detect when the message will appear.'
},
{
value: 'full-until-compact',
label: 'Full width until compact',
sublabel: selectedOption === 'full-until-compact'
? `(threshold ${compactThreshold}%, active)`
: `(threshold ${compactThreshold}%)`,
description: `Dynamically adjusts width based on context usage. When context reaches ${compactThreshold}%, it switches to leaving space for the auto-compact message.\n\nNOTE: If /ide integration is enabled, it is not recommended to use this mode.`
}
];
}
export interface TerminalWidthMenuProps {
settings: Settings;
onUpdate: (settings: Settings) => void;
onBack: () => void;
}
export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({ settings, onUpdate, onBack }) => {
export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({
settings,
onUpdate,
onBack
}) => {
const [selectedOption, setSelectedOption] = useState<FlexMode>(settings.flexMode);
const [compactThreshold, setCompactThreshold] = useState(settings.compactThreshold);
const [editingThreshold, setEditingThreshold] = useState(false);
const [thresholdInput, setThresholdInput] = useState(String(settings.compactThreshold));
const [validationError, setValidationError] = useState<string | null>(null);
// For manual navigation: 0-2 for options, 3 for back
const [selectedIndex, setSelectedIndex] = useState(() => {
const options: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact'];
return options.indexOf(settings.flexMode);
});
const options: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact'];
useInput((input, key) => {
if (editingThreshold) {
if (key.return) {
const value = parseInt(thresholdInput, 10);
if (isNaN(value)) {
setValidationError('Please enter a valid number');
} else if (value < 1 || value > 99) {
setValidationError(`Value must be between 1 and 99 (you entered ${value})`);
const error = validateCompactThresholdInput(thresholdInput);
if (error) {
setValidationError(error);
} else {
const value = parseInt(thresholdInput, 10);
setCompactThreshold(value);
// Update settings with both flexMode and the new threshold
const updatedSettings = {
...settings,
flexMode: selectedOption,
@@ -66,59 +117,14 @@ export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({ settings,
setValidationError(null);
}
}
} else {
if (key.escape) {
onBack();
} else if (key.upArrow) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow) {
setSelectedIndex(Math.min(3, selectedIndex + 1)); // 0-2 for options, 3 for back
} else if (key.return) {
if (selectedIndex === 3) {
onBack();
} else if (selectedIndex >= 0 && selectedIndex < options.length) {
const mode = options[selectedIndex];
if (mode) {
setSelectedOption(mode);
return;
}
// Update settings
const updatedSettings = {
...settings,
flexMode: mode,
compactThreshold: compactThreshold
};
onUpdate(updatedSettings);
if (mode === 'full-until-compact') {
// Prompt for threshold editing
setEditingThreshold(true);
}
}
}
}
if (key.escape) {
onBack();
}
});
const optionDetails = [
{
value: 'full' as FlexMode,
label: 'Full width always',
description: 'Uses the full terminal width minus 4 characters for terminal padding. If the auto-compact message appears, it may cause the line to wrap.\n\nNOTE: If /ide integration is enabled, it\'s not recommended to use this mode.'
},
{
value: 'full-minus-40' as FlexMode,
label: 'Full width minus 40 (default)',
description: 'Leaves a gap to the right of the status line to accommodate the auto-compact message. This prevents wrapping but may leave unused space. This limitation exists because we cannot detect when the message will appear.'
},
{
value: 'full-until-compact' as FlexMode,
label: 'Full width until compact',
description: `Dynamically adjusts width based on context usage. When context reaches ${compactThreshold}%, it switches to leaving space for the auto-compact message.\n\nNOTE: If /ide integration is enabled, it's not recommended to use this mode.`
}
];
const currentOption = selectedIndex < 3 ? optionDetails[selectedIndex] : null;
return (
<Box flexDirection='column'>
<Text bold>Terminal Width</Text>
@@ -140,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,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,67 @@
import {
describe,
expect,
it
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../../types/Settings';
import {
buildPowerlineSetupMenuItems,
getCapDisplay,
getSeparatorDisplay,
getThemeDisplay
} from '../PowerlineSetup';
describe('PowerlineSetup helpers', () => {
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)'
});
});
});
@@ -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,285 @@
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('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(),
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
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(),
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
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(),
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
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(),
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
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(),
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
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,44 @@
import {
describe,
expect,
it
} from 'vitest';
import type {
CustomKeybind,
WidgetItem
} from '../../../../types/Widget';
import { shouldShowCustomKeybind } from '../keybind-visibility';
const TOGGLE_COMPACT_KEYBIND: CustomKeybind = { key: 's', label: '(s)hort time', action: 'toggle-compact' };
const TOGGLE_INVERT_KEYBIND: CustomKeybind = { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' };
const EDIT_LIST_LIMIT_KEYBIND: CustomKeybind = { key: 'l', label: '(l)imit list', action: 'edit-list-limit' };
function createWidget(type: string, metadata?: Record<string, string>): WidgetItem {
return {
id: 'widget',
type,
metadata
};
}
describe('shouldShowCustomKeybind', () => {
it('shows invert only in progress modes', () => {
expect(shouldShowCustomKeybind(createWidget('block-timer'), TOGGLE_INVERT_KEYBIND)).toBe(false);
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress' }), TOGGLE_INVERT_KEYBIND)).toBe(true);
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress-short' }), TOGGLE_INVERT_KEYBIND)).toBe(true);
});
it('hides short time in progress modes', () => {
expect(shouldShowCustomKeybind(createWidget('block-timer'), TOGGLE_COMPACT_KEYBIND)).toBe(true);
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'time' }), TOGGLE_COMPACT_KEYBIND)).toBe(true);
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress' }), TOGGLE_COMPACT_KEYBIND)).toBe(false);
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress-short' }), TOGGLE_COMPACT_KEYBIND)).toBe(false);
});
it('shows list limit only for skills list mode', () => {
expect(shouldShowCustomKeybind(createWidget('skills'), EDIT_LIST_LIMIT_KEYBIND)).toBe(false);
expect(shouldShowCustomKeybind(createWidget('skills', { mode: 'count' }), EDIT_LIST_LIMIT_KEYBIND)).toBe(false);
expect(shouldShowCustomKeybind(createWidget('skills', { mode: 'list' }), EDIT_LIST_LIMIT_KEYBIND)).toBe(true);
});
});
@@ -0,0 +1,458 @@
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)
}));
} else if (
input
&& !key.ctrl
&& !key.meta
&& !key.tab
) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
categoryQuery: prev.categoryQuery + input
}));
}
} 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)
}));
} else if (
input
&& !key.ctrl
&& !key.meta
&& !key.tab
) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
widgetQuery: prev.widgetQuery + input
}));
}
}
}
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;
getVisibleCustomKeybinds: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[];
setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void;
}
export function handleNormalInputMode({
input,
key,
widgets,
selectedIndex,
separatorChars,
onBack,
onUpdate,
setSelectedIndex,
setMoveMode,
setShowClearConfirm,
openWidgetPicker,
getVisibleCustomKeybinds,
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 = getVisibleCustomKeybinds(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 });
}
}
}
}
}
@@ -0,0 +1,25 @@
import type {
CustomKeybind,
WidgetItem
} from '../../../types/Widget';
function isProgressMode(widget: WidgetItem): boolean {
const mode = widget.metadata?.display;
return mode === 'progress' || mode === 'progress-short';
}
export function shouldShowCustomKeybind(widget: WidgetItem, keybind: CustomKeybind): boolean {
if (keybind.action === 'edit-list-limit') {
return widget.type === 'skills' && widget.metadata?.mode === 'list';
}
if (keybind.action === 'toggle-invert') {
return isProgressMode(widget);
}
if (keybind.action === 'toggle-compact') {
return !isProgressMode(widget);
}
return true;
}
+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';
}
}
+21 -1
View File
@@ -1,13 +1,33 @@
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;
lineIndex?: number; // Index of the current line being rendered (for theme cycling)
+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;
}
+29 -15
View File
@@ -1,5 +1,19 @@
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());
export const StatusJSONSchema = z.looseObject({
hook_event_name: z.string().optional(),
session_id: z.string().optional(),
@@ -19,27 +33,27 @@ 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()
total_cost_usd: CoercedNumberSchema.optional(),
total_duration_ms: CoercedNumberSchema.optional(),
total_api_duration_ms: CoercedNumberSchema.optional(),
total_lines_added: CoercedNumberSchema.optional(),
total_lines_removed: CoercedNumberSchema.optional()
}).optional(),
context_window: z.object({
context_window_size: z.number().nullable().optional(),
total_input_tokens: z.number().nullable().optional(),
total_output_tokens: z.number().nullable().optional(),
context_window_size: CoercedNumberSchema.nullable().optional(),
total_input_tokens: CoercedNumberSchema.nullable().optional(),
total_output_tokens: CoercedNumberSchema.nullable().optional(),
current_usage: z.union([
z.number(),
CoercedNumberSchema,
z.object({
input_tokens: z.number().optional(),
output_tokens: z.number().optional(),
cache_creation_input_tokens: z.number().optional(),
cache_read_input_tokens: z.number().optional()
input_tokens: CoercedNumberSchema.optional(),
output_tokens: CoercedNumberSchema.optional(),
cache_creation_input_tokens: CoercedNumberSchema.optional(),
cache_read_input_tokens: CoercedNumberSchema.optional()
})
]).nullable().optional(),
used_percentage: z.number().nullable().optional(),
remaining_percentage: z.number().nullable().optional()
used_percentage: CoercedNumberSchema.nullable().optional(),
remaining_percentage: CoercedNumberSchema.nullable().optional()
}).nullable().optional()
});
+1
View File
@@ -10,6 +10,7 @@ export interface TranscriptLine {
isSidechain?: boolean;
timestamp?: string;
isApiErrorMessage?: boolean;
type?: 'user' | 'assistant' | 'system' | 'progress' | 'file-history-snapshot';
}
export interface TokenMetrics {
+61
View File
@@ -0,0 +1,61 @@
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);
});
});
+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';
+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();
});
});
@@ -102,6 +102,59 @@ describe('calculateContextPercentage', () => {
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(100);
});
it('should calculate percentage using 1M denominator with 1M context label', () => {
const context: RenderContext = {
data: { model: { id: 'Opus 4.6 (1M context)' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
it('should calculate percentage using 1M denominator with 1M in parentheses', () => {
const context: RenderContext = {
data: { model: { id: 'Opus 4.6 (1M)' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
it('should calculate percentage from display_name when model id lacks context size suffix', () => {
const context: RenderContext = {
data: {
model: {
id: 'claude-opus-4-6',
display_name: 'Opus 4.6 (1M context)'
}
},
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
});
describe('Older models with 200k context window', () => {
+33
View File
@@ -9,6 +9,7 @@ import {
import type { RenderContext } from '../../types/RenderContext';
import {
getGitChangeCounts,
isInsideGitWorkTree,
resolveGitCwd,
runGit
@@ -20,6 +21,7 @@ const mockExecSync = execSync as unknown as {
mock: { calls: unknown[][] };
mockImplementation: (impl: () => never) => void;
mockReturnValue: (value: string) => void;
mockReturnValueOnce: (value: string) => void;
};
describe('git utils', () => {
@@ -134,4 +136,35 @@ describe('git utils', () => {
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
});
});
});
});
+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' }]
}
]
});
});
});
+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
});
});
});
+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);
});
});
+54 -1
View File
@@ -4,7 +4,10 @@ import {
it
} from 'vitest';
import { getContextConfig } from '../model-context';
import {
getContextConfig,
getModelContextIdentifier
} from '../model-context';
describe('getContextConfig', () => {
describe('Status JSON context window size override', () => {
@@ -53,6 +56,34 @@ describe('getContextConfig', () => {
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', () => {
@@ -95,4 +126,26 @@ describe('getContextConfig', () => {
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();
});
});
+37
View File
@@ -93,6 +93,43 @@ describe('openExternalUrl', () => {
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');
@@ -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,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,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);
});
});
+80
View File
@@ -0,0 +1,80 @@
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[][] };
mockImplementationOnce: (impl: () => never) => void;
mockReturnValueOnce: (value: string) => void;
};
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('returns width from tty probe when available', () => {
mockExecSync.mockReturnValueOnce('ttys001\n');
mockExecSync.mockReturnValueOnce('120\n');
expect(getTerminalWidth()).toBe(120);
expect(mockExecSync.mock.calls[0]?.[0]).toContain('ps -o tty=');
expect(mockExecSync.mock.calls[1]?.[0]).toContain('stty size < /dev/ttys001');
});
it('falls back to tput cols when tty probe fails', () => {
mockExecSync.mockImplementationOnce(() => { throw new Error('tty unavailable'); });
mockExecSync.mockReturnValueOnce('90\n');
expect(getTerminalWidth()).toBe(90);
expect(mockExecSync.mock.calls[1]?.[0]).toBe('tput cols 2>/dev/null');
});
it('returns null when width probes fail', () => {
mockExecSync.mockReturnValueOnce('ttys001\n');
mockExecSync.mockReturnValueOnce('not-a-number\n');
mockExecSync.mockImplementationOnce(() => { throw new Error('tput unavailable'); });
expect(getTerminalWidth()).toBeNull();
});
it('detects availability when tty probe succeeds', () => {
mockExecSync.mockReturnValueOnce('ttys001\n');
mockExecSync.mockReturnValueOnce('80\n');
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);
});
});
+555 -60
View File
@@ -10,94 +10,589 @@ import {
} from 'vitest';
interface UsageProbeResult {
first: { error?: string };
second: { error?: string };
first: Record<string, unknown>;
second: Record<string, unknown>;
lockExists: boolean;
cacheExists: boolean;
requestCount: number;
proxyAgentConfigured: boolean;
requestHost: string | null;
lockContents: string | null;
}
describe('fetchUsageData error handling', () => {
it('preserves root errors within lock window and avoids locking on no-credentials', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-usage-test-'));
const probeScriptPath = path.join(tempRoot, 'probe-usage.mjs');
const usageModulePath = fileURLToPath(new URL('../usage.ts', import.meta.url));
interface TokenHome {
bin: string;
claudeConfig: string;
home: string;
}
const noCredentialsHome = path.join(tempRoot, 'home-no-credentials');
const apiErrorHome = path.join(tempRoot, 'home-api-error');
const apiErrorBin = path.join(tempRoot, 'bin-api-error');
const apiErrorClaudeConfig = path.join(tempRoot, 'claude-api-error');
const securityScript = path.join(apiErrorBin, 'security');
const credentialsFile = path.join(apiErrorClaudeConfig, '.credentials.json');
interface ProbeOptions {
claudeConfigDir?: string;
home: string;
httpsProxy?: string;
lowercaseHttpsProxy?: string;
mode?: 'error' | 'status' | 'success' | 'unexpected';
nowMs: number;
pathDir?: string;
responseBody?: string;
responseHeaders?: Record<string, string>;
statusCode?: number;
}
fs.mkdirSync(noCredentialsHome, { recursive: true });
fs.mkdirSync(apiErrorHome, { recursive: true });
fs.mkdirSync(apiErrorBin, { recursive: true });
fs.mkdirSync(apiErrorClaudeConfig, { recursive: true });
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' } }));
const probeScript = `
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { fetchUsageData } from ${JSON.stringify(usageModulePath)};
return {
bin,
claudeConfig,
home
};
}
const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock');
const nowMs = Date.now() + (10 * 365 * 24 * 60 * 60 * 1000);
Date.now = () => nowMs;
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 } : {})
}
});
if (process.env.TEST_FORCE_BAD_EXEC_PATH === '1') {
Object.defineProperty(process, 'execPath', {
value: '/nonexistent-runtime',
writable: true,
configurable: true
});
return JSON.parse(output) as UsageProbeResult;
}
function cleanup(): void {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
return {
cleanup,
createEmptyHome,
createTokenHome,
runProbe
};
}
const first = fetchUsageData();
const second = fetchUsageData();
process.stdout.write(JSON.stringify({
first,
second,
lockExists: fs.existsSync(lockFile)
}));
`;
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 {
fs.writeFileSync(probeScriptPath, probeScript);
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 noCredentialsOutput = execFileSync(process.execPath, [probeScriptPath], {
encoding: 'utf8',
env: {
...process.env,
HOME: noCredentialsHome,
PATH: '/nonexistent',
TEST_FORCE_BAD_EXEC_PATH: '0'
}
const noCredentialsResult = harness.runProbe({
home: noCredentialsHome.home,
mode: 'unexpected',
nowMs
});
const noCredentialsResult = JSON.parse(noCredentialsOutput) as UsageProbeResult;
expect(noCredentialsResult.first).toEqual({ error: 'no-credentials' });
expect(noCredentialsResult.second).toEqual({ error: 'no-credentials' });
expect(noCredentialsResult.lockExists).toBe(false);
expect(noCredentialsResult.cacheExists).toBe(false);
expect(noCredentialsResult.requestCount).toBe(0);
expect(noCredentialsResult.proxyAgentConfigured).toBe(false);
const apiErrorOutput = execFileSync(process.execPath, [probeScriptPath], {
encoding: 'utf8',
env: {
...process.env,
HOME: apiErrorHome,
PATH: apiErrorBin,
CLAUDE_CONFIG_DIR: apiErrorClaudeConfig,
TEST_FORCE_BAD_EXEC_PATH: '1'
}
const apiErrorResult = harness.runProbe({
claudeConfigDir: apiErrorHome.claudeConfig,
home: apiErrorHome.home,
mode: 'error',
nowMs,
pathDir: apiErrorHome.bin
});
const apiErrorResult = JSON.parse(apiErrorOutput) as UsageProbeResult;
expect(apiErrorResult.first).toEqual({ error: 'api-error' });
expect(apiErrorResult.second).toEqual({ error: 'api-error' });
expect(apiErrorResult.cacheExists).toBe(false);
expect(apiErrorResult.requestCount).toBe(1);
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 {
fs.rmSync(tempRoot, { recursive: true, force: true });
harness.cleanup();
}
});
it('reuses stale cached data during a numeric Retry-After backoff and retries after expiry', () => {
const harness = createProbeHarness();
try {
const home = harness.createTokenHome('rate-limited-with-cache');
const rateLimitNowMs = nowMs + 31000;
const successResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'success',
nowMs,
pathDir: home.bin,
responseBody: successResponseBody
});
const rateLimitedResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'status',
nowMs: rateLimitNowMs,
pathDir: home.bin,
responseBody: rateLimitedResponseBody,
responseHeaders: { 'retry-after': '3600' },
statusCode: 429
});
expect(rateLimitedResult.first).toEqual(successResult.first);
expect(rateLimitedResult.second).toEqual(successResult.first);
expect(rateLimitedResult.requestCount).toBe(1);
expect(parseLockContents(rateLimitedResult.lockContents)).toEqual({
blockedUntil: Math.floor(rateLimitNowMs / 1000) + 3600,
error: 'rate-limited'
});
const activeBackoffResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs: rateLimitNowMs + 600000,
pathDir: home.bin
});
expect(activeBackoffResult.first).toEqual(successResult.first);
expect(activeBackoffResult.second).toEqual(successResult.first);
expect(activeBackoffResult.requestCount).toBe(0);
const postBackoffResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'success',
nowMs: rateLimitNowMs + 3601000,
pathDir: home.bin,
responseBody: updatedSuccessResponseBody
});
expect(postBackoffResult.first).toEqual({
sessionUsage: 55,
sessionResetAt: '2030-01-02T00:00:00.000Z',
weeklyUsage: 21,
weeklyResetAt: '2030-01-08T00:00:00.000Z'
});
expect(postBackoffResult.second).toEqual(postBackoffResult.first);
expect(postBackoffResult.requestCount).toBe(1);
} finally {
harness.cleanup();
}
});
it('returns rate-limited without stale cache and falls back to the default backoff when Retry-After is invalid', () => {
const harness = createProbeHarness();
try {
const home = harness.createTokenHome('rate-limited-no-cache');
const firstRateLimitedResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'status',
nowMs,
pathDir: home.bin,
responseBody: rateLimitedResponseBody,
responseHeaders: { 'retry-after': 'not-a-number' },
statusCode: 429
});
expect(firstRateLimitedResult.first).toEqual({ error: 'rate-limited' });
expect(firstRateLimitedResult.second).toEqual({ error: 'rate-limited' });
expect(firstRateLimitedResult.requestCount).toBe(1);
expect(parseLockContents(firstRateLimitedResult.lockContents)).toEqual({
blockedUntil: Math.floor(nowMs / 1000) + 300,
error: 'rate-limited'
});
const activeBackoffResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs: nowMs + 299000,
pathDir: home.bin
});
expect(activeBackoffResult.first).toEqual({ error: 'rate-limited' });
expect(activeBackoffResult.second).toEqual({ error: 'rate-limited' });
expect(activeBackoffResult.requestCount).toBe(0);
const postBackoffResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'success',
nowMs: nowMs + 301000,
pathDir: home.bin,
responseBody: successResponseBody
});
expect(postBackoffResult.first).toEqual({
sessionUsage: 42,
sessionResetAt: '2030-01-01T00:00:00.000Z',
weeklyUsage: 17,
weeklyResetAt: '2030-01-07T00:00:00.000Z'
});
expect(postBackoffResult.second).toEqual(postBackoffResult.first);
expect(postBackoffResult.requestCount).toBe(1);
} finally {
harness.cleanup();
}
});
it('parses HTTP-date Retry-After headers', () => {
const harness = createProbeHarness();
try {
const home = harness.createTokenHome('rate-limited-http-date');
const retryAt = new Date(nowMs + 900000).toUTCString();
const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'status',
nowMs,
pathDir: home.bin,
responseBody: rateLimitedResponseBody,
responseHeaders: { 'retry-after': retryAt },
statusCode: 429
});
expect(result.first).toEqual({ error: 'rate-limited' });
expect(result.second).toEqual({ error: 'rate-limited' });
expect(parseLockContents(result.lockContents)).toEqual({
blockedUntil: Math.floor((nowMs + 900000) / 1000),
error: 'rate-limited'
});
} finally {
harness.cleanup();
}
});
it('supports the legacy empty lock file fallback', () => {
const harness = createProbeHarness();
try {
const home = harness.createTokenHome('legacy-lock');
const lockDir = path.join(home.home, '.cache', 'ccstatusline');
const lockFile = path.join(lockDir, 'usage.lock');
fs.mkdirSync(lockDir, { recursive: true });
fs.writeFileSync(lockFile, '');
fs.utimesSync(lockFile, new Date(nowMs), new Date(nowMs));
const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs,
pathDir: home.bin
});
expect(result.first).toEqual({ error: 'timeout' });
expect(result.second).toEqual({ error: 'timeout' });
expect(result.requestCount).toBe(0);
} finally {
harness.cleanup();
}
});
});
@@ -0,0 +1,83 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import type { WidgetItem } from '../../types/Widget';
import * as usage from '../usage';
import {
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);
});
});
+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]');
});
});
+55 -11
View File
@@ -1,4 +1,5 @@
import {
afterEach,
beforeEach,
describe,
expect,
@@ -7,24 +8,32 @@ import {
} from 'vitest';
import type { BlockMetrics } from '../../types';
import { getCachedBlockMetrics } from '../jsonl';
import * as jsonl from '../jsonl';
import {
FIVE_HOUR_BLOCK_MS,
SEVEN_DAY_WINDOW_MS
} from '../usage-types';
import {
formatUsageDuration,
getUsageWindowFromResetAt,
resolveUsageWindowWithFallback
} from '../usage';
vi.mock('../jsonl', () => ({ getCachedBlockMetrics: vi.fn() }));
const mockGetCachedBlockMetrics = getCachedBlockMetrics as unknown as {
mock: { calls: unknown[][] };
mockReturnValue: (value: BlockMetrics | null) => void;
};
getWeeklyUsageWindowFromResetAt,
resolveUsageWindowWithFallback,
resolveWeeklyUsageWindow
} from '../usage-windows';
describe('usage window helpers', () => {
let mockGetCachedBlockMetrics: {
mock: { calls: unknown[][] };
mockReturnValue: (value: BlockMetrics | null) => void;
};
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
mockGetCachedBlockMetrics = vi.spyOn(jsonl, 'getCachedBlockMetrics');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('parses usage reset timestamp into elapsed and remaining metrics', () => {
@@ -96,10 +105,45 @@ describe('usage window helpers', () => {
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('0hr');
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 in compact style', () => {
expect(formatUsageDuration(0, true)).toBe('0h');
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');
});
});
+44
View File
@@ -11,8 +11,11 @@ import {
import type { WidgetItemType } from '../../types/Widget';
import {
filterWidgetCatalog,
getAllWidgetTypes,
getWidget,
getWidgetCatalog,
getWidgetCatalogCategories,
isKnownWidgetType,
type WidgetCatalogEntry
} from '../widgets';
@@ -28,6 +31,13 @@ describe('widget catalog', () => {
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');
@@ -35,6 +45,20 @@ describe('widget catalog', () => {
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', () => {
@@ -69,12 +93,32 @@ describe('widget catalog', () => {
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', () => {
+151 -24
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 };
@@ -19,6 +28,32 @@ export const CCSTATUSLINE_COMMANDS = {
SELF_MANAGED: 'ccstatusline'
};
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.
@@ -58,16 +93,44 @@ export function getClaudeSettingsPath(): string {
return path.join(getClaudeConfigDir(), 'settings.json');
}
export async function loadClaudeSettings(): Promise<ClaudeSettings> {
/**
* 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 {
const settingsPath = getClaudeSettingsPath();
if (!fs.existsSync(settingsPath)) {
return {};
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 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 {
return {};
} catch (error) {
if (logErrors) {
console.error('Failed to load Claude settings:', error);
}
throw error;
}
}
@@ -76,23 +139,26 @@ export async function saveClaudeSettings(
): Promise<void> {
const settingsPath = getClaudeSettingsPath();
const dir = path.dirname(settingsPath);
// Backup settings before overwriting
await backupClaudeSettings();
await mkdir(dir, { recursive: true });
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 = [
// Default autoinstalled npm command
CCSTATUSLINE_COMMANDS.NPM,
// Default autoinstalled bunx command
CCSTATUSLINE_COMMANDS.BUNX,
// Self managed installation command
CCSTATUSLINE_COMMANDS.SELF_MANAGED
];
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 (
validCommands.includes(settings.statusLine?.command ?? '')
isKnownCommand(command)
&& (settings.statusLine?.padding === 0
|| settings.statusLine?.padding === undefined)
);
@@ -109,31 +175,92 @@ export function isBunxAvailable(): boolean {
}
}
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
? CCSTATUSLINE_COMMANDS.BUNX
: CCSTATUSLINE_COMMANDS.NPM,
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
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { Settings } from '../types/Settings';
export function cloneSettings(settings: Settings): Settings {
const cloneFn = globalThis.structuredClone;
if (typeof cloneFn === 'function') {
return cloneFn(settings);
}
return JSON.parse(JSON.stringify(settings)) as Settings;
}
+66
View File
@@ -0,0 +1,66 @@
import type { WidgetItem } from '../types/Widget';
import { getWidget } from './widgets';
function isCustomColor(value: string | undefined): boolean {
if (!value) {
return false;
}
return value.startsWith('ansi256:') || value.startsWith('hex:');
}
function isIncompatibleForLevel(value: string | undefined, nextLevel: 0 | 1 | 2 | 3): boolean {
if (!isCustomColor(value)) {
return false;
}
if (nextLevel === 2) {
return Boolean(value?.startsWith('hex:'));
}
if (nextLevel === 3) {
return Boolean(value?.startsWith('ansi256:'));
}
return true;
}
function resetWidgetForegroundToDefault(widget: WidgetItem, nextWidget: WidgetItem): WidgetItem {
if (widget.type === 'separator' || widget.type === 'flex-separator') {
return nextWidget;
}
const widgetImpl = getWidget(widget.type);
if (!widgetImpl) {
return nextWidget;
}
return {
...nextWidget,
color: widgetImpl.getDefaultColor()
};
}
export function hasCustomWidgetColors(lines: WidgetItem[][]): boolean {
return lines.some(line => line.some(widget => isCustomColor(widget.color) || isCustomColor(widget.backgroundColor)));
}
export function sanitizeLinesForColorLevel(lines: WidgetItem[][], nextLevel: 0 | 1 | 2 | 3): WidgetItem[][] {
return lines.map(line => line.map((widget) => {
let nextWidget: WidgetItem = { ...widget };
if (isIncompatibleForLevel(widget.color, nextLevel)) {
nextWidget = resetWidgetForegroundToDefault(widget, nextWidget);
}
if (isIncompatibleForLevel(widget.backgroundColor, nextLevel)) {
nextWidget = {
...nextWidget,
backgroundColor: undefined
};
}
return nextWidget;
}));
}
+42 -37
View File
@@ -111,13 +111,13 @@ export function getChalkColor(colorName: string | undefined, colorLevel: 'ansi16
}
switch (colorLevel) {
case 'ansi256':
return colorEntry.ansi256;
case 'truecolor':
return colorEntry.truecolor;
case 'ansi16':
default:
return colorEntry.ansi16;
case 'ansi256':
return colorEntry.ansi256;
case 'truecolor':
return colorEntry.truecolor;
case 'ansi16':
default:
return colorEntry.ansi16;
}
}
@@ -132,31 +132,36 @@ export function applyColors(
return text;
}
let result = text;
// Use raw ANSI codes for precise reset sequencing.
// This avoids style leakage (for example, bold affecting later widgets).
let prefix = '';
let suffix = '';
// Apply background color first
// This ensures the background is properly established before other styles
if (backgroundColor) {
const bgChalk = getChalkColor(backgroundColor, colorLevel, true);
if (bgChalk) {
result = bgChalk(result);
}
}
// Apply foreground color second
if (foregroundColor) {
const fgChalk = getChalkColor(foregroundColor, colorLevel, false);
if (fgChalk) {
result = fgChalk(result);
}
}
// Apply bold last if needed
// Apply bold first so it can be reset independently before color resets.
if (bold) {
result = chalk.bold(result);
prefix += '\x1b[1m';
suffix = '\x1b[22m' + suffix;
}
return result;
// Apply background color
if (backgroundColor) {
const bgCode = getColorAnsiCode(backgroundColor, colorLevel, true);
if (bgCode) {
prefix += bgCode;
suffix = '\x1b[49m' + suffix;
}
}
// Apply foreground color
if (foregroundColor) {
const fgCode = getColorAnsiCode(foregroundColor, colorLevel, false);
if (fgCode) {
prefix += fgCode;
suffix = '\x1b[39m' + suffix;
}
}
return prefix + text + suffix;
}
// Get raw ANSI codes for a color without the reset codes
@@ -193,15 +198,15 @@ export function getColorAnsiCode(colorName: string | undefined, colorLevel: 'ans
// Now that chalk.level is set correctly, we can use chalk to generate the codes
let chalkFn: ChalkInstance;
switch (colorLevel) {
case 'ansi256':
chalkFn = colorEntry.ansi256;
break;
case 'truecolor':
chalkFn = colorEntry.truecolor;
break;
default:
chalkFn = colorEntry.ansi16;
break;
case 'ansi256':
chalkFn = colorEntry.ansi256;
break;
case 'truecolor':
chalkFn = colorEntry.truecolor;
break;
default:
chalkFn = colorEntry.ansi16;
break;
}
// Apply the color and extract the opening ANSI code
+72 -29
View File
@@ -19,23 +19,60 @@ const readFile = fs.promises.readFile;
const writeFile = fs.promises.writeFile;
const mkdir = fs.promises.mkdir;
const CONFIG_DIR = path.join(os.homedir(), '.config', 'ccstatusline');
const SETTINGS_PATH = path.join(CONFIG_DIR, 'settings.json');
const SETTINGS_BACKUP_PATH = path.join(CONFIG_DIR, 'settings.bak');
const DEFAULT_SETTINGS_PATH = path.join(os.homedir(), '.config', 'ccstatusline', 'settings.json');
async function backupBadSettings(): Promise<void> {
let settingsPath = DEFAULT_SETTINGS_PATH;
export function initConfigPath(filePath?: string): void {
settingsPath = filePath ? path.resolve(filePath) : DEFAULT_SETTINGS_PATH;
}
export function getConfigPath(): string {
return settingsPath;
}
export function isCustomConfigPath(): boolean {
return settingsPath !== DEFAULT_SETTINGS_PATH;
}
interface SettingsPaths {
configDir: string;
settingsPath: string;
settingsBackupPath: string;
}
function getSettingsPaths(): SettingsPaths {
const configDir = path.dirname(settingsPath);
const parsedPath = path.parse(settingsPath);
const backupBaseName = parsedPath.ext
? `${parsedPath.name}.bak`
: `${parsedPath.base}.bak`;
return {
configDir,
settingsPath,
settingsBackupPath: path.join(configDir, backupBaseName)
};
}
async function writeSettingsJson(settings: unknown, paths: SettingsPaths): Promise<void> {
await mkdir(paths.configDir, { recursive: true });
await writeFile(paths.settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
}
async function backupBadSettings(paths: SettingsPaths): Promise<void> {
try {
if (fs.existsSync(SETTINGS_PATH)) {
const content = await readFile(SETTINGS_PATH, 'utf-8');
await writeFile(SETTINGS_BACKUP_PATH, content, 'utf-8');
console.error(`Bad settings backed up to ${SETTINGS_BACKUP_PATH}`);
if (fs.existsSync(paths.settingsPath)) {
const content = await readFile(paths.settingsPath, 'utf-8');
await writeFile(paths.settingsBackupPath, content, 'utf-8');
console.error(`Bad settings backed up to ${paths.settingsBackupPath}`);
}
} catch (error) {
console.error('Failed to backup bad settings:', error);
}
}
async function writeDefaultSettings(): Promise<Settings> {
async function writeDefaultSettings(paths: SettingsPaths): Promise<Settings> {
const defaults = SettingsSchema.parse({});
const settingsWithVersion = {
...defaults,
@@ -43,9 +80,8 @@ async function writeDefaultSettings(): Promise<Settings> {
};
try {
await mkdir(CONFIG_DIR, { recursive: true });
await writeFile(SETTINGS_PATH, JSON.stringify(settingsWithVersion, null, 2), 'utf-8');
console.error(`Default settings written to ${SETTINGS_PATH}`);
await writeSettingsJson(settingsWithVersion, paths);
console.error(`Default settings written to ${paths.settingsPath}`);
} catch (error) {
console.error('Failed to write default settings:', error);
}
@@ -53,13 +89,20 @@ async function writeDefaultSettings(): Promise<Settings> {
return defaults;
}
async function recoverWithDefaults(paths: SettingsPaths): Promise<Settings> {
await backupBadSettings(paths);
return await writeDefaultSettings(paths);
}
export async function loadSettings(): Promise<Settings> {
const paths = getSettingsPaths();
try {
// Check if settings file exists
if (!fs.existsSync(SETTINGS_PATH))
return await writeDefaultSettings();
if (!fs.existsSync(paths.settingsPath))
return await writeDefaultSettings(paths);
const content = await readFile(SETTINGS_PATH, 'utf-8');
const content = await readFile(paths.settingsPath, 'utf-8');
let rawData: unknown;
try {
@@ -67,8 +110,7 @@ export async function loadSettings(): Promise<Settings> {
} catch {
// If we can't parse the JSON, backup and write defaults
console.error('Failed to parse settings.json, backing up and using defaults');
await backupBadSettings();
return await writeDefaultSettings();
return await recoverWithDefaults(paths);
}
// Check if this is a v1 config (no version field)
@@ -78,17 +120,16 @@ export async function loadSettings(): Promise<Settings> {
const v1Result = SettingsSchema_v1.safeParse(rawData);
if (!v1Result.success) {
console.error('Invalid v1 settings format:', v1Result.error);
await backupBadSettings();
return await writeDefaultSettings();
return await recoverWithDefaults(paths);
}
// Migrate v1 to current version and save the migrated settings back to disk
rawData = migrateConfig(rawData, CURRENT_VERSION);
await writeFile(SETTINGS_PATH, JSON.stringify(rawData, null, 2), 'utf-8');
await writeSettingsJson(rawData, paths);
} else if (needsMigration(rawData, CURRENT_VERSION)) {
// Handle migrations for versioned configs (v2+) and save the migrated settings back to disk
rawData = migrateConfig(rawData, CURRENT_VERSION);
await writeFile(SETTINGS_PATH, JSON.stringify(rawData, null, 2), 'utf-8');
await writeSettingsJson(rawData, paths);
}
// At this point, data should be in current format with version field
@@ -96,22 +137,19 @@ export async function loadSettings(): Promise<Settings> {
const result = SettingsSchema.safeParse(rawData);
if (!result.success) {
console.error('Failed to parse settings:', result.error);
await backupBadSettings();
return await writeDefaultSettings();
return await recoverWithDefaults(paths);
}
return result.data;
} catch (error) {
// Any other error, backup and write defaults
console.error('Error loading settings:', error);
await backupBadSettings();
return await writeDefaultSettings();
return await recoverWithDefaults(paths);
}
}
export async function saveSettings(settings: Settings): Promise<void> {
// Ensure config directory exists
await mkdir(CONFIG_DIR, { recursive: true });
const paths = getSettingsPaths();
// Always include version when saving
const settingsWithVersion = {
@@ -119,6 +157,11 @@ export async function saveSettings(settings: Settings): Promise<void> {
version: CURRENT_VERSION
};
// Write settings using Node.js-compatible API
await writeFile(SETTINGS_PATH, JSON.stringify(settingsWithVersion, null, 2), 'utf-8');
await writeSettingsJson(settingsWithVersion, paths);
// Sync widget hooks to Claude settings
try {
const { syncWidgetHooks } = await import('./hooks');
await syncWidgetHooks(settings);
} catch { /* ignore hook sync failures */ }
}
+6 -4
View File
@@ -1,7 +1,10 @@
import type { RenderContext } from '../types';
import { getContextWindowMetrics } from './context-window';
import { getContextConfig } from './model-context';
import {
getContextConfig,
getModelContextIdentifier
} from './model-context';
/**
* Calculate context window usage percentage based on model's max tokens
@@ -16,9 +19,8 @@ export function calculateContextPercentage(context: RenderContext): number {
return 0;
}
const model = context.data?.model;
const modelId = typeof model === 'string' ? model : model?.id;
const contextConfig = getContextConfig(modelId, contextWindowMetrics.windowSize);
const modelIdentifier = getModelContextIdentifier(context.data?.model);
const contextConfig = getContextConfig(modelIdentifier, contextWindowMetrics.windowSize);
return Math.min(100, (context.tokenMetrics.contextLength / contextConfig.maxTokens) * 100);
}
+27
View File
@@ -2,6 +2,11 @@ import { execSync } from 'child_process';
import type { RenderContext } from '../types/RenderContext';
export interface GitChangeCounts {
insertions: number;
deletions: number;
}
export function resolveGitCwd(context: RenderContext): string | undefined {
const candidates = [
context.data?.cwd,
@@ -35,4 +40,26 @@ export function runGit(command: string, context: RenderContext): string | null {
export function isInsideGitWorkTree(context: RenderContext): boolean {
return runGit('rev-parse --is-inside-work-tree', context) === 'true';
}
function parseDiffShortStat(stat: string): GitChangeCounts {
const insertMatch = /(\d+)\s+insertions?/.exec(stat);
const deleteMatch = /(\d+)\s+deletions?/.exec(stat);
return {
insertions: insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0,
deletions: deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0
};
}
export function getGitChangeCounts(context: RenderContext): GitChangeCounts {
const unstagedStat = runGit('diff --shortstat', context) ?? '';
const stagedStat = runGit('diff --cached --shortstat', context) ?? '';
const unstagedCounts = parseDiffShortStat(unstagedStat);
const stagedCounts = parseDiffShortStat(stagedStat);
return {
insertions: unstagedCounts.insertions + stagedCounts.insertions,
deletions: unstagedCounts.deletions + stagedCounts.deletions
};
}
+96
View File
@@ -0,0 +1,96 @@
import type { Settings } from '../types/Settings';
import type { Widget } from '../types/Widget';
import {
getExistingStatusLine,
loadClaudeSettings,
saveClaudeSettings
} from './claude-settings';
import { getWidget } from './widgets';
export interface WidgetHookDef {
event: string;
matcher?: string;
}
const HOOK_TAG = 'ccstatusline-managed';
interface HookEntry {
_tag?: string;
matcher?: string;
hooks?: { type: string; command: string }[];
}
function stripManagedHooks(hooks: Record<string, HookEntry[]>): void {
for (const event of Object.keys(hooks)) {
hooks[event] = (hooks[event] ?? []).filter(entry => entry._tag !== HOOK_TAG);
if (hooks[event].length === 0) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete hooks[event];
}
}
}
function getActiveHookDefs(settings: Settings): WidgetHookDef[] {
const seen = new Set<string>();
const defs: WidgetHookDef[] = [];
for (const line of settings.lines) {
for (const item of line) {
const widget = getWidget(item.type) as (Widget & { getHooks?: () => WidgetHookDef[] }) | null;
if (!widget?.getHooks) {
continue;
}
for (const hook of widget.getHooks()) {
const key = `${hook.event}:${hook.matcher ?? ''}`;
if (!seen.has(key)) {
seen.add(key);
defs.push(hook);
}
}
}
}
return defs;
}
export async function syncWidgetHooks(settings: Settings): Promise<void> {
const needed = getActiveHookDefs(settings);
const claudeSettings = await loadClaudeSettings({ logErrors: false });
const hooks = (claudeSettings.hooks ?? {}) as Record<string, HookEntry[]>;
// Remove all ccstatusline-managed hooks
stripManagedHooks(hooks);
const statusCommand = await getExistingStatusLine();
if (!statusCommand) {
claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined;
await saveClaudeSettings(claudeSettings);
return;
}
const hookCommand = `${statusCommand} --hook`;
// Add needed hooks
for (const def of needed) {
const entry: HookEntry = {
_tag: HOOK_TAG,
hooks: [{ type: 'command', command: hookCommand }]
};
if (def.matcher) {
entry.matcher = def.matcher;
}
const list = hooks[def.event] ??= [];
list.push(entry);
}
claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined;
await saveClaudeSettings(claudeSettings);
}
export async function removeManagedHooks(): Promise<void> {
const claudeSettings = await loadClaudeSettings({ logErrors: false });
const hooks = (claudeSettings.hooks ?? {}) as Record<string, HookEntry[]>;
stripManagedHooks(hooks);
claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined;
await saveClaudeSettings(claudeSettings);
}
+233
View File
@@ -0,0 +1,233 @@
import * as fs from 'fs';
import path from 'node:path';
import { globSync } from 'tinyglobby';
import type { BlockMetrics } from '../types';
import { getClaudeConfigDir } from './claude-settings';
import {
parseJsonlLine,
readJsonlLinesSync
} from './jsonl-lines';
const statSync = fs.statSync;
/**
* Gets block metrics for the current 5-hour block from JSONL files
*/
export function getBlockMetrics(): BlockMetrics | null {
const claudeDir: string | null = getClaudeConfigDir();
if (!claudeDir)
return null;
try {
return findMostRecentBlockStartTime(claudeDir);
} catch {
return null;
}
}
/**
* Efficiently finds the most recent 5-hour block start time from JSONL files
* Uses file modification times as hints to avoid unnecessary reads
*/
function findMostRecentBlockStartTime(
rootDir: string,
sessionDurationHours = 5
): BlockMetrics | null {
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
const now = new Date();
// Step 1: Find all JSONL files with their modification times
// Use forward slashes for glob patterns on all platforms (tinyglobby requirement)
const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl');
const files = globSync([pattern], {
absolute: true, // Ensure we get absolute paths
cwd: rootDir // Set working directory to rootDir
});
if (files.length === 0)
return null;
// Step 2: Get file stats and sort by modification time (most recent first)
const filesWithStats = files.map((file) => {
const stats = statSync(file);
return { file, mtime: stats.mtime };
});
filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
// Step 3: Progressive lookback - start small and expand if needed
// Start with 2x session duration (10 hours), expand to 48 hours if needed
const lookbackChunks = [
10, // 2x session duration - catches most cases
20, // 4x session duration - catches longer sessions
48 // Maximum lookback for marathon sessions
];
let timestamps: Date[] = [];
let mostRecentTimestamp: Date | null = null;
let continuousWorkStart: Date | null = null;
let foundSessionGap = false;
for (const lookbackHours of lookbackChunks) {
const cutoffTime = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
timestamps = [];
// Collect timestamps for this lookback period
for (const { file, mtime } of filesWithStats) {
if (mtime.getTime() < cutoffTime.getTime()) {
break;
}
const fileTimestamps = getAllTimestampsFromFile(file);
timestamps.push(...fileTimestamps);
}
if (timestamps.length === 0) {
continue; // Try next chunk
}
// Sort timestamps (most recent first)
timestamps.sort((a, b) => b.getTime() - a.getTime());
// Get most recent timestamp (only set once)
if (!mostRecentTimestamp && timestamps[0]) {
mostRecentTimestamp = timestamps[0];
// Check if the most recent activity is within the current session period
const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime();
if (timeSinceLastActivity > sessionDurationMs) {
// No activity within the current session period
return null;
}
}
// Look for a session gap in this chunk
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) {
// Found a true session boundary
foundSessionGap = true;
break;
}
continuousWorkStart = currentTimestamp;
}
// If we found a gap, we're done
if (foundSessionGap) {
break;
}
// If this was our last chunk, use what we have
if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) {
break;
}
}
if (!mostRecentTimestamp || !continuousWorkStart) {
return null;
}
// Build actual blocks from timestamps going forward
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())) {
// Start new block
currentBlockStart = floorToHour(timestamp);
currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs);
blocks.push({ start: currentBlockStart, end: currentBlockEnd });
}
}
// Find current block
for (const block of blocks) {
if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) {
// Verify we have activity in this block
const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime()
&& t.getTime() <= block.end.getTime()
);
if (hasActivity) {
return {
startTime: block.start,
lastActivity: mostRecentTimestamp
};
}
}
}
return null;
}
/**
* Gets all timestamps from a JSONL file
*/
function getAllTimestampsFromFile(filePath: string): Date[] {
const timestamps: Date[] = [];
try {
const lines = readJsonlLinesSync(filePath);
for (const line of lines) {
const json = parseJsonlLine(line) as {
timestamp?: string;
isSidechain?: boolean;
message?: { usage?: { input_tokens?: number; output_tokens?: number } };
} | null;
if (!json) {
continue;
}
// Only treat entries with real token usage as block activity
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);
}
return timestamps;
} catch {
return [];
}
}
/**
* Floors a timestamp to the beginning of the hour (matching existing logic)
*/
function floorToHour(timestamp: Date): Date {
const floored = new Date(timestamp);
floored.setUTCMinutes(0, 0, 0);
return floored;
}
+133
View File
@@ -0,0 +1,133 @@
import * as fs from 'fs';
import { createHash } from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import type { BlockMetrics } from '../types';
import { getClaudeConfigDir } from './claude-settings';
import { getBlockMetrics } from './jsonl-blocks';
const readFileSync = fs.readFileSync;
const writeFileSync = fs.writeFileSync;
const mkdirSync = fs.mkdirSync;
const existsSync = fs.existsSync;
interface BlockCache {
startTime: string;
configDir?: string;
}
function normalizeConfigDir(configDir: string): string {
return path.resolve(configDir);
}
/**
* Returns the path to the block cache file for a specific Claude config directory
*/
export function getBlockCachePath(configDir = getClaudeConfigDir()): string {
const normalizedConfigDir = normalizeConfigDir(configDir);
const configHash = createHash('sha256')
.update(normalizedConfigDir)
.digest('hex')
.slice(0, 16);
return path.join(
os.homedir(),
'.cache',
'ccstatusline',
`block-cache-${configHash}.json`
);
}
/**
* Reads the block cache file and returns the cached start time
* Returns null if cache doesn't exist or is invalid
*/
export function readBlockCache(expectedConfigDir?: string): Date | null {
try {
const normalizedExpectedConfigDir = expectedConfigDir !== undefined
? normalizeConfigDir(expectedConfigDir)
: undefined;
const cachePath = getBlockCachePath(normalizedExpectedConfigDir);
if (!existsSync(cachePath)) {
return null;
}
const content = readFileSync(cachePath, 'utf-8');
const cache = JSON.parse(content) as BlockCache;
if (typeof cache.startTime !== 'string') {
return null;
}
if (normalizedExpectedConfigDir !== undefined) {
if (typeof cache.configDir !== 'string') {
return null;
}
if (cache.configDir !== normalizedExpectedConfigDir) {
return null;
}
}
const date = new Date(cache.startTime);
if (Number.isNaN(date.getTime())) {
return null;
}
return date;
} catch {
return null;
}
}
/**
* Writes the block start time to the cache file
* Creates the cache directory if it doesn't exist
*/
export function writeBlockCache(startTime: Date, configDir = getClaudeConfigDir()): void {
try {
const normalizedConfigDir = normalizeConfigDir(configDir);
const cachePath = getBlockCachePath(normalizedConfigDir);
const cacheDir = path.dirname(cachePath);
if (!existsSync(cacheDir)) {
mkdirSync(cacheDir, { recursive: true });
}
const cache: BlockCache = {
startTime: startTime.toISOString(),
configDir: normalizedConfigDir
};
writeFileSync(cachePath, JSON.stringify(cache), 'utf-8');
} catch {
// Silently fail - caching is best-effort
}
}
/**
* Gets block metrics with caching support
* Returns cached result if still valid, otherwise recalculates
*/
export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | null {
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
const now = new Date();
const activeConfigDir = getClaudeConfigDir();
// Check cache first
const cachedStartTime = readBlockCache(activeConfigDir);
if (cachedStartTime) {
const blockEndTime = new Date(cachedStartTime.getTime() + sessionDurationMs);
if (now.getTime() <= blockEndTime.getTime()) {
// Cache is valid - return cached result
return {
startTime: cachedStartTime,
lastActivity: now // We don't cache lastActivity, use current time
};
}
// Cache expired - need to recalculate
}
// Cache miss or expired - run full calculation
const metrics = getBlockMetrics();
// Write to cache if we found a valid block
if (metrics) {
writeBlockCache(metrics.startTime, activeConfigDir);
}
return metrics;
}
+27
View File
@@ -0,0 +1,27 @@
import * as fs from 'fs';
import { promisify } from 'util';
const readFile = promisify(fs.readFile);
const readFileSync = fs.readFileSync;
function splitJsonlContent(content: string): string[] {
return content.trim().split('\n').filter(line => line.length > 0);
}
export async function readJsonlLines(filePath: string): Promise<string[]> {
const content = await readFile(filePath, 'utf-8');
return splitJsonlContent(content);
}
export function readJsonlLinesSync(filePath: string): string[] {
const content = readFileSync(filePath, 'utf-8');
return splitJsonlContent(content);
}
export function parseJsonlLine(line: string): unknown {
try {
return JSON.parse(line) as unknown;
} catch {
return null;
}
}
+543
View File
@@ -0,0 +1,543 @@
import * as fs from 'fs';
import path from 'node:path';
import type {
SpeedMetrics,
TokenMetrics,
TranscriptLine
} from '../types';
import {
parseJsonlLine,
readJsonlLines
} from './jsonl-lines';
export interface SpeedMetricsOptions {
includeSubagents?: boolean;
windowSeconds?: number;
}
interface SpeedMetricsCollectionOptions {
includeSubagents?: boolean;
windowSeconds?: number[];
}
export interface SpeedMetricsCollection {
sessionAverage: SpeedMetrics;
windowed: Record<string, SpeedMetrics>;
}
interface SpeedInterval {
startMs: number;
endMs: number;
}
interface SpeedRequest {
inputTokens: number;
outputTokens: number;
assistantTimestampMs: number | null;
interval: SpeedInterval | null;
}
interface CollectedSpeedMetrics {
requests: SpeedRequest[];
latestTimestampMs: number | null;
}
function collectAgentIds(value: unknown, agentIds: Set<string>) {
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
for (const item of value) {
collectAgentIds(item, agentIds);
}
return;
}
for (const [key, nestedValue] of Object.entries(value)) {
if (key === 'agentId' && typeof nestedValue === 'string' && nestedValue.trim() !== '') {
agentIds.add(nestedValue);
continue;
}
collectAgentIds(nestedValue, agentIds);
}
}
function getReferencedSubagentIds(lines: string[]): Set<string> {
const agentIds = new Set<string>();
for (const line of lines) {
const data = parseJsonlLine(line);
if (!data) {
continue;
}
collectAgentIds(data, agentIds);
}
return agentIds;
}
export async function getSessionDuration(transcriptPath: string): Promise<string | null> {
try {
if (!fs.existsSync(transcriptPath)) {
return null;
}
const lines = await readJsonlLines(transcriptPath);
if (lines.length === 0) {
return null;
}
let firstTimestamp: Date | null = null;
let lastTimestamp: Date | null = null;
// Find first valid timestamp
for (const line of lines) {
const data = parseJsonlLine(line) as { timestamp?: string } | null;
if (data?.timestamp) {
firstTimestamp = new Date(data.timestamp);
break;
}
}
// Find last valid timestamp (iterate backwards)
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (!line) {
continue;
}
const data = parseJsonlLine(line) as { timestamp?: string } | null;
if (data?.timestamp) {
lastTimestamp = new Date(data.timestamp);
break;
}
}
if (!firstTimestamp || !lastTimestamp) {
return null;
}
// Calculate duration in milliseconds
const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime();
// Convert to minutes
const totalMinutes = Math.floor(durationMs / (1000 * 60));
if (totalMinutes < 1) {
return '<1m';
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours === 0) {
return `${minutes}m`;
} else if (minutes === 0) {
return `${hours}hr`;
} else {
return `${hours}hr ${minutes}m`;
}
} catch {
return null;
}
}
export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetrics> {
try {
// Use Node.js-compatible file reading
if (!fs.existsSync(transcriptPath)) {
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
}
const lines = await readJsonlLines(transcriptPath);
let inputTokens = 0;
let outputTokens = 0;
let cachedTokens = 0;
let contextLength = 0;
// Parse each line and sum up token usage for totals
let mostRecentMainChainEntry: TranscriptLine | null = null;
let mostRecentTimestamp: Date | null = null;
for (const line of lines) {
const data = parseJsonlLine(line) as TranscriptLine | null;
if (data?.message?.usage) {
inputTokens += data.message.usage.input_tokens || 0;
outputTokens += data.message.usage.output_tokens || 0;
cachedTokens += data.message.usage.cache_read_input_tokens ?? 0;
cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0;
// Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain)
// Also skip API error messages (synthetic messages with 0 tokens)
if (data.isSidechain !== true && data.timestamp && !data.isApiErrorMessage) {
const entryTime = new Date(data.timestamp);
if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) {
mostRecentTimestamp = entryTime;
mostRecentMainChainEntry = data;
}
}
}
}
// Calculate context length from the most recent main chain message
if (mostRecentMainChainEntry?.message?.usage) {
const usage = mostRecentMainChainEntry.message.usage;
contextLength = (usage.input_tokens || 0)
+ (usage.cache_read_input_tokens ?? 0)
+ (usage.cache_creation_input_tokens ?? 0);
}
const totalTokens = inputTokens + outputTokens + cachedTokens;
return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength };
} catch {
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
}
}
function parseTimestamp(value: string | undefined): Date | null {
if (!value) {
return null;
}
const timestamp = new Date(value);
return Number.isNaN(timestamp.getTime()) ? null : timestamp;
}
function mergeIntervals(intervals: SpeedInterval[]): SpeedInterval[] {
if (intervals.length === 0) {
return [];
}
const sorted = intervals
.slice()
.sort((a, b) => a.startMs - b.startMs);
const first = sorted[0];
if (!first) {
return [];
}
const merged: SpeedInterval[] = [{ ...first }];
for (let i = 1; i < sorted.length; i++) {
const current = sorted[i];
const last = merged[merged.length - 1];
if (!current || !last) {
continue;
}
if (current.startMs <= last.endMs) {
last.endMs = Math.max(last.endMs, current.endMs);
} else {
merged.push({ ...current });
}
}
return merged;
}
function getIntervalsDurationMs(intervals: SpeedInterval[]): number {
return intervals.reduce((total, interval) => total + (interval.endMs - interval.startMs), 0);
}
function createEmptySpeedMetrics(): SpeedMetrics {
return {
totalDurationMs: 0,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
requestCount: 0
};
}
function normalizeWindowSeconds(value: number | undefined): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
const normalized = Math.trunc(value);
return normalized > 0 ? normalized : null;
}
function collectSpeedMetricsFromLines(lines: string[], ignoreSidechain: boolean): CollectedSpeedMetrics {
const requests: SpeedRequest[] = [];
let lastUserTimestamp: Date | null = null;
let latestTimestampMs: number | null = null;
for (const line of lines) {
const data = parseJsonlLine(line) as TranscriptLine | null;
if (!data || data.isApiErrorMessage) {
continue;
}
if (ignoreSidechain && data.isSidechain === true) {
continue;
}
const entryTimestamp = parseTimestamp(data.timestamp);
if (entryTimestamp) {
const entryTimestampMs = entryTimestamp.getTime();
if (latestTimestampMs === null || entryTimestampMs > latestTimestampMs) {
latestTimestampMs = entryTimestampMs;
}
}
if (data.type === 'user' && entryTimestamp) {
lastUserTimestamp = entryTimestamp;
continue;
}
if (data.type === 'assistant' && data.message?.usage) {
const inputTokens = data.message.usage.input_tokens || 0;
const outputTokens = data.message.usage.output_tokens || 0;
let interval: SpeedInterval | null = null;
if (entryTimestamp && lastUserTimestamp) {
const startMs = lastUserTimestamp.getTime();
const endMs = entryTimestamp.getTime();
if (endMs > startMs) {
interval = { startMs, endMs };
}
}
requests.push({
inputTokens,
outputTokens,
assistantTimestampMs: entryTimestamp ? entryTimestamp.getTime() : null,
interval
});
}
}
return {
requests,
latestTimestampMs
};
}
function mergeCollectedSpeedMetrics(parts: CollectedSpeedMetrics[]): CollectedSpeedMetrics {
const requests: SpeedRequest[] = [];
let latestTimestampMs: number | null = null;
for (const part of parts) {
requests.push(...part.requests);
if (part.latestTimestampMs !== null && (latestTimestampMs === null || part.latestTimestampMs > latestTimestampMs)) {
latestTimestampMs = part.latestTimestampMs;
}
}
return {
requests,
latestTimestampMs
};
}
function buildSpeedMetrics(
collected: CollectedSpeedMetrics,
windowSeconds?: number
): SpeedMetrics {
const normalizedWindowSeconds = normalizeWindowSeconds(windowSeconds);
if (normalizedWindowSeconds !== null && collected.latestTimestampMs === null) {
return createEmptySpeedMetrics();
}
const windowEndMs = normalizedWindowSeconds !== null && collected.latestTimestampMs !== null
? collected.latestTimestampMs
: null;
const windowStartMs = normalizedWindowSeconds !== null && windowEndMs !== null
? windowEndMs - (normalizedWindowSeconds * 1000)
: null;
const selectedRequests = normalizedWindowSeconds !== null && windowStartMs !== null && windowEndMs !== null
? collected.requests.filter(request => request.assistantTimestampMs !== null
&& request.assistantTimestampMs >= windowStartMs
&& request.assistantTimestampMs <= windowEndMs
)
: collected.requests;
let inputTokens = 0;
let outputTokens = 0;
const intervals: SpeedInterval[] = [];
for (const request of selectedRequests) {
inputTokens += request.inputTokens;
outputTokens += request.outputTokens;
if (!request.interval) {
continue;
}
if (windowStartMs === null || windowEndMs === null) {
intervals.push(request.interval);
continue;
}
const clippedStartMs = Math.max(request.interval.startMs, windowStartMs);
const clippedEndMs = Math.min(request.interval.endMs, windowEndMs);
if (clippedEndMs > clippedStartMs) {
intervals.push({
startMs: clippedStartMs,
endMs: clippedEndMs
});
}
}
const mergedIntervals = mergeIntervals(intervals);
const totalDurationMs = getIntervalsDurationMs(mergedIntervals);
return {
totalDurationMs,
inputTokens,
outputTokens,
totalTokens: inputTokens + outputTokens,
requestCount: selectedRequests.length
};
}
function buildEmptyWindowedMetrics(windowSeconds: number[]): Record<string, SpeedMetrics> {
const windowed: Record<string, SpeedMetrics> = {};
for (const window of windowSeconds) {
windowed[window.toString()] = createEmptySpeedMetrics();
}
return windowed;
}
function getSubagentTranscriptPaths(transcriptPath: string, referencedAgentIds: Set<string>): string[] {
if (referencedAgentIds.size === 0) {
return [];
}
const transcriptDir = path.dirname(transcriptPath);
const transcriptStem = path.parse(transcriptPath).name;
const candidateDirs = [
path.join(transcriptDir, 'subagents'),
path.join(transcriptDir, transcriptStem, 'subagents')
];
const seenPaths = new Set<string>();
const matchedPaths: string[] = [];
for (const subagentsDir of candidateDirs) {
if (!fs.existsSync(subagentsDir)) {
continue;
}
try {
const dirEntries = fs.readdirSync(subagentsDir, { withFileTypes: true });
for (const entry of dirEntries) {
if (!entry.isFile()) {
continue;
}
const match = /^agent-(.+)\.jsonl$/.exec(entry.name);
if (!match?.[1]) {
continue;
}
if (!referencedAgentIds.has(match[1])) {
continue;
}
const fullPath = path.join(subagentsDir, entry.name);
if (seenPaths.has(fullPath)) {
continue;
}
seenPaths.add(fullPath);
matchedPaths.push(fullPath);
}
} catch {
continue;
}
}
return matchedPaths;
}
export async function getSpeedMetricsCollection(
transcriptPath: string,
options: SpeedMetricsCollectionOptions = {}
): Promise<SpeedMetricsCollection> {
const normalizedWindows = Array.from(
new Set(
(options.windowSeconds ?? [])
.map(window => normalizeWindowSeconds(window))
.filter((window): window is number => window !== null)
)
);
const emptyWindowedMetrics = buildEmptyWindowedMetrics(normalizedWindows);
try {
if (!fs.existsSync(transcriptPath)) {
return {
sessionAverage: createEmptySpeedMetrics(),
windowed: emptyWindowedMetrics
};
}
const mainLines = await readJsonlLines(transcriptPath);
const allCollected: CollectedSpeedMetrics[] = [
collectSpeedMetricsFromLines(mainLines, true)
];
if (options.includeSubagents === true) {
const referencedSubagentIds = getReferencedSubagentIds(mainLines);
const subagentPaths = getSubagentTranscriptPaths(transcriptPath, referencedSubagentIds);
const subagentMetricsResults = await Promise.all(subagentPaths.map(async (subagentPath) => {
try {
const subagentLines = await readJsonlLines(subagentPath);
return collectSpeedMetricsFromLines(subagentLines, false);
} catch {
return null;
}
}));
for (const subagentMetrics of subagentMetricsResults) {
if (!subagentMetrics) {
continue;
}
allCollected.push(subagentMetrics);
}
}
const combined = mergeCollectedSpeedMetrics(allCollected);
const windowed: Record<string, SpeedMetrics> = {};
for (const window of normalizedWindows) {
windowed[window.toString()] = buildSpeedMetrics(combined, window);
}
return {
sessionAverage: buildSpeedMetrics(combined),
windowed
};
} catch {
return {
sessionAverage: createEmptySpeedMetrics(),
windowed: emptyWindowedMetrics
};
}
}
export async function getSpeedMetrics(
transcriptPath: string,
options: SpeedMetricsOptions = {}
): Promise<SpeedMetrics> {
const requestedWindow = normalizeWindowSeconds(options.windowSeconds);
const metricsCollection = await getSpeedMetricsCollection(transcriptPath, {
includeSubagents: options.includeSubagents,
windowSeconds: requestedWindow ? [requestedWindow] : []
});
if (requestedWindow === null) {
return metricsCollection.sessionAverage;
}
return metricsCollection.windowed[requestedWindow.toString()] ?? createEmptySpeedMetrics();
}
+13 -496
View File
@@ -1,496 +1,13 @@
import * as fs from 'fs';
import { createHash } from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { globSync } from 'tinyglobby';
import { promisify } from 'util';
import type {
BlockMetrics,
TokenMetrics,
TranscriptLine
} from '../types';
import { getClaudeConfigDir } from './claude-settings';
// Ensure fs.promises compatibility for older Node versions
const readFile = promisify(fs.readFile);
const readFileSync = fs.readFileSync;
const statSync = fs.statSync;
const writeFileSync = fs.writeFileSync;
const mkdirSync = fs.mkdirSync;
const existsSync = fs.existsSync;
// --- Block Cache Functions ---
interface BlockCache {
startTime: string;
configDir?: string;
}
function normalizeConfigDir(configDir: string): string {
return path.resolve(configDir);
}
/**
* Returns the path to the block cache file for a specific Claude config directory
*/
export function getBlockCachePath(configDir = getClaudeConfigDir()): string {
const normalizedConfigDir = normalizeConfigDir(configDir);
const configHash = createHash('sha256')
.update(normalizedConfigDir)
.digest('hex')
.slice(0, 16);
return path.join(
os.homedir(),
'.cache',
'ccstatusline',
`block-cache-${configHash}.json`
);
}
/**
* Reads the block cache file and returns the cached start time
* Returns null if cache doesn't exist or is invalid
*/
export function readBlockCache(expectedConfigDir?: string): Date | null {
try {
const normalizedExpectedConfigDir = expectedConfigDir !== undefined
? normalizeConfigDir(expectedConfigDir)
: undefined;
const cachePath = getBlockCachePath(normalizedExpectedConfigDir);
if (!existsSync(cachePath)) {
return null;
}
const content = readFileSync(cachePath, 'utf-8');
const cache = JSON.parse(content) as BlockCache;
if (typeof cache.startTime !== 'string') {
return null;
}
if (normalizedExpectedConfigDir !== undefined) {
if (typeof cache.configDir !== 'string') {
return null;
}
if (cache.configDir !== normalizedExpectedConfigDir) {
return null;
}
}
const date = new Date(cache.startTime);
if (Number.isNaN(date.getTime())) {
return null;
}
return date;
} catch {
return null;
}
}
/**
* Writes the block start time to the cache file
* Creates the cache directory if it doesn't exist
*/
export function writeBlockCache(startTime: Date, configDir = getClaudeConfigDir()): void {
try {
const normalizedConfigDir = normalizeConfigDir(configDir);
const cachePath = getBlockCachePath(normalizedConfigDir);
const cacheDir = path.dirname(cachePath);
if (!existsSync(cacheDir)) {
mkdirSync(cacheDir, { recursive: true });
}
const cache: BlockCache = {
startTime: startTime.toISOString(),
configDir: normalizedConfigDir
};
writeFileSync(cachePath, JSON.stringify(cache), 'utf-8');
} catch {
// Silently fail - caching is best-effort
}
}
/**
* Gets block metrics with caching support
* Returns cached result if still valid, otherwise recalculates
*/
export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | null {
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
const now = new Date();
const activeConfigDir = getClaudeConfigDir();
// Check cache first
const cachedStartTime = readBlockCache(activeConfigDir);
if (cachedStartTime) {
const blockEndTime = new Date(cachedStartTime.getTime() + sessionDurationMs);
if (now.getTime() <= blockEndTime.getTime()) {
// Cache is valid - return cached result
return {
startTime: cachedStartTime,
lastActivity: now // We don't cache lastActivity, use current time
};
}
// Cache expired - need to recalculate
}
// Cache miss or expired - run full calculation
const metrics = getBlockMetrics();
// Write to cache if we found a valid block
if (metrics) {
writeBlockCache(metrics.startTime, activeConfigDir);
}
return metrics;
}
export async function getSessionDuration(transcriptPath: string): Promise<string | null> {
try {
if (!fs.existsSync(transcriptPath)) {
return null;
}
const content = await readFile(transcriptPath, 'utf-8');
const lines = content.trim().split('\n').filter((line: string) => line.trim());
if (lines.length === 0) {
return null;
}
let firstTimestamp: Date | null = null;
let lastTimestamp: Date | null = null;
// Find first valid timestamp
for (const line of lines) {
try {
const data = JSON.parse(line) as { timestamp?: string };
if (data.timestamp) {
firstTimestamp = new Date(data.timestamp);
break;
}
} catch {
// Skip invalid lines
}
}
// Find last valid timestamp (iterate backwards)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const data = JSON.parse(lines[i] ?? '') as { timestamp?: string };
if (data.timestamp) {
lastTimestamp = new Date(data.timestamp);
break;
}
} catch {
// Skip invalid lines
}
}
if (!firstTimestamp || !lastTimestamp) {
return null;
}
// Calculate duration in milliseconds
const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime();
// Convert to minutes
const totalMinutes = Math.floor(durationMs / (1000 * 60));
if (totalMinutes < 1) {
return '<1m';
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours === 0) {
return `${minutes}m`;
} else if (minutes === 0) {
return `${hours}hr`;
} else {
return `${hours}hr ${minutes}m`;
}
} catch {
return null;
}
}
export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetrics> {
try {
// Use Node.js-compatible file reading
if (!fs.existsSync(transcriptPath)) {
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
}
const content = await readFile(transcriptPath, 'utf-8');
const lines = content.trim().split('\n');
let inputTokens = 0;
let outputTokens = 0;
let cachedTokens = 0;
let contextLength = 0;
// Parse each line and sum up token usage for totals
let mostRecentMainChainEntry: TranscriptLine | null = null;
let mostRecentTimestamp: Date | null = null;
for (const line of lines) {
try {
const data = JSON.parse(line) as TranscriptLine;
if (data.message?.usage) {
inputTokens += data.message.usage.input_tokens || 0;
outputTokens += data.message.usage.output_tokens || 0;
cachedTokens += data.message.usage.cache_read_input_tokens ?? 0;
cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0;
// Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain)
// Also skip API error messages (synthetic messages with 0 tokens)
if (data.isSidechain !== true && data.timestamp && !data.isApiErrorMessage) {
const entryTime = new Date(data.timestamp);
if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) {
mostRecentTimestamp = entryTime;
mostRecentMainChainEntry = data;
}
}
}
} catch {
// Skip invalid JSON lines
}
}
// Calculate context length from the most recent main chain message
if (mostRecentMainChainEntry?.message?.usage) {
const usage = mostRecentMainChainEntry.message.usage;
contextLength = (usage.input_tokens || 0)
+ (usage.cache_read_input_tokens ?? 0)
+ (usage.cache_creation_input_tokens ?? 0);
}
const totalTokens = inputTokens + outputTokens + cachedTokens;
return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength };
} catch {
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
}
}
/**
* Gets block metrics for the current 5-hour block from JSONL files
*/
export function getBlockMetrics(): BlockMetrics | null {
const claudeDir: string | null = getClaudeConfigDir();
if (!claudeDir)
return null;
try {
return findMostRecentBlockStartTime(claudeDir);
} catch {
return null;
}
}
/**
* Efficiently finds the most recent 5-hour block start time from JSONL files
* Uses file modification times as hints to avoid unnecessary reads
*/
function findMostRecentBlockStartTime(
rootDir: string,
sessionDurationHours = 5
): BlockMetrics | null {
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
const now = new Date();
// Step 1: Find all JSONL files with their modification times
// Use forward slashes for glob patterns on all platforms (tinyglobby requirement)
const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl');
const files = globSync([pattern], {
absolute: true, // Ensure we get absolute paths
cwd: rootDir // Set working directory to rootDir
});
if (files.length === 0)
return null;
// Step 2: Get file stats and sort by modification time (most recent first)
const filesWithStats = files.map((file) => {
const stats = statSync(file);
return { file, mtime: stats.mtime };
});
filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
// Step 3: Progressive lookback - start small and expand if needed
// Start with 2x session duration (10 hours), expand to 48 hours if needed
const lookbackChunks = [
10, // 2x session duration - catches most cases
20, // 4x session duration - catches longer sessions
48 // Maximum lookback for marathon sessions
];
let timestamps: Date[] = [];
let mostRecentTimestamp: Date | null = null;
let continuousWorkStart: Date | null = null;
let foundSessionGap = false;
for (const lookbackHours of lookbackChunks) {
const cutoffTime = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
timestamps = [];
// Collect timestamps for this lookback period
for (const { file, mtime } of filesWithStats) {
if (mtime.getTime() < cutoffTime.getTime()) {
break;
}
const fileTimestamps = getAllTimestampsFromFile(file);
timestamps.push(...fileTimestamps);
}
if (timestamps.length === 0) {
continue; // Try next chunk
}
// Sort timestamps (most recent first)
timestamps.sort((a, b) => b.getTime() - a.getTime());
// Get most recent timestamp (only set once)
if (!mostRecentTimestamp && timestamps[0]) {
mostRecentTimestamp = timestamps[0];
// Check if the most recent activity is within the current session period
const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime();
if (timeSinceLastActivity > sessionDurationMs) {
// No activity within the current session period
return null;
}
}
// Look for a session gap in this chunk
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) {
// Found a true session boundary
foundSessionGap = true;
break;
}
continuousWorkStart = currentTimestamp;
}
// If we found a gap, we're done
if (foundSessionGap) {
break;
}
// If this was our last chunk, use what we have
if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) {
break;
}
}
if (!mostRecentTimestamp || !continuousWorkStart) {
return null;
}
// Build actual blocks from timestamps going forward
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())) {
// Start new block
currentBlockStart = floorToHour(timestamp);
currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs);
blocks.push({ start: currentBlockStart, end: currentBlockEnd });
}
}
// Find current block
for (const block of blocks) {
if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) {
// Verify we have activity in this block
const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime()
&& t.getTime() <= block.end.getTime()
);
if (hasActivity) {
return {
startTime: block.start,
lastActivity: mostRecentTimestamp
};
}
}
}
return null;
}
/**
* Gets all timestamps from a JSONL file
*/
function getAllTimestampsFromFile(filePath: string): Date[] {
const timestamps: Date[] = [];
try {
const content = readFileSync(filePath, 'utf-8');
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 } };
};
// Only treat entries with real token usage as block activity
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 {
// Skip invalid JSON lines
continue;
}
}
return timestamps;
} catch {
return [];
}
}
/**
* Floors a timestamp to the beginning of the hour (matching existing logic)
*/
function floorToHour(timestamp: Date): Date {
const floored = new Date(timestamp);
floored.setUTCMinutes(0, 0, 0);
return floored;
}
export {
getBlockCachePath,
getCachedBlockMetrics,
readBlockCache,
writeBlockCache
} from './jsonl-cache';
export { getBlockMetrics } from './jsonl-blocks';
export {
getSessionDuration,
getSpeedMetrics,
getSpeedMetricsCollection,
getTokenMetrics
} from './jsonl-metrics';
+108 -49
View File
@@ -10,11 +10,116 @@ interface Migration {
migrate: (data: Record<string, unknown>) => Record<string, unknown>;
}
type V1MigratedField
= | 'flexMode'
| 'compactThreshold'
| 'colorLevel'
| 'defaultSeparator'
| 'defaultPadding'
| 'inheritSeparatorColors'
| 'overrideBackgroundColor'
| 'overrideForegroundColor'
| 'globalBold';
interface V1FieldRule {
key: V1MigratedField;
isValid: (value: unknown) => boolean;
}
// Type guards for checking data structure
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
const V1_FIELD_RULES: V1FieldRule[] = [
{
key: 'flexMode',
isValid: value => typeof value === 'string'
},
{
key: 'compactThreshold',
isValid: value => typeof value === 'number'
},
{
key: 'colorLevel',
isValid: value => typeof value === 'number'
},
{
key: 'defaultSeparator',
isValid: value => typeof value === 'string'
},
{
key: 'defaultPadding',
isValid: value => typeof value === 'string'
},
{
key: 'inheritSeparatorColors',
isValid: value => typeof value === 'boolean'
},
{
key: 'overrideBackgroundColor',
isValid: value => typeof value === 'string'
},
{
key: 'overrideForegroundColor',
isValid: value => typeof value === 'string'
},
{
key: 'globalBold',
isValid: value => typeof value === 'boolean'
}
];
function toWidgetLine(line: unknown[], stripSeparators: boolean): WidgetItem[] {
const lineToProcess = stripSeparators
? line.filter((item) => {
if (isRecord(item)) {
return item.type !== 'separator';
}
return true;
})
: line;
const typedLine: WidgetItem[] = [];
for (const item of lineToProcess) {
if (isRecord(item) && typeof item.type === 'string') {
typedLine.push({
...item,
id: generateGuid(),
type: item.type
} as WidgetItem);
}
}
return typedLine;
}
function migrateV1Lines(data: Record<string, unknown>): WidgetItem[][] | undefined {
if (!Array.isArray(data.lines)) {
return undefined;
}
const stripSeparators = Boolean(data.defaultSeparator);
const processedLines: WidgetItem[][] = [];
for (const line of data.lines) {
if (Array.isArray(line)) {
processedLines.push(toWidgetLine(line, stripSeparators));
}
}
return processedLines;
}
function copyV1Fields(data: Record<string, unknown>, target: Record<string, unknown>): void {
for (const rule of V1_FIELD_RULES) {
const value = data[rule.key];
if (rule.isValid(value)) {
target[rule.key] = value;
}
}
}
// Define all migrations here
export const migrations: Migration[] = [
{
@@ -26,59 +131,13 @@ export const migrations: Migration[] = [
const migrated: Record<string, unknown> = {};
// Process lines: strip separators if needed and assign GUIDs
if (data.lines && Array.isArray(data.lines)) {
const processedLines: WidgetItem[][] = [];
for (const line of data.lines) {
if (Array.isArray(line)) {
// Filter out separators if defaultSeparator is enabled
let processedLine = line;
if (data.defaultSeparator) {
processedLine = line.filter((item: unknown) => {
if (isRecord(item)) {
return item.type !== 'separator';
}
return true;
});
}
// Assign GUIDs to all items and build typed array
const typedLine: WidgetItem[] = [];
for (const item of processedLine) {
if (isRecord(item) && typeof item.type === 'string') {
typedLine.push({
...item,
id: generateGuid(),
type: item.type
} as WidgetItem);
}
}
processedLines.push(typedLine);
}
}
const processedLines = migrateV1Lines(data);
if (processedLines) {
migrated.lines = processedLines;
}
// Copy all v1 fields that exist
if (typeof data.flexMode === 'string')
migrated.flexMode = data.flexMode;
if (typeof data.compactThreshold === 'number')
migrated.compactThreshold = data.compactThreshold;
if (typeof data.colorLevel === 'number')
migrated.colorLevel = data.colorLevel;
if (typeof data.defaultSeparator === 'string')
migrated.defaultSeparator = data.defaultSeparator;
if (typeof data.defaultPadding === 'string')
migrated.defaultPadding = data.defaultPadding;
if (typeof data.inheritSeparatorColors === 'boolean')
migrated.inheritSeparatorColors = data.inheritSeparatorColors;
if (typeof data.overrideBackgroundColor === 'string')
migrated.overrideBackgroundColor = data.overrideBackgroundColor;
if (typeof data.overrideForegroundColor === 'string')
migrated.overrideForegroundColor = data.overrideForegroundColor;
if (typeof data.globalBold === 'boolean')
migrated.globalBold = data.globalBold;
copyV1Fields(data, migrated);
// Add version field for v2
migrated.version = 2;
+72 -11
View File
@@ -3,6 +3,14 @@ interface ModelContextConfig {
usableTokens: number;
}
interface ModelIdentifier {
id?: string;
display_name?: string;
}
const DEFAULT_CONTEXT_WINDOW_SIZE = 200000;
const USABLE_CONTEXT_RATIO = 0.8;
function toValidWindowSize(value: number | null | undefined): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return null;
@@ -11,33 +19,86 @@ function toValidWindowSize(value: number | null | undefined): number | null {
return value;
}
export function getContextConfig(modelId?: string, contextWindowSize?: number | null): ModelContextConfig {
function parseContextWindowSize(modelIdentifier: string): number | null {
const delimitedMatch = /(?:\(|\[)\s*(\d+(?:[,_]\d+)*(?:\.\d+)?)\s*([km])\s*(?:\)|\])/i.exec(modelIdentifier);
if (delimitedMatch) {
const delimitedValue = delimitedMatch[1];
const delimitedUnit = delimitedMatch[2];
if (!delimitedValue || !delimitedUnit) {
return null;
}
const parsed = Number.parseFloat(delimitedValue.replace(/[,_]/g, ''));
if (Number.isFinite(parsed) && parsed > 0) {
return Math.round(parsed * (delimitedUnit.toLowerCase() === 'm' ? 1000000 : 1000));
}
}
const contextMatch = /\b(\d+(?:[,_]\d+)*(?:\.\d+)?)\s*([km])(?:\s*(?:token\s*)?context)?\b/i.exec(modelIdentifier);
if (!contextMatch) {
return null;
}
const contextValue = contextMatch[1];
const contextUnit = contextMatch[2];
if (!contextValue || !contextUnit) {
return null;
}
const parsed = Number.parseFloat(contextValue.replace(/[,_]/g, ''));
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.round(parsed * (contextUnit.toLowerCase() === 'm' ? 1000000 : 1000));
}
export function getModelContextIdentifier(model?: string | ModelIdentifier): string | undefined {
if (typeof model === 'string') {
const trimmed = model.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (!model) {
return undefined;
}
const id = model.id?.trim();
const displayName = model.display_name?.trim();
if (id && displayName) {
return `${id} ${displayName}`;
}
return id ?? displayName;
}
export function getContextConfig(modelIdentifier?: string, contextWindowSize?: number | null): ModelContextConfig {
const statusWindowSize = toValidWindowSize(contextWindowSize);
if (statusWindowSize !== null) {
return {
maxTokens: statusWindowSize,
usableTokens: Math.floor(statusWindowSize * 0.8)
usableTokens: Math.floor(statusWindowSize * USABLE_CONTEXT_RATIO)
};
}
// Default to 200k for older models
const defaultConfig = {
maxTokens: 200000,
usableTokens: 160000
maxTokens: DEFAULT_CONTEXT_WINDOW_SIZE,
usableTokens: Math.floor(DEFAULT_CONTEXT_WINDOW_SIZE * USABLE_CONTEXT_RATIO)
};
if (!modelId)
if (!modelIdentifier) {
return defaultConfig;
}
// Any model with [1m] suffix has 1M context
if (modelId.toLowerCase().includes('[1m]')) {
const inferredWindowSize = parseContextWindowSize(modelIdentifier);
if (inferredWindowSize !== null) {
return {
maxTokens: 1000000,
usableTokens: 800000 // 80% of 1M
maxTokens: inferredWindowSize,
usableTokens: Math.floor(inferredWindowSize * USABLE_CONTEXT_RATIO)
};
}
// Add future models here as needed
return defaultConfig;
}
+51 -24
View File
@@ -6,6 +6,12 @@ export interface OpenExternalUrlResult {
error?: string;
}
interface OpenCommandPlan {
command: string;
args: (url: string) => string[];
errorPrefix?: string;
}
function runOpenCommand(command: string, args: string[]): string | null {
const result = spawnSync(command, args, {
stdio: 'ignore',
@@ -27,6 +33,33 @@ function runOpenCommand(command: string, args: string[]): string | null {
return null;
}
const PLATFORM_OPEN_PLANS: Record<string, OpenCommandPlan[]> = {
darwin: [
{
command: 'open',
args: url => [url]
}
],
win32: [
{
command: 'cmd',
args: url => ['/c', 'start', '', url]
}
],
linux: [
{
command: 'xdg-open',
args: url => [url],
errorPrefix: 'xdg-open failed: '
},
{
command: 'gio',
args: url => ['open', url],
errorPrefix: 'gio open failed: '
}
]
};
export function openExternalUrl(url: string): OpenExternalUrlResult {
let parsedUrl: URL;
@@ -47,36 +80,30 @@ export function openExternalUrl(url: string): OpenExternalUrlResult {
}
const platform = os.platform();
if (platform === 'darwin') {
const commandError = runOpenCommand('open', [url]);
return commandError ? { success: false, error: commandError } : { success: true };
}
if (platform === 'win32') {
const commandError = runOpenCommand('cmd', ['/c', 'start', '', url]);
return commandError ? { success: false, error: commandError } : { success: true };
}
if (platform === 'linux') {
const xdgError = runOpenCommand('xdg-open', [url]);
if (!xdgError) {
return { success: true };
}
const gioError = runOpenCommand('gio', ['open', url]);
if (!gioError) {
return { success: true };
}
const plans = PLATFORM_OPEN_PLANS[platform];
if (!plans) {
return {
success: false,
error: `xdg-open failed: ${xdgError}; gio open failed: ${gioError}`
error: `Unsupported platform: ${platform}`
};
}
const errors: string[] = [];
for (const plan of plans) {
const commandError = runOpenCommand(plan.command, plan.args(url));
if (!commandError) {
return { success: true };
}
if (plan.errorPrefix) {
errors.push(`${plan.errorPrefix}${commandError}`);
} else {
errors.push(commandError);
}
}
return {
success: false,
error: `Unsupported platform: ${platform}`
error: errors.join('; ')
};
}
+32
View File
@@ -0,0 +1,32 @@
import type { Settings } from '../types/Settings';
import { getDefaultPowerlineTheme } from './colors';
function resolveEnabledPowerlineTheme(theme: string | undefined): string {
if (!theme || theme === 'custom') {
return getDefaultPowerlineTheme();
}
return theme;
}
export function buildEnabledPowerlineSettings(settings: Settings, removeManualSeparators: boolean): Settings {
const powerlineConfig = settings.powerline;
const lines = removeManualSeparators
? settings.lines.map(line => line.filter(item => item.type !== 'separator' && item.type !== 'flex-separator'))
: settings.lines;
return {
...settings,
powerline: {
...powerlineConfig,
enabled: true,
theme: resolveEnabledPowerlineTheme(powerlineConfig.theme),
// Separators are initialized by schema defaults, preserve existing values.
separators: powerlineConfig.separators,
separatorInvertBackground: powerlineConfig.separatorInvertBackground
},
defaultPadding: ' ',
lines
};
}
+43 -68
View File
@@ -31,6 +31,47 @@ export function formatTokens(count: number): string {
return count.toString();
}
function resolveEffectiveTerminalWidth(
detectedWidth: number | null,
settings: Settings,
context: RenderContext
): number | null {
if (!detectedWidth) {
return null;
}
const flexMode = settings.flexMode as string;
if (context.isPreview) {
if (flexMode === 'full') {
return detectedWidth - 6;
}
if (flexMode === 'full-minus-40') {
return detectedWidth - 40;
}
if (flexMode === 'full-until-compact') {
return detectedWidth - 6;
}
return null;
}
if (flexMode === 'full') {
return detectedWidth - 6;
}
if (flexMode === 'full-minus-40') {
return detectedWidth - 40;
}
if (flexMode === 'full-until-compact') {
const threshold = settings.compactThreshold;
const contextPercentage = calculateContextPercentage(context);
return contextPercentage >= threshold
? detectedWidth - 40
: detectedWidth - 6;
}
return null;
}
function renderPowerlineStatusLine(
widgets: WidgetItem[],
settings: Settings,
@@ -82,37 +123,7 @@ function renderPowerlineStatusLine(
const detectedWidth = context.terminalWidth ?? getTerminalWidth();
// Calculate terminal width based on flex mode settings
let terminalWidth: number | null = null;
if (detectedWidth) {
const flexMode = settings.flexMode as string;
if (context.isPreview) {
// In preview mode, account for box borders and padding (6 chars total)
if (flexMode === 'full') {
terminalWidth = detectedWidth - 6;
} else if (flexMode === 'full-minus-40') {
terminalWidth = detectedWidth - 40;
} else if (flexMode === 'full-until-compact') {
terminalWidth = detectedWidth - 6;
}
} else {
// In actual rendering mode
if (flexMode === 'full') {
terminalWidth = detectedWidth - 6;
} else if (flexMode === 'full-minus-40') {
terminalWidth = detectedWidth - 40;
} else if (flexMode === 'full-until-compact') {
const threshold = settings.compactThreshold;
const contextPercentage = calculateContextPercentage(context);
if (contextPercentage >= threshold) {
terminalWidth = detectedWidth - 40;
} else {
terminalWidth = detectedWidth - 6;
}
}
}
}
const terminalWidth = resolveEffectiveTerminalWidth(detectedWidth, settings, context);
// Build widget elements (similar to regular mode but without separators)
const widgetElements: { content: string; bgColor?: string; fgColor?: string; widget: WidgetItem }[] = [];
@@ -632,43 +643,7 @@ export function renderStatusLine(
const detectedWidth = context.terminalWidth ?? getTerminalWidth();
// Calculate terminal width based on flex mode settings
let terminalWidth: number | null = null;
if (detectedWidth) {
const flexMode = settings.flexMode as string;
if (context.isPreview) {
// In preview mode, account for box borders and padding (6 chars total)
if (flexMode === 'full') {
terminalWidth = detectedWidth - 6; // Subtract 6 for box borders and padding in preview
} else if (flexMode === 'full-minus-40') {
terminalWidth = detectedWidth - 40; // -40 for auto-compact + 3 for preview
} else if (flexMode === 'full-until-compact') {
// For preview, always show full width minus preview padding
terminalWidth = detectedWidth - 6;
}
} else {
// In actual rendering mode
if (flexMode === 'full') {
// Use full width minus 4 for terminal padding
terminalWidth = detectedWidth - 6;
} else if (flexMode === 'full-minus-40') {
// Always subtract 41 for auto-compact message
terminalWidth = detectedWidth - 40;
} else if (flexMode === 'full-until-compact') {
// Check context percentage to decide
const threshold = settings.compactThreshold;
const contextPercentage = calculateContextPercentage(context);
if (contextPercentage >= threshold) {
// Context is high, leave space for auto-compact
terminalWidth = detectedWidth - 40;
} else {
// Context is low, use full width minus 4 for padding
terminalWidth = detectedWidth - 6;
}
}
}
}
const terminalWidth = resolveEffectiveTerminalWidth(detectedWidth, settings, context);
const elements: { content: string; type: string; widget?: WidgetItem }[] = [];
let hasFlexSeparator = false;
+10
View File
@@ -0,0 +1,10 @@
import type { WidgetItem } from '../types/Widget';
export function countSeparatorSlots(widgets: WidgetItem[]): number {
const nonMergedWidgets = widgets.filter((_, idx) => idx === widgets.length - 1 || !widgets[idx]?.merge);
return Math.max(0, nonMergedWidgets.length - 1);
}
export function advanceGlobalSeparatorIndex(currentIndex: number, widgets: WidgetItem[]): number {
return currentIndex + countSeparatorSlots(widgets);
}
+58
View File
@@ -0,0 +1,58 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type {
SkillInvocation,
SkillsMetrics
} from '../types/SkillsMetrics';
const EMPTY: SkillsMetrics = { totalInvocations: 0, uniqueSkills: [], lastSkill: null };
function getSkillsDir(): string {
return path.join(os.homedir(), '.cache', 'ccstatusline', 'skills');
}
export function getSkillsFilePath(sessionId: string): string {
return path.join(getSkillsDir(), `skills-${sessionId}.jsonl`);
}
export function getSkillsMetrics(sessionId: string): SkillsMetrics {
const filePath = getSkillsFilePath(sessionId);
if (!fs.existsSync(filePath)) {
return EMPTY;
}
try {
const invocations: SkillInvocation[] = fs.readFileSync(filePath, 'utf-8')
.trim().split('\n')
.filter(line => line.trim())
.map((line) => {
try { return JSON.parse(line) as SkillInvocation; } catch {
return null;
}
})
.filter((e): e is SkillInvocation => e !== null && typeof e.skill === 'string' && typeof e.session_id === 'string');
if (invocations.length === 0) {
return EMPTY;
}
const uniqueSkills: string[] = [];
const seenSkills = new Set<string>();
for (let i = invocations.length - 1; i >= 0; i--) {
const skill = invocations[i]?.skill;
if (skill && !seenSkills.has(skill)) {
seenSkills.add(skill);
uniqueSkills.push(skill);
}
}
return {
totalInvocations: invocations.length,
uniqueSkills,
lastSkill: invocations[invocations.length - 1]?.skill ?? null
};
} catch {
return EMPTY;
}
}
+62
View File
@@ -0,0 +1,62 @@
import type { SpeedMetrics } from '../types/SpeedMetrics';
/**
* Calculates output tokens per second from speed metrics.
*
* @param metrics SpeedMetrics containing timing and token data
* @returns Output tokens per second, or null if duration is zero
*/
export function calculateOutputSpeed(metrics: SpeedMetrics): number | null {
if (metrics.totalDurationMs === 0) {
return null;
}
const seconds = metrics.totalDurationMs / 1000;
return metrics.outputTokens / seconds;
}
/**
* Calculates input tokens per second from speed metrics.
*
* @param metrics SpeedMetrics containing timing and token data
* @returns Input tokens per second, or null if duration is zero
*/
export function calculateInputSpeed(metrics: SpeedMetrics): number | null {
if (metrics.totalDurationMs === 0) {
return null;
}
const seconds = metrics.totalDurationMs / 1000;
return metrics.inputTokens / seconds;
}
/**
* Calculates total tokens per second from speed metrics.
*
* @param metrics SpeedMetrics containing timing and token data
* @returns Total tokens per second, or null if duration is zero
*/
export function calculateTotalSpeed(metrics: SpeedMetrics): number | null {
if (metrics.totalDurationMs === 0) {
return null;
}
const seconds = metrics.totalDurationMs / 1000;
return metrics.totalTokens / seconds;
}
/**
* Formats a tokens per second value for display.
*
* @param tokensPerSec Tokens per second value, or null if unavailable
* @returns Formatted string (e.g., "42.5 t/s", "1.2k t/s", or "—" for null)
*/
export function formatSpeed(tokensPerSec: number | null): string {
if (tokensPerSec === null) {
return '—';
}
if (tokensPerSec >= 1000) {
const kValue = tokensPerSec / 1000;
return `${kValue.toFixed(1)}k t/s`;
}
return `${tokensPerSec.toFixed(1)} t/s`;
}
+49
View File
@@ -0,0 +1,49 @@
import type { WidgetItem } from '../types/Widget';
export const SPEED_WINDOW_METADATA_KEY = 'windowSeconds';
export const DEFAULT_SPEED_WINDOW_SECONDS = 0;
export const MIN_SPEED_WINDOW_SECONDS = 0;
export const MAX_SPEED_WINDOW_SECONDS = 120;
export function clampSpeedWindowSeconds(value: number): number {
if (!Number.isFinite(value)) {
return DEFAULT_SPEED_WINDOW_SECONDS;
}
const normalized = Math.trunc(value);
if (normalized < MIN_SPEED_WINDOW_SECONDS) {
return MIN_SPEED_WINDOW_SECONDS;
}
if (normalized > MAX_SPEED_WINDOW_SECONDS) {
return MAX_SPEED_WINDOW_SECONDS;
}
return normalized;
}
export function getWidgetSpeedWindowSeconds(item: WidgetItem): number {
const metadataValue = item.metadata?.[SPEED_WINDOW_METADATA_KEY];
if (!metadataValue) {
return DEFAULT_SPEED_WINDOW_SECONDS;
}
const parsed = Number.parseInt(metadataValue, 10);
if (!Number.isFinite(parsed)) {
return DEFAULT_SPEED_WINDOW_SECONDS;
}
return clampSpeedWindowSeconds(parsed);
}
export function isWidgetSpeedWindowEnabled(item: WidgetItem): boolean {
return getWidgetSpeedWindowSeconds(item) > 0;
}
export function withWidgetSpeedWindowSeconds(item: WidgetItem, seconds: number): WidgetItem {
return {
...item,
metadata: {
...(item.metadata ?? {}),
[SPEED_WINDOW_METADATA_KEY]: clampSpeedWindowSeconds(seconds).toString()
}
};
}
+7 -47
View File
@@ -32,8 +32,7 @@ export function getPackageVersion(): string {
return '';
}
// Get terminal width
export function getTerminalWidth(): number | null {
function probeTerminalWidth(): number | null {
// Preserve historical behavior on Windows: width detection is unavailable.
// This avoids Unix fallback command behavior (e.g. 2>/dev/null) on Windows.
if (process.platform === 'win32') {
@@ -87,51 +86,12 @@ export function getTerminalWidth(): number | null {
return null;
}
// Get terminal width
export function getTerminalWidth(): number | null {
return probeTerminalWidth();
}
// Check if terminal width detection is available
export function canDetectTerminalWidth(): boolean {
// Preserve historical behavior on Windows: width detection is unavailable.
if (process.platform === 'win32') {
return false;
}
try {
// First try to get the tty of the parent process
const tty = execSync('ps -o tty= -p $(ps -o ppid= -p $$)', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
shell: '/bin/sh'
}).trim();
// Check if we got a valid tty
if (tty && tty !== '??' && tty !== '?') {
const width = execSync(
`stty size < /dev/${tty} | awk '{print $2}'`,
{
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
shell: '/bin/sh'
}
).trim();
const parsed = parseInt(width, 10);
if (!isNaN(parsed) && parsed > 0) {
return true;
}
}
} catch {
// Try fallback
}
// Fallback: try tput cols
try {
const width = execSync('tput cols 2>/dev/null', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
const parsed = parseInt(width, 10);
return !isNaN(parsed) && parsed > 0;
} catch {
return false;
}
return probeTerminalWidth() !== null;
}
+435
View File
@@ -0,0 +1,435 @@
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as https from 'https';
import { HttpsProxyAgent } from 'https-proxy-agent';
import * as os from 'os';
import * as path from 'path';
import { z } from 'zod';
import { getClaudeConfigDir } from './claude-settings';
import type {
UsageData,
UsageError
} from './usage-types';
import { UsageErrorSchema } from './usage-types';
// Cache configuration
const CACHE_DIR = path.join(os.homedir(), '.cache', 'ccstatusline');
const CACHE_FILE = path.join(CACHE_DIR, 'usage.json');
const LOCK_FILE = path.join(CACHE_DIR, 'usage.lock');
const CACHE_MAX_AGE = 180; // seconds
const LOCK_MAX_AGE = 30; // rate limit: only try API once per 30 seconds
const DEFAULT_RATE_LIMIT_BACKOFF = 300; // seconds
const TOKEN_CACHE_MAX_AGE = 3600; // 1 hour
const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() });
const UsageLockErrorSchema = z.enum(['timeout', 'rate-limited']);
const UsageLockSchema = z.object({
blockedUntil: z.number(),
error: UsageLockErrorSchema.optional()
});
const CachedUsageDataSchema = z.object({
sessionUsage: z.number().nullable().optional(),
sessionResetAt: z.string().nullable().optional(),
weeklyUsage: z.number().nullable().optional(),
weeklyResetAt: z.string().nullable().optional(),
extraUsageEnabled: z.boolean().nullable().optional(),
extraUsageLimit: z.number().nullable().optional(),
extraUsageUsed: z.number().nullable().optional(),
extraUsageUtilization: z.number().nullable().optional(),
error: z.string().nullable().optional()
});
const UsageApiResponseSchema = z.object({
five_hour: z.object({
utilization: z.number().nullable().optional(),
resets_at: z.string().nullable().optional()
}).optional(),
seven_day: z.object({
utilization: z.number().nullable().optional(),
resets_at: z.string().nullable().optional()
}).optional(),
extra_usage: z.object({
is_enabled: z.boolean().nullable().optional(),
monthly_limit: z.number().nullable().optional(),
used_credits: z.number().nullable().optional(),
utilization: z.number().nullable().optional()
}).optional()
});
function parseJsonWithSchema<T>(rawJson: string, schema: z.ZodType<T>): T | null {
try {
const parsed = schema.safeParse(JSON.parse(rawJson));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
function parseUsageAccessToken(rawJson: string): string | null {
const parsed = parseJsonWithSchema(rawJson, UsageCredentialsSchema);
return parsed?.claudeAiOauth?.accessToken ?? null;
}
function parseCachedUsageData(rawJson: string): UsageData | null {
const parsed = parseJsonWithSchema(rawJson, CachedUsageDataSchema);
if (!parsed) {
return null;
}
const parsedError = UsageErrorSchema.safeParse(parsed.error);
return {
sessionUsage: parsed.sessionUsage ?? undefined,
sessionResetAt: parsed.sessionResetAt ?? undefined,
weeklyUsage: parsed.weeklyUsage ?? undefined,
weeklyResetAt: parsed.weeklyResetAt ?? undefined,
extraUsageEnabled: parsed.extraUsageEnabled ?? undefined,
extraUsageLimit: parsed.extraUsageLimit ?? undefined,
extraUsageUsed: parsed.extraUsageUsed ?? undefined,
extraUsageUtilization: parsed.extraUsageUtilization ?? undefined,
error: parsedError.success ? parsedError.data : undefined
};
}
function parseUsageApiResponse(rawJson: string): UsageData | null {
const parsed = parseJsonWithSchema(rawJson, UsageApiResponseSchema);
if (!parsed) {
return null;
}
return {
sessionUsage: parsed.five_hour?.utilization ?? undefined,
sessionResetAt: parsed.five_hour?.resets_at ?? undefined,
weeklyUsage: parsed.seven_day?.utilization ?? undefined,
weeklyResetAt: parsed.seven_day?.resets_at ?? undefined,
extraUsageEnabled: parsed.extra_usage?.is_enabled ?? undefined,
extraUsageLimit: parsed.extra_usage?.monthly_limit ?? undefined,
extraUsageUsed: parsed.extra_usage?.used_credits ?? undefined,
extraUsageUtilization: parsed.extra_usage?.utilization ?? undefined
};
}
// Memory caches
let cachedUsageData: UsageData | null = null;
let usageCacheTime = 0;
let cachedUsageToken: string | null = null;
let usageTokenCacheTime = 0;
let usageErrorCacheMaxAge = LOCK_MAX_AGE;
type UsageLockError = z.infer<typeof UsageLockErrorSchema>;
type UsageApiFetchResult = { kind: 'success'; body: string } | { kind: 'rate-limited'; retryAfterSeconds: number } | { kind: 'error' };
function ensureCacheDirExists(): void {
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
}
}
function setCachedUsageError(error: UsageError, now: number, maxAge = LOCK_MAX_AGE): UsageData {
const errorData: UsageData = { error };
cachedUsageData = errorData;
usageCacheTime = now;
usageErrorCacheMaxAge = maxAge;
return errorData;
}
function cacheUsageData(data: UsageData, now: number): UsageData {
cachedUsageData = data;
usageCacheTime = now;
usageErrorCacheMaxAge = LOCK_MAX_AGE;
return data;
}
function getStaleUsageOrError(error: UsageError, now: number, errorCacheMaxAge = LOCK_MAX_AGE): UsageData {
const stale = readStaleUsageCache();
if (stale && !stale.error) {
return cacheUsageData(stale, now);
}
return setCachedUsageError(error, now, errorCacheMaxAge);
}
function getUsageToken(): string | null {
const now = Math.floor(Date.now() / 1000);
// Return cached token if still valid
if (cachedUsageToken && (now - usageTokenCacheTime) < TOKEN_CACHE_MAX_AGE) {
return cachedUsageToken;
}
try {
const isMac = process.platform === 'darwin';
if (isMac) {
// macOS: read from keychain
const result = execSync(
'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null',
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
const token = parseUsageAccessToken(result);
if (token) {
cachedUsageToken = token;
usageTokenCacheTime = now;
}
return token;
}
// Non-macOS: read from credentials file, honoring CLAUDE_CONFIG_DIR
const credFile = path.join(getClaudeConfigDir(), '.credentials.json');
const token = parseUsageAccessToken(fs.readFileSync(credFile, 'utf8'));
if (token) {
cachedUsageToken = token;
usageTokenCacheTime = now;
}
return token;
} catch {
return null;
}
}
function readStaleUsageCache(): UsageData | null {
try {
return parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8'));
} catch {
return null;
}
}
function writeUsageLock(blockedUntil: number, error: UsageLockError): void {
try {
ensureCacheDirExists();
fs.writeFileSync(LOCK_FILE, JSON.stringify({ blockedUntil, error }));
} catch {
// Ignore lock file errors
}
}
function readActiveUsageLock(now: number): { blockedUntil: number; error: UsageLockError } | null {
let hasValidJsonLock = false;
try {
const parsed = parseJsonWithSchema(fs.readFileSync(LOCK_FILE, 'utf8'), UsageLockSchema);
if (parsed) {
hasValidJsonLock = true;
if (parsed.blockedUntil > now) {
return {
blockedUntil: parsed.blockedUntil,
error: parsed.error ?? 'timeout'
};
}
return null;
}
} catch {
// Fall back to the legacy mtime-based lock behavior below.
}
if (hasValidJsonLock) {
return null;
}
try {
const lockStat = fs.statSync(LOCK_FILE);
const lockMtime = Math.floor(lockStat.mtimeMs / 1000);
const blockedUntil = lockMtime + LOCK_MAX_AGE;
if (blockedUntil > now) {
return {
blockedUntil,
error: 'timeout'
};
}
} catch {
// Lock file doesn't exist - OK to proceed
}
return null;
}
function parseRetryAfterSeconds(headerValue: string | string[] | undefined, nowMs = Date.now()): number | null {
const rawValue = Array.isArray(headerValue) ? headerValue[0] : headerValue;
const trimmedValue = rawValue?.trim();
if (!trimmedValue) {
return null;
}
if (/^\d+$/.test(trimmedValue)) {
const seconds = Number.parseInt(trimmedValue, 10);
return seconds > 0 ? seconds : null;
}
const retryAtMs = Date.parse(trimmedValue);
if (Number.isNaN(retryAtMs)) {
return null;
}
const retryAfterSeconds = Math.ceil((retryAtMs - nowMs) / 1000);
return retryAfterSeconds > 0 ? retryAfterSeconds : null;
}
const USAGE_API_HOST = 'api.anthropic.com';
const USAGE_API_PATH = '/api/oauth/usage';
const USAGE_API_TIMEOUT_MS = 5000;
function getUsageApiProxyUrl(): string | null {
const proxyUrl = process.env.HTTPS_PROXY?.trim();
if (proxyUrl === '') {
return null;
}
return proxyUrl ?? null;
}
function getUsageApiRequestOptions(token: string): https.RequestOptions | null {
const proxyUrl = getUsageApiProxyUrl();
try {
return {
hostname: USAGE_API_HOST,
path: USAGE_API_PATH,
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'anthropic-beta': 'oauth-2025-04-20'
},
timeout: USAGE_API_TIMEOUT_MS,
...(proxyUrl ? { agent: new HttpsProxyAgent(proxyUrl) } : {})
};
} catch {
return null;
}
}
async function fetchFromUsageApi(token: string): Promise<UsageApiFetchResult> {
return new Promise((resolve) => {
let settled = false;
const finish = (value: UsageApiFetchResult) => {
if (settled) {
return;
}
settled = true;
resolve(value);
};
const requestOptions = getUsageApiRequestOptions(token);
if (!requestOptions) {
finish({ kind: 'error' });
return;
}
const request = https.request(requestOptions, (response) => {
let data = '';
response.setEncoding('utf8');
response.on('data', (chunk: string) => {
data += chunk;
});
response.on('end', () => {
if (response.statusCode === 200 && data) {
finish({ kind: 'success', body: data });
return;
}
if (response.statusCode === 429) {
finish({
kind: 'rate-limited',
retryAfterSeconds: parseRetryAfterSeconds(response.headers['retry-after']) ?? DEFAULT_RATE_LIMIT_BACKOFF
});
return;
}
finish({ kind: 'error' });
});
});
request.on('error', () => { finish({ kind: 'error' }); });
request.on('timeout', () => {
request.destroy();
finish({ kind: 'error' });
});
request.end();
});
}
export async function fetchUsageData(): Promise<UsageData> {
const now = Math.floor(Date.now() / 1000);
// Check memory cache (fast path)
if (cachedUsageData) {
const cacheAge = now - usageCacheTime;
if (!cachedUsageData.error && cacheAge < CACHE_MAX_AGE) {
return cachedUsageData;
}
if (cachedUsageData.error && cacheAge < usageErrorCacheMaxAge) {
return cachedUsageData;
}
}
// Check file cache
try {
const stat = fs.statSync(CACHE_FILE);
const fileAge = now - Math.floor(stat.mtimeMs / 1000);
if (fileAge < CACHE_MAX_AGE) {
const fileData = parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8'));
if (fileData && !fileData.error) {
return cacheUsageData(fileData, now);
}
}
} catch {
// File doesn't exist or read error - continue to API call
}
// Get token before lock/rate-limit checks so auth failures are not masked as timeout.
const token = getUsageToken();
if (!token) {
return getStaleUsageOrError('no-credentials', now);
}
const activeLock = readActiveUsageLock(now);
if (activeLock) {
return getStaleUsageOrError(
activeLock.error,
now,
Math.max(1, activeLock.blockedUntil - now)
);
}
writeUsageLock(now + LOCK_MAX_AGE, 'timeout');
// Fetch from API using Node's https module
try {
const response = await fetchFromUsageApi(token);
if (response.kind === 'rate-limited') {
writeUsageLock(now + response.retryAfterSeconds, 'rate-limited');
return getStaleUsageOrError('rate-limited', now, response.retryAfterSeconds);
}
if (response.kind === 'error') {
return getStaleUsageOrError('api-error', now);
}
const usageData = parseUsageApiResponse(response.body);
if (!usageData) {
return getStaleUsageOrError('parse-error', now);
}
// Validate we got actual data
if (usageData.sessionUsage === undefined && usageData.weeklyUsage === undefined) {
return getStaleUsageOrError('parse-error', now);
}
// Save to cache
try {
ensureCacheDirExists();
fs.writeFileSync(CACHE_FILE, JSON.stringify(usageData));
} catch {
// Ignore cache write errors
}
return cacheUsageData(usageData, now);
} catch {
return getStaleUsageOrError('parse-error', now);
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { WidgetItem } from '../types/Widget';
import type { UsageData } from './usage';
import { fetchUsageData } from './usage';
const USAGE_WIDGET_TYPES = new Set<string>([
'session-usage',
'weekly-usage',
'block-timer',
'reset-timer',
'weekly-reset-timer'
]);
export function hasUsageDependentWidgets(lines: WidgetItem[][]): boolean {
return lines.some(line => line.some(item => USAGE_WIDGET_TYPES.has(item.type)));
}
export async function prefetchUsageDataIfNeeded(lines: WidgetItem[][]): Promise<UsageData | null> {
if (!hasUsageDependentWidgets(lines)) {
return null;
}
return await fetchUsageData();
}
+27
View File
@@ -0,0 +1,27 @@
import { z } from 'zod';
export const FIVE_HOUR_BLOCK_MS = 5 * 60 * 60 * 1000;
export const SEVEN_DAY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
export const UsageErrorSchema = z.enum(['no-credentials', 'timeout', 'rate-limited', 'api-error', 'parse-error']);
export type UsageError = z.infer<typeof UsageErrorSchema>;
export interface UsageData {
sessionUsage?: number; // five_hour.utilization (percentage)
sessionResetAt?: string; // five_hour.resets_at
weeklyUsage?: number; // seven_day.utilization (percentage)
weeklyResetAt?: string; // seven_day.resets_at
extraUsageEnabled?: boolean;
extraUsageLimit?: number; // in cents
extraUsageUsed?: number; // in cents
extraUsageUtilization?: number;
error?: UsageError;
}
export interface UsageWindowMetrics {
sessionDurationMs: number;
elapsedMs: number;
remainingMs: number;
elapsedPercent: number;
remainingPercent: number;
}
+122
View File
@@ -0,0 +1,122 @@
import type { BlockMetrics } from '../types';
import { getCachedBlockMetrics } from './jsonl';
import {
FIVE_HOUR_BLOCK_MS,
SEVEN_DAY_WINDOW_MS,
type UsageData,
type UsageError,
type UsageWindowMetrics
} from './usage-types';
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function buildUsageWindow(resetAtMs: number, nowMs: number, durationMs: number): UsageWindowMetrics | null {
if (!Number.isFinite(resetAtMs) || !Number.isFinite(nowMs) || !Number.isFinite(durationMs) || durationMs <= 0) {
return null;
}
const startAtMs = resetAtMs - durationMs;
const elapsedMs = clamp(nowMs - startAtMs, 0, durationMs);
const remainingMs = durationMs - elapsedMs;
const elapsedPercent = (elapsedMs / durationMs) * 100;
return {
sessionDurationMs: durationMs,
elapsedMs,
remainingMs,
elapsedPercent,
remainingPercent: 100 - elapsedPercent
};
}
export function getUsageWindowFromResetAt(sessionResetAt: string | undefined, nowMs = Date.now()): UsageWindowMetrics | null {
if (!sessionResetAt) {
return null;
}
const resetAtMs = Date.parse(sessionResetAt);
if (Number.isNaN(resetAtMs)) {
return null;
}
return buildUsageWindow(resetAtMs, nowMs, FIVE_HOUR_BLOCK_MS);
}
export function getUsageWindowFromBlockMetrics(blockMetrics: BlockMetrics, nowMs = Date.now()): UsageWindowMetrics | null {
const startAtMs = blockMetrics.startTime.getTime();
if (Number.isNaN(startAtMs)) {
return null;
}
return buildUsageWindow(startAtMs + FIVE_HOUR_BLOCK_MS, nowMs, FIVE_HOUR_BLOCK_MS);
}
export function resolveUsageWindowWithFallback(
usageData: UsageData,
blockMetrics?: BlockMetrics | null,
nowMs = Date.now()
): UsageWindowMetrics | null {
const usageWindow = getUsageWindowFromResetAt(usageData.sessionResetAt, nowMs);
if (usageWindow) {
return usageWindow;
}
const fallbackMetrics = blockMetrics ?? getCachedBlockMetrics();
if (!fallbackMetrics) {
return null;
}
return getUsageWindowFromBlockMetrics(fallbackMetrics, nowMs);
}
export function getWeeklyUsageWindowFromResetAt(weeklyResetAt: string | undefined, nowMs = Date.now()): UsageWindowMetrics | null {
if (!weeklyResetAt) {
return null;
}
const resetAtMs = Date.parse(weeklyResetAt);
if (Number.isNaN(resetAtMs)) {
return null;
}
return buildUsageWindow(resetAtMs, nowMs, SEVEN_DAY_WINDOW_MS);
}
export function resolveWeeklyUsageWindow(usageData: UsageData, nowMs = Date.now()): UsageWindowMetrics | null {
return getWeeklyUsageWindowFromResetAt(usageData.weeklyResetAt, nowMs);
}
export function formatUsageDuration(durationMs: number, compact = false): string {
const clampedMs = Math.max(0, durationMs);
const elapsedHours = Math.floor(clampedMs / (1000 * 60 * 60));
const elapsedMinutes = Math.floor((clampedMs % (1000 * 60 * 60)) / (1000 * 60));
if (compact) {
return elapsedMinutes === 0 ? `${elapsedHours}h` : `${elapsedHours}h${elapsedMinutes}m`;
}
if (elapsedMinutes === 0) {
return `${elapsedHours}hr`;
}
return `${elapsedHours}hr ${elapsedMinutes}m`;
}
export function getUsageErrorMessage(error: UsageError): string {
switch (error) {
case 'no-credentials': return '[No credentials]';
case 'timeout': return '[Timeout]';
case 'rate-limited': return '[Rate limited]';
case 'api-error': return '[API Error]';
case 'parse-error': return '[Parse Error]';
}
}
export function makeUsageProgressBar(percent: number, width = 15): string {
const filled = Math.round((percent / 100) * width);
const empty = width - filled;
return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']';
}
+18 -434
View File
@@ -1,434 +1,18 @@
import {
execSync,
spawnSync
} from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { z } from 'zod';
import type { BlockMetrics } from '../types';
import { getClaudeConfigDir } from './claude-settings';
import { getCachedBlockMetrics } from './jsonl';
// Cache configuration
const CACHE_DIR = path.join(os.homedir(), '.cache', 'ccstatusline');
const CACHE_FILE = path.join(CACHE_DIR, 'usage.json');
const LOCK_FILE = path.join(CACHE_DIR, 'usage.lock');
const CACHE_MAX_AGE = 180; // seconds
const LOCK_MAX_AGE = 30; // rate limit: only try API once per 30 seconds
const TOKEN_CACHE_MAX_AGE = 3600; // 1 hour
export const FIVE_HOUR_BLOCK_MS = 5 * 60 * 60 * 1000;
// Error types matching shell script
const UsageErrorSchema = z.enum(['no-credentials', 'timeout', 'api-error', 'parse-error']);
export type UsageError = z.infer<typeof UsageErrorSchema>;
export interface UsageData {
sessionUsage?: number; // five_hour.utilization (percentage)
sessionResetAt?: string; // five_hour.reset_at
weeklyUsage?: number; // seven_day.utilization (percentage)
extraUsageEnabled?: boolean;
extraUsageLimit?: number; // in cents
extraUsageUsed?: number; // in cents
extraUsageUtilization?: number;
error?: UsageError;
}
export interface UsageWindowMetrics {
sessionDurationMs: number;
elapsedMs: number;
remainingMs: number;
elapsedPercent: number;
remainingPercent: number;
}
const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() });
const CachedUsageDataSchema = z.object({
sessionUsage: z.number().nullable().optional(),
sessionResetAt: z.string().nullable().optional(),
weeklyUsage: z.number().nullable().optional(),
extraUsageEnabled: z.boolean().nullable().optional(),
extraUsageLimit: z.number().nullable().optional(),
extraUsageUsed: z.number().nullable().optional(),
extraUsageUtilization: z.number().nullable().optional(),
error: z.string().nullable().optional()
});
const UsageApiResponseSchema = z.object({
five_hour: z.object({
utilization: z.number().nullable().optional(),
reset_at: z.string().nullable().optional(),
resets_at: z.string().nullable().optional()
}).optional(),
seven_day: z.object({ utilization: z.number().nullable().optional() }).optional(),
extra_usage: z.object({
is_enabled: z.boolean().nullable().optional(),
monthly_limit: z.number().nullable().optional(),
used_credits: z.number().nullable().optional(),
utilization: z.number().nullable().optional()
}).optional()
});
function parseJsonWithSchema<T>(rawJson: string, schema: z.ZodType<T>): T | null {
try {
const parsed = schema.safeParse(JSON.parse(rawJson));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
function parseUsageAccessToken(rawJson: string): string | null {
const parsed = parseJsonWithSchema(rawJson, UsageCredentialsSchema);
return parsed?.claudeAiOauth?.accessToken ?? null;
}
function parseCachedUsageData(rawJson: string): UsageData | null {
const parsed = parseJsonWithSchema(rawJson, CachedUsageDataSchema);
if (!parsed) {
return null;
}
const parsedError = UsageErrorSchema.safeParse(parsed.error);
return {
sessionUsage: parsed.sessionUsage ?? undefined,
sessionResetAt: parsed.sessionResetAt ?? undefined,
weeklyUsage: parsed.weeklyUsage ?? undefined,
extraUsageEnabled: parsed.extraUsageEnabled ?? undefined,
extraUsageLimit: parsed.extraUsageLimit ?? undefined,
extraUsageUsed: parsed.extraUsageUsed ?? undefined,
extraUsageUtilization: parsed.extraUsageUtilization ?? undefined,
error: parsedError.success ? parsedError.data : undefined
};
}
function parseUsageApiResponse(rawJson: string): UsageData | null {
const parsed = parseJsonWithSchema(rawJson, UsageApiResponseSchema);
if (!parsed) {
return null;
}
return {
sessionUsage: parsed.five_hour?.utilization ?? undefined,
sessionResetAt: parsed.five_hour?.resets_at ?? parsed.five_hour?.reset_at ?? undefined,
weeklyUsage: parsed.seven_day?.utilization ?? undefined,
extraUsageEnabled: parsed.extra_usage?.is_enabled ?? undefined,
extraUsageLimit: parsed.extra_usage?.monthly_limit ?? undefined,
extraUsageUsed: parsed.extra_usage?.used_credits ?? undefined,
extraUsageUtilization: parsed.extra_usage?.utilization ?? undefined
};
}
// Memory caches
let cachedUsageData: UsageData | null = null;
let usageCacheTime = 0;
let cachedUsageToken: string | null = null;
let usageTokenCacheTime = 0;
function setCachedUsageError(error: UsageError, now: number): UsageData {
const errorData: UsageData = { error };
cachedUsageData = errorData;
usageCacheTime = now;
return errorData;
}
function getStaleUsageOrError(error: UsageError, now: number): UsageData {
const stale = readStaleUsageCache();
if (stale && !stale.error) {
cachedUsageData = stale;
usageCacheTime = now;
return stale;
}
return setCachedUsageError(error, now);
}
function getUsageToken(): string | null {
const now = Math.floor(Date.now() / 1000);
// Return cached token if still valid
if (cachedUsageToken && (now - usageTokenCacheTime) < TOKEN_CACHE_MAX_AGE) {
return cachedUsageToken;
}
try {
const isMac = process.platform === 'darwin';
if (isMac) {
// macOS: read from keychain
const result = execSync(
'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null',
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
const token = parseUsageAccessToken(result);
if (token) {
cachedUsageToken = token;
usageTokenCacheTime = now;
}
return token;
}
// Non-macOS: read from credentials file, honoring CLAUDE_CONFIG_DIR
const credFile = path.join(getClaudeConfigDir(), '.credentials.json');
const token = parseUsageAccessToken(fs.readFileSync(credFile, 'utf8'));
if (token) {
cachedUsageToken = token;
usageTokenCacheTime = now;
}
return token;
} catch {
return null;
}
}
function readStaleUsageCache(): UsageData | null {
try {
return parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8'));
} catch {
return null;
}
}
function fetchFromUsageApi(token: string): string | null {
// Use the active runtime to make a synchronous API call via spawnSync.
const script = `
const token = process.env.TOKEN;
if (!token) {
process.exit(1);
}
const https = require('https');
const options = {
hostname: 'api.anthropic.com',
path: '/api/oauth/usage',
method: 'GET',
headers: {
'Authorization': 'Bearer ' + token,
'anthropic-beta': 'oauth-2025-04-20'
},
timeout: 5000
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (res.statusCode === 200 && data) {
process.stdout.write(data);
} else {
process.exit(1);
}
});
});
req.on('error', () => process.exit(1));
req.on('timeout', () => {
req.destroy();
process.exit(1);
});
req.end();
`;
const runtimePath = process.execPath || 'node';
const result = spawnSync(runtimePath, ['-e', script], {
encoding: 'utf8',
timeout: 6000,
env: { ...process.env, TOKEN: token }
});
if (result.error || result.status !== 0 || !result.stdout) {
return null;
}
return result.stdout;
}
export function fetchUsageData(): UsageData {
const now = Math.floor(Date.now() / 1000);
// Check memory cache (fast path)
if (cachedUsageData) {
const cacheAge = now - usageCacheTime;
if (!cachedUsageData.error && cacheAge < CACHE_MAX_AGE) {
return cachedUsageData;
}
if (cachedUsageData.error && cacheAge < LOCK_MAX_AGE) {
return cachedUsageData;
}
}
// Check file cache
try {
const stat = fs.statSync(CACHE_FILE);
const fileAge = now - Math.floor(stat.mtimeMs / 1000);
if (fileAge < CACHE_MAX_AGE) {
const fileData = parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8'));
if (fileData && !fileData.error) {
cachedUsageData = fileData;
usageCacheTime = now;
return fileData;
}
}
} catch {
// File doesn't exist or read error - continue to API call
}
// Get token before lock/rate-limit checks so auth failures are not masked as timeout.
const token = getUsageToken();
if (!token) {
return getStaleUsageOrError('no-credentials', now);
}
// Rate limit: only try API once per 30 seconds
try {
const lockStat = fs.statSync(LOCK_FILE);
const lockAge = now - Math.floor(lockStat.mtimeMs / 1000);
if (lockAge < LOCK_MAX_AGE) {
// Rate limited - return stale cache or timeout error
const stale = readStaleUsageCache();
if (stale && !stale.error)
return stale;
return { error: 'timeout' };
}
} catch {
// Lock file doesn't exist - OK to proceed
}
// Touch lock file
try {
const lockDir = path.dirname(LOCK_FILE);
if (!fs.existsSync(lockDir)) {
fs.mkdirSync(lockDir, { recursive: true });
}
fs.writeFileSync(LOCK_FILE, '');
} catch {
// Ignore lock file errors
}
// Fetch from API using Node's https module
try {
const response = fetchFromUsageApi(token);
if (!response) {
return getStaleUsageOrError('api-error', now);
}
const usageData = parseUsageApiResponse(response);
if (!usageData) {
return getStaleUsageOrError('parse-error', now);
}
// Validate we got actual data
if (usageData.sessionUsage === undefined && usageData.weeklyUsage === undefined) {
return getStaleUsageOrError('parse-error', now);
}
// Save to cache
try {
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
}
fs.writeFileSync(CACHE_FILE, JSON.stringify(usageData));
} catch {
// Ignore cache write errors
}
cachedUsageData = usageData;
usageCacheTime = now;
return usageData;
} catch {
return getStaleUsageOrError('parse-error', now);
}
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function buildUsageWindow(resetAtMs: number, nowMs: number): UsageWindowMetrics | null {
if (!Number.isFinite(resetAtMs) || !Number.isFinite(nowMs)) {
return null;
}
const startAtMs = resetAtMs - FIVE_HOUR_BLOCK_MS;
const elapsedMs = clamp(nowMs - startAtMs, 0, FIVE_HOUR_BLOCK_MS);
const remainingMs = FIVE_HOUR_BLOCK_MS - elapsedMs;
const elapsedPercent = (elapsedMs / FIVE_HOUR_BLOCK_MS) * 100;
return {
sessionDurationMs: FIVE_HOUR_BLOCK_MS,
elapsedMs,
remainingMs,
elapsedPercent,
remainingPercent: 100 - elapsedPercent
};
}
export function getUsageWindowFromResetAt(sessionResetAt: string | undefined, nowMs = Date.now()): UsageWindowMetrics | null {
if (!sessionResetAt) {
return null;
}
const resetAtMs = Date.parse(sessionResetAt);
if (Number.isNaN(resetAtMs)) {
return null;
}
return buildUsageWindow(resetAtMs, nowMs);
}
export function getUsageWindowFromBlockMetrics(blockMetrics: BlockMetrics, nowMs = Date.now()): UsageWindowMetrics | null {
const startAtMs = blockMetrics.startTime.getTime();
if (Number.isNaN(startAtMs)) {
return null;
}
return buildUsageWindow(startAtMs + FIVE_HOUR_BLOCK_MS, nowMs);
}
export function resolveUsageWindowWithFallback(
usageData: UsageData,
blockMetrics?: BlockMetrics | null,
nowMs = Date.now()
): UsageWindowMetrics | null {
const usageWindow = getUsageWindowFromResetAt(usageData.sessionResetAt, nowMs);
if (usageWindow) {
return usageWindow;
}
const fallbackMetrics = blockMetrics ?? getCachedBlockMetrics();
if (!fallbackMetrics) {
return null;
}
return getUsageWindowFromBlockMetrics(fallbackMetrics, nowMs);
}
export function formatUsageDuration(durationMs: number): string {
const clampedMs = Math.max(0, durationMs);
const elapsedHours = Math.floor(clampedMs / (1000 * 60 * 60));
const elapsedMinutes = Math.floor((clampedMs % (1000 * 60 * 60)) / (1000 * 60));
if (elapsedMinutes === 0) {
return `${elapsedHours}hr`;
}
return `${elapsedHours}hr ${elapsedMinutes}m`;
}
export function getUsageErrorMessage(error: UsageError): string {
switch (error) {
case 'no-credentials': return '[No credentials]';
case 'timeout': return '[Timeout]';
case 'api-error': return '[API Error]';
case 'parse-error': return '[Parse Error]';
}
}
export function makeUsageProgressBar(percent: number, width = 15): string {
const filled = Math.round((percent / 100) * width);
const empty = width - filled;
return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']';
}
export { fetchUsageData } from './usage-fetch';
export {
formatUsageDuration,
getUsageErrorMessage,
getUsageWindowFromBlockMetrics,
getUsageWindowFromResetAt,
getWeeklyUsageWindowFromResetAt,
makeUsageProgressBar,
resolveUsageWindowWithFallback,
resolveWeeklyUsageWindow
} from './usage-windows';
export {
FIVE_HOUR_BLOCK_MS,
SEVEN_DAY_WINDOW_MS,
type UsageData,
type UsageError,
type UsageWindowMetrics
} from './usage-types';
+71
View File
@@ -0,0 +1,71 @@
import type {
Widget,
WidgetItemType
} from '../types/Widget';
import * as widgets from '../widgets';
export interface WidgetManifestEntry {
type: WidgetItemType;
create: () => Widget;
}
export interface LayoutWidgetManifestEntry {
type: WidgetItemType;
displayName: string;
description: string;
category: string;
}
export const WIDGET_MANIFEST: WidgetManifestEntry[] = [
{ type: 'model', create: () => new widgets.ModelWidget() },
{ type: 'output-style', create: () => new widgets.OutputStyleWidget() },
{ type: 'git-branch', create: () => new widgets.GitBranchWidget() },
{ type: 'git-changes', create: () => new widgets.GitChangesWidget() },
{ type: 'git-insertions', create: () => new widgets.GitInsertionsWidget() },
{ type: 'git-deletions', create: () => new widgets.GitDeletionsWidget() },
{ type: 'git-root-dir', create: () => new widgets.GitRootDirWidget() },
{ type: 'git-worktree', create: () => new widgets.GitWorktreeWidget() },
{ type: 'current-working-dir', create: () => new widgets.CurrentWorkingDirWidget() },
{ type: 'tokens-input', create: () => new widgets.TokensInputWidget() },
{ type: 'tokens-output', create: () => new widgets.TokensOutputWidget() },
{ type: 'tokens-cached', create: () => new widgets.TokensCachedWidget() },
{ type: 'tokens-total', create: () => new widgets.TokensTotalWidget() },
{ type: 'input-speed', create: () => new widgets.InputSpeedWidget() },
{ type: 'output-speed', create: () => new widgets.OutputSpeedWidget() },
{ type: 'total-speed', create: () => new widgets.TotalSpeedWidget() },
{ type: 'context-length', create: () => new widgets.ContextLengthWidget() },
{ type: 'context-percentage', create: () => new widgets.ContextPercentageWidget() },
{ type: 'context-percentage-usable', create: () => new widgets.ContextPercentageUsableWidget() },
{ type: 'session-clock', create: () => new widgets.SessionClockWidget() },
{ type: 'session-cost', create: () => new widgets.SessionCostWidget() },
{ type: 'block-timer', create: () => new widgets.BlockTimerWidget() },
{ type: 'terminal-width', create: () => new widgets.TerminalWidthWidget() },
{ type: 'version', create: () => new widgets.VersionWidget() },
{ type: 'custom-text', create: () => new widgets.CustomTextWidget() },
{ type: 'custom-command', create: () => new widgets.CustomCommandWidget() },
{ type: 'link', create: () => new widgets.LinkWidget() },
{ type: 'claude-session-id', create: () => new widgets.ClaudeSessionIdWidget() },
{ type: 'session-name', create: () => new widgets.SessionNameWidget() },
{ type: 'free-memory', create: () => new widgets.FreeMemoryWidget() },
{ type: 'session-usage', create: () => new widgets.SessionUsageWidget() },
{ type: 'weekly-usage', create: () => new widgets.WeeklyUsageWidget() },
{ type: 'reset-timer', create: () => new widgets.BlockResetTimerWidget() },
{ type: 'weekly-reset-timer', create: () => new widgets.WeeklyResetTimerWidget() },
{ type: 'context-bar', create: () => new widgets.ContextBarWidget() },
{ type: 'skills', create: () => new widgets.SkillsWidget() }
];
export const LAYOUT_WIDGET_MANIFEST: LayoutWidgetManifestEntry[] = [
{
type: 'separator',
displayName: 'Separator',
description: 'A separator character between status line widgets',
category: 'Layout'
},
{
type: 'flex-separator',
displayName: 'Flex Separator',
description: 'Expands to fill available terminal width',
category: 'Layout'
}
];
+24 -59
View File
@@ -3,47 +3,24 @@ import type {
Widget,
WidgetItemType
} from '../types/Widget';
import * as widgets from '../widgets';
import {
LAYOUT_WIDGET_MANIFEST,
WIDGET_MANIFEST
} from './widget-manifest';
// Create widget registry
const widgetRegistry = new Map<WidgetItemType, Widget>([
['model', new widgets.ModelWidget()],
['output-style', new widgets.OutputStyleWidget()],
['git-branch', new widgets.GitBranchWidget()],
['git-changes', new widgets.GitChangesWidget()],
['git-root-dir', new widgets.GitRootDirWidget()],
['git-worktree', new widgets.GitWorktreeWidget()],
['current-working-dir', new widgets.CurrentWorkingDirWidget()],
['tokens-input', new widgets.TokensInputWidget()],
['tokens-output', new widgets.TokensOutputWidget()],
['tokens-cached', new widgets.TokensCachedWidget()],
['tokens-total', new widgets.TokensTotalWidget()],
['context-length', new widgets.ContextLengthWidget()],
['context-percentage', new widgets.ContextPercentageWidget()],
['context-percentage-usable', new widgets.ContextPercentageUsableWidget()],
['session-clock', new widgets.SessionClockWidget()],
['session-cost', new widgets.SessionCostWidget()],
['block-timer', new widgets.BlockTimerWidget()],
['terminal-width', new widgets.TerminalWidthWidget()],
['version', new widgets.VersionWidget()],
['custom-text', new widgets.CustomTextWidget()],
['custom-command', new widgets.CustomCommandWidget()],
['link', new widgets.LinkWidget()],
['claude-session-id', new widgets.ClaudeSessionIdWidget()],
['session-name', new widgets.SessionNameWidget()],
['free-memory', new widgets.FreeMemoryWidget()],
['session-usage', new widgets.SessionUsageWidget()],
['weekly-usage', new widgets.WeeklyUsageWidget()],
['reset-timer', new widgets.ResetTimerWidget()],
['context-bar', new widgets.ContextBarWidget()]
]);
const widgetRegistry = new Map<WidgetItemType, Widget>(
WIDGET_MANIFEST.map((entry): [WidgetItemType, Widget] => [entry.type, entry.create()])
);
const layoutWidgetTypes = new Set<WidgetItemType>(LAYOUT_WIDGET_MANIFEST.map(entry => entry.type));
export function getWidget(type: WidgetItemType): Widget | null {
return widgetRegistry.get(type) ?? null;
}
export function getAllWidgetTypes(settings: Settings): WidgetItemType[] {
const allTypes = Array.from(widgetRegistry.keys());
const allTypes = WIDGET_MANIFEST.map(entry => entry.type);
// Add separator types based on settings
if (!settings.powerline.enabled) {
@@ -64,32 +41,21 @@ export interface WidgetCatalogEntry {
searchText: string;
}
const LAYOUT_WIDGETS: Record<string, Omit<WidgetCatalogEntry, 'type' | 'searchText'>> = {
'separator': {
displayName: 'Separator',
description: 'A separator character between status line widgets',
category: 'Layout'
},
'flex-separator': {
displayName: 'Flex Separator',
description: 'Expands to fill available terminal width',
category: 'Layout'
}
};
const layoutCatalogEntries = new Map<WidgetItemType, WidgetCatalogEntry>(
LAYOUT_WIDGET_MANIFEST.map((entry): [WidgetItemType, WidgetCatalogEntry] => [
entry.type,
{
type: entry.type,
displayName: entry.displayName,
description: entry.description,
category: entry.category,
searchText: `${entry.displayName} ${entry.description} ${entry.type}`.toLowerCase()
}
])
);
function getLayoutCatalogEntry(type: WidgetItemType): WidgetCatalogEntry | null {
const layout = LAYOUT_WIDGETS[type];
if (!layout) {
return null;
}
return {
type,
displayName: layout.displayName,
description: layout.description,
category: layout.category,
searchText: `${layout.displayName} ${layout.description} ${type}`.toLowerCase()
};
return layoutCatalogEntries.get(type) ?? null;
}
export function getWidgetCatalog(settings: Settings): WidgetCatalogEntry[] {
@@ -182,6 +148,5 @@ export function filterWidgetCatalog(catalog: WidgetCatalogEntry[], category: str
export function isKnownWidgetType(type: string): boolean {
return widgetRegistry.has(type)
|| type === 'separator'
|| type === 'flex-separator';
|| layoutWidgetTypes.has(type);
}
+114
View File
@@ -0,0 +1,114 @@
import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
CustomKeybind,
Widget,
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';
import {
formatUsageDuration,
getUsageErrorMessage,
resolveUsageWindowWithFallback
} from '../utils/usage';
import { formatRawOrLabeledValue } from './shared/raw-or-labeled';
import {
cycleUsageDisplayMode,
getUsageDisplayMode,
getUsageDisplayModifierText,
getUsageProgressBarWidth,
isUsageCompact,
isUsageInverted,
isUsageProgressMode,
toggleUsageCompact,
toggleUsageInverted
} from './shared/usage-display';
function makeTimerProgressBar(percent: number, width: number): string {
const clampedPercent = Math.max(0, Math.min(100, percent));
const filledWidth = Math.floor((clampedPercent / 100) * width);
const emptyWidth = width - filledWidth;
return '█'.repeat(filledWidth) + '░'.repeat(emptyWidth);
}
export class BlockResetTimerWidget implements Widget {
getDefaultColor(): string { return 'brightBlue'; }
getDescription(): string { return 'Shows time remaining until current 5hr block reset window'; }
getDisplayName(): string { return 'Block Reset Timer'; }
getCategory(): string { return 'Usage'; }
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return {
displayText: this.getDisplayName(),
modifierText: getUsageDisplayModifierText(item, { includeCompact: true })
};
}
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
if (action === 'toggle-progress') {
return cycleUsageDisplayMode(item);
}
if (action === 'toggle-invert') {
return toggleUsageInverted(item);
}
if (action === 'toggle-compact') {
return toggleUsageCompact(item);
}
return null;
}
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
const displayMode = getUsageDisplayMode(item);
const inverted = isUsageInverted(item);
const compact = isUsageCompact(item);
if (context.isPreview) {
const previewPercent = inverted ? 90.0 : 10.0;
if (isUsageProgressMode(displayMode)) {
const barWidth = getUsageProgressBarWidth(displayMode);
const progressBar = makeTimerProgressBar(previewPercent, barWidth);
return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`);
}
return formatRawOrLabeledValue(item, 'Reset: ', compact ? '4h30m' : '4hr 30m');
}
const usageData = context.usageData ?? {};
const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics);
if (!window) {
if (usageData.error) {
return getUsageErrorMessage(usageData.error);
}
return null;
}
if (isUsageProgressMode(displayMode)) {
const barWidth = getUsageProgressBarWidth(displayMode);
const percent = inverted ? window.remainingPercent : window.elapsedPercent;
const progressBar = makeTimerProgressBar(percent, barWidth);
const percentage = percent.toFixed(1);
return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${percentage}%`);
}
const remainingTime = formatUsageDuration(window.remainingMs, compact);
return formatRawOrLabeledValue(item, 'Reset: ', remainingTime);
}
getCustomKeybinds(): CustomKeybind[] {
return [
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' },
{ key: 's', label: '(s)hort time', action: 'toggle-compact' }
];
}
supportsRawValue(): boolean { return true; }
supportsColors(item: WidgetItem): boolean { return true; }
}
+38 -82
View File
@@ -7,24 +7,22 @@ import type {
WidgetItem
} from '../types/Widget';
import {
fetchUsageData,
formatUsageDuration,
resolveUsageWindowWithFallback
} from '../utils/usage';
type DisplayMode = 'time' | 'progress' | 'progress-short';
function getDisplayMode(item: WidgetItem): DisplayMode {
const mode = item.metadata?.display;
if (mode === 'progress' || mode === 'progress-short') {
return mode;
}
return 'time';
}
function isInverted(item: WidgetItem): boolean {
return item.metadata?.invert === 'true';
}
import { formatRawOrLabeledValue } from './shared/raw-or-labeled';
import {
cycleUsageDisplayMode,
getUsageDisplayMode,
getUsageDisplayModifierText,
getUsageProgressBarWidth,
isUsageCompact,
isUsageInverted,
isUsageProgressMode,
toggleUsageCompact,
toggleUsageInverted
} from './shared/usage-display';
function makeTimerProgressBar(percent: number, width: number): string {
const clampedPercent = Math.max(0, Math.min(100, percent));
@@ -40,117 +38,75 @@ export class BlockTimerWidget implements Widget {
getCategory(): string { return 'Usage'; }
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
const mode = getDisplayMode(item);
const modifiers: string[] = [];
if (mode === 'progress') {
modifiers.push('progress bar');
} else if (mode === 'progress-short') {
modifiers.push('short bar');
}
if (isInverted(item)) {
modifiers.push('inverted');
}
return {
displayText: this.getDisplayName(),
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
modifierText: getUsageDisplayModifierText(item, { includeCompact: true })
};
}
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
if (action === 'toggle-progress') {
const currentMode = getDisplayMode(item);
let nextMode: DisplayMode;
if (currentMode === 'time') {
nextMode = 'progress';
} else if (currentMode === 'progress') {
nextMode = 'progress-short';
} else {
nextMode = 'time';
}
const nextMetadata: Record<string, string> = {
...(item.metadata ?? {}),
display: nextMode
};
if (nextMode === 'time') {
delete nextMetadata.invert;
}
return {
...item,
metadata: nextMetadata
};
return cycleUsageDisplayMode(item);
}
if (action === 'toggle-invert') {
return {
...item,
metadata: {
...item.metadata,
invert: (!isInverted(item)).toString()
}
};
return toggleUsageInverted(item);
}
if (action === 'toggle-compact') {
return toggleUsageCompact(item);
}
return null;
}
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
const displayMode = getDisplayMode(item);
const inverted = isInverted(item);
const displayMode = getUsageDisplayMode(item);
const inverted = isUsageInverted(item);
const compact = isUsageCompact(item);
if (context.isPreview) {
const previewPercent = inverted ? 26.1 : 73.9;
const prefix = item.rawValue ? '' : 'Block ';
if (displayMode === 'progress' || displayMode === 'progress-short') {
const barWidth = displayMode === 'progress' ? 32 : 16;
if (isUsageProgressMode(displayMode)) {
const barWidth = getUsageProgressBarWidth(displayMode);
const progressBar = makeTimerProgressBar(previewPercent, barWidth);
return `${prefix}[${progressBar}] ${previewPercent.toFixed(1)}%`;
return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${previewPercent.toFixed(1)}%`);
}
return item.rawValue ? '3hr 45m' : 'Block: 3hr 45m';
return formatRawOrLabeledValue(item, 'Block: ', compact ? '3h45m' : '3hr 45m');
}
const usageData = fetchUsageData();
const usageData = context.usageData ?? {};
const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics);
if (!window) {
if (displayMode === 'progress' || displayMode === 'progress-short') {
const barWidth = displayMode === 'progress' ? 32 : 16;
if (isUsageProgressMode(displayMode)) {
const barWidth = getUsageProgressBarWidth(displayMode);
const emptyBar = '░'.repeat(barWidth);
return item.rawValue ? `[${emptyBar}] 0.0%` : `Block [${emptyBar}] 0.0%`;
return formatRawOrLabeledValue(item, 'Block ', `[${emptyBar}] 0.0%`);
}
return item.rawValue ? '0hr 0m' : 'Block: 0hr 0m';
return formatRawOrLabeledValue(item, 'Block: ', compact ? '0h' : '0hr 0m');
}
if (displayMode === 'progress' || displayMode === 'progress-short') {
const barWidth = displayMode === 'progress' ? 32 : 16;
if (isUsageProgressMode(displayMode)) {
const barWidth = getUsageProgressBarWidth(displayMode);
const percent = inverted ? window.remainingPercent : window.elapsedPercent;
const progressBar = makeTimerProgressBar(percent, barWidth);
const percentage = percent.toFixed(1);
if (item.rawValue) {
return `[${progressBar}] ${percentage}%`;
}
return `Block [${progressBar}] ${percentage}%`;
return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${percentage}%`);
}
const elapsedTime = formatUsageDuration(window.elapsedMs);
return item.rawValue ? elapsedTime : `Block: ${elapsedTime}`;
const elapsedTime = formatUsageDuration(window.elapsedMs, compact);
return formatRawOrLabeledValue(item, 'Block: ', elapsedTime);
}
getCustomKeybinds(): CustomKeybind[] {
return [
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' },
{ key: 's', label: '(s)hort time', action: 'toggle-compact' }
];
}

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