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

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%
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 check failed

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

Finished (the success color is blue in this theme)

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.
| Your setup | Todo list? | What to do |
|---|---|---|
| Claude 3.x, Opus 4 to 4.7, Sonnet 4 to 4.6, Haiku 4.5 | yes, by default | nothing |
| Background and cloud sessions, any model | yes | nothing |
| Newer models (for example Sonnet 5.5) | no, left out since Claude Code 2.1.268 | set 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.
/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.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.
Three things to say to Claude, each shows one state of the line:
0/3, and the bar grows as tasks are finished.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.needs you above the dialog.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.).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.
The line is filled from tool calls Claude already makes, so it needs no extra instruction in the prompt:
| Source | Use |
|---|---|
TodoWrite | one list, replaced on each call |
TaskCreate, TaskUpdate | one 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).
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:
| Program | Counts with |
|---|---|
npm, pnpm, yarn, bun | test, build, lint, typecheck or check |
just, task | test, build, check or lint |
deno | test, check or lint |
make, gradle, gradlew | the 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/validate | any call |
cargo | test, build, check or clippy |
go | test, build or vet |
mvn, mvnw | the goal test, package or verify, also after other goals (mvn clean verify); mvn clean is no check |
dotnet | test or build |
cd app && npm test counts, echo "run cargo test" does not. How the line is read is in How a command is read.npm --prefix app test counts), and so is what comes before the program (sudo, uv run, FOO=1).grep with no match for one) never counts, and neither does a command run by a subagent.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.|| 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.# comment and reads a << inside quotes as a here document, which only changes what it shows. Examples in How a command is read./clear, /resume and /branch start with none, a reload of the mod keeps them.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.next(e) and this one hide each other's output. More in Where and how the line draws.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.success, warning, error, claude, inactive), so they follow your theme. In some themes success is not green.A test fails when a hook on another event is added.
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.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 startedTaskCreate result the id of the new taskTaskUpdate result whether it succeeded/task-line (whose arguments it does not read) and the drawing of the line, and no event of your prompt or your messages.| What | Value | Constant | Pinned by |
|---|---|---|---|
| Bar width | about 40% of the width, at least 10 and at most 60 characters, and it gives way when the row would not fit | BAR_SHARE, BAR_MIN, BAR_MAX (layout.ts) | units.test.ts, the layout tests |
| Label slot | as long as the longest label, at least 8 and at most 35% of the width, down to 4 when the row is narrow | LABEL_MIN, LABEL_SHARE, LABEL_FLOOR (layout.ts) | units.test.ts, the layout tests |
| Task subject | cut at 200 characters | SUBJECT_MAX (cells.ts) | units.test.ts, the plain test |
| List name | cut at 60 characters | LIST_MAX (cells.ts) | boundaries.test.tsx, the work_state_write name test |
| Tasks in a list | 500 | MAX_TASKS (lists.ts) | units.test.ts and boundaries.test.tsx, the put tests |
| Lists kept | the 20 newest | MAX_KEPT_LISTS (lists.ts) | units.test.ts and boundaries.test.tsx, the put tests |
| Lists drawn in the band | 2 | MAX_LISTS (register.tsx) | boundaries.test.tsx, the test for two lists |
Settings lingerSeconds and joinSeconds | 0 to 120 seconds, a value outside is set to the nearest | SETTING_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 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.
work_state_writeA 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.
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.
MIT, see LICENSE.
hooks/register.tsx 340 lines1import { 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}
340hooks/cells.ts 110 lines1// 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}
110hooks/config.ts 20 lines1// 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})
20hooks/lists.ts 41 lines1// 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}
41hooks/layout.ts 63 lines1// 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}
63hooks/shell.ts 217 lines1// 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)
217types/index.d.ts 13 lines1export 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