SLOPSHOPPER

shelltime-statusline

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…

newbandguardpromptprocessnetwork
A shopper browsing a rack in a slop shop
README

shelltime-statusline

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%
SegmentShowsColorLink
🌿Git branch, with * when the tree is dirtygreen, gray - outside a repo
🤖Model (terminal only)
💰This session's costcyanthe session on shelltime.xyz
📊Today's Claude Code costyellow (amber on desktop), gray - with noneyour coding agent page
🚦5-hour and 7-day quota usedgreen, yellow from 50%, red from 80%; on desktop each bucket has its own bar and colorclaude.ai usage
⏱️Today's AI agent timemagenta, gray - with noneyour profile
📈Context window used, with a bar on desktopgreen, yellow from 50%, red from 80%

Install

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.

Configuration

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.

Where the numbers come from

shelltime cc statuslinethis mod
gitthe daemon runs gitruns the same git commands (GIT_OPTIONAL_LOCKS=0)
model, session cost, contextClaude Code's statusline JSONthe same figures, from Claude Code directly
quotathe daemon calls Anthropic's usage API with the OAuth token from the Keychainthe rate limits Claude Code already read from its last API response. No Keychain access, works on any OS
daily cost, agent timethe daemon queries ShellTime's APIthe same GraphQL query, at most once every 15 s
session → project mappingsent to ShellTime's APIthe same request, once per session and directory
session → pull requestsshelltime 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.

Pull request links

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.

  • The mod looks for ~/.shelltime/bin/shelltime first, then shelltime on PATH.
  • Each URL is sent once per session. If the CLI can't be started, the next gh pr create that prints the URL tries again, and the failure goes to Claude Code's debug log.
  • If the CLI can't be found, or is too old to have cc pr, nothing is linked. Errors from the CLI itself (not logged in, server unreachable) go to ~/.shelltime/log.log.
  • This runs from a PostToolUse hook, in the background, after the tool returns. It never delays or changes the Bash result Claude sees.

Session cost comment

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 session's cost in USD
  • tokens: total, input, output, cache read and cache write
  • duration and active time
  • the model
  • prompts and lines changed
  • a link to the session on shelltime.xyz, which only you can open

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.

  • If you turned off showing your AI cost publicly on shelltime.xyz, the comment leaves out the USD amounts.
  • Delete the comment and it is not posted again.
  • Only PRs opened by a GitHub account linked to your ShellTime account get a comment. If you signed in to ShellTime without GitHub, link your GitHub account first.
  • Without the app on the repository, nothing is posted.
  • Nothing is posted for GitHub Enterprise hosts.

The mod and the CLI do nothing extra for this: ShellTime's server posts the comment once the PR is linked.

Terminal

If ~/.claude/settings.json also has a statusLine running shelltime cc statusline, the terminal shows both lines. Keep both, or remove one.

Source 9 files
hooks/register.tsx 300 lines
1import { 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}
300
hooks/api.ts 107 lines
1// 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}
107
hooks/config.ts 89 lines
1// 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}
89
hooks/pullRequests.ts 35 lines
1// 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}
35
hooks/segments.ts 161 lines
1// 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}
161
hooks/ui/desktop.tsx 111 lines
1// 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}
111
hooks/ui/terminal.tsx 39 lines
1// 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}
39
hooks/ui/common.tsx 20 lines
1// 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}
20
types/index.d.ts 23 lines
1export 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