tasuku 2.3.0 → 3.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,239 +1,226 @@
1
- <p align="center">
2
- <img src=".github/tasuku.svg">
3
- <br>
4
- <i>The minimal task runner for Node.js</i>
1
+ <p align="center" demo>
2
+ <img src=".github/media/script.gif" width="500" alt="Terminal showing three build-pipeline tasks completing sequentially with title updates">
3
+
4
+ <!-- @vhs
5
+ Set Height 440
6
+ Hide
7
+ Type "node {file}"
8
+ Enter
9
+ Show
10
+ Sleep 5s
11
+ -->
12
+
13
+ <!--
14
+ ```js
15
+ import { setTimeout } from 'node:timers/promises'
16
+ import task from 'tasuku'
17
+
18
+ await task.group(task => [
19
+ task('Resolving dependencies', async ({ setTitle }) => {
20
+ await setTimeout(1000)
21
+ setTitle('Resolved 148 dependencies')
22
+ }),
23
+ task('Running tests', async ({ setTitle }) => {
24
+ await setTimeout(1500)
25
+ setTitle('42 tests passed')
26
+ }),
27
+ task('Building project', async ({ setTitle }) => {
28
+ await setTimeout(1200)
29
+ setTitle('Built in 1.2s')
30
+ }),
31
+ ])
32
+ ```
33
+ -->
34
+ </p>
35
+
36
+ <h1 align="center">タスク</h1>
37
+ <p align="center">
38
+ <i>The minimalist's task runner for Node.js</i>
5
39
  </p>
6
40
 
41
+
7
42
  ### Features
8
43
  - Task list with dynamic states
9
44
  - Parallel & nestable tasks
10
- - Unopinionated
45
+ - Customizable themes (icons, colors, spinners)
46
+ - Two renderers: [pinned](#pinned-default) (animated) and [inline](#inline) (sequential)
47
+ - Zero runtime dependencies
48
+ - Renders to stderr — stdout stays clean for program output
11
49
  - Type-safe
12
50
 
13
- [Try it out online](https://stackblitz.com/edit/tasuku-demo?file=index.js&devtoolsheight=50&view=editor)
14
-
15
- <sub>Found this package useful? Show your support & appreciation by [sponsoring](https://github.com/sponsors/privatenumber)! ❤️</sub>
51
+ > [!TIP]
52
+ > [Try it out online](https://stackblitz.com/edit/tasuku-demo?file=index.js&devtoolsheight=50&view=editor)
16
53
 
17
54
  ## Install
18
55
  ```sh
19
56
  npm i tasuku
20
57
  ```
21
58
 
22
- ## About
23
- タスク (Tasuku) is a minimal task runner for Node.js. You can use it to label any task/function so that its loading, success, and error states are rendered in the terminal.
59
+ ## Quick start
24
60
 
25
- For example, here's a simple script that copies a file from path A to B.
61
+ タスク (Tasuku) is a minimal task runner for Node.js. Call `task()` from anywhere to display loading, success, and error states in the terminal:
26
62
 
27
63
  ```ts
28
- import { copyFile } from 'node:fs/promises'
29
64
  import task from 'tasuku'
30
65
 
31
- task('Copying file from path A to B', async ({ setTitle }) => {
32
- await copyFile('/path/A', '/path/B')
33
-
34
- setTitle('Successfully copied file from path A to B!')
66
+ await task('Copying files', async () => {
67
+ await copyFiles(source, destination)
35
68
  })
36
69
  ```
37
70
 
38
- Running the script will look like this in the terminal:
39
-
40
- <img src=".github/media/basic.gif">
71
+ Tasks can be grouped, nested, run in parallel, and cleared — all with a simple functional API. Read on for the full usage guide.
41
72
 
42
73
  ## Usage
43
- ### Task list
44
- Call `task(taskTitle, taskFunction)` to start a task and display it in a task list in the terminal.
45
74
 
46
- ```ts
47
- import task from 'tasuku'
75
+ ### Nesting
48
76
 
49
- task('Task 1', async () => {
50
- await someAsyncTask()
51
- })
77
+ Tasks can be nested indefinitely. Any `task()` call inside a task function automatically becomes a child task via async context tracking.
52
78
 
53
- task('Task 2', async () => {
54
- await someAsyncTask()
55
- })
79
+ <p align="center" demo>
80
+ <img src=".github/media/nested.gif" width="600" alt="Terminal showing three levels of nested tasks">
81
+ <details>
82
+ <summary>View code</summary>
56
83
 
57
- task('Task 3', async () => {
58
- await someAsyncTask()
59
- })
60
- ```
84
+ <!-- @vhs
85
+ Set Height 440
86
+ Hide
87
+ Type "node {file}"
88
+ Enter
89
+ Show
90
+ Sleep 3s
91
+ -->
61
92
 
62
- <img src=".github/media/task-list.gif">
93
+ ```js
94
+ import { setTimeout } from 'node:timers/promises'
95
+ import task from 'tasuku'
63
96
 
64
- #### Task states
65
- - **◽️ Pending** The task is queued and has not started
66
- - **🔅 Loading** The task is running
67
- - **⚠️ Warning** The task completed with a warning
68
- - **❌ Error** The task exited with an error
69
- - **✅ Success** The task completed without error
97
+ await task('Deploy', async () => {
98
+ await setTimeout(1000)
70
99
 
71
- <img src=".github/media/task-states.png">
100
+ await task('Run migrations', async () => {
101
+ await setTimeout(1000)
72
102
 
73
- ### Unopinionated
74
- You can call `task()` from anywhere. There are no requirements. It is designed to be as unopinionated as possible not to interfere with your code.
103
+ await task('Seed database', async () => {
104
+ await setTimeout(1000)
105
+ })
106
+ })
107
+ })
108
+ ```
75
109
 
76
- The tasks will be displayed in the terminal in a consolidated list.
110
+ </details>
111
+ </p>
77
112
 
78
- You can change the title of the task by calling `setTitle()`.
113
+ Since nesting is based on async context, task functions are composable across modules:
79
114
  ```ts
115
+ // db.ts
80
116
  import task from 'tasuku'
81
117
 
82
- task('Task 1', async () => {
83
- await someAsyncTask()
118
+ export const migrate = (directory: string) => task('Running migrations', async () => {
119
+ await runMigrations(directory)
84
120
  })
85
121
 
86
- // ...
87
-
88
- someOtherCode()
89
-
90
- // ...
91
-
92
- task('Task 2', async ({ setTitle }) => {
93
- await someAsyncTask()
94
-
95
- setTitle('Task 2 complete')
122
+ export const seed = (count: number) => task('Seeding data', async () => {
123
+ await seedDatabase(count)
96
124
  })
97
125
  ```
98
-
99
- <img src=".github/media/set-title.gif">
100
-
101
- ### Task return values
102
- The return value of a task will be stored in the output `.result` property.
103
-
104
- If using TypeScript, the type of `.result` will be inferred from the task function.
105
-
106
126
  ```ts
107
- const myTask = await task('Task 2', async () => {
108
- await someAsyncTask()
127
+ // deploy.ts
128
+ import { migrate, seed } from './db.js'
129
+ import task from 'tasuku'
109
130
 
110
- return 'Success'
131
+ await task('Deploy', async () => {
132
+ await migrate('./migrations') // automatically nested under "Deploy"
133
+ await seed(1000)
111
134
  })
112
-
113
- console.log(myTask.result) // 'Success'
114
135
  ```
115
136
 
116
- ### Nesting tasks
117
- Tasks can be nested indefinitely. Nested tasks will be stacked hierarchically in the task list.
118
- ```ts
119
- await task('Do task', async ({ task }) => {
120
- await someAsyncTask()
137
+ ### Collapsing
121
138
 
122
- await task('Do another task', async ({ task }) => {
123
- await someAsyncTask()
139
+ Call `.clear()` on the task promise to collapse the nested task. `.clear()` returns the promise, so you can chain it.
124
140
 
125
- await task('And another', async () => {
126
- await someAsyncTask()
127
- })
128
- })
129
- })
130
- ```
141
+ <p align="center" demo>
142
+ <img src=".github/media/collapse.gif" width="600" alt="Terminal showing a nested task that collapses after completion">
143
+ <details>
144
+ <summary>View code</summary>
131
145
 
132
- <img src=".github/media/nested.gif">
146
+ <!-- @vhs
147
+ Set Height 300
148
+ Hide
149
+ Type "node {file}"
150
+ Enter
151
+ Show
152
+ Sleep 3s
153
+ -->
133
154
 
134
- ### Collapsing nested tasks
135
- Call `.clear()` on the returned task API to collapse the nested task.
136
- ```ts
137
- await task('Do task', async ({ task }) => {
138
- await someAsyncTask()
155
+ ```js
156
+ import { setTimeout } from 'node:timers/promises'
157
+ import task from 'tasuku'
139
158
 
140
- const nestedTask = await task('Do another task', async ({ task }) => {
141
- await someAsyncTask()
142
- })
159
+ await task('Deploy', async () => {
160
+ await setTimeout(500)
143
161
 
144
- nestedTask.clear()
162
+ // .clear() collapses the nested task on completion
163
+ await task('Run migrations', async () => {
164
+ await setTimeout(500)
165
+ }).clear()
145
166
  })
146
167
  ```
147
168
 
148
- <img src=".github/media/collapse.gif">
149
-
150
- ### Grouped tasks
151
- Tasks can be grouped with `task.group()`. Pass in a function that returns an array of tasks to run them sequentially.
169
+ </details>
170
+ </p>
152
171
 
153
- This is useful for displaying a queue of tasks that have yet to run.
172
+ ### task.group
154
173
 
155
- ```ts
156
- const groupedTasks = await task.group(task => [
157
- task('Task 1', async () => {
158
- await someAsyncTask()
174
+ Group tasks with `task.group()` to display a queue and control execution. Pass a function that returns an array of tasks. Set `concurrency` to run tasks in parallel — queued tasks show as pending until their turn.
159
175
 
160
- return 'one'
161
- }),
176
+ <p align="center" demo>
177
+ <img src=".github/media/grouped-parallel.gif" width="600" alt="Terminal showing four tasks running with concurrency 2, two at a time">
178
+ <details>
179
+ <summary>View code</summary>
162
180
 
163
- task('Waiting for Task 1', async ({ setTitle }) => {
164
- setTitle('Task 2 running...')
181
+ <!-- @vhs
182
+ Set Height 400
183
+ Hide
184
+ Type "node {file}"
185
+ Enter
186
+ Show
187
+ Sleep 5s
188
+ -->
165
189
 
166
- await someAsyncTask()
167
-
168
- setTitle('Task 2 complete')
190
+ ```js
191
+ import { setTimeout } from 'node:timers/promises'
192
+ import task from 'tasuku'
169
193
 
170
- return 'two'
194
+ await task.group(task => [
195
+ task('Lint', async () => {
196
+ await setTimeout(1000)
197
+ }),
198
+ task('Type check', async () => {
199
+ await setTimeout(1500)
200
+ }),
201
+ task('Unit tests', async () => {
202
+ await setTimeout(2000)
203
+ }),
204
+ task('Build', async () => {
205
+ await setTimeout(2500)
171
206
  })
172
-
173
- // ...
174
- ])
175
-
176
- console.log(groupedTasks) // [{ result: 'one' }, { result: 'two' }]
177
- ```
178
-
179
- <img src=".github/media/grouped.gif">
180
-
181
- ### Running tasks in parallel
182
- You can run tasks in parallel by passing in `{ concurrency: n }` as the second argument in `task.group()`.
183
-
184
- ```ts
185
- const api = await task.group(task => [
186
- task(
187
- 'Task 1',
188
- async () => await someAsyncTask()
189
- ),
190
-
191
- task(
192
- 'Task 2',
193
- async () => await someAsyncTask()
194
- )
195
-
196
- // ...
197
- ], {
198
- concurrency: 2 // Number of tasks to run at a time
199
- })
200
-
201
- api.clear() // Clear output
207
+ ], { concurrency: 2 })
202
208
  ```
203
209
 
204
- <img src=".github/media/parallel.gif">
205
-
206
- Alternatively, you can also use the native `Promise.all()` if you prefer. The advantage of using `task.group()` is that you can limit concurrency, displays queued tasks as pending, and it returns an API to easily clear the results.
207
-
208
- ```ts
209
- // No API
210
- await Promise.all([
211
- task(
212
- 'Task 1',
213
- async () => await someAsyncTask()
214
- ),
215
-
216
- task(
217
- 'Task 2',
218
- async () => await someAsyncTask()
219
- )
210
+ </details>
211
+ </p>
220
212
 
221
- // ...
222
- ])
223
- ```
213
+ Alternatively, use `Promise.all()` if you prefer. The advantage of `task.group()` is concurrency control, pending state for queued tasks, and `.clear()` on the group promise.
224
214
 
225
215
  ## API
226
216
 
227
- ### task(taskTitle, taskFunction, options?)
217
+ ### task(title, taskFunction, options?)
228
218
 
229
- Returns a Promise that resolves with object:
219
+ Returns a `TaskPromise<T>` — a Promise that resolves to `T` (the task function's return value) with additional properties:
230
220
  ```ts
231
- type TaskAPI = {
232
- // Result from taskFunction
233
- result: unknown
234
-
221
+ type TaskPromise<T> = Promise<T> & {
235
222
  // State of the task
236
- state: 'error' | 'warning' | 'success'
223
+ state: 'loading' | 'error' | 'warning' | 'success'
237
224
 
238
225
  // Warning message if state is 'warning', otherwise undefined
239
226
  warning: string | undefined
@@ -241,88 +228,195 @@ type TaskAPI = {
241
228
  // Error message if state is 'error', otherwise undefined
242
229
  error: string | undefined
243
230
 
244
- // Invoke to clear the results from the terminal
245
- clear: () => void
231
+ // Clear the task from the terminal. Returns the promise for chaining.
232
+ // If the task is still running, clears automatically on completion.
233
+ clear: () => TaskPromise<T>
246
234
  }
247
235
  ```
248
236
 
249
- #### taskTitle
250
- Type: `string`
237
+ The return value is the resolved value of the task function. If using TypeScript, the type is inferred:
238
+
239
+ ```ts
240
+ const result = await task('Fetch data', async () => {
241
+ const response = await fetch(apiUrl)
242
+ return response.json()
243
+ })
244
+
245
+ console.log(result) // typed as the return value
246
+ ```
251
247
 
252
- Required: true
248
+ ### Task inner API
253
249
 
254
- The name of the task displayed.
250
+ The task function receives an API object for controlling the task display:
255
251
 
256
- #### taskFunction
257
- Type:
258
252
  ```ts
259
- type TaskFunction = (taskInnerApi: {
260
- task: createTask
253
+ type TaskFunction = (api: {
254
+ signal: AbortSignal
261
255
  setTitle(title: string): void
262
256
  setStatus(status?: string): void
263
257
  setOutput(output: string | { message: string }): void
264
258
  setWarning(warning?: Error | string | false | null): void
265
259
  setError(error?: Error | string | false | null): void
266
- streamPreview: Writable
260
+ streamPreview: Writable & { clear(): void }
267
261
  startTime(): void
268
262
  stopTime(): number
269
263
  }) => Promise<unknown>
270
264
  ```
271
265
 
272
- Required: true
266
+ #### signal
267
+
268
+ An `AbortSignal` that the task can use to respond to cancellation. The signal is cooperative — it only cancels work if you pass it to an API that respects it (like `fetch()`, streams, or `setTimeout` from `timers/promises`). Tasks that don't use the signal will continue running normally.
269
+
270
+ The signal is aborted automatically when:
271
+ - **In `task.group()`**: a sibling task fails (when `stopOnError` is `true`, the default)
272
+ - **In nested tasks**: the parent task throws an error
273
+
274
+ The error that caused the abort is available on `signal.reason`.
275
+
276
+ Many APIs like `fetch()` accept a signal and cancel automatically. For multi-step work, use `signal.throwIfAborted()` between steps to bail out early:
277
+
278
+ ```ts
279
+ await task('Deploy', async ({ signal }) => {
280
+ const artifact = await build({ signal })
281
+
282
+ signal.throwIfAborted() // stop here if aborted during build
283
+
284
+ await upload(artifact, { signal })
285
+
286
+ signal.throwIfAborted() // stop here if aborted during upload
287
+
288
+ await notifySlack('Deployed!')
289
+ })
290
+ ```
291
+
292
+ ##### Aborting by throwing
293
+
294
+ Throwing an error from a task aborts the signal for all child and sibling tasks:
295
+
296
+ ```ts
297
+ // Nested tasks: parent throw aborts children
298
+ await task('Deploy', async () => {
299
+ task('Upload assets', async ({ signal }) => {
300
+ await upload(files, { signal })
301
+ }).catch(() => {})
302
+
303
+ throw new Error('deploy failed')
304
+ })
305
+
306
+ // Group tasks: sibling throw aborts siblings
307
+ await task.group(task => [
308
+ task('Upload A', async ({ signal }) => {
309
+ // signal.aborted becomes true when B fails, but only
310
+ // cancels work if you pass it to an API that respects it
311
+ await upload(fileA, { signal })
312
+ }),
313
+ task('Upload B', async () => {
314
+ throw new Error('network error')
315
+ })
316
+ ], { concurrency: 2 })
317
+ ```
318
+
319
+ ##### Aborting with an external signal
273
320
 
274
- The task function. The return value will be stored in the `.result` property of the `task()` output object.
321
+ Pass an `AbortController` signal via `options.signal` to cancel from outside:
275
322
 
323
+ ```ts
324
+ // Nested tasks — abort after a timeout
325
+ await task('Deploy', async () => {
326
+ await task('Long upload', async ({ signal }) => {
327
+ await upload(files, { signal })
328
+ }, { signal: AbortSignal.timeout(5000) })
329
+ })
276
330
 
277
- #### task
278
- A task function to use for nesting.
331
+ // Group tasks — abort after a timeout
332
+ await task.group(task => [
333
+ task('Upload A', async ({ signal }) => {
334
+ await upload(fileA, { signal })
335
+ }),
336
+ task('Upload B', async ({ signal }) => {
337
+ await upload(fileB, { signal })
338
+ })
339
+ ], { signal: AbortSignal.timeout(5000) })
340
+ ```
279
341
 
280
342
  #### setTitle()
281
- Call with a string to change the task title.
343
+
344
+ Change the task title.
282
345
 
283
346
  #### setStatus()
284
- Call with a string to set the status of the task.
347
+
348
+ Set dimmed metadata after the title.
285
349
 
286
350
  #### setOutput()
287
- Call with a string to set the output of the task.
288
351
 
289
- <img src=".github/media/task-output.png">
352
+ Set static output below the task.
290
353
 
291
354
  #### streamPreview
292
- A `Writable` stream for displaying live output below the task. Pipe a child process or any readable stream into it to show a scrolling preview of the output.
293
355
 
294
- Handles both `\n` (newline) and `\r` (carriage return) programs like `curl` that use `\r` for in-place progress bars work out of the box.
356
+ A `Writable` stream for displaying live output below the task. Pipe a child process or any readable stream into it to show a scrolling preview.
295
357
 
296
- ```ts
358
+ Handles both `\n` (newline) and `\r` (carriage return) — programs like `wget` that use `\r` for in-place progress bars work out of the box.
359
+
360
+ <p align="center" demo>
361
+ <img src=".github/media/stream-preview.gif" width="600" alt="Terminal showing a task with a wget progress bar streamed below it">
362
+ <details>
363
+ <summary>View code</summary>
364
+
365
+ <!-- @vhs
366
+ Set Width 1790
367
+ Set Height 300
368
+ Hide
369
+ Type "node {file}"
370
+ Enter
371
+ Show
372
+ Sleep 5s
373
+ -->
374
+
375
+ ```js
297
376
  import { spawn } from 'node:child_process'
298
377
  import { pipeline } from 'node:stream/promises'
378
+ import task from 'tasuku'
299
379
 
300
- await task('Download', async ({ streamPreview }) => {
301
- const child = spawn('curl', ['-o', '/dev/null', 'https://example.com/file'])
380
+ await task('Download TypeScript', async ({ setTitle, streamPreview }) => {
381
+ const child = spawn('wget', [
382
+ '-q',
383
+ '--show-progress',
384
+ '--progress=bar:force',
385
+ '--limit-rate=2M',
386
+ '-O',
387
+ '/dev/null',
388
+ 'https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz'
389
+ ])
302
390
  await pipeline(child.stderr, streamPreview)
391
+ setTitle('Downloaded TypeScript')
303
392
  })
304
393
  ```
305
394
 
306
- <img src=".github/media/stream-preview.gif">
395
+ </details>
396
+ </p>
307
397
 
308
398
  By default, shows the last 5 lines. Use the `previewLines` option to change this. When there are more lines than the limit, a `(+ N lines)` indicator is shown.
309
399
 
400
+ Call `streamPreview.clear()` to remove the preview output. Useful for cleaning up verbose output after a task succeeds.
401
+
310
402
  > [!NOTE]
311
403
  > `setOutput()` and `streamPreview` render independently. If both are used, static output appears above the stream preview.
312
404
 
313
405
  #### setWarning()
314
- Call with a string or Error instance to put the task in a warning state. Call with no argument (or a falsy value) to revert to loading state.
406
+
407
+ Call with a string or Error to put the task in a warning state. Call with no argument (or a falsy value) to revert to loading state.
315
408
 
316
409
  #### setError()
317
- Call with a string or Error instance to put the task in an error state. Call with no argument (or a falsy value) to revert to loading state. Tasks automatically go into an error state when it catches an error in the task.
318
410
 
319
- <img src=".github/media/set-error.png">
411
+ Call with a string or Error to put the task in an error state. Call with no argument (or a falsy value) to revert to loading state. Tasks automatically enter error state when an uncaught error is thrown.
320
412
 
321
413
  #### startTime()
414
+
322
415
  Start or restart the elapsed time counter. Calling again resets to 0. Time is displayed after the status: `⠋ Task [status] (3s)`
323
416
 
324
417
  #### stopTime()
325
- Stop the elapsed time counter and return the elapsed milliseconds. The displayed time freezes at the stopped value. Useful for profiling task phases.
418
+
419
+ Stop the elapsed time counter and return the elapsed milliseconds. The displayed time freezes at the stopped value.
326
420
 
327
421
  ```ts
328
422
  await task('Multi-phase', async ({ startTime, stopTime, setStatus }) => {
@@ -339,130 +433,509 @@ await task('Multi-phase', async ({ startTime, stopTime, setStatus }) => {
339
433
  })
340
434
  ```
341
435
 
436
+ Time format: `(Xs)` under a minute, `(Xm Ys)` under an hour, `(Xh Ym)` for longer. Not shown if elapsed < 1 second.
437
+
342
438
  #### options
343
- Type: `{ showTime?: boolean, previewLines?: number }`
344
439
 
345
- Optional task options.
440
+ ##### showTime
441
+
442
+ Type: `boolean`
443
+
444
+ Automatically start the elapsed time counter when the task begins. Equivalent to calling `startTime()` at the start of the task function.
346
445
 
347
446
  ##### previewLines
447
+
348
448
  Type: `number`
349
449
 
350
450
  Default: `5`
351
451
 
352
- Maximum number of lines to display in the `streamPreview` output (minimum 1). When the stream produces more lines, older lines scroll off and a `(+ N lines)` indicator shows the total.
353
-
354
- ##### showTime
355
- When `true`, automatically starts the elapsed time counter when the task begins. Equivalent to calling `startTime()` at the start of the task function.
452
+ Maximum lines to display in `streamPreview` output (minimum 1). When the stream produces more lines, older lines scroll off and a `(+ N lines)` indicator shows the total.
356
453
 
357
- ```ts
358
- await task('Building', async () => {
359
- await build()
360
- }, { showTime: true })
361
- // Output: ✔ Building (3s)
362
- ```
454
+ ##### signal
363
455
 
364
- <img src=".github/media/elapsed-time.gif">
456
+ Type: `AbortSignal`
365
457
 
366
- Time display:
367
- - Format: `(Xs)` for under a minute, `(Xm Ys)` for under an hour, `(Xh Ym)` for longer
368
- - Not shown if elapsed < 1 second
369
- - Freezes at final value when task completes
458
+ An external abort signal to cancel the task. The signal is exposed via the task inner API's `signal` property. When used in a group, the effective signal is a combination of both the external and group-internal signals.
370
459
 
371
-
372
- ### task.group(createTaskFunctions, options)
373
- Returns a Promise that resolves with object:
374
460
  ```ts
375
- // The results from the taskFunctions
376
- type TaskGroupAPI = {
377
- // Result from taskFunction
378
- result: unknown
379
-
380
- // State of the task
381
- state: 'error' | 'warning' | 'success'
461
+ task('Upload', async ({ signal }) => {
462
+ await fetch(url, { signal })
463
+ }, { signal: AbortSignal.timeout(5000) })
464
+ ```
382
465
 
383
- // Invoke to clear the task result
384
- clear: () => void
385
- }[] & {
466
+ ### task.group(createTasks, options?)
386
467
 
387
- // Invoke to clear ALL results
388
- clear: () => void
468
+ Returns a `TaskGroupPromise` — a Promise that resolves to an array of return values with a `.clear()` method:
469
+ ```ts
470
+ type TaskGroupPromise<Results> = Promise<Results> & {
471
+ // Clear ALL task results from the terminal. Returns the promise for chaining.
472
+ clear: () => TaskGroupPromise<Results>
389
473
  }
390
474
  ```
391
475
 
392
- #### createTaskFunctions
393
- Type: `(task) => Task[]`
476
+ #### createTasks
394
477
 
395
- Required: true
478
+ Type: `(task) => Task[]`
396
479
 
397
- A function that returns all the tasks you want to group in an array.
480
+ A function that returns all the tasks to group in an array.
398
481
 
399
482
  #### options
400
483
 
401
- Directly passed into [`p-map`](https://github.com/sindresorhus/p-map).
402
-
403
484
  ##### concurrency
404
- Type: `number` (Integer)
485
+
486
+ Type: `number`
405
487
 
406
488
  Default: `1`
407
489
 
408
490
  Number of tasks to run at a time.
409
491
 
410
492
  ##### stopOnError
493
+
411
494
  Type: `boolean`
412
495
 
413
496
  Default: `true`
414
497
 
415
- When set to `false`, instead of stopping when a task fails, it will wait for all the tasks to finish and then reject with an aggregated error containing all the errors from the rejected promises.
498
+ When `false`, instead of stopping when a task fails, waits for all tasks to finish and rejects with an aggregated error.
416
499
 
417
- ##### maxVisible
500
+ ##### signal
501
+
502
+ Type: `AbortSignal`
418
503
 
419
- <p align="center"><img src=".github/media/max-visible.gif" width="600"></p>
504
+ Abort signal to cancel pending tasks.
505
+
506
+ In addition to this external signal, `task.group()` creates an internal signal that auto-aborts all running tasks when one fails (when `stopOnError` is `true`, the default). This signal is passed to each task's inner API as `signal`, so task functions can react to sibling failures:
507
+
508
+ ```ts
509
+ await task.group(task => [
510
+ task('Upload A', async ({ signal }) => {
511
+ await upload(fileA, { signal }) // aborted when B fails
512
+ }),
513
+ task('Upload B', async () => {
514
+ throw new Error('network error') // triggers abort of A
515
+ })
516
+ ], { concurrency: 2 })
517
+ ```
518
+
519
+ ##### maxVisible
420
520
 
421
521
  Type: `number | ((terminalHeight: number) => number)`
422
522
 
423
523
  Default: Responsive to terminal height (rows - 2, minimum 5)
424
524
 
425
- Maximum number of lines to display in the task list. When there are more task lines than this limit, remaining tasks are hidden with a state breakdown (e.g., "(+ 3 loading, 5 queued, 4 completed)"). Active tasks are always prioritized over pending and completed ones. This accounts for nested subtasks which add extra lines.
525
+ Maximum number of lines to display in the task list. When there are more task lines than this limit, remaining tasks are hidden with a state breakdown (e.g., "(+ 3 loading, 5 queued, 4 completed)"). Active tasks are always prioritized over pending and completed ones.
426
526
 
427
- Can be a fixed number or a function called on each render for responsive limits.
527
+ Can be a fixed number or a function called on each render for responsive limits. By default, the limit is automatically lifted when all tasks complete and `.clear()` is called.
428
528
 
429
- By default, the limit is automatically lifted when all tasks complete and `.clear()` is called, revealing the full list.
529
+ <p align="center" demo>
530
+ <img src=".github/media/max-visible.gif" width="600" alt="Terminal showing a task group with maxVisible limiting displayed tasks">
531
+ <details>
532
+ <summary>View code</summary>
430
533
 
431
- ```ts
432
- // Fixed limit
433
- await task.group(task => [...tasks], {
434
- concurrency: 5,
534
+ <!-- @vhs
535
+ Set Height 660
536
+ Hide
537
+ Type "node {file}"
538
+ Enter
539
+ Show
540
+ Sleep 8s
541
+ -->
542
+
543
+ ```js
544
+ import { setTimeout } from 'node:timers/promises'
545
+ import task from 'tasuku'
546
+
547
+ await task.group(
548
+ task => Array.from(
549
+ { length: 10 },
550
+ (_, i) => task(
551
+ `Task ${i + 1}`,
552
+ () => setTimeout(500 + Math.random() * 1200)
553
+ )
554
+ ),
555
+ {
556
+ concurrency: 2,
557
+ maxVisible: 8
558
+ }
559
+ )
560
+ ```
561
+
562
+ </details>
563
+ </p>
564
+
565
+ ### Task anatomy
566
+
567
+ <p align="center" demo>
568
+ <img src=".github/media/task-anatomy.gif" width="600" alt="Terminal showing task API methods being called: setStatus, setOutput, and setTitle">
569
+ <details>
570
+ <summary>View code</summary>
571
+
572
+ <!-- @vhs
573
+ Set Height 340
574
+ Hide
575
+ Type "node {file}"
576
+ Enter
577
+ Show
578
+ Sleep 7s
579
+ -->
580
+
581
+ ```js
582
+ import { setTimeout } from 'node:timers/promises'
583
+ import task from 'tasuku'
584
+
585
+ await task('my title', async ({ setTitle, setStatus, setOutput }) => {
586
+ await setTimeout(1000)
587
+
588
+ setStatus('my status')
589
+ await setTimeout(1500)
590
+
591
+ setOutput('my output')
592
+ await setTimeout(1500)
593
+
594
+ setTitle('updated title')
595
+ await setTimeout(1000)
596
+ })
597
+ ```
598
+
599
+ </details>
600
+ </p>
601
+
602
+ #### Task states
603
+
604
+ | State | Icon | Description |
605
+ | :--- | :---: | :--- |
606
+ | Pending | ◼ | Queued, not yet started |
607
+ | Loading | ⠋ | Running (animated spinner) |
608
+ | Success | ✔ | Completed without error |
609
+ | Warning | ⚠ | Completed with a warning |
610
+ | Error | ✖ | Exited with an error |
611
+
612
+ <p align="center" demo>
613
+ <img src=".github/media/task-states.gif" width="600" alt="Terminal showing all five task states: success, warning, error, loading, and pending">
614
+ <details>
615
+ <summary>View code</summary>
616
+
617
+ <!-- @vhs
618
+ Set Height 480
619
+ Set TypingSpeed 0
620
+ Hide
621
+ Type "node {file}"
622
+ Enter
623
+ Sleep 800ms
624
+ Show
625
+ Sleep 3s
626
+ -->
627
+
628
+ ```js
629
+ import { setTimeout } from 'node:timers/promises'
630
+ import task from 'tasuku'
631
+
632
+ const tasks = task.group(task => [
633
+ task('Success task', async () => {
634
+ await setTimeout(100)
635
+ }),
636
+
637
+ task('Warning task', async ({ setWarning }) => {
638
+ await setTimeout(100)
639
+ setWarning('Something might be wrong')
640
+ }),
641
+
642
+ task('Error task', async ({ setError }) => {
643
+ await setTimeout(100)
644
+ setError(new Error('Something went wrong'))
645
+ }),
646
+
647
+ task('Loading task', async () => {
648
+ await setTimeout(5000)
649
+ }),
650
+
651
+ task('Pending task', async () => {
652
+ await setTimeout(100)
653
+ })
654
+ ], {
655
+ concurrency: 1,
435
656
  maxVisible: 10
436
657
  })
658
+ await tasks
659
+
660
+ tasks.clear()
661
+ ```
662
+
663
+ </details>
664
+ </p>
665
+
666
+ ## Renderers
667
+
668
+ Tasuku ships two renderers that control how task output appears in the terminal. The default export uses `pinned`, but you can switch to `inline` via `tasuku/inline`.
669
+
670
+ ### Pinned (default)
671
+
672
+ The pinned renderer keeps the task list fixed at the bottom of the terminal using cursor save/restore. Spinner animations update in-place, and `console.log` output is moved above the task area. This is the default behavior.
673
+
674
+ ### Inline
675
+
676
+ The inline renderer writes output sequentially — each task result is appended as a new line, and `console.log` output appears exactly where it was called, interleaved with task results.
677
+
678
+ Use this when:
679
+ - You want `console.log` and task output in natural order ([#16](https://github.com/privatenumber/tasuku/issues/16))
680
+ - You're logging to a file or piping output
681
+ - You want minimal terminal manipulation
682
+
683
+ <p align="center" demo>
684
+ <img src=".github/media/inline.gif" width="600" alt="Terminal showing inline renderer with console.log interleaved between tasks">
685
+ <details>
686
+ <summary>View code</summary>
687
+
688
+ <!-- @vhs
689
+ Set Height 440
690
+ Hide
691
+ Type "node {file}"
692
+ Enter
693
+ Show
694
+ Sleep 7s
695
+ -->
696
+
697
+ ```js
698
+ import { setTimeout } from 'node:timers/promises'
699
+ import task from 'tasuku/inline'
700
+
701
+ console.log('Starting build pipeline...')
702
+
703
+ await task('Resolving dependencies', async ({ setTitle }) => {
704
+ await setTimeout(1500)
705
+ setTitle('Resolved 148 dependencies')
706
+ })
707
+
708
+ console.log('Dependencies locked ✓')
709
+
710
+ await task('Running tests', async ({ setTitle }) => {
711
+ await setTimeout(2000)
712
+ setTitle('42 tests passed')
713
+ })
714
+
715
+ console.log('All checks passed — ready to deploy')
716
+ ```
717
+
718
+ </details>
719
+ </p>
720
+
721
+ On TTY, the inline renderer tracks each task line by its offset from the cursor and updates it in-place using `CSI n A` (cursor up) and `CSI n B` (cursor down). On non-TTY (piped output, CI), only the final state is written — no spinner frames or cursor sequences.
722
+
723
+ > [!NOTE]
724
+ > The inline renderer does not support `maxVisible` since tasks are written to scrollback immediately.
725
+
726
+ > [!IMPORTANT]
727
+ > `CSI n A` clamps at row 1 of the visible viewport — it cannot enter the scrollback buffer. Tasks that scroll above the visible terminal window can no longer be updated in-place. This is a terminal limitation, not a software one. If you have more concurrent tasks than terminal rows, consider using the [pinned](#pinned-default) renderer instead.
728
+
729
+ ## Themes
730
+
731
+ ### Default
732
+
733
+ The built-in theme with braille spinner and standard terminal colors.
734
+
735
+ <p align="center" demo>
736
+ <img src=".github/media/theme-default.gif" width="600" alt="Terminal showing the default theme with braille spinner">
737
+ <details>
738
+ <summary>View code</summary>
739
+
740
+ <!-- @vhs
741
+ Set Height 260
742
+ Hide
743
+ Type "node {file}"
744
+ Enter
745
+ Show
746
+ Sleep 6s
747
+ -->
748
+
749
+ ```js
750
+ import { setTimeout } from 'node:timers/promises'
751
+ import task from 'tasuku'
752
+
753
+ await task('Building project', async ({ setTitle }) => {
754
+ await setTimeout(3000)
755
+ setTitle('Build complete')
756
+ })
757
+ ```
758
+
759
+ </details>
760
+ </p>
761
+
762
+ ### Claude
763
+
764
+ Claude Code-inspired theme with truecolor palette, dingbat star spinner, and shimmer title animation. Import the theme from `tasuku/theme/claude`.
765
+
766
+ <p align="center" demo>
767
+ <img src=".github/media/theme-claude.gif" width="600" alt="Terminal showing the Claude theme with star spinner and shimmer animation">
768
+ <details>
769
+ <summary>View code</summary>
770
+
771
+ <!-- @vhs
772
+ Set Height 260
773
+ Hide
774
+ Type "node {file}"
775
+ Enter
776
+ Show
777
+ Sleep 6s
778
+ -->
779
+
780
+ ```js
781
+ import { setTimeout } from 'node:timers/promises'
782
+ import task from 'tasuku/theme/claude'
783
+
784
+ await task('Building project', async ({ setTitle }) => {
785
+ await setTimeout(5000)
786
+ setTitle('Build complete')
787
+ })
788
+ ```
789
+
790
+ </details>
791
+ </p>
792
+
793
+ ### Blink
794
+
795
+ Reduced-motion theme inspired by Claude Code's accessibility mode. The `⏺` indicator pulses between bright and dim on a 2-second cycle. Import the theme from `tasuku/theme/blink`.
796
+
797
+ <p align="center" demo>
798
+ <img src=".github/media/theme-blink.gif" width="600" alt="Terminal showing the Blink reduced-motion theme with pulsing indicator">
799
+ <details>
800
+ <summary>View code</summary>
801
+
802
+ <!-- @vhs
803
+ Set Height 260
804
+ Hide
805
+ Type "node {file}"
806
+ Enter
807
+ Show
808
+ Sleep 6s
809
+ -->
810
+
811
+ ```js
812
+ import { setTimeout } from 'node:timers/promises'
813
+ import task from 'tasuku/theme/blink'
814
+
815
+ await task('Building project', async ({ setTitle }) => {
816
+ await setTimeout(5000)
817
+ setTitle('Build complete')
818
+ })
819
+ ```
820
+
821
+ </details>
822
+ </p>
823
+
824
+ ### Codex
825
+
826
+ OpenAI Codex CLI-inspired theme with cosine-based shimmer gradient and monochrome palette. Import the theme from `tasuku/theme/codex`.
827
+
828
+ <p align="center" demo>
829
+ <img src=".github/media/theme-codex.gif" width="600" alt="Terminal showing the Codex theme with shimmer gradient">
830
+ <details>
831
+ <summary>View code</summary>
832
+
833
+ <!-- @vhs
834
+ Set Height 260
835
+ Hide
836
+ Type "node {file}"
837
+ Enter
838
+ Show
839
+ Sleep 6s
840
+ -->
841
+
842
+ ```js
843
+ import { setTimeout } from 'node:timers/promises'
844
+ import task from 'tasuku/theme/codex'
845
+
846
+ await task('Building project', async ({ setTitle }) => {
847
+ await setTimeout(5000)
848
+ setTitle('Build complete')
849
+ })
850
+ ```
851
+
852
+ </details>
853
+ </p>
854
+
855
+ ### Custom themes
856
+
857
+ Create your own theme with `createTasuku()`. Each call returns an independent task runner with its own renderer.
858
+
859
+ The `createTasuku` export from `tasuku` (or `tasuku/inline`) accepts partial overrides — renderer and theme default to the entry point's built-in values. You can also use any built-in theme as a base:
860
+
861
+ ```ts
862
+ import { rgb } from 'ansis'
863
+ import { createTasuku, theme } from 'tasuku'
864
+
865
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
866
+ const rainbow = frames.map((frame, i) => {
867
+ const hue = (i / frames.length) * 360
868
+ const [r, g, b] = hslToRgb(hue, 100, 50)
869
+ return rgb(r, g, b)(frame)
870
+ })
871
+
872
+ const task = createTasuku({
873
+ theme: {
874
+ ...theme,
875
+ spinner: rainbow
876
+ }
877
+ })
878
+
879
+ await task('Custom task', async () => {
880
+ await someAsyncTask()
881
+ })
882
+ ```
437
883
 
438
- // Responsive limit (terminal height passed as parameter)
439
- await task.group(task => [...tasks], {
440
- concurrency: 5,
441
- maxVisible: height => height - 5
884
+ For full control over both renderer and theme, use the raw factory from `tasuku/create`:
885
+
886
+ ```ts
887
+ import { createTasuku, pinned } from 'tasuku/create'
888
+ import { theme } from 'tasuku/theme/claude'
889
+
890
+ const task = createTasuku({
891
+ renderer: pinned,
892
+ theme
442
893
  })
443
894
  ```
444
895
 
445
- ## FAQ
896
+ #### Theme object
897
+
898
+ ```ts
899
+ type TasukuTheme = {
900
+ spinner: string[] // Pre-colored spinner frames
901
+ spinnerInterval?: number // ms between frames (default: 80)
902
+ icons: {
903
+ pending: string // Pre-colored icon strings
904
+ success: string
905
+ error: string
906
+ warning: string
907
+ parent: string // Parent task with children
908
+ parentError: string // Parent task in error state
909
+ }
910
+ colors: {
911
+ title?: (text: string, state: State, frame: number) => string
912
+ dim: (text: string) => string // Status, elapsed time
913
+ secondary: (text: string) => string // Output text, stream preview
914
+ }
915
+ }
916
+ ```
917
+
918
+ The `title` color function receives the task state and animation frame counter, enabling per-frame effects like shimmer animations.
919
+
920
+ #### renderer
446
921
 
447
- ### What does "Tasuku" mean?
448
- _Tasuku_ or タスク is the phonetic Japanese pronounciation of the word "task".
922
+ Type: `RendererFactory`
449
923
 
924
+ Required. The [renderer](#renderers) to use. Import `pinned` or `inline` from `tasuku/create`.
450
925
 
451
- ### Why did you make this?
926
+ #### outputStream
452
927
 
453
- I built _Tasuku_ as a lightweight task runner for scripts and CLI tools. It's designed to show task progress clearly without forcing a rigid structure on how you write your code.
928
+ Type: `NodeJS.WriteStream`
454
929
 
455
- Big thanks to [listr](https://github.com/SamVerschueren/listr) and [listr2](https://github.com/cenk1cenk2/listr2), which inspired both the visuals and the idea—I've relied on them for years. But over time, I found their declarative approach too restrictive for my workflow, so I created something simpler and more flexible.
930
+ Default: `process.stderr`
456
931
 
457
- _Tasuku_ uses its own minimal ANSI-based renderer for terminal output, giving you smooth `console.log()` integration with zero runtime dependencies. The rendering model was originally inspired by [ink](https://github.com/vadimdemedes/ink)'s approach to terminal UIs.
932
+ The stream to render task UI to. Defaults to stderr so that stdout stays clean for program output (e.g. `mytool | jq`). `console.log` output goes to stdout unaffected.
458
933
 
459
- ### Doesn't the usage of nested `task` functions violate ESLint's [no-shadow](https://eslint.org/docs/rules/no-shadow)?
460
- Yes, but it should be fine as you don't need access to other `task` functions aside from the immediate one.
934
+ ### Contributing a theme
461
935
 
462
- Put `task` in the allow list:
463
- - `"no-shadow": ["error", { "allow": ["task"] }]`
464
- - `"@typescript-eslint/no-shadow": ["error", { "allow": ["task"] }]`
936
+ Have a theme you're proud of? We'd love to see it. Open a PR to add it as a built-in theme.
465
937
 
938
+ We hold themes to a high design standard — they should be elegant, versatile, and visually cohesive across all task states. We may decline themes that don't meet this bar, so don't take it personally.
466
939
 
467
940
  ## Sponsors
468
941
  <p align="center">