diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0d17d52 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +# CI workflow for VSCode z.ai Usage extension +name: CI + +on: + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Biome Check (format + lint) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run Biome check + run: npm run check:ci + + typecheck: + name: Type Check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: TypeScript type check + run: npx tsc --noEmit + + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Compile TypeScript + run: npm run compile + + ci-status: + name: CI status check + runs-on: ubuntu-latest + needs: [check, typecheck, build] + if: always() + steps: + - name: Some checks failed + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} + run: exit 1 + - name: All checks passed + run: exit 0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..2d31664 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,87 @@ +name: Publish Extension + +on: + push: + branches: + - main + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Get version from package.json + id: package-version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=v$VERSION" >> $GITHUB_OUTPUT + + - name: Check if tag exists + id: check-tag + run: | + if git rev-parse "v${{ steps.package-version.outputs.version }}" >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Exit if tag exists + if: steps.check-tag.outputs.exists == 'true' + run: | + echo "Tag ${{ steps.package-version.outputs.tag }} already exists. Skipping publish." + exit 0 + + - name: Install dependencies + if: steps.check-tag.outputs.exists == 'false' + run: npm ci + + - name: Install vsce + if: steps.check-tag.outputs.exists == 'false' + run: npm install -g @vscode/vsce + + - name: Create and push tag + if: steps.check-tag.outputs.exists == 'false' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag ${{ steps.package-version.outputs.tag }} + git push origin ${{ steps.package-version.outputs.tag }} + + - name: Publish to VS Marketplace + if: steps.check-tag.outputs.exists == 'false' + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + run: vsce publish -p $VSCE_PAT + + - name: Extract changelog for version + if: steps.check-tag.outputs.exists == 'false' + id: changelog + run: | + VERSION=${{ steps.package-version.outputs.version }} + CHANGELOG=$(awk "/## \[$VERSION\]/,/## \[/" CHANGELOG.md | sed '1d;$d' | sed '/^$/d') + echo "content<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + if: steps.check-tag.outputs.exists == 'false' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.package-version.outputs.tag }} + name: Release ${{ steps.package-version.outputs.tag }} + body: ${{ steps.changelog.outputs.content }} + draft: false + prerelease: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3593ae0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +out/ +*.vsix +.vscode-test/ +.worktrees/ +.DS_Store +CLAUDE.md +plans/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2a203c9 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Extension", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}" + ] + } + ] +} diff --git a/.vscodeignore b/.vscodeignore new file mode 100644 index 0000000..137fb4c --- /dev/null +++ b/.vscodeignore @@ -0,0 +1,16 @@ +.vscode/** +.vscode-test/** +src/** +.gitignore +**/tsconfig.json +**/*.map +**/*.ts +!node_modules/** +biome.json +.github/** +CLAUDE.md +assets/icons/** +assets/fonts/*.css +assets/fonts/*.html +assets/fonts/*.json +assets/fonts/*.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d353923 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-03-12 + +### Added + +- Initial release +- Display z.ai token usage percentage in the VS Code status bar +- Show time remaining until quota resets (`nextResetTime`) when available +- Secure API key storage via VS Code Secret Storage +- Configurable refresh interval (`zaiUsage.refreshInterval`, default 60s) +- Toggle between z.ai icon and text prefix (`zaiUsage.useIcon`) +- Command: `z.ai Usage: Set API Key` — enter and verify your z.ai API token +- Command: `z.ai Usage: Clear API Key` — remove the stored API token +- Status bar click triggers API key setup dialog when unauthenticated +- Cache invalidation when `nextResetTime` has passed diff --git a/README.md b/README.md index 99c5dbb..346f6eb 100644 --- a/README.md +++ b/README.md @@ -1 +1,64 @@ -# vscode-zai-usage \ No newline at end of file +
+ +# VSCode z.ai Usage + +![Status bar example](./assets/screenshots/statusbar.png) + +[![Latest Release](https://img.shields.io/github/v/release/j4rviscmd/vscode-zai-usage?style=for-the-badge&color=green&label=Latest&logo=github&logoColor=white)](https://github.com/j4rviscmd/vscode-zai-usage/releases/latest) +[![Last Commit](https://img.shields.io/github/last-commit/j4rviscmd/vscode-zai-usage/main?style=for-the-badge&color=1F6FEB&label=Last%20Update&logo=git&logoColor=white)](https://github.com/j4rviscmd/vscode-zai-usage/commits/main) +[![License](https://img.shields.io/badge/License-MIT-018FF5?style=for-the-badge&logo=opensourceinitiative&logoColor=white)](LICENSE) + +## Display your [z.ai](https://z.ai) token usage percentage directly in the VS Code status bar + +
+ +## Features + +- Shows token quota usage (e.g. `45% (2h30m)`) in the status bar +- Displays time remaining until quota resets when available +- Automatically refreshes at a configurable interval +- Secure API key storage via VS Code Secret Storage + +**Status bar examples:** + +| Situation | Display | +| ------------------------------ | --------------- | +| Authenticated, usage available | `⬡ 45% (2h30m)` | +| Authenticated, no reset time | `⬡ 45%` | +| API key not set | `⬡ Set API Key` | +| Error / fetch failed | `⬡ -` | + +> The `⬡` icon is the z.ai icon. Set `zaiUsage.useIcon: false` to display `z.ai:` as a text prefix instead. + +## Setup + +1. Get your API token from the [z.ai API Key page](https://z.ai/manage-apikey/apikey-list) +2. Open the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) +3. Run **z.ai Usage: Set API Key** and paste your token +4. The status bar will update immediately + +## Commands + +| Command | Description | +| --------------------------- | ------------------------------------ | +| `z.ai Usage: Set API Key` | Enter and verify your z.ai API token | +| `z.ai Usage: Clear API Key` | Remove the stored API token | + +## Settings + +| Setting | Type | Default | Description | +| -------------------------- | --------- | ------- | -------------------------------------------------- | +| `zaiUsage.refreshInterval` | `number` | `60` | Data refresh interval in seconds | +| `zaiUsage.useIcon` | `boolean` | `true` | Use z.ai icon (`⬡`) instead of text prefix `z.ai:` | + +## Requirements + +- VS Code 1.85.0 or higher +- A valid z.ai API token + +## License + +> [!WARNING] +> This extension uses an internal z.ai API (`api.z.ai/api/monitor/usage/quota/limit`) which is not officially documented. The API may change without notice, which could break this extension. + +MIT diff --git a/assets/fonts/zai-icons.css b/assets/fonts/zai-icons.css new file mode 100644 index 0000000..10120ff --- /dev/null +++ b/assets/fonts/zai-icons.css @@ -0,0 +1,19 @@ +@font-face { + font-family: "zai-icons"; + src: url("./zai-icons.woff?210fea25eb9b126fc5df23b3039ee6c0") format("woff"); +} + +i[class^="icon-"]:before, i[class*=" icon-"]:before { + font-family: zai-icons !important; + font-style: normal; + font-weight: normal !important; + font-variant: normal; + text-transform: none; + line-height: 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.icon-z-ai:before { + content: "\f101"; +} diff --git a/assets/fonts/zai-icons.html b/assets/fonts/zai-icons.html new file mode 100644 index 0000000..b00c012 --- /dev/null +++ b/assets/fonts/zai-icons.html @@ -0,0 +1,69 @@ + + + + + zai-icons + + + + + + + +

zai-icons

+ + +
+ + + +
+ z-ai +
+ + + + diff --git a/assets/fonts/zai-icons.js b/assets/fonts/zai-icons.js new file mode 100644 index 0000000..3cf7653 --- /dev/null +++ b/assets/fonts/zai-icons.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ZAI_ICONS_CODEPOINTS = exports.ZaiIcons = void 0; +var ZaiIcons; +(function (ZaiIcons) { + ZaiIcons["ZAi"] = "z-ai"; +})(ZaiIcons || (exports.ZaiIcons = ZaiIcons = {})); +exports.ZAI_ICONS_CODEPOINTS = { + [ZaiIcons.ZAi]: "61697", +}; +//# sourceMappingURL=zai-icons.js.map \ No newline at end of file diff --git a/assets/fonts/zai-icons.js.map b/assets/fonts/zai-icons.js.map new file mode 100644 index 0000000..77ef15d --- /dev/null +++ b/assets/fonts/zai-icons.js.map @@ -0,0 +1 @@ +{"version":3,"file":"zai-icons.js","sourceRoot":"","sources":["zai-icons.ts"],"names":[],"mappings":";;;AAMA,IAAY,QAEX;AAFD,WAAY,QAAQ;IAClB,wBAAY,CAAA;AACd,CAAC,EAFW,QAAQ,wBAAR,QAAQ,QAEnB;AAEY,QAAA,oBAAoB,GAAkC;IACjE,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO;CACxB,CAAC"} \ No newline at end of file diff --git a/assets/fonts/zai-icons.json b/assets/fonts/zai-icons.json new file mode 100644 index 0000000..af93155 --- /dev/null +++ b/assets/fonts/zai-icons.json @@ -0,0 +1,3 @@ +{ + "z-ai": 61697 +} \ No newline at end of file diff --git a/assets/fonts/zai-icons.ts b/assets/fonts/zai-icons.ts new file mode 100644 index 0000000..23d8c93 --- /dev/null +++ b/assets/fonts/zai-icons.ts @@ -0,0 +1,13 @@ +export type ZaiIconsId = + | "z-ai"; + +export type ZaiIconsKey = + | "ZAi"; + +export enum ZaiIcons { + ZAi = "z-ai", +} + +export const ZAI_ICONS_CODEPOINTS: { [key in ZaiIcons]: string } = { + [ZaiIcons.ZAi]: "61697", +}; diff --git a/assets/fonts/zai-icons.woff b/assets/fonts/zai-icons.woff new file mode 100644 index 0000000..51ed01d Binary files /dev/null and b/assets/fonts/zai-icons.woff differ diff --git a/assets/icons/z-ai.svg b/assets/icons/z-ai.svg new file mode 100644 index 0000000..4da889b --- /dev/null +++ b/assets/icons/z-ai.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/screenshots/statusbar.png b/assets/screenshots/statusbar.png new file mode 100644 index 0000000..b35df70 Binary files /dev/null and b/assets/screenshots/statusbar.png differ diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..7c68abb --- /dev/null +++ b/biome.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": ["src/**/*.ts"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + } +} diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..1f1d54a Binary files /dev/null and b/icon.png differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ef7d452 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,223 @@ +{ + "name": "vscode-zai-usage", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vscode-zai-usage", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@biomejs/biome": "^2.4.6", + "@types/node": "^20.10.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.6.tgz", + "integrity": "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.6", + "@biomejs/cli-darwin-x64": "2.4.6", + "@biomejs/cli-linux-arm64": "2.4.6", + "@biomejs/cli-linux-arm64-musl": "2.4.6", + "@biomejs/cli-linux-x64": "2.4.6", + "@biomejs/cli-linux-x64-musl": "2.4.6", + "@biomejs/cli-win32-arm64": "2.4.6", + "@biomejs/cli-win32-x64": "2.4.6" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.6.tgz", + "integrity": "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.6.tgz", + "integrity": "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.6.tgz", + "integrity": "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.6.tgz", + "integrity": "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.6.tgz", + "integrity": "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.6.tgz", + "integrity": "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.6.tgz", + "integrity": "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.6.tgz", + "integrity": "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.110.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.110.0.tgz", + "integrity": "sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b9bd0d8 --- /dev/null +++ b/package.json @@ -0,0 +1,89 @@ +{ + "name": "vscode-zai-usage", + "displayName": "VSCode z.ai Usage", + "description": "Display z.ai token usage in the status bar", + "version": "0.1.0", + "publisher": "j4rviscmd", + "license": "MIT", + "icon": "icon.png", + "repository": { + "type": "git", + "url": "https://github.com/j4rviscmd/vscode-zai-usage.git" + }, + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Programming Languages", + "Data Science" + ], + "keywords": [ + "z.ai", + "zai", + "ai usage", + "token usage", + "quota", + "status bar", + "metrics", + "monitoring", + "productivity", + "ai assistant" + ], + "activationEvents": [ + "onStartupFinished" + ], + "main": "./out/extension.js", + "contributes": { + "icons": { + "zai-icon": { + "description": "z.ai icon", + "default": { + "fontPath": "./assets/fonts/zai-icons.woff", + "fontCharacter": "\\F101" + } + } + }, + "commands": [ + { + "command": "zaiUsage.setApiKey", + "title": "z.ai Usage: Set API Key" + }, + { + "command": "zaiUsage.clearApiKey", + "title": "z.ai Usage: Clear API Key" + } + ], + "configuration": { + "title": "z.ai Usage", + "properties": { + "zaiUsage.refreshInterval": { + "type": "number", + "default": 60, + "minimum": 10, + "description": "Data refresh interval in seconds (minimum: 10, default: 60)" + }, + "zaiUsage.useIcon": { + "type": "boolean", + "default": true, + "description": "Use z.ai icon in status bar instead of text label 'z.ai:'" + } + } + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "lint": "biome lint ./src", + "format": "biome format --write ./src", + "format:check": "biome check --formatter-enabled=true --linter-enabled=false ./src", + "check": "biome check --write ./src", + "check:ci": "biome check ./src" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.6", + "@types/node": "^20.10.0", + "@types/vscode": "^1.85.0", + "typescript": "^5.3.0" + } +} diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..7092414 --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,486 @@ +import * as vscode from "vscode"; + +/** + * Represents a single usage limit entry returned by the z.ai quota API. + */ +interface ZaiLimit { + /** The type identifier of the limit (e.g. `"TOKENS_LIMIT"`). */ + type: string; + /** The current usage as a decimal percentage (e.g. `0.753` means 75.3 %). */ + percentage: number; + /** Unix timestamp (ms) at which this limit will reset. */ + nextResetTime: number; +} + +/** + * The top-level response shape returned by the z.ai quota/limit API endpoint. + */ +interface ZaiApiResponse { + /** Whether the API call succeeded. `false` indicates an application-level error. */ + success?: boolean; + /** Application-level error code, present when `success` is `false`. */ + code?: number; + /** Human-readable error message, present when `success` is `false`. */ + msg?: string; + /** The payload containing the list of quota limits. */ + data: { + /** Array of per-type quota limit entries. */ + limits: ZaiLimit[]; + }; +} + +/** + * The structure persisted to `globalState` for caching API responses. + */ +interface CacheData { + /** Schema version used to invalidate stale cache entries across extension updates. */ + version: string; + /** Unix timestamp (ms) at which the cache entry was written. */ + timestamp: number; + /** The raw API response that was cached. */ + data: ZaiApiResponse; +} + +/** + * Simplified token-usage statistics derived from a {@link ZaiApiResponse}. + */ +interface UsageData { + /** Rounded token usage percentage (one decimal place, e.g. `75.3`). */ + percentage: number; + /** Unix timestamp (ms) of the next quota reset, or `null` if unknown. */ + nextResetTime: number | null; +} + +/** Schema version embedded in every cache entry; increment to bust old caches. */ +const CACHE_VERSION = "1.0"; +/** Key used to store the cache object in `vscode.ExtensionContext.globalState`. */ +const CACHE_KEY = "zaiUsage.cache"; +/** Key used to store the API key in `vscode.ExtensionContext.secrets`. */ +const API_KEY_SECRET = "zaiUsage.apiKey"; +/** The z.ai quota/limit API endpoint. */ +const API_URL = "https://api.z.ai/api/monitor/usage/quota/limit"; + +/** + * Activates the extension. + * @param context - The extension context provided by VSCode. + */ +export function activate(context: vscode.ExtensionContext): void { + const statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 100, + ); + statusBarItem.text = getLabel("..."); + statusBarItem.show(); + + let intervalId: ReturnType | undefined; + + /** + * Returns the polling interval in milliseconds from the workspace configuration. + * The value is clamped to a minimum of 10 seconds to prevent API flooding. + * + * @returns The refresh interval in milliseconds (minimum 10,000 ms). + */ + function getRefreshInterval(): number { + const seconds = vscode.workspace + .getConfiguration("zaiUsage") + .get("refreshInterval", 60); + // Clamp to a minimum of 10 seconds to prevent API flooding from invalid config values. + return Math.max(seconds, 10) * 1000; + } + + /** + * Builds the status bar label by prepending the configured prefix to a given suffix. + * Uses a custom icon when the `useIcon` setting is enabled, otherwise falls back to "z.ai:". + * + * @param suffix - The text to append after the prefix (e.g. "75.3% (2h30m)"). + * @returns The fully composed status bar label string. + */ + function getLabel(suffix: string): string { + const useIcon = vscode.workspace + .getConfiguration("zaiUsage") + .get("useIcon", true); + const prefix = useIcon ? "$(zai-icon)" : "z.ai:"; + return `${prefix} ${suffix}`; + } + + /** + * Retrieves the cached API response from the extension's global state. + * Returns `null` when no cache exists or when the stored cache version does not + * match the current {@link CACHE_VERSION}. + * + * @returns The cached {@link CacheData} object, or `null` if absent or stale. + */ + function getCache(): CacheData | null { + const cache = context.globalState.get(CACHE_KEY); + if (!cache || cache.version !== CACHE_VERSION) { + return null; + } + return cache; + } + + /** + * Persists the given API response to the extension's global state as a versioned cache entry. + * The entry records the current timestamp so that {@link isCacheValid} can later evaluate + * whether the data is still fresh. + * + * @param data - The raw {@link ZaiApiResponse} to store in the cache. + * @returns `void` + */ + function setCache(data: ZaiApiResponse): void { + context.globalState.update(CACHE_KEY, { + version: CACHE_VERSION, + timestamp: Date.now(), + data, + } satisfies CacheData); + } + + /** + * Extracts token-usage statistics from a raw API response. + * Looks for the `TOKENS_LIMIT` entry inside `data.limits` and maps it to a + * simplified {@link UsageData} object. + * + * @param data - The raw {@link ZaiApiResponse} returned by the z.ai quota API. + * @returns A {@link UsageData} object containing the usage percentage and next reset + * timestamp, or `null` when the expected data structure is absent. + */ + function extractUsageData(data: ZaiApiResponse): UsageData | null { + const limits = data.data?.limits; + if (!Array.isArray(limits)) { + return null; + } + + const tokenLimit = limits.find((l) => l.type === "TOKENS_LIMIT"); + if (!tokenLimit) { + return null; + } + + return { + percentage: Math.round(tokenLimit.percentage * 10) / 10, + nextResetTime: tokenLimit.nextResetTime ?? null, + }; + } + + /** + * Determines whether a cached API response is still valid and can be used + * without issuing a new network request. + * + * A cache is considered invalid when any of the following conditions are met: + * - The cache object is `null`. + * - The elapsed time since caching exceeds the configured refresh interval. + * - The `nextResetTime` stored in the cache is in the past, meaning a usage + * reset has already occurred and fresh data is required. + * + * @param cache - The {@link CacheData} to validate, or `null`. + * @returns `true` if the cache is fresh and can be used; `false` otherwise. + */ + function isCacheValid(cache: CacheData | null): boolean { + if (!cache) { + return false; + } + if (Date.now() - cache.timestamp >= getRefreshInterval()) { + return false; + } + // If nextResetTime stored in the cache is in the past, invalidate and fetch fresh data. + const usage = extractUsageData(cache.data); + if (usage?.nextResetTime && usage.nextResetTime <= Date.now()) { + return false; + } + return true; + } + + /** + * Formats the time remaining until the next usage quota reset into a short + * human-readable string such as `"(2h30m)"` or `"(45m)"`. + * + * Returns an empty string when `nextResetTime` is falsy, non-positive, or + * already in the past. + * + * @param nextResetTime - The Unix timestamp (in milliseconds) of the next reset, + * or `null` if unknown. + * @returns A formatted countdown string like `"(1h5m)"`, or `""` if not applicable. + */ + function formatResetTime(nextResetTime: number | null): string { + if (!nextResetTime || nextResetTime <= 0) { + return ""; + } + const diffMs = nextResetTime - Date.now(); + if (diffMs <= 0) { + return ""; + } + const diffSec = Math.floor(diffMs / 1000); + const diffHours = Math.floor(diffSec / 3600); + const diffMins = Math.floor((diffSec % 3600) / 60); + const parts: string[] = []; + if (diffHours > 0) parts.push(`${diffHours}h`); + parts.push(`${diffMins}m`); + return `(${parts.join("")})`; + } + + /** + * Calls the z.ai quota API with the provided API key and returns the parsed response. + * + * Returns `null` on any of the following failure conditions: + * - A non-2xx HTTP status code is received. + * - The HTTP 200 response payload contains `success: false` (the API may return + * authentication errors with a 200 status, so the payload must be inspected). + * - A network or parsing error is thrown. + * + * @param apiKey - The Bearer token used to authenticate the API request. + * @returns A promise that resolves to the {@link ZaiApiResponse}, or `null` on failure. + */ + async function fetchFromApi(apiKey: string): Promise { + try { + const response = await fetch(API_URL, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (!response.ok) { + console.error( + "[z.ai Usage] API HTTP error:", + response.status, + await response.text(), + ); + return null; + } + + const data: ZaiApiResponse = await response.json(); + + // The API may return an authentication error within a 200 response — always inspect the payload. + if (data.success === false) { + console.error("[z.ai Usage] API error response:", data.code, data.msg); + return null; + } + + return data; + } catch (error) { + console.error("[z.ai Usage] Error:", error); + return null; + } + } + + /** + * Orchestrates the full usage-data retrieval flow: secret lookup → cache check → + * optional API call → stale-cache fallback. + * + * Resolution order: + * 1. If no API key is stored, returns `noApiKey: true` immediately. + * 2. If a valid cache entry exists, returns the cached data without an API call. + * 3. Calls the API; on success, writes the response to the cache and returns it. + * 4. On API failure, falls back to the expired cache if one is available. + * 5. Returns `usage: null` when all sources are unavailable. + * + * @returns A promise resolving to an object with: + * - `usage` — The parsed {@link UsageData}, or `null` when unavailable. + * - `apiCalled` — `true` when a live API request was made successfully. + * - `noApiKey` — `true` when no API key is stored in the secret store. + */ + async function fetchUsage(): Promise<{ + usage: UsageData | null; + apiCalled: boolean; + noApiKey: boolean; + }> { + const apiKey = await context.secrets.get(API_KEY_SECRET); + if (!apiKey) { + return { usage: null, apiCalled: false, noApiKey: true }; + } + + const cache = getCache(); + + if (cache && isCacheValid(cache)) { + return { + usage: extractUsageData(cache.data), + apiCalled: false, + noApiKey: false, + }; + } + + const apiData = await fetchFromApi(apiKey); + + if (apiData) { + setCache(apiData); + return { + usage: extractUsageData(apiData), + apiCalled: true, + noApiKey: false, + }; + } + + // Fall back to the expired cache when the API call fails. + if (cache) { + return { + usage: extractUsageData(cache.data), + apiCalled: false, + noApiKey: false, + }; + } + + return { usage: null, apiCalled: false, noApiKey: false }; + } + + /** + * Starts (or restarts) the polling interval that periodically calls + * {@link updateStatusBar}. + * + * If a previous interval is already running it is cleared before a new one + * is created, ensuring that configuration changes (e.g. `refreshInterval`) + * take effect immediately without spawning duplicate timers. + * + * @returns `void` + */ + function startInterval(): void { + if (intervalId !== undefined) { + clearInterval(intervalId); + } + intervalId = setInterval(updateStatusBar, getRefreshInterval()); + } + + /** + * Configures the status bar item to reflect the "no API key" state. + * + * Sets the item's label to "Set API Key" and attaches the `zaiUsage.setApiKey` + * command so that clicking the item opens the API key input prompt. + * + * @returns `void` + */ + function applyNoApiKeyState(): void { + statusBarItem.command = "zaiUsage.setApiKey"; + statusBarItem.text = getLabel("Set API Key"); + statusBarItem.tooltip = "Click to set your z.ai API key"; + } + + /** + * Fetches the latest usage data and updates the status bar item accordingly. + * + * Possible outcomes: + * - **No API key**: delegates to {@link applyNoApiKeyState} to prompt the user. + * - **Fetch failure**: displays a dash and an error tooltip. + * - **Success**: renders the usage percentage and optional reset countdown; also + * shows a tooltip with full details and the configured refresh interval. + * + * After a successful live API call ({@link fetchUsage} returns `apiCalled: true`), + * the polling interval is restarted via {@link startInterval} so that the next + * refresh is scheduled relative to the moment fresh data was obtained. + * + * @returns A promise that resolves once the status bar has been updated. + */ + async function updateStatusBar(): Promise { + const { usage, apiCalled, noApiKey } = await fetchUsage(); + + if (noApiKey) { + applyNoApiKeyState(); + } else if (usage === null) { + statusBarItem.command = undefined; + statusBarItem.text = getLabel("-"); + statusBarItem.tooltip = "Unable to fetch z.ai usage data"; + } else { + statusBarItem.command = undefined; + const refreshSec = getRefreshInterval() / 1000; + const resetStr = formatResetTime(usage.nextResetTime); + const suffix = resetStr + ? `${usage.percentage}% ${resetStr}` + : `${usage.percentage}%`; + statusBarItem.text = getLabel(suffix); + statusBarItem.tooltip = `z.ai token usage: ${usage.percentage}%${resetStr ? ` — resets in ${resetStr.replace(/[()]/g, "")}` : ""} (auto-refreshes every ${refreshSec}s)`; + } + + if (apiCalled) { + startInterval(); + } + } + + /** + * Handles the `zaiUsage.setApiKey` command. + * + * Prompts the user for a Bearer token, verifies it against the z.ai API, + * and — on success — persists it to the secret store and refreshes the status bar. + * On verification failure the stored key and cache are both cleared. + */ + const setApiKeyCmd = vscode.commands.registerCommand( + "zaiUsage.setApiKey", + async () => { + const apiKey = await vscode.window.showInputBox({ + prompt: "Enter your z.ai API key", + placeHolder: "Bearer token...", + password: true, + ignoreFocusOut: true, + }); + + if (!apiKey) { + return; + } + + statusBarItem.text = getLabel("Verifying..."); + const result = await fetchFromApi(apiKey); + + if (result === null) { + await context.secrets.delete(API_KEY_SECRET); + await context.globalState.update(CACHE_KEY, undefined); + vscode.window.showErrorMessage( + "z.ai Usage: Failed to verify API key. Please check the key and try again.", + ); + await updateStatusBar(); + return; + } + + await context.secrets.store(API_KEY_SECRET, apiKey); + setCache(result); + vscode.window.showInformationMessage( + "z.ai Usage: API key saved successfully.", + ); + await updateStatusBar(); + startInterval(); + }, + ); + + /** + * Handles the `zaiUsage.clearApiKey` command. + * + * Removes the stored API key from the secret store, clears the usage cache, + * and transitions the status bar to the "no API key" state. + */ + const clearApiKeyCmd = vscode.commands.registerCommand( + "zaiUsage.clearApiKey", + async () => { + await context.secrets.delete(API_KEY_SECRET); + await context.globalState.update(CACHE_KEY, undefined); + applyNoApiKeyState(); + vscode.window.showInformationMessage("z.ai Usage: API key cleared."); + }, + ); + + updateStatusBar(); + startInterval(); + + context.subscriptions.push( + statusBarItem, + setApiKeyCmd, + clearApiKeyCmd, + /** + * Listens for workspace configuration changes and re-applies them immediately. + * + * When `zaiUsage.refreshInterval` or `zaiUsage.useIcon` changes, the status bar + * is refreshed and the polling interval is restarted so the new settings take + * effect without requiring a window reload. + */ + vscode.workspace.onDidChangeConfiguration((e) => { + if ( + e.affectsConfiguration("zaiUsage.refreshInterval") || + e.affectsConfiguration("zaiUsage.useIcon") + ) { + updateStatusBar(); + startInterval(); + } + }), + { + dispose: () => { + if (intervalId !== undefined) clearInterval(intervalId); + }, + }, + ); +} + +/** + * Deactivates the extension. + * Called when VSCode is shutting down or the extension is disabled. + */ +export function deactivate(): void {} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..927d446 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "outDir": "out", + "rootDir": "src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", ".vscode-test", "assets/**"] +}