diff --git a/.changeset/humble-gifts-beam.md b/.changeset/humble-gifts-beam.md new file mode 100644 index 0000000..1d88f81 --- /dev/null +++ b/.changeset/humble-gifts-beam.md @@ -0,0 +1,5 @@ +--- +"stack-effect": patch +--- + +add ModuleChild schema for nested module selection diff --git a/.changeset/tough-sloths-mate.md b/.changeset/tough-sloths-mate.md new file mode 100644 index 0000000..3b3aff1 --- /dev/null +++ b/.changeset/tough-sloths-mate.md @@ -0,0 +1,5 @@ +--- +"stack-effect": patch +--- + +add Think toolkit as default toolkit for chat service diff --git a/.docs/architecture/catalog-architecture.md b/.docs/architecture/catalog-architecture.md index d816750..9f69afa 100644 --- a/.docs/architecture/catalog-architecture.md +++ b/.docs/architecture/catalog-architecture.md @@ -75,6 +75,11 @@ Defines modules organized by category: - **Infrastructure modules**: ai, ai-sample-toolkit, ai-chat-service, presence (supported on specific package identities) +Modules may declare `children` for same-target parent-child relationships used +in nested selection UI. Children are either `required` (auto-selected with +parent) or `optional` (user-toggleable). Modules listed as children are excluded +from top-level selection lists. + ### Content Templates Template files in `content/` use a token substitution system: @@ -110,6 +115,11 @@ erDiagram string description } + ModuleChild { + ModuleId moduleId + string requirement "required | optional" + } + SupportedOn { string _tag "kind | identity" TargetKind kind "optional" @@ -135,6 +145,7 @@ erDiagram ModuleDefinition ||--o{ SupportedOn : "supportedOn" ModuleDefinition ||--o{ ModuleDefinition : "dependencies" ModuleDefinition ||--o{ ModuleImplication : "implies" + ModuleDefinition ||--o{ ModuleChild : "children" ``` ## Catalog Graph Structure @@ -175,6 +186,7 @@ graph TB - `supportedOn`: Module can attach to target - `requiredModule`: Module depends on another module - `implies`: Module implies another module on a different target +- `childOf`: Module is a child of another module (same-target parent-child) ## Dependency Chains @@ -205,9 +217,13 @@ flowchart LR E -->|requires| B E -->|requires| C C -->|requires| B - C -->|requires| D + C -.->|optional child| D ``` +Note: `ai-sample-toolkit` is an **optional child** of `ai-chat-service`, shown +in nested selection UI when the parent is selected. This differs from +dependencies which are always resolved by BlueprintService. + ## Integration Points The catalog is consumed by: diff --git a/.docs/domain-lexicon.md b/.docs/domain-lexicon.md index b8a6a1c..5752a71 100644 --- a/.docs/domain-lexicon.md +++ b/.docs/domain-lexicon.md @@ -184,15 +184,36 @@ Invariants: - Identity is `id: ModuleId`. - Compatibility is declared via `SupportedOn` rules. - Dependencies can require a target, a module attachment, or both. +- Children declare same-target parent-child relationships for nested selection UI. Connected terms: -- `ModuleId`, `SupportedOn`, `DesiredContributions`, `ModuleImplication` +- `ModuleId`, `SupportedOn`, `DesiredContributions`, `ModuleImplication`, `ModuleChild` In code: - `ModuleDefinition` +### ModuleChild + +Definition: +A parent-child relationship between modules on the same target, used to organize nested selection in interactive CLI flows. + +Invariants: + +- Children must share at least one `SupportedOn` rule with their parent (same-target constraint). +- Requirement is either `"required"` (auto-selected when parent selected, not user-toggleable) or `"optional"` (user can toggle). +- A module listed as a child is excluded from top-level selection lists (inferred from parent relationship). +- Children are a UI/selection concept only; they do not affect Blueprint dependency resolution. + +Connected terms: + +- `ModuleDefinition`, `ModuleId`, `Visibility` + +In code: + +- `ModuleChild` + ## Resolution-Space Terms ### BlueprintTargetNode diff --git a/.docs/how-to/add-new-module.md b/.docs/how-to/add-new-module.md index 034ba6b..e3b3ccb 100644 --- a/.docs/how-to/add-new-module.md +++ b/.docs/how-to/add-new-module.md @@ -238,7 +238,37 @@ different target kind, add the `implies` field: This means: when `http-api-client` is selected on a client target, the blueprint will also include `http-api-server` on any server target. -## Step 7: Verify +## Step 7: Add Children (Optional) + +If this module should have sub-modules that appear nested in the selection UI, +add the `children` field. Children must be on the **same target** as the parent. + +```typescript +{ + id: ModuleId.make("ai-chat-service"), + // ... other fields + children: [ + { moduleId: ModuleId.make("ai-sample-toolkit"), requirement: "optional" }, + { moduleId: ModuleId.make("ai-weather-toolkit"), requirement: "optional" }, + ], +} +``` + +**Requirement types:** + +| Value | Behavior | +| ---------- | ----------------------------------------------------- | +| `required` | Auto-selected when parent selected, not user-toggleable | +| `optional` | User can toggle on/off when parent is selected | + +**Key points:** + +- Children are a **UI concept only** - they don't affect Blueprint resolution +- Modules listed as children are **excluded from top-level** selection lists +- Children must share at least one `supportedOn` rule with their parent +- For cross-target relationships, use `dependencies` or `implies` instead + +## Step 8: Verify Run the validation suite: diff --git a/.docs/ubiquitous-language.md b/.docs/ubiquitous-language.md index 7ab8e30..0aefa97 100644 --- a/.docs/ubiquitous-language.md +++ b/.docs/ubiquitous-language.md @@ -197,6 +197,23 @@ See also: - `TargetDefinition`, `ModuleDefinition` +## ModuleChild + +Say: + +- "ModuleChild declares a parent-child relationship between modules on the same target for nested selection." +- "Required children are auto-selected when the parent is selected; optional children are user-toggleable." +- "Children are inferred from parent relationships and excluded from top-level selection." + +Avoid: + +- Confusing children with dependencies (children are UI-only, dependencies affect Blueprint resolution) +- Using children for cross-target relationships (use dependencies or implications instead) + +See also: + +- `ModuleDefinition`, `Visibility` + ## Ambiguities We Explicitly Resolve - `Selection` means user intent; `Blueprint` means resolved implication. diff --git a/apps/cli/e2e/matrix.test.ts b/apps/cli/e2e/matrix.test.ts index 19d5031..e5f9625 100644 --- a/apps/cli/e2e/matrix.test.ts +++ b/apps/cli/e2e/matrix.test.ts @@ -1,6 +1,6 @@ import { describe, layer } from "@effect/vitest"; import { CatalogService } from "@repo/catalog"; -import { TargetKind } from "@repo/domain/Catalog"; +import { ModuleId, TargetKind } from "@repo/domain/Catalog"; import { Effect } from "effect"; import { CLI } from "./harness"; @@ -19,6 +19,20 @@ interface MatrixEntry { readonly label: string; // human-readable test name } +/** + * Entry for testing modules with their optional children. + * Tracks parent module and all optional children to add separately. + */ +interface ChildrenMatrixEntry { + readonly target: string; // "kind/name" for --target + readonly parentModule: string; // parent module ID + readonly optionalChildren: ReadonlyArray<{ + readonly moduleId: string; + readonly childTarget: string; // target for the child module + }>; + readonly label: string; // human-readable test name +} + /** * Canonical target names per kind. Identity-based modules override this * with their specific name (e.g., "package/domain", "package/ai"). @@ -74,7 +88,7 @@ const buildMatrix = Effect.gen(function* () { // For each non-init target kind, get supported modules and group by identity for (const kind of catalog.getTargetKinds({ visibility: "public" })) { const modules = yield* catalog.getSupportedModules(kind); - const grouped = groupModulesByTarget(modules as any); + const grouped = groupModulesByTarget(modules); for (const [target, moduleIds] of grouped) { // Individual module tests @@ -111,6 +125,76 @@ const singleTargetEntries = matrix.filter( !e.target.startsWith("client-foldkit"), ); +// --------------------------------------------------------------------------- +// Build the optional children matrix - tests modules with all optional children +// --------------------------------------------------------------------------- + +/** + * Get the target string for a module based on its supportedOn configuration. + */ +function getModuleTarget(mod: { + supportedOn: ReadonlyArray< + | { _tag: "kind"; kind: string } + | { _tag: "identity"; identity: { kind: string; name: string } } + >; +}): string { + const s = mod.supportedOn[0]; + if (!s) return "unknown/unknown"; + return s._tag === "identity" + ? `${s.identity.kind}/${s.identity.name}` + : `${s.kind}/${defaultTargetNames.get(s.kind) ?? s.kind}`; +} + +const buildChildrenMatrix = Effect.gen(function* () { + const catalog = yield* CatalogService; + const entries: Array = []; + + // Get all modules and find those with optional children + const allModules = catalog.getModules(); + + // Build a lookup map for quick module access + const moduleMap = new Map(allModules.map((m) => [m.id, m])); + + for (const mod of allModules) { + const children = mod.children as + | Array<{ moduleId: string; requirement: "required" | "optional" }> + | undefined; + + if (!children || children.length === 0) continue; + + // Filter to only optional children + const optionalChildren = children.filter( + (c) => c.requirement === "optional", + ); + if (optionalChildren.length === 0) continue; + + // Get target for parent module + const parentTarget = getModuleTarget(mod); + + // Get targets for each optional child + const childrenWithTargets = optionalChildren.map((child) => { + const childMod = moduleMap.get(ModuleId.make(child.moduleId)); + return { + moduleId: child.moduleId, + childTarget: childMod ? getModuleTarget(childMod) : parentTarget, + }; + }); + + entries.push({ + target: parentTarget, + parentModule: mod.id, + optionalChildren: childrenWithTargets, + label: `${parentTarget} + ${mod.id} (with optional: ${optionalChildren.map((c) => c.moduleId).join(", ")})`, + }); + } + + return entries; +}); + +const childrenMatrix = Effect.runSync( + buildChildrenMatrix.pipe(Effect.provide(CatalogService.layer)), +); + // --------------------------------------------------------------------------- // Full-stack combos: pair each client combo with its required server modules // --------------------------------------------------------------------------- @@ -325,4 +409,60 @@ describe("matrix", () => { ); } }); + + layer(CLI.layer)("modules with optional children (maximal)", (it) => { + for (const entry of childrenMatrix) { + it.effect( + entry.label, + () => + Effect.gen(function* () { + const cli = yield* CLI; + const sluggedModules = [ + entry.parentModule, + ...entry.optionalChildren.map((c) => c.moduleId), + ].join("-"); + const name = `matrix-children-${entry.target.replace("/", "-")}-${sluggedModules}`; + const root = `${cli.workdir}/${name}`; + + // Init project + yield* cli.run("init", name, "--yes", "--root", cli.workdir); + yield* cli.expectExitCode(0); + + // Add parent module to its target + yield* cli.run( + "add", + "--yes", + "--root", + root, + "--target", + entry.target, + "--modules", + entry.parentModule, + ); + yield* cli.expectExitCode(0); + + // Add each optional child to its respective target + for (const child of entry.optionalChildren) { + yield* cli.run( + "add", + "--yes", + "--root", + root, + "--target", + child.childTarget, + "--modules", + child.moduleId, + ); + yield* cli.expectExitCode(0); + } + + // Validate + yield* cli.withinProject(name, function* (project) { + yield* project.expectTypeCheckPasses(); + }); + }).pipe(Effect.provide(CLI.layer)), + { timeout: 180_000 }, + ); + } + }); }); diff --git a/apps/cli/src/commands/add.ts b/apps/cli/src/commands/add.ts index 94a565b..6f22c0d 100644 --- a/apps/cli/src/commands/add.ts +++ b/apps/cli/src/commands/add.ts @@ -1,13 +1,31 @@ import { CatalogService } from "@repo/catalog"; -import { ModuleId, TargetIdentity, TargetKind } from "@repo/domain/Catalog"; +import { + ModuleDefinition, + ModuleId, + TargetIdentity, + TargetKind, +} from "@repo/domain/Catalog"; import type { Selection } from "@repo/domain/Selection"; -import { HorizontalSelect, MultiSelect, Select, TextInput } from "@repo/tui"; import { + HorizontalSelect, + MultiSelect, + type NestedModuleChild, + type NestedModuleNode, + NestedMultiSelect, + Select, + TextInput, +} from "@repo/tui"; +import { + Array as Arr, Console, Effect, FileSystem, + Match, Option, Predicate, + pipe, + Ref, + Result, Schedule, } from "effect"; import { Command, Flag } from "effect/unstable/cli"; @@ -16,12 +34,12 @@ import { dryRunFlag, rootFlag, trustFlag, yesFlag } from "../flags"; import { ConfigureService } from "../service/ConfigureService"; import { ScaffoldPipeline } from "../service/ScaffoldPipeline"; -interface CollectedTarget { +type CollectedTarget = { kind: typeof TargetKind.Type; name: string; modules: Array; confirmed: boolean; -} +}; const targetFlag = Flag.string("target").pipe( Flag.optional, @@ -68,6 +86,171 @@ const formatTargetSummary = ( ); }; +/** + * Build a tree structure for nested module selection showing the full + * cross-target dependency graph. Each module shows its required dependencies + * and implied modules as nested children. + */ +const buildModuleTree = ( + modules: ReadonlyArray, +): Effect.Effect< + ReadonlyArray>, + never, + CatalogService +> => + Effect.gen(function* () { + const catalog = yield* CatalogService; + + // Build tree node recursively, following dependencies and implies + // visited is a Ref for mutable tracking across sibling branches + const buildNode = ( + mod: typeof ModuleDefinition.Type, + requirement: "root" | "required" | "optional", + visitedRef: Ref.Ref>, + ): Effect.Effect< + NestedModuleChild, + never, + CatalogService + > => + Effect.gen(function* () { + const visited = yield* Ref.get(visitedRef); + + // Skip if already visited (cycle prevention) + if (visited.has(mod.id)) { + return { + node: { + id: mod.id, + title: mod.title, + description: mod.description, + value: mod.id, + }, + requirement: requirement === "root" ? "required" : requirement, + }; + } + + // Mark as visited before processing children + yield* Ref.update(visitedRef, (s) => new Set([...s, mod.id])); + + // Process required dependencies + const depChildren = yield* pipe( + mod.dependencies, + Arr.filter( + (dep): dep is typeof dep & { _tag: "required-module" } => + dep._tag === "required-module", + ), + Effect.forEach((dep) => + Effect.gen(function* () { + const depMod = yield* catalog + .getModule(dep.moduleId) + .pipe(Effect.orElseSucceed(() => null)); + + const currentVisited = yield* Ref.get(visitedRef); + if (!depMod || currentVisited.has(depMod.id)) { + return Result.failVoid; + } + + const childNode = yield* buildNode( + depMod, + "required", + visitedRef, + ); + return Result.succeed({ + node: { + ...childNode.node, + title: childNode.node.title, + description: childNode.node.description ?? "", + }, + requirement: "required" as const, + }); + }), + ), + Effect.map(Arr.filterMap((x) => x)), + ); + + // Process implied modules (cross-target) + const impChildren = yield* pipe( + mod.implies ?? [], + Effect.forEach((imp) => + Effect.gen(function* () { + const impMod = yield* catalog + .getModule(imp.moduleId) + .pipe(Effect.orElseSucceed(() => null)); + + const currentVisited = yield* Ref.get(visitedRef); + if (!impMod || currentVisited.has(impMod.id)) { + return Result.failVoid; + } + + const childNode = yield* buildNode( + impMod, + "required", + visitedRef, + ); + return Result.succeed({ + node: { + ...childNode.node, + title: childNode.node.title, + description: childNode.node.description ?? "", + }, + requirement: "required" as const, + }); + }), + ), + Effect.map(Arr.filterMap((x) => x)), + ); + + // Process same-target children (optional sub-modules) + const subChildren = yield* pipe( + mod.children ?? [], + Effect.forEach((child) => + Effect.gen(function* () { + const childMod = yield* catalog + .getModule(child.moduleId) + .pipe(Effect.orElseSucceed(() => null)); + + const currentVisited = yield* Ref.get(visitedRef); + if (!childMod || currentVisited.has(childMod.id)) { + return Result.failVoid; + } + + const childNode = yield* buildNode( + childMod, + child.requirement, + visitedRef, + ); + return Result.succeed({ + node: childNode.node, + requirement: child.requirement, + }); + }), + ), + Effect.map(Arr.filterMap((x) => x)), + ); + + const children = [...depChildren, ...impChildren, ...subChildren]; + const base = { + id: mod.id, + title: mod.title, + description: mod.description, + value: mod.id, + }; + + return { + node: Arr.isArrayNonEmpty(children) ? { ...base, children } : base, + requirement: requirement === "root" ? "required" : requirement, + }; + }); + + // Build tree for each top-level module (each with fresh visited Ref) + return yield* Effect.forEach(modules, (mod) => + Effect.gen(function* () { + const visitedRef = yield* Ref.make(new Set()); + const result = yield* buildNode(mod, "root", visitedRef); + return result.node; + }), + ); + }); + /** * Resolve module implications: when a module implies another module on a * different target kind, ensure that target+module exists in the collection. @@ -78,58 +261,78 @@ const resolveImplications = (targets: Array) => const catalog = yield* CatalogService; let changed = false; - for (const target of targets) { - for (const moduleId of target.modules) { - const definition = yield* catalog.getModule(moduleId); - for (const implication of definition.implies ?? []) { - const candidates = targets.filter( - (t) => t.kind === implication.targetKind, - ); + yield* Effect.forEach(targets, (target) => + Effect.forEach(target.modules, (moduleId) => + Effect.gen(function* () { + const definition = yield* catalog.getModule(moduleId); - if (candidates.length === 0) { - const name = yield* TextInput({ - message: `Module "${definition.title}" requires a ${implication.targetKind} target. What should it be called?`, - }); - targets.push({ - kind: implication.targetKind, - name, - modules: [implication.moduleId], - confirmed: false, - }); - changed = true; - } else if (candidates.length === 1) { - const candidate = candidates[0]; - if ( - candidate && - !candidate.modules.includes(implication.moduleId) - ) { - candidate.modules.push(implication.moduleId); - candidate.confirmed = false; - changed = true; - } - } else { - const alreadyPresent = candidates.some((c) => - c.modules.includes(implication.moduleId), - ); - if (!alreadyPresent) { - const chosen = yield* Select({ - message: `Module "${definition.title}" implies "${implication.moduleId}". Which ${implication.targetKind} target should receive it?`, - choices: candidates.map((c) => ({ - title: `${c.kind}/${c.name}`, - value: c.name, - })), - }); - const found = candidates.find((c) => c.name === chosen); - if (found) { - found.modules.push(implication.moduleId); - found.confirmed = false; - changed = true; - } - } - } - } - } - } + yield* Effect.forEach(definition.implies ?? [], (implication) => + Effect.gen(function* () { + const candidates = Arr.filter( + targets, + (t) => t.kind === implication.targetKind, + ); + + yield* pipe( + Match.value(candidates.length), + Match.when(0, () => + Effect.gen(function* () { + const name = yield* TextInput({ + message: `Module "${definition.title}" requires a ${implication.targetKind} target. What should it be called?`, + }); + targets.push({ + kind: implication.targetKind, + name, + modules: [implication.moduleId], + confirmed: false, + }); + changed = true; + }), + ), + Match.when(1, () => + Effect.gen(function* () { + const candidate = candidates[0]; + if ( + candidate && + !Arr.contains(candidate.modules, implication.moduleId) + ) { + candidate.modules.push(implication.moduleId); + candidate.confirmed = false; + changed = true; + } + }), + ), + Match.orElse(() => + Effect.gen(function* () { + const alreadyPresent = Arr.some(candidates, (c) => + Arr.contains(c.modules, implication.moduleId), + ); + if (!alreadyPresent) { + const chosen = yield* Select({ + message: `Module "${definition.title}" implies "${implication.moduleId}". Which ${implication.targetKind} target should receive it?`, + choices: Arr.map(candidates, (c) => ({ + title: `${c.kind}/${c.name}`, + value: c.name, + })), + }); + const found = Arr.findFirst( + candidates, + (c) => c.name === chosen, + ); + if (Option.isSome(found)) { + found.value.modules.push(implication.moduleId); + found.value.confirmed = false; + changed = true; + } + } + }), + ), + ); + }), + ); + }), + ), + ); return changed; }); @@ -141,7 +344,7 @@ const resolveImplications = (targets: Array) => const getActiveImplications = (targets: ReadonlyArray) => Effect.gen(function* () { const catalog = yield* CatalogService; - const allModuleIds = targets.flatMap((t) => t.modules); + const allModuleIds = Arr.flatMap(targets, (t) => t.modules); return yield* catalog.getImplications(allModuleIds); }); @@ -159,27 +362,40 @@ const removeOrphanedImplications = ( const activeImplications = yield* getActiveImplications(targets); let changed = false; - for (const target of targets) { - const toRemove: Array = []; - for (const moduleId of target.modules) { - if (pinned.has(`${target.kind}:${moduleId}`)) continue; - const isImplied = yield* catalog.isImpliedByAny(moduleId, target.kind); - if ( - isImplied && - !activeImplications.has(`${target.kind}:${moduleId}`) - ) { - toRemove.push(moduleId); + yield* Effect.forEach(targets, (target) => + Effect.gen(function* () { + const toRemove = yield* pipe( + target.modules, + Effect.filter((moduleId) => + Effect.gen(function* () { + if (pinned.has(`${target.kind}:${moduleId}`)) return false; + const isImplied = yield* catalog.isImpliedByAny( + moduleId, + target.kind, + ); + return ( + isImplied && + !activeImplications.has(`${target.kind}:${moduleId}`) + ); + }), + ), + ); + + if (Arr.isArrayNonEmpty(toRemove)) { + target.modules = Arr.filter( + target.modules, + (m) => !Arr.contains(toRemove, m), + ); + changed = true; } - } - if (toRemove.length > 0) { - target.modules = target.modules.filter((m) => !toRemove.includes(m)); - changed = true; - } - } + }), + ); // Remove empty targets const before = targets.length; - const remaining = targets.filter((t) => t.modules.length > 0); + const remaining = Arr.filter(targets, (t) => + Arr.isArrayNonEmpty(t.modules), + ); targets.length = 0; targets.push(...remaining); if (targets.length !== before) changed = true; @@ -199,71 +415,87 @@ const resolveImplicationsNonInteractive = ( while (changed) { changed = false; - for (const target of targets) { - for (const moduleId of target.modules) { - const definition = yield* catalog.getModule(moduleId); + yield* Effect.forEach(targets, (target) => + Effect.forEach(target.modules, (moduleId) => + Effect.gen(function* () { + const definition = yield* catalog.getModule(moduleId); - for (const implication of definition.implies ?? []) { - const candidates = targets.filter( - (t) => t.kind === implication.targetKind, - ); + yield* Effect.forEach(definition.implies ?? [], (implication) => + Effect.gen(function* () { + const candidates = Arr.filter( + targets, + (t) => t.kind === implication.targetKind, + ); - if (candidates.length === 0) { - // Check if the implied target/module already exists on disk - // by scanning for any directory matching the target kind pattern - const appsDir = `${repoRoot}/apps`; - const packagesDir = `${repoRoot}/packages`; - const searchDir = - implication.targetKind === "package" ? packagesDir : appsDir; - const prefix = - implication.targetKind === "package" - ? "" - : `${implication.targetKind}-`; - - const dirExists = yield* fs.readDirectory(searchDir).pipe( - Effect.map((entries) => - entries.some((entry) => - implication.targetKind === "package" - ? true - : entry.startsWith(prefix) || - entry === implication.targetKind, + yield* pipe( + Match.value(candidates.length), + Match.when(0, () => + Effect.gen(function* () { + // Check if the implied target/module already exists on disk + const appsDir = `${repoRoot}/apps`; + const packagesDir = `${repoRoot}/packages`; + const searchDir = + implication.targetKind === "package" + ? packagesDir + : appsDir; + const prefix = + implication.targetKind === "package" + ? "" + : `${implication.targetKind}-`; + + const dirExists = yield* pipe( + fs.readDirectory(searchDir), + Effect.map((entries) => + Arr.some( + entries, + (entry) => + implication.targetKind === "package" || + entry.startsWith(prefix) || + entry === implication.targetKind, + ), + ), + Effect.catch(() => Effect.succeed(false)), + ); + + if (!dirExists) { + return yield* Effect.fail( + `Module "${definition.id}" implies "${implication.moduleId}" on target kind "${implication.targetKind}". Non-interactive mode requires explicit support for implied targets. Use interactive add or choose modules without cross-target implications.`, + ); + } + }), + ), + Match.when( + (n) => n > 1, + () => + Effect.gen(function* () { + const alreadyPresent = Arr.some(candidates, (c) => + Arr.contains(c.modules, implication.moduleId), + ); + if (!alreadyPresent) { + return yield* Effect.fail( + `Module "${definition.id}" implies "${implication.moduleId}" for target kind "${implication.targetKind}", but multiple candidate targets exist. Use interactive add to disambiguate.`, + ); + } + }), + ), + Match.orElse(() => + Effect.gen(function* () { + const candidate = candidates[0]; + if ( + candidate && + !Arr.contains(candidate.modules, implication.moduleId) + ) { + candidate.modules.push(implication.moduleId); + changed = true; + } + }), ), - ), - Effect.catch(() => Effect.succeed(false)), - ); - - if (dirExists) { - // Implication is already satisfied by existing project state - continue; - } - - return yield* Effect.fail( - `Module "${definition.id}" implies "${implication.moduleId}" on target kind "${implication.targetKind}". Non-interactive mode requires explicit support for implied targets. Use interactive add or choose modules without cross-target implications.`, - ); - } - - if (candidates.length > 1) { - const alreadyPresent = candidates.some((c) => - c.modules.includes(implication.moduleId), - ); - if (!alreadyPresent) { - return yield* Effect.fail( - `Module "${definition.id}" implies "${implication.moduleId}" for target kind "${implication.targetKind}", but multiple candidate targets exist. Use interactive add to disambiguate.`, ); - } - } - - const candidate = candidates[0]; - if ( - candidate && - !candidate.modules.includes(implication.moduleId) - ) { - candidate.modules.push(implication.moduleId); - changed = true; - } - } - } - } + }), + ); + }), + ), + ); } return targets; @@ -304,37 +536,38 @@ const parseTargetIdentity = (targetId: string) => const parseModuleInputs = (rawModules: ReadonlyArray) => Effect.gen(function* () { - const parts = rawModules.flatMap((entry) => - entry - .split(",") - .map((part) => part.trim()) - .filter((part) => part.length > 0), + const parts = pipe( + rawModules, + Arr.flatMap((entry) => + pipe( + entry.split(","), + Arr.map((part) => part.trim()), + Arr.filter((part) => part.length > 0), + ), + ), ); - if (parts.length === 0) { + if (Arr.isArrayEmpty(parts)) { return yield* Effect.fail( "At least one module ID is required when using --modules.", ); } - const seen = new Set(); - const duplicates = new Set(); - - for (const moduleId of parts) { - if (seen.has(moduleId)) { - duplicates.add(moduleId); - } else { - seen.add(moduleId); - } - } + // Find duplicates using Array.groupBy + const grouped = Arr.groupBy(parts, (id) => id); + const duplicates = pipe( + Object.entries(grouped), + Arr.filter(([, items]) => items.length > 1), + Arr.map(([id]) => id), + ); - if (duplicates.size > 0) { + if (Arr.isArrayNonEmpty(duplicates)) { return yield* Effect.fail( - `Duplicate module IDs provided: ${Array.from(duplicates).join(", ")}`, + `Duplicate module IDs provided: ${Arr.join(duplicates, ", ")}`, ); } - return parts.map((moduleId) => ModuleId.make(moduleId)); + return Arr.map(parts, (moduleId) => ModuleId.make(moduleId)); }); const collectTargetsFromFlags = ( @@ -351,39 +584,39 @@ const collectTargetsFromFlags = ( name: parsedTarget.name, }); - yield* catalog - .getTarget(targetIdentity.kind) - .pipe( - Effect.mapError( - () => - `Unknown target kind \"${targetIdentity.kind}\" in --target value \"${targetId}\".`, - ), - ); + yield* pipe( + catalog.getTarget(targetIdentity.kind), + Effect.mapError( + () => + `Unknown target kind "${targetIdentity.kind}" in --target value "${targetId}".`, + ), + ); const moduleIds = yield* parseModuleInputs(rawModules); - const unsupported: Array = []; - - for (const moduleId of moduleIds) { - yield* catalog - .getModule(moduleId) - .pipe( - Effect.mapError( - () => `Unknown module ID \"${moduleId}\" provided via --modules.`, - ), - ); - const isSupported = yield* catalog.isSupportedOn( - moduleId, - targetIdentity, - ); - if (!isSupported) { - unsupported.push(moduleId); - } - } + // Validate modules and collect unsupported ones + const unsupported = yield* pipe( + moduleIds, + Effect.filter((moduleId) => + Effect.gen(function* () { + yield* pipe( + catalog.getModule(moduleId), + Effect.mapError( + () => `Unknown module ID "${moduleId}" provided via --modules.`, + ), + ); + const isSupported = yield* catalog.isSupportedOn( + moduleId, + targetIdentity, + ); + return !isSupported; + }), + ), + ); - if (unsupported.length > 0) { + if (Arr.isArrayNonEmpty(unsupported)) { return yield* Effect.fail( - `Unsupported module(s) for target ${targetIdentity.kind}/${targetIdentity.name}: ${unsupported.join(", ")}`, + `Unsupported module(s) for target ${targetIdentity.kind}/${targetIdentity.name}: ${Arr.join(unsupported, ", ")}`, ); } @@ -391,7 +624,7 @@ const collectTargetsFromFlags = ( { kind: parsedTarget.kind, name: parsedTarget.name, - modules: moduleIds, + modules: [...moduleIds], confirmed: true, }, ]; @@ -410,14 +643,15 @@ const collectTargetsInteractive = Effect.gen(function* () { const catalog = yield* CatalogService; // Build target kind choices from catalog (public targets only) - const targetChoices: Array<{ - title: string; - value: typeof TargetKind.Type; - }> = []; - for (const kind of catalog.getTargetKinds({ visibility: "public" })) { - const target = yield* catalog.getTarget(kind); - targetChoices.push({ title: target.title, value: kind }); - } + const targetChoices = yield* pipe( + catalog.getTargetKinds({ visibility: "public" }), + Effect.forEach((kind) => + Effect.gen(function* () { + const target = yield* catalog.getTarget(kind); + return { title: target.title, value: kind }; + }), + ), + ); const targets: Array = []; @@ -434,17 +668,16 @@ const collectTargetsInteractive = Effect.gen(function* () { const availableModules = yield* catalog.getSupportedModules(kind, { visibility: "public", }); - const modules = - availableModules.length > 0 - ? yield* MultiSelect({ - message: `Which modules do you want to add to "${kind}/${name}"?`, - choices: availableModules.map((mod) => ({ - title: mod.title, - value: mod.id, - description: mod.description, - })), - }) - : []; + + // Build nested tree structure for module selection + const moduleTree = yield* buildModuleTree(availableModules); + + const modules = Arr.isReadonlyArrayNonEmpty(moduleTree) + ? yield* NestedMultiSelect({ + message: `Which modules do you want to add to "${kind}/${name}"?`, + choices: moduleTree, + }) + : []; targets.push({ kind, name, modules: [...modules], confirmed: false }); }); @@ -453,9 +686,13 @@ const collectTargetsInteractive = Effect.gen(function* () { yield* addTarget; // The user explicitly chose modules for the first target, mark it confirmed - if (targets[0] && targets[0].modules.length > 0) { - targets[0].confirmed = true; - } + pipe( + Option.fromNullishOr(targets[0]), + Option.filter((t) => Arr.isArrayNonEmpty(t.modules)), + Option.map((t) => { + t.confirmed = true; + }), + ); // Resolve implications (fixed-point) let implChanged = true; @@ -490,57 +727,77 @@ const collectTargetsInteractive = Effect.gen(function* () { ], }); - if (action === "confirm-all") { - for (const t of targets) t.confirmed = true; - allConfirmed = true; - } else if (action === "edit") { - const targetToEdit = yield* Select({ - message: "Which target do you want to edit?", - choices: targets.map((t, i) => ({ - title: `${t.kind}/${t.name} [${t.modules.join(", ")}]`, - value: i, - })), - }); - - const t = targets[targetToEdit]; - if (t) { - const availableModules = yield* catalog.getSupportedModules(t.kind, { - visibility: "public", - }); - - if (availableModules.length > 0) { - const newModules = yield* MultiSelect({ - message: `Select modules for "${t.kind}/${t.name}":`, - choices: availableModules.map((mod) => ({ - title: mod.title, - value: mod.id, - description: mod.description, - selected: t.modules.includes(mod.id), + yield* pipe( + Match.value(action), + Match.when("confirm-all", () => + Effect.sync(() => { + Arr.forEach(targets, (t) => { + t.confirmed = true; + }); + allConfirmed = true; + }), + ), + Match.when("edit", () => + Effect.gen(function* () { + const targetToEdit = yield* Select({ + message: "Which target do you want to edit?", + choices: Arr.map(targets, (t, i) => ({ + title: `${t.kind}/${t.name} [${Arr.join(t.modules, ", ")}]`, + value: i, })), }); - t.modules = [...newModules]; - t.confirmed = true; - // Cascade: remove orphaned implications then re-resolve - // Pin the user's explicit selections so they aren't stripped - const pinned = new Set(newModules.map((m) => `${t.kind}:${m}`)); - yield* removeOrphanedImplications(targets, pinned); + yield* pipe( + Option.fromNullishOr(targets[targetToEdit]), + Option.match({ + onNone: () => Effect.void, + onSome: (t) => + Effect.gen(function* () { + const availableModules = yield* catalog.getSupportedModules( + t.kind, + { visibility: "public" }, + ); + + if (Arr.isReadonlyArrayNonEmpty(availableModules)) { + const moduleTree = yield* buildModuleTree(availableModules); + + const newModules = yield* NestedMultiSelect({ + message: `Select modules for "${t.kind}/${t.name}":`, + choices: moduleTree, + initialSelected: t.modules, + }); + t.modules = [...newModules]; + t.confirmed = true; + + // Cascade: remove orphaned implications then re-resolve + const pinned = new Set( + Arr.map(newModules, (m) => `${t.kind}:${m}`), + ); + yield* removeOrphanedImplications(targets, pinned); + let changed = true; + while (changed) { + changed = yield* resolveImplications(targets); + } + } else { + t.confirmed = true; + } + }), + }), + ); + }), + ), + Match.when("add", () => + Effect.gen(function* () { + yield* addTarget; + let changed = true; while (changed) { changed = yield* resolveImplications(targets); } - } else { - t.confirmed = true; - } - } - } else { - yield* addTarget; - - let changed = true; - while (changed) { - changed = yield* resolveImplications(targets); - } - } + }), + ), + Match.exhaustive, + ); } return targets; @@ -587,9 +844,9 @@ export const add = Command.make( // Build selection const selection: typeof Selection.Type = { - targets: collected.map((t) => ({ + targets: Arr.map(collected, (t) => ({ identity: new TargetIdentity({ kind: t.kind, name: t.name }), - modules: t.modules.map((id) => ({ id })), + modules: Arr.map(t.modules, (id) => ({ id })), })), }; diff --git a/apps/cli/src/commands/graph.ts b/apps/cli/src/commands/graph.ts index af217b8..f3564c9 100644 --- a/apps/cli/src/commands/graph.ts +++ b/apps/cli/src/commands/graph.ts @@ -54,12 +54,14 @@ interface RowData { readonly supportedOn: ReadonlyArray; readonly requires: ReadonlyArray; readonly implies: ReadonlyArray; + readonly childOf: ReadonlyArray; } const classifyEdge = Match.type().pipe( Match.when("supportedOn", () => "supportedOn" as const), Match.when("requiredModule", () => "requires" as const), Match.when("implies", () => "implies" as const), + Match.when("childOf", () => "childOf" as const), Match.exhaustive, ); @@ -84,6 +86,7 @@ const collectRowData = (g: CatalogGraph): Array => { supportedOn: resolve("supportedOn"), requires: resolve("requires"), implies: resolve("implies"), + childOf: resolve("childOf"), }; }); }; @@ -234,6 +237,7 @@ const countEdges = (g: CatalogGraph) => { supportedOn: (counts["supportedOn"] ?? []).length, requiredModule: (counts["requiredModule"] ?? []).length, implies: (counts["implies"] ?? []).length, + childOf: (counts["childOf"] ?? []).length, }; }; @@ -254,7 +258,7 @@ const renderTable = (g: CatalogGraph) => { `Nodes: ${nodeCount} (${targets.length} targets, ${modules.length} modules)`, ), Box.text( - `Edges: ${edgeCount} (${edgeCounts.supportedOn} supportedOn, ${edgeCounts.requiredModule} requiredModule, ${edgeCounts.implies} implies)`, + `Edges: ${edgeCount} (${edgeCounts.supportedOn} supportedOn, ${edgeCounts.requiredModule} requiredModule, ${edgeCounts.implies} implies, ${edgeCounts.childOf} childOf)`, ), Box.text(`Acyclic: ${acyclic ? "yes" : "no"}`), ], @@ -274,6 +278,7 @@ const renderTable = (g: CatalogGraph) => { { header: "supportedOn →", width: 16 }, { header: "requires →", width: 16 }, { header: "implies →", width: 16 }, + { header: "childOf →", width: 16 }, ] as const; const sortedRows = Arr.sortBy( @@ -303,6 +308,9 @@ const renderTable = (g: CatalogGraph) => { Box.para(Arr.join(r.implies, "\n") || "—", Box.left, 16).pipe( Box.annotate(Ansi.dim), ), + Box.para(Arr.join(r.childOf, "\n") || "—", Box.left, 16).pipe( + Box.annotate(Ansi.dim), + ), ]); const table = Table([...columns], rows); diff --git a/media/nestselect.gif b/media/nestselect.gif new file mode 100644 index 0000000..9621c56 Binary files /dev/null and b/media/nestselect.gif differ diff --git a/media/nestselect.tape b/media/nestselect.tape new file mode 100644 index 0000000..251c892 --- /dev/null +++ b/media/nestselect.tape @@ -0,0 +1,88 @@ +# VHS Demo: stack-effect init command +# Demonstrates the interactive project initialization flow + +# --- Dependencies --- +Require bun + +# --- Configuration --- +Output media/nestselect.gif +Set Shell "zsh" +Set FontSize 16 +Set Width 1100 +Set Height 500 +Set TypingSpeed 60ms +Set WindowBar Colorful +Set Padding 20 + +# --- Setup: create a temp directory --- +Hide +Type "export TMP_REPO=$(mktemp -d)" Enter +Sleep 300ms +Type "cd apps/cli" Enter +Sleep 300ms +Type "alias stack-effect='bun run start --'" Enter +Sleep 300ms +Type "clear" Enter +Sleep 300ms + +# Run the init command +Type "stack-effect init . --root $TMP_REPO -y" +Enter +Sleep 5s +Type "clear" Enter +Sleep 300ms +Show + +# --- Demo Start --- +Sleep 1s + +# Run the add command +Type "stack-effect add --root $TMP_REPO" +Sleep 1s +Enter +Sleep 1.5s + +# Prompt: Target kind selection (HorizontalSelect) +# Options: client-react, client-foldkit, server, package (adjust arrows to land on "server") +Enter +Sleep 1.5s + +# Prompt: Target name +Enter +Sleep 1.5s + +Set TypingSpeed 130ms + +# Prompt: Module selection (multiSelect - select chat-server) +# Navigate to chat-server and toggle with Space +Down 2 +Sleep 300ms +Up 2 +Sleep 300ms +Space +Sleep 600ms +Space +Sleep 300ms +Down 2 +Sleep 300ms +Space +Sleep 300ms +Down 6 +Sleep 300ms +Space +Sleep 300ms +Up 7 +Sleep 300ms +Space +Sleep 300ms +Down 5 +Sleep 300ms +Up 3 +Sleep 2s + + +# Show result +Sleep 10s + +# Final pause +Sleep 1.5s diff --git a/packages/catalog/src/CatalogService.ts b/packages/catalog/src/CatalogService.ts index 0a00286..64c0e65 100644 --- a/packages/catalog/src/CatalogService.ts +++ b/packages/catalog/src/CatalogService.ts @@ -184,6 +184,14 @@ export class CatalogService extends Context.Service()( Graph.addEdge(g, modIdx, impliedIdx, "implies"); } } + + for (const child of mod.children ?? []) { + const childIdx = moduleNodes.get(child.moduleId); + if (childIdx !== undefined) { + // Edge direction: child points to parent (childOf relationship) + Graph.addEdge(g, childIdx, modIdx, "childOf"); + } + } } }); diff --git a/packages/catalog/src/registry/content/ai.ts b/packages/catalog/src/registry/content/ai.ts index a1a3697..798d674 100644 --- a/packages/catalog/src/registry/content/ai.ts +++ b/packages/catalog/src/registry/content/ai.ts @@ -20,14 +20,57 @@ export const FastModelLive = AnthropicLanguageModel.layer({ }).pipe(Layer.provide(AnthropicLive)); `; +// Think Toolkit - minimal required toolkit for ChatService +export const aiThinkToolkitContents = `import { Effect, Schema } from "effect"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +/** + * Think Tool - Allows the AI to reason through complex problems step-by-step. + * This is a minimal tool that simply returns the thought, enabling the model + * to "think out loud" without requiring external computation. + */ +const thinkTool = Tool.make("think", { + description: + "Use this tool to think through a problem step-by-step before responding. " + + "Output your reasoning process. This helps with complex tasks that require " + + "multi-step reasoning. Example: think(thought: 'Let me break this down...')", + parameters: Schema.Struct({ + thought: Schema.String, + }), + success: Schema.String, +}); + +export const ThinkToolkit = Toolkit.make(thinkTool); + +export const ThinkToolkitLive = ThinkToolkit.toLayer( + Effect.succeed({ + think: (params) => + Effect.gen(function* () { + yield* Effect.logDebug(\`Thinking: \${params.thought}\`); + return params.thought; + }), + }), +); +`; + export const aiChatServiceContents = `import type { ChatStreamPart } from "@repo/domain/Chat"; import { Cause, Context, Effect, Layer, Queue, String } from "effect"; -import { Chat, Prompt } from "effect/unstable/ai"; -import { SampleToolkit } from "../toolkits/SampleToolkit"; +import { Chat, Prompt, Toolkit } from "effect/unstable/ai"; +import { ThinkToolkit, ThinkToolkitLive } from "../toolkits/ThinkToolkit"; import { runAgenticLoop } from "../workflow/AgenticLoop"; +// ChatToolkit - Merged toolkit for the chat service +// AST can append additional toolkits to this merge call +export const ChatToolkit = Toolkit.merge(ThinkToolkit); + +// ChatToolkitLive - Merged layer providing handlers for all toolkits +// AST can append additional toolkit layers to this merge call +export const ChatToolkitLive = Layer.mergeAll(ThinkToolkitLive); + export class ChatService extends Context.Service()("ChatService", { make: Effect.gen(function* () { + const toolkit = yield* ChatToolkit; + const chat = Effect.fn("chat")(function* (history: Array) { const queue = yield* Queue.make(); @@ -43,8 +86,6 @@ export class ChatService extends Context.Service()("ChatService", { Prompt.make(history).pipe(Prompt.setSystem(systemMessage)), ); - const toolkit = yield* SampleToolkit; - yield* runAgenticLoop({ chat: session, queue, @@ -72,7 +113,9 @@ export class ChatService extends Context.Service()("ChatService", { }), }) {} -export const ChatServiceLive = Layer.effect(ChatService)(ChatService.make); +export const ChatServiceLive = Layer.effect(ChatService)(ChatService.make).pipe( + Layer.provide(ChatToolkitLive), +); `; export const aiSampleToolkitContents = `import { Data, DateTime, Effect, Schema } from "effect"; diff --git a/packages/catalog/src/registry/modules/packages.ts b/packages/catalog/src/registry/modules/packages.ts index 3017e47..70385ed 100644 --- a/packages/catalog/src/registry/modules/packages.ts +++ b/packages/catalog/src/registry/modules/packages.ts @@ -11,6 +11,7 @@ import { aiLanguageModelContents, aiMailboxEventsContents, aiSampleToolkitContents, + aiThinkToolkitContents, } from "../content/ai"; import { presenceClientGeneratorContents, @@ -92,6 +93,35 @@ export const packageModules: ReadonlyArray = [ }, ], }, + { + id: ModuleId.make("ai-think-toolkit"), + title: "Think Toolkit", + description: + "Minimal AI toolkit with a think tool for step-by-step reasoning", + visibility: "internal", + supportedOn: [ + { + _tag: "identity", + identity: new TargetIdentity({ + kind: TargetKind.make("package"), + name: "ai", + }), + }, + ], + dependencies: [], + contributions: [ + { + _tag: "file", + path: "{{targetPath}}/src/toolkits/ThinkToolkit.ts", + contents: aiThinkToolkitContents, + }, + { + _tag: "barrel-export", + barrelPath: "{{targetPath}}/src/index.ts", + exportPath: "./toolkits/ThinkToolkit", + }, + ], + }, { id: ModuleId.make("ai-sample-toolkit"), title: "Sample Toolkit", @@ -117,6 +147,30 @@ export const packageModules: ReadonlyArray = [ barrelPath: "{{targetPath}}/src/index.ts", exportPath: "./toolkits/SampleToolkit", }, + // Add SampleToolkit to ChatToolkit merge + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/services/ChatService.ts", + targetVariable: "ChatToolkit", + functionName: "Toolkit.merge", + argument: "SampleToolkit", + import: { + moduleSpecifier: "../toolkits/SampleToolkit", + namedImports: ["SampleToolkit"], + }, + }, + // Add SampleToolkitLive to ChatToolkitLive merge + { + _tag: "ts-call-arg", + path: "{{targetPath}}/src/services/ChatService.ts", + targetVariable: "ChatToolkitLive", + functionName: "Layer.mergeAll", + argument: "SampleToolkitLive", + import: { + moduleSpecifier: "../toolkits/SampleToolkit", + namedImports: ["SampleToolkitLive"], + }, + }, ], }, { @@ -148,7 +202,7 @@ export const packageModules: ReadonlyArray = [ kind: TargetKind.make("package"), name: "ai", }), - moduleId: ModuleId.make("ai-sample-toolkit"), + moduleId: ModuleId.make("ai"), }, { _tag: "required-module", @@ -156,9 +210,12 @@ export const packageModules: ReadonlyArray = [ kind: TargetKind.make("package"), name: "ai", }), - moduleId: ModuleId.make("ai"), + moduleId: ModuleId.make("ai-think-toolkit"), }, ], + children: [ + { moduleId: ModuleId.make("ai-sample-toolkit"), requirement: "optional" }, + ], contributions: [ { _tag: "file", diff --git a/packages/catalog/src/registry/modules/server.ts b/packages/catalog/src/registry/modules/server.ts index 760809b..619f199 100644 --- a/packages/catalog/src/registry/modules/server.ts +++ b/packages/catalog/src/registry/modules/server.ts @@ -132,17 +132,6 @@ export const serverModules: ReadonlyArray = [ namedImports: ["ChatServiceLive"], }, }, - { - _tag: "ts-call-arg", - path: "{{targetPath}}/src/index.ts", - targetVariable: "AllRouters", - functionName: "Layer.mergeAll", - argument: "SampleToolkitLive", - import: { - moduleSpecifier: "@repo/ai", - namedImports: ["SampleToolkitLive"], - }, - }, { _tag: "ts-call-arg", path: "{{targetPath}}/src/index.ts", diff --git a/packages/domain/src/Catalog.ts b/packages/domain/src/Catalog.ts index 095f6a1..6c76cd4 100644 --- a/packages/domain/src/Catalog.ts +++ b/packages/domain/src/Catalog.ts @@ -256,6 +256,20 @@ export const ModuleCategory = Schema.String.pipe( Schema.brand("ModuleCategory"), ); +/** + * Declares a parent-child relationship between modules on the same target. + * Used by the TUI to render nested selection trees. + * + * - `required` children are auto-selected when the parent is selected (display-only) + * - `optional` children can be toggled by the user + * + * Children must share at least one `supportedOn` rule with their parent. + */ +export const ModuleChild = Schema.Struct({ + moduleId: ModuleId, + requirement: Schema.Literals(["required", "optional"]), +}); + export const ModuleDefinition = Schema.Struct({ id: ModuleId, title: Schema.String, @@ -274,6 +288,14 @@ export const ModuleDefinition = Schema.Struct({ Schema.optionalKey, Schema.withConstructorDefault(Effect.succeed([])), ), + /** + * Same-target child modules shown in nested selection UI. + * Children must share at least one `supportedOn` rule with the parent. + */ + children: Schema.Array(ModuleChild).pipe( + Schema.optionalKey, + Schema.withConstructorDefault(Effect.succeed([])), + ), contributions: Schema.Array(Contribution), scripts: Schema.Array(ScriptDefinition).pipe( Schema.optionalKey, @@ -321,6 +343,7 @@ export const CatalogEdge = Schema.Literals([ "supportedOn", "requiredModule", "implies", + "childOf", ]); export type CatalogGraph = Graph.DirectedGraph< diff --git a/packages/scaffold/src/service/plan/PlanService.test.ts b/packages/scaffold/src/service/plan/PlanService.test.ts index d30366c..b9044a7 100644 --- a/packages/scaffold/src/service/plan/PlanService.test.ts +++ b/packages/scaffold/src/service/plan/PlanService.test.ts @@ -328,11 +328,6 @@ const HttpRpcRouter = Layer.empty; moduleSpecifier: "@repo/ai", namedImports: ["ChatServiceLive"], }), - expect.objectContaining({ - _tag: "ts-add-import", - moduleSpecifier: "@repo/ai", - namedImports: ["SampleToolkitLive"], - }), expect.objectContaining({ _tag: "ts-add-import", moduleSpecifier: "@repo/ai", @@ -353,12 +348,6 @@ const HttpRpcRouter = Layer.empty; functionName: "Layer.mergeAll", argument: "ChatServiceLive", }), - expect.objectContaining({ - _tag: "ts-append-call-arg", - targetVariable: "AllRouters", - functionName: "Layer.mergeAll", - argument: "SampleToolkitLive", - }), expect.objectContaining({ _tag: "ts-append-call-arg", targetVariable: "AllRouters", diff --git a/packages/tui/src/components/HorizontalSelect.ts b/packages/tui/src/components/HorizontalSelect.ts index b69e9e5..94129cf 100644 --- a/packages/tui/src/components/HorizontalSelect.ts +++ b/packages/tui/src/components/HorizontalSelect.ts @@ -1,4 +1,4 @@ -import { Data, Effect, Match } from "effect"; +import { Array as Arr, Data, Effect, Match, pipe, Terminal } from "effect"; import { Prompt } from "effect/unstable/cli"; import { Ansi, Box, Cmd } from "effect-boxes"; import { KeyBinding, whenBinding } from "../lib/KeyBinding.js"; @@ -31,12 +31,56 @@ const HorizontalSelectKeys = { }), }; +/** + * Groups items into rows that fit within the given width. + * Similar to CSS flex-wrap behavior. + */ +const wrapItems = ( + items: ReadonlyArray>, + maxWidth: number, + gap: number, +): ReadonlyArray>> => + pipe( + items, + Arr.reduce( + { + rows: [] as Array>>, + currentRow: [] as Array>, + currentWidth: 0, + }, + (acc, item) => { + const itemWidth = item.cols; + const widthWithGap = + acc.currentWidth > 0 ? acc.currentWidth + gap + itemWidth : itemWidth; + + if (widthWithGap <= maxWidth || acc.currentRow.length === 0) { + return { + ...acc, + currentRow: [...acc.currentRow, item], + currentWidth: widthWithGap, + }; + } + return { + rows: [...acc.rows, acc.currentRow], + currentRow: [item], + currentWidth: itemWidth, + }; + }, + ), + ({ rows, currentRow }) => + currentRow.length > 0 ? [...rows, currentRow] : rows, + ); + export const HorizontalSelect = ( options: Prompt.SelectOptions, ): Prompt.Prompt => { const { message, choices } = options; - const renderLayout = (cursor: number, submitted: boolean) => { + const renderLayout = Effect.fnUntraced(function* ( + cursor: number, + submitted: boolean, + ) { + const terminal = yield* Terminal.Terminal; const prefix = submitted ? Box.text("✔").pipe(Box.annotate(Ansi.green)) : Box.text("?").pipe(Box.annotate(Ansi.cyan)); @@ -67,11 +111,17 @@ export const HorizontalSelect = ( ); } + const terminalWidth = yield* terminal.columns ?? 80; + const gap = 2; + const wrappedRows = wrapItems(items, terminalWidth - 4, gap); // -4 for chrome padding + + const itemsLayout = Box.vcat( + Arr.map(wrappedRows, (row) => Box.hsep(row, gap, Box.center1)), + Box.left, + ); + const content = Box.vcat( - [ - Box.hsep([prefix, label, Box.text(" ")], 2, Box.center1), - Box.hsep(items, 2, Box.center1), - ], + [Box.hsep([prefix, label, Box.text(" ")], 2, Box.center1), itemsLayout], Box.left, ); @@ -79,13 +129,13 @@ export const HorizontalSelect = ( [content.pipe(PromptChrome()), Hint(HorizontalSelectKeys)], Box.left, ); - }; + }); let hasRendered = false; return Prompt.custom(0, { render: Effect.fnUntraced(function* (cursor, action) { - const layout = Action.$match(action, { + const layout = yield* Action.$match(action, { Beep: () => renderLayout(cursor, false), Submit: () => renderLayout(cursor, true), NextFrame: ({ state }) => renderLayout(state, false), @@ -94,7 +144,7 @@ export const HorizontalSelect = ( // Compute previous frame height from old state; skip on initial render const clear = hasRendered - ? Cmd.clearLines(renderLayout(cursor, false).rows) + ? Cmd.clearLines((yield* renderLayout(cursor, false)).rows) : Cmd.cursorHide; hasRendered = true; diff --git a/packages/tui/src/components/MultiSelect.ts b/packages/tui/src/components/MultiSelect.ts index 5f96a09..5bba654 100644 --- a/packages/tui/src/components/MultiSelect.ts +++ b/packages/tui/src/components/MultiSelect.ts @@ -121,7 +121,16 @@ export const MultiSelect = ( ); const description = isCursor && c.description - ? Box.text(c.description).pipe(Box.annotate(Ansi.dim)) + ? Box.hsep( + [ + Box.text("·").pipe(Box.annotate(Ansi.dim)), + Box.text(c.description).pipe( + Box.annotate(Ansi.combine(Ansi.dim, Ansi.italic)), + ), + ], + 1, + Box.left, + ) : Box.nullBox; return Box.hsep( diff --git a/packages/tui/src/components/NestedMultiSelect.ts b/packages/tui/src/components/NestedMultiSelect.ts new file mode 100644 index 0000000..95a3c71 --- /dev/null +++ b/packages/tui/src/components/NestedMultiSelect.ts @@ -0,0 +1,510 @@ +import { + Array as Arr, + Data, + Effect, + Match, + Option, + pipe, + Result, +} from "effect"; +import { Prompt } from "effect/unstable/cli"; +import { Ansi, Box, Cmd } from "effect-boxes"; +import { KeyBinding, whenBinding } from "../lib/KeyBinding.js"; +import { Hint } from "./atom/Hint.js"; +import { PromptChrome } from "./atom/Panel.js"; + +const Action = Data.taggedEnum(); + +// ============================================================================= +// Types +// ============================================================================= +export type NestedModuleNode = { + readonly id: string; + readonly title: string; + readonly description?: string; + readonly value: A; + readonly children?: ReadonlyArray>; +}; + +export type NestedModuleChild = { + readonly node: NestedModuleNode; + readonly requirement: "required" | "optional"; +}; + +export type NestedMultiSelectOptions = { + readonly message: string; + readonly choices: ReadonlyArray>; + readonly min?: number; + readonly max?: number; + /** IDs of initially selected modules */ + readonly initialSelected?: ReadonlyArray; +}; + +// ============================================================================= +// Internal Types +// ============================================================================= +type FlatItem = { + readonly node: NestedModuleNode; + readonly depth: number; + readonly isLast: boolean; + readonly parentPath: ReadonlyArray; // true = parent is last in its level + readonly requirement: "root" | "required" | "optional"; + readonly parentId: string | null; +}; + +type NestedMultiSelectState = { + readonly cursor: number; + readonly selected: ReadonlySet; // Set of module IDs +}; + +// ============================================================================= +// Key Bindings +// ============================================================================= +const NestedSelectKeys = { + Down: new KeyBinding({ + keys: ["down", "j", "tab"], + label: "↑/↓", + action: "navigate", + }), + Up: new KeyBinding({ + keys: ["up", "k"], + label: "↑/↓", + action: "navigate", + }), + Toggle: new KeyBinding({ + keys: ["space"], + label: "space", + action: "toggle", + }), + Submit: new KeyBinding({ + keys: ["enter", "return"], + label: "enter", + action: "submit", + }), +}; + +const NestedSelectHintKeys = { + Navigate: NestedSelectKeys.Down, + Toggle: NestedSelectKeys.Toggle, + Submit: NestedSelectKeys.Submit, +}; + +// ============================================================================= +// Tree Helpers +// ============================================================================= +const flattenTree = ( + choices: ReadonlyArray>, + selected: ReadonlySet, +): ReadonlyArray> => { + const visitChildren = ( + children: ReadonlyArray>, + depth: number, + parentPath: ReadonlyArray, + parentId: string, + ): ReadonlyArray> => + Arr.flatMap(children, (child, index) => { + const item: FlatItem = { + node: child.node, + depth, + isLast: index === children.length - 1, + parentPath, + requirement: child.requirement, + parentId, + }; + + // Recursively visit grandchildren if this child is selected + const grandchildren = + child.node.children && + child.node.children.length > 0 && + selected.has(child.node.id) + ? visitChildren( + child.node.children, + depth + 1, + [...parentPath, index === children.length - 1], + child.node.id, + ) + : []; + + return [item, ...grandchildren]; + }); + + // Visit top-level nodes + return pipe( + choices, + Arr.flatMap((node, index) => { + const item: FlatItem = { + node, + depth: 0, + isLast: index === choices.length - 1, + parentPath: [], + requirement: "root", + parentId: null, + }; + + // Visit children if this node is selected (expanded) + const children = + node.children && node.children.length > 0 && selected.has(node.id) + ? visitChildren(node.children, 1, [], node.id) + : []; + + return [item, ...children]; + }), + ); +}; + +const countChildren = (node: NestedModuleNode): number => + pipe( + Option.fromNullishOr(node.children), + Option.map(Arr.length), + Option.getOrElse(() => 0), + ); + +const collectRequiredChildren = ( + node: NestedModuleNode, +): ReadonlySet => + Option.getOrElse( + Option.map(Option.fromNullishOr(node.children), (children) => + Arr.reduce(children, new Set(), (acc, child) => + child.requirement === "required" + ? new Set([ + ...acc, + child.node.id, + ...collectRequiredChildren(child.node), + ]) + : acc, + ), + ), + () => new Set(), + ); + +/** + * Collect all descendant IDs recursively (for removal). + */ +const collectDescendants = ( + node: NestedModuleNode, +): ReadonlySet => + pipe( + Option.fromNullishOr(node.children), + Option.map((children) => + Arr.reduce( + children, + new Set([node.id]), + (acc, child) => new Set([...acc, ...collectDescendants(child.node)]), + ), + ), + Option.getOrElse(() => new Set([node.id])), + ); + +const findNodeById = ( + choices: ReadonlyArray>, + id: string, +): Option.Option> => { + const searchInNode = ( + node: NestedModuleNode, + ): Option.Option> => + node.id === id + ? Option.some(node) + : pipe( + Option.fromNullishOr(node.children), + Option.flatMap((children) => + pipe( + children, + Arr.findFirst((child) => Option.isSome(searchInNode(child.node))), + Option.flatMap((child) => searchInNode(child.node)), + ), + ), + ); + + return pipe( + choices, + Arr.findFirst((node) => Option.isSome(searchInNode(node))), + Option.flatMap(searchInNode), + ); +}; + +// ============================================================================= +// Rendering +// ============================================================================= + +const renderTreeLine = ( + depth: number, + isLast: boolean, + parentPath: ReadonlyArray, +) => { + if (depth === 0) return ""; + + // Base indentation to align with parent's checkbox (2 spaces for indicator + hsep) + const baseIndent = " "; + + // Build the prefix for all ancestor levels + // Each level is 3 chars wide to match "├─ " / "└─ " / "│ " / " " + const ancestorPrefix = pipe( + parentPath, + Arr.map((wasLast) => (wasLast ? " " : "│ ")), + Arr.join(""), + ); + + // Add the connector for this level + const connector = isLast ? "└─" : "├─"; + + return baseIndent + ancestorPrefix + connector; +}; + +// ============================================================================= +// Component +// ============================================================================= + +export const NestedMultiSelect = ( + options: NestedMultiSelectOptions, +): Prompt.Prompt> => { + const { message, choices } = options; + const min = options.min ?? 0; + + const renderLayout = (state: NestedMultiSelectState, submitted: boolean) => { + const label = Box.text(message).pipe(Box.annotate(Ansi.bold)); + + if (submitted) { + const selectedTitles = pipe( + Arr.fromIterable(state.selected), + Arr.filterMap((id) => + pipe( + findNodeById(choices, id), + Option.map((node) => node.title), + Result.fromOption(() => undefined), + ), + ), + ); + return Box.hsep( + [ + Box.text("✔").pipe(Box.annotate(Ansi.green)), + label, + Box.text( + selectedTitles.length === 0 + ? "None" + : Arr.join(selectedTitles, ", "), + ).pipe(Box.annotate(Ansi.cyan)), + ], + 1, + Box.top, + ); + } + + const flatItems = flattenTree(choices, state.selected); + + const items = pipe( + flatItems, + Arr.map((item, index) => { + const isCursor = index === state.cursor; + const isSelected = state.selected.has(item.node.id); + const hasChildren = countChildren(item.node) > 0; + const isRequired = item.requirement === "required"; + + // Tree line prefix (string, includes indentation and connectors) + const treeLineStr = renderTreeLine( + item.depth, + item.isLast, + item.parentPath, + ); + + // Cursor indicator + const indicator = Box.char(isCursor ? "⏵" : " ").pipe( + Box.annotate(Ansi.cyan), + ); + + const checkbox = Match.value({ + isSelected, + hasChildren, + isRequired, + }).pipe( + // Collapsed with children - show chevron + Match.when({ isSelected: false, hasChildren: true }, () => + Box.char("▶").pipe(Box.annotate(Ansi.dim)), + ), + // Required child - always checked, dimmed + Match.when({ isRequired: true }, () => + Box.char("◼").pipe(Box.annotate(Ansi.dim)), + ), + // Normal checkbox + Match.orElse(({ isSelected }) => + Box.char(isSelected ? "◼" : "◻").pipe( + Box.annotate(isSelected ? Ansi.green : Ansi.dim), + ), + ), + ); + + const title = Box.text(item.node.title).pipe( + Box.annotate( + isRequired + ? isCursor + ? Ansi.combine(Ansi.dim, Ansi.bold) + : Ansi.dim + : isCursor + ? Ansi.combine(isSelected ? Ansi.green : Ansi.white, Ansi.bold) + : isSelected + ? Ansi.green + : Ansi.white, + ), + ); + + const description = Option.liftPredicate(isCursor, Boolean).pipe( + Option.flatMap(() => + Option.map(Option.fromNullishOr(item.node.description), (desc) => + Box.hsep( + [ + Box.text("·").pipe(Box.annotate(Ansi.dim)), + Box.text(desc).pipe( + Box.annotate(Ansi.combine(Ansi.dim, Ansi.italic)), + ), + ], + 1, + Box.left, + ), + ), + ), + Option.getOrElse(() => Box.nullBox), + ); + + if (item.depth === 0) { + return Box.hsep( + [indicator, checkbox, title, description], + 1, + Box.left, + ); + } + + const treePart = Box.text(treeLineStr).pipe( + Box.annotate(isCursor ? Ansi.cyan : Ansi.dim), + ); + const indicatorPart = isCursor + ? Box.char("⏵").pipe(Box.annotate(Ansi.cyan)) + : Box.char(" "); + const prefix = Box.hcat([treePart, indicatorPart, checkbox], Box.left); + + return Box.hsep([prefix, title, description], 1, Box.left); + }), + ); + + const count = Box.text(`(${state.selected.size})`).pipe( + Box.annotate(Ansi.dim), + ); + + const content = Box.vcat( + [Box.hsep([label, count], 1, Box.center1), Box.vcat(items, Box.left)], + Box.left, + ); + + return Box.vcat( + [content.pipe(PromptChrome()), Hint(NestedSelectHintKeys)], + Box.left, + ); + }; + + const initialState: NestedMultiSelectState = { + cursor: 0, + selected: new Set(options.initialSelected ?? []), + }; + + let hasRendered = false; + let lastRowCount = 0; + + return Prompt.custom>(initialState, { + render: Effect.fnUntraced(function* (state, action) { + const layout = Action.$match(action, { + Beep: () => renderLayout(state, false), + Submit: () => renderLayout(state, true), + NextFrame: ({ state: s }) => renderLayout(s, false), + default: () => renderLayout(state, false), + }); + + // Clear based on the previous render's row count + const clear = hasRendered ? Cmd.clearLines(lastRowCount) : Cmd.cursorHide; + + // Track this render's row count for next clear + lastRowCount = layout.rows; + hasRendered = true; + + const cmds = + action._tag === "Submit" + ? Box.combine(Cmd.cursorShow, Cmd.cursorNextLine(1)) + : Cmd.cursorHide; + + return yield* Box.renderPretty( + Box.combine(clear, layout.pipe(Box.combine(cmds))), + ); + }), + + process: Effect.fnUntraced(function* (input, state) { + const flatItems = flattenTree(choices, state.selected); + + const next = (patch: Partial) => + Action.NextFrame({ state: { ...state, ...patch } }); + + return Match.value(input).pipe( + whenBinding(NestedSelectKeys.Down, () => { + const nextCursor = (state.cursor + 1) % flatItems.length; + return next({ cursor: nextCursor }); + }), + + whenBinding(NestedSelectKeys.Up, () => { + const nextCursor = + (state.cursor - 1 + flatItems.length) % flatItems.length; + return next({ cursor: nextCursor }); + }), + + whenBinding(NestedSelectKeys.Toggle, () => + pipe( + Option.fromNullishOr(flatItems[state.cursor]), + Option.filter((item) => item.requirement !== "required"), + Option.map((item) => { + const isCurrentlySelected = state.selected.has(item.node.id); + const nextSelected = isCurrentlySelected + ? new Set( + Arr.filter( + Arr.fromIterable(state.selected), + (id) => !collectDescendants(item.node).has(id), + ), + ) + : new Set([ + ...state.selected, + item.node.id, + ...collectRequiredChildren(item.node), + ]); + return next({ selected: nextSelected }); + }), + Option.getOrElse(() => Action.Beep()), + ), + ), + + whenBinding(NestedSelectKeys.Submit, () => { + if (state.selected.size < min) return Action.Beep(); + + const collectValues = ( + nodes: ReadonlyArray>, + ): Array => + pipe( + nodes, + Arr.flatMap((node) => { + const nodeValue = state.selected.has(node.id) + ? [node.value] + : []; + const childValues = pipe( + Option.fromNullishOr(node.children), + Option.map((children) => + collectValues(Arr.map(children, (c) => c.node)), + ), + Option.getOrElse(() => [] as Array), + ); + return [...nodeValue, ...childValues]; + }), + ); + + return Action.Submit({ value: collectValues(choices) }); + }), + + Match.orElse(() => next({})), + ); + }), + + clear: () => Effect.succeed(""), + }); +}; diff --git a/packages/tui/src/components/Select.ts b/packages/tui/src/components/Select.ts index 05d0403..3441986 100644 --- a/packages/tui/src/components/Select.ts +++ b/packages/tui/src/components/Select.ts @@ -39,7 +39,16 @@ export const Select = ( ); const description = isSelected && c.description - ? Box.text(c.description).pipe(Box.annotate(Ansi.dim)) + ? Box.hsep( + [ + Box.text("·").pipe(Box.annotate(Ansi.dim)), + Box.text(c.description).pipe( + Box.annotate(Ansi.combine(Ansi.dim, Ansi.italic)), + ), + ], + 1, + Box.left, + ) : Box.nullBox; return Box.hsep([indicator, title, description], 1, Box.left); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 90ed3ca..5b85ca7 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -4,6 +4,7 @@ export * from "./components/atom/Table"; export * from "./components/Confirm"; export * from "./components/HorizontalSelect"; export * from "./components/MultiSelect"; +export * from "./components/NestedMultiSelect"; export * from "./components/Select"; export * from "./components/TextArea"; export * from "./components/TextInput";