Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,17 @@ jobs:
for dir in plugins/*/; do
claude plugin test "$dir"
done

ui:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: lts/*
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm test
- run: pnpm build-storybook
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ plugins/*/tsconfig.json

node_modules/
.DS_Store

storybook-static/
12 changes: 12 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { StorybookConfig } from '@storybook/react-vite'

import { hooksJsx } from '../tooling/engine/hooks-jsx.ts'

const config: StorybookConfig = {
framework: '@storybook/react-vite',
stories: ['../stories/**/*.stories.tsx'],
core: { disableTelemetry: true },
viteFinal: config => ({ ...config, plugins: [hooksJsx(), ...(config.plugins ?? [])] }),
}

export default config
38 changes: 38 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Preview } from '@storybook/react-vite'

import { ThemeContext } from '../tooling/engine/Surface'
import type { Theme } from '../tooling/engine/Surface'
import { installRuntime } from '../tooling/engine/runtime'

installRuntime()

// The page around the stories: Claude's warm neutrals.
const PAGE: Record<Theme, { background: string; color: string }> = {
light: { background: '#f0eee6', color: '#3d3d3a' },
dark: { background: '#1f1e1d', color: '#c2c0b6' },
}

const preview: Preview = {
globalTypes: {
theme: {
description: 'Light or dark surface',
toolbar: { title: 'Theme', icon: 'mirror', items: ['light', 'dark'], dynamicTitle: true },
},
},
initialGlobals: { theme: 'dark' },
parameters: { layout: 'fullscreen' },
decorators: [
(Story, context) => {
const theme = (context.globals.theme ?? 'dark') as Theme
return (
<ThemeContext.Provider value={theme}>
<div style={{ ...PAGE[theme], padding: 24, minHeight: '100vh', boxSizing: 'border-box' }}>
<Story />
</div>
</ThemeContext.Provider>
)
},
],
}

export default preview
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ claude --plugin-dir plugins/shelltime-statusline

The engine writes the API types beside a loaded mod in `.claude-plugin/types/`, along with a `tsconfig.json`, so `tsc -p plugins/shelltime-statusline` type-checks it.

### UI preview and tests

The mods' UI can be previewed in [Storybook](https://storybook.js.org) and tested with [Vitest](https://vitest.dev), outside Claude Code. The tooling lives at the repo root, so installed plugins ship none of it.

```sh
pnpm install

# every surface in light and dark, at http://localhost:6006
pnpm storybook

# the drawings' trees, and every story rendered
pnpm test
pnpm typecheck
```

`tooling/engine` stands in for the engine. It provides the JSX runtime (`h`) and the element tables, which produce the same plain-data trees the engine checks, plus an approximate paint of those trees for Storybook. The preview is close to the real thing, not identical: `claude plugin test` stays the check of what the engine accepts. Stories are in `stories/`, specs in `tests/`.

## Release

Commits follow [Conventional Commits](https://www.conventionalcommits.org). On `main`, [release-please](https://github.com/googleapis/release-please) keeps a release PR open. Merging it tags `vX.Y.Z`, updates `CHANGELOG.md` and bumps the version in each mod's `plugin.json`, which is what tells installed copies to update.
Expand Down
26 changes: 26 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "claude-code-mods",
"private": true,
"type": "module",
"packageManager": "pnpm@12.0.0",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc -p .",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"devDependencies": {
"@storybook/react-vite": "^10.6.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"jsdom": "^30.1.1",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"storybook": "^10.6.1",
"typescript": "^7.0.2",
"vite": "^8.3.2",
"vitest": "^5.0.3"
}
}
8 changes: 3 additions & 5 deletions plugins/shelltime-statusline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,16 @@

The [ShellTime](https://shelltime.xyz) 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 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 each segment is a rounded pill, in tones that read on the light and the dark theme. Quota and context get a small bar beside each percentage. The model is left out there, since the composer's footer already shows it.
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% │
╰─────────╯ ╰─────────╯ ╰───────────╯ ╰───────────────────────────╯ ╰─────────╯ ╰──────────────╯
🌿 main* 💰 $1.23 📊 $12.50 🚦 5h ━━── 23% 7d ━━━─ 45% ⏱️ 1h5m 📈 ━━── 42%
```

| Segment | Shows | Color | Link |
Expand Down
8 changes: 4 additions & 4 deletions plugins/shelltime-statusline/hooks/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,9 @@ test('draws the full statusline, with links, on terminal and desktop', async ($,

const desktop = await mountBand($, 'desktop')
for (const key of ['git', 'sessionCost', 'dailyCost', 'quota', 'agentTime', 'context']) {
expect(await desktop.find({ key: `pill-${key}` }), key).toBeDefined()
expect(await desktop.find({ key: `segment-${key}` }), key).toBeDefined()
}
expect(await desktop.find({ key: 'pill-model' })).toBeUndefined()
expect(await desktop.find({ key: 'segment-model' })).toBeUndefined()
expect(await desktop.find({ type: 'Text', text: 'Opus 5.5' })).toBeUndefined()
for (const text of ['main*', '$1.23', '$12.50', '1h5m', '23%', '45%', '42%']) {
expect(await desktop.find({ type: 'Text', text }), text).toBeDefined()
Expand Down Expand Up @@ -151,8 +151,8 @@ test('without a ShellTime token: local figures only, no API calls', async ($, on

const desktop = await mountBand($, 'desktop')
expect(await desktop.find({ type: 'Text', text: '$1.23' })).toBeDefined()
expect((await desktop.find({ key: 'pill-dailyCost' }))?.text).toBe('📊-')
expect((await desktop.find({ key: 'pill-agentTime' }))?.text).toBe('⏱️-')
expect((await desktop.find({ key: 'segment-dailyCost' }))?.text).toBe('📊-')
expect((await desktop.find({ key: 'segment-agentTime' }))?.text).toBe('⏱️-')
expect((await desktop.findAll({ type: 'Link' })).map(link => link.props.href)).toEqual([USAGE_URL, USAGE_URL])
await desktop.unmount()
})
Expand Down
6 changes: 3 additions & 3 deletions plugins/shelltime-statusline/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { ApiRequest, ApiResponse } from './api'
import { BASE_FILES, LOCAL_FILES, formatOf, mergeConfig, parseShellTimeConfig, resolveConfig } from './config'
import type { ShellTimeConfig } from './config'
import { buildSegments, displayModelName } from './segments'
import { StatusPills } from './ui/desktop'
import { StatusRow } from './ui/desktop'
import { StatusLine } from './ui/terminal'

const view = atom({ plugin: 'shelltime-statusline', key: 'view' } as const, null)
Expand Down Expand Up @@ -242,12 +242,12 @@ export const register: Register = on => {
return next(e)
}

// The terminal keeps the CLI's line; graphical surfaces draw pills.
// The terminal keeps the CLI's line; graphical surfaces draw a flat row.
const segments = buildSegments(current)
return e.surface === 'terminal' ? (
<StatusLine ui={$.ui.resolve(e)} segments={segments} />
) : (
<StatusPills ui={$.ui.resolve(e)} segments={segments} />
<StatusRow ui={$.ui.resolve(e)} segments={segments} />
)
})
}
18 changes: 0 additions & 18 deletions plugins/shelltime-statusline/hooks/segments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
quotaSegment,
segmentText,
} from './segments'
import { DESKTOP_TONES, meterSvg } from './ui/desktop'

const VIEW: StatuslineView = {
git: { branch: 'main', dirty: false },
Expand Down Expand Up @@ -136,23 +135,6 @@ describe('quotaSegment', () => {
})
})

describe('meterSvg', () => {
const fillWidth = (svg: string) => /<rect width="(\d+)" height="6" rx="3" fill="#[0-9a-f]{6}"\/>/.exec(svg)?.[1]

test('fills its share of the track in the given color', () => {
const svg = meterSvg(50, DESKTOP_TONES.yellow)
expect(fillWidth(svg)).toBe('16')
expect(svg).toContain('fill="#d97706"')
})

test('clamped to the track; any use at all shows at least a dot', () => {
expect(fillWidth(meterSvg(150, DESKTOP_TONES.red))).toBe('32')
expect(fillWidth(meterSvg(1, DESKTOP_TONES.green))).toBe('6')
expect(fillWidth(meterSvg(0, DESKTOP_TONES.green))).toBeUndefined()
expect(fillWidth(meterSvg(-5, DESKTOP_TONES.green))).toBeUndefined()
})
})

describe('formatDuration', () => {
test('hours and minutes, minutes and seconds, seconds', () => {
expect(formatDuration(3900)).toBe('1h5m')
Expand Down
62 changes: 24 additions & 38 deletions plugins/shelltime-statusline/hooks/ui/desktop.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// The desktop's drawing: one rounded pill per segment, its figure in a tone
// that reads on the band's light and dark backgrounds, and a bar beside each
// percentage.
// The desktop's drawing: one flat row, no borders or padding, each segment
// its icon and figure in a tone that reads on the band's light and dark
// backgrounds, and a thin bar beside each percentage.
import type { ElementTable, TextProps } from 'claude-code'

import { thresholdColor } from '../segments'
Expand All @@ -12,26 +12,22 @@ export type GraphicalElements = Pick<ElementTable<'desktop'>, 'Box' | 'Text' | '
// The composer's footer already names the model.
export const HIDDEN_ON_DESKTOP: readonly SegmentKey[] = ['model']

// The terminal's ANSI names as mid tones with contrast on either theme;
// gray stays the surface's own dim.
// The terminal's ANSI names as Claude-palette mid tones with contrast on
// either theme; gray stays the surface's own dim. A figure and its bar share one.
export const DESKTOP_TONES: Record<Exclude<SegmentColor, 'gray'>, string> = {
green: '#16a34a',
cyan: '#0891b2',
yellow: '#d97706',
red: '#dc2626',
magenta: '#c026d3',
green: '#3f9b57',
cyan: '#3e8fb0',
yellow: '#b88016',
red: '#d1454f',
magenta: '#9b6bd3',
}

const METER_WIDTH = 32
const METER_HEIGHT = 6

function toneOf(color: SegmentColor | undefined): string | undefined {
return color === undefined || color === 'gray' ? undefined : DESKTOP_TONES[color]
}
const METER_WIDTH = 28
const METER_HEIGHT = 4

function toneStyle(color: SegmentColor | undefined): TextProps {
if (color === 'gray') return { dimColor: true }
return { color: toneOf(color) }
if (color === undefined) return {}
return color === 'gray' ? { dimColor: true } : { color: DESKTOP_TONES[color] }
}

// A rounded track, filled to `percent` (clamped to 0–100) in `color`, any
Expand All @@ -42,17 +38,17 @@ export function meterSvg(percent: number, color: string): string {
const radius = METER_HEIGHT / 2
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${METER_WIDTH}" height="${METER_HEIGHT}" viewBox="0 0 ${METER_WIDTH} ${METER_HEIGHT}">`,
`<rect width="${METER_WIDTH}" height="${METER_HEIGHT}" rx="${radius}" fill="#808080" fill-opacity="0.3"/>`,
`<rect width="${METER_WIDTH}" height="${METER_HEIGHT}" rx="${radius}" fill="#808080" fill-opacity="0.25"/>`,
fill > 0 ? `<rect width="${fill}" height="${METER_HEIGHT}" rx="${radius}" fill="${color}"/>` : '',
'</svg>',
].join('')
}

type FigureProps = { ui: GraphicalElements; url?: string }

// Bold, in the segment's tone, underlined under the pointer when it links.
// In the segment's tone, underlined under the pointer when it links.
function figureStyle(color: SegmentColor | undefined, url: string | undefined): TextProps {
return { ...toneStyle(color), bold: true, hover: url === undefined ? undefined : { underline: true } }
return url === undefined ? toneStyle(color) : { ...toneStyle(color), hover: { underline: true } }
}

// `5h ━━── 23%`: the label, the bar and the figure, in the meter's own
Expand All @@ -75,7 +71,7 @@ function MeterBar({ ui, url, meter }: FigureProps & { meter: Meter }) {
)
}

// The figure a pill shows: its bars when it has percentages, else its value.
// The figure a segment shows: its bars when it has percentages, else its value.
function Figure({ ui, url, segment }: FigureProps & { segment: Segment }) {
const { Box } = ui
if (segment.meters === undefined) {
Expand All @@ -90,34 +86,24 @@ function Figure({ ui, url, segment }: FigureProps & { segment: Segment }) {
)
}

// One segment in a rounded border of its tone, brightening under the pointer.
function Pill({ ui, segment }: { ui: GraphicalElements; segment: Segment }) {
// One segment: its icon and figure, keyed so a hover underlines its link alone.
function SegmentItem({ ui, segment }: { ui: GraphicalElements; segment: Segment }) {
const { Box, Text } = ui
return (
<Box
key={`pill-${segment.key}`}
flexDirection="row"
alignItems="center"
columnGap={1}
paddingX={1}
borderStyle="round"
borderColor={toneOf(segment.color)}
borderDimColor
hover={{ borderDimColor: false }}
>
<Box key={`segment-${segment.key}`} flexDirection="row" alignItems="center" columnGap={1}>
<Text>{segment.icon}</Text>
<Figure ui={ui} url={segment.url} segment={segment} />
</Box>
)
}

export function StatusPills({ ui, segments }: { ui: GraphicalElements; segments: readonly Segment[] }) {
export function StatusRow({ ui, segments }: { ui: GraphicalElements; segments: readonly Segment[] }) {
const { Box } = ui
const shown = segments.filter(segment => !HIDDEN_ON_DESKTOP.includes(segment.key))
return (
<Box key="shelltime-statusline" flexDirection="row" flexWrap="wrap" columnGap={1}>
<Box key="shelltime-statusline" flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={2}>
{shown.map(segment => (
<Pill ui={ui} segment={segment} />
<SegmentItem ui={ui} segment={segment} />
))}
</Box>
)
Expand Down
14 changes: 12 additions & 2 deletions plugins/shelltime-statusline/hooks/ui/terminal.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
// The terminal's drawing: the CLI's own line, `🌿 main | 🤖 Opus 5.5 | …`,
// in its ANSI colors, wrapping between segments.
// in Claude Code's theme colors, wrapping between segments.
import type { TextProps } from 'claude-code'

import { SEPARATOR, segmentText } from '../segments'
import type { Segment, SegmentColor } from '../segments'
import { LinkedText } from './common'
import type { BaseElements } from './common'

// Each ANSI name as the theme key the ANSI themes draw in exactly that color,
// so the line follows `/theme` and still matches the CLI under an ANSI theme.
export const TERMINAL_COLORS: Record<Exclude<SegmentColor, 'gray'>, string> = {
green: 'success',
yellow: 'warning',
red: 'error',
cyan: 'planMode',
magenta: 'merged',
}

// Gray is the terminal's dim, which follows its theme; no color is the default.
function toneStyle(color: SegmentColor | undefined): TextProps {
if (color === undefined) return {}
return color === 'gray' ? { dimColor: true } : { color }
return color === 'gray' ? { dimColor: true } : { color: TERMINAL_COLORS[color] }
}

export function StatusLine({ ui, segments }: { ui: BaseElements; segments: readonly Segment[] }) {
Expand Down
Loading
Loading