ShellTime statusline for Claude Code: git, model, session and daily cost, quota, agent time and context, in the terminal and the desktop app. Also links PRs…

The ShellTime statusline as a Claude Code mod. It draws the ShellTime statusline in a band above the prompt. It runs in the terminal and in the Code tab of the Claude desktop app, where the statusLine command from settings.json is not drawn.
In the terminal it is the same line as shelltime cc statusline, in Claude Code's theme colors, so it follows /theme:
🌿 main* | 🤖 Opus 5.5 | 💰 $1.23 | 📊 $12.50 | 🚦 5h:23% 7d:45% | ⏱️ 1h5m | 📈 42%
In the desktop app it is one flat row, with no borders or padding, in tones that read on the light and the dark theme. Quota and context get a thin bar beside each percentage. The model is left out there, since the composer's footer already shows it.
🌿 main* 💰 $1.23 📊 $12.50 🚦 5h ━━── 23% 7d ━━━─ 45% ⏱️ 1h5m 📈 ━━── 42%
| Segment | Shows | Color | Link |
|---|---|---|---|
| 🌿 | Git branch, with * when the tree is dirty | green, gray - outside a repo | |
| 🤖 | Model (terminal only) | ||
| 💰 | This session's cost | cyan | the session on shelltime.xyz |
| 📊 | Today's Claude Code cost | yellow (amber on desktop), gray - with none | your coding agent page |
| 🚦 | 5-hour and 7-day quota used | green, yellow from 50%, red from 80%; on desktop each bucket has its own bar and color | claude.ai usage |
| ⏱️ | Today's AI agent time | magenta, gray - with none | your profile |
| 📈 | Context window used, with a bar on desktop | green, yellow from 50%, red from 80% |
claude plugin marketplace add shelltime/claude-code-mods
claude plugin install shelltime-statusline@shelltime
Desktop sessions on the same machine load the same installed plugins.
There is nothing to configure in the mod. It reads the ShellTime CLI's own config file, ~/.shelltime/config.yaml (or .yml / .toml, with config.local.* merged over it), in the same order the CLI does. Run shelltime init once and the mod picks up your token. Changes to the file are picked up on the next refresh.
The statusline doesn't need the shelltime binary or its daemon. Without a token it still shows git, model, session cost, quota and context. Daily cost and agent time show -, and nothing is sent to ShellTime. Linking pull requests (below) is the one feature that uses the CLI.
shelltime cc statusline | this mod | |
|---|---|---|
| git | the daemon runs git | runs the same git commands (GIT_OPTIONAL_LOCKS=0) |
| model, session cost, context | Claude Code's statusline JSON | the same figures, from Claude Code directly |
| quota | the daemon calls Anthropic's usage API with the OAuth token from the Keychain | the rate limits Claude Code already read from its last API response. No Keychain access, works on any OS |
| daily cost, agent time | the daemon queries ShellTime's API | the same GraphQL query, at most once every 15 s |
| session → project mapping | sent to ShellTime's API | the same request, once per session and directory |
| session → pull requests | shelltime cc pr, after gh pr create prints a PR URL |
Like the native statusline, it refreshes as the conversation changes: when you send a prompt, after each tool call, and when a turn ends. It doesn't poll while the session is idle.
When a Bash call runs gh pr create, the mod reads the PR URLs that gh printed on stdout. Commands that chain several gh pr create calls are covered too. It then runs:
shelltime cc pr --session-id <session id> <pr url>...
The CLI hands the URLs to the ShellTime daemon, which sends them to ShellTime, and they show up on the session. Without a daemon the CLI sends them itself.
~/.shelltime/bin/shelltime first, then shelltime on PATH.gh pr create that prints the URL tries again, and the failure goes to Claude Code's debug log.cc pr, nothing is linked. Errors from the CLI itself (not logged in, server unreachable) go to ~/.shelltime/log.log.PostToolUse hook, in the background, after the tool returns. It never delays or changes the Bash result Claude sees.If the ShellTime GitHub App is installed on the repository, ShellTime comments on each linked github.com PR, as the app. The comment shows:
The comment is posted a couple of minutes after the PR is linked. ShellTime edits the same comment 30 minutes and 24 hours later, so it ends with the whole session's numbers.
The mod and the CLI do nothing extra for this: ShellTime's server posts the comment once the PR is linked.
If ~/.claude/settings.json also has a statusLine running shelltime cc statusline, the terminal shows both lines. Keep both, or remove one.
hooks/register.tsx 300 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { GitInfo, StatuslineView } from '../types'
5import {
6 checkOk,
7 dailyStatsRequest,
8 parseDailyStats,
9 parseUserLogin,
10 sessionProjectRequest,
11 userProfileRequest,
12} from './api'
13import type { ApiRequest, ApiResponse } from './api'
14import { BASE_FILES, LOCAL_FILES, formatOf, mergeConfig, parseShellTimeConfig, resolveConfig } from './config'
15import type { ShellTimeConfig } from './config'
16import { ccPrArgs, createdPullRequestUrls } from './pullRequests'
17import { buildSegments, displayModelName } from './segments'
18import { StatusRow } from './ui/desktop'
19import { StatusLine } from './ui/terminal'
20
21const view = atom({ plugin: 'shelltime-statusline', key: 'view' } as const, null)
22
23// The native statusline redraws as the conversation changes, never on an
24// idle timer; so does this one: at most one local refresh per DEBOUNCE_MS,
25// and ShellTime's API at most once per REMOTE_INTERVAL_MS.
26const DEBOUNCE_MS = 300
27const REMOTE_INTERVAL_MS = 15_000
28
29const EMPTY_VIEW: StatuslineView = {
30 git: null,
31 model: '',
32 sessionCost: 0,
33 daily: null,
34 fiveHourPercent: null,
35 sevenDayPercent: null,
36 contextPercent: 0,
37 login: '',
38 webEndpoint: '',
39 sessionId: '',
40}
41
42// The module's own bookkeeping; a reload starts it over, the view stays.
43let localTimer: Timer | undefined
44let remoteTimer: Timer | undefined
45let isLocalRunning = false
46let isLocalDirty = false
47let isRemoteRunning = false
48let lastRemoteAt = Number.NEGATIVE_INFINITY
49let login = ''
50let loginToken = ''
51const sentProjects = new Set<string>()
52const sentPullRequests = new Set<string>()
53const loggedErrors = new Set<string>()
54
55function logOnce($: EngineInterface, what: string, err: unknown) {
56 const message = `${what} failed: ${err instanceof Error ? err.message : String(err)}`
57 if (loggedErrors.has(message)) return
58 loggedErrors.add(message)
59 $.ui.log(`shelltime-statusline: ${message}`, { to: 'debug' })
60}
61
62function patch($: EngineInterface, fields: Partial<StatuslineView>) {
63 return update($, view, current => ({ ...(current ?? EMPTY_VIEW), ...fields }))
64}
65
66async function send($: EngineInterface, request: ApiRequest): Promise<ApiResponse> {
67 return $.http.fetch(request.url, request.init)
68}
69
70async function readConfigFile($: EngineInterface, dir: string, names: readonly string[]) {
71 for (const name of names) {
72 try {
73 return parseShellTimeConfig(await $.fs.read(`${dir}/${name}`), formatOf(name))
74 } catch {
75 // not there: try the next name
76 }
77 }
78 return {}
79}
80
81// The CLI's config, re-read on every remote refresh so `shelltime init` takes
82// effect without a restart.
83async function loadConfig($: EngineInterface): Promise<ShellTimeConfig> {
84 const home = await $.env.get('HOME')
85 if (home === undefined || home === '') return resolveConfig({})
86 const dir = `${home}/.shelltime`
87 const base = await readConfigFile($, dir, BASE_FILES)
88 const local = await readConfigFile($, dir, LOCAL_FILES)
89 return resolveConfig(mergeConfig(base, local))
90}
91
92// shelltime/cli daemon/git.go GetGitInfo: the same three git commands, with
93// optional locks off so the statusline never contends with the person's git.
94async function fetchGitInfo($: EngineInterface, cwd: string): Promise<GitInfo | null> {
95 const init = { env: { GIT_OPTIONAL_LOCKS: '0' }, timeoutMs: 2000 }
96 try {
97 const repo = await $.process.run(['git', '-C', cwd, 'rev-parse', '--git-dir'], init)
98 if (repo.exitCode !== 0) return null
99 const head = await $.process.run(['git', '-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], init)
100 const status = await $.process.run(['git', '-C', cwd, 'status', '--porcelain'], init)
101 return {
102 branch: head.exitCode === 0 ? head.stdout.trim() : '',
103 dirty: status.exitCode === 0 && status.stdout.trim() !== '',
104 }
105 } catch {
106 return null
107 }
108}
109
110async function sendSessionProject($: EngineInterface, config: ShellTimeConfig, sessionId: string, cwd: string) {
111 const project = `${sessionId}\n${cwd}`
112 if (sessionId === '' || cwd === '' || sentProjects.has(project)) return
113 sentProjects.add(project)
114 try {
115 checkOk(await send($, sessionProjectRequest(config, sessionId, cwd)))
116 } catch (err) {
117 sentProjects.delete(project)
118 logOnce($, 'session-project', err)
119 }
120}
121
122// The CLI as install.bash lays it out, then whatever PATH has: a desktop host's
123// PATH often lacks ~/.shelltime/bin.
124async function runShelltime($: EngineInterface, args: readonly string[]) {
125 const home = await $.env.get('HOME')
126 const bins = home === undefined || home === '' ? ['shelltime'] : [`${home}/.shelltime/bin/shelltime`, 'shelltime']
127 let notFound: unknown
128 for (const bin of bins) {
129 let ran
130 try {
131 ran = await $.process.run([bin, ...args], { timeoutMs: 10_000 })
132 } catch (err) {
133 // not there (or hung): try the next one
134 notFound = err
135 continue
136 }
137 if (ran.exitCode !== 0) {
138 throw new Error(`shelltime exited with ${ran.exitCode}: ${ran.stderr.trim()}`)
139 }
140 return
141 }
142 throw notFound
143}
144
145// Links PRs opened by `gh pr create` to the session: `shelltime cc pr` hands
146// them to the daemon, which sends them to ShellTime.
147async function linkPullRequests($: EngineInterface, sessionId: string, urls: readonly string[]) {
148 const fresh = urls.filter(url => !sentPullRequests.has(`${sessionId}\n${url}`))
149 if (sessionId === '' || fresh.length === 0) return
150 for (const url of fresh) sentPullRequests.add(`${sessionId}\n${url}`)
151 try {
152 await runShelltime($, ccPrArgs(sessionId, fresh))
153 } catch (err) {
154 for (const url of fresh) sentPullRequests.delete(`${sessionId}\n${url}`)
155 logOnce($, 'pull-request link', err)
156 }
157}
158
159// Daily cost, agent time and login from ShellTime's API.
160async function refreshRemote($: EngineInterface, sessionId: string, cwd: string) {
161 if (isRemoteRunning) return
162 const now = await $.clock.now()
163 const wait = lastRemoteAt + REMOTE_INTERVAL_MS - now
164 if (wait > 0) {
165 remoteTimer ??= $.clock.after(wait, () => {
166 remoteTimer = undefined
167 void refreshRemote($, sessionId, cwd)
168 })
169 return
170 }
171 isRemoteRunning = true
172 lastRemoteAt = now
173 try {
174 const config = await loadConfig($)
175 if (config.token === '') {
176 await patch($, { daily: null, login: '', webEndpoint: '' })
177 return
178 }
179
180 void sendSessionProject($, config, sessionId, cwd)
181
182 if (login === '' || loginToken !== config.token) {
183 try {
184 login = parseUserLogin(await send($, userProfileRequest(config)))
185 loginToken = config.token
186 } catch (err) {
187 logOnce($, 'user profile', err)
188 }
189 }
190
191 let daily: StatuslineView['daily'] | undefined
192 try {
193 daily = parseDailyStats(await send($, dailyStatsRequest(config, now)))
194 } catch (err) {
195 logOnce($, 'daily stats', err)
196 }
197
198 await patch($, {
199 ...(daily === undefined ? {} : { daily }),
200 login: loginToken === config.token ? login : '',
201 webEndpoint: config.webEndpoint,
202 })
203 } catch (err) {
204 logOnce($, 'remote refresh', err)
205 } finally {
206 isRemoteRunning = false
207 }
208}
209
210// What the engine already knows (cost, context, rate limits, model) and git.
211async function refreshLocal($: EngineInterface) {
212 if (isLocalRunning) {
213 isLocalDirty = true
214 return
215 }
216 isLocalRunning = true
217 try {
218 do {
219 isLocalDirty = false
220 const [usage, model, sessionId, cwd] = await Promise.all([
221 $.session.usage(),
222 $.session.model(),
223 $.session.id(),
224 $.session.cwd(),
225 ])
226 const percentOf = (kind: string) =>
227 usage.rateLimits.find(limit => limit.kind === kind)?.percentUsed ?? null
228 const git = await fetchGitInfo($, cwd)
229 await patch($, {
230 git,
231 model: displayModelName(model),
232 sessionCost: usage.cost?.usd ?? 0,
233 contextPercent: usage.context.percent ?? 0,
234 fiveHourPercent: percentOf('five_hour'),
235 sevenDayPercent: percentOf('seven_day'),
236 sessionId,
237 })
238 void refreshRemote($, sessionId, cwd)
239 } while (isLocalDirty)
240 } catch (err) {
241 logOnce($, 'refresh', err)
242 } finally {
243 isLocalRunning = false
244 }
245}
246
247function schedule($: EngineInterface) {
248 localTimer ??= $.clock.after(DEBOUNCE_MS, () => {
249 localTimer = undefined
250 void refreshLocal($)
251 })
252}
253
254export const register: Register = on => {
255 on('session.start', async ($, e, next) => {
256 const started = await next(e)
257 schedule($)
258 return started
259 })
260
261 on('prompt.submit', ($, e, next) => {
262 schedule($)
263 return next(e)
264 })
265
266 on('tool.call', async ($, e, next) => {
267 const result = await next(e)
268 schedule($)
269 return result
270 })
271
272 on('classic.PostToolUse', async ($, e, next) => {
273 const result = await next(e)
274 // In the background: the hook never holds up or changes the tool's result.
275 void linkPullRequests($, e.session_id, createdPullRequestUrls(e.tool_name, e.tool_input, e.tool_response))
276 return result
277 })
278
279 on('turn.complete', async ($, e, next) => {
280 const result = await next(e)
281 schedule($)
282 return result
283 })
284
285 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
286 const current = await read($, view)
287 if (current === null || e.props.hasSurvey) {
288 return next(e)
289 }
290
291 // The terminal keeps the CLI's line; graphical surfaces draw a flat row.
292 const segments = buildSegments(current)
293 return e.surface === 'terminal' ? (
294 <StatusLine ui={$.ui.resolve(e)} segments={segments} />
295 ) : (
296 <StatusRow ui={$.ui.resolve(e)} segments={segments} />
297 )
298 })
299}
300hooks/api.ts 107 lines1// The requests the Go statusline and its daemon send to ShellTime's API, built
2// and parsed here; register.tsx sends them over $.http.fetch.
3import type { DailyStats } from '../types'
4import type { ShellTimeConfig } from './config'
5
6export const USER_AGENT = 'shelltimeClaudeCodeMod@0.1.0'
7
8// shelltime/cli model/cc_statusline_types.go CCStatuslineDailyCostQuery
9export const DAILY_STATS_QUERY = `query fetchAICodeOtelAnalytics($filter: AICodeAnalyticsFilter!) {
10 fetchUser {
11 aiCodeOtel {
12 analytics(filter: $filter) {
13 totalCostUsd
14 totalSessionSeconds
15 }
16 }
17 }
18}`
19
20// shelltime/cli model/user_profile_service.go FetchCurrentUserProfileQuery
21export const USER_PROFILE_QUERY = `query fetchCurrentUserProfile {
22 fetchUser {
23 login
24 }
25}`
26
27export type ApiRequest = {
28 url: string
29 init: { method: string; headers: Record<string, string>; body: string }
30}
31
32export type ApiResponse = { ok: boolean; status: number; text: string }
33
34function post(config: ShellTimeConfig, path: string, payload: unknown): ApiRequest {
35 return {
36 url: `${config.apiEndpoint}${path}`,
37 init: {
38 method: 'POST',
39 headers: {
40 'Content-Type': 'application/json',
41 'User-Agent': USER_AGENT,
42 Authorization: `CLI ${config.token}`,
43 },
44 body: JSON.stringify(payload),
45 },
46 }
47}
48
49// RFC 3339 in UTC without milliseconds, as Go's time.RFC3339 writes it.
50export function rfc3339(ms: number): string {
51 return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
52}
53
54// Local midnight of the day `now` falls on: the daemon's "today" range.
55export function startOfLocalDay(now: number): number {
56 const d = new Date(now)
57 d.setHours(0, 0, 0, 0)
58 return d.getTime()
59}
60
61export function dailyStatsRequest(config: ShellTimeConfig, now: number): ApiRequest {
62 return post(config, '/api/v2/graphql', {
63 query: DAILY_STATS_QUERY,
64 variables: {
65 filter: { since: rfc3339(startOfLocalDay(now)), until: rfc3339(now), clientType: 'claude_code' },
66 },
67 })
68}
69
70export function userProfileRequest(config: ShellTimeConfig): ApiRequest {
71 return post(config, '/api/v2/graphql', { query: USER_PROFILE_QUERY })
72}
73
74// shelltime/cli model/api_session_project.go SendSessionProjectUpdate
75export function sessionProjectRequest(
76 config: ShellTimeConfig,
77 sessionId: string,
78 projectPath: string,
79): ApiRequest {
80 return post(config, '/api/v1/cc/session-project', { sessionId, projectPath })
81}
82
83function graphqlData<T>(res: ApiResponse): T {
84 if (!res.ok) throw new Error(`HTTP error: ${res.status}`)
85 const body = JSON.parse(res.text) as { data?: T; errors?: { message: string }[] }
86 const [first] = body.errors ?? []
87 if (first !== undefined) throw new Error(`GraphQL error: ${first.message}`)
88 if (body.data === undefined || body.data === null) throw new Error('GraphQL error: no data')
89 return body.data
90}
91
92export function parseDailyStats(res: ApiResponse): DailyStats {
93 type Data = {
94 fetchUser: { aiCodeOtel: { analytics: { totalCostUsd: number; totalSessionSeconds: number } } }
95 }
96 const { totalCostUsd, totalSessionSeconds } = graphqlData<Data>(res).fetchUser.aiCodeOtel.analytics
97 return { costUsd: totalCostUsd, sessionSeconds: totalSessionSeconds }
98}
99
100export function parseUserLogin(res: ApiResponse): string {
101 return graphqlData<{ fetchUser: { login: string } }>(res).fetchUser.login
102}
103
104export function checkOk(res: ApiResponse): void {
105 if (!res.ok && res.status !== 204) throw new Error(`HTTP error: ${res.status}`)
106}
107hooks/config.ts 89 lines1// The ShellTime CLI's own config file (~/.shelltime/config.*), the one
2// `shelltime init` writes, so the CLI and this mod share a single config.
3// Discovery and merge mirror shelltime/cli model/config.go; register.tsx
4// reads the files.
5export type ShellTimeConfig = {
6 token: string
7 apiEndpoint: string
8 webEndpoint: string
9}
10
11export type ConfigFormat = 'yaml' | 'toml'
12
13export const DEFAULT_API_ENDPOINT = 'https://api.shelltime.xyz'
14export const DEFAULT_WEB_ENDPOINT = 'https://shelltime.xyz'
15
16// findConfigFiles: the first existing base file and the first existing local
17// file, YAML before TOML; the local file overrides the base.
18export const BASE_FILES = ['config.yaml', 'config.yml', 'config.toml'] as const
19export const LOCAL_FILES = ['config.local.yaml', 'config.local.yml', 'config.local.toml'] as const
20
21const KEYS: Record<string, keyof ShellTimeConfig> = {
22 token: 'token',
23 apiendpoint: 'apiEndpoint',
24 webendpoint: 'webEndpoint',
25}
26
27export function formatOf(fileName: string): ConfigFormat {
28 return /\.ya?ml$/i.test(fileName) ? 'yaml' : 'toml'
29}
30
31function unquote(raw: string): string {
32 const value = raw.trim()
33 const quote = value.charAt(0)
34 if (quote === '"' || quote === "'") {
35 const end = value.indexOf(quote, 1)
36 if (end > 0) {
37 const inner = value.slice(1, end)
38 return quote === '"' ? inner.replace(/\\(["\\])/g, '$1') : inner
39 }
40 }
41 // An unquoted value ends at a comment.
42 return value.replace(/\s+#.*$/, '').trim()
43}
44
45// Only the three top-level keys the statusline needs are read. Nested keys
46// (an indented YAML key, a TOML key under a [table]) are other features'
47// settings, such as codeTracking's own token, and are skipped.
48export function parseShellTimeConfig(text: string, format: ConfigFormat): Partial<ShellTimeConfig> {
49 const config: Partial<ShellTimeConfig> = {}
50 for (const line of text.split(/\r?\n/)) {
51 if (format === 'toml') {
52 const trimmed = line.trim()
53 if (trimmed.startsWith('[')) break
54 const match = /^"?([A-Za-z_][\w-]*)"?\s*=\s*(.*)$/.exec(trimmed)
55 const key = match === null ? undefined : KEYS[(match[1] ?? '').toLowerCase()]
56 if (match !== null && key !== undefined) config[key] = unquote(match[2] ?? '')
57 continue
58 }
59 if (/^\s/.test(line) || line.startsWith('#')) continue
60 const match = /^"?([A-Za-z_][\w-]*)"?\s*:\s*(.*)$/.exec(line)
61 const key = match === null ? undefined : KEYS[(match[1] ?? '').toLowerCase()]
62 if (match !== null && key !== undefined) config[key] = unquote(match[2] ?? '')
63 }
64 return config
65}
66
67// mergeConfig: a non-empty local value overrides the base.
68export function mergeConfig(
69 base: Partial<ShellTimeConfig>,
70 local: Partial<ShellTimeConfig>,
71): Partial<ShellTimeConfig> {
72 const merged = { ...base }
73 for (const key of ['token', 'apiEndpoint', 'webEndpoint'] as const) {
74 const value = local[key]
75 if (value !== undefined && value !== '') merged[key] = value
76 }
77 return merged
78}
79
80export function resolveConfig(partial: Partial<ShellTimeConfig>): ShellTimeConfig {
81 const apiEndpoint = partial.apiEndpoint || DEFAULT_API_ENDPOINT
82 const webEndpoint = partial.webEndpoint ?? ''
83 return {
84 token: partial.token ?? '',
85 apiEndpoint: apiEndpoint.replace(/\/+$/, ''),
86 webEndpoint: (webEndpoint.startsWith('http') ? webEndpoint : DEFAULT_WEB_ENDPOINT).replace(/\/+$/, ''),
87 }
88}
89hooks/pullRequests.ts 35 lines1// Pull requests a Bash call opened with `gh pr create`, linked to the session
2// through `shelltime cc pr` (shelltime/cli commands/cc_pr.go), which hands them
3// to the daemon.
4
5const PR_CREATE = /\bgh\s+pr\s+create\b/
6const PR_URL = /https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+/g
7
8export function isPrCreateCommand(command: string): boolean {
9 return PR_CREATE.test(command)
10}
11
12// `gh pr create` prints the new PR's URL on stdout; a command chaining several
13// prints one each. Distinct URLs, in the order they were printed.
14export function extractPullRequestUrls(stdout: string): string[] {
15 return [...new Set(stdout.match(PR_URL) ?? [])]
16}
17
18function stringField(value: unknown, key: string): string {
19 if (typeof value !== 'object' || value === null) return ''
20 const field = (value as Record<string, unknown>)[key]
21 return typeof field === 'string' ? field : ''
22}
23
24// The URLs a PostToolUse event's Bash call printed when it ran `gh pr create`;
25// none for any other tool or command. stdout only: `gh` reports an existing PR
26// for the branch on stderr, and that one wasn't opened here.
27export function createdPullRequestUrls(toolName: string, toolInput: unknown, toolResponse: unknown): string[] {
28 if (toolName !== 'Bash' || !isPrCreateCommand(stringField(toolInput, 'command'))) return []
29 return extractPullRequestUrls(stringField(toolResponse, 'stdout'))
30}
31
32export function ccPrArgs(sessionId: string, urls: readonly string[]): string[] {
33 return ['cc', 'pr', '--session-id', sessionId, ...urls]
34}
35hooks/segments.ts 161 lines1// The statusline's segments, mirroring `formatStatuslineOutput` in
2// shelltime/cli commands/cc_statusline.go: same order, icons, colors,
3// thresholds and links. Surface-agnostic: hooks/ui draws them.
4import type { StatuslineView } from '../types'
5
6export type SegmentColor = 'green' | 'cyan' | 'yellow' | 'red' | 'magenta' | 'gray'
7
8// The colors a percentage takes.
9export type ThresholdColor = Extract<SegmentColor, 'green' | 'yellow' | 'red'>
10
11export type SegmentKey = 'git' | 'model' | 'sessionCost' | 'dailyCost' | 'quota' | 'agentTime' | 'context'
12
13// A percentage a graphical surface draws as a bar beside its figure.
14export type Meter = {
15 label?: string
16 percent: number
17 description: string
18}
19
20export type Segment = {
21 key: SegmentKey
22 icon: string
23 value: string
24 color?: SegmentColor
25 url?: string
26 meters?: readonly Meter[]
27}
28
29export const CLAUDE_USAGE_URL = 'https://claude.ai/settings/usage'
30
31export const SEPARATOR = ' | '
32
33// Same buckets as the Go statusline: red from 80%, yellow from 50%.
34export function thresholdColor(percent: number): ThresholdColor {
35 if (percent >= 80) return 'red'
36 if (percent >= 50) return 'yellow'
37 return 'green'
38}
39
40// formatSessionDuration: "1h5m", "2m3s", "45s".
41export function formatDuration(totalSeconds: number): string {
42 const seconds = Math.max(0, Math.floor(totalSeconds))
43 const h = Math.floor(seconds / 3600)
44 const m = Math.floor((seconds % 3600) / 60)
45 const s = seconds % 60
46 if (h > 0) return `${h}h${m}m`
47 if (m > 0) return `${m}m${s}s`
48 return `${s}s`
49}
50
51const MODEL_ID = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/i
52
53// Turns a model id into the name the native statusline shows
54// (`claude-opus-5-5` → `Opus 5.5`); anything else is shown as given.
55export function displayModelName(model: string): string {
56 const trimmed = model.trim()
57 const match = MODEL_ID.exec(trimmed)
58 if (match === null) return trimmed
59 const [, family = '', major = '', minor, longContext] = match
60 const name = family.charAt(0).toUpperCase() + family.slice(1).toLowerCase()
61 const version = minor === undefined ? major : `${major}.${minor}`
62 return `${name} ${version}${longContext === undefined ? '' : ' (1M context)'}`
63}
64
65// The segment as the Go statusline prints it: icon, space, value.
66export function segmentText(segment: Segment): string {
67 return `${segment.icon} ${segment.value}`
68}
69
70// A figure with nothing to show yet: a gray dash.
71function missing(key: SegmentKey, icon: string, url?: string): Segment {
72 return { key, icon, value: '-', color: 'gray', url }
73}
74
75export function quotaSegment(fiveHour: number | null, sevenDay: number | null): Segment {
76 if (fiveHour === null || sevenDay === null) {
77 return missing('quota', '🚦', CLAUDE_USAGE_URL)
78 }
79 return {
80 key: 'quota',
81 icon: '🚦',
82 value: `5h:${Math.round(fiveHour)}% 7d:${Math.round(sevenDay)}%`,
83 color: thresholdColor(Math.max(fiveHour, sevenDay)),
84 url: CLAUDE_USAGE_URL,
85 meters: [
86 { label: '5h', percent: fiveHour, description: '5-hour quota used' },
87 { label: '7d', percent: sevenDay, description: '7-day quota used' },
88 ],
89 }
90}
91
92export function buildSegments(v: StatuslineView): Segment[] {
93 const hasProfile = v.login !== '' && v.webEndpoint !== ''
94 const profileUrl = `${v.webEndpoint}/users/${v.login}`
95 const segments: Segment[] = []
96
97 // Git info first (green)
98 if (v.git !== null && v.git.branch !== '') {
99 segments.push({ key: 'git', icon: '🌿', value: `${v.git.branch}${v.git.dirty ? '*' : ''}`, color: 'green' })
100 } else {
101 segments.push(missing('git', '🌿'))
102 }
103
104 // Model name
105 segments.push({ key: 'model', icon: '🤖', value: v.model })
106
107 // Session cost (cyan), linked to the session page
108 segments.push({
109 key: 'sessionCost',
110 icon: '💰',
111 value: `$${v.sessionCost.toFixed(2)}`,
112 color: 'cyan',
113 url: hasProfile && v.sessionId !== '' ? `${profileUrl}/coding-agent/session/${v.sessionId}` : undefined,
114 })
115
116 // Daily cost (yellow), linked to the coding agent page
117 if (v.daily !== null && v.daily.costUsd > 0) {
118 segments.push({
119 key: 'dailyCost',
120 icon: '📊',
121 value: `$${v.daily.costUsd.toFixed(2)}`,
122 color: 'yellow',
123 url: hasProfile ? `${profileUrl}/coding-agent/claude-code` : undefined,
124 })
125 } else {
126 segments.push(missing('dailyCost', '📊'))
127 }
128
129 // Quota utilization, linked to claude.ai usage
130 segments.push(quotaSegment(v.fiveHourPercent, v.sevenDayPercent))
131
132 // AI agent time (magenta), linked to the user profile
133 if (v.daily !== null && v.daily.sessionSeconds > 0) {
134 segments.push({
135 key: 'agentTime',
136 icon: '⏱️',
137 value: formatDuration(v.daily.sessionSeconds),
138 color: 'magenta',
139 url: hasProfile ? profileUrl : undefined,
140 })
141 } else {
142 segments.push(missing('agentTime', '⏱️'))
143 }
144
145 // Context percentage with color coding
146 segments.push({
147 key: 'context',
148 icon: '📈',
149 value: `${Math.round(v.contextPercent)}%`,
150 color: thresholdColor(v.contextPercent),
151 meters: [{ percent: v.contextPercent, description: 'Context window used' }],
152 })
153
154 return segments
155}
156
157// The whole line as plain text, as the Go statusline prints it without colors.
158export function formatPlain(segments: readonly Segment[]): string {
159 return segments.map(segmentText).join(SEPARATOR)
160}
161hooks/ui/desktop.tsx 111 lines1// The desktop's drawing: one flat row, no borders or padding, each segment
2// its icon and figure in a tone that reads on the band's light and dark
3// backgrounds, and a thin bar beside each percentage.
4import type { ElementTable, TextProps } from 'claude-code'
5
6import { thresholdColor } from '../segments'
7import type { Meter, Segment, SegmentColor, SegmentKey } from '../segments'
8import { LinkedText } from './common'
9
10export type GraphicalElements = Pick<ElementTable<'desktop'>, 'Box' | 'Text' | 'Link' | 'Svg'>
11
12// The composer's footer already names the model.
13export const HIDDEN_ON_DESKTOP: readonly SegmentKey[] = ['model']
14
15// The terminal's ANSI names as Claude-palette mid tones with contrast on
16// either theme; gray stays the surface's own dim. A figure and its bar share one.
17export const DESKTOP_TONES: Record<Exclude<SegmentColor, 'gray'>, string> = {
18 green: '#3f9b57',
19 cyan: '#3e8fb0',
20 yellow: '#b88016',
21 red: '#d1454f',
22 magenta: '#9b6bd3',
23}
24
25const METER_WIDTH = 28
26const METER_HEIGHT = 4
27
28function toneStyle(color: SegmentColor | undefined): TextProps {
29 if (color === undefined) return {}
30 return color === 'gray' ? { dimColor: true } : { color: DESKTOP_TONES[color] }
31}
32
33// A rounded track, filled to `percent` (clamped to 0–100) in `color`, any
34// use at all at least a dot; the track's translucent gray sits on either theme.
35export function meterSvg(percent: number, color: string): string {
36 const clamped = Math.min(100, Math.max(0, percent))
37 const fill = clamped === 0 ? 0 : Math.max(METER_HEIGHT, Math.round((clamped / 100) * METER_WIDTH))
38 const radius = METER_HEIGHT / 2
39 return [
40 `<svg xmlns="http://www.w3.org/2000/svg" width="${METER_WIDTH}" height="${METER_HEIGHT}" viewBox="0 0 ${METER_WIDTH} ${METER_HEIGHT}">`,
41 `<rect width="${METER_WIDTH}" height="${METER_HEIGHT}" rx="${radius}" fill="#808080" fill-opacity="0.25"/>`,
42 fill > 0 ? `<rect width="${fill}" height="${METER_HEIGHT}" rx="${radius}" fill="${color}"/>` : '',
43 '</svg>',
44 ].join('')
45}
46
47type FigureProps = { ui: GraphicalElements; url?: string }
48
49// In the segment's tone, underlined under the pointer when it links.
50function figureStyle(color: SegmentColor | undefined, url: string | undefined): TextProps {
51 return url === undefined ? toneStyle(color) : { ...toneStyle(color), hover: { underline: true } }
52}
53
54// `5h ━━── 23%`: the label, the bar and the figure, in the meter's own
55// threshold color.
56function MeterBar({ ui, url, meter }: FigureProps & { meter: Meter }) {
57 const { Box, Svg, Text } = ui
58 const percent = Math.round(meter.percent)
59 const color = thresholdColor(meter.percent)
60 return (
61 <Box flexDirection="row" alignItems="center" columnGap={1}>
62 {meter.label === undefined ? null : <Text dimColor>{meter.label}</Text>}
63 <Svg
64 source={meterSvg(meter.percent, DESKTOP_TONES[color])}
65 alt={`${meter.description}: ${percent}%`}
66 width={METER_WIDTH}
67 height={METER_HEIGHT}
68 />
69 <LinkedText ui={ui} text={`${percent}%`} url={url} style={figureStyle(color, url)} />
70 </Box>
71 )
72}
73
74// The figure a segment shows: its bars when it has percentages, else its value.
75function Figure({ ui, url, segment }: FigureProps & { segment: Segment }) {
76 const { Box } = ui
77 if (segment.meters === undefined) {
78 return <LinkedText ui={ui} text={segment.value} url={url} style={figureStyle(segment.color, url)} />
79 }
80 return (
81 <Box flexDirection="row" alignItems="center" columnGap={2}>
82 {segment.meters.map(meter => (
83 <MeterBar ui={ui} url={url} meter={meter} />
84 ))}
85 </Box>
86 )
87}
88
89// One segment: its icon and figure, keyed so a hover underlines its link alone.
90function SegmentItem({ ui, segment }: { ui: GraphicalElements; segment: Segment }) {
91 const { Box, Text } = ui
92 return (
93 <Box key={`segment-${segment.key}`} flexDirection="row" alignItems="center" columnGap={1}>
94 <Text>{segment.icon}</Text>
95 <Figure ui={ui} url={segment.url} segment={segment} />
96 </Box>
97 )
98}
99
100export function StatusRow({ ui, segments }: { ui: GraphicalElements; segments: readonly Segment[] }) {
101 const { Box } = ui
102 const shown = segments.filter(segment => !HIDDEN_ON_DESKTOP.includes(segment.key))
103 return (
104 <Box key="shelltime-statusline" flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={2}>
105 {shown.map(segment => (
106 <SegmentItem ui={ui} segment={segment} />
107 ))}
108 </Box>
109 )
110}
111hooks/ui/terminal.tsx 39 lines1// The terminal's drawing: the CLI's own line, `🌿 main | 🤖 Opus 5.5 | …`,
2// in Claude Code's theme colors, wrapping between segments.
3import type { TextProps } from 'claude-code'
4
5import { SEPARATOR, segmentText } from '../segments'
6import type { Segment, SegmentColor } from '../segments'
7import { LinkedText } from './common'
8import type { BaseElements } from './common'
9
10// Each ANSI name as the theme key the ANSI themes draw in exactly that color,
11// so the line follows `/theme` and still matches the CLI under an ANSI theme.
12export const TERMINAL_COLORS: Record<Exclude<SegmentColor, 'gray'>, string> = {
13 green: 'success',
14 yellow: 'warning',
15 red: 'error',
16 cyan: 'planMode',
17 magenta: 'merged',
18}
19
20// Gray is the terminal's dim, which follows its theme; no color is the default.
21function toneStyle(color: SegmentColor | undefined): TextProps {
22 if (color === undefined) return {}
23 return color === 'gray' ? { dimColor: true } : { color: TERMINAL_COLORS[color] }
24}
25
26export function StatusLine({ ui, segments }: { ui: BaseElements; segments: readonly Segment[] }) {
27 const { Box, Text } = ui
28 const children = segments.flatMap((segment, i) => {
29 const drawn = <LinkedText ui={ui} text={segmentText(segment)} url={segment.url} style={toneStyle(segment.color)} />
30 return i === 0 ? [drawn] : [<Text dimColor>{SEPARATOR}</Text>, drawn]
31 })
32
33 return (
34 <Box key="shelltime-statusline" flexDirection="row" flexWrap="wrap">
35 {children}
36 </Box>
37 )
38}
39hooks/ui/common.tsx 20 lines1// What both drawings of the statusline share.
2import type { ElementTable, TextProps } from 'claude-code'
3
4// The elements every surface that raises the band draws with.
5export type BaseElements = Pick<ElementTable, 'Box' | 'Text' | 'Link'>
6
7type LinkedTextProps = {
8 ui: BaseElements
9 text: string
10 url?: string
11 style?: TextProps
12}
13
14// One styled run of text, as a link when it has somewhere to go.
15export function LinkedText({ ui, text, url, style }: LinkedTextProps) {
16 const { Link, Text } = ui
17 const label = <Text {...style}>{text}</Text>
18 return url === undefined ? label : <Link href={url}>{label}</Link>
19}
20types/index.d.ts 23 lines1export type GitInfo = { branch: string; dirty: boolean }
2
3export type DailyStats = { costUsd: number; sessionSeconds: number }
4
5export type StatuslineView = {
6 git: GitInfo | null
7 model: string
8 sessionCost: number
9 daily: DailyStats | null
10 fiveHourPercent: number | null
11 sevenDayPercent: number | null
12 contextPercent: number
13 login: string
14 webEndpoint: string
15 sessionId: string
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'shelltime-statusline': { view: StatuslineView | null }
21 }
22}
23