SLOPSHOPPER

statusline-app

Ligne d'etat sous la saisie dans l'app Claude Code : contexte (pourcentage et tokens), limite 5 h et de la semaine avec leur reset, prix equivalent API.

newstatus
A shopper browsing a rack in a slop shop
README

statusline-app

Ligne d'état sous la saisie de l'app Claude Code : contexte, limite des 5 heures et limite de la semaine.

Contexte ▓▓░░░░░░ 19% 38k/200k │ 5h 21% ↻4h25 │ Semaine 33% ↻5j │ ≈ $0.42

Installation

/plugin install statusline-app --marketplace LouiSvon/claude-code-mods

Contenu de la ligne

  • Dossier de la session, seulement s'il existe.
  • Contexte : jauge de huit crans, pourcentage et tokens utilisés sur la taille de la fenêtre.
  • ≈ $ : prix équivalent API de la session. Ce n'est pas une facture : un abonnement paie un forfait. Absent quand le moteur ne le donne pas.
  • Les compteurs de tokens par session et par projet sont dans task-board, qui peut les colorer.
  • 5h et Semaine : pourcentage utilisé et temps avant le reset. Ces deux champs n'apparaissent que si ton abonnement les expose.
  • /clear conseillé au-delà de 60 % de contexte.

Le modèle n'est pas affiché : l'app l'affiche déjà à côté de la saisie.

Comportement

  • Sur le terminal, le mod ne fait rien : la ligne d'état du shell reste en place.
  • La ligne est du texte simple, sans couleur : l'app de bureau ne dessine pas d'arbre coloré à cet endroit.
  • Elle est recalculée à session.start, turn.step et turn.complete. Une lecture qui échoue est ignorée, sans casser le tour.

Tests

claude plugin test .

Cinq tests : jauge et teintes, dossier réel ou non, temps avant reset, tokens et prix abrégés, ligne épinglée par la session.

Source 1 files
hooks/register.tsx 89 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3const ORANGE = '#ff9f43'
4const BLUE = '#4da3ff'
5
6// Blue while fresh, orange past 60 %, red past 85 %.
7export const tone = (pct: number): string => (pct >= 85 ? 'error' : pct >= 60 ? ORANGE : BLUE)
8
9export const gauge = (pct: number) => {
10  const filled = Math.min(8, Math.floor(pct / 12.5 + 0.5))
11  return { on: '▓'.repeat(filled), off: '░'.repeat(8 - filled) }
12}
13
14// 842, 12k, 200k, 1.2M: the same short form as the task-board's token counters.
15export const short = (n: number): string =>
16  n < 1000 ? String(Math.round(n)) : n < 999_500 ? `${Math.round(n / 1000)}k` : `${(n / 1_000_000).toFixed(1)}M`
17
18// "≈ $0.42": the API-equivalent price, not a charge (a subscription pays a flat fee); "" when unknown.
19export const price = (usd: number | undefined): string =>
20  usd === undefined || !Number.isFinite(usd) || usd <= 0 ? '' : `≈ $${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
21
22// The session's folder, or null when it runs in the app's scratch folder or the home directory.
23export const folderOf = (cwd: string, home = ''): string | null => {
24  if (cwd.includes('/scratch-workspaces/') || (home !== '' && cwd === home)) return null
25  return cwd.split('/').filter(Boolean).pop() ?? null
26}
27
28// "2h13" or "45min" until a window resets; "" when unknown.
29export const until = (resetsAt: string | undefined, nowMs: number): string => {
30  const ms = resetsAt === undefined ? NaN : Date.parse(resetsAt) - nowMs
31  if (!Number.isFinite(ms) || ms <= 0) return ''
32  const min = Math.round(ms / 60000)
33  if (min < 60) return `${min}min`
34  if (min < 48 * 60) return `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}`
35  return `${Math.floor(min / 1440)}j`
36}
37
38// The app draws no coloured tree under the prompt, only a status line of text: build that line.
39async function refresh($: EngineInterface) {
40  try {
41    const [cwd, usage, now] = await Promise.all([$.session.cwd(), $.session.usage(), $.clock.now()])
42    const ctx = Math.round(usage.context.percent ?? 0)
43    const g = gauge(ctx)
44    const limit = (kind: string, label: string) => {
45      const l = usage.rateLimits.find(r => r.kind === kind)
46      if (l === undefined) return null
47      const reset = until(l.resetsAt, now)
48      return `${label} ${Math.round(l.percentUsed)}%${reset !== '' ? ` ↻${reset}` : ''}`
49    }
50    const fill = usage.context.tokens === undefined ? '' : ` ${short(usage.context.tokens)}/${short(usage.context.window)}`
51    const cost = price(usage.cost?.usd)
52    const parts = [
53      folderOf(cwd),
54      `Contexte ${g.on}${g.off} ${ctx}%${fill}`,
55      limit('five_hour', '5h'),
56      limit('seven_day', 'Semaine'),
57      cost === '' ? null : cost,
58      ctx >= 60 ? '/clear conseillé' : null,
59    ].filter((p): p is string => p !== null)
60    $.ui.status(parts.join(' │ '))
61  } catch {
62    // the status line is a comfort: a failed read must not break a turn
63  }
64}
65
66// Learnt at session.start (a reload raises it again); the terminal has its own shell status line.
67let surface: string | null = null
68
69export const register: Register = on => {
70  on('session.start', async ($, e, next) => {
71    surface = e.surface
72    if (surface !== 'terminal') await refresh($)
73
74    return next(e)
75  })
76
77  on('turn.step', async function* ($, e, next) {
78    if (surface !== 'terminal') await refresh($)
79
80    return yield* next(e)
81  })
82
83  on('turn.complete', async ($, e, next) => {
84    if (surface !== 'terminal') await refresh($)
85
86    return next(e)
87  })
88}
89