SLOPSHOPPER

usage

Prompt hint line: remote icon + session name on the left, 5hr / weekly usage on the right

newspinnerstatusprocess
v0.2.0no licenseupdated 2026-10-14Wesely/wesely-claude-mod/usage
A shopper browsing a rack in a slop shop
README

wesely-claude-mod

自用的 Claude Code mod 集合,本 repo 同時是 plugin marketplace。

安裝

/plugin marketplace add wesely/wesely-claude-mod
/plugin install usage@wesely-claude-mod

裝完重開 Claude Code。

Mods

usage

輸入框下方的提示列改為單行顯示:

⏵⏵ auto mode on (shift+tab to cycle) · 💻 Session 名稱            5hr: 3% · Weekly: 10%
  • 左側:💻 本機;📶 有遠端畫面(Remote Control 的手機或網頁)連入。後接 session 名稱,優先取 /rename 的自訂名稱,其次取自動產生的標題。
  • 右側:5 小時與每週用量,依終端機寬度靠右;寬度不足時接在名稱後方。
  • 用量須為訂閱方案;尚無讀數時顯示 --。

開發

claude plugin validate usage
claude plugin test usage

修改後更新已安裝的版本:

claude plugin marketplace update wesely-claude-mod
claude plugin update usage@wesely-claude-mod
Source 3 files
hooks/register.ts 65 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { composeTail, formatLabel, formatUsage, pickTitle } from './format'
5
6const label = atom({ plugin: 'usage', key: 'label' } as const, '')
7const usageText = atom({ plugin: 'usage', key: 'usageText' } as const, '')
8
9// Prints the session's title entries (custom from /rename, else AI-generated) from its transcript.
10const TITLE_SCRIPT =
11  'f=$(ls "$HOME"/.claude/projects/*/"$1".jsonl 2>/dev/null | head -1); ' +
12  '[ -n "$f" ] && grep -E \'"type":"(custom|ai)-title"\' "$f" | tail -20'
13
14const refresh = async ($: EngineInterface): Promise<void> => {
15  const [usage, surfaces, id] = await Promise.all([
16    $.session.usage(),
17    $.session.surfaces(),
18    $.session.id(),
19  ])
20  const ran = await $.process.run(['sh', '-c', TITLE_SCRIPT, 'sh', id])
21  const next = formatLabel(surfaces.some(s => s !== 'terminal'), pickTitle(ran.stdout))
22  await update($, label, () => next)
23  await update($, usageText, () => formatUsage(usage.rateLimits))
24}
25
26const safely = ($: EngineInterface): Promise<void> =>
27  refresh($).catch(err => $.ui.log(`usage: ${String(err)}`, { to: 'debug' }))
28
29export const register: Register = on => {
30  on('session.start', async ($, e, next) => {
31    const result = await next(e)
32    $.ui.status(undefined) // drop the pinned line an earlier version left
33    await safely($)
34    return result
35  })
36  on('session.measure', async ($, e, next) => {
37    const result = await next(e)
38    await safely($)
39    return result
40  })
41  on('session.attach', async ($, e, next) => {
42    const result = await next(e)
43    await safely($)
44    return result
45  })
46  on('session.detach', async ($, e, next) => {
47    const result = await next(e)
48    await safely($)
49    return result
50  })
51  on('turn.complete', async ($, e, next) => {
52    const result = await next(e)
53    await safely($)
54    return result
55  })
56
57  // One row: the engine's hint stays live, the label follows it, usage sits at the right edge.
58  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
59    const [text, usage] = [await read($, label), await read($, usageText)]
60    if (text === '') return next(e)
61    const tail = composeTail(e.props.hint, text, usage, e.viewport?.columns)
62    return next({ ...e, props: { ...e.props, tail } })
63  })
64}
65
hooks/format.ts 52 lines
1export const ICON_REMOTE = '📶'
2export const ICON_LOCAL = '💻'
3const UNTITLED = '(untitled)'
4
5type Limit = { kind: string; percentUsed: number }
6
7const pct = (limits: readonly Limit[], kind: string): string => {
8  const found = limits.find(l => l.kind === kind)
9  return found === undefined ? '--' : `${Math.round(found.percentUsed)}%`
10}
11
12/** Last custom title wins over the AI title; each input line is one transcript JSON entry. */
13export const pickTitle = (jsonl: string): string | undefined => {
14  const entries = jsonl
15    .split('\n')
16    .filter(line => line.trim() !== '')
17    .flatMap(line => {
18      try {
19        return [JSON.parse(line) as { type?: string; customTitle?: string; aiTitle?: string }]
20      } catch {
21        return []
22      }
23    })
24  const custom = entries.filter(x => x.type === 'custom-title' && x.customTitle).at(-1)
25  const ai = entries.filter(x => x.type === 'ai-title' && x.aiTitle).at(-1)
26  return custom?.customTitle ?? ai?.aiTitle
27}
28
29/** Right side: the pinned status text (the engine prefixes it with the plugin name). */
30export const formatUsage = (rateLimits: readonly Limit[]): string =>
31  `5hr: ${pct(rateLimits, 'five_hour')} · Weekly: ${pct(rateLimits, 'seven_day')}`
32
33/** Left side: remote-control icon and session name, drawn on the hint line. */
34export const formatLabel = (isRemote: boolean, title: string | undefined): string =>
35  `${isRemote ? ICON_REMOTE : ICON_LOCAL} ${title ?? UNTITLED}`
36
37// Wide (2-column) code points: CJK, fullwidth forms, emoji.
38const WIDE = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
39const ROW_INDENT = 2 // the hint line's left padding
40const RIGHT_MARGIN = 2 // slack so the row never wraps
41
42export const displayWidth = (text: string): number =>
43  [...text].reduce((sum, ch) => sum + (WIDE.test(ch) ? 2 : 1), 0)
44
45/** Tail after the engine's hint: label on the left, usage pushed to the row's right edge. */
46export const composeTail = (hint: string, label: string, usage: string, columns: number | undefined): string => {
47  const left = `${hint === '' ? '' : ' · '}${label}`
48  const used = ROW_INDENT + displayWidth(hint) + displayWidth(left) + displayWidth(usage) + RIGHT_MARGIN
49  const gap = columns === undefined ? 0 : columns - used
50  return `${left}${gap >= 2 ? ' '.repeat(gap) : ' · '}${usage}`
51}
52
types/index.d.ts 8 lines
1export type Label = string
2
3declare module 'claude-code' {
4  interface PluginState {
5    usage: { label: Label; usageText: string }
6  }
7}
8