@stibbons/pi-opensuperwhisper 0.1.0 → 0.2.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Answer [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi](https://github.com/can1357/oh-my-pi) agents from the [OpenSuperWhisper](https://github.com/my-monkeys/OpenSuperWhisper) panel on macOS.
4
4
 
5
- When a run is finished and waiting for you, OpenSuperWhisper's Agents panel opens with the agent's last message. Dictate (or type) your reply there. It shows up in the session as a normal user message and starts the next run. You don't have to go back to the terminal.
5
+ When a run is finished and waiting for you, OpenSuperWhisper's Agents panel opens with the agent's last message. Dictate (or type) your reply there. It shows up in the session as a normal user message and starts the next run. You don't have to go back to the terminal. On oh-my-pi, questions the agent asks with its `ask` tool open in the panel too.
6
6
 
7
7
  ## Install
8
8
 
@@ -32,6 +32,8 @@ There's nothing to configure. Turn the panel on or off in OpenSuperWhisper's own
32
32
  - **Your reply** is sent as the next user message, without the panel's "The user answered by voice…" prefix.
33
33
  - **"Let it stop", or the panel timing out,** leaves the agent idle with no side effects.
34
34
  - **The terminal always wins.** Typing in the terminal, starting a new run, switching or resuming sessions, or quitting cancels the panel request. Exactly one run happens, even if a panel reply arrives at the same moment.
35
+ - **oh-my-pi `ask` questions** open in the panel and in the terminal dialog at the same time. Whichever you answer first wins: a panel answer closes the terminal dialog and reaches the agent exactly as if you had picked it there (single choice, multi-select or free text); answering in the terminal closes the panel. Dismissing the panel, or letting it time out, leaves the terminal dialog open. Questions from subagents, and calls with two identical question texts, stay terminal-only.
36
+ - **pi has no `ask` tool.** For structured questions on pi, install a question plugin such as [`@mazli/pi-ask-user-question`](https://www.npmjs.com/package/@mazli/pi-ask-user-question) (`pi install npm:@mazli/pi-ask-user-question`). Its questions are answered in the terminal and don't open in the panel: pi gives extensions no `ctx.invokeTool`, so this extension can't run another extension's question tool and the panel side by side.
35
37
  - **Fails open.** If OpenSuperWhisper isn't running, its files are missing or stale, or anything goes wrong, the extension does nothing and logs nothing. It never blocks the agent or the terminal.
36
38
  - **No network.** The extension only talks to the local OpenSuperWhisper app.
37
39
 
@@ -39,6 +41,10 @@ There's nothing to configure. Turn the panel on or off in OpenSuperWhisper's own
39
41
 
40
42
  The extension uses OpenSuperWhisper's own Claude Code integration path. It finds the running app through `~/Library/Application Support/fr.my-monkey.opensuperwhisper/agents/` (`app.pid`, `app.executable`), then runs `<app> agent-hook stop` with a Claude Code–style Stop request on stdin and `CLAUDE_PROJECT_DIR` set. If you answer, the app prints the reply on stdout. Cancelling a request kills that process, and the app closes the panel within about a second.
41
43
 
44
+ For oh-my-pi's `ask` tool, the extension registers its own `ask` (same description and parameters) when a session starts. Each call runs the native `ask` through `ctx.invokeTool` and, at the same time, `<app> agent-hook question` with the questions as a Claude Code `AskUserQuestion` request. The first answer wins and the other side is cancelled.
45
+
46
+ **Known cost:** because the extension replaces the built-in `ask`, oh-my-pi shows `ask` calls in the transcript like any other extension tool instead of with its built-in renderer. The interactive question dialog is unchanged.
47
+
42
48
  ## Development
43
49
 
44
50
  The package ships TypeScript source; there's no build step. pi loads it through jiti, oh-my-pi through Bun.
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@stibbons/pi-opensuperwhisper",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Answer pi and oh-my-pi agents from the OpenSuperWhisper panel on macOS",
5
5
  "license": "MIT",
6
6
  "author": "Daniel Holzmann <daniel@danielholzmann.com>",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/dVelopment/pi-opensuperwhisper.git"
9
+ "url": "git+https://github.com/stibbons-ai/pi-opensuperwhisper.git"
10
10
  },
11
11
  "publishConfig": {
12
12
  "access": "public"
package/src/hook.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
+ import type { QuestionRequest } from "./question.ts";
3
+
4
+ /** The `agent-hook` subcommands this extension runs, each with the request it reads on stdin. */
5
+ interface HookRequests {
6
+ stop: StopRequest;
7
+ question: QuestionRequest;
8
+ }
2
9
 
3
10
  /** The Claude Code–style Stop payload `agent-hook stop` reads from stdin. */
4
11
  export interface StopRequest {
@@ -9,7 +16,7 @@ export interface StopRequest {
9
16
  stop_hook_active: false;
10
17
  }
11
18
 
12
- export interface StopHook {
19
+ export interface HookProcess {
13
20
  /** Resolves with the hook's stdout when it exits 0, or nothing on any failure. */
14
21
  readonly stdout: Promise<string | undefined>;
15
22
  /** Stops the hook (SIGTERM). Safe to call after it has exited. */
@@ -17,12 +24,12 @@ export interface StopHook {
17
24
  }
18
25
 
19
26
  /**
20
- * Spawns `<executable> agent-hook stop` without blocking, writes the Stop request to its stdin
21
- * and collects its stdout. `CLAUDE_PROJECT_DIR` makes the app accept the request as Claude Code.
27
+ * Spawns `<executable> agent-hook <event>` without blocking, writes the request as JSON to its
28
+ * stdin and collects its stdout. `CLAUDE_PROJECT_DIR` makes the app accept the request as Claude Code.
22
29
  */
23
- export function runStopHook(executable: string, request: StopRequest): StopHook {
30
+ export function runHook<E extends keyof HookRequests>(executable: string, event: E, request: HookRequests[E]): HookProcess {
24
31
  const { promise, resolve } = Promise.withResolvers<string | undefined>();
25
- const child = spawn(executable, ["agent-hook", "stop"], {
32
+ const child = spawn(executable, ["agent-hook", event], {
26
33
  env: { ...process.env, CLAUDE_PROJECT_DIR: request.cwd },
27
34
  stdio: ["pipe", "pipe", "ignore"],
28
35
  });
package/src/index.ts CHANGED
@@ -1,15 +1,23 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
- import type { AgentEndEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type {
4
+ AgentEndEvent,
5
+ AgentToolResult,
6
+ ExtensionAPI,
7
+ ExtensionContext,
8
+ ToolDefinition,
9
+ ToolInfo,
10
+ } from "@earendil-works/pi-coding-agent";
4
11
  import { locateApp } from "./app.ts";
5
- import { runStopHook, type StopHook } from "./hook.ts";
12
+ import { type HookProcess, runHook, type StopRequest } from "./hook.ts";
6
13
  import { type AssistantMessageLike, extractText, parseReply } from "./message.ts";
14
+ import { type AskInput, parseQuestionAnswers, sanitizeAskInput, toQuestionRequest } from "./question.ts";
7
15
 
8
16
  export interface ExtensionOptions {
9
17
  /** OpenSuperWhisper's agent registry directory (holds `app.pid` and `app.executable`). */
10
18
  agentsDir: string;
11
19
  platform: NodeJS.Platform;
12
- /** Safety net: kill a hook that outlives the app's own maximum wait (590 s). */
20
+ /** Safety net: kill a hook that outlives the app's own wait (30–590 s). */
13
21
  hardKillMs: number;
14
22
  }
15
23
 
@@ -21,11 +29,26 @@ const DEFAULT_OPTIONS: ExtensionOptions = {
21
29
 
22
30
  export function createExtension(overrides: Partial<ExtensionOptions> = {}): (pi: ExtensionAPI) => void {
23
31
  const options = { ...DEFAULT_OPTIONS, ...overrides };
32
+
33
+ /** The panel is a macOS app, and only an interactive terminal session has a user to ask. */
34
+ function canShowPanel(ctx: ExtensionContext): boolean {
35
+ return options.platform === "darwin" && ctx.mode === "tui";
36
+ }
37
+
38
+ /** Calls `kill` once the hook has outlived `hardKillMs`. Returns the function that disarms it. */
39
+ function armHardKill(kill: () => void): () => void {
40
+ const timer = setTimeout(kill, options.hardKillMs);
41
+ timer.unref();
42
+ return () => clearTimeout(timer);
43
+ }
44
+
24
45
  return (pi) => {
25
46
  // At most one pending panel request per session. Bumping the generation orphans any reply
26
47
  // still in flight, so typed input racing a panel reply produces exactly one run.
27
- let pending: StopHook | undefined;
48
+ let pending: HookProcess | undefined;
28
49
  let generation = 0;
50
+ /** Panel requests of `ask` calls still in flight. */
51
+ const askHooks = new Set<HookProcess>();
29
52
 
30
53
  function cancelPending(): void {
31
54
  generation++;
@@ -36,27 +59,26 @@ export function createExtension(overrides: Partial<ExtensionOptions> = {}): (pi:
36
59
  /** Opens the panel for a settled run, unless a guard says the user shouldn't be asked. */
37
60
  function requestReply(ctx: ExtensionContext, message: AssistantMessageLike | undefined, sessionId: string): void {
38
61
  cancelPending();
39
- if (options.platform !== "darwin" || ctx.mode !== "tui") return;
62
+ if (!canShowPanel(ctx)) return;
40
63
  if (!message || message.stopReason === "aborted" || message.stopReason === "error") return;
41
64
  const text = extractText(message);
42
65
  if (!text) return;
43
66
  const executable = locateApp(options.agentsDir);
44
67
  if (!executable) return;
45
68
  const request = generation;
46
- pending = runStopHook(executable, {
69
+ const stopRequest: StopRequest = {
47
70
  hook_event_name: "Stop",
48
71
  session_id: sessionId,
49
72
  cwd: ctx.cwd,
50
73
  last_assistant_message: text,
51
74
  stop_hook_active: false,
52
- });
53
- // Safety net in case the hook outlives the app's own wait (30–590 s).
54
- const hardKill = setTimeout(() => {
75
+ };
76
+ pending = runHook(executable, "stop", stopRequest);
77
+ const disarmHardKill = armHardKill(() => {
55
78
  if (request === generation) cancelPending();
56
- }, options.hardKillMs);
57
- hardKill.unref();
79
+ });
58
80
  void pending.stdout.then((stdout) => {
59
- clearTimeout(hardKill);
81
+ disarmHardKill();
60
82
  if (request !== generation) return;
61
83
  pending = undefined;
62
84
  const reply = stdout && parseReply(stdout);
@@ -75,7 +97,10 @@ export function createExtension(overrides: Partial<ExtensionOptions> = {}): (pi:
75
97
  if (event.source !== "extension") cancelPending();
76
98
  });
77
99
  pi.on("agent_start", cancelPending);
78
- pi.on("session_shutdown", cancelPending);
100
+ pi.on("session_shutdown", () => {
101
+ cancelPending();
102
+ for (const hook of askHooks) hook.kill();
103
+ });
79
104
  pi.on("session_before_switch", cancelPending);
80
105
 
81
106
  // pi: fires once the run is fully settled (no retry, compaction or queued continuation left).
@@ -97,6 +122,73 @@ export function createExtension(overrides: Partial<ExtensionOptions> = {}): (pi:
97
122
  if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
98
123
  requestReply(ctx, lastAssistant(messages), ctx.sessionManager.getSessionId());
99
124
  });
125
+
126
+ // omp: shadow the built-in `ask` and race its native dialog against the panel; the first
127
+ // answer wins. pi has no built-in `ask`.
128
+ let askRegistered = false;
129
+
130
+ pi.on("session_start", (_event, ctx) => {
131
+ if (askRegistered || !isOmp(ctx)) return;
132
+ const builtin = pi.getAllTools().find((tool) => tool.name === "ask" && tool.sourceInfo.source === "builtin");
133
+ if (!builtin) return;
134
+ askRegistered = true;
135
+ const ask: OmpToolDefinition = {
136
+ name: "ask",
137
+ description: builtin.description,
138
+ parameters: builtin.parameters,
139
+ // The native tool's traits (`tools/ask.ts`). Exclusive: the dialog is one shared UI
140
+ // surface, so two concurrent asks would clobber each other.
141
+ label: "Ask",
142
+ approval: "read",
143
+ strict: true,
144
+ loadMode: "discoverable",
145
+ concurrency: "exclusive",
146
+ execute: (_toolCallId, params, signal, _onUpdate, toolCtx) =>
147
+ answerAsk(params as Record<string, unknown>, signal, toolCtx as unknown as OmpToolContext),
148
+ };
149
+ pi.registerTool(ask);
150
+ });
151
+
152
+ /** Runs the native `ask`, plus the panel when a guard doesn't rule it out. */
153
+ async function answerAsk(params: Record<string, unknown>, signal: AbortSignal | undefined, ctx: OmpToolContext) {
154
+ if (!ctx.invokeTool) throw new Error("omp offers no native `ask` to delegate to");
155
+ // The panel must show and answer the text native shows; native gets the call unchanged.
156
+ const input = sanitizeAskInput(params as unknown as AskInput);
157
+ const request =
158
+ canShowPanel(ctx) && ctx.agent.kind === "main" && !signal?.aborted
159
+ ? toQuestionRequest(input, ctx.sessionManager.getSessionId(), ctx.cwd)
160
+ : undefined;
161
+ const executable = request && locateApp(options.agentsDir);
162
+ if (!request || !executable) return ctx.invokeTool(params);
163
+
164
+ const abortNativeDialog = new AbortController();
165
+ const native = ctx.invokeTool(params, {
166
+ signal: signal ? AbortSignal.any([signal, abortNativeDialog.signal]) : abortNativeDialog.signal,
167
+ });
168
+ const hook = runHook(executable, "question", request);
169
+ askHooks.add(hook);
170
+ const kill = () => hook.kill();
171
+ signal?.addEventListener("abort", kill, { once: true });
172
+ const disarmHardKill = armHardKill(kill);
173
+ try {
174
+ // Resolves with the panel's answer, or nothing once native settled or the panel closed
175
+ // without one. Handling native's rejection here also swallows the one the panel's win causes.
176
+ const answer = await Promise.race([
177
+ native.then(() => undefined, () => undefined),
178
+ hook.stdout.then((stdout) => (stdout === undefined ? undefined : parseQuestionAnswers(stdout, input))),
179
+ ]);
180
+ if (answer) {
181
+ abortNativeDialog.abort();
182
+ return answer;
183
+ }
184
+ return await native;
185
+ } finally {
186
+ disarmHardKill();
187
+ signal?.removeEventListener("abort", kill);
188
+ askHooks.delete(hook);
189
+ hook.kill();
190
+ }
191
+ }
100
192
  };
101
193
  }
102
194
 
@@ -106,6 +198,19 @@ interface OmpContext extends ExtensionContext {
106
198
  agent: { kind: "main" | "sub" };
107
199
  }
108
200
 
201
+ /** The `ctx` omp passes to a tool that re-registers a built-in: `invokeTool` runs the native one. */
202
+ interface OmpToolContext extends OmpContext {
203
+ invokeTool?(params: Record<string, unknown>, options?: { signal?: AbortSignal }): Promise<AgentToolResult<unknown>>;
204
+ }
205
+
206
+ /** omp-only tool traits (`extensibility/extensions/types.ts`, proxied by `RegisteredToolAdapter`). */
207
+ interface OmpToolDefinition extends ToolDefinition {
208
+ approval: "read";
209
+ strict: boolean;
210
+ loadMode: "discoverable";
211
+ concurrency: "exclusive";
212
+ }
213
+
109
214
  interface OmpAgentEndEvent extends AgentEndEvent {
110
215
  /** True when omp will continue on its own (retry, compaction recovery, reminders, async wakes). */
111
216
  willContinue?: boolean;
@@ -0,0 +1,194 @@
1
+ /** The parts of an omp `ask` call this extension reads (`tools/ask.ts` input schema). */
2
+ export interface AskInput {
3
+ questions: AskQuestion[];
4
+ }
5
+
6
+ export interface AskQuestion {
7
+ id: string;
8
+ question: string;
9
+ header?: string;
10
+ options: { label: string; description?: string; preview?: string }[];
11
+ multi?: boolean;
12
+ recommended?: number;
13
+ }
14
+
15
+ /** What `agent-hook question` reads on stdin: a Claude Code `AskUserQuestion` PreToolUse call. */
16
+ export interface QuestionRequest {
17
+ hook_event_name: "PreToolUse";
18
+ tool_name: "AskUserQuestion";
19
+ tool_input: { questions: PanelQuestion[] };
20
+ session_id: string;
21
+ cwd: string;
22
+ }
23
+
24
+ interface PanelQuestion {
25
+ question: string;
26
+ header?: string;
27
+ options: { label: string; description?: string }[];
28
+ multiSelect: boolean;
29
+ }
30
+
31
+ /**
32
+ * Strips the `\r` runs models sometimes inject, as native `ask` does before it shows or answers a
33
+ * call (`sanitizeAskParams` in `tools/ask.ts` at v18.12.6), so the panel shows and answers the
34
+ * same text native would.
35
+ */
36
+ export function sanitizeAskInput(input: AskInput): AskInput {
37
+ return {
38
+ ...input,
39
+ questions: input.questions.map((q) => ({
40
+ ...q,
41
+ id: sanitizeCarriageReturns(q.id),
42
+ question: sanitizeCarriageReturns(q.question),
43
+ ...(q.header === undefined ? {} : { header: sanitizeCarriageReturns(q.header) }),
44
+ options: q.options.map((o) => ({
45
+ ...o,
46
+ label: sanitizeCarriageReturns(o.label),
47
+ ...(o.description === undefined ? {} : { description: sanitizeCarriageReturns(o.description) }),
48
+ ...(o.preview === undefined ? {} : { preview: sanitizeCarriageReturns(o.preview) }),
49
+ })),
50
+ })),
51
+ };
52
+ }
53
+
54
+ /** omp's `sanitizeCarriageReturns` (`pi-tui/render/render-utils.ts` at v18.12.6). */
55
+ function sanitizeCarriageReturns(text: string): string {
56
+ if (!text.includes("\r")) return text;
57
+ return text.replaceAll("\r\n", "\n").replace(/\r+/g, " ");
58
+ }
59
+
60
+ /**
61
+ * Maps an `ask` call to the stdin of `agent-hook question`. Returns nothing when the panel
62
+ * can't represent the call: it keys answers by question text, so two questions with the same
63
+ * text can't be told apart.
64
+ */
65
+ export function toQuestionRequest(input: AskInput, sessionId: string, cwd: string): QuestionRequest | undefined {
66
+ const texts = new Set(input.questions.map((q) => q.question));
67
+ if (texts.size !== input.questions.length) return undefined;
68
+ return {
69
+ hook_event_name: "PreToolUse",
70
+ tool_name: "AskUserQuestion",
71
+ tool_input: {
72
+ questions: input.questions.map((q) => ({
73
+ question: q.question,
74
+ ...(q.header === undefined ? {} : { header: q.header }),
75
+ options: q.options.map((o) => ({
76
+ label: o.label,
77
+ ...(o.description === undefined ? {} : { description: o.description }),
78
+ })),
79
+ multiSelect: q.multi ?? false,
80
+ })),
81
+ },
82
+ session_id: sessionId,
83
+ cwd,
84
+ };
85
+ }
86
+
87
+ /** One answered question, as native `ask` reports it in `details.results` (`AskToolDetails`). */
88
+ export interface QuestionResult {
89
+ id: string;
90
+ question: string;
91
+ options: string[];
92
+ multi: boolean;
93
+ selectedOptions: string[];
94
+ customInput?: string;
95
+ }
96
+
97
+ /** The `ask` tool result: one question puts its answer in `details`, several in `details.results`. */
98
+ export interface AskResult {
99
+ content: [{ type: "text"; text: string }];
100
+ details: Partial<Omit<QuestionResult, "id">> & { results?: QuestionResult[] };
101
+ }
102
+
103
+ /**
104
+ * Maps the stdout of `agent-hook question` to the result omp's native `ask` returns for the same
105
+ * selections (`tools/ask.ts` at v18.12.6). Returns nothing unless the panel allowed the call with
106
+ * an answer for every question: dismissed, timed out, empty or malformed output.
107
+ */
108
+ export function parseQuestionAnswers(stdout: string, input: AskInput): AskResult | undefined {
109
+ const answers = readAnswers(stdout);
110
+ if (answers === undefined) return undefined;
111
+ const results: QuestionResult[] = [];
112
+ for (const q of input.questions) {
113
+ const answer = answers[q.question];
114
+ if (typeof answer !== "string" || answer === "") return undefined;
115
+ results.push(toResult(q, answer));
116
+ }
117
+ const [only] = results;
118
+ if (only !== undefined && results.length === 1) {
119
+ const { id: _, ...details } = only;
120
+ return { content: [{ type: "text", text: formatSingleQuestionResponse(only) }], details };
121
+ }
122
+ return {
123
+ content: [{ type: "text", text: `User answers:\n${results.map(formatQuestionResult).join("\n")}` }],
124
+ details: { results },
125
+ };
126
+ }
127
+
128
+ /** The parts of `agent-hook question` stdout this extension reads; any of them may be missing or wrong. */
129
+ interface HookOutput {
130
+ hookSpecificOutput?: { permissionDecision?: unknown; updatedInput?: { answers?: unknown } };
131
+ }
132
+
133
+ /** The `updatedInput.answers` of an allowed `agent-hook question` call, keyed by question text. */
134
+ function readAnswers(stdout: string): Record<string, unknown> | undefined {
135
+ let json: HookOutput | null;
136
+ try {
137
+ json = JSON.parse(stdout);
138
+ } catch {
139
+ return undefined;
140
+ }
141
+ const output = json?.hookSpecificOutput;
142
+ if (output?.permissionDecision !== "allow") return undefined;
143
+ const answers = output.updatedInput?.answers;
144
+ return typeof answers === "object" && answers !== null ? (answers as Record<string, unknown>) : undefined;
145
+ }
146
+
147
+ /**
148
+ * The panel answers with one label, the picked labels joined with ", " in option order
149
+ * (multi-select), or free text. Anything that isn't a label (or labels) is custom input.
150
+ */
151
+ function toResult(q: AskQuestion, answer: string): QuestionResult {
152
+ const labels = q.options.map((o) => o.label);
153
+ const multi = q.multi ?? false;
154
+ const selected = labels.includes(answer) ? [answer] : multi ? splitLabels(answer, labels, 0) : undefined;
155
+ const base = { id: q.id, question: q.question, options: labels, multi };
156
+ return selected === undefined
157
+ ? { ...base, selectedOptions: [], customInput: answer }
158
+ : { ...base, selectedOptions: selected };
159
+ }
160
+
161
+ /** Labels from `labels[from..]`, in order, whose ", "-join is `value`; labels may contain ", " themselves. */
162
+ function splitLabels(value: string, labels: string[], from: number): string[] | undefined {
163
+ for (let i = from; i < labels.length; i++) {
164
+ const label = labels[i] as string;
165
+ if (value === label) return [label];
166
+ if (!value.startsWith(`${label}, `)) continue;
167
+ const rest = splitLabels(value.slice(label.length + 2), labels, i + 1);
168
+ if (rest !== undefined) return [label, ...rest];
169
+ }
170
+ return undefined;
171
+ }
172
+
173
+ // The two formatters mirror omp's `tools/ask.ts` (v18.12.6) for the answers the panel can give:
174
+ // picked labels or custom input, never both, and no note or timeout.
175
+
176
+ function formatQuestionResult(result: QuestionResult): string {
177
+ if (result.customInput !== undefined) return `${result.id}: "${result.customInput}"`;
178
+ const picked = result.multi ? `[${result.selectedOptions.join(", ")}]` : result.selectedOptions[0];
179
+ return `${result.id}: ${picked}`;
180
+ }
181
+
182
+ function formatSingleQuestionResponse(result: QuestionResult): string {
183
+ if (result.customInput === undefined) {
184
+ return result.multi
185
+ ? `User selected: ${result.selectedOptions.join(", ")}`
186
+ : `User selected: ${result.selectedOptions[0]}`;
187
+ }
188
+ return result.customInput.includes("\n")
189
+ ? `User provided custom input:\n${result.customInput
190
+ .split("\n")
191
+ .map((line) => ` ${line}`)
192
+ .join("\n")}`
193
+ : `User provided custom input: ${result.customInput}`;
194
+ }