A control panel for Claude Code: quick-reply chips and a status band above the prompt, and a scrolling pane of cards for context, limits, prompt cache…

A Claude Code mod that puts what you check all day in one place: quick-reply chips and a status band above the prompt, and a pane on the right with a scrolling feed of cards. English and Russian.
<img src="docs/cards-en.png" width="420" alt="The pult cards: context ring with session tokens and context growth, gauges for the 5-hour and weekly limits, prompt cache countdown, server rings">
Above the prompt (the band)
yes, go on, continue… or your own); a press sends the text as your own messageI'll resume at 02:20 after a usage limit, with a cancel button; keep cache 3 h ten minutes before a big context cools; save journal after 15 turnsOn the right (the pane, one feed that scrolls)
| Card | Shows | Needs |
|---|---|---|
| Areas | your work areas as buttons, what is urgent in each, its dates, a button to move the session there | areasRoot, focusNote |
| My deadlines | the next rows of a deadlines table from your notes, all of them on demand, open in Obsidian | deadlinesNote |
| Waiting for you | bold lines without an area in the focus note, the inbox count | focusNote, inboxDir |
| Session | context ring, tokens in/out/cache, subagents, turns, tools, time, context growth by turn, save journal, compact context | nothing |
| Limits | gauges for the 5-hour and weekly windows, reset times, forecast, I'll resume at … | nothing |
| Cache | how long the prompt cache stays warm, keep 3 h / release | nothing |
| Server | CPU, RAM, GPU, VRAM over ssh, heavy processes, refresh, terminal | sshHost |
In the desktop app the cards are drawn as images; in the terminal they fall back to text and a sparkline. A card whose option is empty does not show.
Commands: /pult opens the pane again, /journal saves a session journal, /keep-cache 3 keeps the cache warm for three hours.
Needs Claude Code 2.1.287 or later (mods on).
git clone https://github.com/Egor062020/claude-code-pult
claude --plugin-dir ./claude-code-pult
To load it in every session, the desktop app included, add the folder to env in ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-code-pult",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
CLAUDE_CODE_PLUGIN_DIR_WATCH makes open sessions reload the mod when its files change.
Everything personal lives in your settings, under pluginConfigs. Empty means off.
{
"pluginConfigs": {
"pult": {
"options": {
"language": "en",
"vaultPath": "/home/sam/notes",
"focusNote": "focus.md",
"deadlinesNote": "deadlines.md",
"inboxDir": "Inbox",
"journalDir": "logs",
"obsidianVault": "notes",
"areasRoot": "/home/sam/work",
"sshHost": "sam@box",
"serverName": "BOX",
"serverShell": "sh"
}
}
}
}
| Option | Meaning | |
|---|---|---|
language | en or ru | |
quickReplies | chips above the prompt, separated by `\ | `; empty: the language's defaults |
vaultPath | absolute path to your Markdown notes; the note options below are relative to it (or absolute) | |
focusNote | a note with lines like - 40 Science. what is urgent, dates | |
deadlinesNote | a note with a table whose first column is YYYY-MM-DD | |
inboxDir | a folder whose .md files count as unsorted | |
journalDir | where save journal writes | |
obsidianVault | vault name for the open in Obsidian button | |
areasRoot | a folder whose subfolders are named NN Name (Johnny.Decimal) | |
sshHost | user@host reachable by ssh with a key, no password prompt | |
serverName | label of the server card | |
serverShell | sh for Linux, powershell for Windows |
- **10 Admissions.** wire the tests into schedules by 07.11; olympiad 8-11 October
- **10, mapper.** program registration, official deadline 2026-12-07
- **40 Science.** grant report due 10.11
- **Waiting:** code signing request
A line starting with an area code goes to that area; a bold line without a code goes to «Waiting for you». Dates are found in 2026-12-07, 07.11, 07.11.2026, 8 October, October 8, Oct 8 2026 and in Russian (8 октября); the words before a date become its label.
| Date | What | Window | Role |
|---|---|---|---|
| 2026-11-04 | Send invitations to last year's participants | registration until 04.11 | owner |
| 2026-12-06 | Publish the ratings | 23.11-06.12 | co-owner |
Past rows are skipped. A role starting with owner, lead or отв is not repeated on the card.
Mods run with your permissions, without a sandbox. claude plugin validate . lists what this one does:
save journalssh to your sshHost every one or two minutes while a window is attached (never in claude -p), and opens a terminal or Obsidian on a button presssave journal, and with a five-token question every 55 minutes while you keep the cache warm; both count toward your usageNo other network calls, no telemetry.
claude plugin test .
Eight engine tests cover both surfaces (terminal and desktop), English and Russian, every option off and on, the limit resume, the cache, background runs and the date parser.
Пульт для Claude Code: над полем ввода чипы частых ответов и строка состояния, справа лента карточек - разделы, сроки из заметок, сессия, лимиты, кэш, сервер по ssh. Язык переключается настройкой "language": "ru". Всё личное (папка заметок, заметка с фокусом, таблица сроков, сервер) задаётся в pluginConfigs вашего settings.json; пустая настройка выключает свою карточку. Установка и форматы заметок - выше.
MIT
hooks/register.tsx 653 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, RenderSurface, Timer } from 'claude-code'
3
4import type { PultServer } from '../types'
5import { areaOf, areasFrom, parseAreas } from './areas'
6import { daysBetween, parseDeadlines } from './deadlines'
7import { isOwnRole, parseDuties } from './duties'
8import {
9 EMPTY_TOKENS, addTokens, amount, bottomLine, contextLine, days, ddmm, forecast, hhmm, limitLine,
10 localDate, localStamp, norm, placeOf, shortLabel, span, sparkline, toUsage,
11} from './format'
12import { setLanguage, t } from './i18n'
13import { offline, parseProbe, probeArgv, sshError } from './server'
14import { cacheSvg, limitsSvg, serverSvg, sessionSvg } from './svg'
15
16const PANE = 'pult'
17const CACHE_MS = 60 * 60_000
18const WARN_BEFORE_MS = 10 * 60_000
19const PING_EVERY_MS = 55 * 60_000
20const BIG_CONTEXT = 150_000
21const LONG_SESSION = 15
22const AFTER_RESET_MS = 60_000
23const RETRY_MS = 60_000
24const STAGGER_MS = 3 * 60_000
25const SERVER_SHOWN_MS = 60_000
26const SERVER_IDLE_MS = 120_000
27const SHOWN_DEADLINES = 6
28
29// Everything personal comes from the options; an empty option turns its feature off
30const cfg = {
31 quick: [] as string[],
32 vault: '',
33 focus: '',
34 deadlines: '',
35 deadlinesNote: '',
36 inbox: '',
37 inboxDir: '',
38 journal: '',
39 obsidian: '',
40 areasRoot: '',
41 host: '',
42 serverName: '',
43 shell: 'sh',
44}
45
46const usage = atom({ plugin: 'pult', key: 'usage' } as const, null)
47const samples = atom({ plugin: 'pult', key: 'samples' } as const, [])
48const turns = atom({ plugin: 'pult', key: 'turns' } as const, 0)
49const tools = atom({ plugin: 'pult', key: 'tools' } as const, 0)
50const tokens = atom({ plugin: 'pult', key: 'tokens' } as const, EMPTY_TOKENS)
51const agentTokens = atom({ plugin: 'pult', key: 'agentTokens' } as const, EMPTY_TOKENS)
52const history = atom({ plugin: 'pult', key: 'history' } as const, [])
53const lastTurnAt = atom({ plugin: 'pult', key: 'lastTurnAt' } as const, 0)
54const nowAtom = atom({ plugin: 'pult', key: 'now' } as const, 0)
55const keepUntil = atom({ plugin: 'pult', key: 'keepUntil' } as const, 0)
56const resumeAt = atom({ plugin: 'pult', key: 'resumeAt' } as const, 0)
57const journalPath = atom({ plugin: 'pult', key: 'journalPath' } as const, '')
58const server = atom({ plugin: 'pult', key: 'server' } as const, null)
59const isPolling = atom({ plugin: 'pult', key: 'isPolling' } as const, false)
60const deadline = atom({ plugin: 'pult', key: 'deadline' } as const, null)
61const areas = atom({ plugin: 'pult', key: 'areas' } as const, [])
62const waiting = atom({ plugin: 'pult', key: 'waiting' } as const, '')
63const inbox = atom({ plugin: 'pult', key: 'inbox' } as const, 0)
64const area = atom({ plugin: 'pult', key: 'area' } as const, '')
65const areaList = atom({ plugin: 'pult', key: 'areaList' } as const, [])
66const duties = atom({ plugin: 'pult', key: 'duties' } as const, [])
67const isDutiesOpen = atom({ plugin: 'pult', key: 'isDutiesOpen' } as const, false)
68
69let place = ''
70let here = ''
71let cwd = ''
72let isPaneOpen = false
73let retries = 0
74let warnTimer: Timer | undefined
75let pingTimer: Timer | undefined
76let resumeTimer: Timer | undefined
77
78// Surfaces where the engine raises PromptHint; elsewhere the usage line lives in the band
79const hintSurfaces = new Set<RenderSurface>()
80
81const dayTone = (n: number) => (n <= 3 ? { color: 'red' } : n <= 10 ? { color: 'yellow' } : { dimColor: true as const })
82const isAbsolute = (p: string): boolean => /^([A-Za-z]:[\\/]|\/|~)/.test(p)
83const isWindows = (): boolean => /^[A-Za-z]:[\\/]/.test(cwd)
84
85function configure(options: PluginOptions): void {
86 const opt = (key: string): string => String(options[key] ?? '').trim()
87 setLanguage(opt('language'))
88 cfg.quick = (opt('quickReplies') || t.quickReplies).split('|').map(s => s.trim()).filter(Boolean).slice(0, 9)
89 cfg.vault = norm(opt('vaultPath'))
90 const inVault = (p: string): string => (p === '' ? '' : isAbsolute(p) ? norm(p) : cfg.vault === '' ? '' : `${cfg.vault}/${norm(p).replace(/^\/+/, '')}`)
91 cfg.focus = inVault(opt('focusNote'))
92 cfg.deadlinesNote = opt('deadlinesNote')
93 cfg.deadlines = inVault(cfg.deadlinesNote)
94 cfg.inboxDir = opt('inboxDir')
95 cfg.inbox = inVault(cfg.inboxDir)
96 cfg.journal = inVault(opt('journalDir'))
97 cfg.obsidian = opt('obsidianVault')
98 cfg.areasRoot = norm(opt('areasRoot'))
99 cfg.host = opt('sshHost')
100 cfg.serverName = opt('serverName') || 'Server'
101 cfg.shell = opt('serverShell') === 'powershell' ? 'powershell' : 'sh'
102}
103
104// Open a link or start a terminal the way the machine does it
105function opener(target: string): string[] {
106 if (isWindows()) return ['explorer.exe', target]
107 return cwd.startsWith('/Users/') ? ['open', target] : ['xdg-open', target]
108}
109
110function terminalArgv(): string[] {
111 if (isWindows()) return ['cmd.exe', '/c', 'start', cfg.serverName, 'ssh', cfg.host]
112 if (cwd.startsWith('/Users/')) return ['osascript', '-e', `tell application "Terminal" to do script "ssh ${cfg.host}"`]
113 return ['x-terminal-emulator', '-e', 'ssh', cfg.host]
114}
115
116// --- server -----------------------------------------------------------------
117
118async function pollServer($: EngineInterface, isForced: boolean): Promise<void> {
119 if (cfg.host === '' || (await read($, isPolling))) return
120 // A background run (claude -p, routines) attaches no surface: nobody looks, no ssh.
121 // isInteractive will not do: the desktop app starts the engine as a host, and it is false there too
122 if (!isForced && (await $.session.surfaces()).length === 0) return
123 const now = await $.clock.now()
124 const every = isPaneOpen ? SERVER_SHOWN_MS : SERVER_IDLE_MS
125 const mine = await read($, server)
126 if (!isForced && mine !== null && now - mine.checkedAt < every - 5_000) return
127 // A fresh snapshot from another session saves an ssh call
128 const key = `pult-server:${cfg.host}`
129 const shared = (await $.store.get(key)) as PultServer | undefined
130 if (!isForced && shared !== undefined && now - shared.checkedAt < every - 5_000) {
131 if (mine === null || shared.checkedAt > mine.checkedAt) await update($, server, () => shared)
132 return
133 }
134 await update($, isPolling, () => true)
135 let fresh: PultServer
136 try {
137 const ran = await $.process.run(probeArgv(cfg.host, cfg.shell), { timeoutMs: 20_000 })
138 fresh = parseProbe(ran.stdout, now) ?? offline(now, sshError(ran.stderr))
139 } catch {
140 fresh = offline(now, 'no answer in 20 s')
141 }
142 await update($, server, () => fresh)
143 await update($, isPolling, () => false)
144 await $.store.set(key, fresh)
145}
146
147// --- notes: focus, deadlines table, inbox ------------------------------------------
148
149async function readText($: EngineInterface, path: string): Promise<string> {
150 if (path === '') return ''
151 try {
152 return await $.fs.read(path)
153 } catch {
154 return ''
155 }
156}
157
158async function refreshNotes($: EngineInterface): Promise<void> {
159 const today = localDate(await $.clock.now())
160 const text = await readText($, cfg.focus)
161 const table = await readText($, cfg.deadlines)
162 const codes = (await read($, areaList)).map(a => a.code)
163 const parsed = parseAreas(text, today, codes)
164 const mine = parseDuties(table, today)
165 // The band shows the earliest date from the focus note and the table
166 const candidates = [parseDeadlines(text, today)[0], mine[0] && { date: mine[0].date, label: mine[0].what }].filter(d => d !== undefined)
167 const nearest = candidates.sort((a, b) => a.date.localeCompare(b.date))[0] ?? null
168 if (JSON.stringify(await read($, areas)) !== JSON.stringify(parsed.areas)) await update($, areas, () => parsed.areas)
169 if ((await read($, waiting)) !== parsed.waiting) await update($, waiting, () => parsed.waiting)
170 if (JSON.stringify(await read($, deadline)) !== JSON.stringify(nearest)) await update($, deadline, () => nearest)
171 if (JSON.stringify(await read($, duties)) !== JSON.stringify(mine)) await update($, duties, () => mine)
172 if (cfg.inbox !== '') {
173 try {
174 const count = (await $.fs.list(cfg.inbox)).filter(f => f.kind === 'file' && f.name.endsWith('.md')).length
175 if ((await read($, inbox)) !== count) await update($, inbox, () => count)
176 } catch {
177 // no folder, nothing to count
178 }
179 }
180}
181
182async function refreshAreas($: EngineInterface): Promise<void> {
183 if (cfg.areasRoot === '') return
184 try {
185 const found = areasFrom((await $.fs.list(cfg.areasRoot)).filter(f => f.kind === 'dir').map(f => f.name))
186 if (JSON.stringify(await read($, areaList)) !== JSON.stringify(found)) await update($, areaList, () => found)
187 } catch {
188 // no folder, no areas
189 }
190}
191
192// --- journal -----------------------------------------------------------------
193
194async function saveJournal($: EngineInterface): Promise<string | undefined> {
195 if (cfg.journal === '') {
196 $.ui.toast(t.journalOff)
197 return undefined
198 }
199 const r = await $.model.fork({ prompt: t.journalAsk, maxTokens: 1500 })
200 if (!r.isAnswered) {
201 $.ui.toast(t.journalFailed)
202 return undefined
203 }
204 const stamp = localStamp(await $.clock.now())
205 const title = (r.text.match(/^#\s*(.+)$/m) ?? [])[1]?.trim() ?? 'session'
206 const slug = title.toLowerCase().replace(/[^a-zа-яё0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 50)
207 const path = `${cfg.journal}/${stamp.slice(0, 10)}-${slug}.md`
208 const front = `---\ncreated: ${stamp.slice(0, 10)}\ntime: '${stamp.slice(11, 16)}'\ntype: session-log\n---\n`
209 await $.fs.write(path, front + r.text.trim() + '\n')
210 await update($, journalPath, () => path)
211 $.ui.toast(t.journalWritten(path.split('/').pop() ?? path))
212 return path
213}
214
215// --- prompt cache ------------------------------------------------------------
216
217function rearmWarn($: EngineInterface): void {
218 warnTimer?.cancel()
219 warnTimer = $.clock.after(CACHE_MS - WARN_BEFORE_MS, async () => {
220 const { context } = await $.session.usage()
221 if ((context.tokens ?? 0) < BIG_CONTEXT) return
222 if ((await $.clock.now()) < (await read($, keepUntil))) return
223 $.ui.toast(t.cacheSoon(amount(context.tokens ?? 0)))
224 })
225}
226
227function startPing($: EngineInterface): void {
228 pingTimer?.cancel()
229 pingTimer = $.clock.every(PING_EVERY_MS, async () => {
230 if ((await $.clock.now()) > (await read($, keepUntil))) {
231 pingTimer?.cancel()
232 pingTimer = undefined
233 return
234 }
235 // A question over the cached conversation: reading the cache extends its life
236 await $.model.fork({ prompt: 'Reply with a single dot.', maxTokens: 5 })
237 })
238}
239
240async function keepCache($: EngineInterface, hours: number): Promise<string> {
241 const until = (await $.clock.now()) + hours * 3_600_000
242 await update($, keepUntil, () => until)
243 startPing($)
244 return t.keptReply(hhmm(until))
245}
246
247async function releaseCache($: EngineInterface): Promise<void> {
248 pingTimer?.cancel()
249 pingTimer = undefined
250 await update($, keepUntil, () => 0)
251}
252
253// --- resume after a limit ----------------------------------------------------
254
255function scheduleResume($: EngineInterface, at: number, wait: number, text: string): void {
256 resumeTimer?.cancel()
257 void update($, resumeAt, () => at)
258 resumeTimer = $.clock.after(wait, () => {
259 resumeTimer = undefined
260 void update($, resumeAt, () => 0)
261 void $.prompt.submit({ text })
262 })
263}
264
265async function cancelResume($: EngineInterface): Promise<void> {
266 resumeTimer?.cancel()
267 resumeTimer = undefined
268 await update($, resumeAt, () => 0)
269}
270
271// Several sessions on one limit wake up in turn, not all in the same minute
272async function slotFor($: EngineInterface, resets: string): Promise<number> {
273 const id = await $.session.id()
274 const queue = ((await $.store.get('pult-resume-queue')) ?? []) as { id: string; resetsAt: string }[]
275 const others = queue.filter(x => x.resetsAt === resets && x.id !== id)
276 await $.store.set('pult-resume-queue', [...others, { id, resetsAt: resets }])
277 return others.length
278}
279
280// --- registration --------------------------------------------------------------
281
282async function openPane($: EngineInterface): Promise<boolean> {
283 const opened = await $.ui.open({ id: PANE, title: t.title })
284 isPaneOpen = opened.isPlaced
285 return opened.isPlaced
286}
287
288export const register: Register = (on, options) => {
289 configure(options)
290
291 on('session.start', async ($, e, next) => {
292 await update($, isPolling, () => false)
293 cwd = e.cwd
294 place = placeOf(e.cwd, cfg.areasRoot)
295 here = areaOf(e.cwd, cfg.areasRoot)
296 if ((await read($, area)) === '' && here !== '') await update($, area, () => here)
297 const now = await $.clock.now()
298 await update($, nowAtom, () => now)
299 const first = await $.session.usage()
300 await update($, usage, prev => toUsage(first, prev))
301
302 await $.command.register({ name: 'pult', description: t.cmdPult })
303 await $.command.register({ name: 'journal', description: t.cmdJournal })
304 await $.command.register({ name: 'keep-cache', description: t.cmdKeep })
305
306 // Timers live until the module reloads; what was promised before it is armed again
307 const resume = await read($, resumeAt)
308 if (resume > now) scheduleResume($, resume, resume - now, t.resumeText)
309 if ((await read($, keepUntil)) > now) startPing($)
310 if ((await read($, lastTurnAt)) > 0) rearmWarn($)
311
312 $.clock.every(60_000, async () => {
313 const tick = await $.clock.now()
314 await update($, nowAtom, () => tick)
315 if (Math.floor(tick / 60_000) % 10 === 0) await refreshNotes($)
316 })
317 $.clock.every(30_000, () => void pollServer($, false))
318 void refreshAreas($).then(() => refreshNotes($))
319 void pollServer($, false)
320 void openPane($)
321 return next(e)
322 })
323
324 on('session.measure', async ($, e, next) => {
325 const result = await next(e)
326 await update($, usage, prev => toUsage(e, prev))
327 return result
328 })
329
330 on('tool.call', async ($, e, next) => {
331 await update($, tools, n => n + 1)
332 return next(e)
333 })
334
335 on('turn.complete', async ($, e, next) => {
336 const result = await next(e)
337 const fresh = await $.session.usage()
338 await update($, usage, prev => toUsage(fresh, prev))
339 if (e.usage) {
340 const spent = e.usage
341 if (e.agentId === undefined) await update($, tokens, sum => addTokens(sum, spent))
342 else await update($, agentTokens, sum => addTokens(sum, spent))
343 }
344 if (e.agentId !== undefined) return result
345
346 const now = await $.clock.now()
347 const n = (await read($, turns)) + 1
348 await update($, turns, () => n)
349 const pct = fresh.context.percent
350 if (pct !== undefined) await update($, history, list => [...list, { turn: n, pct }].slice(-200))
351 const five = fresh.rateLimits.find(l => l.kind === 'five_hour')
352 if (five) await update($, samples, list => [...list.filter(s => now - s.t < 2 * 3_600_000), { t: now, p: five.percentUsed }])
353 const before = await read($, lastTurnAt)
354 await update($, lastTurnAt, () => now)
355 await update($, nowAtom, () => now)
356 rearmWarn($)
357
358 if (e.reason === 'error') {
359 const hit = fresh.rateLimits.find(l => l.percentUsed >= 100 && l.resetsAt)
360 if (hit?.resetsAt) {
361 const slot = await slotFor($, hit.resetsAt)
362 const wait = Math.max(0, Date.parse(hit.resetsAt) - now) + AFTER_RESET_MS + slot * STAGGER_MS
363 scheduleResume($, now + wait, wait, t.resumeText)
364 $.ui.toast(t.limitHit(hhmm(now + wait)))
365 } else if (retries < 1) {
366 retries += 1
367 scheduleResume($, now + RETRY_MS, RETRY_MS, t.retryText)
368 $.ui.toast(t.retrySoon)
369 }
370 return result
371 }
372 retries = 0
373
374 // Back after a night: a journal and a new session are cheaper than dragging the context on
375 const pause = before > 0 ? now - before : 0
376 if (pause > 8 * 3_600_000 && (fresh.context.tokens ?? 0) >= BIG_CONTEXT) {
377 return { ...result, text: t.longPause(Math.round(pause / 3_600_000)) }
378 }
379 return result
380 })
381
382 // The person wrote first: the scheduled resume is no longer needed
383 on('prompt.submit', async ($, e, next) => {
384 if (e.origin.kind === 'composer' && (await read($, resumeAt)) > 0) await cancelResume($)
385 return next(e)
386 })
387
388 on('command.run', { command: 'pult' }, async $ => ({ text: (await openPane($)) ? t.paneOpened : t.paneNoRoom }))
389
390 on('command.run', { command: 'journal' }, async $ => {
391 if (cfg.journal === '') return { text: t.journalOff }
392 const path = await saveJournal($)
393 return { text: path ? t.journalWritten(path) : t.journalNot }
394 })
395
396 on('command.run', { command: 'keep-cache' }, async ($, e) => ({
397 text: await keepCache($, Math.min(12, Math.max(1, Number(e.args) || 3))),
398 }))
399
400 on('ui.close', { id: PANE }, async ($, e, next) => {
401 isPaneOpen = false
402 return next(e)
403 })
404
405 // --- the usage line in the footer ---------------------------------------------------
406
407 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
408 const label = shortLabel(await read($, usage))
409 return label ? next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } }) : next(e)
410 })
411
412 // The desktop app does not raise this one (checked on 2.1.286); there the usage line lives in the band
413 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
414 if (!hintSurfaces.has(e.surface)) {
415 hintSurfaces.add(e.surface)
416 $.clock.after(0, () => $.ui.invalidate('ui.render'))
417 }
418 const line = bottomLine(await read($, usage), await read($, samples), await read($, tokens), await read($, nowAtom))
419 if (line === undefined) return next(e)
420 if (e.surface === 'terminal') return next({ ...e, props: { ...e.props, tail: ` · ${line}` } })
421 return next({ ...e, props: { ...e.props, hint: e.props.hint ? `${line} ${e.props.hint}` : line } })
422 })
423
424 // --- the band above the prompt: quick replies and state ---------------------------------
425
426 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
427 const theirs = await next(e)
428 if (e.props.hasSurvey) return theirs
429 const { Box, Button, Text } = $.ui.resolve(e)
430
431 const now = (await read($, nowAtom)) || (await $.clock.now())
432 const u = await read($, usage)
433 const due = await read($, deadline)
434 const box = await read($, server)
435 const last = await read($, lastTurnAt)
436 const keep = await read($, keepUntil)
437 const resume = await read($, resumeAt)
438 const n = await read($, turns)
439 const isSaved = (await read($, journalPath)) !== ''
440 const left = last > 0 ? last + CACHE_MS - now : 0
441 const isBig = (u?.contextTokens ?? 0) >= BIG_CONTEXT
442
443 const info: { text: string; color?: string; dim?: boolean }[] = [{ text: place || t.noFolder, dim: true }]
444 if (due !== null) {
445 const d = daysBetween(localDate(now), due.date)
446 const label = due.label.length > 40 ? `${due.label.slice(0, 39)}…` : due.label
447 info.push({ text: `${t.due} ${ddmm(due.date)} ${label} (${days(d)})`, ...(d <= 3 ? { color: 'red' } : d <= 10 ? { color: 'yellow' } : {}) })
448 }
449 if (box !== null) {
450 const jobs = box.jobs.map(j => `${j.name} ${span(j.minutes * 60_000)}`).join(', ')
451 info.push(box.isOnline ? { text: `${cfg.serverName} ${t.serverOk}${jobs ? ` ${jobs}` : ''}`, color: 'green' } : { text: `${cfg.serverName} ${t.serverDown}`, color: 'red' })
452 }
453 if (keep > now) info.push({ text: t.cacheKeptUntil(hhmm(keep)), color: 'green' })
454 else if (left > 0) info.push({ text: t.cacheLeft(Math.ceil(left / 60_000)), ...(left < WARN_BEFORE_MS ? { color: 'yellow' } : { dim: true }) })
455 else if (last > 0 && isBig) info.push({ text: t.cacheCold, dim: true })
456
457 const fallback = hintSurfaces.has(e.surface) ? undefined : bottomLine(u, await read($, samples), await read($, tokens), now)
458
459 return (
460 <Box flexDirection="column">
461 {theirs}
462 <Box flexWrap="wrap" columnGap={1}>
463 {cfg.quick.map((label, i) => (
464 <Button key={`q${i + 1}`} label={label} onPress={() => void $.prompt.submit({ text: label, asUser: true })} />
465 ))}
466 </Box>
467 <Box flexWrap="wrap" columnGap={1}>
468 {info.map((item, i) => (
469 <Text {...(item.color ? { color: item.color } : {})} {...(item.dim ? { dimColor: true } : {})}>
470 {`${i > 0 ? '· ' : ''}${item.text}`}
471 </Text>
472 ))}
473 {resume > now && <Text color="yellow">{t.resumeAt(hhmm(resume))}</Text>}
474 {resume > now && <Button key="resume-cancel" label={t.cancel} plain dimColor onPress={() => void cancelResume($)} />}
475 {keep <= now && left > 0 && left < WARN_BEFORE_MS && isBig && (
476 <Button key="keep" label={t.keep3h} onPress={() => void keepCache($, 3).then(text => $.ui.toast(text))} />
477 )}
478 {cfg.journal !== '' && !isSaved && n >= LONG_SESSION && <Text dimColor>{t.turnsNoJournal(n)}</Text>}
479 {cfg.journal !== '' && !isSaved && n >= LONG_SESSION && <Button key="journal" label={t.saveJournal} onPress={() => void saveJournal($)} />}
480 </Box>
481 {fallback !== undefined && <Text dimColor wrap="truncate-end">{fallback}</Text>}
482 </Box>
483 )
484 })
485
486 // --- the pane: a feed of cards, scrolling when taller than the window ------------------------
487
488 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
489 const table = $.ui.resolve(e)
490 const { Box, Button, Text } = table
491 // The terminal's table answers Svg with an empty fragment, so look at the surface
492 const Svg = e.surface !== 'terminal' && 'Svg' in table ? table.Svg : undefined
493
494 const now = (await read($, nowAtom)) || (await $.clock.now())
495 const u = await read($, usage)
496 const list = await read($, areaList)
497 const picked = (await read($, area)) || here || (list[0]?.code ?? '')
498 const info = (await read($, areas)).find(a => a.code === picked)
499 const meta = list.find(a => a.code === picked)
500 const wait = await read($, waiting)
501 const unread = await read($, inbox)
502 const mine = await read($, duties)
503 const isAll = await read($, isDutiesOpen)
504 const own = await read($, tokens)
505 const helpers = await read($, agentTokens)
506 const points = await read($, history)
507 const n = await read($, turns)
508 const toolCount = await read($, tools)
509 const pace = u?.five ? forecast(await read($, samples), u.five.resetsAt, now) : undefined
510 const resume = await read($, resumeAt)
511 const last = await read($, lastTurnAt)
512 const keep = await read($, keepUntil)
513 const left = last > 0 ? last + CACHE_MS - now : 0
514 const box = await read($, server)
515 const isBusy = await read($, isPolling)
516 const isSaved = (await read($, journalPath)) !== ''
517 const elapsed = u && u.startedAt > 0 ? now - u.startedAt : 0
518
519 const card = (key: string, title: string, aside: string | undefined, children: unknown[]) => (
520 <Box key={key} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginBottom={1}>
521 <Box justifyContent="space-between">
522 <Text bold>{title}</Text>
523 {aside !== undefined && <Text dimColor>{aside}</Text>}
524 </Box>
525 {children}
526 </Box>
527 )
528 const picture = (source: string, alt: string) => (Svg !== undefined ? <Svg source={source} alt={alt} /> : undefined)
529 const dateRow = (key: string, date: string, label: string) => {
530 const dn = daysBetween(localDate(now), date)
531 return (
532 <Box key={key}>
533 <Box width={7} flexShrink={0}><Text dimColor>{ddmm(date)}</Text></Box>
534 <Box flexGrow={1}><Text wrap="truncate-end">{label}</Text></Box>
535 <Text {...dayTone(dn)}>{` ${days(dn)}`}</Text>
536 </Box>
537 )
538 }
539 const tokenText = (x: typeof own) => t.tokensLine(amount(x.input), amount(x.output), amount(x.cacheRead), amount(x.cacheWrite))
540
541 return (
542 <Box flexDirection="column">
543 {list.length > 0 &&
544 card('areas', t.areas, meta ? meta.folder : undefined, [
545 <Box key="chips" flexWrap="wrap" columnGap={1}>
546 {list.map(a => (
547 <Button key={`area-${a.code}`} label={a.short} {...(a.code === picked ? { variant: 'primary' as const } : {})} onPress={() => void update($, area, () => a.code)} />
548 ))}
549 </Box>,
550 ...(cfg.focus !== '' ? [<Text key="focus" wrap="wrap">{info?.text || t.nothingBurns}</Text>] : []),
551 ...(info?.deadlines ?? []).map(d => dateRow(`d-${d.date}-${d.label}`, d.date, d.label)),
552 picked !== here && meta !== undefined ? (
553 <Box key="go" marginTop={1}>
554 <Button key="area-go" label={t.workIn(meta.short)} onPress={() => void $.prompt.submit({ text: t.workInPrompt(meta.folder, `${cfg.areasRoot}/${meta.folder}`.replace(/\//g, isWindows() ? '\\' : '/')), asUser: true })} />
555 </Box>
556 ) : (
557 <Text key="here" dimColor>{t.youAreHere}</Text>
558 ),
559 ])}
560
561 {mine.length > 0 &&
562 card('deadlines', t.myDeadlines, t.ahead(mine.length), [
563 ...(isAll ? mine : mine.slice(0, SHOWN_DEADLINES)).map(d => dateRow(`dl-${d.date}-${d.what}`, d.date, isOwnRole(d.role) ? d.what : `${d.what} (${d.role})`)),
564 <Box key="deadlines-actions" columnGap={1} marginTop={1}>
565 {mine.length > SHOWN_DEADLINES && (
566 <Button key="deadlines-more" label={isAll ? t.collapse : t.all(mine.length)} onPress={() => void update($, isDutiesOpen, v => !v)} />
567 )}
568 {cfg.obsidian !== '' && (
569 <Button
570 key="deadlines-note"
571 label={t.openNote}
572 onPress={() => void $.process.run(opener(`obsidian://open?vault=${encodeURIComponent(cfg.obsidian)}&file=${encodeURIComponent(cfg.deadlinesNote.replace(/\.md$/, ''))}`))}
573 />
574 )}
575 </Box>,
576 ])}
577
578 {(wait !== '' || unread > 0) &&
579 card('waiting', t.waiting, unread > 0 ? t.inbox(unread) : undefined, [
580 ...(wait !== '' ? [<Text key="w" wrap="wrap">{wait}</Text>] : []),
581 ...(unread > 0
582 ? [
583 <Box key="inbox-row" marginTop={1}>
584 <Button key="inbox" label={t.sortInbox} onPress={() => void $.prompt.submit({ text: t.sortInboxPrompt(cfg.inboxDir), asUser: true })} />
585 </Box>,
586 ]
587 : []),
588 ])}
589
590 {card('session', t.sessionCard, place, [
591 picture(sessionSvg({ usage: u, tokens: own, agentTokens: helpers, turns: n, tools: toolCount, elapsedMs: elapsed, history: points }), `${t.context} ${Math.round(u?.contextPct ?? 0)}%, ${tokenText(own)}`) ?? (
592 <Box key="session-text" flexDirection="column">
593 <Text>{u ? (contextLine(u) ?? t.noData) : t.numbersLater}</Text>
594 <Text dimColor>{tokenText(own)}</Text>
595 {helpers.output + helpers.input > 0 && <Text dimColor>{`${t.helpers}: ${tokenText(helpers)}`}</Text>}
596 <Text dimColor>{t.turnsLine(n, toolCount, span(elapsed))}</Text>
597 {points.length >= 2 && <Text color="cyan">{sparkline(points.slice(-40).map(p => p.pct))}</Text>}
598 </Box>
599 ),
600 <Box key="session-actions" flexWrap="wrap" columnGap={1} marginTop={1}>
601 {cfg.journal !== '' && <Button key="pane-journal" label={isSaved ? t.journalSaved : t.saveJournal} onPress={() => void saveJournal($)} />}
602 <Button key="compact" label={t.compact} onPress={() => void $.command.run({ command: 'compact' })} />
603 </Box>,
604 ])}
605
606 {card('limits', t.limits, u?.costUsd !== undefined ? t.apiPrice(u.costUsd.toFixed(2)) : undefined, [
607 picture(limitsSvg(u, pace, now), `${t.fiveHoursTitle} ${Math.round(u?.five?.pct ?? 0)}%, ${t.weekTitle} ${Math.round(u?.week?.pct ?? 0)}%`) ?? (
608 <Box key="limits-text" flexDirection="column">
609 <Text>{limitLine(t.fiveHours, u?.five, now, pace) ?? t.noDataFor(t.fiveHours)}</Text>
610 <Text>{limitLine(t.week, u?.week, now) ?? t.noDataFor(t.week)}</Text>
611 </Box>
612 ),
613 ...(resume > now
614 ? [
615 <Box key="resume" columnGap={1}>
616 <Text color="yellow">{t.resumeAt(hhmm(resume)).replace(/^· /, '')}</Text>
617 <Button key="pane-resume-cancel" label={t.cancel} onPress={() => void cancelResume($)} />
618 </Box>,
619 ]
620 : []),
621 ])}
622
623 {card('cache', t.cache, undefined, [
624 picture(cacheSvg(left, keep, now), keep > now ? t.cacheKeptUntil(hhmm(keep)) : left > 0 ? t.cacheLeft(Math.ceil(left / 60_000)) : t.cacheCold) ?? (
625 <Text key="cache-text">{keep > now ? t.cacheKept(hhmm(keep)) : left > 0 ? t.cacheWarm(Math.ceil(left / 60_000)) : t.cacheColdLong}</Text>
626 ),
627 <Box key="cache-actions" columnGap={1} marginTop={1}>
628 {keep > now ? (
629 <Button key="release" label={t.release} onPress={() => void releaseCache($)} />
630 ) : (
631 <Button key="pane-keep" label={t.keep} onPress={() => void keepCache($, 3).then(text => $.ui.toast(text))} />
632 )}
633 </Box>,
634 ])}
635
636 {cfg.host !== '' &&
637 card('server', cfg.serverName, box?.isOnline ? t.online : box ? t.offline('').replace(/: $/, '') : undefined, [
638 picture(serverSvg(box, isBusy), box?.isOnline ? `${cfg.serverName}: ${t.online}, ${t.cpu} ${box.cpu ?? 0}%` : `${cfg.serverName}: ${t.noData}`) ?? (
639 <Text key="server-text" {...(box?.isOnline ? { color: 'green' } : { color: 'red' })}>
640 {box === null ? t.noData : box.isOnline ? `● ${t.cpu} ${box.cpu ?? 0}% · ${t.gpu} ${box.gpu?.util ?? 0}%` : `○ ${box.error ?? ''}`}
641 </Text>
642 ),
643 ...(box?.isOnline ? [<Text key="jobs" wrap="wrap">{box.jobs.length > 0 ? t.running(box.jobs.map(j => `${j.name} ${span(j.minutes * 60_000)}`).join(', ')) : t.idle}</Text>] : []),
644 <Box key="server-actions" columnGap={1} marginTop={1}>
645 <Button key="refresh" label={isBusy ? t.checking : t.refresh} onPress={() => void pollServer($, true)} />
646 <Button key="terminal" label={t.terminal} onPress={() => void $.process.run(terminalArgv())} />
647 </Box>,
648 ])}
649 </Box>
650 )
651 })
652}
653hooks/areas.ts 60 lines1import type { PultArea, PultAreaFolder } from '../types'
2import { parseDeadlines } from './deadlines'
3
4// Work areas: folders named «NN Name» (Johnny.Decimal) under the areas root
5export function areasFrom(names: string[]): PultAreaFolder[] {
6 return names
7 .map(name => name.match(/^(\d\d) (.+)$/))
8 .filter((m): m is RegExpMatchArray => m !== null)
9 .map(m => {
10 const name = m[2] ?? ''
11 return { code: m[1] ?? '', folder: m[0], short: name.length <= 12 ? name : (name.split(/\s+/)[0] ?? name) }
12 })
13 .sort((a, b) => a.code.localeCompare(b.code))
14}
15
16// Area code of the working directory: «…/40 Science/44 Patents» under the root → «40»
17export function areaOf(cwd: string, root: string): string {
18 const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
19 const here = norm(cwd)
20 const base = norm(root)
21 if (base === '' || !here.startsWith(`${base}/`)) return ''
22 return cwd.replace(/\\/g, '/').slice(root.replace(/\\/g, '/').replace(/\/+$/, '').length + 1).split('/')[0]?.match(/^(\d\d) /)?.[1] ?? ''
23}
24
25// Links [[name]] become the name; no asterisks or backticks
26const plain = (s: string): string =>
27 s
28 .replace(/\s*\(\s*\[\[[^()]*\]\]\s*\)/g, '')
29 .replace(/\[\[([^\]|]+)(\|([^\]]+))?\]\]/g, (_, name: string, __, alias?: string) => alias ?? name)
30 .replace(/[*`]/g, '')
31 .replace(/\s{2,}/g, ' ')
32 .trim()
33
34// The focus note: «- **40 Science.** …» or «- **10, project.** …» belongs to the area by its first two digits;
35// a bold line without a code («- **Waiting:** …») goes to the waiting card
36export function parseAreas(note: string, today: string, codes: string[]): { areas: PultArea[]; waiting: string } {
37 const lines = note.split('\n').filter(l => l.startsWith('- **'))
38 const waiting: string[] = []
39 const byCode = new Map<string, string[]>()
40 for (const line of lines) {
41 const head = line.match(/^- \*\*([^*]+)\*\*\s*(.*)$/)
42 if (!head) continue
43 const title = (head[1] ?? '').trim()
44 const body = head[2] ?? ''
45 const code = title.match(/^(\d\d)\b/)?.[1]
46 if (code === undefined) {
47 waiting.push(plain(body))
48 continue
49 }
50 // «10 Admissions.» is the area itself; «10, project.» names a project inside it
51 const extra = title.match(/^\d\d,\s*(.+?)\.?$/)?.[1]
52 byCode.set(code, [...(byCode.get(code) ?? []), (extra ? `${extra}: ` : '') + plain(body)])
53 }
54 const areas = codes.map(code => {
55 const raw = lines.filter(l => l.match(/^- \*\*(\d\d)/)?.[1] === code).join('\n')
56 return { code, text: (byCode.get(code) ?? []).join('\n'), deadlines: parseDeadlines(raw, today).slice(0, 5) }
57 })
58 return { areas, waiting: waiting.filter(Boolean).join('\n') }
59}
60hooks/deadlines.ts 90 lines1import type { PultDeadline as Deadline } from '../types'
2
3const RU = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
4const EN = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']
5const EN3 = EN.map(m => m.slice(0, 3))
6const MONTH = [...RU, ...EN, ...EN3].join('|')
7
8// 2026-12-07 | 07.11 and 07.11.2026 | 4 ноября 2026, 8-11 октября, 8 October 2026 (first day) | October 8, Oct 8 2026
9const DATE = new RegExp(
10 String.raw`\b(20\d\d)-(\d\d)-(\d\d)\b` +
11 String.raw`|\b(\d\d)\.(\d\d)(?:\.(20\d\d))?\b` +
12 String.raw`|\b(\d{1,2})(?:[-–]\d{1,2})?\s+(${MONTH})\.?(?:\s+(20\d\d))?` +
13 String.raw`|\b(${[...EN, ...EN3].join('|')})\.?\s+(\d{1,2})(?:st|nd|rd|th)?(?:,?\s+(20\d\d))?`,
14 'gi',
15)
16const FILLER = /^(до|срок|официальный|дедлайн|к|с|по|после|by|due|until|till|deadline|on|before|after|from)$/i
17const DAY = 86_400_000
18
19const pad = (n: number): string => String(n).padStart(2, '0')
20
21export const daysBetween = (from: string, to: string): number => Math.round((Date.parse(to) - Date.parse(from)) / DAY)
22
23function monthOf(name: string): number {
24 const low = name.toLowerCase()
25 const at = [RU, EN, EN3].map(list => list.indexOf(low)).find(i => i >= 0)
26 return (at ?? -1) + 1
27}
28
29function toIso(m: RegExpMatchArray, today: string): string | undefined {
30 const year = (y: string | undefined) => [Number(y ?? today.slice(0, 4)), y !== undefined] as const
31 let y: number
32 let mo: number
33 let d: number
34 let hasYear: boolean
35 if (m[1]) {
36 ;[y, mo, d, hasYear] = [Number(m[1]), Number(m[2]), Number(m[3]), true]
37 } else if (m[4]) {
38 ;[[y, hasYear], mo, d] = [year(m[6]), Number(m[5]), Number(m[4])]
39 } else if (m[7]) {
40 ;[[y, hasYear], mo, d] = [year(m[9]), monthOf(m[8] ?? ''), Number(m[7])]
41 } else {
42 ;[[y, hasYear], mo, d] = [year(m[12]), monthOf(m[10] ?? ''), Number(m[11])]
43 }
44 if (mo < 1 || mo > 12 || d < 1 || d > 31) return undefined
45 // A date without a year that is more than half a year in the past belongs to next year
46 if (!hasYear && daysBetween(today, `${y}-${pad(mo)}-${pad(d)}`) < -180) y += 1
47 return `${y}-${pad(mo)}-${pad(d)}`
48}
49
50// Words without trailing «by», «due», «до», «срок» and punctuation
51function trimmed(text: string): string {
52 const words = text.replace(/^[\s,:;]+/, '').split(/\s+/).filter(Boolean)
53 while (words.length > 0 && FILLER.test(words[words.length - 1] ?? '')) words.pop()
54 return words.join(' ').replace(/[\s,:;]+$/, '')
55}
56
57// Label: the last piece of the phrase before the date (after a comma, colon or bracket);
58// with nothing before the date, the words after it up to a comma
59function labelOf(clause: string, from: number, start: number, end: number): string {
60 const head = clause.slice(from, start).replace(/\*\*/g, '')
61 const before = trimmed(head.split(/[,:;()]/).pop() ?? '') || trimmed(head)
62 return before || (clause.slice(end).replace(/\*\*/g, '').split(/[,:(]/)[0] ?? '').trim()
63}
64
65// Dates in the bullet lines of a note, each with the words around it, today and later, by date
66export function parseDeadlines(note: string, today: string): Deadline[] {
67 const found: Deadline[] = []
68 for (const raw of note.split('\n')) {
69 if (!raw.startsWith('- ')) continue
70 const line = raw
71 .slice(2)
72 .replace(/^\*\*[^*]*\*\*\s*/, '')
73 .replace(/\(\s*\[\[[^\]]*\]\](\s*,\s*\[\[[^\]]*\]\])*\s*\)/g, '')
74 .replace(/\[\[[^\]]*\]\]/g, '')
75 .replace(/`/g, '')
76
77 for (const clause of line.split(/;|\.\s+/)) {
78 let from = 0
79 for (const m of clause.matchAll(DATE)) {
80 const start = m.index ?? 0
81 const end = start + m[0].length
82 const date = toIso(m, today)
83 if (date !== undefined && date >= today) found.push({ date, label: labelOf(clause, from, start, end) })
84 from = end
85 }
86 }
87 }
88 return found.filter(item => item.label !== '').sort((a, b) => a.date.localeCompare(b.date))
89}
90hooks/duties.ts 17 lines1import type { PultDuty } from '../types'
2
3// A deadlines table: rows like «| 2026-12-06 | what to do | window | role |», today and later, by date
4export function parseDuties(note: string, today: string): PultDuty[] {
5 const rows: PultDuty[] = []
6 for (const line of note.split('\n')) {
7 const cells = line.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim())
8 const [date = '', what = '', window = '', role = ''] = cells
9 if (!/^20\d\d-\d\d-\d\d$/.test(date) || what === '' || date < today) continue
10 rows.push({ date, what, window, role })
11 }
12 return rows.sort((a, b) => a.date.localeCompare(b.date))
13}
14
15// A role that means «mine» is not worth repeating on every row
16export const isOwnRole = (role: string): boolean => role === '' || /^(отв|owner|lead|me\b|mine)/i.test(role)
17hooks/format.ts 135 lines1import type { SessionUsage } from 'claude-code'
2
3import type { PultSample, PultTokens, PultUsage } from '../types'
4import { t } from './i18n'
5
6// The machine's own UTC offset at that moment
7const shift = (ms: number): number => -new Date(ms).getTimezoneOffset() * 60_000
8
9export const hhmm = (ms: number): string => new Date(ms + shift(ms)).toISOString().slice(11, 16)
10export const localDate = (ms: number): string => new Date(ms + shift(ms)).toISOString().slice(0, 10)
11export const localStamp = (ms: number): string => new Date(ms + shift(ms)).toISOString()
12export const ddmm = (iso: string): string => `${iso.slice(8, 10)}.${iso.slice(5, 7)}`
13
14// Reset time in local time; not today - with the date
15export function resetAt(iso: string, now: number): string {
16 const at = Date.parse(iso)
17 return localDate(at) === localDate(now) ? hhmm(at) : `${ddmm(localDate(at))} ${hhmm(at)}`
18}
19
20const isEnglish = (): boolean => t.thousand === 'k'
21
22const decimal = (n: number): string => {
23 const s = n.toFixed(1).replace(/\.0$/, '')
24 return isEnglish() ? s : s.replace('.', ',')
25}
26
27// 950 · 82k · 1.2M (ru: 82 тыс. · 1,2 млн)
28export function amount(n: number): string {
29 const sep = isEnglish() ? '' : ' '
30 if (n >= 1_000_000) return `${decimal(n / 1_000_000)}${sep}${t.million}`
31 if (n >= 1000) return `${Math.round(n / 1000)}${sep}${t.thousand}`
32 return String(n)
33}
34
35export function span(ms: number): string {
36 const minutes = Math.max(0, Math.round(ms / 60_000))
37 return minutes >= 60 ? `${Math.floor(minutes / 60)} ${t.hours} ${minutes % 60} ${t.minutes}` : `${minutes} ${t.minutes}`
38}
39
40export const days = (n: number): string => (n === 0 ? t.today : n === 1 ? t.tomorrow : t.days(n))
41
42export const EMPTY_TOKENS: PultTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
43
44export function toUsage(u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'> & { startedAt?: number }, before: PultUsage | null): PultUsage {
45 const limit = (kind: string) => {
46 const found = u.rateLimits.find(l => l.kind === kind)
47 return found ? { pct: found.percentUsed, ...(found.resetsAt ? { resetsAt: found.resetsAt } : {}) } : undefined
48 }
49 const five = limit('five_hour')
50 const week = limit('seven_day')
51 return {
52 startedAt: u.startedAt ?? before?.startedAt ?? 0,
53 window: u.context.window,
54 ...(u.context.tokens !== undefined ? { contextTokens: u.context.tokens } : {}),
55 ...(u.context.percent !== undefined ? { contextPct: u.context.percent } : {}),
56 ...(five ? { five } : {}),
57 ...(week ? { week } : {}),
58 ...(u.cost ? { costUsd: u.cost.usd } : {}),
59 }
60}
61
62// Forecast for the five-hour window from the pace over the last hour
63export function forecast(points: PultSample[], resetsAt: string | undefined, now: number): string | undefined {
64 const recent = points.filter(s => now - s.t <= 60 * 60_000)
65 const first = recent[0]
66 const last = recent[recent.length - 1]
67 if (first === undefined || last === undefined || recent.length < 2) return undefined
68 const dt = last.t - first.t
69 const dp = last.p - first.p
70 if (dt < 5 * 60_000 || dp <= 0) return undefined
71 const at = last.t + ((100 - last.p) / dp) * dt
72 if (resetsAt && at >= Date.parse(resetsAt)) return t.enough
73 return t.pace(hhmm(at))
74}
75
76export function contextLine(u: PultUsage): string | undefined {
77 if (u.contextPct === undefined) return undefined
78 const used = u.contextTokens ?? 0
79 return `${t.context} ${Math.round(u.contextPct)}%, ${amount(used)} ${t.of} ${amount(u.window)}, ${t.free} ${amount(Math.max(0, u.window - used))}`
80}
81
82type PultLimit = NonNullable<PultUsage['five']>
83
84export function limitLine(label: string, l: PultLimit | undefined, now: number, pace?: string): string | undefined {
85 if (l === undefined) return undefined
86 const parts = [`${label} ${Math.round(l.pct)}%`.trim()]
87 if (l.resetsAt) parts.push(`${t.resets} ${resetAt(l.resetsAt, now)}`)
88 if (pace) parts.push(pace)
89 return parts.join(', ')
90}
91
92// The usage line: context, both limits, what the session spent
93export function bottomLine(u: PultUsage | null, samples: PultSample[], tokens: PultTokens, now: number): string | undefined {
94 if (u === null) return undefined
95 const five = u.five ? limitLine(t.fiveHours, u.five, now, forecast(samples, u.five.resetsAt, now)) : undefined
96 const spent = tokens.output + tokens.input + tokens.cacheWrite > 0 ? t.session(amount(tokens.output), amount(tokens.cacheRead)) : undefined
97 const parts = [contextLine(u), five, limitLine(t.week, u.week, now), spent].filter((p): p is string => p !== undefined)
98 return parts.length > 0 ? parts.join(' · ') : undefined
99}
100
101// Short label for the footer modes: «context 41% · 5 h 63%»
102export function shortLabel(u: PultUsage | null): string | undefined {
103 if (u === null) return undefined
104 const parts = [u.contextPct !== undefined ? `${t.context} ${Math.round(u.contextPct)}%` : undefined, u.five ? `${t.fiveHours} ${Math.round(u.five.pct)}%` : undefined]
105 const shown = parts.filter((p): p is string => p !== undefined)
106 return shown.length > 0 ? shown.join(' · ') : undefined
107}
108
109export function addTokens(sum: PultTokens, u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): PultTokens {
110 return {
111 input: sum.input + u.input_tokens,
112 output: sum.output + u.output_tokens,
113 cacheRead: sum.cacheRead + u.cache_read_input_tokens,
114 cacheWrite: sum.cacheWrite + u.cache_creation_input_tokens,
115 }
116}
117
118export const norm = (p: string): string => p.replace(/\\/g, '/').replace(/\/+$/, '')
119
120// Where the session runs: the path under the areas folder, or the last folder name
121export function placeOf(cwd: string, areasRoot: string): string {
122 const here = norm(cwd)
123 const root = norm(areasRoot)
124 if (root !== '' && here.toLowerCase().startsWith(`${root.toLowerCase()}/`)) {
125 return here.slice(root.length + 1).split('/').slice(0, 3).join(' › ')
126 }
127 return here.split('/').pop() ?? cwd
128}
129
130// ▁▂▃▅▇ by percent, for the terminal, which has no Svg
131export function sparkline(values: number[]): string {
132 const marks = '▁▂▃▄▅▆▇█'
133 return values.map(v => marks[Math.min(marks.length - 1, Math.max(0, Math.floor((v / 100) * marks.length)))] ?? '▁').join('')
134}
135hooks/i18n.ts 238 lines1// Interface strings in English and Russian. `t` is picked once per load from the `language` option.
2
3const en = {
4 thousand: 'k',
5 million: 'M',
6 hours: 'h',
7 minutes: 'min',
8 today: 'today',
9 tomorrow: 'tomorrow',
10 days: (n: number) => `${n} d`,
11 quickReplies: 'yes|go on|continue|start|save everything|shorter, with a diagram',
12 noFolder: 'folder unknown',
13 due: 'due',
14 serverOk: '●',
15 serverDown: '○ offline',
16 cacheKeptUntil: (t: string) => `cache kept warm until ${t}`,
17 cacheLeft: (m: number) => `cache ${m} min left`,
18 cacheCold: 'cache cold',
19 resumeAt: (t: string) => `· I'll resume at ${t}`,
20 cancel: 'cancel',
21 keep3h: 'keep cache 3 h',
22 turnsNoJournal: (n: number) => `· ${n} turns, no journal`,
23 saveJournal: 'save journal',
24 journalSaved: 'journal saved, again',
25 // band and footer line
26 context: 'context',
27 of: 'of',
28 free: 'free',
29 fiveHours: '5 h',
30 week: 'week',
31 resets: 'resets',
32 pace: (t: string) => `at this pace until ${t}`,
33 enough: 'lasts until reset',
34 session: (out: string, cache: string) => `session: output ${out}, cache ${cache}`,
35 // pane
36 areas: 'Areas',
37 nothingBurns: 'Nothing urgent for this area in the focus note.',
38 youAreHere: 'you are here',
39 workIn: (a: string) => `work in «${a}»`,
40 workInPrompt: (folder: string, path: string) => `Switch to the area «${folder}»: change the working directory to ${path}.`,
41 myDeadlines: 'My deadlines',
42 ahead: (n: number) => `${n} ahead`,
43 all: (n: number) => `all ${n}`,
44 collapse: 'collapse',
45 openNote: 'open in Obsidian',
46 waiting: 'Waiting for you',
47 inbox: (n: number) => `inbox ${n}`,
48 sortInbox: 'sort the inbox',
49 sortInboxPrompt: (dir: string) => `Sort the inbox of my notes (folder ${dir}): where should each note go?`,
50 sessionCard: 'Session',
51 compact: 'compact context',
52 limits: 'Limits',
53 apiPrice: (usd: string) => `at API prices $${usd}`,
54 cache: 'Cache',
55 keep: 'keep 3 h',
56 release: 'release',
57 refresh: 'refresh',
58 checking: 'checking…',
59 terminal: 'terminal',
60 running: (jobs: string) => `Running: ${jobs}`,
61 idle: 'Nothing heavy is running',
62 noData: 'no data yet',
63 numbersLater: 'numbers appear after the first answer',
64 tokensLine: (i: string, o: string, r: string, w: string) => `input ${i} · output ${o} · cache: read ${r}, write ${w}`,
65 helpers: 'helpers',
66 turnsLine: (n: number, tools: number, time: string) => `turns ${n} · tools ${tools} · ${time}`,
67 // svg cards
68 ofContext: 'of context',
69 waitingFirst: 'waiting for the first answer',
70 sessionTokens: 'session tokens',
71 input: 'input',
72 output: 'output',
73 cacheRead: 'cache, read',
74 cacheWrite: 'cache, write',
75 helpersLine: (i: string, o: string) => `helpers: input ${i}, output ${o}`,
76 growthLater: 'context growth appears after a couple of turns',
77 growth: 'context growth by turn',
78 turnRange: (a: number, b: number) => `turn ${a} → ${b}`,
79 fiveHoursTitle: '5 hours',
80 weekTitle: 'week',
81 noDataFor: (s: string) => `${s}: no data`,
82 resetAt: (t: string) => `resets ${t}`,
83 cacheTitle: 'cache',
84 cacheKept: (t: string) => `kept warm until ${t}`,
85 cacheWarm: (m: number) => `warm for ${m} more min`,
86 cacheColdLong: 'cold: the next turn re-reads the context',
87 checkingLink: 'checking connection…',
88 online: 'online',
89 offline: (e: string) => `offline: ${e}`,
90 checkedAt: (t: string) => `checked at ${t}`,
91 cpu: 'CPU',
92 ram: 'RAM',
93 gpu: 'GPU',
94 vram: 'VRAM',
95 gb: 'GB',
96 // toasts and commands
97 journalAsk:
98 'Write a journal of this session as a Markdown note. First line: a title like "# Topic". Then sections: what was done, decisions and why, what is left, file paths. Short, no filler.',
99 journalFailed: 'Journal not saved: the model did not answer',
100 journalWritten: (f: string) => `Journal saved: ${f}`,
101 journalNot: 'Journal not saved.',
102 journalOff: 'Set the journalDir option to save journals.',
103 cacheSoon: (n: string) => `Cache cools in 10 minutes; coming back will re-read ${n} tokens. Pult: "keep 3 h" or save a journal`,
104 keptReply: (t: string) => `Keeping the cache warm until ${t}: one request every 55 minutes over the cached conversation.`,
105 limitHit: (t: string) => `Limit reached: I'll resume at ${t}`,
106 retrySoon: 'Turn broke off: retrying in a minute',
107 resumeText: 'The limit has reset. Continue where you stopped.',
108 retryText: 'The last turn broke off with a server error. Continue where you stopped.',
109 longPause: (h: number) => `Paused ${h} h: the whole context was re-read. A journal and a new session would be cheaper from here.`,
110 paneOpened: 'Pult opened.',
111 paneNoRoom: 'Pult did not fit: widen the window.',
112 cmdPult: 'Open the pult: areas, deadlines, session, limits, cache, server',
113 cmdJournal: 'Save a journal of this session to your notes',
114 cmdKeep: 'Keep the prompt cache warm for N hours: /keep-cache 3',
115 title: 'Pult',
116}
117
118export type Strings = typeof en
119
120const ru: Strings = {
121 thousand: 'тыс.',
122 million: 'млн',
123 hours: 'ч',
124 minutes: 'мин',
125 today: 'сегодня',
126 tomorrow: 'завтра',
127 days: n => `${n} дн.`,
128 quickReplies: 'да|давай|продолжай|начинай|сохрани всё|короче и схемой',
129 noFolder: 'папка не определена',
130 due: 'срок',
131 serverOk: '●',
132 serverDown: '○ нет связи',
133 cacheKeptUntil: t => `кэш держу до ${t}`,
134 cacheLeft: m => `кэш ещё ${m} мин`,
135 cacheCold: 'кэш остыл',
136 resumeAt: t => `· продолжу сам в ${t}`,
137 cancel: 'отменить',
138 keep3h: 'держать кэш 3 ч',
139 turnsNoJournal: n => `· ${n} ходов без журнала`,
140 saveJournal: 'в хранилище',
141 journalSaved: 'журнал записан, ещё раз',
142 context: 'контекст',
143 of: 'из',
144 free: 'свободно',
145 fiveHours: '5 ч',
146 week: 'неделя',
147 resets: 'сброс',
148 pace: t => `по темпу до ${t}`,
149 enough: 'хватит до сброса',
150 session: (out, cache) => `сессия: выход ${out}, кэш ${cache}`,
151 areas: 'Разделы',
152 nothingBurns: 'В «Активном фокусе» по разделу ничего не горит.',
153 youAreHere: 'ты здесь',
154 workIn: a => `работать в «${a}»`,
155 workInPrompt: (folder, path) => `Переходим в раздел «${folder}»: смени рабочую папку на ${path}.`,
156 myDeadlines: 'Мои сроки',
157 ahead: n => `впереди ${n}`,
158 all: n => `все ${n}`,
159 collapse: 'свернуть',
160 openNote: 'таблица в Obsidian',
161 waiting: 'Ждут тебя',
162 inbox: n => `входящих ${n}`,
163 sortInbox: 'разобрать входящие',
164 sortInboxPrompt: dir => `Разбери входящие заметок (папка ${dir}): что куда разложить.`,
165 sessionCard: 'Сессия',
166 compact: 'сжать контекст',
167 limits: 'Лимиты',
168 apiPrice: usd => `по ценам API $${usd}`,
169 cache: 'Кэш',
170 keep: 'держать 3 ч',
171 release: 'отпустить',
172 refresh: 'обновить',
173 checking: 'проверяю…',
174 terminal: 'терминал',
175 running: jobs => `Считается: ${jobs}`,
176 idle: 'Ничего тяжёлого не считается',
177 noData: 'данных ещё нет',
178 numbersLater: 'цифры появятся после первого ответа',
179 tokensLine: (i, o, r, w) => `вход ${i} · выход ${o} · кэш: чтение ${r}, запись ${w}`,
180 helpers: 'помощники',
181 turnsLine: (n, tools, time) => `ходов ${n} · инструментов ${tools} · ${time}`,
182 ofContext: 'контекста',
183 waitingFirst: 'ждёт первого ответа',
184 sessionTokens: 'токены сессии',
185 input: 'вход',
186 output: 'выход',
187 cacheRead: 'кэш, чтение',
188 cacheWrite: 'кэш, запись',
189 helpersLine: (i, o) => `помощники: вход ${i}, выход ${o}`,
190 growthLater: 'рост контекста появится после пары ходов',
191 growth: 'рост контекста по ходам',
192 turnRange: (a, b) => `ход ${a} → ${b}`,
193 fiveHoursTitle: '5 часов',
194 weekTitle: 'неделя',
195 noDataFor: s => `${s}: нет данных`,
196 resetAt: t => `сброс ${t}`,
197 cacheTitle: 'кэш',
198 cacheKept: t => `держу тёплым до ${t}`,
199 cacheWarm: m => `тёплый ещё ${m} мин`,
200 cacheColdLong: 'остыл: следующий ход перечитает контекст',
201 checkingLink: 'проверяю связь…',
202 online: 'в сети',
203 offline: e => `нет связи: ${e}`,
204 checkedAt: t => `проверено в ${t}`,
205 cpu: 'ЦП',
206 ram: 'ОЗУ',
207 gpu: 'ГП',
208 vram: 'видеопамять',
209 gb: 'ГБ',
210 journalAsk:
211 'Напиши журнал этой сессии заметкой Markdown по-русски, только дефис, без длинного тире. Первая строка: заголовок вида «# Тема». Дальше разделы: что сделали, решения и почему, что осталось, пути к файлам. Коротко, без воды.',
212 journalFailed: 'Журнал не записан: модель не ответила',
213 journalWritten: f => `Журнал записан: ${f}`,
214 journalNot: 'Журнал не записан.',
215 journalOff: 'Чтобы писать журнал, задай настройку journalDir.',
216 cacheSoon: n => `Кэш остынет через 10 минут, возврат перечитает ${n} токенов. На пульте: «держать 3 ч» или журнал`,
217 keptReply: t => `Кэш держу тёплым до ${t}: запрос раз в 55 минут поверх закэшированной беседы.`,
218 limitHit: t => `Лимит исчерпан: продолжу сам в ${t}`,
219 retrySoon: 'Ход оборвался: повторю через минуту',
220 resumeText: 'Лимит сбросился. Продолжай с того места, где остановился.',
221 retryText: 'Прошлый ход оборвался из-за ошибки сервера. Продолжай с того места.',
222 longPause: h => `Пауза ${h} ч: контекст перечитан целиком. Дальше выгоднее журнал и новая сессия.`,
223 paneOpened: 'Пульт открыт.',
224 paneNoRoom: 'Пульт не поместился: расширь окно.',
225 cmdPult: 'Открыть пульт: разделы, сроки, сессия, лимиты, кэш, сервер',
226 cmdJournal: 'Записать журнал сессии в заметки',
227 cmdKeep: 'Держать кэш тёплым N часов: /keep-cache 3',
228 title: 'Пульт',
229}
230
231export const STRINGS = { en, ru }
232
233export let t: Strings = en
234
235export function setLanguage(lang: string): void {
236 t = lang === 'ru' ? ru : en
237}
238hooks/server.ts 86 lines1import type { PultServer } from '../types'
2
3const KB_IN_GB = 1024 * 1024
4// Process names counted as heavy work
5const JOBS = ['python', 'pythonw', 'blender', 'UnrealEditor*', 'ffmpeg', 'ollama', 'whisper*']
6
7// Runs on a Windows machine in PowerShell 5.1 and prints one key=value line
8const POWERSHELL = [
9 "$ErrorActionPreference='SilentlyContinue'; $ProgressPreference='SilentlyContinue'",
10 '$c=[int](Get-CimInstance Win32_Processor | Measure-Object LoadPercentage -Average).Average',
11 '$o=Get-CimInstance Win32_OperatingSystem',
12 "$g=(& nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu --format=csv,noheader,nounits | Select-Object -First 1) -replace ' ',''",
13 '$now=Get-Date',
14 `$j=(Get-Process ${JOBS.join(',')} | Where-Object StartTime | ForEach-Object { $_.ProcessName+'@'+[int]($now-$_.StartTime).TotalMinutes }) -join ','`,
15 '"cpu=$c;ramfree=$($o.FreePhysicalMemory);ramtotal=$($o.TotalVisibleMemorySize);gpu=$g;jobs=$j"',
16].join('\n')
17
18// Runs on Linux in sh and prints the same line
19const SH = [
20 // procps prints «95.6 id», older top «95.6%id»
21 "c=$(top -bn1 | awk -F'[ ,]+' '/^%?Cpu/ {for(i=1;i<=NF;i++){if($i==\"id\"){print int(100-$(i-1)); exit} if($i ~ /%id$/){sub(/%id$/,\"\",$i); print int(100-$i); exit}}}')",
22 // MemAvailable since Linux 3.14, MemFree before it
23 "t=$(awk '/MemTotal/ {print $2}' /proc/meminfo); f=$(awk '/MemAvailable/ {print $2}' /proc/meminfo); [ -n \"$f\" ] || f=$(awk '/MemFree/ {print $2}' /proc/meminfo)",
24 "g=$(nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu --format=csv,noheader,nounits 2>/dev/null | head -1 | tr -d ' ')",
25 `j=$(ps -eo comm=,etimes= | awk '$1 ~ /^(${JOBS.map(j => j.replace('*', '')).join('|')})/ {printf "%s%s@%d", s, $1, $2/60; s=","}')`,
26 'echo "cpu=$c;ramfree=$f;ramtotal=$t;gpu=$g;jobs=$j"',
27].join('\n')
28
29// -EncodedCommand expects UTF-16LE in base64: the script passes through ssh without quoting trouble
30function encode(script: string): string {
31 let bytes = ''
32 for (let i = 0; i < script.length; i += 1) {
33 const code = script.charCodeAt(i)
34 bytes += String.fromCharCode(code & 0xff, code >> 8)
35 }
36 return btoa(bytes)
37}
38
39export function probeArgv(host: string, shell: string): string[] {
40 const base = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host]
41 return shell === 'powershell' ? [...base, 'powershell', '-NoProfile', '-EncodedCommand', encode(POWERSHELL)] : [...base, SH]
42}
43
44export function offline(checkedAt: number, error: string): PultServer {
45 return { isOnline: false, checkedAt, jobs: [], error }
46}
47
48// The last meaningful line of an ssh error, without key exchange warnings
49export function sshError(stderr: string): string {
50 const lines = stderr.split(/\r?\n/).map(line => line.trim()).filter(line => line !== '' && !line.startsWith('**'))
51 return lines[lines.length - 1] ?? 'ssh failed'
52}
53
54export function parseProbe(stdout: string, checkedAt: number): PultServer | undefined {
55 const line = stdout.split(/\r?\n/).find(one => one.startsWith('cpu='))
56 if (line === undefined) return undefined
57
58 const field = new Map(
59 line.split(';').map(pair => {
60 const at = pair.indexOf('=')
61 return [pair.slice(0, at), pair.slice(at + 1)] as const
62 }),
63 )
64 const ramFree = Number(field.get('ramfree')) / KB_IN_GB
65 const ramTotal = Number(field.get('ramtotal')) / KB_IN_GB
66 const [util, memUsed, memTotal, tempC] = (field.get('gpu') ?? '').split(',').map(Number)
67 const jobs = (field.get('jobs') ?? '')
68 .split(',')
69 .filter(Boolean)
70 .map(job => {
71 const [name = '', minutes = '0'] = job.split('@')
72 return { name, minutes: Number(minutes) }
73 })
74
75 return {
76 isOnline: true,
77 checkedAt,
78 cpu: Number(field.get('cpu')) || 0,
79 ...(ramTotal > 0 ? { ramUsedGb: ramTotal - ramFree, ramTotalGb: ramTotal } : {}),
80 ...(util !== undefined && memUsed !== undefined && memTotal !== undefined && tempC !== undefined && memTotal > 0
81 ? { gpu: { util, memUsedGb: memUsed / 1024, memTotalGb: memTotal / 1024, tempC } }
82 : {}),
83 jobs,
84 }
85}
86hooks/svg.ts 181 lines1// Pult cards for the desktop app: SVG on its own dark background, readable in light and dark themes alike
2
3import type { PultPoint, PultServer, PultTokens, PultUsage } from '../types'
4import { amount, hhmm, resetAt, span } from './format'
5import { t } from './i18n'
6
7const W = 560
8const FONT = "'Segoe UI', Inter, system-ui, sans-serif"
9export const C = {
10 bg: '#14171c',
11 edge: '#272c34',
12 ink: '#e6e8eb',
13 muted: '#8b93a1',
14 track: '#2a3038',
15 blue: '#58a6ff',
16 clay: '#e8875b',
17 green: '#3fb950',
18 yellow: '#d29922',
19 red: '#f85149',
20 violet: '#a371f7',
21}
22
23const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
24
25export const tone = (pct: number, calm = C.green): string => (pct >= 85 ? C.red : pct >= 60 ? C.yellow : calm)
26
27function text(x: number, y: number, s: string, size: number, fill: string, extra = ''): string {
28 return `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" ${extra}>${esc(s)}</text>`
29}
30
31function frame(h: number, body: string): string {
32 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${h}" width="${W}" height="${h}" font-family="${FONT}"><rect x="0.5" y="0.5" width="${W - 1}" height="${h - 1}" rx="14" fill="${C.bg}" stroke="${C.edge}"/>${body}</svg>`
33}
34
35// A ring: an arc by share, the label in the middle
36function ring(cx: number, cy: number, r: number, pct: number, color: string, width: number): string {
37 const len = 2 * Math.PI * r
38 const on = (Math.min(100, Math.max(0, pct)) / 100) * len
39 return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${C.track}" stroke-width="${width}"/><circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="${width}" stroke-linecap="round" stroke-dasharray="${on.toFixed(1)} ${len.toFixed(1)}" transform="rotate(-90 ${cx} ${cy})"/>`
40}
41
42// A half-circle gauge
43function gauge(cx: number, cy: number, r: number, pct: number, color: string): string {
44 const len = Math.PI * r
45 const on = (Math.min(100, Math.max(0, pct)) / 100) * len
46 const d = `M${cx - r},${cy} A${r},${r} 0 0 1 ${cx + r},${cy}`
47 return `<path d="${d}" fill="none" stroke="${C.track}" stroke-width="12" stroke-linecap="round"/><path d="${d}" fill="none" stroke="${color}" stroke-width="12" stroke-linecap="round" stroke-dasharray="${on.toFixed(1)} ${len.toFixed(1)}"/>`
48}
49
50function bar(x: number, y: number, w: number, pct: number, color: string, h = 8): string {
51 const on = Math.max(0, Math.min(1, pct / 100)) * w
52 return `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}" fill="${C.track}"/><rect x="${x}" y="${y}" width="${on.toFixed(1)}" height="${h}" rx="${h / 2}" fill="${color}"/>`
53}
54
55export type SessionCard = {
56 usage: PultUsage | null
57 tokens: PultTokens
58 agentTokens: PultTokens
59 turns: number
60 tools: number
61 elapsedMs: number
62 history: PultPoint[]
63}
64
65export function sessionSvg(s: SessionCard): string {
66 const pct = s.usage?.contextPct ?? 0
67 const used = s.usage?.contextTokens ?? 0
68 const win = s.usage?.window ?? 0
69 const color = pct >= 85 ? C.red : pct >= 70 ? C.yellow : C.blue
70 const rows: [string, number, string][] = [
71 [t.input, s.tokens.input, C.blue],
72 [t.output, s.tokens.output, C.clay],
73 [t.cacheRead, s.tokens.cacheRead, C.green],
74 [t.cacheWrite, s.tokens.cacheWrite, C.violet],
75 ]
76 const top = Math.max(1, ...rows.map(r => r[1]))
77 const table = rows
78 .map(([label, n, c], i) => {
79 const y = 44 + i * 30
80 // A log scale, so thousands of output tokens still show beside millions of cache reads
81 const share = n > 0 ? (Math.log10(n + 1) / Math.log10(top + 1)) * 100 : 0
82 return text(196, y, label, 13, C.muted) + text(W - 20, y, amount(n), 14, C.ink, 'text-anchor="end" font-weight="600"') + bar(196, y + 7, W - 216, share, c, 5)
83 })
84 .join('')
85 const meta =
86 text(196, 166, t.turnsLine(s.turns, s.tools, span(s.elapsedMs)), 12, C.muted) +
87 (s.agentTokens.output + s.agentTokens.input > 0 ? text(196, 184, t.helpersLine(amount(s.agentTokens.input), amount(s.agentTokens.output)), 12, C.muted) : '')
88
89 const pts = s.history.slice(-60)
90 let chart = text(20, 236, t.growthLater, 12, C.muted)
91 if (pts.length >= 2) {
92 const x0 = 20
93 const x1 = W - 20
94 const y0 = 292
95 const y1 = 228
96 const first = pts[0]?.turn ?? 0
97 const lastTurn = pts[pts.length - 1]?.turn ?? 1
98 const x = (n: number) => x0 + ((n - first) / Math.max(1, lastTurn - first)) * (x1 - x0)
99 const y = (p: number) => y0 - (Math.min(100, p) / 100) * (y0 - y1)
100 const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.turn).toFixed(1)},${y(p.pct).toFixed(1)}`).join(' ')
101 chart =
102 `<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${C.blue}" stop-opacity="0.45"/><stop offset="1" stop-color="${C.blue}" stop-opacity="0"/></linearGradient></defs>` +
103 `<line x1="${x0}" x2="${x1}" y1="${y(50)}" y2="${y(50)}" stroke="${C.track}" stroke-dasharray="3 5"/>` +
104 `<path d="${line} L${x(lastTurn).toFixed(1)},${y0} L${x0},${y0} Z" fill="url(#g)"/>` +
105 `<path d="${line}" fill="none" stroke="${C.blue}" stroke-width="2"/>` +
106 text(x0, y1 - 8, t.growth, 12, C.muted) +
107 text(x1, y1 - 8, t.turnRange(first, lastTurn), 12, C.muted, 'text-anchor="end"')
108 }
109 return frame(
110 306,
111 ring(92, 92, 62, pct, color, 14) +
112 text(92, 96, `${Math.round(pct)}%`, 30, C.ink, 'text-anchor="middle" font-weight="700"') +
113 text(92, 118, t.ofContext, 12, C.muted, 'text-anchor="middle"') +
114 text(92, 182, win > 0 ? `${amount(used)} ${t.of} ${amount(win)}` : t.waitingFirst, 12, C.muted, 'text-anchor="middle"') +
115 text(196, 22, t.sessionTokens, 12, C.muted) +
116 table +
117 meta +
118 chart,
119 )
120}
121
122export function limitsSvg(u: PultUsage | null, pace: string | undefined, now: number): string {
123 const one = (cx: number, title: string, l: PultUsage['five'], note?: string): string => {
124 if (l === undefined) return text(cx, 90, t.noDataFor(title), 13, C.muted, 'text-anchor="middle"')
125 return (
126 gauge(cx, 96, 70, l.pct, tone(l.pct, C.clay)) +
127 text(cx, 86, `${Math.round(l.pct)}%`, 28, C.ink, 'text-anchor="middle" font-weight="700"') +
128 text(cx, 120, title, 13, C.muted, 'text-anchor="middle"') +
129 (l.resetsAt ? text(cx, 140, t.resetAt(resetAt(l.resetsAt, now)), 12, C.muted, 'text-anchor="middle"') : '') +
130 (note ? text(cx, 158, note, 12, note === t.enough ? C.green : C.yellow, 'text-anchor="middle"') : '')
131 )
132 }
133 return frame(172, one(W / 4, t.fiveHoursTitle, u?.five, pace) + one((3 * W) / 4, t.weekTitle, u?.week))
134}
135
136export function cacheSvg(leftMs: number, keepUntil: number, now: number): string {
137 let label: string
138 let color: string
139 let pct: number
140 if (keepUntil > now) {
141 label = t.cacheKept(hhmm(keepUntil))
142 color = C.green
143 pct = 100
144 } else if (leftMs > 0) {
145 const minutes = Math.ceil(leftMs / 60_000)
146 label = t.cacheWarm(minutes)
147 color = minutes <= 10 ? C.yellow : C.blue
148 pct = (leftMs / 3_600_000) * 100
149 } else {
150 label = t.cacheColdLong
151 color = C.muted
152 pct = 0
153 }
154 return frame(70, text(20, 30, t.cacheTitle, 13, C.muted) + text(W - 20, 30, label, 14, C.ink, 'text-anchor="end" font-weight="600"') + bar(20, 44, W - 40, pct, color, 10))
155}
156
157export function serverSvg(s: PultServer | null, isBusy: boolean): string {
158 const head =
159 s === null
160 ? text(20, 30, isBusy ? t.checkingLink : t.noData, 14, C.muted)
161 : s.isOnline
162 ? `<circle cx="26" cy="25" r="6" fill="${C.green}"/>` + text(40, 30, t.online, 14, C.ink, 'font-weight="600"')
163 : `<circle cx="26" cy="25" r="6" fill="${C.red}"/>` + text(40, 30, t.offline((s.error ?? '').slice(0, 60)), 13, C.ink)
164 const checked = s !== null ? text(W - 20, 30, isBusy ? t.checking : t.checkedAt(hhmm(s.checkedAt)), 12, C.muted, 'text-anchor="end"') : ''
165 if (s === null || !s.isOnline) return frame(52, head + checked)
166 const gb = (v: number) => (t.gb === 'GB' ? v.toFixed(1) : v.toFixed(1).replace('.', ','))
167 const cells: [string, number, string][] = [
168 [t.cpu, s.cpu ?? 0, ''],
169 [t.ram, s.ramUsedGb !== undefined && s.ramTotalGb ? (s.ramUsedGb / s.ramTotalGb) * 100 : 0, s.ramUsedGb !== undefined && s.ramTotalGb !== undefined ? `${gb(s.ramUsedGb)}/${gb(s.ramTotalGb)} ${t.gb}` : '-'],
170 [t.gpu, s.gpu?.util ?? 0, s.gpu ? `${s.gpu.tempC}°C` : '-'],
171 [t.vram, s.gpu ? (s.gpu.memUsedGb / s.gpu.memTotalGb) * 100 : 0, s.gpu ? `${gb(s.gpu.memUsedGb)}/${gb(s.gpu.memTotalGb)} ${t.gb}` : '-'],
172 ]
173 const rings = cells
174 .map(([label, pct, value], i) => {
175 const cx = 70 + i * 140
176 return ring(cx, 96, 38, pct, tone(pct), 9) + text(cx, 102, `${Math.round(pct)}%`, 17, C.ink, 'text-anchor="middle" font-weight="700"') + text(cx, 154, label, 12, C.muted, 'text-anchor="middle"') + text(cx, 172, value, 12, C.ink, 'text-anchor="middle"')
177 })
178 .join('')
179 return frame(190, head + checked + rings)
180}
181types/index.d.ts 71 lines1export type PultLimit = { pct: number; resetsAt?: string }
2
3export type PultUsage = {
4 startedAt: number
5 window: number
6 contextTokens?: number
7 contextPct?: number
8 five?: PultLimit
9 week?: PultLimit
10 costUsd?: number
11}
12
13export type PultSample = { t: number; p: number }
14
15export type PultTokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
16
17export type PultPoint = { turn: number; pct: number }
18
19export type PultDeadline = { date: string; label: string }
20
21// One area of the focus note and its dates
22export type PultArea = { code: string; text: string; deadlines: PultDeadline[] }
23
24// One row of the deadlines table
25export type PultDuty = { date: string; what: string; window: string; role: string }
26
27// A work area folder «NN Name»
28export type PultAreaFolder = { code: string; folder: string; short: string }
29
30export type PultJob = { name: string; minutes: number }
31
32export type PultServer = {
33 isOnline: boolean
34 checkedAt: number
35 cpu?: number
36 ramUsedGb?: number
37 ramTotalGb?: number
38 gpu?: { util: number; memUsedGb: number; memTotalGb: number; tempC: number }
39 jobs: PultJob[]
40 error?: string
41}
42
43declare module 'claude-code' {
44 interface PluginState {
45 pult: {
46 usage: PultUsage | null
47 samples: PultSample[]
48 turns: number
49 tools: number
50 tokens: PultTokens
51 agentTokens: PultTokens
52 history: PultPoint[]
53 lastTurnAt: number
54 now: number
55 keepUntil: number
56 resumeAt: number
57 journalPath: string
58 server: PultServer | null
59 isPolling: boolean
60 deadline: PultDeadline | null
61 areas: PultArea[]
62 waiting: string
63 inbox: number
64 area: string
65 areaList: PultAreaFolder[]
66 duties: PultDuty[]
67 isDutiesOpen: boolean
68 }
69 }
70}
71