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/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 7e0da3d..6f22c0d 100644 --- a/apps/cli/src/commands/add.ts +++ b/apps/cli/src/commands/add.ts @@ -20,8 +20,12 @@ import { Console, Effect, FileSystem, + Match, Option, Predicate, + pipe, + Ref, + Result, Schedule, } from "effect"; import { Command, Flag } from "effect/unstable/cli"; @@ -30,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, @@ -257,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; }); @@ -320,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); }); @@ -338,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; @@ -378,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; @@ -483,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 = ( @@ -530,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, ", ")}`, ); } @@ -570,7 +624,7 @@ const collectTargetsFromFlags = ( { kind: parsedTarget.kind, name: parsedTarget.name, - modules: moduleIds, + modules: [...moduleIds], confirmed: true, }, ]; @@ -589,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 = []; @@ -631,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; @@ -785,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/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/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/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/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);