Skip to content

You can create a custom renderer that is fit for your application.

Implement or Extend the Default Listr Renderer

ts
import type { ListrRenderer, ListrTaskObject } from 'listr2'
import { Listr } from 'listr2'

type MyAmazingRendererOptions = (typeof MyAmazingRenderer)['rendererOptions']

type MyAmazingRendererTasks = ListrTaskObject<any, typeof MyAmazingRenderer>[]

class MyAmazingRenderer implements ListrRenderer {
  // Designate this renderer as tty or nonTTY
  public static nonTTY = true
  // designate your renderer options that will be showed inside the `ListrOptions` as rendererOptions
  public static rendererOptions: never
  // designate your custom internal task-based options that will show as `options` in the task itself
  public static rendererTaskOptions: never

  // get tasks to be rendered and options of the renderer from the parent
  constructor (
    public tasks: MyAmazingRendererTasks,
    public options: MyAmazingRendererOptions
  ) {}

  // implement custom logic for render functionality
  public render (): void {}

  // implement custom logic for end functionality
  public end (err: Error): void {}
}

INFO

For Javascript, since you cannot implement ListrRenderer, you can extend either the SilentRenderer or ListrBaseRenderer.

Utilizing the Task

Take a look at DefaultRenderer since it is implemented this way.

Code Example
ts
import type truncate from 'cli-truncate'
import type { createLogUpdate } from 'log-update'
import { EOL } from 'os'
import type wrap from 'wrap-ansi'

import { LISTR_DEFAULT_RENDERER_STYLE, ListrDefaultRendererLogLevels } from './renderer.constants'
import type {
  ListrDefaultRendererCache,
  ListrDefaultRendererOptions,
  ListrDefaultRendererOutputBuffer,
  ListrDefaultRendererTask,
  ListrDefaultRendererTaskOptions
} from './renderer.interface'
import { ListrEventType, ListrTaskEventType, ListrTaskState } from '@constants'
import type { ListrRenderer, ListrTaskEventMap } from '@interfaces'
import { ListrRendererError } from '@interfaces'
import type { ListrEventManager } from '@lib'
import { PRESET_TIMER } from '@presets'
import { ListrLogLevels, ListrLogger, ProcessOutputBuffer, Spinner, assertFunctionOrSelf, cleanseAnsi, color, indent } from '@utils'

export class DefaultRenderer implements ListrRenderer {
  public static nonTTY = false
  public static rendererOptions: ListrDefaultRendererOptions = {
    indentation: 2,
    clearOutput: false,
    showSubtasks: true,
    collapseSubtasks: true,
    collapseSkips: true,
    showSkipMessage: true,
    suffixSkips: false,
    collapseErrors: true,
    showErrorMessage: true,
    suffixRetries: true,
    lazy: false,
    removeEmptyLines: true,
    formatOutput: 'wrap',
    pausedTimer: {
      ...PRESET_TIMER,
      format: () => color.yellowBright
    }
  }
  public static rendererTaskOptions: ListrDefaultRendererTaskOptions = {
    outputBar: true
  }

  private prompt: string
  private activePrompt: string
  private readonly spinner: Spinner
  private readonly logger: ListrLogger<ListrDefaultRendererLogLevels>
  private updater: ReturnType<typeof createLogUpdate>
  private truncate: typeof truncate
  private wrap: typeof wrap
  private readonly buffer: ListrDefaultRendererOutputBuffer = {
    output: new Map(),
    bottom: new Map()
  }
  private readonly cache: ListrDefaultRendererCache = {
    render: new Map(),
    rendererOptions: new Map(),
    rendererTaskOptions: new Map()
  }

  constructor (
    private readonly tasks: ListrDefaultRendererTask[],
    private readonly options: ListrDefaultRendererOptions,
    private readonly events: ListrEventManager
  ) {
    this.options = {
      ...DefaultRenderer.rendererOptions,
      ...this.options,
      icon: {
        ...LISTR_DEFAULT_RENDERER_STYLE.icon,
        ...options?.icon ?? {}
      },
      color: {
        ...LISTR_DEFAULT_RENDERER_STYLE.color,
        ...options?.color ?? {}
      }
    }

    this.spinner = this.options.spinner ?? new Spinner()

    this.logger = this.options.logger ?? new ListrLogger<ListrDefaultRendererLogLevels>({ useIcons: true, toStderr: [] })
    this.logger.options.icon = this.options.icon
    this.logger.options.color = this.options.color
  }

  public async render (): Promise<void> {
    const { createLogUpdate } = await import('log-update')
    const { default: truncate } = await import('cli-truncate')
    const { default: wrap } = await import('wrap-ansi')

    this.updater = createLogUpdate(this.logger.process.stdout)
    this.truncate = truncate
    this.wrap = wrap

    this.logger.process.hijack()

    /* istanbul ignore if */
    if (!this.options?.lazy) {
      this.spinner.start(() => {
        this.update()
      })
    }

    this.events.on(ListrEventType.SHOULD_REFRESH_RENDER, () => {
      this.update()
    })
  }

  public update (): void {
    this.updater(this.create())
  }

  public end (): void {
    this.spinner.stop()

    // clear log updater
    this.updater.clear()
    this.updater.done()

    // directly write to process.stdout, since logupdate only can update the seen height of terminal
    if (!this.options.clearOutput) {
      this.logger.process.toStdout(this.create({ prompt: false }))
    }

    this.logger.process.release()
  }

  public create (options?: { tasks?: boolean, bottomBar?: boolean, prompt?: boolean }): string {
    options = {
      tasks: true,
      bottomBar: true,
      prompt: true,
      ...options
    }

    const render: string[] = []

    const renderTasks = this.renderer(this.tasks)
    const renderBottomBar = this.renderBottomBar()
    const renderPrompt = this.renderPrompt()

    if (options.tasks && renderTasks.length > 0) {
      render.push(...renderTasks)
    }

    if (options.bottomBar && renderBottomBar.length > 0) {
      if (render.length > 0) {
        render.push('')
      }

      render.push(...renderBottomBar)
    }

    if (options.prompt && renderPrompt.length > 0) {
      if (render.length > 0) {
        render.push('')
      }

      render.push(...renderPrompt)
    }

    return render.join(EOL)
  }

  // eslint-disable-next-line complexity
  protected style (task: ListrDefaultRendererTask, output = false): string {
    const rendererOptions = this.cache.rendererOptions.get(task.id)

    if (task.isSkipped()) {
      if (output || rendererOptions.collapseSkips) {
        return this.logger.icon(ListrDefaultRendererLogLevels.SKIPPED_WITH_COLLAPSE)
      } else if (rendererOptions.collapseSkips === false) {
        return this.logger.icon(ListrDefaultRendererLogLevels.SKIPPED_WITHOUT_COLLAPSE)
      }
    }

    if (output) {
      if (this.shouldOutputToBottomBar(task)) {
        return this.logger.icon(ListrDefaultRendererLogLevels.OUTPUT_WITH_BOTTOMBAR)
      }

      return this.logger.icon(ListrDefaultRendererLogLevels.OUTPUT)
    }

    if (task.hasSubtasks()) {
      if (task.isStarted() || task.isPrompt() && rendererOptions.showSubtasks !== false && !task.subtasks.every((subtask) => !subtask.hasTitle())) {
        return this.logger.icon(ListrDefaultRendererLogLevels.PENDING)
      } else if (task.isCompleted() && task.subtasks.some((subtask) => subtask.hasFailed())) {
        return this.logger.icon(ListrDefaultRendererLogLevels.COMPLETED_WITH_FAILED_SUBTASKS)
      } else if (task.hasFailed()) {
        return this.logger.icon(ListrDefaultRendererLogLevels.FAILED_WITH_FAILED_SUBTASKS)
      }
    }

    if (task.isStarted() || task.isPrompt()) {
      return this.logger.icon(ListrDefaultRendererLogLevels.PENDING, !this.options?.lazy && this.spinner.fetch())
    } else if (task.isCompleted()) {
      return this.logger.icon(ListrDefaultRendererLogLevels.COMPLETED)
    } else if (task.isRetrying()) {
      return this.logger.icon(ListrDefaultRendererLogLevels.RETRY, !this.options?.lazy && this.spinner.fetch())
    } else if (task.isRollingBack()) {
      return this.logger.icon(ListrDefaultRendererLogLevels.ROLLING_BACK, !this.options?.lazy && this.spinner.fetch())
    } else if (task.hasRolledBack()) {
      return this.logger.icon(ListrDefaultRendererLogLevels.ROLLED_BACK)
    } else if (task.hasFailed()) {
      return this.logger.icon(ListrDefaultRendererLogLevels.FAILED)
    } else if (task.isPaused()) {
      return this.logger.icon(ListrDefaultRendererLogLevels.PAUSED)
    }

    return this.logger.icon(ListrDefaultRendererLogLevels.WAITING)
  }

  protected format (message: string, icon: string, level: number): string[] {
    // we dont like empty data around here
    if (message.trim() === '') {
      return []
    }

    if (icon) {
      message = icon + ' ' + message
    }

    let parsed: string[]

    const columns = (process.stdout.columns ?? 80) - level * this.options.indentation - 2

    switch (this.options.formatOutput) {
    case 'truncate':
      parsed = message.split(EOL).map((s, i) => {
        return this.truncate(this.indent(s, i), columns)
      })

      break

    case 'wrap':
      parsed = this.wrap(message, columns, { hard: true })
        .split(EOL)
        .map((s, i) => this.indent(s, i))

      break

    default:
      throw new ListrRendererError('Format option for the renderer is wrong.')
    }

    // this removes the empty lines
    if (this.options.removeEmptyLines) {
      parsed = parsed.filter(Boolean)
    }

    return parsed.map((str) => indent(str, level * this.options.indentation))
  }

  protected shouldOutputToOutputBar (task: ListrDefaultRendererTask): boolean {
    const outputBar = this.cache.rendererTaskOptions.get(task.id).outputBar

    return typeof outputBar === 'number' && outputBar !== 0 || typeof outputBar === 'boolean' && outputBar !== false
  }

  protected shouldOutputToBottomBar (task: ListrDefaultRendererTask): boolean {
    const bottomBar = this.cache.rendererTaskOptions.get(task.id).bottomBar

    return typeof bottomBar === 'number' && bottomBar !== 0 || typeof bottomBar === 'boolean' && bottomBar !== false || !task.hasTitle()
  }

  private renderer (tasks: ListrDefaultRendererTask[], level = 0): string[] {
    // eslint-disable-next-line complexity
    return tasks.flatMap((task) => {
      if (!task.isEnabled()) {
        return []
      }

      // if this is already cached return the cache
      if (this.cache.render.has(task.id)) {
        return this.cache.render.get(task.id)
      }

      this.calculate(task)
      this.setupBuffer(task)

      const rendererOptions = this.cache.rendererOptions.get(task.id)
      const rendererTaskOptions = this.cache.rendererTaskOptions.get(task.id)

      const output: string[] = []

      if (task.isPrompt()) {
        if (this.activePrompt && this.activePrompt !== task.id) {
          throw new ListrRendererError('Only one prompt can be active at the given time, please re-evaluate your task design.')
        } else if (!this.activePrompt) {
          task.on(ListrTaskEventType.PROMPT, (prompt: ListrTaskEventMap[ListrTaskEventType.PROMPT]): void => {
            const cleansed = cleanseAnsi(prompt)

            if (cleansed) {
              this.prompt = cleansed
            }
          })

          task.on(ListrTaskEventType.STATE, (state) => {
            if (state === ListrTaskState.PROMPT_COMPLETED || task.hasFinalized() || task.hasReset()) {
              this.prompt = null
              this.activePrompt = null
              task.off(ListrTaskEventType.PROMPT)
            }
          })

          this.activePrompt = task.id
        }
      }

      // Current Task Title
      if (task.hasTitle()) {
        if (!(tasks.some((task) => task.hasFailed()) && !task.hasFailed() && task.options.exitOnError !== false && !(task.isCompleted() || task.isSkipped()))) {
          // if task is skipped
          if (task.hasFailed() && rendererOptions.collapseErrors) {
            // current task title and skip change the title
            output.push(...this.format(!task.hasSubtasks() && task.message.error && rendererOptions.showErrorMessage ? task.message.error : task.title, this.style(task), level))
          } else if (task.isSkipped() && rendererOptions.collapseSkips) {
            // current task title and skip change the title
            output.push(
              ...this.format(
                this.logger.suffix(task.message.skip && rendererOptions.showSkipMessage ? task.message.skip : task.title, {
                  field: ListrLogLevels.SKIPPED,
                  condition: rendererOptions.suffixSkips,
                  format: () => color.dim
                }),
                this.style(task),
                level
              )
            )
          } else if (task.isRetrying()) {
            output.push(
              ...this.format(
                this.logger.suffix(task.title, {
                  field: `${ListrLogLevels.RETRY}:${task.message.retry.count}`,
                  format: () => color.yellow,
                  condition: rendererOptions.suffixRetries
                }),
                this.style(task),
                level
              )
            )
          } else if (task.isCompleted() && task.hasTitle() && assertFunctionOrSelf(rendererTaskOptions.timer?.condition, task.message.duration)) {
            // task with timer
            output.push(
              ...this.format(
                this.logger.suffix(task?.title, {
                  ...rendererTaskOptions.timer,
                  args: [ task.message.duration ]
                }),
                this.style(task),
                level
              )
            )
          } else if (task.isPaused()) {
            output.push(
              ...this.format(
                this.logger.suffix(task.title, {
                  ...rendererOptions.pausedTimer,
                  args: [ task.message.paused - Date.now() ]
                }),
                this.style(task),
                level
              )
            )
          } else {
            // normal state
            output.push(...this.format(task.title, this.style(task), level))
          }
        } else {
          // some sibling task but self has failed and this has stopped
          output.push(...this.format(task.title, this.logger.icon(ListrDefaultRendererLogLevels.COMPLETED_WITH_FAILED_SISTER_TASKS), level))
        }
      }

      // task should not have subtasks since subtasks will handle the error already
      // maybe it is a better idea to show the error or skip messages when show subtasks is disabled.
      if (!task.hasSubtasks() || !rendererOptions.showSubtasks) {
        // without the collapse option for skip and errors
        if (task.hasFailed() && rendererOptions.collapseErrors === false && (rendererOptions.showErrorMessage || !rendererOptions.showSubtasks)) {
          // show skip data if collapsing is not defined
          output.push(...this.dump(task, level, ListrLogLevels.FAILED))
        } else if (task.isSkipped() && rendererOptions.collapseSkips === false && (rendererOptions.showSkipMessage || !rendererOptions.showSubtasks)) {
          // show skip data if collapsing is not defined
          output.push(...this.dump(task, level, ListrLogLevels.SKIPPED))
        }
      }

      if (task.isPending() || rendererTaskOptions.persistentOutput) {
        output.push(...this.renderOutputBar(task, level))
      }

      // render subtasks, some complicated conditionals going on
      if (
        // check if renderer option is on first
        rendererOptions.showSubtasks !== false &&
        // if it doesnt have subtasks no need to check
        task.hasSubtasks() &&
        (task.isPending() ||
          task.hasFinalized() && !task.hasTitle() ||
          // have to be completed and have subtasks
          task.isCompleted() &&
            rendererOptions.collapseSubtasks === false &&
            !task.subtasks.some((subtask) => this.cache.rendererOptions.get(subtask.id)?.collapseSubtasks === true) ||
          // if any of the subtasks have the collapse option of
          task.subtasks.some((subtask) => this.cache.rendererOptions.get(subtask.id)?.collapseSubtasks === false) ||
          // if any of the subtasks has failed
          task.subtasks.some((subtask) => subtask.hasFailed()) ||
          // if any of the subtasks rolled back
          task.subtasks.some((subtask) => subtask.hasRolledBack()))
      ) {
        // set level
        const subtaskLevel = !task.hasTitle() ? level : level + 1

        // render the subtasks as in the same way
        const subtaskRender = this.renderer(task.subtasks, subtaskLevel)

        output.push(...subtaskRender)
      }

      // after task is finished actions
      if (task.hasFinalized()) {
        // clean up the output buffer if not persistent
        if (!rendererTaskOptions.persistentOutput) {
          this.buffer.bottom.delete(task.id)
          this.buffer.output.delete(task.id)
        }
      }

      if (task.isClosed()) {
        this.cache.render.set(task.id, output)
        this.reset(task)
      }

      return output
    })
  }

  private renderOutputBar (task: ListrDefaultRendererTask, level: number): string[] {
    const output = this.buffer.output.get(task.id)

    if (!output) {
      return []
    }

    return output.all.flatMap((o) => this.dump(task, level, ListrLogLevels.OUTPUT, o.entry))
  }

  private renderBottomBar (): string[] {
    // parse through all objects return only the last mentioned items
    if (this.buffer.bottom.size === 0) {
      return []
    }

    return Array.from(this.buffer.bottom.values())
      .flatMap((output) => output.all)
      .sort((a, b) => a.time - b.time)
      .map((output) => output.entry)
  }

  private renderPrompt (): string[] {
    if (!this.prompt) {
      return []
    }

    return [ this.prompt ]
  }

  private calculate (task: ListrDefaultRendererTask): void {
    if (this.cache.rendererOptions.has(task.id) && this.cache.rendererTaskOptions.has(task.id)) {
      return
    }

    const rendererOptions: ListrDefaultRendererOptions = {
      ...this.options,
      ...task.rendererOptions
    }

    this.cache.rendererOptions.set(task.id, rendererOptions)

    this.cache.rendererTaskOptions.set(task.id, {
      ...DefaultRenderer.rendererTaskOptions,
      timer: rendererOptions.timer,
      ...task.rendererTaskOptions
    })
  }

  private setupBuffer (task: ListrDefaultRendererTask): void {
    if (this.buffer.bottom.has(task.id) || this.buffer.output.has(task.id)) {
      return
    }

    const rendererTaskOptions = this.cache.rendererTaskOptions.get(task.id)

    // lazily create the process output buffer for the current task output
    if (this.shouldOutputToBottomBar(task) && !this.buffer.bottom.has(task.id)) {
      // create new if there is no persistent storage created for bottom bar
      this.buffer.bottom.set(task.id, new ProcessOutputBuffer({ limit: typeof rendererTaskOptions.bottomBar === 'number' ? rendererTaskOptions.bottomBar : 1 }))

      task.on(ListrTaskEventType.OUTPUT, (output) => {
        const data = this.dump(task, -1, ListrLogLevels.OUTPUT, output)

        this.buffer.bottom.get(task.id).write(data.join(EOL))
      })

      task.on(ListrTaskEventType.STATE, (state) => {
        switch (state) {
        case ListrTaskState.RETRY || ListrTaskState.ROLLING_BACK:
          this.buffer.bottom.delete(task.id)

          break
        }
      })
    } else if (this.shouldOutputToOutputBar(task) && !this.buffer.output.has(task.id)) {
      this.buffer.output.set(task.id, new ProcessOutputBuffer({ limit: typeof rendererTaskOptions.outputBar === 'number' ? rendererTaskOptions.outputBar : 1 }))

      task.on(ListrTaskEventType.OUTPUT, (output) => {
        this.buffer.output.get(task.id).write(output)
      })

      task.on(ListrTaskEventType.STATE, (state) => {
        switch (state) {
        case ListrTaskState.RETRY || ListrTaskState.ROLLING_BACK:
          this.buffer.output.delete(task.id)

          break
        }
      })
    }
  }

  private reset (task: ListrDefaultRendererTask): void {
    this.cache.rendererOptions.delete(task.id)
    this.cache.rendererTaskOptions.delete(task.id)

    // no need for this since this is now cached
    this.buffer.output.delete(task.id)
  }

  private dump (
    task: ListrDefaultRendererTask,
    level: number,
    source: ListrLogLevels.OUTPUT | ListrLogLevels.SKIPPED | ListrLogLevels.FAILED = ListrLogLevels.OUTPUT,
    data?: string | boolean
  ): string[] {
    if (!data) {
      switch (source) {
      case ListrLogLevels.OUTPUT:
        data = task.output

        break

      case ListrLogLevels.SKIPPED:
        data = task.message.skip

        break

      case ListrLogLevels.FAILED:
        data = task.message.error

        break
      }
    }

    // dont return anything on some occasions
    if (task.hasTitle() && source === ListrLogLevels.FAILED && data === task.title || typeof data !== 'string') {
      return []
    }

    if (source === ListrLogLevels.OUTPUT) {
      data = cleanseAnsi(data)
    }

    return this.format(data, this.style(task, true), level + 1)
  }

  private indent (str: string, i: number): string {
    return i > 0 ? indent(str.trim(), this.options.indentation) : str.trim()
  }
}

Utilizing the Events

Listr and its Task fires many events to indicate the task status. Task depending on what is currently done will fire ListrTaskState and ListrTaskEventType through ListrTaskEventManager which you can subscribe.

Take a look at SimpleRenderer or VerboseRenderer since it is implemented this way.

Code Example
ts
import type { ListrSimpleRendererCache, ListrSimpleRendererOptions, ListrSimpleRendererTask, ListrSimpleRendererTaskOptions } from './renderer.interface'
import { ListrTaskEventType, ListrTaskState } from '@constants'
import type { ListrRenderer } from '@interfaces'
import { PRESET_TIMER } from '@presets'
import { LISTR_LOGGER_STDERR_LEVELS, LISTR_LOGGER_STYLE, ListrLogLevels, ListrLogger, color } from '@utils'

export class SimpleRenderer implements ListrRenderer {
  public static nonTTY = true
  public static rendererOptions: ListrSimpleRendererOptions = {
    pausedTimer: {
      ...PRESET_TIMER,
      field: (time) => `${ListrLogLevels.PAUSED}:${time}`,
      format: () => color.yellowBright
    }
  }
  public static rendererTaskOptions: ListrSimpleRendererTaskOptions = {}

  private readonly logger: ListrLogger
  private readonly cache: ListrSimpleRendererCache = {
    rendererOptions: new Map(),
    rendererTaskOptions: new Map()
  }

  constructor (
    private readonly tasks: ListrSimpleRendererTask[],
    private options: ListrSimpleRendererOptions
  ) {
    this.options = {
      ...SimpleRenderer.rendererOptions,
      ...options,
      icon: {
        ...LISTR_LOGGER_STYLE.icon,
        ...options?.icon ?? {}
      },
      color: {
        ...LISTR_LOGGER_STYLE.color,
        ...options?.color ?? {}
      }
    }

    this.logger = this.options.logger ?? new ListrLogger<ListrLogLevels>({ useIcons: true, toStderr: LISTR_LOGGER_STDERR_LEVELS })

    this.logger.options.icon = this.options.icon
    this.logger.options.color = this.options.color

    if (this.options.timestamp) {
      this.logger.options.fields.prefix.unshift(this.options.timestamp)
    }
  }

  // eslint-disable-next-line @typescript-eslint/no-empty-function
  public end (): void {}

  public render (): void {
    this.renderer(this.tasks)
  }

  private renderer (tasks: ListrSimpleRendererTask[]): void {
    tasks.forEach((task) => {
      this.calculate(task)

      task.once(ListrTaskEventType.CLOSED, () => {
        this.reset(task)
      })

      const rendererOptions = this.cache.rendererOptions.get(task.id)
      const rendererTaskOptions = this.cache.rendererTaskOptions.get(task.id)

      task.on(ListrTaskEventType.SUBTASK, (subtasks) => {
        this.renderer(subtasks)
      })

      task.on(ListrTaskEventType.STATE, (state) => {
        if (!task.hasTitle()) {
          return
        }

        if (state === ListrTaskState.STARTED) {
          this.logger.log(ListrLogLevels.STARTED, task.title)
        } else if (state === ListrTaskState.COMPLETED) {
          const timer = rendererTaskOptions?.timer

          this.logger.log(
            ListrLogLevels.COMPLETED,
            task.title,
            timer && {
              suffix: {
                ...timer,
                condition: !!task.message?.duration && timer.condition,
                args: [ task.message.duration ]
              }
            }
          )
        } else if (state === ListrTaskState.PROMPT) {
          this.logger.process.hijack()

          task.on(ListrTaskEventType.PROMPT, (prompt) => {
            this.logger.process.toStderr(prompt, false)
          })
        } else if (state === ListrTaskState.PROMPT_COMPLETED) {
          task.off(ListrTaskEventType.PROMPT)

          this.logger.process.release()
        }
      })

      task.on(ListrTaskEventType.OUTPUT, (output) => {
        this.logger.log(ListrLogLevels.OUTPUT, output)
      })

      task.on(ListrTaskEventType.MESSAGE, (message) => {
        if (message.error) {
          // error message
          this.logger.log(ListrLogLevels.FAILED, task.title, {
            suffix: {
              field: `${ListrLogLevels.FAILED}: ${message.error}`,
              format: () => color.red
            }
          })
        } else if (message.skip) {
          this.logger.log(ListrLogLevels.SKIPPED, task.title, {
            suffix: {
              field: `${ListrLogLevels.SKIPPED}: ${message.skip}`,
              format: () => color.yellow
            }
          })
        } else if (message.rollback) {
          this.logger.log(ListrLogLevels.ROLLBACK, task.title, {
            suffix: {
              field: `${ListrLogLevels.ROLLBACK}: ${message.rollback}`,
              format: () => color.red
            }
          })
        } else if (message.retry) {
          this.logger.log(ListrLogLevels.RETRY, task.title, {
            suffix: {
              field: `${ListrLogLevels.RETRY}:${message.retry.count}`,
              format: () => color.red
            }
          })
        } else if (message.paused) {
          const timer = rendererOptions?.pausedTimer

          this.logger.log(
            ListrLogLevels.PAUSED,
            task.title,
            timer && {
              suffix: {
                ...timer,
                condition: !!message?.paused && timer.condition,
                args: [ message.paused - Date.now() ]
              }
            }
          )
        }
      })
    })
  }

  private calculate (task: ListrSimpleRendererTask): void {
    if (this.cache.rendererOptions.has(task.id) && this.cache.rendererTaskOptions.has(task.id)) {
      return
    }

    const rendererOptions: ListrSimpleRendererOptions = {
      ...this.options,
      ...task.rendererOptions
    }

    this.cache.rendererOptions.set(task.id, rendererOptions)

    this.cache.rendererTaskOptions.set(task.id, {
      ...SimpleRenderer.rendererTaskOptions,
      timer: rendererOptions.timer,
      ...task.rendererTaskOptions
    })
  }

  private reset (task: ListrSimpleRendererTask): void {
    this.cache.rendererOptions.delete(task.id)
    this.cache.rendererTaskOptions.delete(task.id)
  }
}
Code Example
ts
import type { ListrVerboseRendererCache, ListrVerboseRendererOptions, ListrVerboseRendererTask, ListrVerboseRendererTaskOptions } from './renderer.interface'
import { ListrTaskEventType, ListrTaskState } from '@constants'
import type { ListrRenderer } from '@interfaces'
import { PRESET_TIMER } from '@presets'
import { LISTR_LOGGER_STDERR_LEVELS, LISTR_LOGGER_STYLE, ListrLogLevels, ListrLogger, cleanseAnsi, color } from '@utils'

export class VerboseRenderer implements ListrRenderer {
  public static nonTTY = true
  public static rendererOptions: ListrVerboseRendererOptions = {
    logTitleChange: false,
    pausedTimer: {
      ...PRESET_TIMER,
      format: () => color.yellowBright
    }
  }
  public static rendererTaskOptions: ListrVerboseRendererTaskOptions

  private logger: ListrLogger
  private readonly cache: ListrVerboseRendererCache = {
    rendererOptions: new Map(),
    rendererTaskOptions: new Map()
  }

  constructor (
    private readonly tasks: ListrVerboseRendererTask[],
    private readonly options: ListrVerboseRendererOptions
  ) {
    this.options = {
      ...VerboseRenderer.rendererOptions,
      ...this.options,
      icon: {
        ...LISTR_LOGGER_STYLE.icon,
        ...options?.icon ?? {}
      },
      color: {
        ...LISTR_LOGGER_STYLE.color,
        ...options?.color ?? {}
      }
    }

    this.logger = this.options.logger ?? new ListrLogger<ListrLogLevels>({ useIcons: false, toStderr: LISTR_LOGGER_STDERR_LEVELS })

    this.logger.options.icon = this.options.icon
    this.logger.options.color = this.options.color

    if (this.options.timestamp) {
      this.logger.options.fields.prefix.unshift(this.options.timestamp)
    }
  }

  public render (): void {
    this.renderer(this.tasks)
  }

  // eslint-disable-next-line @typescript-eslint/no-empty-function
  public end (): void {}

  private renderer (tasks: ListrVerboseRendererTask[]): void {
    tasks.forEach((task) => {
      this.calculate(task)

      task.once(ListrTaskEventType.CLOSED, () => {
        this.reset(task)
      })

      const rendererOptions = this.cache.rendererOptions.get(task.id)
      const rendererTaskOptions = this.cache.rendererTaskOptions.get(task.id)

      task.on(ListrTaskEventType.SUBTASK, (subtasks) => {
        this.renderer(subtasks)
      })

      task.on(ListrTaskEventType.STATE, (state) => {
        if (!task.hasTitle()) {
          return
        }

        if (state === ListrTaskState.STARTED) {
          this.logger.log(ListrLogLevels.STARTED, task.title)
        } else if (state === ListrTaskState.COMPLETED) {
          const timer = rendererTaskOptions.timer

          this.logger.log(
            ListrLogLevels.COMPLETED,
            task.title,
            timer && {
              suffix: {
                ...timer,
                condition: !!task.message?.duration && timer.condition,
                args: [ task.message.duration ]
              }
            }
          )
        }
      })

      task.on(ListrTaskEventType.OUTPUT, (data) => {
        this.logger.log(ListrLogLevels.OUTPUT, data)
      })

      task.on(ListrTaskEventType.PROMPT, (prompt) => {
        const cleansed = cleanseAnsi(prompt)

        if (cleansed) {
          this.logger.log(ListrLogLevels.PROMPT, cleansed)
        }
      })

      if (this.options?.logTitleChange !== false) {
        task.on(ListrTaskEventType.TITLE, (title) => {
          this.logger.log(ListrLogLevels.TITLE, title)
        })
      }

      task.on(ListrTaskEventType.MESSAGE, (message) => {
        if (message?.error) {
          // error message
          this.logger.log(ListrLogLevels.FAILED, message.error)
        } else if (message?.skip) {
          // skip message
          this.logger.log(ListrLogLevels.SKIPPED, message.skip)
        } else if (message?.rollback) {
          // rollback message
          this.logger.log(ListrLogLevels.ROLLBACK, message.rollback)
        } else if (message?.retry) {
          this.logger.log(ListrLogLevels.RETRY, task.title, { suffix: message.retry.count.toString() })
        } else if (message?.paused) {
          const timer = rendererOptions?.pausedTimer

          this.logger.log(
            ListrLogLevels.PAUSED,
            task.title,
            timer && {
              suffix: {
                ...timer,
                condition: !!message?.paused && timer.condition,
                args: [ message.paused - Date.now() ]
              }
            }
          )
        }
      })
    })
  }

  private calculate (task: ListrVerboseRendererTask): void {
    if (this.cache.rendererOptions.has(task.id) && this.cache.rendererTaskOptions.has(task.id)) {
      return
    }

    const rendererOptions: ListrVerboseRendererOptions = {
      ...this.options,
      ...task.rendererOptions
    }

    this.cache.rendererOptions.set(task.id, rendererOptions)

    this.cache.rendererTaskOptions.set(task.id, {
      ...VerboseRenderer.rendererTaskOptions,
      timer: rendererOptions.timer,
      ...task.rendererTaskOptions
    })
  }

  private reset (task: ListrVerboseRendererTask): void {
    this.cache.rendererOptions.delete(task.id)
    this.cache.rendererTaskOptions.delete(task.id)
  }
}

Using Render Hooks

v2.1.0

Additional to listening to the events, another singleton hook that come from the root Listr is events. This provides some generic events like ListrEventType.SHOULD_REFRESH_RENDER which can be used to trigger an update on an updating renderer.

These events can be the third optional variable of a given renderer while using it is always optional.

typescript
export class MyAmazingRenderer implements ListrRenderer {
  constructor(
    private readonly tasks: ListrDefaultRendererTasks,
    private readonly options: ListrDefaultRendererOptions,
    private readonly events: ListrEventManager
  ) {}
}

These events can be later listened to trigger an update.

typescript
this.events.on(ListrEventType.SHOULD_REFRESH_RENDER, () => {
  this.update()
})

Using a Custom Renderer

You can tell Listr to use your custom renderer by setting the renderer option in Listr to your custom renderer.

ts
const tasks = new Listr(
  [
    /* Array of task objects */
  ],
  { renderer: MyAmazingRenderer }
)

await tasks.run()