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

自用的 Claude Code mod 集合,本 repo 同時是 plugin marketplace。
/plugin marketplace add wesely/wesely-claude-mod
/plugin install usage@wesely-claude-mod
裝完重開 Claude Code。
輸入框下方的提示列改為單行顯示:
⏵⏵ auto mode on (shift+tab to cycle) · 💻 Session 名稱 5hr: 3% · Weekly: 10%
/rename 的自訂名稱,其次取自動產生的標題。--。claude plugin validate usage
claude plugin test usage
修改後更新已安裝的版本:
claude plugin marketplace update wesely-claude-mod
claude plugin update usage@wesely-claude-modhooks/register.ts 65 lines1import { 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}
65hooks/format.ts 52 lines1export 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}
52types/index.d.ts 8 lines1export type Label = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 usage: { label: Label; usageText: string }
6 }
7}
8