@dex-ai/ask-extension 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@dex-ai/ask-extension",
3
+ "version": "0.1.0",
4
+ "description": "Structured ask tool — multi-choice questions with tabbed wizard flows for the agent.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ }
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "scripts": {
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "bun test"
18
+ },
19
+ "dependencies": {
20
+ "@dex-ai/sdk": "^0.1.2",
21
+ "zod": "^3.23.0"
22
+ },
23
+ "sideEffects": false,
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org/"
27
+ }
28
+ }
@@ -0,0 +1,420 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Agent } from "@dex-ai/sdk";
3
+ import type { Message, ToolOutput } from "@dex-ai/sdk";
4
+ import { askExtension, ASK_REQUEST_KEY, ASK_RESULT_KEY } from "./index";
5
+ import type { AskRequest, AskResult } from "./types";
6
+
7
+ /* ------------------------------------------------------------------ */
8
+ /* Helpers */
9
+ /* ------------------------------------------------------------------ */
10
+
11
+ const FAKE_PROVIDER = "scripted";
12
+ const FAKE_MODEL = "scripted-1";
13
+
14
+ const userMsg = (t: string): Message => ({
15
+ role: "user",
16
+ content: [{ type: "text", text: t }],
17
+ });
18
+
19
+ type FakeStep =
20
+ | { kind: "tool-call"; toolName: string; input: unknown; toolCallId?: string }
21
+ | { kind: "text"; text: string };
22
+
23
+ function scriptedProvider(steps: FakeStep[]) {
24
+ const queue = steps.slice();
25
+ let callCount = 0;
26
+ return {
27
+ name: FAKE_PROVIDER,
28
+ models: [
29
+ {
30
+ id: FAKE_MODEL,
31
+ async *stream() {
32
+ callCount++;
33
+ const step = queue.shift() ?? { kind: "text" as const, text: "done" };
34
+ const meta = {
35
+ providerName: FAKE_PROVIDER,
36
+ modelId: FAKE_MODEL,
37
+ startedAt: Date.now(),
38
+ };
39
+ yield { type: "response-start", meta };
40
+ yield { type: "message-start", role: "assistant" };
41
+
42
+ if (step.kind === "text") {
43
+ yield { type: "text-delta", delta: step.text };
44
+ yield {
45
+ type: "message-stop",
46
+ message: {
47
+ role: "assistant",
48
+ content: [{ type: "text", text: step.text }],
49
+ },
50
+ };
51
+ yield { type: "finish", reason: "stop", usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 } };
52
+ } else {
53
+ const toolCallId = step.toolCallId ?? `call-${callCount}`;
54
+ yield {
55
+ type: "tool-call",
56
+ toolCallId,
57
+ toolName: step.toolName,
58
+ input: step.input,
59
+ };
60
+ yield {
61
+ type: "message-stop",
62
+ message: {
63
+ role: "assistant",
64
+ content: [
65
+ {
66
+ type: "tool-call",
67
+ toolCallId,
68
+ toolName: step.toolName,
69
+ input: step.input,
70
+ },
71
+ ],
72
+ },
73
+ };
74
+ yield { type: "finish", reason: "tool-calls", usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 } };
75
+ }
76
+ yield { type: "response-stop", meta, usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, finishReason: step.kind === "text" ? "stop" : "tool-calls" };
77
+ },
78
+ },
79
+ ],
80
+ };
81
+ }
82
+
83
+ /** Simulate the TUI side: watch for ask:request, then respond after a delay. */
84
+ function autoResponder(
85
+ agent: { context: { state: Map<string, unknown> } },
86
+ makeResult: (req: AskRequest) => AskResult,
87
+ delayMs = 10,
88
+ ) {
89
+ const timer = setInterval(() => {
90
+ const req = agent.context.state.get(ASK_REQUEST_KEY) as AskRequest | undefined;
91
+ if (req) {
92
+ clearInterval(timer);
93
+ setTimeout(() => {
94
+ agent.context.state.set(ASK_RESULT_KEY, makeResult(req));
95
+ }, delayMs);
96
+ }
97
+ }, 5);
98
+ return () => clearInterval(timer);
99
+ }
100
+
101
+ function lastToolOutput(messages: ReadonlyArray<Message>, toolName: string): string | null {
102
+ for (let i = messages.length - 1; i >= 0; i--) {
103
+ const msg = messages[i]!;
104
+ if (msg.role !== "tool") continue;
105
+ for (const c of msg.content) {
106
+ if (c.type === "tool-result" && (c as any).toolName === toolName) {
107
+ const out = (c as any).output as ToolOutput;
108
+ if (out.type === "text") return out.value;
109
+ if (out.type === "error-text") return out.value;
110
+ if (out.type === "json") return typeof out.value === "string" ? out.value : JSON.stringify(out.value);
111
+ }
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /* ------------------------------------------------------------------ */
118
+ /* Tests */
119
+ /* ------------------------------------------------------------------ */
120
+
121
+ const VALID_INPUT = {
122
+ tabs: [
123
+ {
124
+ label: "Scope",
125
+ question: "What scope?",
126
+ options: [
127
+ { value: "a", label: "Option A" },
128
+ { value: "b", label: "Option B" },
129
+ ],
130
+ },
131
+ ],
132
+ };
133
+
134
+ describe("structured_ask tool", () => {
135
+ test("sets ask:request in state and resolves on submit", async () => {
136
+ const agent = await Agent.create({
137
+ provider: FAKE_PROVIDER,
138
+ model: FAKE_MODEL,
139
+ extensions: [
140
+ scriptedProvider([
141
+ { kind: "tool-call", toolName: "structured_ask", input: VALID_INPUT },
142
+ { kind: "text", text: "got it" },
143
+ ]),
144
+ askExtension(),
145
+ ],
146
+ });
147
+
148
+ // Simulate TUI: respond with submit
149
+ const cleanup = autoResponder(agent, (req) => ({
150
+ id: req.id,
151
+ type: "submit",
152
+ tabs: [{ label: "Scope", selected: ["a"], freeText: null }],
153
+ }));
154
+
155
+ const stream = agent.generate({ input: [userMsg("ask me")] });
156
+ await stream.result;
157
+ for await (const _ of stream) { /* drain */ }
158
+ cleanup();
159
+
160
+ const output = lastToolOutput(agent.context.messages, "structured_ask");
161
+ expect(output).toBeTruthy();
162
+ const parsed = JSON.parse(output!);
163
+ expect(parsed.tabs).toEqual([{ label: "Scope", selected: ["a"], freeText: null }]);
164
+
165
+ // State cleaned up
166
+ expect(agent.context.state.get(ASK_REQUEST_KEY)).toBeUndefined();
167
+ expect(agent.context.state.get(ASK_RESULT_KEY)).toBeUndefined();
168
+
169
+ await agent.dispose();
170
+ });
171
+
172
+ test("eject returns partial results", async () => {
173
+ const agent = await Agent.create({
174
+ provider: FAKE_PROVIDER,
175
+ model: FAKE_MODEL,
176
+ extensions: [
177
+ scriptedProvider([
178
+ {
179
+ kind: "tool-call",
180
+ toolName: "structured_ask",
181
+ input: {
182
+ tabs: [
183
+ { label: "Tab1", question: "Q1?", options: [{ value: "x", label: "X" }] },
184
+ { label: "Tab2", question: "Q2?", options: [{ value: "y", label: "Y" }] },
185
+ ],
186
+ },
187
+ },
188
+ { kind: "text", text: "ok lets chat" },
189
+ ]),
190
+ askExtension(),
191
+ ],
192
+ });
193
+
194
+ const cleanup = autoResponder(agent, (req) => ({
195
+ id: req.id,
196
+ type: "eject",
197
+ completedTabs: [{ label: "Tab1", selected: ["x"], freeText: null }],
198
+ currentTab: "Tab2",
199
+ partialSelected: [],
200
+ }));
201
+
202
+ const stream = agent.generate({ input: [userMsg("do it")] });
203
+ await stream.result;
204
+ for await (const _ of stream) { /* drain */ }
205
+ cleanup();
206
+
207
+ const output = lastToolOutput(agent.context.messages, "structured_ask");
208
+ const parsed = JSON.parse(output!);
209
+ expect(parsed.ejected).toBe(true);
210
+ expect(parsed.completedTabs).toHaveLength(1);
211
+ expect(parsed.currentTab).toBe("Tab2");
212
+
213
+ await agent.dispose();
214
+ });
215
+
216
+ test("cancel returns cancelled", async () => {
217
+ const agent = await Agent.create({
218
+ provider: FAKE_PROVIDER,
219
+ model: FAKE_MODEL,
220
+ extensions: [
221
+ scriptedProvider([
222
+ { kind: "tool-call", toolName: "structured_ask", input: VALID_INPUT },
223
+ { kind: "text", text: "ok" },
224
+ ]),
225
+ askExtension(),
226
+ ],
227
+ });
228
+
229
+ const cleanup = autoResponder(agent, (req) => ({
230
+ id: req.id,
231
+ type: "cancel",
232
+ }));
233
+
234
+ const stream = agent.generate({ input: [userMsg("ask")] });
235
+ await stream.result;
236
+ for await (const _ of stream) { /* drain */ }
237
+ cleanup();
238
+
239
+ const output = lastToolOutput(agent.context.messages, "structured_ask");
240
+ const parsed = JSON.parse(output!);
241
+ expect(parsed.cancelled).toBe(true);
242
+
243
+ await agent.dispose();
244
+ });
245
+
246
+ test("concurrent call rejected", async () => {
247
+ // We can't easily test true concurrency with the generate loop,
248
+ // but we can verify that if ask:request is already set, a second
249
+ // call returns an error.
250
+ const agent = await Agent.create({
251
+ provider: FAKE_PROVIDER,
252
+ model: FAKE_MODEL,
253
+ extensions: [
254
+ scriptedProvider([
255
+ { kind: "tool-call", toolName: "structured_ask", input: VALID_INPUT },
256
+ { kind: "text", text: "done" },
257
+ ]),
258
+ askExtension(),
259
+ ],
260
+ });
261
+
262
+ // Pre-set ask:request to simulate an existing pending ask
263
+ agent.context.state.set(ASK_REQUEST_KEY, { id: "existing", tabs: [] });
264
+
265
+ // Also set up a delayed response so it doesn't hang
266
+ const cleanup = autoResponder(agent, (req) => ({
267
+ id: req.id,
268
+ type: "cancel",
269
+ }), 5);
270
+
271
+ const stream = agent.generate({ input: [userMsg("ask")] });
272
+ await stream.result;
273
+ for await (const _ of stream) { /* drain */ }
274
+ cleanup();
275
+
276
+ const output = lastToolOutput(agent.context.messages, "structured_ask");
277
+ expect(output).toContain("Error");
278
+ expect(output).toContain("already pending");
279
+
280
+ // Clean up the pre-set state
281
+ agent.context.state.delete(ASK_REQUEST_KEY);
282
+ await agent.dispose();
283
+ });
284
+
285
+ test("abort signal cancels pending ask", async () => {
286
+ const controller = new AbortController();
287
+ const agent = await Agent.create({
288
+ provider: FAKE_PROVIDER,
289
+ model: FAKE_MODEL,
290
+ extensions: [
291
+ scriptedProvider([
292
+ { kind: "tool-call", toolName: "structured_ask", input: VALID_INPUT },
293
+ { kind: "text", text: "done" },
294
+ ]),
295
+ askExtension(),
296
+ ],
297
+ signal: controller.signal,
298
+ });
299
+
300
+ // After request is set, abort
301
+ const checkTimer = setInterval(() => {
302
+ if (agent.context.state.get(ASK_REQUEST_KEY)) {
303
+ clearInterval(checkTimer);
304
+ setTimeout(() => controller.abort(), 10);
305
+ }
306
+ }, 5);
307
+
308
+ const stream = agent.generate({ input: [userMsg("go")] });
309
+ try {
310
+ await stream.result;
311
+ } catch {
312
+ // Abort may throw
313
+ }
314
+ for await (const _ of stream) { /* drain */ }
315
+ clearInterval(checkTimer);
316
+
317
+ // On abort the tool should cancel and state should be cleaned up
318
+ // The tool might produce a cancel output, or the generate loop might
319
+ // produce an error — either way, state must be clean.
320
+ const output = lastToolOutput(agent.context.messages, "structured_ask");
321
+ if (output) {
322
+ try {
323
+ const parsed = JSON.parse(output);
324
+ expect(parsed.cancelled).toBe(true);
325
+ } catch {
326
+ // If not valid JSON, it's an error message from the loop — acceptable
327
+ expect(output).toContain("abort");
328
+ }
329
+ }
330
+ // Key invariant: state cleaned up after abort
331
+ expect(agent.context.state.has(ASK_REQUEST_KEY)).toBe(false);
332
+
333
+ await agent.dispose();
334
+ });
335
+
336
+ test("multi-tab submit returns all tab results", async () => {
337
+ const multiInput = {
338
+ tabs: [
339
+ { label: "Scope", question: "What?", options: [{ value: "a", label: "A" }, { value: "b", label: "B" }], multiSelect: true },
340
+ { label: "Strategy", question: "How?", options: [{ value: "x", label: "X" }, { value: "y", label: "Y" }] },
341
+ ],
342
+ };
343
+
344
+ const agent = await Agent.create({
345
+ provider: FAKE_PROVIDER,
346
+ model: FAKE_MODEL,
347
+ extensions: [
348
+ scriptedProvider([
349
+ { kind: "tool-call", toolName: "structured_ask", input: multiInput },
350
+ { kind: "text", text: "done" },
351
+ ]),
352
+ askExtension(),
353
+ ],
354
+ });
355
+
356
+ const cleanup = autoResponder(agent, (req) => ({
357
+ id: req.id,
358
+ type: "submit",
359
+ tabs: [
360
+ { label: "Scope", selected: ["a", "b"], freeText: null },
361
+ { label: "Strategy", selected: ["x"], freeText: null },
362
+ ],
363
+ }));
364
+
365
+ const stream = agent.generate({ input: [userMsg("go")] });
366
+ await stream.result;
367
+ for await (const _ of stream) { /* drain */ }
368
+ cleanup();
369
+
370
+ const output = lastToolOutput(agent.context.messages, "structured_ask");
371
+ const parsed = JSON.parse(output!);
372
+ expect(parsed.tabs).toHaveLength(2);
373
+ expect(parsed.tabs[0].selected).toEqual(["a", "b"]);
374
+ expect(parsed.tabs[1].selected).toEqual(["x"]);
375
+
376
+ await agent.dispose();
377
+ });
378
+
379
+ test("free text result included", async () => {
380
+ const freeTextInput = {
381
+ tabs: [
382
+ {
383
+ label: "Name",
384
+ question: "Convention?",
385
+ options: [{ value: "camel", label: "camelCase" }],
386
+ allowFreeText: true,
387
+ },
388
+ ],
389
+ };
390
+
391
+ const agent = await Agent.create({
392
+ provider: FAKE_PROVIDER,
393
+ model: FAKE_MODEL,
394
+ extensions: [
395
+ scriptedProvider([
396
+ { kind: "tool-call", toolName: "structured_ask", input: freeTextInput },
397
+ { kind: "text", text: "done" },
398
+ ]),
399
+ askExtension(),
400
+ ],
401
+ });
402
+
403
+ const cleanup = autoResponder(agent, (req) => ({
404
+ id: req.id,
405
+ type: "submit",
406
+ tabs: [{ label: "Name", selected: [], freeText: "PascalCase for types" }],
407
+ }));
408
+
409
+ const stream = agent.generate({ input: [userMsg("go")] });
410
+ await stream.result;
411
+ for await (const _ of stream) { /* drain */ }
412
+ cleanup();
413
+
414
+ const output = lastToolOutput(agent.context.messages, "structured_ask");
415
+ const parsed = JSON.parse(output!);
416
+ expect(parsed.tabs[0].freeText).toBe("PascalCase for types");
417
+
418
+ await agent.dispose();
419
+ });
420
+ });
package/src/index.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @dex-ai/ask-extension — Structured ask tool.
3
+ *
4
+ * Architecture: zero hard coupling to any TUI/CLI.
5
+ * - The tool sets `ask:request` in agent.state and polls `ask:result`.
6
+ * - Any host (CLI TUI, web UI, IDE plugin) watches `ask:request`,
7
+ * presents UI, and sets `ask:result` when the user responds.
8
+ * - The tool picks up the result via polling and returns it to the agent.
9
+ */
10
+
11
+ import { Tool } from "@dex-ai/sdk";
12
+ import { z } from "zod";
13
+ import type { GenerateContext } from "@dex-ai/sdk";
14
+ import {
15
+ ASK_REQUEST_KEY,
16
+ ASK_RESULT_KEY,
17
+ type AskRequest,
18
+ type AskResult,
19
+ type AskTab,
20
+ } from "./types";
21
+
22
+ /* ------------------------------------------------------------------ */
23
+ /* Schemas */
24
+ /* ------------------------------------------------------------------ */
25
+
26
+ const askOptionSchema = z.object({
27
+ value: z.string().describe("Unique identifier for this option"),
28
+ label: z.string().describe("Display label"),
29
+ description: z.string().optional().describe("Context shown below the label"),
30
+ selected: z.boolean().optional().describe("Pre-selected by default"),
31
+ });
32
+
33
+ const askTabSchema = z.object({
34
+ label: z.string().describe("Tab header label"),
35
+ question: z.string().describe("The question for this tab"),
36
+ options: z.array(askOptionSchema).min(1).describe("Choices for this step"),
37
+ multiSelect: z
38
+ .boolean()
39
+ .optional()
40
+ .default(false)
41
+ .describe("true = checkboxes (multi), false = radio (single)"),
42
+ allowFreeText: z
43
+ .boolean()
44
+ .optional()
45
+ .default(false)
46
+ .describe("Add free-text input option at end"),
47
+ });
48
+
49
+ const askParams = z.object({
50
+ tabs: z
51
+ .array(askTabSchema)
52
+ .min(1)
53
+ .max(6)
54
+ .describe(
55
+ "Each tab is a step/question. Single tab = simple ask; multiple = wizard. Max 5 question tabs + auto-appended Submit.",
56
+ ),
57
+ });
58
+
59
+ type AskInput = z.infer<typeof askParams>;
60
+
61
+ /* ------------------------------------------------------------------ */
62
+ /* Poll interval for result checking */
63
+ /* ------------------------------------------------------------------ */
64
+
65
+ const POLL_INTERVAL_MS = 50;
66
+
67
+ /* ------------------------------------------------------------------ */
68
+ /* Tool definition */
69
+ /* ------------------------------------------------------------------ */
70
+
71
+ const structuredAskTool = Tool.define({
72
+ name: "structured_ask",
73
+ displayName: "Ask User",
74
+ description:
75
+ "Ask the user a structured question with multiple-choice options. " +
76
+ "Supports single/multi-select, free text, and multi-tab wizard flows. " +
77
+ "The UI replaces the input area until the user responds.",
78
+ access: "read",
79
+ visible: false,
80
+ parameters: askParams,
81
+
82
+ async execute(input: AskInput, gctx: GenerateContext) {
83
+ const requestId = `${gctx.generateId}:${gctx.stepCount}`;
84
+
85
+ // Normalize tabs
86
+ const tabs: AskTab[] = input.tabs.map((t) => ({
87
+ label: t.label,
88
+ question: t.question,
89
+ options: t.options.map((o) => ({
90
+ value: o.value,
91
+ label: o.label,
92
+ ...(o.description != null && { description: o.description }),
93
+ selected: o.selected ?? false,
94
+ })),
95
+ multiSelect: t.multiSelect ?? false,
96
+ allowFreeText: t.allowFreeText ?? false,
97
+ }));
98
+
99
+ // Reject if another ask is already pending
100
+ const existing = gctx.agent.state.get(ASK_REQUEST_KEY);
101
+ if (existing) {
102
+ return {
103
+ type: "error-text" as const,
104
+ value: "Error: Another structured_ask is already pending. Wait for it to complete.",
105
+ };
106
+ }
107
+
108
+ // Publish request into agent state (host UI watches this)
109
+ const request: AskRequest = { id: requestId, tabs };
110
+ gctx.agent.state.set(ASK_REQUEST_KEY, request);
111
+
112
+ // Wait for result via polling agent.state
113
+ const result = await new Promise<AskResult>((resolve) => {
114
+ const timer = setInterval(() => {
115
+ const res = gctx.agent.state.get(ASK_RESULT_KEY) as
116
+ | AskResult
117
+ | null
118
+ | undefined;
119
+ if (res && res.id === requestId) {
120
+ clearInterval(timer);
121
+ resolve(res);
122
+ }
123
+ }, POLL_INTERVAL_MS);
124
+
125
+ // Abort signal cleanup
126
+ const onAbort = () => {
127
+ clearInterval(timer);
128
+ gctx.agent.state.delete(ASK_REQUEST_KEY);
129
+ resolve({ id: requestId, type: "cancel" });
130
+ };
131
+ gctx.signal?.addEventListener("abort", onAbort, { once: true });
132
+
133
+ // Also listen on agent-level signal
134
+ gctx.agent.signal?.addEventListener("abort", onAbort, { once: true });
135
+ });
136
+
137
+ // Cleanup state
138
+ gctx.agent.state.delete(ASK_REQUEST_KEY);
139
+ gctx.agent.state.delete(ASK_RESULT_KEY);
140
+
141
+ // Format output for the agent
142
+ switch (result.type) {
143
+ case "submit":
144
+ return {
145
+ type: "text" as const,
146
+ value: JSON.stringify({ tabs: result.tabs }, null, 2),
147
+ };
148
+
149
+ case "eject":
150
+ return {
151
+ type: "text" as const,
152
+ value: JSON.stringify(
153
+ {
154
+ ejected: true,
155
+ message:
156
+ "User chose to discuss this in chat. Use the completed tabs as context.",
157
+ completedTabs: result.completedTabs,
158
+ currentTab: result.currentTab,
159
+ partialSelected: result.partialSelected,
160
+ },
161
+ null,
162
+ 2,
163
+ ),
164
+ };
165
+
166
+ case "cancel":
167
+ return {
168
+ type: "text" as const,
169
+ value: JSON.stringify({ cancelled: true }),
170
+ };
171
+ }
172
+ },
173
+ });
174
+
175
+ /* ------------------------------------------------------------------ */
176
+ /* Extension factory */
177
+ /* ------------------------------------------------------------------ */
178
+
179
+ export interface AskExtensionOptions {
180
+ /** Override the tool name. Default: "structured_ask". */
181
+ name?: string;
182
+ }
183
+
184
+ export function askExtension(_opts?: AskExtensionOptions) {
185
+ return {
186
+ name: "ask",
187
+ tools: [structuredAskTool],
188
+ };
189
+ }
190
+
191
+ /* ------------------------------------------------------------------ */
192
+ /* Re-exports */
193
+ /* ------------------------------------------------------------------ */
194
+
195
+ export type {
196
+ AskOption,
197
+ AskTab,
198
+ AskRequest,
199
+ AskTabResult,
200
+ AskResult,
201
+ AskResultSubmit,
202
+ AskResultEject,
203
+ AskResultCancel,
204
+ } from "./types";
205
+ export { ASK_REQUEST_KEY, ASK_RESULT_KEY } from "./types";
package/src/types.ts ADDED
@@ -0,0 +1,84 @@
1
+ /* ------------------------------------------------------------------ */
2
+ /* State keys */
3
+ /* ------------------------------------------------------------------ */
4
+
5
+ /** Key for the pending ask request (tool → TUI). */
6
+ export const ASK_REQUEST_KEY = "ask:request";
7
+
8
+ /** Key for the user's response (TUI → tool). */
9
+ export const ASK_RESULT_KEY = "ask:result";
10
+
11
+ /* ------------------------------------------------------------------ */
12
+ /* Request types (tool → TUI) */
13
+ /* ------------------------------------------------------------------ */
14
+
15
+ export interface AskOption {
16
+ /** Unique identifier returned in results. */
17
+ value: string;
18
+ /** Display label shown to the user. */
19
+ label: string;
20
+ /** Optional description shown below the label (dim text). */
21
+ description?: string;
22
+ /** Pre-selected by default. */
23
+ selected?: boolean;
24
+ }
25
+
26
+ export interface AskTab {
27
+ /** Tab header label (e.g. "Scope", "Strategy"). */
28
+ label: string;
29
+ /** The question shown when this tab is active. */
30
+ question: string;
31
+ /** Choices for this step. */
32
+ options: AskOption[];
33
+ /** true = checkboxes (multi-select), false = radio (single-select). */
34
+ multiSelect: boolean;
35
+ /** Add a free-text input option at the end. */
36
+ allowFreeText: boolean;
37
+ }
38
+
39
+ export interface AskRequest {
40
+ /** Unique per-call — derived from generateId + stepCount. */
41
+ id: string;
42
+ /** One or more tabs (single tab = simple mode, multi = wizard). */
43
+ tabs: AskTab[];
44
+ }
45
+
46
+ /* ------------------------------------------------------------------ */
47
+ /* Result types (TUI → tool) */
48
+ /* ------------------------------------------------------------------ */
49
+
50
+ export interface AskTabResult {
51
+ /** Matches the tab's label. */
52
+ label: string;
53
+ /** Values of the selected options. */
54
+ selected: string[];
55
+ /** Free text entered by the user, or null. */
56
+ freeText: string | null;
57
+ }
58
+
59
+ /** User submitted all tabs. */
60
+ export interface AskResultSubmit {
61
+ id: string;
62
+ type: "submit";
63
+ tabs: AskTabResult[];
64
+ }
65
+
66
+ /** User chose "Chat about this" — ejected mid-flow. */
67
+ export interface AskResultEject {
68
+ id: string;
69
+ type: "eject";
70
+ /** Tabs that were fully completed before eject. */
71
+ completedTabs: AskTabResult[];
72
+ /** Label of the tab the user was on when they ejected. */
73
+ currentTab: string;
74
+ /** Any partially selected values on the current tab. */
75
+ partialSelected: string[];
76
+ }
77
+
78
+ /** User pressed Esc to cancel. */
79
+ export interface AskResultCancel {
80
+ id: string;
81
+ type: "cancel";
82
+ }
83
+
84
+ export type AskResult = AskResultSubmit | AskResultEject | AskResultCancel;