@tt-a1i/openpi 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.
Files changed (114) hide show
  1. package/README.md +643 -0
  2. package/SETUP.md +74 -0
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-package.png +0 -0
  5. package/assets/readme-hero-mobile.svg +72 -0
  6. package/assets/readme-hero.svg +118 -0
  7. package/assets/readme-runtime-mobile.svg +91 -0
  8. package/assets/readme-runtime.svg +111 -0
  9. package/extensions/ask-user/handoff.ts +205 -0
  10. package/extensions/ask-user/index.ts +1110 -0
  11. package/extensions/ask-user/limits.ts +89 -0
  12. package/extensions/ask-user/prompt.ts +76 -0
  13. package/extensions/background-terminals/index.ts +653 -0
  14. package/extensions/background-terminals/src/domain.ts +99 -0
  15. package/extensions/background-terminals/src/manager.ts +989 -0
  16. package/extensions/background-terminals/src/output.ts +84 -0
  17. package/extensions/background-terminals/src/prompt.ts +195 -0
  18. package/extensions/background-terminals/src/result-delivery.ts +43 -0
  19. package/extensions/background-terminals/src/runtime.ts +36 -0
  20. package/extensions/background-terminals/src/ui/output-view.ts +55 -0
  21. package/extensions/background-terminals/src/ui/ps.ts +642 -0
  22. package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
  23. package/extensions/background-terminals/src/watch.ts +192 -0
  24. package/extensions/context-pivot/index.ts +222 -0
  25. package/extensions/copy-all/index.ts +65 -0
  26. package/extensions/cron/index.ts +173 -0
  27. package/extensions/cron/schedule.ts +127 -0
  28. package/extensions/file-mutation-display/index.ts +105 -0
  29. package/extensions/file-mutation-display/render.ts +107 -0
  30. package/extensions/file-search/index.ts +515 -0
  31. package/extensions/file-search/src/args.ts +129 -0
  32. package/extensions/file-search/src/binaries.ts +419 -0
  33. package/extensions/file-search/src/output.ts +142 -0
  34. package/extensions/file-search/src/process.ts +309 -0
  35. package/extensions/file-search/src/prompt.ts +53 -0
  36. package/extensions/git-info/index.ts +272 -0
  37. package/extensions/git-info/src/changed-files-view.ts +414 -0
  38. package/extensions/git-info/src/process.ts +107 -0
  39. package/extensions/git-info/src/refresh-coordinator.ts +13 -0
  40. package/extensions/git-info/src/runtime.ts +28 -0
  41. package/extensions/goal/controller.ts +794 -0
  42. package/extensions/goal/index.ts +521 -0
  43. package/extensions/goal/prompts.ts +122 -0
  44. package/extensions/goal/state.ts +763 -0
  45. package/extensions/goal/ui.ts +158 -0
  46. package/extensions/model-info/index.ts +234 -0
  47. package/extensions/plan-mode/bash-policy.ts +313 -0
  48. package/extensions/plan-mode/index.ts +539 -0
  49. package/extensions/post-edit/index.ts +129 -0
  50. package/extensions/sessions/LICENSE.upstream +21 -0
  51. package/extensions/sessions/git-stats.ts +226 -0
  52. package/extensions/sessions/index.ts +1092 -0
  53. package/extensions/sessions/sessions.ts +385 -0
  54. package/extensions/setup/index.ts +408 -0
  55. package/extensions/shared/activity-status.ts +65 -0
  56. package/extensions/shared/below-editor-navigation.ts +343 -0
  57. package/extensions/shared/child-session.ts +352 -0
  58. package/extensions/shared/context-utilization.ts +47 -0
  59. package/extensions/shared/dashboard-state.ts +102 -0
  60. package/extensions/shared/plan-mode-state.ts +65 -0
  61. package/extensions/shared/setup-config.ts +971 -0
  62. package/extensions/shared/subagent-roles.ts +22 -0
  63. package/extensions/shared/terminal-text.ts +38 -0
  64. package/extensions/shared/tool-call-timeout.ts +104 -0
  65. package/extensions/shared/worktree.ts +526 -0
  66. package/extensions/subagents/index.ts +1225 -0
  67. package/extensions/subagents/navigation.ts +121 -0
  68. package/extensions/subagents/src/agent-types.ts +543 -0
  69. package/extensions/subagents/src/backend.ts +63 -0
  70. package/extensions/subagents/src/backends/pi.ts +493 -0
  71. package/extensions/subagents/src/backends/stub.ts +296 -0
  72. package/extensions/subagents/src/by-the-way.ts +21 -0
  73. package/extensions/subagents/src/domain.ts +271 -0
  74. package/extensions/subagents/src/format.ts +48 -0
  75. package/extensions/subagents/src/manager.ts +769 -0
  76. package/extensions/subagents/src/prompt.ts +190 -0
  77. package/extensions/subagents/src/result-delivery.ts +20 -0
  78. package/extensions/subagents/src/runtime.ts +51 -0
  79. package/extensions/subagents/src/ui/takeover.ts +615 -0
  80. package/extensions/subagents/src/ui/transcript.ts +293 -0
  81. package/extensions/subagents/src/ui/wait-result.ts +89 -0
  82. package/extensions/suggestions/index.ts +172 -0
  83. package/extensions/suggestions/src/config.ts +12 -0
  84. package/extensions/suggestions/src/predictor.ts +147 -0
  85. package/extensions/suggestions/src/prompt.ts +20 -0
  86. package/extensions/suggestions/src/transcript.ts +233 -0
  87. package/extensions/suggestions/src/ui.ts +224 -0
  88. package/extensions/tasks/index.ts +512 -0
  89. package/extensions/tasks/tasks.ts +649 -0
  90. package/extensions/tasks/ui.ts +421 -0
  91. package/extensions/turn-time/index.ts +61 -0
  92. package/extensions/ui-customization/footer.ts +512 -0
  93. package/extensions/ui-customization/index.ts +217 -0
  94. package/extensions/workflows/acceptance.ts +298 -0
  95. package/extensions/workflows/artifacts.ts +225 -0
  96. package/extensions/workflows/controller.ts +210 -0
  97. package/extensions/workflows/dashboard.ts +1226 -0
  98. package/extensions/workflows/index.ts +1884 -0
  99. package/extensions/workflows/journal.ts +188 -0
  100. package/extensions/workflows/meta.ts +250 -0
  101. package/extensions/workflows/model.ts +423 -0
  102. package/extensions/workflows/navigation.ts +93 -0
  103. package/extensions/workflows/prompt.ts +212 -0
  104. package/extensions/workflows/replay-safety.ts +577 -0
  105. package/extensions/workflows/runner.ts +786 -0
  106. package/extensions/workflows/sandbox-child.cjs +402 -0
  107. package/extensions/workflows/sandbox.ts +397 -0
  108. package/extensions/workflows/serialization.ts +162 -0
  109. package/extensions/workflows/worktree-handoff.ts +216 -0
  110. package/package.json +87 -0
  111. package/scripts/prepare-effect-tsgo.mjs +16 -0
  112. package/skills/background-terminals/SKILL.md +30 -0
  113. package/skills/subagents/SKILL.md +15 -0
  114. package/themes/github-dark-default.json +89 -0
@@ -0,0 +1,1110 @@
1
+ /**
2
+ * ask_user — structured root-agent questions with a focused TUI.
3
+ *
4
+ * - 1 to 3 independent questions per call, shown one at a time
5
+ * - 2 to 5 options per question, plus an automatic free-form option
6
+ * - Enter records a draft answer; Tab adds optional notes to the highlighted option
7
+ * - every call ends on an explicit review screen before answers are submitted
8
+ * - review can reopen any question; Esc dismisses without returning draft answers
9
+ */
10
+
11
+ import type {
12
+ ExtensionAPI,
13
+ ExtensionContext,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import {
16
+ Editor,
17
+ type EditorTheme,
18
+ Key,
19
+ matchesKey,
20
+ Text,
21
+ truncateToWidth,
22
+ type Focusable,
23
+ wrapTextWithAnsi,
24
+ } from "@earendil-works/pi-tui";
25
+ import { Cause, Effect, Exit } from "effect";
26
+ import { Type, type Static } from "typebox";
27
+ import { sanitizeTerminalText } from "../shared/terminal-text.ts";
28
+ import { createHumanHandoffToolDefinition } from "./handoff.ts";
29
+ import {
30
+ BRACKETED_PASTE_END,
31
+ BRACKETED_PASTE_START,
32
+ MAX_ANSWER_DRAFT_UTF8_BYTES,
33
+ answerDraftByteLength,
34
+ answerDraftFits,
35
+ longerThanAnswerDraftLimit,
36
+ prospectiveAnswerDraftFits,
37
+ sanitizeAnswerDraftEditorInput,
38
+ } from "./limits.ts";
39
+ import {
40
+ ASK_USER_PARAMETER_DESCRIPTIONS,
41
+ ASK_USER_PROMPT_GUIDELINES,
42
+ ASK_USER_PROMPT_SNIPPET,
43
+ ASK_USER_TOOL_DESCRIPTION,
44
+ buildAskUserResultMessage,
45
+ } from "./prompt.ts";
46
+
47
+ const MIN_QUESTIONS = 1;
48
+ const MAX_QUESTIONS = 3;
49
+ const MIN_OPTIONS = 2;
50
+ const MAX_OPTIONS = 5;
51
+ /** Preview lines rendered before the tail is summarized. */
52
+ const PREVIEW_MAX_LINES = 20;
53
+
54
+ export { MAX_ANSWER_DRAFT_UTF8_BYTES, answerDraftFits } from "./limits.ts";
55
+
56
+ const OptionSchema = Type.Object({
57
+ label: Type.String({
58
+ minLength: 1,
59
+ maxLength: 120,
60
+ description: ASK_USER_PARAMETER_DESCRIPTIONS.optionLabel,
61
+ }),
62
+ description: Type.String({
63
+ minLength: 1,
64
+ maxLength: 500,
65
+ description: ASK_USER_PARAMETER_DESCRIPTIONS.optionDescription,
66
+ }),
67
+ preview: Type.Optional(
68
+ Type.String({
69
+ maxLength: 2_000,
70
+ description: ASK_USER_PARAMETER_DESCRIPTIONS.optionPreview,
71
+ }),
72
+ ),
73
+ });
74
+
75
+ const QuestionSchema = Type.Object({
76
+ id: Type.String({
77
+ minLength: 1,
78
+ maxLength: 80,
79
+ description: ASK_USER_PARAMETER_DESCRIPTIONS.id,
80
+ }),
81
+ header: Type.String({
82
+ minLength: 1,
83
+ maxLength: 80,
84
+ description: ASK_USER_PARAMETER_DESCRIPTIONS.header,
85
+ }),
86
+ question: Type.String({
87
+ minLength: 1,
88
+ maxLength: 2_000,
89
+ description: ASK_USER_PARAMETER_DESCRIPTIONS.question,
90
+ }),
91
+ options: Type.Array(OptionSchema, {
92
+ minItems: MIN_OPTIONS,
93
+ maxItems: MAX_OPTIONS,
94
+ description: ASK_USER_PARAMETER_DESCRIPTIONS.options,
95
+ }),
96
+ });
97
+
98
+ const AskUserParams = Type.Object({
99
+ questions: Type.Array(QuestionSchema, {
100
+ minItems: MIN_QUESTIONS,
101
+ maxItems: MAX_QUESTIONS,
102
+ description: ASK_USER_PARAMETER_DESCRIPTIONS.questions,
103
+ }),
104
+ });
105
+
106
+ export type AskUserInput = Static<typeof AskUserParams>;
107
+
108
+ export interface AskUserAnswer {
109
+ readonly id: string;
110
+ readonly selected?: string;
111
+ readonly note?: string;
112
+ readonly custom?: string;
113
+ /** The user submitted the blank free-form option to request a better question. */
114
+ readonly rephrase?: true;
115
+ }
116
+
117
+ interface AskUserDetails {
118
+ questions: string[];
119
+ answers: AskUserAnswer[];
120
+ cancelled: boolean;
121
+ }
122
+
123
+ type SelectionResult = AskUserAnswer[] | null;
124
+ type EditMode = "notes" | "custom" | undefined;
125
+ type ScreenMode = "question" | "review";
126
+
127
+ interface DisplayOption {
128
+ label: string;
129
+ description?: string;
130
+ /** Optional artifact shown while this option is highlighted. */
131
+ preview?: string;
132
+ isOther?: boolean;
133
+ }
134
+
135
+ export function formatPreviewLine(raw: string, width: number) {
136
+ return truncateToWidth(sanitizeTerminalText(raw), width, "…");
137
+ }
138
+
139
+ function safeSingleLine(text: string) {
140
+ return sanitizeTerminalText(text).replace(/\s+/g, " ").trim();
141
+ }
142
+
143
+ function boundedUtf8PrefixEnd(
144
+ value: string,
145
+ start: number,
146
+ end: number,
147
+ maximumBytes: number,
148
+ ) {
149
+ let bytes = 0;
150
+ let index = start;
151
+ while (index < end) {
152
+ const codePoint = value.codePointAt(index)!;
153
+ const size =
154
+ codePoint <= 0x7f
155
+ ? 1
156
+ : codePoint <= 0x7ff
157
+ ? 2
158
+ : codePoint <= 0xffff
159
+ ? 3
160
+ : 4;
161
+ if (bytes + size > maximumBytes) {
162
+ return { end: index, exceeded: true } as const;
163
+ }
164
+ bytes += size;
165
+ index += codePoint > 0xffff ? 2 : 1;
166
+ }
167
+ return { end: index, exceeded: false } as const;
168
+ }
169
+
170
+ const optionLabelIdentity = (label: string) =>
171
+ safeSingleLine(label)
172
+ // ZWNJ/ZWJ are preserved in displayed text for legitimate shaping, but
173
+ // cannot serve as option identity because many terminals render them
174
+ // invisibly. Normalize after removal because an inserted joiner can block
175
+ // composition of the characters on either side.
176
+ .replace(/\p{Cf}/gu, "")
177
+ .normalize("NFC");
178
+
179
+ function wrapText(text: string, width: number): string[] {
180
+ const safe = sanitizeTerminalText(text);
181
+ const renderWidth = Math.max(1, width);
182
+ return safe
183
+ .split("\n")
184
+ .flatMap((paragraph) =>
185
+ paragraph ? wrapTextWithAnsi(paragraph, renderWidth) : [""],
186
+ );
187
+ }
188
+
189
+ export function formatAnswers(answers: readonly AskUserAnswer[]) {
190
+ return answers
191
+ .map((answer) => {
192
+ const id = sanitizeTerminalText(answer.id).replace(/\s+/g, " ").trim();
193
+ return `${id}: ${formatReviewAnswer(answer, 512)}`;
194
+ })
195
+ .join("\n");
196
+ }
197
+
198
+ export function formatReviewAnswer(
199
+ answer: AskUserAnswer | undefined,
200
+ maxCharacters = 240,
201
+ ) {
202
+ if (!answer) return "(unanswered)";
203
+ const value = answer.rephrase
204
+ ? "Rephrase or split this question"
205
+ : (answer.custom ?? answer.selected ?? "(unanswered)");
206
+ const combined = `${value}${answer.note ? ` — ${answer.note}` : ""}`;
207
+ const plain = sanitizeTerminalText(combined).replace(/\s+/g, " ").trim();
208
+ const characters = [...plain];
209
+ if (characters.length <= maxCharacters) return plain;
210
+ return `${characters.slice(0, Math.max(0, maxCharacters - 1)).join("")}…`;
211
+ }
212
+
213
+ type AskUserQuestion = AskUserInput["questions"][number];
214
+
215
+ function dialogOptions(signal: AbortSignal | undefined) {
216
+ return signal ? { signal } : undefined;
217
+ }
218
+
219
+ async function boundedDialogInput(
220
+ ctx: ExtensionContext,
221
+ title: string,
222
+ placeholder: string,
223
+ signal: AbortSignal | undefined,
224
+ ) {
225
+ while (!signal?.aborted) {
226
+ const value = await ctx.ui.input(title, placeholder, dialogOptions(signal));
227
+ if (value === undefined) return undefined;
228
+ if (answerDraftFits(value)) return value;
229
+ ctx.ui.notify(
230
+ `Answer drafts are limited to ${MAX_ANSWER_DRAFT_UTF8_BYTES} UTF-8 bytes.`,
231
+ "error",
232
+ );
233
+ }
234
+ return undefined;
235
+ }
236
+
237
+ function dialogChoiceLabels(question: AskUserQuestion) {
238
+ return [
239
+ ...question.options.map(
240
+ (option, index) =>
241
+ `${index + 1}. ${safeSingleLine(option.label)} — ${safeSingleLine(option.description)}`,
242
+ ),
243
+ `${question.options.length + 1}. Write my own answer…`,
244
+ ];
245
+ }
246
+
247
+ async function collectDialogAnswer(
248
+ ctx: ExtensionContext,
249
+ question: AskUserQuestion,
250
+ signal: AbortSignal | undefined,
251
+ ): Promise<AskUserAnswer | undefined> {
252
+ const choices = dialogChoiceLabels(question);
253
+ const title = `${safeSingleLine(question.header)} — ${safeSingleLine(question.question)}`;
254
+
255
+ while (!signal?.aborted) {
256
+ const choice = await ctx.ui.select(title, choices, dialogOptions(signal));
257
+ if (!choice || signal?.aborted) return undefined;
258
+ const choiceIndex = choices.indexOf(choice);
259
+ if (choiceIndex < 0) continue;
260
+
261
+ if (choiceIndex < question.options.length) {
262
+ const selected = question.options[choiceIndex]!;
263
+ if (selected.preview) {
264
+ const accepted = await ctx.ui.confirm(
265
+ `Review preview for ${safeSingleLine(selected.label)}`,
266
+ sanitizeTerminalText(selected.preview),
267
+ dialogOptions(signal),
268
+ );
269
+ if (signal?.aborted) return undefined;
270
+ if (!accepted) continue;
271
+ }
272
+ const note = await boundedDialogInput(
273
+ ctx,
274
+ `Optional note for ${safeSingleLine(selected.label)}`,
275
+ "Leave blank to continue without a note",
276
+ signal,
277
+ );
278
+ if (signal?.aborted) return undefined;
279
+ const normalizedNote = note?.trim();
280
+ return {
281
+ id: question.id,
282
+ selected: selected.label,
283
+ ...(normalizedNote ? { note: normalizedNote } : {}),
284
+ };
285
+ }
286
+
287
+ const custom = await boundedDialogInput(
288
+ ctx,
289
+ "Your answer",
290
+ "Leave blank to ask the agent to rephrase or split the question",
291
+ signal,
292
+ );
293
+ if (signal?.aborted) return undefined;
294
+ if (custom === undefined) continue;
295
+ const normalizedCustom = custom.trim();
296
+ if (normalizedCustom) {
297
+ return { id: question.id, custom: normalizedCustom };
298
+ }
299
+
300
+ const note = await boundedDialogInput(
301
+ ctx,
302
+ "Optional rephrase guidance",
303
+ "What should be clearer or split apart?",
304
+ signal,
305
+ );
306
+ if (signal?.aborted) return undefined;
307
+ const normalizedNote = note?.trim();
308
+ return {
309
+ id: question.id,
310
+ rephrase: true,
311
+ ...(normalizedNote ? { note: normalizedNote } : {}),
312
+ };
313
+ }
314
+ return undefined;
315
+ }
316
+
317
+ export async function showQuestionsWithDialogs(
318
+ ctx: ExtensionContext,
319
+ questions: readonly AskUserQuestion[],
320
+ signal: AbortSignal | undefined,
321
+ ): Promise<SelectionResult> {
322
+ const answers: AskUserAnswer[] = [];
323
+ for (const question of questions) {
324
+ const answer = await collectDialogAnswer(ctx, question, signal);
325
+ if (!answer) return null;
326
+ answers.push(answer);
327
+ }
328
+
329
+ while (!signal?.aborted) {
330
+ const editLabels = questions.map(
331
+ (question, index) =>
332
+ `${index + 1}. ${safeSingleLine(question.header)} — ${formatReviewAnswer(answers[index])}`,
333
+ );
334
+ const submit = "✓ Submit answers";
335
+ const dismiss = "Dismiss without submitting";
336
+ const choice = await ctx.ui.select(
337
+ "Review answers",
338
+ [...editLabels, submit, dismiss],
339
+ dialogOptions(signal),
340
+ );
341
+ if (!choice || choice === dismiss || signal?.aborted) return null;
342
+ if (choice === submit) return answers;
343
+
344
+ const editIndex = editLabels.indexOf(choice);
345
+ if (editIndex < 0) continue;
346
+ const revised = await collectDialogAnswer(
347
+ ctx,
348
+ questions[editIndex]!,
349
+ signal,
350
+ );
351
+ if (signal?.aborted) return null;
352
+ if (revised) answers[editIndex] = revised;
353
+ }
354
+ return null;
355
+ }
356
+
357
+ export default function askUser(pi: ExtensionAPI) {
358
+ pi.registerTool({
359
+ name: "ask_user",
360
+ label: "Ask User",
361
+ description: ASK_USER_TOOL_DESCRIPTION,
362
+ promptSnippet: ASK_USER_PROMPT_SNIPPET,
363
+ promptGuidelines: ASK_USER_PROMPT_GUIDELINES,
364
+ executionMode: "sequential",
365
+ parameters: AskUserParams,
366
+
367
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
368
+ const reply = (text: string, answers: AskUserAnswer[] = []) => ({
369
+ content: [{ type: "text" as const, text }],
370
+ details: {
371
+ questions: params.questions.map((question) => question.id),
372
+ answers,
373
+ cancelled: answers.length === 0,
374
+ } satisfies AskUserDetails,
375
+ });
376
+
377
+ if (
378
+ params.questions.length < MIN_QUESTIONS ||
379
+ params.questions.length > MAX_QUESTIONS
380
+ ) {
381
+ throw new Error(
382
+ `ask_user requires between ${MIN_QUESTIONS} and ${MAX_QUESTIONS} questions.`,
383
+ );
384
+ }
385
+ const ids = params.questions.map((question) => question.id);
386
+ if (new Set(ids).size !== ids.length) {
387
+ throw new Error("ask_user question ids must be unique.");
388
+ }
389
+ for (const question of params.questions) {
390
+ if (
391
+ question.options.length < MIN_OPTIONS ||
392
+ question.options.length > MAX_OPTIONS
393
+ ) {
394
+ throw new Error(
395
+ `ask_user question "${question.id}" requires between ${MIN_OPTIONS} and ${MAX_OPTIONS} options.`,
396
+ );
397
+ }
398
+ const labels = question.options.map((option) =>
399
+ optionLabelIdentity(option.label),
400
+ );
401
+ if (new Set(labels).size !== labels.length) {
402
+ throw new Error(
403
+ `ask_user question "${question.id}" option labels must be unique.`,
404
+ );
405
+ }
406
+ }
407
+
408
+ if (!ctx.hasUI) {
409
+ return reply(buildAskUserResultMessage({ kind: "no-ui" }));
410
+ }
411
+ if (signal?.aborted) {
412
+ return reply(buildAskUserResultMessage({ kind: "cancelled" }));
413
+ }
414
+
415
+ const showQuestionsInTui = (uiSignal: AbortSignal) =>
416
+ ctx.ui.custom<SelectionResult>((tui, theme, _kb, done) => {
417
+ let screen: ScreenMode = "question";
418
+ let questionIndex = 0;
419
+ let optionIndex = 0;
420
+ let reviewIndex = params.questions.length;
421
+ let returnToReview = false;
422
+ let editMode: EditMode;
423
+ let editError: string | undefined;
424
+ let cachedWidth: number | undefined;
425
+ let cachedLines: string[] | undefined;
426
+ let settled = false;
427
+ const answers: Array<AskUserAnswer | undefined> = Array.from({
428
+ length: params.questions.length,
429
+ });
430
+ const customDrafts = new Map<string, string>();
431
+ const noteDrafts = new Map<string, string>();
432
+
433
+ const editorTheme: EditorTheme = {
434
+ borderColor: (text) => theme.fg("accent", text),
435
+ selectList: {
436
+ selectedPrefix: (text) => theme.fg("accent", text),
437
+ selectedText: (text) => theme.fg("accent", text),
438
+ description: (text) => theme.fg("muted", text),
439
+ scrollInfo: (text) => theme.fg("dim", text),
440
+ noMatch: (text) => theme.fg("warning", text),
441
+ },
442
+ };
443
+ const editor = new Editor(tui, editorTheme);
444
+ let pendingPaste:
445
+ | { mode: "collecting"; buffer: string; baseBytes: number }
446
+ | { mode: "discarding"; tail: string }
447
+ | undefined;
448
+
449
+ const question = () => params.questions[questionIndex]!;
450
+ const options = (): DisplayOption[] => [
451
+ ...question().options,
452
+ {
453
+ label: "Write my own answer…",
454
+ description:
455
+ "Submit it blank to ask for a clearer or split question.",
456
+ isOther: true,
457
+ },
458
+ ];
459
+ const noteDraftKey = (questionId: string, optionLabel: string) =>
460
+ `${questionId}\u0000${optionLabel}`;
461
+
462
+ function refresh() {
463
+ cachedWidth = undefined;
464
+ cachedLines = undefined;
465
+ tui.requestRender();
466
+ }
467
+
468
+ function showDraftLimitError() {
469
+ editError = `Answer drafts are limited to ${MAX_ANSWER_DRAFT_UTF8_BYTES} UTF-8 bytes.`;
470
+ }
471
+
472
+ function applyEditorInput(data: string) {
473
+ const previous = editor.getExpandedText();
474
+ if (!prospectiveAnswerDraftFits(previous, data)) {
475
+ showDraftLimitError();
476
+ return;
477
+ }
478
+ editor.handleInput(sanitizeAnswerDraftEditorInput(data));
479
+ const next = editor.getExpandedText();
480
+ if (!answerDraftFits(next)) {
481
+ if (longerThanAnswerDraftLimit(next, previous)) {
482
+ editor.setText(previous);
483
+ }
484
+ showDraftLimitError();
485
+ return;
486
+ }
487
+ const safeNext = sanitizeTerminalText(next);
488
+ if (safeNext !== next) editor.setText(safeNext);
489
+ editError = undefined;
490
+ }
491
+
492
+ function applyEditorInputRange(
493
+ source: string,
494
+ start: number,
495
+ end: number,
496
+ ) {
497
+ if (start >= end) return;
498
+ const remainingBytes =
499
+ MAX_ANSWER_DRAFT_UTF8_BYTES -
500
+ answerDraftByteLength(editor.getExpandedText());
501
+ const prefix = boundedUtf8PrefixEnd(
502
+ source,
503
+ start,
504
+ end,
505
+ Math.max(0, remainingBytes),
506
+ );
507
+ if (remainingBytes < 0 || prefix.exceeded) {
508
+ showDraftLimitError();
509
+ return;
510
+ }
511
+ applyEditorInput(source.slice(start, end));
512
+ }
513
+
514
+ function handleEditorInput(data: string) {
515
+ let offset = 0;
516
+ while (offset < data.length || pendingPaste) {
517
+ if (pendingPaste?.mode === "collecting") {
518
+ const state = pendingPaste;
519
+ const available =
520
+ MAX_ANSWER_DRAFT_UTF8_BYTES -
521
+ state.baseBytes -
522
+ answerDraftByteLength(state.buffer);
523
+ const prefix = boundedUtf8PrefixEnd(
524
+ data,
525
+ offset,
526
+ data.length,
527
+ Math.max(0, available),
528
+ );
529
+ // Include only a marker-sized lookahead. The probe stays
530
+ // bounded even when this input event carries megabytes.
531
+ const probeEnd = Math.min(
532
+ data.length,
533
+ prefix.end + BRACKETED_PASTE_END.length,
534
+ );
535
+ const probe = state.buffer + data.slice(offset, probeEnd);
536
+ const endIndex = probe.indexOf(BRACKETED_PASTE_END);
537
+ if (endIndex >= 0) {
538
+ const content = probe.slice(0, endIndex);
539
+ const consumed =
540
+ endIndex + BRACKETED_PASTE_END.length - state.buffer.length;
541
+ offset += Math.max(0, consumed);
542
+ pendingPaste = undefined;
543
+ if (
544
+ state.baseBytes + answerDraftByteLength(content) >
545
+ MAX_ANSWER_DRAFT_UTF8_BYTES
546
+ ) {
547
+ showDraftLimitError();
548
+ } else {
549
+ applyEditorInput(
550
+ `${BRACKETED_PASTE_START}${content}${BRACKETED_PASTE_END}`,
551
+ );
552
+ }
553
+ continue;
554
+ }
555
+
556
+ if (available < 0 || prefix.exceeded) {
557
+ showDraftLimitError();
558
+ const endInChunk = data.indexOf(BRACKETED_PASTE_END, offset);
559
+ if (endInChunk >= 0) {
560
+ pendingPaste = undefined;
561
+ offset = endInChunk + BRACKETED_PASTE_END.length;
562
+ continue;
563
+ }
564
+ pendingPaste = {
565
+ mode: "discarding",
566
+ tail: data.slice(
567
+ Math.max(
568
+ offset,
569
+ data.length - (BRACKETED_PASTE_END.length - 1),
570
+ ),
571
+ ),
572
+ };
573
+ return;
574
+ }
575
+
576
+ // The entire remainder fits; append only this bounded slice.
577
+ state.buffer += data.slice(offset);
578
+ return;
579
+ }
580
+
581
+ if (pendingPaste?.mode === "discarding") {
582
+ const state = pendingPaste;
583
+ const headEnd = Math.min(
584
+ data.length,
585
+ offset + BRACKETED_PASTE_END.length - 1,
586
+ );
587
+ const boundary = state.tail + data.slice(offset, headEnd);
588
+ const boundaryEnd = boundary.indexOf(BRACKETED_PASTE_END);
589
+ if (boundaryEnd >= 0) {
590
+ offset += Math.max(
591
+ 0,
592
+ boundaryEnd +
593
+ BRACKETED_PASTE_END.length -
594
+ state.tail.length,
595
+ );
596
+ pendingPaste = undefined;
597
+ continue;
598
+ }
599
+
600
+ const endInChunk = data.indexOf(BRACKETED_PASTE_END, offset);
601
+ if (endInChunk >= 0) {
602
+ offset = endInChunk + BRACKETED_PASTE_END.length;
603
+ pendingPaste = undefined;
604
+ continue;
605
+ }
606
+ state.tail = data.slice(
607
+ Math.max(
608
+ offset,
609
+ data.length - (BRACKETED_PASTE_END.length - 1),
610
+ ),
611
+ );
612
+ return;
613
+ }
614
+
615
+ const startIndex = data.indexOf(BRACKETED_PASTE_START, offset);
616
+ if (startIndex < 0) {
617
+ applyEditorInputRange(data, offset, data.length);
618
+ return;
619
+ }
620
+ applyEditorInputRange(data, offset, startIndex);
621
+ pendingPaste = {
622
+ mode: "collecting",
623
+ buffer: "",
624
+ baseBytes: answerDraftByteLength(editor.getExpandedText()),
625
+ };
626
+ offset = startIndex + BRACKETED_PASTE_START.length;
627
+ }
628
+ }
629
+
630
+ function finish(result: SelectionResult) {
631
+ if (settled) return;
632
+ settled = true;
633
+ uiSignal.removeEventListener("abort", cancel);
634
+ done(result);
635
+ }
636
+
637
+ function cancel() {
638
+ finish(null);
639
+ }
640
+
641
+ function completeAnswers() {
642
+ if (answers.some((answer) => !answer)) return;
643
+ finish(answers.filter((answer) => answer !== undefined));
644
+ }
645
+
646
+ function showReview() {
647
+ pendingPaste = undefined;
648
+ screen = "review";
649
+ reviewIndex = params.questions.length;
650
+ returnToReview = false;
651
+ editMode = undefined;
652
+ editError = undefined;
653
+ editor.setText("");
654
+ refresh();
655
+ }
656
+
657
+ function editQuestion(index: number) {
658
+ screen = "question";
659
+ questionIndex = index;
660
+ returnToReview = true;
661
+ editMode = undefined;
662
+ editError = undefined;
663
+ editor.setText("");
664
+ const answer = answers[index];
665
+ const currentOptions = options();
666
+ if (answer?.custom !== undefined || answer?.rephrase) {
667
+ optionIndex = currentOptions.length - 1;
668
+ } else if (answer?.selected) {
669
+ const selectedIndex = question().options.findIndex(
670
+ (option) => option.label === answer.selected,
671
+ );
672
+ optionIndex = Math.max(0, selectedIndex);
673
+ } else {
674
+ optionIndex = 0;
675
+ }
676
+ refresh();
677
+ }
678
+
679
+ function advance(answer: AskUserAnswer) {
680
+ answers[questionIndex] = answer;
681
+ if (
682
+ returnToReview ||
683
+ questionIndex === params.questions.length - 1
684
+ ) {
685
+ showReview();
686
+ return;
687
+ }
688
+ questionIndex++;
689
+ optionIndex = 0;
690
+ editMode = undefined;
691
+ editor.setText("");
692
+ refresh();
693
+ }
694
+
695
+ function rememberEditorDraft() {
696
+ if (!editMode) return;
697
+ const current = question();
698
+ const selected = options()[optionIndex];
699
+ const text = sanitizeTerminalText(editor.getExpandedText());
700
+ if (editMode === "custom") {
701
+ customDrafts.set(current.id, text);
702
+ } else if (selected && !selected.isOther) {
703
+ noteDrafts.set(noteDraftKey(current.id, selected.label), text);
704
+ }
705
+ }
706
+
707
+ function startEditing(mode: Exclude<EditMode, undefined>) {
708
+ pendingPaste = undefined;
709
+ const current = question();
710
+ const selected = options()[optionIndex];
711
+ if (!selected) return;
712
+ editMode = mode;
713
+ editError = undefined;
714
+ if (mode === "custom") {
715
+ editor.setText(
716
+ customDrafts.get(current.id) ??
717
+ answers[questionIndex]?.custom ??
718
+ "",
719
+ );
720
+ } else if (!selected.isOther) {
721
+ const existing = answers[questionIndex];
722
+ editor.setText(
723
+ noteDrafts.get(noteDraftKey(current.id, selected.label)) ??
724
+ (existing?.selected === selected.label
725
+ ? (existing.note ?? "")
726
+ : ""),
727
+ );
728
+ }
729
+ refresh();
730
+ }
731
+
732
+ editor.onSubmit = (value) => {
733
+ const safeValue = sanitizeTerminalText(value);
734
+ if (!answerDraftFits(value)) {
735
+ // Normal input is rejected before Editor stores an oversized
736
+ // paste. Fail closed for alternate editor paths too, without
737
+ // putting raw or over-limit text back into the renderer.
738
+ editor.setText(answerDraftFits(safeValue) ? safeValue : "");
739
+ editError = `Answer drafts are limited to ${MAX_ANSWER_DRAFT_UTF8_BYTES} UTF-8 bytes.`;
740
+ refresh();
741
+ return;
742
+ }
743
+ const text = safeValue.trim();
744
+ const current = question();
745
+ const selected = options()[optionIndex];
746
+ if (editMode === "custom") {
747
+ customDrafts.set(current.id, safeValue);
748
+ advance(
749
+ text
750
+ ? { id: current.id, custom: text }
751
+ : { id: current.id, rephrase: true },
752
+ );
753
+ } else if (selected && !selected.isOther) {
754
+ noteDrafts.set(
755
+ noteDraftKey(current.id, selected.label),
756
+ safeValue,
757
+ );
758
+ advance({
759
+ id: current.id,
760
+ selected: selected.label,
761
+ ...(text ? { note: text } : {}),
762
+ });
763
+ }
764
+ };
765
+
766
+ uiSignal.addEventListener("abort", cancel, { once: true });
767
+ if (uiSignal.aborted) queueMicrotask(cancel);
768
+
769
+ function selectOption(index: number) {
770
+ const selected = options()[index];
771
+ if (!selected) return;
772
+ optionIndex = index;
773
+ if (selected.isOther) {
774
+ startEditing("custom");
775
+ return;
776
+ }
777
+ const existing = answers[questionIndex];
778
+ advance({
779
+ id: question().id,
780
+ selected: selected.label,
781
+ ...(existing?.selected === selected.label && existing.note
782
+ ? { note: existing.note }
783
+ : {}),
784
+ });
785
+ }
786
+
787
+ function handleReviewInput(data: string) {
788
+ const rowCount = params.questions.length + 1;
789
+ if (matchesKey(data, Key.up)) {
790
+ reviewIndex = (reviewIndex - 1 + rowCount) % rowCount;
791
+ refresh();
792
+ return;
793
+ }
794
+ if (matchesKey(data, Key.down)) {
795
+ reviewIndex = (reviewIndex + 1) % rowCount;
796
+ refresh();
797
+ return;
798
+ }
799
+ if (
800
+ data.length === 1 &&
801
+ data >= "1" &&
802
+ data <= String(params.questions.length)
803
+ ) {
804
+ editQuestion(Number(data) - 1);
805
+ return;
806
+ }
807
+ if (matchesKey(data, Key.enter)) {
808
+ if (reviewIndex === params.questions.length) {
809
+ completeAnswers();
810
+ } else {
811
+ editQuestion(reviewIndex);
812
+ }
813
+ return;
814
+ }
815
+ if (matchesKey(data, Key.escape)) finish(null);
816
+ }
817
+
818
+ function handleInput(data: string) {
819
+ if (editMode) {
820
+ if (matchesKey(data, Key.escape)) {
821
+ pendingPaste = undefined;
822
+ rememberEditorDraft();
823
+ editMode = undefined;
824
+ editError = undefined;
825
+ editor.setText("");
826
+ refresh();
827
+ return;
828
+ }
829
+ // Route bracketed paste ourselves so split chunks are bounded
830
+ // before Pi's Editor can accumulate an unbounded pasteBuffer.
831
+ handleEditorInput(data);
832
+ refresh();
833
+ return;
834
+ }
835
+
836
+ if (screen === "review") {
837
+ handleReviewInput(data);
838
+ return;
839
+ }
840
+
841
+ const currentOptions = options();
842
+ if (matchesKey(data, Key.up)) {
843
+ optionIndex =
844
+ (optionIndex - 1 + currentOptions.length) %
845
+ currentOptions.length;
846
+ refresh();
847
+ return;
848
+ }
849
+ if (matchesKey(data, Key.down)) {
850
+ optionIndex = (optionIndex + 1) % currentOptions.length;
851
+ refresh();
852
+ return;
853
+ }
854
+ if (matchesKey(data, Key.tab)) {
855
+ const selected = currentOptions[optionIndex];
856
+ if (selected && !selected.isOther) startEditing("notes");
857
+ return;
858
+ }
859
+ if (
860
+ data.length === 1 &&
861
+ data >= "1" &&
862
+ data <= String(currentOptions.length)
863
+ ) {
864
+ selectOption(Number(data) - 1);
865
+ return;
866
+ }
867
+ if (matchesKey(data, Key.enter)) {
868
+ selectOption(optionIndex);
869
+ return;
870
+ }
871
+ if (matchesKey(data, Key.escape)) finish(null);
872
+ }
873
+
874
+ function render(width: number): string[] {
875
+ if (cachedLines && cachedWidth === width) return cachedLines;
876
+ const lines: string[] = [];
877
+ const add = (text: string) =>
878
+ lines.push(truncateToWidth(text, width));
879
+
880
+ if (screen === "review") {
881
+ const title = " Review answers ";
882
+ add(
883
+ theme.fg(
884
+ "accent",
885
+ `─${title}${"─".repeat(Math.max(0, width - title.length - 1))}`,
886
+ ),
887
+ );
888
+ add(
889
+ ` ${theme.fg("text", theme.bold("Check every answer before submitting"))}`,
890
+ );
891
+ lines.push("");
892
+ for (let index = 0; index < params.questions.length; index++) {
893
+ const selected = reviewIndex === index;
894
+ const prefix = selected ? theme.fg("accent", " ❯ ") : " ";
895
+ const current = params.questions[index]!;
896
+ const header = safeSingleLine(current.header);
897
+ add(
898
+ `${prefix}${theme.fg(selected ? "accent" : "text", `${index + 1}. ${header}`)}${theme.fg("muted", ` — ${formatReviewAnswer(answers[index])}`)}`,
899
+ );
900
+ }
901
+ lines.push("");
902
+ const submitSelected = reviewIndex === params.questions.length;
903
+ add(
904
+ `${submitSelected ? theme.fg("accent", " ❯ ") : " "}${theme.fg(submitSelected ? "accent" : "success", "✓ Submit answers")}`,
905
+ );
906
+ lines.push("");
907
+ add(
908
+ theme.fg(
909
+ "dim",
910
+ ` ↑↓ choose • 1-${params.questions.length} edit answer • Enter open/submit • Esc dismiss`,
911
+ ),
912
+ );
913
+ add(theme.fg("accent", "─".repeat(width)));
914
+ cachedWidth = width;
915
+ cachedLines = lines;
916
+ return lines;
917
+ }
918
+
919
+ const current = question();
920
+ const currentOptions = options();
921
+ const progress = `${questionIndex + 1}/${params.questions.length}`;
922
+ const title = ` ${safeSingleLine(current.header)} · ${progress} `;
923
+ add(
924
+ theme.fg(
925
+ "accent",
926
+ `─${title}${"─".repeat(Math.max(0, width - title.length - 1))}`,
927
+ ),
928
+ );
929
+ for (const line of wrapText(
930
+ current.question,
931
+ Math.max(10, width - 2),
932
+ )) {
933
+ add(` ${theme.fg("text", theme.bold(line))}`);
934
+ }
935
+ lines.push("");
936
+
937
+ for (let i = 0; i < currentOptions.length; i++) {
938
+ const option = currentOptions[i]!;
939
+ const selected = i === optionIndex;
940
+ const prefix = selected ? theme.fg("accent", " ❯ ") : " ";
941
+ const marker = option.isOther ? "✎" : `${i + 1}.`;
942
+ const label = `${marker} ${safeSingleLine(option.label)}`;
943
+ add(
944
+ prefix +
945
+ theme.fg(
946
+ selected ? "accent" : option.isOther ? "muted" : "text",
947
+ label,
948
+ ),
949
+ );
950
+ if (option.description) {
951
+ for (const line of wrapText(
952
+ option.description,
953
+ Math.max(10, width - 7),
954
+ )) {
955
+ add(` ${theme.fg("muted", line)}`);
956
+ }
957
+ }
958
+ }
959
+
960
+ // Preview for the highlighted option: a concrete artifact the
961
+ // user compares against the other options. Rendered verbatim
962
+ // (indentation preserved, hard-truncated) because re-wrapping
963
+ // would destroy code and ASCII layouts.
964
+ const preview = currentOptions[optionIndex]?.preview;
965
+ if (preview) {
966
+ lines.push("");
967
+ add(theme.fg("muted", " ┌ preview"));
968
+ const inner = Math.max(10, width - 4);
969
+ const previewLines = sanitizeTerminalText(preview).split("\n");
970
+ const shown = previewLines.slice(0, PREVIEW_MAX_LINES);
971
+ for (const raw of shown) {
972
+ const line = formatPreviewLine(raw, inner);
973
+ add(` ${theme.fg("muted", "│")} ${theme.fg("text", line)}`);
974
+ }
975
+ const hidden = previewLines.length - shown.length;
976
+ if (hidden > 0) {
977
+ add(
978
+ ` ${theme.fg("muted", "│")} ${theme.fg("dim", `… ${hidden} more line${hidden === 1 ? "" : "s"}`)}`,
979
+ );
980
+ }
981
+ add(theme.fg("muted", " └"));
982
+ }
983
+
984
+ if (editMode) {
985
+ lines.push("");
986
+ add(
987
+ theme.fg(
988
+ "muted",
989
+ editMode === "notes"
990
+ ? ` Notes for “${safeSingleLine(currentOptions[optionIndex]?.label ?? "")}”:`
991
+ : " Your answer:",
992
+ ),
993
+ );
994
+ for (const line of editor.render(Math.max(1, width - 2))) {
995
+ add(` ${line}`);
996
+ }
997
+ if (editError) add(` ${theme.fg("error", editError)}`);
998
+ }
999
+
1000
+ lines.push("");
1001
+ add(
1002
+ theme.fg(
1003
+ "dim",
1004
+ editMode
1005
+ ? " Enter save answer • Esc keep draft and return"
1006
+ : ` ↑↓ or 1-${currentOptions.length} select • Tab add notes • Enter save draft answer • Esc dismiss`,
1007
+ ),
1008
+ );
1009
+ add(theme.fg("accent", "─".repeat(width)));
1010
+ cachedWidth = width;
1011
+ cachedLines = lines;
1012
+ return lines;
1013
+ }
1014
+
1015
+ const component: Focusable & {
1016
+ render(width: number): string[];
1017
+ invalidate(): void;
1018
+ handleInput(data: string): void;
1019
+ dispose(): void;
1020
+ } = {
1021
+ get focused() {
1022
+ return editor.focused;
1023
+ },
1024
+ set focused(value: boolean) {
1025
+ editor.focused = value;
1026
+ },
1027
+ render,
1028
+ invalidate: () => {
1029
+ cachedWidth = undefined;
1030
+ cachedLines = undefined;
1031
+ editor.invalidate();
1032
+ },
1033
+ handleInput,
1034
+ dispose: () => {
1035
+ uiSignal.removeEventListener("abort", cancel);
1036
+ },
1037
+ };
1038
+ return component;
1039
+ });
1040
+
1041
+ const uiExit = await Effect.runPromiseExit(
1042
+ Effect.tryPromise((uiSignal) =>
1043
+ ctx.mode === "tui"
1044
+ ? showQuestionsInTui(uiSignal)
1045
+ : showQuestionsWithDialogs(ctx, params.questions, uiSignal),
1046
+ ),
1047
+ signal ? { signal } : undefined,
1048
+ );
1049
+ if (Exit.isFailure(uiExit)) {
1050
+ if (Cause.hasInterruptsOnly(uiExit.cause)) {
1051
+ return reply(buildAskUserResultMessage({ kind: "cancelled" }));
1052
+ }
1053
+ const [first] = Cause.prettyErrors(uiExit.cause);
1054
+ throw new Error(first?.message ?? Cause.pretty(uiExit.cause));
1055
+ }
1056
+
1057
+ const answers = uiExit.value;
1058
+ if (!answers) {
1059
+ return reply(buildAskUserResultMessage({ kind: "dismissed" }));
1060
+ }
1061
+ return reply(
1062
+ buildAskUserResultMessage({ kind: "answered", answers }),
1063
+ answers,
1064
+ );
1065
+ },
1066
+
1067
+ renderCall(args, theme) {
1068
+ const questions = Array.isArray(args.questions) ? args.questions : [];
1069
+ let text = theme.fg("toolTitle", theme.bold("ask_user "));
1070
+ text += theme.fg(
1071
+ "muted",
1072
+ `${questions.length} question${questions.length === 1 ? "" : "s"}`,
1073
+ );
1074
+ for (const question of questions.slice(0, MAX_QUESTIONS)) {
1075
+ if (
1076
+ question &&
1077
+ typeof question === "object" &&
1078
+ "question" in question &&
1079
+ typeof question.question === "string"
1080
+ ) {
1081
+ text += `\n${theme.fg("dim", ` ${safeSingleLine(question.question)}`)}`;
1082
+ }
1083
+ }
1084
+ return new Text(text, 0, 0);
1085
+ },
1086
+
1087
+ renderResult(result, _options, theme) {
1088
+ const details = result.details as AskUserDetails | undefined;
1089
+ if (!details) {
1090
+ const first = result.content[0];
1091
+ return new Text(
1092
+ first?.type === "text" ? sanitizeTerminalText(first.text) : "",
1093
+ 0,
1094
+ 0,
1095
+ );
1096
+ }
1097
+ if (details.cancelled) {
1098
+ return new Text(theme.fg("warning", "✗ dismissed"), 0, 0);
1099
+ }
1100
+ return new Text(
1101
+ theme.fg("success", "✓ answered") +
1102
+ `\n${theme.fg("muted", formatAnswers(details.answers))}`,
1103
+ 0,
1104
+ 0,
1105
+ );
1106
+ },
1107
+ });
1108
+
1109
+ pi.registerTool(createHumanHandoffToolDefinition());
1110
+ }