@promptctl/cc-candybar 1.34.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.34.0",
3
+ "version": "1.35.0",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -91,9 +91,9 @@
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.34.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.34.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.34.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.34.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.35.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.35.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.35.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.35.0"
98
98
  }
99
99
  }
package/src/check.ts CHANGED
@@ -119,6 +119,11 @@ export function checkPayload(
119
119
  weekly: { percentage: 21, resetsAt: nowSec + 5 * 86400 },
120
120
  cache: { expiresAt: nowSec + 15 * 60 },
121
121
  tmux: { session: "work" },
122
+ // `ssh: true` for the same reason `tmux.session` is populated: this
123
+ // fixture deliberately satisfies every gate so a when-gated segment
124
+ // RENDERS and its template gets checked. A local-looking fixture would
125
+ // gate the host segment off and let a typo inside it ship.
126
+ host: { name: "tester-box", user: "tester", ssh: true },
122
127
  theme: { effective: effective.theme },
123
128
  look: { effective: effective.look },
124
129
  // [LAW:one-source-of-truth] Was missing here even though EffectiveGlobals
@@ -465,6 +465,31 @@ export const RAW_DEFAULT_DSL_CONFIG = {
465
465
  // gates the input variant so unused segments cost nothing.
466
466
  "tmux.session": { kind: "input", path: "tmux.session", default: "" },
467
467
 
468
+ // Host identity — which machine this session is on, and whether the user
469
+ // arrived over SSH. All three come through the augmented payload rather
470
+ // than `kind: "env"` / `kind: "shell"`, and that is not a style choice:
471
+ //
472
+ // • `host.ssh` CANNOT be an env var here. Variables are evaluated in the
473
+ // DAEMON, which is detached and serves every session for this user at
474
+ // once — its `SSH_*` env describes whichever shell happened to spawn
475
+ // it. The fact is captured by the live client and carried as a wire
476
+ // hint (the `termCols` pattern); the payload is the only honest source.
477
+ // • `host.name`/`host.user` are machine facts the daemon reads directly,
478
+ // so they cost two syscalls instead of a per-render subprocess.
479
+ //
480
+ // Defaults are the "unknown" values, and for `ssh` that is `false`: an
481
+ // absent field (a client too old to send the hint) renders as local, which
482
+ // is the pre-feature behavior, while the input-fallback chain records a
483
+ // `last_error` so `cc-candybar debug vars` can still tell the two apart.
484
+ "host.name": { kind: "input", path: "host.name", default: "" },
485
+ "host.user": { kind: "input", path: "host.user", default: "" },
486
+ "host.ssh": {
487
+ kind: "input",
488
+ path: "host.ssh",
489
+ type: "boolean",
490
+ default: false,
491
+ },
492
+
468
493
  // Git — every field flows from the daemon's projected GitInfo payload.
469
494
  // The DSL's native `kind: "git"` source covers a 6-field subset
470
495
  // (branch/sha/dirty/ahead/behind/stash); using `input` here gives the
@@ -803,6 +828,34 @@ export const RAW_DEFAULT_DSL_CONFIG = {
803
828
  fg: "foreground",
804
829
  when: '{{ ne .tmux.session "" }}',
805
830
  },
831
+ // "You are not on your own machine." Modelled on the git-taculous zsh
832
+ // theme, which prepends `(%n@%m)` to the prompt under SSH and shows
833
+ // nothing locally — you already know your own hostname.
834
+ //
835
+ // [LAW:dataflow-not-control-flow] Presence IS the signal. There is no SSH
836
+ // "mode" and no force-on flag (git-taculous's GITTACULOUS_ENABLE_SSH_THEME
837
+ // would be a flag with no deletion date, [LAW:no-mode-explosion]); the cell
838
+ // exists exactly when the value says so, like tmux/block/weekly. A user who
839
+ // wants it always-on overrides this one segment's `when` to `"true"`.
840
+ //
841
+ // `bg: "warning"` is load-bearing, not decoration: warning is one of the
842
+ // hue-ANCHORED palette roots, so it survives every theme, look, and
843
+ // per-segment hue transposition still reading as an alert. Any other slot
844
+ // would drift with the hue stepper and could land camouflaged against its
845
+ // neighbours — exactly what a "wrong machine" warning must never do.
846
+ // `contrastOn (bgOf)` then derives a readable foreground from whatever that
847
+ // resolves to, rather than betting a fixed `foreground` stays legible.
848
+ //
849
+ // Each half falls back to "?" so a failed hostname/username read renders
850
+ // `⇄ ?@?` — still unmistakably "remote", and legibly missing its identity
851
+ // rather than a blank that reads as a rendering bug ([LAW:no-silent-failure]).
852
+ host: {
853
+ template:
854
+ '⇄ {{ .host.user | default "?" }}@{{ .host.name | default "?" }}',
855
+ bg: "warning",
856
+ fg: "{{ contrastOn (bgOf) }}",
857
+ when: "{{ .host.ssh }}",
858
+ },
806
859
  git: {
807
860
  template: GIT_TEMPLATE,
808
861
  bg: "surface-active",
@@ -1268,6 +1321,12 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1268
1321
  kind: "container",
1269
1322
  direction: "horizontal",
1270
1323
  children: [
1324
+ // Leads the identity row: the first thing to read is WHICH MACHINE,
1325
+ // because it reframes every path and branch to its right. Same
1326
+ // placement git-taculous gives `(%n@%m)` — ahead of the directory.
1327
+ // Gated off entirely on a local session, so the row still opens with
1328
+ // `directory` where it always has.
1329
+ { kind: "segment", name: "host" },
1271
1330
  { kind: "segment", name: "directory" },
1272
1331
  { kind: "segment", name: "gitaculous" },
1273
1332
  { kind: "segment", name: "toolbar" },
@@ -12,7 +12,7 @@
12
12
  import type { ClaudeHookData } from "../utils/claude";
13
13
  import { requestOutcome } from "./client-transport";
14
14
  import type { RoundTripBudgets, RoundTripOutcome } from "./client-transport";
15
- import type { Response } from "./protocol";
15
+ import type { ClientHints, Response } from "./protocol";
16
16
 
17
17
  const CONNECT_TIMEOUT_MS = 50;
18
18
  const TOTAL_BUDGET_MS = 150;
@@ -47,14 +47,17 @@ function projectOutput(
47
47
  // There is no inline render path; see src/index.ts. The caller is responsible
48
48
  // for branching on outcome.kind and deciding whether to kick, display an
49
49
  // error glyph, or print the rendered output.
50
+ // [LAW:one-source-of-truth] `hints` carries every fact the daemon cannot
51
+ // observe for itself; it is spread onto the request verbatim so this relay
52
+ // never becomes a second place that decides what the client saw.
50
53
  export function tryRenderViaDaemon(
51
54
  hookData: ClaudeHookData,
52
55
  args: string[],
53
56
  cwd: string,
54
- termCols?: number,
57
+ hints: ClientHints,
55
58
  ): Promise<ClientOutcome> {
56
59
  return requestOutcome(
57
- { kind: "render", hookData, args, cwd, termCols },
60
+ { kind: "render", hookData, args, cwd, ...hints },
58
61
  RENDER_BUDGETS,
59
62
  projectOutput,
60
63
  );
@@ -34,12 +34,61 @@ export interface RenderRequest {
34
34
  hookData: ClaudeHookData;
35
35
  args: string[];
36
36
  cwd: string;
37
- // [LAW:single-enforcer] Terminal width is captured at the trust boundary
38
- // (the client's env, where COLUMNS/ioctl are meaningful) and trusted by the
39
- // daemon. Absence means the client couldn't determine it. The wire field is
40
- // typed `number` but the wire is untrusted JSON callers MUST run it
41
- // through sanitizeTermCols at the receive boundary before using it.
37
+ // ─── Client hints ────────────────────────────────────────────────────────
38
+ // [LAW:single-enforcer] Facts only the LIVE CLIENT can observe, captured at
39
+ // the trust boundary and trusted by the daemon. The daemon is detached and
40
+ // one-per-user, so its own env answers for whichever shell spawned it
41
+ // possibly a different session, possibly hours ago. Every field below is
42
+ // typed here but arrives as untrusted JSON: callers MUST route the request
43
+ // through parseClientHints at the receive boundary, never read these
44
+ // directly. See the ClientHints doc block for the absence semantics.
42
45
  termCols?: number;
46
+ ssh?: boolean;
47
+ }
48
+
49
+ // [LAW:locality-or-seam] The seam for "a fact the daemon cannot observe about
50
+ // the session it is rendering for". `termCols` established the pattern; `ssh`
51
+ // is the second member, and the documented-but-unbuilt client-aware
52
+ // `colorCompatibility: "auto"` is the next. Naming the set as ONE type is what
53
+ // keeps that third addition a field rather than another sanitizer, another
54
+ // wire read, and another parameter threaded through the render path.
55
+ //
56
+ // [LAW:parse-dont-validate] This is the stamped type. `RenderRequest`'s
57
+ // same-named fields are raw JSON of unknown provenance; a `ClientHints` has
58
+ // crossed the checkpoint, so nothing downstream re-checks them.
59
+ //
60
+ // [LAW:types-are-the-program] Both fields are optional, but they mean
61
+ // DIFFERENT things by absence, and each is the strongest true theorem for its
62
+ // own fact:
63
+ // • `termCols` absent — the client tried and could not determine a width
64
+ // (no COLUMNS, no TTY on stderr). A genuine "unknown", reachable from any
65
+ // client version.
66
+ // • `ssh` absent — the client did not REPORT. A current client always knows
67
+ // (its own env is total on this question) and so always sends `true` or
68
+ // `false`; absence therefore means one thing only: a client too old to
69
+ // carry the field — a real case, because `cc-candybar install` stages a
70
+ // native binary that does not turn over with the npm package. Collapsing
71
+ // that to `false` here would fuse "we know it's local" with "we don't
72
+ // know" ([LAW:no-silent-failure]); instead it travels onward as an absent
73
+ // payload field, where the DSL input-fallback chain emits the declared
74
+ // default AND records a `last_error` that `cc-candybar debug vars`
75
+ // surfaces.
76
+ export interface ClientHints {
77
+ readonly termCols?: number;
78
+ readonly ssh?: boolean;
79
+ }
80
+
81
+ // [LAW:single-enforcer] The ONE checkpoint where wire-supplied client hints
82
+ // become trusted values. Per-field sanitizers stay separate (each fact has its
83
+ // own validity rule) but nothing outside this function calls them, so a new
84
+ // hint cannot reach the render path un-sanitized.
85
+ export function parseClientHints(req: RenderRequest): ClientHints {
86
+ const termCols = sanitizeTermCols(req.termCols);
87
+ const ssh = sanitizeSsh(req.ssh);
88
+ return {
89
+ ...(termCols !== undefined && { termCols }),
90
+ ...(ssh !== undefined && { ssh }),
91
+ };
43
92
  }
44
93
 
45
94
  // [LAW:no-defensive-null-guards] exception: trust boundary. The wire is
@@ -58,6 +107,14 @@ export function sanitizeTermCols(v: unknown): number | undefined {
58
107
  return n > MAX_TERM_COLS ? MAX_TERM_COLS : n;
59
108
  }
60
109
 
110
+ // [LAW:no-defensive-null-guards] exception: trust boundary, same shape as
111
+ // sanitizeTermCols. A non-boolean (absent, or a malformed/hostile frame) is
112
+ // NOT coerced to `false` — the three wire states stay three
113
+ // ([LAW:no-silent-failure]): true, false, and "no answer from this client".
114
+ export function sanitizeSsh(v: unknown): boolean | undefined {
115
+ return typeof v === "boolean" ? v : undefined;
116
+ }
117
+
61
118
  export interface ShutdownRequest {
62
119
  v: number;
63
120
  kind: "shutdown";
@@ -17,7 +17,9 @@
17
17
  // resolve falls back to the variable's declared default).
18
18
 
19
19
  import path from "node:path";
20
+ import os from "node:os";
20
21
  import type { ClaudeHookData } from "../utils/claude.js";
22
+ import type { ClientHints } from "./protocol.js";
21
23
  import type { DslConfig, VariableDecl } from "../config/dsl-types.js";
22
24
  import { walkNodes } from "../config/dsl-types.js";
23
25
  import { extractTemplateRefs } from "../config/dsl-loader.js";
@@ -96,6 +98,11 @@ export interface RenderPayload extends ClaudeHookData {
96
98
 
97
99
  readonly git?: GitPayload;
98
100
  readonly tmux?: { readonly session: string };
101
+ // [LAW:types-are-the-program] REQUIRED for the same reason theme/look are:
102
+ // the daemon assembles it every render from sources that cannot be "not
103
+ // requested" (two syscalls and one already-parsed wire hint). The fields
104
+ // INSIDE it carry the real optionality — see HostPayload.
105
+ readonly host: HostPayload;
99
106
  // [LAW:one-source-of-truth] The daemon-resolved effective theme name —
100
107
  // effectiveThemeName(sessionState.theme, globals.palette). The SAME value the
101
108
  // rendered basePalette is built from, surfaced so a trigger label can display
@@ -200,6 +207,41 @@ export interface GitPayload {
200
207
  readonly prError?: string;
201
208
  }
202
209
 
210
+ // Which machine this session is on, and whether the user got here over the
211
+ // network. The two halves have DIFFERENT provenance and that is the whole
212
+ // design ([LAW:one-source-of-truth]):
213
+ //
214
+ // • `name`/`user` are MACHINE facts. Client and daemon are the same machine
215
+ // by construction — the socket path is UID-derived and the pid mutex is
216
+ // per-user — so the daemon reading them directly cannot drift from what
217
+ // the client would have reported. Sending them over the wire would buy
218
+ // nothing and add a second source.
219
+ // • `ssh` is a SESSION fact and is the exact opposite: one daemon serves a
220
+ // local session and an SSH session simultaneously, so the daemon's own env
221
+ // answers for whichever shell spawned it. It can ONLY arrive as a client
222
+ // hint. This is the same reasoning that makes `globals.colorCompatibility:
223
+ // "auto"` deliberately unrepresentable.
224
+ //
225
+ // [LAW:no-silent-failure] Every field is optional because each can genuinely
226
+ // be unknown, and absence is preserved rather than defaulted here: `user` when
227
+ // the uid has no passwd entry, `ssh` when the client predates the hint. Both
228
+ // travel as missing keys to the DSL input-fallback chain, which emits the
229
+ // declared default AND records a `last_error` that `cc-candybar debug vars`
230
+ // surfaces — so "we don't know" stays distinguishable from "we know it's
231
+ // local", which a `?? false` here would have destroyed.
232
+ export interface HostPayload {
233
+ // The SHORT hostname — `os.hostname()` up to the first dot, the same
234
+ // projection zsh's `%m` makes. A statusbar cell identifies a machine to a
235
+ // human; the FQDN is a network address, a different fact, and a separate
236
+ // field the day something needs it.
237
+ readonly name?: string;
238
+ // The EFFECTIVE username from the passwd database, not `$USER`. The env var
239
+ // is a map that drifts (su/sudo leave it stale); the passwd entry for the
240
+ // running uid is the territory. Matches zsh's `%n`.
241
+ readonly user?: string;
242
+ readonly ssh?: boolean;
243
+ }
244
+
203
245
  export interface SessionPayload {
204
246
  readonly cost?: number;
205
247
  readonly tokens?: number;
@@ -463,6 +505,72 @@ function projectSpeedHistory(obs: SpeedObservation): string | undefined {
463
505
  return rates.join(",");
464
506
  }
465
507
 
508
+ // ─── Host identity ───────────────────────────────────────────────────────────
509
+
510
+ /**
511
+ * The short hostname: everything before the first dot, or the whole string when
512
+ * there is no dot. `os.hostname()` yields an FQDN on some hosts (macOS's
513
+ * `mymachine.local`, a DNS-configured server's `web1.prod.example.com`) and a
514
+ * bare name on others; this makes the rendered cell identify the machine the
515
+ * same way on both, which is zsh's `%m` and the projection git-taculous shows.
516
+ *
517
+ * [LAW:effects-at-boundaries] Pure and total — the syscall stays in readHost.
518
+ */
519
+ export function shortHostname(hostname: string): string {
520
+ const dot = hostname.indexOf(".");
521
+ return dot < 0 ? hostname : hostname.slice(0, dot);
522
+ }
523
+
524
+ /**
525
+ * Assemble the host identity for one render.
526
+ *
527
+ * [LAW:effects-at-boundaries] Named `read*`, not `project*`, because it is not
528
+ * pure: two syscalls happen here. That is in bounds — this module IS the
529
+ * daemon's data-assembly edge, the same edge that reads `process.env.HOME`
530
+ * below — and the point of the seam is that nothing downstream reads them
531
+ * again.
532
+ *
533
+ * [LAW:no-silent-failure] A throwing syscall (a uid with no passwd entry is the
534
+ * realistic case, in a stripped container) yields an ABSENT field plus a
535
+ * description for the caller to log, exactly like a failed git lane — never a
536
+ * fabricated name, and never an exception: the whole bar must not blank over a
537
+ * cosmetic cell.
538
+ */
539
+ function readHost(hints: ClientHints): {
540
+ readonly host: HostPayload;
541
+ readonly failures: readonly string[];
542
+ } {
543
+ const failures: string[] = [];
544
+ const attempt = (field: string, read: () => string): string | undefined => {
545
+ try {
546
+ const value = read();
547
+ // "" is not a usable identity; treat it as absence so the DSL default
548
+ // applies rather than rendering an empty `@host` fragment.
549
+ return value === "" ? undefined : value;
550
+ } catch (e) {
551
+ failures.push(`host.${field}: ${String(e)}`);
552
+ return undefined;
553
+ }
554
+ };
555
+
556
+ const name = attempt("name", () => shortHostname(os.hostname()));
557
+ const user = attempt("user", () => os.userInfo().username);
558
+ return {
559
+ host: {
560
+ ...(name !== undefined && { name }),
561
+ ...(user !== undefined && { user }),
562
+ // [LAW:one-source-of-truth] Passed straight through from the parsed
563
+ // hint. The daemon deliberately does NOT consult its own SSH_* env as a
564
+ // fallback: that env belongs to whichever shell spawned it, so a
565
+ // "helpful" fallback would confidently mislabel every session that
566
+ // daemon serves. Absent hint → absent field → declared default + a
567
+ // recorded last_error, which is the honest report of "not answered".
568
+ ...(hints.ssh !== undefined && { ssh: hints.ssh }),
569
+ },
570
+ failures,
571
+ };
572
+ }
573
+
466
574
  // ─── Builder ─────────────────────────────────────────────────────────────────
467
575
 
468
576
  // ─── Config-driven provider gating ───────────────────────────────────────────
@@ -666,6 +774,13 @@ export async function buildRenderPayload(
666
774
  // daemon already computes every one of these for renderDsl's options; this
667
775
  // is that same struct, threaded to the sole payload assembler.
668
776
  effective: EffectiveGlobals,
777
+ // [LAW:locality-or-seam] The parsed client hints, NOT the raw request — this
778
+ // function is downstream of the wire checkpoint and never re-sanitizes.
779
+ // Separate from `effective` on purpose: that struct is resolved globals (what
780
+ // the config and the session chose), this is observed session context (what
781
+ // the client saw). Fusing them would put a config-precedence chain and a
782
+ // trust boundary behind one name.
783
+ hints: ClientHints,
669
784
  ): Promise<RenderPayload> {
670
785
  const wants = (prefix: string): boolean =>
671
786
  anyPathStartsWith(neededInputPaths, prefix);
@@ -756,6 +871,10 @@ export async function buildRenderPayload(
756
871
 
757
872
  const gitProjection = projectGitInfo(gitOutcome);
758
873
  failures.push(...gitProjection.failures);
874
+ // Ungated, like `home` below: two syscalls and a hint already in hand, so a
875
+ // `wants` gate would add a branch and save nothing.
876
+ const hostProjection = readHost(hints);
877
+ failures.push(...hostProjection.failures);
759
878
  const usageValue = take(usage);
760
879
  const todayValue = take(today);
761
880
  const contextValue = take(context);
@@ -867,6 +986,7 @@ export async function buildRenderPayload(
867
986
  ...(home !== undefined && { home }),
868
987
  ...(gitProjection.git !== undefined && { git: gitProjection.git }),
869
988
  ...(tmuxValue !== undefined && { tmux: { session: tmuxValue } }),
989
+ host: hostProjection.host,
870
990
  // [LAW:one-source-of-truth] Always present — the daemon resolves every
871
991
  // one of these each render (for BuildLineOptions/basePalette), and these
872
992
  // are those exact values. No `wants` gate: each costs nothing (already in
@@ -38,7 +38,7 @@ import {
38
38
  PROTOCOL_VERSION,
39
39
  encodeFrame,
40
40
  makeFrameReader,
41
- sanitizeTermCols,
41
+ parseClientHints,
42
42
  } from "./protocol";
43
43
  import type { Request, Response } from "./protocol";
44
44
  import { GitDataProvider } from "./cache/git";
@@ -836,17 +836,21 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
836
836
  req.cwd,
837
837
  sessionConfigFile,
838
838
  );
839
- // [LAW:single-enforcer] Width capture lives at the wire boundary.
840
- // The client (Rust + TTY) is the only process that can see the real
841
- // terminal; the daemon is detached. We do NOT consult getTerminalWidth's
842
- // env/stderr fallbacks here — they would let the daemon's stale
843
- // launch-time COLUMNS env shape rendering for a different terminal,
844
- // which is exactly the wrong source.
839
+ // [LAW:parse-dont-validate] The ONE checkpoint for everything the client
840
+ // observed and the daemon cannot. Raw `req.*` hint fields are not read
841
+ // past this line; `hints` is the stamped type the render path consumes.
842
+ //
843
+ // [LAW:single-enforcer] Every hint is captured client-side because the
844
+ // daemon is detached and shared: its env answers for whichever shell
845
+ // spawned it. We do NOT consult getTerminalWidth's env/stderr fallbacks
846
+ // for width, and we do NOT consult SSH_* for remoteness — both would
847
+ // describe a different session than the one being rendered.
845
848
  // [LAW:one-source-of-truth] Both branches feed raw cols through
846
849
  // applyClaudeCodeReserve, so `width` always means "usable cells
847
850
  // post-reserve" with no semantic split between wire-supplied and
848
851
  // fallback values.
849
- const termCols = sanitizeTermCols(req.termCols);
852
+ const hints = parseClientHints(req);
853
+ const termCols = hints.termCols;
850
854
  const width = applyClaudeCodeReserve(termCols ?? DEFAULT_TERMINAL_WIDTH);
851
855
  const renderOpts: BuildLineOptions = { ...RENDER_OPTS_BASE, width };
852
856
  // [LAW:dataflow-not-control-flow] Two outcomes fall out of one rule:
@@ -926,6 +930,7 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
926
930
  req.cwd,
927
931
  entry.state.neededInputPaths,
928
932
  effective,
933
+ hints,
929
934
  );
930
935
  // [LAW:one-source-of-truth][LAW:dataflow-not-control-flow] basePalette
931
936
  // is derived from the same effective theme resolved above — so a theme
@@ -47,13 +47,15 @@
47
47
  },
48
48
 
49
49
  // ── Segments: how each piece of data is drawn ────────────────────────────
50
- // `bg`/`fg` are palette spec strings. `fg: 'auto'` picks a readable foreground
51
- // for the segment's background. `when` hides a segment when it evaluates false.
50
+ // `bg`/`fg` are palette spec strings, and either may also be a template
51
+ // that computes a color `fg: '{{ contrastOn (bgOf) }}'` below picks
52
+ // black or white, whichever is readable against this segment's own
53
+ // background. `when` hides a segment when it evaluates false.
52
54
  segments: {
53
55
  user: {
54
56
  template: ' {{ .user }} ',
55
57
  bg: 'primary',
56
- fg: 'auto',
58
+ fg: '{{ contrastOn (bgOf) }}',
57
59
  },
58
60
  directory: {
59
61
  template: ' {{ .here }} ',
@@ -63,14 +65,14 @@
63
65
  branch: {
64
66
  template: ' {{ .branch }} ',
65
67
  bg: 'accent',
66
- fg: 'auto',
68
+ fg: '{{ contrastOn (bgOf) }}',
67
69
  when: '{{ ne .branch "" }}', // hidden entirely outside a git repo
68
70
  palette: 'gruvbox', // this segment pulls its OWN palette (per-segment switch)
69
71
  },
70
72
  model: {
71
73
  template: ' {{ .model }} ',
72
74
  bg: 'secondary',
73
- fg: 'auto',
75
+ fg: '{{ contrastOn (bgOf) }}',
74
76
  when: '{{ ne .model "" }}',
75
77
  },
76
78
  session: {
@@ -81,7 +83,7 @@
81
83
  clock: {
82
84
  template: ' {{ .clock }} ',
83
85
  bg: 'primary',
84
- fg: 'auto',
86
+ fg: '{{ contrastOn (bgOf) }}',
85
87
  },
86
88
  },
87
89
 
package/src/index.ts CHANGED
@@ -36,6 +36,33 @@ function detectTermCols(): number | undefined {
36
36
  return undefined;
37
37
  }
38
38
 
39
+ // The env vars an SSH login shell inherits from sshd. Any one of them present
40
+ // and non-empty means this session arrived over the network.
41
+ //
42
+ // [LAW:one-source-of-truth] This vocabulary is mirrored by the Rust client
43
+ // (rust-client/src/main.rs) and diffed by scripts/check-protocol.mjs, which
44
+ // anchors on the declaration below — keep it a named const holding string
45
+ // literals, or repoint the CHECKS row in the same commit. Both runtimes must
46
+ // agree on what "SSH" means or the fast path and the fallback path would
47
+ // disagree about the same session.
48
+ //
49
+ // All three are checked, not just SSH_CONNECTION: SSH_CLIENT is what older
50
+ // sshd builds (and the user's git-taculous zsh theme) key on, and SSH_TTY is
51
+ // the one that survives some `sudo` env_keep policies. Extra names can only
52
+ // widen recall of a fact that is otherwise reported as a plain `false`.
53
+ const SSH_ENV_VARS = ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"] as const;
54
+
55
+ // [LAW:dataflow-not-control-flow] A fold over the vocabulary, not a chain of
56
+ // ifs — adding a name is a data edit.
57
+ //
58
+ // Unlike detectTermCols this is TOTAL: the client reads its own environment, so
59
+ // "no SSH var set" is the affirmative answer "local", never a failure to
60
+ // determine. It therefore always reports, and the daemon reads an ABSENT `ssh`
61
+ // hint as "this client is too old to answer" rather than as "local".
62
+ function detectSsh(): boolean {
63
+ return SSH_ENV_VARS.some((name) => (process.env[name] ?? "") !== "");
64
+ }
65
+
39
66
  function showHelpText(): void {
40
67
  console.log(HELP_TEXT);
41
68
  }
@@ -134,15 +161,18 @@ echo '{"session_id":"test-session","workspace":{"project_dir":"/path/to/project"
134
161
  // caches). On daemon miss we spawn detached and emit empty output; the
135
162
  // next status-line refresh hits the warm daemon and renders for real.
136
163
  //
137
- // [LAW:single-enforcer] Terminal width is captured here, in the user's
164
+ // [LAW:single-enforcer] Client hints are captured here, in the user's
138
165
  // shell environment, then trusted by the daemon. The daemon's own env
139
- // reflects whichever shell launched it minutes/hours ago, so it can't
140
- // measure the active terminal only the live client can.
166
+ // reflects whichever shell launched it minutes/hours ago, so it can
167
+ // measure neither the active terminal nor whether THIS session came in
168
+ // over SSH — only the live client can. One daemon serves a local session
169
+ // and an SSH session at the same time, so the answer genuinely differs per
170
+ // request.
141
171
  const outcome = await tryRenderViaDaemon(
142
172
  hookData,
143
173
  process.argv,
144
174
  process.cwd(),
145
- detectTermCols(),
175
+ { termCols: detectTermCols(), ssh: detectSsh() },
146
176
  );
147
177
  // [LAW:types-are-the-program] Three variants, one per outcome kind. The
148
178
  // "kick on every failure" pattern was the load-bearing half of the