SLOPSHOPPER

wasitme

A /wasitme pane and a quiet band that read one local file (~/.wasitme/glance.json), a /wasitme:report skill, and two hooks that start the local wasitme scan…

newpanebandcommandtimer
v0.1.0MITupdated 2026-10-08draarivpatel-ui/wasitme/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wasitme
│ ┃ wasitme ✕ › fix the failing auth test and add an audit log call │ ┃ [ Check again ] [ Hide ]wasitme │ ┃ ⏺ Read(src/auth.ts) │ ┃ Cannot find the wasitme data file ⎿ Read 6 lines │ ┃ Set "Glance file" in this plugin's options ⏺ Update(src/auth.ts) │ ┃ to the absolute path of glance.json, then ⎿ Added 2 lines, removed 1 line │ ┃ run ~/.local/bin/wasitme scan. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ local only │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /wasitme │ ⎿ wasitme: wasitme pane opened. Cannot find the wasitme data file. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · wasitme
[ Check again ] [ Hide ]wasitme Cannot find the wasitme data file Set "Glance file" in this plugin's options to the absolute path of glance.json, then run ~/.local/bin/wasitme scan. local only
README

wasitme plugin (Claude Code mod + report skill + hooks)

The plugin includes a mod: code that runs inside Claude Code with your permissions. Claude Code's install screen shows only its generic "make sure you trust a plugin" warning (DECISIONS D50), so this is the disclosure. What the mod may call is frozen (D25), and claude plugin validate prints it:

calls: $.clock.every, $.clock.now, $.command.register, $.fs.read, $.fs.stat, $.state.get, $.state.set, $.ui.close, $.ui.open, $.ui.resolve

It reads one file, ~/.wasitme/glance.json, and never runs a program, reads the environment, writes a file or uses the network. To find that file it also reads $.plugin.root, the plugin's own folder: a value the host hands the mod, not a call, so calls: does not list it (below).

wasitme is an independent project, not affiliated with or endorsed by Anthropic or OpenAI.

Layout

PathWhat
.claude-plugin/plugin.jsonManifest. Its version is the engine's, written by scripts/plugin-manifests.mjs (one source for every plugin manifest, D49); a bump is what moves an install forward, because third-party auto-update is off (D25).
hooks/hooks.jsonThe mod module, plus two command hooks that run scripts/session-start.sh / scripts/session-end.sh by absolute path.
scripts/session-start.sh, scripts/session-end.shSessionStart: the sandboxed project snapshot, then kick the scan LaunchAgent. SessionEnd: the kick only. Everything comes from ~/.wasitme/engine.env.
scripts/run.shThe report skill's entry point (and the Codex skill's): finds the installed engine and runs report or status (below).
mod/register.tsWiring only: /wasitme, the pane, the quiet band, the once-a-minute re-check.
mod/glance.tsPure: find, parse, bound and classify ~/.wasitme/glance.json (contract wasitme.glance/1, frozen).
mod/theme.ts, mod/render.tsEverything visual: theme.ts maps the design system onto the mod, render.ts lays it out. The engine owns every finding word; the mod only maps fields.
mod/design-tokens.tsdesign/system/generated/tokens.ts, byte for byte (a mod can only import files inside its plugin folder).
types/index.d.ts$.state contract and the parsed glance shapes. Shipped, with tsconfig.json.
skills/report/SKILL.md/wasitme:report: runs scripts/run.sh report --md and presents the report honestly.
tests/claude plugin test plugin (the mod), sh tests/hooks.sh (the hook scripts), sh tests/run-sh.sh (run.sh). The goldens are the shared contract/fixtures/, embedded by tests/fixtures/sync.mjs; there are no plugin-private fixtures.

An earlier release allow-list named hooks/register.tsx; the mod actually lives in mod/*.ts (hooks.json points at ../mod/register.ts), so the release step ships mod/.

How the mod finds the glance file

$.fs does not expand ~, and the mod may not read the environment. So it derives the home directory from where the plugin lives: <home>/.claude*/plugins/... (a marketplace install's cache copy), <home>/.wasitme/... (the full install, whose plugin root is ~/.wasitme/current/plugin), or <home>/.local/share/wasitme/..., and reads <home>/.wasitme/glance.json. Setup writes the glancePath option anyway; a plugin loaded some other way (--plugin-dir, a custom config directory or prefix) needs it (absolute, or ~/...). If neither works, the pane says so and how to fix it.

What the mod shows

The pane follows the design system's mod-pane screens (design/system/screens/text-*.png, about 64 × 18 cells):

  • Buttons first (Check again, Hide), so an 80-column inline pane never clips them, and the wordmark at the right.
  • Per agent, the CLI's sections in the engine's lead (D28): timeline-led (the default, D61) opens on What changed, then Finding, Signals, Next; verdict-led opens on Finding. The UI says "Finding" (D57); the engine and the contract keep "verdict".
  • What changed is the case line: your changes on canary stickers numbered 1, 2, 3 above a dashed rule, the agent's on blue stickers lettered A, B, C below it, one column per day with that day's k / n under it, and every change of the strip's window listed with its side. The pane's width decides how many days fit (14 at 64 columns).
  • Finding is the state glyph (·┄· too early to tell, ─── no detectable change, ■─▲ can't tell which, ■── your side, ──▲ the agent; ─╱─ out of date), the engine's label (on its sticker for your side or the agent), the deck (headline) and the note (because). When it is too early to tell it says what is still missing, and never a date (D66): "No date yet: it depends on how your sessions go."
  • The sticker colours are the tokens' fixed fill and ink: the mod cannot see which Claude Code theme is on without a call outside its frozen list, and a sticker carries its own background and text colour. The Desktop Code tab's colours are assumed, not captured (D57).

The display rules are the contract's (docs/CONTRACT.md#display-rules): a different schema id is "wasitme parts are out of sync", a file that does not promise privacy.containsText: false is refused, a glance older than its own staleAfterSec (default 2 hours) or more than 5 minutes in the future is out of date, and an unknown state is unclear. The band speaks only when the engine wrote a band line and the finding is your side or the agent's.

Hooks

Both hooks run /bin/sh "${CLAUDE_PLUGIN_ROOT}/scripts/session-start.sh" (or session-end.sh). The command lines stay byte-identical across versions. Everything the script runs comes from ~/.wasitme/engine.env, which the installer writes: key=value lines read with sed, never sourced. It never runs a PATH-resolved wasitme (hooks run in the session's folder, so a repo that ships its own wasitme behind a relative PATH entry must never be reached), sets its own PATH and calls system tools by absolute path, drops NODE_OPTIONS, NODE_PATH and the other variables node reads at startup (a project can set environment variables for its sessions, and NODE_OPTIONS could otherwise load the project's code into the hook's node or widen its sandbox), drops OPENSSL_CONF, OPENSSL_MODULES and OPENSSL_ENGINES (node's OpenSSL reads its configuration file from OPENSSL_CONF before any JavaScript runs and outside the permission sandbox; a configuration can name a provider module to load as native code, or simply stop node), and prints nothing (a SessionStart hook's stdout would reach the model's context). scripts/run.sh, the report skill's entry point, drops the same variables.

  • SessionStart (async, startup|resume; D32/D60):
  • takes cwd and session_id from the hook payload on stdin (at most 256 KiB, bounded sed patterns). The cwd must be absolute and strictly under $HOME (its resolved path too), with no .., *, quote or escape;
  • runs the engine's project snapshot under node's permission sandbox: <node> <flag> --allow-fs-read=<~/.wasitme> --allow-fs-read=<cwd> --allow-fs-write=<~/.wasitme> <cli> hook session-start --cwd <cwd> [--session <id>], one flag per path plus one for each path's resolved spelling when it differs (a grant covers only the spelling it is given). <flag> is the one the installer feature-tested (--permission or --experimental-permission); if node rejects it (exit 9, e.g. after a Node upgrade) the hook retries once with the other. No recorded flag means no snapshot (fail closed);
  • kicks the scan LaunchAgent (/bin/launchctl kickstart gui/<uid>/<label>, never -k).
  • SessionEnd (prompt_input_exit|logout|other, so /clear and /resume do not scan): the kick only. Claude Code gives a plugin's SessionEnd about 1.5 s whatever timeout says (D50), so the script traps TERM.

Every step is a silent no-op unless its guards hold: macOS; engine.env is a regular file, not a symlink, owned by the user, mode 0600; node and cli are absolute and exist; the label is dev.wasitme.scan or dev.wasitme.scan.<suffix>. sh tests/hooks.sh covers each guard, and runs the snapshot command once with the real node --permission against a stand-in engine (project readable, the rest of the home and child processes denied).

The report skill and run.sh

/wasitme:report runs /bin/sh "${CLAUDE_PLUGIN_ROOT}/scripts/run.sh" report --md before the model sees the skill. The skill's allowed-tools allows only that command (run.sh report and its flags): without it, Claude Code does not run the step (checked on 2.1.289 with a throwaway config).

run.sh never uses a PATH-resolved wasitme (D46). It takes the absolute node and cli from ~/.wasitme/engine.json (a regular file owned by you, not writable by group or others), else the installed engine behind ~/.wasitme/current, else the engine bundled with the plugin (scripts/wasitme.mjs, a release step) with --read-only. Every file it would run must pass the same ownership check; node is the recorded one, or /opt/homebrew/bin/node, /usr/local/bin/node, or an absolute entry of your PATH outside the session's folder, and must be 22 or newer. It runs only report or status, from /, and drops WASITME_HOME before the engine starts (a project's settings can set environment variables for a session; report scans and writes its results into the engine's data folder, which stays the install's ~/.wasitme). With the bundled engine only report gets --read-only; status reads what is there. When something is missing it prints one plain line and exits 0, because a failed skill step makes Claude Code print the plugin's absolute path into the conversation.

Codex (plugin-codex/, D32/D49)

Codex gets its own marketplace root, plugin-codex/ (marketplace wasitme-codex, plugin wasitme@wasitme-codex): skills only, never a hooks/ folder. It holds exactly .agents/plugins/marketplace.json, .codex-plugin/plugin.json, plugin.json and skills/report/SKILL.md, because Codex copies the whole root into its cache. The three JSON files come from scripts/plugin-manifests.mjs (one source; Codex installs the root plugin.json's version when the two disagree). The skill tells Codex to run /bin/sh "$HOME/.wasitme/current/plugin/scripts/run.sh" report --md --agent codex, so it needs the full install.

Codex stores the resolved marketplace path, so the current symlink never carries an update to it. The order (D49):

  • install: codex plugin marketplace add ~/.wasitme/current/plugin-codex, then codex plugin add wasitme@wasitme-codex;
  • update: codex plugin marketplace remove wasitme-codex, add it again at ~/.wasitme/current/plugin-codex, then codex plugin add wasitme@wasitme-codex;
  • uninstall: codex plugin remove wasitme@wasitme-codex, then codex plugin marketplace remove wasitme-codex;
  • never prune a version dir Codex's config still points at.

scripts/test/plugin-codex.test.mjs runs that order against a codex stand-in (scripts/test/fixtures/codex-standin.mjs) in a temp CODEX_HOME; the real codex is never started. How a person invokes the skill in Codex is documented only after a live check with a real Codex login (D49).

What the mod may call

node tests/check-calls.mjs runs claude plugin validate (with a throwaway HOME and CLAUDE_CONFIG_DIR, so the real ~/.claude is never touched) and fails if the hooks: or calls: lines leave the allow-list in that file (no http.fetch, process.run, env.get, fs.write, no hooks on tool.call or prompt.submit).

Reports from the plugin alone leave out your configuration history

When no installed engine is found, scripts/run.sh falls back to the engine bundled with the plugin and runs it with --read-only. Read-only runs, and every --until run, do not load the saved history of instruction-file, MCP, skill and hook changes or the project snapshots. So a plugin-only report can differ from what the app and the plain command line show, and it cannot call a change "Agent side" by elimination, which needs days wasitme fully observed.

Working on it

node tests/fixtures/sync.mjs             # re-embed contract/fixtures/ and re-copy the design tokens
node tests/fixtures/sync.mjs --check     # fail if either is out of date
node ../scripts/plugin-manifests.mjs     # write every plugin manifest from engine/package.json (--check to verify)
node tests/check-calls.mjs               # hold hooks/calls to the allow-list
sh tests/hooks.sh                        # the hook scripts, with a throwaway HOME
sh tests/run-sh.sh                       # run.sh, with a throwaway HOME and a stand-in engine
claude plugin test plugin                # the mod's tests (run it with a throwaway CLAUDE_CONFIG_DIR)

Type-checking needs the mod API declarations, which Claude Code writes into .claude-plugin/types/ when it loads this folder in dev mode (claude --plugin-dir plugin), or /plugin-types into .claude/types/; both are ignored by git. Then tsc -p tsconfig.json from this folder.

Source 6 files
mod/register.ts 145 lines
1// wasitme mod: wiring only. It reads the glance file the wasitme CLI keeps
2// fresh, keeps what it learned in $.state, and draws it through render.ts.
3//
4// What it does: registers a bare /wasitme command that opens a pane, draws that
5// pane, and draws an optional quiet band above the prompt.
6//
7// What it may call on `$` (kept minimal; `claude plugin validate` prints the
8// list as `calls:` and tests/check-calls.mjs holds it to an allow-list):
9//   fs.stat, fs.read    the glance file only; never written
10//   clock.now/every     staleness, and the once-a-minute cheap re-check
11//   command.register    /wasitme
12//   ui.open/close/resolve  the pane and the elements it draws with
13//   state.get/set       (through atom/read/update) what the last refresh found
14// Not used, on purpose: http.fetch, process.run, env.get, fs.write, tool.call
15// or prompt.submit hooks. Every `$.noun.method` is spelled literally because the
16// host reads them off this source.
17
18import { atom, read, update } from 'claude-code'
19import type { EngineInterface, Register } from 'claude-code'
20
21import type { GlanceLoad, GlanceSnapshot } from '../types'
22import { MAX_GLANCE_BYTES, classify, parseGlance, resolveGlancePath } from './glance'
23import { commandSummary, renderBand, renderPane } from './render'
24
25const PANE = 'wasitme'
26const REFRESH_MS = 60_000
27
28const snapshot = atom({ plugin: 'wasitme', key: 'snapshot' } as const, null as GlanceSnapshot | null)
29const isBandHidden = atom({ plugin: 'wasitme', key: 'isBandHidden' } as const, false)
30
31function failed(problem: 'unlocated' | 'missing' | 'unreadable'): GlanceLoad {
32  return { ok: false, problem, found: '' }
33}
34
35// A re-read of an unchanged file gives the same answer, except after a read
36// error, which is worth retrying.
37function isDeterministic(previous: GlanceSnapshot): boolean {
38  return previous.load.ok || previous.load.problem === 'invalid' || previous.load.problem === 'mismatch'
39}
40
41// Top-level on purpose: the host follows `$` only into functions declared at
42// the top of this file. Never throws on the file's account: whatever goes wrong
43// becomes a `problem` for the drawing to explain, and the error text (which
44// carries paths) is dropped.
45async function refresh($: EngineInterface, glancePath: unknown, force: boolean): Promise<void> {
46  const nowMs = await $.clock.now()
47  const path = resolveGlancePath(glancePath, $.plugin.root)
48  if (path === null) {
49    await update($, snapshot, () => ({ load: failed('unlocated'), mtimeMs: null, size: null, nowMs }))
50    return
51  }
52
53  let stat
54  try {
55    stat = await $.fs.stat(path)
56  } catch (err) {
57    const isMissing = /ENOENT|no such file/i.test(err instanceof Error ? err.message : String(err))
58    await update($, snapshot, () => ({
59      load: failed(isMissing ? 'missing' : 'unreadable'),
60      mtimeMs: null,
61      size: null,
62      nowMs,
63    }))
64    return
65  }
66
67  if (stat.kind !== 'file' || stat.size === 0 || stat.size > MAX_GLANCE_BYTES) {
68    await update($, snapshot, () => ({ load: failed('unreadable'), mtimeMs: stat.mtimeMs, size: stat.size, nowMs }))
69    return
70  }
71
72  // Unchanged file: keep what was parsed, only move the clock (staleness and
73  // "updated 6h ago" depend on it). The file is small; this saves a read a minute.
74  const previous = await read($, snapshot)
75  if (!force && previous !== null && previous.mtimeMs === stat.mtimeMs && previous.size === stat.size && isDeterministic(previous)) {
76    await update($, snapshot, () => ({ ...previous, nowMs }))
77    return
78  }
79
80  let load: GlanceLoad
81  try {
82    load = parseGlance(await $.fs.read(path))
83  } catch {
84    load = failed('unreadable')
85  }
86  await update($, snapshot, () => ({ load, mtimeMs: stat.mtimeMs, size: stat.size, nowMs }))
87}
88
89export const register: Register = (on, options) => {
90  const glancePath = options.glancePath
91  const showBand = options.showBand !== false
92  let isTicking = false
93
94  on('session.start', async ($, e, next) => {
95    // A failed registration must not take the band down with it.
96    await $.command
97      .register({
98        name: 'wasitme',
99        description: 'Was it me, or the agent? Open the wasitme pane',
100        immediate: true,
101      })
102      .catch(() => undefined)
103
104    await refresh($, glancePath, true).catch(() => undefined)
105    if (!isTicking) {
106      isTicking = true
107      $.clock.every(REFRESH_MS, () => void refresh($, glancePath, false).catch(() => undefined))
108    }
109    return next(e)
110  })
111
112  on('command.run', { command: 'wasitme' }, async $ => {
113    await refresh($, glancePath, true).catch(() => undefined)
114    const opened = await $.ui.open({ id: PANE, title: 'wasitme' })
115    const display = classify(await read($, snapshot))
116    // Short on purpose: the model reads command output too.
117    return { text: commandSummary(display, opened.isPlaced) }
118  })
119
120  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
121    const kit = $.ui.resolve(e)
122    const display = classify(await read($, snapshot))
123    return renderPane(kit, display, {
124      columns: e.props.bodyColumns,
125      actions: {
126        onRefresh: () => void refresh($, glancePath, true).catch(() => undefined),
127        onClose: () => void $.ui.close({ id: PANE }),
128      },
129    })
130  })
131
132  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
133    // Yield to a survey, and stay out of the way when the person hid the band.
134    if (!showBand || e.props.hasSurvey || (await read($, isBandHidden))) return next(e)
135
136    const display = classify(await read($, snapshot))
137    const kit = $.ui.resolve(e)
138    const band = renderBand(kit, display, {
139      columns: e.props.bodyColumns,
140      actions: { onHide: () => void update($, isBandHidden, () => true) },
141    })
142    return band ?? next(e)
143  })
144}
145
mod/glance.ts 453 lines
1// Pure data layer of the wasitme mod: finds, parses, bounds and classifies the
2// glance file (contract/glance.v1.schema.json, frozen; display rules in
3// docs/CONTRACT.md). No `$`, no drawing, no copy: everything here is testable
4// with plain values, and nothing in it is a visual choice (those live in
5// theme.ts and render.ts).
6//
7// Posture: the glance file is local but not trusted. It is parsed defensively
8// (wrong types are dropped, never thrown on), every string is stripped of
9// control characters and bounded before it can reach a terminal, and a file
10// that does not promise `privacy.containsText: false` is refused outright.
11
12import type {
13  GlanceAgent,
14  GlanceCounts,
15  GlanceEvent,
16  GlanceFamily,
17  GlanceFile,
18  GlanceGate,
19  GlanceKN,
20  GlanceLead,
21  GlanceLoad,
22  GlanceMetric,
23  GlanceMetricStatus,
24  GlanceProblem,
25  GlanceProgress,
26  GlanceReason,
27  GlanceRole,
28  GlanceScanError,
29  GlanceSide,
30  GlanceSnapshot,
31  GlanceStrength,
32  GlanceStrip,
33  GlanceVerdict,
34} from '../types'
35
36export const GLANCE_SCHEMA = 'wasitme.glance/1'
37
38/** The contract says <= 16 KB; anything past four times that is not a glance. */
39export const MAX_GLANCE_BYTES = 64 * 1024
40
41/** The contract's default when a glance carries no `staleAfterSec` (2 hours). */
42export const DEFAULT_STALE_AFTER_MS = 7_200_000
43
44/** The `staleAfterSec` range the contract allows; anything else falls back to the default. */
45const STALE_AFTER_SEC_MIN = 60
46const STALE_AFTER_SEC_MAX = 604_800
47
48/** A `generatedAt` further ahead of the clock than this means one of the clocks is wrong (contract: 300 s). */
49export const FUTURE_SKEW_MS = 5 * 60_000
50
51/** More agents than this are not drawn (the contract knows two). */
52export const MAX_AGENTS = 4
53
54/** Counts (exchanges, days, ...) beyond this are not believable and are dropped. */
55export const MAX_COUNT = 1_000_000_000
56
57// ---- bounding strings and numbers ----------------------------------------
58
59// What is never drawn, because a terminal or the model reading command output could
60// act on it, or because the person could not see it:
61//   \p{Cc}  C0, DEL and C1 controls (ESC among them, so no escape sequence survives)
62//   \p{Cf}  format characters: soft hyphen, zero-width and bidi controls, BOM, and the
63//           tag characters (U+E0001, U+E0020-E007F) that can smuggle hidden text
64//   \p{Cs}  lone surrogates (a well-formed pair is one code point and is not matched)
65//   \p{Zl}\p{Zp}  line and paragraph separators
66// plus ranges Unicode leaves unassigned or non-Cf but that hide or alter text: U+2060-206F
67// (invisible operators, with the unassigned U+2065), the whole tag block U+E0000-E007F, and
68// the variation selectors U+FE00-FE0F and U+E0100-E01EF.
69const UNSAFE = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\u2060-\u206f\u{E0000}-\u{E007F}\ufe00-\ufe0f\u{E0100}-\u{E01EF}]/gu
70
71/** A display-safe string: controls stripped, whitespace collapsed, at most `max` characters. */
72export function clean(value: unknown, max: number): string {
73  if (typeof value !== 'string') return ''
74  // Cut by code point, never by UTF-16 unit, so a surrogate pair is not split in two.
75  const head = Array.from(value).slice(0, max * 4).join('')
76  const chars = Array.from(head.replace(UNSAFE, ' ').replace(/\s+/g, ' ').trim())
77  if (chars.length <= max) return chars.join('')
78  return chars.slice(0, Math.max(0, max - 1)).join('').trimEnd() + '…'
79}
80
81type Obj = Record<string, unknown>
82
83function isObject(value: unknown): value is Obj {
84  return typeof value === 'object' && value !== null && !Array.isArray(value)
85}
86
87function finite(value: unknown): number | null {
88  return typeof value === 'number' && Number.isFinite(value) ? value : null
89}
90
91function count(value: unknown): number | null {
92  const n = finite(value)
93  return n !== null && n >= 0 && n <= MAX_COUNT ? Math.floor(n) : null
94}
95
96function pick<T extends string>(value: unknown, allowed: readonly T[]): T | null {
97  return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : null
98}
99
100function list(value: unknown, max: number): unknown[] {
101  return Array.isArray(value) ? value.slice(0, max) : []
102}
103
104// ---- time -----------------------------------------------------------------
105
106// RFC 3339 `date-time`, which the contract requires: a date, `T`, a time, and an offset
107// (`Z` or `+hh:mm`). `Date.parse` is not used on the raw text: it reads a timestamp with no
108// offset as the local time of whoever is looking (hours off, in either direction), accepts
109// non-ISO text, and rolls "February 30" over into March.
110const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?([Zz]|[+-]\d{2}:\d{2})$/
111
112function daysInMonth(year: number, month: number): number {
113  if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28
114  return [4, 6, 9, 11].includes(month) ? 30 : 31
115}
116
117/**
118 * Milliseconds since the epoch for an RFC 3339 date-time, or null for anything else: no
119 * offset, a date alone, an impossible date or time, stray text. A leap second (`:60`) counts
120 * as `:59`. The glance's own timestamp is never guessed at: null makes the pane say it cannot
121 * tell how old the scan is.
122 */
123export function parseTimestamp(text: string): number | null {
124  const match = RFC3339.exec(text)
125  if (match === null) return null
126  const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number) as [
127    number, number, number, number, number, number,
128  ]
129  const zone = match[8] ?? 'Z'
130  const offsetHours = zone.length === 6 ? Number(zone.slice(1, 3)) : 0
131  const offsetMinutes = zone.length === 6 ? Number(zone.slice(4, 6)) : 0
132  if (
133    month < 1 || month > 12 ||
134    day < 1 || day > daysInMonth(year, month) ||
135    hour > 23 || minute > 59 || second > 60 ||
136    offsetHours > 23 || offsetMinutes > 59
137  ) {
138    return null
139  }
140  // Every field is checked, and the string is rebuilt in the one shape ECMAScript defines (four-digit
141  // year, three-digit fraction, explicit offset), so Date.parse has nothing to guess at.
142  const fraction = (match[7] ?? '').slice(0, 3).padEnd(3, '0')
143  const pad = (n: number) => String(n).padStart(2, '0')
144  const sign = zone === 'Z' || zone === 'z' ? '+' : zone.slice(0, 1)
145  const iso =
146    `${match[1]}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}:${pad(Math.min(second, 59))}.${fraction}` +
147    `${sign}${pad(offsetHours)}:${pad(offsetMinutes)}`
148  const ms = Date.parse(iso)
149  return Number.isFinite(ms) ? ms : null
150}
151
152// ---- parsing --------------------------------------------------------------
153
154// The frozen vocabulary (D22). Anything else is `unclear` (the contract: "render
155// unknown states as 'unclear'"); the pre-freeze names are not special-cased.
156const STATES: readonly GlanceVerdict[] = ['insufficient', 'none', 'unclear', 'you', 'agent']
157const REASONS: readonly GlanceReason[] = [
158  'calibration_pending',
159  'needs_data',
160  'single_indicator',
161  'mixed',
162  'workload',
163  'unknown_provenance',
164  'both_sides',
165  'nothing_recorded_on_your_side',
166  'blind_spot',
167  'by_elimination',
168]
169const SIDES: readonly GlanceSide[] = ['you', 'agent', 'unknown', 'meta']
170const STRENGTHS: readonly GlanceStrength[] = ['strong', 'weak', 'routine']
171const METRIC_STATUSES: readonly GlanceMetricStatus[] = ['worse', 'better', 'none', 'ineligible']
172const FAMILIES: readonly GlanceFamily[] = ['errors', 'research', 'friction']
173const ROLES: readonly GlanceRole[] = ['vote', 'support', 'context']
174const SCAN_ERRORS: readonly GlanceScanError[] = ['permission_denied', 'write_failed', 'timeout', 'internal']
175const LEADS: readonly GlanceLead[] = ['timeline', 'verdict']
176
177function kn(raw: unknown): GlanceKN | null {
178  if (!isObject(raw)) return null
179  const k = count(raw.k)
180  const n = count(raw.n)
181  return k === null || n === null ? null : { k, n }
182}
183
184function parseMetric(raw: unknown): GlanceMetric | null {
185  if (!isObject(raw)) return null
186  const id = clean(raw.id, 32)
187  if (id === '') return null
188  const bounds = Array.isArray(raw.range) && raw.range.length === 2 ? raw.range : []
189  const low = finite(bounds[0])
190  const high = finite(bounds[1])
191  return {
192    id,
193    label: clean(raw.label, 40) || id,
194    unit: clean(raw.unit, 24),
195    family: pick(raw.family, FAMILIES),
196    role: pick(raw.role, ROLES),
197    recent: kn(raw.recent),
198    baseline: kn(raw.baseline),
199    ratio: finite(raw.ratio),
200    range: low !== null && high !== null && low <= high ? [low, high] : null,
201    mde: finite(raw.mde),
202    status: pick(raw.status, METRIC_STATUSES),
203  }
204}
205
206function parseEvent(raw: unknown): GlanceEvent | null {
207  if (!isObject(raw)) return null
208  const label = clean(raw.label, 60)
209  if (label === '') return null
210  return {
211    day: clean(raw.day, 12),
212    kind: clean(raw.kind, 24),
213    // An unknown side is `unknown`, never `agent`.
214    side: pick(raw.side, SIDES) ?? 'unknown',
215    strength: pick(raw.strength, STRENGTHS),
216    label,
217    isNew: raw.new === true,
218  }
219}
220
221function parseStrip(raw: unknown): GlanceStrip | null {
222  if (!isObject(raw)) return null
223  const days = list(raw.days, 42).map(d =>
224    isObject(d) ? { day: clean(d.d, 12), k: count(d.k), n: count(d.n) } : { day: '', k: null, n: null },
225  )
226  if (days.length === 0) return null
227  const window = isObject(raw.window) ? raw.window : {}
228  return {
229    metric: clean(raw.metric, 32),
230    days,
231    ratio: finite(window.ratio),
232    lo: finite(window.lo),
233    hi: finite(window.hi),
234    mde: finite(window.mde),
235  }
236}
237
238function gate(raw: unknown): GlanceGate {
239  const g = isObject(raw) ? raw : {}
240  return { events: count(g.events), sessions: count(g.sessions), sessionDays: count(g.sessionDays) }
241}
242
243function parseProgress(raw: unknown): GlanceProgress | null {
244  if (!isObject(raw)) return null
245  const tier = count(raw.tier)
246  const notAtCurrentPace = raw.notAtCurrentPace === true
247  return {
248    tier: tier !== null && tier >= 1 && tier <= 3 ? tier : null,
249    // A day count is never shown when the engine says no tier gets there at the current pace.
250    etaDate: notAtCurrentPace ? '' : clean(raw.etaDate, 10),
251    notAtCurrentPace,
252    unlock: list(raw.unlock, 6).flatMap(u => {
253      if (!isObject(u)) return []
254      const metric = clean(u.metric, 32)
255      // Interrupts never get a progress bar (METHOD.md §13: progress is for voting indicators).
256      if (metric === '' || metric === 'interrupts') return []
257      return [{ metric, family: pick(u.family, FAMILIES), have: gate(u.have), need: gate(u.need) }]
258    }),
259  }
260}
261
262function parseAgent(raw: unknown): GlanceAgent | null {
263  if (!isObject(raw)) return null
264  const agent = clean(raw.agent, 32)
265  if (agent === '') return null
266  const n = isObject(raw.n) ? raw.n : {}
267  const counts: GlanceCounts = {
268    exchanges: count(n.exchanges),
269    sessions: count(n.sessions),
270    sessionDays: count(n.sessionDays),
271    days: count(n.days),
272  }
273
274  return {
275    agent,
276    state: pick(raw.state, STATES) ?? 'unclear',
277    reason: pick(raw.reason, REASONS),
278    pending: raw.pending === true,
279    calibrated: raw.calibrated === true,
280    label: clean(raw.label, 24),
281    headline: clean(raw.headline, 80),
282    because: clean(raw.because, 200),
283    tryThis: clean(raw.tryThis, 160),
284    confidence: clean(raw.confidence, 160),
285    band: clean(raw.band, 100),
286    statusLine: clean(raw.statusLine, 80),
287    n: counts,
288    progress: parseProgress(raw.progress),
289    metrics: list(raw.topMetrics, 3).flatMap(m => parseMetric(m) ?? []),
290    strip: parseStrip(raw.strip),
291    events: list(raw.events, 5).flatMap(e => parseEvent(e) ?? []),
292  }
293}
294
295function staleAfterMs(value: unknown): number {
296  const sec = finite(value)
297  return sec !== null && sec >= STALE_AFTER_SEC_MIN && sec <= STALE_AFTER_SEC_MAX ? sec * 1000 : DEFAULT_STALE_AFTER_MS
298}
299
300function fail(problem: GlanceProblem, found = ''): GlanceLoad {
301  return { ok: false, problem, found }
302}
303
304/** Parses the text of glance.json. Never throws; says why when there is nothing to draw. */
305export function parseGlance(text: string): GlanceLoad {
306  if (text.length > MAX_GLANCE_BYTES) return fail('unreadable')
307  let raw: unknown
308  try {
309    raw = JSON.parse(text)
310  } catch {
311    return fail('unreadable')
312  }
313  if (!isObject(raw)) return fail('unreadable')
314
315  if (typeof raw.schema !== 'string') return fail('invalid')
316  if (raw.schema !== GLANCE_SCHEMA) return fail('mismatch', clean(raw.schema, 32))
317
318  // The contract requires these. A glance that does not promise it holds no
319  // text is not drawn: this pane sits next to the person's prompt.
320  const privacy = raw.privacy
321  if (
322    !Array.isArray(raw.agents) ||
323    typeof raw.scanOk !== 'boolean' ||
324    typeof raw.generatedAt !== 'string' ||
325    !isObject(privacy) ||
326    privacy.containsText !== false
327  ) {
328    return fail('invalid')
329  }
330
331  const scanError =
332    raw.scanError === null || raw.scanError === undefined ? null : (pick(raw.scanError, SCAN_ERRORS) ?? 'internal')
333  return {
334    ok: true,
335    glance: {
336      generatedAt: clean(raw.generatedAt, 40),
337      generatedAtMs: typeof raw.generatedAt === 'string' ? parseTimestamp(raw.generatedAt) : null,
338      staleAfterMs: staleAfterMs(raw.staleAfterSec),
339      scanOk: raw.scanOk,
340      scanError: raw.scanOk ? null : scanError,
341      demo: raw.demo === true,
342      lead: pick(raw.lead, LEADS) ?? 'timeline',
343      agents: raw.agents.slice(0, 16).flatMap(a => parseAgent(a) ?? []),
344    },
345  }
346}
347
348// ---- where the file lives -------------------------------------------------
349
350// The module finds the home directory from where the plugin lives, without reading
351// the environment (`$.fs` does not expand `~`):
352//   <home>/.claude*/plugins/...            a marketplace install's cache copy
353//   <home>/.wasitme/current/plugin         the full install (D32: the marketplace is registered at
354//                                          the `current` symlink and the plugin root resolves there)
355//   <home>/.local/share/wasitme/...        the installer's default prefix layout
356// A custom config directory, a custom prefix, or a plugin loaded with --plugin-dir has
357// none of these shapes: the `glancePath` option covers those (setup always writes it).
358const HOMES_OF_PLUGIN = [
359  /^(.*?)\/\.claude[^/]*\/plugins\//,
360  /^(.*?)\/\.wasitme\//,
361  /^(.*?)\/\.local\/share\/wasitme\//,
362]
363
364/** The home directory implied by the plugin's own location, or null. */
365export function deriveHome(pluginRoot: string): string | null {
366  const path = pluginRoot.replace(/\\/g, '/')
367  for (const pattern of HOMES_OF_PLUGIN) {
368    const match = pattern.exec(path)
369    if (match !== null) return match[1] ?? ''
370  }
371  return null
372}
373
374function isAbsolute(path: string): boolean {
375  return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
376}
377
378/**
379 * Where glance.json is: the `glancePath` option (absolute, or `~/` expanded
380 * against the derived home), else `<home>/.wasitme/glance.json`; null when
381 * neither can be worked out.
382 */
383export function resolveGlancePath(option: unknown, pluginRoot: string): string | null {
384  const home = deriveHome(pluginRoot)
385  const given = typeof option === 'string' ? option.trim() : ''
386  if (given !== '') {
387    if (given.startsWith('~/')) return home === null ? null : `${home}/${given.slice(2)}`
388    return isAbsolute(given) ? given : null
389  }
390  return home === null ? null : `${home}/.wasitme/glance.json`
391}
392
393// ---- what to show ---------------------------------------------------------
394
395export type StaleReason = 'old' | 'clock' | 'unknown'
396
397/** What the drawing shows, decided once from a snapshot. */
398export type Display =
399  | { kind: 'loading' }
400  | { kind: 'problem'; problem: GlanceProblem; found: string }
401  | { kind: 'scan-failed'; glance: GlanceFile; ageMs: number | null }
402  | { kind: 'stale'; glance: GlanceFile; reason: StaleReason; ageMs: number | null }
403  | { kind: 'empty'; glance: GlanceFile; ageMs: number | null }
404  | { kind: 'ready'; glance: GlanceFile; ageMs: number | null }
405
406/**
407 * Milliseconds since the scan, never negative (clocks can go backward), or
408 * null when the scan's timestamp did not parse.
409 */
410export function ageOf(glance: GlanceFile, nowMs: number): number | null {
411  return glance.generatedAtMs === null ? null : Math.max(0, nowMs - glance.generatedAtMs)
412}
413
414/**
415 * Why a glance cannot be trusted as current, or null when it can (contract display rule 3):
416 * no parseable timestamp, older than the file's own `staleAfterSec`, or more than five minutes
417 * in the future (a clock that went backward).
418 */
419export function staleReason(glance: GlanceFile, nowMs: number): StaleReason | null {
420  if (glance.generatedAtMs === null) return 'unknown'
421  if (glance.generatedAtMs - nowMs > FUTURE_SKEW_MS) return 'clock'
422  return nowMs - glance.generatedAtMs > glance.staleAfterMs ? 'old' : null
423}
424
425/** Decides what to draw. Order matters: a problem beats a failed scan beats staleness beats emptiness. */
426export function classify(snapshot: GlanceSnapshot | null): Display {
427  if (snapshot === null) return { kind: 'loading' }
428  const { load, nowMs } = snapshot
429  if (!load.ok) return { kind: 'problem', problem: load.problem, found: load.found }
430
431  const glance = load.glance
432  const ageMs = ageOf(glance, nowMs)
433  if (!glance.scanOk) return { kind: 'scan-failed', glance, ageMs }
434  const reason = staleReason(glance, nowMs)
435  if (reason !== null) return { kind: 'stale', glance, reason, ageMs }
436  if (glance.agents.length === 0) return { kind: 'empty', glance, ageMs }
437  return { kind: 'ready', glance, ageMs }
438}
439
440/** The agents to draw, in the engine's order (the first is the one a one-line surface speaks for), at most MAX_AGENTS. */
441export function orderedAgents(glance: GlanceFile): GlanceAgent[] {
442  return glance.agents.slice(0, MAX_AGENTS)
443}
444
445/**
446 * The first agent the band may speak for: the engine wrote a band line, and the verdict is an
447 * answer (your side, or the agent). Both must hold, so the band never speaks for "too early",
448 * "no detectable change" or "unclear", whatever the file says.
449 */
450export function attentionAgent(glance: GlanceFile): GlanceAgent | null {
451  return orderedAgents(glance).find(a => a.band !== '' && (a.state === 'you' || a.state === 'agent')) ?? null
452}
453
mod/render.ts 720 lines
1// The wasitme mod's drawings: layout only, built from theme.ts (the design
2// system's tokens and the mod's few own words) and a display decided by
3// glance.ts. No `$` in here: register.ts resolves the element table and passes
4// it in with the handlers and the width, so every function below is a plain
5// function of its arguments.
6//
7// The pane follows design/system/screens/text-*.png (64 × 18 cells): buttons
8// first (an 80-column inline pane must never clip them), then, per agent, the
9// sections the CLI prints, in the engine's lead (D28/D61; timeline-led is the
10// default):
11//   timeline-led:  What changed · Finding · Signals · Next
12//   verdict-led:   Finding · Signals · What changed · Next
13// "What changed" is the case line: your changes numbered above a dashed rule,
14// the agent's lettered below it, one column per day, with that day's k/n under
15// it. No projected dates anywhere (D66).
16
17import type { Elements, RenderElement } from 'claude-code'
18
19import type { GlanceAgent, GlanceEvent, GlanceFile, GlanceMetric, GlanceStrip, GlanceStripDay, GlanceUnlock } from '../types'
20import { attentionAgent, orderedAgents } from './glance'
21import type { Display } from './glance'
22import { theme } from './theme'
23import type { Party } from './theme'
24
25/** The elements every surface's table has: all this mod draws with. */
26export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
27
28type Props = Record<string, unknown>
29type Child = RenderElement | null
30
31const { copy, layout } = theme
32
33// ---- small builders -------------------------------------------------------
34
35// Props are plain data: an `undefined` is left out, not passed.
36function defined(props: Props): Props {
37  return Object.fromEntries(Object.entries(props).filter(([, value]) => value !== undefined))
38}
39
40function text(kit: Kit, content: string, props: Props = {}): RenderElement {
41  return h(kit.Text, defined({ wrap: 'wrap', ...props }), content) as RenderElement
42}
43
44function dim(kit: Kit, content: string, props: Props = {}): RenderElement {
45  return text(kit, content, { ...theme.secondary, ...props })
46}
47
48function strong(kit: Kit, content: string, props: Props = {}): RenderElement {
49  return text(kit, content, { ...theme.emphasis, ...props })
50}
51
52/** One cell-exact row of the case line: never wraps. */
53function cells(kit: Kit, content: string, props: Props = {}): RenderElement {
54  return text(kit, content, { wrap: 'truncate-end', ...props })
55}
56
57function sticker(kit: Kit, party: Party, mark: string, props: Props = {}): RenderElement {
58  return cells(kit, ` ${mark} `, { ...theme.sticker[party], ...props })
59}
60
61function box(kit: Kit, props: Props, children: readonly Child[]): RenderElement {
62  return h(kit.Box, props, ...children.filter(child => child !== null)) as RenderElement
63}
64
65function column(kit: Kit, children: readonly Child[], props: Props = {}): RenderElement {
66  return box(kit, { flexDirection: 'column', ...props }, children)
67}
68
69function row(kit: Kit, children: readonly Child[], props: Props = {}): RenderElement {
70  return box(kit, { flexDirection: 'row', ...props }, children)
71}
72
73function section(kit: Kit, children: readonly Child[], props: Props = {}): RenderElement {
74  return column(kit, children, { marginTop: 1, ...props })
75}
76
77function spacer(kit: Kit): RenderElement {
78  return box(kit, { flexGrow: 1 }, [])
79}
80
81// ---- formatting (the engine's own rules, words/format.ts) ------------------
82
83/** A whole count with thousands separators; `–` for none. */
84export function count(n: number | null): string {
85  if (n === null || !Number.isFinite(n) || n < 0) return '–'
86  return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
87}
88
89/** A count in at most 3 cells, for one day under the case line ("88", "3k"). */
90export function shortCount(n: number | null): string {
91  if (n === null || !Number.isFinite(n) || n < 0) return '–'
92  const v = Math.round(n)
93  if (v < 1000) return String(v)
94  if (v < 99_500) return `${Math.round(v / 1000)}k`
95  return '99k'
96}
97
98/** A ratio, two decimals ("×0.75"). */
99export function ratio2(r: number): string {
100  return Number.isFinite(r) && r > 0 ? `×${r.toFixed(2)}` : '×?'
101}
102
103/** A range rounded outward, so it is never printed narrower than it is ("×0.42–×1.33"). */
104export function rangeText(lo: number, hi: number): string {
105  const l = Math.floor(lo * 100 + 1e-9) / 100
106  const u = Math.ceil(hi * 100 - 1e-9) / 100
107  return `×${l.toFixed(2)}–×${u.toFixed(2)}`
108}
109
110/** A minimum detectable change in prose, one decimal ("×2.5"). */
111export function mdeText(m: number): string {
112  return Number.isFinite(m) && m > 0 ? `×${m.toFixed(1)}` : '×?'
113}
114
115const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
116
117/** "Sep 21" from YYYY-MM-DD, with the year when it is not `today`'s; '' for anything else. */
118export function dayLabel(d: string, today = ''): string {
119  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(d)
120  if (m === null) return ''
121  const month = MONTHS[Number(m[2]) - 1]
122  if (month === undefined) return ''
123  const base = `${month} ${Number(m[3])}`
124  const year = /^(\d{4})-/.exec(today)?.[1]
125  return year !== undefined && year !== m[1] ? `${base}, ${m[1]}` : base
126}
127
128/** "6h ago", never negative; the caller passes an age already clamped at zero. */
129export function formatAge(ageMs: number | null): string {
130  if (ageMs === null) return copy.unknownAge
131  const minutes = Math.floor(ageMs / 60_000)
132  if (minutes < 1) return copy.justNow
133  if (minutes < 60) return `${minutes}m ago`
134  const hours = Math.floor(minutes / 60)
135  if (hours < 48) return `${hours}h ago`
136  return `${Math.floor(hours / 24)}d ago`
137}
138
139export function agentName(id: string): string {
140  return Object.hasOwn(theme.agentName, id) ? (theme.agentName[id] ?? id) : id
141}
142
143export function progressBar(have: number, need: number): string {
144  const { full, empty, cells: width } = theme.bar
145  const filled = need > 0 ? Math.min(width, Math.max(0, Math.round((have / need) * width))) : 0
146  return full.repeat(filled) + empty.repeat(width - filled)
147}
148
149function today(glance: GlanceFile): string {
150  return glance.generatedAtMs === null ? '' : new Date(glance.generatedAtMs).toISOString().slice(0, 10)
151}
152
153/** An indicator's name: the glance's own label, else the engine's (theme.metricName), else for an id this version
154 *  does not know its words ("fooBarBaz" -> "Foo bar baz"; "Indicator" when that is empty or longer than a label's 40).
155 *  Never the id itself. */
156export function metricName(agent: GlanceAgent, id: string): string {
157  const own = agent.metrics.find(m => m.id === id)?.label ?? ''
158  if (own !== '') return own
159  const known = Object.hasOwn(theme.metricName, id) ? theme.metricName[id] : undefined
160  if (known !== undefined) return known
161  const words = id.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[^A-Za-z0-9+]+/g, ' ').trim().toLowerCase()
162  return words !== '' && words.length <= 40 ? words.charAt(0).toUpperCase() + words.slice(1) : 'Indicator'
163}
164
165function eventNoun(id: string): string {
166  return Object.hasOwn(theme.eventNoun, id) ? (theme.eventNoun[id] ?? 'events') : 'events'
167}
168
169export type GateUnit = 'sessions' | 'sessionDays' | 'events'
170/** The one pair an unlock counter shows: its unit, count, target and what the unit is called. */
171export type GatePair = { unit: GateUnit; have: number; need: number; noun: string }
172const GATE_UNITS: readonly GateUnit[] = ['sessions', 'sessionDays', 'events']
173
174/**
175 * The unlock counter (docs/CONTRACT.md#display-rules; the engine's contract/display.ts bindingGate is the reference):
176 * of the units still short of their target, the one furthest from it by have / need, a tie going to the earlier of
177 * sessions, session-days, events. The count, its noun and the bar all come from this pair, so a count is never set
178 * against another quantity's target ("3,150 of 10"). Null when no unit is short (only one session dominating).
179 */
180export function bindingGate(u: GlanceUnlock | undefined): GatePair | null {
181  if (u === undefined) return null
182  let best: GatePair | null = null
183  for (const unit of GATE_UNITS) {
184    const have = u.have[unit]
185    const need = u.need[unit]
186    if (have === null || need === null || !(have >= 0) || !(need > 0) || !(have < need) || !Number.isFinite(need)) continue
187    if (best === null || have / need < best.have / best.need) {
188      best = { unit, have, need, noun: unit === 'sessions' ? 'sessions' : unit === 'sessionDays' ? 'session-days' : eventNoun(u.metric) }
189    }
190  }
191  return best
192}
193
194/** The ledger's status word (the CLI's statusWord, from what a glance carries). */
195export function statusWord(metric: GlanceMetric): string {
196  if (metric.role === 'context') return theme.status.context
197  if (metric.status === 'ineligible' || metric.status === null) {
198    return metric.family === 'friction' ? theme.status.context : theme.status.notYet
199  }
200  if (metric.status === 'worse' || metric.status === 'better') {
201    return metric.ratio !== null && metric.ratio < 1 ? theme.status.fewer : theme.status.more
202  }
203  return theme.status.notDetected
204}
205
206// ---- the case line (pure, so the tests can read it cell by cell) -----------
207
208/** The changes a person can act on, oldest first (the glance lists them newest first). */
209export function visibleEvents(agent: GlanceAgent): GlanceEvent[] {
210  return agent.events.filter(e => e.side !== 'meta' && e.kind !== 'system-prompt').slice().reverse()
211}
212
213/** How many days fit on the case line at this width. */
214export function daySlots(columns: number): number {
215  const fit = Math.floor((columns - layout.laneLeft) / layout.pitch)
216  return Math.max(layout.minDays, Math.min(layout.maxDays, fit))
217}
218
219const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
220
221/**
222 * Markers for changes, oldest first: yours numbered (1, 2, ...), the agent's lettered (A, B, ...), one per day and
223 * side; a change of unknown origin is "?" and is never given the agent's letter.
224 */
225export function markers(events: readonly GlanceEvent[]): string[] {
226  const groups = new Map<string, string>()
227  let n = 0
228  let l = 0
229  return events.map(e => {
230    if (e.side !== 'you' && e.side !== 'agent') return '?'
231    const key = `${e.day}|${e.side}`
232    let mark = groups.get(key)
233    if (mark === undefined) {
234      if (e.side === 'you') mark = n < 99 ? String(++n) : '+'
235      else mark = l < ALPHABET.length ? (ALPHABET[l++] ?? '+') : '+'
236      groups.set(key, mark)
237    }
238    return mark
239  })
240}
241
242/** One run of a lane row: plain cells, or a sticker. */
243export type Run = { text: string; party: Party | null }
244
245export type CaseLine = {
246  days: GlanceStripDay[]
247  /** The changes on the drawn days, oldest first, with their markers. */
248  inView: { event: GlanceEvent; mark: string }[]
249  yours: Run[]
250  rule: string
251  agent: Run[]
252  dates: string
253  kRow: string
254  nRow: string
255  labels: readonly [string, string]
256}
257
258function put(chars: string[], at: number, value: string): void {
259  Array.from(value).forEach((c, i) => {
260    if (at + i >= 0 && at + i < chars.length) chars[at + i] = c
261  })
262}
263
264function lane(width: number, label: string, stickers: Map<number, string>, party: Party): Run[] {
265  const runs: Run[] = []
266  let plain = label.padEnd(layout.laneLeft, ' ')
267  for (let x = layout.laneLeft; x < width; ) {
268    const mark = stickers.get(x)
269    if (mark !== undefined) {
270      runs.push({ text: plain, party: null })
271      runs.push({ text: ` ${mark} `, party })
272      plain = ''
273      x += 3
274    } else {
275      plain += ' '
276      x += 1
277    }
278  }
279  if (plain.trim() !== '' || runs.length === 0) runs.push({ text: plain.trimEnd(), party: null })
280  return runs.filter(r => r.text !== '')
281}
282
283/** The case line for the last `slots` days of the strip, or null when there is no strip to draw. */
284export function caseLine(strip: GlanceStrip | null, events: readonly GlanceEvent[], slots: number, today = ''): CaseLine | null {
285  if (strip === null || strip.days.length === 0) return null
286  const days = strip.days.slice(-slots)
287  const first = days[0]?.day ?? ''
288  const last = days[days.length - 1]?.day ?? ''
289  const index = new Map(days.map((d, i) => [d.day, i]))
290  const inViewEvents = events.filter(e => e.day !== '' && e.day >= first && e.day <= last && index.has(e.day))
291  const marks = markers(inViewEvents)
292  const inView = inViewEvents.map((event, i) => ({ event, mark: marks[i] ?? '?' }))
293
294  const width = layout.laneLeft + days.length * layout.pitch
295  const rule = Array.from({ length: days.length * layout.pitch }, () => theme.caseLine.rule)
296  const you = new Map<number, string>()
297  const agent = new Map<number, string>()
298  for (const { event, mark } of inView) {
299    const i = index.get(event.day)
300    if (i === undefined) continue
301    const x = layout.laneLeft + i * layout.pitch
302    if (event.side === 'you') {
303      if (!you.has(x)) you.set(x, mark)
304      rule[i * layout.pitch + 1] = theme.caseLine.you
305    } else if (event.side === 'agent') {
306      if (!agent.has(x)) agent.set(x, mark)
307      rule[i * layout.pitch + 1] = theme.caseLine.agent
308    } else if (rule[i * layout.pitch + 1] === theme.caseLine.rule) {
309      rule[i * layout.pitch + 1] = theme.caseLine.unknown
310    }
311  }
312
313  // Date labels: the first day, a week in, two weeks in, and the last (right-aligned to the line).
314  const dates = Array.from({ length: width }, () => ' ')
315  const lastLabel = dayLabel(last, today).replace(/, \d{4}$/, '')
316  const lastAt = width - 1 - Array.from(lastLabel).length
317  for (const i of [0, 7, 14]) {
318    const d = days[i]
319    if (d === undefined || i >= days.length - 2) continue
320    const label = dayLabel(d.day, today).replace(/, \d{4}$/, '')
321    const at = layout.laneLeft + i * layout.pitch
322    if (at + Array.from(label).length < lastAt) put(dates, at, label)
323  }
324  put(dates, lastAt, lastLabel)
325
326  const labels = Object.hasOwn(theme.stripRows, strip.metric)
327    ? (theme.stripRows[strip.metric] ?? theme.stripRowsFallback)
328    : theme.stripRowsFallback
329  const kRow = Array.from({ length: width }, () => ' ')
330  const nRow = Array.from({ length: width }, () => ' ')
331  put(kRow, 0, labels[0])
332  put(nRow, 0, labels[1])
333  days.forEach((d, i) => {
334    const k = d.n === 0 || d.n === null ? '–' : shortCount(d.k)
335    const n = shortCount(d.n)
336    const x = layout.laneLeft + i * layout.pitch
337    put(kRow, x + 3 - Array.from(k).length, k)
338    put(nRow, x + 3 - Array.from(n).length, n)
339  })
340
341  return {
342    days,
343    inView,
344    yours: lane(width, copy.yours, you, 'you'),
345    rule: ' '.repeat(layout.laneLeft) + rule.join(''),
346    agent: lane(width, copy.agentLane, agent, 'agent'),
347    dates: dates.join('').trimEnd(),
348    kRow: kRow.join('').trimEnd(),
349    nRow: nRow.join('').trimEnd(),
350    labels,
351  }
352}
353
354// ---- what a notice says ---------------------------------------------------
355
356type Notice = { glyph: string; title: string; help: string; extra: string[] }
357
358/** The words for every display that is not a finding; null for `ready`. */
359export function noticeOf(display: Display): Notice | null {
360  switch (display.kind) {
361    case 'ready':
362      return null
363    case 'loading':
364      return { glyph: '', title: copy.loading, help: '', extra: [] }
365    case 'problem': {
366      const words = copy.problem[display.problem]
367      const found = display.problem === 'mismatch' && display.found !== '' ? [`Found: ${display.found}`] : []
368      return { glyph: '', title: words.title, help: words.help, extra: found }
369    }
370    case 'scan-failed':
371      return {
372        glyph: '',
373        title: copy.scanFailed.title,
374        help: copy.scanFailed.help,
375        extra: [copy.scanError[display.glance.scanError ?? 'internal']],
376      }
377    case 'stale': {
378      const words =
379        display.reason === 'old' ? copy.staleOld : display.reason === 'clock' ? copy.staleClock : copy.staleUnknown
380      const when = display.reason === 'old' ? [`Last scan ${formatAge(display.ageMs)}.`] : []
381      return { glyph: theme.glyph('stale'), title: words.title, help: words.help, extra: when }
382    }
383    case 'empty':
384      return { glyph: '', title: copy.empty.title, help: copy.empty.help, extra: [] }
385  }
386}
387
388/** The engine's state label ("Too early to tell", "Timeline only"), or the design system's. */
389export function labelOf(agent: GlanceAgent): string {
390  return agent.label === '' ? theme.label(agent.state) : agent.label
391}
392
393/** The one-line text `/wasitme` answers with; the model reads command output, so it carries derived words only. */
394export function commandSummary(display: Display, isPlaced: boolean): string {
395  const opened = isPlaced ? copy.commandOpened : copy.commandWaiting
396  if (display.kind === 'ready') {
397    const first = orderedAgents(display.glance)[0]
398    if (first === undefined) return opened
399    const deck = first.headline === '' ? '' : ` ${first.headline}`
400    return `${opened} ${agentName(first.agent)}: ${labelOf(first)}.${deck}`
401  }
402  const notice = noticeOf(display)
403  return notice === null ? opened : `${opened} ${notice.title}.`
404}
405
406// ---- the pane: one agent ----------------------------------------------------
407
408type View = { kit: Kit; glance: GlanceFile; agent: GlanceAgent; index: number; showName: boolean; slots: number; today: string }
409
410function partyOf(state: GlanceAgent['state']): Party | null {
411  return state === 'you' ? 'you' : state === 'agent' ? 'agent' : null
412}
413
414function sideWords(event: GlanceEvent): string {
415  const side = event.side === 'you' ? copy.yourSide : event.side === 'agent' ? copy.agentSide : copy.unknownSide
416  const tags = [...(event.strength === 'routine' ? [copy.routine] : []), ...(event.isNew ? [copy.isNew] : [])]
417  return [side, ...tags].join(layout.separator)
418}
419
420function runs(kit: Kit, parts: readonly { text: string; party: Party | null }[], key: string): RenderElement {
421  return row(
422    kit,
423    parts.map((part, i) => (part.party === null ? cells(kit, part.text, { key: `${key}-${i}` }) : sticker(kit, part.party, part.text.trim(), { key: `${key}-${i}` }))),
424    { key },
425  )
426}
427
428/** One change: its marker (or a blank cell), the day, what changed, and on which side. */
429function eventRow(v: View, event: GlanceEvent, mark: string | null, key: string, pad = false): RenderElement {
430  const { kit } = v
431  const party = event.side === 'you' ? 'you' : event.side === 'agent' ? 'agent' : null
432  const markCell =
433    mark === null
434      ? pad
435        ? cells(kit, '   ')
436        : null
437      : party !== null && mark !== '?'
438        ? sticker(kit, party, mark)
439        : cells(kit, ` ${mark} `)
440  const when = dayLabel(event.day, v.today)
441  return row(
442    kit,
443    [
444      markCell === null ? null : box(kit, { flexShrink: 0, marginRight: 1 }, [markCell]),
445      box(kit, { flexShrink: 1, flexGrow: 1, flexDirection: 'column' }, [
446        text(kit, when === '' ? event.label : `${when}  ${event.label}`),
447        box(kit, { paddingLeft: layout.indent.length }, [dim(kit, sideWords(event))]),
448      ]),
449    ],
450    { key },
451  )
452}
453
454function whatChanged(v: View): RenderElement {
455  const { kit, agent } = v
456  const all = visibleEvents(agent)
457  const line = caseLine(agent.strip, all, v.slots, v.today)
458  const heading = strong(kit, copy.whatChanged)
459
460  if (line === null) {
461    // Timeline only (no comparison ran): the changes of the last 30 days, as a list.
462    const from = v.today === '' ? '' : new Date(Date.parse(`${v.today}T00:00:00Z`) - 30 * 86_400_000).toISOString().slice(0, 10)
463    const recent = all.filter(e => from === '' || e.day >= from)
464    const shown = recent.slice(-layout.maxEvents)
465    return section(kit, [
466      row(kit, [heading, dim(kit, `  ${copy.lastDays}`)]),
467      ...(shown.length === 0 ? [dim(kit, copy.noChanges)] : shown.map((e, i) => eventRow(v, e, null, `ev-${v.index}-${i}`))),
468      recent.length > shown.length ? dim(kit, copy.earlier(recent.length - shown.length)) : null,
469      shown.some(e => e.strength === 'routine') ? dim(kit, copy.routineNote) : null,
470    ])
471  }
472
473  // Listed: every change over the whole strip, so a narrow pane that draws fewer days still names them; only the ones
474  // on the drawn days carry a marker.
475  const stripDays = agent.strip?.days ?? []
476  const from = stripDays[0]?.day ?? ''
477  const to = stripDays[stripDays.length - 1]?.day ?? ''
478  const markOf = new Map(line.inView.map(x => [x.event, x.mark]))
479  const listed = all.filter(e => e.day !== '' && e.day >= from && e.day <= to).map(event => ({ event, mark: markOf.get(event) ?? null }))
480  const shown = listed.slice(-layout.maxEvents)
481  const hidden = listed.length - shown.length
482  const marked = shown.some(x => x.mark !== null)
483  const hasYou = line.inView.some(x => x.event.side === 'you')
484  const hasAgent = line.inView.some(x => x.event.side === 'agent')
485  const legend =
486    hasYou || hasAgent
487      ? row(kit, [
488          hasYou ? sticker(kit, 'you', '1') : null,
489          hasYou ? cells(kit, ` ${copy.yours}  `) : null,
490          hasAgent ? sticker(kit, 'agent', 'A') : null,
491          hasAgent ? cells(kit, ` ${agentName(agent.agent)}'s`) : null,
492        ])
493      : null
494  return section(kit, [
495    heading,
496    runs(kit, line.yours, `you-${v.index}`),
497    cells(kit, line.rule, theme.rule),
498    runs(kit, line.agent, `agent-${v.index}`),
499    dim(kit, line.dates, { wrap: 'truncate-end' }),
500    cells(kit, line.kRow),
501    dim(kit, line.nRow, { wrap: 'truncate-end' }),
502    legend === null ? null : box(kit, { marginTop: 1 }, [legend]),
503    shown.length === 0
504      ? null
505      : column(kit, [
506          hidden > 0 ? dim(kit, copy.earlier(hidden)) : null,
507          ...shown.map(({ event, mark }, i) => eventRow(v, event, mark, `ev-${v.index}-${i}`, marked)),
508          shown.some(x => x.event.strength === 'routine') ? dim(kit, copy.routineNote) : null,
509        ], { marginTop: 1 }),
510  ])
511}
512
513/** "Next to unlock": what an unqualified comparison is still missing, and never a date (D66). */
514function progressLines(v: View): Child[] {
515  const { kit, agent } = v
516  const p = agent.progress
517  if (p === null || agent.state !== 'insufficient' || agent.reason === 'calibration_pending') return []
518  const waiting = p.unlock
519    .map((u: GlanceUnlock) => {
520      const parts: string[] = []
521      const gap = (have: number | null, need: number | null, noun: string) => {
522        if (have !== null && need !== null && need > have) parts.push(`${count(have)} of ${count(need)} ${noun}`)
523      }
524      gap(u.have.events, u.need.events, eventNoun(u.metric))
525      gap(u.have.sessions, u.need.sessions, 'sessions')
526      gap(u.have.sessionDays, u.need.sessionDays, 'session-days')
527      return { u, name: metricName(agent, u.metric), parts }
528    })
529    .filter(x => x.parts.length > 0)
530  const first = waiting[0]
531  if (first === undefined) return []
532  const others = waiting.slice(1).map(x => x.name)
533  const then = others.length > 0 ? `, then ${others.join(' and ')}` : ''
534  // The bar and its count are the one binding pair (bindingGate), never the events pair when another unit is short.
535  const g = bindingGate(first.u)
536  // The name already says what is counted when the indicator counts its own events ("Edits without reading first:
537  // 7 of 10"); otherwise the noun says it ("Reads per edit: 31 of 40 edits", "Tool errors: 7 of 10 session-days").
538  const unit = g === null || g.noun.toLowerCase() === first.name.toLowerCase() ? '' : ` ${g.noun}`
539  const bar =
540    g !== null
541      ? row(kit, [cells(kit, `${first.name}: ${count(g.have)} of ${count(g.need)}${unit} `), cells(kit, progressBar(g.have, g.need))], { key: `bar-${v.index}` })
542      : null
543  return [
544    text(kit, `${copy.nextToUnlock} ${first.name} (${first.parts.join(', ')})${then}.`),
545    bar,
546    dim(kit, copy.noDate),
547  ]
548}
549
550function finding(v: View): RenderElement {
551  const { kit, agent } = v
552  const party = partyOf(agent.state)
553  const label = labelOf(agent)
554  return section(kit, [
555    row(kit, [
556      strong(kit, copy.finding, { wrap: 'truncate-end' }),
557      cells(kit, `  ${theme.glyph(agent.state)} `),
558      party === null ? strong(kit, label, { wrap: 'truncate-end' }) : cells(kit, ` ${label} `, theme.sticker[party]),
559    ]),
560    agent.pending ? text(kit, copy.confirming) : null,
561    agent.headline === '' ? null : text(kit, agent.headline),
562    agent.because === '' ? null : dim(kit, agent.because),
563    ...progressLines(v),
564  ])
565}
566
567function signals(v: View): RenderElement | null {
568  const { kit, agent } = v
569  const metrics = agent.metrics.filter(m => m.recent !== null || m.baseline !== null).slice(0, 8)
570  if (metrics.length === 0) return null
571  return section(kit, [
572    strong(kit, copy.signals),
573    ...metrics.map((m, i) => {
574      const kn = (x: GlanceMetric['recent']) => (x === null ? '–' : `${count(x.k)}/${count(x.n)}`)
575      const facts = [`${kn(m.recent)} ${copy.vs} ${kn(m.baseline)}`]
576      const eligible = m.status !== 'ineligible' && m.status !== null
577      if (eligible && m.ratio !== null) {
578        facts.push(m.range === null ? ratio2(m.ratio) : `${ratio2(m.ratio)}, ${copy.range} ${rangeText(m.range[0], m.range[1])}`)
579        if (m.mde !== null) facts.push(copy.wontShow(mdeText(m.mde)))
580      } else {
581        const g = bindingGate(agent.progress?.unlock.find(x => x.metric === m.id))
582        if (g !== null) facts.push(`${count(g.have)} of ${count(g.need)} ${g.noun} needed`)
583      }
584      return column(
585        kit,
586        [
587          row(kit, [text(kit, m.label === '' ? metricName(agent, m.id) : m.label), spacer(kit), dim(kit, ` ${statusWord(m)}`, { wrap: 'truncate-end' })]),
588          box(kit, { paddingLeft: layout.indent.length }, [dim(kit, facts.join(layout.separator))]),
589        ],
590        { key: `m-${v.index}-${i}` },
591      )
592    }),
593  ])
594}
595
596function nextBlock(v: View): RenderElement | null {
597  const { kit, agent } = v
598  const lines: Child[] = [
599    agent.tryThis === '' ? null : text(kit, `${copy.next}: ${agent.tryThis}`),
600    agent.confidence === '' ? null : dim(kit, agent.confidence),
601  ]
602  return lines.every(l => l === null) ? null : section(kit, lines)
603}
604
605function agentBlock(v: View): RenderElement {
606  const { kit, glance } = v
607  // The engine decides the order (D28); anything but an explicit verdict lead is timeline-led (D61's default).
608  const body =
609    glance.lead === 'verdict'
610      ? [finding(v), signals(v), whatChanged(v), nextBlock(v)]
611      : [whatChanged(v), finding(v), signals(v), nextBlock(v)]
612  return column(kit, [v.showName ? strong(kit, agentName(v.agent.agent)) : null, ...body], {
613    key: `agent-${v.index}`,
614    marginTop: v.index === 0 ? 0 : 1,
615  })
616}
617
618// ---- the pane ---------------------------------------------------------------
619
620export type PaneActions = { onRefresh: () => void; onClose: () => void }
621
622export type PaneContext = { actions: PaneActions; columns?: number }
623
624/** Buttons first, so an 80-column inline pane never clips them; the wordmark at the right. */
625function topBar(kit: Kit, actions: PaneActions | null): RenderElement {
626  return row(kit, [
627    actions === null
628      ? null
629      : box(kit, { flexDirection: 'row', gap: 1, flexShrink: 0 }, [
630          h(kit.Button, { key: 'refresh', label: theme.button.refresh, hotkey: 'r', onPress: actions.onRefresh }) as RenderElement,
631          h(kit.Button, { key: 'close', label: theme.button.close, role: 'dismiss', onPress: actions.onClose }) as RenderElement,
632        ]),
633    spacer(kit),
634    box(kit, { flexDirection: 'row', flexShrink: 0 }, [cells(kit, 'wasit'), cells(kit, 'me', { underline: true })]),
635  ])
636}
637
638function noticeBlock(kit: Kit, notice: Notice): RenderElement {
639  return section(kit, [
640    strong(kit, notice.glyph === '' ? notice.title : `${notice.glyph} ${notice.title}`),
641    ...notice.extra.map(line => text(kit, line)),
642    notice.help === '' ? null : text(kit, notice.help),
643  ])
644}
645
646/** What an out-of-date or failed scan last said, one line per agent: history, never presented as current. */
647function lastKnown(kit: Kit, display: Display): RenderElement | null {
648  if (display.kind !== 'stale' && display.kind !== 'scan-failed') return null
649  const agents = orderedAgents(display.glance)
650  if (agents.length === 0) return null
651  return section(kit, [
652    dim(kit, copy.lastKnown),
653    ...agents.map((agent, i) => dim(kit, `${theme.glyph(agent.state)} ${agentName(agent.agent)}: ${labelOf(agent)}`, { key: `last-${i}` })),
654  ])
655}
656
657function footer(kit: Kit, display: Display): RenderElement {
658  const age = display.kind === 'ready' || display.kind === 'empty' ? `${copy.updated} ${formatAge(display.ageMs)}${layout.separator}` : ''
659  return section(kit, [dim(kit, `${age}${copy.privacy}`)])
660}
661
662/** The wasitme pane body for any display. */
663export function renderPane(kit: Kit, display: Display, context: PaneContext): RenderElement {
664  const columns = context.columns ?? layout.columns
665  const notice = noticeOf(display)
666  if (notice !== null) {
667    return column(kit, [
668      topBar(kit, display.kind === 'loading' ? null : context.actions),
669      noticeBlock(kit, notice),
670      lastKnown(kit, display),
671      footer(kit, display),
672    ])
673  }
674  if (display.kind !== 'ready') return column(kit, [])
675
676  const glance = display.glance
677  const agents = orderedAgents(glance)
678  const slots = daySlots(columns)
679  const day = today(glance)
680  return column(kit, [
681    topBar(kit, context.actions),
682    glance.demo ? box(kit, { marginTop: 1 }, [strong(kit, copy.demo)]) : null,
683    ...agents.map((agent, index) =>
684      agentBlock({ kit, glance, agent, index, showName: agents.length > 1, slots, today: day }),
685    ),
686    footer(kit, display),
687  ])
688}
689
690// ---- the band -------------------------------------------------------------
691
692export type BandActions = { onHide: () => void }
693
694export type BandContext = { columns: number; actions: BandActions }
695
696/**
697 * The quiet one-line band: only for a fresh scan whose finding is "your side" or
698 * "agent side" AND for which the engine wrote a band line (the engine decides
699 * band eligibility; the state check is a second lock). Everything else (too
700 * early, no detectable change, can't tell which, out of date, errors) returns
701 * null and draws nothing.
702 */
703export function renderBand(kit: Kit, display: Display, context: BandContext): RenderElement | null {
704  if (display.kind !== 'ready') return null
705  const agent = attentionAgent(display.glance)
706  if (agent === null) return null
707
708  const who = context.columns < layout.bandCompactBelow ? '' : `${agentName(agent.agent)}: `
709  return box(kit, {}, [
710    box(kit, { flexShrink: 1, flexGrow: 1 }, [
711      text(kit, `${theme.glyph(agent.state)} ${copy.bandTag}${layout.separator}${who}${agent.band} `, {
712        ...theme.emphasis,
713        wrap: 'truncate-end',
714      }),
715    ]),
716    box(kit, { flexShrink: 0 }, [dim(kit, `${copy.bandHint} `, { wrap: 'truncate-end' })]),
717    h(kit.Button, { key: 'hide', label: theme.button.hide, onPress: context.actions.onHide }) as RenderElement,
718  ])
719}
720
mod/theme.ts 209 lines
1// Every visual and wording choice of the wasitme mod, in one place, built from the
2// design system (design/system, D57 "Case File"). The values come from
3// design-tokens.ts, a byte-for-byte copy of design/system/generated/tokens.ts
4// (tests/fixtures/sync.mjs --check holds the copy to the source); nothing here
5// invents a colour or a glyph.
6//
7// The glyph grammar (D57): one rule. Your side is a square above it (■──), the
8// agent a triangle below it (──▲), no detectable change the bare rule (───),
9// can't tell which both shapes (■─▲), too early to tell a dashed rule with specks
10// (·┄·), out of date the struck rule (─╱─). On the case line your changes are
11// canary stickers numbered 1, 2, 3 above the rule and the agent's are blue
12// stickers lettered A, B, C below it, so party is carried four ways: colour,
13// shape, numeral vs letter, above vs below. No red, no green: `you` and `agent`
14// are answers, not good or bad.
15//
16// The engine owns every finding word (label, headline, because, tryThis,
17// confidence, band, event and metric labels). The words here are only what the
18// engine cannot say: notices about the file itself, section names, the ledger's
19// short status words (the CLI's), and fallbacks for an empty field.
20
21import { modSticker, tokens } from './design-tokens'
22import type { GlanceProblem, GlanceScanError, GlanceVerdict } from '../types'
23
24export type Party = 'you' | 'agent'
25
26// Which Claude Code theme is on is not visible to the mod: only `$.config.list`
27// would say, and that call is outside the frozen allow-list (D25). So the
28// stickers use one fixed fill + ink pair (the tokens' dark family). A sticker
29// carries its own background and text colour, so it reads the same on a light
30// or a dark theme; on a 16-colour terminal Claude Code maps the fill to the
31// nearest palette colour (the risk DESIGN.md §5 records for the mod pane), and
32// the numeral vs letter and above vs below still carry the party there. The
33// Desktop Code tab's colours are assumed, not captured (D57).
34const STICKER_THEME = 'dark'
35
36const sticker: Record<Party, { color: string; backgroundColor: string }> = {
37  you: modSticker('you', STICKER_THEME),
38  agent: modSticker('agent', STICKER_THEME),
39}
40
41/** The three-cell text glyph of a state; `stale` is a display, not a state. */
42function glyph(state: GlanceVerdict | 'stale'): string {
43  return tokens.states[state].textGlyph
44}
45
46/** The design system's state label, used only when the engine's own label is empty. */
47function label(state: GlanceVerdict | 'stale'): string {
48  return tokens.states[state].label
49}
50
51/** What the person sees when there is no finding to draw. */
52const problemCopy: Record<GlanceProblem, { title: string; help: string }> = {
53  unlocated: {
54    title: 'Cannot find the wasitme data file',
55    help: 'Set "Glance file" in this plugin\'s options to the absolute path of glance.json, then run ~/.local/bin/wasitme scan.',
56  },
57  missing: {
58    title: 'No wasitme scan on this Mac yet',
59    help: 'Installed? Run ~/.local/bin/wasitme scan. Not installed? One-off check: /wasitme:report.',
60  },
61  unreadable: { title: 'Could not read the last scan', help: 'Run ~/.local/bin/wasitme scan to write it again.' },
62  invalid: {
63    title: 'The last scan is not in the shape this plugin expects',
64    help: 'Run ~/.local/bin/wasitme scan to write it again.',
65  },
66  mismatch: {
67    title: 'wasitme parts are out of sync',
68    help: 'Run ~/.local/bin/wasitme update, then /reload-plugins.',
69  },
70}
71
72/** The scan error kinds (the contract carries a kind, never text). */
73const scanErrorCopy: Record<GlanceScanError, string> = {
74  permission_denied: 'wasitme was not allowed to read a session folder.',
75  write_failed: 'wasitme could not write its results.',
76  timeout: 'The scan took too long and was stopped.',
77  internal: 'Something went wrong inside the scan.',
78}
79
80export const theme = {
81  glyph,
82  label,
83  sticker,
84
85  /** Secondary text and rules: Claude Code's own dim (tokens modPane.secondary / modPane.rule). */
86  secondary: tokens.modPane.secondary,
87  rule: tokens.modPane.rule,
88  emphasis: tokens.modPane.emphasis,
89
90  /** The case line: a dashed rule, a tick up where your change sits, a tick down where the agent's does. */
91  caseLine: { rule: '┄', you: '┴', agent: '┬', unknown: '?' },
92
93  agentName: { 'claude-code': 'Claude Code', codex: 'Codex' } as Record<string, string>,
94
95  /** Metric names when the glance carries an unlock row for a metric it lists no label for: the engine's labels
96   *  (engine/src/words/names.ts), so an indicator has one name on every surface. */
97  metricName: {
98    toolErrors: 'Tool errors',
99    toolErrorsNonCmd: 'Tool errors (excl. commands)',
100    cmdFailures: 'Command failures',
101    readsPerEdit: 'Reads per edit',
102    blindEdits: 'Edits without reading first',
103    interrupts: 'Interruptions',
104    pushback: 'Pushback prompts',
105    churn: 'Files edited 3+ times',
106  } as Record<string, string>,
107
108  /** What a metric's "events" are, for "31 of 40 edits" (the CLI's nouns). */
109  eventNoun: {
110    toolErrors: 'errors',
111    toolErrorsNonCmd: 'errors',
112    cmdFailures: 'failures',
113    readsPerEdit: 'edits',
114    blindEdits: 'edits without reading first',
115    interrupts: 'interruptions',
116    pushback: 'pushback prompts',
117    churn: 'files edited 3+ times',
118  } as Record<string, string>,
119
120  /** Row labels under the case line (events, then opportunities), at most 6 cells, so a long name is shortened to what
121   *  it counts ("unread" edits for edits without reading first, "3+" for files edited 3+ times). */
122  stripRows: {
123    toolErrors: ['errors', 'calls'],
124    toolErrorsNonCmd: ['errors', 'calls'],
125    cmdFailures: ['fails', 'cmds'],
126    readsPerEdit: ['reads', 'edits'],
127    blindEdits: ['unread', 'edits'],
128    interrupts: ['stops', 'turns'],
129    pushback: ['pushes', 'turns'],
130    churn: ['3+', 'edits'],
131  } as Record<string, readonly [string, string]>,
132  stripRowsFallback: ['events', 'of'] as const,
133
134  /** The ledger's short status words (the CLI's): never worse or better. */
135  status: {
136    context: 'context',
137    notYet: 'not yet',
138    more: 'moved, more',
139    fewer: 'moved, fewer',
140    notDetected: 'not detected',
141  },
142
143  bar: { full: '█', empty: '░', cells: 10 },
144
145  copy: {
146    problem: problemCopy,
147    scanError: scanErrorCopy,
148    loading: 'Reading the last scan…',
149    scanFailed: { title: 'The last scan failed', help: 'Run ~/.local/bin/wasitme doctor to see why.' },
150    staleOld: { title: 'Out of date', help: 'Run ~/.local/bin/wasitme scan to refresh it.' },
151    staleClock: { title: 'The last scan has a timestamp from the future', help: 'Check the clock, then run ~/.local/bin/wasitme scan.' },
152    staleUnknown: { title: 'Cannot tell how old the last scan is', help: 'Run ~/.local/bin/wasitme scan to refresh it.' },
153    lastKnown: 'Last result:',
154    empty: {
155      title: 'No Claude Code or Codex logs found in ~/.claude or ~/.codex',
156      help: 'Different place? ~/.local/bin/wasitme doctor',
157    },
158    demo: 'DEMO DATA: not from your logs',
159    whatChanged: 'What changed',
160    finding: 'Finding',
161    signals: 'Signals',
162    next: 'Next',
163    lastDays: 'the last 30 days',
164    noChanges: 'No changes recorded.',
165    yours: 'yours',
166    agentLane: 'agent',
167    yourSide: 'your side',
168    agentSide: 'agent side',
169    unknownSide: 'origin unknown',
170    routine: 'routine update',
171    isNew: 'new',
172    earlier: (n: number) => `${n} earlier ${n === 1 ? 'change' : 'changes'} not shown; /wasitme:report lists them all`,
173    routineNote: "Routine updates alone aren't evidence.",
174    confirming: 'Possible shift — confirming',
175    nextToUnlock: 'Next to unlock:',
176    noDate: 'No date yet: it depends on how your sessions go.',
177    vs: 'vs',
178    range: 'range',
179    wontShow: (mde: string) => `under ${mde} won't show`,
180    updated: 'updated',
181    justNow: 'just now',
182    unknownAge: 'at an unknown time',
183    privacy: tokens.copy.privacyShort,
184    bandTag: 'wasitme',
185    bandHint: '/wasitme',
186    commandOpened: 'wasitme pane opened.',
187    commandWaiting: 'wasitme pane is waiting for a surface that can draw it.',
188  },
189
190  button: { refresh: 'Check again', close: 'Hide', hide: 'Hide' },
191
192  layout: {
193    /** The pane is drawn for about 64 columns (DESIGN.md §9) and shrinks to what it is given. */
194    columns: 64,
195    /** Label column of the case line and the rows under it. */
196    laneLeft: 8,
197    /** Cells per day on the case line. */
198    pitch: 4,
199    minDays: 4,
200    maxDays: 21,
201    /** At most this many changes are listed under the case line. */
202    maxEvents: 8,
203    /** The band drops its agent name below this many columns. */
204    bandCompactBelow: 64,
205    indent: '  ',
206    separator: ' · ',
207  },
208}
209
mod/design-tokens.ts 599 lines
1// GENERATED from design/system/tokens.json by design/system/gen/build.mjs. Do not edit by hand.
2// Zero dependencies. Import from the engine/CLI and the Claude Code mod; never hand-edit.
3
4export const tokens = {
5  "color": {
6    "light": {
7      "surface": {
8        "page": "#F1F0EC",
9        "sheet": "#FCFCFA",
10        "sidebar": "#EAE9E4",
11        "raised": "#FFFFFF",
12        "well": "#F4F3EF",
13        "selection": "#E3E2DC"
14      },
15      "rule": {
16        "hair": "#DEDDD6",
17        "strong": "#B8B7AF",
18        "ink": "#1C1D21"
19      },
20      "ink": {
21        "primary": "#1C1D21",
22        "secondary": "#46474D",
23        "muted": "#5E5F64",
24        "onInk": "#FCFCFA"
25      },
26      "accent": {
27        "action": "#1C1D21",
28        "actionText": "#FCFCFA",
29        "focus": "#1C1D21"
30      },
31      "party": {
32        "you": {
33          "fill": "#F5C842",
34          "ink": "#1C1D21",
35          "keyline": "#1C1D21",
36          "tint": "#FAF0D0"
37        },
38        "agent": {
39          "fill": "#8DBCF0",
40          "ink": "#1C1D21",
41          "keyline": "#1C1D21",
42          "tint": "#E7EEFB"
43        }
44      },
45      "chart": {
46        "mark": "#1C1D21",
47        "axis": "#1C1D21",
48        "grid": "#E6E5DF",
49        "label": "#5E5F64",
50        "range": "#1C1D21",
51        "estimate": "#1C1D21",
52        "mdeFill": "#EFEEE9",
53        "mdeHatch": "#85868A",
54        "connector": "#85868A"
55      },
56      "state": {
57        "insufficient": {
58          "fg": "#46474D",
59          "bg": "#FCFCFA",
60          "glyph": "#46474D",
61          "edge": "#66676C",
62          "edgeStyle": "dashed"
63        },
64        "none": {
65          "fg": "#FCFCFA",
66          "bg": "#1C1D21",
67          "glyph": "#FCFCFA",
68          "edge": "#1C1D21",
69          "edgeStyle": "solid"
70        },
71        "unclear": {
72          "fg": "#1C1D21",
73          "bg": "#FCFCFA",
74          "glyph": "#1C1D21",
75          "edge": "#1C1D21",
76          "edgeStyle": "solid",
77          "swatch": [
78            "#F5C842",
79            "#8DBCF0"
80          ]
81        },
82        "you": {
83          "fg": "#1C1D21",
84          "bg": "#F5C842",
85          "glyph": "#1C1D21",
86          "edge": "#1C1D21",
87          "edgeStyle": "solid"
88        },
89        "agent": {
90          "fg": "#1C1D21",
91          "bg": "#8DBCF0",
92          "glyph": "#1C1D21",
93          "edge": "#1C1D21",
94          "edgeStyle": "solid"
95        },
96        "stale": {
97          "fg": "#46474D",
98          "bg": "#FCFCFA",
99          "glyph": "#46474D",
100          "edge": "#66676C",
101          "edgeStyle": "dotted"
102        }
103      }
104    },
105    "dark": {
106      "surface": {
107        "page": "#131418",
108        "sheet": "#1B1C21",
109        "sidebar": "#17181C",
110        "raised": "#23242A",
111        "well": "#202126",
112        "selection": "#2D2E34"
113      },
114      "rule": {
115        "hair": "#2E2F36",
116        "strong": "#4A4B53",
117        "ink": "#ECEBE6"
118      },
119      "ink": {
120        "primary": "#ECEBE6",
121        "secondary": "#B9B8B2",
122        "muted": "#9C9B95",
123        "onInk": "#1B1C21"
124      },
125      "accent": {
126        "action": "#ECEBE6",
127        "actionText": "#1B1C21",
128        "focus": "#ECEBE6"
129      },
130      "party": {
131        "you": {
132          "fill": "#F2C230",
133          "ink": "#1B1C21",
134          "keyline": "#F2C230",
135          "tint": "#332B14"
136        },
137        "agent": {
138          "fill": "#8CC4F2",
139          "ink": "#1B1C21",
140          "keyline": "#8CC4F2",
141          "tint": "#182838"
142        }
143      },
144      "chart": {
145        "mark": "#ECEBE6",
146        "axis": "#ECEBE6",
147        "grid": "#2A2B31",
148        "label": "#9C9B95",
149        "range": "#ECEBE6",
150        "estimate": "#ECEBE6",
151        "mdeFill": "#25262C",
152        "mdeHatch": "#74757B",
153        "connector": "#74757B"
154      },
155      "state": {
156        "insufficient": {
157          "fg": "#B9B8B2",
158          "bg": "#1B1C21",
159          "glyph": "#B9B8B2",
160          "edge": "#8F8E89",
161          "edgeStyle": "dashed"
162        },
163        "none": {
164          "fg": "#1B1C21",
165          "bg": "#ECEBE6",
166          "glyph": "#1B1C21",
167          "edge": "#ECEBE6",
168          "edgeStyle": "solid"
169        },
170        "unclear": {
171          "fg": "#ECEBE6",
172          "bg": "#1B1C21",
173          "glyph": "#ECEBE6",
174          "edge": "#ECEBE6",
175          "edgeStyle": "solid",
176          "swatch": [
177            "#F2C230",
178            "#8CC4F2"
179          ]
180        },
181        "you": {
182          "fg": "#1B1C21",
183          "bg": "#F2C230",
184          "glyph": "#1B1C21",
185          "edge": "#F2C230",
186          "edgeStyle": "solid"
187        },
188        "agent": {
189          "fg": "#1B1C21",
190          "bg": "#8CC4F2",
191          "glyph": "#1B1C21",
192          "edge": "#8CC4F2",
193          "edgeStyle": "solid"
194        },
195        "stale": {
196          "fg": "#B9B8B2",
197          "bg": "#1B1C21",
198          "glyph": "#B9B8B2",
199          "edge": "#8F8E89",
200          "edgeStyle": "dotted"
201        }
202      }
203    }
204  },
205  "states": {
206    "insufficient": {
207      "label": "Too early to tell",
208      "headline": "Too early to tell.",
209      "legend": "Dashed rule, two specks: too early to tell",
210      "textGlyph": "·┄·",
211      "ascii": "[..]",
212      "voiceOver": "wasitme: too early to tell"
213    },
214    "none": {
215      "label": "No detectable change",
216      "headline": "No detectable change.",
217      "legend": "Bare rule: no detectable change",
218      "textGlyph": "───",
219      "ascii": "[none]",
220      "voiceOver": "wasitme: no detectable change"
221    },
222    "unclear": {
223      "label": "Can't tell which",
224      "headline": "Can't tell which.",
225      "legend": "Square and triangle: can't tell which",
226      "textGlyph": "■─▲",
227      "ascii": "[?]",
228      "voiceOver": "wasitme: numbers moved, can't tell which change"
229    },
230    "you": {
231      "label": "Your side",
232      "headline": "Your side changed.",
233      "legend": "Square above the rule: your side changed",
234      "textGlyph": "■──",
235      "ascii": "[you]",
236      "voiceOver": "wasitme: your side changed"
237    },
238    "agent": {
239      "label": "Agent side",
240      "headline": "The agent changed.",
241      "legend": "Triangle below the rule: the agent changed",
242      "textGlyph": "──▲",
243      "ascii": "[agent]",
244      "voiceOver": "wasitme: the agent changed"
245    },
246    "stale": {
247      "label": "Out of date",
248      "headline": "Out of date.",
249      "legend": "Struck-through rule: out of date",
250      "textGlyph": "─╱─",
251      "ascii": "[stale]",
252      "voiceOver": "wasitme: out of date, last checked {when}"
253    }
254  },
255  "stateOrder": [
256    "insufficient",
257    "none",
258    "unclear",
259    "you",
260    "agent"
261  ],
262  "appStates": {
263    "order": [
264      "loading",
265      "notSetUp",
266      "empty",
267      "unreadable",
268      "updateNeeded",
269      "refused"
270    ],
271    "chip": {
272      "fg": "ink.secondary",
273      "bg": "surface.sheet",
274      "glyph": "ink.secondary",
275      "edge": "rule.strong",
276      "edgeStyle": "solid"
277    },
278    "loading": {
279      "label": "Loading",
280      "legend": "Three dots: loading"
281    },
282    "notSetUp": {
283      "label": "Not set up yet",
284      "legend": "Empty brackets: not set up yet"
285    },
286    "empty": {
287      "label": "No agents yet",
288      "legend": "Ticked rule, nothing on it: no agents yet"
289    },
290    "unreadable": {
291      "label": "Can't read status",
292      "legend": "Rule and exclamation mark: can't read status"
293    },
294    "updateNeeded": {
295      "label": "Update needed",
296      "legend": "Two rules out of step: update needed"
297    },
298    "refused": {
299      "label": "Not shown",
300      "legend": "Blacked-out rule: not shown"
301    }
302  },
303  "newEvent": {
304    "text": "+{n}"
305  },
306  "terminal": {
307    "sgr": {
308      "ansi16": {
309        "stickerYou": "7",
310        "stickerAgent": "7",
311        "chipNone": "7",
312        "chipInsufficient": "2",
313        "chipStale": "2",
314        "chipUnclear": "the text glyph and the words in the default colours (no colour cells)",
315        "muted": "2",
316        "rule": "2",
317        "emphasis": "1",
318        "brandUnderline": "4",
319        "why": "16-colour mode (TERM without 256color: tmux/screen defaults, the Linux console) cannot know the palette, and common themes remap the bright colours (Solarized: bright yellow and bright cyan are greys; black on them is 2.92:1 and the two stickers look alike). Reverse video swaps the theme's own fg and bg, so a sticker always has the theme's contrast. Your stickers and the agent's look alike here; the numeral vs letter, above vs below the line, and the words carry the party. muted and rule stay SGR 2 (dim) because no grey is safe without knowing the palette; how each terminal draws dim is not checked (DESIGN.md section 12). Rules and glyphs are never bold (Menlo Bold has no box-drawing glyphs)."
320      },
321      "ansi256": {
322        "dark": {
323          "stickerYou": "38;5;16;48;5;221",
324          "stickerAgent": "38;5;16;48;5;117",
325          "muted": "38;5;246",
326          "rule": "38;5;243",
327          "emphasis": "1"
328        },
329        "light": {
330          "stickerYou": "38;5;16;48;5;221",
331          "stickerAgent": "38;5;16;48;5;117",
332          "muted": "38;5;242",
333          "rule": "38;5;244",
334          "emphasis": "1"
335        },
336        "xterm256Hex": {
337          "16": "#000000",
338          "117": "#87D7FF",
339          "221": "#FFD75F",
340          "242": "#6C6C6C",
341          "243": "#767676",
342          "244": "#808080",
343          "246": "#949494"
344        }
345      },
346      "truecolor": {
347        "stickerYou": "color.<mode>.party.you.fill behind color.<mode>.party.you.ink",
348        "stickerAgent": "color.<mode>.party.agent.fill behind color.<mode>.party.agent.ink",
349        "dark": {
350          "muted": "#9C9B95",
351          "rule": "#74757B"
352        },
353        "light": {
354          "muted": "#66676B",
355          "rule": "#85868A"
356        },
357        "why": "Explicit greys (38;2;r;g;b), not SGR 2 dim: dim is drawn differently by every terminal and a 50% blend falls under 4.5:1. Each grey is gated against every declared background of its mode (report: 'terminal greys'). The mode comes from chooseMode; a light terminal that does not set COLORFGBG lands in the dark greys (the same risk ansi256 has)."
358      },
359      "noColor": {
360        "rule": "NO_COLOR or a pipe: no escape codes at all. Stickers become [1] and [A]; the state is the text glyph plus its words, e.g. '·┄· too early to tell'.",
361        "stickerYou": "[{n}]",
362        "stickerAgent": "[{letter}]"
363      }
364    },
365    "backgrounds": {
366      "darkMock": {
367        "bg": "#16171B",
368        "fg": "#D9D8D3",
369        "muted": "#8E8D88",
370        "status": "mock theme (our own)"
371      },
372      "lightMock": {
373        "bg": "#FBFBF8",
374        "fg": "#26272B",
375        "muted": "#66676B",
376        "status": "mock theme (our own)"
377      },
378      "ansi16Mock": {
379        "bg": "#000000",
380        "fg": "#E5E5E5",
381        "muted": "#7F7F7F",
382        "status": "mock theme: xterm palette 0 / 7, muted = palette 8"
383      },
384      "codeTabLight": {
385        "bg": "#FAF9F5",
386        "fg": "#3D3D3A",
387        "muted": "#6B6A65",
388        "status": "assumed - replace with a capture of the Claude Desktop Code tab"
389      },
390      "codeTabDark": {
391        "bg": "#262624",
392        "fg": "#E8E6E1",
393        "muted": "#A3A19B",
394        "status": "assumed - replace with a capture of the Claude Desktop Code tab"
395      }
396    }
397  },
398  "modPane": {
399    "byClaudeTheme": {
400      "dark": {
401        "stickerYou": {
402          "color": "#1B1C21",
403          "backgroundColor": "#F2C230"
404        },
405        "stickerAgent": {
406          "color": "#1B1C21",
407          "backgroundColor": "#8CC4F2"
408        }
409      },
410      "light": {
411        "stickerYou": {
412          "color": "#1C1D21",
413          "backgroundColor": "#F5C842"
414        },
415        "stickerAgent": {
416          "color": "#1C1D21",
417          "backgroundColor": "#8DBCF0"
418        }
419      },
420      "ansi": {
421        "stickerYou": {
422          "color": "black",
423          "backgroundColor": "yellowBright"
424        },
425        "stickerAgent": {
426          "color": "black",
427          "backgroundColor": "cyanBright"
428        }
429      }
430    },
431    "themeMatch": "Theme names containing 'light' use 'light'; names containing 'ansi' use 'ansi'; everything else uses 'dark'. Theme names are read from the theme config row; the exact list was not verified for this build.",
432    "ansiRisk": "The 'ansi' family names terminal palette colours, so it has the 16-colour palette risk (Solarized greys out yellowBright/cyanBright). If the mod kit's Text supports inverse, use inverse instead, as terminal.sgr.ansi16 does; not verified for this build.",
433    "secondary": {
434      "dimColor": true
435    },
436    "rule": {
437      "dimColor": true
438    },
439    "emphasis": {
440      "bold": true
441    },
442    "buttons": "Claude Code draws Buttons itself; the primary action (Report) uses variant=primary, everything else default.",
443    "layout": "Buttons first so an 80-column inline pane can't clip them (plugin/README.md, What the mod shows)."
444  },
445  "copy": {
446    "privacyLine": "No network code. Only the installer downloads, and only when you run it.",
447    "privacyShort": "local only",
448    "disclaimer": "These indicators don't measure answer quality. Evidence, not proof.",
449    "tagline": "Measure twice, blame once.",
450    "question": "Was it me, or the model?"
451  },
452  "glanceBanned": "\\b(worse|better|nerf\\w*|degrad\\w*|improv\\w*)\\b",
453  "size": {
454    "popover": {
455      "width": 352,
456      "height": 480
457    },
458    "panelSmall": {
459      "width": 170,
460      "height": 170
461    },
462    "panelMedium": {
463      "width": 360,
464      "height": 170
465    },
466    "controlCenter": {
467      "width": 1280,
468      "height": 800
469    },
470    "controlCenterMinimum": {
471      "width": 900,
472      "height": 600
473    },
474    "menuBarGlyphLarge": 18,
475    "menuBarGlyphSmall": 16,
476    "chipHeight": 26,
477    "chipGlyph": 16,
478    "chipSwatch": 9,
479    "sticker": {
480      "width": 20,
481      "height": 20
482    },
483    "tag": {
484      "width": 20,
485      "height": 22
486    },
487    "badgeYou": {
488      "width": 18,
489      "height": 18
490    },
491    "badgeAgent": {
492      "width": 18,
493      "height": 20
494    },
495    "buttonHeight": 30,
496    "buttonCompactHeight": 28,
497    "panelGlyph": 30,
498    "pinGlyph": 22
499  },
500  "chart": {
501    "strip": {
502      "tickHeight": 2,
503      "tickGap": 1,
504      "denseAbove": 25,
505      "denseTickHeight": 1,
506      "denseTickGap": 1,
507      "columnWidth": 14,
508      "columnGap": 6,
509      "compactColumnWidth": 6,
510      "compactColumnGap": 3,
511      "lowNThreshold": 100
512    },
513    "ratio": {
514      "domain": [
515        0.25,
516        8
517      ],
518      "ticks": [
519        0.5,
520        1,
521        2,
522        4
523      ],
524      "rangeStroke": 2
525    },
526    "timelineRule": 2
527  }
528} as const;
529
530export type FindingState = keyof typeof tokens.states;
531export type Party = 'you' | 'agent';
532export type ColorMode = 'none' | 'ansi16' | 'ansi256-dark' | 'ansi256-light' | 'truecolor-dark' | 'truecolor-light';
533
534const ESC = '\u001b[';
535const wrap = (sgr: string, text: string): string => (sgr ? ESC + sgr + 'm' + text + ESC + '0m' : text);
536const rgb = (hex: string): string => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)).join(';');
537
538/** Pick the colour mode from the environment (see tokens.json terminal.choose). */
539export function chooseMode(env: Record<string, string | undefined>, isTTY: boolean): ColorMode {
540  if (!isTTY || env.NO_COLOR !== undefined || env.TERM === 'dumb') return 'none';
541  const fgbg = env.COLORFGBG ?? '';
542  const light = /;(15|7)$/.test(fgbg);
543  if (env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit') return light ? 'truecolor-light' : 'truecolor-dark';
544  if ((env.TERM ?? '').includes('256color')) return light ? 'ansi256-light' : 'ansi256-dark';
545  return 'ansi16';
546}
547
548/** A party sticker: " 1 " on canary or " A " on blue; reverse video in 16 colours; "[1]" / "[A]" without colour. */
549export function sticker(party: Party, text: string, mode: ColorMode): string {
550  if (mode === 'none') return '[' + text + ']';
551  const key = party === 'you' ? 'stickerYou' : 'stickerAgent';
552  if (mode === 'ansi16') return wrap(tokens.terminal.sgr.ansi16[key], ' ' + text + ' ');
553  if (mode === 'ansi256-dark' || mode === 'ansi256-light') {
554    const m = mode === 'ansi256-dark' ? tokens.terminal.sgr.ansi256.dark : tokens.terminal.sgr.ansi256.light;
555    return wrap(m[key], ' ' + text + ' ');
556  }
557  const c = (mode === 'truecolor-dark' ? tokens.color.dark : tokens.color.light).party[party];
558  return wrap('38;2;' + rgb(c.ink) + ';48;2;' + rgb(c.fill), ' ' + text + ' ');
559}
560
561/** Secondary text, rules and emphasis: explicit greys in 256 and truecolor modes, SGR 2 only in 16 colours. Never bold on box-drawing glyphs. */
562export function styled(role: 'muted' | 'rule' | 'emphasis' | 'brandUnderline', text: string, mode: ColorMode): string {
563  if (mode === 'none') return text;
564  if (role === 'brandUnderline') return wrap('4', text);
565  if (role === 'emphasis') return wrap('1', text);
566  // 16 colours: no grey is safe without knowing the palette, so muted text and rules use SGR 2 (dim) here only.
567  if (mode === 'ansi16') return wrap(role === 'muted' ? tokens.terminal.sgr.ansi16.muted : tokens.terminal.sgr.ansi16.rule, text);
568  if (mode === 'truecolor-dark' || mode === 'truecolor-light') {
569    const g = mode === 'truecolor-dark' ? tokens.terminal.sgr.truecolor.dark : tokens.terminal.sgr.truecolor.light;
570    return wrap('38;2;' + rgb(role === 'muted' ? g.muted : g.rule), text);
571  }
572  const m = mode === 'ansi256-dark' ? tokens.terminal.sgr.ansi256.dark : tokens.terminal.sgr.ansi256.light;
573  return wrap(role === 'muted' ? m.muted : m.rule, text);
574}
575
576/** The three-cell text glyph, or its ASCII fallback. */
577export function stateGlyph(state: FindingState, ascii = false): string {
578  return ascii ? tokens.states[state].ascii : tokens.states[state].textGlyph;
579}
580
581/** One-row status line: the state and an optional new-change count. Never a cause, never a quality word. */
582export function statusLine(state: FindingState, newChanges: number, mode: ColorMode, ascii = false): string {
583  const s = tokens.states[state];
584  let label: string = s.label.toLowerCase();
585  if (state === 'you') label = sticker('you', label, mode);
586  else if (state === 'agent') label = sticker('agent', label, mode);
587  const extra = newChanges > 0 ? '  ' + styled('muted', tokens.newEvent.text.replace('{n}', String(newChanges)), mode) : '';
588  const brand = 'wasit' + styled('brandUnderline', 'me', mode);
589  return brand + '  ' + stateGlyph(state, ascii) + ' ' + label + extra;
590}
591
592/** Mod-pane sticker props for a Claude Code theme name. */
593export function modSticker(party: Party, claudeTheme: string): { color: string; backgroundColor: string } {
594  const t = claudeTheme.toLowerCase();
595  const fam = t.includes('ansi') ? 'ansi' : t.includes('light') ? 'light' : 'dark';
596  const m = tokens.modPane.byClaudeTheme[fam];
597  return party === 'you' ? m.stickerYou : m.stickerAgent;
598}
599
types/index.d.ts 170 lines
1// wasitme mod: the shapes the module passes around and its `$.state` contract.
2// Every `$.state` key the hooks module names is declared at the bottom;
3// `claude plugin validate` holds the module to it. State is plain JSON data,
4// so absent values are `null`, never `undefined`.
5//
6// These are the module's own view of contract/glance.v1.schema.json (frozen,
7// docs/CONTRACT.md): parsed, bounded and stripped of control characters (see
8// mod/glance.ts), never the raw file.
9
10/** D22 verdict states. Unknown input is mapped to `unclear`; `stale` is a display, not a state. */
11export type GlanceVerdict = 'insufficient' | 'none' | 'unclear' | 'you' | 'agent'
12
13/** Decision-table reasons (METHOD.md §11); an unknown reason is `null`. */
14export type GlanceReason =
15  | 'calibration_pending'
16  | 'needs_data'
17  | 'single_indicator'
18  | 'mixed'
19  | 'workload'
20  | 'unknown_provenance'
21  | 'both_sides'
22  | 'nothing_recorded_on_your_side'
23  | 'blind_spot'
24  | 'by_elimination'
25
26/** Who initiated a change. Unknown input is `unknown`, never `agent`. */
27export type GlanceSide = 'you' | 'agent' | 'unknown' | 'meta'
28
29export type GlanceStrength = 'strong' | 'weak' | 'routine'
30
31export type GlanceMetricStatus = 'worse' | 'better' | 'none' | 'ineligible'
32
33export type GlanceFamily = 'errors' | 'research' | 'friction'
34
35export type GlanceRole = 'vote' | 'support' | 'context'
36
37/** Why the last scan failed (an unknown kind reads as `internal`). */
38export type GlanceScanError = 'permission_denied' | 'write_failed' | 'timeout' | 'internal'
39
40export type GlanceLead = 'timeline' | 'verdict'
41
42/** Integer totals for one window: k events over n opportunities. */
43export type GlanceKN = { k: number; n: number }
44
45export type GlanceMetric = {
46  id: string
47  label: string
48  unit: string
49  family: GlanceFamily | null
50  role: GlanceRole | null
51  recent: GlanceKN | null
52  baseline: GlanceKN | null
53  ratio: number | null
54  /** The interval around `ratio`, printed as "range". */
55  range: [number, number] | null
56  /** Minimum detectable ratio. */
57  mde: number | null
58  status: GlanceMetricStatus | null
59}
60
61export type GlanceEvent = {
62  day: string
63  kind: string
64  side: GlanceSide
65  strength: GlanceStrength | null
66  label: string
67  isNew: boolean
68}
69
70export type GlanceStripDay = { day: string; k: number | null; n: number | null }
71
72export type GlanceStrip = {
73  metric: string
74  days: GlanceStripDay[]
75  ratio: number | null
76  lo: number | null
77  hi: number | null
78  mde: number | null
79}
80
81export type GlanceGate = { events: number | null; sessions: number | null; sessionDays: number | null }
82
83export type GlanceUnlock = { metric: string; family: GlanceFamily | null; have: GlanceGate; need: GlanceGate }
84
85export type GlanceProgress = {
86  tier: number | null
87  /** YYYY-MM-DD, or '' when there is none. */
88  etaDate: string
89  notAtCurrentPace: boolean
90  unlock: GlanceUnlock[]
91}
92
93export type GlanceCounts = {
94  exchanges: number | null
95  sessions: number | null
96  sessionDays: number | null
97  days: number | null
98}
99
100export type GlanceAgent = {
101  /** `claude-code`, `codex`, or a future id (sanitized). */
102  agent: string
103  state: GlanceVerdict
104  reason: GlanceReason | null
105  /** A change waiting for a second agreeing evaluation; the state shown is the held one. */
106  pending: boolean
107  /** False: this agent's verdicts are off until its own calibration passes ("Timeline only"). */
108  calibrated: boolean
109  label: string
110  headline: string
111  because: string
112  tryThis: string
113  confidence: string
114  /** The engine's band line; empty unless the engine says the band should speak. */
115  band: string
116  statusLine: string
117  n: GlanceCounts
118  progress: GlanceProgress | null
119  metrics: GlanceMetric[]
120  strip: GlanceStrip | null
121  /** Newest first. */
122  events: GlanceEvent[]
123}
124
125export type GlanceFile = {
126  /** The engine's own timestamp string, sanitized. */
127  generatedAt: string
128  /** `generatedAt` as epoch milliseconds, or null when it does not parse. */
129  generatedAtMs: number | null
130  /** From the file's `staleAfterSec` (default 7,200 s). */
131  staleAfterMs: number
132  scanOk: boolean
133  scanError: GlanceScanError | null
134  demo: boolean
135  lead: GlanceLead
136  /** Engine order: the first agent is the one a single-line surface speaks for. */
137  agents: GlanceAgent[]
138}
139
140/**
141 * Why there is nothing to draw:
142 * - `unlocated`: the module cannot work out where glance.json lives.
143 * - `missing`: the file is not there (no scan has run).
144 * - `unreadable`: it could not be read, is empty, too large, or not JSON.
145 * - `invalid`: JSON, right schema id, but not shaped like a glance (or it
146 *   does not promise it holds no text: the contract's "refused").
147 * - `mismatch`: a different schema id (`found` says which).
148 */
149export type GlanceProblem = 'unlocated' | 'missing' | 'unreadable' | 'invalid' | 'mismatch'
150
151export type GlanceLoad = { ok: true; glance: GlanceFile } | { ok: false; problem: GlanceProblem; found: string }
152
153/** What one refresh produced. `nowMs` is the clock when it ran, so a drawing never reads the clock. */
154export type GlanceSnapshot = {
155  load: GlanceLoad
156  /** The file's mtime/size at the last read, to skip re-reading an unchanged file. */
157  mtimeMs: number | null
158  size: number | null
159  nowMs: number
160}
161
162declare module 'claude-code' {
163  interface PluginState {
164    wasitme: {
165      snapshot: GlanceSnapshot | null
166      isBandHidden: boolean
167    }
168  }
169}
170