oStraps banner in a band above the prompt (git-status's git state, model, context window); clears git-status's own status line

The oStraps banner in a band above the prompt: git-status's git snapshot, then the main loop's model and context window. One of the banner options listed in plugins/banner-status/README.md.
oStraps: ⎇ feature/x ↑2 ↓1 · +3 ~3 ?2 · (R,L):1,2 | [Opus 5.5] · ctx:110k 11%
no upstream, staged (+), unstaged and unmerged (~) and untracked (?) counts or clean, then (R,L):<behind>,<ahead>.[model] from $.session.model(), ctx:<tokens> <percent>% from $.session.usage(); each is left out until known (a fresh or just compacted session has no context figure yet).⚠.git-status takes the snapshot ($.state['git-status'].git) and attaches the per-prompt GIT_STATE context; this mod only reads the snapshot, so it lists git-status under dependencies. With no snapshot yet, the band draws nothing.
While this mod is loaded, git-status's own status line is cleared: a ui.status hook answers git-status's calls with an empty line, so the git state shows once.
| Hook | Does |
|---|---|
session.start | Reads the session figures and starts a 60 s refresh of them. |
turn.complete, prompt.submit | Read the session figures; a write only when they moved. |
ui.status | Clears the line when git-status sets it; every other plugin's line passes. |
ui.render AbovePrompt | Draws the banner above whatever the hooks beneath drew (pr-review-watch's band stays on screen); yields to a survey. |
$.state['band-line'] (contract: types/index.d.ts):
| Key | Meaning |
|---|---|
session | The model id and context tokens and percentage the banner shows; all null before the first reading. |
claude plugin validate --strict <mod folder>
claude plugin test <mod folder>
The ui.status hook is not covered by claude plugin test (the test engine raises no ui.status); check it in a live session with both mods loaded: no ⚠ git-status: line under the prompt.
claude plugin marketplace add t968rs/operator-straps
claude plugin install git-status@operator-straps
claude plugin install band-line@operator-straps
.claude-plugin/types/ is generated by the engine on load and git-ignored; tsconfig.json extends it.
hooks/register.ts 10 lines1import type { Register } from 'claude-code'
2
3import { registerBand } from './band'
4import { registerBanner } from './banner'
5
6export const register: Register = on => {
7 registerBanner(on)
8 registerBand(on)
9}
10hooks/band.tsx 31 lines1import type { On } from 'claude-code'
2import { atom, read } from 'claude-code'
3
4import { formatBanner, needsAttention, NO_FIGURES } from './banner'
5
6/**
7 * The oStraps banner above the prompt: git-status's snapshot, then the session's model and context window.
8 * Reads state only; the band redraws when git-status writes its snapshot or ./banner the session figures. It
9 * wraps whatever the hooks beneath drew, so another mod's band (pr-review-watch's) stays on screen under it.
10 */
11
12const gitAtom = atom({ plugin: 'git-status', key: 'git' } as const, null)
13const sessionAtom = atom({ plugin: 'band-line', key: 'session' } as const, NO_FIGURES)
14
15export const registerBand = (on: On): void => {
16 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
17 if (e.props.hasSurvey) return next(e)
18 const git = await read($, gitAtom)
19 if (git === null) return next(e)
20 const text = formatBanner(git, await read($, sessionAtom), e.props.bodyColumns)
21 const below = await next(e).catch(() => undefined)
22 const { Box, Text } = $.ui.resolve(e)
23 return (
24 <Box flexDirection="column">
25 <Text color={needsAttention(git) ? 'warning' : 'subtle'}>{text}</Text>
26 {below}
27 </Box>
28 )
29 })
30}
31hooks/banner.ts 166 lines1import type { EngineInterface, Register } from 'claude-code'
2import { atom, update } from 'claude-code'
3
4import type { GitSnapshot, SessionFigures } from '../types'
5
6/**
7 * The oStraps banner: its text, and the session figures it shows beside git-status's snapshot.
8 *
9 * git-status owns the snapshot (its `git` state); this mod reads it and keeps the main loop's model and
10 * context window in its own `session` state. While this mod is loaded, git-status's own status line is
11 * cleared, so the banner is the one place the git state shows.
12 */
13
14type Dollar = EngineInterface
15
16/** No reading of the session yet. */
17export const NO_FIGURES: SessionFigures = { model: null, ctxTokens: null, ctxPercent: null }
18const sessionState = atom({ plugin: 'band-line', key: 'session' } as const, NO_FIGURES)
19
20/** Background refresh period of the session figures, in milliseconds. */
21export const TICK_MS = 60_000
22/** The plugin whose status line the banner replaces. */
23export const GIT_STATUS_PLUGIN = 'git-status'
24
25const MAX_BRANCH = 32
26/** The banner's label; the engine draws a plugin's own status line under the plugin's name instead. */
27const LABEL = 'oStraps:'
28const WARNING = '⚠ '
29/** Fewest characters the clipped head keeps, so a very narrow band still shows its label. */
30const MIN_HEAD = 12
31const MAX_MODEL = 24
32
33// ---------------------------------------------------------------------------
34// Pure formatting
35
36/** Whole-string truncation with an ellipsis. */
37const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max - 1)}…`)
38
39/** The branch segment: name, then ahead/behind or "no upstream". */
40const branchSegment = (g: GitSnapshot): string => {
41 const head = `⎇ ${clip(g.branch || '?', MAX_BRANCH)}`
42 if (g.upstream === null) return `${head} no upstream`
43 return g.ahead > 0 || g.behind > 0 ? `${head} ↑${g.ahead} ↓${g.behind}` : head
44}
45
46/** The working-tree segment: non-zero counts, or "clean". */
47const treeSegment = (g: GitSnapshot): string => {
48 const parts: string[] = []
49 if (g.staged > 0) parts.push(`+${g.staged}`)
50 if (g.unstaged > 0) parts.push(`~${g.unstaged}`)
51 if (g.untracked > 0) parts.push(`?${g.untracked}`)
52 return parts.length === 0 ? 'clean' : parts.join(' ')
53}
54
55/** The commit-count segment: commits only the remote has (behind), then commits only the local branch has (ahead). */
56const commitsSegment = (g: GitSnapshot): string => ` · (R,L):${g.behind},${g.ahead}`
57
58/** A branch that tracks nothing, or a detached head: the states worth a glance. */
59export const needsAttention = (g: GitSnapshot): boolean => g.upstream === null
60
61const cap = (word: string): string => word.charAt(0).toUpperCase() + word.slice(1)
62
63/**
64 * A model id as a short name: `claude-sonnet-5-5` is `Sonnet 5.5`; a date stamp or a `[1m]` suffix is dropped,
65 * an alias is capitalized and any other id is kept, clipped.
66 */
67export const modelLabel = (id: string): string => {
68 const bare = id.replace(/\[[^\]]*\]$/, '')
69 const m = bare.match(/^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/)
70 if (m !== null) return `${cap(m[1] ?? '')} ${m[2]}${m[3] === undefined ? '' : `.${m[3]}`}`
71 return /^[a-z]+$/.test(bare) ? cap(bare) : clip(bare, MAX_MODEL)
72}
73
74/** `850`, `110k`, `1.3M`. */
75const tokenSize = (tokens: number): string => {
76 if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
77 return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : `${tokens}`
78}
79
80/** The context segment, `ctx:110k 11%`; undefined without a token count (a fresh or just compacted session). */
81export const ctxLabel = (tokens: number | null, percent: number | null): string | undefined => {
82 if (tokens === null) return undefined
83 const size = tokenSize(tokens)
84 return percent === null ? `ctx:${size}` : `ctx:${size} ${percent}%`
85}
86
87/** The session segment, ` | [Sonnet 5.5] · ctx:110k 11%`, each part left out while unknown; empty when both are. */
88const sessionSegment = (s: SessionFigures): string => {
89 const parts = [s.model === null ? undefined : `[${modelLabel(s.model)}]`, ctxLabel(s.ctxTokens, s.ctxPercent)]
90 const shown = parts.filter((p): p is string => p !== undefined)
91 return shown.length === 0 ? '' : ` | ${shown.join(' · ')}`
92}
93
94/**
95 * The banner: label, branch, tree, commit counts, then the session figures. When it runs over `columns`
96 * the branch and tree are clipped; the commit counts and the session figures are kept whole.
97 */
98export const formatBanner = (g: GitSnapshot, s: SessionFigures, columns = 120): string => {
99 const tail = `${commitsSegment(g)}${sessionSegment(s)}`
100 const head = `${needsAttention(g) ? WARNING : ''}${LABEL} ${branchSegment(g)} · ${treeSegment(g)}`
101 return clip(head, Math.max(columns - tail.length, MIN_HEAD)) + tail
102}
103
104// ---------------------------------------------------------------------------
105// Session figures
106
107/** The main loop's model and context window as the engine reports them; a refusal leaves a figure null. */
108const readFigures = async ($: Dollar): Promise<SessionFigures> => {
109 const model = await $.session.model().catch(() => undefined)
110 const usage = await $.session.usage().catch(() => undefined)
111 return {
112 model: model === undefined || model === '' ? null : model,
113 ctxTokens: usage?.context.tokens ?? null,
114 ctxPercent: usage?.context.percent ?? null,
115 }
116}
117
118const sameFigures = (a: SessionFigures, b: SessionFigures): boolean =>
119 a.model === b.model && a.ctxTokens === b.ctxTokens && a.ctxPercent === b.ctxPercent
120
121/** Stores the session figures when they moved, so an unchanged reading never redraws; never throws. */
122const refreshSession = async ($: Dollar): Promise<void> => {
123 const next = await readFigures($).catch(() => NO_FIGURES)
124 await update($, sessionState, prev => (sameFigures(prev, next) ? prev : next)).catch(() => undefined)
125}
126
127/** The periodic refresh of the session figures (a reload starts afresh). */
128const ticking = { tick: undefined as { cancel: () => void } | undefined }
129
130/** (Re)starts the periodic refresh; a timer costs no model tokens. */
131const startTicking = ($: Dollar): void => {
132 try {
133 ticking.tick?.cancel()
134 ticking.tick = $.clock.every(TICK_MS, () => void refreshSession($))
135 } catch {
136 ticking.tick = undefined
137 }
138}
139
140// ---------------------------------------------------------------------------
141// Registration
142
143/** Registers the session-figure refresh triggers and clears git-status's own status line. */
144export const registerBanner = (on: Parameters<Register>[0]): void => {
145 on('session.start', async ($, e, next) => {
146 const started = await next(e)
147 startTicking($)
148 await refreshSession($)
149 return started
150 })
151
152 on('turn.complete', async ($, e, next) => {
153 const result = await next(e)
154 await refreshSession($)
155 return result
156 })
157
158 on('prompt.submit', async ($, e, next) => {
159 await refreshSession($)
160 return next(e)
161 }).catch((_$, e, next) => next(e))
162
163 on('ui.status', async (_$, e, next) => (next.origin.plugin === GIT_STATUS_PLUGIN ? next({ ...e, text: undefined }) : next(e)))
164 .catch((_$, e, next) => next(e))
165}
166types/index.d.ts 37 lines1/**
2 * The git-status snapshot this mod draws, as git-status's contract declares it; the `git-status.git` state
3 * key itself is typed by that contract (plugin.json `dependencies`).
4 */
5export type GitSnapshot = {
6 /** `# branch.head`: the branch name, or `(detached)`. */
7 branch: string
8 /** `# branch.upstream`; null when the branch tracks none. */
9 upstream: string | null
10 ahead: number
11 behind: number
12 /** Entries with an index (X) change. */
13 staged: number
14 /** Entries with a worktree (Y) change, plus unmerged entries. */
15 unstaged: number
16 untracked: number
17}
18
19/** What the banner shows of the session: the main loop's model and its context window. */
20export type SessionFigures = {
21 /** `$.session.model()`; null until the engine answers. */
22 model: string | null
23 /** Input tokens of the last response; null until the live window has one (a fresh or just compacted session). */
24 ctxTokens: number | null
25 /** `ctxTokens` against the model's window, a whole percentage; null when unknown. */
26 ctxPercent: number | null
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'band-line': {
32 /** The session figures the banner shows; all null before the first reading. */
33 session: SessionFigures
34 }
35 }
36}
37