SLOPSHOPPER

tasqx-pane

A pane with your active tasqx task, the project's queue, its numbers, its standing rulings and your notes, in every repo that has a tasqx project.

paneguardcommandtoaststatusprompt
v0.8.0MITupdated 2026-10-24dimitritholen/tasqx-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tasqx-pane
│ ┃ tasqx ✕ › fix the failing auth test and add an audit log call │ ┃ [ Task ] [ Queue ] [ Project ] [ Memory ] [ │ ┃ no project "?" updated 08:53 ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ tasqx did not answer │ ⏺ Update(src/auth.ts) │ ┃ │ tasqx exited 0 │ ⎿ Added 2 lines, removed 1 line │ ┃ │ Is tasqx installed? Set its path under │ ⏺ Bash(bun test) │ ┃ │ /config → tasqx-pane → tasqx binary (now │ ⎿ 3 pass, 1 fail │ ┃ │ "tasqx"). │ │ ┃ ╰──────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tasqx-pane │ ⎿ tasqx-pane: tasqx pane: tasqx exited 0 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · tasqx
[ Task ] [ Queue ] [ Project ] [ Memory ] [ Notes ] [ ↻ ] no project "?" updated 08:53 ╭──────────────────────────────────────────────────────────╮ │ tasqx did not answer │ │ tasqx exited 0 │ │ Is tasqx installed? Set its path under /config → │ │ tasqx-pane → tasqx binary (now "tasqx"). │ ╰──────────────────────────────────────────────────────────╯
README

tasqx-pane

A Claude Code mod that docks a tasqx pane beside the transcript, so you and Claude can see the work that is going on while the session runs.

It works in every repository whose folder name matches a tasqx project, or that you point at one with the project picker, and reads your store through the tasqx CLI.

 [ Task ] [ Queue ] [ Project ] [ Memory ] [ Notes ] [ ↻ ]
 tasqx-pane                              updated 15:12
╭──────────────────────────────────────────────────────╮
│ #2270                                       ▶ active │
│ tasqx-pane v0.1: a Claude Code mod showing the       │
│ active task, queue and project stats in a pane       │
│  H   tasqx-pane · urgency 6.0  +mod  +claude-code    │
│ ██████░░░░░░░░░░░░░░░░░░░░░░░░  32m / 3h 00m · 17%   │
│ [ Stop timer ] [ Hand to Claude ]                    │
╰──────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────╮
│ Checks                                           4/5 │
│ ████████████████████████░░░░░░                       │
│ ✔ claude plugin validate passes on the mod folder    │
│ ○ pane shows active task, checks, queue and stats    │
╰──────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────╮
│ Burndown                                  6 open now │
│ █▇                                                ▂  │
│ ██▆▃▁                                            ▃█  │
│ 2026-08-21           peak 53                   today │
╰──────────────────────────────────────────────────────╯

What it shows

Each section is a card. Colors come from your Claude Code theme, so the pane reads in light and dark themes alike; red is kept for overdue tasks, failed checks and errors.

TabHotkeyWhat is on it
TasktThe project's active task (one from another project never shows): title, priority, tags, tracked time against the estimate, due date, token use against its budget, the description (its first note), the checks, what it depends on and unblocks, and the newest note. With no active task it shows the next one up, with a Start button.
QueueqThe project's working set by urgency (blocked tasks dimmed), each with a Start button. A tag picker above it narrows the list to one tag.
ProjectpDone/total with a progress bar, a burndown of open tasks over the last 60 days (a colored bar chart in the terminal, a sparkline in the desktop app and VS Code), open work, estimated time left, time tracked, overdue and blocked counts, a table per status, and the last five finished tasks.
MemorymThe project's standing rulings, and the memory hits tasqx finds for the active task.
NotesnYour tasqx notes inbox (tasqx 0.16.0 or later): every open note, of any project, newest first, the first 15 of them. A URL in a note is a link you can click. A field at the top saves what you type as a new note (not on mobile, where tasqx note "…" does it), and Links only (l) narrows the list to notes with a URL.

Under the prompt a status line shows the active task, also while the pane is closed: ▶ #2270 42m / 3h 00m · 2/5 checks · tasqx-pane v0.1 …

What the pane can change

  • Start a task from the queue, and Stop timer on the active one (s).
  • Hand to Claude (c, or → Claude on a queue row) starts the task's timer and puts a prompt in the prompt box, unsent: Work on tasqx task #2265: <title>. Read its brief first. Press Esc to get back to the prompt, change the text if you like, and press Enter. Claude never starts work you did not send.
  • Hand +tag to Claude appears once you pick a tag in the queue. It puts one prompt in the prompt box, unsent, that asks Claude to work through every task under that tag, most urgent first, one at a time. It starts no timer: Claude starts each task as it gets to it.
  • Press a check to cycle it open → passed → failed.
  • On a note: Archive it, file it as a task (→ Task) or a memory doc (→ Memory), which archives it too, or Remove it for good. Remove asks for a second press first, and tasqx undo cannot bring a removed note back.

When you start or stop a timer, mark a check or promote a note, the pane also leaves Claude a note it reads on its next turn, such as the user started the tasqx timer on #2265. The note starts no turn and does not show in the transcript, so Claude knows what you are working on without being asked to act. Turn it off with Tell Claude in /config.

It does not complete tasks. Completing one goes through your agent or tasqx done, so the delivery note gets written first.

When it refreshes

Every 15 seconds (configurable), right after any mcp__tasqx__* tool call or Bash command that runs tasqx, and when you press Refresh (r). While the pane is closed it reads only the active task, for the status line.

Keeping Claude to the rules

Two things the pane does beside the session, each switchable in /config:

  • Start timer on spawn. When Claude launches a subagent whose prompt names a tasqx task as tasqx show <id>, task/<id>- (a branch) or tasqx task #<id>, the first of those wins and the pane runs tasqx start <id> --keep, once. When that started the timer it shows a toast: tasqx: timer started on #2342 "…". Other running timers keep running, and a task already active is left alone, without a toast. If tasqx refuses (a done task, an unknown id) or is missing, a toast says why and the subagent starts anyway; the spawn is never refused or changed.
  • Live rulings. tasqx hands Claude the standing rulings once, when the session starts. A ruling recorded later (tasqx_add_memory with standing, or tasqx memory add --standing) would otherwise wait for the next session. The pane adds a section to Claude's system prompt, Standing rulings added during this session — follow them:, listing each standing ruling of this project or of no project that was created or changed after the session started, with its title and first line. It reads the rulings at most every 30 seconds, and adds nothing when there are none.

Install

You need Claude Code 2.1.287 or later, and tasqx installed.

At the prompt of a terminal session:

/plugin install tasqx-pane --marketplace dimitritholen/tasqx-pane

Answer y to add the marketplace, then pick the user scope so it loads in every project.

From a shell:

claude plugin install tasqx-pane --marketplace dimitritholen/tasqx-pane

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with dimitritholen/tasqx-pane, then Add plugin.

Use

The pane opens by itself when a session starts in a repo that has a tasqx project and the terminal is wide enough: 144 columns or more, in fullscreen. At any width you can open it with:

/tasqx-pane              open the pane
/tasqx-pane queue        open it on a tab: task, queue, project, memory or notes
/tasqx-pane refresh      read tasqx again
/tasqx-pane close        close it

ctrl+x tab gives the pane the keyboard. The hotkeys then work, and the arrow keys scroll.

Settings

Under /config → tasqx-pane:

SettingDefaultWhat it does
tasqx binarytasqxThe executable to run. Give an absolute path (~/.cargo/bin/tasqx, /opt/homebrew/bin/tasqx) when Claude Code starts without your shell's PATH, as the desktop app can.
Project override(empty)Show this project in every folder that has no project picked in the pane.
Open on startonOpen the pane when a session starts in a repo that has a project.
Status lineonShow the active task under the prompt.
Refresh every (seconds)15How often it reads tasqx (at least 5).
Hand to Claude promptWork on tasqx task #{id}: {title}. Read its brief first.What Hand to Claude puts in the prompt box. {id}, {title} and {project} are filled in.
Tell ClaudeonLeave Claude a note when you start or stop a timer or mark a check in the pane.
Start timer on spawnonStart the timer of the task a subagent is launched on.
Live rulingsonAdd standing rulings recorded during the session to Claude's system prompt.

How it finds the project

It takes the name of the git repository's folder (the main worktree's for a linked worktree, or the session's folder outside git) and looks for a live tasqx project with that name, ignoring case. If there is none, the pane says so instead of showing another project.

The project name at the top of the pane is a picker: focus it, scroll the projects with the arrow keys or type to filter them, and press Enter to pick. The mouse wheel does not scroll it. The project you pick is remembered for that folder, across sessions, and wins over the folder name and Project override. A picked project that is archived or removed falls back to the folder name. Or create one with tasqx init <folder name>.

Trust

A mod runs with your permissions and is not sandboxed. This one runs only the tasqx binary you configure: list, brief, report, projects, chart burndown, memory list and notes to read, start, stop, check set and note (save, archive, promote, remove) when you press a button or save a note, and start --keep when a subagent is launched on a task that is not active. Besides that it fills your prompt box when you press Hand to Claude, and leaves Claude the notes described above. claude plugin validate on a clone lists every call it makes.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .      # a session with your working copy loaded

hooks/lib.ts holds the parsing and formatting and has no $, so tests/lib.test.ts covers it without a session. tests/pane.test.tsx draws the pane over a fake tasqx on the terminal and desktop surfaces, and tests/enforce.test.ts covers the spawn and prompt hooks.

License

MIT

Source 3 files
hooks/register.tsx 1090 lines
1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { ActiveTask, Snapshot, StatusCount, Tab } from '../types'
5import * as lib from './lib'
6
7type Engine = EngineInterface
8
9const PANE = 'tasqx'
10const BURNDOWN_DAYS = 60
11const TABS: readonly Tab[] = ['task', 'queue', 'project', 'memory', 'notes']
12const TAB_LABEL: Record<Tab, string> = { task: 'Task', queue: 'Queue', project: 'Project', memory: 'Memory', notes: 'Notes' }
13const NOTES_SHOWN = 15
14
15const snapshot = atom({ plugin: 'tasqx-pane', key: 'snapshot' } as const, null)
16const tab = atom({ plugin: 'tasqx-pane', key: 'tab' } as const, 'task')
17const busy = atom({ plugin: 'tasqx-pane', key: 'busy' } as const, null)
18const tagFilter = atom({ plugin: 'tasqx-pane', key: 'tag' } as const, null)
19const notesLinks = atom({ plugin: 'tasqx-pane', key: 'notesLinks' } as const, false)
20const confirmRemove = atom({ plugin: 'tasqx-pane', key: 'confirmRemove' } as const, null)
21// in $.state, not the module, so a hot reload (which runs session.start again) keeps it
22const sessionStart = atom({ plugin: 'tasqx-pane', key: 'sessionStart' } as const, null)
23
24const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
25const projectFilter = (p: string) => `project:${/\s/.test(p) ? `"${p}"` : p}`
26/** The queue's filter: the project's working set, under `tag` when one is picked. */
27const queueFilter = (p: string, tag: string | null) => `${projectFilter(p)} @working${tag ? ` +${tag}` : ''}`
28
29// Set from the plugin's options when the module registers.
30const cfg = {
31  bin: 'tasqx',
32  override: '',
33  autoOpen: true,
34  showStatus: true,
35  periodMs: 15_000,
36  tellClaude: true,
37  handoff: 'Work on tasqx task #{id}: {title}. Read its brief first.',
38  autoStart: true,
39  liveRulings: true,
40}
41
42// What the module remembers between refreshes; a reload starts it over.
43const cache = {
44  projects: null as string[] | null,
45  briefKey: '',
46  briefAt: 0,
47  brief: null as ActiveTask | null,
48  running: null as Promise<void> | null,
49  again: false,
50  full: false,
51  standing: null as any[] | null,
52  standingAt: 0,
53}
54
55/** One `tasqx <args> --json` call (`--json` added unless `args` has it, as before a `--`); rejects with tasqx's own error message. */
56async function tasqx($: Engine, args: string[]): Promise<any> {
57  let ran
58  try {
59    ran = await $.process.run([cfg.bin, ...args, ...(args.includes('--json') ? [] : ['--json'])], { timeoutMs: 15000 })
60  } catch (err) {
61    throw new Error(`could not run ${cfg.bin}: ${message(err)}`)
62  }
63  const out = ran.stdout.trim()
64  let parsed: any = null
65  try {
66    parsed = out ? JSON.parse(out) : null
67  } catch {
68    // not JSON: fall through to the exit code
69  }
70  if (parsed?.ok === false) throw new Error(parsed.error?.message ?? 'tasqx refused the call')
71  if (ran.exitCode !== 0 || parsed === null) {
72    throw new Error((ran.stderr || out).trim().split('\n')[0] || `${cfg.bin} exited ${ran.exitCode}`)
73  }
74  return parsed
75}
76
77async function resolveProject($: Engine) {
78  if (!cache.projects) {
79    const listed = await tasqx($, ['projects'])
80    cache.projects = (listed.projects ?? []).filter((p: any) => !p.archived).map((p: any) => String(p.name))
81  }
82  const root = await folder($)
83  const dirName = lib.basename(root)
84  const chosen = String((await $.store.get(folderKey(root))) ?? '')
85  return { ...lib.pickProject(cache.projects ?? [], dirName, cfg.override, chosen), dirName, projects: cache.projects ?? [] }
86}
87
88async function folder($: Engine) {
89  return (await $.session.repo())?.root ?? (await $.session.root())
90}
91
92const folderKey = (root: string) => `folder:${root}`
93
94/** Remembers `project` for this folder, then shows it. */
95async function chooseProject($: Engine, project: string) {
96  await $.store.set(folderKey(await folder($)), project)
97  // another project has other tags
98  await update($, tagFilter, () => null)
99  await openPane($)
100}
101
102async function load($: Engine, isFull: boolean): Promise<Snapshot> {
103  const fetchedAt = await $.clock.now()
104  const base: Snapshot = {
105    project: null,
106    projectSource: 'none',
107    projects: [],
108    dirName: null,
109    active: null,
110    otherActive: [],
111    queue: [],
112    queueTotal: 0,
113    tags: [],
114    tag: await read($, tagFilter),
115    recentDone: [],
116    stats: [],
117    rulings: [],
118    notes: [],
119    notesTotal: 0,
120    notesError: null,
121    burndown: { from: null, remaining: [] },
122    fetchedAt,
123    isFull,
124    error: null,
125  }
126  try {
127    const { project, source, dirName, projects } = await resolveProject($)
128    Object.assign(base, { project, projectSource: source, dirName, projects })
129
130    // only this folder's project: another project's active task is not this repo's work
131    const actives: any[] = project
132      ? ((await tasqx($, ['list', projectFilter(project), 'status:active', '--sort', '-modified'])).tasks ?? [])
133      : []
134    const [first, ...rest] = actives
135    base.otherActive = rest.map(lib.toTaskRef)
136    if (first) {
137      // brief runs a memory search (~0.5 s): read it again on a change, else each minute
138      const key = `${first.id}:${first._rev}`
139      if (!cache.brief || key !== cache.briefKey || fetchedAt - cache.briefAt > 60_000) {
140        cache.brief = lib.toActiveTask(await tasqx($, ['brief', String(first.short_id), '--memory-limit', '3']))
141        cache.briefKey = key
142        cache.briefAt = fetchedAt
143      }
144      base.active = { ...cache.brief, trackedSeconds: lib.parseDuration(first.tracked) ?? cache.brief.trackedSeconds }
145    } else {
146      cache.brief = null
147    }
148
149    // every open note, whatever the project; an older tasqx without notes costs this tab alone
150    const notes = isFull
151      ? tasqx($, ['notes', ...((await read($, notesLinks)) ? ['--links'] : [])]).then(
152          listed => {
153            const all: any[] = listed.notes ?? []
154            base.notes = all.slice(0, NOTES_SHOWN).map(lib.toInboxNote)
155            base.notesTotal = Number(listed.total ?? all.length)
156          },
157          err => {
158            base.notesError = message(err)
159          },
160        )
161      : null
162
163    if (isFull && project) {
164      const scope = projectFilter(project)
165      const [queue, tagged, done, report, rulings, burndown] = await Promise.all([
166        tasqx($, ['list', scope, '@working', ...(base.tag ? [`+${base.tag}`] : []), '--limit', '15']),
167        tasqx($, ['list', scope, '@working', '--fields', 'tags', '--limit', '10000']),
168        tasqx($, ['list', scope, 'status:done', '--sort', '-modified', '--limit', '5']),
169        tasqx($, ['report', 'status', scope]),
170        tasqx($, ['memory', 'list', '--project', project, '--standing', '--limit', '10']),
171        tasqx($, ['chart', 'burndown', scope, '--days', String(BURNDOWN_DAYS)]),
172      ])
173      const series: any[] = burndown.series ?? []
174      base.burndown = { from: series[0]?.date ?? null, remaining: series.map(d => Number(d.remaining) || 0) }
175      base.queue = (queue.tasks ?? []).filter((t: any) => t.status !== 'active').map(lib.toQueueRow)
176      base.queueTotal = queue.total ?? base.queue.length
177      base.tags = lib.collectTags(tagged.tasks ?? [])
178      base.recentDone = (done.tasks ?? []).map(lib.toTaskRef)
179      base.stats = lib.toStats(report)
180      base.rulings = (rulings.docs ?? []).map((d: any) => ({
181        title: String(d.title ?? ''),
182        preview: String(d.body_preview ?? ''),
183      }))
184    }
185    await notes
186  } catch (err) {
187    base.error = message(err)
188  }
189  return base
190}
191
192async function isPaneOpen($: Engine) {
193  return (await $.ui.panes()).some(p => p.id === PANE)
194}
195
196/**
197 * Reads tasqx again: everything while the pane is open (or `full`), else the
198 * active task alone. Calls made while one runs fold into one more pass.
199 */
200function refresh($: Engine, force = false, full = false): Promise<void> {
201  if (force) cache.briefKey = ''
202  if (full) cache.full = true
203  if (cache.running) {
204    cache.again = true
205    return cache.running
206  }
207  cache.running = (async () => {
208    do {
209      cache.again = false
210      const isFull = cache.full || (await isPaneOpen($))
211      cache.full = false
212      const snap = await load($, isFull)
213      await update($, snapshot, () => snap)
214      if (cfg.showStatus) {
215        $.ui.status(snap.active ? lib.statusText(snap.active, snap.active.trackedSeconds) : undefined)
216      }
217    } while (cache.again)
218  })().finally(() => {
219    cache.running = null
220  })
221  return cache.running
222}
223
224/**
225 * Tells Claude what the person did in the pane: a row the model reads on its
226 * next turn, which starts no turn and which the transcript does not show.
227 */
228async function tell($: Engine, text: string) {
229  if (!cfg.tellClaude) return
230  try {
231    await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: `[tasqx-pane] ${text}` }] } })
232  } catch {
233    // a session that takes no rows (a -p run with hooks off) just is not told
234  }
235}
236
237/** Runs one tasqx write; on success tells Claude `note` (when given, or made from the result). */
238async function act($: Engine, args: string[], label: string, note?: string | ((result: any) => string)): Promise<boolean> {
239  await update($, busy, () => label)
240  try {
241    const result = await tasqx($, args)
242    $.ui.toast(`tasqx: ${label}`)
243    if (note) await tell($, typeof note === 'string' ? note : note(result))
244    return true
245  } catch (err) {
246    $.ui.toast(`tasqx: ${label} failed: ${message(err)}`)
247    return false
248  } finally {
249    await update($, busy, () => null)
250    await refresh($, true)
251  }
252}
253
254/**
255 * Hands a task to Claude: starts its timer (unless it runs already), then puts
256 * the handoff prompt in the prompt box, unsent, for the person to send.
257 */
258async function handoff($: Engine, task: { shortId: number; title: string; project: string | null }, isActive: boolean) {
259  if (!isActive && !(await act($, ['start', String(task.shortId)], `started #${task.shortId}`))) return
260  const filled = await $.prompt.fill({ text: lib.handoffText(cfg.handoff, task) })
261  $.ui.toast(
262    filled.isFilled
263      ? `#${task.shortId} is in the prompt: Esc back to it, then Enter to send.`
264      : `tasqx: could not fill the prompt (${filled.refusal ?? 'no prompt box'}).`,
265  )
266}
267
268/** Puts every open task under `tag` in the prompt box, unsent; starts no timer, Claude starts each in turn. */
269async function handoffTag($: Engine, project: string, tag: string, ids: number[]) {
270  const filled = await $.prompt.fill({ text: lib.tagHandoffText(queueFilter(project, tag), ids) })
271  $.ui.toast(
272    filled.isFilled
273      ? `+${tag} is in the prompt: Esc back to it, then Enter to send.`
274      : `tasqx: could not fill the prompt (${filled.refusal ?? 'no prompt box'}).`,
275  )
276}
277
278/**
279 * Starts the task a builder is spawned on: one call, which leaves a running
280 * timer alone (`already_running`) and refuses a done task. Never throws.
281 */
282async function autoStart($: Engine, id: number) {
283  try {
284    const started = await tasqx($, ['start', String(id), '--keep'])
285    if (started.already_running) return
286    $.ui.toast(`tasqx: timer started on #${id} ${quote(String(started.title ?? ''))}`)
287    $.clock.after(150, () => void refresh($, true))
288  } catch (err) {
289    $.ui.toast(`tasqx: could not start #${id}: ${message(err)}`)
290  }
291}
292
293/** The prompt section of rulings added since the session started; the listing is reused for 30 s. */
294async function liveRulings($: Engine): Promise<string | null> {
295  const since = await read($, sessionStart)
296  if (since === null) return null
297  const now = await $.clock.now()
298  if (!cache.standing || now - cache.standingAt > 30_000) {
299    cache.standing = (await tasqx($, ['memory', 'list', '--standing'])).docs ?? []
300    cache.standingAt = now
301  }
302  const { project } = await resolveProject($)
303  return lib.liveRulingsText(cache.standing ?? [], project, since)
304}
305
306const quote = (title: string) => `"${title.length > 80 ? `${title.slice(0, 77)}…` : title}"`
307
308/** Opens (or retitles) the pane, then reads everything for it. */
309async function openPane($: Engine) {
310  let title = 'tasqx'
311  try {
312    const { project } = await resolveProject($)
313    if (project) title = `tasqx · ${project}`
314  } catch {
315    // the refresh records the error in the snapshot
316  }
317  const opened = await $.ui.open({ id: PANE, title })
318  await refresh($, true, true)
319  return opened
320}
321
322export const register: Register = (on, options) => {
323  cfg.bin = String(options.tasqxPath || 'tasqx')
324  cfg.override = String(options.project ?? '')
325  cfg.autoOpen = options.autoOpen !== 'off'
326  cfg.showStatus = options.statusLine !== 'off'
327  cfg.periodMs = Math.max(5, Number(options.refreshSeconds) || 15) * 1000
328  cfg.tellClaude = options.tellClaude !== 'off'
329  cfg.handoff = String(options.handoffPrompt || cfg.handoff)
330  cfg.autoStart = options.autoStartOnSpawn !== 'off'
331  cfg.liveRulings = options.liveRulings !== 'off'
332
333  on('session.start', async ($, e, next) => {
334    await $.command.register({
335      name: 'tasqx-pane',
336      description: 'Open the tasqx pane: active task, queue, project numbers, standing rulings, notes',
337      argumentHint: '[task|queue|project|memory|notes|refresh|close]',
338    })
339    const started = await next(e)
340    const now = await $.clock.now()
341    await update($, sessionStart, at => at ?? now)
342    $.clock.every(cfg.periodMs, () => void refresh($))
343    $.clock.after(10, async () => {
344      let project: string | null = null
345      try {
346        project = (await resolveProject($)).project
347      } catch {
348        // the refresh records the error in the snapshot
349      }
350      if (cfg.autoOpen && project) await openPane($)
351      else await refresh($)
352    })
353    return started
354  })
355
356  on('command.run', { command: 'tasqx-pane' }, async ($, e) => {
357    const arg = (e.args ?? '').trim().toLowerCase()
358    if (arg === 'close') {
359      await $.ui.close({ id: PANE })
360      return { text: 'Closed the tasqx pane.' }
361    }
362    if ((TABS as readonly string[]).includes(arg)) await update($, tab, () => arg as Tab)
363    cache.projects = null
364    const opened = await openPane($)
365    const snap = await read($, snapshot)
366    if (snap?.error) return { text: `tasqx pane: ${snap.error}` }
367    if (!opened.isPlaced) {
368      const active = snap?.active ? lib.statusText(snap.active, snap.active.trackedSeconds) : 'no active task'
369      return { text: `tasqx pane is open but not drawn here (${opened.reason}). ${active}` }
370    }
371    return { text: `Opened the tasqx pane${snap?.project ? ` for ${snap.project}` : ''}.` }
372  })
373
374  // Refresh right after anything that may have written to the store.
375  on('tool.call', async ($, e, next) => {
376    const result = await next(e)
377    if (lib.touchesTasqx(String(e.tool), e)) $.clock.after(150, () => void refresh($, true))
378    return result
379  }).catch(($, e, next) => next(e))
380
381  // A builder launched on a task gets its timer running, whatever the builder forgets.
382  on('agent.spawn', async ($, e, next) => {
383    const id = cfg.autoStart ? lib.taskIdIn(e.prompt) : null
384    if (id !== null) await autoStart($, id)
385    return next(e)
386  }).catch(($, e, next) => next(e))
387
388  // Rulings recorded mid-session reach the model on its next request.
389  on('prompt.compose', async ($, e, next) => {
390    const composed = await next(e)
391    if (!cfg.liveRulings) return composed
392    let text: string | null = null
393    try {
394      text = await liveRulings($)
395    } catch {
396      // tasqx unreachable: compose as if there were none
397    }
398    if (!text) return composed
399    return { sections: [...composed.sections, { id: 'tasqx-pane:live-rulings', text, scope: 'session' as const }] }
400  }).catch(($, e, next) => next(e))
401
402
403  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
404    const elements = $.ui.resolve(e)
405    const { Box, Text, Button, Markdown, Link } = elements
406    // not every surface has a Select; there the project name shows as text
407    const Select = 'Select' in elements ? elements.Select : null
408    // mobile draws no field: there notes are captured with tasqx note
409    const Input = 'Input' in elements ? elements.Input : null
410    const Raster = e.surface === 'terminal' ? $.ui.resolve(e).Raster : null
411    const snap = await read($, snapshot)
412    const current = await read($, tab)
413    const working = await read($, busy)
414    const linksOnly = await read($, notesLinks)
415    const removing = await read($, confirmRemove)
416    const now = await $.clock.now()
417    const width = Math.max(24, e.props.bodyColumns)
418    // a card's border and padding take four columns
419    const inner = width - 4
420    // a snapshot read while the pane was closed lacks the queue: read it whole
421    if (snap && !snap.isFull) $.clock.after(0, () => void refresh($, false, true))
422
423    const card = (key: string, title: string, body: RenderChildren, opts: { color?: Color; titleColor?: Color; right?: string } = {}) => (
424      <Box key={key} flexDirection="column" borderStyle="round" borderColor={opts.color ?? 'inactive'} paddingX={1}>
425        <Box flexDirection="row" justifyContent="space-between" gap={1}>
426          <Text bold color={opts.titleColor} wrap="truncate-end">
427            {title}
428          </Text>
429          {opts.right ? <Text dimColor>{opts.right}</Text> : null}
430        </Box>
431        {body}
432      </Box>
433    )
434    const chip = (text: string, color: Color) => (
435      <Text inverse bold color={color}>
436        {` ${text} `}
437      </Text>
438    )
439    const meter = (ratio: number, cells: number, color: Color) => {
440      const m = lib.meter(ratio, cells)
441      return (
442        <Text>
443          <Text color={color}>{m.filled}</Text>
444          <Text dimColor>{m.empty}</Text>
445        </Text>
446      )
447    }
448    const dim = (text: string) => <Text dimColor>{text}</Text>
449
450    const tabs = (
451      <Box flexDirection="row" gap={1} flexWrap="wrap">
452        {TABS.map(t => (
453          <Button
454            key={`tab-${t}`}
455            label={TAB_LABEL[t]}
456            hotkey={t[0]}
457            variant={t === current ? 'primary' : undefined}
458            onPress={() => update($, tab, () => t)}
459          />
460        ))}
461        <Button key="refresh" label="↻" hotkey="r" onPress={() => refresh($, true)} />
462      </Box>
463    )
464
465    if (!snap) {
466      return (
467        <Box flexDirection="column">
468          {tabs}
469          {dim('Reading tasqx…')}
470        </Box>
471      )
472    }
473
474    const updated = new Date(snap.fetchedAt).toTimeString().slice(0, 5)
475    const header = (
476      <Box flexDirection="column">
477        {tabs}
478        <Box flexDirection="row" justifyContent="space-between" gap={1} paddingX={1} flexWrap="wrap">
479          {Select && snap.projects.length > 0 ? (
480            <Select
481              key="project-pick"
482              label={snap.project ? undefined : `no project "${snap.dirName ?? '?'}"`}
483              options={[
484                // a placeholder, so the picker does not look like the first project is chosen
485                ...(snap.project ? [] : [{ value: '', label: 'pick a project' }]),
486                ...snap.projects.map(p => ({ value: p })),
487              ]}
488              value={snap.project ?? ''}
489              onSelect={p => void (p && chooseProject($, p))}
490            />
491          ) : (
492            <Text bold wrap="truncate-end">{`no project "${snap.dirName ?? '?'}"`}</Text>
493          )}
494          {snap.projectSource === 'config' ? <Box flexShrink={0}><Text dimColor>(from /config)</Text></Box> : null}
495          {/* never shrink: a squeezed status wraps one letter per line down the pane */}
496          <Box flexShrink={0}>
497            <Text dimColor>{working ? `${working}…` : `updated ${updated}`}</Text>
498          </Box>
499        </Box>
500      </Box>
501    )
502
503    if (snap.error) {
504      return (
505        <Box flexDirection="column">
506          {header}
507          {card(
508            'error',
509            'tasqx did not answer',
510            <Box flexDirection="column">
511              <Text color="error" wrap="wrap">
512                {snap.error}
513              </Text>
514              {dim(`Is tasqx installed? Set its path under /config → tasqx-pane → tasqx binary (now "${cfg.bin}").`)}
515            </Box>,
516            { color: 'error' },
517          )}
518        </Box>
519      )
520    }
521
522    const noProject =
523      snap.project === null
524        ? card(
525            'no-project',
526            'No project for this folder',
527            dim(
528              `Pick one with the project picker above (this folder remembers it), or create one with tasqx init ${snap.dirName ?? '<name>'}.`,
529            ),
530            { color: 'warning' },
531          )
532        : null
533
534    const startButton = (t: { shortId: number; title: string }, hotkey?: string) => (
535      <Button
536        key={`start-${t.shortId}`}
537        label="Start"
538        hotkey={hotkey}
539        onPress={() =>
540          act(
541            $,
542            ['start', String(t.shortId)],
543            `started #${t.shortId}`,
544            `The user started the tasqx timer on #${t.shortId} ${quote(t.title)} from the pane, so it is their task in progress. Nothing is asked of you.`,
545          )
546        }
547      />
548    )
549    const handoffButton = (t: { shortId: number; title: string }, isActive: boolean, hotkey?: string) => (
550      <Button
551        key={`handoff-${t.shortId}`}
552        label={hotkey ? 'Hand to Claude' : '→ Claude'}
553        hotkey={hotkey}
554        onPress={() => handoff($, { ...t, project: snap.project }, isActive)}
555      />
556    )
557
558    const statusIcon = (status: string) =>
559      status === 'done' ? <Text color="success">✔</Text> : status === 'active' ? <Text color="suggestion">▶</Text> : <Text color="warning">●</Text>
560
561    const taskTab = () => {
562      const a = snap.active
563      if (!a) {
564        const top = snap.queue.find(t => !t.blocked)
565        return (
566          <Box flexDirection="column">
567            {card(
568              'idle',
569              'No active task',
570              top ? (
571                <Box flexDirection="column">
572                  {dim('Next up')}
573                  <Text bold wrap="wrap">
574                    #{top.shortId} {top.title}
575                  </Text>
576                  <Text>
577                    {chip(top.priority ?? '-', lib.priorityColor(top.priority))}
578                    <Text dimColor>
579                      {'  '}urgency {top.urgency.toFixed(1)} · est {lib.formatDuration(top.estimateSeconds)}
580                    </Text>
581                  </Text>
582                  <Box marginTop={1} gap={1}>
583                    {startButton(top, 's')}
584                    {handoffButton(top, false, 'c')}
585                  </Box>
586                </Box>
587              ) : (
588                dim('Nothing waiting in the working set.')
589              ),
590            )}
591            {noProject}
592          </Box>
593        )
594      }
595
596      const elapsed = a.trackedSeconds + Math.max(0, (now - snap.fetchedAt) / 1000)
597      const ratio = a.estimateSeconds ? elapsed / a.estimateSeconds : 0
598      const isOver = a.estimateSeconds !== null && ratio > 1
599      const passed = a.checks.filter(c => c.state === 'passed').length
600      const isOverdue = a.due !== null && Date.parse(a.due) < now
601      const timeText = a.estimateSeconds
602        ? ` ${lib.formatDuration(elapsed)} / ${lib.formatDuration(a.estimateSeconds)} · ${Math.round(ratio * 100)}%`
603        : ` ${lib.formatDuration(elapsed)} tracked`
604
605      return (
606        <Box flexDirection="column">
607          {card(
608            'task',
609            `#${a.shortId}`,
610            <Box flexDirection="column">
611              <Text bold wrap="wrap">
612                {a.title}
613              </Text>
614              <Text wrap="truncate-end">
615                {chip(a.priority ?? '-', lib.priorityColor(a.priority))}
616                <Text dimColor>
617                  {'  '}
618                  {a.project} · urgency {a.urgency.toFixed(1)}
619                </Text>
620                {a.tags.map(t => (
621                  <Text key={`tag-${t}`} color="suggestion">{`  +${t}`}</Text>
622                ))}
623              </Text>
624              <Text>
625                {a.estimateSeconds ? meter(Math.min(1, ratio), Math.max(8, inner - timeText.length), isOver ? 'warning' : 'suggestion') : null}
626                <Text color={isOver ? 'warning' : undefined}>{timeText}</Text>
627              </Text>
628              {a.due ? (
629                <Text color={isOverdue ? 'error' : undefined}>
630                  Due {a.due.slice(0, 10)} · {lib.relativeTime(a.due, now)}
631                </Text>
632              ) : null}
633              {a.freshTokens > 0 || a.budgetTokens !== null
634                ? dim(
635                    `${a.freshTokens.toLocaleString('en-US')} fresh tokens${a.budgetTokens !== null ? ` of ${a.budgetTokens.toLocaleString('en-US')}` : ''}`,
636                  )
637                : null}
638              <Box flexDirection="row" marginTop={1} gap={1}>
639                <Button
640                  key="stop"
641                  label="Stop timer"
642                  hotkey="s"
643                  onPress={() =>
644                    act(
645                      $,
646                      ['stop', String(a.shortId)],
647                      `stopped #${a.shortId}`,
648                      `The user stopped the tasqx timer on #${a.shortId} ${quote(a.title)} from the pane.`,
649                    )
650                  }
651                />
652                {handoffButton(a, true, 'c')}
653              </Box>
654            </Box>,
655            { color: 'suggestion', right: '▶ active' },
656          )}
657
658          {a.description ? card('desc', 'Description', <Markdown key="desc-md" text={a.description} />) : null}
659
660          {a.checks.length > 0
661            ? card(
662                'checks',
663                'Checks',
664                <Box flexDirection="column">
665                  {meter(passed / a.checks.length, Math.min(inner, 30), passed === a.checks.length ? 'success' : 'suggestion')}
666                  {a.checks.map((c, i) => (
667                    <Button
668                      key={`check-${c.id}`}
669                      plain
670                      onPress={() => {
671                        const to = lib.nextCheckState(c.state)
672                        return act(
673                          $,
674                          ['check', 'set', String(a.shortId), String(i + 1), to],
675                          `check ${i + 1} ${to}`,
676                          `The user marked check ${i + 1} of #${a.shortId} (${quote(c.body)}) ${to} from the pane.`,
677                        )
678                      }}
679                    >
680                      <Text color={c.state === 'passed' ? 'success' : c.state === 'failed' ? 'error' : 'inactive'}>
681                        {c.state === 'passed' ? '✔' : c.state === 'failed' ? '✘' : '○'}
682                      </Text>{' '}
683                      {c.body}
684                    </Button>
685                  ))}
686                  {dim('press a check: open → passed → failed')}
687                </Box>,
688                { right: `${passed}/${a.checks.length}`, color: passed === a.checks.length ? 'success' : undefined },
689              )
690            : null}
691
692          {a.dependsOn.length > 0 || a.blocks.length > 0
693            ? card(
694                'links',
695                'Dependencies',
696                <Box flexDirection="column">
697                  {a.dependsOn.map(t => (
698                    <Text key={`dep-${t.shortId}`} wrap="truncate-end">
699                      {statusIcon(t.status)} <Text dimColor>needs #{t.shortId}</Text> {t.title}
700                    </Text>
701                  ))}
702                  {a.blocks.map(t => (
703                    <Text key={`blk-${t.shortId}`} wrap="truncate-end">
704                      <Text color="suggestion">→</Text> <Text dimColor>unblocks #{t.shortId}</Text> {t.title}
705                    </Text>
706                  ))}
707                </Box>,
708              )
709            : null}
710
711          {a.latestNote
712            ? card('note', 'Newest note', <Markdown key="note-md" text={a.latestNote.body} />, {
713                right: `${lib.relativeTime(a.latestNote.created, now)} · ${a.noteCount} notes`,
714              })
715            : null}
716
717          {snap.otherActive.length > 0
718            ? card(
719                'also',
720                'Also active',
721                <Box flexDirection="column">
722                  {snap.otherActive.map(t => (
723                    <Text key={`also-${t.shortId}`} wrap="truncate-end">
724                      <Text color="suggestion">▶</Text> #{t.shortId} {t.title}
725                    </Text>
726                  ))}
727                </Box>,
728                { color: 'warning' },
729              )
730            : null}
731        </Box>
732      )
733    }
734
735    const queueTab = () => {
736      if (snap.project === null) return noProject
737      const project = snap.project
738      const tag = snap.tag
739      const ready = snap.queue.filter(t => !t.blocked).map(t => t.shortId)
740      const tagRow =
741        Select && (snap.tags.length > 0 || tag) ? (
742          <Box flexDirection="row" gap={1}>
743            <Select
744              key="tag-pick"
745              options={[
746                { value: '', label: 'all tags' },
747                // keep a picked tag that left the working set, so the picker still shows it
748                ...[...new Set([...snap.tags, ...(tag ? [tag] : [])])].map(t => ({ value: t, label: `+${t}` })),
749              ]}
750              value={tag ?? ''}
751              onSelect={t => void update($, tagFilter, () => t || null).then(() => refresh($, true, true))}
752            />
753            {tag && ready.length > 0 ? (
754              <Button key="handoff-tag" label={`Hand +${tag} to Claude`} onPress={() => handoffTag($, project, tag, ready)} />
755            ) : null}
756          </Box>
757        ) : null
758      if (snap.queue.length === 0) {
759        return card(
760          'queue',
761          'Working set',
762          <Box flexDirection="column">
763            {tagRow}
764            {dim(tag ? `Nothing waiting under +${tag}.` : 'Nothing waiting.')}
765          </Box>,
766        )
767      }
768      const more = snap.queueTotal - snap.queue.length
769      // ids of different lengths would push their titles out of line
770      const idWidth = Math.max(...snap.queue.map(t => String(t.shortId).length))
771      return card(
772        'queue',
773        'Working set',
774        <Box flexDirection="column" gap={1}>
775          {tagRow}
776          {snap.queue.map(t => (
777            <Box key={`q-${t.shortId}`} flexDirection="row" gap={1}>
778              {/* a long title would squeeze the chip and wrap it over two lines */}
779              <Box flexShrink={0}>{chip(t.priority ?? '-', t.blocked ? 'inactive' : lib.priorityColor(t.priority))}</Box>
780              <Box flexDirection="column" flexGrow={1} flexShrink={1}>
781                <Text wrap="truncate-end" dimColor={t.blocked}>
782                  <Text dimColor>#{String(t.shortId).padEnd(idWidth)}</Text> {t.title}
783                </Text>
784                <Text wrap="truncate-end">
785                  <Text dimColor>
786                    {[
787                      t.urgency.toFixed(1),
788                      t.estimateSeconds ? lib.formatDuration(t.estimateSeconds) : null,
789                      t.status === 'pending' ? null : t.status,
790                    ]
791                      .filter(Boolean)
792                      .join(' · ')}
793                  </Text>
794                  {t.due ? <Text color={Date.parse(t.due) < now ? 'error' : 'warning'}>{`  due ${lib.relativeTime(t.due, now)}`}</Text> : null}
795                  {t.blocked ? <Text color="warning">{'  blocked'}</Text> : null}
796                </Text>
797              </Box>
798              {t.blocked ? null : startButton(t)}
799              {t.blocked ? null : handoffButton(t, false)}
800            </Box>
801          ))}
802          {more > 0 ? dim(`…${more} more: tasqx list ${queueFilter(project, tag)}`) : null}
803        </Box>,
804        { right: `${snap.queueTotal} by urgency` },
805      )
806    }
807
808    const projectTab = () => {
809      if (snap.project === null) return noProject
810      const sum = (pick: (s: StatusCount) => number, only?: (status: string) => boolean) =>
811        snap.stats.filter(s => !only || only(s.status)).reduce((n, s) => n + pick(s), 0)
812      const isOpen = (status: string) => status !== 'done' && status !== 'cancelled'
813      const total = sum(s => s.count)
814      const done = snap.stats.find(s => s.status === 'done')?.count ?? 0
815      const open = sum(s => s.count, isOpen)
816      const overdue = sum(s => s.overdue)
817      const blocked = snap.queue.filter(t => t.blocked).length
818      const left = sum(s => s.estimateSeconds, isOpen)
819      const tracked = sum(s => s.trackedSeconds)
820      const pct = total ? Math.round((done / total) * 100) : 0
821
822      const days = snap.burndown.remaining
823      const shown = Math.min(days.length, inner)
824      const start = snap.burndown.from
825        ? new Date(Date.parse(snap.burndown.from) + (days.length - shown) * 86_400_000).toISOString().slice(0, 10)
826        : ''
827      const peak = Math.max(0, ...days.slice(-shown))
828      const chart =
829        days.length === 0 ? (
830          dim('No history yet.')
831        ) : Raster ? (
832          (() => {
833            const drawn = lib.barCells(days, inner, 4, 0xd97757, 0x4eba65)
834            return <Raster key="burndown" columns={drawn.columns} rows={4} cells={drawn.cells} />
835          })()
836        ) : (
837          <Text color="suggestion">{lib.sparkline(days, inner)}</Text>
838        )
839
840      const row = (a: string, b: string, c: string, d: string) =>
841        `${a.padEnd(10)}${b.padStart(6)}  ${c.padStart(9)}  ${d.padStart(10)}`
842
843      return (
844        <Box flexDirection="column">
845          {card(
846            'summary',
847            snap.project,
848            <Box flexDirection="column">
849              {meter(total ? done / total : 0, inner, 'success')}
850              <Text wrap="wrap">
851                <Text bold>{done}</Text>
852                <Text dimColor> done · </Text>
853                <Text bold>{open}</Text>
854                <Text dimColor> open · </Text>
855                <Text bold>{left ? lib.formatDuration(left) : '—'}</Text>
856                <Text dimColor> estimated left · </Text>
857                <Text bold>{lib.formatDuration(tracked)}</Text>
858                <Text dimColor> tracked</Text>
859              </Text>
860              {overdue > 0 || blocked > 0 ? (
861                <Text>
862                  {overdue > 0 ? chip(`${overdue} overdue`, 'error') : null}
863                  {overdue > 0 && blocked > 0 ? ' ' : null}
864                  {blocked > 0 ? chip(`${blocked} blocked`, 'warning') : null}
865                </Text>
866              ) : null}
867            </Box>,
868            { right: `${pct}% done`, color: 'success' },
869          )}
870          {card(
871            'burndown-card',
872            'Burndown',
873            <Box flexDirection="column">
874              {chart}
875              {days.length > 0 ? (
876                <Box flexDirection="row" justifyContent="space-between">
877                  {dim(start)}
878                  {dim(`peak ${peak}`)}
879                  {dim('today')}
880                </Box>
881              ) : null}
882            </Box>,
883            { right: `${days[days.length - 1] ?? 0} open now` },
884          )}
885          {card(
886            'statuses',
887            'By status',
888            <Box flexDirection="column">
889              <Text dimColor>{row('status', 'tasks', 'estimate', 'tracked')}</Text>
890              {snap.stats.map(s => (
891                <Text key={`st-${s.status}`}>
892                  {row(s.status, String(s.count), lib.formatDuration(s.estimateSeconds), lib.formatDuration(s.trackedSeconds))}
893                </Text>
894              ))}
895            </Box>,
896          )}
897          {snap.recentDone.length > 0
898            ? card(
899                'recent',
900                'Recently done',
901                <Box flexDirection="column">
902                  {snap.recentDone.map(t => (
903                    <Text key={`done-${t.shortId}`} wrap="truncate-end">
904                      <Text color="success">✔</Text> <Text dimColor>#{t.shortId}</Text> {t.title}
905                    </Text>
906                  ))}
907                </Box>,
908              )
909            : null}
910        </Box>
911      )
912    }
913
914    const memoryTab = () => {
915      const related = snap.active?.related ?? []
916      return (
917        <Box flexDirection="column">
918          {snap.project === null
919            ? noProject
920            : card(
921                'rulings',
922                'Standing rulings',
923                snap.rulings.length === 0 ? (
924                  dim('None for this project.')
925                ) : (
926                  <Box flexDirection="column" gap={1}>
927                    {snap.rulings.map((r, i) => (
928                      <Box key={`rule-${i}`} flexDirection="column">
929                        <Text bold wrap="wrap">
930                          <Text color="suggestion">▌</Text> {r.title}
931                        </Text>
932                        <Text dimColor wrap="wrap">
933                          {r.preview}
934                        </Text>
935                      </Box>
936                    ))}
937                  </Box>
938                ),
939                { right: String(snap.rulings.length) },
940              )}
941          {snap.active
942            ? card(
943                'related',
944                `Related to #${snap.active.shortId}`,
945                related.length === 0 ? (
946                  dim('No memory hits.')
947                ) : (
948                  <Box flexDirection="column" gap={1}>
949                    {related.map((hit, i) => (
950                      <Box key={`hit-${i}`} flexDirection="column">
951                        <Text bold wrap="wrap">
952                          <Text color="suggestion">▌</Text> {hit.title}
953                        </Text>
954                        {dim(hit.source)}
955                        <Text wrap="wrap">{hit.snippet}</Text>
956                      </Box>
957                    ))}
958                  </Box>
959                ),
960              )
961            : null}
962        </Box>
963      )
964    }
965
966    const notesTab = () => {
967      // any press but the second Remove drops a pending Remove
968      const noteAct = (args: string[], label: string, note?: string | ((result: any) => string)) =>
969        update($, confirmRemove, () => null).then(() => act($, args, label, note))
970      const capture = Input ? (
971        <Input
972          key="note-capture"
973          placeholder="idea or URL…"
974          submitLabel="save"
975          value=""
976          onSubmit={v => {
977            const text = v.trim()
978            // `--` so a note like "7" or "-x" is text, not an id or a flag
979            if (text) void noteAct(['note', '--json', '--', text], 'noted')
980          }}
981        />
982      ) : null
983      const filter = (
984        <Button
985          key="notes-links"
986          label={linksOnly ? 'All notes' : 'Links only'}
987          hotkey="l"
988          onPress={() => update($, notesLinks, shown => !shown).then(() => update($, confirmRemove, () => null)).then(() => refresh($, true, true))}
989        />
990      )
991      if (snap.notesError) {
992        return card(
993          'notes',
994          'Notes',
995          <Box flexDirection="column">
996            <Text color="error" wrap="wrap">
997              {snap.notesError}
998            </Text>
999            {dim('Notes need tasqx 0.16.0 or later.')}
1000          </Box>,
1001          { color: 'error' },
1002        )
1003      }
1004      const more = snap.notesTotal - snap.notes.length
1005      return card(
1006        'notes',
1007        linksOnly ? 'Notes with a link' : 'Notes',
1008        <Box flexDirection="column" gap={1}>
1009          {capture}
1010          {filter}
1011          {snap.notes.length === 0
1012            ? dim(linksOnly ? 'No open note has a link.' : 'No open notes. Type one above, or tasqx note "…".')
1013            : snap.notes.map(n => (
1014                <Box key={`note-${n.shortId}`} flexDirection="column">
1015                  <Text wrap="wrap">
1016                    <Text dimColor>#{n.shortId} </Text>
1017                    {lib.splitLinks(n.body).map((seg, i) =>
1018                      seg.href ? (
1019                        <Link key={`note-${n.shortId}-link-${i}`} href={seg.href}>
1020                          {seg.text}
1021                        </Link>
1022                      ) : (
1023                        seg.text
1024                      ),
1025                    )}
1026                  </Text>
1027                  <Box flexDirection="row" gap={1} flexWrap="wrap">
1028                    {dim(lib.relativeTime(n.created, now))}
1029                    <Button key={`archive-${n.shortId}`} label="Archive" onPress={() => noteAct(['note', String(n.shortId), 'archive'], `archived note #${n.shortId}`)} />
1030                    <Button
1031                      key={`totask-${n.shortId}`}
1032                      label="→ Task"
1033                      onPress={() =>
1034                        noteAct(
1035                          ['note', String(n.shortId), 'promote', 'task'],
1036                          `note #${n.shortId} → task`,
1037                          r => `The user promoted tasqx note #${n.shortId} to task #${r.task?.short_id ?? '?'} from the pane. Nothing is asked of you.`,
1038                        )
1039                      }
1040                    />
1041                    <Button
1042                      key={`tomemory-${n.shortId}`}
1043                      label="→ Memory"
1044                      onPress={() =>
1045                        noteAct(
1046                          ['note', String(n.shortId), 'promote', 'memory'],
1047                          `note #${n.shortId} → memory`,
1048                          r => `The user promoted tasqx note #${n.shortId} to the memory doc ${quote(String(r.doc?.title ?? ''))} from the pane. Nothing is asked of you.`,
1049                        )
1050                      }
1051                    />
1052                    {removing === n.shortId ? (
1053                      <Button
1054                        key={`remove-${n.shortId}`}
1055                        label="Press again to remove"
1056                        variant="primary"
1057                        onPress={() => noteAct(['note', String(n.shortId), 'rm'], `removed note #${n.shortId}`)}
1058                      />
1059                    ) : (
1060                      <Button key={`remove-${n.shortId}`} label="Remove" onPress={() => update($, confirmRemove, () => n.shortId)} />
1061                    )}
1062                  </Box>
1063                </Box>
1064              ))}
1065          {more > 0 ? dim(`…${more} more: tasqx notes${linksOnly ? ' --links' : ''}`) : null}
1066        </Box>,
1067        { right: String(snap.notesTotal) },
1068      )
1069    }
1070
1071    const body =
1072      current === 'queue'
1073        ? queueTab()
1074        : current === 'project'
1075          ? projectTab()
1076          : current === 'memory'
1077            ? memoryTab()
1078            : current === 'notes'
1079              ? notesTab()
1080              : taskTab()
1081
1082    return (
1083      <Box flexDirection="column">
1084        {header}
1085        {body}
1086      </Box>
1087    )
1088  })
1089}
1090
hooks/lib.ts 360 lines
1// Pure helpers: parsing tasqx's JSON and formatting it. No `$` here, so the
2// tests reach every branch without a session.
3
4import type { ActiveTask, InboxNote, MemoryHit, ProjectSource, QueueRow, StatusCount, TaskRef } from '../types'
5
6type Json = any
7
8/** "PT1H30M" / "P2DT3H" / "PT45.5S" → seconds; null for null or unparsable. */
9export function parseDuration(iso: string | null | undefined): number | null {
10  if (!iso) return null
11  const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/.exec(iso)
12  if (!m) return null
13  const [, w, d, h, min, s] = m
14  return (
15    Number(w ?? 0) * 604800 +
16    Number(d ?? 0) * 86400 +
17    Number(h ?? 0) * 3600 +
18    Number(min ?? 0) * 60 +
19    Math.floor(Number(s ?? 0))
20  )
21}
22
23/** Seconds → "2h 05m", "12m", "40s". */
24export function formatDuration(seconds: number | null): string {
25  if (seconds === null) return '—'
26  const s = Math.max(0, Math.floor(seconds))
27  if (s < 60) return `${s}s`
28  const h = Math.floor(s / 3600)
29  const m = Math.floor((s % 3600) / 60)
30  if (h === 0) return `${m}m`
31  if (h >= 24 && m === 0) return `${h}h`
32  return `${h}h ${String(m).padStart(2, '0')}m`
33}
34
35/** An instant relative to now: "in 2 days", "3 hours ago", "today". */
36export function relativeTime(iso: string, nowMs: number): string {
37  const diff = Date.parse(iso) - nowMs
38  if (Number.isNaN(diff)) return iso
39  const ahead = diff >= 0
40  const mins = Math.round(Math.abs(diff) / 60000)
41  const say = (n: number, unit: string) => {
42    const word = `${n} ${unit}${n === 1 ? '' : 's'}`
43    return ahead ? `in ${word}` : `${word} ago`
44  }
45  if (mins < 1) return 'just now'
46  if (mins < 60) return say(mins, 'minute')
47  const hours = Math.round(mins / 60)
48  if (hours < 24) return say(hours, 'hour')
49  return say(Math.round(hours / 24), 'day')
50}
51
52/** "[#####-----]" filled to ratio, at least 1 and at most `width` cells inside. */
53export function bar(ratio: number, width: number): string {
54  const cells = Math.max(1, width)
55  const filled = Math.min(cells, Math.max(0, Math.round(ratio * cells)))
56  return `[${'#'.repeat(filled)}${'-'.repeat(cells - filled)}]`
57}
58
59export function firstParagraph(body: string | null | undefined): string | null {
60  if (!body) return null
61  const para = body.trim().split(/\n\s*\n/)[0]?.trim()
62  return para ? para : null
63}
64
65export function basename(path: string): string {
66  const parts = path.replace(/[\\/]+$/, '').split(/[\\/]/)
67  return parts[parts.length - 1] ?? path
68}
69
70/**
71 * Which project to show: the one picked for this folder, else the override,
72 * each only while it names a live project, else the project named after the
73 * repository folder (case-insensitive), else none.
74 */
75export function pickProject(
76  projects: readonly string[],
77  dirName: string | null,
78  override: string,
79  chosen = '',
80): { project: string | null; source: ProjectSource } {
81  const find = (name: string) => projects.find(p => p.toLowerCase() === name.toLowerCase())
82  if (chosen) {
83    const hit = find(chosen)
84    if (hit) return { project: hit, source: 'folder' }
85  }
86  const wanted = override.trim()
87  if (wanted) {
88    const hit = find(wanted)
89    if (hit) return { project: hit, source: 'config' }
90  }
91  if (dirName) {
92    const hit = find(dirName)
93    if (hit) return { project: hit, source: 'repo' }
94  }
95  return { project: null, source: 'none' }
96}
97
98const taskRef = (t: Json): TaskRef => ({
99  shortId: t.short_id,
100  title: t.title ?? `#${t.short_id}`,
101  status: t.status ?? '',
102})
103
104/** `tasqx brief <id> --json` → the pane's view of the active task. */
105export function toActiveTask(brief: Json): ActiveTask {
106  const t = brief.task
107  const notes: Json[] = t.annotations ?? []
108  // ponytail: the newest note is the last of the first page; a task with more
109  // notes than one page shows the page's last. Fetch the last page if it matters.
110  const last = notes.length > 1 ? notes[notes.length - 1] : null
111  const near = brief.neighbourhood ?? {}
112  const hits: Json[] = brief.memory?.hits ?? []
113  return {
114    shortId: t.short_id,
115    title: t.title,
116    project: t.project,
117    priority: t.priority ?? null,
118    urgency: t.urgency ?? 0,
119    estimateSeconds: parseDuration(t.estimate),
120    trackedSeconds: parseDuration(t.tracked) ?? 0,
121    due: t.due ?? null,
122    tags: t.tags ?? [],
123    description: firstParagraph(t.first_annotation?.body),
124    latestNote: last ? { body: firstParagraph(last.body) ?? '', created: last.created } : null,
125    noteCount: t.annotations_total ?? notes.length,
126    checks: (t.checks ?? []).map((c: Json) => ({ id: c.id, body: c.body, state: c.state })),
127    dependsOn: (near.depends_on ?? []).map(taskRef),
128    blocks: (near.blocks ?? []).map(taskRef),
129    freshTokens: t.fresh_tokens ?? 0,
130    budgetTokens: t.budget_tokens ?? null,
131    related: hits.slice(0, 3).map(
132      (h): MemoryHit => ({
133        title: h.title ?? '',
134        source: h.source ?? '',
135        snippet: String(h.snippet ?? '').replace(/\s+/g, ' ').trim(),
136      }),
137    ),
138  }
139}
140
141export function toQueueRow(t: Json): QueueRow {
142  return {
143    shortId: t.short_id,
144    title: t.title,
145    status: t.status,
146    priority: t.priority ?? null,
147    urgency: t.urgency ?? 0,
148    blocked: t.blocked === true,
149    due: t.due ?? null,
150    estimateSeconds: parseDuration(t.estimate),
151  }
152}
153
154export function toStats(report: Json): StatusCount[] {
155  return (report.groups ?? []).map(
156    (g: Json): StatusCount => ({
157      status: g.status,
158      count: g.count ?? 0,
159      estimateSeconds: parseDuration(g.est_total) ?? 0,
160      trackedSeconds: parseDuration(g.tracked_total) ?? 0,
161      overdue: g.overdue ?? 0,
162    }),
163  )
164}
165
166export function toTaskRef(t: Json): TaskRef {
167  return taskRef(t)
168}
169
170/** The status line: "▶ #2270 12m / 3h · 1/5 checks · tasqx-pane". */
171export function statusText(active: ActiveTask, elapsedSeconds: number): string {
172  const est = active.estimateSeconds ? ` / ${formatDuration(active.estimateSeconds)}` : ''
173  const passed = active.checks.filter(c => c.state === 'passed').length
174  const checks = active.checks.length ? ` · ${passed}/${active.checks.length} checks` : ''
175  return `▶ #${active.shortId} ${formatDuration(elapsedSeconds)}${est}${checks} · ${active.title}`
176}
177
178/** What a press on a check sets it to: open → passed → failed → open. */
179export function nextCheckState(state: string): 'open' | 'passed' | 'failed' {
180  return state === 'open' ? 'passed' : state === 'passed' ? 'failed' : 'open'
181}
182
183/** Whether a finished tool call may have changed the store. */
184export function touchesTasqx(tool: string, input: Json): boolean {
185  if (tool.startsWith('mcp__tasqx__')) return true
186  return tool === 'Bash' && /\btasqx\b/.test(String(input?.command ?? ''))
187}
188
189const EIGHTHS = ' ▁▂▃▄▅▆▇█'
190
191/** The last `width` values, scaled so 0 is empty and the largest is full. */
192function fit(values: readonly number[], width: number): { tail: number[]; max: number } {
193  const tail = values.slice(-Math.max(1, width))
194  return { tail, max: Math.max(1, ...tail) }
195}
196
197/** A one-row sparkline of eighth blocks, for surfaces without a Raster. */
198export function sparkline(values: readonly number[], width: number): string {
199  const { tail, max } = fit(values, width)
200  return tail.map(v => EIGHTHS[Math.max(v > 0 ? 1 : 0, Math.round((v / max) * 8))]).join('')
201}
202
203const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
204
205export function base64(bytes: Uint8Array): string {
206  let out = ''
207  for (let i = 0; i < bytes.length; i += 3) {
208    const [a = 0, b = 0, c = 0] = [bytes[i], bytes[i + 1], bytes[i + 2]]
209    const n = (a << 16) | (b << 8) | c
210    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
211    out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
212    out += i + 2 < bytes.length ? B64[n & 63]! : '='
213  }
214  return out
215}
216
217export const DEFAULT_COLOR = 0x01000000
218
219/**
220 * A bar chart `rows` cells tall for a Raster: one column per value (the last
221 * `columns` of them), eighth blocks stacked bottom-up, the last column in
222 * `lastColor`. Returns the Raster's `cells` and how many columns it drew.
223 */
224export function barCells(
225  values: readonly number[],
226  columns: number,
227  rows: number,
228  color: number,
229  lastColor: number,
230): { cells: string; columns: number } {
231  const { tail, max } = fit(values, columns)
232  const words = new Uint32Array(tail.length * rows * 3)
233  tail.forEach((v, x) => {
234    const level = v > 0 ? Math.max(1, Math.round((v / max) * rows * 8)) : 0
235    for (let r = 0; r < rows; r++) {
236      const cell = Math.min(8, Math.max(0, level - (rows - 1 - r) * 8))
237      const i = (r * tail.length + x) * 3
238      words[i] = EIGHTHS.codePointAt(cell)!
239      words[i + 1] = x === tail.length - 1 ? lastColor : color
240      words[i + 2] = DEFAULT_COLOR
241    }
242  })
243  return { cells: base64(new Uint8Array(words.buffer)), columns: tail.length }
244}
245
246/** A filled meter `width` cells wide: the filled and the empty part. */
247export function meter(ratio: number, width: number): { filled: string; empty: string } {
248  const cells = Math.max(1, width)
249  const n = Math.min(cells, Math.max(0, Math.round(ratio * cells)))
250  return { filled: '█'.repeat(n), empty: '░'.repeat(cells - n) }
251}
252
253/** The theme color for a priority chip. */
254// No red here: red on a pane's grey reads poorly. Red is kept for overdue and failed.
255export function priorityColor(priority: string | null): 'warning' | 'suggestion' | 'inactive' {
256  return priority === 'H' ? 'warning' : priority === 'M' ? 'suggestion' : 'inactive'
257}
258
259/** The handoff prompt: `{id}`, `{title}` and `{project}` filled in. */
260export function handoffText(template: string, task: { shortId: number; title: string; project: string | null }): string {
261  return template
262    .replaceAll('{id}', String(task.shortId))
263    .replaceAll('{title}', task.title)
264    .replaceAll('{project}', task.project ?? '')
265}
266
267/** Every tag on these tasks, once each, sorted. */
268export function collectTags(tasks: Json[]): string[] {
269  return [...new Set<string>(tasks.flatMap(t => t.tags ?? []))].sort()
270}
271
272/** The prompt that hands every task under a tag to Claude, most urgent first. */
273export function tagHandoffText(filter: string, ids: number[]): string {
274  const now = ids.length > 0 ? ` (now ${ids.map(id => `#${id}`).join(', ')})` : ''
275  return `Work through the tasqx tasks in \`${filter}\`, most urgent first${now}. Read each brief first and finish one before you start the next.`
276}
277
278/**
279 * The tasqx task a subagent's prompt is about: the first of `tasqx show <id>`,
280 * `task/<id>-` (a branch) or `tasqx task #<id>`; null when it names none.
281 */
282export function taskIdIn(prompt: string): number | null {
283  const m = /\btasqx show (\d+)\b|\btask\/(\d+)-|\btasqx task #(\d+)\b/i.exec(prompt)
284  return m ? Number(m[1] ?? m[2] ?? m[3]) : null
285}
286
287/**
288 * The prompt section for standing rulings of `project` (or unscoped) created or
289 * changed after `sinceMs`: title plus first body line each; null when none.
290 */
291export function liveRulingsText(docs: readonly Json[], project: string | null, sinceMs: number): string | null {
292  const fresh = docs.filter(
293    d =>
294      d.standing !== false &&
295      (d.project == null || (project !== null && String(d.project).toLowerCase() === project.toLowerCase())) &&
296      Date.parse(d.modified ?? d.created) > sinceMs,
297  )
298  if (fresh.length === 0) return null
299  const lines = fresh.map(d => {
300    const first = String(d.body_preview ?? '').split('\n').find(l => l.trim())?.trim()
301    return `- ${d.title}${first ? `: ${first}` : ''}`
302  })
303  return ['Standing rulings added during this session — follow them:', ...lines].join('\n')
304}
305
306/** A note from `tasqx notes --json`. */
307export function toInboxNote(n: Json): InboxNote {
308  return {
309    shortId: Number(n.short_id),
310    body: String(n.body ?? ''),
311    hasLinks: Boolean(n.has_links),
312    created: String(n.created ?? ''),
313  }
314}
315
316// Ported from tasqx-desktop's state/notes.ts, so a link reads the same in both.
317// A link runs to the next whitespace or quote; the scheme is case-sensitive, as `has_links` is.
318const LINK_RE = /https?:\/\/[^\s<>"']+/g
319const TRAILING = '.,;:!?)]}'
320// C0, DEL and C1 controls
321// eslint-disable-next-line no-control-regex
322const CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]/
323
324/** Trailing punctuation is prose, except a `)` closing a `(` inside the link. */
325function trimTrailing(href: string): string {
326  let end = href.length
327  while (end > 0 && TRAILING.includes(href[end - 1]!)) {
328    const head = href.slice(0, end)
329    if (head.endsWith(')') && head.split('(').length > head.split(')').length - 1) break
330    end -= 1
331  }
332  return href.slice(0, end)
333}
334
335/** No userinfo (`https://google.com@evil.com/` reads as one host and goes to another), backslash or control character; `Link` takes 2048 characters. */
336function openable(href: string): boolean {
337  const authority = href.slice(href.indexOf('//') + 2).split(/[/?#]/)[0] ?? ''
338  return (
339    /^https?:\/\/[^/]/.test(href) && !authority.includes('@') && !href.includes('\\') && !CONTROL_RE.test(href) && href.length <= 2048
340  )
341}
342
343export type BodySegment = { text: string; href?: string }
344
345/** A note body cut into plain text and the http(s) links in it, in order; a link it refuses stays text. */
346export function splitLinks(body: string): BodySegment[] {
347  const segments: BodySegment[] = []
348  let at = 0
349  for (const match of body.matchAll(LINK_RE)) {
350    const href = trimTrailing(match[0])
351    if (!openable(href)) continue
352    const start = match.index ?? 0
353    if (start > at) segments.push({ text: body.slice(at, start) })
354    segments.push({ text: href, href })
355    at = start + href.length
356  }
357  if (at < body.length || segments.length === 0) segments.push({ text: body.slice(at) })
358  return segments
359}
360
types/index.d.ts 108 lines
1export type CheckState = 'open' | 'passed' | 'failed'
2
3export type Check = { id: string; body: string; state: CheckState }
4
5export type TaskRef = { shortId: number; title: string; status: string }
6
7export type Note = { body: string; created: string }
8
9/** One note from tasqx's inbox (`tasqx notes`), not a task annotation. */
10export type InboxNote = { shortId: number; body: string; hasLinks: boolean; created: string }
11
12export type MemoryHit = { title: string; source: string; snippet: string }
13
14export type ActiveTask = {
15  shortId: number
16  title: string
17  project: string
18  priority: string | null
19  urgency: number
20  estimateSeconds: number | null
21  trackedSeconds: number
22  due: string | null
23  tags: string[]
24  description: string | null
25  latestNote: Note | null
26  noteCount: number
27  checks: Check[]
28  dependsOn: TaskRef[]
29  blocks: TaskRef[]
30  freshTokens: number
31  budgetTokens: number | null
32  related: MemoryHit[]
33}
34
35export type QueueRow = {
36  shortId: number
37  title: string
38  status: string
39  priority: string | null
40  urgency: number
41  blocked: boolean
42  due: string | null
43  estimateSeconds: number | null
44}
45
46export type StatusCount = {
47  status: string
48  count: number
49  estimateSeconds: number
50  trackedSeconds: number
51  overdue: number
52}
53
54export type Ruling = { title: string; preview: string }
55
56export type Burndown = { from: string | null; remaining: number[] }
57
58export type ProjectSource = 'folder' | 'config' | 'repo' | 'none'
59
60export type Snapshot = {
61  project: string | null
62  projectSource: ProjectSource
63  /** every live project, for the picker */
64  projects: string[]
65  dirName: string | null
66  active: ActiveTask | null
67  otherActive: TaskRef[]
68  queue: QueueRow[]
69  queueTotal: number
70  /** every tag in the project's working set, for the tag picker */
71  tags: string[]
72  /** the tag the queue is filtered on, or null for all */
73  tag: string | null
74  recentDone: TaskRef[]
75  stats: StatusCount[]
76  rulings: Ruling[]
77  /** open notes, newest first, under the links filter when it is on */
78  notes: InboxNote[]
79  notesTotal: number
80  /** why the notes could not be read (a tasqx before 0.16.0 has none), else null */
81  notesError: string | null
82  /** Open tasks left at the end of each of the last days, oldest first. */
83  burndown: Burndown
84  fetchedAt: number
85  /** False when only the active task was read (the pane was closed). */
86  isFull: boolean
87  error: string | null
88}
89
90export type Tab = 'task' | 'queue' | 'project' | 'memory' | 'notes'
91
92declare module 'claude-code' {
93  interface PluginState {
94    'tasqx-pane': {
95      snapshot: Snapshot | null
96      tab: Tab
97      busy: string | null
98      tag: string | null
99      /** When this session started (ms), for rulings added during it. */
100      sessionStart: number | null
101      /** Whether the notes tab shows only notes with a URL. */
102      notesLinks: boolean
103      /** The note whose Remove waits for its second press. */
104      confirmRemove: number | null
105    }
106  }
107}
108