shortcutxl 0.2.12 → 0.2.13

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 (110) hide show
  1. package/README.md +26 -26
  2. package/agent-docs/README.md +397 -397
  3. package/agent-docs/docs/compaction.md +390 -390
  4. package/agent-docs/docs/custom-provider.md +580 -580
  5. package/agent-docs/docs/extensions.md +1971 -1971
  6. package/agent-docs/docs/packages.md +209 -209
  7. package/agent-docs/docs/rpc.md +1317 -1317
  8. package/agent-docs/docs/sdk.md +962 -962
  9. package/agent-docs/docs/session.md +412 -412
  10. package/agent-docs/docs/termux.md +127 -127
  11. package/agent-docs/docs/tui.md +887 -887
  12. package/agent-docs/examples/README.md +25 -25
  13. package/agent-docs/examples/extensions/README.md +205 -205
  14. package/agent-docs/examples/extensions/antigravity-image-gen.ts +447 -447
  15. package/agent-docs/examples/extensions/auto-commit-on-exit.ts +49 -49
  16. package/agent-docs/examples/extensions/bash-spawn-hook.ts +30 -30
  17. package/agent-docs/examples/extensions/bookmark.ts +50 -50
  18. package/agent-docs/examples/extensions/built-in-tool-renderer.ts +256 -256
  19. package/agent-docs/examples/extensions/claude-rules.ts +86 -86
  20. package/agent-docs/examples/extensions/commands.ts +75 -75
  21. package/agent-docs/examples/extensions/confirm-destructive.ts +59 -59
  22. package/agent-docs/examples/extensions/custom-compaction.ts +126 -126
  23. package/agent-docs/examples/extensions/custom-footer.ts +63 -63
  24. package/agent-docs/examples/extensions/custom-header.ts +73 -73
  25. package/agent-docs/examples/extensions/custom-provider-anthropic/index.ts +660 -660
  26. package/agent-docs/examples/extensions/custom-provider-gitlab-duo/index.ts +362 -362
  27. package/agent-docs/examples/extensions/custom-provider-gitlab-duo/test.ts +88 -88
  28. package/agent-docs/examples/extensions/custom-provider-qwen-cli/index.ts +349 -349
  29. package/agent-docs/examples/extensions/dirty-repo-guard.ts +56 -56
  30. package/agent-docs/examples/extensions/doom-overlay/doom-component.ts +133 -133
  31. package/agent-docs/examples/extensions/doom-overlay/doom-keys.ts +108 -108
  32. package/agent-docs/examples/extensions/doom-overlay/index.ts +74 -74
  33. package/agent-docs/examples/extensions/dynamic-resources/index.ts +15 -15
  34. package/agent-docs/examples/extensions/dynamic-tools.ts +77 -77
  35. package/agent-docs/examples/extensions/event-bus.ts +43 -43
  36. package/agent-docs/examples/extensions/file-trigger.ts +41 -41
  37. package/agent-docs/examples/extensions/git-checkpoint.ts +53 -53
  38. package/agent-docs/examples/extensions/handoff.ts +155 -155
  39. package/agent-docs/examples/extensions/hello.ts +25 -25
  40. package/agent-docs/examples/extensions/inline-bash.ts +94 -94
  41. package/agent-docs/examples/extensions/input-transform.ts +43 -43
  42. package/agent-docs/examples/extensions/interactive-shell.ts +209 -209
  43. package/agent-docs/examples/extensions/mac-system-theme.ts +47 -47
  44. package/agent-docs/examples/extensions/message-renderer.ts +59 -59
  45. package/agent-docs/examples/extensions/minimal-mode.ts +430 -430
  46. package/agent-docs/examples/extensions/modal-editor.ts +90 -90
  47. package/agent-docs/examples/extensions/model-status.ts +31 -31
  48. package/agent-docs/examples/extensions/notify.ts +55 -55
  49. package/agent-docs/examples/extensions/overlay-qa-tests.ts +936 -936
  50. package/agent-docs/examples/extensions/overlay-test.ts +159 -159
  51. package/agent-docs/examples/extensions/permission-gate.ts +37 -37
  52. package/agent-docs/examples/extensions/pirate.ts +47 -47
  53. package/agent-docs/examples/extensions/plan-mode/index.ts +363 -363
  54. package/agent-docs/examples/extensions/preset.ts +418 -418
  55. package/agent-docs/examples/extensions/protected-paths.ts +30 -30
  56. package/agent-docs/examples/extensions/qna.ts +122 -122
  57. package/agent-docs/examples/extensions/question.ts +278 -278
  58. package/agent-docs/examples/extensions/questionnaire.ts +440 -440
  59. package/agent-docs/examples/extensions/rainbow-editor.ts +90 -90
  60. package/agent-docs/examples/extensions/reload-runtime.ts +37 -37
  61. package/agent-docs/examples/extensions/rpc-demo.ts +124 -124
  62. package/agent-docs/examples/extensions/sandbox/index.ts +324 -324
  63. package/agent-docs/examples/extensions/send-user-message.ts +97 -97
  64. package/agent-docs/examples/extensions/session-name.ts +27 -27
  65. package/agent-docs/examples/extensions/shutdown-command.ts +69 -69
  66. package/agent-docs/examples/extensions/snake.ts +343 -343
  67. package/agent-docs/examples/extensions/space-invaders.ts +566 -566
  68. package/agent-docs/examples/extensions/ssh.ts +233 -233
  69. package/agent-docs/examples/extensions/status-line.ts +40 -40
  70. package/agent-docs/examples/extensions/subagent/agents.ts +130 -130
  71. package/agent-docs/examples/extensions/subagent/index.ts +1068 -1068
  72. package/agent-docs/examples/extensions/summarize.ts +206 -206
  73. package/agent-docs/examples/extensions/system-prompt-header.ts +17 -17
  74. package/agent-docs/examples/extensions/timed-confirm.ts +72 -72
  75. package/agent-docs/examples/extensions/titlebar-spinner.ts +58 -58
  76. package/agent-docs/examples/extensions/todo.ts +314 -314
  77. package/agent-docs/examples/extensions/tool-override.ts +146 -146
  78. package/agent-docs/examples/extensions/tools.ts +145 -145
  79. package/agent-docs/examples/extensions/trigger-compact.ts +40 -40
  80. package/agent-docs/examples/extensions/truncated-tool.ts +194 -194
  81. package/agent-docs/examples/extensions/widget-placement.ts +17 -17
  82. package/agent-docs/examples/extensions/with-deps/index.ts +37 -37
  83. package/agent-docs/examples/rpc-extension-ui.ts +654 -654
  84. package/agent-docs/examples/sdk/01-minimal.ts +22 -22
  85. package/agent-docs/examples/sdk/02-custom-model.ts +48 -48
  86. package/agent-docs/examples/sdk/03-custom-prompt.ts +55 -55
  87. package/agent-docs/examples/sdk/04-skills.ts +53 -53
  88. package/agent-docs/examples/sdk/05-tools.ts +56 -56
  89. package/agent-docs/examples/sdk/06-extensions.ts +88 -88
  90. package/agent-docs/examples/sdk/07-context-files.ts +40 -40
  91. package/agent-docs/examples/sdk/08-prompt-templates.ts +47 -47
  92. package/agent-docs/examples/sdk/09-api-keys-and-oauth.ts +48 -48
  93. package/agent-docs/examples/sdk/10-settings.ts +54 -54
  94. package/agent-docs/examples/sdk/11-sessions.ts +48 -48
  95. package/agent-docs/examples/sdk/12-full-control.ts +82 -82
  96. package/agent-docs/examples/sdk/README.md +144 -144
  97. package/agent-docs/xll-spec.md +110 -110
  98. package/dist/core/auth-storage.js +21 -2
  99. package/package.json +1 -1
  100. package/xll/ShortcutXL.xll +0 -0
  101. package/xll/modules/debug_render.py +272 -272
  102. package/xll/modules/gameboy.py +241 -241
  103. package/xll/modules/pong.py +188 -188
  104. package/xll/modules/shortcut_xl/_diff_highlight.py +176 -0
  105. package/xll/modules/shortcut_xl/_log.py +12 -12
  106. package/xll/modules/shortcut_xl/_registry.py +44 -44
  107. package/xll/modules/stocks.py +100 -100
  108. /package/skills/{com-advanced-api → COM-advanced-api}/SKILL.md +0 -0
  109. /package/skills/{com-advanced-api → COM-advanced-api}/excel-type-library.py +0 -0
  110. /package/skills/{com-advanced-api → COM-advanced-api}/office-type-library.py +0 -0
@@ -1,440 +1,440 @@
1
- /**
2
- * Questionnaire Tool - Unified tool for asking single or multiple questions
3
- *
4
- * Single question: simple options list
5
- * Multiple questions: tab bar navigation between questions
6
- */
7
-
8
- import { Type } from '@sinclair/typebox';
9
- import type { ExtensionAPI } from 'shortcutxl';
10
- import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from 'shortcutxl';
11
-
12
- // Types
13
- interface QuestionOption {
14
- value: string;
15
- label: string;
16
- description?: string;
17
- }
18
-
19
- type RenderOption = QuestionOption & { isOther?: boolean };
20
-
21
- interface Question {
22
- id: string;
23
- label: string;
24
- prompt: string;
25
- options: QuestionOption[];
26
- allowOther: boolean;
27
- }
28
-
29
- interface Answer {
30
- id: string;
31
- value: string;
32
- label: string;
33
- wasCustom: boolean;
34
- index?: number;
35
- }
36
-
37
- interface QuestionnaireResult {
38
- questions: Question[];
39
- answers: Answer[];
40
- cancelled: boolean;
41
- }
42
-
43
- // Schema
44
- const QuestionOptionSchema = Type.Object({
45
- value: Type.String({ description: 'The value returned when selected' }),
46
- label: Type.String({ description: 'Display label for the option' }),
47
- description: Type.Optional(Type.String({ description: 'Optional description shown below label' }))
48
- });
49
-
50
- const QuestionSchema = Type.Object({
51
- id: Type.String({ description: 'Unique identifier for this question' }),
52
- label: Type.Optional(
53
- Type.String({
54
- description:
55
- "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)"
56
- })
57
- ),
58
- prompt: Type.String({ description: 'The full question text to display' }),
59
- options: Type.Array(QuestionOptionSchema, { description: 'Available options to choose from' }),
60
- allowOther: Type.Optional(
61
- Type.Boolean({ description: "Allow 'Type something' option (default: true)" })
62
- )
63
- });
64
-
65
- const QuestionnaireParams = Type.Object({
66
- questions: Type.Array(QuestionSchema, { description: 'Questions to ask the user' })
67
- });
68
-
69
- function errorResult(
70
- message: string,
71
- questions: Question[] = []
72
- ): { content: { type: 'text'; text: string }[]; details: QuestionnaireResult } {
73
- return {
74
- content: [{ type: 'text', text: message }],
75
- details: { questions, answers: [], cancelled: true }
76
- };
77
- }
78
-
79
- export default function questionnaire(shortcut: ExtensionAPI) {
80
- shortcut.registerTool({
81
- name: 'questionnaire',
82
- label: 'Questionnaire',
83
- description:
84
- 'Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface.',
85
- parameters: QuestionnaireParams,
86
-
87
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
88
- if (!ctx.hasUI) {
89
- return errorResult('Error: UI not available (running in non-interactive mode)');
90
- }
91
- if (params.questions.length === 0) {
92
- return errorResult('Error: No questions provided');
93
- }
94
-
95
- // Normalize questions with defaults
96
- const questions: Question[] = params.questions.map((q, i) => ({
97
- ...q,
98
- label: q.label || `Q${i + 1}`,
99
- allowOther: q.allowOther !== false
100
- }));
101
-
102
- const isMulti = questions.length > 1;
103
- const totalTabs = questions.length + 1; // questions + Submit
104
-
105
- const result = await ctx.ui.custom<QuestionnaireResult>((tui, theme, _kb, done) => {
106
- // State
107
- let currentTab = 0;
108
- let optionIndex = 0;
109
- let inputMode = false;
110
- let inputQuestionId: string | null = null;
111
- let cachedLines: string[] | undefined;
112
- const answers = new Map<string, Answer>();
113
-
114
- // Editor for "Type something" option
115
- const editorTheme: EditorTheme = {
116
- borderColor: (s) => theme.fg('accent', s),
117
- selectList: {
118
- selectedPrefix: (t) => theme.fg('accent', t),
119
- selectedText: (t) => theme.fg('accent', t),
120
- description: (t) => theme.fg('muted', t),
121
- scrollInfo: (t) => theme.fg('dim', t),
122
- noMatch: (t) => theme.fg('warning', t)
123
- }
124
- };
125
- const editor = new Editor(tui, editorTheme);
126
-
127
- // Helpers
128
- function refresh() {
129
- cachedLines = undefined;
130
- tui.requestRender();
131
- }
132
-
133
- function submit(cancelled: boolean) {
134
- done({ questions, answers: Array.from(answers.values()), cancelled });
135
- }
136
-
137
- function currentQuestion(): Question | undefined {
138
- return questions[currentTab];
139
- }
140
-
141
- function currentOptions(): RenderOption[] {
142
- const q = currentQuestion();
143
- if (!q) return [];
144
- const opts: RenderOption[] = [...q.options];
145
- if (q.allowOther) {
146
- opts.push({ value: '__other__', label: 'Type something.', isOther: true });
147
- }
148
- return opts;
149
- }
150
-
151
- function allAnswered(): boolean {
152
- return questions.every((q) => answers.has(q.id));
153
- }
154
-
155
- function advanceAfterAnswer() {
156
- if (!isMulti) {
157
- submit(false);
158
- return;
159
- }
160
- if (currentTab < questions.length - 1) {
161
- currentTab++;
162
- } else {
163
- currentTab = questions.length; // Submit tab
164
- }
165
- optionIndex = 0;
166
- refresh();
167
- }
168
-
169
- function saveAnswer(
170
- questionId: string,
171
- value: string,
172
- label: string,
173
- wasCustom: boolean,
174
- index?: number
175
- ) {
176
- answers.set(questionId, { id: questionId, value, label, wasCustom, index });
177
- }
178
-
179
- // Editor submit callback
180
- editor.onSubmit = (value) => {
181
- if (!inputQuestionId) return;
182
- const trimmed = value.trim() || '(no response)';
183
- saveAnswer(inputQuestionId, trimmed, trimmed, true);
184
- inputMode = false;
185
- inputQuestionId = null;
186
- editor.setText('');
187
- advanceAfterAnswer();
188
- };
189
-
190
- function handleInput(data: string) {
191
- // Input mode: route to editor
192
- if (inputMode) {
193
- if (matchesKey(data, Key.escape)) {
194
- inputMode = false;
195
- inputQuestionId = null;
196
- editor.setText('');
197
- refresh();
198
- return;
199
- }
200
- editor.handleInput(data);
201
- refresh();
202
- return;
203
- }
204
-
205
- const q = currentQuestion();
206
- const opts = currentOptions();
207
-
208
- // Tab navigation (multi-question only)
209
- if (isMulti) {
210
- if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
211
- currentTab = (currentTab + 1) % totalTabs;
212
- optionIndex = 0;
213
- refresh();
214
- return;
215
- }
216
- if (matchesKey(data, Key.shift('tab')) || matchesKey(data, Key.left)) {
217
- currentTab = (currentTab - 1 + totalTabs) % totalTabs;
218
- optionIndex = 0;
219
- refresh();
220
- return;
221
- }
222
- }
223
-
224
- // Submit tab
225
- if (currentTab === questions.length) {
226
- if (matchesKey(data, Key.enter) && allAnswered()) {
227
- submit(false);
228
- } else if (matchesKey(data, Key.escape)) {
229
- submit(true);
230
- }
231
- return;
232
- }
233
-
234
- // Option navigation
235
- if (matchesKey(data, Key.up)) {
236
- optionIndex = Math.max(0, optionIndex - 1);
237
- refresh();
238
- return;
239
- }
240
- if (matchesKey(data, Key.down)) {
241
- optionIndex = Math.min(opts.length - 1, optionIndex + 1);
242
- refresh();
243
- return;
244
- }
245
-
246
- // Select option
247
- if (matchesKey(data, Key.enter) && q) {
248
- const opt = opts[optionIndex];
249
- if (opt.isOther) {
250
- inputMode = true;
251
- inputQuestionId = q.id;
252
- editor.setText('');
253
- refresh();
254
- return;
255
- }
256
- saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
257
- advanceAfterAnswer();
258
- return;
259
- }
260
-
261
- // Cancel
262
- if (matchesKey(data, Key.escape)) {
263
- submit(true);
264
- }
265
- }
266
-
267
- function render(width: number): string[] {
268
- if (cachedLines) return cachedLines;
269
-
270
- const lines: string[] = [];
271
- const q = currentQuestion();
272
- const opts = currentOptions();
273
-
274
- // Helper to add truncated line
275
- const add = (s: string) => lines.push(truncateToWidth(s, width));
276
-
277
- add(theme.fg('accent', '─'.repeat(width)));
278
-
279
- // Tab bar (multi-question only)
280
- if (isMulti) {
281
- const tabs: string[] = ['← '];
282
- for (let i = 0; i < questions.length; i++) {
283
- const isActive = i === currentTab;
284
- const isAnswered = answers.has(questions[i].id);
285
- const lbl = questions[i].label;
286
- const box = isAnswered ? '■' : '□';
287
- const color = isAnswered ? 'success' : 'muted';
288
- const text = ` ${box} ${lbl} `;
289
- const styled = isActive
290
- ? theme.bg('selectedBg', theme.fg('text', text))
291
- : theme.fg(color, text);
292
- tabs.push(`${styled} `);
293
- }
294
- const canSubmit = allAnswered();
295
- const isSubmitTab = currentTab === questions.length;
296
- const submitText = ' ✓ Submit ';
297
- const submitStyled = isSubmitTab
298
- ? theme.bg('selectedBg', theme.fg('text', submitText))
299
- : theme.fg(canSubmit ? 'success' : 'dim', submitText);
300
- tabs.push(`${submitStyled} →`);
301
- add(` ${tabs.join('')}`);
302
- lines.push('');
303
- }
304
-
305
- // Helper to render options list
306
- function renderOptions() {
307
- for (let i = 0; i < opts.length; i++) {
308
- const opt = opts[i];
309
- const selected = i === optionIndex;
310
- const isOther = opt.isOther === true;
311
- const prefix = selected ? theme.fg('accent', '> ') : ' ';
312
- const color = selected ? 'accent' : 'text';
313
- // Mark "Type something" differently when in input mode
314
- if (isOther && inputMode) {
315
- add(prefix + theme.fg('accent', `${i + 1}. ${opt.label} ✎`));
316
- } else {
317
- add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`));
318
- }
319
- if (opt.description) {
320
- add(` ${theme.fg('muted', opt.description)}`);
321
- }
322
- }
323
- }
324
-
325
- // Content
326
- if (inputMode && q) {
327
- add(theme.fg('text', ` ${q.prompt}`));
328
- lines.push('');
329
- // Show options for reference
330
- renderOptions();
331
- lines.push('');
332
- add(theme.fg('muted', ' Your answer:'));
333
- for (const line of editor.render(width - 2)) {
334
- add(` ${line}`);
335
- }
336
- lines.push('');
337
- add(theme.fg('dim', ' Enter to submit • Esc to cancel'));
338
- } else if (currentTab === questions.length) {
339
- add(theme.fg('accent', theme.bold(' Ready to submit')));
340
- lines.push('');
341
- for (const question of questions) {
342
- const answer = answers.get(question.id);
343
- if (answer) {
344
- const prefix = answer.wasCustom ? '(wrote) ' : '';
345
- add(
346
- `${theme.fg('muted', ` ${question.label}: `)}${theme.fg('text', prefix + answer.label)}`
347
- );
348
- }
349
- }
350
- lines.push('');
351
- if (allAnswered()) {
352
- add(theme.fg('success', ' Press Enter to submit'));
353
- } else {
354
- const missing = questions
355
- .filter((q) => !answers.has(q.id))
356
- .map((q) => q.label)
357
- .join(', ');
358
- add(theme.fg('warning', ` Unanswered: ${missing}`));
359
- }
360
- } else if (q) {
361
- add(theme.fg('text', ` ${q.prompt}`));
362
- lines.push('');
363
- renderOptions();
364
- }
365
-
366
- lines.push('');
367
- if (!inputMode) {
368
- const help = isMulti
369
- ? ' Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel'
370
- : ' ↑↓ navigate • Enter select • Esc cancel';
371
- add(theme.fg('dim', help));
372
- }
373
- add(theme.fg('accent', '─'.repeat(width)));
374
-
375
- cachedLines = lines;
376
- return lines;
377
- }
378
-
379
- return {
380
- render,
381
- invalidate: () => {
382
- cachedLines = undefined;
383
- },
384
- handleInput
385
- };
386
- });
387
-
388
- if (result.cancelled) {
389
- return {
390
- content: [{ type: 'text', text: 'User cancelled the questionnaire' }],
391
- details: result
392
- };
393
- }
394
-
395
- const answerLines = result.answers.map((a) => {
396
- const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
397
- if (a.wasCustom) {
398
- return `${qLabel}: user wrote: ${a.label}`;
399
- }
400
- return `${qLabel}: user selected: ${a.index}. ${a.label}`;
401
- });
402
-
403
- return {
404
- content: [{ type: 'text', text: answerLines.join('\n') }],
405
- details: result
406
- };
407
- },
408
-
409
- renderCall(args, theme) {
410
- const qs = (args.questions as Question[]) || [];
411
- const count = qs.length;
412
- const labels = qs.map((q) => q.label || q.id).join(', ');
413
- let text = theme.fg('toolTitle', theme.bold('questionnaire '));
414
- text += theme.fg('muted', `${count} question${count !== 1 ? 's' : ''}`);
415
- if (labels) {
416
- text += theme.fg('dim', ` (${truncateToWidth(labels, 40)})`);
417
- }
418
- return new Text(text, 0, 0);
419
- },
420
-
421
- renderResult(result, _options, theme) {
422
- const details = result.details as QuestionnaireResult | undefined;
423
- if (!details) {
424
- const text = result.content[0];
425
- return new Text(text?.type === 'text' ? text.text : '', 0, 0);
426
- }
427
- if (details.cancelled) {
428
- return new Text(theme.fg('warning', 'Cancelled'), 0, 0);
429
- }
430
- const lines = details.answers.map((a) => {
431
- if (a.wasCustom) {
432
- return `${theme.fg('success', '✓ ')}${theme.fg('accent', a.id)}: ${theme.fg('muted', '(wrote) ')}${a.label}`;
433
- }
434
- const display = a.index ? `${a.index}. ${a.label}` : a.label;
435
- return `${theme.fg('success', '✓ ')}${theme.fg('accent', a.id)}: ${display}`;
436
- });
437
- return new Text(lines.join('\n'), 0, 0);
438
- }
439
- });
440
- }
1
+ /**
2
+ * Questionnaire Tool - Unified tool for asking single or multiple questions
3
+ *
4
+ * Single question: simple options list
5
+ * Multiple questions: tab bar navigation between questions
6
+ */
7
+
8
+ import { Type } from '@sinclair/typebox';
9
+ import type { ExtensionAPI } from 'shortcutxl';
10
+ import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from 'shortcutxl';
11
+
12
+ // Types
13
+ interface QuestionOption {
14
+ value: string;
15
+ label: string;
16
+ description?: string;
17
+ }
18
+
19
+ type RenderOption = QuestionOption & { isOther?: boolean };
20
+
21
+ interface Question {
22
+ id: string;
23
+ label: string;
24
+ prompt: string;
25
+ options: QuestionOption[];
26
+ allowOther: boolean;
27
+ }
28
+
29
+ interface Answer {
30
+ id: string;
31
+ value: string;
32
+ label: string;
33
+ wasCustom: boolean;
34
+ index?: number;
35
+ }
36
+
37
+ interface QuestionnaireResult {
38
+ questions: Question[];
39
+ answers: Answer[];
40
+ cancelled: boolean;
41
+ }
42
+
43
+ // Schema
44
+ const QuestionOptionSchema = Type.Object({
45
+ value: Type.String({ description: 'The value returned when selected' }),
46
+ label: Type.String({ description: 'Display label for the option' }),
47
+ description: Type.Optional(Type.String({ description: 'Optional description shown below label' }))
48
+ });
49
+
50
+ const QuestionSchema = Type.Object({
51
+ id: Type.String({ description: 'Unique identifier for this question' }),
52
+ label: Type.Optional(
53
+ Type.String({
54
+ description:
55
+ "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)"
56
+ })
57
+ ),
58
+ prompt: Type.String({ description: 'The full question text to display' }),
59
+ options: Type.Array(QuestionOptionSchema, { description: 'Available options to choose from' }),
60
+ allowOther: Type.Optional(
61
+ Type.Boolean({ description: "Allow 'Type something' option (default: true)" })
62
+ )
63
+ });
64
+
65
+ const QuestionnaireParams = Type.Object({
66
+ questions: Type.Array(QuestionSchema, { description: 'Questions to ask the user' })
67
+ });
68
+
69
+ function errorResult(
70
+ message: string,
71
+ questions: Question[] = []
72
+ ): { content: { type: 'text'; text: string }[]; details: QuestionnaireResult } {
73
+ return {
74
+ content: [{ type: 'text', text: message }],
75
+ details: { questions, answers: [], cancelled: true }
76
+ };
77
+ }
78
+
79
+ export default function questionnaire(shortcut: ExtensionAPI) {
80
+ shortcut.registerTool({
81
+ name: 'questionnaire',
82
+ label: 'Questionnaire',
83
+ description:
84
+ 'Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface.',
85
+ parameters: QuestionnaireParams,
86
+
87
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
88
+ if (!ctx.hasUI) {
89
+ return errorResult('Error: UI not available (running in non-interactive mode)');
90
+ }
91
+ if (params.questions.length === 0) {
92
+ return errorResult('Error: No questions provided');
93
+ }
94
+
95
+ // Normalize questions with defaults
96
+ const questions: Question[] = params.questions.map((q, i) => ({
97
+ ...q,
98
+ label: q.label || `Q${i + 1}`,
99
+ allowOther: q.allowOther !== false
100
+ }));
101
+
102
+ const isMulti = questions.length > 1;
103
+ const totalTabs = questions.length + 1; // questions + Submit
104
+
105
+ const result = await ctx.ui.custom<QuestionnaireResult>((tui, theme, _kb, done) => {
106
+ // State
107
+ let currentTab = 0;
108
+ let optionIndex = 0;
109
+ let inputMode = false;
110
+ let inputQuestionId: string | null = null;
111
+ let cachedLines: string[] | undefined;
112
+ const answers = new Map<string, Answer>();
113
+
114
+ // Editor for "Type something" option
115
+ const editorTheme: EditorTheme = {
116
+ borderColor: (s) => theme.fg('accent', s),
117
+ selectList: {
118
+ selectedPrefix: (t) => theme.fg('accent', t),
119
+ selectedText: (t) => theme.fg('accent', t),
120
+ description: (t) => theme.fg('muted', t),
121
+ scrollInfo: (t) => theme.fg('dim', t),
122
+ noMatch: (t) => theme.fg('warning', t)
123
+ }
124
+ };
125
+ const editor = new Editor(tui, editorTheme);
126
+
127
+ // Helpers
128
+ function refresh() {
129
+ cachedLines = undefined;
130
+ tui.requestRender();
131
+ }
132
+
133
+ function submit(cancelled: boolean) {
134
+ done({ questions, answers: Array.from(answers.values()), cancelled });
135
+ }
136
+
137
+ function currentQuestion(): Question | undefined {
138
+ return questions[currentTab];
139
+ }
140
+
141
+ function currentOptions(): RenderOption[] {
142
+ const q = currentQuestion();
143
+ if (!q) return [];
144
+ const opts: RenderOption[] = [...q.options];
145
+ if (q.allowOther) {
146
+ opts.push({ value: '__other__', label: 'Type something.', isOther: true });
147
+ }
148
+ return opts;
149
+ }
150
+
151
+ function allAnswered(): boolean {
152
+ return questions.every((q) => answers.has(q.id));
153
+ }
154
+
155
+ function advanceAfterAnswer() {
156
+ if (!isMulti) {
157
+ submit(false);
158
+ return;
159
+ }
160
+ if (currentTab < questions.length - 1) {
161
+ currentTab++;
162
+ } else {
163
+ currentTab = questions.length; // Submit tab
164
+ }
165
+ optionIndex = 0;
166
+ refresh();
167
+ }
168
+
169
+ function saveAnswer(
170
+ questionId: string,
171
+ value: string,
172
+ label: string,
173
+ wasCustom: boolean,
174
+ index?: number
175
+ ) {
176
+ answers.set(questionId, { id: questionId, value, label, wasCustom, index });
177
+ }
178
+
179
+ // Editor submit callback
180
+ editor.onSubmit = (value) => {
181
+ if (!inputQuestionId) return;
182
+ const trimmed = value.trim() || '(no response)';
183
+ saveAnswer(inputQuestionId, trimmed, trimmed, true);
184
+ inputMode = false;
185
+ inputQuestionId = null;
186
+ editor.setText('');
187
+ advanceAfterAnswer();
188
+ };
189
+
190
+ function handleInput(data: string) {
191
+ // Input mode: route to editor
192
+ if (inputMode) {
193
+ if (matchesKey(data, Key.escape)) {
194
+ inputMode = false;
195
+ inputQuestionId = null;
196
+ editor.setText('');
197
+ refresh();
198
+ return;
199
+ }
200
+ editor.handleInput(data);
201
+ refresh();
202
+ return;
203
+ }
204
+
205
+ const q = currentQuestion();
206
+ const opts = currentOptions();
207
+
208
+ // Tab navigation (multi-question only)
209
+ if (isMulti) {
210
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
211
+ currentTab = (currentTab + 1) % totalTabs;
212
+ optionIndex = 0;
213
+ refresh();
214
+ return;
215
+ }
216
+ if (matchesKey(data, Key.shift('tab')) || matchesKey(data, Key.left)) {
217
+ currentTab = (currentTab - 1 + totalTabs) % totalTabs;
218
+ optionIndex = 0;
219
+ refresh();
220
+ return;
221
+ }
222
+ }
223
+
224
+ // Submit tab
225
+ if (currentTab === questions.length) {
226
+ if (matchesKey(data, Key.enter) && allAnswered()) {
227
+ submit(false);
228
+ } else if (matchesKey(data, Key.escape)) {
229
+ submit(true);
230
+ }
231
+ return;
232
+ }
233
+
234
+ // Option navigation
235
+ if (matchesKey(data, Key.up)) {
236
+ optionIndex = Math.max(0, optionIndex - 1);
237
+ refresh();
238
+ return;
239
+ }
240
+ if (matchesKey(data, Key.down)) {
241
+ optionIndex = Math.min(opts.length - 1, optionIndex + 1);
242
+ refresh();
243
+ return;
244
+ }
245
+
246
+ // Select option
247
+ if (matchesKey(data, Key.enter) && q) {
248
+ const opt = opts[optionIndex];
249
+ if (opt.isOther) {
250
+ inputMode = true;
251
+ inputQuestionId = q.id;
252
+ editor.setText('');
253
+ refresh();
254
+ return;
255
+ }
256
+ saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
257
+ advanceAfterAnswer();
258
+ return;
259
+ }
260
+
261
+ // Cancel
262
+ if (matchesKey(data, Key.escape)) {
263
+ submit(true);
264
+ }
265
+ }
266
+
267
+ function render(width: number): string[] {
268
+ if (cachedLines) return cachedLines;
269
+
270
+ const lines: string[] = [];
271
+ const q = currentQuestion();
272
+ const opts = currentOptions();
273
+
274
+ // Helper to add truncated line
275
+ const add = (s: string) => lines.push(truncateToWidth(s, width));
276
+
277
+ add(theme.fg('accent', '─'.repeat(width)));
278
+
279
+ // Tab bar (multi-question only)
280
+ if (isMulti) {
281
+ const tabs: string[] = ['← '];
282
+ for (let i = 0; i < questions.length; i++) {
283
+ const isActive = i === currentTab;
284
+ const isAnswered = answers.has(questions[i].id);
285
+ const lbl = questions[i].label;
286
+ const box = isAnswered ? '■' : '□';
287
+ const color = isAnswered ? 'success' : 'muted';
288
+ const text = ` ${box} ${lbl} `;
289
+ const styled = isActive
290
+ ? theme.bg('selectedBg', theme.fg('text', text))
291
+ : theme.fg(color, text);
292
+ tabs.push(`${styled} `);
293
+ }
294
+ const canSubmit = allAnswered();
295
+ const isSubmitTab = currentTab === questions.length;
296
+ const submitText = ' ✓ Submit ';
297
+ const submitStyled = isSubmitTab
298
+ ? theme.bg('selectedBg', theme.fg('text', submitText))
299
+ : theme.fg(canSubmit ? 'success' : 'dim', submitText);
300
+ tabs.push(`${submitStyled} →`);
301
+ add(` ${tabs.join('')}`);
302
+ lines.push('');
303
+ }
304
+
305
+ // Helper to render options list
306
+ function renderOptions() {
307
+ for (let i = 0; i < opts.length; i++) {
308
+ const opt = opts[i];
309
+ const selected = i === optionIndex;
310
+ const isOther = opt.isOther === true;
311
+ const prefix = selected ? theme.fg('accent', '> ') : ' ';
312
+ const color = selected ? 'accent' : 'text';
313
+ // Mark "Type something" differently when in input mode
314
+ if (isOther && inputMode) {
315
+ add(prefix + theme.fg('accent', `${i + 1}. ${opt.label} ✎`));
316
+ } else {
317
+ add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`));
318
+ }
319
+ if (opt.description) {
320
+ add(` ${theme.fg('muted', opt.description)}`);
321
+ }
322
+ }
323
+ }
324
+
325
+ // Content
326
+ if (inputMode && q) {
327
+ add(theme.fg('text', ` ${q.prompt}`));
328
+ lines.push('');
329
+ // Show options for reference
330
+ renderOptions();
331
+ lines.push('');
332
+ add(theme.fg('muted', ' Your answer:'));
333
+ for (const line of editor.render(width - 2)) {
334
+ add(` ${line}`);
335
+ }
336
+ lines.push('');
337
+ add(theme.fg('dim', ' Enter to submit • Esc to cancel'));
338
+ } else if (currentTab === questions.length) {
339
+ add(theme.fg('accent', theme.bold(' Ready to submit')));
340
+ lines.push('');
341
+ for (const question of questions) {
342
+ const answer = answers.get(question.id);
343
+ if (answer) {
344
+ const prefix = answer.wasCustom ? '(wrote) ' : '';
345
+ add(
346
+ `${theme.fg('muted', ` ${question.label}: `)}${theme.fg('text', prefix + answer.label)}`
347
+ );
348
+ }
349
+ }
350
+ lines.push('');
351
+ if (allAnswered()) {
352
+ add(theme.fg('success', ' Press Enter to submit'));
353
+ } else {
354
+ const missing = questions
355
+ .filter((q) => !answers.has(q.id))
356
+ .map((q) => q.label)
357
+ .join(', ');
358
+ add(theme.fg('warning', ` Unanswered: ${missing}`));
359
+ }
360
+ } else if (q) {
361
+ add(theme.fg('text', ` ${q.prompt}`));
362
+ lines.push('');
363
+ renderOptions();
364
+ }
365
+
366
+ lines.push('');
367
+ if (!inputMode) {
368
+ const help = isMulti
369
+ ? ' Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel'
370
+ : ' ↑↓ navigate • Enter select • Esc cancel';
371
+ add(theme.fg('dim', help));
372
+ }
373
+ add(theme.fg('accent', '─'.repeat(width)));
374
+
375
+ cachedLines = lines;
376
+ return lines;
377
+ }
378
+
379
+ return {
380
+ render,
381
+ invalidate: () => {
382
+ cachedLines = undefined;
383
+ },
384
+ handleInput
385
+ };
386
+ });
387
+
388
+ if (result.cancelled) {
389
+ return {
390
+ content: [{ type: 'text', text: 'User cancelled the questionnaire' }],
391
+ details: result
392
+ };
393
+ }
394
+
395
+ const answerLines = result.answers.map((a) => {
396
+ const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
397
+ if (a.wasCustom) {
398
+ return `${qLabel}: user wrote: ${a.label}`;
399
+ }
400
+ return `${qLabel}: user selected: ${a.index}. ${a.label}`;
401
+ });
402
+
403
+ return {
404
+ content: [{ type: 'text', text: answerLines.join('\n') }],
405
+ details: result
406
+ };
407
+ },
408
+
409
+ renderCall(args, theme) {
410
+ const qs = (args.questions as Question[]) || [];
411
+ const count = qs.length;
412
+ const labels = qs.map((q) => q.label || q.id).join(', ');
413
+ let text = theme.fg('toolTitle', theme.bold('questionnaire '));
414
+ text += theme.fg('muted', `${count} question${count !== 1 ? 's' : ''}`);
415
+ if (labels) {
416
+ text += theme.fg('dim', ` (${truncateToWidth(labels, 40)})`);
417
+ }
418
+ return new Text(text, 0, 0);
419
+ },
420
+
421
+ renderResult(result, _options, theme) {
422
+ const details = result.details as QuestionnaireResult | undefined;
423
+ if (!details) {
424
+ const text = result.content[0];
425
+ return new Text(text?.type === 'text' ? text.text : '', 0, 0);
426
+ }
427
+ if (details.cancelled) {
428
+ return new Text(theme.fg('warning', 'Cancelled'), 0, 0);
429
+ }
430
+ const lines = details.answers.map((a) => {
431
+ if (a.wasCustom) {
432
+ return `${theme.fg('success', '✓ ')}${theme.fg('accent', a.id)}: ${theme.fg('muted', '(wrote) ')}${a.label}`;
433
+ }
434
+ const display = a.index ? `${a.index}. ${a.label}` : a.label;
435
+ return `${theme.fg('success', '✓ ')}${theme.fg('accent', a.id)}: ${display}`;
436
+ });
437
+ return new Text(lines.join('\n'), 0, 0);
438
+ }
439
+ });
440
+ }