From 0d2879894f41a6d0575f30f842799686a33e0f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Fernando=20Gomes=20Marcial?= Date: Mon, 29 Jun 2026 12:32:06 -0400 Subject: [PATCH 1/3] feat: add macOS support (BSD date/stat, locale-safe rendering) Abstract date/stat over GNU vs BSD by probing behavior once (epoch_from_iso, file_mtime), so macOS works without coreutils. Pin LC_ALL=C so comma-decimal locales (e.g. pt_BR) render identically to the C-locale CI. install.sh accepts BSD date/stat; the npm CLI accepts darwin as a supported platform. Closes #2 --- .changeset/macos-support.md | 8 ++++++++ AGENTS.md | 5 +++-- README.md | 18 ++++++++++-------- install.sh | 24 ++++++++++++++---------- src/infra/system.ts | 6 +++--- tokenline.sh | 37 ++++++++++++++++++++++++++++++++++--- 6 files changed, 72 insertions(+), 26 deletions(-) create mode 100644 .changeset/macos-support.md diff --git a/.changeset/macos-support.md b/.changeset/macos-support.md new file mode 100644 index 0000000..6d86e98 --- /dev/null +++ b/.changeset/macos-support.md @@ -0,0 +1,8 @@ +--- +"@inbrace-tech/tokenline": minor +--- + +Add macOS support. `tokenline.sh` now abstracts `date`/`stat` over GNU vs BSD by +probing behavior once (`epoch_from_iso`, `file_mtime`), pins `LC_ALL=C` so a +comma-decimal locale renders identically, and the installer accepts macOS +(`brew install bash jq`). Closes #2. diff --git a/AGENTS.md b/AGENTS.md index b9ed98d..f537c9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,8 +43,9 @@ Strip ANSI to check structure: pipe the output through `sed 's/\x1b\[[0-9;]*m//g ## Platform -v1 targets Linux / WSL2. macOS and Windows are tracked as roadmap issues — the -GNU-specific calls (`mapfile`, `date -d`, `stat -c`) are the things to abstract. +Runs on Linux / WSL2 and macOS. `date`/`stat` are abstracted over GNU vs BSD by +probing behavior once (`epoch_from_iso`, `file_mtime` in `tokenline.sh`); `mapfile` +still needs bash 4+, so macOS users `brew install bash`. Windows is a roadmap issue. ## The npm installer diff --git a/README.md b/README.md index ab524f8..faa2d1a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ > See your AI coding costs in real time. Tokenline adds context usage, prompt-cache savings, TTL countdown and rate-limit pacing to Claude Code and Gemini CLI. ![License: MIT](https://img.shields.io/badge/License-MIT-green.svg) -![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20WSL2-blue) +![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20WSL2%20%7C%20macOS-blue) ![Shell](https://img.shields.io/badge/shell-bash%204%2B-lightgrey) ## Quickstart @@ -78,14 +78,16 @@ If `tokenline` reveals you are getting 1-hour cache writes, keep in mind they bu ## Requirements -v1 targets **Linux / WSL2**: +Runs on **Linux / WSL2** and **macOS**: -- `bash` 4 or newer +- `bash` 4 or newer — macOS ships 3.2, so `brew install bash` - [`jq`](https://jqlang.github.io/jq/) -- GNU coreutils (`date -d`, `stat -c`) +- `date` and `stat` — GNU (`-d` / `-c`) or BSD (`-j` / `-f`); both are handled -> macOS and Windows support are on the [roadmap](#roadmap). `install.sh` checks all of -> the above and tells you exactly what's missing. +On macOS: `brew install bash jq`. BSD `date`/`stat` work as-is; no `coreutils` needed. + +> Windows support is on the [roadmap](#roadmap). `install.sh` checks all of the +> above and tells you exactly what's missing. ## Advanced Installation @@ -139,11 +141,11 @@ On every refresh the host CLI pipes a JSON snapshot of the session to the script | Blank statusline, or `[tokenline] jq not found` | Install `jq` (`apt install jq` / `brew install jq`), then re-run `./install.sh`. | | Cache shows `COLD` immediately | Normal right after a long idle gap — the cache window has elapsed. It goes `HOT` again on your next turn. | | Colors look wrong / show escape codes | Your terminal must support 256-color ANSI. Most modern terminals do; check your `$TERM`. | -| Nothing renders on macOS | Expected on v1 — macOS uses BSD `date`/`stat`. See the [roadmap](#roadmap). | +| Nothing renders on macOS | Install `bash` 4+ and `jq`: `brew install bash jq`. Stock bash 3.2 lacks `mapfile`. | ## Roadmap -- [ ] macOS support (BSD `date`/`stat`, bash 3.2 fallback) +- [x] macOS support (BSD `date`/`stat`, Homebrew bash) - [ ] Windows support (Git Bash / PowerShell) - [ ] Configurable colors and thresholds via `TOKENLINE_*` env vars diff --git a/install.sh b/install.sh index 0d0e5ef..0d53e58 100755 --- a/install.sh +++ b/install.sh @@ -24,11 +24,11 @@ printf '%s\n' "--------------------------------" missing=0 -# bash 4+ (mapfile/associative features) +# bash 4+ (mapfile). macOS ships 3.2 — `brew install bash` provides 5.x. if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then ok "bash ${BASH_VERSION%%(*}" else - err "bash 4+ required (found ${BASH_VERSION%%(*}). macOS ships 3.2 — see README roadmap." + err "bash 4+ required (found ${BASH_VERSION%%(*}). On macOS: brew install bash" missing=1 fi @@ -40,19 +40,23 @@ else missing=1 fi -# GNU date (-d): used to parse ISO timestamps from the transcript +# date: GNU (-d) or BSD (-j) — tokenline handles both. if date -d "@0" >/dev/null 2>&1; then - ok "GNU date (-d)" + ok "date (GNU -d)" +elif date -j -f "%s" 0 >/dev/null 2>&1; then + ok "date (BSD -j)" else - err "GNU date (-d) missing — BSD/macOS date differs (see README roadmap)" + err "no usable date (need GNU -d or BSD -j)" missing=1 fi -# GNU stat (-c): mtime fallback for the cache timer +# stat: GNU (-c) or BSD (-f) — tokenline handles both. if stat -c %Y . >/dev/null 2>&1; then - ok "GNU stat (-c)" + ok "stat (GNU -c)" +elif stat -f %m . >/dev/null 2>&1; then + ok "stat (BSD -f)" else - err "GNU stat (-c) missing — BSD/macOS stat differs (see README roadmap)" + err "no usable stat (need GNU -c or BSD -f)" missing=1 fi @@ -67,8 +71,8 @@ fi printf '\n' if [ "$missing" -ne 0 ]; then - warn "Missing dependencies above. tokenline targets Linux/WSL2 for v1;" - warn "macOS/Windows support is on the roadmap (see README)." + warn "Missing dependencies above. On macOS: brew install bash jq." + warn "Windows support is on the roadmap (see README)." printf '\n' fi diff --git a/src/infra/system.ts b/src/infra/system.ts index d862710..438023e 100644 --- a/src/infra/system.ts +++ b/src/infra/system.ts @@ -5,13 +5,13 @@ import { ok, warn } from '../shared/logger' export function checkPlatform(): boolean { const p = platform() - if (p === 'linux') { + if (p === 'linux' || p === 'darwin') { ok(`platform: ${p} (supported)`) return true } warn( - `platform: ${p} — v1 targets Linux/WSL2; the bash statusline likely won't ` + - `render yet (see roadmap). Use --force to install anyway.`, + `platform: ${p} — supported on Linux/WSL2 and macOS; the bash statusline ` + + `likely won't render yet (see roadmap). Use --force to install anyway.`, ) return false } diff --git a/tokenline.sh b/tokenline.sh index c83d422..4e04023 100755 --- a/tokenline.sh +++ b/tokenline.sh @@ -9,9 +9,14 @@ # # Repo: https://github.com/inbrace-tech/tokenline # License: MIT -# Requires: bash 4+, jq, GNU coreutils (date -d, stat -c). Linux/WSL2 for v1. +# Requires: bash 4+, jq. Linux/WSL2 or macOS (brew install bash jq). # ============================================================================== +# Pin C locale: a comma-decimal locale (e.g. pt_BR) makes awk/printf emit +# "46,2k" and reject dotted input. LC_ALL beats LC_NUMERIC, so set LC_ALL to +# stay deterministic even when the user exports LC_ALL. Output is ASCII/bytes. +export LC_ALL=C + # --- Colors & Formatting Constants --- COLOR_GRAY=$'\033[38;5;244m' COLOR_DARK_GRAY=$'\033[38;5;240m' @@ -34,6 +39,32 @@ if ! command -v jq >/dev/null 2>&1; then exit 0 fi +# --- GNU vs BSD coreutils (Linux vs macOS) --- +# Probe behavior, not `uname`, so Homebrew coreutils is picked up automatically. +if date -d "@0" >/dev/null 2>&1; then _date_gnu=1; else _date_gnu=0; fi +if stat -c %Y . >/dev/null 2>&1; then _stat_gnu=1; else _stat_gnu=0; fi + +epoch_from_iso() { + # ISO-8601 -> epoch seconds; empty on failure (callers fall back to mtime). + local iso="$1" + if [ "$_date_gnu" -eq 1 ]; then + date -d "$iso" +%s 2>/dev/null + else + # BSD date needs an explicit format and rejects fractional secs / 'Z'. + # First 19 chars are always 'YYYY-MM-DDTHH:MM:SS'; transcripts are UTC. + date -u -j -f "%Y-%m-%dT%H:%M:%S" "${iso:0:19}" +%s 2>/dev/null + fi +} + +file_mtime() { + local f="$1" + if [ "$_stat_gnu" -eq 1 ]; then + stat -c %Y "$f" 2>/dev/null + else + stat -f %m "$f" 2>/dev/null + fi +} + # --- Runtime state directory --- # Per-turn timestamp / TTL are cached between the 1s refreshes. Prefer a # per-user dir (0700) under XDG_RUNTIME_DIR: it avoids predictable, world- @@ -176,10 +207,10 @@ compute_cache_timer() { | tail -n 1 ) if [ -n "$iso" ]; then - last_ts=$(date -d "$iso" +%s 2>/dev/null) + last_ts=$(epoch_from_iso "$iso") fi # Mtime fallback if parsing is unsuccessful - [ -z "$last_ts" ] && last_ts=$(stat -c %Y "$transcript_path" 2>/dev/null) + [ -z "$last_ts" ] && last_ts=$(file_mtime "$transcript_path") fi # Fallback to local session caching file if transcript read is unavailable or cached timestamp is newer From 9bd1d784b64327aa77ab1d40c860e612074e27a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Fernando=20Gomes=20Marcial?= Date: Mon, 29 Jun 2026 12:56:04 -0400 Subject: [PATCH 2/3] feat: interactive install.sh (multi-profile, arrow-key select, safe patch) Rework install.sh from a print-only dependency checker into an installer that copies tokenline.sh into the chosen Claude profile(s) and patches each settings.json with the npm CLI's safety contract: backup, merge-only statusLine, idempotent, and never clobbering invalid JSON. - Discovers ~/.claude, ~/.claude-*, and ./.claude. Arrow-key multi-select menu (up/down move, space toggle, digits 1-9 quick-toggle, Enter confirm, q/Esc cancel), with a typed-number fallback for TERM=dumb. Also --dir for a specific directory, --yes for non-interactive ~/.claude. - Adds --dry-run, --print, --force; minimal stepped UI with a TTY-only braille spinner; piped/non-interactive runs stay plain and default to ~/.claude. - Pure bash + ANSI (no stty), so the menu behaves the same on macOS bash 3.2 and Linux. Checks the PATH bash (not the interpreter) for the 4+ requirement, locale-independently via BASH_VERSINFO. --- .changeset/interactive-installer.md | 15 + README.md | 21 +- install.sh | 480 ++++++++++++++++++++++++---- 3 files changed, 445 insertions(+), 71 deletions(-) create mode 100644 .changeset/interactive-installer.md diff --git a/.changeset/interactive-installer.md b/.changeset/interactive-installer.md new file mode 100644 index 0000000..dcca155 --- /dev/null +++ b/.changeset/interactive-installer.md @@ -0,0 +1,15 @@ +--- +"@inbrace-tech/tokenline": minor +--- + +Rework `install.sh` into an interactive installer. It now copies `tokenline.sh` +into the Claude profile(s) you choose and patches each `settings.json` with the +same safety contract as the npm CLI (backup, merge-only `statusLine`, idempotent, +never clobbers invalid JSON). It discovers `~/.claude`, any `~/.claude-*`, and +`./.claude`, and lets you install into several profiles at once via an arrow-key +menu (↑/↓ move, space toggle, digits 1-9 quick-toggle, Enter confirm) with a +typed-number fallback for `TERM=dumb`. Adds `--dir`, `--yes`, `--dry-run`, +`--print`, and `--force`. A minimal stepped UI with a TTY-only spinner; +piped/non-interactive runs stay plain and default to `~/.claude`. Runs on stock +macOS bash 3.2 and checks the PATH `bash` (not the interpreter) for the 4+ +requirement. diff --git a/README.md b/README.md index faa2d1a..73daa06 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,10 @@ On macOS: `brew install bash jq`. BSD `date`/`stat` work as-is; no `coreutils` n ### Without Node (clone + install.sh) -No Node? Clone the repo and run the dependency checker, which prints a ready-to-paste snippet: +No Node? Clone the repo and run the installer. It checks dependencies, then +copies `tokenline.sh` into the profile(s) you pick and patches each +`settings.json` with the same safety contract as the npm CLI (backup, +merge-only, idempotent, never clobbers invalid JSON): ```bash git clone https://github.com/inbrace-tech/tokenline.git @@ -101,14 +104,16 @@ cd tokenline ./install.sh ``` -Add the printed block to your project's `.claude/settings.json` (or `~/.claude/settings.json` for global), inside the top-level object: +It discovers your Claude profile directories (`~/.claude`, any `~/.claude-*`, +and `./.claude`) and lets you install into one or several at once — handy if you +run multiple profiles. `~/.claude` is the default. -```json -"statusLine": { - "type": "command", - "command": "bash /absolute/path/to/tokenline/tokenline.sh", - "refreshInterval": 1 -} +```bash +./install.sh --dir ~/.claude-work # install into a specific directory +./install.sh --yes # non-interactive, install into ~/.claude +./install.sh --dry-run # show what would happen, write nothing +./install.sh --print # just print the snippet to paste manually +./install.sh --force # replace a different existing statusLine ``` Then restart Claude Code. diff --git a/install.sh b/install.sh index 0d53e58..09505ad 100755 --- a/install.sh +++ b/install.sh @@ -2,87 +2,441 @@ # ============================================================================== # tokenline installer # -# Verifies dependencies and prints the Claude Code settings.json snippet that -# enables the statusline. It does NOT edit your settings file — it prints the -# block so you can paste it where you want (global or per-project). +# Copies tokenline.sh into one or more Claude profile directories and safely +# patches each settings.json (backup, merge-only statusLine, idempotent, never +# clobbers invalid JSON). Mirrors the npm CLI in pure bash + jq, so non-Node +# users get the same guarantees. +# +# Runs on stock macOS bash 3.2 (no bash-4 features here). Animations show only +# on a TTY; piped/non-interactive runs stay plain and never prompt. # # Usage: -# ./install.sh +# ./install.sh # interactive — pick one or more profiles +# ./install.sh --yes # non-interactive — install to ~/.claude +# ./install.sh --dir # install to a specific directory +# ./install.sh --dry-run # show what would happen, write nothing +# ./install.sh --print # only print the settings snippet +# ./install.sh --force # replace a different existing statusLine # ============================================================================== set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -TOKENLINE="$SCRIPT_DIR/tokenline.sh" +TOKENLINE_SRC="$SCRIPT_DIR/tokenline.sh" -c_green=$'\033[0;32m'; c_red=$'\033[0;31m'; c_yellow=$'\033[0;33m'; c_reset=$'\033[0m' -ok() { printf '%s✓%s %s\n' "$c_green" "$c_reset" "$1"; } -warn() { printf '%s!%s %s\n' "$c_yellow" "$c_reset" "$1"; } -err() { printf '%s✗%s %s\n' "$c_red" "$c_reset" "$1"; } +# --- Options ----------------------------------------------------------------- +OPT_DIR="" +OPT_YES=0 +OPT_DRYRUN=0 +OPT_PRINT=0 +OPT_FORCE=0 -printf '\ntokenline — dependency check\n' -printf '%s\n' "--------------------------------" +usage() { + sed -n '3,21p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} -missing=0 +while [ $# -gt 0 ]; do + case "$1" in + --dir) OPT_DIR="${2:-}"; shift 2 ;; + -y|--yes) OPT_YES=1; shift ;; + --dry-run) OPT_DRYRUN=1; shift ;; + --print) OPT_PRINT=1; shift ;; + --force) OPT_FORCE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) printf 'unknown option: %s (try --help)\n' "$1" >&2; exit 2 ;; + esac +done -# bash 4+ (mapfile). macOS ships 3.2 — `brew install bash` provides 5.x. -if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then - ok "bash ${BASH_VERSION%%(*}" +# --- Presentation ------------------------------------------------------------ +# Colors and animation only when stdout is a TTY and NO_COLOR is unset. +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + C_ACCENT=$'\033[38;5;51m'; C_DIM=$'\033[38;5;244m'; C_OK=$'\033[38;5;46m' + C_ERR=$'\033[38;5;196m'; C_WARN=$'\033[38;5;226m'; C_BOLD=$'\033[1m' + C_RESET=$'\033[0m'; TTY=1 else - err "bash 4+ required (found ${BASH_VERSION%%(*}). On macOS: brew install bash" - missing=1 + C_ACCENT=""; C_DIM=""; C_OK=""; C_ERR=""; C_WARN=""; C_BOLD="" + C_RESET=""; TTY=0 fi +ANIM=$TTY +[ "$OPT_DRYRUN" -eq 1 ] && ANIM=0 -# jq — the JSON parser the statusline depends on -if command -v jq >/dev/null 2>&1; then - ok "jq $(jq --version 2>/dev/null)" -else - err "jq not found — install it (apt install jq / brew install jq)" - missing=1 -fi +# Restore the cursor if the arrow-key menu is interrupted mid-draw. +[ "$TTY" -eq 1 ] && trap 'printf "\033[?25h"' EXIT INT TERM -# date: GNU (-d) or BSD (-j) — tokenline handles both. -if date -d "@0" >/dev/null 2>&1; then - ok "date (GNU -d)" -elif date -j -f "%s" 0 >/dev/null 2>&1; then - ok "date (BSD -j)" -else - err "no usable date (need GNU -d or BSD -j)" - missing=1 -fi +nap() { [ "$ANIM" -eq 1 ] && sleep 0.04; return 0; } -# stat: GNU (-c) or BSD (-f) — tokenline handles both. -if stat -c %Y . >/dev/null 2>&1; then - ok "stat (GNU -c)" -elif stat -f %m . >/dev/null 2>&1; then - ok "stat (BSD -f)" -else - err "no usable stat (need GNU -c or BSD -f)" - missing=1 -fi +hero() { + printf '\n %s%stokenline%s\n' "$C_BOLD" "$C_ACCENT" "$C_RESET" + printf ' %scache-aware statusline installer%s\n\n' "$C_DIM" "$C_RESET" +} -# the script itself -if [ -f "$TOKENLINE" ]; then - chmod +x "$TOKENLINE" 2>/dev/null || true - ok "tokenline.sh found" -else - err "tokenline.sh not found next to install.sh" - missing=1 +ok_line() { printf ' %s✓%s %s\n' "$C_OK" "$C_RESET" "$1"; nap; } +warn_line() { printf ' %s!%s %s\n' "$C_WARN" "$C_RESET" "$1"; nap; } +err_line() { printf ' %s✗%s %s\n' "$C_ERR" "$C_RESET" "$1" >&2; nap; } +note_line() { printf ' %s→ %s%s\n' "$C_DIM" "$1" "$C_RESET"; nap; } + +# Decorative braille spinner held for ~$1 seconds while $2 is the label. The work +# runs synchronously right after, so this only paces the reveal — never fakes a +# result. No-ops off a TTY. +spin_for() { + local secs="$1" label="$2" + [ "$ANIM" -eq 1 ] || return 0 + local frames=( ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ ) + local n i=0 + n=$(awk -v s="$secs" 'BEGIN { printf "%d", s / 0.08 }') + while [ "$i" -lt "$n" ]; do + printf '\r %s%s%s %s' "$C_ACCENT" "${frames[$((i % 10))]}" "$C_RESET" "$label" + i=$((i + 1)); sleep 0.08 + done + printf '\r\033[K' +} + +# --- Path helpers ------------------------------------------------------------ +expand_path() { + # Expand a leading ~ in user-typed paths. The quoted "~/" is matched + # literally on purpose (we want the tilde character, not shell expansion). + # shellcheck disable=SC2088 + case "$1" in + "~") printf '%s' "$HOME" ;; + "~/"*) printf '%s/%s' "$HOME" "${1#"~/"}" ;; + *) printf '%s' "$1" ;; + esac +} + +statusline_command() { + # Quote only when the path has a space, to match the npm CLI byte-for-byte. + case "$1" in + *" "*) printf 'bash "%s"' "$1" ;; + *) printf 'bash %s' "$1" ;; + esac +} + +# --- Dependency check -------------------------------------------------------- +HAVE_JQ=0 +check_deps() { + local missing=0 + spin_for 0.5 "checking dependencies" + + # Check the `bash` on PATH — that's what runs `bash tokenline.sh`, not the + # interpreter running this installer (stock macOS launches it as 3.2). Ask + # that bash for its own version so a localized `--version` can't break it. + local bash_ver bash_major + bash_ver=$(bash -c 'printf "%s.%s" "${BASH_VERSINFO[0]}" "${BASH_VERSINFO[1]}"' 2>/dev/null) + bash_major=${bash_ver%%.*} + if [ -n "$bash_major" ] && [ "$bash_major" -ge 4 ] 2>/dev/null; then + ok_line "bash $bash_ver" + else + err_line "bash 4+ required for the statusline (PATH bash is ${bash_ver:-unknown}). On macOS: brew install bash" + missing=1 + fi + + if command -v jq >/dev/null 2>&1; then + HAVE_JQ=1 + ok_line "jq $(jq --version 2>/dev/null)" + else + err_line "jq not found — install it (apt install jq / brew install jq)" + missing=1 + fi + + if date -d "@0" >/dev/null 2>&1 || date -j -f "%s" 0 >/dev/null 2>&1; then + ok_line "date (GNU or BSD)" + else + err_line "no usable date (need GNU -d or BSD -j)" + missing=1 + fi + + if stat -c %Y . >/dev/null 2>&1 || stat -f %m . >/dev/null 2>&1; then + ok_line "stat (GNU or BSD)" + else + err_line "no usable stat (need GNU -c or BSD -f)" + missing=1 + fi + + if [ -f "$TOKENLINE_SRC" ]; then + ok_line "tokenline.sh found" + else + err_line "tokenline.sh not found next to install.sh" + missing=1 + fi + + return "$missing" +} + +# --- Profile selection ------------------------------------------------------- +CAND_PATHS=() +CAND_LABELS=() +add_candidate() { + local p="$1" label="$2" existing + if [ "${#CAND_PATHS[@]}" -gt 0 ]; then + for existing in "${CAND_PATHS[@]}"; do + [ "$existing" = "$p" ] && return 0 + done + fi + CAND_PATHS+=("$p") + CAND_LABELS+=("$label") +} + +discover_candidates() { + add_candidate "$HOME/.claude" "default" + local d + shopt -s nullglob + for d in "$HOME"/.claude-* "$HOME"/.claude_*; do + [ -d "$d" ] && add_candidate "$d" "profile" + done + shopt -u nullglob + [ -d "$PWD/.claude" ] && add_candidate "$PWD/.claude" "this project" +} + +# Clip a string to $2 columns, adding an ellipsis, so a long path can't wrap and +# desync the redraw line count. +fit() { + local s="$1" max="$2" + if [ "$max" -lt 1 ]; then printf '%s' "$s"; return 0; fi + if [ "${#s}" -gt "$max" ]; then printf '%s…' "${s:0:$((max - 1))}"; else printf '%s' "$s"; fi +} + +# Menu state shared between select_arrows() and draw_menu(). +CURSOR=0 +CUSTOM_IDX=0 +COLS=80 +SEL=() + +draw_menu() { + local i box cur label + for i in "${!CAND_PATHS[@]}"; do + cur=" "; [ "$i" -eq "$CURSOR" ] && cur="${C_ACCENT}❯${C_RESET} " + box="${C_DIM}◯${C_RESET}"; [ "${SEL[$i]:-0}" = "1" ] && box="${C_OK}◉${C_RESET}" + label="${CAND_PATHS[$i]}" + [ -n "${CAND_LABELS[$i]}" ] && label="$label (${CAND_LABELS[$i]})" + printf ' %s%s %s\033[K\n' "$cur" "$box" "$(fit "$label" "$((COLS - 8))")" + done + cur=" "; [ "$CURSOR" -eq "$CUSTOM_IDX" ] && cur="${C_ACCENT}❯${C_RESET} " + printf ' %s %scustom path…%s\033[K\n' "$cur" "$C_DIM" "$C_RESET" +} + +# Arrow-key multi-select. ↑/↓ move, space toggles, digits 1-9 jump+toggle, +# Enter confirms, q/Esc cancels. Pure bash + ANSI; no stty, so it works the same +# on macOS bash 3.2 and Linux. Fills TARGETS. +select_arrows() { + local total key k2 i + CURSOR=0; SEL=() + for i in "${!CAND_PATHS[@]}"; do SEL[i]=0; done + CUSTOM_IDX=${#CAND_PATHS[@]} + total=$((CUSTOM_IDX + 1)) + COLS=$(tput cols 2>/dev/null || printf 80) + + printf ' %sInstall to which profile?%s %s(↑/↓ move · space toggle · 1-9 quick · enter confirm)%s\n\n' \ + "$C_BOLD" "$C_RESET" "$C_DIM" "$C_RESET" + printf '\033[?25l' + draw_menu + + while :; do + IFS= read -rsn1 key || key="" + if [ "$key" = $'\033' ]; then + IFS= read -rsn2 -t 1 k2 2>/dev/null || k2="" + key="$key$k2" + fi + case "$key" in + $'\033[A'|$'\033OA') CURSOR=$(((CURSOR - 1 + total) % total)) ;; + $'\033[B'|$'\033OB') CURSOR=$(((CURSOR + 1) % total)) ;; + ' ') [ "$CURSOR" -ne "$CUSTOM_IDX" ] && SEL[CURSOR]=$((1 - ${SEL[CURSOR]:-0})) ;; + [1-9]) + i=$((key - 1)) + if [ "$i" -lt "$CUSTOM_IDX" ]; then CURSOR=$i; SEL[i]=$((1 - ${SEL[i]:-0})); fi + ;; + ""|$'\n'|$'\r') break ;; + q|Q|$'\033') CURSOR=-1; break ;; + *) : ;; + esac + printf '\033[%dA' "$total" + draw_menu + done + printf '\033[?25h\n' + + if [ "$CURSOR" -eq -1 ]; then err_line "cancelled"; exit 1; fi + + if [ "$CURSOR" -eq "$CUSTOM_IDX" ]; then + local custom + printf ' %sCustom path%s: ' "$C_BOLD" "$C_RESET" + IFS= read -r custom || custom="" + printf '\n' + [ -n "$custom" ] || { err_line "no path given"; exit 1; } + TARGETS=( "$(expand_path "$custom")" ) + return 0 + fi + + TARGETS=() + for i in "${!CAND_PATHS[@]}"; do + [ "${SEL[$i]:-0}" = "1" ] && TARGETS+=( "${CAND_PATHS[$i]}" ) + done + [ "${#TARGETS[@]}" -gt 0 ] || TARGETS=( "${CAND_PATHS[$CURSOR]}" ) +} + +# Typed fallback for terminals without cursor control (TERM=dumb). Reads a line +# of space-separated numbers, or 'c' for a custom path. Fills TARGETS. +select_typed() { + printf ' %sInstall to which profile?%s\n' "$C_BOLD" "$C_RESET" + local i + for i in "${!CAND_PATHS[@]}"; do + local suffix="" + [ -n "${CAND_LABELS[$i]}" ] && suffix=" ${C_DIM}(${CAND_LABELS[$i]})${C_RESET}" + printf ' %s%d)%s %s%s\n' "$C_DIM" "$((i + 1))" "$C_RESET" "${CAND_PATHS[$i]}" "$suffix" + done + printf ' %sc)%s custom path…\n' "$C_DIM" "$C_RESET" + printf '\n %sSelect%s %s[Enter=default, e.g. 1 3, or c]%s: ' "$C_BOLD" "$C_RESET" "$C_DIM" "$C_RESET" + + local reply + read -r reply || reply="" + printf '\n' + case "$reply" in + "") TARGETS=( "${CAND_PATHS[0]}" ) ;; + c|C) + local custom + printf ' %sCustom path%s: ' "$C_BOLD" "$C_RESET" + read -r custom || custom="" + printf '\n' + [ -n "$custom" ] || { err_line "no path given"; exit 1; } + TARGETS=( "$(expand_path "$custom")" ) + ;; + *) + local tok + for tok in $reply; do + case "$tok" in + *[!0-9]*) warn_line "ignoring '$tok' (not a number)" ;; + *) + if [ "$tok" -ge 1 ] && [ "$tok" -le "${#CAND_PATHS[@]}" ]; then + TARGETS+=( "${CAND_PATHS[$((tok - 1))]}" ) + else + warn_line "ignoring '$tok' (out of range)" + fi + ;; + esac + done + [ "${#TARGETS[@]}" -gt 0 ] || TARGETS=( "${CAND_PATHS[0]}" ) + ;; + esac +} + +# Fills the global TARGETS array with the directories to install into. +TARGETS=() +choose_targets() { + if [ -n "$OPT_DIR" ]; then + TARGETS=( "$(expand_path "$OPT_DIR")" ) + return 0 + fi + if [ "$OPT_YES" -eq 1 ]; then + TARGETS=( "$HOME/.claude" ) + return 0 + fi + + discover_candidates + + # Interactive needs a TTY on both ends. Arrow menu when the terminal supports + # cursor moves; typed fallback for TERM=dumb; default when not interactive. + if [ -t 0 ] && [ -t 1 ]; then + if [ "${TERM:-dumb}" = "dumb" ]; then select_typed; else select_arrows; fi + else + TARGETS=( "${CAND_PATHS[0]}" ) + fi +} + +# --- Settings patching (mirrors the npm CLI's safety contract) --------------- +print_manual() { + local cmd="$1" + printf '\n Add this to your settings.json, inside the top-level object:\n\n' + printf ' "statusLine": {\n' + printf ' "type": "command",\n' + printf ' "command": "%s",\n' "$cmd" + printf ' "refreshInterval": 1\n' + printf ' }\n\n' +} + +patch_settings() { + local settings="$1" cmd="$2" + + if [ "$HAVE_JQ" -ne 1 ]; then + warn_line "jq missing — can't patch $settings safely" + print_manual "$cmd" + return 0 + fi + + if [ ! -f "$settings" ]; then + jq -n --arg c "$cmd" \ + '{statusLine:{type:"command",command:$c,refreshInterval:1}}' > "$settings" + ok_line "settings → $settings (created)" + return 0 + fi + + if ! jq empty "$settings" >/dev/null 2>&1; then + err_line "invalid JSON in $settings — left untouched" + print_manual "$cmd" + return 0 + fi + + local existing + existing=$(jq -r '.statusLine.command // empty' "$settings") + if [ "$existing" = "$cmd" ]; then + ok_line "settings → $settings (already configured)" + return 0 + fi + if [ -n "$existing" ] && [ "$OPT_FORCE" -ne 1 ]; then + warn_line "a different statusLine exists in $settings — re-run with --force to replace" + return 0 + fi + + cp "$settings" "$settings.bak" + jq --arg c "$cmd" \ + '.statusLine={type:"command",command:$c,refreshInterval:1}' \ + "$settings" > "$settings.tmp" && mv "$settings.tmp" "$settings" + local verb="added"; [ -n "$existing" ] && verb="replaced" + ok_line "settings → $settings ($verb, backup → settings.json.bak)" +} + +install_one() { + local dir="$1" + local script_path="$dir/tokenline.sh" + local settings="$dir/settings.json" + local cmd; cmd=$(statusline_command "$script_path") + + if [ "$OPT_DRYRUN" -eq 1 ]; then + note_line "would copy → $script_path" + note_line "would patch → $settings" + return 0 + fi + + spin_for 0.4 "installing into $dir" + mkdir -p "$dir" + cp "$TOKENLINE_SRC" "$script_path" + chmod 755 "$script_path" + ok_line "script → $script_path" + patch_settings "$settings" "$cmd" +} + +# --- Main -------------------------------------------------------------------- +hero + +# --print: keep the original copy-paste workflow, no writes. +if [ "$OPT_PRINT" -eq 1 ]; then + cmd=$(statusline_command "$TOKENLINE_SRC") + print_manual "$cmd" + exit 0 fi -printf '\n' -if [ "$missing" -ne 0 ]; then - warn "Missing dependencies above. On macOS: brew install bash jq." - warn "Windows support is on the roadmap (see README)." +if ! check_deps; then printf '\n' + warn_line "Missing dependencies above. On macOS: brew install bash jq." + warn_line "Fix them, then re-run ./install.sh." + exit 1 fi +printf '\n' + +choose_targets -printf 'Add this to %s/.claude/settings.json\n' "$HOME" -printf '(or your project .claude/settings.json), inside the top-level object:\n\n' -cat < Date: Mon, 29 Jun 2026 16:40:02 -0400 Subject: [PATCH 3/3] feat: add statusline themes and a theme picker to the installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tokenline.sh gains --theme (or TOKENLINE_THEME): full (default, the original three-line render), minimal (model · ctx% · cache), compact (one dense line with tokens + saving%), economics (model/ctx/cache + per-turn breakdown), and limits (model/ctx/cache + 5h/7d bars). Unknown names fall back to full so a typo never blanks the statusline; full emits no flag, keeping the command byte-identical to before. install.sh asks for a theme before the profile: a single-select arrow menu whose preview renders each theme in full, with the live statusline's colors and layout, so you see exactly what gets installed. The chosen --theme is baked into the statusLine command; --theme skips the prompt. --- .changeset/interactive-installer.md | 7 ++ README.md | 24 +++- install.sh | 182 +++++++++++++++++++++++----- tokenline.sh | 115 +++++++++++++++--- 4 files changed, 278 insertions(+), 50 deletions(-) diff --git a/.changeset/interactive-installer.md b/.changeset/interactive-installer.md index dcca155..2596f17 100644 --- a/.changeset/interactive-installer.md +++ b/.changeset/interactive-installer.md @@ -13,3 +13,10 @@ typed-number fallback for `TERM=dumb`. Adds `--dir`, `--yes`, `--dry-run`, piped/non-interactive runs stay plain and default to `~/.claude`. Runs on stock macOS bash 3.2 and checks the PATH `bash` (not the interpreter) for the 4+ requirement. + +Adds themes to `tokenline.sh` via `--theme ` (or `TOKENLINE_THEME`): +`full` (default, unchanged), `minimal` (model · ctx% · cache), `compact` +(one line with tokens + saving%), `economics` (model/ctx/cache + per-turn +breakdown), and `limits` (model/ctx/cache + 5h/7d bars). The installer asks you +to pick a theme before the profile; an unknown value falls back to `full` so a +typo never blanks the statusline. diff --git a/README.md b/README.md index 73daa06..55e2a1e 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,13 @@ cd tokenline ./install.sh ``` -It discovers your Claude profile directories (`~/.claude`, any `~/.claude-*`, -and `./.claude`) and lets you install into one or several at once — handy if you -run multiple profiles. `~/.claude` is the default. +It first asks you to pick a [theme](#themes), then discovers your Claude profile +directories (`~/.claude`, any `~/.claude-*`, and `./.claude`) and lets you +install into one or several at once — handy if you run multiple profiles. Use +↑/↓ and space to choose; `~/.claude` is the default. ```bash +./install.sh --theme minimal # skip the theme prompt ./install.sh --dir ~/.claude-work # install into a specific directory ./install.sh --yes # non-interactive, install into ~/.claude ./install.sh --dry-run # show what would happen, write nothing @@ -118,6 +120,22 @@ run multiple profiles. `~/.claude` is the default. Then restart Claude Code. +### Themes + +The statusline ships five layouts, selected with `--theme ` in the +`statusLine` command (or the `TOKENLINE_THEME` env var). `full` is the default +and needs no flag. + +| Theme | Lines | Shows | +| --- | --- | --- | +| `full` | 3 | model · ctx · cache + per-turn economics + 5h/7d limit bars | +| `minimal` | 1 | model · ctx% · cache state | +| `compact` | 1 | model · ctx (tokens + %) · cache TTL · saving% | +| `economics` | 2 | `full`'s first line + the per-turn economics breakdown | +| `limits` | 2 | `full`'s first line + the 5h/7d rate-limit bars | + +An unknown theme name falls back to `full`, so a typo never blanks the line. + ### What the installer does `npx @inbrace-tech/tokenline init` is deliberately transparent about touching your config: diff --git a/install.sh b/install.sh index 09505ad..9988466 100755 --- a/install.sh +++ b/install.sh @@ -1,22 +1,11 @@ #!/usr/bin/env bash # ============================================================================== -# tokenline installer +# tokenline installer — copies tokenline.sh into the chosen Claude profile(s) +# and safely patches each settings.json (backup, merge-only, idempotent, never +# clobbers invalid JSON), mirroring the npm CLI in pure bash + jq. # -# Copies tokenline.sh into one or more Claude profile directories and safely -# patches each settings.json (backup, merge-only statusLine, idempotent, never -# clobbers invalid JSON). Mirrors the npm CLI in pure bash + jq, so non-Node -# users get the same guarantees. -# -# Runs on stock macOS bash 3.2 (no bash-4 features here). Animations show only -# on a TTY; piped/non-interactive runs stay plain and never prompt. -# -# Usage: -# ./install.sh # interactive — pick one or more profiles -# ./install.sh --yes # non-interactive — install to ~/.claude -# ./install.sh --dir # install to a specific directory -# ./install.sh --dry-run # show what would happen, write nothing -# ./install.sh --print # only print the settings snippet -# ./install.sh --force # replace a different existing statusLine +# Runs on stock macOS bash 3.2. Animations only on a TTY; piped runs stay plain. +# See usage() / --help for flags. # ============================================================================== set -euo pipefail @@ -29,14 +18,31 @@ OPT_YES=0 OPT_DRYRUN=0 OPT_PRINT=0 OPT_FORCE=0 +OPT_THEME="" usage() { - sed -n '3,21p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + cat <<'EOF' +tokenline installer + +Copies tokenline.sh into one or more Claude profile directories and patches +each settings.json (backup, merge-only statusLine, idempotent, never clobbers +invalid JSON). Pick a theme and the profile(s) interactively. + +Usage: + ./install.sh # interactive — pick a theme, then profile(s) + ./install.sh --theme # full | minimal | compact | economics | limits + ./install.sh --yes # non-interactive — full theme into ~/.claude + ./install.sh --dir # install into a specific directory + ./install.sh --dry-run # show what would happen, write nothing + ./install.sh --print # only print the settings snippet + ./install.sh --force # replace a different existing statusLine +EOF } while [ $# -gt 0 ]; do case "$1" in --dir) OPT_DIR="${2:-}"; shift 2 ;; + --theme) OPT_THEME="${2:-}"; shift 2 ;; -y|--yes) OPT_YES=1; shift ;; --dry-run) OPT_DRYRUN=1; shift ;; --print) OPT_PRINT=1; shift ;; @@ -46,6 +52,27 @@ while [ $# -gt 0 ]; do esac done +# Theme catalog (must match tokenline.sh --theme names; full = default). +THEME_NAMES=(full minimal compact economics limits) +THEME_DESCS=( + "everything — model · ctx · cache + economics + limits" + "model · ctx% · cache state" + "model · ctx · cache · saving%" + "model · ctx · cache + per-turn economics" + "model · ctx · cache + 5h/7d limit bars" +) +# Tallest preview (full); the preview area is padded to this so the menu block +# keeps a constant height for the fixed cursor-up redraw. +PREVIEW_H=4 + +# Unknown --theme falls back to full. +if [ -n "$OPT_THEME" ]; then + case "$OPT_THEME" in + full|minimal|compact|economics|limits) ;; + *) printf 'unknown theme: %s (using full)\n' "$OPT_THEME" >&2; OPT_THEME="full" ;; + esac +fi + # --- Presentation ------------------------------------------------------------ # Colors and animation only when stdout is a TTY and NO_COLOR is unset. if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then @@ -74,9 +101,8 @@ warn_line() { printf ' %s!%s %s\n' "$C_WARN" "$C_RESET" "$1"; nap; } err_line() { printf ' %s✗%s %s\n' "$C_ERR" "$C_RESET" "$1" >&2; nap; } note_line() { printf ' %s→ %s%s\n' "$C_DIM" "$1" "$C_RESET"; nap; } -# Decorative braille spinner held for ~$1 seconds while $2 is the label. The work -# runs synchronously right after, so this only paces the reveal — never fakes a -# result. No-ops off a TTY. +# Decorative braille spinner (~$1s, label $2) that paces the reveal; real work +# runs right after. No-ops off a TTY. spin_for() { local secs="$1" label="$2" [ "$ANIM" -eq 1 ] || return 0 @@ -104,10 +130,16 @@ expand_path() { statusline_command() { # Quote only when the path has a space, to match the npm CLI byte-for-byte. + local base case "$1" in - *" "*) printf 'bash "%s"' "$1" ;; - *) printf 'bash %s' "$1" ;; + *" "*) base="bash \"$1\"" ;; + *) base="bash $1" ;; esac + # Omit the flag for full (the default) so the command stays identical. + if [ -n "$OPT_THEME" ] && [ "$OPT_THEME" != "full" ]; then + base="$base --theme $OPT_THEME" + fi + printf '%s' "$base" } # --- Dependency check -------------------------------------------------------- @@ -116,9 +148,8 @@ check_deps() { local missing=0 spin_for 0.5 "checking dependencies" - # Check the `bash` on PATH — that's what runs `bash tokenline.sh`, not the - # interpreter running this installer (stock macOS launches it as 3.2). Ask - # that bash for its own version so a localized `--version` can't break it. + # Check the PATH `bash` (what runs the statusline), not this interpreter + # (3.2 on stock macOS). Ask it directly so a localized `--version` can't break. local bash_ver bash_major bash_ver=$(bash -c 'printf "%s.%s" "${BASH_VERSINFO[0]}" "${BASH_VERSINFO[1]}"' 2>/dev/null) bash_major=${bash_ver%%.*} @@ -213,9 +244,7 @@ draw_menu() { printf ' %s %scustom path…%s\033[K\n' "$cur" "$C_DIM" "$C_RESET" } -# Arrow-key multi-select. ↑/↓ move, space toggles, digits 1-9 jump+toggle, -# Enter confirms, q/Esc cancels. Pure bash + ANSI; no stty, so it works the same -# on macOS bash 3.2 and Linux. Fills TARGETS. +# Arrow-key multi-select (pure bash + ANSI, no stty). Fills TARGETS. select_arrows() { local total key k2 i CURSOR=0; SEL=() @@ -316,6 +345,101 @@ select_typed() { esac } +# --- Theme selection --------------------------------------------------------- +THEME_CURSOR=0 + +# Colored mock of each theme, matching the live statusline layout/palette, so +# the preview shows what gets installed. One statusline line per printf line. +theme_preview() { + local g=$'\033[38;5;244m' r=$'\033[0m' gb=$'\033[01;32m' cy=$'\033[38;5;51m' + local ye=$'\033[38;5;226m' mg=$'\033[38;5;201m' og=$'\033[38;5;208m' + local gr=$'\033[38;5;46m' dg=$'\033[38;5;240m' dot + dot="${g} · ${r}" + local l1="Opus 4.8 ${g}| ctx: ${r}${gb}46.2k/200.0k (42%)${r} ${g}| [5m] cache: ${gr}4:05 HOT${r}" + local econ="${g}read(0.1x): ${cy}40.0k${r} ${g}write(1.25x): ${ye}5.0k${r} ${g}new(1x): ${mg}1.2k${r} ${g}output(5x): ${gr}800${r} ${g}eq: ${og}15.4k${r} ${g}saving: ${gr}69%${r}" + local sep="${dg}──────────────────────────────${r}" + local rl="${g}5h: ${r}${gr}███${dg}░░░░░░░${r} ${gr}30%${r} ${g}7d: ${r}${gr}█${dg}░░░░░░░░░${r} ${gr}12%${r}" + case "$1" in + full) printf '%s\n%s\n%s\n%s\n' "$l1" "$econ" "$sep" "$rl" ;; + economics) printf '%s\n%s\n' "$l1" "$econ" ;; + limits) printf '%s\n%s\n%s\n' "$l1" "$sep" "$rl" ;; + minimal) printf 'Opus 4.8%s%sctx %s%s42%%%s%s%scache %sHOT%s\n' \ + "$dot" "$g" "$r" "$gb" "$r" "$dot" "$g" "$gr" "$r" ;; + compact) printf 'Opus 4.8%s%sctx %s%s46.2k/200.0k 42%%%s%s%scache 4:05 %sHOT%s%s%ssave %s69%%%s\n' \ + "$dot" "$g" "$r" "$gb" "$r" "$dot" "$g" "$gr" "$r" "$dot" "$g" "$gr" "$r" ;; + esac +} + +# Clip to $2 *visible* columns, copying ANSI escapes through without counting +# them (so a colored preview line never wraps and desyncs the redraw count). +clip_visible() { + local s="$1" max="$2" out="" vis=0 i=0 n=${#1} ch + while [ "$i" -lt "$n" ]; do + ch="${s:i:1}" + if [ "$ch" = $'\033' ]; then + while [ "$i" -lt "$n" ]; do + ch="${s:i:1}"; out="$out$ch"; i=$((i + 1)) + [ "$ch" = "m" ] && break + done + continue + fi + [ "$vis" -ge "$max" ] && break + out="$out$ch"; vis=$((vis + 1)); i=$((i + 1)) + done + printf '%s\033[0m' "$out" +} + +draw_theme() { + local i cur_m line n=0 + for i in "${!THEME_NAMES[@]}"; do + cur_m=" "; [ "$i" -eq "$THEME_CURSOR" ] && cur_m="${C_ACCENT}❯${C_RESET} " + printf ' %s%s%-10s%s %s%s%s\033[K\n' \ + "$cur_m" "$C_BOLD" "${THEME_NAMES[$i]}" "$C_RESET" "$C_DIM" "${THEME_DESCS[$i]}" "$C_RESET" + done + printf '\033[K\n' + printf ' %spreview%s\033[K\n' "$C_DIM" "$C_RESET" + while IFS= read -r line; do + printf ' %s\033[K\n' "$(clip_visible "$line" "$((COLS - 6))")"; n=$((n + 1)) + done < <(theme_preview "${THEME_NAMES[$THEME_CURSOR]}") + while [ "$n" -lt "$PREVIEW_H" ]; do printf '\033[K\n'; n=$((n + 1)); done +} + +# Single-select arrow menu for the theme. Fills OPT_THEME. +choose_theme() { + # Explicit flag, non-interactive, or dumb terminal: keep current (default full). + if [ -n "$OPT_THEME" ]; then return 0; fi + if ! { [ -t 0 ] && [ -t 1 ]; } || [ "${TERM:-dumb}" = "dumb" ]; then + OPT_THEME="full"; return 0 + fi + + local total=${#THEME_NAMES[@]} key k2 i + THEME_CURSOR=0 + COLS=$(tput cols 2>/dev/null || printf 80) + printf ' %sChoose a theme%s %s(↑/↓ move · enter select)%s\n\n' \ + "$C_BOLD" "$C_RESET" "$C_DIM" "$C_RESET" + # Fixed block height (preview padded to PREVIEW_H) for a cursor-up redraw — + # CUU is portable, SCO save/restore isn't. + local block=$((total + 2 + PREVIEW_H)) + printf '\033[?25l' + draw_theme + while :; do + IFS= read -rsn1 key || key="" + if [ "$key" = $'\033' ]; then IFS= read -rsn2 -t 1 k2 2>/dev/null || k2=""; key="$key$k2"; fi + case "$key" in + $'\033[A'|$'\033OA') THEME_CURSOR=$(((THEME_CURSOR - 1 + total) % total)) ;; + $'\033[B'|$'\033OB') THEME_CURSOR=$(((THEME_CURSOR + 1) % total)) ;; + [1-9]) i=$((key - 1)); [ "$i" -lt "$total" ] && THEME_CURSOR=$i ;; + ""|$'\n'|$'\r') break ;; + q|Q|$'\033') OPT_THEME="full"; printf '\033[?25h\n'; return 0 ;; + *) : ;; + esac + printf '\033[%dA' "$block" + draw_theme + done + printf '\033[?25h\n' + OPT_THEME="${THEME_NAMES[$THEME_CURSOR]}" +} + # Fills the global TARGETS array with the directories to install into. TARGETS=() choose_targets() { @@ -429,6 +553,8 @@ if ! check_deps; then fi printf '\n' +choose_theme +printf '\n' choose_targets for t in "${TARGETS[@]}"; do diff --git a/tokenline.sh b/tokenline.sh index 4e04023..3d8c118 100755 --- a/tokenline.sh +++ b/tokenline.sh @@ -39,6 +39,22 @@ if ! command -v jq >/dev/null 2>&1; then exit 0 fi +# --- Theme --- +# Layout preset from `--theme ` or $TOKENLINE_THEME. Unknown values fall +# back to "full" (the original three-line render) so a typo never blanks it. +THEME="${TOKENLINE_THEME:-full}" +while [ $# -gt 0 ]; do + case "$1" in + --theme) THEME="${2:-full}"; shift 2 ;; + --theme=*) THEME="${1#--theme=}"; shift ;; + *) shift ;; + esac +done +case "$THEME" in + full|minimal|compact|economics|limits) ;; + *) THEME="full" ;; +esac + # --- GNU vs BSD coreutils (Linux vs macOS) --- # Probe behavior, not `uname`, so Homebrew coreutils is picked up automatically. if date -d "@0" >/dev/null 2>&1; then _date_gnu=1; else _date_gnu=0; fi @@ -175,6 +191,8 @@ fmt_eta() { # --- 3. Cache Timer Logic --- compute_cache_timer() { cache_info="" + # Exposed for the short themes (minimal/compact): cache state, clock, color. + cache_state=""; cache_clock=""; cache_color="" local ts_cache_file="$_runtime_dir/lastts-${session_id:-default}" local ttl_cache_file="$_runtime_dir/ttl-${session_id:-default}" local tokens_cache_file="$_runtime_dir/lasttokens-${session_id:-default}" @@ -271,8 +289,11 @@ compute_cache_timer() { local suffix="HOT" [ "$pct10" -lt 1 ] && suffix="HOT !" + cache_state="$suffix"; cache_color="$fg" + cache_clock=$(printf '%d:%02d' "$mins" "$secs") cache_info=$(printf '%s[%s] cache: %s%d:%02d %s%s' "$COLOR_GRAY" "$ttl_label" "$fg" "$mins" "$secs" "$suffix" "$COLOR_RESET") else + cache_state="COLD"; cache_color="${COLOR_RED}"; cache_clock="" cache_info=$(printf '%s[%s] cache: \033[1;5m%sCOLD%s' "$COLOR_GRAY" "$ttl_label" "$COLOR_RED" "$COLOR_RESET") fi } @@ -388,6 +409,8 @@ compute_rate_limits() { # --- 6. Last-Turn Token Economics Breakdown & Equivalents --- compute_turn_breakdown() { last_info="" + # Exposed for the compact theme: saving percentage and its color. + saving_pct=""; save_color="" if [ "$cur_cread" -gt 0 ] || [ "$cur_cwrite" -gt 0 ] || [ "$cur_input" -gt 0 ] || [ "$cur_output" -gt 0 ]; then local read_mult local write_mult @@ -414,15 +437,14 @@ compute_turn_breakdown() { eq_tokens=$(awk "BEGIN { printf \"%d\", ($cur_cread * $read_mult) + ($cur_cwrite * $write_mult) + ($cur_input * $input_mult) + ($cur_output * $output_mult) }") uncached_eq=$(awk "BEGIN { printf \"%d\", ($cur_cread + $cur_cwrite + $cur_input) * $input_mult + ($cur_output * $output_mult) }") - local saving_pct=0 + saving_pct=0 [ "$uncached_eq" -gt 0 ] && saving_pct=$(awk "BEGIN { printf \"%d\", 100 * ($uncached_eq - $eq_tokens) / $uncached_eq }") - + local read_lbl="${read_mult}x" local write_lbl="${write_mult}x" local input_lbl="${input_mult}x" local output_lbl="${output_mult}x" - local save_color if [ "$saving_pct" -ge 90 ]; then save_color="$COLOR_GREEN" elif [ "$saving_pct" -ge 70 ]; then save_color="$COLOR_YELLOW" elif [ "$saving_pct" -ge 50 ]; then save_color="$COLOR_ORANGE" @@ -441,36 +463,91 @@ compute_turn_breakdown() { } # --- 7. Compose and Render Output --- -render_statusline() { - # Line 1: client/model | ctx | cache TTL - local display_header="" +ctx_color_for() { + # Bold green/yellow/red by context-used percentage. + local p=$1 + if [ "$p" -ge 80 ]; then printf '\033[01;31m' + elif [ "$p" -ge 50 ]; then printf '\033[01;33m' + else printf '\033[01;32m' + fi +} + +model_header() { if [ "$cli_client" = "antigravity" ]; then - # Custom styled brand display for Antigravity CLI users - display_header="${COLOR_CYAN}🌌 Antigravity${COLOR_RESET} (${model})" + printf '%s🌌 Antigravity%s (%s)' "$COLOR_CYAN" "$COLOR_RESET" "$model" else - # Default display for Claude Code - display_header="${model}" + printf '%s' "$model" fi +} - local line1="$display_header" +# Line 1, shared by the multi-line themes: model | ctx | cache TTL. +render_line1() { + local line1; line1=$(model_header) [ -n "$ctx_info" ] && line1="$line1 | $ctx_info" [ -n "$cache_info" ] && line1="$line1 | $cache_info" - printf "%s\n" "$line1" - - # Line 2: breakdown of cache / raw / saving (only shown when activity happens) - [ -n "$last_info" ] && printf "%s\n" "$last_info" + printf '%s\n' "$line1" +} - # Line 3: API rate limits and progress bars (Only shown for Claude models where limits exist) +# Rate-limit line (Claude only, when limits exist). +render_rate_limits() { if [ "$is_gemini" = false ] && { [ -n "$rl_5h_info" ] || [ -n "$rl_7d_info" ]; }; then - local sep_line="${COLOR_DARK_GRAY}──────────────────────────────${COLOR_RESET}" - printf "%s\n" "$sep_line" + printf '%s\n' "${COLOR_DARK_GRAY}──────────────────────────────${COLOR_RESET}" local line_rl="" [ -n "$rl_5h_info" ] && line_rl="$rl_5h_info" [ -n "$rl_7d_info" ] && line_rl="${line_rl:+$line_rl }$rl_7d_info" - printf "%s\n" "$line_rl" + printf '%s\n' "$line_rl" fi } +# Single-line themes: minimal (model · ctx% · cache state) and compact +# (model · ctx tokens+% · cache clock+state · saving%). +render_oneline() { + local sep="${COLOR_GRAY} · ${COLOR_RESET}" + local out; out=$(model_header) + local p="" + [ -n "$used_pct" ] && p=$(printf '%.0f' "$used_pct") + + if [ -n "$p" ]; then + local cc; cc=$(ctx_color_for "$p") + if [ "$THEME" = "compact" ] && [ "${tokens_used:-0}" -gt 0 ] && [ "${tokens_limit:-0}" -gt 0 ]; then + out="$out${sep}${COLOR_GRAY}ctx ${COLOR_RESET}${cc}$(fmt_k "$tokens_used")/$(fmt_k "$tokens_limit") ${p}%${COLOR_RESET}" + else + out="$out${sep}${COLOR_GRAY}ctx ${COLOR_RESET}${cc}${p}%${COLOR_RESET}" + fi + fi + + if [ -n "$cache_state" ]; then + local cstr="cache " + [ "$THEME" = "compact" ] && [ -n "$cache_clock" ] && cstr="cache ${cache_clock} " + out="$out${sep}${COLOR_GRAY}${cstr}${cache_color}${cache_state}${COLOR_RESET}" + fi + + if [ "$THEME" = "compact" ] && [ -n "$saving_pct" ]; then + out="$out${sep}${COLOR_GRAY}save ${save_color}${saving_pct}%${COLOR_RESET}" + fi + + printf '%s\n' "$out" +} + +render_statusline() { + case "$THEME" in + minimal|compact) render_oneline ;; + economics) + render_line1 + [ -n "$last_info" ] && printf '%s\n' "$last_info" + ;; + limits) + render_line1 + render_rate_limits + ;; + full|*) + render_line1 + [ -n "$last_info" ] && printf '%s\n' "$last_info" + render_rate_limits + ;; + esac +} + # --- Orchestrated Execution Flow --- parse_and_prepare_paths compute_cache_timer