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.

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
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
(sha) when detached, and ⑂wt:<name> in a linked worktree. Outside git it shows <folder> (no git); before git is read, where.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.cache cold after expiry. Subagent requests don't move it.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.Needs Claude Code 2.1.294 or later.
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 ..
| 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) |
/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.
~/.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.
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.
claude plugin validate --strict . lists the footprint:
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.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.~/.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./where, in the plugin's $.store; the last git reading and usage windows in $.state.$.clock.every tick.The directory's validator listed these items for this plugin. Each bullet says what the code does and where.
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.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.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_MISSING). Added at .claude-plugin/icon.png (512 × 512); its source is docs/assets/icons/lk-where-am-i.svg in the repo.MOD_CALL_NAMED_IN_TEXT). command.run is named in the header comment of hooks/register.ts (line 11), which lists the hooks.MOD_REGISTERS_SURFACES). It registers one slash command, /where, at session start (line 342). It registers no tools or agents.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.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.$.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..git counts as "no git"..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
The stale-window filter and the re-arming 90% toast follow usage-meter in hamzafer/claude-code-mods (MIT).
MIT. See LICENSE.
hooks/register.ts 534 lines1// 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}
534hooks/where.ts 495 lines1// 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')
495hooks/kit.ts 955 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
955hooks/pane.ts 92 lines1// 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}
92types/index.d.ts 80 lines1/** 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