SLOPSHOPPER

deskbar

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

newpanebandguardcommandstatus
★ 1v1.0.0MITupdated 2026-10-030NE-C0DEMAN/deskbar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deskbar
│ ┃ deskbar-timer ✕ › fix the failing auth test and add an audit log call │ ┃ ● Off │ ┃ 0:00:00 ⏺ Read(src/auth.ts) │ ┃ 0.00h today ⎿ Read 6 lines │ ┃ 0.0h week ⏺ Update(src/auth.ts) │ ┃ [ Open ] ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /desk │ ⎿ deskbar: Desk widgets are on for this session. /desk off turns t │ │ ⛅ 49% [ 📧 ? ▸ ] [ 📅 ? ▸ ] [ ✅ – ▸ ] [ 📝 0 ▸ ] [ 🎵 ▸ ] [ ⚙ ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ deskbar: timer stopped, today 0.00 h

Draws

Band
⛅ 49% [ 📧 ? ▸ ] [ 📅 ? ▸ ] [ ✅ – ▸ ] [ 📝 0 ▸ ] [ 🎵 ▸ ] [ ⚙ ]
Pane · deskbar-timer
● Off 0:00:00 0.00h today 0.0h week [ Open ]
README

Deskbar

Deskbar: a 28-second tour

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:

WidgetWhat it showsWhat you can do
WeatherHow full the context window is, as a weather icon and a percentage/weather prints the token counts and their history
MailYour Gmail Primary tab, the latest few, unread ones markedClick a subject to open that thread in Gmail
CalendarHow many meetings are left this week, and the next one as it gets closeA Join link, and a field to add an event by typing "call with Sam tomorrow 6pm for 45m"
TasksThe task lists Claude keeps in each session, to do and done side by side, with a progress barSearch and switch between the sessions of any project
NotesOne notes list shared by every sessionType 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
MusicA retro deck: track, artist, cover, progress and a live spectrum of your speaker outputBack, play or pause, stop, next, volume, for anything Windows sees as media (YouTube Music in a browser, Spotify)
TimerA billable-hours clock for the projectStart and stop; today and the week against a weekly cap; Claude starts and stops it by the rules in rules.md
SettingsWhich 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).

Requirements

  • Claude Code 2.1.286 or later, in the desktop app (Code tab) or a terminal. 2.1.287 or later is better: it draws the smooth animations in the desktop app too.
  • For mail and calendar: the Gmail and Google Calendar connectors connected to your Claude account.
  • For music: Windows 10 or 11 and Python 3.11 or later. Everything else works on any platform.

What each widget needs before it shows anything:

WidgetNeeds
Weather, tasks, notes, timer, settingsNothing. They work as soon as the row is on.
MailThe Gmail connector, connected in Claude's settings.
CalendarThe Google Calendar connector, connected in Claude's settings.
MusicWindows, 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.

Install

  1. Get the code.
   git clone https://github.com/0NE-C0DEMAN/deskbar.git

Put it somewhere permanent, for example ~/.claude/mods/deskbar.

  1. Tell Claude Code to load it. Open ~/.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.

  1. Restart Claude Code. In the desktop app, quit it from the system tray or menu bar and open it again; closing the window is not enough.
  1. Turn it on where you want it. In a session, run:
   /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

Connect Gmail and Google Calendar

The mail and calendar widgets use Claude's own connectors; the mod never sees your password or a token.

  1. Open Claude (the desktop app or claude.ai), go to Settings, then Connectors.
  2. Connect Gmail and Google Calendar and approve the access Google asks for.
  3. Restart Claude Code. The mail and calendar icons fill in within a few seconds of /desk on.

If an icon shows ?, open its dropdown: it says what the connector answered.

Set up the music deck (Windows)

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.

Set up the timer

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.

Commands

CommandWhat it does
/desk on, /desk off, /deskTurn the row on or off for this session, or toggle it
/desk demoShow made-up mail, meetings, tasks and notes instead of yours, for screenshots and recordings; run it again to switch back
/weatherPrint 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 panelPrint 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

Settings

Click the gear at the end of the row. Settings apply to every session:

  • Widgets: which ones show in the row.
  • Mail check: every 1, 3, 5 or 10 minutes. Mail and calendar are also checked on every click of their icon and after each reply.
  • Music bars: on or off.
  • Sounds: on or off.

They are saved in ~/.claude/deskbar-data/settings.json.

Motion and sound

  • In a terminal, and in the desktop app from Claude Code 2.1.287, the music bars, the tasks progress bar, the timer's week bar and the attention dots are surface modules (hooks/spectrum.tsx, meter.tsx, pulse.tsx): small regions the app animates on its own, at frame rate, without redrawing the row.
  • In the desktop app on 2.1.286, which leaves those regions blank, the mod draws self-animating SVGs instead (hooks/motion.ts), and the music bars are drawn into the deck's picture.
  • Short sounds (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.

Privacy: what it reads and where it keeps things

  • Reads: your Gmail Primary tab and your calendar, through Claude's connectors; the task lists Claude keeps under ~/.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.
  • Keeps: everything in ~/.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.
  • Sends: nothing of its own. The only network traffic is Claude's connectors talking to Google.

Troubleshooting

You seeDo this
No row at allRun /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 transcriptIt names what failed. Open an issue with that line.
? on the mail or calendar iconOpen the dropdown for the connector's answer; reconnect the connector if it has expired.
Music deck says "starting" and stays thereThe helper is not installed: see the music setup above.
A blank gap where something should moveYour 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 openIt should not: every dropdown sizes itself to the 12 rows the app allows. Open an issue with a screenshot.

Uninstall

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/.

Development

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.

License

MIT

Source 8 files
hooks/register.tsx 13 lines
1import 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}
13
hooks/desk.tsx 2383 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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 lines
1import { 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
1105
hooks/motion.ts 23 lines
1// 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}
23
hooks/meter.tsx 63 lines
1// 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}
63
hooks/pulse.tsx 43 lines
1// 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}
43
hooks/spectrum.tsx 81 lines
1// 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}
81
types/index.d.ts 106 lines
1export 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