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…

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.
| Path | What |
|---|---|
.claude-plugin/plugin.json | Manifest. 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.json | The 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.sh | SessionStart: the sandboxed project snapshot, then kick the scan LaunchAgent. SessionEnd: the kick only. Everything comes from ~/.wasitme/engine.env. |
scripts/run.sh | The report skill's entry point (and the Codex skill's): finds the installed engine and runs report or status (below). |
mod/register.ts | Wiring only: /wasitme, the pane, the quiet band, the once-a-minute re-check. |
mod/glance.ts | Pure: find, parse, bound and classify ~/.wasitme/glance.json (contract wasitme.glance/1, frozen). |
mod/theme.ts, mod/render.ts | Everything 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.ts | design/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/.
$.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.
The pane follows the design system's mod-pane screens (design/system/screens/text-*.png, about 64 × 18 cells):
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 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.
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.
startup|resume; D32/D60):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;<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);/bin/launchctl kickstart gui/<uid>/<label>, never -k).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).
/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.
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):
codex plugin marketplace add ~/.wasitme/current/plugin-codex, then codex plugin add wasitme@wasitme-codex;codex plugin marketplace remove wasitme-codex, add it again at ~/.wasitme/current/plugin-codex, then codex plugin add wasitme@wasitme-codex;codex plugin remove wasitme@wasitme-codex, then codex plugin marketplace remove wasitme-codex;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).
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).
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.
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.
mod/register.ts 145 lines1// 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}
145mod/glance.ts 453 lines1// 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}
453mod/render.ts 720 lines1// 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}
720mod/theme.ts 209 lines1// 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}
209mod/design-tokens.ts 599 lines1// 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}
599types/index.d.ts 170 lines1// 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