SLOPSHOPPER

task-line

One clean line per task list above the prompt, filled from Claude's own todo tools, same look in the terminal and the desktop app

newbandrowsguardcommandtimer
v0.1.0MITupdated 2026-10-05muellerei/task-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-line
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /task-line ⎿ task-line: Task line hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

task-line

One clean line per task list above the Claude Code prompt, filled from Claude's own todo tools. You do not operate it.

Nothing on the line is made up or measured by the mod: it reads the todo list that Claude Code keeps for the work. It is a Claude Code mod (a plugin of function hooks).

New here? Ideas, questions and a quick "works on my setup" are all welcome. Here's how: CONTRIBUTING.md.

● Write the tests    ━━━━━━━━━━━━━━━━━━━━────────────────────  2/5   40%
? Ship it            ━━━━━━━━━━━━━━━━━━━━────────────────────  2/5   40%  needs you
✕ Fix the build      ━━━━━━━━━━━━━━━━━━━━────────────────────  2/5   40%  npm test failed
✓ Done               ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  5/5  100%

What you see

A line shows the task Claude is working on, a bar that grows with finished tasks, the count and the percent. It turns yellow with needs you while Claude waits for your answer to a question, red with the name of the check when a test or build command fails, and green when the list is finished. A finished list goes away after the time lingerSeconds sets (see Settings).

Cut from real terminal screenshots (dark theme, fullscreen, so the × button shows):

Working on a task

A line while Claude works on a task

A check failed

The line turns red and names the failed check

Claude waits for your answer (drawn above the question dialog, which has no ×)

The line turns yellow while Claude waits for an answer

Finished (the success color is blue in this theme)

A finished list

Install

Run these inside Claude Code:

/plugin marketplace add muellerei/task-line
/plugin install task-line@muellerei-mods

Or from a shell: claude plugin marketplace add muellerei/task-line, then claude plugin install task-line@muellerei-mods. Then run /reload-plugins. The line needs a todo list to read: see Requirements.

Requirements

  • Claude Code 2.1.287 or later. Mods are on by default from that version (an administrator can switch them off). Tested with 2.1.288.
  • A todo list that Claude keeps. The mod only shows lists Claude already writes and creates none itself, on purpose: a model does not use a substitute tool unless told to. Without a list the line stays empty.
Your setupTodo list?What to do
Claude 3.x, Opus 4 to 4.7, Sonnet 4 to 4.6, Haiku 4.5yes, by defaultnothing
Background and cloud sessions, any modelyesnothing
Newer models (for example Sonnet 5.5)no, left out since Claude Code 2.1.268set CLAUDE_CODE_ENABLE_TODO_TOOLS=1 (Claude Code 2.1.233 or later), for example with enable-todo-tools, or use a tool named work_state_write for the lists

enable-todo-tools is a separate companion mod that sets that variable when a session starts, so the todo tools are on without touching your settings. Install it only if you want that: Claude then keeps lists, which costs extra turns. It does not need task-line, and task-line does not need it.

Details under What it reads.

Hide it

  • /task-line hides the line and shows it again. The lists keep being filled while it is hidden.
  • × at the end of a line hides that list until it changes again. The button is drawn where there is a click: in the desktop app and in a fullscreen terminal. A terminal band has none.

Settings

Two settings, in the /config menu under task-line:

  • lingerSeconds, listed as Finished list stays (seconds): how long a finished list stays in the line, in seconds. 0 never shows a finished list.
  • joinSeconds, listed as New task joins (seconds): how long after a list finished a new task of the same name still joins it, in seconds. 0 never joins. A task joins only while the list is shown, so a shorter lingerSeconds shortens this too: after that time a new task starts a new list (0/1, not 3/4).

A value outside the allowed range is set to the nearest allowed one. If joinSeconds is shorter than lingerSeconds, a change to a task of the finished list after the join time drops that list.

Try it

Three things to say to Claude, each shows one state of the line:

  1. "Make a todo list with three tasks for this change and start the first one." The line shows the task in progress, 0/3, and the bar grows as tasks are finished.
  2. "Run npm test in a folder that has no package.json, as the only command." The newest list turns red and names the check (npm test failed). A command that ends with echo or || true exits with 0 and does not count.
  3. "Ask me a question with the question tool." The line turns yellow with needs you above the dialog.

Troubleshooting

  • No line. Ask Claude to "make a todo list with three tasks": if it answers that it has no todo tool, there is nothing for the line to read (see Requirements). If it does have the tool, the usual reason is that Claude keeps no list for the work at hand: the line shows only the list Claude writes, it moves only when Claude changes a task's status, and what a subagent does is not shown. A short question or a single step makes no list. Ask for one at the start, or tell Claude in your CLAUDE.md to keep a todo list for work with several steps. Mods need Claude Code 2.1.287 or later. Run /reload-plugins, then claude plugin validate on the plugin folder. A finished list goes away after the time lingerSeconds sets, a list you closed with × comes back with the next change to it, and /task-line may have hidden the line (it says Task line hidden. or Task line shown.).
  • No red after a failed command. Only a check that ran and exited with a code other than 0 counts, a finished list is never red, and a command refused by you or by a hook is no failure.
  • Only one list above a question. Claude Code refuses a larger tree around the dialog, so the newest list is shown there.
  • The bar wraps or looks cut. Report the width of the window and whether it is the terminal or the desktop app.

Support

Report problems and ideas as issues at https://github.com/muellerei/task-line/issues, or start a discussion at https://github.com/muellerei/task-line/discussions. Say which Claude Code version and surface (terminal, desktop app) you use, and what the line showed. If you would like to help, CONTRIBUTING.md is the place to start.

What it reads

The line is filled from tool calls Claude already makes, so it needs no extra instruction in the prompt:

SourceUse
TodoWriteone list, replaced on each call
TaskCreate, TaskUpdateone list, items added and changed
work_state_write (the to-do list tool of claude-mem, which task-line is not affiliated with, or any MCP tool of that name)one list per list, items from fields.task and fields.status

Calls from subagents, calls that were refused and calls that failed are ignored. Of work_state_write the mod sees only the inputs of Claude's calls and whether a call was refused or failed, never what the tool stores or returns.

Claude Code leaves its task tools out on newer models because they keep track of multi-step work without a written checklist (Claude Code's documentation, Task tool availability). With CLAUDE_CODE_ENABLE_TODO_TOOLS=1 Claude keeps the list with TaskCreate and TaskUpdate, and TodoWrite only with CLAUDE_CODE_ENABLE_TASKS=0. A work_state_write tool can come with instructions that make Claude keep its lists there instead of with the built-in tools, and the line reads those lists too.

Where the data comes from. The tasks are the todo list Claude Code itself keeps for the work (its own todo tools, or a work_state_write to-do list tool), and the mod reads them as Claude writes them. The count and the percent are finished tasks over all tasks of that list, so the bar is only as good as Claude's list: it is no estimate of time or effort. A red line comes from a test or build command Claude ran that exited with an error code (which commands count is under Failing checks), a yellow one from a question Claude asked you. The mod runs no commands and reads no files itself (see Privacy).

Failing checks

A failing test or build command turns the newest list red (a list that is already finished is never red) and names the check (npm test failed). These count:

ProgramCounts with
npm, pnpm, yarn, buntest, build, lint, typecheck or check
just, tasktest, build, check or lint
denotest, check or lint
make, gradle, gradlewthe target test, build or check among the options (make -C build test), or no target at all (make -j 4); make deploy, make -C build install and ./gradlew bootRun are no checks
pytest (also python -m pytest), jest, vitest, mocha, tsc, eslint, ruff, mypy, pyright, rspec, phpunit, claude plugin test/validateany call
cargotest, build, check or clippy
gotest, build or vet
mvn, mvnwthe goal test, package or verify, also after other goals (mvn clean verify); mvn clean is no check
dotnettest or build
  • A check has to be the command that starts, read as a shell reads a line: cd app && npm test counts, echo "run cargo test" does not. How the line is read is in How a command is read.
  • Options between the program and its subcommand are skipped (npm --prefix app test counts), and so is what comes before the program (sudo, uv run, FOO=1).
  • Any other failing command (a grep with no match for one) never counts, and neither does a command run by a subagent.
  • A command that you interrupted, that ran into its timeout or that was started in the background does not count either, and neither turns the line red nor clears it.
  • A line that runs several checks (npm run build && npm test) fails as check failed: the exit code is one for the whole line, so the line cannot tell which of them failed.
  • A check in front of a pipe or || true (npm test | tail -5) is not seen: the line gets the exit code the shell reports for the whole line (the last command of a pipe, unless pipefail is set), so a line that exits with 0 takes a red one away.
  • The line does not understand a # comment and reads a << inside quotes as a here document, which only changes what it shows. Examples in How a command is read.
  • The line goes back when the same check passes again (other arguments are fine), when a line that exited with 0 ran all the checks of the failure, or when the list changes: a task moves on, a task is added or one is removed.
  • A question to you (yellow) wins over a failed check.

Behavior and limits

  • The lists belong to the session: /clear, /resume and /branch start with none, a reload of the mod keeps them.
  • At most two lists are drawn in the band, the newest ones. A third active list is kept, but not shown. Above a question dialog in the terminal only the newest list is shown; the desktop app keeps the band below its dialog.
  • A narrow band keeps its shape: the label gives way first, then the note (npm test failed, needs you) is left out, the glyph and the color still say it. The bar keeps its minimum length (see Limits).
  • /task-line also works while Claude is busy.
  • A mod that draws in the band above the prompt without calling next(e) and this one hide each other's output. More in Where and how the line draws.
  • A plan approval (ExitPlanMode) has no render component of its own, so the line is not drawn there. The yellow state is set while the plan waits, but only the question dialog shows it.
  • Colors are theme colors (success, warning, error, claude, inactive), so they follow your theme. In some themes success is not green.

Privacy

A test fails when a hook on another event is added.

  • The mod sees the Bash commands Claude runs and the task names of the lists. It keeps the lists and the name of a failed check in the mod's session state, nothing else of a command.
  • It reads and writes no files and makes no network calls itself. It does not read Claude's memory, the chat history, summaries or your files.
  • It sees the inputs of tool calls Claude makes, and of a result only what it needs:
  • of a Bash result the error flag, whether the text starts with Exit code and a number (and is not an interrupt or a timeout), and whether a background task started
  • of a TaskCreate result the id of the new task
  • of a TaskUpdate result whether it succeeded
  • of every result whether the call was refused or failed
  • It does not watch what you type: it hooks the tool calls Claude makes, the start of a session, the command /task-line (whose arguments it does not read) and the drawing of the line, and no event of your prompt or your messages.
  • Of a question to you it knows only that one is open, not your answer.

Details

Limits

WhatValueConstantPinned by
Bar widthabout 40% of the width, at least 10 and at most 60 characters, and it gives way when the row would not fitBAR_SHARE, BAR_MIN, BAR_MAX (layout.ts)units.test.ts, the layout tests
Label slotas long as the longest label, at least 8 and at most 35% of the width, down to 4 when the row is narrowLABEL_MIN, LABEL_SHARE, LABEL_FLOOR (layout.ts)units.test.ts, the layout tests
Task subjectcut at 200 charactersSUBJECT_MAX (cells.ts)units.test.ts, the plain test
List namecut at 60 charactersLIST_MAX (cells.ts)boundaries.test.tsx, the work_state_write name test
Tasks in a list500MAX_TASKS (lists.ts)units.test.ts and boundaries.test.tsx, the put tests
Lists keptthe 20 newestMAX_KEPT_LISTS (lists.ts)units.test.ts and boundaries.test.tsx, the put tests
Lists drawn in the band2MAX_LISTS (register.tsx)boundaries.test.tsx, the test for two lists
Settings lingerSeconds and joinSeconds0 to 120 seconds, a value outside is set to the nearestSETTING_MAX_SECONDS (config.ts)units.test.ts, the settingToMs test

Wide characters such as CJK or emoji count as two cells. Neither the bar nor the percent shows a finished list before the list is finished (199 of 200 is 99%), and both show something as soon as one task is done.

Names and subjects

Names and subjects are made safe to draw. Escape sequences and invisible characters (bidi controls, zero width spaces, the private-use planes) are removed, and other control characters and line breaks become spaces.

Lists of work_state_write

A work_state_write list stays as long as its name is in use: when Claude closes it (a write with the status done and no task) the line lets it go, and the next task of that name starts a new list. A finished list that has faded (no longer shown after the time lingerSeconds sets, or hidden with ×) is over as well: a task that comes to its name then starts a new list. A task that comes while the finished list is still shown, and not later than joinSeconds after it finished, joins it, so that adding one task, finishing it and adding the next keeps the progress. Without both rules a name used again and again would grow into one list of everything it ever held.

Develop

The commands to build and check a change, and the reason for each, are in CONTRIBUTING.md.

The tests mount the band on the terminal and the desktop surface. units.test.ts tests the pure functions with their limits, boundaries.test.tsx the line at its limits (the ends of the bar and the percent, the time a finished list stays to the millisecond, odd statuses and names, concurrent questions, failed checks), register.test.tsx the rest: the list sources, the question state, the table of recognized commands, the × button and /task-line. The question dialog itself cannot be mounted in the test kit, so its look is only checked in a session.

License

MIT, see LICENSE.

Source 7 files
hooks/register.tsx 340 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Task, TaskList } from '../types'
5import { LIST_MAX, plain, fit } from './cells'
6import { timingOf } from './config'
7import { BUILTIN, put, statusOf } from './lists'
8import type { Timing } from './lists'
9import { DEFAULT_COLUMNS, PERCENT_WIDTH, layout, progressOf, rowOf } from './layout'
10import type { Row } from './layout'
11import { checksOf, failureKey, hasPassed, ranAndFailed } from './shell'
12
13const lists = atom({ plugin: 'task-line', key: 'lists' } as const, [] as TaskList[])
14// How many questions or plans wait for the person's answer right now.
15const questions = atom({ plugin: 'task-line', key: 'questions' } as const, 0)
16// Switched off with /task-line: no line is drawn, the lists keep being filled.
17const isOff = atom({ plugin: 'task-line', key: 'isOff' } as const, false)
18// The check (`npm test`, `pytest`, ...) whose last run failed, until it passes or the statuses of a list change (a task moves on, is added or removed).
19const failure = atom({ plugin: 'task-line', key: 'failure' } as const, null as string | null)
20
21// The tool of claude-mem's to-do list, whatever the plugin is called in the person's setup: told by how its name ends.
22const WORK_STATE = /^mcp__.+__work_state_write$/
23const NBSP = '\u00a0'
24// The theme color of the bar's empty part: a quiet area.
25const TRACK_COLOR = 'userMessageBackground'
26const MAX_LISTS = 2
27
28// Only the main conversation counts, and only a call the tool accepted.
29const counts = (e: { agentId?: string }, r: { deny?: unknown; isError?: boolean }) => e.agentId === undefined && r.deny === undefined && !r.isError
30
31// A note for the debug log. A log that fails must not fail the work it reports on, so the failure of the log itself is let go.
32function debugLog($: Engine, text: string) {
33  try {
34    $.ui.log(`task-line: ${text}`, { to: 'debug' })
35  } catch {
36    // nothing left to tell
37  }
38}
39
40// The line is a display: whatever goes wrong in its own work is logged and never reaches the tool call it watches.
41async function safely<T>($: Engine, what: string, work: () => Promise<T>): Promise<T | null> {
42  try {
43    return await work()
44  } catch (error) {
45    debugLog($, `${what} failed: ${String(error)}`)
46    return null
47  }
48}
49
50// One wait per list and stamp, however often the line is drawn meanwhile.
51const scheduled = new Set<string>()
52
53// Hides the list after the wait, unless it was touched again meanwhile (a new stamp, or none).
54function hideLater($: Engine, name: string, stamp: number, ms: number) {
55  const key = `${name}@${stamp}`
56  if (scheduled.has(key)) return
57  // The key is set before the timer is asked for, since a timer may run at once, and given back when no timer came: a key that stays
58  // without a timer would keep the list from ever being hidden.
59  scheduled.add(key)
60  try {
61    $.clock.after(ms, () => {
62      scheduled.delete(key)
63      void safely($, 'hide', () => update($, lists, all => all.map(l => (l.name === name && l.doneAt === stamp ? { ...l, isHidden: true } : l))))
64    })
65  } catch (error) {
66    scheduled.delete(key)
67    debugLog($, `hide failed: ${String(error)}`)
68  }
69}
70
71// Applies the change, and once every task is done hides the list after a short while.
72async function touch($: Engine, name: string, timing: Timing, change: (tasks: Task[]) => Task[]) {
73  const statuses = (list?: TaskList) => list?.tasks.map(t => t.status).join(',') ?? ''
74  const now = await $.clock.now()
75  const before = statuses((await read($, lists)).find(l => l.name === name))
76  await update($, lists, all => put(all, name, change, timing, now))
77  const list = (await read($, lists)).find(l => l.name === name)
78  // A task moved on: the failure belonged to the step before.
79  if (statuses(list) !== before) await update($, failure, () => null)
80  if (!list || list.tasks.some(t => t.status !== 'done')) return
81  const stamp = await $.clock.now()
82  await update($, lists, all => all.map(l => (l.name === name ? { ...l, doneAt: stamp } : l)))
83  hideLater($, name, stamp, timing.lingerMs)
84}
85
86// What the mod reads of inputs and results the engine's own types do not name (tools of other plugins, the viewport of a dialog).
87type WorkStateInput = { list?: unknown; fields?: Record<string, unknown> }
88type CreatedResult = { result?: { task?: { id?: unknown } } }
89type UpdatedResult = { result?: { success?: unknown } }
90type BashResult = { result?: { backgroundTaskId?: unknown } }
91type RenderInput = Parameters<Engine['ui']['resolve']>[0] & { surface: string; viewport?: { columns?: number; isFullscreen?: boolean } }
92
93// The rows of the line, or null when there is nothing to show.
94async function lineOf($: Engine, e: RenderInput, columns: number, timing: Timing, isPlain = false) {
95  if (await read($, isOff)) return null
96  // A finished list is judged by its age, not by a timer alone: a reload of this module drops timers, the state stays.
97  const now = await $.clock.now()
98  const age = (l: TaskList) => (typeof l.doneAt === 'number' && Number.isFinite(l.doneAt) ? now - l.doneAt : 0)
99  // Above the question dialog only the newest list is drawn. With two rows Claude Code refuses the tree and draws no line at all (its
100  // debug log: "more than 12 rows around the dialog"); one row passes. Found by hand, the limit is not documented.
101  const shown = (await read($, lists))
102    .filter(l => !l.isHidden && l.tasks.length > 0 && (l.doneAt === null || age(l) < timing.lingerMs))
103    .slice(isPlain ? -1 : -MAX_LISTS)
104  if (shown.length === 0) return null
105  // A render may not write state: the hide runs just after, once the wait is over.
106  for (const l of shown) if (l.doneAt !== null && Number.isFinite(l.doneAt)) hideLater($, l.name, l.doneAt, timing.lingerMs - age(l))
107
108  const asking = (await read($, questions)) > 0
109  const failed = await read($, failure)
110  // Only the newest list is the one Claude is working on.
111  const rows = shown.map((l, i) => rowOf(l, asking && i === shown.length - 1, i === shown.length - 1 ? failed : null))
112  // A button needs a click: the desktop app and a fullscreen terminal have one, a terminal band does not.
113  const canClick = e.surface !== 'terminal' || e.viewport?.isFullscreen === true
114  const { label: labelWidth, bar: barWidth, showNote } = layout(rows, columns, canClick && !isPlain)
115  const visible = showNote ? rows : rows.map(r => ({ ...r, note: null }))
116  const { Box, Button, Text } = $.ui.resolve(e)
117  const dismiss = (name: string) => () => safely($, 'dismiss', () => update($, lists, all => all.map(l => (l.name === name ? { ...l, isHidden: true } : l))))
118
119  // The bar. A terminal draws characters. The desktop app draws a box-drawing character wider than a cell, so a bar of them wrapped into
120  // a second line there (found by hand; `overflow` and `height` do not clip it): it draws two boxes with a width in cells and a background.
121  const isDesktop = e.surface === 'desktop'
122  const barOf = (r: Row, filled: number) =>
123    !isDesktop ? (
124      <Box key="bar">
125        <Text color={r.color}>{'━'.repeat(filled)}</Text>
126        <Text dimColor>{'─'.repeat(barWidth - filled)}</Text>
127      </Box>
128    ) : (
129      <Box key="bar" width={barWidth} flexShrink={0}>
130        <Box width={filled} flexShrink={0} backgroundColor={r.color}>
131          <Text>{NBSP.repeat(filled)}</Text>
132        </Box>
133        <Box width={barWidth - filled} flexShrink={0} backgroundColor={TRACK_COLOR}>
134          <Text>{NBSP.repeat(barWidth - filled)}</Text>
135        </Box>
136      </Box>
137    )
138
139  // Around the question dialog the engine refuses layout props such as width: spaces do the aligning there.
140  if (isPlain) {
141    return (
142      <Box flexDirection="column">
143        {visible.map(r => {
144          const { filled, percent } = progressOf(r.done, r.total, barWidth)
145          return (
146            <Box key={`row:${r.name}`} flexDirection="row">
147              <Text color={r.color}>{` ${r.glyph} `}</Text>
148              <Text>{`${fit(r.label, labelWidth)} `}</Text>
149              {barOf(r, filled)}
150              <Text>{` ${r.done}/${r.total}`}</Text>
151              <Text dimColor>{` ${`${percent}%`.padStart(PERCENT_WIDTH)}`}</Text>
152              {r.note && (
153                <Text color={r.color} bold>
154                  {` ${r.note}`}
155                </Text>
156              )}
157            </Box>
158          )
159        })}
160      </Box>
161    )
162  }
163
164  return (
165    <Box flexDirection="column" paddingX={1}>
166      {visible.map(r => {
167        const { filled, percent } = progressOf(r.done, r.total, barWidth)
168        return (
169          <Box key={`row:${r.name}`} flexDirection="row" gap={1}>
170            <Text color={r.color}>{r.glyph}</Text>
171            <Box key="label" width={labelWidth} flexShrink={0}>
172              <Text wrap="truncate">{r.label}</Text>
173            </Box>
174            {barOf(r, filled)}
175            <Text>{`${r.done}/${r.total}`}</Text>
176            <Text dimColor>{`${percent}%`.padStart(PERCENT_WIDTH)}</Text>
177            {r.note && (
178              <Text color={r.color} bold>
179                {r.note}
180              </Text>
181            )}
182            {canClick && <Button key={`dismiss:${r.name}`} label="×" plain dimColor onPress={dismiss(r.name)} />}
183          </Box>
184        )
185      })}
186    </Box>
187  )
188}
189
190// Runs a question or a plan approval with the count of waiting questions raised. A subagent's question goes to Claude, not to the
191// person, and does not count.
192async function whileAsked<T>($: Engine, tool: string, agentId: string | undefined, run: () => Promise<T>): Promise<T> {
193  if (agentId !== undefined) return run()
194  await safely($, tool, () => update($, questions, n => n + 1))
195  try {
196    return await run()
197  } finally {
198    // Never below zero, should the count have been reset while the question was open. The test kit has no state to reset, so this
199    // guard is the one line that no test reaches.
200    await safely($, tool, () => update($, questions, n => Math.max(0, n - 1)))
201  }
202}
203
204export const register: Register = (on, options) => {
205  const timing = timingOf(options)
206
207  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
208    const r = await next(e)
209    if (!counts(e, r) || !Array.isArray(e.todos)) return r
210    const todos = e.todos
211    await safely($, 'TodoWrite', () =>
212      touch($, BUILTIN, timing, () => todos.map((todo, i) => ({ id: `todo${i}`, subject: plain(todo?.content), status: statusOf(todo?.status, 'pending') }))),
213    )
214    return r
215  })
216
217  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
218    const r = await next(e)
219    if (!counts(e, r)) return r
220    // The id the tool answers with is the one TaskUpdate names; without one the item still gets an id of its own.
221    const made = (r as CreatedResult).result?.task?.id ?? e.tool_use_id
222    await safely($, 'TaskCreate', () =>
223      touch($, BUILTIN, timing, tasks => [...tasks, { id: String(made ?? `created${tasks.length}`), subject: plain(e.subject), status: 'pending' }]),
224    )
225    return r
226  })
227
228  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
229    const r = await next(e)
230    // The tool answers `success: false` (with an `error`) for an update it did not make: then nothing changed.
231    if (!counts(e, r) || (r as UpdatedResult).result?.success === false) return r
232    const id = String(e.taskId)
233    await safely($, 'TaskUpdate', () =>
234      touch($, BUILTIN, timing, tasks =>
235        e.status === 'deleted'
236          ? tasks.filter(t => t.id !== id)
237          : tasks.map(t =>
238              t.id === id ? { ...t, subject: (e.subject === undefined ? '' : plain(e.subject)) || t.subject, status: statusOf(e.status, t.status) } : t,
239            ),
240      ),
241    )
242    return r
243  })
244
245  // The to-do list claude-mem keeps for a project: fields {task, status}; a call without `task` is list state, not an item.
246  on('tool.call', { tool: WORK_STATE }, async ($, e, next) => {
247    const r = await next(e)
248    const { fields, list } = e as unknown as WorkStateInput
249    const name = typeof list === 'string' ? plain(list, LIST_MAX) : ''
250    const task = typeof fields?.task === 'string' ? plain(fields.task) : ''
251    if (!counts(e, r) || !name) return r
252    // The state of the list itself: claude-mem closes a list when it is written with the status done and no task. The line lets the
253    // list go, so that the next task of that name starts a new one and does not grow a list of everything the name ever held.
254    if (fields?.task === undefined && fields?.status === 'done') {
255      await safely($, 'work_state_write', () => touch($, name, timing, () => []))
256      return r
257    }
258    if (!task) return r
259    await safely($, 'work_state_write', () =>
260      touch($, name, timing, tasks => {
261        const old = tasks.find(t => t.id === task)
262        const status = statusOf(fields?.status, old?.status ?? 'pending')
263        const item: Task = { id: task, subject: task, status }
264        return old ? tasks.map(t => (t.id === task ? item : t)) : [...tasks, item]
265      }),
266    )
267    return r
268  })
269
270  // A check that fails turns the line red; the same check passing again turns it back. Other commands never count. A result with the
271  // error flag is a failure only when the command ran: its text then starts with `Exit code N`. A call that a hook blocked or the
272  // person refused also comes with the error flag (measured), but with another text. An interrupt or a timeout comes with
273  // `Exit code 137` or `143` and one more line, which `ranAndFailed` reads as cut off. None of them says anything about the check:
274  // it neither turns the line red nor takes the red away.
275  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
276    const r = await next(e)
277    if (e.agentId !== undefined || r.deny !== undefined) return r
278    const checks = checksOf(String(e.command ?? ''))
279    if (checks.length === 0) return r
280    if (r.isError && !ranAndFailed(r.text)) return r
281    // A command started in the background is answered at once, without an error flag and before it has run: it neither passed nor failed.
282    if (e.run_in_background === true || typeof (r as BashResult).result?.backgroundTaskId === 'string') return r
283    await safely($, 'Bash', () =>
284      r.isError
285        ? update($, failure, () => failureKey(checks))
286        : update($, failure, current => (current !== null && hasPassed(current, checks) ? null : current)),
287    )
288    return r
289  })
290
291  // Claude asks: the line turns yellow until the person has answered, however many questions are open at once.
292  on('tool.call', { tool: 'AskUserQuestion' }, ($, e, next) => whileAsked($, 'AskUserQuestion', e.agentId, () => next(e)))
293  on('tool.call', { tool: 'ExitPlanMode' }, ($, e, next) => whileAsked($, 'ExitPlanMode', e.agentId, () => next(e)))
294
295  on('session.start', async ($, e, next) => {
296    await safely($, 'session.start', () => $.command.register({ name: 'task-line', description: 'Show or hide the task line', immediate: true }))
297    return next(e)
298  })
299
300  on('command.run', { command: 'task-line' }, async $ => {
301    const wasOff = await safely($, 'task-line', async () => {
302      const was = await read($, isOff)
303      await update($, isOff, () => !was)
304      return was
305    })
306    return { text: wasOff === null ? 'Task line could not be switched.' : wasOff ? 'Task line shown.' : 'Task line hidden.' }
307  })
308
309  // Every plugin beneath keeps drawing: next(e) first, the line below its output.
310  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
311    const below = await next(e)
312    if (e.props.hasSurvey) return below
313    const line = await safely($, 'AbovePrompt', () => lineOf($, e as RenderInput, e.props.bodyColumns || DEFAULT_COLUMNS, timing))
314    if (!line) return below
315    const { Box } = $.ui.resolve(e)
316    return (
317      <Box flexDirection="column">
318        {below}
319        {line}
320      </Box>
321    )
322  })
323
324  // In the terminal a question replaces the prompt area, so the band is not drawn then: the line goes above the dialog instead.
325  on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
326    const below = await next(e)
327    // The desktop app keeps the band below its dialog (seen by hand: a line above it showed the list twice).
328    if ((e as RenderInput).surface === 'desktop') return below
329    const line = await safely($, 'AskUserQuestion', () => lineOf($, e as RenderInput, (e as RenderInput).viewport?.columns || DEFAULT_COLUMNS, timing, true))
330    if (!line) return below
331    const { Box } = $.ui.resolve(e)
332    return (
333      <Box flexDirection="column">
334        {line}
335        {below}
336      </Box>
337    )
338  })
339}
340
hooks/cells.ts 110 lines
1// Terminal cells: how wide a character is, a text cut or padded to a width, and a name made safe to draw.
2
3const ELLIPSIS = '…'
4// A name or a subject is cut at this many characters. The engine refuses a tree with more than 100000 characters of text.
5const SUBJECT_MAX = 200
6export const LIST_MAX = 60
7
8// Emoji drawn two cells wide that lie outside the wide blocks below (Unicode Emoji_Presentation, as ranges): ✅ and ⭐ are two cells.
9const WIDE_EMOJI: [number, number][] = [
10  [0x231a, 0x231b],
11  [0x23e9, 0x23ec],
12  [0x23f0, 0x23f0],
13  [0x23f3, 0x23f3],
14  [0x25fd, 0x25fe],
15  [0x2614, 0x2615],
16  [0x2648, 0x2653],
17  [0x267f, 0x267f],
18  [0x2693, 0x2693],
19  [0x26a1, 0x26a1],
20  [0x26aa, 0x26ab],
21  [0x26bd, 0x26be],
22  [0x26c4, 0x26c5],
23  [0x26ce, 0x26ce],
24  [0x26d4, 0x26d4],
25  [0x26ea, 0x26ea],
26  [0x26f2, 0x26f3],
27  [0x26f5, 0x26f5],
28  [0x26fa, 0x26fa],
29  [0x26fd, 0x26fd],
30  [0x2705, 0x2705],
31  [0x270a, 0x270b],
32  [0x2728, 0x2728],
33  [0x274c, 0x274c],
34  [0x274e, 0x274e],
35  [0x2753, 0x2755],
36  [0x2757, 0x2757],
37  [0x2795, 0x2797],
38  [0x27b0, 0x27b0],
39  [0x27bf, 0x27bf],
40  [0x2b1b, 0x2b1c],
41  [0x2b50, 0x2b50],
42  [0x2b55, 0x2b55],
43  [0x1f004, 0x1f004],
44  [0x1f0cf, 0x1f0cf],
45  [0x1f18e, 0x1f18e],
46  [0x1f191, 0x1f19a],
47  [0x1f200, 0x1f202],
48  [0x1f210, 0x1f23b],
49  [0x1f240, 0x1f248],
50  [0x1f250, 0x1f251],
51  [0x1f260, 0x1f265],
52]
53
54// Terminal cells one character takes: none for a combining mark or joiner, two for a wide one (CJK, emoji), else one.
55export function cellsOf(char: string): number {
56  const code = char.codePointAt(0) ?? 0
57  if (code < 0x20 || (code >= 0x7f && code < 0xa0)) return 0
58  if ((code >= 0x300 && code <= 0x36f) || code === 0x200d || (code >= 0xfe00 && code <= 0xfe0f)) return 0
59  const isWide =
60    (code >= 0x1100 && code <= 0x115f) ||
61    (code >= 0x2e80 && code <= 0xa4cf) ||
62    (code >= 0xac00 && code <= 0xd7a3) ||
63    (code >= 0xf900 && code <= 0xfaff) ||
64    (code >= 0xfe30 && code <= 0xfe6f) ||
65    (code >= 0xff00 && code <= 0xff60) ||
66    (code >= 0xffe0 && code <= 0xffe6) ||
67    (code >= 0x1f300 && code <= 0x1faff) ||
68    (code >= 0x20000 && code <= 0x3fffd) ||
69    WIDE_EMOJI.some(([first, last]) => code >= first && code <= last)
70  return isWide ? 2 : 1
71}
72
73export const width = (text: string) => [...text].reduce((sum, char) => sum + cellsOf(char), 0)
74
75// The text in exactly `cells` cells: cut with an ellipsis when it is longer, padded with spaces when it is shorter.
76export function fit(text: string, cells: number): string {
77  if (cells <= 0) return ''
78  if (width(text) <= cells) return text + ' '.repeat(cells - width(text))
79  let out = ''
80  let used = 0
81  for (const char of text) {
82    if (used + cellsOf(char) > cells - 1) break
83    out += char
84    used += cellsOf(char)
85  }
86  return out + ELLIPSIS + ' '.repeat(cells - used - 1)
87}
88
89// Escape sequences go first, so that nothing of them is left behind when their control character goes. Then the characters that draw
90// nothing but can break a row or hide text (bidi controls, zero width spaces, tag characters, the private-use planes that hold
91// the Kitty placeholder U+10EEEE), and every other control character becomes a space. The zero width joiner of an emoji, the
92// variation selector, a zero width non-joiner and the private use area of the BMP (icon fonts) stay.
93const ESCAPES = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-_]/g
94const INVISIBLE = /[\u001b\u061c\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff\ufff9-\ufffb\u{e0000}-\u{e007f}\u{f0000}-\u{10ffff}]/gu
95const BREAKS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g
96
97// What the person or Claude wrote as a name or a subject, made safe to draw: one line, nothing invisible, at most `max` characters.
98// The engine unmounts a tree with a control character in a text node and says so only in the debug log.
99export function plain(text: unknown, max = SUBJECT_MAX): string {
100  if (max <= 0) return ''
101  const clean = String(text ?? '')
102    .replace(ESCAPES, '')
103    .replace(INVISIBLE, '')
104    .replace(BREAKS, ' ')
105    .replace(/\s+/g, ' ')
106    .trim()
107  const chars = [...clean]
108  return chars.length > max ? chars.slice(0, max - 1).join('') + ELLIPSIS : clean
109}
110
hooks/config.ts 20 lines
1// Reads the settings of the plugin into the times the lists work with.
2
3import type { Timing } from './lists'
4
5const SETTING_MAX_SECONDS = 120
6// The default of plugin.json, for a value that is no number. The host checks the type before it loads the mod, so this is a second line
7// of defence; the tests pin it and the default of plugin.json to the same value.
8const SETTING_FALLBACK_SECONDS = 15
9
10// Not `Number(raw)`: it turns an empty text, null and an empty list into 0 ("never show"), true into 1 and "5" into 5.
11export const settingToMs = (raw: unknown): number => {
12  const seconds = typeof raw === 'number' && Number.isFinite(raw) ? Math.min(SETTING_MAX_SECONDS, Math.max(0, raw)) : SETTING_FALLBACK_SECONDS
13  return Math.round(seconds * 1000)
14}
15
16export const timingOf = (options: Record<string, unknown>): Timing => ({
17  lingerMs: settingToMs(options.lingerSeconds),
18  joinMs: settingToMs(options.joinSeconds),
19})
20
hooks/lists.ts 41 lines
1// The lists the line shows: statuses, and how a change is put into the list of a name.
2
3import type { Status, Task, TaskList } from '../types'
4
5// The built-in todo tools share one list; work_state_write has one per `list`.
6export const BUILTIN = 'Tasks'
7// A list holds this many tasks, the state this many lists: the state is no place for an endless list.
8const MAX_TASKS = 500
9const MAX_KEPT_LISTS = 20
10
11// A Map, not an object: a status such as "constructor" must not find a property of Object.
12const STATUS_OF = new Map<string, Status>([
13  ['pending', 'pending'],
14  ['todo', 'pending'],
15  ['in_progress', 'active'],
16  ['doing', 'active'],
17  ['completed', 'done'],
18  ['done', 'done'],
19  ['dropped', 'done'],
20])
21
22export const statusOf = (raw: unknown, fallback: Status): Status => STATUS_OF.get(String(raw)) ?? fallback
23
24// How long a finished list is shown, and how long after its end a new task of its name still joins it (the settings, in milliseconds).
25export type Timing = { lingerMs: number; joinMs: number }
26
27// The touched list moves to the end: the newest list is the one drawn last. A new task joins the finished list of its name only while
28// that list is shown and finished no longer ago than the join time; otherwise the list is over: it is dropped, and the task starts a new
29// list instead of joining the tasks of everything the name ever held. That holds by the time, not by `isHidden` alone, which only a
30// timer or the button sets (a reload drops timers). Another finished list is dropped when it is hidden or no longer shown. Of the
31// rest only the newest lists are kept.
32export function put(all: TaskList[], name: string, change: (tasks: Task[]) => Task[], timing: Timing, now = Number.NaN): TaskList[] {
33  const faded = (l: TaskList, ms: number) => l.doneAt !== null && (l.isHidden || now - l.doneAt >= ms)
34  // A task joins only a list that is still shown, so the join time never reaches past the time the list is shown.
35  const joinMs = Math.min(timing.joinMs, timing.lingerMs)
36  const held = all.find(l => l.name === name)
37  const tasks = change(held && !faded(held, joinMs) ? held.tasks : []).slice(0, MAX_TASKS)
38  const rest = all.filter(l => l.name !== name && !faded(l, timing.lingerMs))
39  return tasks.length === 0 ? rest : [...rest, { name, tasks, doneAt: null, isHidden: false }].slice(-MAX_KEPT_LISTS)
40}
41
hooks/layout.ts 63 lines
1// What one list shows, and how wide each part of a row is.
2
3import type { TaskList } from '../types'
4import { width } from './cells'
5import { BUILTIN } from './lists'
6import { failureLabel } from './shell'
7
8// The width assumed when the host gives none.
9export const DEFAULT_COLUMNS = 100
10export const PERCENT_WIDTH = 4
11// The bar takes a share of the width, within these limits (terminal columns).
12const BAR_SHARE = 0.4
13const BAR_MIN = 10
14const BAR_MAX = 60
15const LABEL_SHARE = 0.35
16const LABEL_MIN = 8
17const LABEL_FLOOR = 4
18const WAITING_TEXT = 'needs you'
19const UNTITLED = 'Task'
20
21const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
22
23// How much of the bar is filled and which percent is shown. Neither reaches the end before the list is finished, and neither is
24// empty once one task is done, whatever rounding says.
25export function progressOf(done: number, total: number, bar: number) {
26  if (total <= 0 || done <= 0) return { filled: 0, percent: 0 }
27  if (done >= total) return { filled: bar, percent: 100 }
28  const share = done / total
29  return { filled: clamp(Math.round(share * bar), 1, bar - 1), percent: clamp(Math.round(share * 100), 1, 99) }
30}
31
32export type Row = { name: string; glyph: string; color: string; label: string; done: number; total: number; note: string | null }
33
34// What one list shows. A finished list is done; an unfinished one waits when Claude asks, which beats a failed check.
35export function rowOf(list: TaskList, asking: boolean, failed: string | null): Row {
36  const total = list.tasks.length
37  const done = list.tasks.filter(t => t.status === 'done').length
38  const active = list.tasks.find(t => t.status === 'active')
39  const state = done === total ? 'done' : asking ? 'asking' : failed ? 'failed' : active ? 'working' : 'waiting'
40  const subject = state === 'done' ? 'Done' : (active ?? list.tasks.find(t => t.status === 'pending'))?.subject.trim() || UNTITLED
41  const color = { done: 'success', asking: 'warning', failed: 'error', working: 'claude', waiting: 'inactive' }[state]
42  const glyph = { done: '✓', asking: '?', failed: '✕', working: '●', waiting: '○' }[state]
43  const note = state === 'asking' ? WAITING_TEXT : state === 'failed' ? `${failureLabel(failed!)} failed` : null
44  return { name: list.name, glyph, color, label: `${list.name === BUILTIN ? '' : `${list.name}: `}${subject}`, done, total, note }
45}
46
47// The label slot fits the longest label, the bar takes what is left up to its share of the width. When the row would not fit, the
48// label gives way first, down to a floor, then the note is left out (the glyph and the color say it as well), and last the bar
49// stays at its minimum: a row fits at every width from the floor of label and bar, with or without its note.
50export function layout(rows: Row[], columns_: number, hasButton = false) {
51  const columns = Number.isFinite(columns_) ? columns_ : DEFAULT_COLUMNS
52  const count = Math.max(...rows.map(r => width(`${r.done}/${r.total}`)))
53  // The dismiss button and the gap before it take two more cells.
54  const fixed = 2 + 1 + count + PERCENT_WIDTH + 4 + (hasButton ? 2 : 0)
55  const noteWidth = Math.max(0, ...rows.map(r => (r.note ? width(r.note) + 1 : 0)))
56  const showNote = noteWidth === 0 || columns >= fixed + noteWidth + LABEL_FLOOR + BAR_MIN
57  const room = columns - fixed - (showNote ? noteWidth : 0)
58  const wanted = clamp(Math.max(...rows.map(r => width(r.label))), LABEL_MIN, Math.max(LABEL_MIN, Math.floor(columns * LABEL_SHARE)))
59  const label = clamp(wanted, LABEL_FLOOR, Math.max(LABEL_FLOOR, room - BAR_MIN))
60  const bar = clamp(Math.min(Math.floor(columns * BAR_SHARE), room - label), BAR_MIN, BAR_MAX)
61  return { label, bar, showNote }
62}
63
hooks/shell.ts 217 lines
1// Reading a command line as a shell does, and finding the checks (tests, builds, linters) in it.
2
3// Commands that test or build: a failing one turns the line red, other failing commands (a grep with no match) do not. The README
4// (Failing checks) lists them: keep both in step. The name
5// must end there: `cat pytest.ini`, `ls tsc/` and `make-dist` name a file or another command.
6const CHECK_PATTERN = String.raw`(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|lint|typecheck|check)|(?:npx\s+)?(?:pytest|jest|vitest|mocha|tsc|eslint|ruff|mypy|pyright|rspec|phpunit)|(?:just|task)\s+(?:test|build|check|lint)|deno\s+(?:test|check|lint)|cargo\s+(?:test|build|check|clippy)|go\s+(?:test|build|vet)|dotnet\s+(?:test|build)|claude\s+plugin\s+(?:test|validate)|python3?\s+-m\s+pytest)`
7const CHECK = new RegExp(`^${CHECK_PATTERN}(?![\\w./-])`, 'i')
8
9// Words that come before the command they start: a shell keyword, an environment assignment, a runner such as `uv run`.
10const BEFORE = new Set(['sudo', 'time', 'nice', 'nohup', 'command', 'exec', 'env', 'bunx', 'if', 'then', 'elif', 'else', 'while', 'until', 'do', '!', '{'])
11
12// The body of a here document is text, not commands: cut from the line after the opener to its terminator, and keep the rest of the
13// opener's own line (`cat <<EOF > f && npm test` still runs the test). A scan, not one regular expression: a pattern that backtracks
14// over the delimiter and then scans on for every opener costs time with the square of the length (64 KB of `<<aaa…` took 2 s). The
15// lines that can end a here document are listed once, with their places, and every delimiter walks its own list forward, so the cost
16// stays in step with the length however many openers there are. An opener without a terminator, a here-string (`<<<`) and a line
17// break missing after the opener leave the text as it is.
18function terminatorsOf(text: string): Map<string, number[]> {
19  const places = new Map<string, number[]>()
20  for (let start = 0; start <= text.length;) {
21    const next = text.indexOf('\n', start)
22    const last = next === -1 ? text.length : next
23    const name = text.slice(start, last).trim()
24    if (/^\w+$/.test(name)) {
25      const list = places.get(name)
26      if (list) list.push(start, last)
27      else places.set(name, [start, last])
28    }
29    if (next === -1) break
30    start = next + 1
31  }
32  return places
33}
34
35function withoutHeredocs(line: string): string {
36  const opener = /<<-?[ \t]*(['"]?)(\w+)\1/g
37  const walked = new Map<string, number>()
38  let places: Map<string, number[]> | null = null
39  let out = ''
40  let from = 0
41  for (let found = opener.exec(line); found !== null; found = opener.exec(line)) {
42    const end = found.index + found[0].length
43    const lineEnd = line.indexOf('\n', end)
44    // No line break after this opener means none after any later one.
45    if (lineEnd === -1) break
46    if (found.index > 0 && line[found.index - 1] === '<') continue
47    places ??= terminatorsOf(line)
48    const delimiter = found[2]!
49    const list = places.get(delimiter)
50    if (!list) continue
51    let at = walked.get(delimiter) ?? 0
52    while (at < list.length && list[at]! <= lineEnd) at += 2
53    walked.set(delimiter, at)
54    if (at >= list.length) continue
55    const stop = list[at + 1]!
56    out += line.slice(from, found.index) + line.slice(end, lineEnd)
57    from = stop
58    opener.lastIndex = stop
59  }
60  return out + line.slice(from)
61}
62
63// The words of a command line as a shell reads them: quotes group a word and hide separators, an unquoted `;`, `|`, `&`, `(`, `)`,
64// a backtick or a line break ends a command, a backslash before a line break joins the lines, and the body of a here document is no command.
65export function commandsOf(line: string): string[][] {
66  const text = withoutHeredocs(line)
67  const commands: string[][] = []
68  let words: string[] = []
69  let word = ''
70  let started = false
71  let quote = ''
72  const endWord = () => {
73    if (started) words.push(word)
74    word = ''
75    started = false
76  }
77  const endCommand = () => {
78    endWord()
79    if (words.length > 0) commands.push(words)
80    words = []
81  }
82  for (let i = 0; i < text.length; i++) {
83    const char = text[i]!
84    if (quote) {
85      if (char === quote) quote = ''
86      else if (char === '\\' && quote === '"' && i + 1 < text.length) word += text[++i]
87      else word += char
88    } else if (char === '"' || char === "'") {
89      quote = char
90      started = true
91    } else if (char === '\\' && text[i + 1] === '\n') {
92      i++
93    } else if (char === '\\' && i + 1 < text.length) {
94      word += text[++i]
95      started = true
96    } else if (char === ' ' || char === '\t') endWord()
97    else if ('\n;|&()`'.includes(char)) endCommand()
98    else {
99      word += char
100      started = true
101    }
102  }
103  endCommand()
104  return commands
105}
106
107// make, gradle and mvn name the work as targets or goals among options and variables, so they are not matched by the pattern above: the
108// check is the program and the first of its test, build or check goal (`make -C build test`, `mvn clean verify`). A word that an option
109// takes as its value (`-C build`, `-pl app`) is no goal. `make` and `gradle` with no goal at all build everything and count, `mvn` alone does not.
110const MAKE_VALUES = ['-C', '-f', '-I', '-o', '-W', '--directory', '--file', '--makefile']
111const GRADLE_VALUES = [
112  '-p',
113  '-b',
114  '-c',
115  '-I',
116  '-g',
117  '-x',
118  '--project-dir',
119  '--build-file',
120  '--settings-file',
121  '--init-script',
122  '--gradle-user-home',
123  '--exclude-task',
124]
125const MAVEN_VALUES = ['-f', '-pl', '-P', '-T', '-s', '-rf', '-gs', '-t', '-l', '-b']
126const BUILD_GOALS = ['test', 'build', 'check']
127const MAVEN_GOALS = ['test', 'package', 'verify']
128const GOAL_PROGRAMS = new Map([
129  ['make', { goals: BUILD_GOALS, values: MAKE_VALUES, bare: true }],
130  ['gradle', { goals: BUILD_GOALS, values: GRADLE_VALUES, bare: true }],
131  ['gradlew', { goals: BUILD_GOALS, values: GRADLE_VALUES, bare: true }],
132  ['mvn', { goals: MAVEN_GOALS, values: MAVEN_VALUES, bare: false }],
133  ['mvnw', { goals: MAVEN_GOALS, values: MAVEN_VALUES, bare: false }],
134])
135function goalCheck(program: string, args: string[]): string | null {
136  const spec = GOAL_PROGRAMS.get(program)!
137  const goals: string[] = []
138  for (let i = 0; i < args.length; i++) {
139    const word = args[i]!
140    if (spec.values.includes(word)) i += 1
141    else if (word === '-j' && /^\d+$/.test(args[i + 1] ?? '')) i += 1
142    else if (!word.startsWith('-') && !/^[A-Za-z_]\w*=/.test(word)) goals.push(word.toLowerCase())
143  }
144  // the name has to end there, as for the pattern above: `test:unit` is the goal `test`, `testing` is none
145  for (const goal of goals) {
146    const name = spec.goals.find(g => goal.startsWith(g) && !/[\w./-]/.test(goal[g.length] ?? ''))
147    if (name !== undefined) return `${program} ${name}`
148  }
149  return goals.length === 0 && spec.bare ? program : null
150}
151
152// Options between a package manager and its subcommand (`npm --prefix app test`, `pnpm -r test`, `yarn workspace a test`, `cargo +nightly
153// test`) are skipped, with the word an option takes as its value.
154const FRONT_VALUES = new Map([
155  ['npm', ['--prefix', '-C', '--workspace', '-w']],
156  ['pnpm', ['--filter', '-F', '--dir', '-C']],
157  ['yarn', ['--cwd']],
158  ['bun', ['--cwd']],
159])
160function withoutFrontOptions(program: string, args: string[]): string[] {
161  if (program === 'cargo') return args[0]?.startsWith('+') ? args.slice(1) : args
162  const values = FRONT_VALUES.get(program)
163  if (!values) return args
164  let i = program === 'yarn' && args[0] === 'workspace' ? 2 : 0
165  while (i < args.length && args[i]!.startsWith('-')) i += values.includes(args[i]!) ? 2 : 1
166  return args.slice(i)
167}
168
169// The check one command runs, in a form that matches the same check run again with other arguments, or null.
170function checkOfWords(all: string[]): string | null {
171  let i = 0
172  while (i < all.length) {
173    const word = all[i]!
174    const next = all[i + 1]
175    if (/^[A-Za-z_]\w*=/.test(word) || BEFORE.has(word)) i += 1
176    else if ((word === 'uv' || word === 'poetry' || word === 'pipenv') && next === 'run') i += 2
177    else if ((word === 'bundle' || word === 'pnpm') && next === 'exec') i += 2
178    else break
179  }
180  const [first, ...rest] = all.slice(i)
181  if (first === undefined) return null
182  // a path names the program by its last part: ./gradlew, node_modules/.bin/eslint
183  const program = first.slice(first.lastIndexOf('/') + 1).toLowerCase()
184  if (GOAL_PROGRAMS.has(program)) return goalCheck(program, rest)
185  const found = [program, ...withoutFrontOptions(program, rest)].join(' ').match(CHECK)
186  return found ? found[0].toLowerCase().replace(/\s+/g, ' ') : null
187}
188
189// Every check a command line runs, in order, each once: `npm run build && npm test` runs two.
190export function checksOf(line: string): string[] {
191  const checks: string[] = []
192  for (const words of commandsOf(line)) {
193    const check = checkOfWords(words)
194    if (check !== null && !checks.includes(check)) checks.push(check)
195  }
196  return checks
197}
198
199// The first check of a command line, or null.
200export const checkOf = (line: string): string | null => checksOf(line)[0] ?? null
201
202// A line that ran several checks tells only that it failed, not which of them: the exit code is one for the whole line. The failure
203// is kept as the checks joined, shown as the check when there is one, and as `check` when there are several.
204const JOIN = ' + '
205export const failureKey = (checks: string[]) => checks.join(JOIN)
206export const failureLabel = (key: string) => (key.includes(JOIN) ? 'check' : key)
207// A line that exited with 0 passed every check it ran, so it takes away a failure whose checks were all among them.
208export const hasPassed = (key: string, passed: string[]) => key.split(JOIN).every(check => passed.includes(check))
209
210// What the tool answers for a command that ran and exited with a code other than 0 (measured: `Exit code 254`, then the output).
211// A command the person interrupted or that ran into its timeout is answered the same way, with the code of the signal that ended it and
212// one more line (measured: `Exit code 137` and `[Request interrupted by user for tool use]`, `Exit code 143` and `Command timed out
213// after 1s`). That is no result of the check, so it counts as no failure.
214const RAN_AND_FAILED = /^Exit code [1-9]\d*/
215const CUT_OFF = /^Exit code \d+\n(?:\[Request interrupted|Command timed out)/
216export const ranAndFailed = (text: unknown) => typeof text === 'string' && RAN_AND_FAILED.test(text) && !CUT_OFF.test(text)
217
types/index.d.ts 13 lines
1export type Status = 'pending' | 'active' | 'done'
2
3export type Task = { id: string; subject: string; status: Status }
4
5// One list per todo source: the built-in todo tools share one list, work_state_write has one per `list`.
6export type TaskList = { name: string; tasks: Task[]; doneAt: number | null; isHidden: boolean }
7
8declare module 'claude-code' {
9  interface PluginState {
10    'task-line': { lists: TaskList[]; questions: number; failure: string | null; isOff: boolean }
11  }
12}
13