Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions challenges/vibe-coding-mad-libs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

A 10-minute terminal challenge. The participant fills four Mad Libs blanks,
Theme, Subject, Audience, and Style, by inventing answers or rolling physical
dice against [`dice-sheet.md`](dice-sheet.md). Pi builds a self-contained
`index.html` from the sentence, shows it in Chrome beside the station, and
then makes one change the participant asks for.
dice and looking each roll up under Roll dice in the form, which shows the
tables in [`dice-sheet.md`](dice-sheet.md). Pi builds a self-contained
`index.html` from the sentence, offers to show it in Chrome beside the
station, and then makes one change the participant asks for.

| File | Role |
| --- | --- |
| `mad-libs.mjs` | Pi extension: the four-blank form and `/mad-libs` |
| `mad-libs.mjs` | Pi extension: the four-blank form, its Roll dice tables, and `/mad-libs` |
| `prompt.mjs` | Assembles and checks the sentence |
| `dice.mjs` | Parses `dice-sheet.md` into the Roll dice tables |
| `preview.mjs` | Serves `index.html` on `http://127.0.0.1:4321/` with live reload |
| `install` | Installs the Pi packages the extension imports |
| `setup`, `cleanup` | Copy the dice sheet and start the preview; stop it |
Expand All @@ -20,7 +22,7 @@ Install Pi and the adapter at the versions boothstrap pins, as in
boothstrap's authoring guide, then, from this repo's root:

```sh
challenge="$PWD/vibe-coding-mad-libs"
challenge="$PWD/challenges/vibe-coding-mad-libs"
"$challenge/install"
mkdir -p tmp/mad-libs && cd tmp/mad-libs
"$challenge/setup"
Expand Down
2 changes: 1 addition & 1 deletion challenges/vibe-coding-mad-libs/challenge.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
{
"id": "fill-prompt",
"title": "Fill in your prompt",
"hint": "Fill the four blanks. Invent answers, or roll dice and ask Pi to show dice-sheet.md.",
"hint": "Fill the four blanks. Invent answers, or roll dice and look them up under Roll dice in the form.",
"done_when": "The participant sent a prompt naming a kind of app, a subject, an audience, and a style, with no blank left unfilled."
},
{
Expand Down
49 changes: 49 additions & 0 deletions challenges/vibe-coding-mad-libs/dice.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// The optional dice tables, parsed from dice-sheet.md. dice-sheet.md is the
// one source: the form shows these tables, and setup copies the file into the
// workspace.

import { readFileSync } from 'node:fs'

import { FIELDS } from './prompt.mjs'

const DICE_SHEET = new URL('./dice-sheet.md', import.meta.url)

const TABLE_HEADING = /^## (\w+) \(d(\d+)\)$/
const TABLE_ROW = /^\| (\d+) \| (.+?) \|$/

/**
* Parses a dice sheet's `## <Label> (d<sides>)` tables into one entry per
* field, in field order: `{ field, die, options }`, where `options[roll - 1]`
* is the option for that roll. Throws naming the field when its table is
* missing or its rolls aren't 1 to the die's sides, in order.
*/
export function parseDiceSheet(markdown) {
const tablesByLabel = new Map()
let current
for (const line of markdown.split('\n')) {
const heading = TABLE_HEADING.exec(line)
if (heading) {
current = { sides: Number(heading[2]), rows: [] }
tablesByLabel.set(heading[1], current)
continue
}

const row = TABLE_ROW.exec(line)
if (row && current) current.rows.push({ roll: Number(row[1]), option: row[2] })
}

return FIELDS.map((field) => {
const table = tablesByLabel.get(field.label)
if (!table) throw new Error(`The dice sheet has no ${field.label} table`)

const inOrder = table.rows.length === table.sides && table.rows.every(({ roll }, index) => roll === index + 1)
if (!inOrder) throw new Error(`The dice sheet's ${field.label} table must list rolls 1 to ${table.sides} in order`)

return { field, die: `d${table.sides}`, options: table.rows.map(({ option }) => option) }
})
}

/** Reads and parses the package's own dice-sheet.md, as {@link parseDiceSheet} does. */
export function readDiceTables() {
return parseDiceSheet(readFileSync(DICE_SHEET, 'utf8'))
}
5 changes: 2 additions & 3 deletions challenges/vibe-coding-mad-libs/intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ Tell Pi what to build by filling in one sentence:
> Build a [Theme] about [Subject] for [Audience] in [Style] style.

A form opens after Pi says hello. Type your own answers, or roll the dice
at the booth: press Esc and ask Pi to show `dice-sheet.md` to look up each
roll, then type `/mad-libs` to return to the form. The dice are optional,
and mixing rolled and invented answers is fine.
at the booth and look up each roll under Roll dice in the form. The dice are
optional, and mixing rolled and invented answers is fine.

Pi then builds your app as one web page and offers to open it in Chrome
beside the station. Try it, ask Pi for one change, and try it again.
Expand Down
97 changes: 84 additions & 13 deletions challenges/vibe-coding-mad-libs/mad-libs.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
// Pi extension: the four-blank Mad Libs form. It opens once Pi's welcome
// finishes, reopens with /mad-libs, and sends the filled sentence as the
// participant's message. Typing the sentence in Pi's editor works without it.
// participant's message. Its Roll dice view shows the dice sheet's tables.
// Typing the sentence in Pi's editor works without it.

import { Input, matchesKey, truncateToWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui'
import { Input, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui'

import { readDiceTables } from './dice.mjs'
import { FIELDS, PROMPT_TEMPLATE, assemblePrompt, fillSentence, findBlankField, hasUnfilledBlank, isFinishedPrompt } from './prompt.mjs'

/** The form's focus stops: one per field, then the Build button. */
/** The form's focus stops: one per field, then the Build and Roll dice buttons. */
const BUILD_BUTTON = FIELDS.length
const FOCUS_STOPS = FIELDS.length + 1
const ROLL_DICE_BUTTON = FIELDS.length + 1
const FOCUS_STOPS = FIELDS.length + 2

/** How many lines of the sentence preview the form shows. */
const PREVIEW_LINES = 5
Expand All @@ -27,23 +30,61 @@ function promptAlreadySent(ctx) {
&& textParts(entry.message.content).some(isFinishedPrompt))
}

/**
* Draws one dice table as `Theme (d6): 1 Dashboard · 2 Game · …`, wrapped
* to `width` between rolls, so a roll stays whole with the separator after
* it. pi-tui's wrapping can't do this: it breaks at no-break spaces too. A
* roll wider than `width` wraps on its own lines.
*/
function diceTableLines({ field, die, options }, width) {
const pieces = options.map((option, index) => `${index + 1} ${option}${index < options.length - 1 ? ' ·' : ''}`)
const lines = []
let line = `${field.label} (${die}):`
for (const piece of pieces) {
if (visibleWidth(`${line} ${piece}`) <= width) {
line = `${line} ${piece}`
continue
}

lines.push(line)
line = piece
}
lines.push(line)

return lines.flatMap((text) => wrapTextWithAnsi(text, width))
}

/**
* Builds the form component for `ctx.ui.custom`. `draft` keeps answers
* across closes, keyed by field; `keybindings` supplies Pi's submit and
* cancel keys; `done` receives the sentence, or null when cancelled.
* across closes, keyed by field; `diceTables` fill the Roll dice view;
* `keybindings` supplies Pi's submit and cancel keys; `done` receives the
* sentence, or null when cancelled.
*/
function createForm({ tui, theme, keybindings, draft, done }) {
function createForm({ tui, theme, keybindings, draft, diceTables, done }) {
const inputs = FIELDS.map((field) => {
const input = new Input({ prompt: '', placeholder: field.placeholder })
const input = new Input({ prompt: '', placeholder: field.placeholder, placeholderStyle: (text) => theme.fg('muted', text) })
// setValue leaves the cursor at column zero; typing the draft puts it at the end.
if (draft[field.key]) input.handleInput(draft[field.key])

return input
})
let focusedStop = 0
let hasFocus = true
let showingDice = false
let error = ''

/**
* Draws field `index`: the focused one as its Input, with the cursor, and
* the rest as plain text, since Input draws a cursor even when unfocused.
*/
function renderField(index, width) {
if (hasFocus && index === focusedStop) return inputs[index].render(width)

const value = inputs[index].getValue()

return [value || theme.fg('muted', FIELDS[index].placeholder)]
}

/** Moves focus to stop `target`, wrapping around both ends. */
function focusStop(target) {
focusedStop = (target + FOCUS_STOPS) % FOCUS_STOPS
Expand All @@ -65,6 +106,25 @@ function createForm({ tui, theme, keybindings, draft, done }) {
done(assemblePrompt(draft))
}

/** Switches between the fields and the Roll dice view, keeping the draft. */
function showDice(show) {
showingDice = show
tui.requestRender()
}

/** Draws the dice tables, wrapped to `width`, with how to go back first. */
function renderDice(width) {
const wrapWidth = Math.max(1, width)
const wrap = (text) => wrapTextWithAnsi(text, wrapWidth)

return [
...wrap(theme.fg('accent', theme.bold('Roll dice'))),
...wrap(theme.fg('muted', 'Enter/Esc: back to the form')),
...wrap(theme.fg('muted', 'Type each roll\'s option into its blank, or invent your own.')),
...diceTables.flatMap((table) => diceTableLines(table, wrapWidth)),
]
}

focusStop(0)

return {
Expand All @@ -76,13 +136,21 @@ function createForm({ tui, theme, keybindings, draft, done }) {
focusStop(focusedStop)
},
handleInput(data) {
if (showingDice) {
const leavesDice = keybindings.matches(data, 'tui.select.cancel') || keybindings.matches(data, 'tui.input.submit')

return leavesDice ? showDice(false) : undefined
}
if (keybindings.matches(data, 'tui.select.cancel')) return done(null)
if (matchesKey(data, 'shift+tab')) return focusStop(focusedStop - 1)
if (matchesKey(data, 'tab')) return focusStop(focusedStop + 1)
if (keybindings.matches(data, 'tui.input.submit')) {
return focusedStop === BUILD_BUTTON ? build() : focusStop(focusedStop + 1)
if (focusedStop === BUILD_BUTTON) return build()
if (focusedStop === ROLL_DICE_BUTTON) return showDice(true)

return focusStop(focusedStop + 1)
}
if (focusedStop === BUILD_BUTTON) return
if (focusedStop >= BUILD_BUTTON) return

const field = FIELDS[focusedStop]
inputs[focusedStop].handleInput(data)
Expand All @@ -91,6 +159,8 @@ function createForm({ tui, theme, keybindings, draft, done }) {
tui.requestRender()
},
render(width) {
if (showingDice) return renderDice(width)

const fit = (text) => truncateToWidth(text, Math.max(1, width))
const marker = (stop) => (focusedStop === stop ? '›' : ' ')
const lines = [
Expand All @@ -99,9 +169,9 @@ function createForm({ tui, theme, keybindings, draft, done }) {
]
FIELDS.forEach((field, index) => {
lines.push(fit(`${marker(index)} ${field.label}`))
lines.push(...inputs[index].render(width).map(fit))
lines.push(...renderField(index, width).map(fit))
})
lines.push(fit(`${marker(BUILD_BUTTON)} [ Build ]`), '')
lines.push(fit(`${marker(BUILD_BUTTON)} [ Build ] ${marker(ROLL_DICE_BUTTON)} [ Roll dice ]`), '')

const preview = wrapTextWithAnsi(fillSentence(draft), Math.max(1, width))
lines.push(...preview.slice(0, PREVIEW_LINES).map(fit))
Expand All @@ -119,6 +189,7 @@ function createForm({ tui, theme, keybindings, draft, done }) {
/** Registers the form, its /mad-libs command, and the typed-sentence check with Pi. */
export default function madLibs(pi) {
const draft = Object.fromEntries(FIELDS.map((field) => [field.key, '']))
const diceTables = readDiceTables()
let formOpen = false
let openAfterWelcome = false

Expand All @@ -136,7 +207,7 @@ export default function madLibs(pi) {
formOpen = true
let sentence
try {
sentence = await ctx.ui.custom((tui, theme, keybindings, done) => createForm({ tui, theme, keybindings, draft, done }))
sentence = await ctx.ui.custom((tui, theme, keybindings, done) => createForm({ tui, theme, keybindings, draft, diceTables, done }))
} catch {
ctx.ui.notify(`The form couldn't open. Type the sentence instead: ${PROMPT_TEMPLATE}`, 'warning')
return
Expand Down
36 changes: 19 additions & 17 deletions challenges/vibe-coding-mad-libs/skills/start-challenge/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,17 @@ Be warm, brief, and concrete. Work only in the current directory.

## Welcome

Welcome the participant in two or three sentences, then say that a form opens
once you finish: four blanks, Theme (the kind of app), Subject, Audience, and
Style. The form shows its own keys. They can invent answers, or roll the
booth's dice and look up each roll on the dice sheet: Esc closes the form so
they can ask you to show it, and `/mad-libs` brings the form back. You can't
roll for them, so never pretend you did. If the form doesn't open, they can
type the whole sentence instead:
Welcome the participant in two sentences: one saying a form for their four
blanks opens once you finish, and one asking them to keep secrets and personal
details out of their answers. The form shows its keys and its Roll dice
tables, so don't explain them. Then end your turn, so the form can open.

> Build a [Theme] about [Subject] for [Audience] in [Style] style.
If they say the form didn't open, give them this sentence to type:

Ask them to keep secrets and personal details out of their answers. End your
turn there, so the form can open.
> Build a [Theme] about [Subject] for [Audience] in [Style] style.

When they ask for the dice sheet, `read` `dice-sheet.md` and show its four
tables as they are, then remind them that `/mad-libs` reopens the form.
You can't roll for them, so never pretend you did. When they ask for the dice
sheet, `read` `dice-sheet.md` and show its four tables as they are.

## Steps

Expand All @@ -35,15 +31,21 @@ participant which step they finished instead, without mentioning points.
you start building. A bracketed blank or a request to skip doesn't count.
2. **build-app.** Write the app to `index.html`, then read it back to check
it is whole. As soon as it is, call `show_preview` with
`http://127.0.0.1:4321/` without asking first.
When `show_preview` is missing or fails, give them that URL to open.
`http://127.0.0.1:4321/` without asking first, and word what you say by
its `outcome`:
- `offered`: a dialog is asking first, so tell them to click Open preview
to see their app. Nothing is open yet; never say it is.
- `navigated`: say their app is showing in Chrome beside the station.
- `show_preview` missing or failed: give them that URL to open.

Nothing watches the page, so ask them to try it and tell you how it went.
Report `build-app` only after they say it works; if it doesn't, fix it and
ask again.
3. **improve-app.** Ask what one thing they'd change. Make that change in
`index.html`. After the change, call `show_preview` again; it brings back
a closed preview, and an open one reloads on its own. Ask them to try it,
and report `improve-app` only after they confirm it works.
`index.html`. After the change, call `show_preview` again and word it by
its `outcome` as in step 2: `navigated` means the open preview now shows
the change, and `offered` means they click Open preview to see it. Ask
them to try it, and report `improve-app` only after they confirm it works.

## Building the app

Expand Down
Loading
Loading