SLOPSHOPPER

loopkeeper Where Am I

Know where the session is at a glance: repo, branch, worktree, uncommitted and unpushed work, usage windows and the prompt cache in one line above the prompt.

newpanebandcommandtoastprocess
★ 1v0.3.1MITupdated 2026-10-12connectwithub/loopkeeper/plugins/lk-where-am-i
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lk-where-am-i
│ ┃ Where am I ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ where you are │ ⏺ Read(src/auth.ts) │ ┃ │ app (no git) │ ⎿ Read 6 lines │ ┃ │ /work/app │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ┌──────────────────────────────────────────┐ ⎿ 3 pass, 1 fail │ ┃ │ Changed │ │ ┃ │ — │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ └──────────────────────────────────────────┘ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ LOCATION 2 │ ┃ Folder app (not a git repository) › /where │ ┃ cwd /work/app │ ┃ │ ┃ USAGE 1 │ ┃ 5h ━━━╌╌╌╌╌╌╌ 31% │ ┃ │ ┃ PROMPT CACHE │ ┃ no request yet TTL 60m from default │ ┃ │ ┃ r: Refresh c: Close │ app (no git) ● 5h 31% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
app (no git) ● 5h 31% ⟨Claude Code's own drawing⟩
Pane · Where am I
╭──────────────────────────────────────────────────────────╮ │ where you are │ │ app (no git) │ │ /work/app │ ╰──────────────────────────────────────────────────────────╯ ┌──────────────────────────────────────────────────────────┐ │ Changed │ │ — │ └──────────────────────────────────────────────────────────┘ LOCATION 2 Folder app (not a git repository) cwd /work/app USAGE 1 5h ━━━╌╌╌╌╌╌╌ 31% PROMPT CACHE no request yet TTL 60m from default r: Refresh c: Close
README

lk-where-am-i

A loopkeeper add-on for Claude Code that keeps one line above the prompt saying which repo, branch and worktree the session is in, how dirty it is, and how the account's usage windows and prompt cache are doing. Made for running many repos and worktrees at once. A glance tells you which checkout this session will change before you ask it to change anything, and how close you are to a usage limit.

Use it when

  • You run Claude Code in several repos or worktrees at once and want to see, before you type, which one this session is in.
  • You are on a subscription and want the 5-hour and 7-day usage windows in view without opening another tool.
  • You come back after a break and want to know whether the prompt cache is still warm and what is uncommitted or unpushed.

Install: /plugin install lk-where-am-i@loopkeeper, after /plugin marketplace add connectwithub/loopkeeper (more under Install). Requires Claude Code 2.1.294 or later. Its usage-window chips need a Claude subscription.

Data handling: it keeps the settings you save with /where in its own plugin store (a JSON file under your Claude Code configuration folder) and the last git reading in session state. It reads ~/.claude/where-am-i.json, runs read-only git in the session's folder and writes no file. It makes no model calls and sends nothing over the network. It collects no personal data; the saved settings stay until you delete them. Details under What it runs.

Commands: /where and its options are listed under Commands.

The band (the figures are examples):

acme-repo@main ⑂wt:feat-x  ● 5h 34%  ● 7d 12%  ● cache 41m          3 dirty · 2↑ · 14m since commit
  • Label: the location in bold: the repo name (the main repo's folder, also from a linked worktree), the branch or (sha) when detached, and ⑂wt:<name> in a linked worktree. Outside git it shows <folder> (no git); before git is read, where.
  • Usage windows: 5h and 7d percent used as chips, from session.measure (falling back to $.session.usage()). A chip is muted, in the warn tone from 80%, and in the danger tone from 95%; one toast per window at 90% (re-armed when the window resets). Windows past their reset time are hidden until a fresh reading.
  • Prompt cache: a chip with the time left since the last main-loop model request against the TTL (default 60 minutes): muted, in the warn tone under 5 minutes, cache cold after expiry. Subagent requests don't move it.
  • Right side: N dirty or clean, N↑ unpushed or no upstream, and the time since the last commit, in quiet text; in the default text color while a window is at the danger level.
  • Unknown parts are left out. The line stays one row: its label and right side truncate at the band's width. The bands of other plugins and Claude Code's own content stay under it. Colors are theme keys; nothing in the band is filled or drawn as Svg.

Needs Claude Code 2.1.294 or later.

Install

lk-where-am-i is one plugin in the loopkeeper marketplace. Add the marketplace once, then install it, inside a Claude Code session:

/plugin marketplace add connectwithub/loopkeeper
/plugin install lk-where-am-i@loopkeeper

Turn it off and on again with /plugin disable lk-where-am-i@loopkeeper and /plugin enable lk-where-am-i@loopkeeper. When the loopkeeper core is on, its hub links to the add-ons that are on.

To try a local checkout for one session:

claude --plugin-dir /path/to/loopkeeper/plugins/lk-where-am-i

Check it from its folder with claude plugin validate --strict . and claude plugin test ..

Commands

| Command | What it does | | :- | :- | | /where | Opens the pane (below) | | /where text | Prints the same details into the transcript | | /where refresh | Checks git again now | | /where band on\|off | Shows or hides the line (saved) | | /where cache <min> | Sets the prompt-cache TTL in minutes (saved) |

Pane

/where draws one pane with the loopkeeper kit (kit v3), the same in the terminal and the desktop app. At the top a where you are card: repo@branch (with ⑂wt:<name> in a linked worktree) as the title, and the dirty count, unpushed commits and time since the last commit under it. Then four tiles: Changed, Unpushed, Cache (time left, warn tone under 5 minutes) and Cost. Then headers with counts: Location (repo, branch, worktree, cwd, top level, default branch, upstream, changes, last commit), Usage (a bar per window, warn tone at 80% and danger at 95%, the reset time on the right), Prompt cache (time left, the TTL and where it came from), and Worktrees (● and a here pill on this one, locked and prunable pills, the branch on the right). The pills are Svg on the desktop and ● here chips in the terminal. Buttons: r Refresh, c Close.

Configure

~/.claude/where-am-i.json (or $CLAUDE_CONFIG_DIR/where-am-i.json):

{ "cacheTtlMin": 60, "band": true }

Settings saved with /where win over the file. Read at session start and after /clear, /resume, /branch.

When it refreshes

Git is read on session start, after /clear / /resume / /branch, when the session's directory changes, after every main-loop turn, and every 60 seconds while no turn is running. Each refresh runs in the background (no turn waits on it), shares one run with any refresh already in flight, and caps every git call at 2 seconds. Status uses --no-optional-locks so polling never takes the index lock. A failed or missing git leaves the folder name.

What it runs

claude plugin validate --strict . lists the footprint:

  • Hooks: session.start, classic.SessionStart (/clear, /resume, /branch), classic.CwdChanged, turn.start, turn.step, turn.complete, session.measure, session.end, command.run (/where), and ui.render for the band and the pane. Every hook passes its event on unchanged.
  • Processes: read-only git through $.process.run, in the session's directory, each capped at 2 seconds: rev-parse, symbolic-ref, status --porcelain (with --no-optional-locks), rev-list --count, log -1 and worktree list --porcelain. Nothing that writes to the repository.
  • Files: reads ~/.claude/where-am-i.json (or the one under CLAUDE_CONFIG_DIR); HOME and CLAUDE_CONFIG_DIR are the only environment variables it reads. It writes no file.
  • Stored data: the settings saved with /where, in the plugin's $.store; the last git reading and usage windows in $.state.
  • Model calls and network: none.
  • Timers: one 60-second $.clock.every tick.

Notes for directory reviewers

The directory's validator listed these items for this plugin. Each bullet says what the code does and where.

  • Programs it runs (policy holds MOD_RUNS_PROCESS, MOD_PROCESS_COMMAND_COMPUTED). Only git, from one helper, git() in hooks/register.ts (line 102): $.process.run(['git', ...args], { cwd, timeoutMs: 2000 }). The program is always git and the arguments are the fixed lists in readLocation() (lines 112–124): rev-parse --absolute-git-dir --git-common-dir --show-toplevel, symbolic-ref --short -q HEAD, rev-parse --short HEAD, --no-optional-locks status --porcelain, rev-parse --abbrev-ref --symbolic-full-name @{upstream}, rev-list --count @{upstream}..HEAD, log -1 --format=%ct%x00%s, symbolic-ref --short refs/remotes/origin/HEAD and worktree list --porcelain. Only cwd varies: it is the session's directory. Every call is local and read-only; none writes to the repository or contacts a remote. It is there to show the repo, branch, worktree and dirtiness in the band.
  • Local data and the conversation (policy holds MOD_LOCAL_DATA_LEAVES, MOD_SESSION_DATA_LEAVES). Nothing is sent anywhere. Git's output is parsed into the band and the pane and kept in $.state. The only file it reads is its own config, ~/.claude/where-am-i.json (or the one under CLAUDE_CONFIG_DIR), and nothing from it reaches a git argument. The turn.step hook (line 381) reads no message text: after the step it records the time, for the prompt-cache clock, and returns the step's result unchanged. The session.measure hook (line 408) reads only rateLimits and cost.
  • Fields in plugin.json (warnings UNKNOWN_KEY). documentationUrl and supportUrl are listing fields the directory reads. types points at types/index.d.ts, the $.state contract that claude plugin validate checks. Claude Code ignores all three at load time.
  • Icon (warning ICON_MISSING). Added at .claude-plugin/icon.png (512 × 512); its source is docs/assets/icons/lk-where-am-i.svg in the repo.
  • A call named in text (note MOD_CALL_NAMED_IN_TEXT). command.run is named in the header comment of hooks/register.ts (line 11), which lists the hooks.
  • Its command (note MOD_REGISTERS_SURFACES). It registers one slash command, /where, at session start (line 342). It registers no tools or agents.
  • Classic hooks (note MOD_HOOKS_POLICY_EVENT). classic.SessionStart with source clear, resume or fork (line 355) loads the settings and refreshes git again, because those resets clear $.state. classic.CwdChanged (line 365) stores the new directory and refreshes git. Both return next(e) unchanged; neither alters settings, instructions or other hooks.
  • The command.run hook (note MOD_HOOKS_OPERATION). The hook at line 430 has the matcher { command: 'where' }, so it runs only for /where: it opens the pane, prints a text summary, refreshes git, shows or hides the band, or sets the cache TTL. It does not see or change any other command.

Limitations

  • Git is read through $.process.run (CLI only); on a surface without it the band shows only the usage and cache parts.
  • rateLimits is empty off a subscription, so the usage chips are hidden there.
  • The cache clock is an estimate from request times; it doesn't see server-side evictions or a TTL change mid-session.
  • A bare repository or the inside of .git counts as "no git".

Files

.claude-plugin/plugin.json   manifest
hooks/hooks.json             points at register.ts
hooks/register.ts            hooks, git refresh, the band and the pane
hooks/where.ts               pure helpers: git parsing, usage levels, the cache clock, the band's parts
hooks/pane.ts                the pane's view model (pure)
hooks/kit.ts                 the UI kit, kit v3 (identical to the core's hooks/lib/kit.ts; edit it there)
types/index.d.ts             $.state declarations and shared types
tests/where.test.ts          the pure helpers
tests/pane.test.ts           the pane and the band on both surfaces
tests/register.test.ts       hook tests with stubbed git
tests/kit.test.ts            the kit's tests

Credits

The stale-window filter and the re-arming 90% toast follow usage-meter in hamzafer/claude-code-mods (MIT).

License

MIT. See LICENSE.

Source 5 files
hooks/register.ts 534 lines
1// lk-where-am-i: one line above the prompt with the repo, branch, worktree,
2// dirtiness, the account's usage windows and the prompt-cache clock.
3//
4// session.start          registers /where, loads settings, starts the 60 s tick,
5//                        refreshes git in the background
6// classic.SessionStart   the same after /clear, /resume, /branch (state was reset)
7// classic.CwdChanged     refreshes git when the session's directory moves
8// turn.start/complete    marks the main loop busy; refreshes git after each turn
9// turn.step              stamps the end of each main-loop model request (cache clock)
10// session.measure        reads rateLimits and cost, one toast per window at 90%
11// command.run            /where and its subcommands
12// ui.render              the band (AbovePrompt) and the /where pane
13//
14// Observe only: every hook passes its event on unchanged, and every git call is
15// capped, caught, and run in the background so no turn waits on it.
16
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, On } from 'claude-code'
19
20import type { Location, RateWindow, Settings } from '../types'
21import {
22  DEFAULT_TTL_MIN,
23  HELP,
24  bandLine,
25  countLines,
26  folderOnly,
27  isLinkedWorktree,
28  basename,
29  nextWarned,
30  parseBranch,
31  parseCount,
32  parseDefaultBranch,
33  parseLastCommit,
34  parseRevParse,
35  parseSha,
36  parseWorktrees,
37  repoName,
38  sanitizeTtl,
39  sanitizeWindows,
40  summaryText,
41  toastText,
42} from './where'
43import { TONES, makeKit } from './kit'
44import type { Elements, Tone } from './kit'
45import { cacheRow, locationRows, paneHero, paneTiles, usageRows, worktreeRows } from './pane'
46
47type Api = EngineInterface
48type Run = { exitCode: number; stdout: string; stderr: string }
49
50const PLUGIN = 'lk-where-am-i'
51const PANE = 'where-am-i'
52const SETTINGS_KEY = 'settings'
53const CONFIG_FILE = 'where-am-i.json'
54/** The quiet theme color (the kit's): labels and secondary text in the pane. */
55const QUIET = 'inactive'
56/** Each git call is killed after this long; a status line must never wait on git. */
57export const GIT_TIMEOUT_MS = 2000
58export const TICK_MS = 60_000
59
60const locAtom = atom({ plugin: 'lk-where-am-i', key: 'loc' } as const, null as Location | null)
61const windowsAtom = atom({ plugin: 'lk-where-am-i', key: 'windows' } as const, [] as RateWindow[])
62const costAtom = atom({ plugin: 'lk-where-am-i', key: 'costUsd' } as const, null as number | null)
63const warnedAtom = atom({ plugin: 'lk-where-am-i', key: 'warned' } as const, {} as Record<string, boolean>)
64const lastStepAtom = atom({ plugin: 'lk-where-am-i', key: 'lastStep' } as const, null as number | null)
65const nowAtom = atom({ plugin: 'lk-where-am-i', key: 'now' } as const, 0)
66const bandAtom = atom({ plugin: 'lk-where-am-i', key: 'band' } as const, true)
67const ttlAtom = atom({ plugin: 'lk-where-am-i', key: 'ttlMin' } as const, DEFAULT_TTL_MIN)
68const ttlSourceAtom = atom({ plugin: 'lk-where-am-i', key: 'ttlSource' } as const, 'default')
69const cwdAtom = atom({ plugin: 'lk-where-am-i', key: 'cwd' } as const, '')
70
71// Module-level: lost on reload, which only means one extra refresh.
72let inflight: Promise<void> | null = null
73let busy = false
74let timer: { cancel(): void } | undefined
75
76async function nowMs($: Api): Promise<number> {
77  try {
78    const n = await $.clock.now()
79    if (typeof n === 'number' && Number.isFinite(n)) return n
80  } catch {
81    // fall through
82  }
83  return Date.now()
84}
85
86async function currentCwd($: Api): Promise<string> {
87  try {
88    const c = await $.session.cwd()
89    if (typeof c === 'string' && c) {
90      await update($, cwdAtom, () => c)
91      return c
92    }
93  } catch {
94    // fall back to what session.start reported
95  }
96  return read($, cwdAtom)
97}
98
99/** Runs git with the short cap; null when it could not start or timed out. */
100async function git($: Api, cwd: string, args: string[]): Promise<Run | null> {
101  try {
102    const r = (await $.process.run(['git', ...args], { cwd: cwd || undefined, timeoutMs: GIT_TIMEOUT_MS })) as Run
103    if (!r || typeof r.exitCode !== 'number') return null
104    return { exitCode: r.exitCode, stdout: String(r.stdout ?? ''), stderr: String(r.stderr ?? '') }
105  } catch {
106    return null
107  }
108}
109
110/** Reads every location field from git. Never throws. */
111export async function readLocation($: Api, cwd: string, at: number): Promise<Location> {
112  const top = await git($, cwd, ['rev-parse', '--absolute-git-dir', '--git-common-dir', '--show-toplevel'])
113  const paths = top && top.exitCode === 0 ? parseRevParse(top.stdout, cwd) : null
114  if (!paths) return folderOnly(cwd, at)
115
116  const [sym, sha, status, up, ahead, last, origin, wts] = await Promise.all([
117    git($, cwd, ['symbolic-ref', '--short', '-q', 'HEAD']),
118    git($, cwd, ['rev-parse', '--short', 'HEAD']),
119    git($, cwd, ['--no-optional-locks', 'status', '--porcelain']),
120    git($, cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']),
121    git($, cwd, ['rev-list', '--count', '@{upstream}..HEAD']),
122    git($, cwd, ['log', '-1', '--format=%ct%x00%s']),
123    git($, cwd, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']),
124    git($, cwd, ['worktree', 'list', '--porcelain']),
125  ])
126
127  const linked = isLinkedWorktree(paths)
128  const commit = last && last.exitCode === 0 ? parseLastCommit(last.stdout) : null
129  const upstreamName = up && up.exitCode === 0 ? up.stdout.trim() || null : null
130  const upstream: Location['upstream'] = !up ? 'unknown' : upstreamName ? 'set' : 'none'
131  return {
132    isRepo: true,
133    cwd,
134    folder: basename(cwd),
135    repo: repoName(paths),
136    toplevel: paths.toplevel,
137    branch: parseBranch(sym),
138    sha: parseSha(sha),
139    isLinkedWorktree: linked,
140    worktreeName: linked ? basename(paths.toplevel) : null,
141    dirty: status && status.exitCode === 0 ? countLines(status.stdout) : null,
142    upstream,
143    upstreamName,
144    ahead: upstream === 'set' && ahead && ahead.exitCode === 0 ? parseCount(ahead.stdout) : null,
145    lastCommitAt: commit ? commit.at : null,
146    lastSubject: commit ? commit.subject : null,
147    defaultBranch: parseDefaultBranch(origin),
148    worktrees: wts && wts.exitCode === 0 ? parseWorktrees(wts.stdout) : [],
149    checkedAt: at,
150  }
151}
152
153async function doRefresh($: Api): Promise<void> {
154  const at = await nowMs($)
155  await update($, nowAtom, () => at)
156  const cwd = await currentCwd($)
157  const loc = await readLocation($, cwd, at)
158  await update($, locAtom, () => loc)
159  try {
160    const u = await $.session.usage()
161    if (u && typeof u === 'object') await takeUsage($, (u as { rateLimits?: unknown }).rateLimits, (u as { cost?: unknown }).cost)
162  } catch {
163    // session.measure will bring the figures
164  }
165}
166
167/** Refreshes everything; concurrent callers share one run. Never rejects. */
168export function refresh($: Api): Promise<void> {
169  if (inflight) return inflight
170  const run = doRefresh($)
171    .catch(() => {})
172    .finally(() => {
173      if (inflight === run) inflight = null
174    })
175  inflight = run
176  return run
177}
178
179/** Stores the usage windows and cost, then shows any one-time 90% toast. */
180async function takeUsage($: Api, rateLimits: unknown, cost: unknown): Promise<void> {
181  // An empty or missing list keeps the last reading: off a subscription there is none.
182  const windows = sanitizeWindows(rateLimits)
183  if (windows.length > 0) await update($, windowsAtom, () => windows)
184  const usd = cost && typeof cost === 'object' ? (cost as { usd?: unknown }).usd : undefined
185  if (typeof usd === 'number' && Number.isFinite(usd)) await update($, costAtom, () => usd)
186  if (windows.length === 0) return
187  const before = await read($, warnedAtom)
188  const { warned, toast } = nextWarned(before, windows)
189  await update($, warnedAtom, () => warned)
190  if (toast.length === 0) return
191  const at = await nowMs($)
192  for (const w of toast) {
193    try {
194      await $.ui.toast(toastText(w, at))
195    } catch {
196      // a toast is a courtesy
197    }
198  }
199}
200
201async function readSettings($: Api): Promise<Settings> {
202  try {
203    const s = await $.store.get(SETTINGS_KEY)
204    return s && typeof s === 'object' ? (s as Settings) : {}
205  } catch {
206    return {}
207  }
208}
209
210/** ~/.claude/where-am-i.json (or $CLAUDE_CONFIG_DIR/where-am-i.json); null when absent or bad. */
211async function readConfigFile($: Api): Promise<{ path: string; cfg: Record<string, unknown> } | null> {
212  try {
213    const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) || ((await $.env.get('HOME')) ? (await $.env.get('HOME')) + '/.claude' : '')
214    if (!dir) return null
215    const path = dir.replace(/[\\/]+$/, '') + '/' + CONFIG_FILE
216    if (!(await $.fs.exists(path))) return null
217    const cfg = JSON.parse(String(await $.fs.read(path)))
218    return cfg && typeof cfg === 'object' ? { path, cfg } : null
219  } catch {
220    return null
221  }
222}
223
224/** Defaults, then ~/.claude/where-am-i.json, then what /where saved. */
225async function loadSettings($: Api): Promise<void> {
226  let ttl = DEFAULT_TTL_MIN
227  let source = 'default'
228  let band = true
229  const file = await readConfigFile($)
230  if (file) {
231    const t = sanitizeTtl(file.cfg.cacheTtlMin)
232    if (t !== null) {
233      ttl = t
234      source = '~/.claude/' + CONFIG_FILE
235    }
236    if (typeof file.cfg.band === 'boolean') band = file.cfg.band
237  }
238  const saved = await readSettings($)
239  const st = sanitizeTtl(saved.cacheTtlMin)
240  if (st !== null) {
241    ttl = st
242    source = '/where cache'
243  }
244  if (typeof saved.band === 'boolean') band = saved.band
245  await update($, ttlAtom, () => ttl)
246  await update($, ttlSourceAtom, () => source)
247  await update($, bandAtom, () => band)
248}
249
250async function saveSetting($: Api, patch: Settings): Promise<void> {
251  const saved = await readSettings($)
252  await $.store.set(SETTINGS_KEY, { ...saved, ...patch })
253  await loadSettings($)
254}
255
256function startTick($: Api): void {
257  try {
258    timer?.cancel()
259  } catch {
260    // an old timer from a previous load
261  }
262  try {
263    timer = $.clock.every(TICK_MS, () => {
264      void (async () => {
265        const at = await nowMs($)
266        await update($, nowAtom, () => at)
267        // Git is checked only while idle; during a turn, turn.complete does it.
268        if (!busy) await refresh($)
269      })().catch(() => {})
270    })
271  } catch {
272    timer = undefined
273  }
274}
275
276async function onStart($: Api, cwd: string): Promise<void> {
277  if (cwd) await update($, cwdAtom, () => cwd)
278  await loadSettings($)
279  startTick($)
280  void refresh($).catch(() => {})
281}
282
283function snapshot($: Api) {
284  return Promise.all([
285    read($, locAtom),
286    read($, windowsAtom),
287    read($, nowAtom),
288    read($, lastStepAtom),
289    read($, ttlAtom),
290    read($, costAtom),
291    read($, ttlSourceAtom),
292  ]).then(([loc, windows, now, lastStep, ttlMin, costUsd, ttlSource]) => ({
293    loc,
294    windows,
295    nowMs: now,
296    lastStepMs: lastStep,
297    ttlMin,
298    costUsd,
299    ttlSource,
300  }))
301}
302
303async function runCommand($: Api, args: string): Promise<{ text?: string }> {
304  const words = args.trim().split(/\s+/).filter(Boolean)
305  const sub = (words[0] ?? '').toLowerCase()
306  const arg = (words[1] ?? '').toLowerCase()
307  if (sub === '') {
308    void refresh($).catch(() => {})
309    await $.ui.open({ id: PANE, title: 'Where am I', focus: true, closeOnEscape: true })
310    return {}
311  }
312  if (sub === 'text') {
313    await refresh($)
314    return { text: summaryText(await snapshot($)) }
315  }
316  if (sub === 'refresh') {
317    await refresh($)
318    return { text: 'where-am-i: checked again.' }
319  }
320  if (sub === 'band') {
321    if (arg !== 'on' && arg !== 'off') return { text: 'Use /where band on or /where band off.' }
322    await saveSetting($, { band: arg === 'on' })
323    return { text: (await read($, bandAtom)) ? 'Band shown above the prompt.' : 'Band hidden.' }
324  }
325  if (sub === 'cache') {
326    const t = sanitizeTtl(words[1] ?? '')
327    if (t === null) return { text: 'Give the cache TTL in minutes, such as /where cache 60 or /where cache 5.' }
328    await saveSetting($, { cacheTtlMin: t })
329    const ttlNow = await read($, ttlAtom)
330    return { text: `Prompt-cache TTL set to ${ttlNow} minutes.` }
331  }
332  return { text: HELP }
333}
334
335export function register(on: On) {
336  on('session.start', async ($, e, next) => {
337    try {
338      await onStart($, typeof e.cwd === 'string' ? e.cwd : '')
339    } catch {
340      // the band starts empty and fills on the next refresh
341    }
342    try {
343      await $.command.register({
344        name: 'where',
345        description: 'Where am I: repo, branch, worktree, usage windows, prompt cache. text, refresh, band on|off, cache <min>',
346        argumentHint: '[text | refresh | band on|off | cache <min>]',
347        immediate: true,
348      })
349    } catch {
350      // the name is taken: the band still works
351    }
352    return next(e)
353  })
354
355  // /clear, /resume and /branch reset $.state without a session.start.
356  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
357    try {
358      const c = (e as { cwd?: unknown }).cwd
359      await onStart($, typeof c === 'string' ? c : '')
360    } catch {
361      // nothing to do
362    }
363    return next(e)
364  }).catch(($, e, next) => next(e))
365
366  on('classic.CwdChanged', async ($, e, next) => {
367    try {
368      const c = (e as { new_cwd?: unknown }).new_cwd
369      if (typeof c === 'string' && c) await update($, cwdAtom, () => c)
370      void refresh($).catch(() => {})
371    } catch {
372      // observe only
373    }
374    return next(e)
375  }).catch(($, e, next) => next(e))
376
377  on('turn.start', async ($, e, next) => {
378    if (!(e as { agentId?: unknown }).agentId) busy = true
379    return next(e)
380  })
381
382  on('turn.step', async function* ($, e, next) {
383    const result = yield* next(e)
384    try {
385      if (!(e as { agentId?: unknown }).agentId) {
386        const at = await nowMs($)
387        await update($, lastStepAtom, () => at)
388        await update($, nowAtom, () => at)
389      }
390    } catch {
391      // the cache clock just keeps its last reading
392    }
393    return result
394  })
395
396  on('turn.complete', async ($, e, next) => {
397    const result = await next(e)
398    try {
399      if (!(e as { agentId?: unknown }).agentId) {
400        busy = false
401        void refresh($).catch(() => {})
402      }
403    } catch {
404      // observe only
405    }
406    return result
407  })
408
409  on('session.measure', async ($, e, next) => {
410    const result = await next(e)
411    try {
412      const at = await nowMs($)
413      await update($, nowAtom, () => at)
414      await takeUsage($, (e as { rateLimits?: unknown }).rateLimits, (e as { cost?: unknown }).cost)
415    } catch {
416      // observe only
417    }
418    return result
419  })
420
421  on('session.end', async ($, e, next) => {
422    try {
423      timer?.cancel()
424    } catch {
425      // already gone
426    }
427    timer = undefined
428    return next(e)
429  })
430
431  on('command.run', { command: 'where' }, async ($, e) => {
432    try {
433      return await runCommand($, typeof e.args === 'string' ? e.args : '')
434    } catch (err) {
435      return { text: 'where-am-i: ' + (err instanceof Error ? err.message : String(err)) }
436    }
437  })
438
439  // The band: one kit line, `acme-repo@main  ● 5h 34%  ● 7d 12%  ● cache 41m      3 dirty · 2↑ · 14m since
440  // commit`: the bold location, the usage windows and the cache as chips (muted, warn from 80%, danger
441  // from 95%, warn under 5 cache minutes), the rest quiet on the right (default text once a window is
442  // at danger). Other plugins' bands stack under it.
443  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
444    const props = (e.props ?? {}) as { hasSurvey?: boolean }
445    if (props.hasSurvey === true || !(await read($, bandAtom))) return next(e)
446    const band = bandLine(await snapshot($))
447    const theirs = await next(e)
448    if (!band) return theirs
449    const ui = $.ui.resolve(e)
450    const { Box } = ui
451    const kit = makeKit(ui as unknown as Elements, e.surface)
452    const drawn = (
453      band.urgent && band.right.length > 0
454        ? Box({
455            flexDirection: 'row',
456            justifyContent: 'space-between',
457            columnGap: 2,
458            children: [
459              kit.line(band.label, band.chips) as ReturnType<typeof Box>,
460              kit.text(band.right.join(' · '), { wrap: 'truncate' }) as ReturnType<typeof Box>,
461            ],
462          })
463        : kit.line(band.label, band.chips, band.right)
464    ) as ReturnType<typeof Box>
465    const line = Box({ key: 'where-am-i-band', flexDirection: 'column', children: [drawn] })
466    if (!theirs) return line
467    return Box({ flexDirection: 'column', children: [line, theirs] })
468  })
469
470  // The /where pane, drawn with the kit: where you are, the band's numbers as tiles,
471  // then location, usage windows, prompt cache and worktrees under headers with counts;
472  // the worktree marks are pills. A page: one row between blocks, each section's rows tight.
473  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
474    if (e.requestId !== PANE) return next(e)
475    const snap = await snapshot($)
476    const kit = makeKit($.ui.resolve(e) as unknown as Elements, e.surface)
477    const quiet = (s: string) => kit.text(s, { color: QUIET, wrap: 'truncate' })
478    const toned = (s: string, tone?: Tone) => kit.text(s, tone ? { color: TONES[tone].fg, wrap: 'truncate' } : { wrap: 'truncate' })
479
480    const hero = paneHero(snap)
481    const loc = locationRows(snap)
482    const w = Math.max(0, ...loc.map(r => r.label.length))
483    const usage = usageRows(snap)
484    const cache = cacheRow(snap)
485    const wts = worktreeRows(snap.loc)
486
487    const sections: unknown[] = [
488      kit.stack([
489        kit.header('Location', String(loc.length)),
490        ...loc.map(r => kit.row({ key: 'loc-' + r.label, left: [quiet(r.label.padEnd(w)), toned(r.value, r.tone)] })),
491      ]),
492      kit.stack([
493        kit.header('Usage', String(usage.length)),
494        ...(usage.length > 0
495          ? usage.map(u => kit.row({ key: u.key, left: [kit.text(u.label.padEnd(5), { bold: true }), kit.bar(u.ratio, { tone: u.tone })], right: u.right }))
496          : [quiet('no rate-limit reading yet')]),
497      ]),
498      kit.stack([
499        kit.header('Prompt cache'),
500        kit.row({ key: 'cache', left: [toned(cache.value, cache.tone)], right: cache.right }),
501      ]),
502    ]
503    if (wts.length > 0) {
504      sections.push(
505        kit.stack([
506          kit.header('Worktrees', String(wts.length)),
507          ...wts.map(t =>
508            kit.row({
509              key: t.key,
510              left: [
511                t.here ? toned('●', 'info') : quiet('○'),
512                kit.text(t.path, { bold: t.here, wrap: 'truncate' }),
513                t.here ? kit.pill('here', 'info') : null,
514                ...t.flags.map(f => kit.pill(f, 'warn')),
515              ],
516              right: t.head,
517            }),
518          ),
519        ]),
520      )
521    }
522
523    return kit.page([
524      kit.hero({ kicker: 'where you are', title: hero.title, sub: hero.sub, tone: hero.tone }),
525      kit.tiles(paneTiles(snap)),
526      ...sections,
527      kit.buttons([
528        { key: 'refresh', label: 'Refresh', hotkey: 'r', plain: true, onPress: () => refresh($) },
529        { key: 'close', label: 'Close', hotkey: 'c', plain: true, onPress: () => $.ui.close({ id: PANE }) },
530      ]),
531    ]) as never
532  })
533}
534
hooks/where.ts 495 lines
1// lk-where-am-i: pure helpers. Git output parsing, usage-window thresholds, the
2// prompt-cache clock, and the band's composition (the kit line truncates it).
3// Nothing here touches $, so every function is unit tested directly.
4//
5// The stale-window filter and the "re-arm the toast once the window drops back
6// under the line" rule follow an idea from another mod; see the README's Credits.
7
8import type { CacheState, Level, Location, RateWindow, Worktree } from '../types'
9
10export const DEFAULT_TTL_MIN = 60
11export const WARN_PCT = 80
12export const TOAST_PCT = 90
13export const DANGER_PCT = 95
14export const CACHE_LOW_MS = 5 * 60_000
15
16const ORDER = ['five_hour', 'seven_day', 'spend_limit']
17const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
18
19// ---------------------------------------------------------------- paths
20
21function trimSlashes(p: string): string {
22  const t = p.replace(/[\\/]+$/, '')
23  return t === '' && p !== '' ? '/' : t
24}
25
26export function basename(p: string): string {
27  const t = trimSlashes(String(p ?? ''))
28  if (t === '/') return '/'
29  const parts = t.split(/[\\/]/)
30  return parts[parts.length - 1] ?? ''
31}
32
33export function dirname(p: string): string {
34  const t = trimSlashes(String(p ?? ''))
35  const i = Math.max(t.lastIndexOf('/'), t.lastIndexOf('\\'))
36  if (i < 0) return '.'
37  if (i === 0) return '/'
38  return t.slice(0, i)
39}
40
41function isAbsolute(p: string): boolean {
42  return p.startsWith('/') || p.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(p)
43}
44
45/** Joins a relative path onto base and folds `.` and `..`; an absolute path is only normalized. */
46export function resolvePath(base: string, p: string): string {
47  const raw = isAbsolute(p) ? p : String(base ?? '').replace(/[\\/]+$/, '') + '/' + p
48  const drive = /^[A-Za-z]:/.test(raw) ? raw.slice(0, 2) : ''
49  const out: string[] = []
50  for (const seg of raw.slice(drive.length).split(/[\\/]+/)) {
51    if (seg === '' || seg === '.') continue
52    if (seg === '..') out.pop()
53    else out.push(seg)
54  }
55  return drive + '/' + out.join('/')
56}
57
58// ---------------------------------------------------------------- git output
59
60export type RevParse = { gitDir: string; commonDir: string; toplevel: string }
61
62/** `git rev-parse --absolute-git-dir --git-common-dir --show-toplevel`, relative paths resolved against cwd. */
63export function parseRevParse(stdout: string, cwd: string): RevParse | null {
64  const lines = String(stdout ?? '')
65    .split(/\r?\n/)
66    .map(l => l.trim())
67    .filter(Boolean)
68  if (lines.length < 3) return null
69  return {
70    gitDir: resolvePath(cwd, lines[0]),
71    commonDir: resolvePath(cwd, lines[1]),
72    toplevel: resolvePath(cwd, lines[2]),
73  }
74}
75
76/** A linked worktree has its own git dir under the common dir's worktrees/. */
77export function isLinkedWorktree(r: RevParse): boolean {
78  return trimSlashes(r.gitDir) !== trimSlashes(r.commonDir)
79}
80
81/** The repository's name: the folder holding the common .git, so linked worktrees name the main repo. */
82export function repoName(r: RevParse): string {
83  const common = trimSlashes(r.commonDir)
84  const b = basename(common)
85  if (b === '.git') return basename(dirname(common)) || basename(r.toplevel)
86  if (b.endsWith('.git') && b.length > 4) return b.slice(0, -4)
87  return basename(r.toplevel) || b
88}
89
90/** `git symbolic-ref --short -q HEAD`: the branch, or null when HEAD is detached (exit 1). */
91export function parseBranch(res: { exitCode: number; stdout: string } | null): string | null {
92  if (!res || res.exitCode !== 0) return null
93  const b = String(res.stdout ?? '').trim()
94  return b || null
95}
96
97/** `git rev-parse --short HEAD`. */
98export function parseSha(res: { exitCode: number; stdout: string } | null): string | null {
99  if (!res || res.exitCode !== 0) return null
100  const s = String(res.stdout ?? '').trim()
101  return /^[0-9a-f]{4,64}$/i.test(s) ? s : null
102}
103
104/** `git status --porcelain`: one line per changed or untracked path. */
105export function countLines(stdout: string): number {
106  return String(stdout ?? '')
107    .split(/\r?\n/)
108    .filter(l => l.trim() !== '').length
109}
110
111/** `git rev-list --count A..B`. */
112export function parseCount(stdout: string): number | null {
113  const s = String(stdout ?? '').trim()
114  return /^\d+$/.test(s) ? Number(s) : null
115}
116
117/** `git log -1 --format=%ct%x00%s`: commit time in seconds and the subject. */
118export function parseLastCommit(stdout: string): { at: number; subject: string } | null {
119  const s = String(stdout ?? '').replace(/\r?\n$/, '')
120  const i = s.indexOf('\u0000')
121  const ct = (i >= 0 ? s.slice(0, i) : s).trim()
122  if (!/^\d+$/.test(ct)) return null
123  return { at: Number(ct), subject: i >= 0 ? s.slice(i + 1).trim() : '' }
124}
125
126/** `git symbolic-ref --short refs/remotes/origin/HEAD` ("origin/main") to "main". */
127export function parseDefaultBranch(res: { exitCode: number; stdout: string } | null): string | null {
128  if (!res || res.exitCode !== 0) return null
129  const s = String(res.stdout ?? '').trim()
130  if (!s) return null
131  const short = s.replace(/^refs\/remotes\//, '')
132  const slash = short.indexOf('/')
133  return slash >= 0 ? short.slice(slash + 1) || null : short
134}
135
136/** `git worktree list --porcelain`. */
137export function parseWorktrees(stdout: string): Worktree[] {
138  const out: Worktree[] = []
139  let cur: Worktree | null = null
140  for (const line of String(stdout ?? '').split(/\r?\n/)) {
141    if (line.startsWith('worktree ')) {
142      if (cur) out.push(cur)
143      cur = { path: line.slice(9), head: null, branch: null, detached: false, bare: false, locked: false, prunable: false }
144      continue
145    }
146    if (!cur) continue
147    if (line.startsWith('HEAD ')) cur.head = line.slice(5).trim()
148    else if (line.startsWith('branch ')) cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '')
149    else if (line === 'detached') cur.detached = true
150    else if (line === 'bare') cur.bare = true
151    else if (line === 'locked' || line.startsWith('locked ')) cur.locked = true
152    else if (line === 'prunable' || line.startsWith('prunable ')) cur.prunable = true
153  }
154  if (cur) out.push(cur)
155  return out
156}
157
158/** The location shown outside a git repository: the folder name only. */
159export function folderOnly(cwd: string, checkedAt: number): Location {
160  return {
161    isRepo: false,
162    cwd,
163    folder: basename(cwd) || cwd,
164    repo: '',
165    toplevel: '',
166    branch: null,
167    sha: null,
168    isLinkedWorktree: false,
169    worktreeName: null,
170    dirty: null,
171    upstream: 'unknown',
172    upstreamName: null,
173    ahead: null,
174    lastCommitAt: null,
175    lastSubject: null,
176    defaultBranch: null,
177    worktrees: [],
178    checkedAt,
179  }
180}
181
182// ---------------------------------------------------------------- formatting
183
184/** 14m, 5h, 3d. */
185export function fmtAge(seconds: number): string {
186  const s = Math.max(0, Math.floor(seconds))
187  const m = Math.floor(s / 60)
188  if (m < 60) return `${m}m`
189  const h = Math.floor(m / 60)
190  if (h < 48) return `${h}h`
191  return `${Math.floor(h / 24)}d`
192}
193
194/** 41m, 1h20, <1m. */
195export function fmtLeft(ms: number): string {
196  if (ms < 60_000) return '<1m'
197  const m = Math.floor(ms / 60_000)
198  if (m < 60) return `${m}m`
199  const h = Math.floor(m / 60)
200  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}`
201  return `${Math.floor(h / 24)}d${h % 24}h`
202}
203
204// ---------------------------------------------------------------- usage windows
205
206export function kindLabel(kind: string): string {
207  return LABELS[kind] ?? kind
208}
209
210export function levelOf(pct: number): Level {
211  if (pct >= DANGER_PCT) return 'danger'
212  if (pct >= WARN_PCT) return 'warn'
213  return 'ok'
214}
215
216/** Keeps well-formed windows from an untrusted list. */
217export function sanitizeWindows(input: unknown): RateWindow[] {
218  if (!Array.isArray(input)) return []
219  const out: RateWindow[] = []
220  for (const w of input) {
221    if (!w || typeof w !== 'object') continue
222    const r = w as Record<string, unknown>
223    if (typeof r.kind !== 'string' || !r.kind) continue
224    if (typeof r.percentUsed !== 'number' || !Number.isFinite(r.percentUsed)) continue
225    out.push({
226      kind: r.kind,
227      percentUsed: r.percentUsed,
228      ...(typeof r.resetsAt === 'string' && r.resetsAt ? { resetsAt: r.resetsAt } : {}),
229    })
230  }
231  return out
232}
233
234function resetMs(w: RateWindow): number {
235  const t = w.resetsAt ? Date.parse(w.resetsAt) : NaN
236  return Number.isFinite(t) ? t : 0
237}
238
239/** Windows to show: those not past their reset time, 5h then 7d then the rest. */
240export function liveWindows(windows: readonly RateWindow[], nowMs: number): RateWindow[] {
241  const rank = (k: string) => {
242    const i = ORDER.indexOf(k)
243    return i < 0 ? ORDER.length : i
244  }
245  return windows
246    .filter(w => {
247      const r = resetMs(w)
248      return r === 0 || r > nowMs
249    })
250    .sort((a, b) => rank(a.kind) - rank(b.kind))
251}
252
253/**
254 * Decides the one-time toasts. A window at TOAST_PCT or more that was not
255 * warned yet gets one; a window back under the line (a new period) re-arms.
256 */
257export function nextWarned(
258  warned: Readonly<Record<string, boolean>>,
259  windows: readonly RateWindow[],
260): { warned: Record<string, boolean>; toast: RateWindow[] } {
261  const next: Record<string, boolean> = { ...warned }
262  const toast: RateWindow[] = []
263  for (const w of windows) {
264    if (w.percentUsed >= TOAST_PCT) {
265      if (!next[w.kind]) {
266        next[w.kind] = true
267        toast.push(w)
268      }
269    } else if (next[w.kind]) {
270      delete next[w.kind]
271    }
272  }
273  return { warned: next, toast }
274}
275
276export function toastText(w: RateWindow, nowMs: number): string {
277  const r = resetMs(w)
278  const when = r > 0 ? `, resets in ${fmtLeft(r - nowMs)}` : ''
279  const what = w.kind === 'spend_limit' ? 'Spend limit' : `${kindLabel(w.kind)} usage window`
280  return `${what} at ${Math.round(w.percentUsed)}%${when}`
281}
282
283// ---------------------------------------------------------------- prompt cache
284
285export function sanitizeTtl(v: unknown): number | null {
286  const n = typeof v === 'string' ? Number(v.trim()) : v
287  if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0 || n > 24 * 60) return null
288  return Math.round(n * 10) / 10
289}
290
291export function cacheState(lastStepMs: number | null, nowMs: number, ttlMin: number): CacheState {
292  if (lastStepMs === null || !Number.isFinite(lastStepMs)) return { kind: 'unknown' }
293  const left = lastStepMs + ttlMin * 60_000 - nowMs
294  if (left <= 0) return { kind: 'cold' }
295  if (left <= CACHE_LOW_MS) return { kind: 'low', leftMs: left }
296  return { kind: 'warm', leftMs: left }
297}
298
299// ---------------------------------------------------------------- band
300
301/** A band part; `tone` (a kit tone) marks one that needs you. */
302export type Seg = { text: string; tone?: 'warn' | 'danger' }
303
304export type BandInput = {
305  loc: Location | null
306  windows: readonly RateWindow[]
307  nowMs: number
308  lastStepMs: number | null
309  ttlMin: number
310}
311
312/** The location parts: `repo@branch ⑂wt:name`, dirty, ahead, since commit. */
313export function locationParts(loc: Location | null, nowMs: number): Seg[][] {
314  if (!loc) return []
315  if (!loc.isRepo) return loc.folder ? [[{ text: `${loc.folder} (no git)` }]] : []
316  const parts: Seg[][] = []
317  const head = loc.branch ?? (loc.sha ? `(${loc.sha})` : '?')
318  let name = `${loc.repo || basename(loc.toplevel)}@${head}`
319  if (loc.isLinkedWorktree && loc.worktreeName) name += ` ⑂wt:${loc.worktreeName}`
320  parts.push([{ text: name }])
321  if (loc.dirty !== null) parts.push([{ text: loc.dirty === 0 ? 'clean' : `${loc.dirty} dirty` }])
322  if (loc.upstream === 'none' && loc.branch) parts.push([{ text: 'no upstream' }])
323  else if (loc.ahead !== null && loc.ahead > 0) parts.push([{ text: `${loc.ahead}↑` }])
324  if (loc.lastCommitAt !== null) parts.push([{ text: `${fmtAge(nowMs / 1000 - loc.lastCommitAt)} since commit` }])
325  return parts
326}
327
328export function usageParts(windows: readonly RateWindow[], nowMs: number): Seg[][] {
329  return liveWindows(windows, nowMs).map(w => {
330    const lvl = levelOf(w.percentUsed)
331    const seg: Seg = { text: `${kindLabel(w.kind)} ${Math.round(w.percentUsed)}%` }
332    if (lvl !== 'ok') seg.tone = lvl
333    return [seg]
334  })
335}
336
337export function cacheParts(lastStepMs: number | null, nowMs: number, ttlMin: number): Seg[][] {
338  const c = cacheState(lastStepMs, nowMs, ttlMin)
339  if (c.kind === 'unknown') return []
340  if (c.kind === 'cold') return [[{ text: 'cache cold' }]]
341  const seg: Seg = { text: `cache ${fmtLeft(c.leftMs)}` }
342  if (c.kind === 'low') seg.tone = 'warn'
343  return [[seg]]
344}
345
346/** Every part of the band, joined with ` · `. Parts that are unknown are left out. */
347export function bandSegments(input: BandInput): Seg[] {
348  const parts = [
349    ...locationParts(input.loc, input.nowMs),
350    ...usageParts(input.windows, input.nowMs),
351    ...cacheParts(input.lastStepMs, input.nowMs, input.ttlMin),
352  ]
353  const out: Seg[] = []
354  parts.forEach((p, i) => {
355    if (i > 0) out.push({ text: ' · ' })
356    out.push(...p)
357  })
358  return out
359}
360
361/** A band chip, drawn `● text` in a kit tone: muted until it needs you. */
362export type Chip = { text: string; tone: 'muted' | 'warn' | 'danger' }
363
364export type BandLine = { label: string; chips: Chip[]; right: string[]; urgent: boolean }
365
366/** The kit draws at most this many chips on a band line. */
367const MAX_CHIPS = 4
368
369/**
370 * The band as a kit line: the bold label (`repo@branch ⑂wt:name`, the folder outside git, or
371 * `where` before git is read), the usage windows and the prompt cache as chips, and the rest of the
372 * location (dirty, ahead, since commit) quiet on the right. Windows past the fourth chip move to the
373 * right side; the cache chip always stays. `urgent` when a window is at the danger level. null when
374 * nothing is known yet.
375 */
376export function bandLine(input: BandInput): BandLine | null {
377  const loc = locationParts(input.loc, input.nowMs).map(p => p.map(s => s.text).join(''))
378  const chip = (p: Seg[]): Chip => ({ text: p.map(s => s.text).join(''), tone: p.find(s => s.tone)?.tone ?? 'muted' })
379  const windows = usageParts(input.windows, input.nowMs).map(chip)
380  const cache = cacheParts(input.lastStepMs, input.nowMs, input.ttlMin).map(chip)
381  if (loc.length === 0 && windows.length === 0 && cache.length === 0) return null
382  const room = MAX_CHIPS - cache.length
383  const chips = [...windows.slice(0, room), ...cache]
384  const right = [...loc.slice(1), ...windows.slice(room).map(c => c.text)]
385  return { label: loc[0] ?? 'where', chips, right, urgent: chips.some(c => c.tone === 'danger') }
386}
387
388export function bandText(segs: readonly Seg[]): string {
389  return segs.map(s => s.text).join('')
390}
391
392// ---------------------------------------------------------------- pane and /where text
393
394export type Row = { label: string; value: string; tone?: 'warn' | 'danger' }
395
396export function detailRows(input: BandInput & { costUsd: number | null; ttlSource: string }): Row[] {
397  const { loc, nowMs } = input
398  const rows: Row[] = []
399  if (!loc) {
400    rows.push({ label: 'Location', value: 'not checked yet' })
401  } else if (!loc.isRepo) {
402    rows.push({ label: 'Folder', value: `${loc.folder} (not a git repository)` })
403    rows.push({ label: 'cwd', value: loc.cwd })
404  } else {
405    rows.push({ label: 'Repo', value: loc.repo })
406    rows.push({ label: 'Branch', value: loc.branch ?? (loc.sha ? `detached at ${loc.sha}` : 'unknown') })
407    rows.push({
408      label: 'Worktree',
409      value: loc.isLinkedWorktree ? `linked: ${loc.worktreeName ?? basename(loc.toplevel)}` : 'main checkout',
410    })
411    rows.push({ label: 'cwd', value: loc.cwd })
412    rows.push({ label: 'Top level', value: loc.toplevel })
413    rows.push({ label: 'Default branch', value: loc.defaultBranch ?? 'unknown (no origin/HEAD)' })
414    rows.push({
415      label: 'Upstream',
416      value:
417        loc.upstream === 'set'
418          ? `${loc.upstreamName ?? '?'}${loc.ahead !== null ? ` · ${loc.ahead} unpushed` : ''}`
419          : loc.upstream === 'none'
420            ? 'no upstream'
421            : 'unknown',
422    })
423    rows.push({
424      label: 'Changes',
425      value: loc.dirty === null ? 'unknown' : loc.dirty === 0 ? 'clean' : `${loc.dirty} dirty paths`,
426    })
427    rows.push({
428      label: 'Last commit',
429      value:
430        loc.lastCommitAt === null
431          ? 'none'
432          : `${fmtAge(nowMs / 1000 - loc.lastCommitAt)} ago${loc.lastSubject ? ` · ${loc.lastSubject}` : ''}`,
433    })
434  }
435  const live = liveWindows(input.windows, nowMs)
436  if (live.length === 0) rows.push({ label: 'Usage', value: 'no rate-limit reading yet' })
437  for (const w of live) {
438    const r = resetMs(w)
439    const lvl = levelOf(w.percentUsed)
440    const row: Row = {
441      label: `Usage ${kindLabel(w.kind)}`,
442      value: `${Math.round(w.percentUsed)}% used${r > 0 ? ` · resets in ${fmtLeft(r - nowMs)}` : ''}`,
443    }
444    if (lvl !== 'ok') row.tone = lvl
445    rows.push(row)
446  }
447  if (input.costUsd !== null && input.costUsd > 0) rows.push({ label: 'Session cost', value: `$${input.costUsd.toFixed(2)}` })
448  const c = cacheState(input.lastStepMs, nowMs, input.ttlMin)
449  const ttl = `TTL ${input.ttlMin}m from ${input.ttlSource}`
450  const cache: Row = {
451    label: 'Prompt cache',
452    value:
453      c.kind === 'unknown'
454        ? `no request yet · ${ttl}`
455        : c.kind === 'cold'
456          ? `cold · ${ttl}`
457          : `${fmtLeft(c.leftMs)} left · ${ttl}`,
458  }
459  if (c.kind === 'low') cache.tone = 'warn'
460  rows.push(cache)
461  return rows
462}
463
464export function worktreeLines(loc: Location | null): string[] {
465  if (!loc || !loc.isRepo || loc.worktrees.length === 0) return []
466  return loc.worktrees.map(w => {
467    const here = trimSlashes(w.path) === trimSlashes(loc.toplevel) ? '●' : '○'
468    const head = w.bare ? 'bare' : w.branch ?? (w.head ? `detached ${w.head.slice(0, 7)}` : 'detached')
469    const flags = [w.locked ? 'locked' : '', w.prunable ? 'prunable' : ''].filter(Boolean).join(', ')
470    return `${here} ${w.path}  ${head}${flags ? `  (${flags})` : ''}`
471  })
472}
473
474export function summaryText(input: BandInput & { costUsd: number | null; ttlSource: string }): string {
475  const rows = detailRows(input)
476  const w = Math.max(...rows.map(r => r.label.length))
477  const lines = [bandText(bandSegments(input)) || 'where-am-i: nothing known yet', '']
478  for (const r of rows) lines.push(`${r.label.padEnd(w)}  ${r.value}`)
479  const wts = worktreeLines(input.loc)
480  if (wts.length > 0) {
481    lines.push('', `Worktrees (${wts.length})`)
482    for (const l of wts) lines.push('  ' + l)
483  }
484  return lines.join('\n')
485}
486
487export const HELP = [
488  'Usage: /where [text | refresh | band on|off | cache <minutes>]',
489  '  /where              open the pane',
490  '  /where text         print the details here',
491  '  /where refresh      check git again now',
492  '  /where band on|off  show or hide the line above the prompt',
493  '  /where cache <min>  set the prompt-cache TTL (default 60)',
494].join('\n')
495
hooks/kit.ts 955 lines
1// kit.ts: the shared UI kit (kit v3).
2//
3// The module has no imports and touches no mods API: every helper takes the elements a hook got
4// from `$.ui.resolve(e)` and the surface (`e.surface`) and returns an element tree.
5//
6//   const k = makeKit($.ui.resolve(e) as unknown as Elements, e.surface)
7//   return k.stack([k.hero({ kicker: 'right now', title: 'Reading client.test.js' }), k.tiles([...])], 1)
8//
9// What v2 changed on purpose (v1 painted grey fills and dimmed grey text, unreadable on the desktop):
10//   - colors are theme keys (suggestion, success, warning, error, claude, inactive) that the host
11//     tunes for light and dark; PALETTE below swaps them for named colors in one line;
12//   - no dimColor, no backgroundColor, no yellow, white, gray, black or subtle on content: secondary
13//     text is `inactive`, state is a bold `● word` chip (glyph and word, so color is never the only cue);
14//   - hero and card are one round frame (paddingX 1), tiles are single-border boxes with the label
15//     over a bold value; terminal and desktop draw the same structure (the Svg doodle is desktop-only);
16//   - kicker, section and tile labels keep the caller's case (v1 upper-cased kicker and section);
17//   - additive: line(label, chips, right?) draws one band line.
18//
19// Calibration (claude plugin test, Claude Code 2.1.294): the test harness validates the tree a
20// ui.render hook returns (a Text with a bogus prop makes `$.ui.mount` reject on both surfaces), but
21// not how the desktop paints a theme key. A Button given `plain: false` throws when built (plain and
22// autoFocus take only `true`), so the kit leaves false and undefined props out; a Box inside a Text
23// is refused. doodle() draws its Svg on the desktop only (a pane holding only an Svg opens empty in
24// the terminal).
25//
26// What v3 adds (strictly additive: every v2 export above renders as before). The desktop paints
27// Svg, and its light theme made the v1 fills unreadable, so v3 draws small pictures as Svg wherever
28// the element table has one (desktop, mobile, vscode) and as text on the terminal:
29//   - pill(text, tone?): a fully rounded Svg chip (PILL 'svg', the default); PILL 'fill' draws a
30//     theme-key backgroundColor under `inverseText` instead (the only fill in the kit); the v2
31//     `● text` chip on the terminal;
32//   - header(label, right?): an upper-cased `inactive` section label with a right-hand count;
33//   - icon(name, tone?), typeBadge(fileName) and progress(ratio, opts?): Svg icons, file-type badges
34//     and a rounded bar; a glyph, a bold label and the v2 bar on the terminal;
35//   - buttons() items take an optional `variant` ('primary' | 'secondary');
36//   - nav(items, currentKey): a pane's nav bar, the current item bold Text, the others plain Buttons;
37//   - pure helpers: pillSvg, shortPath and extOf; TONE_HEX, the Svg colors of icons and bars, and
38//     CHIP_HEX, the darker pill fills that keep white text at 4.5:1 or more.
39// Svg markup cannot read theme keys, so it uses fixed mid-tone hexes that read on light and dark
40// grounds (never a color word), and each distinct drawing is built once and cached.
41//
42// What v3.1 adds (spacing; additive): on the desktop, blocks stacked with no gap touch border to
43// border and wrapped rows sit line on line. So every wrapping row (buttons, tiles, nav) gets rowGap 1,
44// buttons get columnGap 2, and two new columns space a pane out:
45//   - page(children): a pane's top-level column, one row between blocks;
46//   - list(children): a column of repeated cards or blocks, one row between them.
47// Rows inside one card or block stay tight (stack with no gap); stack() keeps its props. Bands that
48// draw buttons() are one row, so the new gaps change nothing there. buttons() items also take
49// `autoFocus` (passed only when true).
50
51export const KIT_VERSION = '3.1.0'
52
53/** How pill() draws on a surface with Svg: 'svg' (a fully rounded chip) or 'fill' (a theme-key
54 *  background). makeKit's `pill` option overrides it for one kit. */
55export const PILL: 'fill' | 'svg' = 'svg'
56
57/** kit v3: makeKit's options. `pill` overrides PILL for this kit. */
58export type KitOptions = { pill?: 'fill' | 'svg' }
59
60/** 'theme': theme keys that follow the person's light or dark theme. 'named': named colors, for a
61 *  host that does not paint theme keys (never yellow: warn is red plus its glyph). One-line switch. */
62export const PALETTE: 'theme' | 'named' = 'theme'
63
64export type Surface = 'terminal' | 'desktop' | (string & {})
65export type Tone = 'info' | 'ok' | 'warn' | 'danger' | 'muted' | 'accent'
66export type Role = 'main' | 'recon' | 'execute' | 'verify' | 'hard' | 'reviewer' | 'other'
67export type DoodleState = 'running' | 'done' | 'failed' | 'waiting' | 'idle'
68/** The tones of pill() and icon(): the six tones plus violet. */
69export type PillTone = Tone | 'violet'
70export type IconName =
71  | 'eye'
72  | 'pencil'
73  | 'plus'
74  | 'branch'
75  | 'search'
76  | 'folder'
77  | 'folder-open'
78  | 'file'
79  | 'shield'
80  | 'flag'
81  | 'check'
82  | 'dot'
83  | 'clock'
84/** What `$.ui.resolve(e)` returns, loosely typed so the kit compiles without the engine's types. */
85export type Elements = Record<string, (props: Record<string, unknown>) => unknown>
86
87export type Kit = {
88  hero(p: { kicker?: string; title: string; sub?: string; icon?: string; tone?: Tone }): unknown
89  card(p: { title?: string; right?: string; tone?: Tone; children: unknown[] }): unknown
90  section(label: string, right?: string): unknown
91  tiles(items: { label: string; value: string; tone?: Tone }[]): unknown
92  badge(text: string, tone?: Tone): unknown
93  legend(items: { tone: Tone; label: string }[]): unknown
94  bar(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
95  /** One row, left side truncating, right side muted. `align: 'center'` (kit v3) centers a row holding a pill. */
96  row(p: { left: unknown[] | string; right?: unknown[] | string; key?: string; align?: 'center' }): unknown
97  kv(label: string, value: string, tone?: Tone): unknown
98  buttons(
99    items: {
100      key: string
101      label: string
102      hotkey?: string
103      onPress: (e?: unknown) => unknown
104      plain?: boolean
105      /** kit v3: the Button's variant, passed only when set. */
106      variant?: 'primary' | 'secondary'
107      /** kit 3.1: focus this Button when the pane opens; passed only when true. */
108      autoFocus?: boolean
109    }[],
110  ): unknown
111  doodle(role: Role, state?: DoodleState): unknown
112  stack(children: unknown[], gap?: number): unknown
113  /** kit 3.1: a pane's top-level column, one row between its blocks; null children are dropped. */
114  page(children: unknown[]): unknown
115  /** kit 3.1: a column of repeated cards or blocks, one row between them; null children are dropped. */
116  list(children: unknown[]): unknown
117  text(s: string, props?: Record<string, unknown>): unknown
118  /**
119   * One band line: `label  ● a  ● b      right · text`. A chip is `{ text, tone? }`, a string (a muted
120   * chip) or an element such as badge(); at most four are drawn. `right` is a string or a list
121   * joined with ` · `, drawn `inactive` and truncated. One row high.
122   */
123  line(label: string, chips: unknown[], right?: string | string[]): unknown
124  /** kit v3: a rounded chip (Svg surfaces) or the v2 `● text` chip (terminal). */
125  pill(text: string, tone?: Tone | 'violet'): unknown
126  /** kit v3: one row, the label upper-cased, bold and `inactive`; `right` upper-cased, right-aligned. */
127  header(label: string, right?: string): unknown
128  /** kit v3: a 16×16 stroke icon (Svg surfaces) or one glyph in the tone (terminal). */
129  icon(name: IconName, tone?: Tone | 'violet'): unknown
130  /** kit v3: a file-type badge ('JS', 'PDF', …) for a file name; null when the name has no extension. */
131  typeBadge(fileName: string): unknown
132  /** kit v3: a rounded bar (Svg surfaces; `width` in CSS pixels, absent: the slot's width) or bar(). */
133  progress(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
134  /**
135   * kit v3: a pane's nav bar, one row keyed 'nav': the item whose key is `currentKey` as bold Text in the accent
136   * tone (not pressable), the others plain Buttons with their hotkeys (`1: Stage`); one without onPress quiet Text.
137   */
138  nav(items: { key: string; label: string; hotkey?: string; onPress?: () => unknown }[], currentKey: string): unknown
139  surface: Surface
140}
141
142/** The quiet color: secondary text, labels, units, timestamps, frame and tile borders. */
143const QUIET = 'inactive'
144
145const THEME_TONES: Record<Tone, string> = {
146  info: 'suggestion',
147  ok: 'success',
148  warn: 'warning',
149  danger: 'error',
150  muted: QUIET,
151  accent: 'claude',
152}
153
154const NAMED_TONES: Record<Tone, string> = {
155  info: 'blue',
156  ok: 'green',
157  warn: 'red',
158  danger: 'red',
159  muted: QUIET,
160  accent: 'magenta',
161}
162
163const TONE_COLORS = PALETTE === 'named' ? NAMED_TONES : THEME_TONES
164
165/** fg: the color of the tone, a theme key (a named color when PALETTE is 'named'). bg equals fg: v2
166 *  draws no fills; the field stays so v1 callers compile. */
167export const TONES: Record<Tone, { fg: string; bg: string }> = {
168  info: { fg: TONE_COLORS.info, bg: TONE_COLORS.info },
169  ok: { fg: TONE_COLORS.ok, bg: TONE_COLORS.ok },
170  warn: { fg: TONE_COLORS.warn, bg: TONE_COLORS.warn },
171  danger: { fg: TONE_COLORS.danger, bg: TONE_COLORS.danger },
172  muted: { fg: TONE_COLORS.muted, bg: TONE_COLORS.muted },
173  accent: { fg: TONE_COLORS.accent, bg: TONE_COLORS.accent },
174}
175
176/**
177 * kit v3: the Svg colors of icons and bars, a mid-tone hex per tone that reads on light and dark grounds
178 * (Svg markup cannot read theme keys). warn and accent share the Claude orange: the theme's warning
179 * color is unreadable as a fill. A pill fills with CHIP_HEX instead.
180 *
181 *   info #3B82F6   ok #22A35A   warn #D97757   danger #E5484D   accent #D97757   muted #8A8F98
182 *   violet #8B5CF6
183 */
184export const TONE_HEX: Record<PillTone, string> = {
185  info: '#3B82F6',
186  ok: '#22A35A',
187  warn: '#D97757',
188  danger: '#E5484D',
189  accent: '#D97757',
190  muted: '#8A8F98',
191  violet: '#8B5CF6',
192}
193
194/**
195 * kit v3: a pill's fill per tone, darker than TONE_HEX so its white text has a contrast ratio of at
196 * least 4.5:1 (WCAG AA for small text). The ratios against white:
197 *
198 *   info #2563EB 5.17   ok #15803D 5.02   warn #C2410C 5.18   danger #B91C1C 6.47   accent #C2410C 5.18
199 *   muted #5B6170 6.20   violet #7C3AED 5.70
200 */
201export const CHIP_HEX: Record<PillTone, string> = {
202  info: '#2563EB',
203  ok: '#15803D',
204  warn: '#C2410C',
205  danger: '#B91C1C',
206  accent: '#C2410C',
207  muted: '#5B6170',
208  violet: '#7C3AED',
209}
210
211/** kit v3: the theme key a pill fills with, per tone (warn is 'claude': a 'warning' fill is unreadable). */
212const PILL_FILLS: Record<PillTone, string> = {
213  info: 'suggestion',
214  ok: 'success',
215  warn: 'claude',
216  danger: 'error',
217  accent: 'claude',
218  muted: QUIET,
219  violet: 'permission',
220}
221
222/** kit v3: the stroke of an icon drawn without a tone. */
223const ICON_STROKE = '#6B7280'
224/** kit v3: the track of progress(): the muted hex at quarter opacity. */
225const TRACK_HEX = '#8A8F98'
226/** kit v3: the text on a light fill (the amber JS badge); light text everywhere else. */
227const INK_DARK = '#1A1A1A'
228const INK_LIGHT = '#FFFFFF'
229
230/** kit v3: icon markup on a 16×16 grid, stroked with `currentColor`'s stand-in `S`. */
231const ICONS: Record<IconName, string> = {
232  eye: '<path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>',
233  pencil: '<path d="M11 2.5 13.5 5 5.5 13H3v-2.5z"/><path d="M9.5 4 12 6.5"/>',
234  plus: '<path d="M8 3v10M3 8h10"/>',
235  branch:
236    '<circle cx="4.5" cy="3.5" r="1.5"/><circle cx="4.5" cy="12.5" r="1.5"/><circle cx="11.5" cy="5" r="1.5"/>' +
237    '<path d="M4.5 5v6M11.5 6.5c0 2.5-2 3.5-4 3.8-1.6.2-3 .7-3 .7"/>',
238  search: '<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/>',
239  folder: '<path d="M1.75 3.25h4.5l1.5 1.75h6.5v7.75H1.75z"/>',
240  'folder-open': '<path d="M1.75 12.75v-9.5h4.5l1.5 1.75h5v2"/><path d="M1.75 12.75 3.75 7h10.5l-2 5.75z"/>',
241  file: '<path d="M9 1.75H3v12.5h10v-8.5z"/><path d="M9 1.75v4h4"/>',
242  shield: '<path d="M8 1.75 13 3.75v4c0 3.2-2.2 5.4-5 6.5-2.8-1.1-5-3.3-5-6.5v-4z"/>',
243  flag: '<path d="M3 14.5V2"/><path d="M3 2.5h8.5L9.75 5.5l1.75 3H3"/>',
244  check: '<path d="M3 8.5 6 11.5 13 4.5"/>',
245  dot: '<circle cx="8" cy="8" r="3" fill="S"/>',
246  clock: '<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/>',
247}
248
249/** kit v3: the terminal glyph of each icon. */
250const ICON_GLYPHS: Record<IconName, string> = {
251  eye: '◉',
252  pencil: '✎',
253  plus: '+',
254  branch: '⎇',
255  search: '⌕',
256  folder: '▸',
257  'folder-open': '▾',
258  file: '·',
259  shield: '⛨',
260  flag: '⚑',
261  check: '✓',
262  dot: '●',
263  clock: '◷',
264}
265
266/** kit v3: file-type badges by extension: the label, its Svg fill and its terminal tone. */
267const FILE_TYPES: { exts: string[]; label: string; fill: string; tone: Tone }[] = [
268  { exts: ['js', 'jsx', 'mjs', 'cjs'], label: 'JS', fill: '#F0C419', tone: 'accent' },
269  { exts: ['ts', 'tsx'], label: 'TS', fill: '#3178C6', tone: 'info' },
270  { exts: ['pdf'], label: 'PDF', fill: '#E5484D', tone: 'danger' },
271  { exts: ['md', 'mdx'], label: 'MD', fill: '#4A7BD0', tone: 'info' },
272  { exts: ['doc', 'docx'], label: 'DOC', fill: '#2B579A', tone: 'info' },
273  { exts: ['csv'], label: 'CSV', fill: '#22A35A', tone: 'ok' },
274  { exts: ['json'], label: 'JSON', fill: '#64748B', tone: 'muted' },
275  { exts: ['yml', 'yaml'], label: 'YML', fill: '#64748B', tone: 'muted' },
276  { exts: ['html'], label: 'HTML', fill: '#E8743B', tone: 'accent' },
277  { exts: ['css'], label: 'CSS', fill: '#4A7BD0', tone: 'info' },
278  { exts: ['py'], label: 'PY', fill: '#3776AB', tone: 'info' },
279  { exts: ['sh'], label: 'SH', fill: '#64748B', tone: 'muted' },
280  { exts: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'], label: 'IMG', fill: '#8B5CF6', tone: 'accent' },
281  { exts: ['txt'], label: 'TXT', fill: '#64748B', tone: 'muted' },
282]
283const OTHER_FILL = '#64748B'
284
285const GLYPHS: Record<Role, string> = {
286  main: '◉',
287  recon: '◈',
288  execute: '▣',
289  verify: '✓',
290  hard: '◆',
291  reviewer: '◎',
292  other: '●',
293}
294
295const STATE_TONES: Record<DoodleState, Tone> = {
296  running: 'info',
297  done: 'ok',
298  failed: 'danger',
299  waiting: 'warn',
300  idle: 'muted',
301}
302
303const STATE_COLORS: Record<DoodleState, string> = {
304  running: TONES[STATE_TONES.running].fg,
305  done: TONES[STATE_TONES.done].fg,
306  failed: TONES[STATE_TONES.failed].fg,
307  waiting: TONES[STATE_TONES.waiting].fg,
308  idle: TONES[STATE_TONES.idle].fg,
309}
310
311// Desktop doodles (svg fills are not text colors, so they stay hex): 12×12 cells, 3 px each. B = body (by role), A = accent (by state), . = empty.
312const BODY_FILL: Record<Role, string> = {
313  main: '#6c63ff',
314  recon: '#1f9eaa',
315  execute: '#3b7ddd',
316  verify: '#2f9e55',
317  hard: '#b5523b',
318  reviewer: '#c08a1e',
319  other: '#7d8590',
320}
321
322const ACCENT_FILL: Record<DoodleState, string> = {
323  running: '#22d3ee',
324  done: '#4ade80',
325  failed: '#f87171',
326  waiting: '#facc15',
327  idle: '#e5e7eb',
328}
329
330const SPRITES: Record<Role, string[]> = {
331  // a crowned blob
332  main: [
333    '............',
334    '..A..AA..A..',
335    '..AAAAAAAA..',
336    '..BBBBBBBB..',
337    '.BBBBBBBBBB.',
338    '.BBAABBAABB.',
339    '.BBAABBAABB.',
340    '.BBBBBBBBBB.',
341    '.BBBAAAABBB.',
342    '.BBBBBBBBBB.',
343    '..BB....BB..',
344    '..BB....BB..',
345  ],
346  // a one-eyed scout drone
347  recon: [
348    '....BBBB....',
349    '..BBBBBBBB..',
350    '.BBBBBBBBBB.',
351    'BBBBAAAABBBB',
352    'BBBAAAAAABBB',
353    'BBBAA..AABBB',
354    'BBBAAAAAABBB',
355    'BBBBAAAABBBB',
356    '.BBBBBBBBBB.',
357    '..BBBBBBBB..',
358    '...B.BB.B...',
359    '..B..BB..B..',
360  ],
361  // a boxy robot with an antenna
362  execute: [
363    '.....AA.....',
364    '.....BB.....',
365    '.BBBBBBBBBB.',
366    '.BBBBBBBBBB.',
367    '.BBAABBAABB.',
368    '.BBAABBAABB.',
369    '.BBBBBBBBBB.',
370    '.BBAAAAAABB.',
371    '.BBBBBBBBBB.',
372    '...BBBBBB...',
373    '..BBB..BBB..',
374    '..BB....BB..',
375  ],
376  // a shield with a check mark
377  verify: [
378    '.BBBBBBBBBB.',
379    'BBBBBBBBBBBB',
380    'BBBBBBBBBBBB',
381    'BBBBBBBBBAAB',
382    'BBBBBBBBAABB',
383    'BAABBBBAABBB',
384    'BBAABBAABBBB',
385    '.BBAAAABBBB.',
386    '.BBBAABBBBB.',
387    '..BBBBBBBB..',
388    '...BBBBBB...',
389    '.....BB.....',
390  ],
391  // a gem golem
392  hard: [
393    '.....BB.....',
394    '....BBBB....',
395    '...BBBBBB...',
396    '..BBBBBBBB..',
397    '.BBAABBAABB.',
398    'BBBAABBAABBB',
399    'BBBBBBBBBBBB',
400    '.BBBBAABBBB.',
401    '..BBBBBBBB..',
402    '...BBBBBB...',
403    '....BBBB....',
404    '.....BB.....',
405  ],
406  // a round face with glasses
407  reviewer: [
408    '...BBBBBB...',
409    '..BBBBBBBB..',
410    '.BBBBBBBBBB.',
411    'BBBBBBBBBBBB',
412    'BAAAABBAAAAB',
413    'BA..AAAA..AB',
414    'BAAAABBAAAAB',
415    'BBBBBBBBBBBB',
416    'BBBBAAAABBBB',
417    '.BBBBBBBBBB.',
418    '..BBBBBBBB..',
419    '...BBBBBB...',
420  ],
421  // a ghost
422  other: [
423    '....BBBB....',
424    '..BBBBBBBB..',
425    '.BBBBBBBBBB.',
426    '.BBBBBBBBBB.',
427    'BBBAABBAABBB',
428    'BBBAABBAABBB',
429    'BBBBBBBBBBBB',
430    'BBBBBBBBBBBB',
431    'BBBBBBBBBBBB',
432    'BBBBBBBBBBBB',
433    'BBB.BBBB.BBB',
434    'B....BB....B',
435  ],
436}
437
438const CELL = 3
439const GRID = 12
440
441// ---------------------------------------------------------------- formatters
442
443/** '≈$0.08', '≈$1.2', '≈$12'; '$0' for zero, '<$0.01' below a cent, '—' when unknown. */
444export function money(usd: number | null | undefined): string {
445  if (typeof usd !== 'number' || !Number.isFinite(usd)) return '—'
446  const v = Math.max(0, usd)
447  if (v === 0) return '$0'
448  if (v < 0.005) return '<$0.01'
449  const cents = Math.round(v * 100) / 100
450  if (cents < 1) return '≈$' + cents.toFixed(2)
451  const dimes = Math.round(v * 10) / 10
452  if (dimes < 10) return '≈$' + dimes.toFixed(1)
453  return '≈$' + String(Math.round(v))
454}
455
456/** '0', '950', '1.5k', '37k', '1.2M', '15M'. */
457export function tokensShort(n: number): string {
458  if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return '0'
459  if (n < 1000) return String(Math.round(n))
460  const k = n / 1000
461  if (k < 10) {
462    const r = Math.round(k * 10) / 10
463    if (r < 10) return trimZero(r.toFixed(1)) + 'k'
464  }
465  if (Math.round(k) < 1000) return String(Math.round(k)) + 'k'
466  const m = n / 1_000_000
467  const r = Math.round(m * 10) / 10
468  if (r < 10) return trimZero(r.toFixed(1)) + 'M'
469  return String(Math.round(m)) + 'M'
470}
471
472/** '0:40', '10:05', '1:02:03'. */
473export function clock(ms: number): string {
474  const total = typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0
475  const h = Math.floor(total / 3600)
476  const m = Math.floor((total % 3600) / 60)
477  const s = total % 60
478  if (h > 0) return `${h}:${pad2(m)}:${pad2(s)}`
479  return `${m}:${pad2(s)}`
480}
481
482/** '4%', '100%'. Not capped at 100 (a budget can run over); negative and NaN read 0%. */
483export function pct(ratio: number): string {
484  if (typeof ratio !== 'number' || !Number.isFinite(ratio) || ratio <= 0) return '0%'
485  return String(Math.round(ratio * 100)) + '%'
486}
487
488/** '━━━━╌╌╌╌╌╌' for 0.4 at width 10; the ratio is clamped to 0..1. */
489export function barText(ratio: number, width: number = 10): string {
490  const w = typeof width === 'number' && Number.isFinite(width) && width >= 1 ? Math.floor(width) : 10
491  const filled = Math.round(clamp01(ratio) * w)
492  return '━'.repeat(filled) + '╌'.repeat(w - filled)
493}
494
495function clamp01(ratio: number): number {
496  if (typeof ratio !== 'number' || !Number.isFinite(ratio)) return 0
497  return Math.min(1, Math.max(0, ratio))
498}
499
500function pad2(n: number): string {
501  return n < 10 ? '0' + n : String(n)
502}
503
504function trimZero(s: string): string {
505  return s.endsWith('.0') ? s.slice(0, -2) : s
506}
507
508function toneOf(tone: unknown, fallback: Tone): Tone {
509  return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONES, tone) ? (tone as Tone) : fallback
510}
511
512function roleOf(role: unknown): Role {
513  return typeof role === 'string' && Object.prototype.hasOwnProperty.call(GLYPHS, role) ? (role as Role) : 'other'
514}
515
516function stateOf(state: unknown): DoodleState {
517  return typeof state === 'string' && Object.prototype.hasOwnProperty.call(STATE_COLORS, state)
518    ? (state as DoodleState)
519    : 'idle'
520}
521
522/** Drops undefined and false props: some props (Button plain, autoFocus) take only `true`. */
523function clean(props: Record<string, unknown>): Record<string, unknown> {
524  const out: Record<string, unknown> = {}
525  for (const k of Object.keys(props)) {
526    const v = props[k]
527    if (v === undefined || v === false) continue
528    out[k] = v
529  }
530  return out
531}
532
533function present(children: unknown[]): unknown[] {
534  return children.filter(c => c !== null && c !== undefined && c !== false && c !== '')
535}
536
537/** The SVG document of one doodle: a 12×12 sprite at 3 px per cell, runs merged per row. */
538export function doodleSvg(role: Role, state: DoodleState = 'idle'): string {
539  const r = roleOf(role)
540  const s = stateOf(state)
541  const fills: Record<string, string> = { B: BODY_FILL[r], A: ACCENT_FILL[s] }
542  const size = GRID * CELL
543  let rects = ''
544  SPRITES[r].forEach((line, y) => {
545    let x = 0
546    while (x < GRID) {
547      const ch = line[x] ?? '.'
548      let end = x + 1
549      while (end < GRID && line[end] === ch) end++
550      const fill = fills[ch]
551      if (fill) {
552        rects += `<rect x="${x * CELL}" y="${y * CELL}" width="${(end - x) * CELL}" height="${CELL}" fill="${fill}"/>`
553      }
554      x = end
555    }
556  })
557  return (
558    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" ` +
559    `shape-rendering="crispEdges">${rects}</svg>`
560  )
561}
562
563/** The terminal glyph of a role, as doodle() draws it and as the desktop doodle's alt text. */
564export function glyph(role: Role): string {
565  return GLYPHS[roleOf(role)]
566}
567
568// ---------------------------------------------------------------- kit v3: pure helpers and Svg
569
570/** 'ts' for 'src/a.test.TS', '' for 'Makefile', '.gitignore' or 'notes.'. Lower case, no dot. */
571export function extOf(name: string): string {
572  const base = String(name ?? '').split(/[\\/]/).pop() ?? ''
573  const dot = base.lastIndexOf('.')
574  return dot > 0 && dot < base.length - 1 ? base.slice(dot + 1).toLowerCase() : ''
575}
576
577/**
578 * A path for display: inside `root` it is relative ('.' for the root itself), inside `home` it is
579 * '~/' and the rest, anywhere else '…/' and its last two segments, so no absolute path from outside
580 * the project is drawn. A relative path is already short and comes back as given. Trailing slashes
581 * are dropped.
582 */
583export function shortPath(path: string, root: string, home: string): string {
584  const p = trimSlash(path)
585  const r = trimSlash(root)
586  const h = trimSlash(home)
587  if (!p.startsWith('/')) return p
588  if (r && p === r) return '.'
589  if (r && r !== '/' && p.startsWith(r + '/')) return p.slice(r.length + 1)
590  if (h && p === h) return '~'
591  if (h && h !== '/' && p.startsWith(h + '/')) return '~/' + p.slice(h.length + 1)
592  return '…/' + p.split('/').filter(Boolean).slice(-2).join('/')
593}
594
595function trimSlash(s: string): string {
596  const t = String(s ?? '')
597  return t.length > 1 ? t.replace(/\/+$/, '') || '/' : t
598}
599
600const SVG_CACHE = new Map<string, string>()
601const SVG_CACHE_MAX = 512
602
603/** Builds each distinct drawing once; the cache is cleared when it grows past its bound. */
604function cached(key: string, build: () => string): string {
605  const hit = SVG_CACHE.get(key)
606  if (hit !== undefined) return hit
607  if (SVG_CACHE.size >= SVG_CACHE_MAX) SVG_CACHE.clear()
608  const svg = build()
609  SVG_CACHE.set(key, svg)
610  return svg
611}
612
613function xml(s: string): string {
614  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
615}
616
617/** Dark text on a light fill (relative luminance above one half), light text otherwise. */
618function inkOn(hex: string): string {
619  const n = parseInt(hex.slice(1), 16)
620  const lin = (c: number) => {
621    const v = c / 255
622    return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
623  }
624  const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255)
625  return l > 0.5 ? INK_DARK : INK_LIGHT
626}
627
628function pillToneOf(tone: unknown, fallback: PillTone): PillTone {
629  return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONE_HEX, tone) ? (tone as PillTone) : fallback
630}
631
632function iconOf(name: unknown): IconName {
633  return typeof name === 'string' && Object.prototype.hasOwnProperty.call(ICONS, name) ? (name as IconName) : 'dot'
634}
635
636const FONT = 'font-family="system-ui,-apple-system,Segoe UI,sans-serif"'
637
638/** A label in a rounded rect: the shape of a pill (height 18) and of a type badge (height 16). */
639function labelSvg(text: string, fill: string, width: number, height: number, rx: number, size: number, ink = inkOn(fill)): string {
640  return (
641    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` +
642    `<rect width="${width}" height="${height}" rx="${rx}" fill="${fill}"/>` +
643    `<text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="central" ${FONT} ` +
644    `font-size="${size}" font-weight="700" fill="${ink}">${xml(text)}</text></svg>`
645  )
646}
647
648/** A pill's height in CSS pixels; its corners are fully rounded (rx = height / 2). */
649const PILL_HEIGHT = 18
650
651/** A pill's width in CSS pixels: about 6.5 per character of 11 px bold text plus 7 each side. */
652function pillWidth(text: string): number {
653  return Math.max(PILL_HEIGHT, Math.round(text.length * 6.5 + 14))
654}
655
656/** The Svg of a pill: a fully rounded rect in the tone's CHIP_HEX fill, white text. */
657export function pillSvg(text: string, tone: Tone | 'violet' = 'muted'): string {
658  const t = pillToneOf(tone, 'muted')
659  const s = String(text)
660  return cached(`pill|${t}|${s}`, () => labelSvg(s, CHIP_HEX[t], pillWidth(s), PILL_HEIGHT, PILL_HEIGHT / 2, 11, INK_LIGHT))
661}
662
663function iconSvg(name: IconName, hex: string): string {
664  return cached(`icon|${name}|${hex}`, () =>
665    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none" ` +
666    `stroke="${hex}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">` +
667    `${ICONS[name].replace(/"S"/g, `"${hex}"`)}</svg>`,
668  )
669}
670
671function fileType(fileName: string): { label: string; fill: string; tone: Tone } | null {
672  const ext = extOf(fileName)
673  if (!ext) return null
674  const known = FILE_TYPES.find(t => t.exts.includes(ext))
675  return known ?? { label: ext.slice(0, 3).toUpperCase(), fill: OTHER_FILL, tone: 'muted' }
676}
677
678/** A type badge is 16 high and about 7 px per letter plus 8 wide. */
679function badgeWidth(label: string): number {
680  return Math.max(16, label.length * 7 + 8)
681}
682
683function badgeSvg(label: string, fill: string): string {
684  return cached(`badge|${label}|${fill}`, () => labelSvg(label, fill, badgeWidth(label), 16, 3, 10))
685}
686
687/** A track and a fill, 6 high. Without a width the markup is 100% wide and stretches to the slot. */
688function progressSvg(ratio: number, hex: string, width: number | undefined): string {
689  const full = width ?? 100
690  const fill = Math.round(clamp01(ratio) * full * 10) / 10
691  return cached(`progress|${fill}|${hex}|${width ?? ''}`, () => {
692    const size = width === undefined ? 'width="100%" height="6" preserveAspectRatio="none"' : `width="${width}" height="6"`
693    return (
694      `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${full} 6" ${size}>` +
695      `<rect width="${full}" height="6" rx="3" fill="${TRACK_HEX}" opacity="0.25"/>` +
696      (fill > 0 ? `<rect width="${fill}" height="6" rx="3" fill="${hex}"/>` : '') +
697      `</svg>`
698    )
699  })
700}
701
702// ---------------------------------------------------------------- the kit
703
704const MAX_CHIPS = 4
705
706export function makeKit(ui: Elements, surface: Surface, opts: KitOptions = {}): Kit {
707  const desktop = surface === 'desktop'
708  const pillMode = opts.pill ?? PILL
709
710  const el = (type: string, props: Record<string, unknown>, children?: unknown[]): unknown => {
711    const make = ui[type]
712    const p = clean(props)
713    if (children !== undefined) p.children = present(children)
714    return make(p)
715  }
716  const T = (s: string, props: Record<string, unknown> = {}): unknown => el('Text', props, [s])
717  const color = (tone: Tone | undefined): string | undefined => (tone ? TONES[tone].fg : undefined)
718  const pieces = (part: unknown[] | string, right: boolean): unknown[] =>
719    (typeof part === 'string' ? [part] : part).map(c =>
720      typeof c === 'string' || typeof c === 'number'
721        ? T(String(c), { color: right ? QUIET : undefined, wrap: 'truncate' })
722        : c,
723    )
724  const frame = (children: unknown[], tone: Tone | undefined): unknown =>
725    el('Box', { flexDirection: 'column', borderStyle: 'round', borderColor: color(tone) ?? QUIET, paddingX: 1 }, children)
726  const chip = (text: string, tone: Tone): unknown =>
727    T('● ' + text, { color: TONES[tone].fg, bold: tone !== 'muted', wrap: 'truncate' })
728  // kit v3: Svg wherever the table has it (desktop, mobile, vscode); the terminal draws text.
729  const svgOk = surface !== 'terminal' && typeof ui.Svg === 'function'
730  const svg = (source: string, alt: string, width?: number, height?: number): unknown =>
731    el('Svg', { source, alt: alt || ' ', width, height })
732  /** The theme key of a v3 tone on the terminal: the v2 tone's color, violet 'permission'. */
733  const fg = (tone: PillTone): string => (tone === 'violet' ? PILL_FILLS.violet : TONES[tone].fg)
734
735  const kit: Kit = {
736    surface,
737
738    hero(p) {
739      const tone = p.tone ? toneOf(p.tone, 'info') : undefined
740      const title = (p.icon ? p.icon + ' ' : '') + p.title
741      return frame(
742        [
743          p.kicker ? T(p.kicker, { color: QUIET, wrap: 'truncate' }) : null,
744          T(title, { bold: true, color: color(tone), wrap: 'truncate' }),
745          p.sub ? T(p.sub, { color: QUIET, wrap: 'wrap' }) : null,
746        ],
747        tone,
748      )
749    },
750
751    card(p) {
752      const tone = p.tone ? toneOf(p.tone, 'info') : undefined
753      const head =
754        p.title || p.right
755          ? el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
756              p.title ? T(p.title, { bold: true, wrap: 'truncate' }) : null,
757              p.right ? T(p.right, { color: QUIET, wrap: 'truncate' }) : null,
758            ])
759          : null
760      return frame([head, ...(Array.isArray(p.children) ? p.children : [])], tone)
761    },
762
763    section(label, right) {
764      return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
765        T(String(label), { bold: true, wrap: 'truncate' }),
766        right ? T(right, { color: QUIET, wrap: 'truncate' }) : null,
767      ])
768    },
769
770    tiles(items) {
771      return el(
772        'Box',
773        { flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, rowGap: 1 },
774        items.map(it => {
775          const tone = it.tone ? toneOf(it.tone, 'info') : undefined
776          return el(
777            'Box',
778            { flexDirection: 'column', flexGrow: 1, borderStyle: 'single', borderColor: QUIET, paddingX: 1 },
779            [
780              T(it.label, { color: QUIET, wrap: 'truncate' }),
781              T(it.value, { bold: true, color: color(tone), wrap: 'truncate' }),
782            ],
783          )
784        }),
785      )
786    },
787
788    badge(text, tone) {
789      return chip(text, toneOf(tone, 'muted'))
790    },
791
792    legend(items) {
793      const parts: unknown[] = []
794      items.forEach((it, i) => {
795        if (i > 0) parts.push('  ')
796        parts.push(T('●', { color: TONES[toneOf(it.tone, 'muted')].fg }))
797        parts.push(' ' + it.label)
798      })
799      return el('Text', { wrap: 'wrap' }, parts)
800    },
801
802    bar(ratio, opts = {}) {
803      const t = toneOf(opts.tone, 'info')
804      return el('Text', { wrap: 'truncate' }, [
805        T(barText(ratio, opts.width ?? 10), { color: TONES[t].fg }),
806        T(' ' + pct(ratio), { color: QUIET }),
807      ])
808    },
809
810    row(p) {
811      const left = pieces(p.left, false)
812      const right = p.right === undefined || p.right === '' ? [] : pieces(p.right, true)
813      const alignItems = p.align === 'center' ? 'center' : undefined
814      return el('Box', { key: p.key, flexDirection: 'row', justifyContent: 'space-between', alignItems, columnGap: 1 }, [
815        el('Box', { flexDirection: 'row', flexShrink: 1, alignItems, columnGap: 1 }, left),
816        right.length ? el('Box', { flexDirection: 'row', alignItems, columnGap: 1 }, right) : null,
817      ])
818    },
819
820    kv(label, value, tone) {
821      const t = tone ? toneOf(tone, 'info') : undefined
822      return el('Text', { wrap: 'truncate' }, [
823        T(label + ' ', { color: QUIET }),
824        T(value, { bold: true, color: color(t) }),
825      ])
826    },
827
828    buttons(items) {
829      return el(
830        'Box',
831        { flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
832        items.map(b =>
833          el('Button', {
834            key: b.key,
835            label: b.label,
836            hotkey: b.hotkey,
837            onPress: b.onPress,
838            plain: b.plain === true ? true : undefined,
839            variant: b.variant === 'primary' || b.variant === 'secondary' ? b.variant : undefined,
840            autoFocus: b.autoFocus === true ? true : undefined,
841          }),
842        ),
843      )
844    },
845
846    doodle(role, state = 'idle') {
847      const r = roleOf(role)
848      const s = stateOf(state)
849      if (desktop && typeof ui.Svg === 'function') {
850        return el('Svg', { alt: GLYPHS[r], width: GRID * CELL, height: GRID * CELL, source: doodleSvg(r, s) })
851      }
852      return T(GLYPHS[r], { color: STATE_COLORS[s], bold: s === 'running' })
853    },
854
855    stack(children, gap = 0) {
856      return el('Box', { flexDirection: 'column', rowGap: gap > 0 ? Math.floor(gap) : undefined }, children)
857    },
858
859    page(children) {
860      return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
861    },
862
863    list(children) {
864      return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
865    },
866
867    text(s, props = {}) {
868      return el('Text', props, [String(s)])
869    },
870
871    line(label, chips, right) {
872      const drawn = (Array.isArray(chips) ? chips : [])
873        .filter(c => c !== null && c !== undefined && c !== false && c !== '')
874        .slice(0, MAX_CHIPS)
875        .map(c => {
876          if (typeof c === 'string') return chip(c, 'muted')
877          if (isChipSpec(c)) return chip(c.text, toneOf(c.tone, 'muted'))
878          return c
879        })
880      const quiet = (Array.isArray(right) ? right.filter(Boolean).join(' · ') : right ?? '').trim()
881      return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 2 }, [
882        el('Box', { flexDirection: 'row', flexShrink: 1, columnGap: 2 }, [
883          T(String(label), { bold: true, wrap: 'truncate' }),
884          ...drawn,
885        ]),
886        quiet ? T(quiet, { color: QUIET, wrap: 'truncate' }) : null,
887      ])
888    },
889
890    // ------------------------------------------------------------ kit v3
891
892    pill(text, tone) {
893      const t = pillToneOf(tone, 'muted')
894      const s = String(text)
895      if (!svgOk) return t === 'violet' ? T('● ' + s, { color: fg(t), bold: true, wrap: 'truncate' }) : chip(s, t)
896      if (pillMode === 'svg') return svg(pillSvg(s, t), s, pillWidth(s), PILL_HEIGHT)
897      return T(` ${s} `, { backgroundColor: PILL_FILLS[t], color: 'inverseText', bold: true, wrap: 'truncate' })
898    },
899
900    header(label, right) {
901      return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
902        T(String(label).toUpperCase(), { bold: true, color: QUIET, wrap: 'truncate' }),
903        right ? T(String(right).toUpperCase(), { color: QUIET, wrap: 'truncate' }) : null,
904      ])
905    },
906
907    icon(name, tone) {
908      const n = iconOf(name)
909      const t = tone === undefined ? undefined : pillToneOf(tone, 'muted')
910      if (svgOk) return svg(iconSvg(n, t ? TONE_HEX[t] : ICON_STROKE), n, 16, 16)
911      return T(ICON_GLYPHS[n], { color: t ? fg(t) : QUIET })
912    },
913
914    typeBadge(fileName) {
915      const type = fileType(fileName)
916      if (!type) return null
917      if (svgOk) {
918        return svg(badgeSvg(type.label, type.fill), type.label, badgeWidth(type.label), 16)
919      }
920      return T(type.label, { bold: true, color: TONES[type.tone].fg })
921    },
922
923    progress(ratio, opts = {}) {
924      if (!svgOk) return kit.bar(ratio, opts)
925      const t = toneOf(opts.tone, 'info')
926      const width = typeof opts.width === 'number' && Number.isFinite(opts.width) && opts.width >= 1 ? Math.floor(opts.width) : undefined
927      // Without a width the Svg gets no width prop and markup 100% wide: the user's look confirms the
928      // desktop draws it the full width of its slot.
929      return svg(progressSvg(ratio, TONE_HEX[t], width), pct(ratio), width, 6)
930    },
931
932    nav(items, currentKey) {
933      return el(
934        'Box',
935        { key: 'nav', flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
936        (Array.isArray(items) ? items : []).map(it => {
937          if (it.key === currentKey) return T(String(it.label), { bold: true, color: TONES.accent.fg, wrap: 'truncate' })
938          if (typeof it.onPress !== 'function') return T(String(it.label), { color: QUIET, wrap: 'truncate' })
939          return el('Button', { key: it.key, label: String(it.label), hotkey: it.hotkey, onPress: it.onPress, plain: true })
940        }),
941      )
942    },
943  }
944  return kit
945}
946
947function isChipSpec(c: unknown): c is { text: string; tone?: Tone } {
948  return (
949    typeof c === 'object' &&
950    c !== null &&
951    !('type' in c) &&
952    typeof (c as { text?: unknown }).text === 'string'
953  )
954}
955
hooks/pane.ts 92 lines
1// lk-where-am-i: the /where pane's view model. Pure: it reads the same snapshot the band
2// and /where text read and returns strings and tones; register.ts draws them with the kit.
3// Nothing here takes `$`.
4
5import type { Location } from '../types'
6import type { Tone } from './kit'
7import { type BandInput, type Row, cacheState, detailRows, fmtLeft, kindLabel, levelOf, liveWindows, locationParts } from './where'
8
9export type PaneInput = BandInput & { costUsd: number | null; ttlSource: string }
10
11export type Hero = { title: string; sub?: string; tone: Tone }
12export type Tile = { label: string; value: string; tone?: Tone }
13export type UsageRow = { key: string; label: string; ratio: number; tone: Tone; right: string }
14export type WorktreeRow = { key: string; here: boolean; path: string; head: string; flags: string[] }
15export type CacheRow = { value: string; tone?: Tone; right: string }
16
17function trimSlashes(p: string): string {
18  const t = String(p ?? '').replace(/[\\/]+$/, '')
19  return t === '' && p !== '' ? '/' : t
20}
21
22function levelTone(pct: number): Tone {
23  return levelOf(pct)
24}
25
26/** The hero: `repo@branch ⑂wt:name` over `3 dirty · 2↑ · 14m since commit`; the folder outside git. */
27export function paneHero(input: PaneInput): Hero {
28  const loc = input.loc
29  if (!loc) return { title: 'Nothing known yet', sub: 'Git is read in the background; press Refresh to check now.', tone: 'muted' }
30  const parts = locationParts(loc, input.nowMs).map(p => p.map(s => s.text).join(''))
31  if (!loc.isRepo) return { title: parts[0] ?? loc.folder, sub: loc.cwd || undefined, tone: 'muted' }
32  const sub = parts.slice(1).join(' · ')
33  return { title: parts[0] ?? loc.repo, sub: sub || undefined, tone: 'info' }
34}
35
36/** Changed · Unpushed · Cache · Cost: the numbers the band already shows, one tile each. */
37export function paneTiles(input: PaneInput): Tile[] {
38  const loc = input.loc && input.loc.isRepo ? input.loc : null
39  const dirty = loc ? loc.dirty : null
40  const changed: Tile =
41    dirty === null ? { label: 'Changed', value: '—', tone: 'muted' } : { label: 'Changed', value: String(dirty), tone: dirty > 0 ? 'warn' : 'ok' }
42  let unpushed: Tile = { label: 'Unpushed', value: '—', tone: 'muted' }
43  if (loc && loc.upstream === 'none' && loc.branch) unpushed = { label: 'Unpushed', value: 'no upstream', tone: 'muted' }
44  else if (loc && loc.ahead !== null) unpushed = { label: 'Unpushed', value: String(loc.ahead), tone: loc.ahead > 0 ? 'warn' : 'ok' }
45  const c = cacheState(input.lastStepMs, input.nowMs, input.ttlMin)
46  const cache: Tile =
47    c.kind === 'unknown'
48      ? { label: 'Cache', value: '—', tone: 'muted' }
49      : c.kind === 'cold'
50        ? { label: 'Cache', value: 'cold', tone: 'muted' }
51        : { label: 'Cache', value: fmtLeft(c.leftMs) + ' left', tone: c.kind === 'low' ? 'warn' : 'ok' }
52  const usd = input.costUsd
53  const cost: Tile =
54    usd !== null && usd > 0 ? { label: 'Cost', value: `$${usd.toFixed(2)}`, tone: 'accent' } : { label: 'Cost', value: '—', tone: 'muted' }
55  return [changed, unpushed, cache, cost]
56}
57
58/** The location rows of detailRows (everything before the usage, cost and cache rows), unchanged. */
59export function locationRows(input: PaneInput): Row[] {
60  return detailRows(input).filter(r => r.label !== 'Usage' && !r.label.startsWith('Usage ') && r.label !== 'Session cost' && r.label !== 'Prompt cache')
61}
62
63/** One row per live usage window, 5h then 7d, with the ratio for a bar and the reset time. */
64export function usageRows(input: PaneInput): UsageRow[] {
65  return liveWindows(input.windows, input.nowMs).map(w => {
66    const t = w.resetsAt ? Date.parse(w.resetsAt) : NaN
67    const reset = Number.isFinite(t) && t > 0 ? `resets in ${fmtLeft(t - input.nowMs)}` : ''
68    return { key: 'usage-' + w.kind, label: kindLabel(w.kind), ratio: w.percentUsed / 100, tone: levelTone(w.percentUsed), right: reset }
69  })
70}
71
72/** The prompt-cache row: time left (or cold, or no request yet) and where the TTL came from. */
73export function cacheRow(input: PaneInput): CacheRow {
74  const c = cacheState(input.lastStepMs, input.nowMs, input.ttlMin)
75  const right = `TTL ${input.ttlMin}m from ${input.ttlSource}`
76  if (c.kind === 'unknown') return { value: 'no request yet', right }
77  if (c.kind === 'cold') return { value: 'cold', right }
78  return { value: `${fmtLeft(c.leftMs)} left`, tone: c.kind === 'low' ? 'warn' : undefined, right }
79}
80
81/** The worktrees as worktreeLines lists them, split into parts (`here` marks this one). */
82export function worktreeRows(loc: Location | null): WorktreeRow[] {
83  if (!loc || !loc.isRepo || loc.worktrees.length === 0) return []
84  return loc.worktrees.map(w => ({
85    key: 'wt-' + w.path,
86    here: trimSlashes(w.path) === trimSlashes(loc.toplevel),
87    path: w.path,
88    head: w.bare ? 'bare' : w.branch ?? (w.head ? `detached ${w.head.slice(0, 7)}` : 'detached'),
89    flags: [w.locked ? 'locked' : '', w.prunable ? 'prunable' : ''].filter(Boolean),
90  }))
91}
92
types/index.d.ts 80 lines
1/** One entry of `git worktree list --porcelain`. */
2export type Worktree = {
3  path: string
4  head: string | null
5  /** Short branch name, or null when detached or bare. */
6  branch: string | null
7  detached: boolean
8  bare: boolean
9  locked: boolean
10  prunable: boolean
11}
12
13/** Where the session is, as the last git refresh found it. */
14export type Location = {
15  /** false outside a git work tree: only `folder` and `cwd` mean anything. */
16  isRepo: boolean
17  cwd: string
18  folder: string
19  /** The main repository's folder name, also from a linked worktree. */
20  repo: string
21  toplevel: string
22  /** null when HEAD is detached or unknown. */
23  branch: string | null
24  /** Short sha of HEAD, shown when detached. */
25  sha: string | null
26  isLinkedWorktree: boolean
27  worktreeName: string | null
28  /** `git status --porcelain` lines; null when unknown. */
29  dirty: number | null
30  /** 'none' when the branch tracks nothing; 'unknown' when the check failed. */
31  upstream: 'set' | 'none' | 'unknown'
32  upstreamName: string | null
33  /** Commits on HEAD not on the upstream; null when unknown or no upstream. */
34  ahead: number | null
35  /** Last commit time, Unix seconds. */
36  lastCommitAt: number | null
37  lastSubject: string | null
38  /** origin/HEAD's branch, such as main. */
39  defaultBranch: string | null
40  worktrees: Worktree[]
41  /** $.clock.now() when this was read. */
42  checkedAt: number
43}
44
45/** One rate-limit window as session.measure and $.session.usage() report it. */
46export type RateWindow = { kind: string; percentUsed: number; resetsAt?: string }
47
48/** A usage window's level as a kit tone: ok, warn from 80%, danger from 95%. */
49export type Level = 'ok' | 'warn' | 'danger'
50
51export type CacheState =
52  | { kind: 'unknown' }
53  | { kind: 'cold' }
54  | { kind: 'low'; leftMs: number }
55  | { kind: 'warm'; leftMs: number }
56
57/** Settings saved by /where in $.store under "settings". */
58export type Settings = { band?: boolean; cacheTtlMin?: number }
59
60declare module 'claude-code' {
61  interface PluginState {
62    'lk-where-am-i': {
63      loc: Location | null
64      windows: RateWindow[]
65      costUsd: number | null
66      /** Kinds already toasted at 90%, re-armed when the window drops back. */
67      warned: Record<string, boolean>
68      /** $.clock.now() at the end of the last main-loop model request. */
69      lastStep: number | null
70      /** The time the band reads, moved on by every refresh and the 60 s tick. */
71      now: number
72      band: boolean
73      ttlMin: number
74      /** Where ttlMin came from: default, ~/.claude/where-am-i.json, or /where cache. */
75      ttlSource: string
76      cwd: string
77    }
78  }
79}
80