agent-facets 0.1.1
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/CHANGELOG.md +30 -0
- package/bunfig.toml +2 -0
- package/dist/facet +0 -0
- package/dist/facet-ink-test +0 -0
- package/package.json +41 -0
- package/src/__tests__/cli.test.ts +152 -0
- package/src/__tests__/create-build.test.ts +207 -0
- package/src/commands/build.ts +45 -0
- package/src/commands/create/index.ts +30 -0
- package/src/commands/create/types.ts +9 -0
- package/src/commands/create/wizard.tsx +24 -0
- package/src/commands/create-scaffold.ts +180 -0
- package/src/commands.ts +31 -0
- package/src/help.ts +36 -0
- package/src/index.ts +9 -0
- package/src/run.ts +55 -0
- package/src/suggest.ts +35 -0
- package/src/tui/components/asset-inline-input.tsx +79 -0
- package/src/tui/components/asset-item.tsx +48 -0
- package/src/tui/components/asset-section.tsx +145 -0
- package/src/tui/components/button.tsx +92 -0
- package/src/tui/components/editable-field.tsx +172 -0
- package/src/tui/components/exit-toast.tsx +20 -0
- package/src/tui/components/stage-row.tsx +33 -0
- package/src/tui/components/version-selector.tsx +79 -0
- package/src/tui/context/focus-mode-context.ts +36 -0
- package/src/tui/context/focus-order-context.ts +62 -0
- package/src/tui/context/form-state-context.ts +229 -0
- package/src/tui/gradient.ts +1 -0
- package/src/tui/hooks/use-exit-keys.ts +75 -0
- package/src/tui/hooks/use-navigation-keys.ts +34 -0
- package/src/tui/layouts/wizard-layout.tsx +41 -0
- package/src/tui/theme.ts +1 -0
- package/src/tui/views/build/build-view.tsx +153 -0
- package/src/tui/views/create/confirm-view.tsx +74 -0
- package/src/tui/views/create/create-view.tsx +154 -0
- package/src/tui/views/create/wizard.tsx +68 -0
- package/src/version.ts +3 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { FACET_MANIFEST_FILE } from '@agent-facets/core'
|
|
4
|
+
|
|
5
|
+
// --- Types ---
|
|
6
|
+
|
|
7
|
+
export interface CreateOptions {
|
|
8
|
+
name: string
|
|
9
|
+
version: string
|
|
10
|
+
description: string
|
|
11
|
+
skills: string[]
|
|
12
|
+
agents: string[]
|
|
13
|
+
commands: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// --- Validation ---
|
|
17
|
+
|
|
18
|
+
export const KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
|
|
19
|
+
export const SEMVER = /^\d+\.\d+\.\d+$/
|
|
20
|
+
|
|
21
|
+
export function isValidKebabCase(value: string): boolean {
|
|
22
|
+
return KEBAB_CASE.test(value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isValidSemVer(value: string): boolean {
|
|
26
|
+
return SEMVER.test(value)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --- Template generation ---
|
|
30
|
+
|
|
31
|
+
function toTitleCase(kebab: string): string {
|
|
32
|
+
return kebab
|
|
33
|
+
.split('-')
|
|
34
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
35
|
+
.join(' ')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function skillTemplate(name: string): string {
|
|
39
|
+
return `# ${toTitleCase(name)}
|
|
40
|
+
|
|
41
|
+
<!-- This is a starter skill template. Replace this content with your skill's instructions. -->
|
|
42
|
+
<!-- Skills provide reusable knowledge and guidelines that agents and commands can reference. -->
|
|
43
|
+
<!-- A skill needs a description (required) — the description helps consumers decide -->
|
|
44
|
+
<!-- whether to use this skill. The prompt content is this file. -->
|
|
45
|
+
|
|
46
|
+
## Purpose
|
|
47
|
+
|
|
48
|
+
Describe what this skill teaches or what guidelines it provides.
|
|
49
|
+
|
|
50
|
+
## Guidelines
|
|
51
|
+
|
|
52
|
+
- Add your skill's guidelines here
|
|
53
|
+
- Each guideline should be clear and actionable
|
|
54
|
+
`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function agentTemplate(name: string): string {
|
|
58
|
+
return `# ${toTitleCase(name)}
|
|
59
|
+
|
|
60
|
+
<!-- This is a starter agent template. Replace this content with your agent's prompt. -->
|
|
61
|
+
<!-- Agents are AI assistant personas with specific roles, behaviors, and tool access. -->
|
|
62
|
+
|
|
63
|
+
## Role
|
|
64
|
+
|
|
65
|
+
Describe this agent's role and responsibilities.
|
|
66
|
+
|
|
67
|
+
## Behavior
|
|
68
|
+
|
|
69
|
+
- Define how this agent should behave
|
|
70
|
+
- Specify what tools it should use
|
|
71
|
+
- Describe its communication style
|
|
72
|
+
`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function commandTemplate(name: string): string {
|
|
76
|
+
return `# ${toTitleCase(name)}
|
|
77
|
+
|
|
78
|
+
<!-- This is a starter command template. Replace this content with your command's prompt. -->
|
|
79
|
+
<!-- Commands are user-invokable actions that perform specific tasks. -->
|
|
80
|
+
|
|
81
|
+
## Task
|
|
82
|
+
|
|
83
|
+
Describe what this command does when invoked.
|
|
84
|
+
|
|
85
|
+
## Steps
|
|
86
|
+
|
|
87
|
+
1. First step
|
|
88
|
+
2. Second step
|
|
89
|
+
3. Final step
|
|
90
|
+
`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- Manifest generation ---
|
|
94
|
+
|
|
95
|
+
export function generateManifest(opts: CreateOptions): string {
|
|
96
|
+
const manifest: Record<string, unknown> = {
|
|
97
|
+
name: opts.name,
|
|
98
|
+
version: opts.version,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (opts.description) {
|
|
102
|
+
manifest.description = opts.description
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (opts.skills.length > 0) {
|
|
106
|
+
const skills: Record<string, { description: string }> = {}
|
|
107
|
+
for (const skill of opts.skills) {
|
|
108
|
+
skills[skill] = { description: `A ${toTitleCase(skill)} skill` }
|
|
109
|
+
}
|
|
110
|
+
manifest.skills = skills
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (opts.agents.length > 0) {
|
|
114
|
+
const agents: Record<string, { description: string }> = {}
|
|
115
|
+
for (const agent of opts.agents) {
|
|
116
|
+
agents[agent] = { description: `A ${toTitleCase(agent)} agent` }
|
|
117
|
+
}
|
|
118
|
+
manifest.agents = agents
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (opts.commands.length > 0) {
|
|
122
|
+
const commands: Record<string, { description: string }> = {}
|
|
123
|
+
for (const command of opts.commands) {
|
|
124
|
+
commands[command] = { description: `A ${toTitleCase(command)} command` }
|
|
125
|
+
}
|
|
126
|
+
manifest.commands = commands
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return JSON.stringify(manifest, null, 2)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- File listing preview ---
|
|
133
|
+
|
|
134
|
+
export function previewFiles(opts: CreateOptions): string[] {
|
|
135
|
+
const files: string[] = [FACET_MANIFEST_FILE]
|
|
136
|
+
for (const skill of opts.skills) {
|
|
137
|
+
files.push(`skills/${skill}.md`)
|
|
138
|
+
}
|
|
139
|
+
for (const agent of opts.agents) {
|
|
140
|
+
files.push(`agents/${agent}.md`)
|
|
141
|
+
}
|
|
142
|
+
for (const command of opts.commands) {
|
|
143
|
+
files.push(`commands/${command}.md`)
|
|
144
|
+
}
|
|
145
|
+
return files
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- Scaffold writing ---
|
|
149
|
+
|
|
150
|
+
export async function writeScaffold(opts: CreateOptions, targetDir: string): Promise<string[]> {
|
|
151
|
+
const files: string[] = []
|
|
152
|
+
|
|
153
|
+
// Write manifest
|
|
154
|
+
const manifestPath = join(targetDir, FACET_MANIFEST_FILE)
|
|
155
|
+
await Bun.write(manifestPath, generateManifest(opts))
|
|
156
|
+
files.push(FACET_MANIFEST_FILE)
|
|
157
|
+
|
|
158
|
+
// Write skill files
|
|
159
|
+
for (const skill of opts.skills) {
|
|
160
|
+
await mkdir(join(targetDir, 'skills'), { recursive: true })
|
|
161
|
+
await Bun.write(join(targetDir, `skills/${skill}.md`), skillTemplate(skill))
|
|
162
|
+
files.push(`skills/${skill}.md`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Write agent files
|
|
166
|
+
for (const agent of opts.agents) {
|
|
167
|
+
await mkdir(join(targetDir, 'agents'), { recursive: true })
|
|
168
|
+
await Bun.write(join(targetDir, `agents/${agent}.md`), agentTemplate(agent))
|
|
169
|
+
files.push(`agents/${agent}.md`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Write command files
|
|
173
|
+
for (const command of opts.commands) {
|
|
174
|
+
await mkdir(join(targetDir, 'commands'), { recursive: true })
|
|
175
|
+
await Bun.write(join(targetDir, `commands/${command}.md`), commandTemplate(command))
|
|
176
|
+
files.push(`commands/${command}.md`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return files
|
|
180
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { buildCommand } from './commands/build.ts'
|
|
2
|
+
import { createCommand } from './commands/create/index.ts'
|
|
3
|
+
|
|
4
|
+
export type Command = {
|
|
5
|
+
name: string
|
|
6
|
+
description: string
|
|
7
|
+
run: (args: string[]) => Promise<number>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function stubCommand(name: string, description: string): Command {
|
|
11
|
+
return {
|
|
12
|
+
name,
|
|
13
|
+
description,
|
|
14
|
+
run: async () => {
|
|
15
|
+
console.log(`"${name}" is not yet implemented.`)
|
|
16
|
+
return 0
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const commands: Record<string, Command> = {
|
|
22
|
+
add: stubCommand('add', 'Add a facet to the project'),
|
|
23
|
+
build: buildCommand,
|
|
24
|
+
create: createCommand,
|
|
25
|
+
info: stubCommand('info', 'Show information about a facet'),
|
|
26
|
+
install: stubCommand('install', 'Install all facets from the lockfile'),
|
|
27
|
+
list: stubCommand('list', 'List installed facets'),
|
|
28
|
+
publish: stubCommand('publish', 'Publish a facet to the registry'),
|
|
29
|
+
remove: stubCommand('remove', 'Remove a facet from the project'),
|
|
30
|
+
upgrade: stubCommand('upgrade', 'Upgrade installed facets'),
|
|
31
|
+
}
|
package/src/help.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Command } from './commands.ts'
|
|
2
|
+
import { commands } from './commands.ts'
|
|
3
|
+
import { version } from './version.ts'
|
|
4
|
+
|
|
5
|
+
export function printGlobalHelp(): void {
|
|
6
|
+
const entries = Object.values(commands)
|
|
7
|
+
const maxNameLength = Math.max(...entries.map((c) => c.name.length))
|
|
8
|
+
|
|
9
|
+
const lines = [
|
|
10
|
+
`facet v${version}`,
|
|
11
|
+
'',
|
|
12
|
+
'Usage: facet <command> [options]',
|
|
13
|
+
'',
|
|
14
|
+
'Commands:',
|
|
15
|
+
...entries.map((c) => ` ${c.name.padEnd(maxNameLength + 2)}${c.description}`),
|
|
16
|
+
'',
|
|
17
|
+
'Options:',
|
|
18
|
+
' --help Show help',
|
|
19
|
+
' --version Show version',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
console.log(lines.join('\n'))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function printCommandHelp(command: Command): void {
|
|
26
|
+
const lines = [
|
|
27
|
+
`Usage: facet ${command.name} [options]`,
|
|
28
|
+
'',
|
|
29
|
+
` ${command.description}`,
|
|
30
|
+
'',
|
|
31
|
+
'Options:',
|
|
32
|
+
' --help Show help',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
console.log(lines.join('\n'))
|
|
36
|
+
}
|
package/src/index.ts
ADDED
package/src/run.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { parse } from '@bomb.sh/args'
|
|
2
|
+
import { commands } from './commands.ts'
|
|
3
|
+
import { printCommandHelp, printGlobalHelp } from './help.ts'
|
|
4
|
+
import { findClosestCommand } from './suggest.ts'
|
|
5
|
+
import { version } from './version.ts'
|
|
6
|
+
|
|
7
|
+
export async function run(argv: string[]): Promise<number> {
|
|
8
|
+
const args = parse(argv, {
|
|
9
|
+
boolean: ['help', 'version'],
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
if (args.version) {
|
|
13
|
+
console.log(version)
|
|
14
|
+
return 0
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const commandName = String(args._[0] ?? '')
|
|
18
|
+
|
|
19
|
+
// No command given — show global help
|
|
20
|
+
if (!commandName) {
|
|
21
|
+
printGlobalHelp()
|
|
22
|
+
return 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Explicit `help` command: `facets help` or `facets help build`
|
|
26
|
+
if (commandName === 'help') {
|
|
27
|
+
const subCommandName = String(args._[1] ?? '')
|
|
28
|
+
const subCommand = subCommandName ? commands[subCommandName] : undefined
|
|
29
|
+
if (subCommand) {
|
|
30
|
+
printCommandHelp(subCommand)
|
|
31
|
+
} else {
|
|
32
|
+
printGlobalHelp()
|
|
33
|
+
}
|
|
34
|
+
return 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const command = commands[commandName]
|
|
38
|
+
|
|
39
|
+
if (!command) {
|
|
40
|
+
const suggestion = findClosestCommand(commandName, Object.keys(commands))
|
|
41
|
+
const message = suggestion
|
|
42
|
+
? `Unknown command "${commandName}". Did you mean "${suggestion}"?`
|
|
43
|
+
: `Unknown command "${commandName}".`
|
|
44
|
+
console.error(message)
|
|
45
|
+
return 1
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Per-command help: `facets build --help`
|
|
49
|
+
if (args.help) {
|
|
50
|
+
printCommandHelp(command)
|
|
51
|
+
return 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return command.run(argv.slice(1))
|
|
55
|
+
}
|
package/src/suggest.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
function levenshtein(a: string, b: string): number {
|
|
2
|
+
const m = a.length
|
|
3
|
+
const n = b.length
|
|
4
|
+
const row: number[] = []
|
|
5
|
+
|
|
6
|
+
for (let j = 0; j <= n; j++) row.push(j)
|
|
7
|
+
|
|
8
|
+
for (let i = 1; i <= m; i++) {
|
|
9
|
+
let prev = row[0] ?? 0
|
|
10
|
+
row[0] = i
|
|
11
|
+
for (let j = 1; j <= n; j++) {
|
|
12
|
+
const current = row[j] ?? 0
|
|
13
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
14
|
+
row[j] = Math.min(current + 1, (row[j - 1] ?? 0) + 1, prev + cost)
|
|
15
|
+
prev = current
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return row[n] ?? 0
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function findClosestCommand(input: string, commandNames: string[]): string | undefined {
|
|
23
|
+
let best: string | undefined
|
|
24
|
+
let bestDistance = Infinity
|
|
25
|
+
|
|
26
|
+
for (const name of commandNames) {
|
|
27
|
+
const distance = levenshtein(input, name)
|
|
28
|
+
if (distance < bestDistance) {
|
|
29
|
+
bestDistance = distance
|
|
30
|
+
best = name
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return bestDistance <= 3 ? best : undefined
|
|
35
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Box, Text, useInput } from 'ink'
|
|
2
|
+
import TextInput from 'ink-text-input'
|
|
3
|
+
import { THEME } from '../theme.ts'
|
|
4
|
+
|
|
5
|
+
export function AssetInlineInput({
|
|
6
|
+
value,
|
|
7
|
+
placeholder,
|
|
8
|
+
error,
|
|
9
|
+
isFocused,
|
|
10
|
+
onChange,
|
|
11
|
+
validate,
|
|
12
|
+
onError,
|
|
13
|
+
onSubmit,
|
|
14
|
+
onCancel,
|
|
15
|
+
}: {
|
|
16
|
+
id: string
|
|
17
|
+
value: string
|
|
18
|
+
placeholder?: string
|
|
19
|
+
error: string
|
|
20
|
+
isFocused: boolean
|
|
21
|
+
onChange: (value: string) => void
|
|
22
|
+
validate?: (value: string) => string | undefined
|
|
23
|
+
onError: (error: string) => void
|
|
24
|
+
onSubmit: (name: string) => void
|
|
25
|
+
onCancel: () => void
|
|
26
|
+
}) {
|
|
27
|
+
useInput(
|
|
28
|
+
(_input, key) => {
|
|
29
|
+
if (!isFocused) return
|
|
30
|
+
if (key.return) {
|
|
31
|
+
const name = value || placeholder || ''
|
|
32
|
+
if (!name) {
|
|
33
|
+
onCancel()
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
if (validate) {
|
|
37
|
+
const err = validate(name)
|
|
38
|
+
if (err) {
|
|
39
|
+
onError(err)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
onSubmit(name)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
if (key.escape) {
|
|
47
|
+
onCancel()
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
if (key.tab && !value && placeholder) {
|
|
51
|
+
onChange(placeholder)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
{ isActive: isFocused },
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Box marginLeft={2} gap={1}>
|
|
60
|
+
<Text color={THEME.tertiary}>{'>'}</Text>
|
|
61
|
+
<TextInput value={value} onChange={onChange} placeholder={placeholder} focus={isFocused} />
|
|
62
|
+
{error ? (
|
|
63
|
+
<Text color={THEME.warning}>· {error}</Text>
|
|
64
|
+
) : value ? (
|
|
65
|
+
<Text color={THEME.hint}>
|
|
66
|
+
· <Text color={THEME.keyword}>Enter</Text> to save · <Text color={THEME.keyword}>Escape</Text> to revert
|
|
67
|
+
</Text>
|
|
68
|
+
) : placeholder ? (
|
|
69
|
+
<Text color={THEME.hint}>
|
|
70
|
+
· <Text color={THEME.keyword}>Tab</Text> to fill · <Text color={THEME.keyword}>Escape</Text> to cancel
|
|
71
|
+
</Text>
|
|
72
|
+
) : (
|
|
73
|
+
<Text color={THEME.hint}>
|
|
74
|
+
· <Text color={THEME.keyword}>Enter</Text> to add · <Text color={THEME.keyword}>Escape</Text> to cancel
|
|
75
|
+
</Text>
|
|
76
|
+
)}
|
|
77
|
+
</Box>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Box, Text, useInput } from 'ink'
|
|
2
|
+
import { THEME } from '../theme.ts'
|
|
3
|
+
|
|
4
|
+
export function AssetItem({
|
|
5
|
+
name,
|
|
6
|
+
isFocused,
|
|
7
|
+
onEdit,
|
|
8
|
+
onRemove,
|
|
9
|
+
}: {
|
|
10
|
+
id: string
|
|
11
|
+
name: string
|
|
12
|
+
isFocused: boolean
|
|
13
|
+
onEdit: () => void
|
|
14
|
+
onRemove: () => void
|
|
15
|
+
}) {
|
|
16
|
+
useInput(
|
|
17
|
+
(_input, key) => {
|
|
18
|
+
if (key.return) {
|
|
19
|
+
onEdit()
|
|
20
|
+
}
|
|
21
|
+
if (key.delete || key.backspace) {
|
|
22
|
+
onRemove()
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
{ isActive: isFocused },
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Box gap={1} marginLeft={2}>
|
|
30
|
+
{isFocused ? (
|
|
31
|
+
<>
|
|
32
|
+
<Text color={THEME.primary} bold>
|
|
33
|
+
▸
|
|
34
|
+
</Text>
|
|
35
|
+
<Text color={THEME.primary}>{name}</Text>
|
|
36
|
+
<Text color={THEME.hint}>
|
|
37
|
+
<Text color={THEME.keyword}>Enter</Text> to edit · <Text color={THEME.keyword}>Delete</Text> to remove
|
|
38
|
+
</Text>
|
|
39
|
+
</>
|
|
40
|
+
) : (
|
|
41
|
+
<>
|
|
42
|
+
<Text color={THEME.success}>•</Text>
|
|
43
|
+
<Text>{name}</Text>
|
|
44
|
+
</>
|
|
45
|
+
)}
|
|
46
|
+
</Box>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { useFocusMode } from '../context/focus-mode-context.ts'
|
|
4
|
+
import { useFocusOrder } from '../context/focus-order-context.ts'
|
|
5
|
+
import type { AssetSectionKey } from '../context/form-state-context.ts'
|
|
6
|
+
import { useFormState } from '../context/form-state-context.ts'
|
|
7
|
+
import { AssetInlineInput } from './asset-inline-input.tsx'
|
|
8
|
+
import { AssetItem } from './asset-item.tsx'
|
|
9
|
+
import { Button } from './button.tsx'
|
|
10
|
+
|
|
11
|
+
export function AssetSection({
|
|
12
|
+
section,
|
|
13
|
+
label,
|
|
14
|
+
defaultName,
|
|
15
|
+
dimmed,
|
|
16
|
+
validate,
|
|
17
|
+
}: {
|
|
18
|
+
section: AssetSectionKey
|
|
19
|
+
label: string
|
|
20
|
+
defaultName?: string
|
|
21
|
+
dimmed?: boolean
|
|
22
|
+
validate?: (value: string) => string | undefined
|
|
23
|
+
}) {
|
|
24
|
+
const { form, addAsset, removeAsset, renameAsset, setAssetAdding, setAssetEditing } = useFormState()
|
|
25
|
+
const { items, editing, adding } = form.assets[section]
|
|
26
|
+
const { setMode } = useFocusMode()
|
|
27
|
+
const { focusedId, focus } = useFocusOrder()
|
|
28
|
+
const [inputValue, setInputValue] = useState('')
|
|
29
|
+
const [error, setError] = useState('')
|
|
30
|
+
|
|
31
|
+
const startAdding = () => {
|
|
32
|
+
setAssetAdding(section, true)
|
|
33
|
+
setInputValue('')
|
|
34
|
+
setError('')
|
|
35
|
+
setMode('field-revision')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const startEditing = (name: string) => {
|
|
39
|
+
setAssetEditing(section, name)
|
|
40
|
+
setInputValue(name)
|
|
41
|
+
setError('')
|
|
42
|
+
setMode('field-revision')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const closeInput = () => {
|
|
46
|
+
setAssetAdding(section, false)
|
|
47
|
+
setAssetEditing(section, undefined)
|
|
48
|
+
setInputValue('')
|
|
49
|
+
setError('')
|
|
50
|
+
setMode('form-navigation')
|
|
51
|
+
focus(`add-${section}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleRemove = (name: string) => {
|
|
55
|
+
const index = items.indexOf(name)
|
|
56
|
+
removeAsset(section, name)
|
|
57
|
+
|
|
58
|
+
if (index < items.length - 1) {
|
|
59
|
+
focus(`item-${section}-${index}`)
|
|
60
|
+
} else if (index > 0) {
|
|
61
|
+
focus(`item-${section}-${index - 1}`)
|
|
62
|
+
} else {
|
|
63
|
+
focus(`add-${section}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Box flexDirection="column" gap={0}>
|
|
69
|
+
<Box gap={1}>
|
|
70
|
+
<Text bold dimColor={dimmed}>
|
|
71
|
+
{label}
|
|
72
|
+
</Text>
|
|
73
|
+
{items.length === 0 && !adding && <Text dimColor>(none)</Text>}
|
|
74
|
+
</Box>
|
|
75
|
+
|
|
76
|
+
{items.map((item, i) => {
|
|
77
|
+
const itemId = `item-${section}-${i}`
|
|
78
|
+
const isFocusedItem = focusedId === itemId
|
|
79
|
+
|
|
80
|
+
if (editing === item) {
|
|
81
|
+
return (
|
|
82
|
+
<AssetInlineInput
|
|
83
|
+
key={itemId}
|
|
84
|
+
id={itemId}
|
|
85
|
+
value={inputValue}
|
|
86
|
+
placeholder={item}
|
|
87
|
+
error={error}
|
|
88
|
+
isFocused={isFocusedItem}
|
|
89
|
+
onChange={setInputValue}
|
|
90
|
+
validate={validate}
|
|
91
|
+
onError={setError}
|
|
92
|
+
onSubmit={(newName) => {
|
|
93
|
+
renameAsset(section, item, newName)
|
|
94
|
+
closeInput()
|
|
95
|
+
}}
|
|
96
|
+
onCancel={closeInput}
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<AssetItem
|
|
103
|
+
key={itemId}
|
|
104
|
+
id={itemId}
|
|
105
|
+
name={item}
|
|
106
|
+
isFocused={isFocusedItem}
|
|
107
|
+
onEdit={() => startEditing(item)}
|
|
108
|
+
onRemove={() => handleRemove(item)}
|
|
109
|
+
/>
|
|
110
|
+
)
|
|
111
|
+
})}
|
|
112
|
+
|
|
113
|
+
{adding ? (
|
|
114
|
+
<AssetInlineInput
|
|
115
|
+
id={`add-${section}`}
|
|
116
|
+
value={inputValue}
|
|
117
|
+
placeholder={defaultName}
|
|
118
|
+
error={error}
|
|
119
|
+
isFocused={focusedId === `add-${section}`}
|
|
120
|
+
onChange={setInputValue}
|
|
121
|
+
validate={validate}
|
|
122
|
+
onError={setError}
|
|
123
|
+
onSubmit={(name) => {
|
|
124
|
+
addAsset(section, name)
|
|
125
|
+
closeInput()
|
|
126
|
+
}}
|
|
127
|
+
onCancel={closeInput}
|
|
128
|
+
/>
|
|
129
|
+
) : (
|
|
130
|
+
<Box marginLeft={2}>
|
|
131
|
+
<Button
|
|
132
|
+
id={`add-${section}`}
|
|
133
|
+
label="+ Add"
|
|
134
|
+
hint={
|
|
135
|
+
<Text dimColor>
|
|
136
|
+
<Text>Enter</Text> to add
|
|
137
|
+
</Text>
|
|
138
|
+
}
|
|
139
|
+
onPress={startAdding}
|
|
140
|
+
/>
|
|
141
|
+
</Box>
|
|
142
|
+
)}
|
|
143
|
+
</Box>
|
|
144
|
+
)
|
|
145
|
+
}
|