A desk for the Claude Code prompt: context weather, Gmail, calendar, tasks across sessions, notes, a music deck and a billable-hours timer

The tour above is an animation made with demo data. Click it for the version with sound, or see the vertical cut.
A desk for the Claude Code prompt. One row of small widgets sits above the prompt box, and each one opens a dropdown:
| Widget | What it shows | What you can do |
|---|---|---|
| Weather | How full the context window is, as a weather icon and a percentage | /weather prints the token counts and their history |
| Your Gmail Primary tab, the latest few, unread ones marked | Click a subject to open that thread in Gmail | |
| Calendar | How many meetings are left this week, and the next one as it gets close | A Join link, and a field to add an event by typing "call with Sam tomorrow 6pm for 45m" |
| Tasks | The task lists Claude keeps in each session, to do and done side by side, with a progress bar | Search and switch between the sessions of any project |
| Notes | One notes list shared by every session | Type or dictate a note; "in 30m", "at 5pm" or "tomorrow" sets a reminder; turn a note into a calendar event, or hand it to Claude |
| Music | A retro deck: track, artist, cover, progress and a live spectrum of your speaker output | Back, play or pause, stop, next, volume, for anything Windows sees as media (YouTube Music in a browser, Spotify) |
| Timer | A billable-hours clock for the project | Start and stop; today and the week against a weekly cap; Claude starts and stops it by the rules in rules.md |
| Settings | Which widgets show, how often mail is checked, the music bars and sounds |
The widgets read their data directly. No model call is made unless you press the one button that asks for it (handing a note to Claude).
What each widget needs before it shows anything:
| Widget | Needs |
|---|---|
| Weather, tasks, notes, timer, settings | Nothing. They work as soon as the row is on. |
| The Gmail connector, connected in Claude's settings. | |
| Calendar | The Google Calendar connector, connected in Claude's settings. |
| Music | Windows, Python 3.11 or later, and the helper set up as below. Any player Windows sees as media works; no browser extension is needed. |
A widget whose connector is missing shows ? on its icon and says why in its dropdown. The rest of the row keeps working. To drop a widget you do not use, turn it off in the settings dropdown.
git clone https://github.com/0NE-C0DEMAN/deskbar.git
Put it somewhere permanent, for example ~/.claude/mods/deskbar.
~/.claude/settings.json (create it if it does not exist) and add these two lines to its env block, with your own path: {
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1",
"CLAUDE_CODE_PLUGIN_DIRS": "C:\\Users\\you\\.claude\\mods\\deskbar"
}
}
On macOS or Linux the path looks like /Users/you/.claude/mods/deskbar. To load several mods, separate their paths with ; on Windows or : elsewhere.
/desk on
The row is off in every session until you turn it on there, so it never appears where you did not ask for it, and never in scheduled-task sessions.
To try it for one terminal session without changing any settings:
claude --plugin-dir /path/to/deskbar
The mail and calendar widgets use Claude's own connectors; the mod never sees your password or a token.
/desk on.If an icon shows ?, open its dropdown: it says what the connector answered.
The deck needs a small helper that reads Windows' media session (the same one the volume pop-up shows) and listens to the speaker output for the spectrum. It runs only while the music dropdown is open, and stops by itself about 12 seconds after you close it.
In a terminal, with Python 3.11 or later:
cd %USERPROFILE%\.claude
mkdir deskbar-data\music
cd deskbar-data\music
python -m venv .venv
.venv\Scripts\pip install -r C:\path\to\deskbar\helper\requirements.txt
Then open the music dropdown, click Open YouTube Music (or start your own player) and play something. With uv, uv venv --python 3.11 .venv and uv pip install --python .venv\Scripts\python.exe -r ... do the same, faster.
The timer shows in every project. The first time you press start, or ask Claude to time the work, it makes a timelog folder in the project with the folder's name as the client and no rate or cap. Until then Claude does not start the timer by itself. To set a rate and a weekly cap, run:
/timer setup 20 15 Acme
That makes timelog/config.json (a rate of 20 an hour, a 15-hour weekly cap, the client's name) and an empty ledger, timelog/entries.jsonl, which keeps one JSON line per entry:
{"id": 1, "date": "2026-10-02", "start": "2026-10-02T09:30:00", "end": "2026-10-02T11:00:00", "hours": 1.5, "task": "Parser fix", "note": "", "evidence": "", "billable": true, "invoice": null}
Claude gets a timer tool and the rules in rules.md: when to start, when to stop, what is never timed. To give one project its own rules, put a rules.md in its timelog folder.
| Command | What it does |
|---|---|
/desk on, /desk off, /desk | Turn the row on or off for this session, or toggle it |
/desk demo | Show made-up mail, meetings, tasks and notes instead of yours, for screenshots and recordings; run it again to switch back |
/weather | Print the context usage in detail |
/timer start <task>, /timer call <task> | Start timing; a call is recorded but never billed |
/timer stop [note], /timer switch <task> | Close the entry, or close it and start another |
/timer status, /timer panel | Print today's, this week's and the unbilled totals, or open the timer in a side panel |
/timer setup <rate> <weekly cap> [client] | Turn the timer on for this project |
Click the gear at the end of the row. Settings apply to every session:
They are saved in ~/.claude/deskbar-data/settings.json.
hooks/spectrum.tsx, meter.tsx, pulse.tsx): small regions the app animates on its own, at frame rate, without redrawing the row.hooks/motion.ts), and the music bars are drawn into the deck's picture.fx/*.wav) play when a note's reminder is due, ten minutes before a meeting, when the timer starts or stops, and when a note is saved.~/.claude/tasks; the names of your project folders under ~/.claude/projects (to label sessions); the project's timelog folder; and, for the music deck, what Windows reports as playing.~/.claude/deskbar-data/ (settings, which sessions it is on in, the music helper's files) and your notes in Claude Code's plugin store. Nothing is kept inside the mod's own folder.| You see | Do this |
|---|---|
| No row at all | Run /desk on in that session. The row draws in the main pane, or a session's own window, but not in split side panes. |
A grey deskbar: line in the transcript | It names what failed. Open an issue with that line. |
? on the mail or calendar icon | Open the dropdown for the connector's answer; reconnect the connector if it has expired. |
| Music deck says "starting" and stays there | The helper is not installed: see the music setup above. |
| A blank gap where something should move | Your desktop app is on 2.1.286; update the app, or ignore it, since the mod falls back on its own. |
| The row scrolls away when a dropdown is open | It should not: every dropdown sizes itself to the 12 rows the app allows. Open an issue with a screenshot. |
Remove the path from CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json, restart Claude Code, and delete the mod's folder and ~/.claude/deskbar-data/.
claude plugin validate /path/to/deskbar
claude plugin test /path/to/deskbar
hooks/register.tsx is the entry point. hooks/desk.tsx holds the weather, mail, calendar, tasks, notes, music and settings; hooks/timer.tsx holds the timer. The tests in hooks/*.test.ts run both halves against a fake engine on the desktop and terminal surfaces.
MIT
hooks/register.tsx 13 lines1import type { Register } from 'claude-code'
2
3import { registerDesk } from './desk'
4import { registerTimer } from './timer'
5
6// One plugin, two halves: the work timer (registered first, so it sits
7// outermost and places its chip at the right end of the row) and the desk
8// (context weather, mail, calendar, tasks, notes, music, settings).
9export const register: Register = (on, options) => {
10 registerTimer(on, options)
11 registerDesk(on, options)
12}
13hooks/desk.tsx 2383 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { meterSvg, pulseSvg } from './motion'
5
6import type { Desk, Inbox, Meeting, Music, Note, Reading, Task, Todo, TodoSession } from '../types'
7
8const readings = atom({ plugin: 'deskbar', key: 'readings' } as const, [] as Reading[])
9const hidden = atom({ plugin: 'deskbar', key: 'isHidden' } as const, false)
10const desk = atom({ plugin: 'deskbar', key: 'desk' } as const, {
11 inbox: null,
12 events: [],
13 calendarError: '',
14 nowMs: 0,
15} as Desk)
16// Which dropdown is open under the row: '', 'mail' or 'calendar'.
17const panel = atom({ plugin: 'deskbar', key: 'panel' } as const, '')
18// When that dropdown was opened (0 while none is). The work timer keeps the
19// same for its table, and each mod reads the other's: only the one opened
20// last is drawn, so one dropdown shows at a time across both.
21const panelAt = atom({ plugin: 'deskbar', key: 'panelAt' } as const, 0)
22// Counts changes of the settings, so a drawing that reads it is drawn again.
23const settingsAt = atom({ plugin: 'deskbar', key: 'settingsAt' } as const, 0)
24// What is playing on this machine and the speaker's spectrum, as the music
25// helper last wrote them; null until the music dropdown has been opened.
26const music = atom({ plugin: 'deskbar', key: 'music' } as const, null as Music | null)
27// Notes are one list for every session, kept in the plugin's own store.
28const notes = atom({ plugin: 'deskbar', key: 'notes' } as const, [] as Note[])
29// The task lists Claude keeps per session (~/.claude/tasks/<session>/<n>.json),
30// read as they are: no model call. `open` is this session's count, -1 for none.
31const todo = atom({ plugin: 'deskbar', key: 'todo' } as const, {
32 sessions: [],
33 selected: '',
34 tasks: [],
35 open: -1,
36 isLoading: false,
37 query: '',
38 pageOpen: 0,
39 pageDone: 0,
40} as Todo)
41
42// Gmail and Calendar are read through the app's own connectors, every few
43// minutes; the minute tick only moves the meeting countdown.
44// Gmail's own Primary tab, newest first (a bare `category:primary` search is
45// what the tab shows; promotions, social, updates and forums are not in it).
46const MAIL_QUERY = 'category:primary in:inbox'
47const MAIL_ROWS = 10
48// A sender that is a program, not a person: listed after people in the table.
49const MACHINE =
50 /(^|[._-])(no-?reply|do-?not-?reply|notifications?|info|team|services?|support|alerts?|billing|accounts?|admin|bounce|statements?|security)([._-]|@)|@([\w-]+\.)*(mail|custcomm|customer)\./i
51// Build-robot mail nobody reads here: GitHub Actions run results.
52const NOISE = /^\[[^\]]+\] (Run (failed|cancelled|succeeded|skipped)|PR run failed)|workflow run/i
53const POLL_MS = 3 * 60 * 1000
54const AMBER = '#eda100'
55const RED = '#e34948'
56const BLUE = '#2a78d6'
57const GREEN = '#1baf7a'
58const EDGE = '#2f4149'
59const HEAD_BG = '#22333b'
60const STRIPE_BG = '#172229'
61const MUTED = '#8fa3aa'
62const ICON = '#c3d0d5'
63const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
64const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
65
66let isPolling = false
67// the meetings (by start time) the ten-minute chime has already rung for
68let chimed: number[] = []
69let hasTimers = false
70
71const BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
72
73const weather = (percent: number) => {
74 if (percent < 25) return 'Clear'
75 if (percent < 50) return 'Cloudy'
76 if (percent < 70) return 'Showers'
77 if (percent < 85) return 'Storm'
78 return 'Hurricane'
79}
80
81const tone = (percent: number) => {
82 if (percent < 25) return '#1baf7a'
83 if (percent < 50) return '#2a78d6'
84 if (percent < 70) return '#eda100'
85 if (percent < 85) return '#eb6834'
86 return '#e34948'
87}
88
89const icon = (percent: number) => {
90 if (percent < 25) return '☀️'
91 if (percent < 50) return '⛅'
92 if (percent < 70) return '🌧️'
93 if (percent < 85) return '⛈️'
94 return '🌀'
95}
96
97// Line icons, drawn as SVG where the surface has the element (the desktop app);
98// the terminal gets the emoji instead. 24-unit grid, 2-unit round strokes.
99const ICONS: Record<string, string> = {
100 clear:
101 '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>',
102 cloudy: '<path d="M17.5 19a4.5 4.5 0 1 0-1.2-8.84A6 6 0 1 0 6 16.5V17a2 2 0 0 0 2 2z"/>',
103 showers:
104 '<path d="M20 16.2A4.5 4.5 0 0 0 17.5 8h-1.8A7 7 0 1 0 4 14.9"/><path d="M16 14v6M8 14v6M12 16v6"/>',
105 storm: '<path d="M19 16.9A5 5 0 0 0 18 7h-1.26a8 8 0 1 0-11.62 9"/><path d="M13 11l-4 6h6l-4 6"/>',
106 hurricane: '<path d="M9.59 4.59A2 2 0 1 1 11 8H2M12.59 19.41A2 2 0 1 0 14 16H2M17.73 7.73A2.5 2.5 0 1 1 19.5 12H2"/>',
107 mail: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 7l-10 6L2 7"/>',
108 calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
109 tasks: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
110 notes: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/>',
111 search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
112}
113
114// Full-colour icons on the same 24-unit grid, each element carrying its own
115// fill: a four-colour mail envelope, a calendar page with today's date on it,
116// a green tick box, a yellow sticky note, and the weather in colour.
117function colorIcon(name: string, day: number) {
118 const cloud = (fill: string) => `<path d="M17.5 19a4.5 4.5 0 1 0-1.2-8.84A6 6 0 1 0 6 16.5V17a2 2 0 0 0 2 2z" fill="${fill}"/>`
119 const art: Record<string, string> = {
120 mail:
121 '<rect x="2" y="4" width="20" height="16" rx="2" fill="#ffffff"/>' +
122 '<path d="M2 6v12a2 2 0 0 0 2 2h2V9.5z" fill="#4285F4"/>' +
123 '<path d="M22 6v12a2 2 0 0 1-2 2h-2V9.5z" fill="#34A853"/>' +
124 '<path d="M2 6a2 2 0 0 1 2-2h.6L6 5.2v4.3z" fill="#C5221F"/>' +
125 '<path d="M22 6a2 2 0 0 0-2-2h-.6L18 5.2v4.3z" fill="#FBBC04"/>' +
126 '<path d="M6 9.5V5.2l6 4.6 6-4.6v4.3l-6 4.6z" fill="#EA4335"/>',
127 calendar:
128 '<rect x="3" y="3" width="18" height="18" rx="3" fill="#ffffff"/>' +
129 '<path d="M3 6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v3H3z" fill="#4285F4"/>' +
130 '<circle cx="8" cy="6" r="1" fill="#ffffff"/><circle cx="16" cy="6" r="1" fill="#ffffff"/>' +
131 `<text x="12" y="18.4" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif" font-size="9" font-weight="700" fill="#4285F4">${day}</text>`,
132 tasks:
133 '<rect x="3" y="3" width="18" height="18" rx="5" fill="#1baf7a"/>' +
134 '<path d="M7.5 12.5l3 3 6-7" fill="none" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>',
135 music:
136 '<circle cx="12" cy="12" r="10" fill="#FF0033"/>' +
137 '<circle cx="12" cy="12" r="5.6" fill="none" stroke="#ffffff" stroke-width="1.3"/>' +
138 '<path d="M10.3 9.2v5.6l4.8-2.8z" fill="#ffffff"/>',
139 notes:
140 '<path d="M4 3h16a1 1 0 0 1 1 1v11l-6 6H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" fill="#FFD54A"/>' +
141 '<path d="M15 21v-5a1 1 0 0 1 1-1h5z" fill="#E0A800"/>' +
142 '<path d="M7 8h10M7 12h7" fill="none" stroke="#8a6d00" stroke-width="1.7" stroke-linecap="round"/>',
143 clear:
144 '<circle cx="12" cy="12" r="5" fill="#FDB813"/>' +
145 '<path d="M12 2v2.5M12 19.5V22M4.2 4.2l1.8 1.8M18 18l1.8 1.8M2 12h2.5M19.5 12H22M4.2 19.8L6 18M18 6l1.8-1.8" fill="none" stroke="#FDB813" stroke-width="2" stroke-linecap="round"/>',
146 cloudy: `<circle cx="8.5" cy="8.5" r="4.5" fill="#FDB813"/>${cloud('#dfe8ec')}`,
147 showers: `${cloud('#b9c6cd').replace('M17.5 19', 'M17.5 16').replace('6 16.5V17a2 2 0 0 0 2 2z', '6 13.5V14a2 2 0 0 0 2 2z').replace('-8.84', '-8.84')}<path d="M9 18.5l-1 3M13 18.5l-1 3M17 18.5l-1 3" fill="none" stroke="#4aa3ff" stroke-width="2" stroke-linecap="round"/>`,
148 storm: `${cloud('#8a98a3').replace('M17.5 19', 'M17.5 16').replace('6 16.5V17a2 2 0 0 0 2 2z', '6 13.5V14a2 2 0 0 0 2 2z')}<path d="M13 13l-4 5h3l-1.5 4.5 5-6h-3.2z" fill="#FFD54A"/>`,
149 hurricane:
150 '<path d="M9.59 4.59A2 2 0 1 1 11 8H2M12.59 19.41A2 2 0 1 0 14 16H2M17.73 7.73A2.5 2.5 0 1 1 19.5 12H2" fill="none" stroke="#e34948" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>',
151 }
152
153 return art[name] ?? ''
154}
155
156const EMOJI: Record<string, string> = { mail: '📧', calendar: '📅', tasks: '✅', notes: '📝', music: '🎵' }
157
158const MAIL_OPEN =
159 '<path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="M22 10l-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/>'
160
161// A chip's icon with its count on it: the line icon (mail shows an open
162// envelope while its dropdown is open) and a small filled circle at the
163// corner with the number; no circle at zero.
164function chipSvg(name: string, count: string, isOpen: boolean, tint: string, badge: string, day: number) {
165 const dot =
166 count === '' || count === '0' || count === '…' || count === '–'
167 ? ''
168 : `<circle cx="22" cy="5" r="7" fill="${badge}" stroke="#1b1f22" stroke-width="1.5"/><text x="22" y="8.2" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif" font-size="${count.length > 1 ? 8 : 10}" font-weight="700" fill="#ffffff">${count}</text>`
169 const ring = isOpen ? `<rect x="0" y="1" width="24" height="22" rx="5" fill="none" stroke="${tint}" stroke-width="1.5" opacity="0.9"/>` : ''
170 const scale = isOpen ? '<g transform="translate(2.4 2.4) scale(0.8)">' : '<g>'
171
172 return `<svg xmlns="http://www.w3.org/2000/svg" width="22" height="20" viewBox="-1 -3 31 27">${ring}${scale}${colorIcon(name, day)}</g>${dot}</svg>`
173}
174
175function svg(name: string, color: string) {
176 return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] ?? ''}</svg>`
177}
178
179const CELLS = 10
180const filled = (percent: number) => Math.max(0, Math.min(CELLS, Math.round((percent / 100) * CELLS)))
181
182const k = (n: number) => {
183 if (n >= 1000000) return `${(n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1)}M`
184 if (n >= 1000) return `${Math.round(n / 1000)}k`
185 return `${n}`
186}
187
188// The short text: the status line, where the band does not draw.
189function brief(history: Reading[]) {
190 const now = history[history.length - 1]
191
192 return now === undefined ? undefined : `${weather(now.percent)} ${now.percent}% ${k(now.tokens)}/${k(now.window)}`
193}
194
195// The long text: what /weather prints.
196function detail(history: Reading[]) {
197 const now = history[history.length - 1]
198
199 if (now === undefined) {
200 return undefined
201 }
202
203 const top = Math.max(...history.map(r => r.tokens), 1)
204 const spark = history.map(r => BARS[Math.min(7, Math.floor((r.tokens / top) * 7))]).join('')
205 const prev = history[history.length - 2]
206 const delta = prev === undefined ? 0 : now.tokens - prev.tokens
207 const move = delta === 0 ? '' : ` ${delta > 0 ? '+' : '-'}${k(Math.abs(delta))} last turn`
208 const n = filled(now.percent)
209
210 return `${weather(now.percent)} ${'█'.repeat(n)}${'░'.repeat(CELLS - n)} ${now.percent}% ${k(now.tokens)} / ${k(now.window)} ${spark}${move}`
211}
212
213const pad = (n: number) => String(n).padStart(2, '0')
214
215const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
216
217// "Dana Lee <dana@x.com>" as "Dana Lee"; a bare program address such as
218// "no-reply@amazonpay.in" as its company, "Amazonpay"; a person's bare address
219// as the part before the @.
220function who(sender: string) {
221 const name = sender.replace(/<.*>/, '').replace(/"/g, '').trim()
222
223 if (name !== '' && !name.includes('@')) {
224 return clip(name, 18)
225 }
226
227 const [local = '', domain = ''] = (name === '' ? sender.replace(/[<>]/g, '') : name).split('@')
228
229 if (!MACHINE.test(`${local}@${domain}`) && !/^(welcome|hello|team|news)$/i.test(local)) {
230 return clip(local, 18)
231 }
232
233 const parts = domain.split('.').filter(part => !/^(com|in|co|io|org|net|bank|so|ai|app)$/i.test(part))
234 const company = parts[parts.length - 1] ?? domain
235
236 return clip(company.charAt(0).toUpperCase() + company.slice(1), 18)
237}
238
239// When the meeting is, as short as it can be said: "in 25m", "now",
240// "today 18:30", "Wed 07 Oct 18:30".
241function when(meeting: Meeting, nowMs: number) {
242 const minutes = Math.round((meeting.startMs - nowMs) / 60000)
243
244 if (minutes <= 0) {
245 return 'now'
246 }
247
248 if (minutes < 60) {
249 return `in ${minutes}m`
250 }
251
252 const at = new Date(meeting.startMs)
253 const time = `${pad(at.getHours())}:${pad(at.getMinutes())}`
254 const isToday = new Date(nowMs).toDateString() === at.toDateString()
255
256 return isToday
257 ? `today ${time}${minutes < 180 ? ` (in ${Math.floor(minutes / 60)}h ${minutes % 60}m)` : ''}`
258 : `${DAYS[at.getDay()]} ${pad(at.getDate())} ${MONTHS[at.getMonth()]} ${time}`
259}
260
261// The connector that has both `tool` and `sibling`, as the engine names it.
262// One tool name is not enough: the app's own session tools also have a
263// `list_events`, so the calendar is the server that has `list_calendars` too.
264async function serverOf($: EngineInterface, tool: string, sibling: string, fallback: string) {
265 const names = (await $.tool.list()).map(t => t.name).filter(name => name.startsWith('mcp__'))
266 const servers = names
267 .filter(name => name.endsWith(`__${tool}`))
268 .map(name => name.slice(5, name.length - tool.length - 2))
269
270 return servers.find(server => names.includes(`mcp__${server}__${sibling}`)) ?? fallback
271}
272
273async function ask($: EngineInterface, tool: string, sibling: string, fallback: string, args: Record<string, unknown>) {
274 const server = await serverOf($, tool, sibling, fallback)
275 let result = await $.mcp.call(server, tool, args)
276
277 // Google answers "currently unavailable" now and then: one more try.
278 if (result.isError) {
279 await $.clock.sleep(1500)
280 result = await $.mcp.call(server, tool, args)
281 }
282
283 const text = result.content.map(block => ('text' in block ? String(block.text) : '')).join('')
284
285 if (result.isError) {
286 throw new Error(text.slice(0, 160) || 'connector error')
287 }
288
289 return JSON.parse(text === '' ? '{}' : text)
290}
291
292// A Link refuses its whole tree over an address it does not take: https
293// only, printable ASCII, no raw "@" (Gmail's has `authuser=me@gmail.com`).
294function href(url: string) {
295 try {
296 const clean = new URL(url).href.replace(/@/g, '%40').replace(/\|/g, '%7C')
297
298 return clean.startsWith('https://') && clean.length <= 2048 && /^[!-~]+$/.test(clean) ? clean : ''
299 } catch {
300 return ''
301 }
302}
303
304// "25m ago", "3h ago", "Tue 29 Sep"
305function ago(atMs: number, nowMs: number) {
306 const minutes = Math.max(0, Math.round((nowMs - atMs) / 60000))
307
308 if (minutes < 60) {
309 return `${minutes}m ago`
310 }
311
312 if (minutes < 24 * 60) {
313 return `${Math.floor(minutes / 60)}h ago`
314 }
315
316 const at = new Date(atMs)
317
318 return `${DAYS[at.getDay()]} ${pad(at.getDate())} ${MONTHS[at.getMonth()]}`
319}
320
321async function readInbox($: EngineInterface): Promise<Inbox> {
322 try {
323 const found = await ask($, 'search_threads', 'get_thread', 'claude.ai Gmail', { query: MAIL_QUERY, pageSize: 20 })
324 const threads: { id?: string; viewUrl?: string; messages?: { sender?: string; subject?: string; date?: string; labelIds?: string[] }[] }[] =
325 found.threads ?? []
326 const all = threads.map(thread => {
327 const messages = thread.messages ?? []
328 const last = messages[messages.length - 1]
329 const sender = last?.sender ?? ''
330 const subject = (last?.subject ?? '(no subject)').trim()
331
332 return {
333 isNoise: /github\.com/i.test(sender) && NOISE.test(subject),
334 isPromo: false,
335 mail: {
336 id: thread.id ?? '',
337 from: who(sender),
338 subject: clip(subject, 58),
339 atMs: Date.parse(last?.date ?? '') || 0,
340 url: href(thread.viewUrl ?? ''),
341 isUnread: messages.some(m => (m.labelIds ?? []).includes('UNREAD')),
342 },
343 }
344 })
345 const list = all
346 .filter(one => !one.isPromo && !one.isNoise)
347 .map(one => one.mail)
348 .sort((a, b) => b.atMs - a.atMs)
349 .slice(0, MAIL_ROWS)
350
351 return { count: list.filter(m => m.isUnread).length, other: all.filter(one => one.isPromo).length, list, error: '' }
352 } catch (error) {
353 return { count: 0, other: 0, list: [], error: String(error).slice(0, 160) }
354 }
355}
356
357// ------------------------------------------------------------------ calendar: add an event
358
359// "2026-10-03T18:00:00+02:00": the machine's own time with its offset.
360function isoLocal(ms: number) {
361 const at = new Date(ms)
362 const off = -at.getTimezoneOffset()
363 const sign = off >= 0 ? '+' : '-'
364
365 return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}:${pad(at.getMinutes())}:00${sign}${pad(Math.floor(Math.abs(off) / 60))}:${pad(Math.abs(off) % 60)}`
366}
367
368// A typed or dictated line as an event, with no model: a clock time ("6pm",
369// "at 18:30", "at 6") is needed; a day ("today", "tomorrow", "friday") and a
370// length ("for 45m", "for 1h") are optional; the rest is the title.
371function eventOf(text: string, nowMs: number) {
372 let rest = ` ${text.trim()} `
373 const take = (re: RegExp) => {
374 const hit = re.exec(rest)
375
376 if (hit !== null) {
377 rest = rest.replace(hit[0], ' ')
378 }
379
380 return hit
381 }
382 const length = take(/\bfor (\d+(?:\.\d+)?)\s*(m|min|mins|minutes?|h|hr|hrs|hours?)\b/i)
383 const clock =
384 take(/\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i) ?? take(/\bat\s+(\d{1,2})(?::(\d{2}))?\b/i) ?? take(/\b(\d{1,2}):(\d{2})\b/)
385
386 if (clock === null) {
387 return null
388 }
389
390 const day = take(/\b(?:on\s+|this\s+|next\s+)?(today|tomorrow|tmrw|mon(?:day)?|tue(?:s|sday)?|wed(?:nesday)?|thu(?:r|rs|rsday)?|fri(?:day)?|sat(?:urday)?|sun(?:day)?)\b/i)
391 let hour = Number(clock[1])
392 const half = (clock[3] ?? '').toLowerCase()
393
394 if (half === 'pm' && hour < 12) {
395 hour += 12
396 } else if (half === 'am' && hour === 12) {
397 hour = 0
398 } else if (half === '' && hour >= 1 && hour <= 7) {
399 hour += 12 // "at 6" in a working day means the evening
400 }
401
402 if (hour > 23) {
403 return null
404 }
405
406 const start = new Date(nowMs)
407 start.setHours(hour, Number(clock[2] ?? 0), 0, 0)
408 const word = (day?.[1] ?? '').toLowerCase()
409
410 if (word === 'tomorrow' || word === 'tmrw') {
411 start.setDate(start.getDate() + 1)
412 } else if (word !== '' && word !== 'today') {
413 const want = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'].indexOf(word.slice(0, 3))
414 let ahead = (want - start.getDay() + 7) % 7
415
416 if (ahead === 0 && start.getTime() <= nowMs) {
417 ahead = 7
418 }
419
420 start.setDate(start.getDate() + ahead)
421 } else if (word === '' && start.getTime() <= nowMs) {
422 start.setDate(start.getDate() + 1)
423 }
424
425 const title = rest.replace(/\s+/g, ' ').replace(/\b(at|on)\s*$/i, '').trim()
426 const minutes = length === null ? 30 : Math.round(Number(length[1]) * (length[2]?.toLowerCase().startsWith('h') ? 60 : 1))
427
428 return { title: title === '' ? 'Meeting' : title.charAt(0).toUpperCase() + title.slice(1), startMs: start.getTime(), minutes: Math.max(5, minutes) }
429}
430
431// What is typed in the new-event field and not added yet.
432let draftEvent = ''
433// how many pages each tasks column has, as last drawn
434let pagesOpen = 1
435let pagesDone = 1
436
437async function addEvent($: EngineInterface, text: string) {
438 const line = text.trim()
439 draftEvent = ''
440
441 if (line === '') {
442 return
443 }
444
445 const nowMs = await $.clock.now()
446 const event = eventOf(line, nowMs)
447
448 // No clock time to be read from it: the model works it out, and asks if unsure.
449 if (event === null) {
450 $.ui.toast('No time found in that, so Claude will add it')
451 void $.prompt.submit({ text: `Add this to my Google Calendar (ask me only if the day or time is unclear): ${line}` })
452
453 return
454 }
455
456 try {
457 await ask($, 'create_event', 'list_calendars', 'claude.ai Google Calendar', {
458 summary: event.title,
459 startTime: isoLocal(event.startMs),
460 endTime: isoLocal(event.startMs + event.minutes * 60000),
461 addGoogleMeetUrl: /\b(call|meet|meeting|sync|standup|interview)\b/i.test(line),
462 })
463 $.ui.toast(`Added: ${event.title}, ${when({ title: '', startMs: event.startMs, endMs: event.startMs, url: '', people: 0 }, nowMs).replace(/ \(.*\)$/, '')}`)
464 await poll($, 0)
465 } catch (error) {
466 $.ui.toast(`Calendar did not take that: ${String(error).slice(0, 90)}`)
467 }
468
469 $.ui.invalidate('ui.render')
470}
471
472async function readEvents($: EngineInterface, nowMs: number): Promise<{ events: Meeting[]; error: string }> {
473 try {
474 const found = await ask($, 'list_events', 'list_calendars', 'claude.ai Google Calendar', { pageSize: 15, orderBy: 'startTime' })
475 const raw: Record<string, any>[] = found.events ?? []
476 const events = raw
477 .filter(e => e.status !== 'cancelled' && typeof e.start?.dateTime === 'string')
478 .map(e => ({
479 title: String(e.summary ?? 'Meeting'),
480 startMs: Date.parse(e.start.dateTime),
481 endMs: Date.parse(e.end?.dateTime ?? e.start.dateTime),
482 url: href(String(e.conferenceUrl ?? e.htmlLink ?? '')),
483 people: Array.isArray(e.attendees) ? e.attendees.length : 0,
484 }))
485 .filter(e => e.endMs > nowMs)
486 .sort((a, b) => a.startMs - b.startMs)
487 .slice(0, 15)
488
489 return { events, error: '' }
490 } catch (error) {
491 return { events: [], error: String(error).slice(0, 160) }
492 }
493}
494
495// One dropdown card: a rounded frame, a tinted header row, striped rows.
496// `cells` are plain strings or elements; the last column fills.
497function card(Box: any, Text: any, title: string, head: string[], rows: unknown[][], widths: (number | undefined)[]) {
498 const line = (cells: unknown[], isHead: boolean) =>
499 cells.map((cell, c) => (
500 <Box
501 width={widths[c]}
502 flexGrow={widths[c] === undefined ? 1 : 0}
503 flexShrink={widths[c] === undefined ? 1 : 0}
504 minWidth={widths[c] === undefined ? 0 : widths[c]}
505 overflow="hidden"
506 >
507 {typeof cell === 'string' ? (
508 <Text bold={isHead} color={isHead ? MUTED : undefined}>
509 {cell === '' ? ' ' : cell}
510 </Text>
511 ) : (
512 cell
513 )}
514 </Box>
515 ))
516
517 return (
518 <Box flexDirection="column" width="100%" borderStyle="round" borderColor={EDGE}>
519 <Box paddingX={1}>
520 <Text bold>{title}</Text>
521 </Box>
522 {head.length > 0 && (
523 <Box paddingX={1} backgroundColor={HEAD_BG}>
524 {line(head, true)}
525 </Box>
526 )}
527 {rows.map((cells, r) => (
528 <Box paddingX={1} backgroundColor={r % 2 === 1 ? STRIPE_BG : undefined}>
529 {line(cells, false)}
530 </Box>
531 ))}
532 </Box>
533 )
534}
535
536// ------------------------------------------------------------------ notes
537
538const NOTES_TOOL = 'mcp__deskbar__notes'
539
540// A reminder time said in the note itself: "in 30m", "in 2 hours", "at 5pm",
541// "at 17:30", "tomorrow" (9:00 unless a time is given). No model reads it.
542function dueOf(text: string, nowMs: number) {
543 const lower = text.toLowerCase()
544 const after = /\bin (\d+)\s*(m|min|mins|minutes?|h|hr|hrs|hours?)\b/.exec(lower)
545
546 if (after !== null) {
547 return nowMs + Number(after[1]) * (after[2]?.startsWith('h') ? 3600000 : 60000)
548 }
549
550 const at = /\bat (\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b/.exec(lower)
551 const isTomorrow = /\btomorrow\b/.test(lower)
552
553 if (at === null && !isTomorrow) {
554 return null
555 }
556
557 const due = new Date(nowMs)
558 let hour = at === null ? 9 : Number(at[1])
559
560 if (at !== null && at[3] === 'pm' && hour < 12) {
561 hour += 12
562 }
563
564 if (at !== null && at[3] === 'am' && hour === 12) {
565 hour = 0
566 }
567
568 due.setHours(hour, at === null ? 0 : Number(at[2] ?? 0), 0, 0)
569
570 if (isTomorrow || due.getTime() <= nowMs) {
571 due.setDate(due.getDate() + 1)
572 }
573
574 return due.getTime()
575}
576
577async function storedNotes($: EngineInterface): Promise<Note[]> {
578 const saved = await $.store.get('notes').catch(() => undefined)
579
580 return Array.isArray(saved) ? (saved as Note[]) : []
581}
582
583async function showNotes($: EngineInterface, list: Note[]) {
584 const current = await read($, notes)
585
586 if (JSON.stringify(current) !== JSON.stringify(list)) {
587 await update($, notes, () => list)
588 }
589}
590
591async function saveNotes($: EngineInterface, list: Note[]) {
592 await $.store.set('notes', list)
593 await showNotes($, list)
594}
595
596async function addNote($: EngineInterface, text: string) {
597 const clean = text.trim()
598
599 if (clean === '') {
600 return 'Nothing to save.'
601 }
602
603 const nowMs = await $.clock.now()
604 const list = await storedNotes($)
605 const note: Note = {
606 id: list.reduce((max, n) => Math.max(max, n.id), 0) + 1,
607 text: clean,
608 createdMs: nowMs,
609 dueMs: dueOf(clean, nowMs),
610 doneMs: null,
611 notifiedMs: null,
612 }
613 await saveNotes($, [...list, note])
614
615 return `Note ${note.id} saved${note.dueMs === null ? '' : `, reminder ${ago(note.dueMs, nowMs).replace(' ago', '')} from now`}.`
616}
617
618// What is typed in the note field and not saved yet: the Save button reads
619// it, and a redraw puts it back in the field.
620let draftNote = ''
621
622async function saveDraft($: EngineInterface, text: string) {
623 draftNote = ''
624 await addNote($, text)
625 play($, 'tick')
626 $.ui.invalidate('ui.render')
627}
628
629async function noteToEvent($: EngineInterface, id: number) {
630 const note = (await storedNotes($)).find(n => n.id === id)
631
632 if (note !== undefined) {
633 await addEvent($, note.text)
634 }
635}
636
637// Hands a note to the session as a task of its own; the note is ticked off
638// by the notes tool once the work is done.
639async function noteToClaude($: EngineInterface, id: number) {
640 const note = (await storedNotes($)).find(n => n.id === id)
641
642 if (note === undefined) {
643 return
644 }
645
646 $.ui.toast('Handed to Claude in this session')
647 void $.prompt.submit({
648 text: `From my notes: ${note.text}\n\nPlease do this now. When it is handled, mark note ${id} done with the ${NOTES_TOOL} tool.`,
649 })
650}
651
652async function finishNote($: EngineInterface, id: number) {
653 const nowMs = await $.clock.now()
654 const list = await storedNotes($)
655 await saveNotes($, list.map(n => (n.id === id && n.doneMs === null ? { ...n, doneMs: nowMs } : n)))
656
657 return list.some(n => n.id === id) ? `Note ${id} marked done.` : `No note ${id}.`
658}
659
660async function clearDone($: EngineInterface) {
661 await saveNotes($, (await storedNotes($)).filter(n => n.doneMs === null))
662}
663
664// Once a minute: pick up notes other sessions wrote, and toast what is due.
665async function remind($: EngineInterface, nowMs: number) {
666 const list = await storedNotes($)
667 const due = list.filter(n => n.doneMs === null && n.dueMs !== null && n.dueMs <= nowMs && n.notifiedMs === null)
668
669 if (due.length === 0) {
670 await showNotes($, list)
671
672 return
673 }
674
675 for (const note of due) {
676 $.ui.toast(`Reminder: ${note.text}`, { timeoutMs: 15000 })
677 play($, 'chime')
678 }
679
680 await saveNotes($, list.map(n => (due.some(d => d.id === n.id) ? { ...n, notifiedMs: nowMs } : n)))
681}
682
683function notesText(list: Note[], nowMs: number) {
684 const open = list.filter(n => n.doneMs === null)
685
686 return open.length === 0
687 ? 'No open notes.'
688 : open
689 .map(n => `${n.id}. ${n.text}${n.dueMs === null ? '' : n.dueMs <= nowMs ? ' (due now)' : ` (due ${new Date(n.dueMs).toString().slice(0, 21)})`}`)
690 .join('\n')
691}
692
693async function notesTool($: EngineInterface, action: string, text: string, id: number) {
694 if (action === 'add') {
695 return addNote($, text)
696 }
697
698 if (action === 'done') {
699 return finishNote($, id)
700 }
701
702 return notesText(await storedNotes($), await $.clock.now())
703}
704
705// ------------------------------------------------------------------ tasks
706
707let sessionNames: Map<string, { name: string; size: number }> | undefined
708const taskCache = new Map<string, { stamp: number; task: Task }>()
709
710// The user's own ~/.claude, found once at the start of the session: the data
711// folder sits there wherever the plugin itself is installed.
712let claudeHome = ''
713
714async function findHome($: EngineInterface) {
715 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
716
717 if (home !== '') {
718 claudeHome = `${home.replace(/\\/g, '/').replace(/\/+$/, '')}/.claude`
719 }
720}
721
722function claudeDir($: EngineInterface) {
723 if (claudeHome !== '') {
724 return claudeHome
725 }
726
727 const root = $.plugin.root.replace(/\\/g, '/')
728 const at = root.indexOf('/.claude/')
729
730 return at < 0 ? '' : root.slice(0, at + 8)
731}
732
733// Which project each session belongs to, from the transcripts' folders.
734async function namesOf($: EngineInterface, dir: string) {
735 if (sessionNames !== undefined) {
736 return sessionNames
737 }
738
739 const names = new Map<string, { name: string; size: number }>()
740 const projects = await $.fs.list(`${dir}/projects`).catch(() => [])
741
742 for (const project of projects.filter(entry => entry.kind === 'dir')) {
743 const files = await $.fs.list(`${dir}/projects/${project.name}`).catch(() => [])
744
745 for (const file of files) {
746 if (file.name.endsWith('.jsonl')) {
747 names.set(file.name.slice(0, -6), { name: project.name.replace(/^[A-Za-z]--/, '').replace(/-/g, ' '), size: file.size })
748 }
749 }
750 }
751
752 sessionNames = names
753
754 return names
755}
756
757async function loadTasks($: EngineInterface, session: string): Promise<Task[]> {
758 const dir = `${claudeDir($)}/tasks/${session}`
759 const files = (await $.fs.list(dir).catch(() => [])).filter(entry => entry.name.endsWith('.json'))
760 const tasks: Task[] = []
761
762 for (const file of files) {
763 const path = `${dir}/${file.name}`
764 const cached = taskCache.get(path)
765
766 if (cached !== undefined && cached.stamp === file.mtimeMs) {
767 tasks.push(cached.task)
768 continue
769 }
770
771 try {
772 const raw = JSON.parse(await $.fs.read(path))
773 const task = { id: String(raw.id ?? file.name.slice(0, -5)), subject: String(raw.subject ?? ''), status: String(raw.status ?? 'pending') }
774 taskCache.set(path, { stamp: file.mtimeMs, task })
775 tasks.push(task)
776 } catch {
777 // a task file being written is read on the next pass
778 }
779 }
780
781 return tasks.sort((a, b) => Number(a.id) - Number(b.id))
782}
783
784const isOpenTask = (task: Task) => task.status !== 'completed' && task.status !== 'deleted'
785
786// This session's open count, for the chip.
787async function countTasks($: EngineInterface) {
788 if (isUnattended || !deskOn || claudeDir($) === '') {
789 return
790 }
791
792 const mine = await $.session.id()
793 const tasks = await loadTasks($, mine)
794 const open = tasks.length === 0 ? -1 : tasks.filter(isOpenTask).length
795 const current = await read($, todo)
796
797 if (current.open !== open || current.selected === mine) {
798 await update($, todo, state => ({ ...state, open, tasks: state.selected === mine ? tasks : state.tasks }))
799 }
800}
801
802async function loadSessions($: EngineInterface): Promise<TodoSession[]> {
803 const dir = claudeDir($)
804 const names = await namesOf($, dir)
805 const folders = (await $.fs.list(`${dir}/tasks`).catch(() => [])).filter(entry => entry.kind === 'dir')
806 const found: { session: TodoSession; newest: number; size: number }[] = []
807
808 // every session's folder is listed at once, not one after another
809 const listings = await Promise.all(folders.map(folder => $.fs.list(`${dir}/tasks/${folder.name}`).catch(() => [])))
810
811 for (const [at, folder] of folders.entries()) {
812 const files = (listings[at] ?? []).filter(entry => entry.name.endsWith('.json'))
813
814 if (files.length > 0) {
815 found.push({
816 session: { id: folder.name, name: names.get(folder.name)?.name ?? folder.name.slice(0, 8), count: files.length },
817 newest: Math.max(...files.map(file => file.mtimeMs)),
818 size: names.get(folder.name)?.size ?? 0,
819 })
820 }
821 }
822
823 // One entry per project: its main session, the one with the longest
824 // transcript. Side sessions (forks, scheduled runs, short one-offs) are left out.
825 const mine = await $.session.id()
826 const main = new Map<string, { session: TodoSession; newest: number; size: number }>()
827
828 for (const one of found) {
829 const kept = main.get(one.session.name)
830
831 if (kept === undefined || one.session.id === mine || (kept.session.id !== mine && one.size > kept.size)) {
832 main.set(one.session.name, one)
833 }
834 }
835
836 return [...main.values()].sort((a, b) => b.newest - a.newest).map(one => one.session)
837}
838
839async function pickSession($: EngineInterface, session: string) {
840 if (settings.demo) {
841 await update($, todo, state => ({ ...state, selected: session }))
842
843 return
844 }
845
846 await update($, todo, state => ({ ...state, selected: session, tasks: [], isLoading: true, pageOpen: 0, pageDone: 0 }))
847 const tasks = await loadTasks($, session)
848 await update($, todo, state => (state.selected === session ? { ...state, tasks, isLoading: false } : state))
849}
850
851async function toggleTodo($: EngineInterface) {
852 if (!(await setPanel($, 'todo'))) {
853 return
854 }
855
856 const sessions = await loadSessions($)
857 const mine = await $.session.id()
858 const current = await read($, todo)
859 const selected = sessions.some(s => s.id === current.selected) ? current.selected : sessions.some(s => s.id === mine) ? mine : (sessions[0]?.id ?? '')
860 await update($, todo, state => ({ ...state, sessions }))
861
862 if (selected !== '') {
863 await pickSession($, selected)
864 }
865}
866
867// The sessions the search field leaves: every word typed must be in the name.
868function matching(sessions: TodoSession[], query: string) {
869 const words = (query ?? '').toLowerCase().split(/\s+/).filter(word => word !== '')
870
871 return words.length === 0 ? sessions : sessions.filter(s => words.every(word => s.name.toLowerCase().includes(word)))
872}
873
874async function stepSession($: EngineInterface, by: number) {
875 const { sessions, selected, query } = await read($, todo)
876 const list = matching(sessions, query)
877
878 if (list.length === 0) {
879 return
880 }
881
882 const at = Math.max(0, list.findIndex(s => s.id === selected))
883 await pickSession($, list[(at + by + list.length) % list.length]?.id ?? selected)
884}
885
886// Enter in the search field: keep the words, and show the first session found.
887async function searchSessions($: EngineInterface, query: string) {
888 const clean = query.trim()
889 await update($, todo, state => ({ ...state, query: clean }))
890 const { sessions, selected } = await read($, todo)
891 const list = matching(sessions, clean)
892
893 if (list.length > 0 && !list.some(s => s.id === selected)) {
894 await pickSession($, list[0]?.id ?? selected)
895 }
896}
897
898// The "more" button of a column: on to its next page of tasks, and from the
899// last page back to the first. The list turns in place; nothing scrolls.
900async function turnPage($: EngineInterface, isDone: boolean, pages: number) {
901 await update($, todo, state =>
902 isDone
903 ? { ...state, pageDone: ((state.pageDone ?? 0) + 1) % Math.max(1, pages) }
904 : { ...state, pageOpen: ((state.pageOpen ?? 0) + 1) % Math.max(1, pages) },
905 )
906}
907
908async function pickMine($: EngineInterface) {
909 await update($, todo, state => ({ ...state, query: '' }))
910 await pickSession($, await $.session.id())
911}
912
913// ------------------------------------------------------------------ music
914
915// A mod cannot hear the speaker or reach Windows' media session, so a small
916// Python helper does (helper/music_helper.py, its files in the data folder): it writes state.json
917// twelve times a second, takes play / next / prev from cmd.txt, and lives only
918// while alive.txt is touched, which this does while the dropdown is open.
919let musicTimer: Timer | undefined
920let isHelperUp = false
921let isFraming = false
922let frames = 0
923let peaks: number[] = []
924let cover = { key: '', uri: '' }
925// The moving bars cost a redraw of the whole row five times a second, which
926// can make a hovered button flicker; with them off the row redraws once a second.
927// Surface modules (regions that animate by themselves) run in the terminal,
928// and in the desktop app from Claude Code 2.1.287; an older desktop build
929// keeps their room and draws nothing in it. The version is read once.
930let engine = [0, 0, 0]
931
932async function readEngine($: EngineInterface) {
933 const { base, version } = await $.session.version().catch(() => ({ base: undefined, version: '' }))
934 const found = /(\d+)\.(\d+)\.(\d+)/.exec(base ?? version ?? '')
935 engine = found === null ? [0, 0, 0] : [Number(found[1]), Number(found[2]), Number(found[3])]
936}
937
938function runsModules(surface: string) {
939 const [major = 0, minor = 0, patch = 0] = engine
940
941 return surface === 'terminal' || major > 2 || (major === 2 && (minor > 1 || (minor === 1 && patch >= 287)))
942}
943
944// whether this surface runs surface modules, as the last drawing found it
945let hasModules = false
946
947const isClassic = () => settings.bars !== 'off' && !hasModules
948// After a press the display already shows the new state: what the helper wrote
949// before it acted is not allowed to flip it back for a moment.
950let holdUntil = 0
951
952const musicDir = ($: EngineInterface) => `${claudeDir($)}/deskbar-data/music`
953
954async function frame($: EngineInterface) {
955 if (isFraming) {
956 return
957 }
958
959 isFraming = true
960
961 try {
962 if (!deskOn || (await shown($)) !== 'music') {
963 musicTimer?.cancel()
964 musicTimer = undefined
965
966 return
967 }
968
969 frames += 1
970 const dir = musicDir($)
971
972 if (frames % 5 === 1) {
973 await $.fs.write(`${dir}/alive.txt`, String(await $.clock.now()))
974 }
975
976 const raw = JSON.parse(await $.fs.read(`${dir}/state.json`))
977 const next: Music = {
978 at: Number(raw.at) || 0,
979 bars: isClassic() && Array.isArray(raw.bars) ? raw.bars.map((v: unknown) => Number(v) || 0) : [],
980 has: raw.has === true,
981 title: String(raw.title ?? ''),
982 artist: String(raw.artist ?? ''),
983 app: String(raw.app ?? ''),
984 playing: Date.now() < holdUntil ? ((await read($, music))?.playing ?? raw.playing === true) : raw.playing === true,
985 now: Number(raw.now) || 0,
986 duration: Number(raw.duration) || 0,
987 error: String(raw.spectrumError ?? raw.error ?? ''),
988 coverKey: String(raw.coverKey ?? ''),
989 }
990
991 // a new song: its cover, a small JPEG the helper wrote once
992 if (next.coverKey !== cover.key) {
993 const art = JSON.parse(await $.fs.read(`${dir}/cover.json`))
994
995 if (art.key === next.coverKey) {
996 cover = { key: next.coverKey, uri: typeof art.jpeg === 'string' && art.jpeg !== '' ? `data:image/jpeg;base64,${art.jpeg}` : '' }
997 }
998 }
999 // peak caps: jump up with a bar, then fall one step every few frames
1000 peaks = next.bars.map((bar, i) => Math.max(bar, (peaks[i] ?? 0) - (frames % 3 === 0 ? 1 : 0)))
1001 const current = await read($, music)
1002
1003 // without the bars only a new song, play or pause, or the next second redraws
1004 const same = (m: Music) => JSON.stringify(isClassic() ? { ...m, at: 0 } : { ...m, at: 0, now: Math.floor(m.now) })
1005
1006 if (current === null || same(current) !== same(next)) {
1007 await update($, music, () => next)
1008 }
1009 } catch {
1010 // the helper is between two writes, or not up yet: the next frame reads
1011 } finally {
1012 isFraming = false
1013 }
1014
1015 // The helper stops when a newer one is saved, or if it crashed: start it again.
1016 if (!isHelperUp && musicTimer !== undefined && frames % 10 === 0) {
1017 startMusic($)
1018 }
1019}
1020
1021function startMusic($: EngineInterface) {
1022 const dir = musicDir($)
1023
1024 if (!isHelperUp) {
1025 isHelperUp = true
1026 void (async () => {
1027 try {
1028 await $.fs.write(`${dir}/alive.txt`, String(await $.clock.now()))
1029 const helper = $.process.spawn({
1030 argv: [`${dir}/.venv/Scripts/python.exe`, `${$.plugin.root}/helper/music_helper.py`, dir],
1031 cwd: dir,
1032 })
1033
1034 for await (const _ of helper) {
1035 // it prints nothing; the loop only keeps it alive
1036 }
1037 } catch {
1038 // no helper: the display says so
1039 } finally {
1040 isHelperUp = false
1041 }
1042 })()
1043 }
1044
1045 if (musicTimer === undefined) {
1046 musicTimer = $.clock.every(200, () => {
1047 void frame($)
1048 })
1049 }
1050}
1051
1052// What the smooth bars' own region asks for, several times a second: the
1053// speaker's latest bar heights, straight from the helper's file. Answering
1054// its message with props redraws that region alone, never the row.
1055async function liveBars($: EngineInterface) {
1056 try {
1057 const raw = JSON.parse(await $.fs.read(`${musicDir($)}/state.json`))
1058 const isFresh = Date.now() / 1000 - (Number(raw.at) || 0) < 3
1059
1060 return { bars: Array.isArray(raw.bars) ? raw.bars : [], live: isFresh && raw.has === true && raw.playing === true }
1061 } catch {
1062 return { bars: [], live: false }
1063 }
1064}
1065
1066async function toggleMusic($: EngineInterface) {
1067 if (await setPanel($, 'music')) {
1068 startMusic($)
1069 }
1070}
1071
1072async function sendMusic($: EngineInterface, action: string) {
1073 // the helper first: it watches this file fifty times a second
1074 await $.fs.write(`${musicDir($)}/cmd.txt`, `${Date.now()} ${action}`)
1075
1076 // then the display, without waiting to hear back: play and pause flip at once
1077 if (action === 'toggle' || action === 'stop') {
1078 await update($, music, m => (m === null ? m : { ...m, playing: action === 'toggle' ? !m.playing : false }))
1079 holdUntil = Date.now() + 700
1080 }
1081}
1082
1083async function openYouTubeMusic($: EngineInterface) {
1084 await $.process.run(['cmd', '/c', 'start', '', 'https://music.youtube.com']).catch(() => undefined)
1085 startMusic($)
1086}
1087
1088const xml = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
1089
1090const mmss = (seconds: number) => `${Math.floor(Math.max(0, seconds) / 60)}:${pad(Math.floor(Math.max(0, seconds) % 60))}`
1091
1092// The retro deck: a dark hi-fi faceplate with a green phosphor display (track,
1093// artist, time and a progress line) and an LED spectrum analyser, nine
1094// segments a bar, green then amber then red, with falling peak caps.
1095function deck(m: Music | null, nowMs: number, width: number, art: string, hasBars: boolean) {
1096 const isLive = m !== null && nowMs / 1000 - m.at < 3
1097 // with the bars in a region of their own, the picture is the display alone
1098 const W_ = hasBars ? Math.max(420, width) : Math.max(300, Math.round(width * 0.54))
1099 const H_ = 96
1100 const split = hasBars ? Math.round(W_ * 0.52) : W_ - 6
1101 const phosphor = '#7dff6b'
1102 const dimmed = '#2f6b33'
1103 const title = !isLive ? 'STARTING…' : !m.has ? 'NO MUSIC PLAYING' : m.title === '' ? 'UNTITLED' : m.title
1104 const artist = !isLive ? 'waking the deck' : !m.has ? 'open YouTube Music and press play' : m.artist
1105 // the cover (or an empty record sleeve) takes the left of the display
1106 const left = 92
1107 const chars = Math.max(10, Math.floor((split - left - 46) / 9.2))
1108 const part = isLive && m.has && m.duration > 0 ? Math.max(0, Math.min(1, m.now / m.duration)) : 0
1109 const barsLeft = split + 16
1110 const barsWide = W_ - barsLeft - 16
1111 const n = 24
1112 const step = barsWide / n
1113 const led = (i: number) => (i >= 8 ? '#ff4d4d' : i >= 6 ? '#ffc93c' : '#3ddc5a')
1114 let leds = ''
1115
1116 for (let b = 0; b < n; b += 1) {
1117 const height = isLive ? Math.max(0, Math.min(9, m.bars[b] ?? 0)) : 0
1118 const peak = isLive ? Math.max(0, Math.min(9, peaks[b] ?? 0)) : 0
1119 const x = (barsLeft + b * step + 1).toFixed(1)
1120 const w = Math.max(3, step - 3).toFixed(1)
1121
1122 for (let i = 0; i < 9; i += 1) {
1123 const y = 80 - i * 7.6
1124 const isOn = i < height
1125 leds += `<rect x="${x}" y="${y.toFixed(1)}" width="${w}" height="5.6" rx="1" fill="${isOn ? led(i) : '#13251a'}"${isOn ? '' : ' opacity="0.9"'}/>`
1126 }
1127
1128 if (peak > 0 && peak >= height) {
1129 leds += `<rect x="${x}" y="${(80 - (peak - 1) * 7.6 - 2.4).toFixed(1)}" width="${w}" height="1.8" fill="#eafff0"/>`
1130 }
1131 }
1132
1133 const state = !isLive || !m.has ? '■' : m.playing ? '▶' : '❚❚'
1134
1135 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W_}" height="${H_}" viewBox="0 0 ${W_} ${H_}">
1136<defs><linearGradient id="face" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2b2f33"/><stop offset="1" stop-color="#15181b"/></linearGradient>
1137<pattern id="scan" width="4" height="3" patternUnits="userSpaceOnUse"><rect width="4" height="1" fill="#000000" opacity="0.28"/></pattern></defs>
1138<rect x="0.5" y="0.5" width="${W_ - 1}" height="${H_ - 1}" rx="9" fill="url(#face)" stroke="#3b4146"/>
1139<rect x="10" y="9" width="${split - 14}" height="78" rx="5" fill="#07130a" stroke="#0e2a14"/>
1140<clipPath id="sleeve"><rect x="20" y="17" width="62" height="62" rx="6"/></clipPath>
1141<rect x="20" y="17" width="62" height="62" rx="6" fill="#0d2413" stroke="#1c4a26"/>
1142<circle cx="51" cy="48" r="22" fill="none" stroke="#1c4a26" stroke-width="8"/><circle cx="51" cy="48" r="4" fill="#2f6b33"/>
1143${art === '' || !isLive || !m.has ? '' : `<image href="${art}" x="20" y="17" width="62" height="62" clip-path="url(#sleeve)" preserveAspectRatio="xMidYMid slice"/>`}
1144<text x="${left}" y="33" font-family="Consolas, 'Courier New', monospace" font-size="15" font-weight="700" fill="${phosphor}">${state}</text>
1145<text x="${left + 26}" y="33" font-family="Consolas, 'Courier New', monospace" font-size="15" font-weight="700" fill="${phosphor}">${xml(clip(title.toUpperCase(), chars))}</text>
1146<text x="${left + 26}" y="52" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="${dimmed}" style="fill:#4fbf55">${xml(clip(artist, chars + 8))}</text>
1147<rect x="${left}" y="64" width="${split - left - 86}" height="4" rx="2" fill="#12301a"/>
1148<rect x="${left}" y="64" width="${((split - left - 86) * part).toFixed(1)}" height="4" rx="2" fill="${phosphor}"/>
1149<text x="${split - 12}" y="70" text-anchor="end" font-family="Consolas, 'Courier New', monospace" font-size="11" fill="${phosphor}">${isLive && m.has ? `${mmss(m.now)}${m.duration > 0 ? `/${mmss(m.duration)}` : ''}` : '-:--'}</text>
1150<rect x="10" y="9" width="${split - 14}" height="78" rx="5" fill="url(#scan)"/>
1151${hasBars ? `<rect x="${split + 6}" y="9" width="${W_ - split - 16}" height="78" rx="5" fill="#060b08" stroke="#0e2a14"/>\n${leds}` : ''}
1152</svg>`
1153}
1154
1155// ------------------------------------------------------------------ mail and calendar
1156
1157// `maxAgeMs` is how old a kept answer may be before Google is asked again:
1158// the background timer accepts a full period, a click or a finished turn
1159// wants it fresh.
1160async function poll($: EngineInterface, maxAgeMs = POLL_MS - 10000) {
1161 if (isPolling || isUnattended || !deskOn) {
1162 return
1163 }
1164
1165 isPolling = true
1166
1167 try {
1168 const nowMs = await $.clock.now()
1169 // Every open session runs this timer; only the first one in a period asks
1170 // Google, the others take what it kept in the plugin's store.
1171 const kept = (await $.store.get('desk').catch(() => undefined)) as (Desk & { atMs?: number }) | undefined
1172
1173 if (kept !== undefined && typeof kept.atMs === 'number' && nowMs - kept.atMs < maxAgeMs && kept.inbox !== null && kept.inbox.error === '') {
1174 await update($, desk, () => ({ inbox: kept.inbox, events: kept.events ?? [], calendarError: kept.calendarError ?? '', nowMs }))
1175
1176 return
1177 }
1178
1179 const inbox = await readInbox($)
1180 const { events, error } = await readEvents($, nowMs)
1181 await $.store.set('desk', { inbox, events, calendarError: error, nowMs, atMs: nowMs }).catch(() => undefined)
1182 await update($, desk, () => ({ inbox, events, calendarError: error, nowMs }))
1183 } finally {
1184 isPolling = false
1185 }
1186}
1187
1188async function minute($: EngineInterface) {
1189 if (!deskOn) {
1190 return
1191 }
1192
1193 const nowMs = await $.clock.now()
1194 await readSettings($) // another session may have changed them
1195
1196 // a chime ten minutes before a meeting, once for each
1197 const soon = (await read($, desk)).events?.find(event => event.startMs - nowMs > 0 && event.startMs - nowMs <= 10 * 60000)
1198
1199 if (soon !== undefined && !chimed.includes(soon.startMs)) {
1200 chimed = [...chimed, soon.startMs].slice(-20)hooks/timer.tsx 1105 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Config, Day, Entry, Row, Running, Snap } from '../types'
5
6import { meterSvg, pulseSvg } from './motion'
7
8// The ledger format is the one an optional timelog/timelog.py writes, so
9// the script, its Excel report and this mod all read and write the same files.
10const TOOL = 'mcp__deskbar__timer'
11const PANE = 'deskbar-timer'
12const SLIM = 14
13const WIDE = 48
14const MAX_SANE_HOURS = 10
15const GREEN = '#5fcfa7'
16const AMBER = '#f0a45d'
17const GREY = '#9fb1b6'
18const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
19const CELLS = 10
20
21const snap = atom({ plugin: 'deskbar', key: 'snap' } as const, null)
22const isOpen = atom({ plugin: 'deskbar', key: 'isOpen' } as const, false)
23// When the table was opened (0 while closed). the desk row keeps the same
24// for its dropdowns, and each mod reads the other's: only the one opened last
25// is drawn, so one dropdown shows at a time across both.
26const openAt = atom({ plugin: 'deskbar', key: 'openAt' } as const, 0)
27// The side panel is the view; the band above the prompt is opt-in (/timer show).
28const isHidden = atom({ plugin: 'deskbar', key: 'timerHidden' } as const, false)
29
30// `isFresh`: the project has no timelog folder yet; the first start makes it.
31type Context = { dir: string; config: Config; hasScript: boolean; isFresh?: boolean }
32
33let ctx: Context | null = null
34let ledger: Entry[] = []
35let ledgerStamp = ''
36let expenses = 0
37let ticks = 0
38let isBusy = false
39let lastKey = ''
40let lastStatus: string | undefined
41let wasOverCap: boolean | undefined
42let ticker: Timer | undefined
43
44const pad = (n: number) => String(n).padStart(2, '0')
45const round2 = (n: number) => Math.round(n * 100) / 100
46
47function hms(seconds: number) {
48 const s = Math.max(0, Math.floor(seconds))
49
50 return `${Math.floor(s / 3600)}:${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`
51}
52
53// Ledger times are local wall-clock times with no zone, as Python wrote them.
54function localIso(ms: number, offset: number) {
55 return new Date(ms + offset * 60000).toISOString().slice(0, 19)
56}
57
58function parseLocal(iso: string, offset: number) {
59 return Date.parse(`${iso.slice(0, 19)}Z`) - offset * 60000
60}
61
62function money(hours: number, s: Snap) {
63 return s.rate > 0 ? ` ${s.currency}${(hours * s.rate).toFixed(2)}` : ''
64}
65
66function clip(text: string, max: number) {
67 return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text
68}
69
70function view(s: Snap) {
71 const run = s.running
72 const gross = run === null ? 0 : (s.nowMs - s.startMs) / 1000
73 const net = run === null ? 0 : Math.max(0, gross - run.idle)
74 const live = run !== null && run.billable ? net / 3600 : 0
75 const today = s.todayBase + live
76 const week = s.weekBase + live
77 const isOver = s.cap > 0 && week > s.cap
78 const pill = run === null ? 'STOPPED' : run.billable ? 'RUNNING' : 'CALL, NOT BILLED'
79 const color = run === null ? GREY : run.billable ? GREEN : AMBER
80 const filled = s.cap > 0 ? Math.max(0, Math.min(CELLS, Math.round((week / s.cap) * CELLS))) : 0
81
82 return { net, live, today, week, isOver, pill, color, filled }
83}
84
85function summary(s: Snap) {
86 const v = view(s)
87 const run = s.running
88 const head =
89 run === null
90 ? 'Timer stopped.'
91 : `${v.pill} ${hms(v.net)} ${run.task} (started ${run.start.slice(11, 16)})`
92 const week = s.cap > 0 ? `${v.week.toFixed(2)} of ${s.cap} h` : `${v.week.toFixed(2)} h`
93 const unbilled =
94 s.unbilledHours > 0
95 ? ` Unbilled ${s.unbilledHours.toFixed(2)} h${money(s.unbilledHours, s)} since ${s.unbilledFrom}.`
96 : ''
97
98 return `${head} Today ${v.today.toFixed(2)} h${money(v.today, s)}. This week ${week}${v.isOver ? ', over the cap' : ''}.${unbilled}`
99}
100
101// Scheduled-task sessions (the daily backup pull and the like) run with
102// nobody watching: the mod stays out of them. Such a session opens with a
103// prompt that carries a <scheduled-task ...> tag.
104let isUnattended = false
105
106async function checkUnattended($: EngineInterface) {
107 try {
108 const messages = await $.session.messages()
109 const first = Array.isArray(messages) ? messages.find(m => m.role === 'user') : undefined
110
111 if (first !== undefined && String(first.text ?? '').includes('<scheduled-task')) {
112 isUnattended = true
113 }
114 } catch {
115 // an unreadable transcript counts as an ordinary session
116 }
117
118 return isUnattended
119}
120
121// The desk is off in a session until /desk on (the desk's command) turns
122// it on there; the choice is in deskbar-data/desk-on.json, by session id.
123let deskOn = false
124let hasTool = false
125// Whether this build's desktop app runs surface modules (2.1.287 on); the
126// terminal always does.
127let hasDesktopModules = false
128let hasSounds = true
129// The data folder, under the user's own ~/.claude wherever the plugin is installed.
130let claudeHome = ''
131
132async function dataRoot($: EngineInterface) {
133 if (claudeHome === '') {
134 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
135 claudeHome = home === '' ? '' : `${home.replace(/\\/g, '/').replace(/\/+$/, '')}/.claude`
136 }
137
138 return `${claudeHome}/deskbar-data`
139}
140
141async function readOn($: EngineInterface) {
142 try {
143 const map = JSON.parse(await $.fs.read(`${await dataRoot($)}/desk-on.json`))
144 deskOn = !isUnattended && map[await $.session.id()] === true
145 } catch {
146 deskOn = false
147 }
148
149 if (deskOn) {
150 try {
151 const saved = JSON.parse(await $.fs.read(`${await dataRoot($)}/settings.json`))
152 deskOn = saved?.show?.timer !== false
153 hasSounds = saved?.sounds !== false
154 } catch {
155 // no settings file: the timer shows
156 }
157 }
158
159 return deskOn
160}
161
162async function locate($: EngineInterface) {
163 const root = (await $.session.root()).replace(/[\\/]+$/, '')
164 const dir = `${root}/timelog`
165 const hasLedger = await $.fs.exists(`${dir}/entries.jsonl`)
166 const hasConfig = await $.fs.exists(`${dir}/config.json`)
167
168 const isFresh = !hasLedger && !hasConfig
169
170 const hasScript = await $.fs.exists(`${dir}/timelog.py`)
171 let config: Config = {
172 client: root.split(/[\\/]/).pop() ?? 'Client',
173 rate: 0,
174 weeklyCap: 0,
175 currency: '$',
176 utcOffsetMinutes: -new Date().getTimezoneOffset(),
177 }
178
179 if (hasConfig) {
180 try {
181 config = { ...config, ...JSON.parse(await $.fs.read(`${dir}/config.json`)) }
182 } catch {
183 // a broken config keeps the defaults
184 }
185 } else if (hasScript) {
186 try {
187 const source = await $.fs.read(`${dir}/timelog.py`)
188 const rate = /^RATE\s*=\s*([\d.]+)/m.exec(source)
189 const cap = /^WEEKLY_CAP\s*=\s*([\d.]+)/m.exec(source)
190 config = { ...config, rate: Number(rate?.[1] ?? 0), weeklyCap: Number(cap?.[1] ?? 0) }
191 } catch {
192 // no rate or cap shown
193 }
194 }
195
196 ctx = { dir, config, hasScript, isFresh }
197
198 return ctx
199}
200
201async function readRunning($: EngineInterface, dir: string): Promise<Running | null> {
202 try {
203 if (!(await $.fs.exists(`${dir}/running.json`))) {
204 return null
205 }
206
207 const raw = JSON.parse(await $.fs.read(`${dir}/running.json`))
208
209 if (raw === null || typeof raw.start !== 'string') {
210 return null
211 }
212
213 return {
214 task: String(raw.task ?? ''),
215 start: raw.start,
216 billable: raw.billable !== false,
217 idle: Number(raw.idle) || 0,
218 }
219 } catch {
220 return null
221 }
222}
223
224async function readLines($: EngineInterface, path: string) {
225 if (!(await $.fs.exists(path))) {
226 return []
227 }
228
229 const text = await $.fs.read(path)
230 const rows: Record<string, unknown>[] = []
231
232 for (const line of text.split(/\r?\n/)) {
233 if (line.trim() !== '') {
234 try {
235 rows.push(JSON.parse(line))
236 } catch {
237 // a damaged line is skipped, never rewritten
238 }
239 }
240 }
241
242 return rows
243}
244
245async function loadLedger($: EngineInterface, dir: string, isForced: boolean) {
246 const path = `${dir}/entries.jsonl`
247 const stat = await $.fs.stat(path).catch(() => undefined)
248 const stamp = stat === undefined ? 'none' : `${stat.mtimeMs}:${stat.size}`
249
250 if (stamp === ledgerStamp && !isForced) {
251 return ledger
252 }
253
254 ledgerStamp = stamp
255 ledger = (await readLines($, path)) as Entry[]
256 const paid = await readLines($, `${dir}/expenses.jsonl`).catch(() => [])
257 expenses = paid.filter(x => !x.invoice).reduce((sum, x) => sum + (Number(x.amount) || 0), 0)
258
259 return ledger
260}
261
262async function refresh($: EngineInterface, isForced = false) {
263 if (ctx === null) {
264 return
265 }
266
267 const { dir, config } = ctx
268 const offset = config.utcOffsetMinutes
269 const nowMs = await $.clock.now()
270 const running = await readRunning($, dir)
271 const entries = await loadLedger($, dir, isForced)
272 const today = localIso(nowMs, offset).slice(0, 10)
273 const weekday = (new Date(nowMs + offset * 60000).getUTCDay() + 6) % 7
274 const mondayMs = nowMs - weekday * 86400000
275 const week: Day[] = WEEKDAYS.map((label, i) => {
276 const date = localIso(mondayMs + i * 86400000, offset).slice(0, 10)
277 const hours = entries.filter(e => e.billable && e.date === date).reduce((sum, e) => sum + e.hours, 0)
278
279 return { label, date, hours: round2(hours) }
280 })
281 const rows: Row[] = entries
282 .filter(e => e.date === today)
283 .map(e => ({
284 from: e.start === null ? '' : e.start.slice(11, 16),
285 to: e.end === null ? '' : e.end.slice(11, 16),
286 hours: e.hours,
287 task: e.task,
288 billable: e.billable,
289 }))
290 const unbilled = entries.filter(e => e.billable && !e.invoice)
291 const closed = [...entries].reverse().find(e => e.end !== null)
292 const next: Snap = {
293 client: config.client,
294 rate: config.rate,
295 cap: config.weeklyCap,
296 currency: config.currency,
297 offset,
298 nowMs,
299 running,
300 startMs: running === null ? 0 : parseLocal(running.start, offset),
301 todayBase: round2(week[weekday]?.hours ?? 0),
302 weekBase: round2(week.reduce((sum, d) => sum + d.hours, 0)),
303 today: rows,
304 week,
305 unbilledHours: round2(unbilled.reduce((sum, e) => sum + e.hours, 0)),
306 unbilledFrom: unbilled[0]?.date ?? '',
307 unbilledDays: new Set(unbilled.map(e => e.date)).size,
308 expenses: round2(expenses),
309 last: closed === undefined || closed.end === null ? null : { task: closed.task, end: closed.end },
310 }
311 const key = JSON.stringify({ ...next, nowMs: 0 })
312
313 if (running === null && key === lastKey && !isForced) {
314 return
315 }
316
317 lastKey = key
318 await update($, snap, () => next)
319
320 const v = view(next)
321 const status =
322 running === null
323 ? `timer stopped, today ${v.today.toFixed(2)} h`
324 : `${running.billable ? 'timer' : 'call'} ${hms(v.net).slice(0, -3)} ${clip(running.task, 28)}`
325
326 if (status !== lastStatus) {
327 lastStatus = status
328 $.ui.status(status)
329 }
330
331 if (wasOverCap === false && v.isOver) {
332 $.ui.toast(`${next.client}: this week is over the ${next.cap} h cap (${v.week.toFixed(1)} h)`)
333 }
334
335 wasOverCap = v.isOver
336}
337
338async function tick($: EngineInterface) {
339 if (isBusy || isUnattended || ctx === null) {
340 return
341 }
342
343 isBusy = true
344
345 try {
346 ticks += 1
347
348 // every few seconds: has this session been switched on or off?
349 if (ticks % 4 === 1) {
350 const was = deskOn
351
352 if ((await readOn($)) !== was) {
353 lastKey = ''
354 lastStatus = undefined
355 $.ui.status(undefined)
356 $.ui.invalidate('ui.render')
357 }
358 }
359
360 if (!deskOn) {
361 return
362 }
363
364 if (!hasTool) {
365 await registerTool($)
366 }
367
368 const current = await read($, snap)
369
370 // A stopped timer has nothing moving: look at the files every 3 seconds.
371 if (current === null || current.running !== null || ticks % 3 === 0) {
372 await refresh($)
373 }
374 } catch {
375 // one failed read is retried on the next tick
376 } finally {
377 isBusy = false
378 }
379}
380
381function startTicking($: EngineInterface) {
382 if (ticker === undefined) {
383 ticker = $.clock.every(1000, () => {
384 void tick($)
385 })
386 }
387}
388
389// A short sound of the mod's own (fx/<name>.wav), when sounds are on.
390function play($: EngineInterface, name: 'start' | 'done') {
391 if (hasSounds) {
392 void $.audio.play({ asset: `fx/${name}.wav` }).catch(() => undefined)
393 }
394}
395
396async function startTimer($: EngineInterface, task: string, isCall: boolean) {
397 if (ctx === null) {
398 return 'No timelog in this project. Set one up with /timer setup <rate> <weekly cap>.'
399 }
400
401 if (task.trim() === '') {
402 return 'Give the task a short name, e.g. start "Publish button end to end".'
403 }
404
405 const { dir, config } = ctx
406
407 // A project timed for the first time: its timelog folder is made now, with
408 // the folder's name as the client and no rate or cap (/timer setup sets them).
409 if (ctx.isFresh === true) {
410 await $.fs.write(`${dir}/config.json`, `${JSON.stringify(config, null, 2)}\n`)
411 await $.fs.write(`${dir}/entries.jsonl`, '')
412 ctx = { ...ctx, isFresh: false }
413 hasTool = false
414 }
415
416 const running = await readRunning($, dir)
417
418 if (running !== null) {
419 const hours = ((await $.clock.now()) - parseLocal(running.start, config.utcOffsetMinutes)) / 3600000
420
421 return `Already running: "${running.task}" (${hours.toFixed(2)} h so far). Stop it first, or use switch.`
422 }
423
424 const start = localIso(await $.clock.now(), config.utcOffsetMinutes)
425 await $.fs.write(
426 `${dir}/running.json`,
427 JSON.stringify({ task: task.trim(), start, billable: !isCall, idle: 0 }),
428 )
429 await refresh($, true)
430 play($, 'start')
431
432 return `Started ${start.slice(11, 16)} [${isCall ? 'call, not billable' : 'billable'}] ${task.trim()}`
433}
434
435async function stopTimer($: EngineInterface, note: string, hours: number | undefined) {
436 if (ctx === null) {
437 return 'No timelog in this project.'
438 }
439
440 const { dir, config, hasScript } = ctx
441 const running = await readRunning($, dir)
442
443 if (running === null) {
444 return 'No timer running.'
445 }
446
447 // The project's own script closes the entry where there is one: it also
448 // removes running.json and rewrites the Excel report.
449 if (hasScript) {
450 const argv = ['python', 'timelog.py', 'stop', '--note', note]
451
452 if (hours !== undefined) {
453 argv.push('--hours', String(hours))
454 }
455
456 try {
457 const ran = await $.process.run(argv, { cwd: dir, timeoutMs: 90000 })
458 await refresh($, true)
459 play($, 'done')
460
461 return (ran.stdout + ran.stderr).trim().slice(0, 600) || `timelog.py exited ${ran.exitCode}`
462 } catch {
463 return `The mod could not run timelog.py here. Run it in the terminal: python "${dir}/timelog.py" stop`
464 }
465 }
466
467 const nowMs = await $.clock.now()
468 const measured = (nowMs - parseLocal(running.start, config.utcOffsetMinutes)) / 3600000
469
470 if (hours === undefined && measured > MAX_SANE_HOURS) {
471 return `That timer has been running ${measured.toFixed(1)} h, which looks like it was left open. Stop again with the real hours.`
472 }
473
474 const entries = await loadLedger($, dir, true)
475 const entry: Entry = {
476 id: entries.reduce((max, e) => Math.max(max, e.id), 0) + 1,
477 date: running.start.slice(0, 10),
478 start: running.start,
479 end: localIso(nowMs, config.utcOffsetMinutes),
480 hours: round2(hours ?? measured),
481 task: running.task,
482 note,
483 evidence: '',
484 billable: running.billable,
485 invoice: null,
486 }
487 const path = `${dir}/entries.jsonl`
488 const before = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
489 const joint = before === '' || before.endsWith('\n') ? '' : '\n'
490 await $.fs.write(path, `${before}${joint}${JSON.stringify(entry)}\n`)
491 await $.fs.write(`${dir}/running.json`, '{}')
492 await refresh($, true)
493 play($, 'done')
494 const current = await read($, snap)
495 const total = current === null ? '' : ` Unbilled so far: ${current.unbilledHours.toFixed(2)} h${money(current.unbilledHours, current)}.`
496
497 return `Stopped. ${entry.hours.toFixed(2)} h ${entry.task}${entry.billable ? '' : ' [call, not billable]'}.${total}`
498}
499
500async function act(
501 $: EngineInterface,
502 action: string,
503 task: string,
504 isCall: boolean,
505 note: string,
506 hours: number | undefined,
507) {
508 if (action === 'start') {
509 return startTimer($, task, isCall)
510 }
511
512 if (action === 'stop') {
513 return stopTimer($, note, hours)
514 }
515
516 if (action === 'switch') {
517 const stopped = await stopTimer($, note, hours)
518 const started = await startTimer($, task, isCall)
519
520 return `${stopped}\n${started}`
521 }
522
523 await refresh($, true)
524 const current = await read($, snap)
525
526 return current === null ? 'No timelog in this project.' : summary(current)
527}
528
529async function setUp($: EngineInterface, rate: number, cap: number, client: string) {
530 const root = (await $.session.root()).replace(/[\\/]+$/, '')
531 const dir = `${root}/timelog`
532 const config: Config = {
533 client: client || (root.split(/[\\/]/).pop() ?? 'Client'),
534 rate,
535 weeklyCap: cap,
536 currency: '$',
537 utcOffsetMinutes: -new Date().getTimezoneOffset(),
538 }
539 await $.fs.write(`${dir}/config.json`, `${JSON.stringify(config, null, 2)}\n`)
540
541 if (!(await $.fs.exists(`${dir}/entries.jsonl`))) {
542 await $.fs.write(`${dir}/entries.jsonl`, '')
543 }
544
545 await activate($)
546
547 return `Timer set up for ${config.client} in ${dir}: ${config.currency}${rate}/h, cap ${cap} h a week.`
548}
549
550async function registerTool($: EngineInterface) {
551 hasTool = true
552 // the rules of use travel with the tool: the model reads them wherever it reads the tool
553 const guide = await rules($).catch(() => null)
554 await $.tool.register({
555 name: 'timer',
556 description: `The billable-hours timer of this project (ledger in timelog/entries.jsonl). start begins timing a task, stop closes the entry, switch closes it and starts another, status reports the running timer with today, week and unbilled totals.${guide === null ? '' : `\n\n${guide}`}`,
557 inputSchema: {
558 type: 'object',
559 properties: {
560 action: { type: 'string', enum: ['start', 'stop', 'switch', 'status'] },
561 task: { type: 'string', description: 'Short task name for start and switch' },
562 call: { type: 'boolean', description: 'A client call: recorded, never billed' },
563 note: { type: 'string', description: 'What got done, for stop and switch' },
564 hours: { type: 'number', description: 'Override the measured time on stop' },
565 },
566 required: ['action'],
567 },
568 })
569}
570
571async function activate($: EngineInterface) {
572 const found = await locate($)
573
574 if (found === null) {
575 return false
576 }
577
578 if (await readOn($)) {
579 await registerTool($)
580 await refresh($, true)
581 }
582
583 // the ticker runs either way: it is what notices /desk on
584 startTicking($)
585
586 return true
587}
588
589async function rules($: EngineInterface) {
590 if (ctx === null) {
591 return null
592 }
593
594 const own = `${ctx.dir}/rules.md`
595 const path = (await $.fs.exists(own)) ? own : `${$.plugin.root}/rules.md`
596 const text = await $.fs.read(path).catch(() => '')
597 const { client, rate, weeklyCap, currency } = ctx.config
598 const terms = `Client: ${client}. Rate: ${rate > 0 ? `${currency}${rate}/h` : 'not set'}. Weekly cap: ${weeklyCap > 0 ? `${weeklyCap} h` : 'none'}.`
599
600 // Where nothing has been timed yet the model does not start the timer by itself.
601 const fresh =
602 ctx.isFresh === true
603 ? 'This project has no timelog yet. Do NOT start the timer on your own here: start it only when the user asks to time or bill this work, or says it is client work. The first start creates the timelog folder; the rules below apply from then on.\n\n'
604 : ''
605
606 return text === '' ? null : `${fresh}${terms}\n\n${text}`
607}
608
609// Whether the table is the dropdown to draw: open, and opened after any of
610// the desk's.
611async function isShown($: EngineInterface) {
612 const theirs = await $.state.get({ plugin: 'deskbar', key: 'panelAt' }).catch(() => undefined)
613
614 const mine = await read($, openAt)
615
616 // Their stamp is negative for a close: either way, anything they did after
617 // this table was opened leaves it shut.
618 return (await read($, isOpen)) && mine > 0 && mine >= Math.abs(Number(theirs?.value) || 0)
619}
620
621async function pressToggle($: EngineInterface) {
622 const isOpening = !(await isShown($))
623 await update($, isOpen, () => isOpening)
624 // later than anything the desk's dropdowns were stamped with, even in the same instant
625 const theirs = Math.abs(Number((await $.state.get({ plugin: 'deskbar', key: 'panelAt' }).catch(() => undefined))?.value) || 0)
626 const stamp = Math.max(await $.clock.now(), theirs + 1)
627 await update($, openAt, () => (isOpening ? stamp : -stamp))
628}
629
630async function pressStop($: EngineInterface) {
631 $.ui.toast(await stopTimer($, '', undefined))
632}
633
634async function pressResume($: EngineInterface) {
635 const current = await read($, snap)
636
637 // the last task again; in a project never timed before, a plain name (the
638 // first start makes the timelog folder)
639 if (current !== null) {
640 $.ui.toast(await startTimer($, current.last?.task ?? 'Work', false))
641 }
642}
643
644const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
645
646// "2026-10-02" as "Fri 02 Oct"
647function dayName(date: string) {
648 const at = new Date(`${date.slice(0, 10)}T00:00:00Z`)
649
650 return `${WEEKDAYS[(at.getUTCDay() + 6) % 7]} ${date.slice(8, 10)} ${MONTHS[at.getUTCMonth()]}`
651}
652
653// Table colours, for the dark theme the app runs in.
654const EDGE = '#2f4149'
655const HEAD_BG = '#22333b'
656const STRIPE_BG = '#172229'
657const TOTAL_BG = '#1b3a31'
658const MUTED = '#8fa3aa'
659
660type Look = { color?: string; bold?: boolean; dimColor?: boolean }
661type Line = { cells: string[]; look?: Look; bg?: string; bar?: number }
662
663// One card: a rounded frame, a tinted header row, striped rows, a tinted
664// total row. Numbers sit right-aligned in fixed columns; the last column fills.
665function card(Box: any, Text: any, title: string, head: string[], lines: Line[], widths: number[]) {
666 const cells = (line: Line, isHead: boolean) =>
667 line.cells.map((text, c) => {
668 const isNumber = c === 1 || c === 2
669 const look = isHead ? { bold: true, color: MUTED } : (line.look ?? {})
670
671 return (
672 <Box
673 width={widths[c]}
674 flexGrow={widths[c] === undefined ? 1 : 0}
675 justifyContent={isNumber ? 'flex-end' : 'flex-start'}
676 paddingRight={isNumber ? 2 : 0}
677 >
678 {line.bar !== undefined && c === line.cells.length - 1 ? (
679 <Text color={GREEN}>{line.bar === 0 ? ' ' : '▇'.repeat(line.bar)}</Text>
680 ) : (
681 <Text {...look}>{text === '' ? ' ' : text}</Text>
682 )}
683 </Box>
684 )
685 })
686
687 return (
688 <Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor={EDGE}>
689 <Box paddingX={1}>
690 <Text bold>{title}</Text>
691 </Box>
692 <Box paddingX={1} backgroundColor={HEAD_BG}>
693 {cells({ cells: head }, true)}
694 </Box>
695 {lines.map((line, r) => (
696 <Box paddingX={1} backgroundColor={line.bg ?? (r % 2 === 1 ? STRIPE_BG : undefined)}>
697 {cells(line, false)}
698 </Box>
699 ))}
700 </Box>
701 )
702}
703
704// `rows` is how many table rows each card may show under its title and header
705// (the band has few; the side panel has plenty).
706function details(Box: any, Text: any, s: Snap, rows = 20, isNarrow = false, Client?: any, Svg?: any) {
707 const v = view(s)
708 const run = s.running
709 const top = Math.max(...s.week.map(d => d.hours), v.today, 0.01)
710 const today = localIso(s.nowMs, s.offset).slice(0, 10)
711 const cost = (hours: number) => (s.rate > 0 ? `${s.currency}${(hours * s.rate).toFixed(2)}` : '')
712
713 const todayLines: Line[] = s.today.slice(-Math.max(1, Math.min(8, rows - (s.running === null ? 1 : 2)))).map(e => ({
714 cells: [e.from === '' ? 'added' : `${e.from} - ${e.to}`, e.hours.toFixed(2), e.billable ? cost(e.hours) : 'call', clip(e.task, 40)],
715 look: e.billable ? {} : { dimColor: true },
716 }))
717
718 if (run !== null) {
719 todayLines.push({
720 cells: [`${run.start.slice(11, 16)} - now`, (v.net / 3600).toFixed(2), run.billable ? cost(v.net / 3600) : 'call', clip(run.task, 40)],
721 look: { color: v.color, bold: true },
722 })
723 }
724
725 if (todayLines.length === 0) {
726 todayLines.push({ cells: ['nothing logged yet', '', '', ''], look: { dimColor: true } })
727 }
728
729 todayLines.push({ cells: ['Total', v.today.toFixed(2), cost(v.today), ''], look: { bold: true }, bg: TOTAL_BG })
730
731 const weekLines: Line[] = s.week
732 // when rows are short, only the days that have hours, the latest kept
733 .filter(day => rows >= 8 || day.hours > 0 || day.date === today)
734 .slice(-Math.max(1, rows - 1))
735 .map(day => {
736 const hours = day.hours + (day.date === today ? v.live : 0)
737 const isToday = day.date === today
738
739 return {
740 cells: [dayName(day.date), hours === 0 ? '-' : hours.toFixed(2), hours === 0 ? '' : cost(hours), ''],
741 look: hours === 0 ? { dimColor: true } : isToday ? { bold: true, color: GREEN } : {},
742 bar: hours > 0 ? Math.max(1, Math.round((hours / top) * 12)) : 0,
743 }
744 })
745 const left = s.cap > 0 ? (v.isOver ? `over the ${s.cap} h cap` : `${Math.max(0, s.cap - v.week).toFixed(2)} h left of ${s.cap}`) : ''
746 weekLines.push({
747 cells: ['Total', v.week.toFixed(2), cost(v.week), left],
748 look: { bold: true, color: v.isOver ? AMBER : undefined },
749 bg: TOTAL_BG,
750 })
751
752 const idle = run !== null && run.idle > 0 ? `, idle ${hms(run.idle)} not counted` : ''
753 const about =
754 run === null
755 ? s.last === null
756 ? s.client
757 : `${s.client} · last entry ended ${s.last.end.slice(11, 16)}, ${dayName(s.last.end)}`
758 : `${s.client} · started ${run.start.slice(11, 16)}${idle}`
759 const unbilled = s.unbilledHours + v.live
760
761 return (
762 <Box flexDirection="column" width="100%" marginTop={1}>
763 <Box width="100%" justifyContent="space-between" paddingX={1}>
764 <Box alignItems="center">
765 <Text color={MUTED}>{about}</Text>
766 {/* the week against its cap: a bar that glides, and sweeps while the timer runs */}
767 {(Client !== undefined || Svg !== undefined) && s.cap > 0 && <Text color={MUTED}>{' week '}</Text>}
768 {Client !== undefined && s.cap > 0 && (
769 <Client
770 key="week-meter"
771 module="./meter.tsx"
772 props={{ value: v.week / s.cap, active: run !== null && run.billable, tint: v.isOver ? AMBER : GREEN }}
773 width={isNarrow ? 8 : 14}
774 height={1}
775 />
776 )}
777 {Client === undefined && Svg !== undefined && s.cap > 0 && (
778 <Svg
779 source={meterSvg(v.week / s.cap, run !== null && run.billable, v.isOver ? AMBER : GREEN, isNarrow ? 56 : 98)}
780 alt="this week against the cap"
781 width={isNarrow ? 56 : 98}
782 height={10}
783 />
784 )}
785 </Box>
786 <Box>
787 <Text color={MUTED}>Unbilled </Text>
788 <Text bold>
789 {unbilled.toFixed(2)} h{money(unbilled, s)}
790 </Text>
791 <Text color={MUTED}>
792 {s.unbilledFrom === '' ? '' : ` since ${dayName(s.unbilledFrom)}`}
793 {s.expenses > 0 ? ` · expenses ${s.currency}${s.expenses.toFixed(2)}` : ''}
794 </Text>
795 </Box>
796 </Box>
797 {isNarrow ? (
798 // too narrow for two cards side by side: today's, and the week as a line
799 <Box flexDirection="column" width="100%">
800 {card(Box, Text, `Today, ${dayName(today)}`, ['Time', 'Hours', 'Amount', 'Task'], todayLines.slice(-Math.max(2, rows - 1)), [14, 7, 9])}
801 <Box paddingX={1}>
802 <Text bold color={v.isOver ? AMBER : GREEN}>
803 This week {v.week.toFixed(2)} h{cost(v.week) === '' ? '' : ` ${cost(v.week)}`}
804 </Text>
805 <Text color={MUTED}>{left === '' ? '' : ` ${left}`}</Text>
806 </Box>
807 </Box>
808 ) : (
809 <Box width="100%" columnGap={1}>
810 {card(Box, Text, `Today, ${dayName(today)}`, ['Time', 'Hours', 'Amount', 'Task'], todayLines, [15, 9, 11])}
811 {card(Box, Text, 'This week', ['Day', 'Hours', 'Amount', left === '' ? '' : 'Share'], weekLines, [13, 9, 11])}
812 </Box>
813 )}
814 </Box>
815 )
816}
817
818async function openPanel($: EngineInterface) {
819 const wide = await read($, isOpen)
820 await $.ui.open({ id: PANE, title: 'Timer', columns: wide ? WIDE : SLIM })
821}
822
823async function setWide($: EngineInterface, wide: boolean) {
824 await update($, isOpen, () => wide)
825 await openPanel($)
826}
827
828
829async function band($: EngineInterface, e: any, below: any): Promise<any> {
830 {
831 const s = await read($, snap)
832
833 if (isUnattended || !deskOn || e.props.hasSurvey || s === null || (await read($, isHidden))) {
834 return below
835 }
836
837 const table = $.ui.resolve(e)
838 const { Box, Button, Text } = table
839 // The terminal cannot paint an SVG: it keeps a dot.
840 const Svg = e.surface === 'terminal' ? undefined : (table as { Svg?: any }).Svg
841 // surface modules run in the terminal; elsewhere the motion is an SVG that animates by itself
842 const Client = e.surface === 'terminal' || hasDesktopModules ? (table as { Client?: any }).Client : undefined
843 const v = view(s)
844 const run = s.running
845 const open = await isShown($)
846 const clock = run === null ? `${v.today.toFixed(2)}h` : hms(v.net)
847 // The stopwatch's hand points at the running second, so it ticks round
848 // the dial once a minute as the band redraws; at rest it points up.
849 const angle = run === null ? 0 : (Math.floor(v.net) % 60) * 6
850 const stopwatch = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path d="M9.5 2h5M12 2v3M18.5 6.5l1.5-1.5" fill="none" stroke="${v.color}" stroke-width="2.2" stroke-linecap="round"/><circle cx="12" cy="14" r="8.5" fill="${v.color}"/><circle cx="12" cy="14" r="6.2" fill="#10181c"/><path d="M12 14V9.4" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" transform="rotate(${angle} 12 14)"/><circle cx="12" cy="14" r="1.3" fill="#ffffff"/></svg>`
851 // Start is a green tile with a play mark, stop a red one with a square.
852 const tile = (fill: string, mark: string) =>
853 `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" rx="6" fill="${fill}"/>${mark}</svg>`
854 const playTile = tile('#1baf7a', '<path d="M9.5 7.2v9.6a.6.6 0 0 0 .92.5l7.2-4.8a.6.6 0 0 0 0-1l-7.2-4.8a.6.6 0 0 0-.92.5z" fill="#ffffff"/>')
855 const stopTile = tile('#e34948', '<rect x="7.5" y="7.5" width="9" height="9" rx="1.8" fill="#ffffff"/>')
856 // A chip as the desk row draws its own: the icon painted over the left of
857 // a button, whose label (the part that takes the click) follows it.
858 const iconChip = (key: string, source: string, alt: string, label: string, onPress: () => unknown) => (
859 <Box position="relative" alignItems="center">
860 <Button key={key} label={` ${label}`} onPress={onPress} />
861 <Box position="absolute" top={0} left={1} height="100%" alignItems="center">
862 <Svg source={source} alt={alt} width={20} height={20} />
863 </Box>
864 </Box>
865 )
866
867 // The chip at the right end of the band: the stopwatch with the arrow that
868 // opens the table, then start (green) or stop (red) with the clock beside
869 // it. Nothing appears on hover; the task is in the table the arrow opens.
870 const chip = (
871 <Box key="timer-chip" alignItems="center">
872 {Svg === undefined ? (
873 <Box alignItems="center">
874 <Text bold color={v.color}>
875 ● {clock}{' '}
876 </Text>
877 {run !== null && <Button key="stop" label="■ Stop" onPress={() => pressStop($)} />}
878 {run === null && <Button key="resume" label="▶ Start" onPress={() => pressResume($)} />}
879 <Text> </Text>
880 <Button key="toggle" label={open ? '▾' : '▸'} onPress={() => pressToggle($)} />
881 </Box>
882 ) : (
883 <Box alignItems="center">
884 {/* a breathing dot while the timer records */}
885 {run !== null && (
886 <Box marginRight={1} alignItems="center">
887 {Client !== undefined ? (
888 <Client key="timer-pulse" module="./pulse.tsx" props={{ tint: v.color }} width={1} height={1} />
889 ) : (
890 <Svg source={pulseSvg(v.color, false)} alt="recording" width={12} height={12} />
891 )}
892 </Box>
893 )}
894 {iconChip('toggle', stopwatch, 'timer', open ? '▾' : '▸', () => pressToggle($))}
895 <Box marginLeft={1} alignItems="center">
896 {run !== null
897 ? iconChip('stop', stopTile, 'stop', clock, () => pressStop($))
898 : iconChip('resume', playTile, 'start', clock, () => pressResume($))}
899 </Box>
900 </Box>
901 )}
902 </Box>
903 )
904 // What sits beneath (the desk row): a row, or a column of its row and a
905 // dropdown. The chip joins that row at the right; the rest stays below.
906 const isColumn = below.type !== 'engine' && (below as { props?: { flexDirection?: string } }).props?.flexDirection === 'column'
907 const kids = isColumn ? ((below as { children?: unknown[] }).children ?? []) : []
908 const left = below.type === 'engine' ? undefined : isColumn ? kids[0] : below
909 const rest = kids.slice(1)
910 const top =
911 left === undefined ? (
912 <Box width="100%" justifyContent="flex-end">
913 {chip}
914 </Box>
915 ) : (
916 <Box width="100%" justifyContent="space-between" alignItems="center">
917 {left}
918 {chip}
919 </Box>
920 )
921
922 if (!open && rest.length === 0) {
923 return top
924 }
925
926 return (
927 <Box flexDirection="column" width="100%">
928 {top}
929 {rest}
930 {open && details(Box, Text, s, Math.max(3, (Number(e.props.maxRows) || 12) - 8), (Number(e.props.bodyColumns) || 96) < 84, Client, Svg)}
931 </Box>
932 )
933 }
934}
935
936export const registerTimer: Register = on => {
937 // The desk half of this plugin has the plain session.start hook, and an
938 // event takes one hook without a matcher per plugin: this one states both
939 // values of a flag instead, which comes to the same thing.
940 for (const flag of [true, false]) {
941 on('session.start', { isInteractive: flag }, async ($, e, next) => {
942 const result = await next(e)
943 await $.command.register({
944 name: 'timer',
945 description: 'Work timer: start <task>, call <task>, stop [note], switch <task>, status, panel, setup <rate> <cap> [client]',
946 argumentHint: 'start <task> | stop [note] | status | panel',
947 })
948
949 const built = await $.session.version().catch(() => ({ base: undefined, version: '' }))
950 const found = /(\d+)\.(\d+)\.(\d+)/.exec(built.base ?? built.version ?? '')
951 hasDesktopModules =
952 found !== null &&
953 (Number(found[1]) > 2 || (Number(found[1]) === 2 && (Number(found[2]) > 1 || (Number(found[2]) === 1 && Number(found[3]) >= 287))))
954
955 if (!(await checkUnattended($))) {
956 await activate($).catch(() => false)
957 }
958
959 return result
960 })
961 }
962
963 on('tool.call', { tool: TOOL }, async ($, e) => {
964 const text = await act(
965 $,
966 String(e.action ?? 'status'),
967 String(e.task ?? ''),
968 e.call === true,
969 String(e.note ?? ''),
970 typeof e.hours === 'number' ? e.hours : undefined,
971 )
972
973 return { result: text }
974 })
975
976 on('command.run', { command: 'timer' }, async ($, e) => {
977 const [verb = 'status', ...rest] = e.args.trim().split(/\s+/)
978 const tail = rest.join(' ')
979
980 if (verb === 'setup') {
981 return { text: await setUp($, Number(rest[0]) || 0, Number(rest[1]) || 0, rest.slice(2).join(' ')) }
982 }
983
984 if (verb === 'more' || verb === 'less') {
985 await update($, isOpen, () => verb === 'more')
986 await update($, openAt, await $.clock.now().then(now => () => (verb === 'more' ? now : -now)))
987
988 return { text: verb === 'more' ? 'Timer expanded.' : 'Timer collapsed.' }
989 }
990
991 if (verb === 'panel') {
992 await openPanel($)
993
994 return { text: 'Timer panel opened.' }
995 }
996
997 if (verb === 'hide' || verb === 'show') {
998 await update($, isHidden, () => verb === 'hide')
999
1000 return { text: verb === 'hide' ? 'Timer bar above the prompt hidden. /timer panel opens the side panel.' : 'Timer bar shown above the prompt.' }
1001 }
1002
1003 if (verb === 'start' || verb === 'call') {
1004 const current = await read($, snap)
1005 const task = tail !== '' ? tail : (current?.last?.task ?? 'Work session')
1006 return { text: await startTimer($, task, verb === 'call') }
1007 }
1008
1009 if (verb === 'stop') {
1010 const hours = /^--hours\s+([\d.]+)\s*(.*)$/.exec(tail)
1011
1012 return {
1013 text: await stopTimer($, hours === null ? tail : (hours[2] ?? ''), hours === null ? undefined : Number(hours[1])),
1014 }
1015 }
1016
1017 if (verb === 'switch') {
1018 return { text: await act($, 'switch', tail, false, '', undefined) }
1019 }
1020
1021 return { text: await act($, 'status', '', false, '', undefined) }
1022 })
1023
1024 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1025 const { Box, Button, Text } = $.ui.resolve(e)
1026 const s = await read($, snap)
1027
1028 if (s === null) {
1029 return <Text dimColor>No timelog in this project. /timer setup starts one.</Text>
1030 }
1031
1032 const v = view(s)
1033 const run = s.running
1034 const wide = await read($, isOpen)
1035
1036 // Slim: a narrow strip on the edge of the chat. Open: the whole breakdown.
1037 if (!wide) {
1038 return (
1039 <Box flexDirection="column">
1040 <Text bold color={v.color}>
1041 ● {run === null ? 'Off' : run.billable ? 'On' : 'Call'}
1042 </Text>
1043 <Text bold>{hms(v.net)}</Text>
1044 <Text dimColor>{v.today.toFixed(2)}h today</Text>
1045 <Text dimColor>
1046 {v.week.toFixed(1)}
1047 {s.cap > 0 ? `/${s.cap}` : ''}h week
1048 </Text>
1049 {run !== null && <Button key="pane-stop" label="Stop" onPress={() => pressStop($)} />}
1050 <Button key="expand" label="Open" onPress={() => setWide($, true)} />
1051 </Box>
1052 )
1053 }
1054
1055 return (
1056 <Box flexDirection="column">
1057 <Text bold color={v.color}>
1058 ● {v.pill}
1059 </Text>
1060 <Text bold>{hms(v.net)}</Text>
1061 <Text>{run === null ? 'No timer running' : run.task}</Text>
1062 <Box>
1063 {run !== null && <Button key="pane-stop" label="Stop" onPress={() => pressStop($)} />}
1064 {run === null && s.last !== null && <Button key="pane-resume" label="Resume last" onPress={() => pressResume($)} />}
1065 <Text> </Text>
1066 <Button key="collapse" label="Collapse" onPress={() => setWide($, false)} />
1067 </Box>
1068 {details(Box, Text, s)}
1069 </Box>
1070 )
1071 })
1072
1073 // The chip's buttons are answered here by their keys: while the row is being
1074 // redrawn often (music playing), a press bound to a drawing's own closure can
1075 // be lost, one answered by key is not.
1076 on('ui.press', { component: 'AbovePrompt' }, async ($, e, next) => {
1077 if (e.plugin !== 'deskbar') {
1078 return next(e)
1079 }
1080
1081 if (e.element === 'toggle') {
1082 await pressToggle($)
1083 } else if (e.element === 'resume') {
1084 await pressResume($)
1085 } else if (e.element === 'stop') {
1086 await pressStop($)
1087 } else {
1088 return next(e)
1089 }
1090
1091 return { element: e.element }
1092 })
1093
1094 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1095 const below = await next(e)
1096
1097 try {
1098 return await band($, e, below)
1099 } catch {
1100 return below
1101 }
1102 })
1103}
1104
1105hooks/motion.ts 23 lines1// Motion for surfaces that do not run surface modules (the desktop app, at the
2// time of writing): small SVGs that animate by themselves with SMIL. Drawn
3// with `isInteractive`, the app puts each in a script-less frame where the
4// animation plays; while its markup stays the same it is not reloaded.
5
6// A progress bar: the fill grows to its value when drawn, and a bright band
7// sweeps along it while the thing it measures is under way.
8export function meterSvg(value: number, isActive: boolean, tint: string, width: number) {
9 const h = 10
10 const fill = (Math.max(0, Math.min(1, Number(value) || 0)) * width).toFixed(1)
11 const sweep =
12 isActive && Number(fill) > 8
13 ? `<rect x="-16" y="0" width="16" height="${h}" fill="#ffffff" opacity="0.5"><animate attributeName="x" from="-16" to="${fill}" dur="1.3s" repeatCount="indefinite"/></rect>`
14 : ''
15
16 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}"><defs><clipPath id="fill"><rect x="0" y="0" width="${fill}" height="${h}" rx="3"><animate attributeName="width" from="0" to="${fill}" dur="0.7s" fill="freeze"/></rect></clipPath></defs><rect width="${width}" height="${h}" rx="3" fill="#22333b"/><g clip-path="url(#fill)"><rect width="${width}" height="${h}" fill="${tint}"/>${sweep}</g></svg>`
17}
18
19// A dot that breathes: one breath every two seconds, or every second when urgent.
20export function pulseSvg(tint: string, isFast: boolean) {
21 return `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="${tint}" opacity="0.25"><animate attributeName="r" values="3;5.5;3" dur="${isFast ? '1s' : '2s'}" repeatCount="indefinite"/></circle><circle cx="6" cy="6" r="3" fill="${tint}"><animate attributeName="opacity" values="1;0.45;1" dur="${isFast ? '1s' : '2s'}" repeatCount="indefinite"/></circle></svg>`
22}
23hooks/meter.tsx 63 lines1// A surface module: one animated progress bar, drawn on the app's own drawing
2// thread. Its fill glides to a new value instead of jumping, and while the
3// thing it measures is under way (`active`) a bright band sweeps along it.
4// Settled and not active, it stops ticking its own redraws.
5
6type Props = { value?: number; active?: boolean; tint?: string }
7type State = { shown: number; n: number }
8
9const TRACK = '#22333b'
10const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
11
12// the newest props of each instance, for its timer to read
13const latest = new WeakMap<object, Props>()
14
15export default function Meter(props: Props, surface: any) {
16 const { Box, Text } = surface.elements
17 latest.set(surface, props ?? {})
18
19 if (surface.state === undefined) {
20 surface.setState({ shown: 0, n: 0 })
21 surface.post({ hello: 'meter' }) // lets the hooks module note that this surface draws modules
22 surface.every(60, () => {
23 const s: State = surface.state ?? { shown: 0, n: 0 }
24 const now = latest.get(surface) ?? {}
25 const want = Math.max(0, Math.min(1, Number(now.value) || 0))
26 const isSettled = Math.abs(want - s.shown) < 0.003
27
28 if (isSettled && now.active !== true) {
29 if (s.shown !== want) {
30 surface.setState({ shown: want, n: s.n })
31 }
32
33 return
34 }
35
36 surface.setState({ shown: isSettled ? want : s.shown + (want - s.shown) * 0.16, n: s.n + 1 })
37 })
38 }
39
40 const s: State = surface.state ?? { shown: 0, n: 0 }
41 const cells = Math.max(4, surface.columns || 16)
42 const tint = props?.tint ?? '#1baf7a'
43 const exact = s.shown * cells
44 const whole = Math.floor(exact)
45 const part = EIGHTHS[Math.round((exact - whole) * 8) % 8] ?? ''
46 const fill = '█'.repeat(whole) + part
47 const rest = '░'.repeat(Math.max(0, cells - whole - (part === '' ? 0 : 1)))
48
49 // the sweeping band: three cells, running the length of the fill and a little past
50 const at = props?.active === true && whole > 2 ? Math.floor((s.n * 0.5) % (whole + 8)) - 4 : -99
51 const from = Math.max(0, Math.min(whole, at))
52 const to = Math.max(0, Math.min(whole, at + 3))
53
54 return (
55 <Box>
56 <Text color={tint}>{fill.slice(0, from)}</Text>
57 <Text color="#ffffff">{fill.slice(from, to)}</Text>
58 <Text color={tint}>{fill.slice(to)}</Text>
59 <Text color={TRACK}>{rest}</Text>
60 </Box>
61 )
62}
63hooks/pulse.tsx 43 lines1// A surface module: one dot that breathes, for the few things that want
2// attention now (a meeting about to start, a note that is due, a timer that
3// is running). It fades between its colour and a dim version of it, on the
4// app's own drawing thread.
5
6type Props = { tint?: string; fast?: boolean }
7type State = { n: number }
8
9const latest = new WeakMap<object, Props>()
10
11const hex = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
12
13// `tint` at `part` of its strength over the dark of the row
14function fade(tint: string, part: number) {
15 const r = parseInt(tint.slice(1, 3), 16)
16 const g = parseInt(tint.slice(3, 5), 16)
17 const b = parseInt(tint.slice(5, 7), 16)
18 const mix = (c: number, dark: number) => dark + (c - dark) * part
19
20 return `#${hex(mix(r, 0x26))}${hex(mix(g, 0x2c))}${hex(mix(b, 0x30))}`
21}
22
23export default function Pulse(props: Props, surface: any) {
24 const { Text } = surface.elements
25 latest.set(surface, props ?? {})
26
27 if (surface.state === undefined) {
28 surface.setState({ n: 0 })
29 surface.every(70, () => {
30 const s: State = surface.state ?? { n: 0 }
31 surface.setState({ n: s.n + 1 })
32 })
33 }
34
35 const s: State = surface.state ?? { n: 0 }
36 const tint = /^#[0-9a-fA-F]{6}$/.test(props?.tint ?? '') ? (props?.tint as string) : '#1baf7a'
37 // one breath every two seconds, or every second when it is urgent
38 const beat = (s.n * 70) / (props?.fast === true ? 1000 : 2000)
39 const part = 0.35 + 0.65 * (0.5 + 0.5 * Math.cos(beat * 2 * Math.PI))
40
41 return <Text color={fade(tint, part)}>●</Text>
42}
43hooks/spectrum.tsx 81 lines1// A surface module: drawn on the app's own drawing thread, at its frame rate,
2// with no `$`. It animates by itself, so the row above the prompt is not
3// redrawn for every frame of the music bars (and its buttons stop flickering).
4//
5// The hooks module hands it the latest bar heights whenever it asks (a `post`
6// answered with `{ props }`); between two answers it eases every bar toward
7// its target, fast on the way up and slow on the way down, with a peak cap
8// that hangs and then falls.
9
10type Props = { bars?: number[]; live?: boolean }
11type State = { shown: number[]; peaks: number[]; n: number }
12
13const BARS = 24
14const TOP = 9 // the helper's bar heights run 0 to 9
15const GLYPHS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
16const GREEN = '#3ddc5a'
17const AMBER = '#ffc93c'
18const RED = '#ff4d4d'
19const CAP = '#eafff0'
20const DARK = '#060b08'
21
22let target: number[] = []
23
24export default function Spectrum(props: Props, surface: any) {
25 const { Box, Text } = surface.elements
26 target = props?.live === true && Array.isArray(props.bars) ? props.bars : []
27
28 if (surface.state === undefined) {
29 surface.setState({ shown: [], peaks: [], n: 0 })
30 surface.every(50, () => {
31 const s: State = surface.state ?? { shown: [], peaks: [], n: 0 }
32 const shown: number[] = []
33 const peaks: number[] = []
34
35 for (let i = 0; i < BARS; i += 1) {
36 const want = target[i] ?? 0
37 const now = s.shown[i] ?? 0
38 const next = now + (want - now) * (want > now ? 0.65 : 0.22)
39 shown.push(next < 0.05 ? 0 : next)
40 peaks.push(Math.max(next, (s.peaks[i] ?? 0) - 0.14))
41 }
42
43 surface.setState({ shown, peaks, n: s.n + 1 })
44
45 // every tick: ask the hooks module for the speaker's latest bars
46 surface.post({ want: 'bars' })
47 })
48 }
49
50 const s: State = surface.state ?? { shown: [], peaks: [], n: 0 }
51 const rows = Math.max(2, surface.rows || 5)
52 const columns = Math.max(BARS, surface.columns || 48)
53 const wide = Math.max(1, Math.floor((columns - 2) / BARS) - 1)
54 const lines = []
55
56 for (let r = rows - 1; r >= 0; r -= 1) {
57 let text = ' '
58
59 for (let i = 0; i < BARS; i += 1) {
60 const level = ((s.shown[i] ?? 0) / TOP) * rows - r
61 const peak = Math.floor(((s.peaks[i] ?? 0) / TOP) * rows * 0.999)
62 const fill = Math.max(0, Math.min(8, Math.round(level * 8)))
63 const glyph = fill === 0 && peak === r && (s.peaks[i] ?? 0) > 0.4 ? '▔' : GLYPHS[fill]
64 text += `${(glyph ?? ' ').repeat(wide)} `
65 }
66
67 const tone = r >= rows - 1 ? RED : r >= rows - 2 ? AMBER : GREEN
68 lines.push(
69 <Text color={text.includes('▔') && !/[▁-█]/.test(text) ? CAP : tone} backgroundColor={DARK}>
70 {text.padEnd(columns)}
71 </Text>,
72 )
73 }
74
75 return (
76 <Box flexDirection="column" backgroundColor={DARK}>
77 {lines}
78 </Box>
79 )
80}
81types/index.d.ts 106 lines1export type Reading = { tokens: number; window: number; percent: number }
2
3export type Mail = { id: string; from: string; subject: string; atMs: number; url: string; isUnread: boolean }
4
5export type Inbox = { count: number; other: number; list: Mail[]; error: string }
6
7export type Meeting = { title: string; startMs: number; endMs: number; url: string; people: number }
8
9export type Desk = { inbox: Inbox | null; events: Meeting[]; calendarError: string; nowMs: number }
10
11export type Note = {
12 id: number
13 text: string
14 createdMs: number
15 dueMs: number | null
16 doneMs: number | null
17 notifiedMs: number | null
18}
19
20export type Task = { id: string; subject: string; status: string }
21
22export type TodoSession = { id: string; name: string; count: number }
23
24export type Todo = { sessions: TodoSession[]; selected: string; tasks: Task[]; open: number; isLoading: boolean; query: string; pageOpen: number; pageDone: number }
25
26export type Music = {
27 at: number
28 bars: number[]
29 has: boolean
30 title: string
31 artist: string
32 app: string
33 playing: boolean
34 now: number
35 duration: number
36 error: string
37 coverKey: string
38}
39
40export type Config = {
41 client: string
42 rate: number
43 weeklyCap: number
44 currency: string
45 utcOffsetMinutes: number
46}
47
48export type Running = { task: string; start: string; billable: boolean; idle: number }
49
50export type Entry = {
51 id: number
52 date: string
53 start: string | null
54 end: string | null
55 hours: number
56 task: string
57 note: string
58 evidence: string
59 billable: boolean
60 invoice: string | null
61}
62
63export type Row = { from: string; to: string; hours: number; task: string; billable: boolean }
64
65export type Day = { label: string; date: string; hours: number }
66
67export type Snap = {
68 client: string
69 rate: number
70 cap: number
71 currency: string
72 offset: number
73 nowMs: number
74 running: Running | null
75 startMs: number
76 todayBase: number
77 weekBase: number
78 today: Row[]
79 week: Day[]
80 unbilledHours: number
81 unbilledFrom: string
82 unbilledDays: number
83 expenses: number
84 last: { task: string; end: string } | null
85}
86
87declare module 'claude-code' {
88 interface PluginState {
89 deskbar: {
90 readings: Reading[]
91 isHidden: boolean
92 desk: Desk
93 panel: string
94 panelAt: number
95 settingsAt: number
96 notes: Note[]
97 todo: Todo
98 music: Music | null
99 snap: Snap | null
100 isOpen: boolean
101 timerHidden: boolean
102 openAt: number
103 }
104 }
105}
106