Skip to content

Commit 71bcfdb

Browse files
committed
add ui
1 parent 9fa4edb commit 71bcfdb

File tree

13 files changed

+733
-5
lines changed

13 files changed

+733
-5
lines changed

.vscode/settings.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212
"editor.defaultFormatter": "biomejs.biome"
1313
},
1414
"editor.codeActionsOnSave": {
15-
"quickfix.biome": "explicit",
16-
"source.organizeImports.biome": "explicit"
15+
"source.fixAll.biome": "explicit"
1716
},
1817
"editor.defaultFormatter": "biomejs.biome",
1918
"editor.formatOnSave": true,

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
- [2025-09-24] [add ui](https://github.com/RubricLab/cli/commit/a809ae0177be8dfd9aa948b860e51a64fdf639db)
12
- [2025-07-01] [run format](https://github.com/RubricLab/cli/commit/7ca23a6d4940452913b3111c1d43b7179638dcf9)
23
- [2025-07-01] [consolidate biome format script](https://github.com/RubricLab/cli/commit/ea2999efb0c28c7a217e81245026d3009bc78081)
34
- [2025-06-25] [Use package post-commit hooks](https://github.com/RubricLab/cli/commit/93141e16972175bb4b23d83f75c7c800662b7f85)

lib/parse.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { z } from 'zod/v4'
2-
import { format } from './colors'
32
import { showHelp } from './help'
43
import type { Command } from './types'
54
import { kebabToCamel } from './utils'
@@ -115,7 +114,7 @@ export function parseArgs<T extends z.ZodType>({
115114

116115
if (nextIsValue) {
117116
args[flagName] = nextArg
118-
i++ // Skip the next arg
117+
i++
119118
} else {
120119
throw new Error(`Missing value for flag --${flagName}`)
121120
}

lib/ui.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { stdin as input, stdout as output } from 'node:process'
2+
import { emitKeypressEvents } from 'node:readline'
3+
import { type ZodTypeAny, z } from 'zod/v4'
4+
5+
type Key = { name?: string; ctrl?: boolean; sequence?: string }
6+
const gray = (s: string) => `\x1b[90m${s}\x1b[0m`
7+
const red = (s: string) => `\x1b[31m${s}\x1b[0m`
8+
const erase = (n: number) => output.write(`\x1b[${n}D${' '.repeat(n)}\x1b[${n}D`)
9+
10+
let onKeyRef: ((s: string, k?: Key) => void) | null = null
11+
function cleanup() {
12+
if (onKeyRef) {
13+
input.off('keypress', onKeyRef)
14+
onKeyRef = null
15+
}
16+
input.setRawMode?.(false)
17+
}
18+
export function terminate(code = 0): never {
19+
cleanup()
20+
output.write('\n')
21+
process.exit(code)
22+
}
23+
24+
async function prompt(label: string, placeholder: string): Promise<string> {
25+
output.write(`${label}: `)
26+
if (placeholder) output.write(gray(placeholder))
27+
let buf = ''
28+
let cleared = placeholder.length === 0
29+
emitKeypressEvents(input)
30+
input.setRawMode?.(true)
31+
32+
return await new Promise<string>(resolve => {
33+
const done = (v: string) => {
34+
output.write('\n')
35+
cleanup()
36+
resolve(v)
37+
}
38+
const onKey = (_: string, k?: Key) => {
39+
if (!k) return
40+
if ((k.ctrl && k.name === 'c') || k.name === 'escape') terminate(k.name === 'escape' ? 0 : 130)
41+
if (k.name === 'return') return done(buf || placeholder)
42+
if (k.name === 'backspace') {
43+
if (!cleared && placeholder) {
44+
erase(placeholder.length)
45+
cleared = true
46+
}
47+
if (buf) {
48+
buf = buf.slice(0, -1)
49+
output.write('\b \b')
50+
}
51+
return
52+
}
53+
const ch = k.sequence ?? ''
54+
if (ch.length === 1 && ch >= ' ') {
55+
if (!cleared && placeholder) {
56+
erase(placeholder.length)
57+
cleared = true
58+
}
59+
buf += ch
60+
output.write(ch)
61+
}
62+
}
63+
onKeyRef = onKey
64+
input.on('keypress', onKey)
65+
})
66+
}
67+
68+
async function ask<T extends ZodTypeAny>(
69+
label: string,
70+
def: string,
71+
schema: T
72+
): Promise<z.infer<T>> {
73+
for (;;) {
74+
const raw = await prompt(label, def)
75+
const r = schema.safeParse(raw)
76+
if (r.success) return r.data as z.infer<T>
77+
output.write(red(`✖ ${r.error.issues[0]?.message}\n`))
78+
}
79+
}
80+
81+
// ---------- existing inputs ----------
82+
export async function textInput(label: string, defaultValue: string): Promise<string> {
83+
return ask(label, defaultValue, z.string())
84+
}
85+
export async function numberInput(label: string, defaultValue: number): Promise<number> {
86+
return ask(
87+
label,
88+
String(defaultValue),
89+
z.coerce.number().refine(Number.isFinite, 'Enter a valid number')
90+
)
91+
}
92+
export async function booleanInput(label: string, defaultValue: boolean): Promise<boolean> {
93+
const hint = `${label} ${defaultValue ? '[Y/n]' : '[y/N]'}`
94+
return ask(
95+
hint,
96+
defaultValue ? 'Y' : 'N',
97+
z
98+
.string()
99+
.transform(s => s.trim().toLowerCase())
100+
.refine(s => ['', 'y', 'yes', 'true', '1', 'n', 'no', 'false', '0'].includes(s), 'Enter y/n')
101+
.transform(s => (s === '' ? defaultValue : ['y', 'yes', 'true', '1'].includes(s)))
102+
)
103+
}
104+
105+
export async function selectInput<const Options extends readonly [string, ...string[]]>(
106+
label: string,
107+
options: Options,
108+
defaultValue: Options[number]
109+
): Promise<Options[number]> {
110+
const item = z.union([
111+
z.coerce
112+
.number()
113+
.int()
114+
.min(1)
115+
.max(options.length)
116+
.transform(i => options[i - 1] as Options[number]),
117+
z.enum(options)
118+
])
119+
const hint = `${label} ${options.map((o, i) => `[${i + 1}:${o}]`).join(' ')}`
120+
return ask(hint, String(defaultValue), item)
121+
}
122+
123+
export async function multiSelectInput<const Options extends readonly [string, ...string[]]>(
124+
label: string,
125+
options: Options,
126+
defaultValues: readonly Options[number][]
127+
): Promise<Options[number][]> {
128+
const toArray = z
129+
.string()
130+
.transform(s => s.trim())
131+
.transform(s =>
132+
s === ''
133+
? []
134+
: s
135+
.split(/[,\s]+/)
136+
.map(t => t.trim())
137+
.filter(Boolean)
138+
)
139+
.transform(tokens =>
140+
tokens.map(t => {
141+
const n = Number(t)
142+
return Number.isInteger(n) && n >= 1 && n <= options.length
143+
? (options[n - 1] as Options[number])
144+
: (t as Options[number])
145+
})
146+
)
147+
const arr = toArray
148+
.pipe(z.array(z.enum(options)).nonempty('Pick at least one'))
149+
.transform(a => Array.from(new Set(a))) // dedupe deterministically
150+
151+
const hint = `${label} (comma/space) ${options.map((o, i) => `[${i + 1}:${o}]`).join(' ')}`
152+
return ask(hint, defaultValues.join(','), arr)
153+
}
154+
155+
const _name = await textInput('Name', 'John Doe')
156+
const _age = await numberInput('Age', 30)
157+
const _subscribe = await booleanInput('Subscribe to newsletter', true)
158+
159+
const _color = await selectInput('Color', ['red', 'green', 'blue'] as const, 'green')
160+
const _toppings = await multiSelectInput(
161+
'Toppings',
162+
['onion', 'olive', 'mushroom', 'pepper'] as const,
163+
['olive']
164+
)
165+
166+
console.log({ _age, _color, _name, _subscribe, _toppings })
167+
168+
terminate()

lib/ui/heading.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
const GLYPH_COLUMNS = 3
2+
const GLYPH_ROWS = 5
3+
const PIXEL = '█'
4+
5+
const FONT: Record<string, readonly string[]> = {
6+
' ': ['000', '000', '000', '000', '000'],
7+
'-': ['000', '000', '111', '000', '000'],
8+
'!': ['010', '010', '010', '000', '010'],
9+
'?': ['111', '001', '011', '000', '010'],
10+
'0': ['111', '101', '101', '101', '111'],
11+
'1': ['010', '110', '010', '010', '111'],
12+
'2': ['111', '001', '111', '100', '111'],
13+
'3': ['111', '001', '111', '001', '111'],
14+
'4': ['101', '101', '111', '001', '001'],
15+
'5': ['111', '100', '111', '001', '111'],
16+
'6': ['111', '100', '111', '101', '111'],
17+
'7': ['111', '001', '010', '010', '010'],
18+
'8': ['111', '101', '111', '101', '111'],
19+
'9': ['111', '101', '111', '001', '111'],
20+
A: ['010', '101', '111', '101', '101'],
21+
B: ['110', '101', '110', '101', '110'],
22+
C: ['011', '100', '100', '100', '011'],
23+
D: ['110', '101', '101', '101', '110'],
24+
E: ['111', '100', '110', '100', '111'],
25+
F: ['111', '100', '110', '100', '100'],
26+
G: ['011', '100', '101', '101', '011'],
27+
H: ['101', '101', '111', '101', '101'],
28+
I: ['111', '010', '010', '010', '111'],
29+
J: ['001', '001', '001', '101', '010'],
30+
K: ['101', '110', '100', '110', '101'],
31+
L: ['100', '100', '100', '100', '111'],
32+
M: ['111', '111', '101', '101', '101'],
33+
N: ['101', '111', '111', '111', '101'],
34+
O: ['010', '101', '101', '101', '010'],
35+
P: ['110', '101', '110', '100', '100'],
36+
Q: ['010', '101', '101', '111', '011'],
37+
R: ['110', '101', '110', '110', '101'],
38+
S: ['011', '100', '010', '001', '110'],
39+
T: ['111', '010', '010', '010', '010'],
40+
U: ['101', '101', '101', '101', '111'],
41+
V: ['101', '101', '101', '010', '010'],
42+
W: ['101', '101', '101', '111', '111'],
43+
X: ['101', '010', '010', '010', '101'],
44+
Y: ['101', '101', '010', '010', '010'],
45+
Z: ['111', '001', '010', '100', '111']
46+
} as const
47+
48+
function renderBlock(text: string): string[] {
49+
const rows = Array.from({ length: GLYPH_ROWS }, () => '')
50+
for (const ch of text.toUpperCase()) {
51+
const glyph = FONT[ch] ?? FONT['?']
52+
for (let r = 0; r < GLYPH_ROWS; r++)
53+
rows[r] += `${glyph[r].replace(/1/g, PIXEL).replace(/0/g, ' ')} `
54+
}
55+
return rows.map(r => r.trimEnd())
56+
}
57+
58+
function widthOf(text: string): number {
59+
const perChar = GLYPH_COLUMNS + 1 // 3 pixels + 1 space
60+
return Math.max(0, text.length * perChar - 1)
61+
}
62+
63+
function wrapByWords(text: string, maxWidth: number): string[] {
64+
const words = text.trim().split(/\s+/)
65+
const lines: string[] = []
66+
const perChar = GLYPH_COLUMNS + 1
67+
const spaceChars = 1
68+
69+
let currentChars = 0
70+
let currentWords: string[] = []
71+
72+
for (const word of words) {
73+
const nextChars = (currentWords.length ? spaceChars : 0) + word.length
74+
const nextWidth = (currentChars + nextChars) * perChar - 1
75+
if (currentWords.length && nextWidth > maxWidth) {
76+
lines.push(currentWords.join(' '))
77+
currentWords = [word]
78+
currentChars = word.length
79+
} else {
80+
currentWords.push(word)
81+
currentChars += nextChars
82+
}
83+
}
84+
if (currentWords.length) lines.push(currentWords.join(' '))
85+
return lines
86+
}
87+
88+
export function heading(text: string): void {
89+
const columns = process.stdout.columns ?? 80
90+
91+
const words = text.trim().split(/\s+/)
92+
if (Math.max(...words.map(w => widthOf(w))) > columns) {
93+
process.stdout.write(`\n${text}\n\n`)
94+
return
95+
}
96+
97+
const lines = wrapByWords(text, columns)
98+
99+
process.stdout.write('\n')
100+
lines.forEach((line, index) => {
101+
const block = renderBlock(line)
102+
process.stdout.write(`${block.join('\n')}\n`)
103+
if (index < lines.length - 1) {
104+
const w = Math.min(widthOf(line), columns)
105+
process.stdout.write(`${'─'.repeat(w)}\n`)
106+
}
107+
})
108+
process.stdout.write('\n')
109+
}
110+
111+
heading('create rubric app')

0 commit comments

Comments
 (0)