/music: what macOS Music.app is playing, in one line above the prompt. A pressable play/pause glyph, track · artist · album, a progress bar with the clocks…

/musicfor Claude Code: what Music.app is playing, in one line above the prompt, live, with play/pause and next a click away.
claude-code · mod · function-hooks · macos · music · now-playing
▶️ Tipsy · WANUKA · Greenhorn ████████████░░░░░░░░ 2:43 / 3:39 ⏭️
<sub>The band directly above the prompt, in any layout. Its clock ticks every second while shown; ▶️ and ⏭️ are clickable.</sub>
Long sessions have a soundtrack, and reaching for Music.app to check what is on breaks the flow. This mod keeps the answer one command away, inside the terminal you are already looking at: the track, where it is, and two glyphs to pause it or skip it.
It is a Claude Code mod: a plugin whose behaviour lives in a TypeScript hooks module, written against the same engine API as the built-in /diff pane. It draws into the AbovePrompt band, so it sits above the input whether the transcript is fullscreen or inline, and never takes a side dock. No shell hooks, no MCP server, and one osascript call every five seconds however many sessions are open.
Function hooks are early access, so the engine loads a mod only with the flag on. Put it in your shell profile:
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
Then install it as a plugin, from GitHub or from a local clone:
/plugin marketplace add zyx1121/music-mod
/plugin install music-mod@music-mod
Or try it for one session without installing:
claude --plugin-dir /path/to/music-mod
The line shows as soon as an interactive session starts. In the session:
/music hide it, or show it again
Your last choice is remembered between sessions.
macOS only: it reads Music.app through Apple events. The first read may ask you to allow your terminal to control Music.
Reading never launches Music.app, not even when a read lands while you quit it: the events go to the running process by its ID, and a process that has gone reads as closed. Only a click on the state glyph or ⏭️ talks to Music.app by name.
One line, left to right:
| Part | Content |
|---|---|
▶️ ⏸️ ⏩ ⏪ | The player state. Click it to play or pause. |
| Title | track · artist · album, cut with an ellipsis when the band is narrow |
| Bar | Progress, stretched to fill whatever width the title leaves (6 cells at the least), then position / duration |
⏭️ | Click it to skip to the next track. |
When Music.app is closed or stopped the band says so instead. When osascript fails (not authorized, timed out), the band shows the reason and keeps polling. A survey that takes the band is yielded to.
| Field | Type | Default | What it does |
|---|---|---|---|
refreshMs | number | 5000 | Milliseconds between reads of Music.app while the band is shown. Every open session shares one read per interval, and the clock ticks in between. Floored at 1000. |
showOnStart | boolean | true | Show the line as soon as an interactive session starts. Once you have used /music, that choice wins over this. |
Set it as any plugin userConfig field: /config, or music-mod.refreshMs in settings.
hooks/register.ts exports register(on, options). On session.start it registers /music and shows the band (the last /music choice from $.store, else showOnStart); command.run toggles it and remembers; a one-second $.clock.every tick redraws the clock from the last reading and, once refreshMs has passed (or the track has run out), takes a new one; the timer is cancelled on session.end; a pressed glyph runs its AppleScript and reads at once.hooks/now-playing.ts holds the JXA script osascript -l JavaScript runs, the parser that turns its JSON into a Model, the clock and progress-bar formatters, the position carried forward between reads, and the one-line AppleScript behind each glyph. The script asks only for what the band draws: no system volume (reading it wakes coreaudiod) and no playlist walk.hooks/shared-read.ts is the reading every session shares: a small JSON file at $TMPDIR/music-mod/now.json. A session takes the reading there when it is recent enough, else claims the file, runs osascript and writes the result back, so the other sessions wait for it instead of running their own.hooks/views/band-view.tsx draws the Model with the engine's Box, Text and Button on ui.render for the AbovePrompt component.types/claude-code.d.ts is the engine contract this mod is typed against, copied from anthropics/claude-code/mods/types.bunx -p typescript tsc -p tsconfig.json # typecheck
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test . # 35 tests, the engine's own harness
The API these mods are written against may change between releases without notice. When it does, refresh types/claude-code.d.ts from upstream and let the typecheck point at what moved.
Issues and PRs are welcome. Ground rules live in CONTRIBUTING.md.
MIT · now playing: whatever you left on
hooks/register.tsx 294 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { EngineInterface, On, PluginOptions, Timer } from 'claude-code'
5
6import { ARGV, controlArgvOf, hasEnded, modelAt, modelOf, READ_TIMEOUT_MS, type Control, type Model } from './now-playing'
7import { cachePathOf, entryOf, isFresh, type Entry } from './shared-read'
8import { bandView } from './views/band-view'
9
10export const COMMAND_NAME = 'music'
11export const DEFAULT_REFRESH_MS = 5000
12export const MIN_REFRESH_MS = 1000
13
14/** How often the band redraws its clock while playing; no read happens on a tick. */
15export const TICK_MS = 1000
16
17/** At a track's end, a reading this recent (another session's) is still taken. */
18export const END_MAX_AGE_MS = 1000
19
20export const SHOWN_TEXT = 'Music shown above the prompt'
21export const HIDDEN_TEXT = 'Music hidden'
22
23/** The store key under which the last /music choice is kept between sessions. */
24export const STORE_SHOWN_KEY = 'shown'
25
26/**
27 * The refresh interval the options ask for, floored at MIN_REFRESH_MS.
28 *
29 * @param options the plugin's userConfig values
30 * @returns milliseconds between reads
31 */
32export function refreshMsOf(options: PluginOptions): number {
33 const value = options.refreshMs
34
35 return typeof value === 'number' && Number.isFinite(value)
36 ? Math.max(MIN_REFRESH_MS, Math.floor(value))
37 : DEFAULT_REFRESH_MS
38}
39
40type Host = {
41 run: (
42 argv: readonly string[],
43 init: { timeoutMs: number },
44 ) => Promise<{ exitCode: number; stdout: string; stderr: string }>
45 invalidate: () => void
46 every: (ms: number, fn: () => void) => Timer
47 now: () => Promise<number>
48 readShared: () => Promise<Entry | null>
49 writeShared: (entry: Entry) => Promise<void>
50 storeGet: (key: string) => Promise<unknown>
51 storeSet: (key: string, value: unknown) => Promise<void>
52}
53
54/**
55 * Whether the band shows at a session's start: the last /music choice the
56 * store kept, else the `showOnStart` option (true unless set false).
57 *
58 * @param kept what the store holds under STORE_SHOWN_KEY
59 * @param options the plugin's userConfig values
60 * @returns whether to show
61 */
62export function isShownAtStart(kept: unknown, options: PluginOptions): boolean {
63 if (typeof kept === 'boolean') {
64 return kept
65 }
66
67 return options.showOnStart !== false
68}
69
70/**
71 * The engine calls the band needs, taken off `$`. The shared reading lives
72 * under `TMPDIR`; a file the engine cannot read or write counts as absent.
73 *
74 * @param $ the engine
75 * @returns the host
76 */
77async function hostOf($: EngineInterface): Promise<Host> {
78 const path = cachePathOf(await $.env.get('TMPDIR').catch(() => undefined))
79
80 return {
81 run: (argv, init) => $.process.run(argv, init),
82 invalidate: () => $.ui.invalidate('ui.render'),
83 every: (ms, fn) => $.clock.every(ms, fn),
84 now: () => $.clock.now(),
85 readShared: () =>
86 $.fs.read(path).then(
87 text => (typeof text === 'string' ? entryOf(text) : null),
88 () => null,
89 ),
90 writeShared: entry => $.fs.write(path, JSON.stringify(entry)).catch(() => undefined),
91 storeGet: key => $.store.get(key),
92 storeSet: (key, value) => $.store.set(key, value),
93 }
94}
95
96/**
97 * The Music band: one line above the prompt, shown from the session's start
98 * (the last /music choice, else `showOnStart`) and toggled by /music. While
99 * it shows, a one-second tick redraws the clock from the last reading and,
100 * once `refreshMs` has passed, takes a new one: another session's from the
101 * shared file when it is recent enough, else its own from osascript. A press
102 * on a glyph sends its control and reads at once.
103 *
104 * @param on the engine's hook registrar
105 * @param options the plugin's userConfig values
106 */
107export function register(on: On, options: PluginOptions): void {
108 const refreshMs = refreshMsOf(options)
109
110 let model: Model = { kind: 'idle' }
111 let timer: Timer | null = null
112 let isShown = false
113 let isReading = false
114 let checkedAt = 0
115 let host: Host | null = null
116
117 /**
118 * Takes a reading no older than `maxAgeMs`: the shared one when it is,
119 * else a new osascript run, claimed in the shared file first so the other
120 * sessions wait for it instead of running their own.
121 */
122 async function read(host: Host, maxAgeMs: number): Promise<void> {
123 if (isReading) {
124 return
125 }
126
127 isReading = true
128
129 try {
130 const at = await host.now()
131 const shared = await host.readShared()
132
133 if (isFresh(shared, at, maxAgeMs)) {
134 if (!shared.run) {
135 // another session's read is under way: look again on the next tick
136 checkedAt = Number.NEGATIVE_INFINITY
137
138 return
139 }
140
141 checkedAt = at
142
143 if (model.kind !== 'ok' || model.readAt !== shared.readAt) {
144 model = modelOf(shared.run, shared.readAt)
145 }
146
147 return
148 }
149
150 checkedAt = at
151
152 await host.writeShared({ readAt: at, run: null })
153
154 const run = await host.run(ARGV, { timeoutMs: READ_TIMEOUT_MS })
155 const readAt = await host.now()
156
157 model = modelOf(run, readAt)
158 await host.writeShared({ readAt, run })
159 } catch (error) {
160 model = { kind: 'error', text: error instanceof Error ? error.message : String(error) }
161 } finally {
162 isReading = false
163 host.invalidate()
164 }
165 }
166
167 async function tick(host: Host): Promise<void> {
168 const at = await host.now().catch(() => null)
169
170 if (at === null) {
171 return
172 }
173
174 // a reading that already stood at the end (the track outruns its
175 // metadata) waits for the usual refresh instead of re-reading each tick
176 const isEndDue = hasEnded(model, at) && model.kind === 'ok' && !hasEnded(model, model.readAt)
177
178 if (isEndDue) {
179 await read(host, END_MAX_AGE_MS)
180 } else if (at < checkedAt || at - checkedAt >= refreshMs) {
181 await read(host, refreshMs)
182 } else if (model.kind === 'ok' && model.now.state === 'playing') {
183 host.invalidate()
184 }
185 }
186
187 async function control(which: Control): Promise<void> {
188 if (!host) {
189 return
190 }
191
192 const run = await host.run(controlArgvOf(which), { timeoutMs: READ_TIMEOUT_MS }).catch(() => null)
193
194 if (run && run.exitCode !== 0) {
195 model = { kind: 'error', text: `osascript exited ${run.exitCode}: ${run.stderr.trim()}` }
196 host.invalidate()
197
198 return
199 }
200
201 await read(host, 0)
202 }
203
204 const actions = {
205 playpause: () => void control('playpause'),
206 next: () => void control('next'),
207 }
208
209 function stop(): void {
210 timer?.cancel()
211 timer = null
212 isShown = false
213 }
214
215 async function show(engine: Host): Promise<void> {
216 host = engine
217 isShown = true
218 model = { kind: 'idle' }
219 await read(engine, refreshMs)
220
221 timer?.cancel()
222 timer = engine.every(TICK_MS, () => {
223 void tick(engine)
224 })
225 }
226
227 function hide(engine: Host): void {
228 stop()
229 engine.invalidate()
230 }
231
232 on('session.start', async ($, e, next) => {
233 try {
234 await $.command.register({
235 name: COMMAND_NAME,
236 description: 'What Music.app is playing, one line above the prompt',
237 })
238 } catch (error) {
239 $.ui.log(
240 `/${COMMAND_NAME} did not register: ${error instanceof Error ? error.message : String(error)}`,
241 )
242 }
243
244 if (e.isInteractive && e.surface === 'terminal') {
245 const engine = await hostOf($)
246 const kept = await engine.storeGet(STORE_SHOWN_KEY).catch(() => undefined)
247
248 if (isShownAtStart(kept, options)) {
249 await show(engine)
250 }
251 }
252
253 return next(e)
254 })
255
256 on('command.run', { command: COMMAND_NAME }, async $ => {
257 const engine = await hostOf($)
258 const isHiding = isShown
259
260 if (isHiding) {
261 hide(engine)
262 } else {
263 await show(engine)
264 }
265
266 await engine.storeSet(STORE_SHOWN_KEY, !isHiding).catch(() => undefined)
267
268 return { text: isHiding ? HIDDEN_TEXT : SHOWN_TEXT }
269 })
270
271 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
272 if (!isShown || e.props.hasSurvey) {
273 return next(e)
274 }
275
276 const { Box, Text, Button } = $.ui.resolve(e)
277 const at = await $.clock.now()
278 const beneath = await next(e)
279
280 return (
281 <Box flexDirection="column">
282 {bandView({ Box, Text, Button }, modelAt(model, at), e.props.bodyColumns, actions)}
283 {beneath}
284 </Box>
285 )
286 })
287
288 on('session.end', ($, e, next) => {
289 stop()
290
291 return next(e)
292 })
293}
294hooks/now-playing.ts 236 lines1/**
2 * What Music.app reports, read with one `osascript -l JavaScript` run and
3 * parsed from the JSON it prints: only what the band draws.
4 */
5export type NowPlaying = {
6 state: 'closed' | 'stopped' | 'playing' | 'paused' | 'fast forwarding' | 'rewinding'
7 track: { name: string; artist: string; album: string; duration: number } | null
8 position: number | null
9}
10
11/**
12 * The pane's state: nothing read yet, a reading taken at `readAt`
13 * (milliseconds since the epoch), or why the read failed.
14 */
15export type Model =
16 | { kind: 'idle' }
17 | { kind: 'ok'; now: NowPlaying; readAt: number }
18 | { kind: 'error'; text: string }
19
20/**
21 * The JXA script osascript runs. One IIFE whose value is the JSON text
22 * (osascript prints the top-level expression's value). It asks Music.app for
23 * the fewest properties the band draws: no system volume (each read of it
24 * wakes coreaudiod) and no playlist walk (`tracks.length` is slow on a
25 * long playlist).
26 *
27 * It never launches Music.app. `Application('Music')` relaunches the app
28 * when a read lands while it quits, so the script finds the running process
29 * through NSRunningApplication (no Apple event) and sends each get as a raw
30 * Apple event to that process ID. An event to a process that has gone fails
31 * (-600, -609) and the read reports closed.
32 */
33export const SCRIPT = `(() => {
34 ObjC.import('AppKit')
35 const D = $.NSAppleEventDescriptor
36 const code = s => ((s.charCodeAt(0) << 24) | (s.charCodeAt(1) << 16) | (s.charCodeAt(2) << 8) | s.charCodeAt(3)) >>> 0
37 const text = n => String.fromCharCode((n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255)
38 const STATES = { kPSS: 'stopped', kPSP: 'playing', kPSp: 'paused', kPSF: 'fast forwarding', kPSR: 'rewinding' }
39 const GONE = [-600, -609]
40 const WHY = { '-1712': 'Music.app did not answer in time', '-1743': 'not allowed to send Apple events to Music.app' }
41 const SEND = 0x3 | 0x10 | 0x80 // wait for the reply, never interact, never reconnect
42 const DEADLINE = Date.now() + 4000 // the whole read, a second inside READ_TIMEOUT_MS
43 const MISSING = code('msng') // missing value: a typeType descriptor holding 'msng'
44 const out = { state: 'closed', track: null, position: null }
45 const found = $.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.apple.Music').js
46 if (found.length === 0) return JSON.stringify(out)
47 const target = D.descriptorWithProcessIdentifier(found[0].processIdentifier)
48 const property = (id, of) => {
49 const spec = D.recordDescriptor
50 spec.setDescriptorForKeyword(D.descriptorWithTypeCode(code('prop')), code('want'))
51 spec.setDescriptorForKeyword(D.descriptorWithEnumCode(code('prop')), code('form'))
52 spec.setDescriptorForKeyword(D.descriptorWithTypeCode(code(id)), code('seld'))
53 spec.setDescriptorForKeyword(of || D.nullDescriptor, code('from'))
54 return spec.coerceToDescriptorType(code('obj '))
55 }
56 const get = spec => {
57 const event = D.appleEventWithEventClassEventIDTargetDescriptorReturnIDTransactionID(code('core'), code('getd'), target, -1, 0)
58 event.setParamDescriptorForKeyword(spec, code('----'))
59 const error = $()
60 const reply = event.sendEventWithOptionsTimeoutError(SEND, Math.max(0.1, (DEADLINE - Date.now()) / 1000), error)
61 if (reply.isNil()) throw { errorNumber: Number(error.code) }
62 const errn = reply.paramDescriptorForKeyword(code('errn'))
63 if (!errn.isNil() && errn.int32Value !== 0) throw { errorNumber: errn.int32Value }
64 return reply.paramDescriptorForKeyword(code('----'))
65 }
66 try {
67 const state = text(get(property('pPlS')).enumCodeValue)
68 out.state = STATES[state] || state
69 if (out.state === 'stopped') return JSON.stringify(out)
70 const track = property('pTrk')
71 const string = id => ObjC.unwrap(get(property(id, track)).stringValue)
72 const number = d => (d.descriptorType === code('type') && d.typeCodeValue === MISSING ? null : d.doubleValue)
73 out.track = { name: string('pnam'), artist: string('pArt'), album: string('pAlb'), duration: number(get(property('pDur', track))) }
74 try { out.position = number(get(property('pPos'))) } catch (e) { out.position = null }
75 return JSON.stringify(out)
76 } catch (e) {
77 if (e instanceof Error) throw e
78 if (GONE.includes(e.errorNumber)) return JSON.stringify({ state: 'closed', track: null, position: null })
79 throw new Error((WHY[e.errorNumber] || 'Music.app answered error') + ' (' + e.errorNumber + ')')
80 }
81})()`
82
83/** The argv that reads Music.app. */
84export const ARGV: readonly string[] = ['osascript', '-l', 'JavaScript', '-e', SCRIPT]
85
86/** How long one read may take before it counts as failed. */
87export const READ_TIMEOUT_MS = 5000
88
89const STATES = new Set(['closed', 'stopped', 'playing', 'paused', 'fast forwarding', 'rewinding'])
90
91function isRecord(value: unknown): value is Record<string, unknown> {
92 return typeof value === 'object' && value !== null && !Array.isArray(value)
93}
94
95function numberOr(value: unknown, fallback: number): number {
96 return typeof value === 'number' && Number.isFinite(value) ? value : fallback
97}
98
99function stringOr(value: unknown, fallback: string): string {
100 return typeof value === 'string' ? value : fallback
101}
102
103/**
104 * The reading a finished osascript run stands for.
105 *
106 * @param run the process result
107 * @param readAt when the run was taken, milliseconds since the epoch
108 * @returns the reading, or the error the pane should show
109 */
110export function modelOf(run: { exitCode: number; stdout: string; stderr: string }, readAt: number): Model {
111 if (run.exitCode !== 0) {
112 const reason = run.stderr.trim().split('\n').at(-1) ?? ''
113
114 return { kind: 'error', text: `osascript exited ${run.exitCode}${reason ? `: ${reason}` : ''}` }
115 }
116
117 let parsed: unknown
118
119 try {
120 parsed = JSON.parse(run.stdout)
121 } catch {
122 return { kind: 'error', text: 'Music.app answered something that is not JSON' }
123 }
124
125 if (!isRecord(parsed) || typeof parsed.state !== 'string' || !STATES.has(parsed.state)) {
126 return { kind: 'error', text: 'Music.app answered an unknown player state' }
127 }
128
129 const track = isRecord(parsed.track) ? parsed.track : null
130
131 return {
132 kind: 'ok',
133 readAt,
134 now: {
135 state: parsed.state as NowPlaying['state'],
136 track: track
137 ? {
138 name: stringOr(track.name, ''),
139 artist: stringOr(track.artist, ''),
140 album: stringOr(track.album, ''),
141 duration: numberOr(track.duration, 0),
142 }
143 : null,
144 position: typeof parsed.position === 'number' ? parsed.position : null,
145 },
146 }
147}
148
149/**
150 * The reading as it stands at `at`: while playing, the position runs on from
151 * where it was read, so the clock ticks without asking Music.app again.
152 *
153 * @param model what was last read
154 * @param at now, milliseconds since the epoch
155 * @returns the model with the position carried forward, capped at the track's end
156 */
157export function modelAt(model: Model, at: number): Model {
158 if (model.kind !== 'ok' || model.now.state !== 'playing' || !model.now.track || model.now.position === null) {
159 return model
160 }
161
162 const elapsed = Math.max(0, at - model.readAt) / 1000
163 const { duration } = model.now.track
164 const position = model.now.position + elapsed
165
166 return { ...model, now: { ...model.now, position: duration > 0 ? Math.min(duration, position) : position } }
167}
168
169/**
170 * Whether the playing track has run out by `at`, so the next one is due.
171 *
172 * @param model what was last read
173 * @param at now, milliseconds since the epoch
174 * @returns true once the carried-forward position reaches the duration
175 */
176export function hasEnded(model: Model, at: number): boolean {
177 const shown = modelAt(model, at)
178
179 return (
180 shown.kind === 'ok' &&
181 shown.now.state === 'playing' &&
182 shown.now.track !== null &&
183 shown.now.track.duration > 0 &&
184 (shown.now.position ?? 0) >= shown.now.track.duration
185 )
186}
187
188/**
189 * Seconds as m:ss (or h:mm:ss past an hour).
190 *
191 * @param seconds a non-negative duration
192 * @returns the clock text
193 */
194export function clockOf(seconds: number): string {
195 const whole = Math.max(0, Math.floor(seconds))
196 const h = Math.floor(whole / 3600)
197 const m = Math.floor((whole % 3600) / 60)
198 const s = whole % 60
199 const mm = h > 0 ? String(m).padStart(2, '0') : String(m)
200
201 return `${h > 0 ? `${h}:` : ''}${mm}:${String(s).padStart(2, '0')}`
202}
203
204/**
205 * A progress bar of `width` cells, filled to `ratio`.
206 *
207 * @param ratio 0 to 1; out of range is clamped
208 * @param width the bar's cells, at least 1
209 * @returns the bar
210 */
211export function barOf(ratio: number, width: number): string {
212 const cells = Math.max(1, Math.floor(width))
213 const filled = Math.round(Math.min(1, Math.max(0, Number.isFinite(ratio) ? ratio : 0)) * cells)
214
215 return `${'█'.repeat(filled)}${'░'.repeat(cells - filled)}`
216}
217
218
219/** The transport controls the band offers. */
220export type Control = 'playpause' | 'next'
221
222const CONTROL_SCRIPT: Record<Control, string> = {
223 playpause: 'tell application "Music" to playpause',
224 next: 'tell application "Music" to next track',
225}
226
227/**
228 * The argv that sends one transport control to Music.app.
229 *
230 * @param control which control
231 * @returns the argv
232 */
233export function controlArgvOf(control: Control): readonly string[] {
234 return ['osascript', '-e', CONTROL_SCRIPT[control]]
235}
236hooks/shared-read.ts 83 lines1/**
2 * One reading of Music.app shared by every session on the machine: each
3 * session looks at a small file under the temporary directory first and runs
4 * osascript only when the reading there is older than it can use, so five
5 * open sessions cost one read per interval, not five.
6 */
7
8/** A finished osascript run, as `$.process.run` resolves it. */
9export type Run = { exitCode: number; stdout: string; stderr: string }
10
11/**
12 * What the shared file holds: when the reading was taken (milliseconds since
13 * the epoch) and the run it came from, or null while the first read is
14 * still under way.
15 */
16export type Entry = { readAt: number; run: Run | null }
17
18/** The shared file's name under the temporary directory. */
19export const CACHE_NAME = 'music-mod/now.json'
20
21/**
22 * The shared file's path.
23 *
24 * @param tmpdir `TMPDIR`, when set
25 * @returns the path under it, else under /tmp
26 */
27export function cachePathOf(tmpdir: string | undefined): string {
28 const dir = tmpdir && tmpdir !== '' ? tmpdir : '/tmp'
29
30 return `${dir.replace(/\/+$/, '')}/${CACHE_NAME}`
31}
32
33function isRun(value: unknown): value is Run {
34 if (typeof value !== 'object' || value === null) {
35 return false
36 }
37
38 const run = value as Record<string, unknown>
39
40 return typeof run.exitCode === 'number' && typeof run.stdout === 'string' && typeof run.stderr === 'string'
41}
42
43/**
44 * The entry a shared file's text stands for.
45 *
46 * @param text the file's content
47 * @returns the entry, or null when the text is not one
48 */
49export function entryOf(text: string): Entry | null {
50 let parsed: unknown
51
52 try {
53 parsed = JSON.parse(text)
54 } catch {
55 return null
56 }
57
58 if (typeof parsed !== 'object' || parsed === null) {
59 return null
60 }
61
62 const { readAt, run } = parsed as Record<string, unknown>
63
64 if (typeof readAt !== 'number' || !Number.isFinite(readAt)) {
65 return null
66 }
67
68 return { readAt, run: isRun(run) ? run : null }
69}
70
71/**
72 * Whether an entry is recent enough to use instead of a new read. An entry
73 * from the future (a clock set back) is never fresh.
74 *
75 * @param entry what the shared file holds
76 * @param at now, milliseconds since the epoch
77 * @param maxAgeMs the oldest reading the caller takes
78 * @returns whether to use it
79 */
80export function isFresh(entry: Entry | null, at: number, maxAgeMs: number): entry is Entry {
81 return entry !== null && entry.readAt <= at && at - entry.readAt < maxAgeMs
82}
83hooks/views/band-view.tsx 166 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { Elements, RenderElement } from 'claude-code'
5
6import { barOf, clockOf, type Control, type Model, type NowPlaying } from '../now-playing'
7
8/** The tags the band draws with. */
9export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
10
11/** What a press on each control runs. */
12export type Actions = Record<Control, () => void>
13
14/** The fewest cells the bar keeps; below that the title gives way. */
15export const BAR_MIN_CELLS = 6
16
17/** The state glyph, a pressable play/pause. */
18export const STATE_MARK: Record<NowPlaying['state'], string> = {
19 closed: '🎵',
20 stopped: '▶️',
21 playing: '▶️',
22 paused: '⏸️',
23 'fast forwarding': '⏩',
24 rewinding: '⏪',
25}
26
27/** The next-track glyph. */
28export const NEXT_MARK = '⏭️'
29
30/** Cells an emoji with a variation selector takes on the terminal. */
31const GLYPH_CELLS = 2
32
33/**
34 * Cells the engine draws its collapse mark (` [-]`) over at the band's right
35 * edge; `bodyColumns` does not set them aside, so the line stops short of them.
36 */
37export const COLLAPSE_CELLS = 4
38
39const WIDE =
40 /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}]/u
41
42/**
43 * The columns `text` takes on the terminal, a CJK or emoji character two.
44 *
45 * @param text the string
46 * @returns its cells
47 */
48export function widthOf(text: string): number {
49 let width = 0
50
51 for (const char of text) {
52 width += WIDE.test(char) ? 2 : 1
53 }
54
55 return width
56}
57
58/**
59 * `text` cut to `cells` columns with an ellipsis when it does not fit.
60 *
61 * @param text the string
62 * @param cells the columns to fit
63 * @returns the fitted string
64 */
65export function fitOf(text: string, cells: number): string {
66 if (widthOf(text) <= cells) {
67 return text
68 }
69
70 let width = 0
71 let out = ''
72
73 for (const char of text) {
74 const w = WIDE.test(char) ? 2 : 1
75
76 if (width + w > cells - 1) {
77 return `${out}…`
78 }
79
80 width += w
81 out += char
82 }
83
84 return out
85}
86
87/**
88 * The band's tree for one `ui.render`: one line above the prompt filling its
89 * width (the bar takes what the title leaves), its two glyphs pressable.
90 *
91 * @param kit Box, Text and Button
92 * @param model what was last read
93 * @param columns the band's body columns
94 * @param actions what the glyphs run
95 * @returns the tree
96 */
97export function bandView(kit: Kit, model: Model, columns: number, actions: Actions): RenderElement {
98 const { Box, Text, Button } = kit
99
100 if (model.kind === 'idle') {
101 return (
102 <Box paddingX={1}>
103 <Text dimColor>🎵 Reading Music.app…</Text>
104 </Box>
105 )
106 }
107
108 if (model.kind === 'error') {
109 return (
110 <Box paddingX={1}>
111 <Text wrap="truncate-end">
112 <Text color="red">⚠️ could not read Music.app</Text>
113 <Text dimColor> {model.text}</Text>
114 </Text>
115 </Box>
116 )
117 }
118
119 const { now } = model
120
121 if (now.state === 'closed') {
122 return (
123 <Box paddingX={1}>
124 <Text dimColor>🎵 music isn't running</Text>
125 </Box>
126 )
127 }
128
129 if (now.state === 'stopped' || !now.track) {
130 return (
131 <Box paddingX={1} flexDirection="row" gap={1}>
132 <Button key="playpause" plain onPress={actions.playpause}>
133 {STATE_MARK.stopped}
134 </Button>
135 <Text dimColor>music is open, nothing playing</Text>
136 </Box>
137 )
138 }
139
140 const { track } = now
141 const position = now.position ?? 0
142 const clocks = `${clockOf(position)} / ${clockOf(track.duration)}`
143 const title = [track.name, track.artist, track.album].filter(part => part !== '').join(' · ')
144 const fixed = 2 + GLYPH_CELLS + 1 + 1 + 1 + clocks.length + 1 + GLYPH_CELLS
145 const spare = Math.max(BAR_MIN_CELLS + 4, columns - COLLAPSE_CELLS - fixed)
146 const isWhole = widthOf(title) + BAR_MIN_CELLS <= spare
147 const titleCells = isWhole ? widthOf(title) : spare - BAR_MIN_CELLS
148 const cells = spare - titleCells
149 const bar = barOf(track.duration > 0 ? position / track.duration : 0, cells)
150
151 return (
152 <Box paddingX={1} flexDirection="row" gap={1}>
153 <Button key="playpause" plain onPress={actions.playpause}>
154 {STATE_MARK[now.state]}
155 </Button>
156 <Text bold>{fitOf(title, titleCells)}</Text>
157 <Text>
158 {bar} <Text dimColor>{clocks}</Text>
159 </Text>
160 <Button key="next" plain onPress={actions.next}>
161 {NEXT_MARK}
162 </Button>
163 </Box>
164 )
165}
166