Skip to content

Custom Prompts ​

This guide explores advanced techniques for creating and customizing prompts in your clapp applications.

Creating Custom Prompts ​

clapp allows you to create your own custom prompts by extending the built-in functionality.

Basic Custom Prompt ​

Create a simple custom prompt:

ts
import { createPrompt } from '@stacksjs/clapp'

// Create an email prompt
const emailPrompt = createPrompt({
  name: 'email',
  validate: (value) => {
    if (!value.includes('@'))
      return 'Please enter a valid email address'
    return true
  },
  transform: value => value.toLowerCase().trim(),
})

// Use the custom prompt
const userEmail = await emailPrompt('Enter your email:')
console.log(`Email: ${userEmail}`)

Advanced Custom Prompt ​

Build a more sophisticated custom prompt with custom rendering:

ts
import { createPrompt, cursor, renderPrompt, style } from '@stacksjs/clapp'

// Create a rating prompt (1-5 stars)
const ratingPrompt = createPrompt({
  name: 'rating',
  initialValue: 3,
  validate: (value) => {
    const rating = Number(value)
    if (Number.isNaN(rating) || rating < 1 || rating > 5)
      return 'Rating must be between 1 and 5'
    return true
  },
  render: (state) => {
    // Clear any previous prompt render
    if (state.firstRender)
      cursor.hide()
    else
      renderPrompt.restoreCursor()

    // Render prompt message
    renderPrompt.message(state.message)

    // Render stars
    const rating = Number(state.value) || 0
    const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating)
    renderPrompt.value(style.yellow(stars))

    // Render hint
    renderPrompt.hint(' (1-5)')

    // Save cursor position
    renderPrompt.saveCursor()
  },
  keybindings: {
    left: (state) => {
      const current = Number(state.value) || 3
      state.value = String(Math.max(1, current - 1))
    },
    right: (state) => {
      const current = Number(state.value) || 3
      state.value = String(Math.min(5, current + 1))
    },
    number: (state, key) => {
      const num = Number(key.name)
      if (num >= 1 && num <= 5)
        state.value = String(num)
    },
  },
})

// Use the rating prompt
const userRating = await ratingPrompt('Rate your experience:')
console.log(`User rating: ${userRating}/5`)

Extending Existing Prompts ​

You can extend the built-in prompts to create specialized variants:

ts
import { prompt } from '@stacksjs/clapp'

// Extend the text prompt for file paths
async function filePathPrompt(message, options = {}) {
  return prompt.text(message, {
    ...options,
    validate: (value) => {
      // Check if the path has the correct format
      if (!value.match(/^([a-z]:)?[/\\]?([^/:*?"<>|]+[/\\])*([^/:*?"<>|]+)?$/i))
        return 'Please enter a valid file path'

      // You could also check if the file exists
      // const exists = fs.existsSync(value)
      // if (!exists) return 'File does not exist'

      return true
    },
  })
}

// Use the extended prompt
const configPath = await filePathPrompt('Path to config file:')

Custom Prompt Layouts ​

Create prompts with custom layouts:

ts
import { createPrompt, renderPrompt, style } from '@stacksjs/clapp'

const formPrompt = createPrompt({
  name: 'form',
  initialValue: { name: '', email: '', age: '' },
  validate: (value) => {
    const errors = []

    if (!value.name)
      errors.push('Name is required')

    if (!value.email.includes('@'))
      errors.push('Email must be valid')

    const age = Number(value.age)
    if (Number.isNaN(age) || age < 18)
      errors.push('Age must be at least 18')

    return errors.length ? errors.join('\n') : true
  },
  render: (state) => {
    renderPrompt.restoreCursor()
    renderPrompt.message(state.message)
    renderPrompt.newline()

    const { name, email, age } = state.value
    const fieldActive = state.cursor || 'name'

    // Render each form field
    renderPrompt.field(
      'Name:     ',
      name,
      fieldActive === 'name' ? style.inverse : style.dim
    )
    renderPrompt.newline()

    renderPrompt.field(
      'Email:    ',
      email,
      fieldActive === 'email' ? style.inverse : style.dim
    )
    renderPrompt.newline()

    renderPrompt.field(
      'Age:      ',
      age,
      fieldActive === 'age' ? style.inverse : style.dim
    )
    renderPrompt.newline()

    // Render validation errors
    if (state.error) {
      renderPrompt.newline()
      renderPrompt.error(state.error)
    }

    renderPrompt.saveCursor()
  },
  keybindings: {
    tab: (state) => {
      // Cycle through fields
      const fields = ['name', 'email', 'age']
      const currentIndex = fields.indexOf(state.cursor || 'name')
      state.cursor = fields[(currentIndex + 1) % fields.length]
    },
    up: (state) => {
      // Move to previous field
      const fields = ['name', 'email', 'age']
      const currentIndex = fields.indexOf(state.cursor || 'name')
      state.cursor = fields[(currentIndex - 1 + fields.length) % fields.length]
    },
    down: (state) => {
      // Move to next field
      const fields = ['name', 'email', 'age']
      const currentIndex = fields.indexOf(state.cursor || 'name')
      state.cursor = fields[(currentIndex + 1) % fields.length]
    },
    key: (state, key) => {
      // Update the current field
      const field = state.cursor || 'name'
      if (key.name === 'backspace') {
        state.value[field] = state.value[field].slice(0, -1)
      }
      else if (key.name !== 'return') {
        state.value[field] += key.sequence
      }
    },
  },
})

// Use the form prompt
const userData = await formPrompt('Please fill out your information:')
console.log('User data:', userData)

Interactive Wizards ​

Create more complex multi-step wizards:

ts
import { box, prompt, style } from '@stacksjs/clapp'

async function setupWizard() {
  // Welcome screen
  box(style.bold('Project Setup Wizard'), {
    padding: 1,
    borderColor: 'blue',
  })

  // Step 1: Project information
  const projectName = await prompt.text('Project name:', {
    validate: value => value.length > 0 || 'Project name is required',
  })

  const projectType = await prompt.select('Project type:', [
    { value: 'app', label: 'Application' },
    { value: 'lib', label: 'Library' },
    { value: 'api', label: 'API Service' },
  ])

  // Step 2: Dependencies (conditional based on project type)
  let dependencies = []

  if (projectType === 'app') {
    dependencies = await prompt.multiselect('Select frontend dependencies:', [
      { name: 'React', value: 'react', checked: true },
      { name: 'Vue', value: 'vue' },
      { name: 'Tailwind CSS', value: 'tailwind' },
      { name: 'TypeScript', value: 'typescript', checked: true },
    ])
  }
  else if (projectType === 'api') {
    dependencies = await prompt.multiselect('Select backend dependencies:', [
      { name: 'Express', value: 'express', checked: true },
      { name: 'Database ORM', value: 'orm' },
      { name: 'Authentication', value: 'auth' },
      { name: 'TypeScript', value: 'typescript', checked: true },
    ])
  }

  // Step 3: Configuration options
  const config = {}

  if (dependencies.includes('typescript')) {
    config.strict = await prompt.confirm('Enable strict TypeScript mode?', {
      default: true,
    })
  }

  if (projectType === 'app' || projectType === 'api') {
    config.port = await prompt.number('Port number:', {
      default: 3000,
      min: 1024,
      max: 65535,
    })
  }

  // Final confirmation
  const summary = `
Project: ${style.bold(projectName)} (${projectType})
Dependencies: ${dependencies.join(', ') || 'none'}
Configuration: ${Object.entries(config)
  .map(([key, value]) => `${key}: ${value}`)
  .join(', ') || 'default'}
  `

  box(`${style.bold('Project Summary')}\n${summary}`, {
    padding: 1,
  })

  const confirmed = await prompt.confirm('Create project with these settings?')

  if (confirmed) {
    return {
      name: projectName,
      type: projectType,
      dependencies,
      config,
    }
  }

  return null
}

// Run the wizard
const project = await setupWizard()
if (project) {
  console.log('Creating project:', project)
  // Implementation details...
}

State Management in Prompts ​

Manage complex state in custom prompts:

ts
import { createPrompt, style } from '@stacksjs/clapp'

// Create a shopping cart prompt
const cartPrompt = createPrompt({
  name: 'cart',
  initialValue: {
    items: [
      { id: 1, name: 'Item 1', price: 10, quantity: 1 },
      { id: 2, name: 'Item 2', price: 15, quantity: 2 },
    ],
    cursor: 0,
  },

  render: (state) => {
    // Render cart items with quantities and prices
    // Allow user to navigate, increase/decrease quantities, etc.
    // Calculate and show total
  },

  keybindings: {
    up: (state) => {
      // Move cursor up
      if (state.value.cursor > 0)
        state.value.cursor--
    },
    down: (state) => {
      // Move cursor down
      if (state.value.cursor < state.value.items.length - 1)
        state.value.cursor++
    },
    plus: (state) => {
      // Increase quantity
      state.value.items[state.value.cursor].quantity++
    },
    minus: (state) => {
      // Decrease quantity (minimum 0)
      const item = state.value.items[state.value.cursor]
      if (item.quantity > 0)
        item.quantity--
    },
    d: (state) => {
      // Delete item
      state.value.items.splice(state.value.cursor, 1)
      if (state.value.cursor >= state.value.items.length)
        state.value.cursor = state.value.items.length - 1
    },
  },

  submit: (state) => {
    // Return only the items, filtering out those with quantity 0
    return state.value.items.filter(item => item.quantity > 0)
  },
})

// Use the shopping cart prompt
const cart = await cartPrompt('Your shopping cart:')
console.log('Cart items:', cart)

Custom Prompt Appearance ​

Customize the appearance of prompts with themes:

ts
import { configPrompts, style } from '@stacksjs/clapp'

// Configure global prompt appearance
configPrompts({
  symbols: {
    // Custom symbols
    pointer: '→',
    check: '✓',
    cross: '✗',
    bullet: '•',
  },

  colors: {
    // Custom colors
    primary: style.blue,
    success: style.green,
    error: style.red,
    muted: style.dim,
  },

  formatting: {
    // Custom text formatting
    message: text => style.bold(text),
    hint: text => style.dim(`(${text})`),
    highlight: text => style.underline.cyan(text),
  },
})

// Prompts will now use the custom appearance

Integrating with System State ​

Create prompts that interact with the system:

ts
import { execSync } from 'node:child_process'
import * as fs from 'node:fs'
import { prompt, spinner } from '@stacksjs/clapp'

// Prompt that shows available disk space
async function diskSpacePrompt(message) {
  const spin = spinner('Checking disk space...')
  spin.start()

  try {
    // Get disk space (this is platform-specific)
    const diskInfo = execSync('df -h').toString()
    spin.stop()

    console.log(diskInfo)

    return prompt.confirm(message)
  }
  catch (err) {
    spin.stop('Failed to check disk space')
    return prompt.confirm(message)
  }
}

// Prompt that shows available npm packages
async function packagePrompt(message) {
  // Get package.json if it exists
  let currentDeps = []
  try {
    const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
    currentDeps = Object.keys(packageJson.dependencies || {})
  }
  catch (err) {
    // No package.json found, that's okay
  }

  return prompt.multiselect(message, [
    { name: 'Express', value: 'express', checked: currentDeps.includes('express') },
    { name: 'Lodash', value: 'lodash', checked: currentDeps.includes('lodash') },
    { name: 'Axios', value: 'axios', checked: currentDeps.includes('axios') },
    { name: 'Dotenv', value: 'dotenv', checked: currentDeps.includes('dotenv') },
  ])
}

// Use the system-aware prompts
const shouldProceed = await diskSpacePrompt('Continue with installation?')
if (shouldProceed) {
  const packages = await packagePrompt('Select packages to install:')
  console.log('Installing:', packages)
}

For more information on creating and customizing prompts, see the Prompts API Reference.

Released under the MIT License.