diff --git a/README.md b/README.md index 539c2d0cd..f40abc8ef 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ - [Features](#-features) - [Localizations](#-localizations) - [Quick Start](#-quick-start) +- [Non-Interactive CLI (for agents & scripts)](#-non-interactive-cli-for-agents--scripts) - [Windows Support](docs/WINDOWS.md) - [Usage](docs/USAGE.md) - [Development](docs/DEVELOPMENT.md) @@ -400,6 +401,37 @@ For pinned installs, launch the TUI with `npx -y ccstatusline@latest` or `bunx - +## πŸ€– Non-Interactive CLI (for agents & scripts) + +Every TUI setting is also scriptable without launching the interactive UI. All +subcommands operate on the same `settings.json` (honor `--config `), +refuse to modify an unreadable/invalid config, and validate every change +before writing. Errors are single-line; add `--json` to any command for a +machine-readable single-line payload. + +```bash +ccstatusline get [--json] # print the effective (post-migration) config +ccstatusline widget add [--index N] [--option value ...] +ccstatusline widget remove +ccstatusline widget move --to +ccstatusline set # global options; value is JSON or a plain string +ccstatusline validate [--file ] # exit 0/1 with a machine-readable report +ccstatusline help +``` + +Indices are 0-based. `widget add` accepts the widget options the TUI exposes +(`--color`, `--customText`, `--bold`, `--maxWidth`, `--metadata key=value`, …). + +`set theme ` applies one of the built-in powerline themes (`nord`, +`dracula`, `tokyonight`, …) to regular (non-powerline) mode as foreground +colors only, cycling the theme's segment palette across widgets. Explicit +per-widget colors win; separators are left untouched; `custom` or an unknown +name disables theming. + +> **Note:** `get` and `validate` read through the same loader the TUI uses, so +> on a missing `settings.json` they write the default config on that first run +> (the file is never overwritten when it exists but is unreadable or invalid). + ## 🀝 Contributing Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/docs/USAGE.md b/docs/USAGE.md index 62cd88bcf..2a58d0e80 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -55,6 +55,7 @@ ccstatusline --version ### Tokens, Usage & Context - **Tokens Input** / **Tokens Output** / **Tokens Cached** / **Tokens Total** - Show current-session token counts. Input/output prefer cumulative transcript metrics and fall back to `context_window.total_input_tokens` / `context_window.total_output_tokens` when transcript metrics are unavailable; cached/total use transcript metrics. +- **Tokens Last Turn** - Show token usage (input + output + cache) for the most recent main-chain assistant API call only, instead of the cumulative session total. Claude Code transcripts record one entry per content block of a call sharing a single `message.id`, so entries are deduplicated per call rather than summed. Sidechain (subagent) and API-error entries are ignored, so the widget keeps reporting your last turn while a subagent runs. - **Cache Hit Rate** / **Cache Read** / **Cache Write** - Show prompt-cache efficiency. Cache Hit Rate uses cache reads divided by cache reads plus cache writes; Cache Read and Cache Write include each value's share of prompt context. They default to the latest turn from `context_window.current_usage`, can switch to cumulative session totals, and can hide when empty. - **Cache Timer** - Estimate time remaining before the current prompt-cache entry expires. It shows `HOT` while a main-chain turn is active, then counts down from the latest assistant request with cache activity and becomes `COLD` just before expiry. The default TTL is 5 minutes; it can switch to 1 hour, hide when no cache anchor is available, and customize the glyph for each state. Because Claude Code transcripts expose cache token activity rather than the actual expiry timestamp, the countdown is best effort. - **Input Speed** / **Output Speed** / **Total Speed** - Show session-average token throughput with an optional per-widget rolling window (`0-120` seconds; `0` = full-session average). @@ -112,6 +113,17 @@ Configure global formatting preferences that apply to all widgets: - Press **(s)** to edit separator - Manual separators look past widgets that render empty, so the intended separator remains between visible neighbors without duplicating an earlier visible boundary. Inherited separator colors come from the actual preceding visible widget. +
+Per-widget width overhead (audit) + +- **Default padding is empty** (`defaultPadding` unset), so widgets add no padding unless you configure it. A non-empty padding of N characters adds up to 2N columns per widget (one per enabled side). +- **Manual `|` separators render as ` | `** (3 columns); in Powerline mode separator widgets are ignored and replaced by the powerline separators. +- **Labels are the dominant fixed cost** of labeled widgets. Defaults: `Model: ` (7), `Ctx: ` (5), `Cost: ` (6), token widgets `In: `/`Out: `/`Total: `/`Cached: ` (4–8), cache widgets `Cache Read: `/`Cache Write: `/`Cache Hit: ` (10–12), timers `Block: `/`Reset: `/`Cache: ` (7) and `Weekly Reset: ` (13), usage widgets `Session: `/`Weekly: `/`Weekly Sonnet: `/`Weekly Opus: `/`Weekly Fable: ` (8–15). +- **Compact Labels** (above) trims the presets it covers (`Model:` βˆ’4, `Context:` βˆ’4, `Cost:` βˆ’6, label gone β€” the value's own `$` remains); Minimalist Mode strips labels entirely. +- **Unbounded-content widgets** (paths, names, URLs) can overflow a narrow terminal: Git Branch, Git Root Dir, Current Working Dir, and Session Name support a per-widget max-width cap β€” select the widget in the line editor and press **(w)idth**. The line renderer truncates the whole line with an ellipsis regardless. + +
+
Global Formatting Options @@ -121,6 +133,10 @@ Configure global formatting preferences that apply to all widgets: - Press **(o)** to toggle - **Minimalist Mode** - Force widgets into raw-value rendering globally for a cleaner, label-free status line - Press **(m)** to toggle +- **Compact Labels** - Use short label presets on labeled widgets: `Model:` β†’ `M:`, `Context:` β†’ `Ctx:`, `Cost:` β†’ `$` + - Press **(j)** to toggle; off by default, so existing configs render exactly as before + - Per-widget override: select a labeled widget in the line editor and press **(j) compact label** to force it on/off regardless of the global setting + - Labels without a preset (e.g. `In:`, `Cached:`) keep their default form; presets live in `COMPACT_LABELS` in `src/widgets/shared/raw-or-labeled.ts` - **Number Formatting** - Choose precise, compact, or whole-number output independently for token, speed, percent, memory, and cost values - Press **(n)** to configure each number type; a global choice overrides per-widget formatting for that type - **Override Foreground Color** - Force all widgets to use the same text color, or a whole-line **gradient** (see below) @@ -250,6 +266,7 @@ Common controls in the line editor: - `Space` cycle a manual separator character - `r` toggle raw value (supported widgets) - `.` cycle precise/compact/whole number formatting (supported widgets) +- `j` toggle the compact label preset for labeled widgets (Model β†’ `M:`, Context β†’ `Ctx:`, Cost β†’ `$`) - `m` cycle merge mode (`off` β†’ `merge` β†’ `merge no padding`) - `x` exclude the selected widget and the rest of its line from shared Powerline column widths (shown only when Powerline auto-alignment is enabled) - `Esc` go back diff --git a/docs/performance-397-results.json b/docs/performance-397-results.json new file mode 100644 index 000000000..cfa136a1e --- /dev/null +++ b/docs/performance-397-results.json @@ -0,0 +1,308 @@ +{ + "aggregates": { + "base-node": { + "batches": 3, + "renders": 240, + "cpu_ms": 253.76999999999998, + "pooled_p50_ms": 240.19275000318885, + "batch_cpu_ms": [ + 246.321, + 246.197, + 268.792 + ], + "batch_p50_ms": [ + 231.993, + 220.007, + 283.665 + ] + }, + "pr-node": { + "batches": 4, + "renders": 320, + "cpu_ms": 256.7125, + "pooled_p50_ms": 243.43347904505208, + "batch_cpu_ms": [ + 245.566, + 260.781, + 272.594, + 247.909 + ], + "batch_p50_ms": [ + 225.165, + 235.287, + 258.215, + 239.932 + ] + }, + "lazy-node": { + "batches": 3, + "renders": 240, + "cpu_ms": 219.515, + "pooled_p50_ms": 210.23656299803406, + "batch_cpu_ms": [ + 211.795, + 217.841, + 228.909 + ], + "batch_p50_ms": [ + 199.623, + 190.829, + 244.28 + ] + }, + "pr-bun": { + "batches": 2, + "renders": 160, + "cpu_ms": 171.86, + "pooled_p50_ms": 153.23652152437717, + "batch_cpu_ms": [ + 172.445, + 171.275 + ], + "batch_p50_ms": [ + 150.541, + 153.675 + ] + }, + "lazy-bun": { + "batches": 2, + "renders": 160, + "cpu_ms": 144.7725, + "pooled_p50_ms": 123.54808300733566, + "batch_cpu_ms": [ + 140.06, + 149.485 + ], + "batch_p50_ms": [ + 112.039, + 132.893 + ] + } + }, + "runs": [ + { + "label": "base13-a", + "runtime": "/opt/homebrew/bin/node", + "entry": "/private/tmp/ccstatusline-perf-4/base/dist/ccstatusline.js", + "bytes": 13009445, + "renders": 80, + "width_override": false, + "cpu_total_s": 19.7057, + "cpu_per_render_ms": 246.321, + "wall_s": 4.81, + "p50_ms": 231.993, + "p95_ms": 334.335, + "hashes": [ + "454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86" + ] + }, + { + "label": "base13-b", + "runtime": "/opt/homebrew/bin/node", + "entry": "/private/tmp/ccstatusline-perf-4/base/dist/ccstatusline.js", + "bytes": 13009445, + "renders": 80, + "width_override": false, + "cpu_total_s": 19.6957, + "cpu_per_render_ms": 246.197, + "wall_s": 4.523, + "p50_ms": 220.007, + "p95_ms": 270.82, + "hashes": [ + "454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86" + ] + }, + { + "label": "final-base-node", + "runtime": "/opt/homebrew/bin/node", + "entry": "/private/tmp/ccstatusline-perf-4/base/dist/ccstatusline.js", + "bytes": 13009445, + "renders": 80, + "width_override": false, + "cpu_total_s": 21.5033, + "cpu_per_render_ms": 268.792, + "wall_s": 5.728, + "p50_ms": 283.665, + "p95_ms": 429.653, + "hashes": [ + "454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86" + ] + }, + { + "label": "pr13-a", + "runtime": "/opt/homebrew/bin/node", + "entry": "/private/tmp/ccstatusline-perf-4/pr/dist/ccstatusline.js", + "bytes": 13009445, + "renders": 80, + "width_override": false, + "cpu_total_s": 19.6453, + "cpu_per_render_ms": 245.566, + "wall_s": 4.719, + "p50_ms": 225.165, + "p95_ms": 309.623, + "hashes": [ + "454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86" + ] + }, + { + "label": "pr13-b", + "runtime": "/opt/homebrew/bin/node", + "entry": "/private/tmp/ccstatusline-perf-4/pr/dist/ccstatusline.js", + "bytes": 13009445, + "renders": 80, + "width_override": false, + "cpu_total_s": 20.8624, + "cpu_per_render_ms": 260.781, + "wall_s": 5.43, + "p50_ms": 235.287, + "p95_ms": 421.339, + "hashes": [ + "454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86" + ] + }, + { + "label": "pr13-c", + "runtime": "/opt/homebrew/bin/node", + "entry": "/private/tmp/ccstatusline-perf-4/pr/dist/ccstatusline.js", + "bytes": 13009445, + "renders": 80, + "width_override": false, + "cpu_total_s": 21.8075, + "cpu_per_render_ms": 272.594, + "wall_s": 5.458, + "p50_ms": 258.215, + "p95_ms": 362.965, + "hashes": [ + "454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86" + ] + }, + { + "label": "final-pr-node", + "runtime": "/opt/homebrew/bin/node", + "entry": "/private/tmp/ccstatusline-perf-4/pr/dist/ccstatusline.js", + "bytes": 13009445, + "renders": 80, + "width_override": false, + "cpu_total_s": 19.8328, + "cpu_per_render_ms": 247.909, + "wall_s": 4.826, + "p50_ms": 239.932, + "p95_ms": 309.555, + "hashes": [ + "454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86" + ] + }, + { + "label": "lazy13-a", + "runtime": "/opt/homebrew/bin/node", + "entry": "/private/tmp/ccstatusline-perf-4/lazy/dist/ccstatusline.js", + "bytes": 13009445, + "renders": 80, + "width_override": false, + "cpu_total_s": 16.9436, + "cpu_per_render_ms": 211.795, + "wall_s": 4.174, + "p50_ms": 199.623, + "p95_ms": 294.081, + "hashes": [ + "454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86" + ] + }, + { + "label": "lazy13-b", + "runtime": "/opt/homebrew/bin/node", + "entry": "/private/tmp/ccstatusline-perf-4/lazy/dist/ccstatusline.js", + "bytes": 13009445, + "renders": 80, + "width_override": false, + "cpu_total_s": 17.4273, + "cpu_per_render_ms": 217.841, + "wall_s": 4.353, + "p50_ms": 190.829, + "p95_ms": 336.969, + "hashes": [ + "454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86" + ] + }, + { + "label": "final-lazy-node", + "runtime": "/opt/homebrew/bin/node", + "entry": "/Users/axisrow/.ao/data/worktrees/ccstatusline/ccstatusline-4/dist/ccstatusline.js", + "bytes": 13009445, + "renders": 80, + "width_override": false, + "cpu_total_s": 18.3127, + "cpu_per_render_ms": 228.909, + "wall_s": 5.099, + "p50_ms": 244.28, + "p95_ms": 352.927, + "hashes": [ + "454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86" + ] + }, + { + "label": "pr-bun13", + "runtime": "/opt/homebrew/bin/bun", + "entry": "/private/tmp/ccstatusline-perf-4/pr/dist/ccstatusline.js", + "bytes": 13009445, + "renders": 80, + "width_override": false, + "cpu_total_s": 13.7956, + "cpu_per_render_ms": 172.445, + "wall_s": 3.102, + "p50_ms": 150.541, + "p95_ms": 182.732, + "hashes": [ + "454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86" + ] + }, + { + "label": "final-pr-bun", + "runtime": "/opt/homebrew/bin/bun", + "entry": "/private/tmp/ccstatusline-perf-4/pr/dist/ccstatusline.js", + "bytes": 13009445, + "renders": 80, + "width_override": false, + "cpu_total_s": 13.702, + "cpu_per_render_ms": 171.275, + "wall_s": 3.312, + "p50_ms": 153.675, + "p95_ms": 226.265, + "hashes": [ + "454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86" + ] + }, + { + "label": "lazy-bun13", + "runtime": "/opt/homebrew/bin/bun", + "entry": "/private/tmp/ccstatusline-perf-4/lazy/dist/ccstatusline.js", + "bytes": 13009445, + "renders": 80, + "width_override": false, + "cpu_total_s": 11.2048, + "cpu_per_render_ms": 140.06, + "wall_s": 2.383, + "p50_ms": 112.039, + "p95_ms": 158.231, + "hashes": [ + "454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86" + ] + }, + { + "label": "final-lazy-bun", + "runtime": "/opt/homebrew/bin/bun", + "entry": "/Users/axisrow/.ao/data/worktrees/ccstatusline/ccstatusline-4/dist/ccstatusline.js", + "bytes": 13009445, + "renders": 80, + "width_override": false, + "cpu_total_s": 11.9588, + "cpu_per_render_ms": 149.485, + "wall_s": 2.809, + "p50_ms": 132.893, + "p95_ms": 186.214, + "hashes": [ + "454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86" + ] + } + ] +} diff --git a/docs/performance-397-verification.md b/docs/performance-397-verification.md new file mode 100644 index 000000000..28ae8576a --- /dev/null +++ b/docs/performance-397-verification.md @@ -0,0 +1,133 @@ +# PR #2 adversarial verification and local follow-up + +Verified on 2026-09-25; macOS 26 / arm64, Node 25.9.0, Bun 1.3.13. +Baseline: `35440e4`; PR: `9d56d93` + `1177422`. Follow-up: `164007b` on +`ao/ccstatusline-4/perf-followups`. PR #2 and its branch were not modified. +No push, PR update, or upstream publication was performed. + +## Phase 1 verdicts + +| Claim | Verdict and evidence | +| --- | --- | +| One `ps` answers both columns | **Confirmed on macOS and Linux procps-ng 4.0.2**. Actual host `ps` and an ephemeral Debian container emitted two headerless columns. Tested Linux default/`linux`, `posix`, `bsd`, and `sun` personalities. Whitespace and `ttys001`, `pts/4`, `ttyv0`, `ttyp0` fixture parsing work; missing TTY, `?`, and `??` become null. | +| Portable across BSD variants | **Refuted.** FreeBSD's format parser treats everything after the first `=` as a column header, including commas: `ppid=,tty=` means one PPID column headed `,tty=`. The PR tries to parse that header as a PID, stops, and falls back to `tput`. Use `ps -o ppid= -o tty= -p PID`: still one spawn. OpenBSD's parser instead splits commas first, like the tested macOS behavior. BSD conclusions are source/manual verification, not native BSD executions. [FreeBSD parser](https://github.com/freebsd/freebsd-src/blob/main/bin/ps/keyword.c), [FreeBSD manual](https://man.freebsd.org/cgi/man.cgi?query=ps&sektion=1), [OpenBSD parser](https://github.com/openbsd/src/blob/master/bin/ps/keyword.c). | +| Same ancestor behavior, modulo earlier inherited TTY | **Refuted as an unconditional parity claim.** The old walk checks generations 1–8; PR checks 0–7 and checks PPID validity *before* the current TTY. Fixtures: only generation 8 has width 160 β†’ old 160, PR fallback 80; a parent with PPID 0 and valid width 120 β†’ old 120, PR 80; caller TTY 100 / parent TTY 120 β†’ old 120, PR 100. Inherited TTY is usually equal, but not guaranteed after a session/PTY change. | +| Two spawns become one per ancestor | **Confirmed structurally, qualified.** Inherited caller TTY: 2 β†’ 1 `ps` calls. Detached caller, immediate parent TTY: 2 β†’ 2, because PR first probes itself. No TTY through eight generations: 16 β†’ 8, but coverage differs. | +| Failure/fallback parity | **Mostly confirmed, not exact.** `ps` failure and malformed/nonpositive PPIDs fall back safely; `stty -F`, `stty -f`, `tput`, memoization and disk-cache logic are unchanged. If the combined parent lookup fails, PR cannot continue using a separately obtained PPID as the old two-call sequence could. Fixtures reproduce old 140 versus PR fallback 80 for that failure sequence. | +| Proxy loading is conditional and survives builds | **Confirmed for the import change.** Both normal bundled dist and an explicitly `--packages=external` build run on Node 25 and Bun. Mocked HTTPS requests to both endpoints have no agent when unset, `HttpsProxyAgent` when set, and no requests when the URL is invalid. Uppercase `HTTPS_PROXY`/whitespace handling is unchanged. | +| Agent-load failures remain safe | **Confirmed.** In isolated copies of the PR modules, replacing the dynamic import target with a missing module yields usage `{error:'api-error'}`, status `null`, no HTTPS requests, no direct-network fallback, exit 0, and no stderr on Node 25, Bun, and real Node 14.21.3. Same outcome for invalid proxy URLs. These probes replace only unrelated config/color dependencies and mock transport; they do not prove a real proxy tunnel handshake. | +| No-proxy startup avoids undici | **Refuted for PR dist.** With proxy variables absent, both baseline and PR make one Yoga WASM `fetch` and instantiate WASM; `process.moduleLoadList` includes `internal/deps/undici/undici`. The proxy chunk is deferred, but another eager path remains. | +| CPU βˆ’24%, p50 373 β†’ 224 ms | **Not reproduced (refuted for this workload).** Final uncontended-within-this-worker pair: CPU 268.8 β†’ 247.9 ms (βˆ’7.8%); p50 283.7 β†’ 239.9 ms. Earlier paired runs varied considerably; all 13 MB batches are preserved in the adjacent results JSON. The PR does not specify its exact fixture, Node version, settings, TTY ancestry or warmup, so this is not proof its original measurements were fabricated. | +| `compileForInternalLoader` 40.7 β†’ 7.5 ms | **Refuted here.** Eight 500 Β΅s-sampled profiles each: baseline mean **42.45 ms**, PR **35.91 ms**. Ranges 34.96–69.81 / 33.09–39.18 ms. These exploratory profiles used the initial 12.26 MB fixture before padding it to 13.009 MB for the benchmark. The no-proxy undici stack is `Yoga ya/c β†’ fetch β†’ requireBuiltin β†’ compileForInternalLoader β†’ undici`, not a proxy request. | + +### Concrete review findings + +1. **P1: BSD portability regression** in `getProcessAncestorInfo` (`terminal.ts`). Separate `-o` arguments fix the format without adding spawns. Linux documentation also warns that custom-header/comma interpretation can vary with personality, although tested procps 4.0.2 personalities accepted this exact empty-header form. [procps manual](https://man7.org/linux/man-pages/man1/ps.1.html) +2. **P2: narrowed ancestry and premature PPID guard** in `probeTerminalWidth`. Starting at `process.ppid` and examining a valid TTY before terminating on its PPID would restore the previous generation budget while retaining one spawn per ancestor. Not changed in this follow-up. +3. **P1: inaccurate startup-graph attribution**: the eager editor β†’ Ink `useInput` β†’ reconciler β†’ Yoga route survives PR #2 and upstream #575. Lazy proxy imports alone do not remove undici from the built render path. + +## Pre-existing Node 14 distribution blocker β€” repair out of scope + +The actual package build **bundles** dependencies; `--packages=external` is not +in `package.json`. All third-party dependencies are devDependencies, so an +external build also requires a dependency-install/packaging policy not supplied +by the current manifest. It is not the shipped standalone build. + +Bun 1.3.13 does not lower syntax for the supplied `--target-version=14` option: +`bun build --help` exposes environment targets, not this version option, and +both baseline and PR outputs retain `??=` and class fields. Public class fields +alone are not the demonstrated failure; **`??=` is the first actual rejection**. +Bun documents that its bundler does not down-convert syntax. [Bun bundler](https://bun.sh/docs/bundler) + +Minimal repro, also run against the unmodified baseline: + +```sh +bun run build +NODE14=/private/tmp/ccstatusline-perf-4/node-v14.21.3-darwin-x64/bin/node +printf '{"model":{"id":"claude-sonnet-4-5"}}' | "$NODE14" dist/ccstatusline.js +# ccstatusline-wckvcay0.js:15 +# var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : ... +# SyntaxError: Unexpected token '??=' +``` + +The explicitly external PR build also fails on `firstTimestampMs ??= timestampMs`. +Installed manifests require Node **>=22** for chalk 6.0.0 and **>=20** for Ink +6.2.0. Thus import syntax being supported is not distribution compatibility. +The on-demand proxy code itself passes the isolated Node 14.21.3 tests above; +that runtime was the official Darwin x64 binary running under Rosetta. +Per orchestrator decision, no build/dependency repair was attempted. This +follow-up adds no newer JS syntax or Node APIs; the distribution blocker remains. + +## Phase 2: implemented win + +`164007b` extends, rather than replaces, `35440e4`: keep its dynamic TUI entry +and split build, then move the eleven widget-editor implementations into lazy +chunks. A small React `lazy`/`Suspense` wrapper preserves the synchronous +`renderEditor` API. Render-time widget logic is unchanged; no new dependency. +The large diff is predominantly moving existing editor bodies. + +Afterward a built no-proxy render records **0 fetches, 0 WASM initializations, +and no undici module**, versus 1/1/loaded at PR head. An isolated fresh-process +regression test checks the widget-registry import; existing glyph/locale/timezone +interaction tests now wait for the first lazy frame rather than sleeping 25 ms. +First opening an editor can suspend briefly (about 300 ms observed in tests). + +### Before/after benchmark for this change + +Final acceptance sequence (80 renders per row): + +| Runtime / revision | CPU total | CPU/render | p50 | p95 | Wall | +| --- | ---: | ---: | ---: | ---: | ---: | +| Node / PR | 19.833 s | 247.9 ms | 239.9 ms | 309.6 ms | 4.826 s | +| Node / follow-up | 18.313 s | 228.9 ms | 244.3 ms | 352.9 ms | 5.099 s | +| Bun / PR | 13.702 s | 171.3 ms | 153.7 ms | 226.3 ms | 3.312 s | +| Bun / follow-up | 11.959 s | 149.5 ms | 132.9 ms | 186.2 ms | 2.809 s | + +Final CPU improvement: **7.7% Node / 12.7% Bun**. **The final Node p50 regressed +1.8%**, so no universal latency improvement is claimed. Earlier batches were +faster: all recorded 13 MB batches average 256.7 β†’ 219.5 ms CPU/render on Node +(βˆ’14.5%) and 171.9 β†’ 144.8 ms on Bun (βˆ’15.8%). This is a shared development host, +not a quiet benchmark machine; exploratory runs also overlapped other verification +work. The final sequence had no other jobs from this worker. Prefer the explicit +final table over a selectively favorable trial. + +All benchmark output hashes match: +`454f8606b708708a4afd72598c1493889cacba100fc86da1f418f1977c044f86`. + +### Reproduction and artifacts + +`scripts/benchmark-render.py` generates a **13,009,445-byte**, 10,000-record +synthetic transcript (alternating user/assistant; assistant usage populated), +runs four concurrent workers Γ— twenty fresh processes, with one untimed warmup +per worker. Each worker has isolated HOME, USERPROFILE and CLAUDE_CONFIG_DIR; +proxy variables are absent. Default settings, empty non-Git cwd, TERM=xterm-256color, +no width override: the host has no controlling TTY and `tput` supplies a numeric +width, so width probing is not bypassed by the no-width disk cache. All workers +read the same fixture. CPU is child user+system time from `getrusage`; latency is +per-process wall time. No npm/npx startup or real API calls are benchmarked. + +```sh +CCSTATUSLINE_BENCH_DIR=/tmp/ccstatusline-bench \ + python3 scripts/benchmark-render.py /opt/homebrew/bin/node /absolute/path/to/dist/ccstatusline.js label +# Repeat for baseline / PR / follow-up and /opt/homebrew/bin/bun. +``` + +Committed summary: `docs/performance-397-results.json` (every 13 MB trial). +Full local artifacts: `/private/tmp/ccstatusline-perf-4/`, including archived +source/build snapshots, benchmark raw latencies, CPU profiles, request/terminal +probe scripts, Linux ps output, and verification logs. + +## Final checks and deliberate cuts + +- `bun run build`: successful. +- Full `bun test`: **2361 passed, 0 failed**, 149 files, run once at the end. +- `bun run lint`: **clean**, run once at the end; no lint suppressions. +- Targeted editor/startup checks preceded that final full run. Built piped renders + verified on Node and Bun; Node 14 full-dist execution is blocked as documented. +- Skipped incremental transcript caching: JSON parsing was ~9.7 ms self-time in + PR profiles versus ~35.9 ms loader compilation, plus Yoga initialization. + Cache correctness for append/partial records, truncation, rewrites, compaction, + options and subagents needs a separate change, not a timeboxed add-on. +- Skipped ps fixes, build/dependency repair, production/minifier experiments, + native BSD execution, real proxy-tunnel integration and additional TUI profiling. + These findings/limitations remain explicit rather than silently broadening scope. diff --git a/package.json b/package.json index 432680b7c..f682d663a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "2.2.30", + "version": "2.2.30.fork1", "bugs": { "url": "https://github.com/sirmalloc/ccstatusline/issues" }, diff --git a/scripts/benchmark-render.py b/scripts/benchmark-render.py new file mode 100644 index 000000000..6243fc374 --- /dev/null +++ b/scripts/benchmark-render.py @@ -0,0 +1,52 @@ +import concurrent.futures, hashlib, json, os, pathlib, resource, statistics, subprocess, sys, tempfile, time +# Set CCSTATUSLINE_BENCH_DIR to reuse a fixture across before/after invocations. +ROOT = pathlib.Path(os.environ.get('CCSTATUSLINE_BENCH_DIR') or tempfile.mkdtemp(prefix='ccstatusline-bench-')).resolve() +(ROOT / 'results').mkdir(parents=True, exist_ok=True) + +def setup(): + fixture = ROOT / 'transcript.jsonl' + if not fixture.exists(): + with fixture.open('w') as f: + for i in range(10000): + row = {'type': 'assistant' if i % 2 else 'user', 'timestamp': '2026-09-25T01:%02d:%02dZ' % ((i // 60) % 60, i % 60), 'message': {'role': 'assistant' if i % 2 else 'user', 'content': [{'type':'text','text': 'x' * 1105}]}} + if i % 2: + row['message'].update(id='msg-' + str(i), stop_reason='end_turn', usage={'input_tokens': 100, 'output_tokens': 50, 'cache_read_input_tokens': 200, 'cache_creation_input_tokens': 10}) + f.write(json.dumps(row, separators=(',', ':')) + '\n') + return fixture + +def environment(home, width=False): + home.mkdir(parents=True, exist_ok=True) + env = {'PATH': os.environ['PATH'], 'HOME': str(home), 'USERPROFILE': str(home), 'CLAUDE_CONFIG_DIR': str(home / '.claude'), 'XDG_CONFIG_HOME': str(home / '.config'), 'XDG_CACHE_HOME': str(home / '.cache'), 'TERM': 'xterm-256color', 'LANG': 'en_US.UTF-8', 'TMPDIR': str(ROOT)} + if width: env['CCSTATUSLINE_WIDTH'] = '120' + return env + +def payload(fixture): + return json.dumps({'model': {'id': 'claude-sonnet-4-5', 'display_name': 'Sonnet 4.5'}, 'session_id': 'perf-synthetic', 'transcript_path': str(fixture), 'cwd': str(ROOT / 'empty-project'), 'workspace': {'current_dir': str(ROOT / 'empty-project')}}) + +def run(runtime, entry, label, width=False, rounds=20): + entry = str(pathlib.Path(entry).resolve()) + fixture = setup() + (ROOT / 'empty-project').mkdir(exist_ok=True) + data = payload(fixture) + homes = [ROOT / 'homes' / label / str(i) for i in range(4)] + envs = [environment(h, width) for h in homes] + def once(i, warm=False): + start = time.perf_counter() + p = subprocess.run([runtime, entry], input=data, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=ROOT / 'empty-project', env=envs[i]) + if p.returncode or (p.stderr and not warm): raise RuntimeError((p.returncode, p.stderr)) + return (time.perf_counter() - start)*1000, hashlib.sha256(p.stdout.encode()).hexdigest() + for i in range(4): once(i, True) + def worker(i): return [once(i) for _ in range(rounds)] + before = resource.getrusage(resource.RUSAGE_CHILDREN) + start = time.perf_counter() + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + samples = sum(pool.map(worker, range(4)), []) + wall = time.perf_counter() - start + after = resource.getrusage(resource.RUSAGE_CHILDREN) + cpu = after.ru_utime + after.ru_stime - before.ru_utime - before.ru_stime + latencies = sorted(s[0] for s in samples) + result = {'label': label, 'runtime': runtime, 'entry': entry, 'bytes': fixture.stat().st_size, 'renders': len(samples), 'width_override': width, 'cpu_total_s': round(cpu,4), 'cpu_per_render_ms': round(cpu*1000/len(samples),3), 'wall_s': round(wall,3), 'p50_ms': round(statistics.median(latencies),3), 'p95_ms': round(latencies[int(len(latencies)*.95)-1],3), 'hashes': sorted(set(s[1] for s in samples)), 'latencies_ms': latencies} + (ROOT/'results'/f'{label}.json').write_text(json.dumps(result, indent=2)) + print(json.dumps({k:v for k,v in result.items() if k != 'latencies_ms'}), flush=True) + +if __name__ == '__main__': run(sys.argv[1], sys.argv[2], sys.argv[3], '--width' in sys.argv) diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 1da963f8f..f6570b766 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -7,6 +7,10 @@ import type { StatusJSON } from './types/StatusJSON'; import { StatusJSONSchema } from './types/StatusJSON'; import { getVisibleText } from './utils/ansi'; import { prefetchClaudeStatusIfNeeded } from './utils/claude-service-status'; +import { + isCliMode, + runCli +} from './utils/cli'; import { updateColorMap } from './utils/colors'; import { ZERO_COMPACTION_STATS } from './utils/compaction'; import { @@ -109,6 +113,7 @@ async function renderMultipleLines(data: StatusJSON) { const hasCompactionWidget = lines.some(line => line.some(item => item.type === 'compaction-counter')); const hasThinkingEffortWidget = lines.some(line => line.some(item => item.type === 'thinking-effort')); const hasSessionNameWidget = lines.some(line => line.some(item => item.type === 'session-name')); + const hasLastTurnTokensWidget = lines.some(line => line.some(item => item.type === 'tokens-last-turn')); const needsTranscriptThinkingEffort = hasThinkingEffortWidget && (!data.effort || !('level' in data.effort)); const requestedSpeedWindows = new Set(); @@ -128,7 +133,8 @@ async function renderMultipleLines(data: StatusJSON) { speedWindowSeconds: Array.from(requestedSpeedWindows), includeCompactionStats: hasCompactionWidget, includeThinkingEffort: needsTranscriptThinkingEffort, - includeSessionName: hasSessionNameWidget + includeSessionName: hasSessionNameWidget, + includeLastTurnTokens: hasLastTurnTokensWidget }) : Promise.resolve(null); const [transcriptAnalysis, usageData, claudeStatusData] = await Promise.all([ @@ -320,6 +326,13 @@ async function main() { return; } + // Non-interactive CLI subcommands (#602): args present + TTY stdin means an + // agent or human is configuring the tool, not rendering a status line. + // Piped stdin (Claude Code) keeps the render path regardless of args. + if (isCliMode(process.argv.slice(2), process.stdin.isTTY)) { + await runCli(process.argv.slice(2)); + } + // Check if we're in a piped/non-TTY environment first if (!process.stdin.isTTY) { await ensureWindowsUtf8CodePage(); diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 4acb6e180..717de6c77 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -103,6 +103,7 @@ import { List, type ListEntry } from './components/List'; +import { PowerlineThemeSelector } from './components/PowerlineThemeSelector'; const GITHUB_REPO_URL = 'https://github.com/sirmalloc/ccstatusline'; @@ -116,6 +117,7 @@ type AppScreen = 'main' | 'items' | 'colorLines' | 'colors' + | 'theme' | 'terminalWidth' | 'terminalConfig' | 'globalOverrides' @@ -987,6 +989,9 @@ export const App: React.FC = () => { case 'colors': setScreen('colorLines'); break; + case 'theme': + setScreen('theme'); + break; case 'terminalConfig': setScreen('terminalConfig'); break; @@ -1418,6 +1423,18 @@ export const App: React.FC = () => { }} /> )} + {screen === 'theme' && ( + { + setSettings(updatedSettings); + }} + onBack={() => { + setScreen('main'); + }} + /> + )} {screen === 'powerline' && ( { })).toEqual([ 'lines', 'colors', + 'theme', 'powerline', '-', 'terminalConfig', @@ -209,6 +210,7 @@ describe('Main menu structure', () => { expect(getMenuValues(false, false)).toEqual([ 'lines', 'colors', + 'theme', 'powerline', '-', 'terminalConfig', @@ -235,6 +237,7 @@ describe('Main menu structure', () => { expect(getMenuValues(true, false, installation)).toEqual([ 'lines', 'colors', + 'theme', 'powerline', '-', 'terminalConfig', @@ -274,14 +277,14 @@ describe('Main menu structure', () => { sublabel: '(install first)' })); expect(buildManageInstallationItems()[0]).toEqual(expect.objectContaining({ label: 'πŸ”„ Check for Updates' })); - expect(getMainMenuInstallSelectionIndex(false)).toBe(7); - expect(getMainMenuInstallSelectionIndex(true, autoInstallation)).toBe(8); - expect(getMainMenuInstallSelectionIndex(true, pinnedInstallation)).toBe(8); - expect(getMainMenuSelectionIndex(buildMainMenuItems(true, false, autoInstallation), 'install')).toBe(8); + expect(getMainMenuInstallSelectionIndex(false)).toBe(8); + expect(getMainMenuInstallSelectionIndex(true, autoInstallation)).toBe(9); + expect(getMainMenuInstallSelectionIndex(true, pinnedInstallation)).toBe(9); + expect(getMainMenuSelectionIndex(buildMainMenuItems(true, false, autoInstallation), 'install')).toBe(9); expect(getMainMenuSelectionIndex( buildMainMenuItems(true, false, pinnedInstallation), 'manageInstallation' - )).toBe(8); + )).toBe(9); }); }); diff --git a/src/tui/components/GlobalOverridesMenu.tsx b/src/tui/components/GlobalOverridesMenu.tsx index 785576924..6fda4b19e 100644 --- a/src/tui/components/GlobalOverridesMenu.tsx +++ b/src/tui/components/GlobalOverridesMenu.tsx @@ -69,6 +69,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin const [inheritColors, setInheritColors] = useState(settings.inheritSeparatorColors); const [globalBold, setGlobalBold] = useState(settings.globalBold); const [minimalistMode, setMinimalistMode] = useState(settings.minimalistMode); + const [compactLabels, setCompactLabels] = useState(settings.compactLabels); const [numberFormatMode, setNumberFormatMode] = useState(false); const [numberFormatKindIndex, setNumberFormatKindIndex] = useState(0); const [gradientMode, setGradientMode] = useState(false); @@ -259,6 +260,15 @@ export const GlobalOverridesMenu: React.FC = ({ settin minimalistMode: newMinimalistMode }; onUpdate(updatedSettings); + } else if (input === 'j' || input === 'J') { + // Toggle compact label presets for labeled widgets + const newCompactLabels = !compactLabels; + setCompactLabels(newCompactLabels); + const updatedSettings = { + ...settings, + compactLabels: newCompactLabels + }; + onUpdate(updatedSettings); } else if (input === 'n' || input === 'N') { setNumberFormatMode(true); setNumberFormatKindIndex(0); @@ -451,6 +461,12 @@ export const GlobalOverridesMenu: React.FC = ({ settin - Press (m) to toggle + + Compact Labels: + {compactLabels ? 'βœ“ Enabled' : 'βœ— Disabled'} + - Press (j) to toggle + + Number Formatting: {settings.numberFormat ? 'customized' : '(defaults)'} @@ -558,6 +574,9 @@ export const GlobalOverridesMenu: React.FC = ({ settin β€’ Minimalist Mode: Strips decorative prefixes and labels from widgets + + β€’ Compact Labels: Uses short label presets (M:, Ctx:, $) on labeled widgets + β€’ Override colors: All widgets will use these colors instead of their configured colors diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 470ee1486..4b19c8dcb 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -21,6 +21,7 @@ import { import { canDetectTerminalWidth } from '../../utils/terminal'; import { filterWidgetCatalog, + getCompactLabelCapableTypes, getMatchSegments, getWidget, getWidgetCatalog, @@ -31,6 +32,10 @@ import { getHideKeybind, getHideModifierText } from '../../widgets/shared/hideable'; +import { + getCompactLabelKeybind, + getCompactLabelModifierText +} from '../../widgets/shared/raw-or-labeled'; import { ConfirmDialog } from './ConfirmDialog'; import { HideStatesEditor } from './HideStatesEditor'; @@ -132,6 +137,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB keybinds.push(getHideKeybind()); } + // Labeled widgets whose label has a compact preset share a single + // compact-label toggle here, mirroring the (h)ide… keybind. + if (getCompactLabelCapableTypes().has(widget.type)) { + keybinds.push(getCompactLabelKeybind()); + } + return keybinds; }; @@ -259,7 +270,8 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB openWidgetPicker, getCustomKeybindsForWidget, setCustomEditorWidget, - getUniqueBackgroundColor + getUniqueBackgroundColor, + settings }); }); @@ -582,6 +594,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ? getNumberFormatModifierText(widget) : undefined; const hideModifierText = widgetImpl ? getHideModifierText(widget, widgetImpl.getHideableStates?.() ?? []) : undefined; + const compactModifierText = widgetImpl && getCompactLabelCapableTypes().has(widget.type) + ? getCompactLabelModifierText(widget, settings) + : undefined; return ( @@ -611,6 +626,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {hideModifierText} )} + {compactModifierText && ( + + {' '} + {compactModifierText} + + )} {supportsRawValue && widget.rawValue && (raw value)} {widget.merge === true && (mergedβ†’)} {widget.merge === 'no-padding' && (merged-no-padβ†’)} diff --git a/src/tui/components/MainMenu.tsx b/src/tui/components/MainMenu.tsx index b3f9813a4..41e0c8375 100644 --- a/src/tui/components/MainMenu.tsx +++ b/src/tui/components/MainMenu.tsx @@ -14,6 +14,7 @@ import { List } from './List'; export type MainMenuOption = 'lines' | 'colors' + | 'theme' | 'powerline' | 'terminalConfig' | 'globalOverrides' @@ -82,7 +83,8 @@ function getInstallationMenuItem( export function buildMainMenuItems( isClaudeInstalled: boolean, hasChanges: boolean, - installation?: InstallationMetadata + installation?: InstallationMetadata, + powerlineEnabled = false ): MainMenuEntry[] { const menuItems: MainMenuEntry[] = [ { @@ -97,6 +99,14 @@ export function buildMainMenuItems( description: 'Customize colors for each widget including foreground, background, and bold styling' }, + // Powerline mode has its own theme picker and ignores the regular-mode + // theme, so the entry only makes sense when powerline is off. + ...(powerlineEnabled ? [] : [{ + label: '🌈 Theme', + value: 'theme' as const, + description: + 'Apply a built-in color theme to regular (non-powerline) mode as foreground colors' + }]), { label: '⚑ Powerline Setup', value: 'powerline', @@ -218,7 +228,7 @@ export const MainMenu: React.FC = ({ installation, previewIsTruncated }) => { - const menuItems = buildMainMenuItems(isClaudeInstalled, hasChanges, installation); + const menuItems = buildMainMenuItems(isClaudeInstalled, hasChanges, installation, settings?.powerline.enabled ?? false); // Check if we should show the truncation warning const showTruncationWarning diff --git a/src/tui/components/PowerlineThemeSelector.tsx b/src/tui/components/PowerlineThemeSelector.tsx index 72bfd4bb0..aaf150f6a 100644 --- a/src/tui/components/PowerlineThemeSelector.tsx +++ b/src/tui/components/PowerlineThemeSelector.tsx @@ -31,7 +31,7 @@ export function buildPowerlineThemeItems( const theme = getPowerlineTheme(themeName); return { - label: theme?.name ?? themeName, + label: theme?.name ?? (themeName === 'none' ? 'None (no theme)' : themeName), sublabel: themeName === originalTheme ? '(original)' : undefined, value: themeName, description: theme?.description ?? '' @@ -87,19 +87,31 @@ export function applyCustomPowerlineTheme( }; } +export type ThemeSelectorMode = 'powerline' | 'regular'; + export interface PowerlineThemeSelectorProps { settings: Settings; onUpdate: (settings: Settings) => void; onBack: () => void; + // 'regular' targets the top-level theme applied in non-powerline mode + // (settings.theme); defaults to the powerline theme picker behavior. + mode?: ThemeSelectorMode; } export const PowerlineThemeSelector: React.FC = ({ settings, onUpdate, - onBack + onBack, + mode = 'powerline' }) => { - const themes = useMemo(() => getPowerlineThemes(), []); - const currentTheme = settings.powerline.theme ?? 'custom'; + const isRegular = mode === 'regular'; + const themes = useMemo( + () => (isRegular ? ['none', ...getPowerlineThemes().filter((name) => name !== 'custom')] : getPowerlineThemes()), + [isRegular] + ); + const currentTheme = isRegular + ? settings.theme ?? 'none' + : settings.powerline.theme ?? 'custom'; const [selectedIndex, setSelectedIndex] = useState(Math.max(0, themes.indexOf(currentTheme))); const [showCustomizeConfirm, setShowCustomizeConfirm] = useState(false); const originalThemeRef = useRef(currentTheme); @@ -127,12 +139,16 @@ export const PowerlineThemeSelector: React.FC = ({ latestOnUpdateRef.current({ ...latestSettingsRef.current, - powerline: { - ...latestSettingsRef.current.powerline, - theme: themeName - } + ...(isRegular + ? { theme: themeName === 'none' ? undefined : themeName } + : { + powerline: { + ...latestSettingsRef.current.powerline, + theme: themeName + } + }) }); - }, [selectedIndex, themes]); + }, [selectedIndex, themes, isRegular]); useInput((input, key) => { if (showCustomizeConfirm) { @@ -142,7 +158,7 @@ export const PowerlineThemeSelector: React.FC = ({ if (key.escape) { onUpdate(originalSettingsRef.current); onBack(); - } else if (input === 'c' || input === 'C') { + } else if (!isRegular && (input === 'c' || input === 'C')) { const currentThemeName = themes[selectedIndex]; if (currentThemeName && currentThemeName !== 'custom') { setShowCustomizeConfirm(true); @@ -193,14 +209,14 @@ export const PowerlineThemeSelector: React.FC = ({ return ( - {`Powerline Theme Selection | `} + {`${isRegular ? 'Theme Selection (regular mode)' : 'Powerline Theme Selection'} | `} {`Original: ${originalThemeRef.current}`} - {`↑↓ navigate, Enter apply${selectedThemeName && selectedThemeName !== 'custom' ? ', (c)ustomize theme' : ''}, ESC cancel`} + {`↑↓ navigate, Enter apply${!isRegular && selectedThemeName && selectedThemeName !== 'custom' ? ', (c)ustomize theme' : ''}, ESC cancel`} @@ -220,7 +236,7 @@ export const PowerlineThemeSelector: React.FC = ({ initialSelection={selectedIndex} /> - {selectedThemeName && selectedThemeName !== 'custom' && ( + {!isRegular && selectedThemeName && selectedThemeName !== 'custom' && ( Press (c) to customize this theme - copies colors to widgets diff --git a/src/tui/components/__tests__/GlobalOverridesMenu.test.ts b/src/tui/components/__tests__/GlobalOverridesMenu.test.ts index 36e4b344b..e4f49a8bd 100644 --- a/src/tui/components/__tests__/GlobalOverridesMenu.test.ts +++ b/src/tui/components/__tests__/GlobalOverridesMenu.test.ts @@ -186,6 +186,45 @@ describe('GlobalOverridesMenu', () => { } }); + it('toggles compact labels on when (j) is pressed', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const onBack = vi.fn(); + + const instance = render( + React.createElement(GlobalOverridesMenu, { + settings: { ...DEFAULT_SETTINGS, compactLabels: false }, + onUpdate, + onBack + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + expect(stdout.getOutput()).toContain('Compact Labels:'); + stdin.write('j'); + await flushInk(); + + expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ compactLabels: true })); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + it('toggles minimalist mode off when (m) is pressed while enabled', async () => { const stdin = createMockStdin(); const stdout = createMockStdout(); diff --git a/src/tui/components/__tests__/PowerlineThemeSelector.test.ts b/src/tui/components/__tests__/PowerlineThemeSelector.test.ts index caf0c4eed..5cbb2c608 100644 --- a/src/tui/components/__tests__/PowerlineThemeSelector.test.ts +++ b/src/tui/components/__tests__/PowerlineThemeSelector.test.ts @@ -80,6 +80,16 @@ describe('PowerlineThemeSelector helpers', () => { }); }); + it('labels the none entry for regular mode', () => { + const items = buildPowerlineThemeItems(['none'], 'none'); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + label: 'None (no theme)', + value: 'none' + }); + }); + it('copies a built-in theme into widget colors and switches to custom mode', () => { const settings = { ...DEFAULT_SETTINGS, @@ -168,4 +178,92 @@ describe('PowerlineThemeSelector helpers', () => { stderr.destroy(); } }); + + it('writes settings.theme when navigating in regular mode', async () => { + const firstTheme = getPowerlineThemes().filter((name) => name !== 'custom')[0]; + expect(firstTheme).toBeDefined(); + + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const onBack = vi.fn(); + const instance = render( + React.createElement(PowerlineThemeSelector, { + settings: { ...DEFAULT_SETTINGS }, + onUpdate, + onBack, + mode: 'regular' + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + expect(onUpdate).not.toHaveBeenCalled(); + + // Regular list starts at 'none' (no theme); one step down picks + // the first real theme and must write the top-level setting. + stdin.write('\u001B[B'); + await waitForInkCondition(() => onUpdate.mock.calls.length > 0); + await flushInk(); + + expect(onUpdate.mock.calls[0]?.[0]?.theme).toBe(firstTheme); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it('clears settings.theme when None is selected in regular mode', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const onBack = vi.fn(); + const instance = render( + React.createElement(PowerlineThemeSelector, { + settings: { ...DEFAULT_SETTINGS, theme: 'nord' }, + onUpdate, + onBack, + mode: 'regular' + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + expect(onUpdate).not.toHaveBeenCalled(); + + // 'nord' sits one slot below 'none' in the regular list. + stdin.write('\u001B[A'); + await waitForInkCondition(() => onUpdate.mock.calls.length > 0); + await flushInk(); + + expect(onUpdate.mock.calls[0]?.[0]?.theme).toBeUndefined(); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); }); diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 2c7f52b2e..377839baa 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -1,3 +1,4 @@ +import type { Settings } from '../../../types/Settings'; import type { CustomKeybind, Widget, @@ -15,6 +16,10 @@ import { type WidgetCatalogEntry } from '../../../utils/widgets'; import { EDIT_HIDE_STATES_ACTION } from '../../../widgets/shared/hideable'; +import { + TOGGLE_COMPACT_LABEL_ACTION, + toggleCompactLabel +} from '../../../widgets/shared/raw-or-labeled'; export type WidgetPickerAction = 'change' | 'add' | 'insert'; export type WidgetPickerLevel = 'category' | 'widget'; @@ -354,6 +359,7 @@ export interface HandleNormalInputModeArgs { getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[]; setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void; getUniqueBackgroundColor?: (insertIndex: number) => string | undefined; + settings?: Settings; } export function handleNormalInputMode({ @@ -371,7 +377,8 @@ export function handleNormalInputMode({ openWidgetPicker, getCustomKeybindsForWidget, setCustomEditorWidget, - getUniqueBackgroundColor + getUniqueBackgroundColor, + settings }: HandleNormalInputModeArgs): void { if (key.upArrow && widgets.length > 0) { setSelectedIndex(selectedIndex - 1 < 0 ? widgets.length - 1 : selectedIndex - 1); @@ -501,6 +508,14 @@ export function handleNormalInputMode({ const newWidgets = [...widgets]; newWidgets[selectedIndex] = cycleNumberStyle(currentWidget); onUpdate(newWidgets); + } else if (matchedKeybind.action === TOGGLE_COMPACT_LABEL_ACTION) { + // The compact-label toggle is shared by every labeled + // widget, so like the precision cycle it is applied here. + // The settings let the cycle start from the effective + // (possibly globally inherited) state. + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = toggleCompactLabel(currentWidget, settings); + onUpdate(newWidgets); } else if (widgetImpl.handleEditorAction) { const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget); if (updatedWidget) { diff --git a/src/types/Settings.ts b/src/types/Settings.ts index 123ac3b88..fea02def8 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -73,7 +73,17 @@ export const SettingsSchema = z.object({ inheritSeparatorColors: z.boolean().default(false), overrideBackgroundColor: z.string().optional(), overrideForegroundColor: z.string().optional(), + // Theme name from POWERLINE_THEMES applied in regular (non-powerline) + // mode: cycles the theme's segment palette across widgets as foreground + // colors. Additive optional key, no version bump needed; unset, 'custom', + // or an unknown name disables theming. Explicit per-widget colors win. + theme: z.string().optional(), globalBold: z.boolean().default(false), + // Compact label presets (Model: -> M:, Context: -> Ctx:, Cost: -> $) for + // labeled widgets. Additive v4 key with an off default, so configs written + // before it render identically without a version bump; per-widget + // metadata.compactLabel overrides the global setting either way. + compactLabels: z.boolean().default(false), numberFormat: GlobalNumberFormatSchema.optional(), gitCacheTtlSeconds: z.number().min(0).max(60).default(5), // How long a "no TTY" result is reused for the same session, in seconds. diff --git a/src/types/TokenMetrics.ts b/src/types/TokenMetrics.ts index 042385d92..d53d43bc1 100644 --- a/src/types/TokenMetrics.ts +++ b/src/types/TokenMetrics.ts @@ -6,13 +6,23 @@ export interface TokenUsage { } export interface TranscriptLine { - message?: { usage?: TokenUsage; stop_reason?: string | null }; + message?: { id?: string; usage?: TokenUsage; stop_reason?: string | null }; isSidechain?: boolean; timestamp?: string; isApiErrorMessage?: boolean; type?: 'user' | 'assistant' | 'system' | 'progress' | 'file-history-snapshot'; } +// Token usage of a single assistant API call (one message id). Claude Code +// writes one JSONL entry per content block sharing that id, so the values are +// deduplicated, not summed across entries. +export interface LastTurnTokens { + inputTokens: number; + outputTokens: number; + cachedTokens: number; + totalTokens: number; +} + export interface TokenMetrics { inputTokens: number; outputTokens: number; @@ -23,4 +33,6 @@ export interface TokenMetrics { cacheCreationTokens?: number; totalTokens: number; contextLength: number; + // Only populated when the scan option includeLastTurnTokens is set. + lastTurnTokens?: LastTurnTokens; } diff --git a/src/types/index.ts b/src/types/index.ts index 57c92c99e..08edbab9f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,7 +12,7 @@ export type { PowerlineConfig } from './PowerlineConfig'; export type { ColorLevel, ColorLevelString } from './ColorLevel'; export { getColorLevelString } from './ColorLevel'; export type { StatusJSON } from './StatusJSON'; -export type { TokenMetrics, TokenUsage, TranscriptLine } from './TokenMetrics'; +export type { LastTurnTokens, TokenMetrics, TokenUsage, TranscriptLine } from './TokenMetrics'; export type { RenderContext } from './RenderContext'; export type { PowerlineFontStatus } from './PowerlineFontStatus'; export type { ClaudeSettings } from './ClaudeSettings'; diff --git a/src/utils/__tests__/claude-service-status.test.ts b/src/utils/__tests__/claude-service-status.test.ts index 5818c662f..a48376ce7 100644 --- a/src/utils/__tests__/claude-service-status.test.ts +++ b/src/utils/__tests__/claude-service-status.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'events'; import { + afterEach, describe, expect, it @@ -182,6 +183,54 @@ describe('status page response handling', () => { }); }); +describe('proxy agent wiring', () => { + const ORIGINAL_HTTPS_PROXY = process.env.HTTPS_PROXY; + + afterEach(() => { + if (ORIGINAL_HTTPS_PROXY === undefined) { + delete process.env.HTTPS_PROXY; + } else { + process.env.HTTPS_PROXY = ORIGINAL_HTTPS_PROXY; + } + }); + + function captureRequest(captured: { agent: unknown }): StatusPageRequestFn { + return (options, onResponse) => { + captured.agent = (options as { agent?: unknown }).agent; + const response = Object.assign(new EventEmitter(), { + statusCode: 200, + setEncoding: () => undefined + }); + const request = Object.assign(new EventEmitter(), { + destroy: () => undefined, + end() { + onResponse(response); + response.emit('data', 'status body'); + response.emit('end'); + } + }); + + return request; + }; + } + + it('attaches a proxy agent when HTTPS_PROXY is set', async () => { + process.env.HTTPS_PROXY = 'http://proxy.example.test:8080'; + const captured: { agent: unknown } = { agent: undefined }; + + await expect(__testing.fetchStatusPagePath('/test', captureRequest(captured))).resolves.toBe('status body'); + expect(captured.agent).toBeTruthy(); + }); + + it('sends no proxy agent without HTTPS_PROXY', async () => { + delete process.env.HTTPS_PROXY; + const captured: { agent: unknown } = { agent: undefined }; + + await expect(__testing.fetchStatusPagePath('/test', captureRequest(captured))).resolves.toBe('status body'); + expect(captured.agent).toBeUndefined(); + }); +}); + describe('claude-status prefetch predicates', () => { it('detects claude-status widgets in configured lines', () => { expect(hasClaudeStatusWidgets([[{ id: '1', type: 'model' }]])).toBe(false); diff --git a/src/utils/__tests__/cli.test.ts b/src/utils/__tests__/cli.test.ts new file mode 100644 index 000000000..b935f7183 --- /dev/null +++ b/src/utils/__tests__/cli.test.ts @@ -0,0 +1,522 @@ +import * as fs from 'fs'; +import path from 'path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type MockInstance +} from 'vitest'; + +import { CURRENT_VERSION } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { + executeCli, + extractJsonFlag, + formatCliResult, + isCliMode +} from '../cli'; +import { initConfigPath } from '../config'; + +const MOCK_HOME_DIR = '/tmp/ccstatusline-cli-test-home'; +const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR; + +let consoleErrorSpy: MockInstance; + +interface DiskConfig { + version?: number; + lines?: WidgetItem[][]; + compactThreshold?: number; + flexMode?: string; + powerline?: { enabled?: boolean }; + numberFormat?: { token?: { style?: string } }; +} + +function getSettingsPaths(): { configDir: string; settingsPath: string } { + const configDir = path.join(MOCK_HOME_DIR, '.config', 'ccstatusline'); + return { + configDir, + settingsPath: path.join(configDir, 'settings.json') + }; +} + +function getClaudeConfigDir(): string { + return path.join(MOCK_HOME_DIR, '.claude'); +} + +function readDisk(): DiskConfig { + return JSON.parse(fs.readFileSync(getSettingsPaths().settingsPath, 'utf-8')) as DiskConfig; +} + +function readDiskRaw(): string { + return fs.readFileSync(getSettingsPaths().settingsPath, 'utf-8'); +} + +function writeDisk(content: string): void { + fs.mkdirSync(getSettingsPaths().configDir, { recursive: true }); + fs.writeFileSync(getSettingsPaths().settingsPath, content, 'utf-8'); +} + +function lineTypes(config: DiskConfig, line: number): string[] { + return (config.lines ?? [])[line]?.map(item => item.type) ?? []; +} + +describe('cli commands', () => { + beforeEach(() => { + fs.rmSync(MOCK_HOME_DIR, { recursive: true, force: true }); + process.env.CLAUDE_CONFIG_DIR = getClaudeConfigDir(); + initConfigPath(getSettingsPaths().settingsPath); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR; + } + initConfigPath(); + fs.rmSync(MOCK_HOME_DIR, { recursive: true, force: true }); + }); + + describe('mode detection', () => { + it('enters cli mode only with args and a TTY stdin', () => { + expect(isCliMode(['get'], true)).toBe(true); + expect(isCliMode([], true)).toBe(false); + expect(isCliMode(['get'], false)).toBe(false); + }); + }); + + describe('get', () => { + it('prints the effective post-migration config on first run', async () => { + const result = await executeCli(['get']); + + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.message ?? '') as DiskConfig; + expect(parsed.version).toBe(CURRENT_VERSION); + expect(parsed.lines).toHaveLength(3); + expect(lineTypes(parsed, 0)).toContain('git-branch'); + }); + + it('fails with a single-line error on an invalid config', async () => { + writeDisk('{not json'); + + const result = await executeCli(['get']); + + expect(result.exitCode).toBe(1); + expect(result.message).not.toContain('\n'); + expect(result.message).toContain('invalid'); + expect(readDiskRaw()).toBe('{not json'); + }); + }); + + describe('widget add', () => { + it('appends a widget with a generated id and persists it', async () => { + const result = await executeCli(['widget', 'add', '0', 'tokens-total']); + + expect(result.exitCode).toBe(0); + const disk = readDisk(); + const added = (disk.lines ?? [])[0]?.at(-1); + expect(added?.type).toBe('tokens-total'); + expect(added?.id).toBeTruthy(); + expect(lineTypes(disk, 0)[0]).toBe('model'); + }); + + it('inserts at --index when given', async () => { + const result = await executeCli(['widget', 'add', '0', 'tokens-total', '--index', '0']); + + expect(result.exitCode).toBe(0); + expect(lineTypes(readDisk(), 0)[0]).toBe('tokens-total'); + }); + + it('applies typed options to the new widget', async () => { + const result = await executeCli([ + 'widget', 'add', '1', 'custom-text', + '--customText', 'hello', '--color', 'red', '--bold', '--maxWidth', '12' + ]); + + expect(result.exitCode).toBe(0); + const added = (readDisk().lines ?? [])[1]?.[0]; + expect(added?.customText).toBe('hello'); + expect(added?.color).toBe('red'); + expect(added?.bold).toBe(true); + expect(added?.maxWidth).toBe(12); + }); + + it('stores --metadata key=value pairs', async () => { + const result = await executeCli([ + 'widget', 'add', '0', 'git-changes', '--metadata', 'hide=no-git,zero' + ]); + + expect(result.exitCode).toBe(0); + const added = (readDisk().lines ?? [])[0]?.at(-1); + expect(added?.metadata?.hide).toBe('no-git,zero'); + }); + + it('rejects an unknown widget type without touching the file', async () => { + writeDisk(JSON.stringify({ version: CURRENT_VERSION })); + const before = readDiskRaw(); + + const result = await executeCli(['widget', 'add', '0', 'not-a-widget']); + + expect(result.exitCode).toBe(1); + expect(result.message).toContain('unknown widget type \'not-a-widget\''); + expect(readDiskRaw()).toBe(before); + }); + + it('rejects an unknown option', async () => { + const result = await executeCli(['widget', 'add', '0', 'model', '--bogus', 'x']); + + expect(result.exitCode).toBe(1); + expect(result.message).toContain('unknown widget option \'--bogus\''); + }); + + it('rejects a non-numeric value for a number option', async () => { + const result = await executeCli(['widget', 'add', '0', 'model', '--maxWidth', 'abc']); + + expect(result.exitCode).toBe(1); + expect(result.message).toContain('--maxWidth expects a number'); + }); + + it('rejects an out-of-range line index', async () => { + const result = await executeCli(['widget', 'add', '5', 'model']); + + expect(result.exitCode).toBe(1); + expect(result.message).toContain('line index 5 out of range'); + }); + + it('rejects an out-of-range --index', async () => { + const result = await executeCli(['widget', 'add', '0', 'model', '--index', '99']); + + expect(result.exitCode).toBe(1); + expect(result.message).toContain('--index 99 out of range'); + }); + }); + + describe('widget remove', () => { + it('removes by index', async () => { + const result = await executeCli(['widget', 'remove', '0', '1']); + + expect(result.exitCode).toBe(0); + // Default line 0 index 1 is the first separator. + expect(lineTypes(readDisk(), 0)).toEqual([ + 'model', 'context-length', 'separator', 'git-branch', 'separator', 'git-changes' + ]); + }); + + it('removes the first match when removing by type', async () => { + await executeCli(['widget', 'add', '0', 'git-branch']); + const result = await executeCli(['widget', 'remove', '0', 'git-branch']); + + expect(result.exitCode).toBe(0); + expect(lineTypes(readDisk(), 0).filter(type => type === 'git-branch')).toHaveLength(1); + }); + + it('errors once no matching widget remains (not idempotent-by-type)', async () => { + await executeCli(['widget', 'remove', '0', 'git-branch']); + const result = await executeCli(['widget', 'remove', '0', 'git-branch']); + + expect(result.exitCode).toBe(1); + expect(result.message).toContain('no widget of type \'git-branch\''); + }); + + it('errors on an out-of-range index', async () => { + const result = await executeCli(['widget', 'remove', '0', '99']); + + expect(result.exitCode).toBe(1); + expect(result.message).toContain('index 99 out of range'); + }); + }); + + describe('widget move', () => { + it('moves a widget within a line', async () => { + const result = await executeCli(['widget', 'move', '0', '0', '--to', '2']); + + expect(result.exitCode).toBe(0); + expect(lineTypes(readDisk(), 0)).toEqual([ + 'separator', 'context-length', 'model', 'separator', 'git-branch', 'separator', 'git-changes' + ]); + }); + + it('is a no-op success when source and target match', async () => { + await executeCli(['get']); + const before = readDiskRaw(); + const result = await executeCli(['widget', 'move', '0', '1', '--to', '1']); + + expect(result.exitCode).toBe(0); + expect(readDiskRaw()).toBe(before); + }); + + it('errors without --to', async () => { + const result = await executeCli(['widget', 'move', '0', '0']); + + expect(result.exitCode).toBe(1); + expect(result.message).toContain('requires --to'); + }); + + it('errors on out-of-range source and target', async () => { + const fromResult = await executeCli(['widget', 'move', '0', '99', '--to', '0']); + const toResult = await executeCli(['widget', 'move', '0', '0', '--to', '99']); + + expect(fromResult.exitCode).toBe(1); + expect(fromResult.message).toContain('index 99 out of range'); + expect(toResult.exitCode).toBe(1); + expect(toResult.message).toContain('--to 99 out of range'); + }); + }); + + describe('set', () => { + it('sets a scalar global option and is idempotent', async () => { + const first = await executeCli(['set', 'compactThreshold', '40']); + const before = readDiskRaw(); + const second = await executeCli(['set', 'compactThreshold', '40']); + + expect(first.exitCode).toBe(0); + expect(second.exitCode).toBe(0); + expect(readDiskRaw()).toBe(before); + expect(readDisk().compactThreshold).toBe(40); + }); + + it('sets a nested option', async () => { + const result = await executeCli(['set', 'powerline.enabled', 'true']); + + expect(result.exitCode).toBe(0); + expect(readDisk().powerline?.enabled).toBe(true); + }); + + it('creates an absent optional top-level object from JSON', async () => { + const result = await executeCli(['set', 'numberFormat', '{"token":{"style":"compact"}}']); + + expect(result.exitCode).toBe(0); + expect(readDisk().numberFormat?.token?.style).toBe('compact'); + }); + + it('rejects a value that makes the config invalid, writing nothing', async () => { + writeDisk(JSON.stringify({ version: CURRENT_VERSION })); + const before = readDiskRaw(); + + const result = await executeCli(['set', 'compactThreshold', '200']); + + expect(result.exitCode).toBe(1); + expect(result.message).toContain('nothing was written'); + expect(readDiskRaw()).toBe(before); + }); + + it('rejects unknown option paths', async () => { + const topResult = await executeCli(['set', 'bogusTop', 'x']); + const nestedResult = await executeCli(['set', 'powerline.bogus', 'x']); + + expect(topResult.exitCode).toBe(1); + expect(topResult.message).toContain('unknown option \'bogusTop\''); + expect(nestedResult.exitCode).toBe(1); + expect(nestedResult.message).toContain('unknown option \'powerline.bogus\''); + }); + + it('rejects inherited Object.prototype keys instead of stripping them silently', async () => { + writeDisk(JSON.stringify({ version: CURRENT_VERSION })); + const before = readDiskRaw(); + + const toStringResult = await executeCli(['set', 'toString', 'x']); + const ctorResult = await executeCli(['set', 'powerline.constructor', 'x']); + + expect(toStringResult.exitCode).toBe(1); + expect(toStringResult.message).toContain('unknown option \'toString\''); + expect(ctorResult.exitCode).toBe(1); + expect(ctorResult.message).toContain('unknown option \'powerline.constructor\''); + expect(readDiskRaw()).toBe(before); + }); + + it('warns on an unknown theme but still writes it', async () => { + const result = await executeCli(['set', 'theme', 'not-a-theme']); + + expect(result.exitCode).toBe(0); + expect(result.message).toContain('warning'); + expect((result.data as { warnings: string[] }).warnings[0]).toContain('unknown theme'); + expect((readDisk() as { theme?: string }).theme).toBe('not-a-theme'); + }); + + it('does not warn for a known theme', async () => { + const result = await executeCli(['set', 'theme', 'dracula']); + + expect(result.exitCode).toBe(0); + expect(result.message).not.toContain('warning'); + expect((result.data as { warnings: string[] }).warnings).toHaveLength(0); + expect((readDisk() as { theme?: string }).theme).toBe('dracula'); + }); + }); + + describe('validate', () => { + it('reports valid for the live config and invalid for a corrupt one', async () => { + const valid = await executeCli(['validate']); + + expect(valid.exitCode).toBe(0); + expect(valid.data).toMatchObject({ valid: true, path: getSettingsPaths().settingsPath }); + + writeDisk('{"lines": "bogus", "version": 4}'); + const invalid = await executeCli(['validate']); + + expect(invalid.exitCode).toBe(1); + expect(invalid.data).toMatchObject({ valid: false }); + expect((invalid.data as { errors: string[] }).errors.length).toBeGreaterThan(0); + }); + + it('never overwrites an invalid config while validating it', async () => { + writeDisk('{broken'); + + await executeCli(['validate']); + + expect(readDiskRaw()).toBe('{broken'); + }); + + it('validates an external file via --file', async () => { + const goodPath = path.join(getSettingsPaths().configDir, 'good.json'); + const badPath = path.join(getSettingsPaths().configDir, 'bad.json'); + fs.mkdirSync(getSettingsPaths().configDir, { recursive: true }); + fs.writeFileSync(goodPath, JSON.stringify({ version: CURRENT_VERSION }), 'utf-8'); + fs.writeFileSync(badPath, JSON.stringify({ version: CURRENT_VERSION + 95 }), 'utf-8'); + + const good = await executeCli(['validate', '--file', goodPath]); + const bad = await executeCli(['validate', '--file', badPath]); + const missing = await executeCli(['validate', '--file', path.join(getSettingsPaths().configDir, 'nope.json')]); + + expect(good.exitCode).toBe(0); + expect(good.data).toMatchObject({ valid: true, path: goodPath }); + expect(bad.exitCode).toBe(1); + expect((bad.data as { errors: string[] }).errors[0]).toContain('newer than supported'); + expect(missing.exitCode).toBe(1); + expect(missing.message).toContain('Cannot read file'); + }); + + it('warns on an unknown theme name while staying valid', async () => { + writeDisk(JSON.stringify({ version: CURRENT_VERSION, theme: 'not-a-theme' })); + + const result = await executeCli(['validate']); + + expect(result.exitCode).toBe(0); + expect(result.message).toContain('with warnings'); + expect((result.data as { warnings: string[] }).warnings[0]).toContain('unknown theme'); + }); + + it('reports no theme warnings when the theme is known', async () => { + writeDisk(JSON.stringify({ version: CURRENT_VERSION, theme: 'nord' })); + + const result = await executeCli(['validate']); + + expect(result.exitCode).toBe(0); + expect((result.data as { warnings: string[] }).warnings).toHaveLength(0); + }); + }); + + describe('safety guarantees', () => { + it('refuses mutations while the on-disk config is invalid', async () => { + writeDisk('{not json'); + const before = readDiskRaw(); + + const result = await executeCli(['widget', 'add', '0', 'model']); + + expect(result.exitCode).toBe(1); + expect(result.message).toContain('refusing to modify invalid config'); + expect(readDiskRaw()).toBe(before); + }); + + it('writes through a symlinked settings file without replacing the link', async () => { + const realDir = path.join(MOCK_HOME_DIR, 'real-config'); + const realPath = path.join(realDir, 'real-settings.json'); + fs.mkdirSync(realDir, { recursive: true }); + fs.writeFileSync(realPath, JSON.stringify({ version: CURRENT_VERSION }), 'utf-8'); + fs.mkdirSync(getSettingsPaths().configDir, { recursive: true }); + fs.symlinkSync(realPath, getSettingsPaths().settingsPath); + + const result = await executeCli(['widget', 'add', '0', 'tokens-total']); + + expect(result.exitCode).toBe(0); + expect(fs.lstatSync(getSettingsPaths().settingsPath).isSymbolicLink()).toBe(true); + const onReal = JSON.parse(fs.readFileSync(realPath, 'utf-8')) as DiskConfig; + expect(lineTypes(onReal, 0).at(-1)).toBe('tokens-total'); + }); + }); + + describe('help and usage errors', () => { + it('prints usage for help and unknown commands', async () => { + const help = await executeCli(['help']); + const unknown = await executeCli(['bogus']); + const unknownWidget = await executeCli(['widget', 'rename']); + + expect(help.exitCode).toBe(0); + expect(help.message).toContain('usage: ccstatusline'); + expect(help.message).toContain('write the default config on first run'); + expect(unknown.exitCode).toBe(1); + expect(unknown.message).toContain('unknown command \'bogus\''); + expect(unknownWidget.exitCode).toBe(1); + expect(unknownWidget.message).toContain('unknown widget command \'rename\''); + }); + }); + + describe('output formatting', () => { + it('emits single-line machine-parseable errors', () => { + const text = formatCliResult({ exitCode: 1, message: 'boom\ngone bad' }, false); + const json = formatCliResult({ exitCode: 1, message: 'boom\ngone bad' }, true); + + expect(text.stream).toBe('stderr'); + expect(text.text).toBe('Error: boom gone bad'); + expect(json.stream).toBe('stdout'); + expect(json.text).toBe('{"error":"boom gone bad"}'); + }); + + it('emits the data payload as one JSON line in --json success mode', () => { + const json = formatCliResult({ exitCode: 0, message: 'added x', data: { added: true, type: 'x' } }, true); + + expect(json.text).toBe('{"added":true,"type":"x"}'); + }); + + it('treats --json as the output flag only when it does not fill an option value', () => { + expect(extractJsonFlag(['get', '--json'])).toEqual({ args: ['get'], json: true }); + expect(extractJsonFlag(['--json', 'get'])).toEqual({ args: ['get'], json: true }); + expect(extractJsonFlag(['get'])).toEqual({ args: ['get'], json: false }); + + // A --json token after a value-taking option is that option's value. + expect(extractJsonFlag(['widget', 'add', '0', 'custom-text', '--customText', '--json'])).toEqual({ + args: ['widget', 'add', '0', 'custom-text', '--customText', '--json'], + json: false + }); + + // A boolean flag followed by a non-`--` token consumes it as the value. + expect(extractJsonFlag(['widget', 'add', '0', 'model', '--bold', 'false', '--json'])).toEqual({ + args: ['widget', 'add', '0', 'model', '--bold', 'false'], + json: true + }); + }); + + it('keeps a --json-looking option value intact for widget add', async () => { + const result = await executeCli(['widget', 'add', '1', 'custom-text', '--customText', '--json']); + + expect(result.exitCode).toBe(0); + const added = (readDisk().lines ?? [])[1]?.[0]; + expect(added?.customText).toBe('--json'); + }); + }); + + describe('end-to-end round trip', () => { + it('get -> widget add -> validate -> get --json keeps a consistent config', async () => { + const initial = await executeCli(['get']); + const add = await executeCli(['widget', 'add', '0', 'custom-text', '--customText', 'demo', '--index', '0']); + const validate = await executeCli(['validate']); + const final = await executeCli(['get', '--json']); + + expect(initial.exitCode).toBe(0); + expect(add.exitCode).toBe(0); + expect(validate.exitCode).toBe(0); + expect(final.exitCode).toBe(0); + + const formatted = formatCliResult(final, true); + expect(formatted.text).not.toContain('\n'); + const parsed = JSON.parse(formatted.text) as DiskConfig; + expect(parsed.version).toBe(CURRENT_VERSION); + expect((parsed.lines ?? [])[0]?.[0]?.type).toBe('custom-text'); + expect(lineTypes(readDisk(), 0)[0]).toBe('custom-text'); + }); + }); +}); diff --git a/src/utils/__tests__/jsonl-metrics.test.ts b/src/utils/__tests__/jsonl-metrics.test.ts index 348ea432a..7699bf06a 100644 --- a/src/utils/__tests__/jsonl-metrics.test.ts +++ b/src/utils/__tests__/jsonl-metrics.test.ts @@ -29,6 +29,11 @@ async function getTokenMetrics(transcriptPath: string): Promise { return analysis.tokenMetrics; } +async function getLastTurnTokens(transcriptPath: string): Promise { + const analysis = await getTranscriptAnalysis(transcriptPath, { includeLastTurnTokens: true }); + return analysis.tokenMetrics.lastTurnTokens; +} + async function getSpeedMetricsCollection( transcriptPath: string, options: { includeSubagents?: boolean; windowSeconds?: number[] } = {} @@ -69,12 +74,14 @@ function makeUsageLine(params: { isSidechain?: boolean; isApiErrorMessage?: boolean; stopReason?: string | null; + messageId?: string; }): string { return JSON.stringify({ timestamp: params.timestamp, isSidechain: params.isSidechain, isApiErrorMessage: params.isApiErrorMessage, message: { + id: params.messageId, stop_reason: params.stopReason, usage: { input_tokens: params.input, @@ -692,6 +699,386 @@ describe('jsonl transcript metrics', () => { }); }); + it('deduplicates last-turn usage across one API call’s per-content-block entries', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'last-turn-dedup.jsonl'); + + // sirmalloc/ccstatusline#549: one JSONL entry per content block, all + // sharing a message.id and the final non-null stop_reason. The last + // turn must read as one API call, not three entries. + fs.writeFileSync(transcriptPath, [ + makeUsageLine({ + timestamp: '2026-01-01T10:00:00.000Z', + input: 10, + output: 5, + cacheRead: 100, + cacheCreate: 0, + stopReason: 'end_turn', + messageId: 'msg_00' + }), + makeUsageLine({ + timestamp: '2026-01-01T10:01:00.000Z', + input: 2, + output: 1, + cacheRead: 3978, + cacheCreate: 12278, + stopReason: 'tool_use', + messageId: 'msg_01' + }), + makeUsageLine({ + timestamp: '2026-01-01T10:01:00.000Z', + input: 2, + output: 100, + cacheRead: 3978, + cacheCreate: 12278, + stopReason: 'tool_use', + messageId: 'msg_01' + }), + makeUsageLine({ + timestamp: '2026-01-01T10:01:01.000Z', + input: 2, + output: 287, + cacheRead: 3978, + cacheCreate: 12278, + stopReason: 'tool_use', + messageId: 'msg_01' + }) + ].join('\n')); + + const lastTurn = await getLastTurnTokens(transcriptPath); + + expect(lastTurn).toEqual({ + inputTokens: 2, + outputTokens: 287, + cachedTokens: 16256, + totalTokens: 16545 + }); + }); + + it('deduplicates cumulative token totals across one API call’s per-content-block entries', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'cumulative-dedup.jsonl'); + + // The same #549-shaped transcript as the last-turn dedup case above: + // msg_01 writes three per-content-block entries with identical + // prompt-side usage and growing output_tokens, so the cumulative + // widgets must count the call once, not once per block. + fs.writeFileSync(transcriptPath, [ + makeUsageLine({ + timestamp: '2026-01-01T10:00:00.000Z', + input: 10, + output: 5, + cacheRead: 100, + cacheCreate: 0, + stopReason: 'end_turn', + messageId: 'msg_00' + }), + makeUsageLine({ + timestamp: '2026-01-01T10:01:00.000Z', + input: 2, + output: 1, + cacheRead: 3978, + cacheCreate: 12278, + stopReason: 'tool_use', + messageId: 'msg_01' + }), + makeUsageLine({ + timestamp: '2026-01-01T10:01:00.000Z', + input: 2, + output: 100, + cacheRead: 3978, + cacheCreate: 12278, + stopReason: 'tool_use', + messageId: 'msg_01' + }), + makeUsageLine({ + timestamp: '2026-01-01T10:01:01.000Z', + input: 2, + output: 287, + cacheRead: 3978, + cacheCreate: 12278, + stopReason: 'tool_use', + messageId: 'msg_01' + }) + ].join('\n')); + + const metrics = await getTokenMetrics(transcriptPath); + + expect(metrics).toEqual({ + inputTokens: 12, // 10 + 2 (msg_01 counted once) + outputTokens: 292, // 5 + 287 (finalized output wins) + cachedTokens: 16356, // (100 + 0) + (3978 + 12278) + cacheReadTokens: 4078, // 100 + 3978 + cacheCreationTokens: 12278, // 0 + 12278 + totalTokens: 16660, // 12 + 292 + 16356 + contextLength: 16258 // 2 + 3978 + 12278 + }); + }); + + it('resolves streaming partials to their finalized values in cumulative totals', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'cumulative-streaming-dedup.jsonl'); + + // Same shape as the live-update case, but every entry carries a + // message.id: msg_a's partials must collapse into its finalized entry, + // and msg_b's in-flight partials must count once, not per partial. + fs.writeFileSync(transcriptPath, [ + makeUsageLine({ + timestamp: '2026-01-01T10:00:00.000Z', + input: 2, + output: 25, + cacheRead: 100, + cacheCreate: 50, + stopReason: null, + messageId: 'msg_a' + }), + makeUsageLine({ + timestamp: '2026-01-01T10:00:01.000Z', + input: 2, + output: 80, + cacheRead: 100, + cacheCreate: 50, + stopReason: 'end_turn', + messageId: 'msg_a' + }), + makeUsageLine({ + timestamp: '2026-01-01T10:00:02.000Z', + input: 3, + output: 30, + cacheRead: 200, + cacheCreate: 25, + stopReason: null, + messageId: 'msg_b' + }), + makeUsageLine({ + timestamp: '2026-01-01T10:00:03.000Z', + input: 3, + output: 120, + cacheRead: 200, + cacheCreate: 25, + stopReason: null, + messageId: 'msg_b' + }) + ].join('\n')); + + const metrics = await getTokenMetrics(transcriptPath); + + expect(metrics).toEqual({ + inputTokens: 5, // 2 + 3, one call each + outputTokens: 200, // 80 + 120, not 25+80 or 30+120 + cachedTokens: 375, + cacheReadTokens: 300, + cacheCreationTokens: 75, + totalTokens: 580, + contextLength: 228 + }); + }); + + it('counts a sidechain call’s per-content-block entries once in cumulative totals', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'cumulative-sidechain-dedup.jsonl'); + + // Sidechain rows keep today's treatment in the cumulative sums (still + // included, still excluded from context length); grouping by their own + // message.id must not count one subagent call per content block. + fs.writeFileSync(transcriptPath, [ + makeUsageLine({ + timestamp: '2026-01-01T10:00:00.000Z', + input: 10, + output: 50, + cacheRead: 20, + cacheCreate: 10, + stopReason: 'end_turn', + messageId: 'msg_main' + }), + makeUsageLine({ + timestamp: '2026-01-01T10:01:00.000Z', + input: 900, + output: 400, + cacheRead: 5000, + cacheCreate: 100, + stopReason: 'tool_use', + messageId: 'msg_sub', + isSidechain: true + }), + makeUsageLine({ + timestamp: '2026-01-01T10:01:01.000Z', + input: 900, + output: 650, + cacheRead: 5000, + cacheCreate: 100, + stopReason: 'tool_use', + messageId: 'msg_sub', + isSidechain: true + }) + ].join('\n')); + + const metrics = await getTokenMetrics(transcriptPath); + + expect(metrics).toEqual({ + inputTokens: 910, // 10 + 900 (sidechain call counted once) + outputTokens: 700, // 50 + 650 + cachedTokens: 5130, // (20 + 10) + (5000 + 100) + cacheReadTokens: 5020, // 20 + 5000 + cacheCreationTokens: 110, // 10 + 100 + totalTokens: 6740, // 910 + 700 + 5130 + contextLength: 40 // main-chain msg_main only + }); + }); + + it('does not report sidechain or API-error usage as the last turn', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'last-turn-main-chain.jsonl'); + + // While a Task subagent runs, its entries are the newest usage rows in + // the file; the last turn must stay on the user's own API call, and a + // synthetic API-error row with usage must not displace it either. + fs.writeFileSync(transcriptPath, [ + makeUsageLine({ + timestamp: '2026-01-01T10:00:00.000Z', + input: 10, + output: 50, + cacheRead: 20, + cacheCreate: 10, + stopReason: 'end_turn', + messageId: 'msg_main' + }), + makeUsageLine({ + timestamp: '2026-01-01T10:01:00.000Z', + input: 900, + output: 400, + cacheRead: 5000, + cacheCreate: 100, + stopReason: 'tool_use', + messageId: 'msg_sub', + isSidechain: true + }), + makeUsageLine({ + timestamp: '2026-01-01T10:01:01.000Z', + input: 999, + output: 1, + cacheRead: 1, + cacheCreate: 1, + stopReason: null, + messageId: 'msg_err', + isApiErrorMessage: true + }) + ].join('\n')); + + const lastTurn = await getLastTurnTokens(transcriptPath); + + expect(lastTurn).toEqual({ + inputTokens: 10, + outputTokens: 50, + cachedTokens: 30, + totalTokens: 90 + }); + }); + + it('keeps the completed output across streaming partials of the same API call', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'last-turn-streaming.jsonl'); + + fs.writeFileSync(transcriptPath, [ + makeUsageLine({ + timestamp: '2026-01-01T10:00:00.000Z', + input: 4, + output: 40, + cacheRead: 1000, + cacheCreate: 200, + stopReason: null, + messageId: 'msg_live' + }), + makeUsageLine({ + timestamp: '2026-01-01T10:00:01.000Z', + input: 4, + output: 90, + cacheRead: 1000, + cacheCreate: 200, + stopReason: null, + messageId: 'msg_live' + }), + makeUsageLine({ + timestamp: '2026-01-01T10:00:02.000Z', + input: 4, + output: 140, + cacheRead: 1000, + cacheCreate: 200, + stopReason: 'end_turn', + messageId: 'msg_live' + }) + ].join('\n')); + + const lastTurn = await getLastTurnTokens(transcriptPath); + + expect(lastTurn).toEqual({ + inputTokens: 4, + outputTokens: 140, + cachedTokens: 1200, + totalTokens: 1344 + }); + }); + + it('treats each id-less usage entry as its own last turn', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'last-turn-no-id.jsonl'); + + fs.writeFileSync(transcriptPath, [ + makeUsageLine({ + timestamp: '2026-01-01T10:00:00.000Z', + input: 100, + output: 50, + cacheRead: 20, + cacheCreate: 10 + }), + makeUsageLine({ + timestamp: '2026-01-01T10:01:00.000Z', + input: 200, + output: 80, + cacheRead: 30, + cacheCreate: 20 + }) + ].join('\n')); + + const lastTurn = await getLastTurnTokens(transcriptPath); + + expect(lastTurn).toEqual({ + inputTokens: 200, + outputTokens: 80, + cachedTokens: 50, + totalTokens: 330 + }); + }); + + it('omits lastTurnTokens unless the scan option requests it', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'last-turn-opt-in.jsonl'); + + fs.writeFileSync(transcriptPath, [ + makeUsageLine({ + timestamp: '2026-01-01T10:00:00.000Z', + input: 2, + output: 3, + cacheRead: 4, + cacheCreate: 1, + stopReason: 'end_turn', + messageId: 'msg_00' + }) + ].join('\n')); + + const metrics = await getTokenMetrics(transcriptPath); + + expect(metrics.lastTurnTokens).toBeUndefined(); + }); + it('collects configured transcript metrics in one combined analysis', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); tempRoots.push(root); diff --git a/src/utils/__tests__/render-startup.test.ts b/src/utils/__tests__/render-startup.test.ts new file mode 100644 index 000000000..fa4bb1898 --- /dev/null +++ b/src/utils/__tests__/render-startup.test.ts @@ -0,0 +1,46 @@ +import type * as childProcess from 'child_process'; +import * as fs from 'fs'; +import { createRequire } from 'module'; +import * as os from 'os'; +import * as path from 'path'; +import { + expect, + it +} from 'vitest'; + +const require = createRequire(import.meta.url); +const { execFileSync } = require('node:child_process') as { execFileSync: typeof childProcess.execFileSync }; + +it('constructs the widget registry without initializing Ink/Yoga or fetching its WASM', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-startup-')); + const widgetsUrl = new URL('../widgets.ts', import.meta.url).href; + try { + // A fresh process avoids module caches and the other tests' Ink imports. + const output = execFileSync('bun', ['-e', ` + let fetches = 0; + let wasm = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (...args) => { fetches++; return originalFetch(...args); }; + const instantiate = WebAssembly.instantiate; + WebAssembly.instantiate = (...args) => { wasm++; return instantiate(...args); }; + const streaming = WebAssembly.instantiateStreaming; + WebAssembly.instantiateStreaming = (...args) => { wasm++; return streaming(...args); }; + const { getWidget } = await import(${JSON.stringify(widgetsUrl)}); + console.log(JSON.stringify({ fetches, wasm, model: getWidget('model')?.getDisplayName() })); + `], { + encoding: 'utf8', + env: { + PATH: process.env.PATH, + HOME: home, + USERPROFILE: home, + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), + HTTPS_PROXY: '', + https_proxy: '' + }, + timeout: 10000 + }); + expect(JSON.parse(output)).toEqual({ fetches: 0, wasm: 0, model: 'Model' }); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/src/utils/__tests__/renderer-compact-labels.test.ts b/src/utils/__tests__/renderer-compact-labels.test.ts new file mode 100644 index 000000000..a4baabb8b --- /dev/null +++ b/src/utils/__tests__/renderer-compact-labels.test.ts @@ -0,0 +1,107 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { + DEFAULT_SETTINGS, + SettingsSchema, + SettingsSchema_v1, + type Settings +} from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { toggleCompactLabel } from '../../widgets/shared/raw-or-labeled'; +import { stripSgrCodes } from '../ansi'; +import { migrateConfig } from '../migrations'; +import { + calculateMaxWidthsFromPreRendered, + preRenderAllWidgets, + renderStatusLine +} from '../renderer'; + +function createSettings(overrides: Partial = {}): Settings { + return { + ...DEFAULT_SETTINGS, + colorLevel: 0, + ...overrides, + powerline: { + ...DEFAULT_SETTINGS.powerline, + ...(overrides.powerline ?? {}) + } + }; +} + +const CONTEXT: RenderContext = { isPreview: false, terminalWidth: 200, data: { model: 'Opus 4.6', cost: { total_cost_usd: 2.456 } } }; + +function renderLine(widgets: WidgetItem[], settings: Settings): string { + const preRenderedLines = preRenderAllWidgets([widgets], settings, CONTEXT); + const maxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings); + return stripSgrCodes(renderStatusLine(widgets, settings, CONTEXT, preRenderedLines[0] ?? [], maxWidths)); +} + +const TRIO: WidgetItem[] = [ + { id: '1', type: 'model' }, + { id: '2', type: 'separator', character: '|' }, + { id: '3', type: 'session-cost' } +]; + +describe('compact labels rendering', () => { + it('keeps default labels when the setting is absent (old configs)', () => { + expect(DEFAULT_SETTINGS.compactLabels).toBe(false); + expect(renderLine(TRIO, createSettings())).toBe('Model: Opus 4.6 | Cost: $2.46'); + }); + + it('shortens mapped labels when compactLabels is on', () => { + expect(renderLine(TRIO, createSettings({ compactLabels: true }))).toBe('M: Opus 4.6 | $2.46'); + }); + + it('lets a per-widget opt-out beat the global setting', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'model' }, + { id: '2', type: 'separator', character: '|' }, + { id: '3', type: 'session-cost', metadata: { compactLabel: 'false' } } + ]; + expect(renderLine(widgets, createSettings({ compactLabels: true }))).toBe('M: Opus 4.6 | Cost: $2.46'); + }); + + it('lets a per-widget opt-in work without the global setting', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'model', metadata: { compactLabel: 'true' } }, + { id: '2', type: 'separator', character: '|' }, + { id: '3', type: 'session-cost' } + ]; + expect(renderLine(widgets, createSettings())).toBe('M: Opus 4.6 | Cost: $2.46'); + }); + + it('the per-widget (j) toggle beats the global setting at render time', () => { + const settings = createSettings({ compactLabels: true }); + // Simulate pressing (j) once on the model widget while the global + // flag is on: the cycle writes an explicit 'false' and the label + // renders in its default form. + const toggled = toggleCompactLabel({ id: '1', type: 'model' }, settings); + expect(toggled.metadata).toEqual({ compactLabel: 'false' }); + // Model is forced back to its default label while session-cost stays + // compact through the global flag. + expect(renderLine([toggled, { id: '2', type: 'separator', character: '|' }, { id: '3', type: 'session-cost' }], settings)) + .toBe('Model: Opus 4.6 | $2.46'); + }); + + it('parses legacy settings without the new key to the off default', () => { + const legacy = SettingsSchema.parse({ version: 4, lines: [[{ id: '1', type: 'model' }]] }); + expect(legacy.compactLabels).toBe(false); + }); + + it('migrates a v1 config and keeps compact labels off', () => { + const v1 = { lines: [[{ id: '1', type: 'model' }]], globalBold: false }; + const migrated = migrateConfig(v1, 4) as Record; + const parsed = SettingsSchema.parse(migrated); + expect(parsed.version).toBe(4); + expect(parsed.compactLabels).toBe(false); + }); + + it('keeps legacy v1 schema unaware of the new key', () => { + expect('compactLabels' in SettingsSchema_v1.shape).toBe(false); + }); +}); diff --git a/src/utils/__tests__/renderer-regular-theme.test.ts b/src/utils/__tests__/renderer-regular-theme.test.ts new file mode 100644 index 000000000..ebf8d32b7 --- /dev/null +++ b/src/utils/__tests__/renderer-regular-theme.test.ts @@ -0,0 +1,191 @@ +import chalk from 'chalk'; +import { + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { + DEFAULT_SETTINGS, + SettingsSchema, + type Settings +} from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { + bgToFg, + getColorAnsiCode, + getPowerlineTheme, + getPowerlineThemes, + updateColorMap +} from '../colors'; +import { + calculateMaxWidthsFromPreRendered, + preRenderAllWidgets, + renderStatusLine +} from '../renderer'; + +// Dracula level-3 segment palette (POWERLINE_THEMES['dracula'][3].bg) β€” the +// array regular mode cycles as foregrounds. +const fg = (color: string, level: 'ansi16' | 'ansi256' | 'truecolor' = 'truecolor'): string => getColorAnsiCode(color, level, false); + +function themedSettings(theme?: string, colorLevel: 1 | 2 | 3 = 3): Settings { + return { + ...DEFAULT_SETTINGS, + colorLevel, + defaultPadding: '', + theme + }; +} + +function renderWidgets(settings: Settings, widgets: WidgetItem[]): string { + const context: RenderContext = { isPreview: false, data: { session_id: 'test-session' } }; + const preRenderedLines = preRenderAllWidgets([widgets], settings, context); + const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings); + + return renderStatusLine(widgets, settings, context, preRenderedLines[0] ?? [], preCalculatedMaxWidths); +} + +describe('renderer regular-mode theme', () => { + const originalLevel = chalk.level; + + // Named colors resolve through COLOR_MAP, which captures chalk's level at + // build time β€” pin it so assertions see real escape codes. + beforeEach(() => { + chalk.level = 3; + updateColorMap(); + }); + + afterEach(() => { + chalk.level = originalLevel; + updateColorMap(); + }); + + it('cycles the theme palette across uncolored widgets', () => { + const line = renderWidgets(themedSettings('dracula'), [ + { id: '1', type: 'custom-text', customText: 'A' }, + { id: '2', type: 'custom-text', customText: 'B' }, + { id: '3', type: 'custom-text', customText: 'C' } + ]); + + expect(line).toContain(fg('hex:BD93F9')); + expect(line).toContain(fg('hex:F8F8F2')); + expect(line).toContain(fg('hex:FF5555')); + expect(line).not.toContain(fg('hex:8BE9FD')); // 3 widgets, palette has 5 + }); + + it('keeps explicit widget colors over the theme but keeps the palette rhythm', () => { + const line = renderWidgets(themedSettings('dracula'), [ + { id: '1', type: 'custom-text', customText: 'A', color: 'red' }, + { id: '2', type: 'custom-text', customText: 'B' } + ]); + + expect(line).toContain(fg('hex:CC0000')); // explicit 'red' wins + expect(line).not.toContain(fg('hex:BD93F9')); // slot 0 still consumed + expect(line).toContain(fg('hex:F8F8F2')); // second widget gets slot 1 + }); + + it('does not theme separators and they consume no palette slot', () => { + const line = renderWidgets(themedSettings('dracula'), [ + { id: '1', type: 'custom-text', customText: 'A' }, + { id: '2', type: 'separator', character: ' | ' }, + { id: '3', type: 'custom-text', customText: 'B' } + ]); + + expect(line).toContain(fg('hex:BD93F9')); + expect(line).toContain(fg('hex:F8F8F2')); // consecutive slots across the separator + expect(line).not.toContain(fg('hex:FF5555')); + }); + + it('does not theme widgets that preserve their own colors', () => { + const line = renderWidgets(themedSettings('dracula'), [ + { id: '1', type: 'custom-command', commandPath: 'echo kept', preserveColors: true }, + { id: '2', type: 'custom-text', customText: 'B' } + ]); + + expect(line).not.toContain(fg('hex:BD93F9')); + expect(line).toContain(fg('hex:F8F8F2')); // preserved widget still advanced the palette + }); + + it('shares one palette slot across merged widgets', () => { + const line = renderWidgets(themedSettings('dracula'), [ + { id: '1', type: 'custom-text', customText: 'A', merge: true }, + { id: '2', type: 'custom-text', customText: 'B' } + ]); + + expect(line).toContain(fg('hex:BD93F9')); + expect(line).not.toContain(fg('hex:F8F8F2')); // both merged widgets share slot 0 + }); + + it('leaves default colors when theme is absent, custom, or unknown', () => { + const widgets: WidgetItem[] = [{ id: '1', type: 'custom-text', customText: 'A' }]; + + for (const theme of [undefined, 'custom', 'not-a-theme']) { + const line = renderWidgets(themedSettings(theme), widgets); + expect(line).not.toContain(fg('hex:BD93F9')); + } + }); + + it('uses the theme level matching colorLevel', () => { + const level2 = renderWidgets(themedSettings('dracula', 2), [ + { id: '1', type: 'custom-text', customText: 'A' } + ]); + + expect(level2).toContain(fg('ansi256:141', 'ansi256')); + expect(level2).not.toContain(fg('hex:BD93F9')); + + // ansi16-level palettes carry bg*-prefixed names; they must render as + // foregrounds, not backgrounds + const level1 = renderWidgets(themedSettings('nord', 1), [ + { id: '1', type: 'custom-text', customText: 'A' } + ]); + + expect(level1).toContain(fg('brightCyan', 'ansi16')); + expect(level1).not.toContain(getColorAnsiCode('bgBrightCyan', 'ansi16', true)); + }); + + it('parses the theme key without a version bump', () => { + expect(SettingsSchema.parse({ theme: 'dracula' }).theme).toBe('dracula'); + expect(SettingsSchema.parse({}).theme).toBeUndefined(); + }); + + // Every shipped theme must behave identically in regular mode: same slot + // rule, same bg[]-as-foreground palette, at all three color levels. + describe('uniform cycle across all themes and levels', () => { + const themeNames = getPowerlineThemes().filter((name) => name !== 'custom'); + const levelName = (colorLevel: 1 | 2 | 3): 'ansi16' | 'ansi256' | 'truecolor' => (colorLevel === 1 ? 'ansi16' : colorLevel === 2 ? 'ansi256' : 'truecolor'); + + it('has a non-empty bg palette at every level for every theme', () => { + for (const name of themeNames) { + const theme = getPowerlineTheme(name); + expect(theme).toBeDefined(); + for (const level of ['1', '2', '3'] as const) { + expect(theme?.[level]?.bg.length ?? 0).toBeGreaterThan(0); + } + } + }); + + it.each(themeNames.flatMap((name) => ([1, 2, 3] as const).map((colorLevel) => ({ name, colorLevel }))))( + 'cycles $name uniformly at colorLevel $colorLevel', + ({ name, colorLevel }) => { + const theme = getPowerlineTheme(name); + expect(theme).toBeDefined(); + const palette = (theme?.[String(colorLevel) as '1' | '2' | '3']?.bg ?? []).map(bgToFg); + const first = palette[0]; + const second = palette[1]; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + + const line = renderWidgets(themedSettings(name, colorLevel), [ + { id: '1', type: 'custom-text', customText: 'A' }, + { id: '2', type: 'custom-text', customText: 'B' } + ]); + + expect(line).toContain(fg(first as string, levelName(colorLevel))); + expect(line).toContain(fg(second as string, levelName(colorLevel))); + } + ); + }); +}); diff --git a/src/utils/__tests__/terminal.test.ts b/src/utils/__tests__/terminal.test.ts index 1f40ef987..80e786eec 100644 --- a/src/utils/__tests__/terminal.test.ts +++ b/src/utils/__tests__/terminal.test.ts @@ -66,12 +66,8 @@ describe('terminal utils', () => { it('returns width from the immediate parent tty when available', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { @@ -83,8 +79,7 @@ describe('terminal utils', () => { expect(getTerminalWidth()).toBe(120); expect(mockExecFileSync.mock.calls.map(([file, args]) => `${file as string} ${(args as string[]).join(' ')}`)).toEqual([ - `ps -o ppid= -p ${process.pid}`, - 'ps -o tty= -p 1234', + `ps -o ppid= -o tty= -p ${process.ppid}`, 'stty -F /dev/ttys001 size' ]); }); @@ -92,20 +87,16 @@ describe('terminal utils', () => { it('walks ancestor processes until it finds a valid tty', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return '??\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ?\n'; } - if (file === 'ps' && args.join(' ') === '-o ppid= -p 1234') { - return '5678\n'; + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 1234') { + return '5678 ??\n'; } - if (file === 'ps' && args.join(' ') === '-o tty= -p 5678') { - return ' ttys009 \n'; + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 5678') { + return '1 ttys009\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys009 size') { @@ -118,16 +109,31 @@ describe('terminal utils', () => { expect(getTerminalWidth()).toBe(104); }); - it('falls back through stty variants when the first form returns no value', () => { + it('reads the current generation TTY even when its PPID ends the walk', () => { pinPosixPlatform(); - // Simulates BSD/macOS, where `stty -F` exits with an error; `stty -f` succeeds. + // A PPID of 0 terminates the walk after this row, not before it: + // the parent's own width must still win over the tput fallback. mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '0 ttys001\n'; + } + + if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { + return '24 120\n'; } - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys003\n'; + throw new Error(`Unexpected command: ${file} ${args.join(' ')}`); + }); + + expect(getTerminalWidth()).toBe(120); + }); + + it('falls back through stty variants when the first form returns no value', () => { + pinPosixPlatform(); + // Simulates BSD/macOS, where `stty -F` exits with an error; `stty -f` succeeds. + mockExecFileSync.mockImplementation((file: string, args: string[]) => { + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ttys003\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys003 size') { @@ -154,25 +160,21 @@ describe('terminal utils', () => { expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual(['cols']); }); - it('returns null when ancestor and fallback probes fail', () => { + it('returns null when ancestor and fallback probes all fail', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ttys001\n'; } - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 1234') { + return '0 ttys001\n'; } if (file === 'stty') { return 'not-a-number not-a-number\n'; } - if (file === 'ps' && args.join(' ') === '-o ppid= -p 1234') { - return '0\n'; - } - if (file === 'tput') { throw new Error('tput unavailable'); } @@ -186,20 +188,16 @@ describe('terminal utils', () => { it('detects availability when an ancestor tty probe succeeds', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return '??\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ?\n'; } - if (file === 'ps' && args.join(' ') === '-o ppid= -p 1234') { - return '5678\n'; + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 1234') { + return '5678 ??\n'; } - if (file === 'ps' && args.join(' ') === '-o tty= -p 5678') { - return 'ttys010\n'; + if (file === 'ps' && args.join(' ') === '-o ppid= -o tty= -p 5678') { + return '1 ttys010\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys010 size') { @@ -232,12 +230,8 @@ describe('terminal utils', () => { process.env.CCSTATUSLINE_WIDTH = '0'; mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { @@ -255,12 +249,8 @@ describe('terminal utils', () => { process.env.CCSTATUSLINE_WIDTH = 'wide'; mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { @@ -293,12 +283,8 @@ describe('terminal utils', () => { it('probes only once across repeated calls when a width is found', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { @@ -434,12 +420,8 @@ describe('terminal utils', () => { it('never persists a discovered numeric width to the L2 cache', () => { pinPosixPlatform(); mockExecFileSync.mockImplementation((file: string, args: string[]) => { - if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { - return '1234\n'; - } - - if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { - return 'ttys001\n'; + if (file === 'ps' && args.join(' ') === `-o ppid= -o tty= -p ${process.ppid}`) { + return '1234 ttys001\n'; } if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { diff --git a/src/utils/__tests__/widgets.test.ts b/src/utils/__tests__/widgets.test.ts index 84b286dbe..8043a8add 100644 --- a/src/utils/__tests__/widgets.test.ts +++ b/src/utils/__tests__/widgets.test.ts @@ -9,9 +9,11 @@ import { type Settings } from '../../types/Settings'; import { getHideKeybind } from '../../widgets/shared/hideable'; +import { getCompactLabelKeybind } from '../../widgets/shared/raw-or-labeled'; import { filterWidgetCatalog, getAllWidgetTypes, + getCompactLabelCapableTypes, getMatchSegments, getWidget, getWidgetCatalog, @@ -197,6 +199,34 @@ describe('hideable state keybind reservation', () => { }); }); +describe('compact label keybind reservation', () => { + it('widgets do not bind the shared compact-label key', () => { + const reservedKey = getCompactLabelKeybind().key; + const settings: Settings = { + ...DEFAULT_SETTINGS, + powerline: { ...DEFAULT_SETTINGS.powerline } + }; + + for (const type of getAllWidgetTypes(settings).filter( + type => type !== 'separator' && type !== 'flex-separator' + )) { + const widget = getWidget(type); + const keys = (widget?.getCustomKeybinds?.() ?? []).map(keybind => keybind.key); + expect(`${type} binds ${keys.includes(reservedKey) ? reservedKey : 'nothing reserved'}`) + .toBe(`${type} binds nothing reserved`); + } + }); + + it('probe marks labeled widgets capable and unlabeled ones not', () => { + const capable = getCompactLabelCapableTypes(); + expect(capable.has('model')).toBe(true); + expect(capable.has('session-cost')).toBe(true); + expect(capable.has('context-bar')).toBe(true); + expect(capable.has('git-branch')).toBe(false); + expect(capable.has('tokens-input')).toBe(false); + }); +}); + describe('widget catalog filtering', () => { const catalog = getWidgetCatalog({ ...DEFAULT_SETTINGS, diff --git a/src/utils/claude-service-status.ts b/src/utils/claude-service-status.ts index 5a31aef15..174837ead 100644 --- a/src/utils/claude-service-status.ts +++ b/src/utils/claude-service-status.ts @@ -1,6 +1,5 @@ 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'; @@ -282,55 +281,76 @@ type StatusPageRequestFn = ( const requestStatusPage: StatusPageRequestFn = (options, onResponse) => https.request(options, onResponse); +async function getStatusPageRequestOptions(): Promise { + const proxyUrl = getStatusPageProxyUrl(); + + try { + let agent: https.RequestOptions['agent'] | undefined; + if (proxyUrl) { + // Loaded on demand: the agent (and its transitive module graph) is + // only needed when a proxy is actually configured, and the + // statusline entry point re-runs on every repaint (#397). + const { HttpsProxyAgent: ProxyAgent } = await import('https-proxy-agent'); + agent = new ProxyAgent(proxyUrl); + } + + return { + hostname: STATUS_HOST, + path: '', + method: 'GET', + timeout: STATUS_TIMEOUT_MS, + ...(agent ? { agent } : {}) + }; + } catch { + return null; + } +} + function fetchStatusPagePath( pathName: string, requestFn: StatusPageRequestFn = requestStatusPage ): Promise { - return new Promise((resolve) => { - let settled = false; + return getStatusPageRequestOptions().then((baseOptions) => { + if (!baseOptions) { + return null; + } - const finish = (value: string | null) => { - if (settled) { - return; - } - settled = true; - resolve(value); - }; + return new Promise((resolve) => { + let settled = false; - let requestOptions: https.RequestOptions; - try { - const proxyUrl = getStatusPageProxyUrl(); - requestOptions = { - hostname: STATUS_HOST, - path: pathName, - method: 'GET', - timeout: STATUS_TIMEOUT_MS, - ...(proxyUrl ? { agent: new HttpsProxyAgent(proxyUrl) } : {}) + const finish = (value: string | null) => { + if (settled) { + return; + } + settled = true; + resolve(value); }; - } catch { - finish(null); - return; - } - const request = requestFn(requestOptions, (response) => { - let data = ''; - response.setEncoding('utf8'); - response.on('data', (chunk: string) => { - data += chunk; - }); - response.on('end', () => { - finish(response.statusCode === 200 && data ? data : null); + const requestOptions: https.RequestOptions = { + ...baseOptions, + path: pathName + }; + + const request = requestFn(requestOptions, (response) => { + let data = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + data += chunk; + }); + response.on('end', () => { + finish(response.statusCode === 200 && data ? data : null); + }); + response.on('aborted', () => { finish(null); }); + response.on('error', () => { finish(null); }); }); - response.on('aborted', () => { finish(null); }); - response.on('error', () => { finish(null); }); - }); - request.on('error', () => { finish(null); }); - request.on('timeout', () => { - request.destroy(); - finish(null); + request.on('error', () => { finish(null); }); + request.on('timeout', () => { + request.destroy(); + finish(null); + }); + request.end(); }); - request.end(); }); } diff --git a/src/utils/cli.ts b/src/utils/cli.ts new file mode 100644 index 000000000..aea685df5 --- /dev/null +++ b/src/utils/cli.ts @@ -0,0 +1,633 @@ +import type { Settings } from '../types/Settings'; +import { SettingsSchema } from '../types/Settings'; +import type { WidgetItem } from '../types/Widget'; +import { WidgetItemSchema } from '../types/Widget'; + +import { getPowerlineThemes } from './colors'; + +import { + getConfigLoadError, + getConfigPath, + loadSettings, + saveSettings, + validateImportFile +} from './config'; +import { generateGuid } from './guid'; +import { + getAllWidgetTypes, + resolveLegacyWidgetType +} from './widgets'; + +// Non-interactive CLI (#602): lets coding agents read and mutate the same +// settings.json the TUI edits. All mutations load via loadSettings(), refuse to +// run on an unreadable/invalid config, validate the resulting config before any +// write, and persist through saveSettings() (atomic rename, symlink +// write-through). Errors are single-line and machine-parseable (--json). + +export interface CliResult { + exitCode: number; + /** Machine-readable payload emitted as a single JSON line in --json mode. */ + data?: unknown; + /** Human-readable message; stdout on success, the error text on failure. */ + message?: string; +} + +const USAGE = [ + 'usage: ccstatusline [args] [--json] [--config ]', + 'commands:', + ' get print the effective (post-migration) config', + ' widget add [--index N] [--option value ...]', + ' widget remove ', + ' widget move --to ', + ' set set a global option (JSON value or plain string)', + ' validate [--file ] exit 0/1 with a machine-readable report', + ' help show this help', + 'note: get and validate write the default config on first run if settings.json is missing' +]; + +function singleLine(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +function fail(message: string): CliResult { + return { exitCode: 1, message: singleLine(message) }; +} + +function ok(message: string, data: unknown): CliResult { + return { exitCode: 0, message, data }; +} + +function parseNonNegativeInt(text: string | undefined, label: string): number | string { + const value = Number(text); + if (text === undefined || text.trim() === '' || !Number.isInteger(value) || value < 0) { + return `${label} must be a non-negative integer, got '${text ?? ''}'`; + } + return value; +} + +function coerceValue(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function hasOwn(object: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(object, key); +} + +type WidgetOptionKind = 'string' | 'boolean' | 'number' | 'dim' | 'merge' | 'json'; + +// Option names mirror the WidgetItem fields the TUI's items editor exposes. +const WIDGET_OPTION_KINDS: Record = { + color: 'string', + backgroundColor: 'string', + character: 'string', + customText: 'string', + customSymbol: 'string', + commandPath: 'string', + numberFormat: 'json', + bold: 'boolean', + rawValue: 'boolean', + preserveColors: 'boolean', + excludeFromAutoAlign: 'boolean', + maxWidth: 'number', + timeout: 'number', + dim: 'dim', + merge: 'merge' +}; + +// Options whose next argv token is their value (widget add also accepts +// value-less boolean flags, hence the separate boolean set). +const WIDGET_BOOLEAN_FLAGS = new Set( + Object.entries(WIDGET_OPTION_KINDS) + .filter(([, kind]) => kind === 'boolean') + .map(([key]) => `--${key}`) +); +const VALUE_OPTION_FLAGS = new Set([ + ...Object.entries(WIDGET_OPTION_KINDS) + .filter(([, kind]) => kind !== 'boolean') + .map(([key]) => `--${key}`), + '--index', + '--metadata', + '--file', + '--to' +]); + +/** + * Pull the --json output flag out of argv without eating option values: a + * --json token that follows a value-taking option (or fills a boolean option + * before a non-`--` token) belongs to that option, not to the output flag. + */ +export function extractJsonFlag(argv: string[]): { args: string[]; json: boolean } { + const valuePositions = new Set(); + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (token === undefined) { + break; + } + const next = argv[i + 1]; + if (VALUE_OPTION_FLAGS.has(token) && next !== undefined) { + valuePositions.add(i + 1); + } else if (WIDGET_BOOLEAN_FLAGS.has(token) && next !== undefined && !next.startsWith('--')) { + valuePositions.add(i + 1); + } + } + const args: string[] = []; + let json = false; + argv.forEach((token, i) => { + if (token === '--json' && !valuePositions.has(i)) { + json = true; + } else { + args.push(token); + } + }); + return { args, json }; +} + +function coerceBooleanOption(name: string, text: string): { value: boolean } | string { + if (text === 'true') + return { value: true }; + if (text === 'false') + return { value: false }; + return `option --${name} expects true or false, got '${text}'`; +} + +function coerceOptionValue(kind: WidgetOptionKind, text: string, name: string): { value: unknown } | string { + switch (kind) { + case 'number': { + const value = Number(text); + return Number.isFinite(value) + ? { value } + : `option --${name} expects a number, got '${text}'`; + } + case 'boolean': + return coerceBooleanOption(name, text); + case 'dim': + if (text === 'parens') + return { value: 'parens' as const }; + return coerceBooleanOption(name, text); + case 'merge': + if (text === 'no-padding') + return { value: 'no-padding' as const }; + return coerceBooleanOption(name, text); + case 'json': + try { + return { value: JSON.parse(text) as unknown }; + } catch { + return `option --${name} expects JSON, got '${text}'`; + } + default: + return { value: text }; + } +} + +async function persistSettings(next: Settings, success: { message: string; data: unknown }): Promise { + // Validate-before-write: a mutation that would produce an invalid config is + // rejected and nothing touches the disk. + const parsed = SettingsSchema.safeParse(next); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const where = issue && issue.path.length > 0 ? `${issue.path.join('.')}: ` : ''; + return fail(`resulting config would be invalid (${where}${issue ? issue.message : 'unknown error'}); nothing was written`); + } + await saveSettings(parsed.data); + return ok(success.message, success.data); +} + +// Theme names outside POWERLINE_THEMES silently no-op in the regular renderer; +// surface a warning instead of failing so a typo never invalidates a config. +// 'custom' is the powerline off state and always allowed. +function getThemeWarnings(settings: Settings): string[] { + if (!settings.theme || settings.theme === 'custom' || getPowerlineThemes().includes(settings.theme)) { + return []; + } + return [`unknown theme '${settings.theme}' (known: ${getPowerlineThemes().filter((name) => name !== 'custom').join(', ')})`]; +} + +interface SettingsLoad { settings: Settings } + +// Mutating commands must never overwrite an unreadable/invalid settings file +// with the in-memory defaults that loadSettings() falls back to. +async function loadMutableSettings(): Promise { + const settings = await loadSettings(); + const loadError = getConfigLoadError(); + if (loadError !== null) { + return fail(`refusing to modify invalid config at ${getConfigPath()}: ${loadError}; fix or remove the file first`); + } + return { settings }; +} + +async function cmdGet(): Promise { + const settings = await loadSettings(); + const loadError = getConfigLoadError(); + if (loadError !== null) { + return fail(`config is invalid: ${loadError}`); + } + return ok(JSON.stringify(settings, null, 2), settings); +} + +async function cmdValidate(args: string[]): Promise { + let filePath: string | undefined; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--file') { + filePath = args[i + 1]; + if (!filePath || filePath.startsWith('--')) { + return fail('--file requires a path argument'); + } + i++; + } else { + return fail(`unknown option '${args[i]}' for validate`); + } + } + + if (filePath) { + const result = await validateImportFile(filePath); + if (result.status === 'valid') { + return ok(`OK ${filePath}`, { valid: true, path: filePath }); + } + const reason = singleLine(result.reason); + return { exitCode: 1, message: reason, data: { valid: false, path: filePath, errors: [reason] } }; + } + + const settings = await loadSettings(); + const loadError = getConfigLoadError(); + const configPath = getConfigPath(); + if (loadError === null) { + const warnings = getThemeWarnings(settings); + return ok( + warnings.length > 0 ? `OK ${configPath} (with warnings)` : `OK ${configPath}`, + { valid: true, path: configPath, warnings } + ); + } + return { exitCode: 1, message: loadError, data: { valid: false, path: configPath, errors: [singleLine(loadError)] } }; +} + +async function cmdWidgetAdd(args: string[]): Promise { + const positional: string[] = []; + const options: Record = {}; + const metadata: Record = {}; + let indexArg: number | null = null; + + for (let i = 0; i < args.length; i++) { + const token = args[i]; + if (token === undefined) { + break; + } + if (!token.startsWith('--')) { + positional.push(token); + continue; + } + const key = token.slice(2); + if (key === 'index') { + const parsed = parseNonNegativeInt(args[i + 1], '--index'); + if (typeof parsed === 'string') + return fail(parsed); + indexArg = parsed; + i++; + continue; + } + if (key === 'metadata') { + const pair = args[i + 1]; + const eq = pair ? pair.indexOf('=') : -1; + if (!pair || eq <= 0) { + return fail(`--metadata expects key=value, got '${pair ?? ''}'`); + } + metadata[pair.slice(0, eq)] = pair.slice(eq + 1); + i++; + continue; + } + const kind = WIDGET_OPTION_KINDS[key]; + if (!kind) { + return fail(`unknown widget option '${token}'`); + } + const raw = args[i + 1]; + if (kind === 'boolean' && (raw === undefined || raw.startsWith('--'))) { + options[key] = true; + continue; + } + if (raw === undefined) { + return fail(`option ${token} expects a value`); + } + const coerced = coerceOptionValue(kind, raw, key); + if (typeof coerced === 'string') + return fail(coerced); + options[key] = coerced.value; + i++; + } + + const lineToken = positional[0]; + const widgetToken = positional[1]; + if (lineToken === undefined || widgetToken === undefined) { + return fail('widget add requires a line index and a widget type, e.g. ccstatusline widget add 0 git-branch'); + } + if (positional.length > 2) { + return fail(`unexpected argument '${positional[2]}' for widget add`); + } + + const lineResult = parseNonNegativeInt(lineToken, 'line index'); + if (typeof lineResult === 'string') + return fail(lineResult); + + const load = await loadMutableSettings(); + if ('exitCode' in load) + return load; + const settings = load.settings; + + if (lineResult >= settings.lines.length) { + return fail(`line index ${lineResult} out of range; config has ${settings.lines.length} lines`); + } + + const type = resolveLegacyWidgetType(widgetToken); + if (!getAllWidgetTypes(settings).includes(type)) { + return fail(`unknown widget type '${widgetToken}'`); + } + + const draft: Record = { id: generateGuid(), type, ...options }; + if (Object.keys(metadata).length > 0) { + draft.metadata = metadata; + } + const itemResult = WidgetItemSchema.safeParse(draft); + if (!itemResult.success) { + const issue = itemResult.error.issues[0]; + return fail(`invalid widget options: ${issue ? issue.message : 'unknown error'}`); + } + const item: WidgetItem = itemResult.data; + + const line = settings.lines[lineResult]; + if (line === undefined) { + return fail(`line index ${lineResult} out of range; config has ${settings.lines.length} lines`); + } + if (indexArg !== null && indexArg > line.length) { + return fail(`--index ${indexArg} out of range; line ${lineResult} has ${line.length} widgets`); + } + const insertAt = indexArg ?? line.length; + + return persistSettings( + { + ...settings, + lines: settings.lines.map((existing, i) => i === lineResult + ? [...existing.slice(0, insertAt), item, ...existing.slice(insertAt)] + : existing) + }, + { + message: `added ${type} to line ${lineResult} at index ${insertAt}`, + data: { added: true, line: lineResult, index: insertAt, type } + } + ); +} + +async function cmdWidgetRemove(args: string[]): Promise { + const lineToken = args[0]; + const targetToken = args[1]; + if (lineToken === undefined || targetToken === undefined) { + return fail('widget remove requires a line index and an index-or-type'); + } + if (args.length > 2) { + return fail(`unexpected argument '${args[2]}' for widget remove`); + } + + const lineResult = parseNonNegativeInt(lineToken, 'line index'); + if (typeof lineResult === 'string') + return fail(lineResult); + + const load = await loadMutableSettings(); + if ('exitCode' in load) + return load; + const settings = load.settings; + + if (lineResult >= settings.lines.length) { + return fail(`line index ${lineResult} out of range; config has ${settings.lines.length} lines`); + } + + const sourceLine = settings.lines[lineResult]; + if (sourceLine === undefined) { + return fail(`line index ${lineResult} out of range; config has ${settings.lines.length} lines`); + } + const line = [...sourceLine]; + let removeIdx: number; + if (/^\d+$/.test(targetToken)) { + removeIdx = Number(targetToken); + if (removeIdx >= line.length) { + return fail(`index ${removeIdx} out of range; line ${lineResult} has ${line.length} widgets`); + } + } else { + const type = resolveLegacyWidgetType(targetToken); + removeIdx = line.findIndex(item => item.type === type); + if (removeIdx === -1) { + return fail(`no widget of type '${targetToken}' on line ${lineResult}`); + } + } + + const removed = line.splice(removeIdx, 1)[0]; + if (removed === undefined) { + return fail(`index ${removeIdx} out of range; line ${lineResult} has ${line.length + 1} widgets`); + } + return persistSettings( + { + ...settings, + lines: settings.lines.map((existing, i) => i === lineResult ? line : existing) + }, + { + message: `removed ${removed.type} from line ${lineResult} at index ${removeIdx}`, + data: { removed: true, line: lineResult, index: removeIdx, type: removed.type } + } + ); +} + +async function cmdWidgetMove(args: string[]): Promise { + const positional: string[] = []; + let to: number | null = null; + for (let i = 0; i < args.length; i++) { + const token = args[i]; + if (token === undefined) { + break; + } + if (token === '--to') { + const parsed = parseNonNegativeInt(args[i + 1], '--to'); + if (typeof parsed === 'string') + return fail(parsed); + to = parsed; + i++; + } else if (token.startsWith('--')) { + return fail(`unknown option '${token}' for widget move`); + } else { + positional.push(token); + } + } + + const lineToken = positional[0]; + const fromToken = positional[1]; + if (lineToken === undefined || fromToken === undefined) { + return fail('widget move requires a line index, a widget index, and --to '); + } + if (positional.length > 2) { + return fail(`unexpected argument '${positional[2]}' for widget move`); + } + if (to === null) { + return fail('widget move requires --to '); + } + + const lineResult = parseNonNegativeInt(lineToken, 'line index'); + if (typeof lineResult === 'string') + return fail(lineResult); + const fromResult = parseNonNegativeInt(fromToken, 'widget index'); + if (typeof fromResult === 'string') + return fail(fromResult); + + const load = await loadMutableSettings(); + if ('exitCode' in load) + return load; + const settings = load.settings; + + if (lineResult >= settings.lines.length) { + return fail(`line index ${lineResult} out of range; config has ${settings.lines.length} lines`); + } + + const sourceLine = settings.lines[lineResult]; + if (sourceLine === undefined) { + return fail(`line index ${lineResult} out of range; config has ${settings.lines.length} lines`); + } + const line = [...sourceLine]; + if (fromResult >= line.length) { + return fail(`index ${fromResult} out of range; line ${lineResult} has ${line.length} widgets`); + } + if (to > line.length - 1) { + return fail(`--to ${to} out of range; line ${lineResult} has ${line.length} widgets`); + } + + const moved = line.splice(fromResult, 1)[0]; + if (moved === undefined) { + return fail(`index ${fromResult} out of range; line ${lineResult} has ${line.length + 1} widgets`); + } + line.splice(to, 0, moved); + + return persistSettings( + { + ...settings, + lines: settings.lines.map((existing, i) => i === lineResult ? line : existing) + }, + { + message: `moved ${moved.type} from index ${fromResult} to ${to} on line ${lineResult}`, + data: { moved: true, line: lineResult, from: fromResult, to, type: moved.type } + } + ); +} + +async function cmdWidget(sub: string | undefined, args: string[]): Promise { + if (sub === 'add') + return cmdWidgetAdd(args); + if (sub === 'remove') + return cmdWidgetRemove(args); + if (sub === 'move') + return cmdWidgetMove(args); + return fail(`unknown widget command '${sub ?? ''}'; expected add, remove, or move`); +} + +async function cmdSet(args: string[]): Promise { + if (args.length < 2) { + return fail('set requires an option path and a value'); + } + + const load = await loadMutableSettings(); + if ('exitCode' in load) + return load; + + const optionPath = args[0]; + if (optionPath === undefined) { + return fail('set requires an option path and a value'); + } + const value = coerceValue(args.slice(1).join(' ')); + const parts = optionPath.split('.'); + + // Deep-clone through JSON: Settings is JSON-serializable by construction. + const next = JSON.parse(JSON.stringify(load.settings)) as Settings; + let node = next as unknown as Record; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (part === undefined) { + return fail(`unknown option path '${optionPath}'`); + } + const child = node[part]; + if (typeof child !== 'object' || child === null) { + return fail(`unknown option path '${parts.slice(0, i + 1).join('.')}'`); + } + node = child as Record; + } + + const last = parts[parts.length - 1]; + if (last === undefined) { + return fail(`unknown option '${optionPath}'`); + } + // hasOwn-style checks: `in` also matches inherited properties, so + // `set toString x` would pass the guard, get stripped by the schema, and + // report success while writing nothing. Object.hasOwn is Node 16.9+ and + // the build targets Node 14+, hence the prototype call. + if (!hasOwn(node, last) && !(parts.length === 1 && hasOwn(SettingsSchema.shape, last))) { + return fail(`unknown option '${optionPath}'`); + } + node[last] = value; + + const warnings = parts.length === 1 && last === 'theme' ? getThemeWarnings(next) : []; + return persistSettings(next, { + message: `set ${optionPath} = ${JSON.stringify(value)}${warnings.length > 0 ? ` (warning: ${warnings[0]})` : ''}`, + data: { set: optionPath, value, warnings } + }); +} + +function cmdHelp(): CliResult { + return ok(USAGE.join('\n'), { usage: USAGE }); +} + +export async function executeCli(argv: string[]): Promise { + const { args } = extractJsonFlag(argv); + const command = args[0]; + + if (command === 'help' || command === '--help' || command === '-h') { + return cmdHelp(); + } + if (command === 'get') { + return cmdGet(); + } + if (command === 'validate') { + return cmdValidate(args.slice(1)); + } + if (command === 'set') { + return cmdSet(args.slice(1)); + } + if (command === 'widget') { + return cmdWidget(args[1], args.slice(2)); + } + return fail(`unknown command '${command ?? ''}'; run 'ccstatusline help'`); +} + +/** True when argv names a CLI subcommand and stdin is a terminal (no piped render input). */ +export function isCliMode(args: string[], isTty: boolean): boolean { + return isTty && args.length > 0; +} + +export function formatCliResult(result: CliResult, json: boolean): { stream: 'stdout' | 'stderr'; text: string } { + if (result.exitCode !== 0) { + const error = singleLine(result.message ?? 'unknown error'); + return json + ? { stream: 'stdout', text: JSON.stringify({ error }) } + : { stream: 'stderr', text: `Error: ${error}` }; + } + if (!json) { + return { stream: 'stdout', text: result.message ?? '' }; + } + return { stream: 'stdout', text: JSON.stringify(result.data ?? { ok: true }) }; +} + +export async function runCli(argv: string[]): Promise { + const { args, json } = extractJsonFlag(argv); + const result = await executeCli(args); + const output = formatCliResult(result, json); + if (output.stream === 'stdout') { + console.log(output.text); + } else { + console.error(output.text); + } + process.exit(result.exitCode); +} diff --git a/src/utils/jsonl-metrics.ts b/src/utils/jsonl-metrics.ts index 88f4c47d8..847570798 100644 --- a/src/utils/jsonl-metrics.ts +++ b/src/utils/jsonl-metrics.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import path from 'node:path'; import type { + LastTurnTokens, SpeedMetrics, TokenMetrics, TranscriptLine @@ -42,6 +43,7 @@ export interface TranscriptAnalysisOptions { includeCompactionStats?: boolean; includeThinkingEffort?: boolean; includeSessionName?: boolean; + includeLastTurnTokens?: boolean; } export interface TranscriptAnalysis { @@ -86,6 +88,7 @@ interface TokenMetricEntry { stopReason: string | null | undefined; timestampMs: number | null; isMainChain: boolean; + messageId: string | null; } interface TokenMetricAccumulator { @@ -102,10 +105,14 @@ interface TokenMetricAccumulator { interface TokenMetricState { metrics: TokenMetricAccumulator; hasStopReasonField: boolean; + countedMessageId: string | null; + countedOutputTokens: number; lastUsageEntry: TokenMetricEntry | null; sawCompactBoundary: boolean; boundaryAfterLastUsage: boolean; lastCompactBoundaryPostTokens: number | null; + lastTurnMessageId: string | null; + lastTurnTokens: LastTurnTokens | null; } function createEmptyTokenMetrics(): TokenMetrics { @@ -169,13 +176,73 @@ function createTokenMetricState(): TokenMetricState { return { metrics: createTokenMetricAccumulator(), hasStopReasonField: false, + countedMessageId: null, + countedOutputTokens: 0, lastUsageEntry: null, sawCompactBoundary: false, boundaryAfterLastUsage: false, - lastCompactBoundaryPostTokens: null + lastCompactBoundaryPostTokens: null, + lastTurnMessageId: null, + lastTurnTokens: null }; } +function createLastTurnTokens(usage: UsageTokens): LastTurnTokens { + const cachedTokens = usage.read + usage.creation; + return { + inputTokens: usage.input, + outputTokens: usage.output, + cachedTokens, + totalTokens: usage.input + usage.output + cachedTokens + }; +} + +// Claude Code writes one JSONL entry per content block (thinking / text / each +// tool_use) of a single API call, all sharing one message.id and repeating +// identical prompt-side usage; only output_tokens grows entry to entry. +// Grouping consecutive counted entries by id keeps one usage per call instead +// of one per content block (see sirmalloc/ccstatusline#549). Entries without +// an id each start a new group. +function accumulateCumulativeTokenEntry( + state: TokenMetricState, + entry: TokenMetricEntry, + includePostCompactionUsage: boolean +): void { + if (state.countedMessageId !== null + && entry.messageId !== null + && entry.messageId === state.countedMessageId) { + // Duplicates only grow output_tokens, so the recency snapshots taken by + // the group's first counted entry stay valid: per-block entries repeat + // identical prompt-side usage and contextLength never reads output. + // Divergent prompt-side usage within one id would need re-snapshotting. + const outputDelta = entry.usage.output - state.countedOutputTokens; + if (outputDelta > 0) { + state.countedOutputTokens = entry.usage.output; + state.metrics.outputTokens += outputDelta; + } + return; + } + + state.countedMessageId = entry.messageId; + state.countedOutputTokens = entry.usage.output; + accumulateTokenMetricEntry(state.metrics, entry, includePostCompactionUsage); +} + +function trackLastTurnTokens(state: TokenMetricState, entry: TokenMetricEntry): void { + const { messageId } = entry; + if (state.lastTurnTokens !== null + && messageId !== null + && messageId === state.lastTurnMessageId) { + const lastTurn = state.lastTurnTokens; + lastTurn.outputTokens = Math.max(lastTurn.outputTokens, entry.usage.output); + lastTurn.totalTokens = lastTurn.inputTokens + lastTurn.outputTokens + lastTurn.cachedTokens; + return; + } + + state.lastTurnMessageId = messageId; + state.lastTurnTokens = createLastTurnTokens(entry.usage); +} + function collectTokenMetricRecord(state: TokenMetricState, data: TranscriptLine | null, timestampMs: number | null): void { const compactBoundary = isCompactBoundary(data); if (compactBoundary) { @@ -188,29 +255,38 @@ function collectTokenMetricRecord(state: TokenMetricState, data: TranscriptLine const message = data?.message; const usage = message?.usage; if (usage) { + const rawMessageId = message.id; const entry: TokenMetricEntry = { usage: parseUsageTokens(usage), stopReason: message.stop_reason, timestampMs, - isMainChain: data?.isSidechain !== true && !data?.isApiErrorMessage + isMainChain: data?.isSidechain !== true && !data?.isApiErrorMessage, + messageId: typeof rawMessageId === 'string' && rawMessageId.length > 0 ? rawMessageId : null }; const hasStopReason = Object.prototype.hasOwnProperty.call(message, 'stop_reason'); if (hasStopReason && !state.hasStopReasonField) { state.hasStopReasonField = true; state.metrics = createTokenMetricAccumulator(); + state.countedMessageId = null; + state.countedOutputTokens = 0; } if (!state.hasStopReasonField || entry.stopReason) { - accumulateTokenMetricEntry(state.metrics, entry, !compactBoundary); + accumulateCumulativeTokenEntry(state, entry, !compactBoundary); + } + // Sidechain (subagent) and API-error rows never form the user's turn, + // matching the recency rule used for context length above. + if (entry.isMainChain) { + trackLastTurnTokens(state, entry); } state.lastUsageEntry = entry; state.boundaryAfterLastUsage = compactBoundary; } } -function finishTokenMetrics(state: TokenMetricState): TokenMetrics { +function finishTokenMetrics(state: TokenMetricState, includeLastTurnTokens: boolean): TokenMetrics { if (state.hasStopReasonField && state.lastUsageEntry?.stopReason === null) { - accumulateTokenMetricEntry(state.metrics, state.lastUsageEntry, !state.boundaryAfterLastUsage); + accumulateCumulativeTokenEntry(state, state.lastUsageEntry, !state.boundaryAfterLastUsage); } const contextLengthFromUsage = (usage: UsageTokens | null): number | null => usage @@ -228,7 +304,10 @@ function finishTokenMetrics(state: TokenMetricState): TokenMetrics { cacheReadTokens: state.metrics.cacheReadTokens, cacheCreationTokens: state.metrics.cacheCreationTokens, totalTokens: state.metrics.inputTokens + state.metrics.outputTokens + cachedTokens, - contextLength + contextLength, + lastTurnTokens: includeLastTurnTokens && state.lastTurnTokens + ? state.lastTurnTokens + : undefined }; } @@ -597,7 +676,7 @@ async function scanTranscript(transcriptPath: string, options: TranscriptScanOpt } return { - tokenMetrics: tokenState ? finishTokenMetrics(tokenState) : null, + tokenMetrics: tokenState ? finishTokenMetrics(tokenState, options.includeLastTurnTokens === true) : null, sessionDuration: options.includeSessionDuration ? formatSessionDuration(firstTimestampMs, lastTimestampMs) : null, diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index a3ca51ec3..9d1edbd08 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -16,6 +16,7 @@ import { MERGE_TARGET_HIDDEN_HIDEABLE_STATE, isHidden } from '../widgets/shared/hideable'; +import { withGlobalCompactLabels } from '../widgets/shared/raw-or-labeled'; import { applyLineGradient, @@ -126,6 +127,25 @@ function resolveEffectiveTerminalWidth( return null; } +// Resolve a POWERLINE_THEMES entry for the configured color level. Returns +// undefined for unset, 'custom', or unknown names. Shared by both render +// paths: powerline uses fg+bg per segment; regular mode cycles bg[] as +// foregrounds (fg[] holds the dark text colors meant to sit on those +// backgrounds). +function resolveThemeColors( + themeName: string | undefined, + settings: Settings +): { fg: string[]; bg: string[] } | undefined { + if (!themeName || themeName === 'custom') + return undefined; + const theme = getPowerlineTheme(themeName); + if (!theme) + return undefined; + const colorLevel = getColorLevelString(settings.colorLevel); + const colorLevelKey = colorLevel === 'ansi16' ? '1' : colorLevel === 'ansi256' ? '2' : '3'; + return theme[colorLevelKey]; +} + function renderPowerlineStatusLine( widgets: WidgetItem[], settings: Settings, @@ -159,17 +179,7 @@ function renderPowerlineStatusLine( ); // Get theme colors if a theme is set and not 'custom' - const themeName = config.theme as string | undefined; - let themeColors: { fg: string[]; bg: string[] } | undefined; - - if (themeName && themeName !== 'custom') { - const theme = getPowerlineTheme(themeName); - if (theme) { - const colorLevel = getColorLevelString(settings.colorLevel); - const colorLevelKey = colorLevel === 'ansi16' ? '1' : colorLevel === 'ansi256' ? '2' : '3'; - themeColors = theme[colorLevelKey]; - } - } + const themeColors = resolveThemeColors(config.theme as string | undefined, settings); // Get color level from settings const colorLevel = getColorLevelString(settings.colorLevel); @@ -896,7 +906,9 @@ export function preRenderAllWidgets( continue; } - const effectiveWidget = context.minimalist ? { ...widget, rawValue: true } : widget; + const effectiveWidget = context.minimalist + ? { ...widget, rawValue: true } + : withGlobalCompactLabels(widget, settings); const widgetText = widgetImpl.render(effectiveWidget, context, settings) ?? ''; // Store the rendered content without padding (padding is applied later) @@ -1043,6 +1055,14 @@ export function renderStatusLine( preCalculatedMaxWidths ); + // Regular-mode theme: cycle the theme's segment palette (bg[]) as widget + // foregrounds (bgToFg converts ansi16-level bg* names to their foreground + // equivalents). Explicit per-widget colors win; separators and flex + // separators are not themed and do not consume palette slots. Merged + // widgets share one palette slot, matching powerline's cycling. + const regularThemePalette = resolveThemeColors(settings.theme, settings)?.bg.map(bgToFg); + let themeColorIndex = 0; + // Helper to apply colors with optional background, bold, and dim const applyColorsWithOverride = (text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean, dim?: boolean | 'parens'): string => { // Override foreground color takes precedence over EVERYTHING, including passed foreground @@ -1175,6 +1195,9 @@ export function renderStatusLine( } if (widgetText) { + const themedFg = regularThemePalette + ? regularThemePalette[themeColorIndex % regularThemePalette.length] + : undefined; // Special handling for widgets that preserve their own colors if (widgetPreservesColors(widget)) { // Handle max width truncation for commands with ANSI codes @@ -1197,13 +1220,16 @@ export function renderStatusLine( widget }); } else { - // Normal widget rendering with colors + // Normal widget rendering with colors; theme palette fills + // in when the widget has no explicit color elements.push({ - content: applyColorsWithOverride(widgetText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, widget.dim), + content: applyColorsWithOverride(widgetText, widget.color ?? themedFg ?? defaultColor, widget.backgroundColor, widget.bold, widget.dim), type: widget.type, widget }); } + if (!widget.merge) + themeColorIndex++; } } catch { // Unknown widget type - skip diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 5faf9ae7d..eb8c4d50b 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -54,25 +54,27 @@ function probeTerminalWidth(): number | null { // Claude Code can spawn ccstatusline with piped stdio, leaving the immediate // parent process without a controlling TTY. Walk up a few ancestors until we - // find the shell process that owns the real PTY. - let pid = process.pid; + // find the shell process that owns the real PTY. process.ppid starts the walk + // at generation 1 for free, matching the old two-spawn walk's generation + // coverage (1..8). + let pid = process.ppid; for (let depth = 0; depth < 8; depth += 1) { - const parentPid = getParentProcessId(pid); - if (parentPid === null) { - break; + const ancestor = getProcessAncestorInfo(pid); + + // Try this generation's TTY even when the PPID looks terminal: a PPID of + // 0 must not hide a perfectly good width on the same ps row. + if (ancestor.tty !== null) { + const width = getWidthForTTY(ancestor.tty); + if (width !== null) { + return width; + } } - pid = parentPid; - - const tty = getTTYForProcess(pid); - if (tty === null) { - continue; + if (ancestor.parentPid === null) { + break; } - const width = getWidthForTTY(tty); - if (width !== null) { - return width; - } + pid = ancestor.parentPid; } // Fallback: try tput cols which might work in some environments @@ -100,35 +102,35 @@ function parsePositiveInteger(value: string): number | null { return parsed; } -function getParentProcessId(pid: number): number | null { - try { - const parentPidOutput = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - windowsHide: true - }).trim(); - - return parsePositiveInteger(parentPidOutput); - } catch { - return null; - } +interface ProcessAncestorInfo { + parentPid: number | null; + tty: string | null; } -function getTTYForProcess(pid: number): string | null { +function getProcessAncestorInfo(pid: number): ProcessAncestorInfo { + // One ps spawn answers both questions of the ancestor walk: the parent pid + // and the controlling TTY. The probe re-runs per render on macOS (numeric + // widths are deliberately not persisted across processes), and under + // several concurrent sessions each saved subprocess is saved per repaint + // (see #397). Two -o flags rather than one comma list: FreeBSD's parser + // treats everything after the first '=' as a single header, so the comma + // form collapses into one bogus column. try { - const tty = execFileSync('ps', ['-o', 'tty=', '-p', String(pid)], { + const output = execFileSync('ps', ['-o', 'ppid=', '-o', 'tty=', '-p', String(pid)], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true - }).replace(/\s+/g, ''); + }).trim(); - if (!tty || tty === '??' || tty === '?') { - return null; - } + const fields = output.split(/\s+/); + const tty = fields[1] ?? ''; - return tty; + return { + parentPid: parsePositiveInteger(fields[0] ?? ''), + tty: !tty || tty === '?' || tty === '??' ? null : tty + }; } catch { - return null; + return { parentPid: null, tty: null }; } } diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index 964ba8be4..315212dde 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'child_process'; import { createHash } from 'crypto'; import * as fs from 'fs'; import * as https from 'https'; -import { HttpsProxyAgent } from 'https-proxy-agent'; +import type { HttpsProxyAgent } from 'https-proxy-agent'; import * as os from 'os'; import * as path from 'path'; import { z } from 'zod'; @@ -719,10 +719,19 @@ function getUsageApiProxyUrl(): string | null { return proxyUrl ?? null; } -function getUsageApiRequestOptions(token: string): https.RequestOptions | null { +async function getUsageApiRequestOptions(token: string): Promise { const proxyUrl = getUsageApiProxyUrl(); try { + let agent: InstanceType | undefined; + if (proxyUrl) { + // Loaded on demand: the agent (and its transitive module graph) only + // matters when a proxy is actually configured, and the statusline + // entry point is re-run on every repaint (#397). + const { HttpsProxyAgent: ProxyAgent } = await import('https-proxy-agent'); + agent = new ProxyAgent(proxyUrl); + } + return { hostname: USAGE_API_HOST, path: USAGE_API_PATH, @@ -732,7 +741,7 @@ function getUsageApiRequestOptions(token: string): https.RequestOptions | null { 'anthropic-beta': 'oauth-2025-04-20' }, timeout: USAGE_API_TIMEOUT_MS, - ...(proxyUrl ? { agent: new HttpsProxyAgent(proxyUrl) } : {}) + ...(agent ? { agent } : {}) }; } catch { return null; @@ -740,6 +749,11 @@ function getUsageApiRequestOptions(token: string): https.RequestOptions | null { } async function fetchFromUsageApi(token: string): Promise { + const requestOptions = await getUsageApiRequestOptions(token); + if (!requestOptions) { + return { kind: 'error' }; + } + return new Promise((resolve) => { let settled = false; @@ -751,12 +765,6 @@ async function fetchFromUsageApi(token: string): Promise { resolve(value); }; - const requestOptions = getUsageApiRequestOptions(token); - if (!requestOptions) { - finish({ kind: 'error' }); - return; - } - const request = https.request(requestOptions, (response) => { let data = ''; response.setEncoding('utf8'); diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index cec521541..591d5e6f1 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -58,6 +58,7 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'tokens-output', create: () => new widgets.TokensOutputWidget() }, { type: 'tokens-cached', create: () => new widgets.TokensCachedWidget() }, { type: 'tokens-total', create: () => new widgets.TokensTotalWidget() }, + { type: 'tokens-last-turn', create: () => new widgets.TokensLastTurnWidget() }, { type: 'cache-hit-rate', create: () => new widgets.CacheHitRateWidget() }, { type: 'cache-read', create: () => new widgets.CacheReadWidget() }, { type: 'cache-write', create: () => new widgets.CacheWriteWidget() }, diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 22075f052..44fe7e37f 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -1,10 +1,13 @@ import type { Settings } from '../types/Settings'; +import { DEFAULT_SETTINGS } from '../types/Settings'; import type { Widget, WidgetItem, WidgetItemType } from '../types/Widget'; +import { startsWithCompactLabel } from '../widgets/shared/raw-or-labeled'; +import { getVisibleText } from './ansi'; import { filterFuzzySearchRecords, type FuzzySearchRecord @@ -141,3 +144,35 @@ export function isKnownWidgetType(type: string): boolean { return widgetRegistry.has(resolved) || layoutWidgetTypes.has(resolved); } + +// Widget types whose preview output opens with a label that has a compact +// preset; these get the shared compact-label editor keybind. The probe runs +// once and is cached β€” preview renders are static and cheap, but the scan +// still touches every registered widget. ponytail: preview-probe heuristic; +// if a widget ever stops emitting its label first in preview output it drops +// out of the keybind, and the fix is an explicit capability flag per widget. +let compactLabelCapableTypes: Set | null = null; + +export function getCompactLabelCapableTypes(): Set { + if (compactLabelCapableTypes === null) { + const capable = new Set(); + for (const entry of WIDGET_MANIFEST) { + const widget = widgetRegistry.get(entry.type); + if (!widget) { + continue; + } + try { + const probeItem: WidgetItem = { id: 'compact-label-probe', type: entry.type }; + const output = widget.render(probeItem, { isPreview: true }, DEFAULT_SETTINGS); + if (output && startsWithCompactLabel(getVisibleText(output))) { + capable.add(entry.type); + } + } catch { + // A widget that cannot render without live data is simply not + // compact-label capable. + } + } + compactLabelCapableTypes = capable; + } + return compactLabelCapableTypes; +} diff --git a/src/widgets/ContextBar.ts b/src/widgets/ContextBar.ts index 16e42fce1..b3fe0ae0c 100644 --- a/src/widgets/ContextBar.ts +++ b/src/widgets/ContextBar.ts @@ -18,6 +18,7 @@ import { import { formatTokens } from '../utils/renderer'; import { makeUsageProgressBar } from '../utils/usage'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { makeSliderBar } from './shared/usage-display'; type DisplayMode = 'progress' | 'progress-short' | 'slider' | 'slider-only'; @@ -93,11 +94,11 @@ export class ContextBarWidget implements Widget { if (isBarSliderMode(displayMode)) { const slider = makeSliderBar(25); const sliderDisplay = displayMode === 'slider' ? `${slider} ${usedDisplay}/${totalDisplay} (${percentDisplay})` : slider; - return item.rawValue ? sliderDisplay : `Context: ${sliderDisplay}`; + return formatRawOrLabeledValue(item, 'Context: ', sliderDisplay); } const barWidth = displayMode === 'progress' ? 32 : 16; const previewDisplay = `${makeUsageProgressBar(25, barWidth)} ${usedDisplay}/${totalDisplay} (${percentDisplay})`; - return item.rawValue ? previewDisplay : `Context: ${previewDisplay}`; + return formatRawOrLabeledValue(item, 'Context: ', previewDisplay); } const contextWindowMetrics = getContextWindowMetrics(context.data); @@ -127,13 +128,13 @@ export class ContextBarWidget implements Widget { if (isBarSliderMode(displayMode)) { const slider = makeSliderBar(clampedPercent); const sliderDisplay = displayMode === 'slider' ? `${slider} ${usedDisplay}/${totalDisplay} (${percentDisplay})` : slider; - return item.rawValue ? sliderDisplay : `Context: ${sliderDisplay}`; + return formatRawOrLabeledValue(item, 'Context: ', sliderDisplay); } const barWidth = displayMode === 'progress' ? 32 : 16; const display = `${makeUsageProgressBar(clampedPercent, barWidth)} ${usedDisplay}/${totalDisplay} (${percentDisplay})`; - return item.rawValue ? display : `Context: ${display}`; + return formatRawOrLabeledValue(item, 'Context: ', display); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/CurrentWorkingDir.tsx b/src/widgets/CurrentWorkingDir.tsx index bc76a4538..88eadbbd8 100644 --- a/src/widgets/CurrentWorkingDir.tsx +++ b/src/widgets/CurrentWorkingDir.tsx @@ -1,10 +1,5 @@ -import { - Box, - Text, - useInput -} from 'ink'; import * as os from 'node:os'; -import React, { useState } from 'react'; +import React from 'react'; import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; @@ -15,8 +10,15 @@ import type { WidgetEditorProps, WidgetItem } from '../types/Widget'; -import { shouldInsertInput } from '../utils/input-guards'; +import { lazyEditor } from './shared/lazy-editor'; +import { + MAX_WIDTH_ACTION, + applyMaxWidth, + getMaxWidthKeybind, + getMaxWidthModifier, + renderMaxWidthEditor +} from './shared/max-width'; import { SYMBOL_OVERRIDE_ACTION, formatSymbolPrefix, @@ -45,6 +47,11 @@ export class CurrentWorkingDirWidget implements Widget { modifiers.push(`segments: ${segments}`); } + const maxWidthText = getMaxWidthModifier(item); + if (maxWidthText) { + modifiers.push(maxWidthText); + } + return { displayText: this.getDisplayName(), modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined @@ -172,7 +179,7 @@ export class CurrentWorkingDirWidget implements Widget { } } - return item.rawValue ? `${symbolPrefix}${displayPath}` : `${symbolPrefix}cwd: ${displayPath}`; + return applyMaxWidth(item.rawValue ? `${symbolPrefix}${displayPath}` : `${symbolPrefix}cwd: ${displayPath}`, item.maxWidth); } getCustomKeybinds(): CustomKeybind[] { @@ -180,7 +187,8 @@ export class CurrentWorkingDirWidget implements Widget { { key: 'h', label: '(h)ome ~', action: 'toggle-abbreviate-home' }, { key: 's', label: '(s)egments', action: 'edit-segments' }, { key: 'f', label: '(f)ish style', action: 'toggle-fish-style' }, - getSymbolKeybind() + getSymbolKeybind(), + getMaxWidthKeybind() ]; } @@ -188,6 +196,9 @@ export class CurrentWorkingDirWidget implements Widget { if (props.action === SYMBOL_OVERRIDE_ACTION) { return renderSymbolOverrideEditor(props, ''); } + if (props.action === MAX_WIDTH_ACTION) { + return renderMaxWidthEditor(props); + } return ; } @@ -249,51 +260,4 @@ export class CurrentWorkingDirWidget implements Widget { } } -const CurrentWorkingDirEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { - const [segmentsInput, setSegmentsInput] = useState(widget.metadata?.segments ?? ''); - - useInput((input, key) => { - if (action === 'edit-segments') { - if (key.return) { - const segments = parseInt(segmentsInput, 10); - if (!isNaN(segments) && segments > 0) { - onComplete({ - ...widget, - metadata: { - ...widget.metadata, - segments: segments.toString() - } - }); - } else { - // Clear segments if blank or invalid - const { segments, ...restMetadata } = widget.metadata ?? {}; - onComplete({ - ...widget, - metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined - }); - } - } else if (key.escape) { - onCancel(); - } else if (key.backspace) { - setSegmentsInput(segmentsInput.slice(0, -1)); - } else if (shouldInsertInput(input, key) && /\d/.test(input)) { - setSegmentsInput(segmentsInput + input); - } - } - }); - - if (action === 'edit-segments') { - return ( - - - Enter number of segments to display (blank for full path): - {segmentsInput} - {' '} - - Press Enter to save, ESC to cancel - - ); - } - - return Unknown editor mode; -}; +const CurrentWorkingDirEditor = lazyEditor(() => import('./editors/CurrentWorkingDirEditor')); diff --git a/src/widgets/CustomCommand.tsx b/src/widgets/CustomCommand.tsx index 17d927edb..9496395dd 100644 --- a/src/widgets/CustomCommand.tsx +++ b/src/widgets/CustomCommand.tsx @@ -1,9 +1,4 @@ -import { - Box, - Text, - useInput -} from 'ink'; -import React, { useState } from 'react'; +import React from 'react'; import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; @@ -16,7 +11,8 @@ import type { } from '../types/Widget'; import { getVisibleText } from '../utils/ansi'; import { runCustomCommand } from '../utils/custom-command'; -import { shouldInsertInput } from '../utils/input-guards'; + +import { lazyEditor } from './shared/lazy-editor'; export class CustomCommandWidget implements Widget { getDefaultColor(): string { return 'white'; } @@ -117,117 +113,4 @@ export class CustomCommandWidget implements Widget { } } -interface EditorMode { type: 'command' | 'width' | 'timeout' | null } - -const CustomCommandEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { - const getMode = (): EditorMode['type'] => { - switch (action) { - case 'edit-command': return 'command'; - case 'edit-width': return 'width'; - case 'edit-timeout': return 'timeout'; - default: return 'command'; - } - }; - const mode = getMode(); - const [commandInput, setCommandInput] = useState(widget.commandPath ?? ''); - const [commandCursorPos, setCommandCursorPos] = useState(commandInput.length); - const [widthInput, setWidthInput] = useState(widget.maxWidth?.toString() ?? ''); - const [timeoutInput, setTimeoutInput] = useState(widget.timeout?.toString() ?? '1000'); - - useInput((input, key) => { - if (mode === 'command') { - if (key.return) { - onComplete({ ...widget, commandPath: commandInput }); - } else if (key.escape) { - onCancel(); - } else if (key.leftArrow) { - setCommandCursorPos(Math.max(0, commandCursorPos - 1)); - } else if (key.rightArrow) { - setCommandCursorPos(Math.min(commandInput.length, commandCursorPos + 1)); - } else if (key.backspace) { - if (commandCursorPos > 0) { - setCommandInput(commandInput.slice(0, commandCursorPos - 1) + commandInput.slice(commandCursorPos)); - setCommandCursorPos(commandCursorPos - 1); - } - } else if (key.delete) { - if (commandCursorPos < commandInput.length) { - setCommandInput(commandInput.slice(0, commandCursorPos) + commandInput.slice(commandCursorPos + 1)); - } - } else if (shouldInsertInput(input, key)) { - setCommandInput(commandInput.slice(0, commandCursorPos) + input + commandInput.slice(commandCursorPos)); - setCommandCursorPos(commandCursorPos + input.length); - } - } else if (mode === 'width') { - if (key.return) { - const width = parseInt(widthInput, 10); - if (!isNaN(width) && width > 0) { - onComplete({ ...widget, maxWidth: width }); - } else { - const { maxWidth, ...rest } = widget; - onComplete(rest); - } - } else if (key.escape) { - onCancel(); - } else if (key.backspace) { - setWidthInput(widthInput.slice(0, -1)); - } else if (shouldInsertInput(input, key) && /\d/.test(input)) { - setWidthInput(widthInput + input); - } - } else if (mode === 'timeout') { - if (key.return) { - const timeout = parseInt(timeoutInput, 10); - if (!isNaN(timeout) && timeout > 0) { - onComplete({ ...widget, timeout }); - } else { - const { timeout, ...rest } = widget; - onComplete(rest); - } - } else if (key.escape) { - onCancel(); - } else if (key.backspace) { - setTimeoutInput(timeoutInput.slice(0, -1)); - } else if (shouldInsertInput(input, key) && /\d/.test(input)) { - setTimeoutInput(timeoutInput + input); - } - } - }); - - if (mode === 'command') { - return ( - - - Enter command path: - {' '} - {commandInput.slice(0, commandCursorPos)} - {commandInput[commandCursorPos] ?? ' '} - {commandInput.slice(commandCursorPos + 1)} - - ←→ move cursor, Enter save, ESC cancel - - ); - } else if (mode === 'width') { - return ( - - - Enter max width (blank for no limit): - {widthInput} - {' '} - - Press Enter to save, ESC to cancel - - ); - } else if (mode === 'timeout') { - return ( - - - Enter timeout in milliseconds (default 1000): - {timeoutInput} - {' '} - - Press Enter to save, ESC to cancel - - ); - } - - return Unknown editor mode; -}; +const CustomCommandEditor = lazyEditor(() => import('./editors/CustomCommandEditor')); diff --git a/src/widgets/CustomSymbol.tsx b/src/widgets/CustomSymbol.tsx index ff388bf51..df0ba2fef 100644 --- a/src/widgets/CustomSymbol.tsx +++ b/src/widgets/CustomSymbol.tsx @@ -1,9 +1,4 @@ -import { - Box, - Text, - useInput -} from 'ink'; -import React, { useState } from 'react'; +import React from 'react'; import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; @@ -15,9 +10,9 @@ import type { WidgetEditorProps, WidgetItem } from '../types/Widget'; -import { shouldInsertInput } from '../utils/input-guards'; import { MERGE_TARGET_HIDDEN_HIDEABLE_STATE } from './shared/hideable'; +import { lazyEditor } from './shared/lazy-editor'; export class CustomSymbolWidget implements Widget { getDefaultColor(): string { return 'white'; } @@ -56,51 +51,4 @@ export class CustomSymbolWidget implements Widget { supportsColors(item: WidgetItem): boolean { return true; } } -const CustomSymbolEditor: React.FC = ({ widget, onComplete, onCancel }) => { - const [symbol, setSymbol] = useState(widget.customSymbol ?? ''); - - // Helper to get grapheme segments if Intl.Segmenter is available - const getFirstGrapheme = (str: string): string => { - if (str.length === 0) { - return ''; - } - - if ('Segmenter' in Intl) { - const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); - const segments = Array.from(segmenter.segment(str)); - return segments[0]?.segment ?? ''; - } - - // Fallback: just take first character - return Array.from(str)[0] ?? ''; - }; - - useInput((input, key) => { - if (key.return) { - onComplete({ ...widget, customSymbol: symbol }); - } else if (key.escape) { - onCancel(); - } else if (key.backspace || key.delete) { - setSymbol(''); - } else if (shouldInsertInput(input, key)) { - // Take only the first grapheme (handles multi-byte emojis correctly) - const firstGrapheme = getFirstGrapheme(input); - setSymbol(firstGrapheme); - } - }); - - return ( - - - Enter custom symbol: - {' '} - {symbol ? ( - {symbol} - ) : ( - (empty) - )} - - Type any character or emoji, Backspace clear, Enter save, ESC cancel - - ); -}; +const CustomSymbolEditor = lazyEditor(() => import('./editors/CustomSymbolEditor')); diff --git a/src/widgets/CustomText.tsx b/src/widgets/CustomText.tsx index 03ba01724..165c05287 100644 --- a/src/widgets/CustomText.tsx +++ b/src/widgets/CustomText.tsx @@ -1,9 +1,4 @@ -import { - Box, - Text, - useInput -} from 'ink'; -import React, { useState } from 'react'; +import React from 'react'; import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; @@ -15,9 +10,9 @@ import type { WidgetEditorProps, WidgetItem } from '../types/Widget'; -import { shouldInsertInput } from '../utils/input-guards'; import { MERGE_TARGET_HIDDEN_HIDEABLE_STATE } from './shared/hideable'; +import { lazyEditor } from './shared/lazy-editor'; export class CustomTextWidget implements Widget { getDefaultColor(): string { return 'white'; } @@ -56,126 +51,4 @@ export class CustomTextWidget implements Widget { supportsColors(item: WidgetItem): boolean { return true; } } -const CustomTextEditor: React.FC = ({ widget, onComplete, onCancel }) => { - const [text, setText] = useState(widget.customText ?? ''); - const [cursorPos, setCursorPos] = useState(text.length); - - // Helper to get grapheme segments if Intl.Segmenter is available - const getGraphemes = (str: string): string[] => { - if ('Segmenter' in Intl) { - const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); - return Array.from(segmenter.segment(str), seg => seg.segment); - } - // Fallback to simple character array (won't handle complex emojis perfectly) - return Array.from(str); - }; - - // Convert between grapheme index and string index - const graphemeToStringIndex = (str: string, graphemeIndex: number): number => { - const graphemes = getGraphemes(str); - let stringIndex = 0; - for (let i = 0; i < Math.min(graphemeIndex, graphemes.length); i++) { - const grapheme = graphemes[i]; - if (grapheme) { - stringIndex += grapheme.length; - } - } - return stringIndex; - }; - - const stringToGraphemeIndex = (str: string, stringIndex: number): number => { - const graphemes = getGraphemes(str); - let currentStringIndex = 0; - for (let i = 0; i < graphemes.length; i++) { - if (currentStringIndex >= stringIndex) - return i; - const grapheme = graphemes[i]; - if (grapheme) { - currentStringIndex += grapheme.length; - } - } - return graphemes.length; - }; - - useInput((input, key) => { - if (key.return) { - onComplete({ ...widget, customText: text }); - } else if (key.escape) { - onCancel(); - } else if (key.leftArrow) { - const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); - if (currentGraphemeIndex > 0) { - const newStringIndex = graphemeToStringIndex(text, currentGraphemeIndex - 1); - setCursorPos(newStringIndex); - } - } else if (key.rightArrow) { - const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); - const graphemeCount = getGraphemes(text).length; - if (currentGraphemeIndex < graphemeCount) { - const newStringIndex = graphemeToStringIndex(text, currentGraphemeIndex + 1); - setCursorPos(newStringIndex); - } - } else if (key.ctrl && input === 'ArrowLeft') { - setCursorPos(0); - } else if (key.ctrl && input === 'ArrowRight') { - setCursorPos(text.length); - } else if (key.backspace) { - if (cursorPos > 0) { - const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); - if (currentGraphemeIndex > 0) { - const deleteFromIndex = graphemeToStringIndex(text, currentGraphemeIndex - 1); - const deleteToIndex = graphemeToStringIndex(text, currentGraphemeIndex); - setText(text.slice(0, deleteFromIndex) + text.slice(deleteToIndex)); - setCursorPos(deleteFromIndex); - } - } - } else if (key.delete) { - if (cursorPos < text.length) { - const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); - const graphemeCount = getGraphemes(text).length; - if (currentGraphemeIndex < graphemeCount) { - const deleteFromIndex = graphemeToStringIndex(text, currentGraphemeIndex); - const deleteToIndex = graphemeToStringIndex(text, currentGraphemeIndex + 1); - setText(text.slice(0, deleteFromIndex) + text.slice(deleteToIndex)); - } - } - } else if (shouldInsertInput(input, key)) { - // Insert the input at cursor position - const newText = text.slice(0, cursorPos) + input + text.slice(cursorPos); - setText(newText); - - // Move cursor by the actual string length of the input - // This handles multi-byte characters including emojis with modifiers - setCursorPos(cursorPos + input.length); - } - }); - - // Get the grapheme at cursor position for display - const graphemes = getGraphemes(text); - const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); - - // Build display with ANSI codes for cursor highlighting - let display = 'Enter custom text: '; - for (let i = 0; i < graphemes.length; i++) { - const grapheme = graphemes[i]; - if (grapheme) { - if (i === currentGraphemeIndex) { - // Use inverse video for cursor position - display += `\x1b[7m${grapheme}\x1b[0m`; - } else { - display += grapheme; - } - } - } - if (currentGraphemeIndex >= graphemes.length) { - // Cursor at end - display += '\x1b[7m \x1b[0m'; - } - - return ( - - {display} - ←→ move cursor, Ctrl+←→ jump to start/end, Enter save, ESC cancel - - ); -}; +const CustomTextEditor = lazyEditor(() => import('./editors/CustomTextEditor')); diff --git a/src/widgets/Link.tsx b/src/widgets/Link.tsx index 04d6b2560..63702ae2f 100644 --- a/src/widgets/Link.tsx +++ b/src/widgets/Link.tsx @@ -1,9 +1,4 @@ -import { - Box, - Text, - useInput -} from 'ink'; -import React, { useState } from 'react'; +import React from 'react'; import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; @@ -15,9 +10,10 @@ import type { WidgetItem } from '../types/Widget'; import { renderOsc8Link } from '../utils/hyperlink'; -import { shouldInsertInput } from '../utils/input-guards'; -function isValidHttpUrl(url: string): boolean { +import { lazyEditor } from './shared/lazy-editor'; + +export function isValidHttpUrl(url: string): boolean { try { const parsed = new URL(url); return parsed.protocol === 'http:' || parsed.protocol === 'https:'; @@ -26,40 +22,6 @@ function isValidHttpUrl(url: string): boolean { } } -function toEditorMetadata(widget: WidgetItem): { url: string; text: string } { - const url = widget.metadata?.url ?? ''; - const text = widget.metadata?.text ?? ''; - return { url, text }; -} - -function buildMetadata(widget: WidgetItem, urlValue: string, textValue: string): WidgetItem { - const metadata = { ...(widget.metadata ?? {}) }; - const trimmedUrl = urlValue.trim(); - const trimmedText = textValue.trim(); - - if (trimmedUrl.length > 0) { - metadata.url = trimmedUrl; - } else { - delete metadata.url; - } - - if (trimmedText.length > 0) { - metadata.text = trimmedText; - } else { - delete metadata.text; - } - - if (Object.keys(metadata).length === 0) { - const { metadata, ...rest } = widget; - return rest; - } - - return { - ...widget, - metadata - }; -} - function getLinkLabel(item: WidgetItem): { url: string; label: string } { const url = item.metadata?.url?.trim() ?? ''; const metadataText = item.metadata?.text?.trim(); @@ -123,91 +85,4 @@ export class LinkWidget implements Widget { } } -type LinkEditorMode = 'url' | 'text'; - -function getEditorMode(action?: string): LinkEditorMode { - if (action === 'edit-url') { - return 'url'; - } - return 'text'; -} - -const LinkEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { - const initial = toEditorMetadata(widget); - const mode = getEditorMode(action); - - const [urlInput, setUrlInput] = useState(initial.url); - const [urlCursorPos, setUrlCursorPos] = useState(initial.url.length); - const [textInput, setTextInput] = useState(initial.text); - const [textCursorPos, setTextCursorPos] = useState(initial.text.length); - - const isUrlMode = mode === 'url'; - const activeValue = isUrlMode ? urlInput : textInput; - const activeCursor = isUrlMode ? urlCursorPos : textCursorPos; - - const updateActiveValue = (value: string, cursor: number) => { - if (isUrlMode) { - setUrlInput(value); - setUrlCursorPos(cursor); - } else { - setTextInput(value); - setTextCursorPos(cursor); - } - }; - - useInput((input, key) => { - if (key.return) { - onComplete(buildMetadata(widget, urlInput, textInput)); - } else if (key.escape) { - onCancel(); - } else if (key.leftArrow) { - updateActiveValue(activeValue, Math.max(0, activeCursor - 1)); - } else if (key.rightArrow) { - updateActiveValue(activeValue, Math.min(activeValue.length, activeCursor + 1)); - } else if (key.backspace) { - if (activeCursor > 0) { - const value = activeValue.slice(0, activeCursor - 1) + activeValue.slice(activeCursor); - updateActiveValue(value, activeCursor - 1); - } - } else if (key.delete) { - if (activeCursor < activeValue.length) { - const value = activeValue.slice(0, activeCursor) + activeValue.slice(activeCursor + 1); - updateActiveValue(value, activeCursor); - } - } else if (shouldInsertInput(input, key)) { - const value = activeValue.slice(0, activeCursor) + input + activeValue.slice(activeCursor); - updateActiveValue(value, activeCursor + input.length); - } - }); - - const showInvalidUrlWarning = isUrlMode && urlInput.trim().length > 0 && !isValidHttpUrl(urlInput.trim()); - const prompt = isUrlMode ? 'Enter URL (http/https): ' : 'Enter link text (blank uses URL): '; - - return ( - - - {prompt} - {activeValue.slice(0, activeCursor)} - {activeValue[activeCursor] ?? ' '} - {activeValue.slice(activeCursor + 1)} - - {isUrlMode ? ( - - Current text: - {' '} - {textInput.trim() || '(uses URL)'} - - ) : ( - - Current URL: - {' '} - {urlInput.trim() || '(none)'} - - )} - {showInvalidUrlWarning && ( - URL must begin with http:// or https:// - )} - ←→ move cursor, Enter save, ESC cancel - - ); -}; +const LinkEditor = lazyEditor(() => import('./editors/LinkEditor')); diff --git a/src/widgets/Model.ts b/src/widgets/Model.ts index 7d6db0584..b1cb12324 100644 --- a/src/widgets/Model.ts +++ b/src/widgets/Model.ts @@ -6,6 +6,8 @@ import type { WidgetItem } from '../types/Widget'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + export class ModelWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Displays the Claude model name (e.g., Claude 3.5 Sonnet)'; } @@ -17,7 +19,7 @@ export class ModelWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return item.rawValue ? 'Claude' : 'Model: Claude'; + return formatRawOrLabeledValue(item, 'Model: ', 'Claude'); } const model = context.data?.model; @@ -27,7 +29,7 @@ export class ModelWidget implements Widget { if (modelDisplayName) { const shortName = modelDisplayName.replace(/\s*\(.*\)$/, ''); - return item.rawValue ? shortName : `Model: ${shortName}`; + return formatRawOrLabeledValue(item, 'Model: ', shortName); } return null; } diff --git a/src/widgets/SessionCost.ts b/src/widgets/SessionCost.ts index 07e336f00..b1002bfa2 100644 --- a/src/widgets/SessionCost.ts +++ b/src/widgets/SessionCost.ts @@ -12,6 +12,7 @@ import { } from '../utils/number-format'; import { isHidden } from './shared/hideable'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when cost is $0.00' }; @@ -31,8 +32,7 @@ export class SessionCostWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('cost', item, settings); if (context.isPreview) { - const value = formatCost(2.45, format); - return item.rawValue ? value : `Cost: ${value}`; + return formatRawOrLabeledValue(item, 'Cost: ', formatCost(2.45, format)); } const totalCost = context.data?.cost?.total_cost_usd; @@ -48,7 +48,7 @@ export class SessionCostWidget implements Widget { } const formattedCost = formatCost(totalCost, format); - return item.rawValue ? formattedCost : `Cost: ${formattedCost}`; + return formatRawOrLabeledValue(item, 'Cost: ', formattedCost); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/SessionName.ts b/src/widgets/SessionName.ts index f3bfb7e6d..17875ccde 100644 --- a/src/widgets/SessionName.ts +++ b/src/widgets/SessionName.ts @@ -1,19 +1,33 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; import { getTranscriptSessionName } from '../utils/jsonl-session'; +import { + MAX_WIDTH_ACTION, + applyMaxWidth, + getMaxWidthKeybind, + getMaxWidthModifier, + renderMaxWidthEditor +} from './shared/max-width'; + export class SessionNameWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows the session name set via /rename command in Claude Code'; } getDisplayName(): string { return 'Session Name'; } getCategory(): string { return 'Session'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { displayText: this.getDisplayName() }; + const maxWidthText = getMaxWidthModifier(item); + return { + displayText: this.getDisplayName(), + modifierText: maxWidthText ?? undefined + }; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { @@ -28,7 +42,18 @@ export class SessionNameWidget implements Widget { return null; } - return item.rawValue ? sessionName : `Session: ${sessionName}`; + return applyMaxWidth(item.rawValue ? sessionName : `Session: ${sessionName}`, item.maxWidth); + } + + getCustomKeybinds(): CustomKeybind[] { + return [getMaxWidthKeybind()]; + } + + renderEditor(props: WidgetEditorProps) { + if (props.action === MAX_WIDTH_ACTION) { + return renderMaxWidthEditor(props); + } + return null; } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/Skills.tsx b/src/widgets/Skills.tsx index 2d626a803..6bf629a98 100644 --- a/src/widgets/Skills.tsx +++ b/src/widgets/Skills.tsx @@ -1,9 +1,4 @@ -import { - Box, - Text, - useInput -} from 'ink'; -import React, { useState } from 'react'; +import React from 'react'; import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; @@ -16,21 +11,21 @@ import type { WidgetItem } from '../types/Widget'; import type { WidgetHookDef } from '../utils/hooks'; -import { shouldInsertInput } from '../utils/input-guards'; import { makeModifierText } from './shared/editor-display'; import { isHidden } from './shared/hideable'; +import { lazyEditor } from './shared/lazy-editor'; import { removeMetadataKeys } from './shared/metadata'; type Mode = 'current' | 'count' | 'list'; const MODES: Mode[] = ['current', 'count', 'list']; const MODE_LABELS: Record = { current: 'last used', count: 'total count', list: 'unique list' }; const LIST_LIMIT_KEY = 'listLimit'; -const EDIT_LIST_LIMIT_ACTION = 'edit-list-limit'; +export const EDIT_LIST_LIMIT_ACTION = 'edit-list-limit'; const EMPTY_HIDEABLE_STATE: HideableState = { key: 'empty', label: 'when no skills have been used' }; -function parseListLimit(item: WidgetItem): number { +export function parseListLimit(item: WidgetItem): number { const parsed = parseInt(item.metadata?.[LIST_LIMIT_KEY] ?? '0', 10); if (Number.isNaN(parsed) || parsed < 0) { return 0; @@ -38,7 +33,7 @@ function parseListLimit(item: WidgetItem): number { return parsed; } -function setListLimit(item: WidgetItem, limit: number): WidgetItem { +export function setListLimit(item: WidgetItem, limit: number): WidgetItem { if (limit <= 0) { const { [LIST_LIMIT_KEY]: removedLimit, ...restMetadata } = item.metadata ?? {}; return { @@ -164,39 +159,4 @@ export class SkillsWidget implements Widget { } } -const SkillsEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { - const [limitInput, setLimitInput] = useState(() => parseListLimit(widget).toString()); - - useInput((input, key) => { - if (action !== EDIT_LIST_LIMIT_ACTION) { - return; - } - - if (key.return) { - const parsed = parseInt(limitInput, 10); - const limit = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed; - onComplete(setListLimit(widget, limit)); - } else if (key.escape) { - onCancel(); - } else if (key.backspace) { - setLimitInput(limitInput.slice(0, -1)); - } else if (shouldInsertInput(input, key) && /\d/.test(input)) { - setLimitInput(limitInput + input); - } - }); - - if (action === EDIT_LIST_LIMIT_ACTION) { - return ( - - - Enter max skills to show (0 for unlimited): - {limitInput} - {' '} - - Press Enter to save, ESC to cancel - - ); - } - - return Unknown editor mode; -}; +const SkillsEditor = lazyEditor(() => import('./editors/SkillsEditor')); diff --git a/src/widgets/TokensLastTurn.ts b/src/widgets/TokensLastTurn.ts new file mode 100644 index 000000000..e9b0a6087 --- /dev/null +++ b/src/widgets/TokensLastTurn.ts @@ -0,0 +1,51 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + HideableState, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; +import { formatTokens } from '../utils/renderer'; + +import { isHidden } from './shared/hideable'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when token count is zero' }; + +export class TokensLastTurnWidget implements Widget { + getDefaultColor(): string { return 'magenta'; } + getDescription(): string { return 'Shows token usage (input + output + cache) for the most recent assistant message only, deduplicated per API call'; } + getDisplayName(): string { return 'Tokens Last Turn'; } + getCategory(): string { return 'Tokens'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + getHideableStates(): HideableState[] { + return [ZERO_HIDEABLE_STATE]; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('token', item, settings); + if (context.isPreview) { + return formatRawOrLabeledValue(item, 'Turn: ', formatTokens(2900, format)); + } + + const lastTurnTokens = context.tokenMetrics?.lastTurnTokens; + if (!lastTurnTokens) { + return null; + } + + if (lastTurnTokens.totalTokens === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { + return null; + } + + return formatRawOrLabeledValue(item, 'Turn: ', formatTokens(lastTurnTokens.totalTokens, format)); + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } +} diff --git a/src/widgets/__tests__/ContextBar.test.ts b/src/widgets/__tests__/ContextBar.test.ts index f8d09ad4a..3f117a223 100644 --- a/src/widgets/__tests__/ContextBar.test.ts +++ b/src/widgets/__tests__/ContextBar.test.ts @@ -44,6 +44,27 @@ describe('ContextBarWidget', () => { expect(widget.render({ id: 'ctx', type: 'context-bar' }, context, DEFAULT_SETTINGS)).toBe('Context: [bar:15.0:16] 30k/200k (15%)'); }); + it('shortens the Context label to Ctx when the compact preset is on', () => { + const context: RenderContext = { + data: { + context_window: { + context_window_size: 200000, + current_usage: { + input_tokens: 20000, + output_tokens: 10000, + cache_creation_input_tokens: 5000, + cache_read_input_tokens: 5000 + } + } + } + }; + const widget = new ContextBarWidget(); + const item = { id: 'ctx', type: 'context-bar', metadata: { compactLabel: 'true' } }; + + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('Ctx: [bar:15.0:16] 30k/200k (15%)'); + expect(widget.render({ ...item, rawValue: true }, context, DEFAULT_SETTINGS)).toBe('[bar:15.0:16] 30k/200k (15%)'); + }); + it('falls back to token metrics and model context size', () => { const context: RenderContext = { data: { model: { id: 'claude-3-5-sonnet-20241022' } }, diff --git a/src/widgets/__tests__/CurrentWorkingDir.test.ts b/src/widgets/__tests__/CurrentWorkingDir.test.ts index cb5acbd33..27ce7283b 100644 --- a/src/widgets/__tests__/CurrentWorkingDir.test.ts +++ b/src/widgets/__tests__/CurrentWorkingDir.test.ts @@ -46,6 +46,7 @@ describe('CurrentWorkingDirWidget', () => { terminalWidthCacheTtlSeconds: 5, customCommandCacheTtlSeconds: 5, minimalistMode: false, + compactLabels: false, powerline: { enabled: false, separators: [], diff --git a/src/widgets/__tests__/CustomCommand.test.ts b/src/widgets/__tests__/CustomCommand.test.ts index 35c9ed91b..841fac97e 100644 --- a/src/widgets/__tests__/CustomCommand.test.ts +++ b/src/widgets/__tests__/CustomCommand.test.ts @@ -77,6 +77,7 @@ describe('CustomCommandWidget', () => { terminalWidthCacheTtlSeconds: 5, customCommandCacheTtlSeconds: 0, minimalistMode: false, + compactLabels: false, powerline: { enabled: false, separators: [], diff --git a/src/widgets/__tests__/Model.test.ts b/src/widgets/__tests__/Model.test.ts index 4ca4d4d44..daef0a1e6 100644 --- a/src/widgets/__tests__/Model.test.ts +++ b/src/widgets/__tests__/Model.test.ts @@ -45,6 +45,13 @@ describe('ModelWidget', () => { expect(new ModelWidget().render(ITEM, ctx, DEFAULT_SETTINGS)).toBe('Model: Opus 4.6'); }); + it('shortens the label to the compact preset when enabled', () => { + const ctx = makeContext({ data: { model: { id: 'claude-opus-4-6[1m]', display_name: 'Opus 4.6' } } }); + const compactItem: WidgetItem = { ...ITEM, metadata: { compactLabel: 'true' } }; + expect(new ModelWidget().render(compactItem, ctx, DEFAULT_SETTINGS)).toBe('M: Opus 4.6'); + expect(new ModelWidget().render({ ...compactItem, rawValue: true }, ctx, DEFAULT_SETTINGS)).toBe('Opus 4.6'); + }); + it('returns null when model is absent', () => { const ctx = makeContext({ data: {} }); expect(new ModelWidget().render(ITEM, ctx, DEFAULT_SETTINGS)).toBeNull(); diff --git a/src/widgets/__tests__/SessionCost.test.ts b/src/widgets/__tests__/SessionCost.test.ts index f5bfd3866..dd5be1744 100644 --- a/src/widgets/__tests__/SessionCost.test.ts +++ b/src/widgets/__tests__/SessionCost.test.ts @@ -36,6 +36,12 @@ describe('SessionCostWidget', () => { }, { isPreview: true })).toBe('Cost: $2'); }); + it('compact labels render the $ glyph prefix', () => { + const item = { id: 'session-cost', type: 'session-cost', metadata: { compactLabel: 'true' } }; + expect(render(item, { data: { cost: { total_cost_usd: 2.456 } } })).toBe('$2.46'); + expect(render(item, { isPreview: true })).toBe('$2.45'); + }); + it('declares the zero hideable state', () => { expect(new SessionCostWidget().getHideableStates().map(state => state.key)).toEqual(['zero']); }); diff --git a/src/widgets/__tests__/TokensLastTurn.test.ts b/src/widgets/__tests__/TokensLastTurn.test.ts new file mode 100644 index 000000000..8ff6b6905 --- /dev/null +++ b/src/widgets/__tests__/TokensLastTurn.test.ts @@ -0,0 +1,142 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import * as renderer from '../../utils/renderer'; + +describe('TokensLastTurn widget', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(renderer, 'formatTokens').mockImplementation((value: number) => `fmt:${value}`); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders the deduplicated last-turn total with a Turn label', async () => { + const { TokensLastTurnWidget } = await import('../TokensLastTurn'); + const context: RenderContext = { + tokenMetrics: { + inputTokens: 99999, + outputTokens: 99999, + cachedTokens: 99999, + totalTokens: 99999, + contextLength: 99999, + lastTurnTokens: { + inputTokens: 100, + outputTokens: 250, + cachedTokens: 4000, + totalTokens: 4350 + } + } + }; + + expect(new TokensLastTurnWidget().render({ id: 'last', type: 'tokens-last-turn' }, context, DEFAULT_SETTINGS)).toBe('Turn: fmt:4350'); + }); + + it('renders null when no last-turn data was collected', async () => { + const { TokensLastTurnWidget } = await import('../TokensLastTurn'); + const context: RenderContext = { + tokenMetrics: { + inputTokens: 999, + outputTokens: 999, + cachedTokens: 999, + totalTokens: 999, + contextLength: 999 + } + }; + + // RenderContext without tokenMetrics (live status JSON only) hides it too. + expect(new TokensLastTurnWidget().render({ id: 'last', type: 'tokens-last-turn' }, context, DEFAULT_SETTINGS)).toBeNull(); + expect(new TokensLastTurnWidget().render({ id: 'last', type: 'tokens-last-turn' }, {}, DEFAULT_SETTINGS)).toBeNull(); + }); + + it('renders raw values without the label', async () => { + const { TokensLastTurnWidget } = await import('../TokensLastTurn'); + const context: RenderContext = { + tokenMetrics: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength: 0, + lastTurnTokens: { + inputTokens: 12, + outputTokens: 34, + cachedTokens: 56, + totalTokens: 102 + } + } + }; + + expect(new TokensLastTurnWidget().render({ id: 'last', type: 'tokens-last-turn', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('fmt:102'); + }); + + it('hides zero counts only when the zero hide state is enabled', async () => { + const { TokensLastTurnWidget } = await import('../TokensLastTurn'); + const context: RenderContext = { + tokenMetrics: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength: 0, + lastTurnTokens: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0 + } + } + }; + + expect(new TokensLastTurnWidget().render({ id: 'last', type: 'tokens-last-turn' }, context, DEFAULT_SETTINGS)).toBe('Turn: fmt:0'); + expect(new TokensLastTurnWidget().render({ id: 'last', type: 'tokens-last-turn', metadata: { hide: 'zero' } }, context, DEFAULT_SETTINGS)).toBeNull(); + }); + + it('passes the number format through to formatTokens', async () => { + const { TokensLastTurnWidget } = await import('../TokensLastTurn'); + const context: RenderContext = { + tokenMetrics: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength: 0, + lastTurnTokens: { + inputTokens: 1, + outputTokens: 2, + cachedTokens: 3, + totalTokens: 6 + } + } + }; + const numberFormat = { style: 'compact' as const }; + + new TokensLastTurnWidget().render({ id: 'last', type: 'tokens-last-turn', numberFormat }, context, DEFAULT_SETTINGS); + + expect(renderer.formatTokens).toHaveBeenCalledWith(6, numberFormat); + }); + + it('renders preview labels and raw values', async () => { + const { TokensLastTurnWidget } = await import('../TokensLastTurn'); + const context: RenderContext = { isPreview: true }; + + expect(new TokensLastTurnWidget().render({ id: 'last', type: 'tokens-last-turn' }, context, DEFAULT_SETTINGS)).toBe('Turn: fmt:2900'); + expect(new TokensLastTurnWidget().render({ id: 'last', type: 'tokens-last-turn', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('fmt:2900'); + }); + + it('declares the zero hideable state', async () => { + const { TokensLastTurnWidget } = await import('../TokensLastTurn'); + + expect(new TokensLastTurnWidget().getHideableStates().map(state => state.key)).toEqual(['zero']); + }); +}); diff --git a/src/widgets/editors/CurrentWorkingDirEditor.tsx b/src/widgets/editors/CurrentWorkingDirEditor.tsx new file mode 100644 index 000000000..1f554373f --- /dev/null +++ b/src/widgets/editors/CurrentWorkingDirEditor.tsx @@ -0,0 +1,60 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { WidgetEditorProps } from '../../types/Widget'; +import { shouldInsertInput } from '../../utils/input-guards'; + +const CurrentWorkingDirEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { + const [segmentsInput, setSegmentsInput] = useState(widget.metadata?.segments ?? ''); + + useInput((input, key) => { + if (action === 'edit-segments') { + if (key.return) { + const segments = parseInt(segmentsInput, 10); + if (!isNaN(segments) && segments > 0) { + onComplete({ + ...widget, + metadata: { + ...widget.metadata, + segments: segments.toString() + } + }); + } else { + // Clear segments if blank or invalid + const { segments, ...restMetadata } = widget.metadata ?? {}; + onComplete({ + ...widget, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }); + } + } else if (key.escape) { + onCancel(); + } else if (key.backspace) { + setSegmentsInput(segmentsInput.slice(0, -1)); + } else if (shouldInsertInput(input, key) && /\d/.test(input)) { + setSegmentsInput(segmentsInput + input); + } + } + }); + + if (action === 'edit-segments') { + return ( + + + Enter number of segments to display (blank for full path): + {segmentsInput} + {' '} + + Press Enter to save, ESC to cancel + + ); + } + + return Unknown editor mode; +}; + +export default CurrentWorkingDirEditor; diff --git a/src/widgets/editors/CustomCommandEditor.tsx b/src/widgets/editors/CustomCommandEditor.tsx new file mode 100644 index 000000000..56823faeb --- /dev/null +++ b/src/widgets/editors/CustomCommandEditor.tsx @@ -0,0 +1,126 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { WidgetEditorProps } from '../../types/Widget'; +import { shouldInsertInput } from '../../utils/input-guards'; + +interface EditorMode { type: 'command' | 'width' | 'timeout' | null } + +const CustomCommandEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { + const getMode = (): EditorMode['type'] => { + switch (action) { + case 'edit-command': return 'command'; + case 'edit-width': return 'width'; + case 'edit-timeout': return 'timeout'; + default: return 'command'; + } + }; + const mode = getMode(); + const [commandInput, setCommandInput] = useState(widget.commandPath ?? ''); + const [commandCursorPos, setCommandCursorPos] = useState(commandInput.length); + const [widthInput, setWidthInput] = useState(widget.maxWidth?.toString() ?? ''); + const [timeoutInput, setTimeoutInput] = useState(widget.timeout?.toString() ?? '1000'); + + useInput((input, key) => { + if (mode === 'command') { + if (key.return) { + onComplete({ ...widget, commandPath: commandInput }); + } else if (key.escape) { + onCancel(); + } else if (key.leftArrow) { + setCommandCursorPos(Math.max(0, commandCursorPos - 1)); + } else if (key.rightArrow) { + setCommandCursorPos(Math.min(commandInput.length, commandCursorPos + 1)); + } else if (key.backspace) { + if (commandCursorPos > 0) { + setCommandInput(commandInput.slice(0, commandCursorPos - 1) + commandInput.slice(commandCursorPos)); + setCommandCursorPos(commandCursorPos - 1); + } + } else if (key.delete) { + if (commandCursorPos < commandInput.length) { + setCommandInput(commandInput.slice(0, commandCursorPos) + commandInput.slice(commandCursorPos + 1)); + } + } else if (shouldInsertInput(input, key)) { + setCommandInput(commandInput.slice(0, commandCursorPos) + input + commandInput.slice(commandCursorPos)); + setCommandCursorPos(commandCursorPos + input.length); + } + } else if (mode === 'width') { + if (key.return) { + const width = parseInt(widthInput, 10); + if (!isNaN(width) && width > 0) { + onComplete({ ...widget, maxWidth: width }); + } else { + const { maxWidth, ...rest } = widget; + onComplete(rest); + } + } else if (key.escape) { + onCancel(); + } else if (key.backspace) { + setWidthInput(widthInput.slice(0, -1)); + } else if (shouldInsertInput(input, key) && /\d/.test(input)) { + setWidthInput(widthInput + input); + } + } else if (mode === 'timeout') { + if (key.return) { + const timeout = parseInt(timeoutInput, 10); + if (!isNaN(timeout) && timeout > 0) { + onComplete({ ...widget, timeout }); + } else { + const { timeout, ...rest } = widget; + onComplete(rest); + } + } else if (key.escape) { + onCancel(); + } else if (key.backspace) { + setTimeoutInput(timeoutInput.slice(0, -1)); + } else if (shouldInsertInput(input, key) && /\d/.test(input)) { + setTimeoutInput(timeoutInput + input); + } + } + }); + + if (mode === 'command') { + return ( + + + Enter command path: + {' '} + {commandInput.slice(0, commandCursorPos)} + {commandInput[commandCursorPos] ?? ' '} + {commandInput.slice(commandCursorPos + 1)} + + ←→ move cursor, Enter save, ESC cancel + + ); + } else if (mode === 'width') { + return ( + + + Enter max width (blank for no limit): + {widthInput} + {' '} + + Press Enter to save, ESC to cancel + + ); + } else if (mode === 'timeout') { + return ( + + + Enter timeout in milliseconds (default 1000): + {timeoutInput} + {' '} + + Press Enter to save, ESC to cancel + + ); + } + + return Unknown editor mode; +}; + +export default CustomCommandEditor; diff --git a/src/widgets/editors/CustomSymbolEditor.tsx b/src/widgets/editors/CustomSymbolEditor.tsx new file mode 100644 index 000000000..2c2438dfa --- /dev/null +++ b/src/widgets/editors/CustomSymbolEditor.tsx @@ -0,0 +1,60 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { WidgetEditorProps } from '../../types/Widget'; +import { shouldInsertInput } from '../../utils/input-guards'; + +const CustomSymbolEditor: React.FC = ({ widget, onComplete, onCancel }) => { + const [symbol, setSymbol] = useState(widget.customSymbol ?? ''); + + // Helper to get grapheme segments if Intl.Segmenter is available + const getFirstGrapheme = (str: string): string => { + if (str.length === 0) { + return ''; + } + + if ('Segmenter' in Intl) { + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + const segments = Array.from(segmenter.segment(str)); + return segments[0]?.segment ?? ''; + } + + // Fallback: just take first character + return Array.from(str)[0] ?? ''; + }; + + useInput((input, key) => { + if (key.return) { + onComplete({ ...widget, customSymbol: symbol }); + } else if (key.escape) { + onCancel(); + } else if (key.backspace || key.delete) { + setSymbol(''); + } else if (shouldInsertInput(input, key)) { + // Take only the first grapheme (handles multi-byte emojis correctly) + const firstGrapheme = getFirstGrapheme(input); + setSymbol(firstGrapheme); + } + }); + + return ( + + + Enter custom symbol: + {' '} + {symbol ? ( + {symbol} + ) : ( + (empty) + )} + + Type any character or emoji, Backspace clear, Enter save, ESC cancel + + ); +}; + +export default CustomSymbolEditor; diff --git a/src/widgets/editors/CustomTextEditor.tsx b/src/widgets/editors/CustomTextEditor.tsx new file mode 100644 index 000000000..695c00af2 --- /dev/null +++ b/src/widgets/editors/CustomTextEditor.tsx @@ -0,0 +1,135 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { WidgetEditorProps } from '../../types/Widget'; +import { shouldInsertInput } from '../../utils/input-guards'; + +const CustomTextEditor: React.FC = ({ widget, onComplete, onCancel }) => { + const [text, setText] = useState(widget.customText ?? ''); + const [cursorPos, setCursorPos] = useState(text.length); + + // Helper to get grapheme segments if Intl.Segmenter is available + const getGraphemes = (str: string): string[] => { + if ('Segmenter' in Intl) { + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + return Array.from(segmenter.segment(str), seg => seg.segment); + } + // Fallback to simple character array (won't handle complex emojis perfectly) + return Array.from(str); + }; + + // Convert between grapheme index and string index + const graphemeToStringIndex = (str: string, graphemeIndex: number): number => { + const graphemes = getGraphemes(str); + let stringIndex = 0; + for (let i = 0; i < Math.min(graphemeIndex, graphemes.length); i++) { + const grapheme = graphemes[i]; + if (grapheme) { + stringIndex += grapheme.length; + } + } + return stringIndex; + }; + + const stringToGraphemeIndex = (str: string, stringIndex: number): number => { + const graphemes = getGraphemes(str); + let currentStringIndex = 0; + for (let i = 0; i < graphemes.length; i++) { + if (currentStringIndex >= stringIndex) + return i; + const grapheme = graphemes[i]; + if (grapheme) { + currentStringIndex += grapheme.length; + } + } + return graphemes.length; + }; + + useInput((input, key) => { + if (key.return) { + onComplete({ ...widget, customText: text }); + } else if (key.escape) { + onCancel(); + } else if (key.leftArrow) { + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + if (currentGraphemeIndex > 0) { + const newStringIndex = graphemeToStringIndex(text, currentGraphemeIndex - 1); + setCursorPos(newStringIndex); + } + } else if (key.rightArrow) { + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + const graphemeCount = getGraphemes(text).length; + if (currentGraphemeIndex < graphemeCount) { + const newStringIndex = graphemeToStringIndex(text, currentGraphemeIndex + 1); + setCursorPos(newStringIndex); + } + } else if (key.ctrl && input === 'ArrowLeft') { + setCursorPos(0); + } else if (key.ctrl && input === 'ArrowRight') { + setCursorPos(text.length); + } else if (key.backspace) { + if (cursorPos > 0) { + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + if (currentGraphemeIndex > 0) { + const deleteFromIndex = graphemeToStringIndex(text, currentGraphemeIndex - 1); + const deleteToIndex = graphemeToStringIndex(text, currentGraphemeIndex); + setText(text.slice(0, deleteFromIndex) + text.slice(deleteToIndex)); + setCursorPos(deleteFromIndex); + } + } + } else if (key.delete) { + if (cursorPos < text.length) { + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + const graphemeCount = getGraphemes(text).length; + if (currentGraphemeIndex < graphemeCount) { + const deleteFromIndex = graphemeToStringIndex(text, currentGraphemeIndex); + const deleteToIndex = graphemeToStringIndex(text, currentGraphemeIndex + 1); + setText(text.slice(0, deleteFromIndex) + text.slice(deleteToIndex)); + } + } + } else if (shouldInsertInput(input, key)) { + // Insert the input at cursor position + const newText = text.slice(0, cursorPos) + input + text.slice(cursorPos); + setText(newText); + + // Move cursor by the actual string length of the input + // This handles multi-byte characters including emojis with modifiers + setCursorPos(cursorPos + input.length); + } + }); + + // Get the grapheme at cursor position for display + const graphemes = getGraphemes(text); + const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); + + // Build display with ANSI codes for cursor highlighting + let display = 'Enter custom text: '; + for (let i = 0; i < graphemes.length; i++) { + const grapheme = graphemes[i]; + if (grapheme) { + if (i === currentGraphemeIndex) { + // Use inverse video for cursor position + display += `\x1b[7m${grapheme}\x1b[0m`; + } else { + display += grapheme; + } + } + } + if (currentGraphemeIndex >= graphemes.length) { + // Cursor at end + display += '\x1b[7m \x1b[0m'; + } + + return ( + + {display} + ←→ move cursor, Ctrl+←→ jump to start/end, Enter save, ESC cancel + + ); +}; + +export default CustomTextEditor; diff --git a/src/widgets/editors/LinkEditor.tsx b/src/widgets/editors/LinkEditor.tsx new file mode 100644 index 000000000..20b66301c --- /dev/null +++ b/src/widgets/editors/LinkEditor.tsx @@ -0,0 +1,138 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { + WidgetEditorProps, + WidgetItem +} from '../../types/Widget'; +import { shouldInsertInput } from '../../utils/input-guards'; +import { isValidHttpUrl } from '../Link'; + +function toEditorMetadata(widget: WidgetItem): { url: string; text: string } { + const url = widget.metadata?.url ?? ''; + const text = widget.metadata?.text ?? ''; + return { url, text }; +} + +function buildMetadata(widget: WidgetItem, urlValue: string, textValue: string): WidgetItem { + const metadata = { ...(widget.metadata ?? {}) }; + const trimmedUrl = urlValue.trim(); + const trimmedText = textValue.trim(); + + if (trimmedUrl.length > 0) { + metadata.url = trimmedUrl; + } else { + delete metadata.url; + } + + if (trimmedText.length > 0) { + metadata.text = trimmedText; + } else { + delete metadata.text; + } + + if (Object.keys(metadata).length === 0) { + const { metadata, ...rest } = widget; + return rest; + } + + return { + ...widget, + metadata + }; +} + +type LinkEditorMode = 'url' | 'text'; + +function getEditorMode(action?: string): LinkEditorMode { + if (action === 'edit-url') { + return 'url'; + } + return 'text'; +} + +const LinkEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { + const initial = toEditorMetadata(widget); + const mode = getEditorMode(action); + + const [urlInput, setUrlInput] = useState(initial.url); + const [urlCursorPos, setUrlCursorPos] = useState(initial.url.length); + const [textInput, setTextInput] = useState(initial.text); + const [textCursorPos, setTextCursorPos] = useState(initial.text.length); + + const isUrlMode = mode === 'url'; + const activeValue = isUrlMode ? urlInput : textInput; + const activeCursor = isUrlMode ? urlCursorPos : textCursorPos; + + const updateActiveValue = (value: string, cursor: number) => { + if (isUrlMode) { + setUrlInput(value); + setUrlCursorPos(cursor); + } else { + setTextInput(value); + setTextCursorPos(cursor); + } + }; + + useInput((input, key) => { + if (key.return) { + onComplete(buildMetadata(widget, urlInput, textInput)); + } else if (key.escape) { + onCancel(); + } else if (key.leftArrow) { + updateActiveValue(activeValue, Math.max(0, activeCursor - 1)); + } else if (key.rightArrow) { + updateActiveValue(activeValue, Math.min(activeValue.length, activeCursor + 1)); + } else if (key.backspace) { + if (activeCursor > 0) { + const value = activeValue.slice(0, activeCursor - 1) + activeValue.slice(activeCursor); + updateActiveValue(value, activeCursor - 1); + } + } else if (key.delete) { + if (activeCursor < activeValue.length) { + const value = activeValue.slice(0, activeCursor) + activeValue.slice(activeCursor + 1); + updateActiveValue(value, activeCursor); + } + } else if (shouldInsertInput(input, key)) { + const value = activeValue.slice(0, activeCursor) + input + activeValue.slice(activeCursor); + updateActiveValue(value, activeCursor + input.length); + } + }); + + const showInvalidUrlWarning = isUrlMode && urlInput.trim().length > 0 && !isValidHttpUrl(urlInput.trim()); + const prompt = isUrlMode ? 'Enter URL (http/https): ' : 'Enter link text (blank uses URL): '; + + return ( + + + {prompt} + {activeValue.slice(0, activeCursor)} + {activeValue[activeCursor] ?? ' '} + {activeValue.slice(activeCursor + 1)} + + {isUrlMode ? ( + + Current text: + {' '} + {textInput.trim() || '(uses URL)'} + + ) : ( + + Current URL: + {' '} + {urlInput.trim() || '(none)'} + + )} + {showInvalidUrlWarning && ( + URL must begin with http:// or https:// + )} + ←→ move cursor, Enter save, ESC cancel + + ); +}; + +export default LinkEditor; diff --git a/src/widgets/editors/MaxWidthEditor.tsx b/src/widgets/editors/MaxWidthEditor.tsx new file mode 100644 index 000000000..b19f43d7a --- /dev/null +++ b/src/widgets/editors/MaxWidthEditor.tsx @@ -0,0 +1,44 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { WidgetEditorProps } from '../../types/Widget'; +import { shouldInsertInput } from '../../utils/input-guards'; + +const MaxWidthEditor: React.FC = ({ widget, onComplete, onCancel }) => { + const [widthInput, setWidthInput] = useState(widget.maxWidth?.toString() ?? ''); + + useInput((input, key) => { + if (key.return) { + const width = parseInt(widthInput, 10); + if (!isNaN(width) && width > 0) { + onComplete({ ...widget, maxWidth: width }); + } else { + const { maxWidth, ...rest } = widget; + onComplete(rest); + } + } else if (key.escape) { + onCancel(); + } else if (key.backspace) { + setWidthInput(widthInput.slice(0, -1)); + } else if (shouldInsertInput(input, key) && /\d/.test(input)) { + setWidthInput(widthInput + input); + } + }); + + return ( + + + Enter max width (blank for no limit): + {widthInput} + {' '} + + Press Enter to save, ESC to cancel + + ); +}; + +export default MaxWidthEditor; diff --git a/src/widgets/editors/SkillsEditor.tsx b/src/widgets/editors/SkillsEditor.tsx new file mode 100644 index 000000000..bbf2ee3d0 --- /dev/null +++ b/src/widgets/editors/SkillsEditor.tsx @@ -0,0 +1,53 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { WidgetEditorProps } from '../../types/Widget'; +import { shouldInsertInput } from '../../utils/input-guards'; +import { + EDIT_LIST_LIMIT_ACTION, + parseListLimit, + setListLimit +} from '../Skills'; + +const SkillsEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { + const [limitInput, setLimitInput] = useState(() => parseListLimit(widget).toString()); + + useInput((input, key) => { + if (action !== EDIT_LIST_LIMIT_ACTION) { + return; + } + + if (key.return) { + const parsed = parseInt(limitInput, 10); + const limit = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed; + onComplete(setListLimit(widget, limit)); + } else if (key.escape) { + onCancel(); + } else if (key.backspace) { + setLimitInput(limitInput.slice(0, -1)); + } else if (shouldInsertInput(input, key) && /\d/.test(input)) { + setLimitInput(limitInput + input); + } + }); + + if (action === EDIT_LIST_LIMIT_ACTION) { + return ( + + + Enter max skills to show (0 for unlimited): + {limitInput} + {' '} + + Press Enter to save, ESC to cancel + + ); + } + + return Unknown editor mode; +}; + +export default SkillsEditor; diff --git a/src/widgets/editors/SpeedWindowEditor.tsx b/src/widgets/editors/SpeedWindowEditor.tsx new file mode 100644 index 000000000..f7bebf854 --- /dev/null +++ b/src/widgets/editors/SpeedWindowEditor.tsx @@ -0,0 +1,75 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { WidgetEditorProps } from '../../types/Widget'; +import { shouldInsertInput } from '../../utils/input-guards'; +import { + DEFAULT_SPEED_WINDOW_SECONDS, + MAX_SPEED_WINDOW_SECONDS, + MIN_SPEED_WINDOW_SECONDS, + getWidgetSpeedWindowSeconds, + withWidgetSpeedWindowSeconds +} from '../../utils/speed-window'; +import { WINDOW_EDITOR_ACTION } from '../shared/speed-widget'; + +const SpeedWindowEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { + const [windowInput, setWindowInput] = useState(getWidgetSpeedWindowSeconds(widget).toString()); + + useInput((input, key) => { + if (action !== WINDOW_EDITOR_ACTION) { + return; + } + + if (key.return) { + const parsedWindow = Number.parseInt(windowInput, 10); + const nextWindow = Number.isFinite(parsedWindow) + ? parsedWindow + : DEFAULT_SPEED_WINDOW_SECONDS; + + onComplete(withWidgetSpeedWindowSeconds(widget, nextWindow)); + return; + } + + if (key.escape) { + onCancel(); + return; + } + + if (key.backspace) { + setWindowInput(windowInput.slice(0, -1)); + return; + } + + if (shouldInsertInput(input, key) && /\d/.test(input)) { + setWindowInput(windowInput + input); + } + }); + + if (action !== WINDOW_EDITOR_ACTION) { + return Unknown editor mode; + } + + return ( + + + + Enter window in seconds ( + {MIN_SPEED_WINDOW_SECONDS} + - + {MAX_SPEED_WINDOW_SECONDS} + ): + {' '} + + {windowInput} + {' '} + + 0 disables window mode and averages the full session. Press Enter to save, ESC to cancel. + + ); +}; + +export default SpeedWindowEditor; diff --git a/src/widgets/editors/SymbolSlotsEditor.tsx b/src/widgets/editors/SymbolSlotsEditor.tsx new file mode 100644 index 000000000..5ed6bfad2 --- /dev/null +++ b/src/widgets/editors/SymbolSlotsEditor.tsx @@ -0,0 +1,97 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { WidgetEditorProps } from '../../types/Widget'; +import { getVisibleWidth } from '../../utils/ansi'; +import { shouldInsertInput } from '../../utils/input-guards'; +import { + getSlotSymbol, + setSlotSymbol, + type SymbolSlot +} from '../shared/symbol-override'; + +// Helper to get grapheme segments if Intl.Segmenter is available +function getFirstGrapheme(str: string): string { + if (str.length === 0) { + return ''; + } + + if ('Segmenter' in Intl) { + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + const segments = Array.from(segmenter.segment(str)); + return segments[0]?.segment ?? ''; + } + + // Fallback: just take first character + return Array.from(str)[0] ?? ''; +} + +const SymbolSlotsEditor: React.FC = ({ widget, slots, onComplete, onCancel }) => { + const [values, setValues] = useState(() => slots.map(slot => getSlotSymbol(widget, slot))); + const [selectedIndex, setSelectedIndex] = useState(0); + const labelWidth = Math.max(...slots.map(slot => getVisibleWidth(slot.label)), 0); + + useInput((input, key) => { + if (key.return) { + onComplete(slots.reduce((item, slot, index) => setSlotSymbol(item, slot, values[index] ?? ''), widget)); + } else if (key.escape) { + onCancel(); + } else if (key.upArrow && slots.length > 1) { + setSelectedIndex(selectedIndex - 1 < 0 ? slots.length - 1 : selectedIndex - 1); + } else if (key.downArrow && slots.length > 1) { + setSelectedIndex(selectedIndex + 1 > slots.length - 1 ? 0 : selectedIndex + 1); + } else if (key.tab) { + setValues(values.map((value, index) => ( + index === selectedIndex ? slots[selectedIndex]?.defaultSymbol ?? '' : value + ))); + } else if (key.backspace || key.delete) { + setValues(values.map((value, index) => (index === selectedIndex ? '' : value))); + } else if (shouldInsertInput(input, key)) { + // Take only the first grapheme (handles multi-byte emojis correctly) + const grapheme = getFirstGrapheme(input); + setValues(values.map((value, index) => (index === selectedIndex ? grapheme : value))); + } + }); + + return ( + + Glyphs + + {slots.length > 1 + ? '↑↓ row, type to set, Tab default, Backspace none, Enter save, ESC cancel' + : 'Type any character or emoji, Tab default, Backspace none, Enter save, ESC cancel'} + + + {slots.map((slot, index) => { + const isSelected = index === selectedIndex; + const value = values[index] ?? ''; + const labelPadding = ' '.repeat(Math.max(labelWidth - getVisibleWidth(slot.label), 0)); + return ( + + + + {isSelected ? 'β–Ά ' : ' '} + + + + {`${labelPadding}${slot.label}: `} + + {value ? ( + {value} + ) : ( + (none) + )} + {` (default: ${slot.defaultSymbol})`} + + ); + })} + + + ); +}; + +export default SymbolSlotsEditor; diff --git a/src/widgets/editors/UsageLocaleEditor.tsx b/src/widgets/editors/UsageLocaleEditor.tsx new file mode 100644 index 000000000..8b7525385 --- /dev/null +++ b/src/widgets/editors/UsageLocaleEditor.tsx @@ -0,0 +1,180 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { + useMemo, + useState +} from 'react'; + +import type { WidgetEditorProps } from '../../types/Widget'; +import { shouldInsertInput } from '../../utils/input-guards'; +import { + DEFAULT_RESET_LOCALE, + canonicalizeLocale, + filterLocaleOptions, + getLocaleMatchSegments, + getLocaleOptions, + type LocaleOption +} from '../../utils/locales'; +import { LOCALE_EDITOR_ACTION } from '../shared/locale-editor'; +import { + getUsageLocale, + setUsageLocale +} from '../shared/usage-display'; + +const MAX_VISIBLE_OPTIONS = 10; + +function getInitialSelectedIndex(options: LocaleOption[], currentLocale: string | undefined): number { + const selectedValue = currentLocale ? canonicalizeLocale(currentLocale) : DEFAULT_RESET_LOCALE; + const selectedIndex = options.findIndex(option => option.value === selectedValue); + return selectedIndex === -1 ? 0 : selectedIndex; +} + +function getVisibleRange(selectedIndex: number, totalOptions: number): { start: number; end: number } { + if (totalOptions <= MAX_VISIBLE_OPTIONS) { + return { start: 0, end: totalOptions }; + } + + const halfWindow = Math.floor(MAX_VISIBLE_OPTIONS / 2); + const maxStart = totalOptions - MAX_VISIBLE_OPTIONS; + const start = Math.min(Math.max(0, selectedIndex - halfWindow), maxStart); + return { start, end: start + MAX_VISIBLE_OPTIONS }; +} + +const UsageLocaleEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { + const currentLocale = getUsageLocale(widget); + const options = useMemo(() => getLocaleOptions(currentLocale), [currentLocale]); + const [query, setQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(() => getInitialSelectedIndex(options, currentLocale)); + + const filteredOptions = filterLocaleOptions(options, query); + const clampedSelectedIndex = filteredOptions.length === 0 + ? 0 + : Math.min(selectedIndex, filteredOptions.length - 1); + const selectedOption = filteredOptions[clampedSelectedIndex]; + const visibleRange = getVisibleRange(clampedSelectedIndex, filteredOptions.length); + const visibleOptions = filteredOptions.slice(visibleRange.start, visibleRange.end); + const currentLabel = currentLocale ?? DEFAULT_RESET_LOCALE; + + useInput((input, key) => { + if (action !== LOCALE_EDITOR_ACTION) { + return; + } + + if (key.return) { + if (selectedOption) { + onComplete(setUsageLocale(widget, selectedOption.value)); + } + return; + } + + if (key.escape) { + onCancel(); + return; + } + + if (key.upArrow || key.downArrow) { + if (filteredOptions.length === 0) { + return; + } + + setSelectedIndex((previous) => { + const current = Math.min(previous, filteredOptions.length - 1); + if (key.downArrow) { + return current + 1 > filteredOptions.length - 1 ? 0 : current + 1; + } + return current - 1 < 0 ? filteredOptions.length - 1 : current - 1; + }); + return; + } + + if (key.backspace || key.delete) { + setQuery(previous => previous.slice(0, -1)); + setSelectedIndex(0); + return; + } + + if (shouldInsertInput(input, key)) { + setQuery(previous => previous + input); + setSelectedIndex(0); + } + }); + + if (action !== LOCALE_EDITOR_ACTION) { + return Unknown editor mode; + } + + return ( + + + Locale + + {' '} + Current: + {' '} + {currentLabel} + + + + Search: + {query || '(none)'} + + Type to search, Up/Down select, Enter save, ESC cancel + + {filteredOptions.length === 0 ? ( + No locales match the search. + ) : ( + visibleOptions.map((option, visibleIndex) => { + const actualIndex = visibleRange.start + visibleIndex; + const isSelected = actualIndex === clampedSelectedIndex; + const segments = getLocaleMatchSegments(option.displayName, query); + + return ( + + + + {isSelected ? '> ' : ' '} + + + {segments.map((segment, index) => ( + + {segment.text} + + ))} + + {' '} + - + {' '} + {option.description} + + + ); + }) + )} + + {filteredOptions.length > MAX_VISIBLE_OPTIONS && ( + + + Showing + {' '} + {visibleRange.start + 1} + - + {visibleRange.end} + {' '} + of + {' '} + {filteredOptions.length} + + + )} + + ); +}; + +export default UsageLocaleEditor; diff --git a/src/widgets/editors/UsageTimezoneEditor.tsx b/src/widgets/editors/UsageTimezoneEditor.tsx new file mode 100644 index 000000000..19840f57e --- /dev/null +++ b/src/widgets/editors/UsageTimezoneEditor.tsx @@ -0,0 +1,178 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { + useMemo, + useState +} from 'react'; + +import type { WidgetEditorProps } from '../../types/Widget'; +import { shouldInsertInput } from '../../utils/input-guards'; +import { + filterTimezoneOptions, + getTimezoneMatchSegments, + getTimezoneOptions, + type TimezoneOption +} from '../../utils/timezones'; +import { TIMEZONE_EDITOR_ACTION } from '../shared/timezone-editor'; +import { + getUsageTimezone, + setUsageTimezone +} from '../shared/usage-display'; + +const MAX_VISIBLE_OPTIONS = 10; + +function getInitialSelectedIndex(options: TimezoneOption[], currentTimezone: string | undefined): number { + const selectedValue = currentTimezone ?? 'UTC'; + const selectedIndex = options.findIndex(option => option.value === selectedValue); + return selectedIndex === -1 ? 0 : selectedIndex; +} + +function getVisibleRange(selectedIndex: number, totalOptions: number): { start: number; end: number } { + if (totalOptions <= MAX_VISIBLE_OPTIONS) { + return { start: 0, end: totalOptions }; + } + + const halfWindow = Math.floor(MAX_VISIBLE_OPTIONS / 2); + const maxStart = totalOptions - MAX_VISIBLE_OPTIONS; + const start = Math.min(Math.max(0, selectedIndex - halfWindow), maxStart); + return { start, end: start + MAX_VISIBLE_OPTIONS }; +} + +const UsageTimezoneEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { + const currentTimezone = getUsageTimezone(widget); + const options = useMemo(() => getTimezoneOptions(currentTimezone), [currentTimezone]); + const [query, setQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(() => getInitialSelectedIndex(options, currentTimezone)); + + const filteredOptions = filterTimezoneOptions(options, query); + const clampedSelectedIndex = filteredOptions.length === 0 + ? 0 + : Math.min(selectedIndex, filteredOptions.length - 1); + const selectedOption = filteredOptions[clampedSelectedIndex]; + const visibleRange = getVisibleRange(clampedSelectedIndex, filteredOptions.length); + const visibleOptions = filteredOptions.slice(visibleRange.start, visibleRange.end); + const currentLabel = currentTimezone ?? 'UTC'; + + useInput((input, key) => { + if (action !== TIMEZONE_EDITOR_ACTION) { + return; + } + + if (key.return) { + if (selectedOption) { + onComplete(setUsageTimezone(widget, selectedOption.value)); + } + return; + } + + if (key.escape) { + onCancel(); + return; + } + + if (key.upArrow || key.downArrow) { + if (filteredOptions.length === 0) { + return; + } + + setSelectedIndex((previous) => { + const current = Math.min(previous, filteredOptions.length - 1); + if (key.downArrow) { + return current + 1 > filteredOptions.length - 1 ? 0 : current + 1; + } + return current - 1 < 0 ? filteredOptions.length - 1 : current - 1; + }); + return; + } + + if (key.backspace || key.delete) { + setQuery(previous => previous.slice(0, -1)); + setSelectedIndex(0); + return; + } + + if (shouldInsertInput(input, key)) { + setQuery(previous => previous + input); + setSelectedIndex(0); + } + }); + + if (action !== TIMEZONE_EDITOR_ACTION) { + return Unknown editor mode; + } + + return ( + + + Timezone + + {' '} + Current: + {' '} + {currentLabel} + + + + Search: + {query || '(none)'} + + Type to search, Up/Down select, Enter save, ESC cancel + + {filteredOptions.length === 0 ? ( + No timezones match the search. + ) : ( + visibleOptions.map((option, visibleIndex) => { + const actualIndex = visibleRange.start + visibleIndex; + const isSelected = actualIndex === clampedSelectedIndex; + const segments = getTimezoneMatchSegments(option.displayName, query); + + return ( + + + + {isSelected ? '> ' : ' '} + + + {segments.map((segment, index) => ( + + {segment.text} + + ))} + + {' '} + - + {' '} + {option.description} + + + ); + }) + )} + + {filteredOptions.length > MAX_VISIBLE_OPTIONS && ( + + + Showing + {' '} + {visibleRange.start + 1} + - + {visibleRange.end} + {' '} + of + {' '} + {filteredOptions.length} + + + )} + + ); +}; + +export default UsageTimezoneEditor; diff --git a/src/widgets/index.ts b/src/widgets/index.ts index c4113d0e3..a98885457 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -30,6 +30,7 @@ export { TokensInputWidget } from './TokensInput'; export { TokensOutputWidget } from './TokensOutput'; export { TokensCachedWidget } from './TokensCached'; export { TokensTotalWidget } from './TokensTotal'; +export { TokensLastTurnWidget } from './TokensLastTurn'; export { CacheHitRateWidget } from './CacheHitRate'; export { CacheReadWidget } from './CacheRead'; export { CacheWriteWidget } from './CacheWrite'; diff --git a/src/widgets/shared/__tests__/locale-editor.test.tsx b/src/widgets/shared/__tests__/locale-editor.test.tsx index 97f3efee8..01fe4277a 100644 --- a/src/widgets/shared/__tests__/locale-editor.test.tsx +++ b/src/widgets/shared/__tests__/locale-editor.test.tsx @@ -55,10 +55,16 @@ function createMockStdout(): CapturedWriteStream { }); } -function flushInk() { - return new Promise((resolve) => { - setTimeout(resolve, 25); - }); +async function flushInk(stdout?: CapturedWriteStream) { + // The first render loads the editor's lazy chunk. Wait for its content, + // not a fixed first-frame delay, before sending keyboard input. + for (let attempt = 0; attempt < 40; attempt++) { + await new Promise(resolve => setTimeout(resolve, 25)); + if (!stdout || getPlainOutput(stdout.getOutput()).includes('Locale')) { + return; + } + } + expect(getPlainOutput(stdout?.getOutput() ?? '')).toContain('Locale'); } function renderEditor(widget: WidgetItem, onComplete = vi.fn(), onCancel = vi.fn()) { @@ -109,7 +115,7 @@ describe('UsageLocaleEditor', () => { const rendered = renderEditor({ id: 'reset', type: 'reset-timer' }); try { - await flushInk(); + await flushInk(rendered.stdout); const output = getPlainOutput(rendered.stdout.getOutput()); expect(output).toMatch(/\n\nShowing \d+-\d+ of \d+/); @@ -122,7 +128,7 @@ describe('UsageLocaleEditor', () => { const rendered = renderEditor({ id: 'reset', type: 'reset-timer' }); try { - await flushInk(); + await flushInk(rendered.stdout); rendered.stdin.write('japan'); await flushInk(); @@ -146,7 +152,7 @@ describe('UsageLocaleEditor', () => { }); try { - await flushInk(); + await flushInk(rendered.stdout); rendered.stdin.write('en-us'); await flushInk(); rendered.stdin.write('\r'); @@ -168,7 +174,7 @@ describe('UsageLocaleEditor', () => { const rendered = renderEditor({ id: 'reset', type: 'reset-timer' }); try { - await flushInk(); + await flushInk(rendered.stdout); rendered.stdin.write('en-au'); await flushInk(); @@ -188,7 +194,7 @@ describe('UsageLocaleEditor', () => { const rendered = renderEditor({ id: 'reset', type: 'reset-timer' }); try { - await flushInk(); + await flushInk(rendered.stdout); rendered.stdin.write('\u001B'); await flushInk(); diff --git a/src/widgets/shared/__tests__/raw-or-labeled.test.ts b/src/widgets/shared/__tests__/raw-or-labeled.test.ts new file mode 100644 index 000000000..f1acf2466 --- /dev/null +++ b/src/widgets/shared/__tests__/raw-or-labeled.test.ts @@ -0,0 +1,117 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../../types/Settings'; +import type { WidgetItem } from '../../../types/Widget'; +import { + formatRawOrLabeledValue, + getCompactLabelKeybind, + getCompactLabelModifierText, + startsWithCompactLabel, + toggleCompactLabel, + withGlobalCompactLabels +} from '../raw-or-labeled'; + +const ITEM: WidgetItem = { id: '1', type: 'model' }; + +describe('formatRawOrLabeledValue', () => { + it('keeps the default label when compact labels are off', () => { + expect(formatRawOrLabeledValue(ITEM, 'Model: ', 'Opus')).toBe('Model: Opus'); + }); + + it('uses the compact preset when the item opts in', () => { + expect(formatRawOrLabeledValue(toggleCompactLabel(ITEM), 'Model: ', 'Opus')).toBe('M: Opus'); + }); + + it('maps Context to Ctx and Cost to the $ glyph', () => { + const compact = toggleCompactLabel(ITEM); + expect(formatRawOrLabeledValue(compact, 'Context: ', '[====] 25%')).toBe('Ctx: [====] 25%'); + expect(formatRawOrLabeledValue(compact, 'Cost: ', '$2.45')).toBe('$2.45'); + }); + + it('keeps labels without a preset unchanged in compact mode', () => { + expect(formatRawOrLabeledValue(toggleCompactLabel(ITEM), 'In: ', '15.2k')).toBe('In: 15.2k'); + }); + + it('rawValue wins over compact labels', () => { + expect(formatRawOrLabeledValue({ ...ITEM, rawValue: true, metadata: { compactLabel: 'true' } }, 'Model: ', 'Opus')).toBe('Opus'); + }); +}); + +describe('toggleCompactLabel', () => { + it('cycles inherit -> on -> forced off -> inherit without the global setting', () => { + const on = toggleCompactLabel(ITEM); + expect(on.metadata).toEqual({ compactLabel: 'true' }); + + const forcedOff = toggleCompactLabel(on); + expect(forcedOff.metadata).toEqual({ compactLabel: 'false' }); + + const inherit = toggleCompactLabel(forcedOff); + expect(inherit.metadata).toBeUndefined(); + expect(inherit).toEqual(ITEM); + }); + + it('starts from the effective state while the global setting is on', () => { + const settings = { ...DEFAULT_SETTINGS, compactLabels: true }; + // Inherited compact -> first press forces off instead of a no-op. + const forcedOff = toggleCompactLabel(ITEM, settings); + expect(forcedOff.metadata).toEqual({ compactLabel: 'false' }); + + // Second press drops the key and inherits the global setting again. + expect(toggleCompactLabel(forcedOff, settings).metadata).toBeUndefined(); + }); + + it('preserves sibling metadata', () => { + const item: WidgetItem = { id: '1', type: 'model', metadata: { hide: 'no-git' } }; + expect(toggleCompactLabel(item).metadata).toEqual({ hide: 'no-git', compactLabel: 'true' }); + expect(toggleCompactLabel(toggleCompactLabel(toggleCompactLabel(item))).metadata).toEqual({ hide: 'no-git' }); + }); +}); + +describe('withGlobalCompactLabels', () => { + it('is a no-op when the global setting is off', () => { + expect(withGlobalCompactLabels(ITEM, DEFAULT_SETTINGS)).toBe(ITEM); + }); + + it('injects the compact flag when the global setting is on', () => { + const settings = { ...DEFAULT_SETTINGS, compactLabels: true }; + expect(withGlobalCompactLabels(ITEM, settings).metadata).toEqual({ compactLabel: 'true' }); + }); + + it('lets an explicit per-widget choice win over the global setting', () => { + const settings = { ...DEFAULT_SETTINGS, compactLabels: true }; + const optedOut: WidgetItem = { id: '1', type: 'model', metadata: { compactLabel: 'false' } }; + expect(withGlobalCompactLabels(optedOut, settings)).toBe(optedOut); + }); + + it('does not touch raw-value items', () => { + const settings = { ...DEFAULT_SETTINGS, compactLabels: true }; + const raw: WidgetItem = { ...ITEM, rawValue: true }; + expect(withGlobalCompactLabels(raw, settings)).toBe(raw); + }); +}); + +describe('startsWithCompactLabel', () => { + it('matches labels that have presets and rejects the rest', () => { + expect(startsWithCompactLabel('Model: Opus')).toBe(true); + expect(startsWithCompactLabel('Context: [bar]')).toBe(true); + expect(startsWithCompactLabel('Cost: $1')).toBe(true); + expect(startsWithCompactLabel('In: 15k')).toBe(false); + expect(startsWithCompactLabel('')).toBe(false); + }); +}); + +describe('compact label keybind', () => { + it('uses a key no widget binds and reports the effective state', () => { + expect(getCompactLabelKeybind().key).toBe('j'); + const settings = { ...DEFAULT_SETTINGS, compactLabels: true }; + + expect(getCompactLabelModifierText(ITEM)).toBeUndefined(); + expect(getCompactLabelModifierText(ITEM, settings)).toBe('(compact label: on)'); + expect(getCompactLabelModifierText(toggleCompactLabel(ITEM))).toBe('(compact label)'); + expect(getCompactLabelModifierText(toggleCompactLabel(toggleCompactLabel(ITEM)), settings)).toBe('(compact label: off)'); + }); +}); diff --git a/src/widgets/shared/__tests__/symbol-override-editor.test.tsx b/src/widgets/shared/__tests__/symbol-override-editor.test.tsx index fdced6b56..f8849cd9f 100644 --- a/src/widgets/shared/__tests__/symbol-override-editor.test.tsx +++ b/src/widgets/shared/__tests__/symbol-override-editor.test.tsx @@ -53,10 +53,16 @@ function createMockStdout(): CapturedWriteStream { }); } -function flushInk() { - return new Promise((resolve) => { - setTimeout(resolve, 25); - }); +async function flushInk(stdout?: CapturedWriteStream) { + // The first render loads the editor's lazy chunk. Wait for its content, + // not a fixed first-frame delay, before sending keyboard input. + for (let attempt = 0; attempt < 40; attempt++) { + await new Promise(resolve => setTimeout(resolve, 25)); + if (!stdout || getPlainOutput(stdout.getOutput()).includes('Glyphs')) { + return; + } + } + expect(getPlainOutput(stdout?.getOutput() ?? '')).toContain('Glyphs'); } const gitStatusSlots: SymbolSlot[] = [ @@ -114,7 +120,7 @@ describe('SymbolSlotsEditor', () => { const rendered = renderEditor({ id: 'git-status', type: 'git-status' }); try { - await flushInk(); + await flushInk(rendered.stdout); const lines = getPlainOutput(rendered.stdout.getOutput()) .split('\n') @@ -136,7 +142,7 @@ describe('SymbolSlotsEditor', () => { }); try { - await flushInk(); + await flushInk(rendered.stdout); rendered.stdin.write('\t'); await flushInk(); rendered.stdin.write('\r'); diff --git a/src/widgets/shared/__tests__/timezone-editor.test.tsx b/src/widgets/shared/__tests__/timezone-editor.test.tsx index 8603742fa..e3bcb7092 100644 --- a/src/widgets/shared/__tests__/timezone-editor.test.tsx +++ b/src/widgets/shared/__tests__/timezone-editor.test.tsx @@ -54,10 +54,16 @@ function createMockStdout(): CapturedWriteStream { }); } -function flushInk() { - return new Promise((resolve) => { - setTimeout(resolve, 25); - }); +async function flushInk(stdout?: CapturedWriteStream) { + // The first render loads the editor's lazy chunk. Wait for its content, + // not a fixed first-frame delay, before sending keyboard input. + for (let attempt = 0; attempt < 40; attempt++) { + await new Promise(resolve => setTimeout(resolve, 25)); + if (!stdout || getPlainOutput(stdout.getOutput()).includes('IANA timezone')) { + return; + } + } + expect(getPlainOutput(stdout?.getOutput() ?? '')).toContain('IANA timezone'); } function renderEditor(widget: WidgetItem, onComplete = vi.fn(), onCancel = vi.fn()) { @@ -108,7 +114,7 @@ describe('UsageTimezoneEditor', () => { const rendered = renderEditor({ id: 'reset', type: 'reset-timer' }); try { - await flushInk(); + await flushInk(rendered.stdout); const output = getPlainOutput(rendered.stdout.getOutput()); expect(output).toMatch(/IANA timezone\n\nShowing \d+-\d+ of \d+/); @@ -125,7 +131,7 @@ describe('UsageTimezoneEditor', () => { const rendered = renderEditor({ id: 'reset', type: 'reset-timer' }); try { - await flushInk(); + await flushInk(rendered.stdout); rendered.stdin.write('tokyo'); await flushInk(); @@ -149,7 +155,7 @@ describe('UsageTimezoneEditor', () => { }); try { - await flushInk(); + await flushInk(rendered.stdout); rendered.stdin.write('utc'); await flushInk(); rendered.stdin.write('\r'); @@ -166,7 +172,7 @@ describe('UsageTimezoneEditor', () => { const rendered = renderEditor({ id: 'reset', type: 'reset-timer' }); try { - await flushInk(); + await flushInk(rendered.stdout); rendered.stdin.write('\u001B'); await flushInk(); diff --git a/src/widgets/shared/lazy-editor.tsx b/src/widgets/shared/lazy-editor.tsx new file mode 100644 index 000000000..09962fe12 --- /dev/null +++ b/src/widgets/shared/lazy-editor.tsx @@ -0,0 +1,17 @@ +import React, { + Suspense, + lazy +} from 'react'; + +// Widget instances are also constructed in piped mode. Defer Ink (including +// its input hook's reconciler/Yoga graph) until an editor is actually rendered. +export function lazyEditor(load: () => Promise<{ default: React.ComponentType }>): React.FC { + const Editor = lazy(load); + return function LazyEditor(props: Props) { + return ( + + + + ); + }; +} diff --git a/src/widgets/shared/locale-editor.tsx b/src/widgets/shared/locale-editor.tsx index 4552f3a11..92b429705 100644 --- a/src/widgets/shared/locale-editor.tsx +++ b/src/widgets/shared/locale-editor.tsx @@ -1,184 +1,13 @@ -import { - Box, - Text, - useInput -} from 'ink'; -import React, { - useMemo, - useState -} from 'react'; +import React from 'react'; import type { WidgetEditorProps } from '../../types/Widget'; -import { shouldInsertInput } from '../../utils/input-guards'; -import { - DEFAULT_RESET_LOCALE, - canonicalizeLocale, - filterLocaleOptions, - getLocaleMatchSegments, - getLocaleOptions, - type LocaleOption -} from '../../utils/locales'; -import { - getUsageLocale, - setUsageLocale -} from './usage-display'; +import { lazyEditor } from './lazy-editor'; export const LOCALE_EDITOR_ACTION = 'edit-locale'; -const MAX_VISIBLE_OPTIONS = 10; - -function getInitialSelectedIndex(options: LocaleOption[], currentLocale: string | undefined): number { - const selectedValue = currentLocale ? canonicalizeLocale(currentLocale) : DEFAULT_RESET_LOCALE; - const selectedIndex = options.findIndex(option => option.value === selectedValue); - return selectedIndex === -1 ? 0 : selectedIndex; -} - -function getVisibleRange(selectedIndex: number, totalOptions: number): { start: number; end: number } { - if (totalOptions <= MAX_VISIBLE_OPTIONS) { - return { start: 0, end: totalOptions }; - } - - const halfWindow = Math.floor(MAX_VISIBLE_OPTIONS / 2); - const maxStart = totalOptions - MAX_VISIBLE_OPTIONS; - const start = Math.min(Math.max(0, selectedIndex - halfWindow), maxStart); - return { start, end: start + MAX_VISIBLE_OPTIONS }; -} - export function renderUsageLocaleEditor(props: WidgetEditorProps): React.ReactElement { return ; } -export const UsageLocaleEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { - const currentLocale = getUsageLocale(widget); - const options = useMemo(() => getLocaleOptions(currentLocale), [currentLocale]); - const [query, setQuery] = useState(''); - const [selectedIndex, setSelectedIndex] = useState(() => getInitialSelectedIndex(options, currentLocale)); - - const filteredOptions = filterLocaleOptions(options, query); - const clampedSelectedIndex = filteredOptions.length === 0 - ? 0 - : Math.min(selectedIndex, filteredOptions.length - 1); - const selectedOption = filteredOptions[clampedSelectedIndex]; - const visibleRange = getVisibleRange(clampedSelectedIndex, filteredOptions.length); - const visibleOptions = filteredOptions.slice(visibleRange.start, visibleRange.end); - const currentLabel = currentLocale ?? DEFAULT_RESET_LOCALE; - - useInput((input, key) => { - if (action !== LOCALE_EDITOR_ACTION) { - return; - } - - if (key.return) { - if (selectedOption) { - onComplete(setUsageLocale(widget, selectedOption.value)); - } - return; - } - - if (key.escape) { - onCancel(); - return; - } - - if (key.upArrow || key.downArrow) { - if (filteredOptions.length === 0) { - return; - } - - setSelectedIndex((previous) => { - const current = Math.min(previous, filteredOptions.length - 1); - if (key.downArrow) { - return current + 1 > filteredOptions.length - 1 ? 0 : current + 1; - } - return current - 1 < 0 ? filteredOptions.length - 1 : current - 1; - }); - return; - } - - if (key.backspace || key.delete) { - setQuery(previous => previous.slice(0, -1)); - setSelectedIndex(0); - return; - } - - if (shouldInsertInput(input, key)) { - setQuery(previous => previous + input); - setSelectedIndex(0); - } - }); - - if (action !== LOCALE_EDITOR_ACTION) { - return Unknown editor mode; - } - - return ( - - - Locale - - {' '} - Current: - {' '} - {currentLabel} - - - - Search: - {query || '(none)'} - - Type to search, Up/Down select, Enter save, ESC cancel - - {filteredOptions.length === 0 ? ( - No locales match the search. - ) : ( - visibleOptions.map((option, visibleIndex) => { - const actualIndex = visibleRange.start + visibleIndex; - const isSelected = actualIndex === clampedSelectedIndex; - const segments = getLocaleMatchSegments(option.displayName, query); - - return ( - - - - {isSelected ? '> ' : ' '} - - - {segments.map((segment, index) => ( - - {segment.text} - - ))} - - {' '} - - - {' '} - {option.description} - - - ); - }) - )} - - {filteredOptions.length > MAX_VISIBLE_OPTIONS && ( - - - Showing - {' '} - {visibleRange.start + 1} - - - {visibleRange.end} - {' '} - of - {' '} - {filteredOptions.length} - - - )} - - ); -}; +export const UsageLocaleEditor = lazyEditor(() => import('../editors/UsageLocaleEditor')); diff --git a/src/widgets/shared/max-width.tsx b/src/widgets/shared/max-width.tsx index 0c132ec8c..3aa5e3cef 100644 --- a/src/widgets/shared/max-width.tsx +++ b/src/widgets/shared/max-width.tsx @@ -1,9 +1,4 @@ -import { - Box, - Text, - useInput -} from 'ink'; -import React, { useState } from 'react'; +import React from 'react'; import type { CustomKeybind, @@ -11,7 +6,8 @@ import type { WidgetItem } from '../../types/Widget'; import { truncateStyledText } from '../../utils/ansi'; -import { shouldInsertInput } from '../../utils/input-guards'; + +import { lazyEditor } from './lazy-editor'; export const MAX_WIDTH_ACTION = 'edit-max-width'; @@ -41,35 +37,4 @@ export function renderMaxWidthEditor(props: WidgetEditorProps): React.ReactEleme return ; } -const MaxWidthEditor: React.FC = ({ widget, onComplete, onCancel }) => { - const [widthInput, setWidthInput] = useState(widget.maxWidth?.toString() ?? ''); - - useInput((input, key) => { - if (key.return) { - const width = parseInt(widthInput, 10); - if (!isNaN(width) && width > 0) { - onComplete({ ...widget, maxWidth: width }); - } else { - const { maxWidth, ...rest } = widget; - onComplete(rest); - } - } else if (key.escape) { - onCancel(); - } else if (key.backspace) { - setWidthInput(widthInput.slice(0, -1)); - } else if (shouldInsertInput(input, key) && /\d/.test(input)) { - setWidthInput(widthInput + input); - } - }); - - return ( - - - Enter max width (blank for no limit): - {widthInput} - {' '} - - Press Enter to save, ESC to cancel - - ); -}; +const MaxWidthEditor = lazyEditor(() => import('../editors/MaxWidthEditor')); diff --git a/src/widgets/shared/raw-or-labeled.ts b/src/widgets/shared/raw-or-labeled.ts index 94d4d847c..bdb67e6e2 100644 --- a/src/widgets/shared/raw-or-labeled.ts +++ b/src/widgets/shared/raw-or-labeled.ts @@ -1,5 +1,123 @@ -import type { WidgetItem } from '../../types/Widget'; +import type { Settings } from '../../types/Settings'; +import type { + CustomKeybind, + WidgetItem +} from '../../types/Widget'; + +// Abbreviated label presets applied when compact labels are enabled. Keys are +// exact default label prefixes as passed to formatRawOrLabeledValue; labels +// without an entry keep their default form. An empty replacement strips the +// label (Cost: $2.46 -> $2.46 β€” the value's own $ is the glyph). Add entries +// here to cover more widgets β€” everything routed through the helper picks +// them up automatically. +const COMPACT_LABELS: Record = { + 'Model: ': 'M: ', + 'Context: ': 'Ctx: ', + 'Cost: ': '' +}; + +const COMPACT_LABEL_METADATA_KEY = 'compactLabel'; + +export const TOGGLE_COMPACT_LABEL_ACTION = 'toggle-compact-label'; + +// Whether visible text opens with a label that has a compact preset. Used by +// the widget registry probe that decides which widgets see the compact-label +// editor keybind. +export function startsWithCompactLabel(text: string): boolean { + return Object.keys(COMPACT_LABELS).some(prefix => text.startsWith(prefix)); +} + +const COMPACT_LABEL_KEYBIND: CustomKeybind = { + key: 'j', + label: '(j) compact label', + action: TOGGLE_COMPACT_LABEL_ACTION +}; + +export function isCompactLabelEnabled(item: WidgetItem): boolean { + return item.metadata?.[COMPACT_LABEL_METADATA_KEY] === 'true'; +} export function formatRawOrLabeledValue(item: WidgetItem, labelPrefix: string, value: string): string { - return item.rawValue ? value : `${labelPrefix}${value}`; + if (item.rawValue) { + return value; + } + if (isCompactLabelEnabled(item)) { + const compact = COMPACT_LABELS[labelPrefix]; + if (compact !== undefined) { + return `${compact}${value}`; + } + } + return `${labelPrefix}${value}`; +} + +// Cycle the per-widget compact-label override over the widget's EFFECTIVE +// state, so the toggle stays useful while the global setting is on: compact +// now (saved 'true', or inherited because the global flag is on) -> write an +// explicit 'false'; forced off -> drop the key and inherit the global setting +// again; otherwise -> write 'true'. Dropping the key keeps untouched items' +// metadata minimal. +export function toggleCompactLabel(item: WidgetItem, settings?: Settings): WidgetItem { + const saved = item.metadata?.[COMPACT_LABEL_METADATA_KEY]; + if (saved === 'true' || (saved === undefined && settings?.compactLabels === true)) { + return withCompactLabel(item, 'false'); + } + if (saved === 'false') { + const { [COMPACT_LABEL_METADATA_KEY]: removed, ...restMetadata } = item.metadata ?? {}; + return { + ...item, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }; + } + return withCompactLabel(item, 'true'); +} + +function withCompactLabel(item: WidgetItem, value: string): WidgetItem { + return { + ...item, + metadata: { + ...item.metadata, + [COMPACT_LABEL_METADATA_KEY]: value + } + }; +} + +export function getCompactLabelKeybind(): CustomKeybind { + return COMPACT_LABEL_KEYBIND; +} + +// Modifier text reflects the effective state, not just saved metadata, so a +// widget that renders compact via the global setting is still marked. +export function getCompactLabelModifierText(item: WidgetItem, settings?: Settings): string | undefined { + const saved = item.metadata?.[COMPACT_LABEL_METADATA_KEY]; + if (saved === 'true') { + return '(compact label)'; + } + if (saved === 'false') { + return '(compact label: off)'; + } + if (saved === undefined && settings?.compactLabels === true) { + return '(compact label: on)'; + } + return undefined; +} + +// Fold the global Compact Labels setting into the item's metadata right before +// rendering. An explicit per-widget choice wins: 'false' blocks the global +// override, 'true' enables it regardless of the setting. Injecting at render +// time keeps the stored settings untouched and works for every widget routed +// through formatRawOrLabeledValue. +export function withGlobalCompactLabels(widget: WidgetItem, settings: Settings): WidgetItem { + if (!settings.compactLabels || widget.rawValue) { + return widget; + } + if (widget.metadata?.[COMPACT_LABEL_METADATA_KEY] !== undefined) { + return widget; + } + return { + ...widget, + metadata: { + ...widget.metadata, + [COMPACT_LABEL_METADATA_KEY]: 'true' + } + }; } diff --git a/src/widgets/shared/speed-widget.tsx b/src/widgets/shared/speed-widget.tsx index 9373e3e42..4daed350f 100644 --- a/src/widgets/shared/speed-widget.tsx +++ b/src/widgets/shared/speed-widget.tsx @@ -1,9 +1,4 @@ -import { - Box, - Text, - useInput -} from 'ink'; -import React, { useState } from 'react'; +import React from 'react'; import type { RenderContext } from '../../types/RenderContext'; import type { Settings } from '../../types/Settings'; @@ -15,7 +10,6 @@ import type { WidgetEditorProps, WidgetItem } from '../../types/Widget'; -import { shouldInsertInput } from '../../utils/input-guards'; import { resolveNumberFormat } from '../../utils/number-format'; import { calculateInputSpeed, @@ -24,21 +18,18 @@ import { formatSpeed } from '../../utils/speed-metrics'; import { - DEFAULT_SPEED_WINDOW_SECONDS, - MAX_SPEED_WINDOW_SECONDS, - MIN_SPEED_WINDOW_SECONDS, getWidgetSpeedWindowSeconds, - isWidgetSpeedWindowEnabled, - withWidgetSpeedWindowSeconds + isWidgetSpeedWindowEnabled } from '../../utils/speed-window'; import { makeModifierText } from './editor-display'; import { isHidden } from './hideable'; +import { lazyEditor } from './lazy-editor'; import { formatRawOrLabeledValue } from './raw-or-labeled'; export type SpeedWidgetKind = 'input' | 'output' | 'total'; -const WINDOW_EDITOR_ACTION = 'edit-window'; +export const WINDOW_EDITOR_ACTION = 'edit-window'; const NO_DATA_HIDEABLE_STATE: HideableState = { key: 'no-data', label: 'when there is no speed data (β€”)' }; @@ -156,58 +147,4 @@ export function renderSpeedWidgetEditor(props: WidgetEditorProps): React.ReactEl return ; } -const SpeedWindowEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { - const [windowInput, setWindowInput] = useState(getWidgetSpeedWindowSeconds(widget).toString()); - - useInput((input, key) => { - if (action !== WINDOW_EDITOR_ACTION) { - return; - } - - if (key.return) { - const parsedWindow = Number.parseInt(windowInput, 10); - const nextWindow = Number.isFinite(parsedWindow) - ? parsedWindow - : DEFAULT_SPEED_WINDOW_SECONDS; - - onComplete(withWidgetSpeedWindowSeconds(widget, nextWindow)); - return; - } - - if (key.escape) { - onCancel(); - return; - } - - if (key.backspace) { - setWindowInput(windowInput.slice(0, -1)); - return; - } - - if (shouldInsertInput(input, key) && /\d/.test(input)) { - setWindowInput(windowInput + input); - } - }); - - if (action !== WINDOW_EDITOR_ACTION) { - return Unknown editor mode; - } - - return ( - - - - Enter window in seconds ( - {MIN_SPEED_WINDOW_SECONDS} - - - {MAX_SPEED_WINDOW_SECONDS} - ): - {' '} - - {windowInput} - {' '} - - 0 disables window mode and averages the full session. Press Enter to save, ESC to cancel. - - ); -}; +const SpeedWindowEditor = lazyEditor(() => import('../editors/SpeedWindowEditor')); diff --git a/src/widgets/shared/symbol-override.tsx b/src/widgets/shared/symbol-override.tsx index 9a7c4b2f9..ba44d6fd2 100644 --- a/src/widgets/shared/symbol-override.tsx +++ b/src/widgets/shared/symbol-override.tsx @@ -1,18 +1,12 @@ -import { - Box, - Text, - useInput -} from 'ink'; -import React, { useState } from 'react'; +import React from 'react'; import type { CustomKeybind, WidgetEditorProps, WidgetItem } from '../../types/Widget'; -import { getVisibleWidth } from '../../utils/ansi'; -import { shouldInsertInput } from '../../utils/input-guards'; +import { lazyEditor } from './lazy-editor'; import { removeMetadataKeys } from './metadata'; export const SYMBOL_OVERRIDE_ACTION = 'edit-symbol-override'; @@ -88,82 +82,4 @@ export function renderSymbolSlotsEditor(props: WidgetEditorProps, slots: SymbolS return ; } -// Helper to get grapheme segments if Intl.Segmenter is available -function getFirstGrapheme(str: string): string { - if (str.length === 0) { - return ''; - } - - if ('Segmenter' in Intl) { - const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); - const segments = Array.from(segmenter.segment(str)); - return segments[0]?.segment ?? ''; - } - - // Fallback: just take first character - return Array.from(str)[0] ?? ''; -} - -const SymbolSlotsEditor: React.FC = ({ widget, slots, onComplete, onCancel }) => { - const [values, setValues] = useState(() => slots.map(slot => getSlotSymbol(widget, slot))); - const [selectedIndex, setSelectedIndex] = useState(0); - const labelWidth = Math.max(...slots.map(slot => getVisibleWidth(slot.label)), 0); - - useInput((input, key) => { - if (key.return) { - onComplete(slots.reduce((item, slot, index) => setSlotSymbol(item, slot, values[index] ?? ''), widget)); - } else if (key.escape) { - onCancel(); - } else if (key.upArrow && slots.length > 1) { - setSelectedIndex(selectedIndex - 1 < 0 ? slots.length - 1 : selectedIndex - 1); - } else if (key.downArrow && slots.length > 1) { - setSelectedIndex(selectedIndex + 1 > slots.length - 1 ? 0 : selectedIndex + 1); - } else if (key.tab) { - setValues(values.map((value, index) => ( - index === selectedIndex ? slots[selectedIndex]?.defaultSymbol ?? '' : value - ))); - } else if (key.backspace || key.delete) { - setValues(values.map((value, index) => (index === selectedIndex ? '' : value))); - } else if (shouldInsertInput(input, key)) { - // Take only the first grapheme (handles multi-byte emojis correctly) - const grapheme = getFirstGrapheme(input); - setValues(values.map((value, index) => (index === selectedIndex ? grapheme : value))); - } - }); - - return ( - - Glyphs - - {slots.length > 1 - ? '↑↓ row, type to set, Tab default, Backspace none, Enter save, ESC cancel' - : 'Type any character or emoji, Tab default, Backspace none, Enter save, ESC cancel'} - - - {slots.map((slot, index) => { - const isSelected = index === selectedIndex; - const value = values[index] ?? ''; - const labelPadding = ' '.repeat(Math.max(labelWidth - getVisibleWidth(slot.label), 0)); - return ( - - - - {isSelected ? 'β–Ά ' : ' '} - - - - {`${labelPadding}${slot.label}: `} - - {value ? ( - {value} - ) : ( - (none) - )} - {` (default: ${slot.defaultSymbol})`} - - ); - })} - - - ); -}; +const SymbolSlotsEditor = lazyEditor(() => import('../editors/SymbolSlotsEditor')); diff --git a/src/widgets/shared/timezone-editor.tsx b/src/widgets/shared/timezone-editor.tsx index 0de981da8..096d4e57c 100644 --- a/src/widgets/shared/timezone-editor.tsx +++ b/src/widgets/shared/timezone-editor.tsx @@ -1,182 +1,13 @@ -import { - Box, - Text, - useInput -} from 'ink'; -import React, { - useMemo, - useState -} from 'react'; +import React from 'react'; import type { WidgetEditorProps } from '../../types/Widget'; -import { shouldInsertInput } from '../../utils/input-guards'; -import { - filterTimezoneOptions, - getTimezoneMatchSegments, - getTimezoneOptions, - type TimezoneOption -} from '../../utils/timezones'; -import { - getUsageTimezone, - setUsageTimezone -} from './usage-display'; +import { lazyEditor } from './lazy-editor'; export const TIMEZONE_EDITOR_ACTION = 'edit-timezone'; -const MAX_VISIBLE_OPTIONS = 10; - -function getInitialSelectedIndex(options: TimezoneOption[], currentTimezone: string | undefined): number { - const selectedValue = currentTimezone ?? 'UTC'; - const selectedIndex = options.findIndex(option => option.value === selectedValue); - return selectedIndex === -1 ? 0 : selectedIndex; -} - -function getVisibleRange(selectedIndex: number, totalOptions: number): { start: number; end: number } { - if (totalOptions <= MAX_VISIBLE_OPTIONS) { - return { start: 0, end: totalOptions }; - } - - const halfWindow = Math.floor(MAX_VISIBLE_OPTIONS / 2); - const maxStart = totalOptions - MAX_VISIBLE_OPTIONS; - const start = Math.min(Math.max(0, selectedIndex - halfWindow), maxStart); - return { start, end: start + MAX_VISIBLE_OPTIONS }; -} - export function renderUsageTimezoneEditor(props: WidgetEditorProps): React.ReactElement { return ; } -export const UsageTimezoneEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { - const currentTimezone = getUsageTimezone(widget); - const options = useMemo(() => getTimezoneOptions(currentTimezone), [currentTimezone]); - const [query, setQuery] = useState(''); - const [selectedIndex, setSelectedIndex] = useState(() => getInitialSelectedIndex(options, currentTimezone)); - - const filteredOptions = filterTimezoneOptions(options, query); - const clampedSelectedIndex = filteredOptions.length === 0 - ? 0 - : Math.min(selectedIndex, filteredOptions.length - 1); - const selectedOption = filteredOptions[clampedSelectedIndex]; - const visibleRange = getVisibleRange(clampedSelectedIndex, filteredOptions.length); - const visibleOptions = filteredOptions.slice(visibleRange.start, visibleRange.end); - const currentLabel = currentTimezone ?? 'UTC'; - - useInput((input, key) => { - if (action !== TIMEZONE_EDITOR_ACTION) { - return; - } - - if (key.return) { - if (selectedOption) { - onComplete(setUsageTimezone(widget, selectedOption.value)); - } - return; - } - - if (key.escape) { - onCancel(); - return; - } - - if (key.upArrow || key.downArrow) { - if (filteredOptions.length === 0) { - return; - } - - setSelectedIndex((previous) => { - const current = Math.min(previous, filteredOptions.length - 1); - if (key.downArrow) { - return current + 1 > filteredOptions.length - 1 ? 0 : current + 1; - } - return current - 1 < 0 ? filteredOptions.length - 1 : current - 1; - }); - return; - } - - if (key.backspace || key.delete) { - setQuery(previous => previous.slice(0, -1)); - setSelectedIndex(0); - return; - } - - if (shouldInsertInput(input, key)) { - setQuery(previous => previous + input); - setSelectedIndex(0); - } - }); - - if (action !== TIMEZONE_EDITOR_ACTION) { - return Unknown editor mode; - } - - return ( - - - Timezone - - {' '} - Current: - {' '} - {currentLabel} - - - - Search: - {query || '(none)'} - - Type to search, Up/Down select, Enter save, ESC cancel - - {filteredOptions.length === 0 ? ( - No timezones match the search. - ) : ( - visibleOptions.map((option, visibleIndex) => { - const actualIndex = visibleRange.start + visibleIndex; - const isSelected = actualIndex === clampedSelectedIndex; - const segments = getTimezoneMatchSegments(option.displayName, query); - - return ( - - - - {isSelected ? '> ' : ' '} - - - {segments.map((segment, index) => ( - - {segment.text} - - ))} - - {' '} - - - {' '} - {option.description} - - - ); - }) - )} - - {filteredOptions.length > MAX_VISIBLE_OPTIONS && ( - - - Showing - {' '} - {visibleRange.start + 1} - - - {visibleRange.end} - {' '} - of - {' '} - {filteredOptions.length} - - - )} - - ); -}; +export const UsageTimezoneEditor = lazyEditor(() => import('../editors/UsageTimezoneEditor'));