SLOPSHOPPER

hud

HUD under the prompt / HUD под полем ввода: model, folder, git, context, 5h and weekly limits with resets, prompt-cache timer

newbandspinnercommandtoastprocess
★ 1v1.4.0MITupdated 2026-10-26ArtyaEXE/claude-mods/hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hud
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /open-project ⎿ hud: No folder picked ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › Opus 5.5 │ /work/app │ ⎇ HEAD ●1 Context █████░░░░░ 49% (97k/200k) │ Limit 5h ███░░░░░░░ 31% ↻ in 0m · 09:53 cache 60m ⟨Claude Code's own drawing⟩

Draws

Prompt hint
Opus 5.5 │ /work/app │ ⎇ HEAD ●1 Context █████░░░░░ 49% (97k/200k) │ Limit 5h ███░░░░░░░ 31% ↻ in 0m · 09:53 cache 60m ⟨Claude Code's own drawing⟩
README

hud

A three-line HUD under the prompt: model, folder, git, context and 5-hour limit bars, weekly limit with reset times, and a prompt-cache timer.

English · Русский

English

  • Line 1: model, working folder, git branch with uncommitted (●), ahead (↑) and behind (↓) counts. Click the folder to pick another one in an Explorer-style dialog: Claude Code opens there in a new Windows Terminal window (/open-project does the same). Click the branch to open /changes, the pane of the session's changed files from the changes-pane mod. With the shot mod installed, 📷 shot · window at the end of the line put a screenshot into the prompt without touching what you typed, so several fit in one message.
  • Line 2: claude-hud-style bars for the context window and the 5-hour limit, side by side, with when the limit resets.
  • Line 3: weekly limit and its reset, and how long until the prompt cache goes cold since the last reply.

Bars turn yellow past 50% and red past 80%. Refreshes after every turn and every 30 seconds. Sits where a status-line HUD such as claude-hud does, with Claude Code's own hint line kept beneath it.

What it touches

Reads the session's usage figures (context, rate limits) and model. Every 30 seconds runs git status --porcelain --branch in the session folder (read-only). Only when you click the folder or run /open-project: shows the Windows folder dialog through PowerShell, then starts wt.exe -w new -d <folder> cmd /k claude (or powershell -NoExit -Command claude with shell: powershell; a plain console window without Windows Terminal). Every 30 seconds reads the list of commands, to show the shot buttons when that mod is installed. On a click on the branch: runs /changes when the changes-pane mod is installed, else shows how to install it. Adds the /open-project command.

Settings (/config)

  • language: auto (default; follows LANG, else the Windows UI language or regional format), ru or en.
  • position: below (default, under the prompt) or above (over the prompt).
  • shell: cmd (default) or powershell — the shell of the window a click on the folder opens.
  • cacheTtlMinutes: idle minutes until the prompt cache goes cold, 60 by default (a subscription); set 5 when using an API key.

Install

/plugin install hud --marketplace ArtyaEXE/claude-mods

Part of claude-mods. MIT license.


Русский

HUD из трёх строк под полем ввода: модель, папка, git, полоски контекста и лимита 5ч, недельный лимит со временем сброса и таймер кэша.

  • Строка 1: модель, рабочая папка, ветка git с незакоммиченными (●), опережением (↑) и отставанием (↓). Клик по папке открывает выбор папки в стиле Проводника, и Claude Code запускается в ней в новом окне Windows Terminal (/open-project делает то же самое). Клик по ветке открывает /changes, панель изменённых за сессию файлов из мода changes-pane. Если установлен мод shot, кнопки 📷 скрин · окно в конце строки вставляют скриншот в промпт, не трогая набранное, так что в одно сообщение помещается несколько снимков.
  • Строка 2: полоски контекста и лимита 5ч в стиле claude-hud рядом и время сброса лимита.
  • Строка 3: недельный лимит и его сброс, и сколько осталось до остывания кэша промптов с последнего ответа.

Полоски желтеют после 50% и краснеют после 80%. Обновляется после каждого хода и раз в 30 секунд. Стоит там же, где HUD в статусной строке вроде claude-hud; строка подсказок Claude Code остаётся под ним.

Что мод делает в системе

Читает показатели сессии (контекст, лимиты) и модель. Раз в 30 секунд выполняет git status --porcelain --branch в папке сессии (только чтение). Только по клику на папку или /open-project: показывает диалог выбора папки Windows через PowerShell, затем запускает wt.exe -w new -d <папка> cmd /k claude (или powershell -NoExit -Command claude при shell: powershell; без Windows Terminal — обычное окно консоли). Раз в 30 секунд читает список команд, чтобы показать кнопки скриншотов, если установлен мод shot. По клику на ветку: выполняет /changes, если установлен мод changes-pane, иначе подсказывает, как его поставить. Добавляет команду /open-project.

Настройки (/config)

  • language: auto (по умолчанию; по LANG, иначе по языку интерфейса или региональному формату Windows), ru или en.
  • position: below (по умолчанию, под полем ввода) или above (над полем).
  • shell: cmd (по умолчанию) или powershell — оболочка окна, которое открывает клик по папке.
  • cacheTtlMinutes: через сколько минут простоя кэш остывает, по умолчанию 60 (подписка); с API-ключом поставь 5.

Установка

/plugin install hud --marketplace ArtyaEXE/claude-mods

Часть claude-mods. Лицензия MIT.

Source 3 files
hooks/register.tsx 387 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
3
4import type { HudLimit, HudSnap } from '../types'
5import { launchCommands, pickerCommand } from './open-project'
6import type { Shell } from './open-project'
7
8const TICK_MS = 30_000
9
10const snap = atom({ plugin: 'hud', key: 'snap' } as const, null as HudSnap | null, { shape: 'snap-2' })
11const lastTurnAt = atom({ plugin: 'hud', key: 'lastTurnAt' } as const, null as number | null)
12const now = atom({ plugin: 'hud', key: 'now' } as const, 0)
13
14let cacheTtlMs = 60 * 60_000
15
16// `claude-opus-5-5` -> `Opus 5.5`; anything else is shown as given.
17const prettyModel = (id: string) => {
18  const m = /claude-([a-z]+)-(\d+)(?:-(\d+))?/i.exec(id)
19  if (m === null) return id
20  const name = (m[1] ?? '').charAt(0).toUpperCase() + (m[1] ?? '').slice(1)
21
22  return m[3] === undefined || m[3].length > 2 ? `${name} ${m[2]}` : `${name} ${m[2]}.${m[3]}`
23}
24
25const kTokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
26
27// The same bar claude-hud draws: █/░, 10 cells from 100 columns, 6 from 60, else 4.
28const barWidth = (columns: number) => (columns >= 100 ? 10 : columns >= 60 ? 6 : 4)
29
30const bar = (percent: number, cells: number) => {
31  const full = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
32
33  return '█'.repeat(full) + '░'.repeat(cells - full)
34}
35
36const levelColor = (percent: number) => (percent >= 80 ? 'red' : percent >= 50 ? 'yellow' : 'green')
37
38type Lang = 'ru' | 'en'
39
40const STRINGS = {
41  ru: {
42    context: 'Контекст',
43    cacheNone: 'кэш —',
44    cacheWorking: 'кэш ● ход идёт',
45    cacheCold: 'кэш остыл',
46    cacheLeft: (m: number) => `кэш ${m} мин`,
47    pickTitle: 'Открыть Claude Code в папке',
48    openCommand: 'Выбрать папку и открыть в ней Claude Code в новом окне',
49    opened: (dir: string) => `Открыл Claude Code в ${dir}`,
50    openFailed: 'Не получилось открыть новое окно терминала',
51    cancelled: 'Папка не выбрана',
52    shotRegion: 'скрин',
53    shotWindow: 'окно',
54    noChanges: 'hud: клик по ветке открывает /changes из мода changes-pane: /plugin install changes-pane --marketplace ArtyaEXE/claude-mods',
55    weekdays: ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'],
56    resetIn: (h: number, m: number, hm: string) => `через ${h > 0 ? `${h}ч ${m}м` : `${m}м`} · ${hm}`,
57    limits: { five_hour: 'Лимит 5ч', seven_day: 'Неделя', spend_limit: 'Бюджет' } as Record<string, string>,
58  },
59  en: {
60    context: 'Context',
61    cacheNone: 'cache —',
62    cacheWorking: 'cache ● turn running',
63    cacheCold: 'cache cold',
64    cacheLeft: (m: number) => `cache ${m}m`,
65    pickTitle: 'Open Claude Code in a folder',
66    openCommand: 'Pick a folder and open Claude Code there in a new window',
67    opened: (dir: string) => `Opened Claude Code in ${dir}`,
68    openFailed: 'Could not open a new terminal window',
69    cancelled: 'No folder picked',
70    shotRegion: 'shot',
71    shotWindow: 'window',
72    noChanges: 'hud: a click on the branch opens /changes from the changes-pane mod: /plugin install changes-pane --marketplace ArtyaEXE/claude-mods',
73    weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
74    resetIn: (h: number, m: number, hm: string) => `in ${h > 0 ? `${h}h ${m}m` : `${m}m`} · ${hm}`,
75    limits: { five_hour: 'Limit 5h', seven_day: 'Week', spend_limit: 'Budget' } as Record<string, string>,
76  },
77}
78
79let lang: Lang = 'en'
80
81// `auto`: the locale variables first, then Windows' UI language or regional format.
82async function resolveLang($: EngineInterface, setting: unknown): Promise<Lang> {
83  if (setting === 'ru' || setting === 'en') return setting
84  const env = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
85  if (env) return env.toLowerCase().startsWith('ru') ? 'ru' : 'en'
86  try {
87    const ran = await $.process.run(
88      ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '(Get-UICulture).Name; (Get-Culture).Name'],
89      { timeoutMs: 10_000 },
90    )
91    if (ran.exitCode === 0 && /^ru\b/im.test(ran.stdout)) return 'ru'
92  } catch {
93    // Not Windows: English.
94  }
95
96  return 'en'
97}
98
99// `через 1ч 12м · 20:59` / `in 1h 12m · 20:59` within a day, `пт 14:00` / `Fri 14:00` past it.
100const resetLabel = (iso: string | undefined, t: number) => {
101  if (iso === undefined) return ''
102  const at = new Date(iso)
103  if (Number.isNaN(at.getTime())) return ''
104  const L = STRINGS[lang]
105  const hm = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
106  const left = at.getTime() - t
107  if (left >= 24 * 3_600_000) return `${L.weekdays[at.getDay()]} ${hm}`
108  const minutes = Math.max(0, Math.ceil(left / 60_000))
109
110  return L.resetIn(Math.floor(minutes / 60), minutes % 60, hm)
111}
112
113async function readGit($: EngineInterface, cwd: string) {
114  try {
115    const ran = await $.process.run(['git', 'status', '--porcelain=v1', '--branch'], { cwd, timeoutMs: 5_000 })
116    if (ran.exitCode !== 0) return { branch: null, dirty: 0, ahead: 0, behind: 0 }
117    const [head = '', ...rest] = ran.stdout.split('\n')
118    const branch = /^## (?:No commits yet on )?([^.\s]+)/.exec(head)?.[1] ?? 'HEAD'
119
120    return {
121      branch,
122      dirty: rest.filter(line => line.trim() !== '').length,
123      ahead: Number(/ahead (\d+)/.exec(head)?.[1] ?? 0),
124      behind: Number(/behind (\d+)/.exec(head)?.[1] ?? 0),
125    }
126  } catch {
127    return { branch: null, dirty: 0, ahead: 0, behind: 0 }
128  }
129}
130
131// Whether another mod's command is installed; false when the list cannot be read.
132async function hasCommand($: EngineInterface, name: string): Promise<boolean> {
133  try {
134    return (await $.command.list()).some(c => c.name === name)
135  } catch {
136    return false
137  }
138}
139
140async function refresh($: EngineInterface) {
141  const [usage, model, cwd] = await Promise.all([$.session.usage(), $.session.model(), $.session.cwd()])
142  const git = await readGit($, cwd)
143  const hasShot = await hasCommand($, 'shot')
144  const limits: HudLimit[] = usage.rateLimits.map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt }))
145  const next: HudSnap = {
146    model: prettyModel(model),
147    cwd,
148    ...git,
149    ctxTokens: usage.context.tokens,
150    ctxWindow: usage.context.window,
151    ctxPercent: usage.context.percent,
152    costUsd: usage.cost?.usd,
153    limits,
154    hasShot,
155  }
156  await update($, snap, () => next)
157  await update($, now, () => Date.now())
158}
159
160async function safeRefresh($: EngineInterface) {
161  try {
162    await refresh($)
163  } catch {
164    // A failed read keeps the last figures on screen.
165  }
166}
167
168// The folder chosen in an Explorer-style dialog, or null when cancelled.
169async function pickFolder($: EngineInterface, start: string, title: string): Promise<string | null> {
170  const ran = await $.process.run(pickerCommand(start, title), { timeoutMs: 600_000 })
171  const chosen = ran.stdout.trim()
172
173  return ran.exitCode === 0 && chosen !== '' ? chosen : null
174}
175
176// A new window running `claude` in the folder: whichever launcher works first.
177async function launchClaude($: EngineInterface, folder: string, shell: Shell): Promise<boolean> {
178  for (const argv of launchCommands(folder, shell)) {
179    try {
180      const ran = await $.process.run(argv, { timeoutMs: 15_000 })
181      if (ran.exitCode === 0) return true
182    } catch {
183      // Not there: try the next.
184    }
185  }
186
187  return false
188}
189
190// Pick a folder in an Explorer-style dialog and start Claude Code there in a new window.
191async function openProject($: EngineInterface): Promise<string> {
192  const L = STRINGS[lang]
193  const folder = await pickFolder($, await $.session.cwd(), L.pickTitle)
194  if (folder === null) return L.cancelled
195  const text = (await launchClaude($, folder, shell)) ? L.opened(folder) : L.openFailed
196  $.ui.toast(text)
197
198  return text
199}
200
201// The changes-pane mod's /changes when it is installed; else how to get it.
202async function openChanges($: EngineInterface) {
203  if ((await $.command.list()).some(c => c.name === 'changes')) {
204    await $.command.run({ command: 'changes' })
205
206    return
207  }
208  $.ui.toast(STRINGS[lang].noChanges)
209}
210
211// The HUD's three rows, then `below`: whatever the site drew beneath it.
212async function drawHud($: EngineInterface, e: RenderInput, s: HudSnap, columns: number, isWorking: boolean, below: RenderElement) {
213  const { Box, Button, Text } = $.ui.resolve(e)
214  const L = STRINGS[lang]
215  const t = (await read($, now)) || Date.now()
216  const last = await read($, lastTurnAt)
217  const sep = <Text dimColor> │ </Text>
218
219  // The branch is a button (opening /changes); its counts stay coloured text beside it.
220  const gitMarks =
221    s.branch === null ? null : (
222      <Text>
223        {s.dirty > 0 && <Text color="yellow"> ●{s.dirty}</Text>}
224        {s.ahead > 0 && <Text color="cyan"> ↑{s.ahead}</Text>}
225        {s.behind > 0 && <Text color="cyan"> ↓{s.behind}</Text>}
226      </Text>
227    )
228
229  const ctxPercent = s.ctxPercent ?? (s.ctxTokens !== undefined && s.ctxWindow > 0 ? Math.round((s.ctxTokens / s.ctxWindow) * 100) : undefined)
230
231  let cache = <Text dimColor>{L.cacheNone}</Text>
232  if (isWorking) {
233    cache = <Text color="green">{L.cacheWorking}</Text>
234  } else if (last !== null) {
235    const left = cacheTtlMs - (t - last)
236    const minutes = Math.ceil(left / 60_000)
237    cache =
238      left <= 0 ? (
239        <Text color="red">{L.cacheCold}</Text>
240      ) : (
241        <Text color={minutes <= 3 ? 'red' : minutes <= 10 ? 'yellow' : 'green'}>{L.cacheLeft(minutes)}</Text>
242      )
243  }
244
245  const cells = barWidth(columns)
246  const order = (kind: string) => (kind === 'five_hour' ? 0 : kind === 'seven_day' ? 1 : 2)
247  const limits = [...s.limits].sort((a, b) => order(a.kind) - order(b.kind))
248  // The 5-hour bar shares the context bar's row, so the two bars never stack into one block.
249  const fiveHour = limits.find(l => l.kind === 'five_hour')
250  const others = limits.filter(l => l.kind !== 'five_hour')
251  const limitView = (l: HudLimit) => (
252    <Text>
253      <Text dimColor>{L.limits[l.kind] ?? l.kind} </Text>
254      {l.kind === 'five_hour' && (
255        <Text>
256          <Text color={levelColor(l.percent)}>{bar(l.percent, cells)}</Text>{' '}
257        </Text>
258      )}
259      <Text color={levelColor(l.percent)}>{Math.round(l.percent)}%</Text>
260      {l.resetsAt !== undefined && (
261        <Text>
262          {'  '}
263          <Text color="cyan">↻ </Text>
264          <Text bold>{resetLabel(l.resetsAt, t)}</Text>
265        </Text>
266      )}
267    </Text>
268  )
269
270  const room = Math.max(10, columns - 40)
271  const cwd = s.cwd.length > room ? `…${s.cwd.slice(-(room - 1))}` : s.cwd
272
273  return (
274    <Box flexDirection="column">
275      <Box flexDirection="row">
276        <Text>
277          <Text bold color="cyan">{s.model}</Text>
278          {sep}
279        </Text>
280        {/* A click opens a folder picker, then Claude Code in a new window there. */}
281        <Button key="open-project" plain onPress={() => void openProject($)}>
282          {cwd}
283        </Button>
284        {s.branch !== null && sep}
285        {s.branch !== null && (
286          <Button key="changes" plain onPress={() => void openChanges($)}>
287            {`⎇ ${s.branch}`}
288          </Button>
289        )}
290        <Text wrap="truncate">{gitMarks}</Text>
291        {/* The shot mod's /shot as buttons: the draft stays, each picture lands at the cursor, so several go in one prompt. */}
292        {s.hasShot === true && (
293          <Box flexDirection="row">
294            {sep}
295            <Text>📷 </Text>
296            <Button key="shot" plain label={L.shotRegion} onPress={() => void $.command.run({ command: 'shot' })} />
297            <Text dimColor> · </Text>
298            <Button key="shot-window" plain label={L.shotWindow} onPress={() => void $.command.run({ command: 'shot', args: 'window' })} />
299          </Box>
300        )}
301      </Box>
302      <Text wrap="truncate">
303        <Text dimColor>{L.context} </Text>
304        {ctxPercent === undefined ? (
305          <Text dimColor>—</Text>
306        ) : (
307          <Text>
308            <Text color={levelColor(ctxPercent)}>{bar(ctxPercent, cells)}</Text>{' '}
309            <Text color={levelColor(ctxPercent)}>{ctxPercent}%</Text>
310            <Text dimColor>
311              {' '}
312              ({kTokens(s.ctxTokens ?? 0)}/{kTokens(s.ctxWindow)})
313            </Text>
314          </Text>
315        )}
316        {fiveHour !== undefined && sep}
317        {fiveHour !== undefined && limitView(fiveHour)}
318      </Text>
319      <Text wrap="truncate">
320        {others.map(l => (
321          <Text>
322            {limitView(l)}
323            {sep}
324          </Text>
325        ))}
326        {cache}
327      </Text>
328      {below}
329    </Box>
330  )
331}
332
333let position: 'above' | 'below' = 'below'
334let shell: Shell = 'cmd'
335
336export const register: Register = (on, options) => {
337  const ttl = Number((options as { cacheTtlMinutes?: unknown }).cacheTtlMinutes ?? 60)
338  cacheTtlMs = (Number.isFinite(ttl) && ttl > 0 ? ttl : 60) * 60_000
339  position = (options as { position?: unknown }).position === 'above' ? 'above' : 'below'
340  shell = (options as { shell?: unknown }).shell === 'powershell' ? 'powershell' : 'cmd'
341
342  on('session.start', async ($, e, next) => {
343    const started = await next(e)
344    lang = await resolveLang($, (options as { language?: unknown }).language)
345    await $.command.register({ name: 'open-project', description: STRINGS[lang].openCommand })
346    void safeRefresh($)
347    $.clock.every(TICK_MS, () => void safeRefresh($))
348
349    return started
350  })
351
352  on('command.run', { command: 'open-project' }, async $ => ({ text: await openProject($) }))
353
354  on('turn.complete', async ($, e, next) => {
355    const done = await next(e)
356    if (e.agentId === undefined) {
357      await update($, lastTurnAt, () => Date.now())
358      void safeRefresh($)
359    }
360
361    return done
362  })
363
364  on('session.end', async ($, e, next) => {
365    if (e.reason === 'clear') await update($, lastTurnAt, () => null)
366
367    return next(e)
368  })
369
370  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
371    const below = await next(e)
372    const s = await read($, snap)
373    if (position !== 'above' || s === null || e.props.hasSurvey || e.props.view.agentId !== undefined) return below
374
375    return drawHud($, e, s, e.props.bodyColumns, e.props.isWorking, below)
376  })
377
378  // Under the prompt, where a status-line HUD sits: the HUD, then the engine's own hint line.
379  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
380    const below = await next(e)
381    const s = await read($, snap)
382    if (position !== 'below' || s === null) return below
383
384    return drawHud($, e, s, Math.max(20, (e.viewport?.columns ?? 120) - 4), e.props.isWorking, below)
385  })
386}
387
hooks/open-project.ts 81 lines
1// The Explorer-style folder picker (IFileOpenDialog with FOS_PICKFOLDERS), owned by the
2// window in front (the terminal that was clicked), so it opens on top of it. Prints the
3// chosen folder as UTF-8, or nothing when cancelled. `__START__` is the folder it opens in.
4const PICKER = String.raw`
5[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
6Add-Type -TypeDefinition @'
7using System;
8using System.Runtime.InteropServices;
9public static class FolderPicker {
10  [ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] class FileOpenDialog {}
11  [ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
12  interface IFileDialog {
13    [PreserveSig] int Show(IntPtr parent);
14    void SetFileTypes(uint c, IntPtr f); void SetFileTypeIndex(uint i); void GetFileTypeIndex(out uint i);
15    void Advise(IntPtr p, out uint c); void Unadvise(uint c);
16    void SetOptions(uint fos); void GetOptions(out uint fos);
17    void SetDefaultFolder(IShellItem si); void SetFolder(IShellItem si);
18    void GetFolder(out IShellItem si); void GetCurrentSelection(out IShellItem si);
19    void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string n); void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string n);
20    void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string t); void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string t);
21    void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string t);
22    void GetResult(out IShellItem si);
23  }
24  [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
25  interface IShellItem {
26    void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv); void GetParent(out IShellItem si);
27    void GetDisplayName(uint sigdn, [MarshalAs(UnmanagedType.LPWStr)] out string name);
28  }
29  [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
30  static extern void SHCreateItemFromParsingName(string path, IntPtr pbc, ref Guid riid, out IShellItem si);
31  [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();
32  public static string Pick(string start, string title) {
33    IFileDialog dialog = (IFileDialog)new FileOpenDialog();
34    dialog.SetOptions(0x20 | 0x40);
35    dialog.SetTitle(title);
36    try {
37      Guid shellItem = typeof(IShellItem).GUID;
38      IShellItem folder;
39      SHCreateItemFromParsingName(start, IntPtr.Zero, ref shellItem, out folder);
40      dialog.SetFolder(folder);
41    } catch { }
42    if (dialog.Show(GetForegroundWindow()) != 0) return "";
43    IShellItem result; dialog.GetResult(out result);
44    string path; result.GetDisplayName(0x80058000, out path);
45    return path;
46  }
47}
48'@
49[FolderPicker]::Pick('__START__', '__TITLE__')
50`
51
52// A PowerShell single-quoted string holds anything but a single quote, which it doubles.
53const psQuote = (s: string) => s.replace(/'/g, "''")
54
55// The PowerShell line that shows the picker opened on `start`.
56export function pickerCommand(start: string, title: string): string[] {
57  const script = PICKER.replace('__START__', psQuote(start.replace(/\//g, '\\'))).replace('__TITLE__', psQuote(title))
58
59  return ['powershell.exe', '-NoProfile', '-NonInteractive', '-STA', '-Command', script]
60}
61
62// A new Windows Terminal window running `claude` in the folder; a plain PowerShell window without it.
63export type Shell = 'cmd' | 'powershell'
64
65// The shell the new window runs `claude` in, kept open after it exits.
66const SHELLS: Record<Shell, string[]> = {
67  cmd: ['cmd.exe', '/k', 'claude'],
68  powershell: ['powershell.exe', '-NoExit', '-Command', 'claude'],
69}
70
71// Windows Terminal first, then a plain `start` window when there is none.
72export function launchCommands(folder: string, shell: Shell): string[][] {
73  // A trailing backslash would escape wt's closing quote: `C:\` becomes `C:\.`.
74  const dir = folder.endsWith('\\') ?`${folder}.` : folder
75
76  return [
77    ['wt.exe', '-w', 'new', '-d', dir, ...SHELLS[shell]],
78    ['cmd.exe', '/c', 'start', '""', '/D', dir, ...SHELLS[shell]],
79  ]
80}
81
types/index.d.ts 29 lines
1export type HudLimit = { kind: string; percent: number; resetsAt?: string }
2
3export type HudSnap = {
4  model: string
5  cwd: string
6  branch: string | null
7  dirty: number
8  ahead: number
9  behind: number
10  ctxTokens?: number
11  ctxWindow: number
12  ctxPercent?: number
13  costUsd?: number
14  limits: HudLimit[]
15  // The shot mod's /shot is there: the HUD shows its buttons.
16  hasShot?: boolean
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    hud: {
22      snap: Shaped<HudSnap | null>
23      // When the main loop's last turn ended: the cache's clock starts there.
24      lastTurnAt: number | null
25      now: number
26    }
27  }
28}
29