SLOPSHOPPER

pult

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…

newpanebandspinnerguardcommand
v1.0.0MITupdated 2026-10-03Egor062020/claude-code-pult
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pult
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ 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 › /pult ⎿ pult: Pult opened. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Pult: a control panel for Claude Code

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">

What it draws

Above the prompt (the band)

  • chips with the replies you type most (yes, go on, continue… or your own); a press sends the text as your own message
  • one status line: where the session runs · the nearest date from your notes · your server and what runs on it · how long the prompt cache stays warm
  • I'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 turns
  • the usage line (context, 5-hour and weekly limits with a forecast at the current pace), since the desktop app does not let mods draw under the prompt

On the right (the pane, one feed that scrolls)

CardShowsNeeds
Areasyour work areas as buttons, what is urgent in each, its dates, a button to move the session thereareasRoot, focusNote
My deadlinesthe next rows of a deadlines table from your notes, all of them on demand, open in ObsidiandeadlinesNote
Waiting for youbold lines without an area in the focus note, the inbox countfocusNote, inboxDir
Sessioncontext ring, tokens in/out/cache, subagents, turns, tools, time, context growth by turn, save journal, compact contextnothing
Limitsgauges for the 5-hour and weekly windows, reset times, forecast, I'll resume at …nothing
Cachehow long the prompt cache stays warm, keep 3 h / releasenothing
ServerCPU, RAM, GPU, VRAM over ssh, heavy processes, refresh, terminalsshHost

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.

Install

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.

Options

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"
      }
    }
  }
}
OptionMeaning
languageen or ru
quickReplieschips above the prompt, separated by `\`; empty: the language's defaults
vaultPathabsolute path to your Markdown notes; the note options below are relative to it (or absolute)
focusNotea note with lines like - 40 Science. what is urgent, dates
deadlinesNotea note with a table whose first column is YYYY-MM-DD
inboxDira folder whose .md files count as unsorted
journalDirwhere save journal writes
obsidianVaultvault name for the open in Obsidian button
areasRoota folder whose subfolders are named NN Name (Johnny.Decimal)
sshHostuser@host reachable by ssh with a key, no password prompt
serverNamelabel of the server card
serverShellsh for Linux, powershell for Windows

The focus note

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

The deadlines table

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

What it can reach

Mods run with your permissions, without a sandbox. claude plugin validate . lists what this one does:

  • reads the notes you point it to, lists two folders, writes one journal file per save journal
  • runs ssh 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 press
  • asks the model over the cached conversation for save journal, and with a five-token question every 55 minutes while you keep the cache warm; both count toward your usage
  • submits a prompt as you when you press a chip, a «work in» button, or after a limit resets
  • keeps its numbers in the session state and a server snapshot in the mod store, shared between your sessions

No other network calls, no telemetry.

Tests

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; пустая настройка выключает свою карточку. Установка и форматы заметок - выше.

License

MIT

Source 9 files
hooks/register.tsx 653 lines
1import { 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}
653
hooks/areas.ts 60 lines
1import 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}
60
hooks/deadlines.ts 90 lines
1import 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}
90
hooks/duties.ts 17 lines
1import 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)
17
hooks/format.ts 135 lines
1import 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}
135
hooks/i18n.ts 238 lines
1// 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}
238
hooks/server.ts 86 lines
1import 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}
86
hooks/svg.ts 181 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
181
types/index.d.ts 71 lines
1export 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