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.

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 │
╰──────────────────────────────────────────────────────╯
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.
| Tab | Hotkey | What is on it |
|---|---|---|
| Task | t | The 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. |
| Queue | q | The 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. |
| Project | p | Done/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. |
| Memory | m | The project's standing rulings, and the memory hits tasqx finds for the active task. |
| Notes | n | Your 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 …
s).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.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.
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.
Two things the pane does beside the session, each switchable in /config:
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.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.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.
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.
Under /config → tasqx-pane:
| Setting | Default | What it does |
|---|---|---|
| tasqx binary | tasqx | The 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 start | on | Open the pane when a session starts in a repo that has a project. |
| Status line | on | Show the active task under the prompt. |
| Refresh every (seconds) | 15 | How often it reads tasqx (at least 5). |
| Hand to Claude prompt | Work 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 Claude | on | Leave Claude a note when you start or stop a timer or mark a check in the pane. |
| Start timer on spawn | on | Start the timer of the task a subagent is launched on. |
| Live rulings | on | Add standing rulings recorded during the session to Claude's system prompt. |
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>.
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.
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.
MIT
hooks/register.tsx 1090 lines1import { 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}
1090hooks/lib.ts 360 lines1// 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}
360types/index.d.ts 108 lines1export 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