@kaynooo/cli-kit 0.7.3 → 0.8.0

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
@@ -43,6 +43,8 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
43
43
  - `runCommand(argv, { cwd, env, stdin })` runs a command and returns `{ code, text, output, chunks }`, the output of both streams (in arrival order, tagged by stream in `chunks`); it throws when the command cannot start. `exitCodeOf(code, signal)` is the exit code a shell reports.
44
44
  - `holdSignals(cleanup?)` takes SIGINT, SIGTERM and SIGHUP over until `release()`: a signal goes to the child given to `forward(child)`, so the CLI exits with the child's code; without one, `cleanup(signal)` runs and the signal is raised again, ending the process as it would have; without either, it is only kept in `signal`, for the caller to wind down (`base` holds them while its worktree exists).
45
45
  - `openUrl(target, env?)` opens a URL, or a file path made absolute, with the command `BROWSER` names or the platform's opener (`xdg-open`, `open`, `start`), without waiting; it returns `false` when that command cannot start.
46
+ - Every command's `parse`, and `runCli` before the command name (`ft --plain runs`), takes `--color auto|always|never` and `--plain` (`--color never`), unless the command has its own option of that name. `always` sets `FORCE_COLOR=1` and `never` sets `NO_COLOR=1`, each dropping the other, so the kit's colours, marks and redrawn lines follow, and so do the processes the command starts; `always` colours a pipe but never redraws in one. `applyOutput(color, plain, env?)` is what they run.
47
+ - `--help` lists the commands under `Commands:`, then a heading per `group` a command names (`{ summary, group: 'Admin', run }`), then the level's `Options:` and the `Examples:` the CLI or a group's `examples` give (`['ft ci', ['ft ci HEAD~1', 'the commit before']]`, a note shown dimmed after `#`); a command's own examples go to `parse(args, usage, options, { examples })`, shown under its options.
46
48
  - `jsonOption(holds?)` is the `--json` flag, worded `print JSON` or `print JSON: <holds>` alike in every CLI, and `printJson(value)` prints the value indented on stdout.
47
49
  - `stripAnsi(text)` removes colour and cursor codes, and `distance(a, b)` is the edit distance between two names.
48
50
  - `table(rows, { cellWidth, header })` also flattens each cell's line breaks and cuts it to `cellWidth`, and with `header` prints the first row bold on a terminal; and `formatDuration(ms)` formats milliseconds like `formatSeconds` but with one decimal under a minute (`0.3s`).
@@ -57,4 +59,8 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
57
59
  - Off a terminal, a prompt fails at once with exit 2, `cannot ask "<question>" off a terminal`, with `hint` under it when given (`{ hint: 'pass --yes' }`); none reads a piped line. Each takes a `terminal: { stdin, stderr }`, the process's by default, for a test to drive.
58
60
  - `@kaynooo/cli-kit/progress` draws `progressLine(stream?, env?)` on stderr, one line redrawn in place while a task runs (`⠹ prerendering ████████░░░░░░░░░░░░ 8/20 routes 4.2s`, the bar cyan on a dimmed track), fed `update({ step, done?, total?, unit? })` and ended with `stop()`, `done(text)` leaving `✓ <text> <time>` or `failed(text)` leaving `✗ <text>`. It draws only where `takesControlCodes`; elsewhere `update` and `stop` do nothing and `done` and `failed` print their plain line. Anything printed meanwhile clears it first, and an interrupt clears it before the process ends.
59
61
  - `task(label, run)` from `/progress` runs one step under a spinner, `run` given an `update({ done?, total?, unit? })` for a bar, and ends on `✓ <label> <time>`, or `✗ <label>` and the error thrown again.
62
+ - `taskList(stream?, env?)` from `/progress` stacks a progress line per task running side by side: `add(label)` returns `{ update, done(text?), failed(text?) }`, and a task ending leaves its `✓` or `✗` line above those still running.
63
+ - `liveRegion(render, stream?, env?)` from `/progress` is the block both draw with: the lines `render(spinner)` returns, redrawn every frame (`redraw()` draws at once), each cut to the terminal's width and the block to its height, `print(text)` printing above it and `stop()` clearing it. Like the progress line it draws only where `takesControlCodes`, and `ft ci` and `waitfor` show what they wait on with it.
64
+ - `badge(word, stream)` marks a status word on a terminal, the same five ways everywhere: `✓ success` green, `✗ failure` red, `● running` cyan, `○ waiting` yellow, `- skipped` dimmed; the word alone elsewhere. `statusOf(word)` reads the words Forgejo and GitLab report (`success`, `failed`, `pending`, `canceled`...) as `passed`, `failed`, `running`, `queued` or `skipped`.
65
+ - `keyValue(pairs)` aligns `label value` lines, `sparkline(values)` draws `▁▃▇`, `bar(ratio)` and `barChart(rows)` draw cyan bars on a dimmed track, and `tree(roots)` hangs `{ label, children? }` nodes on dimmed `├─`, `└─` branches.
60
66
  - `@kaynooo/cli-kit/shell` reads shell command lines the way a hook vetting them needs: `splitCommand` cuts one into pieces at the separators outside quotes and heredocs, `parseCommand` names a piece's program past `sudo`/`env` wrappers and assignments, `tokenize`, `words`, `substitutions` and `replacePieces` take it apart and put it back.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-kit",
3
3
  "type": "module",
4
- "version": "0.7.3",
4
+ "version": "0.8.0",
5
5
  "description": "What every @kaynooo CLI shares: argument parsing with --help, a command dispatcher with typo hints and shell completion, and output formatting.",
6
6
  "author": "Kaynooo <kaynooo@kevyn.fr>",
7
7
  "license": "MIT",
package/src/args.ts CHANGED
@@ -56,8 +56,16 @@ export function usageLine(usage: string, colors = paint(process.stdout)): string
56
56
  return `${colors.bold('Usage:')} ${usage.replace(USAGE_PREFIX, '').replaceAll(OPTIONAL, part => colors.dim(part))}`
57
57
  }
58
58
 
59
- export function helpText(usage: string, options: Options, colors = paint(process.stdout)): string {
60
- return [usageLine(usage, colors), '', colors.bold('Options:'), ...optionRows(options, colors)].join('\n')
59
+ export function helpText(usage: string, options: Options, colors = paint(process.stdout), examples: Example[] = []): string {
60
+ return [usageLine(usage, colors), '', colors.bold('Options:'), ...optionRows(options, colors), ...exampleRows(examples, colors)].join('\n')
61
+ }
62
+
63
+ // An `Examples:` section, after a blank line, with a line per example; nothing without one.
64
+ export function exampleRows(examples: Example[], colors = paint(process.stdout)): string[] {
65
+ if (examples.length === 0)
66
+ return []
67
+
68
+ return ['', colors.bold('Examples:'), ...examples.map(example => typeof example === 'string' ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)]
61
69
  }
62
70
 
63
71
  // A line per option, `-n, --times <value> how many`, aligned on the visible text.
@@ -85,11 +93,44 @@ export interface ParseSettings<Names extends readonly string[] = string[]> {
85
93
  allowNegative?: boolean
86
94
  // The positionals the command takes, by name as its usage writes them: `<key>` or `KEY` required, `[file]` optional, a last one ending in `...` taking any more. One missing or one too many is a usage error, and the positionals come back typed by these names.
87
95
  positionals?: Names
96
+ // Command lines shown under `Examples:` in the help, each with an optional dimmed note: `['ft ci HEAD~1', 'wait for the commit before the last']`.
97
+ examples?: Example[]
98
+ }
99
+
100
+ export type Example = string | [line: string, note: string]
101
+
102
+ // The output flags every command takes, before its own help.
103
+ export const OUTPUT_OPTIONS = {
104
+ color: { type: 'string', description: 'auto (on a terminal alone), always or never' },
105
+ plain: { type: 'boolean', description: 'plain text, no colour or spinner: --color never' },
106
+ } as const satisfies Options
107
+
108
+ // Applies --color or --plain to the environment, where the kit's colours and redrawn lines read it and so do the processes a command starts; `auto` leaves it as it is.
109
+ export function applyOutput(color: string | undefined, plain: boolean | undefined, env: Record<string, string | undefined> = process.env): void {
110
+ const when = plain === true ? 'never' : color ?? 'auto'
111
+
112
+ if (when === 'auto')
113
+ return
114
+
115
+ if (when !== 'always' && when !== 'never')
116
+ badUsage(`--color takes auto, always or never, not ${when}`)
117
+
118
+ // FORCE_COLOR wins over NO_COLOR for the colours, so each choice drops the other.
119
+ if (when === 'always') {
120
+ env.FORCE_COLOR = '1'
121
+ delete env.NO_COLOR
122
+ }
123
+ else {
124
+ env.NO_COLOR = '1'
125
+ delete env.FORCE_COLOR
126
+ }
88
127
  }
89
128
 
90
129
  // Parses a command's arguments; on --help, prints its help and throws HelpShown, which ends the command with exit 0.
91
130
  export function parse<const T extends Options, const Names extends readonly string[] = string[]>(args: string[], usage: string, options: T, settings: ParseSettings<Names> = {}) {
92
- const all = { ...options, help: { type: 'boolean', short: 'h', description: 'show this help' } } as const
131
+ // A command's own --color or --plain is its own to read.
132
+ const output = Object.fromEntries(Object.entries(OUTPUT_OPTIONS).filter(([name]) => !(name in options))) as Omit<typeof OUTPUT_OPTIONS, keyof T>
133
+ const all = { ...options, ...output, help: { type: 'boolean', short: 'h', description: 'show this help' } } as const
93
134
 
94
135
  if (completion.active)
95
136
  throw new Completing(all)
@@ -107,8 +148,12 @@ export function parse<const T extends Options, const Names extends readonly stri
107
148
  badUsage(parseError(error))
108
149
  }
109
150
 
110
- if ((parsed.values as { help?: boolean }).help === true) {
111
- console.log(helpText(usage, all))
151
+ const values = parsed.values as { help?: boolean, color?: unknown, plain?: unknown }
152
+
153
+ applyOutput('color' in output ? values.color as string | undefined : undefined, 'plain' in output ? values.plain as boolean | undefined : undefined)
154
+
155
+ if (values.help === true) {
156
+ console.log(helpText(usage, all, undefined, settings.examples))
112
157
 
113
158
  throw new HelpShown()
114
159
  }
package/src/chart.ts CHANGED
@@ -59,3 +59,30 @@ export function barChart(rows: [label: string, value: number][], { width = 24, f
59
59
 
60
60
  return rows.map(([label, value]) => `${label}${' '.repeat(labels - textWidth(label))} ${bar(largest === 0 ? 0 : value / largest, { width, stream, env })} ${' '.repeat(values - textWidth(format(value)))}${format(value)}`).join('\n')
61
61
  }
62
+
63
+ export interface TreeNode {
64
+ label: string
65
+ children?: TreeNode[]
66
+ }
67
+
68
+ // Each root on a line of its own and its children under it, hung on dimmed `├─`, `└─` and `│` branches.
69
+ export function tree(roots: TreeNode[], { stream = process.stdout, env = process.env }: KeyValueSettings = {}): string {
70
+ const colors = paint(stream, env)
71
+ const lines: string[] = []
72
+
73
+ function branch(nodes: TreeNode[], indent: string) {
74
+ for (const [index, { label, children = [] }] of nodes.entries()) {
75
+ const last = index === nodes.length - 1
76
+
77
+ lines.push(`${colors.dim(`${indent}${last ? '└─' : '├─'}`)} ${label}`)
78
+ branch(children, `${indent}${last ? ' ' : '│ '}`)
79
+ }
80
+ }
81
+
82
+ for (const { label, children = [] } of roots) {
83
+ lines.push(label)
84
+ branch(children, '')
85
+ }
86
+
87
+ return lines.join('\n')
88
+ }
package/src/cli.ts CHANGED
@@ -1,14 +1,18 @@
1
- import type { Options } from './args'
1
+ import type { Example, Options } from './args'
2
2
  import type { Colors } from './style'
3
3
  import process from 'node:process'
4
- import { Completing, completion, Exit, HelpShown, optionRows, UsageError, usageLine } from './args'
4
+ import { applyOutput, Completing, completion, exampleRows, Exit, HelpShown, optionRows, OUTPUT_OPTIONS, UsageError, usageLine } from './args'
5
5
  import { paint, takesControlCodes } from './style'
6
6
  import { distance } from './text'
7
7
 
8
- export type Command = { summary: string } & (
8
+ export type Command = {
9
+ summary: string
10
+ // The heading the command is listed under in the help, after those with none.
11
+ group?: string
12
+ } & (
9
13
  | { run: (args: string[]) => Promise<number> }
10
14
  // A group of subcommands dispatched the same way, with its own help.
11
- | { commands: Record<string, Command>, aliases?: Record<string, string> }
15
+ | { commands: Record<string, Command>, aliases?: Record<string, string>, examples?: Example[] }
12
16
  )
13
17
 
14
18
  export interface Cli {
@@ -23,6 +27,50 @@ export interface Cli {
23
27
  defaultCommand?: string
24
28
  // Rewrites an error's message and cause before they print, for a CLI whose errors may quote what a log must not keep.
25
29
  redact?: (text: string) => string
30
+ // Command lines shown under `Examples:` in `<cli> --help`; a command's own go to `parse`.
31
+ examples?: Example[]
32
+ }
33
+
34
+ const LEVEL_OPTIONS = {
35
+ ...OUTPUT_OPTIONS,
36
+ help: { type: 'boolean', short: 'h', description: 'show this help' },
37
+ } as const satisfies Options
38
+ const TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: 'boolean', short: 'v', description: 'print the version' } } as const satisfies Options
39
+
40
+ // The rows under `Commands:`, then a heading per group in the order first met, names aligned across them all.
41
+ function commandRows(commands: Record<string, Command>, colors: Colors): string[] {
42
+ const width = Math.max(...Object.keys(commands).map(name => name.length))
43
+ const groups = new Map<string, string[]>([['Commands:', []]])
44
+
45
+ for (const [name, { summary, group }] of Object.entries(commands)) {
46
+ const heading = group === undefined ? 'Commands:' : `${group}:`
47
+ const rows = groups.get(heading) ?? []
48
+
49
+ rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`)
50
+ groups.set(heading, rows)
51
+ }
52
+
53
+ return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [''], colors.bold(heading), ...rows])
54
+ }
55
+
56
+ // The output flags typed before the command name, applied and dropped: `ft --plain runs`.
57
+ function takeOutputFlags(argv: string[]): string[] {
58
+ let index = 0
59
+
60
+ for (; index < argv.length; index++) {
61
+ const word = argv[index]!
62
+
63
+ if (word === '--plain')
64
+ applyOutput(undefined, true)
65
+ else if (word.startsWith('--color='))
66
+ applyOutput(word.slice('--color='.length), undefined)
67
+ else if (word === '--color' && argv[index + 1] !== undefined)
68
+ applyOutput(argv[++index], undefined)
69
+ else
70
+ break
71
+ }
72
+
73
+ return argv.slice(index)
26
74
  }
27
75
 
28
76
  // A command the name starts, else one within two edits (one for a name under four letters).
@@ -44,24 +92,29 @@ interface Level {
44
92
  commands: Record<string, Command>
45
93
  aliases: Record<string, string> | undefined
46
94
  defaultCommand: string | undefined
95
+ examples: Example[] | undefined
47
96
  }
48
97
 
49
98
  // The level's help; a default command's options follow the commands, as they apply with no command named.
50
- async function help({ path, header, commands, defaultCommand }: Level, stream: { isTTY?: boolean }): Promise<string> {
99
+ async function help({ path, header, commands, defaultCommand, examples }: Level, stream: { isTTY?: boolean }): Promise<string> {
51
100
  const colors = paint(stream)
52
- const width = Math.max(...Object.keys(commands).map(name => name.length))
53
101
  const prefix = path.join(' ')
54
102
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
55
103
  const options = fallback !== undefined && 'run' in fallback ? await optionsOf(fallback.run) : undefined
104
+ // The default command's own run lists the output flags again, so they show once, under the level's.
105
+ const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS))
56
106
 
57
107
  return [
58
108
  header(colors),
59
109
  '',
60
110
  usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan('<command>') : `[${colors.cyan('<command>')}]`} [options]`, colors),
61
111
  '',
62
- colors.bold('Commands:'),
63
- ...Object.entries(commands).map(([name, { summary }]) => ` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`),
64
- ...options === undefined || Object.keys(options).length === 0 ? [] : ['', `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim('the command run when none is named')}`, ...optionRows(options, colors)],
112
+ ...commandRows(commands, colors),
113
+ ...own.length === 0 ? [] : ['', `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim('the command run when none is named')}`, ...optionRows(Object.fromEntries(own), colors)],
114
+ '',
115
+ colors.bold('Options:'),
116
+ ...optionRows(path.length === 1 ? TOP_OPTIONS : LEVEL_OPTIONS, colors),
117
+ ...exampleRows(examples ?? [], colors),
65
118
  '',
66
119
  `${colors.dim('Run')} ${prefix} ${colors.cyan('<command>')} --help ${colors.dim('for its options.')}`,
67
120
  ].join('\n')
@@ -92,9 +145,18 @@ function reportThrown(prefix: string, error: unknown, redact: (text: string) =>
92
145
  return error instanceof Exit ? error.code : 1
93
146
  }
94
147
 
95
- async function dispatch(level: Level, argv: string[], { version, redact }: { version: string, redact: Cli['redact'] | undefined }): Promise<number> {
96
- const [name, ...args] = argv
148
+ async function dispatch(level: Level, typed: string[], { version, redact }: { version: string, redact: Cli['redact'] | undefined }): Promise<number> {
97
149
  const prefix = level.path.join(' ')
150
+ let argv: string[]
151
+
152
+ try {
153
+ argv = takeOutputFlags(typed)
154
+ }
155
+ catch (error) {
156
+ return reportThrown(prefix, error)
157
+ }
158
+
159
+ const [name, ...args] = argv
98
160
 
99
161
  if (name === undefined && level.path.length > 1) {
100
162
  console.error(await help(level, process.stderr))
@@ -133,7 +195,7 @@ async function dispatch(level: Level, argv: string[], { version, redact }: { ver
133
195
  if ('commands' in command) {
134
196
  const { summary } = command
135
197
 
136
- return dispatch({ path: [...level.path, name], header: colors => `${colors.bold(`${prefix} ${name}`)} ${summary}`, commands: command.commands, aliases: command.aliases, defaultCommand: undefined }, rest, { version, redact })
198
+ return dispatch({ path: [...level.path, name], header: colors => `${colors.bold(`${prefix} ${name}`)} ${summary}`, commands: command.commands, aliases: command.aliases, defaultCommand: undefined, examples: command.examples }, rest, { version, redact })
137
199
  }
138
200
 
139
201
  try {
@@ -204,7 +266,7 @@ export async function runCli(cli: Cli, argv = process.argv.slice(2)): Promise<nu
204
266
  if (argv[0] === COMPLETE)
205
267
  return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)))
206
268
 
207
- return dispatch({ path: [cli.name], header: colors => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${cli.about}`, commands: cli.commands, aliases: cli.aliases, defaultCommand: cli.defaultCommand }, argv, { version: cli.version, redact: cli.redact })
269
+ return dispatch({ path: [cli.name], header: colors => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${cli.about}`, commands: cli.commands, aliases: cli.aliases, defaultCommand: cli.defaultCommand, examples: cli.examples }, argv, { version: cli.version, redact: cli.redact })
208
270
  }
209
271
 
210
272
  export interface Main {
package/src/progress.ts CHANGED
@@ -14,9 +14,31 @@ export interface Progress {
14
14
  interface ProgressStream {
15
15
  isTTY?: boolean
16
16
  columns?: number | undefined
17
+ rows?: number | undefined
17
18
  write: (text: string) => unknown
18
19
  }
19
20
 
21
+ export interface LiveRegion {
22
+ // Draws the lines again now, rather than at the next frame.
23
+ redraw: () => void
24
+ // Prints `text` above the region, where it stays once the region goes.
25
+ print: (text: string) => void
26
+ // Clears the region and leaves nothing behind.
27
+ stop: () => void
28
+ }
29
+
30
+ export interface TaskHandle {
31
+ update: (progress: Omit<Progress, 'step'>) => void
32
+ // Leaves `✓ <text> <time since the task began>` above the tasks still running, the label when no text is given.
33
+ done: (text?: string) => void
34
+ // Leaves `✗ <text>`, the label when no text is given.
35
+ failed: (text?: string) => void
36
+ }
37
+
38
+ export interface TaskList {
39
+ add: (label: string) => TaskHandle
40
+ }
41
+
20
42
  export interface ProgressLine {
21
43
  update: (progress: Progress) => void
22
44
  // Clears the line and leaves nothing behind.
@@ -34,54 +56,54 @@ const FRAME_MS = 80
34
56
  const BAR_WIDTH = 20
35
57
  // Back to the start of the line, then erases it.
36
58
  const CLEAR_LINE = '\r\x1B[2K'
59
+ // Up a line, then erases it.
60
+ const LINE_UP = '\x1B[1A\x1B[2K'
61
+ const ESC = String.fromCharCode(27)
62
+ // A colour or cursor code, or an OSC 8 link's opening or closing, read where `lastIndex` points.
63
+ const ESCAPE_CODE = new RegExp(`${ESC}(?:\\[[\\x30-\\x3F]*[\\x20-\\x2F]*[\\x40-\\x7E]|\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\))`, 'y')
64
+ const RESET_STYLE = `${ESC}[0m`
65
+ const CLOSE_LINK = `${ESC}]8;;${ESC}\\`
37
66
  // What the console prints with, to the terminal the line is drawn on or the other.
38
67
  const CONSOLE_METHODS = ['log', 'info', 'warn', 'error', 'debug', 'dir', 'dirxml', 'table', 'trace', 'group', 'groupCollapsed', 'count', 'timeEnd', 'timeLog', 'assert'] as const
39
68
 
40
69
  // A piece of the line and how it is tinted, so the line can be cut to the terminal's width on its visible text.
41
70
  interface Part { text: string, tint: (colors: Colors) => (text: string) => string }
42
71
 
43
- // One line on `stream`, redrawn in place while a task runs: a spinner, the step, a bar where its total is known, and the time since the step began.
44
- // Drawn only on a terminal that takes control codes, so a log, CI or `NO_COLOR` gets `done` and `failed` lines alone; whatever else is printed meanwhile clears the line first, and the next frame draws it again under it.
45
- export function progressLine(stream: ProgressStream = process.stderr, env: Env = process.env): ProgressLine {
46
- if (!takesControlCodes(stream, env)) {
47
- const begun = performance.now()
48
-
49
- return {
50
- update: () => undefined,
51
- stop: () => undefined,
52
- done: text => stream.write(`${marked('ok', text, stream, formatDuration(performance.now() - begun), env)}\n`),
53
- failed: text => stream.write(`${marked('problem', text, stream, undefined, env)}\n`),
54
- }
55
- }
72
+ // Lines on `stream` redrawn in place every frame, `render` handed the spinner's next glyph, each line cut to the terminal's width and the block to its height.
73
+ // Drawn only on a terminal that takes control codes, so elsewhere `render` never runs and the caller prints its own outcome; whatever else is printed meanwhile clears the block first, the next frame drawing it again under it, and an interrupt clears it before the process ends.
74
+ export function liveRegion(render: (spinner: string) => string[], stream: ProgressStream = process.stderr, env: Env = process.env): LiveRegion {
75
+ if (!takesControlCodes(stream, env))
76
+ return { redraw: () => undefined, print: text => void stream.write(`${text}\n`), stop: () => undefined }
56
77
 
57
- const colors = paint(stream, env)
58
- // Its own write, taken before the line clears itself ahead of every other.
78
+ // Its own write, taken before the region clears itself ahead of every other.
59
79
  const write = stream.write.bind(stream)
60
- const begun = performance.now()
61
- let started = begun
62
- let current: Progress | undefined
63
80
  let frame = 0
64
- let drawn = false
81
+ let drawn = 0
65
82
  const clear = () => {
66
- if (drawn)
67
- write(CLEAR_LINE)
83
+ if (drawn > 0)
84
+ write(`${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}`)
68
85
 
69
- drawn = false
86
+ drawn = 0
70
87
  }
71
88
  const draw = () => {
72
- if (current === undefined)
73
- return
74
-
75
- // A terminal that does not say its width, as a pseudo-terminal may, answers 0.
89
+ // A terminal that does not say its size, as a pseudo-terminal may, answers 0.
76
90
  const width = (stream.columns === undefined || stream.columns === 0 ? 80 : stream.columns) - 1
91
+ const height = stream.rows === undefined || stream.rows === 0 ? 24 : stream.rows
92
+ const lines = render(FRAMES[frame++ % FRAMES.length]!).slice(0, Math.max(1, height - 1)).map(line => cutToWidth(line, width))
93
+
94
+ if (lines.length === 0) {
95
+ clear()
96
+
97
+ return
98
+ }
77
99
 
78
- write(`${CLEAR_LINE}${fit(progressParts(current, FRAMES[frame++ % FRAMES.length]!, performance.now() - started), width, colors)}`)
79
- drawn = true
100
+ write(`${drawn > 0 ? `${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}` : CLEAR_LINE}${lines.join('\n')}`)
101
+ drawn = lines.length
80
102
  }
81
103
  const timer = setInterval(draw, FRAME_MS)
82
104
  const restore = interceptOutput(clear)
83
105
  const onExit = () => clear()
84
- // Interrupted, the line goes before the shell's prompt comes back.
106
+ // Interrupted, the block goes before the shell's prompt comes back.
85
107
  const signals = holdSignals(stop)
86
108
 
87
109
  function stop() {
@@ -95,6 +117,32 @@ export function progressLine(stream: ProgressStream = process.stderr, env: Env =
95
117
  timer.unref()
96
118
  process.on('exit', onExit)
97
119
 
120
+ return {
121
+ redraw: draw,
122
+ print: (text) => {
123
+ clear()
124
+ write(`${text}\n`)
125
+ draw()
126
+ },
127
+ stop,
128
+ }
129
+ }
130
+
131
+ // One line on `stream`, redrawn in place while a task runs: a spinner, the step, a bar where its total is known, and the time since the step began.
132
+ // Off a terminal taking control codes, a log, CI or `NO_COLOR` gets the `done` and `failed` lines alone.
133
+ export function progressLine(stream: ProgressStream = process.stderr, env: Env = process.env): ProgressLine {
134
+ const colors = paint(stream, env)
135
+ // Its own write, taken before the region clears itself ahead of every other.
136
+ const write = stream.write.bind(stream)
137
+ const begun = performance.now()
138
+ let started = begun
139
+ let current: Progress | undefined
140
+ const region = liveRegion(spinner => current === undefined ? [] : [tinted(progressParts(current, spinner, performance.now() - started), colors)], stream, env)
141
+ const end = (line: string) => {
142
+ region.stop()
143
+ write(`${line}\n`)
144
+ }
145
+
98
146
  return {
99
147
  // A new step is drawn at once, its clock started again; a count moving on waits for the next frame, so a burst of updates writes one.
100
148
  update: (progress) => {
@@ -104,17 +152,48 @@ export function progressLine(stream: ProgressStream = process.stderr, env: Env =
104
152
 
105
153
  if (progress.step !== step) {
106
154
  started = performance.now()
107
- draw()
155
+ region.redraw()
108
156
  }
109
157
  },
110
- stop,
111
- done: (text) => {
112
- stop()
113
- write(`${marked('ok', text, stream, formatDuration(performance.now() - begun), env)}\n`)
114
- },
115
- failed: (text) => {
116
- stop()
117
- write(`${marked('problem', text, stream, undefined, env)}\n`)
158
+ stop: region.stop,
159
+ done: text => end(marked('ok', text, stream, formatDuration(performance.now() - begun), env)),
160
+ failed: text => end(marked('problem', text, stream, undefined, env)),
161
+ }
162
+ }
163
+
164
+ // Stacked lines for tasks running side by side, each drawn as a progress line is; a task ending leaves its ✓ or ✗ line above those still running, and the block goes once none is.
165
+ export function taskList(stream: ProgressStream = process.stderr, env: Env = process.env): TaskList {
166
+ const colors = paint(stream, env)
167
+ const running = new Set<{ progress: Progress, started: number }>()
168
+ let region: LiveRegion | undefined
169
+ const render = (spinner: string) => [...running].map(({ progress, started }) => tinted(progressParts(progress, spinner, performance.now() - started), colors))
170
+
171
+ return {
172
+ add: (label) => {
173
+ const entry = { progress: { step: label } as Progress, started: performance.now() }
174
+ const end = (line: string) => {
175
+ if (!running.delete(entry))
176
+ return
177
+
178
+ region?.print(line)
179
+
180
+ if (running.size === 0) {
181
+ region?.stop()
182
+ region = undefined
183
+ }
184
+ }
185
+
186
+ running.add(entry)
187
+ region ??= liveRegion(render, stream, env)
188
+ region.redraw()
189
+
190
+ return {
191
+ update: (progress) => {
192
+ entry.progress = { ...progress, step: label }
193
+ },
194
+ done: text => end(marked('ok', text ?? label, stream, formatDuration(performance.now() - entry.started), env)),
195
+ failed: text => end(marked('problem', text ?? label, stream, undefined, env)),
196
+ }
118
197
  },
119
198
  }
120
199
  }
@@ -168,24 +247,48 @@ function plain(text: string): string {
168
247
  return text
169
248
  }
170
249
 
171
- // The parts cut to `width` visible characters, each tinted after the cut so no escape is split.
172
- function fit(parts: Part[], width: number, colors: Colors): string {
250
+ function tinted(parts: Part[], colors: Colors): string {
251
+ return parts.map(({ text, tint }) => tint(colors)(text)).join('')
252
+ }
253
+
254
+ // The line cut to `width` columns of visible text, its escapes kept whole, and the styles and a link ended after a cut so none runs into the next line.
255
+ function cutToWidth(line: string, width: number): string {
256
+ if (Bun.stringWidth(line) <= width)
257
+ return line
258
+
173
259
  let room = width
174
- let line = ''
260
+ let kept = ''
261
+ let index = 0
262
+ let linked = false
263
+
264
+ while (index < line.length) {
265
+ ESCAPE_CODE.lastIndex = index
266
+
267
+ const escape = ESCAPE_CODE.exec(line)
268
+
269
+ if (escape !== null) {
270
+ kept += escape[0]
271
+ linked ||= escape[0].startsWith(`${ESC}]`)
272
+ index = ESCAPE_CODE.lastIndex
273
+
274
+ continue
275
+ }
175
276
 
176
- for (const { text, tint } of parts) {
177
- const kept = [...text].slice(0, Math.max(room, 0)).join('')
277
+ const char = String.fromCodePoint(line.codePointAt(index)!)
278
+ const columns = Bun.stringWidth(char)
178
279
 
179
- room -= kept.length
280
+ if (columns > room)
281
+ break
180
282
 
181
- if (kept !== '')
182
- line += tint(colors)(kept)
283
+ kept += char
284
+ room -= columns
285
+ index += char.length
183
286
  }
184
287
 
185
- return line
288
+ return `${kept}${RESET_STYLE}${linked ? CLOSE_LINK : ''}`
186
289
  }
187
290
 
188
- // Clears the line before anything the console or a stream writes, the caller's own code meanwhile included, and answers how to put them back.
291
+ // Clears the region before anything the console or a stream writes, the caller's own code meanwhile included, and answers how to put them back.
189
292
  function interceptOutput(clear: () => void): () => void {
190
293
  const targets = [...CONSOLE_METHODS.map(name => [console, name] as const), [process.stdout, 'write'] as const, [process.stderr, 'write'] as const]
191
294
  const wrapped = targets.map(([target, name]) => {
package/src/style.ts CHANGED
@@ -64,6 +64,36 @@ export function role(name: Role, stream: { isTTY?: boolean }, env: Record<string
64
64
  return ROLES[name](paint(stream, env))
65
65
  }
66
66
 
67
+ export type Status = 'passed' | 'failed' | 'running' | 'queued' | 'skipped'
68
+
69
+ // What each status draws, and the words CI services report it by.
70
+ const STATUSES: Record<Status, { symbol: string, words: string[], paint: (colors: Colors) => (text: string) => string }> = {
71
+ passed: { symbol: '✓', words: ['passed', 'success', 'succeeded', 'ok'], paint: colors => colors.green },
72
+ failed: { symbol: '✗', words: ['failed', 'failure', 'error', 'errored'], paint: colors => colors.red },
73
+ running: { symbol: '●', words: ['running', 'in_progress', 'started'], paint: colors => colors.cyan },
74
+ queued: { symbol: '○', words: ['queued', 'waiting', 'pending', 'created', 'blocked', 'scheduled'], paint: colors => colors.yellow },
75
+ skipped: { symbol: '-', words: ['skipped', 'cancelled', 'canceled', 'manual'], paint: colors => colors.dim },
76
+ }
77
+
78
+ const STATUS_OF = new Map(Object.entries(STATUSES).flatMap(([status, { words }]) => words.map(word => [word, status as Status])))
79
+
80
+ // The status a word such as `success` or `in_progress` stands for, undefined for one it does not know.
81
+ export function statusOf(word: string): Status | undefined {
82
+ return STATUS_OF.get(word.toLowerCase())
83
+ }
84
+
85
+ // `✓ success` in green, `✗ failure` in red, `● running`, `○ waiting` or `- skipped` on a terminal; the word alone elsewhere, and a word of no known status as given.
86
+ export function badge(word: string, stream: { isTTY?: boolean }, env: Record<string, string | undefined> = process.env): string {
87
+ const status = statusOf(word)
88
+
89
+ if (status === undefined || !takesControlCodes(stream, env))
90
+ return word
91
+
92
+ const { symbol, paint: tint } = STATUSES[status]
93
+
94
+ return tint(paint(stream, env))(`${symbol} ${word}`)
95
+ }
96
+
67
97
  // A clickable OSC 8 link on a terminal; elsewhere `text (url)`, or the text alone when it is the url.
68
98
  export function link(text: string, url: string, stream: { isTTY?: boolean }, env: Record<string, string | undefined> = process.env): string {
69
99
  if (takesControlCodes(stream, env))