Compare commits

..
Author SHA1 Message Date
Codeman maintainer edeaa15986 feat(terminal): configurable normal and bold font weight (#403)
Bold text on the theme's default foreground carries exactly ONE cue, the
weight step. Claude Code marks its markdown bold with a bare ESC[1m and
changes no colour, and xterm substitutes a bright colour for bold only
when the foreground is a palette index 0-7, so the substitution never
fires for default-foreground text. A family shipping only a regular and
a bold face keeps that step small (measured on Consolas: glyph ink rises
from 14.25% to 16.57%), and picking a different family does not help,
because 400 stays 400 whatever the family. Lowering the NORMAL weight is
the only way to widen the gap.

Two per-device settings beside "Terminal font" in the Font group, each
defaulting to xterm's own value for its slot, so an untouched install
renders exactly as it did before. Both thread into the main terminal and
the Agent Teams panes, and apply on save without a reload.

The bundled face had to be unclamped in the same change or the settings
would look broken on a stock install. fonts/jetbrains-mono-variable.woff2
carries a wght axis of 100 to 800, but styles.css declared the face
`400 700`, and the descriptor is what the browser synthesizes from: at
that range 100, 200 and 300 rendered identically to 400 and 800
identically to 700 (measured in headless Chromium, both directions).
The two families ahead of it in the default stack, Fira Code and Cascadia
Code, exist only if the user installed them, so for most installs
"normal = 300" would have been a no-op. Declared `100 800`, every step is
distinct: 61%, 77% and 90% of the ink at 400, and 800 adds ~14% over 700.
Nothing in the stylesheets asks for a monospace weight outside 400-700,
so widening it changes nothing that rendered before.

Details that are easy to get wrong and are pinned by tests:

- Each slot falls back to its OWN xterm default, so an unset bold weight
  can never inherit `normal` and become a visible change.
- A live save refreshes both echo overlays. They cache
  terminal.options.fontWeight and paint it into their spans, so without
  it the characters being typed keep the old weight while the rest of the
  screen changes. Most visible on a phone, where local echo is on by
  default.
- A live save reaches open Agent Teams panes, which read their options at
  construction, exactly as applyTerminalSkin() propagates its own.
- A stored weight the picker does not list (a hand-set 350) is added to
  the select rather than dropped, so merely opening App Settings cannot
  reset it.
- _awaitTerminalFont() is untouched. CharSizeService measures through the
  CSS `font` shorthand, which resets the weight, so the measured face is
  always the 400 one and a weighted descriptor would request nothing new.

Verified end to end in a headless browser against a live server: the save
reaches the running terminal with no reload, the settings PUT stays 200
(both keys are display keys and are stripped before it, since
SettingsUpdateSchema is strict), the value survives a reload, and the
painted terminal really changes weight with the bundled font (lit-pixel
ink 0.83 / 0.95 / 1.00 / 1.13 / 1.21 at 100 / 300 / default / 700 / 800).

Proposed and analysed by @irisitymichaelgrundberg in discussion #403.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 14:09:13 +02:00
Codeman maintainer d2ff1814ed docs: close the last two Thanks gaps, 1.23.0 and 1.22.0
Auditing every release after the previous backfill turned up two more. 1.23.0
had no Thanks in either artifact; its three PRs (#337, #341, #338) are authored
by the maintainer, so like the others it credits the release it follows.
1.22.0 had the section on its GitHub release but never in CHANGELOG.md, which
is the drift that happens whenever the block is added post-hoc instead of in
the changeset.

Every release from 1.21.0 forward now carries a Thanks section in both places.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:45:42 +02:00
Codeman maintainer 9e2091255b docs: backfill Thanks sections for 1.26.0, 1.24.4 and 1.24.2
Those three shipped with maintainer-only commits and no Thanks section, on the
reasoning that a release with no contributor PRs has nobody to credit. That is
the wrong test: the newest tag is what GitHub marks Latest, so a contributor
who shipped in the release next door lands on a page acknowledging nobody.

Each now credits the release it follows and says so, rather than claiming work
its contributors did not do: 1.24.2 the hotfix on 1.24.1, 1.24.4 the same-day
follow-on to 1.24.3, 1.26.0 the day after 1.25.0. Wording is carried over
verbatim from those releases. The matching GitHub release bodies were edited to
match, since the two are separate artifacts once version-packages has run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:45:04 +02:00
Codeman maintainer 6030a520bd docs: add the Thanks section to the 1.28.1 changelog entry too
1.28.1 is a same-day follow-on to 1.28.0 and is the release people land on as
"Latest", so it credits the same three contributors rather than showing no
acknowledgement at all. Matches the section just added to its GitHub release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:43:40 +02:00
Codeman maintainer 465b842e97 docs: add the missing Thanks section to the 1.28.0 changelog entry
Every release credits its contributors in both places: a "### Thanks" block
and a comment on each merged PR. The PR comments went out, this did not.
Past releases carry it because the block was written INTO the changeset, which
is what feeds both CHANGELOG.md and the GitHub release body; mine went only on
the GitHub release, so the changelog was short a section. Put it in the
changeset next time rather than patching both by hand afterwards.

1.28.1 gets none on purpose: every commit in it is a maintainer commit, the
same call as 1.26.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:35:33 +02:00
26 changed files with 588 additions and 1289 deletions
@@ -1,28 +0,0 @@
---
"aicodeman": patch
---
fix(statusline): stop the plan-usage exporter from stealing the user's statusline (#405)
Claude Code ranks a repo's `.claude/settings.local.json` above `~/.claude/settings.json`, so
the statusLine Codeman injects for the Plan Usage chip shadowed whatever statusline the user
had configured globally, and running `claude` by hand in a managed repo rendered the bare word
`codeman`. The exporter is now a generated, delegating shim (`src/statusline-shim.ts`, the
`deepseek-status-shim` pattern): it forwards the same payload to `/api/status-telemetry` and,
concurrently, runs the statusline it shadows and prints that. Codeman's footer appears only when
there is nothing to shadow, and with neither the line stays blank. The delegate is resolved at
render time from the three settings files Claude Code documents (`workspace.project_dir` first,
then `~/.claude/settings.json`), never from an ancestor directory or a user-level
`settings.local.json`.
The injected command is a self-selecting shell guard that runs the shim where it exists and
falls through to the inline curl exporter where it does not, so the same bind-mounted
`settings.local.json` still reports telemetry from inside a Docker case's container. Ownership
accepts both the new `codeman-statusline-shim` token and the old `/api/status-telemetry`
command, so repos managed by an older Codeman upgrade in place. `POST /api/status-telemetry`
answers an unknown session with an empty body instead of `codeman`, and the session-status
footer is empty rather than a brand word when the payload carries nothing to show.
Turning the Plan Usage chip off now removes the exporter from the workspaces of your live Claude
sessions. The removal rides only the settings save that flips the chip off on a device, so a
phone whose chip was never on cannot strip the exporter a desktop depends on.
+45
View File
@@ -25,6 +25,14 @@
comma-grouped rather than wrapped in `:is()`, so each arm keeps its own (0,2,0)
specificity and `mobile.css`'s matching overrides still win on source order.
### Thanks
1.28.1 is a same-day follow-on to 1.28.0, so the thanks for this pair belong here too:
- **@shenlvkang-collab** for the path picker's typed-path jump and name/date sort (#399), and for the care in the edges: the retry is bounded to one parent level, a typo keeps the listing you had instead of resetting to the root, and a full file path lands in its folder with the entry already selected.
- **@irisitymichaelgrundberg** for Claude truecolor in panes (#409), and above all for flagging the one reading they could not prove: that suppressing truecolor may have made Claude's block collapse into the background rather than fixing anything. That paragraph is why this got measured instead of taken on trust, and the measurement changed the changelog.
- **@timkjr** for trapping Ctrl+Z in agent sessions (#404), for finding that Caps Lock flips `ev.key` to `'Z'` without setting `shiftKey` so a plain `=== 'z'` check misses exactly the keystroke the guard exists for, and for stating up front that an agent CLI already holds its tty with ISIG off rather than overselling the fix.
## 1.28.0
### Minor Changes
@@ -97,6 +105,11 @@
`terminal-overrides ",*:Tc"` on its own tmux server, so 24-bit color already reaches the
browser for the CLIs that ask for it.
### Thanks
- **@shenlvkang-collab** for the path picker's typed-path jump and name/date sort (#399), and for the care in the edges: the retry is bounded to one parent level, a typo keeps the listing you had instead of resetting to the root, and a full file path lands in its folder with the entry already selected.
- **@irisitymichaelgrundberg** for Claude truecolor in panes (#409), and above all for flagging the one reading they could not prove: that suppressing truecolor may have made Claude's block collapse into the background rather than fixing anything. That paragraph is why this got measured instead of taken on trust, and the measurement changed the changelog.
- **@timkjr** for trapping Ctrl+Z in agent sessions (#404), for finding that Caps Lock flips `ev.key` to `'Z'` without setting `shiftKey` so a plain `=== 'z'` check misses exactly the keystroke the guard exists for, and for stating up front that an agent CLI already holds its tty with ISIG off rather than overselling the fix.
## 1.27.0
### Minor Changes
@@ -193,6 +206,15 @@
and nothing ever deleted them (236 orphans on a working machine); the sweep keeps
every live session's file and only takes orphans older than seven days.
### Thanks
1.26.0 carries no contributor PRs of its own. It lands the day after 1.25.0, so the thanks for that pair belong here too:
- @mtiller for the reverse-proxy base URL (#381).
- @dignfei for attaching cases to running containers (#357).
- @shenlvkang-collab for the response viewer fix (#369), the first-hand conversation hook (#367) and the phone Add Case fix (#368).
- @opticon454 for the case picker default (#383).
## 1.25.0
### Minor Changes
@@ -286,6 +308,12 @@
case, which without the plugin falls back to the classic builder Docker has deprecated.
`docker-compose` is not copied; Codeman never shells out to it.
### Thanks
1.24.4 is a same-day follow-on to 1.24.3, so the thanks for that pair belong here too:
- @opticon454 for #349, and for a write-up that made an infrastructure PR quick to review
## 1.24.3
### Patch Changes
@@ -366,6 +394,13 @@
modules, handler counts, frontend module count and app.js size, install.sh size) and
documenting several subsystems that had no entry.
### Thanks
1.24.2 is a hotfix on top of 1.24.1, so the thanks for that pair belong here too:
- @opticon454 for #350, with a reproduction that made this a confirmation rather than a hunt
- @timkjr for reporting #352, and for finding it while verifying Docker support for someone else's PR
## 1.24.1
### Patch Changes
@@ -465,6 +500,12 @@
so cancelling a rename stored an EMPTY session name and the tab fell back to its
folder label. Escape now cancels without a request, in every layout.
### Thanks
1.23.0 carries no contributor PRs of its own. It lands the day after 1.22.0, so the thanks for that pair belong here too:
- **@aakhter** built both halves of the new tab experience: the owner-scoped, server-authoritative tab-layout foundation with recipient-safe SSE publication and an unusually deep test suite (#335), and the resizable vertical session rail with accessible pointer/keyboard sizing and careful FitAddon handoff (#334). Fifth and sixth merged PRs, and the layout work also fixed real multi-user ordering leaks along the way.
## 1.22.0
### Minor Changes
@@ -477,6 +518,10 @@
- Fix the file preview's dead pop-out control: a real detach button now opens the previewed file in a browser tab (raw route for PDFs/images/media/text, converted-PDF preview for docx/pptx) and the copy button reports when a preview has no text to copy instead of silently doing nothing. Review-driven hardening for the new tab features: PUT /api/session-order drops unknown ids again instead of rejecting the whole write (a session deleted inside the browser's debounce window could silently lose the user's reorder), a failed mux restore no longer blocks explicit session/webview deletion for the process lifetime (the automated stale sweep stays fail-closed), and the vertical rail gains the axis-awareness the sidebar-only predicates missed: correct drag-reorder insertion, active-tab scroll-into-view, floating windows anchored beside rail tabs, connector redraws on rail scroll, server-seeded orientation applied on first load, a pre-paint stamp so vertical mode no longer flashes through the header strip, and a 12px session-name default matching the sidebar's historical size so untouched installs are not restyled.
### Thanks
- **@aakhter** built both halves of the new tab experience: the owner-scoped, server-authoritative tab-layout foundation with recipient-safe SSE publication and an unusually deep test suite (#335), and the resizable vertical session rail with accessible pointer/keyboard sizing and careful FitAddon handoff (#334). Fifth and sixth merged PRs, and the layout work also fixed real multi-user ordering leaks along the way.
## 1.21.0
### Minor Changes
+3 -1
View File
@@ -207,7 +207,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the Claude `statusLineTelemetry` flag on session create). It renders compact Claude and Codex provider rows. Claude data comes from Codeman's marked `statusLine.command` exporter, which POSTs `rate_limits` to `POST /api/status-telemetry` and never overwrites a user's hand-authored statusLine. ⚠️ **Injecting a statusLine SHADOWS the user's own** (#405): a repo's `.claude/settings.local.json` outranks `~/.claude/settings.json`, and the old inline exporter printed Codeman's footer in its place, so a hand-run `claude` in any managed repo showed the bare word `codeman`. The exporter is therefore a generated, delegating shim (`src/statusline-shim.ts` writes `dataPath('codeman-statusline-shim.mjs')`, the `deepseek-status-shim` pattern) that forwards the blob and, concurrently, runs the statusline it shadows and prints THAT; the route's footer fills in only when there is nothing to shadow, and with neither it prints NOTHING (the route answers an unknown session with an empty body and `formatSessionStatusText(null)` is `''`: never a brand word). ⚠️ The delegate resolves at RENDER time from exactly the three documented files, `.claude/settings.local.json` + `.claude/settings.json` under `workspace.project_dir` (the launch dir), then `~/.claude/settings.json`, first non-ours wins: no ancestor walk and no user-level `settings.local.json`, since delegating to a command Claude Code would have ignored is the original failure in a new coat. ⚠️ **The injected command stays self-contained shell**: `if [ -x <node> ] && [ -f <shim> ]; then exec …; fi;` followed by the inline curl exporter, so the SAME bind-mounted `settings.local.json` renders the shim on the host and the curl inside a Docker case's container, where neither the host's node nor `~/.codeman` exists. Ownership (`isCodemanStatusLine()`) accepts the version-free `codeman-statusline-shim` token OR the `/api/status-telemetry` path; dropping the second makes every repo an older Codeman managed read as hand-authored. ⚠️ `statusLineTelemetry` is a settings-save ACTION field in BOTH directions: `true` on every save while the chip is on (re-injects into every live Claude workspace, remote pseudo-paths skipped), `false` ONLY on the save that turned the chip OFF on that device (`statusLineTelemetryAction()` in settings-ui.js), which removes our entry from those workspaces. Nothing called `applyStatusLineConfig(dir, false)` before, so turning the chip off left the line in every repo it had ever reached. The flip-only rule is what keeps a phone whose chip was never on from stripping the exporter a desktop depends on; a second device with the chip still on re-injects on its next save or session create and shows the last snapshot meanwhile. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the Claude `statusLineTelemetry` flag on session create). It renders compact Claude and Codex provider rows. Claude data comes from Codeman's marked `statusLine.command` exporter, which POSTs `rate_limits` to `POST /api/status-telemetry`, never overwrites a user's hand-authored statusLine, and prints the footer through. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
@@ -318,6 +318,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): bold text on the theme's default foreground carries exactly ONE cue, the weight step. Claude Code marks its markdown bold with a bare `ESC[1m` and no colour change, and xterm substitutes a bright colour for bold only when the foreground is a palette index 0-7, so the substitution never fires there. A two-face family keeps that step small and 400 stays 400 whatever family is chosen, which is why the NORMAL slot is settable at all. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves both slots, each against **its own** xterm default, so an unset bold weight can never inherit `normal`. ⚠️ **The `@font-face` descriptor, not the file, is what the browser synthesizes from**: `fonts/jetbrains-mono-variable.woff2` carries a `wght` axis of 100-800, and while `styles.css` declared it `400 700` every weight below 400 rendered identically to 400 and 800 identically to 700 — measured — so the setting was a no-op for anyone without Fira Code or Cascadia Code installed, which is most installs. It is declared `100 800`; re-narrowing it silently guts the feature (`test/terminal-font-weight.test.ts` pins the range). ⚠️ A live save must reach **both echo overlays** (`refreshFont()` — they cache `terminal.options.fontWeight` and paint it into their spans, so typed characters otherwise keep the old weight, most visible on a phone) **and open Agent Teams panes** (they read their options at construction, exactly like `applyTerminalSkin()` propagates). ⚠️ `_awaitTerminalFont()` is deliberately untouched: `CharSizeService` measures through the CSS `font` shorthand, which RESETS the weight, so the measured face is always the 400 one and a weighted descriptor would ask for nothing new.
**Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on `<html>`, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. ⚠️ A skin is **four things that must stay in sync**, and missing any one degrades silently: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist, and the Settings picker (both in `index.html`). `test/skin-themes.test.ts` is the static guard. Light skins additionally need `color-scheme: light` and xterm `minimumContrastRatio: 4.5`, and `applyTerminalSkin()` must call the local-echo overlay's `refreshFont()` because it caches the terminal fg/bg. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](docs/architecture-invariants.md#theme-skins)
**Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](docs/architecture-invariants.md#foldable-settings-identity)
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
# Plan Usage Limits Display — Design & As-Built
> **Status: SHIPPED. Deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. **The exporter changed shape in 1.28 (discussion #405)**: it is now a self-selecting shell guard that runs a generated, delegating shim (`src/statusline-shim.ts`) where the shim exists and the inline curl below where it does not (inside a Docker case's container). The shim prints the statusline it shadows and falls back to the footer below only when there is nothing to shadow; with neither it prints nothing, and the route now answers an unknown session with an empty body, so the bare word `codeman` never renders. Turning the chip OFF now also removes the exporter from live workspaces (`statusLineTelemetry:false`, sent only on the save that flips the chip off on a device), so the "removal only via the toggle" sentences below are current again and the "never remove" ones record the 1.9-1.27 shape. See `docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry`. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
>
> Two surfaces from one `statusLine` callback:
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
+4 -8
View File
@@ -101,14 +101,10 @@ subscription plan.
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
off on phones.
It works by installing a status line exporter into each managed repo's
`.claude/settings.local.json`, which posts Claude's own rate limit data back to Codeman. The
exporter is marker-identified, so it only ever touches a status line Codeman installed, never
one you wrote yourself. A repo's status line outranks the one in `~/.claude/settings.json`,
so the exporter also runs the status line it shadows and prints that instead of its own
footer: your global status line keeps rendering in managed repos, and in a repo with no
status line of your own you get Codeman's compact session footer. Turning the chip off takes
the exporter back out of the repos of your live sessions.
It works by installing a status line exporter into Claude Code, which posts Claude's own
rate limit data back to Codeman. The exporter is marker-identified, so it only ever touches
a status line Codeman installed, never one you wrote yourself, and it prints your footer
through so the in-terminal status line still works.
The chip and the exporter are the same setting. Turning the chip on without the exporter
would leave it showing a dash forever, so resolve it in one place: **App Settings**.
+19 -57
View File
@@ -39,7 +39,6 @@ import { fileURLToPath } from 'node:url';
import type { HookEventType } from './types.js';
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
import { dataPath } from './config/instance.js';
import { LEGACY_STATUSLINE_MARKER, statusLineShimGuard, STATUSLINE_SHIM_TOKEN } from './statusline-shim.js';
/**
* Serializes read-modify-write access to a `settings.local.json` path. Every
@@ -845,75 +844,38 @@ async function readWorkspaceHooksEnabled(): Promise<boolean> {
}
}
/**
* Is this statusLine command one Codeman wrote?
*
* Two markers count, and every command Codeman has ever injected carries at
* least one. The version-free `codeman-statusline-shim` token names the shim
* file the current guarded command runs; the `/api/status-telemetry` path is
* what the inline exporter posts to, in the pre-shim command AND in the
* fallback half of the current one. Both must be read as ours, or the upgrade
* mistakes an old injected command for a hand-authored line, refuses to touch
* it, and leaves the user with the shadowing exporter.
*/
export function isCodemanStatusLine(command: unknown): boolean {
return (
typeof command === 'string' &&
(command.includes(STATUSLINE_SHIM_TOKEN) || command.includes(LEGACY_STATUSLINE_MARKER))
);
}
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
const STATUSLINE_MARKER = '/api/status-telemetry';
/**
* The inline exporter: env vars plus curl, portable by construction. It POSTs
* the statusline JSON and prints Codeman's answer, which means it SHADOWS
* whatever statusline the user configured globally. That is the cost the shim
* exists to remove, so this half only renders where the shim cannot run: inside
* a Docker case's container (the workspace is bind-mounted, `~/.codeman` and
* the host's node are not), or on a host whose data dir could not be written.
*
* `curl -sfk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
* production setup, so without -k curl returns 000 (-k is safe here, loopback
* only); -f keeps an HTTP error body off the statusline. On any failure it
* prints NOTHING: the old `|| echo codeman` is the bare word that a hand-run
* `claude` in a managed repo rendered, and that reads as a broken config.
* The plan-usage statusLine exporter command. Mirrors the hook `curlCmd` pattern:
* reads Claude Code's statusline stdin JSON, POSTs `{sessionId,data}` to Codeman,
* and prints the response body (a compact "⟳ 5h 15% · 7d 34%" footer) back to
* stdout so the in-terminal statusline stays useful. Env vars resolve at runtime
* (present in every managed session via tmux setenv), so the config is static.
*/
function generateInlineStatusLineCommand(): string {
export function generateStatusLineCommand(): string {
// `curl -sk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
// production setup; without -k curl returns 000 and the statusline shows
// nothing. -k is safe here (loopback only). Falls back to a brand string so the
// footer is never blank if Codeman is unreachable.
return (
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
`curl -sfk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" ` +
`curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
`-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
`--data @- 2>/dev/null || true`
`--data @- 2>/dev/null || echo codeman`
);
}
/**
* The plan-usage statusLine exporter command.
*
* A self-selecting guard followed by the inline exporter: where the shim and
* the node binary both exist the guard `exec`s the delegating shim, which
* forwards the same JSON to Codeman and then prints the statusline its own
* entry shadows; anywhere else the shell falls through to the inline curl.
* The SAME injected string therefore renders correctly from the host and from
* inside a Docker case's container, which is what lets a bind-mounted
* `settings.local.json` carry it. See `statusLineShimGuard()`.
*/
export function generateStatusLineCommand(): string {
const guard = statusLineShimGuard();
const inline = generateInlineStatusLineCommand();
return guard ? `${guard} ${inline}` : inline;
}
/**
* Add or remove Codeman's plan-usage statusLine exporter in
* `.claude/settings.local.json`. Only ever touches a statusLine that is OURS
* (see `isCodemanStatusLine`, which accepts the shim and the pre-shim inline
* form), so a user's hand-authored statusLine is never removed OR overwritten
* — on both the enable and disable paths we bail out when an existing
* statusLine isn't ours. An enable on a repo still carrying the old inline
* command upgrades it to the shim in place. Callers gate on Claude mode.
* Merges, preserving all other keys (hooks, env, model).
* (command targets `/api/status-telemetry`), so a user's hand-authored
* statusLine is never removed OR overwritten — on both the enable and disable
* paths we bail out when an existing statusLine isn't ours. Callers gate on
* Claude mode. Merges, preserving all other keys (hooks, env, model).
*/
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
@@ -927,7 +889,7 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
}
const current = existing.statusLine as { command?: unknown } | undefined;
const isOurs = !!current && isCodemanStatusLine(current.command);
const isOurs = !!current && typeof current.command === 'string' && current.command.includes(STATUSLINE_MARKER);
if (enabled) {
const desired = generateStatusLineCommand();
-433
View File
@@ -1,433 +0,0 @@
/**
* @fileoverview The plan-usage statusLine exporter, as a delegating shim.
*
* ## Why this exists
*
* Claude Code hands its statusLine command a JSON blob on stdin before every
* render, and on a subscription that blob is the ONLY place `rate_limits`
* surfaces. No hook event carries plan usage. So Codeman takes the statusLine
* slot purely as a data tap for the header "Plan Usage Limits" chip.
*
* Taking that slot has a cost the original inline exporter did not pay back.
* Claude Code ranks a repo's `.claude/settings.local.json` above the user's
* `~/.claude/settings.json`, so writing a statusLine into a managed repo
* SHADOWS whatever statusline the user configured globally. The inline exporter
* then printed Codeman's own footer in its place, and a user who ran `claude`
* by hand in a managed repo saw the bare word `codeman` (discussion #405: seven
* repositories before the cause was found).
*
* This shim keeps the data tap and gives the line back. It forwards the blob
* exactly as before, resolves the statusline it is shadowing, runs that command
* with the same blob on stdin, and prints its output. Codeman's own footer
* still appears when there is nothing to shadow, so the exporter remains useful
* on a machine with no statusline of its own and stops being a thief on one
* that has it.
*
* ## How the delegate is resolved
*
* At RENDER time, not at injection time, from exactly the three files Claude
* Code documents for a project: `.claude/settings.local.json` and
* `.claude/settings.json` under the directory Claude Code was launched in
* (`workspace.project_dir` in the blob), then `~/.claude/settings.json`. The
* first `statusLine` that is not one of ours wins. Resolving late means a user
* who edits their global statusline sees the change immediately, with no
* reinjection and no stale command baked into a config file.
*
* Two candidates are deliberately NOT consulted, because delegating to a
* command Claude Code itself would have ignored is exactly the failure this
* shim exists to end: a user-level `~/.claude/settings.local.json` (not in the
* documented set), and the project files of any ANCESTOR of the launch
* directory (Claude Code reads project settings from the launch directory
* alone, and a walk upward can land on a `.claude` that is not a project root).
*
* ## Why the injected command is shell that MAY run the shim
*
* The shim is a file at an absolute path under this instance's data dir, run
* by the node binary Codeman itself runs on. Neither exists on the other side of
* a Docker case's bind mount: the workspace (and its `settings.local.json`) is
* mounted at the same absolute path inside the container, but `~/.codeman` and
* the host's node are not. So the injected command is a self-selecting guard:
* run the shim when both paths resolve, else fall through to the inline curl
* exporter, which is env vars plus curl and works wherever the hooks do. The
* SAME file therefore renders correctly from the host and from inside the
* container, and the inline form is also what a wiped data dir degrades to.
*
* ## Why it is generated rather than committed
*
* Same reasoning as `deepseek-status-shim`: the shim must be a file at a stable
* absolute path in a git clone, in an `npm i -g aicodeman` install where only
* `dist` ships, and under any `CODEMAN_INSTANCE`. Writing it into the data dir
* covers all three from one code path and single-sources the content here.
*
* @module statusline-shim
*/
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from './config/instance.js';
/**
* Bumped whenever SHIM_SOURCE changes, and embedded in the generated file so
* `ensureStatusLineShim()` can tell a current shim from one an older Codeman
* wrote. Without it an upgraded Codeman would either rewrite on every session
* create or leave a stale shim in place forever.
*/
const SHIM_VERSION = 1;
const SHIM_MARKER = `codeman-statusline-shim v${SHIM_VERSION}`;
/**
* Version-agnostic ownership token, and the shim's own loop guard.
*
* It appears in the generated file's NAME, so it is a substring of the injected
* command for every shim version. Two separate decisions key on that:
* `isCodemanStatusLine()` in hooks-config uses it to recognise a statusLine as
* Codeman's, and the shim itself uses it to skip its own entry while hunting
* for a delegate. Deciding ownership on the version-free token means bumping
* SHIM_VERSION can never disown every previously injected command.
*/
export const STATUSLINE_SHIM_TOKEN = 'codeman-statusline-shim';
/**
* The inline exporter's ownership marker: the route it posts to.
*
* Every command Codeman has ever injected carries this path, the pre-shim
* inline `curl` and the fallback half of the current guarded command alike, so
* `isCodemanStatusLine()` must keep reading it as OURS. Drop it and every repo
* an older Codeman managed reads as hand-authored: the upgrade refuses to touch
* it and the user keeps the shadowing exporter forever.
*/
export const LEGACY_STATUSLINE_MARKER = '/api/status-telemetry';
/**
* The route the shim reports to. Identical in text to the legacy marker above,
* and separate from it on purpose: one names an endpoint this code calls, the
* other names a string an old config is recognised by. Changing the route must
* not silently change what counts as an old config.
*/
const STATUS_TELEMETRY_PATH = '/api/status-telemetry';
/**
* What a pre-1.28 server answers for a session it does not know. The current
* route answers an empty body, but a shim written by a newer Codeman can be
* talking to an older one (two instances sharing a repo), and this exact word
* rendered as a statusline is the symptom the whole change exists to remove,
* so the shim treats it as "no telemetry" rather than printing it.
*/
const NO_TELEMETRY_WORD = 'codeman';
/** Wrap a path for safe use inside a single-quoted shell word. */
function shQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
/**
* The generated shim.
*
* Three behaviours are worth reading closely, because each one exists to avoid
* a specific failure the inline exporter had or would have had:
*
* - **The delegate runs concurrently with the POST.** This command executes on
* every assistant message, so its latency lands in the user's prompt. Running
* both at once costs the slower of the two rather than their sum.
* - **A failing delegate falls through, never blanks by accident.** Empty
* output, a non-zero exit, or a timeout all fall through to Codeman's footer.
* With no footer either the shim prints nothing at all, which is what a user
* with no statusline of their own gets from Claude Code anyway: the one thing
* it never prints is a brand word that reads as a broken config.
* - **Both timeouts are short and independent.** An unreachable Codeman must
* not delay a prompt by more than its own budget, and a hung delegate must
* not hold the render open indefinitely.
*/
const SHIM_SOURCE = `#!/usr/bin/env node
// ${SHIM_MARKER}
// GENERATED BY CODEMAN. Do not edit: rewritten from src/statusline-shim.ts
// whenever its version marker changes.
//
// Forwards Claude Code's statusline JSON to this Codeman instance (the only
// source of plan rate-limit numbers) and then prints the statusline this entry
// shadows, so taking the slot costs the user nothing.
import { existsSync, readFileSync } from 'node:fs'
import { spawn } from 'node:child_process'
import { join } from 'node:path'
import { homedir } from 'node:os'
// The HTTP transport is imported lazily, inside postTelemetry(): node:http
// costs ~38 ms to load on a fast Linux box (measured, versus ~4 ms for
// node:https alone), and this file runs on every assistant message. Loading
// only the transport the URL needs, and none outside a managed session, is
// most of the difference between an 80 ms render and a 110 ms one.
const SHIM_TOKEN = ${JSON.stringify(STATUSLINE_SHIM_TOKEN)}
const LEGACY_MARKER = ${JSON.stringify(LEGACY_STATUSLINE_MARKER)}
const NO_TELEMETRY_WORD = ${JSON.stringify(NO_TELEMETRY_WORD)}
const POST_TIMEOUT_MS = 1500
const DELEGATE_TIMEOUT_MS = 4000
let input = ''
try {
input = readFileSync(0, 'utf-8')
} catch {
// No stdin (a TTY, or a closed pipe): the delegate still deserves a run.
}
if (!input.trim()) input = '{}'
let parsed = {}
try {
parsed = JSON.parse(input)
} catch {
// Malformed payload: still forward it verbatim and still run the delegate.
// Codeman's parser is defensive and the delegate may not need the JSON.
}
if (!parsed || typeof parsed !== 'object') parsed = {}
const str = (value) => (typeof value === 'string' && value ? value : '')
const workspace = parsed.workspace && typeof parsed.workspace === 'object' ? parsed.workspace : {}
// Claude Code reads a project's settings from the directory it was LAUNCHED in,
// which the blob reports as workspace.project_dir; current_dir/cwd can drift
// from it when the working directory changes mid-session. The process cwd is
// the last resort for a blob that carries neither.
const projectDir = str(workspace.project_dir) || str(workspace.current_dir) || str(parsed.cwd) || process.cwd()
/**
* The settings files Claude Code consults for this render, highest precedence
* first: the documented set is exactly these three. No ancestor of the launch
* directory and no user-level settings.local.json: Claude Code reads neither,
* and delegating to a command it would have ignored is the failure this shim
* exists to end.
*/
function settingsCandidates() {
return [
join(projectDir, '.claude', 'settings.local.json'),
join(projectDir, '.claude', 'settings.json'),
join(homedir(), '.claude', 'settings.json'),
]
}
/** The first statusLine command that is not one of ours, or null. */
function resolveDelegate() {
for (const file of settingsCandidates()) {
if (!existsSync(file)) continue
let settings
try {
settings = JSON.parse(readFileSync(file, 'utf-8'))
} catch {
continue // Malformed file: Claude Code would ignore it too.
}
const line = settings && settings.statusLine
if (!line || typeof line !== 'object') continue
if (line.type && line.type !== 'command') continue
const command = line.command
if (typeof command !== 'string' || !command.trim()) continue
// Our own entry, in the guarded shim form or the pre-shim inline form.
// Delegating to either one would recurse or double-report.
if (command.includes(SHIM_TOKEN) || command.includes(LEGACY_MARKER)) continue
return command
}
return null
}
/** Run the shadowed statusline with the same JSON on stdin. Never rejects. */
function runDelegate(command) {
return new Promise((resolve) => {
// bash when it exists: a user's statusline may well use bashisms, and
// /bin/sh is dash on Debian-family systems.
const shell = existsSync('/bin/bash') ? '/bin/bash' : '/bin/sh'
let child
try {
child = spawn(shell, ['-c', command], { stdio: ['pipe', 'pipe', 'ignore'] })
} catch {
return resolve(null)
}
let out = ''
let settled = false
const finish = (value) => {
if (settled) return
settled = true
resolve(value)
}
const timer = setTimeout(() => {
child.kill('SIGKILL')
finish(out.trim() ? out : null) // partial output beats no output
}, DELEGATE_TIMEOUT_MS)
timer.unref?.()
child.stdout.on('data', (chunk) => {
out += chunk
})
child.on('error', () => {
clearTimeout(timer)
finish(null)
})
child.on('close', (code) => {
clearTimeout(timer)
// A non-zero exit that still printed something is worth showing: plenty
// of statusline scripts end on the exit code of their last command.
const usable = out.trim().length > 0 || code === 0
finish(usable ? out : null)
})
child.stdin.on('error', () => {}) // a delegate that ignores stdin closes it early
child.stdin.end(input)
})
}
/** POST the blob to Codeman. Resolves to the footer it answered, or null. */
async function postTelemetry() {
const sessionId = process.env.CODEMAN_SESSION_ID
const apiUrl = process.env.CODEMAN_API_URL
// Outside a managed session there is no session to report against, so the
// shim costs nothing beyond running the delegate.
if (!sessionId || !apiUrl) return null
let url
try {
url = new URL(${JSON.stringify(STATUS_TELEMETRY_PATH)}, apiUrl)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
let secret = ''
try {
secret = readFileSync(process.env.CODEMAN_HOOK_SECRET_FILE || '', 'utf-8').trim()
} catch {
// Missing file: the loopback bypass still applies when no tunnel runs.
}
const { default: transport } = await import(url.protocol === 'https:' ? 'node:https' : 'node:http')
const body = JSON.stringify({ sessionId, data: parsed })
return new Promise((resolve) => {
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
timeout: POST_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'X-Codeman-Hook-Secret': secret,
},
// Loopback HTTPS with a self-signed cert (--https / tailscale installs).
rejectUnauthorized: false,
},
(res) => {
let text = ''
res.setEncoding('utf-8')
res.on('data', (chunk) => {
text += chunk
})
res.on('end', () => resolve(res.statusCode >= 200 && res.statusCode < 300 ? text : null))
}
)
req.on('timeout', () => {
req.destroy()
resolve(null)
})
req.on('error', () => resolve(null))
req.end(body)
})
}
const delegateCommand = resolveDelegate()
const [delegateOut, telemetryOut] = await Promise.all([
delegateCommand ? runDelegate(delegateCommand) : Promise.resolve(null),
postTelemetry(),
])
// The shadowed line wins. Codeman's footer fills in only when there is no line
// to shadow or the delegate produced nothing. With neither, print NOTHING: a
// blank statusline is what Claude Code shows a user with no statusline of
// their own, while the bare brand word is the symptom this shim exists to end.
const own = delegateOut && delegateOut.trim() ? delegateOut : ''
const footer = telemetryOut && telemetryOut.trim() && telemetryOut.trim() !== NO_TELEMETRY_WORD ? telemetryOut : ''
const rendered = own || footer
if (rendered) process.stdout.write(rendered.replace(/\\n$/, ''))
`;
/** Absolute path of the generated shim for this instance. */
export function statusLineShimPath(): string {
return dataPath(`${STATUSLINE_SHIM_TOKEN}.mjs`);
}
let ensuredThisProcess = false;
/**
* Write the shim if it is missing or stale, and return its path.
*
* Idempotent and cheap: after the first call in a process it does nothing, and
* even the first call rewrites only when the on-disk marker differs. Never
* throws. A data dir that cannot be written is a degraded exporter, not a
* failed session start, so the caller receives null and injects the inline
* command alone.
*/
export function ensureStatusLineShim(): string | null {
const path = statusLineShimPath();
if (ensuredThisProcess) return path;
try {
let current = '';
try {
current = readFileSync(path, 'utf-8');
} catch {
// Missing: fall through to the write.
}
if (!current.includes(SHIM_MARKER)) {
mkdirSync(dirname(path), { recursive: true });
// Temp + rename, same reasoning as the DeepSeek shim: a live session can
// be executing this exact path at the moment an upgraded Codeman
// refreshes it, and a reader that catches a half-written file gets a
// syntax error and a blank statusline. rename(2) is atomic within the
// directory. Pid-suffixed so two instances sharing a data dir cannot
// collide on the temp name.
const tempPath = `${path}.${process.pid}.tmp`;
try {
writeFileSync(tempPath, SHIM_SOURCE, { mode: 0o700 });
// The mode argument applies only when writeFileSync CREATES the file,
// so a leftover temp from a crashed run would keep its old permissions.
chmodSync(tempPath, 0o700);
renameSync(tempPath, path);
} catch (err) {
rmSync(tempPath, { force: true });
throw err;
}
}
// Re-assert the mode even when the content matched: a shim that lost its
// executable bit (a restored backup, a copied data dir) would fail on every
// render, and the user would see the fallback string instead of their line.
chmodSync(path, 0o700);
ensuredThisProcess = true;
return path;
} catch (err) {
console.warn(`[statusline] Could not install the shim at ${path}: ${(err as Error).message}`);
return null;
}
}
/**
* The shell guard that runs the shim where it exists, for `generateStatusLineCommand()`
* in hooks-config to prepend to the inline exporter.
*
* `if [ -x <node> ] && [ -f <shim> ]; then exec <node> <shim>; fi;`: both
* tests fail inside a Docker case's container (see the fileoverview), on a
* host whose data dir was wiped, and after the node Codeman ran on moves, so
* the inline exporter after it is what renders there. `process.execPath`
* rather than a bare `node`: Codeman is itself running on that binary, so it
* is known to exist, and a managed session's PATH need not carry node at all.
* The absolute path is also self-healing, because a node that moves changes
* this string, and the next session create rewrites the config to match.
*
* Returns null when the shim could not be installed, in which case the caller
* injects the inline exporter alone.
*/
export function statusLineShimGuard(): string | null {
const shim = ensureStatusLineShim();
if (!shim) return null;
const node = shQuote(process.execPath);
const file = shQuote(shim);
return `if [ -x ${node} ] && [ -f ${file} ]; then exec ${node} ${file}; fi;`;
}
/** Test seam: forget the per-process memo so a fresh temp data dir is provisioned. */
export function resetStatusLineShimForTest(): void {
ensuredThisProcess = false;
}
+3 -7
View File
@@ -173,14 +173,10 @@ export function parseSessionStatus(data: RawStatuslinePayload | undefined): Sess
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
* which live in the Codeman header chip. Claude requires a statusLine command to
* emit the rate_limits JSON at all, so this is what that command prints back
* when it has no statusline of the user's own to delegate to. With nothing to
* show it returns '' rather than a brand word: the exporter's shim reads an
* empty footer as "no telemetry", and a bare `codeman` on the statusline is the
* symptom discussion #405 opened with.
* emit the rate_limits JSON at all, so this is what that command prints back.
*/
export function formatSessionStatusText(s: SessionStatus | null): string {
if (!s) return '';
if (!s) return 'codeman';
const groups: string[] = [];
if (s.modelDisplayName) groups.push(s.modelDisplayName);
const tok: string[] = [];
@@ -188,7 +184,7 @@ export function formatSessionStatusText(s: SessionStatus | null): string {
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
if (tok.length) groups.push(tok.join(' '));
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
return groups.length ? groups.join(' ') : '';
return groups.length ? groups.join(' ') : 'codeman';
}
/**
+50
View File
@@ -709,6 +709,54 @@ function resolveTerminalFontFamily(custom) {
return `${families.join(', ')}, ${TERMINAL_FONT_DEFAULT_STACK}`;
}
/**
* xterm's own defaults for the two weight slots, one per slot.
*
* They are deliberately kept apart rather than collapsed into a single
* fallback: handing the bold slot `normal` (or the normal slot `bold`) would
* turn an unset setting into a visible change, which is exactly the thing this
* feature exists to make controllable.
*/
const TERMINAL_FONT_WEIGHT_DEFAULTS = { fontWeight: 'normal', fontWeightBold: 'bold' };
/**
* Resolve ONE weight slot against xterm's validation rules.
*
* xterm accepts a number in 1..1000, or one of its own keyword/numeric-string
* options, and silently falls back to the slot default for anything else
* (`OptionsService._sanitizeAndValidateOption`). Resolving here instead means a
* stored value the picker does not list (a hand-set 350) still reaches the
* terminal, while junk in localStorage never does.
*/
function resolveTerminalFontWeightSlot(value, fallback) {
if (value === 'normal' || value === 'bold') return value;
const numeric = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN;
if (!Number.isFinite(numeric) || numeric < 1 || numeric > 1000) return fallback;
return Math.round(numeric);
}
/**
* Resolve both xterm weight slots from the per-device settings blob.
*
* Bold text on the theme's default foreground carries exactly ONE cue, the
* weight step: Claude Code marks its markdown bold with a bare `ESC[1m` and no
* colour, and xterm's bold-to-bright substitution only fires for palette
* indices 0-7, so it never applies to default-foreground text. A family that
* ships only a regular and a bold face keeps that step small, and 400 stays
* 400 whatever family is chosen — lowering the NORMAL weight is the only way
* to widen the gap.
*/
function resolveTerminalFontWeights(settings) {
const s = settings && typeof settings === 'object' ? settings : {};
return {
fontWeight: resolveTerminalFontWeightSlot(s.terminalFontWeight, TERMINAL_FONT_WEIGHT_DEFAULTS.fontWeight),
fontWeightBold: resolveTerminalFontWeightSlot(
s.terminalFontWeightBold,
TERMINAL_FONT_WEIGHT_DEFAULTS.fontWeightBold
),
};
}
// ---------------------------------------------------------------------------
// Auto Copy (copy-on-select). Pure decision, so every guard below is testable
// without a terminal, a clipboard, or a browser.
@@ -809,6 +857,8 @@ if (typeof window !== 'undefined') {
window.CodemanTerminalFont = {
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
resolve: resolveTerminalFontFamily,
WEIGHT_DEFAULTS: TERMINAL_FONT_WEIGHT_DEFAULTS,
resolveWeights: resolveTerminalFontWeights,
};
}
+6
View File
@@ -335,6 +335,12 @@
'Terminal font': '终端字体',
'Prepended to the built-in stack, so fallbacks (including bundled Nerd Font symbols) keep working. Must be installed on this device. Leave empty for the default.':
'置于内置字体栈之前,回退字体(包括内置的 Nerd Font 图标)仍然生效。需已安装在本设备上。留空使用默认值。',
'Normal font weight': '常规字重',
'Weight for ordinary terminal text. Lowering it widens the step up to bold, which for a family shipping only a regular and a bold face is the only cue bold text carries. Needs a family with faces at that weight; the bundled font covers 100 to 800.':
'终端普通文本的字重。调低可拉大与粗体之间的差距;对于只提供常规和粗体两种字形的字体,这一差距是粗体文本唯一的视觉提示。需要字体具备该字重的字形,内置字体覆盖 100 至 800。',
'Bold font weight': '粗体字重',
'Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face.':
'终端粗体文本的字重。仅当字体提供比其粗体更重的字形时才有意义。',
'Local Echo': '本地回显',
'CJK Input': '中日韩输入',
'Extended Keyboard Bar': '扩展键盘栏',
+36
View File
@@ -1704,6 +1704,42 @@
</div>
<input type="text" id="appSettingsTerminalFont" class="set-input" placeholder='e.g. JetBrainsMono Nerd Font'>
</div>
<div class="set-row has-field" data-search="terminal font weight normal regular light thin bold contrast">
<div class="set-row-text">
<span class="set-row-label">Normal font weight</span>
<span class="set-row-desc">Weight for ordinary terminal text. Lowering it widens the step up to bold, which for a family shipping only a regular and a bold face is the only cue bold text carries. Needs a family with faces at that weight; the bundled font covers 100 to 800.</span>
</div>
<select id="appSettingsTerminalFontWeight" class="set-select">
<option value="">Default (normal)</option>
<option value="100">100</option>
<option value="200">200</option>
<option value="300">300</option>
<option value="400">400</option>
<option value="500">500</option>
<option value="600">600</option>
<option value="700">700</option>
<option value="800">800</option>
<option value="900">900</option>
</select>
</div>
<div class="set-row has-field" data-search="terminal bold font weight heavy black emphasis">
<div class="set-row-text">
<span class="set-row-label">Bold font weight</span>
<span class="set-row-desc">Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face.</span>
</div>
<select id="appSettingsTerminalFontWeightBold" class="set-select">
<option value="">Default (bold)</option>
<option value="100">100</option>
<option value="200">200</option>
<option value="300">300</option>
<option value="400">400</option>
<option value="500">500</option>
<option value="600">600</option>
<option value="700">700</option>
<option value="800">800</option>
<option value="900">900</option>
</select>
</div>
</div>
</div>
+5 -1
View File
@@ -2282,10 +2282,14 @@ Object.assign(CodemanApp.prototype, {
return;
}
const fontSettings = this.loadAppSettingsFromStorage?.() || {};
const terminal = new Terminal({
theme: { ...window.codemanCurrentXtermTheme() },
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
fontFamily: window.CodemanTerminalFont.resolve(fontSettings.terminalFontFamily),
// A pane opened after a weight change must match the main terminal;
// one open across the change is repainted by applyTerminalFontWeights().
...window.CodemanTerminalFont.resolveWeights(fontSettings),
fontSize: 12,
lineHeight: 1.2,
cursorBlink: true,
+46 -25
View File
@@ -335,6 +335,32 @@ Object.assign(CodemanApp.prototype, {
// App Settings Modal
// ═══════════════════════════════════════════════════════════════
/**
* Point one terminal-weight select at its stored value.
*
* A stored value the picker does not list (a hand-set 350, or a weight from a
* build whose options differ) is ADDED to the select rather than dropped:
* otherwise `select.value = '350'` silently selects nothing, the next save
* reads back '' and the setting resets itself just for having been opened.
* Empty means "use xterm's default for this slot".
*/
populateTerminalFontWeight(select, value) {
if (!select) return;
const stored = value === undefined || value === null ? '' : String(value).trim();
if (stored && !Array.from(select.options).some((opt) => opt.value === stored)) {
const extra = document.createElement('option');
extra.value = stored;
extra.textContent = `${stored} (custom)`;
select.appendChild(extra);
}
select.value = stored;
},
/** Read one terminal-weight select back. '' means default; the resolver in constants.js validates. */
readTerminalFontWeight(select) {
return select?.value.trim() || '';
},
openAppSettings() {
// Load current settings
const settings = this.loadAppSettingsFromStorage();
@@ -411,6 +437,11 @@ Object.assign(CodemanApp.prototype, {
// a way to read, so it is opt-in rather than a default anyone has to discover.
document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true;
document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || '';
this.populateTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight'), settings.terminalFontWeight);
this.populateTerminalFontWeight(
document.getElementById('appSettingsTerminalFontWeightBold'),
settings.terminalFontWeightBold
);
document.getElementById('appSettingsTerminalWheelLocal').checked =
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
@@ -2050,9 +2081,6 @@ Object.assign(CodemanApp.prototype, {
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
// as "previously off" — used below to detect a real OFF→ON flip.
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
// Plan-usage chip: the exporter it depends on is removed from live workspaces
// ONLY on the save that turns the chip off (see statusLineTelemetryAction).
const _prevPlanUsageChip = this.planUsageChipEnabled(_prev);
const settings = {
displayName: window.CodemanI18n?.normalizeDisplayName(
document.getElementById('appSettingsDisplayName').value
@@ -2094,6 +2122,10 @@ Object.assign(CodemanApp.prototype, {
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
terminalFontWeight: this.readTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight')),
terminalFontWeightBold: this.readTerminalFontWeight(
document.getElementById('appSettingsTerminalFontWeightBold')
),
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
@@ -2148,6 +2180,7 @@ Object.assign(CodemanApp.prototype, {
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
this.applyTerminalFontFamily?.(settings.terminalFontFamily);
this.applyTerminalFontWeights?.(settings);
// A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback
// marker so the next reload actually re-tries WebGL. Only the transition
@@ -2283,11 +2316,9 @@ Object.assign(CodemanApp.prototype, {
// and syncing would leak mobile's hidden-checkbox false onto desktop); it's
// also absent from SettingsUpdateSchema, which is .strict() — sending it
// would 400 the whole settings PUT.
// Telemetry COLLECTION is requested out-of-band via the statusLineTelemetry
// action field: `true` on every save while the chip is on, `false` only on the
// save that turned it off here, nothing otherwise (statusLineTelemetryAction),
// so a device whose chip was never on cannot strip the exporter another
// device's chip depends on. See the system-routes settings handler.
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on
// ENABLE only, so a device with the chip OFF never strips the exporter that
// another device's chip depends on — see system-routes settings handler).
const {
localEchoEnabled: _leo,
cjkInputEnabled: _cjk,
@@ -2307,6 +2338,11 @@ Object.assign(CodemanApp.prototype, {
// Per-device by nature (the font must exist on the device) and absent
// from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT.
terminalFontFamily: _tff,
// Same two reasons: which weights a family can actually render is a
// property of the faces installed on THIS device, and neither key is
// declared in the .strict() schema.
terminalFontWeight: _tfw,
terminalFontWeightBold: _tfwb,
// Per-device header/toolbar button toggles — client-only, and absent from
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
showSessionButton: _ssb,
@@ -2321,11 +2357,10 @@ Object.assign(CodemanApp.prototype, {
sessionLineageLines: _sll,
...serverSettings
} = settings;
const statusLineTelemetry = this.statusLineTelemetryAction(_prevPlanUsageChip, settings.showPlanUsageLimits);
try {
const res = await this._apiPut('/api/settings', {
...serverSettings,
...(statusLineTelemetry === undefined ? {} : { statusLineTelemetry }),
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}),
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
@@ -2598,20 +2633,6 @@ Object.assign(CodemanApp.prototype, {
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true;
},
// What a settings save tells the server about the plan-usage exporter, given
// the chip's state before and after the save. `true` re-injects the exporter
// into every live Claude workspace and may ride every save while the chip is
// on. `false` REMOVES it from those workspaces, and the chip is per-device
// while the exporter lives in each repo's shared settings.local.json, so it
// may ride only the save that turned the chip off on this device: a phone
// whose chip was never on must never strip what a desktop's chip depends on.
// Pure, so test/plan-usage-telemetry-action.test.ts can pin all three cases.
statusLineTelemetryAction(prevEnabled, nowEnabled) {
if (nowEnabled) return true;
if (prevEnabled) return false;
return undefined;
},
applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
@@ -3096,7 +3117,7 @@ Object.assign(CodemanApp.prototype, {
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
'terminalWheelLocalScrollback',
'autoCopySelection',
+10 -1
View File
@@ -9,11 +9,20 @@
font-weight: 400 800;
src: url('fonts/manrope-variable.woff2') format('woff2');
}
/* The declared range is what the browser will synthesize from, NOT what the
file carries: the woff2 behind this has a `wght` axis of 100 to 800, and a
narrower descriptor clamps it — at `400 700`, requesting 100, 200 or 300
rendered identically to 400 and 800 identically to 700. The terminal
font-weight settings would then be a no-op for anyone on the bundled face,
which is most installs (the two families ahead of it in the stack, Fira Code
and Cascadia Code, exist only if the user installed them). Nothing in the
stylesheets asks for a monospace weight outside 400-700, so widening it
changes nothing that renders today. */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-display: swap;
font-weight: 400 700;
font-weight: 100 800;
src: url('fonts/jetbrains-mono-variable.woff2') format('woff2');
}
/* Icons-only per-glyph fallback for the terminal (Symbols Nerd Font Mono, MIT,
+56 -1
View File
@@ -248,9 +248,13 @@ Object.assign(CodemanApp.prototype, {
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
this._destroyKeyCode229Recovery();
const fontSettings = this.loadAppSettingsFromStorage?.() || {};
this.terminal = new Terminal({
theme: { ...window.codemanCurrentXtermTheme() },
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
fontFamily: window.CodemanTerminalFont.resolve(fontSettings.terminalFontFamily),
// Both weight slots, each falling back to xterm's own default for that
// slot, so an untouched install renders exactly as it always has.
...window.CodemanTerminalFont.resolveWeights(fontSettings),
// Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line)
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
lineHeight: 1.2,
@@ -4905,6 +4909,57 @@ Object.assign(CodemanApp.prototype, {
this._predictiveEcho?.refreshFont();
},
/**
* Apply the per-device terminal font WEIGHTS to every live xterm.
*
* Both slots move together because they are resolved together: passing a
* settings blob with neither key restores xterm's own `normal`/`bold`.
*
* Three things follow the option write and none of them is optional:
*
* - The echo overlays cache `terminal.options.fontWeight` and paint it into
* their spans, so without `refreshFont()` the characters being typed keep
* the old weight while the rest of the screen changes. Most visible on a
* phone, where local echo is on by default.
* - Agent Teams panes read these options at CONSTRUCTION, so a live save
* would otherwise leave an open pane at the old weight beside a repainted
* terminal. `applyTerminalSkin()` propagates for the same reason.
* - The refit is insurance. `CharSizeService` measures through the CSS
* `font` shorthand, which resets the weight, so the canvas path measures
* the 400 face at every setting — but `DomRenderer` styles its measure
* span with `span:not(.xterm-bold)`, where the normal weight really can
* move the cell.
*/
applyTerminalFontWeights(settings) {
const { fontWeight, fontWeightBold } = window.CodemanTerminalFont.resolveWeights(settings);
if (!this.terminal) return;
if (this.terminal.options.fontWeight === fontWeight && this.terminal.options.fontWeightBold === fontWeightBold) {
return;
}
this.terminal.options.fontWeight = fontWeight;
this.terminal.options.fontWeightBold = fontWeightBold;
// Same race as a live family change: the option write makes xterm
// re-measure immediately, against a face the browser may not have
// rasterized yet. Re-arm the wait and fit again once it settles; the fit
// below still runs, so the terminal is never left unfitted.
this._terminalFontReady = this._awaitTerminalFont().then(() => {
if (this.terminal?.options?.fontWeight === fontWeight) this.fitAddon?.fit();
});
this.fitAddon?.fit();
this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont();
for (const [, entry] of this.teammateTerminals || []) {
if (!entry?.terminal) continue;
entry.terminal.options.fontWeight = fontWeight;
entry.terminal.options.fontWeightBold = fontWeightBold;
try {
entry.fitAddon?.fit();
} catch {
/* pane not laid out yet — its own resize observer refits it */
}
}
},
loadFontSize() {
const saved = localStorage.getItem('codeman-font-size');
if (saved) {
+4 -8
View File
@@ -8,10 +8,8 @@
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
*
* Returns a compact plain-text status string for the exporter to print as the
* in-terminal footer when it has no statusline of the user's own to delegate to
* (see `statusline-shim.ts`). An unknown session gets an EMPTY body: the old
* brand-word answer rendered as the statusline of every hand-run `claude` in a
* managed repo, and cost discussion #405 seven repositories of debugging.
* in-terminal footer (print-through), so injecting our statusLine doesn't leave
* the terminal footer blank.
*/
import { FastifyInstance } from 'fastify';
@@ -38,12 +36,10 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
reply.type('text/plain; charset=utf-8');
// Unknown session: nothing to broadcast and nothing to print. Never a brand
// word here, it would render as the statusline (the shim treats an empty
// answer as "no telemetry" and falls through to the delegate or to blank).
// Unknown session — minimal footer, no broadcast.
if (!ctx.sessions.has(sessionId)) {
lastSig.delete(sessionId);
return '';
return 'codeman';
}
const payload = data as RawStatuslinePayload | undefined;
+11 -25
View File
@@ -1034,34 +1034,20 @@ export function registerSystemRoutes(
});
// Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js).
// Telemetry COLLECTION is a per-save ACTION field in both directions. `true`
// rides every save while the chip is on: we (re)inject our exporter into every
// ACTIVE Claude session's working dir so the live % starts flowing immediately
// (no new session needed), and that is also how a second device catches up.
// `false` rides ONLY the save that turned the chip OFF on that device
// (statusLineTelemetryAction in settings-ui.js) and takes our exporter back
// out of those same dirs. Nothing called the disable path before, so turning
// the chip off left the line in every repo it had ever reached (#405). A
// per-repo settings.local.json is shared by sibling sessions and by every
// device, so the flip-only rule is what keeps a phone whose chip was never on
// from stripping the exporter a desktop's chip depends on; a device with the
// chip still on re-injects on its next save or session create and shows the
// last snapshot meanwhile. Both paths are isOurs-guarded (a hand-authored
// statusLine is never touched), remote attaches are skipped (their workingDir
// is a user@host:session pseudo-path the enable path would mkdir as a junk
// local dir), and each dir is handled once.
if (statusLineTelemetry === true || statusLineTelemetry === false) {
const user = getAuthUser(req);
// Telemetry COLLECTION is server-side and enable-sticky — when a client turns
// the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter
// into every ACTIVE Claude session's working dir so the live % starts flowing
// immediately (no new session needed). We deliberately never auto-REMOVE here:
// the exporter is benign/print-through and a per-repo settings.local.json is
// shared by sibling sessions, so one device's "off" must not yank the exporter
// another device's chip depends on. Each dir handled once.
if (statusLineTelemetry === true) {
const dirs = new Set<string>();
for (const session of ctx.sessions.values()) {
if (!getCli(session.mode)?.capabilities.statusLineTelemetry || !session.workingDir) continue;
if (session.remote) continue;
// Removal is the destructive direction: only the caller's own workspaces
// (canAccessOwned is allow-all for admins and in single-user mode).
if (!statusLineTelemetry && !canAccessOwned(user, session.owner)) continue;
dirs.add(session.workingDir);
if (getCli(session.mode)?.capabilities.statusLineTelemetry && session.workingDir)
dirs.add(session.workingDir);
}
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, statusLineTelemetry).catch(() => {})));
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {})));
}
// Handle tunnel toggle dynamically
-83
View File
@@ -23,7 +23,6 @@ import {
updateCaseModel,
writeHooksConfig,
} from '../src/hooks-config.js';
import { LEGACY_STATUSLINE_MARKER, STATUSLINE_SHIM_TOKEN } from '../src/statusline-shim.js';
describe('generateHooksConfig', () => {
it('should return an object with hooks key', () => {
@@ -1306,85 +1305,3 @@ describe('Hook Config Generation - Extended', () => {
expect(stopHooks[0].hooks[0].command).toContain('stop');
});
});
describe('applyStatusLineConfig', () => {
const testDir = join(tmpdir(), 'codeman-statusline-config-' + Date.now());
const settingsFile = join(testDir, '.claude', 'settings.local.json');
const read = () => JSON.parse(readFileSync(settingsFile, 'utf-8'));
const write = (value: object) => {
mkdirSync(join(testDir, '.claude'), { recursive: true });
writeFileSync(settingsFile, JSON.stringify(value, null, 2));
};
beforeEach(() => {
rmSync(testDir, { recursive: true, force: true });
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('injects the guarded shim command with the inline exporter as its fallback', async () => {
await applyStatusLineConfig(testDir, true);
const { statusLine } = read();
expect(statusLine.type).toBe('command');
// The shim runs first wherever it exists: it is what gives the user their
// own statusline back. The inline half after it is what renders where the
// shim cannot (inside a Docker case's container), and it must not carry the
// brand-word fallback the old exporter printed.
expect(statusLine.command.startsWith('if [ -x ')).toBe(true);
expect(statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
expect(statusLine.command).toContain(LEGACY_STATUSLINE_MARKER);
expect(statusLine.command).not.toContain('echo codeman');
});
it('upgrades a pre-shim inline exporter in place', async () => {
// Every repo a previous Codeman managed still holds this command. If the
// ownership check missed it, the upgrade would read it as hand-authored,
// refuse to touch it, and leave the user shadowed forever.
write({
statusLine: { type: 'command', command: `curl -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}"` },
permissions: { allow: ['Read'] },
});
await applyStatusLineConfig(testDir, true);
const settings = read();
expect(settings.statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
expect(settings.permissions).toEqual({ allow: ['Read'] });
});
it('removes a pre-shim inline exporter on the disable path', async () => {
write({ statusLine: { type: 'command', command: `curl "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}"` } });
await applyStatusLineConfig(testDir, false);
expect(read().statusLine).toBeUndefined();
});
it('removes its own shim entry on the disable path', async () => {
await applyStatusLineConfig(testDir, true);
await applyStatusLineConfig(testDir, false);
expect(read().statusLine).toBeUndefined();
});
it('never touches a statusLine the user wrote themselves', async () => {
// Unchanged contract: a hand-authored entry in the repo's own file stops
// Codeman cold, so it never owns an entry it would have to restore later.
const mine = { type: 'command', command: 'bash ~/.claude/my-statusline.sh' };
write({ statusLine: mine });
await applyStatusLineConfig(testDir, true);
expect(read().statusLine).toEqual(mine);
await applyStatusLineConfig(testDir, false);
expect(read().statusLine).toEqual(mine);
});
it('rewrites nothing when the shim command is already current', async () => {
await applyStatusLineConfig(testDir, true);
const before = readFileSync(settingsFile, 'utf-8');
await applyStatusLineConfig(testDir, true);
expect(readFileSync(settingsFile, 'utf-8')).toBe(before);
});
});
-46
View File
@@ -1,46 +0,0 @@
/**
* `statusLineTelemetryAction()` in settings-ui.js: the one place that decides
* what a settings save tells the server about the plan-usage exporter.
*
* The chip is per-device (desktop default ON, phones OFF), while the exporter
* it depends on lives in each repo's shared `.claude/settings.local.json`. So
* the save may send `true` freely (every save while the chip is on re-injects,
* which is how a second device catches up) but may send `false` ONLY on the
* save that turned the chip off on this device. A phone with the chip off
* saving its font size must not strip the exporter a desktop's chip depends on.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
function loadSettingsUi() {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
});
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
vm.runInContext(source, context, { filename: 'settings-ui.js' });
return CodemanApp.prototype as { statusLineTelemetryAction: (prev: boolean, now: boolean) => boolean | undefined };
}
describe('statusLineTelemetryAction', () => {
const ui = loadSettingsUi();
it('sends true on every save while the chip is on', () => {
expect(ui.statusLineTelemetryAction(true, true)).toBe(true);
expect(ui.statusLineTelemetryAction(false, true)).toBe(true);
});
it('sends false only on the save that turned the chip off', () => {
expect(ui.statusLineTelemetryAction(true, false)).toBe(false);
});
it('sends nothing from a device whose chip was already off', () => {
expect(ui.statusLineTelemetryAction(false, false)).toBeUndefined();
});
});
+2 -5
View File
@@ -49,13 +49,10 @@ describe('POST /api/status-telemetry', () => {
});
});
it('does not broadcast for an unknown session, and answers an empty body', async () => {
// Never the bare brand word: the exporter prints this answer as the
// statusline, and `codeman` on the statusline of every hand-run `claude` in
// a managed repo is the symptom discussion #405 opened with.
it('does not broadcast for an unknown session; returns the brand footer', async () => {
const res = await post({ sessionId: 'does-not-exist', data: REAL });
expect(res.statusCode).toBe(200);
expect(res.body).toBe('');
expect(res.body).toBe('codeman');
expect(h.ctx.broadcast).not.toHaveBeenCalled();
});
@@ -1,133 +0,0 @@
/**
* PUT /api/settings: the `statusLineTelemetry` ACTION field, both directions.
*
* `true` (sent on every save while the plan-usage chip is on) injects Codeman's
* statusLine exporter into every live Claude session's workspace so the chip's
* data starts flowing without a new session. `false` (sent only when the chip
* was just turned OFF on a device) takes the exporter back out of those same
* workspaces. Before this, nothing in `src/` ever called the disable path, so
* turning the chip off left the line in every repo it had ever reached
* (discussion #405).
*
* Both directions are `isOurs`-guarded in `applyStatusLineConfig`, so a
* statusLine the user wrote themselves is never added to, replaced, or removed.
* Remote-attach sessions are skipped in both: their `workingDir` is a
* `user@host:session` pseudo-path that the enable path would otherwise create
* as a junk local directory.
*
* Uses app.inject(), real temp workspaces under the test HOME. Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { createMockSession } from '../mocks/mock-session.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
import { STATUSLINE_SHIM_TOKEN } from '../../src/statusline-shim.js';
// The three service toggles start/stop real watchers from this handler; stub
// them so a settings PUT in a test never starts a filesystem watcher.
const { subagentWatcher, imageWatcher, workflowRunWatcher } = vi.hoisted(() => {
const makeWatcher = () => ({
isRunning: vi.fn(() => false),
start: vi.fn(),
stop: vi.fn(),
getStats: vi.fn(() => ({})),
watchSession: vi.fn(),
getRecentRunSummaries: vi.fn(() => []),
});
return { subagentWatcher: makeWatcher(), imageWatcher: makeWatcher(), workflowRunWatcher: makeWatcher() };
});
vi.mock('../../src/subagent-watcher.js', () => ({ subagentWatcher }));
vi.mock('../../src/image-watcher.js', () => ({ imageWatcher }));
vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher }));
const settingsFile = (dir: string) => join(dir, '.claude', 'settings.local.json');
const readSettings = (dir: string) => JSON.parse(readFileSync(settingsFile(dir), 'utf-8'));
const writeSettings = (dir: string, value: object) => {
mkdirSync(join(dir, '.claude'), { recursive: true });
writeFileSync(settingsFile(dir), JSON.stringify(value, null, 2));
};
describe('PUT /api/settings statusLineTelemetry', () => {
let h: RouteTestHarness;
let root: string;
let claudeDir: string;
let shellDir: string;
let remoteDir: string;
const put = (body: unknown) => h.app.inject({ method: 'PUT', url: '/api/settings', payload: body });
beforeEach(async () => {
h = await createRouteTestHarness(registerSystemRoutes);
root = mkdtempSync(join(tmpdir(), 'codeman-statusline-toggle-'));
claudeDir = join(root, 'claude-repo');
shellDir = join(root, 'shell-repo');
remoteDir = join(root, 'remote-attach');
for (const dir of [claudeDir, shellDir]) mkdirSync(dir, { recursive: true });
const claude = createMockSession('claude-1');
claude.workingDir = claudeDir;
const shell = createMockSession('shell-1');
shell.mode = 'shell';
shell.workingDir = shellDir;
const remote = createMockSession('remote-1');
remote.workingDir = remoteDir;
Object.assign(remote, { remote: { host: 'box', session: 'codeman-ssh-remote-1' } });
h.ctx.sessions.clear();
for (const s of [claude, shell, remote]) h.ctx.sessions.set(s.id, s);
});
afterEach(async () => {
await h.app.close();
});
it('true injects the exporter into live Claude workspaces only', async () => {
const res = await put({ statusLineTelemetry: true });
expect(res.statusCode).toBe(200);
expect(readSettings(claudeDir).statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
// A shell session has no statusline to export from.
expect(existsSync(settingsFile(shellDir))).toBe(false);
// A remote attach's workingDir is a pseudo-path: nothing must be created for it.
expect(existsSync(remoteDir)).toBe(false);
});
it('false removes the exporter it injected', async () => {
await put({ statusLineTelemetry: true });
expect(readSettings(claudeDir).statusLine).toBeDefined();
const res = await put({ statusLineTelemetry: false });
expect(res.statusCode).toBe(200);
expect(readSettings(claudeDir).statusLine).toBeUndefined();
});
it('false keeps every other key in the workspace settings file', async () => {
writeSettings(claudeDir, { permissions: { allow: ['Read'] }, hooks: { Stop: [] } });
await put({ statusLineTelemetry: true });
await put({ statusLineTelemetry: false });
expect(readSettings(claudeDir)).toEqual({ permissions: { allow: ['Read'] }, hooks: { Stop: [] } });
});
it('false never removes a statusLine the user wrote themselves', async () => {
const mine = { type: 'command', command: 'bash ~/.claude/my-statusline.sh' };
writeSettings(claudeDir, { statusLine: mine });
await put({ statusLineTelemetry: false });
expect(readSettings(claudeDir).statusLine).toEqual(mine);
});
it('false creates nothing in a workspace that never had the exporter', async () => {
await put({ statusLineTelemetry: false });
expect(existsSync(settingsFile(claudeDir))).toBe(false);
expect(existsSync(remoteDir)).toBe(false);
});
it('is an action field, never persisted into settings.json', async () => {
await put({ statusLineTelemetry: false, showTokenCount: true });
const res = await h.app.inject({ method: 'GET', url: '/api/settings' });
const stored = JSON.parse(res.body);
const settings = stored.data ?? stored;
expect(settings.showTokenCount).toBe(true);
expect('statusLineTelemetry' in settings).toBe(false);
});
});
-422
View File
@@ -1,422 +0,0 @@
/**
* The generated plan-usage statusLine shim and the command that launches it.
*
* Like the DeepSeek status shim, the shim is emitted as a STRING and executed
* by someone else, Claude Code, before every render, so tsc never sees it. The
* assertions therefore run the real file in a real `node` process, with a real
* temp HOME and a real listener, rather than inspecting the source text. The
* injected command is exercised the same way, through `sh -c`, because its
* fallback half is the only thing that renders inside a Docker case's
* container and a typo there is invisible to every other check.
*
* The load-bearing property is the pair: the shim must keep forwarding plan
* usage to Codeman AND give the user back the statusline it shadows. Losing
* either half silently defeats the feature, in one direction by blanking the
* header chip and in the other by stealing the terminal footer.
*/
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
import { execFileSync, spawn } from 'node:child_process';
import { createServer, type Server } from 'node:http';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import {
ensureStatusLineShim,
LEGACY_STATUSLINE_MARKER,
resetStatusLineShimForTest,
statusLineShimGuard,
statusLineShimPath,
STATUSLINE_SHIM_TOKEN,
} from '../src/statusline-shim.js';
import { generateStatusLineCommand, isCodemanStatusLine } from '../src/hooks-config.js';
const PORT = 3252;
/** A port nothing listens on, for the unreachable-Codeman case. Claimed here so
* the repo-wide `const PORT =` search a contributor runs finds it too. */
const PORT_DEAD = 3253;
/** Point a settings file's statusLine at a shell command. */
function writeStatusLine(file: string, command: string): void {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify({ statusLine: { type: 'command', command } }, null, 2));
}
describe('statusLine shim: provisioning', () => {
beforeEach(() => {
resetStatusLineShimForTest();
});
it('writes an executable shim that node can actually parse', () => {
const path = ensureStatusLineShim();
expect(path).toBeTruthy();
expect(existsSync(path!)).toBe(true);
expect(statSync(path!).mode & 0o777).toBe(0o700);
// `node --check` on the real file: a template-literal typo in SHIM_SOURCE is
// invisible to tsc, because the shim is a string as far as it is concerned.
expect(() => execFileSync(process.execPath, ['--check', path!], { stdio: 'pipe' })).not.toThrow();
});
it('refreshes a shim written by an older Codeman, and leaves no temp file behind', () => {
const path = statusLineShimPath();
ensureStatusLineShim();
const current = readFileSync(path, 'utf-8');
writeFileSync(path, `#!/usr/bin/env node\n// ${STATUSLINE_SHIM_TOKEN} v0\nprocess.exit(0)\n`, { mode: 0o700 });
resetStatusLineShimForTest();
ensureStatusLineShim();
expect(readFileSync(path, 'utf-8')).toBe(current);
const strays = readdirSync(dirname(path)).filter((f) => f.startsWith(STATUSLINE_SHIM_TOKEN) && f.endsWith('.tmp'));
expect(strays).toEqual([]);
});
it('re-asserts the exec bit even when the content already matches', () => {
const path = ensureStatusLineShim()!;
chmodSync(path, 0o600); // a restored backup / copied data dir
resetStatusLineShimForTest();
ensureStatusLineShim();
expect(statSync(path).mode & 0o777).toBe(0o700);
});
it('names the shim so the guard carries the ownership token', () => {
// isCodemanStatusLine decides ownership on this substring. If the file is
// ever renamed out from under it, Codeman stops recognising its own entries
// and starts treating them as hand-authored.
const guard = statusLineShimGuard();
expect(guard).toBeTruthy();
expect(guard).toContain(STATUSLINE_SHIM_TOKEN);
// Absolute node, not a bare `node`: a managed session's PATH need not have one.
expect(guard).toContain(process.execPath);
});
it('tests both paths before exec-ing, and quotes them, so a data dir with a space still runs', () => {
const node = `'${process.execPath}'`;
const shim = `'${statusLineShimPath()}'`;
expect(statusLineShimGuard()).toBe(`if [ -x ${node} ] && [ -f ${shim} ]; then exec ${node} ${shim}; fi;`);
});
});
describe('statusLine shim: rendering', () => {
let shim: string;
let server: Server;
let received: Array<{ url: string; body: string }> = [];
let footer = 'CODEMAN-FOOTER';
let workspace: string;
let fakeHome: string;
/** Run the shim the way Claude Code does: a subprocess, JSON on stdin. */
function render(
payload: object,
env: Record<string, string> = {},
cwd: string = workspace
): Promise<{ stdout: string; code: number | null }> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [shim], {
cwd,
env: { ...process.env, HOME: fakeHome, USERPROFILE: fakeHome, ...env },
stdio: ['pipe', 'pipe', 'ignore'],
});
let stdout = '';
child.stdout.on('data', (c) => (stdout += c));
child.on('close', (code) => resolve({ stdout, code }));
child.stdin.end(JSON.stringify(payload));
});
}
const managed = () => ({ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` });
beforeAll(async () => {
resetStatusLineShimForTest();
shim = ensureStatusLineShim()!;
server = createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
received.push({ url: req.url ?? '', body });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(footer);
});
});
await new Promise<void>((r) => server.listen(PORT, '127.0.0.1', r));
});
afterAll(async () => {
await new Promise<void>((r) => server.close(() => r()));
});
beforeEach(() => {
received = [];
footer = 'CODEMAN-FOOTER';
const root = mkdtempSync(join(tmpdir(), 'codeman-statusline-'));
fakeHome = join(root, 'home');
workspace = join(root, 'repo');
mkdirSync(join(fakeHome, '.claude'), { recursive: true });
mkdirSync(join(workspace, '.claude'), { recursive: true });
// The entry Codeman injects into the managed repo. Every case below has it,
// because the shim must always skip its own entry while hunting a delegate.
writeStatusLine(join(workspace, '.claude', 'settings.local.json'), `'${process.execPath}' '${shim}'`);
});
it('prints the global statusline it shadows', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('THE-USERS-LINE');
});
it('hands the delegate the same JSON Claude Code sent', async () => {
// The delegate is only useful if it sees the payload: every statusline
// script reads the model, the cwd or the rate limits off this blob.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), `bash -c 'read -r j; echo "GOT:$j"'`);
const { stdout } = await render({ cwd: workspace, model: { display_name: 'Opus 5' } });
expect(stdout).toContain('"display_name":"Opus 5"');
});
it('never delegates to its own entry, and prints nothing rather than a brand word', async () => {
// No other statusLine exists, so the only candidate is the shim's own. If
// the loop guard failed this would fork until something ran out. And with
// nothing to shadow and no Codeman to ask, the line stays blank: the bare
// word `codeman` is the symptom discussion #405 opened with.
const { stdout, code } = await render({ cwd: workspace });
expect(code).toBe(0);
expect(stdout).toBe('');
});
it('never delegates to the pre-shim inline exporter', async () => {
// An upgraded install can still have the old command in a parent settings
// file. Running it would double-report and print Codeman's footer anyway.
writeStatusLine(
join(fakeHome, '.claude', 'settings.json'),
`curl -sk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" || echo codeman`
);
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('');
});
it('prefers a project statusline to the global one', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo PROJECT');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('PROJECT');
});
it('reads project settings from the launch directory, not the current one', async () => {
// Claude Code applies a project's settings from the directory it was
// launched in (workspace.project_dir), which the blob keeps reporting after
// the working directory changes mid-session.
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo PROJECT');
const elsewhere = join(dirname(workspace), 'elsewhere');
mkdirSync(elsewhere, { recursive: true });
const { stdout } = await render(
{ cwd: elsewhere, workspace: { current_dir: elsewhere, project_dir: workspace } },
{},
elsewhere
);
expect(stdout).toBe('PROJECT');
});
it('does not walk up from the launch directory', async () => {
// Claude Code reads project settings from the launch directory alone, so a
// .claude in an ancestor is one it would have ignored. Delegating to it
// would run a statusline the user never sees otherwise.
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo ANCESTOR');
const sub = join(workspace, 'packages', 'inner');
mkdirSync(sub, { recursive: true });
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
const { stdout } = await render({ cwd: sub, workspace: { current_dir: sub, project_dir: sub } }, {}, sub);
expect(stdout).toBe('GLOBAL');
});
it('ignores a user-level settings.local.json, which Claude Code does not read', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.local.json'), 'echo NOT-A-REAL-FILE');
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('GLOBAL');
});
it('forwards telemetry to Codeman WHILE delegating', async () => {
// The whole point: taking the user's line back must not cost the header chip.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await render(
{ cwd: workspace, rate_limits: { five_hour: { used_percentage: 12, resets_at: 99 } } },
managed()
);
expect(stdout).toBe('THE-USERS-LINE');
expect(received).toHaveLength(1);
expect(received[0].url).toBe(LEGACY_STATUSLINE_MARKER);
const posted = JSON.parse(received[0].body);
expect(posted.sessionId).toBe('sess-1');
expect(posted.data.rate_limits.five_hour.used_percentage).toBe(12);
});
it("prints Codeman's own footer when there is no line to shadow", async () => {
const { stdout } = await render({ cwd: workspace }, managed());
expect(stdout).toBe('CODEMAN-FOOTER');
expect(received).toHaveLength(1);
});
it('treats the bare brand word from an older server as no telemetry', async () => {
// A pre-1.28 route answers `codeman` for a session it does not know. That
// word on the statusline is what cost discussion #405 seven repositories
// of debugging, so it must never be printed, whichever server answers.
footer = 'codeman';
const { stdout } = await render({ cwd: workspace }, managed());
expect(stdout).toBe('');
expect(received).toHaveLength(1);
});
it('skips the POST entirely outside a managed session', async () => {
// Running `claude` by hand in a managed repo must cost nothing extra.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('THE-USERS-LINE');
expect(received).toEqual([]);
});
it('falls back to the footer when the delegate fails silently', async () => {
// A delegate that exits non-zero with no output must not win over a footer
// Codeman can supply.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'exit 3');
const { stdout } = await render({ cwd: workspace }, managed());
expect(stdout).toBe('CODEMAN-FOOTER');
});
it('still prints a failing delegate that produced output', async () => {
// Plenty of statusline scripts end on the exit code of their last command.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo PARTIAL; exit 1');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('PARTIAL');
});
it('survives an unreachable Codeman and a malformed payload', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const child = spawn(process.execPath, [shim], {
cwd: workspace,
env: {
...process.env,
HOME: fakeHome,
USERPROFILE: fakeHome,
CODEMAN_SESSION_ID: 'sess-1',
// Nothing listens here.
CODEMAN_API_URL: `http://127.0.0.1:${PORT_DEAD}`,
},
stdio: ['pipe', 'pipe', 'ignore'],
});
let stdout = '';
child.stdout.on('data', (c) => (stdout += c));
child.stdin.end('not json at all');
const code = await new Promise<number | null>((r) => child.on('close', r));
expect(code).toBe(0);
expect(stdout).toBe('THE-USERS-LINE');
});
it('ignores a malformed settings file instead of dying on it', async () => {
writeFileSync(join(workspace, '.claude', 'settings.json'), '{ broken');
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout, code } = await render({ cwd: workspace });
expect(code).toBe(0);
expect(stdout).toBe('THE-USERS-LINE');
});
it('ignores a statusLine that is not a command', async () => {
writeFileSync(
join(fakeHome, '.claude', 'settings.json'),
JSON.stringify({ statusLine: { type: 'something-else', command: 'echo NOPE' } })
);
const { stdout } = await render({ cwd: workspace }, managed());
expect(stdout).toBe('CODEMAN-FOOTER');
});
});
describe('the injected statusLine command', () => {
let server: Server;
let received: string[] = [];
let fakeHome: string;
/** Run the command the way Claude Code does: through a shell, JSON on stdin. */
function run(command: string, env: Record<string, string>, stdin = '{"model":{"display_name":"Opus"}}') {
return new Promise<{ stdout: string; code: number | null }>((resolve) => {
const child = spawn('/bin/sh', ['-c', command], {
cwd: fakeHome,
env: { ...process.env, HOME: fakeHome, USERPROFILE: fakeHome, ...env },
stdio: ['pipe', 'pipe', 'ignore'],
});
let stdout = '';
child.stdout.on('data', (c) => (stdout += c));
child.on('close', (code) => resolve({ stdout, code }));
child.stdin.end(stdin);
});
}
const managed = () => ({ CODEMAN_SESSION_ID: 'sess-2', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` });
beforeAll(async () => {
resetStatusLineShimForTest();
server = createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
received.push(body);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('CODEMAN-FOOTER');
});
});
await new Promise<void>((r) => server.listen(PORT, '127.0.0.1', r));
});
afterAll(async () => {
await new Promise<void>((r) => server.close(() => r()));
});
beforeEach(() => {
received = [];
fakeHome = mkdtempSync(join(tmpdir(), 'codeman-statusline-cmd-'));
});
it('is recognised as ours by both of its halves', () => {
const command = generateStatusLineCommand();
expect(command.startsWith('if [ -x ')).toBe(true);
expect(command).toContain(STATUSLINE_SHIM_TOKEN);
expect(command).toContain(LEGACY_STATUSLINE_MARKER);
expect(isCodemanStatusLine(command)).toBe(true);
// The word the whole change exists to remove.
expect(command).not.toContain('echo codeman');
});
it('runs the shim where the shim exists', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await run(generateStatusLineCommand(), managed());
expect(stdout).toBe('THE-USERS-LINE');
expect(JSON.parse(received[0]).sessionId).toBe('sess-2');
});
it('falls through to the inline curl exporter where the shim does not exist', async () => {
// Inside a Docker case's container the workspace's settings.local.json is
// bind-mounted at the same absolute path, but neither the host's node nor
// its data dir is. The same command must still report telemetry there.
const command = generateStatusLineCommand().split(statusLineShimPath()).join(join(fakeHome, 'no-such-shim.mjs'));
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await run(command, managed());
expect(received).toHaveLength(1);
expect(JSON.parse(received[0])).toMatchObject({ sessionId: 'sess-2', data: { model: { display_name: 'Opus' } } });
// The inline half cannot delegate, so it prints the footer through.
expect(stdout).toBe('CODEMAN-FOOTER');
});
it('prints nothing, not a brand word, when the inline half has no Codeman to reach', async () => {
const command = generateStatusLineCommand().split(statusLineShimPath()).join(join(fakeHome, 'no-such-shim.mjs'));
const { stdout, code } = await run(command, { CODEMAN_API_URL: '', CODEMAN_SESSION_ID: '' });
expect(code).toBe(0);
expect(stdout).toBe('');
});
});
+225
View File
@@ -0,0 +1,225 @@
/**
* @fileoverview Terminal font weight: live apply, and the plumbing around it.
*
* Bold text on the theme's default foreground carries exactly ONE cue, the
* weight step. Claude Code marks its markdown bold with a bare `ESC[1m` and no
* colour change, and xterm substitutes a bright colour for bold only when the
* foreground is a palette index below 8, so nothing else distinguishes it. A
* family that ships only a regular and a bold face keeps that step small, and
* 400 stays 400 whatever family is picked — which is why the NORMAL slot is
* settable at all.
*
* Three things are pinned here because each fails silently:
*
* - A live save reaches the echo overlays and the Agent Teams panes. Both
* cache the weight (the overlays paint it into their spans, the panes read
* their options at construction), so without the propagation the characters
* being typed, or a pane left open across the save, keep the old weight
* beside a repainted terminal.
* - An unchanged save is a no-op, so opening and closing App Settings does not
* churn the terminal.
* - The bundled face is declared over its full axis. The `@font-face`
* descriptor, not the file, is what the browser synthesizes from: at
* `400 700` every weight below 400 renders identically to 400, so the
* setting would be inert for anyone without Fira Code or Cascadia Code
* installed.
*
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
* box; see connection-indicator.test.ts), matching terminal-font-settle.test.ts.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
function loadTerminalMixin(): Record<string, unknown> {
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
const context = vm.createContext({
console,
performance,
setTimeout,
clearTimeout,
setInterval: vi.fn(),
clearInterval: vi.fn(),
requestAnimationFrame: vi.fn(),
CodemanApp: FakeCodemanApp,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
document: undefined,
});
const constants = readFileSync(resolve(publicDir, 'constants.js'), 'utf8');
const source = readFileSync(resolve(publicDir, 'terminal-ui.js'), 'utf8');
vm.runInContext(`${constants}\n${source}`, context);
// constants.js publishes CodemanTerminalFont onto the context's window, which
// is the one the mixin closes over.
return FakeCodemanApp.prototype;
}
const mixin = loadTerminalMixin();
function fakeTerminal(options: Record<string, unknown> = {}) {
return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } };
}
function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTerminal> | null } = {}) {
const fit = vi.fn();
const teammateFits: ReturnType<typeof vi.fn>[] = [];
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
for (let i = 0; i < (opts.teammates ?? 0); i++) {
const teammateFit = vi.fn();
teammateFits.push(teammateFit);
teammateTerminals.set(`agent-${i}`, { terminal: fakeTerminal(), fitAddon: { fit: teammateFit } });
}
const app = {
applyTerminalFontWeights: mixin.applyTerminalFontWeights,
_awaitTerminalFont: vi.fn(() => Promise.resolve()),
terminal: opts.terminal === undefined ? fakeTerminal() : opts.terminal,
fitAddon: { fit },
teammateTerminals,
_localEchoOverlay: { refreshFont: vi.fn() },
_predictiveEcho: { refreshFont: vi.fn() },
_terminalFontReady: null as unknown,
};
return { app, fit, teammateFits, teammateTerminals };
}
describe('applyTerminalFontWeights', () => {
it('writes both slots to the live terminal', () => {
const { app, fit } = makeApp();
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
terminalFontWeight: '300',
terminalFontWeightBold: '800',
});
expect(app.terminal?.options.fontWeight).toBe(300);
expect(app.terminal?.options.fontWeightBold).toBe(800);
expect(fit).toHaveBeenCalled();
});
it('refreshes the echo overlays, which cache the weight and paint it', () => {
// Without this the characters being typed keep the old weight while the
// rest of the screen changes — most visible on a phone, where local echo
// is on by default.
const { app } = makeApp();
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
terminalFontWeight: '300',
});
expect(app._localEchoOverlay.refreshFont).toHaveBeenCalledTimes(1);
expect(app._predictiveEcho.refreshFont).toHaveBeenCalledTimes(1);
});
it('reaches Agent Teams panes, which read their options at construction', () => {
const { app, teammateTerminals, teammateFits } = makeApp({ teammates: 2 });
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
terminalFontWeight: '300',
terminalFontWeightBold: '800',
});
for (const [, entry] of teammateTerminals) {
expect(entry.terminal.options.fontWeight).toBe(300);
expect(entry.terminal.options.fontWeightBold).toBe(800);
}
for (const teammateFit of teammateFits) expect(teammateFit).toHaveBeenCalled();
});
it('restores xterm’s own defaults when the setting is cleared', () => {
const { app } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 800 }) });
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({});
expect(app.terminal?.options.fontWeight).toBe('normal');
expect(app.terminal?.options.fontWeightBold).toBe('bold');
});
it('does nothing when neither slot changed', () => {
// saveAppSettings runs on every close of the modal.
const { app, fit } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 'bold' }) });
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
terminalFontWeight: 300,
});
expect(fit).not.toHaveBeenCalled();
expect(app._localEchoOverlay.refreshFont).not.toHaveBeenCalled();
expect(app._awaitTerminalFont).not.toHaveBeenCalled();
});
it('re-arms the font wait, so a fit lands once the face is rasterized', () => {
const { app } = makeApp();
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
terminalFontWeight: '300',
});
expect(app._awaitTerminalFont).toHaveBeenCalledTimes(1);
expect(app._terminalFontReady).toBeInstanceOf(Promise);
});
it('survives a terminal that does not exist yet', () => {
const { app } = makeApp({ terminal: null });
expect(() =>
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
terminalFontWeight: '300',
})
).not.toThrow();
});
});
describe('bundled terminal face', () => {
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
it('is declared over its full weight axis, not xterm’s default span', () => {
// The woff2 carries a `wght` axis of 100 to 800. A narrower @font-face
// descriptor CLAMPS it: at `400 700`, 100/200/300 all render identically to
// 400 and 800 identically to 700, so the settings above would be a no-op
// for every install without Fira Code or Cascadia Code.
const face = styles.slice(styles.indexOf("font-family: 'JetBrains Mono'"));
const declared = /font-weight:\s*(\d+)\s+(\d+)/.exec(face.slice(0, face.indexOf('}')));
expect(declared, 'the bundled mono face must declare a weight RANGE').not.toBeNull();
expect(Number(declared![1])).toBeLessThanOrEqual(100);
expect(Number(declared![2])).toBeGreaterThanOrEqual(800);
});
});
describe('terminal font weight settings plumbing', () => {
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const keys = ['terminalFontWeight', 'terminalFontWeightBold'] as const;
it('offers both selects with a Default entry and the 100-900 steps', () => {
for (const id of ['appSettingsTerminalFontWeight', 'appSettingsTerminalFontWeightBold']) {
const start = html.indexOf(`<select id="${id}"`);
expect(start, `${id} missing from index.html`).toBeGreaterThan(-1);
const select = html.slice(start, html.indexOf('</select>', start));
expect(select).toContain('<option value="">');
for (let w = 100; w <= 900; w += 100) expect(select).toContain(`<option value="${w}">`);
}
});
it('treats both as per-device, which is TWO separate decisions', () => {
// Membership in displayKeys keeps one device from overwriting another's
// value; the strip before the PUT is what stops the .strict() schema from
// 400-ing the whole settings save.
const displayKeys = settingsUi.slice(settingsUi.indexOf('const displayKeys = new Set(['));
const listed = displayKeys.slice(0, displayKeys.indexOf(']);'));
const stripped = settingsUi.slice(settingsUi.indexOf('const {', settingsUi.indexOf('async saveAppSettings()')));
for (const key of keys) {
expect(listed, `${key} must be a display key`).toContain(`'${key}'`);
expect(stripped.slice(0, stripped.indexOf('} = settings;')), `${key} must be stripped from the PUT`).toContain(
`${key}: _`
);
}
});
it('applies the save to the live terminal', () => {
const save = settingsUi.slice(settingsUi.indexOf('async saveAppSettings()'));
expect(save.slice(0, save.indexOf('\n },'))).toContain('this.applyTerminalFontWeights?.(settings)');
});
});
+59
View File
@@ -12,6 +12,8 @@ function loadFontHelper() {
CodemanTerminalFont: {
DEFAULT_STACK: string;
resolve: (custom?: unknown) => string;
WEIGHT_DEFAULTS: { fontWeight: string; fontWeightBold: string };
resolveWeights: (settings?: unknown) => { fontWeight: string | number; fontWeightBold: string | number };
};
}
).CodemanTerminalFont;
@@ -56,3 +58,60 @@ describe('CodemanTerminalFont', () => {
expect(font.resolve('Hack, monospace')).toBe(`Hack, ${font.DEFAULT_STACK}`);
});
});
describe('CodemanTerminalFont.resolveWeights', () => {
const DEFAULTS = { fontWeight: 'normal', fontWeightBold: 'bold' };
it("leaves an untouched install on xterm's own defaults", () => {
// The whole feature has to be invisible until someone asks for it.
expect(font.resolveWeights(undefined)).toEqual(DEFAULTS);
expect(font.resolveWeights({})).toEqual(DEFAULTS);
expect(font.resolveWeights('nonsense')).toEqual(DEFAULTS);
expect(font.WEIGHT_DEFAULTS).toEqual(DEFAULTS);
});
it('resolves each slot independently', () => {
expect(font.resolveWeights({ terminalFontWeight: 300 })).toEqual({ fontWeight: 300, fontWeightBold: 'bold' });
expect(font.resolveWeights({ terminalFontWeightBold: 800 })).toEqual({ fontWeight: 'normal', fontWeightBold: 800 });
expect(font.resolveWeights({ terminalFontWeight: 300, terminalFontWeightBold: 800 })).toEqual({
fontWeight: 300,
fontWeightBold: 800,
});
});
it('never hands one slot the other slot default', () => {
// A shared fallback would turn an unset bold weight into a visible change.
for (const bad of [null, '', ' ', 'heavy', NaN, {}, [], true]) {
expect(font.resolveWeights({ terminalFontWeight: bad, terminalFontWeightBold: bad })).toEqual(DEFAULTS);
}
});
it('accepts the string values the select stores', () => {
expect(font.resolveWeights({ terminalFontWeight: '300', terminalFontWeightBold: '900' })).toEqual({
fontWeight: 300,
fontWeightBold: 900,
});
});
it('keeps a hand-set weight the picker does not offer', () => {
expect(font.resolveWeights({ terminalFontWeight: '350' }).fontWeight).toBe(350);
});
it("passes through xterm's own keywords unchanged", () => {
expect(font.resolveWeights({ terminalFontWeight: 'bold', terminalFontWeightBold: 'normal' })).toEqual({
fontWeight: 'bold',
fontWeightBold: 'normal',
});
});
it('rejects what xterm would reject, rather than letting it silently reset the slot', () => {
// OptionsService accepts a number in 1..1000 and falls back otherwise, so
// anything outside that range must resolve to the default here instead of
// reaching the terminal and being swapped out underneath the setting.
expect(font.resolveWeights({ terminalFontWeight: 0 }).fontWeight).toBe('normal');
expect(font.resolveWeights({ terminalFontWeight: -400 }).fontWeight).toBe('normal');
expect(font.resolveWeights({ terminalFontWeight: 1001 }).fontWeight).toBe('normal');
expect(font.resolveWeights({ terminalFontWeight: 1000 }).fontWeight).toBe(1000);
expect(font.resolveWeights({ terminalFontWeight: 1 }).fontWeight).toBe(1);
});
});
+2 -3
View File
@@ -119,9 +119,8 @@ describe('formatSessionStatusText', () => {
expect(formatSessionStatusText({ modelDisplayName: 'Opus 4.8 (1M context)' })).toBe('Opus 4.8 (1M context)');
});
it('prints nothing when there is no data, never a brand string', () => {
expect(formatSessionStatusText(null)).toBe('');
expect(formatSessionStatusText({})).toBe('');
it('falls back to a brand string when there is no data', () => {
expect(formatSessionStatusText(null)).toBe('codeman');
});
});