dexto 1.6.0 → 1.6.1

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 (132) hide show
  1. package/dist/agents/coding-agent/coding-agent.yml +3 -1
  2. package/dist/cli/assets/sounds/SOURCES.md +35 -0
  3. package/dist/cli/assets/sounds/boot.wav +0 -0
  4. package/dist/cli/assets/sounds/chime.wav +0 -0
  5. package/dist/cli/assets/sounds/coin.wav +0 -0
  6. package/dist/cli/assets/sounds/confirm.wav +0 -0
  7. package/dist/cli/assets/sounds/levelup.wav +0 -0
  8. package/dist/cli/assets/sounds/ping.wav +0 -0
  9. package/dist/cli/assets/sounds/powerup.wav +0 -0
  10. package/dist/cli/assets/sounds/startup.wav +0 -0
  11. package/dist/cli/assets/sounds/success.wav +0 -0
  12. package/dist/cli/assets/sounds/treasure.wav +0 -0
  13. package/dist/cli/assets/sounds/win.wav +0 -0
  14. package/dist/cli/commands/interactive-commands/exit-handler.d.ts +12 -0
  15. package/dist/cli/commands/interactive-commands/exit-handler.d.ts.map +1 -0
  16. package/dist/cli/commands/interactive-commands/exit-handler.js +20 -0
  17. package/dist/cli/commands/interactive-commands/exit-stats.d.ts +24 -0
  18. package/dist/cli/commands/interactive-commands/exit-stats.d.ts.map +1 -0
  19. package/dist/cli/commands/interactive-commands/exit-stats.js +17 -0
  20. package/dist/cli/commands/interactive-commands/general-commands.d.ts.map +1 -1
  21. package/dist/cli/commands/interactive-commands/general-commands.js +53 -3
  22. package/dist/cli/commands/interactive-commands/prompt-commands.d.ts.map +1 -1
  23. package/dist/cli/commands/interactive-commands/prompt-commands.js +12 -67
  24. package/dist/cli/commands/interactive-commands/session/session-commands.d.ts.map +1 -1
  25. package/dist/cli/commands/interactive-commands/session/session-commands.js +0 -2
  26. package/dist/cli/commands/interactive-commands/system/system-commands.d.ts +1 -13
  27. package/dist/cli/commands/interactive-commands/system/system-commands.d.ts.map +1 -1
  28. package/dist/cli/commands/interactive-commands/system/system-commands.js +45 -54
  29. package/dist/cli/ink-cli/InkCLIRefactored.d.ts.map +1 -1
  30. package/dist/cli/ink-cli/InkCLIRefactored.js +132 -21
  31. package/dist/cli/ink-cli/components/ApprovalPrompt.d.ts.map +1 -1
  32. package/dist/cli/ink-cli/components/ApprovalPrompt.js +74 -20
  33. package/dist/cli/ink-cli/components/ElicitationForm.d.ts +5 -3
  34. package/dist/cli/ink-cli/components/ElicitationForm.d.ts.map +1 -1
  35. package/dist/cli/ink-cli/components/ElicitationForm.js +414 -180
  36. package/dist/cli/ink-cli/components/ResourceAutocomplete.d.ts.map +1 -1
  37. package/dist/cli/ink-cli/components/ResourceAutocomplete.js +20 -11
  38. package/dist/cli/ink-cli/components/SlashCommandAutocomplete.d.ts.map +1 -1
  39. package/dist/cli/ink-cli/components/SlashCommandAutocomplete.js +47 -67
  40. package/dist/cli/ink-cli/components/StatusBar.d.ts.map +1 -1
  41. package/dist/cli/ink-cli/components/StatusBar.js +10 -4
  42. package/dist/cli/ink-cli/components/base/BaseSelector.d.ts +2 -1
  43. package/dist/cli/ink-cli/components/base/BaseSelector.d.ts.map +1 -1
  44. package/dist/cli/ink-cli/components/base/BaseSelector.js +37 -27
  45. package/dist/cli/ink-cli/components/chat/Header.d.ts.map +1 -1
  46. package/dist/cli/ink-cli/components/chat/Header.js +1 -1
  47. package/dist/cli/ink-cli/components/chat/MessageItem.d.ts.map +1 -1
  48. package/dist/cli/ink-cli/components/chat/MessageItem.js +3 -1
  49. package/dist/cli/ink-cli/components/chat/ToolIcon.d.ts.map +1 -1
  50. package/dist/cli/ink-cli/components/chat/ToolIcon.js +5 -15
  51. package/dist/cli/ink-cli/components/chat/styled-boxes/LogConfigBox.d.ts.map +1 -1
  52. package/dist/cli/ink-cli/components/chat/styled-boxes/LogConfigBox.js +1 -1
  53. package/dist/cli/ink-cli/components/modes/AlternateBufferCLI.d.ts.map +1 -1
  54. package/dist/cli/ink-cli/components/modes/AlternateBufferCLI.js +4 -2
  55. package/dist/cli/ink-cli/components/modes/StaticCLI.d.ts.map +1 -1
  56. package/dist/cli/ink-cli/components/modes/StaticCLI.js +9 -2
  57. package/dist/cli/ink-cli/components/overlays/CommandOutputOverlay.d.ts +13 -0
  58. package/dist/cli/ink-cli/components/overlays/CommandOutputOverlay.d.ts.map +1 -0
  59. package/dist/cli/ink-cli/components/overlays/CommandOutputOverlay.js +60 -0
  60. package/dist/cli/ink-cli/components/overlays/LogLevelSelector.js +1 -1
  61. package/dist/cli/ink-cli/components/overlays/ModelSelectorRefactored.d.ts.map +1 -1
  62. package/dist/cli/ink-cli/components/overlays/ModelSelectorRefactored.js +213 -100
  63. package/dist/cli/ink-cli/components/overlays/PromptList.d.ts.map +1 -1
  64. package/dist/cli/ink-cli/components/overlays/PromptList.js +12 -16
  65. package/dist/cli/ink-cli/components/overlays/SoundsSelector.d.ts +21 -0
  66. package/dist/cli/ink-cli/components/overlays/SoundsSelector.d.ts.map +1 -0
  67. package/dist/cli/ink-cli/components/overlays/SoundsSelector.js +566 -0
  68. package/dist/cli/ink-cli/components/overlays/ToolBrowser.d.ts.map +1 -1
  69. package/dist/cli/ink-cli/components/overlays/ToolBrowser.js +94 -39
  70. package/dist/cli/ink-cli/components/overlays/custom-model-wizard/LocalModelWizard.d.ts.map +1 -1
  71. package/dist/cli/ink-cli/components/overlays/custom-model-wizard/LocalModelWizard.js +8 -13
  72. package/dist/cli/ink-cli/components/renderers/FilePreviewRenderer.d.ts +3 -3
  73. package/dist/cli/ink-cli/components/renderers/FilePreviewRenderer.d.ts.map +1 -1
  74. package/dist/cli/ink-cli/components/renderers/FilePreviewRenderer.js +6 -5
  75. package/dist/cli/ink-cli/components/renderers/FileRenderer.d.ts +3 -1
  76. package/dist/cli/ink-cli/components/renderers/FileRenderer.d.ts.map +1 -1
  77. package/dist/cli/ink-cli/components/renderers/FileRenderer.js +18 -7
  78. package/dist/cli/ink-cli/components/renderers/ShellRenderer.d.ts.map +1 -1
  79. package/dist/cli/ink-cli/components/renderers/ShellRenderer.js +7 -17
  80. package/dist/cli/ink-cli/components/renderers/index.d.ts.map +1 -1
  81. package/dist/cli/ink-cli/components/renderers/index.js +1 -1
  82. package/dist/cli/ink-cli/components/shared/FocusOverlayFrame.d.ts +7 -0
  83. package/dist/cli/ink-cli/components/shared/FocusOverlayFrame.d.ts.map +1 -0
  84. package/dist/cli/ink-cli/components/shared/FocusOverlayFrame.js +8 -0
  85. package/dist/cli/ink-cli/components/shared/HintBar.d.ts +6 -0
  86. package/dist/cli/ink-cli/components/shared/HintBar.d.ts.map +1 -0
  87. package/dist/cli/ink-cli/components/shared/HintBar.js +6 -0
  88. package/dist/cli/ink-cli/constants/spinnerFrames.d.ts +2 -0
  89. package/dist/cli/ink-cli/constants/spinnerFrames.d.ts.map +1 -0
  90. package/dist/cli/ink-cli/constants/spinnerFrames.js +1 -0
  91. package/dist/cli/ink-cli/constants/tips.d.ts.map +1 -1
  92. package/dist/cli/ink-cli/constants/tips.js +1 -0
  93. package/dist/cli/ink-cli/containers/InputContainer.d.ts.map +1 -1
  94. package/dist/cli/ink-cli/containers/InputContainer.js +19 -15
  95. package/dist/cli/ink-cli/containers/OverlayContainer.d.ts.map +1 -1
  96. package/dist/cli/ink-cli/containers/OverlayContainer.js +21 -5
  97. package/dist/cli/ink-cli/hooks/useAnimationTick.d.ts +11 -0
  98. package/dist/cli/ink-cli/hooks/useAnimationTick.d.ts.map +1 -0
  99. package/dist/cli/ink-cli/hooks/useAnimationTick.js +54 -0
  100. package/dist/cli/ink-cli/hooks/useCLIState.d.ts.map +1 -1
  101. package/dist/cli/ink-cli/hooks/useCLIState.js +1 -0
  102. package/dist/cli/ink-cli/services/processStream.d.ts.map +1 -1
  103. package/dist/cli/ink-cli/services/processStream.js +17 -8
  104. package/dist/cli/ink-cli/state/initialState.d.ts.map +1 -1
  105. package/dist/cli/ink-cli/state/initialState.js +1 -0
  106. package/dist/cli/ink-cli/state/types.d.ts +13 -1
  107. package/dist/cli/ink-cli/state/types.d.ts.map +1 -1
  108. package/dist/cli/ink-cli/utils/commandOverlays.d.ts.map +1 -1
  109. package/dist/cli/ink-cli/utils/commandOverlays.js +1 -0
  110. package/dist/cli/ink-cli/utils/elicitationSchema.d.ts +11 -0
  111. package/dist/cli/ink-cli/utils/elicitationSchema.d.ts.map +1 -0
  112. package/dist/cli/ink-cli/utils/elicitationSchema.js +80 -0
  113. package/dist/cli/ink-cli/utils/index.d.ts +1 -1
  114. package/dist/cli/ink-cli/utils/index.d.ts.map +1 -1
  115. package/dist/cli/ink-cli/utils/index.js +1 -1
  116. package/dist/cli/ink-cli/utils/messageFormatting.d.ts +2 -17
  117. package/dist/cli/ink-cli/utils/messageFormatting.d.ts.map +1 -1
  118. package/dist/cli/ink-cli/utils/messageFormatting.js +22 -128
  119. package/dist/cli/ink-cli/utils/overlayPresentation.d.ts +19 -0
  120. package/dist/cli/ink-cli/utils/overlayPresentation.d.ts.map +1 -0
  121. package/dist/cli/ink-cli/utils/overlayPresentation.js +33 -0
  122. package/dist/cli/ink-cli/utils/overlaySizing.d.ts +19 -0
  123. package/dist/cli/ink-cli/utils/overlaySizing.d.ts.map +1 -0
  124. package/dist/cli/ink-cli/utils/overlaySizing.js +11 -0
  125. package/dist/cli/ink-cli/utils/soundNotification.d.ts +19 -13
  126. package/dist/cli/ink-cli/utils/soundNotification.d.ts.map +1 -1
  127. package/dist/cli/ink-cli/utils/soundNotification.js +120 -97
  128. package/dist/utils/session-logger-factory.d.ts.map +1 -1
  129. package/dist/utils/session-logger-factory.js +17 -2
  130. package/dist/webui/assets/{index-DwtueA8l.js → index-CKhumsZA.js} +135 -135
  131. package/dist/webui/index.html +1 -1
  132. package/package.json +11 -11
@@ -1,203 +1,335 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * ElicitationForm Component
4
- * Renders a form for ask_user/elicitation requests in the CLI
5
- * Supports string, number, boolean, and enum field types
4
+ * Renders a form for ask_user/elicitation requests in the CLI.
5
+ *
6
+ * Uses a wizard flow (one question at a time) to avoid huge modals and improve
7
+ * usability on small terminals.
6
8
  */
7
9
  import { useState, forwardRef, useImperativeHandle, useCallback, useMemo } from 'react';
8
10
  import { Box, Text } from 'ink';
11
+ import wrapAnsi from 'wrap-ansi';
12
+ import { useTerminalSize } from '../hooks/useTerminalSize.js';
13
+ import { parseElicitationSchema } from '../utils/elicitationSchema.js';
14
+ function hasOwn(obj, key) {
15
+ return Object.prototype.hasOwnProperty.call(obj, key);
16
+ }
17
+ function getDisplayValue(value) {
18
+ if (value === undefined || value === null || value === '')
19
+ return '—';
20
+ if (Array.isArray(value))
21
+ return value.length > 0 ? value.join(', ') : '—';
22
+ if (value === true)
23
+ return 'Yes';
24
+ if (value === false)
25
+ return 'No';
26
+ return String(value ?? '');
27
+ }
28
+ function clamp(value, min, max) {
29
+ return Math.max(min, Math.min(max, value));
30
+ }
9
31
  /**
10
32
  * Form component for elicitation/ask_user requests
11
33
  */
12
34
  export const ElicitationForm = forwardRef(({ metadata, onSubmit, onCancel }, ref) => {
13
- // Parse schema into form fields
35
+ const { rows: terminalRows, columns: terminalColumns } = useTerminalSize();
36
+ const headerLineCount = 1;
37
+ const stepHeaderLineCount = 2; // hard cap: 2 lines
38
+ const spacerAfterStepLineCount = 1;
39
+ const questionLineCount = 2;
40
+ const helpLineCount = 2;
41
+ const errorLineCount = 1;
42
+ const questionHeaderHeight = headerLineCount +
43
+ stepHeaderLineCount +
44
+ spacerAfterStepLineCount +
45
+ questionLineCount +
46
+ helpLineCount +
47
+ errorLineCount;
48
+ const reviewHeaderHeight = headerLineCount + spacerAfterStepLineCount;
49
+ const maxHeaderHeight = Math.max(questionHeaderHeight, reviewHeaderHeight);
50
+ const footerHeight = 1; // key hints
51
+ const minContentHeight = 4;
52
+ const viewportHeight = useMemo(() => {
53
+ // Ink clears + redraws when dynamic output height >= terminal rows, which looks like flicker.
54
+ // Keep the elicitation UI small and scroll internally to stay under that threshold.
55
+ // Leave slack so Ink doesn't hit the "clear + redraw everything" path.
56
+ // (Ink clears when dynamic output height >= terminal rows.)
57
+ const reservedRows = 8;
58
+ const minViewportHeight = maxHeaderHeight + footerHeight + minContentHeight;
59
+ const maxHeight = Math.max(minViewportHeight, terminalRows - reservedRows);
60
+ const desired = Math.max(minViewportHeight, Math.floor(terminalRows * 0.6));
61
+ return Math.min(maxHeight, desired);
62
+ }, [footerHeight, maxHeaderHeight, minContentHeight, terminalRows]);
63
+ const availableWidth = Math.max(20, terminalColumns - 2);
14
64
  const fields = useMemo(() => {
15
- const schema = metadata.schema;
16
- if (!schema?.properties)
65
+ return parseElicitationSchema(metadata.schema);
66
+ }, [metadata.schema]);
67
+ const wrapClampedLines = useCallback((text, maxLines) => {
68
+ if (maxLines <= 0)
17
69
  return [];
18
- const required = schema.required || [];
19
- return Object.entries(schema.properties)
20
- .filter((entry) => typeof entry[1] !== 'boolean')
21
- .map(([name, prop]) => {
22
- let type = 'string';
23
- let enumValues;
24
- if (prop.type === 'boolean') {
25
- type = 'boolean';
26
- }
27
- else if (prop.type === 'number' || prop.type === 'integer') {
28
- type = 'number';
29
- }
30
- else if (prop.enum && Array.isArray(prop.enum)) {
31
- type = 'enum';
32
- enumValues = prop.enum;
33
- }
34
- else if (prop.type === 'array' &&
35
- typeof prop.items === 'object' &&
36
- prop.items &&
37
- 'enum' in prop.items) {
38
- type = 'array-enum';
39
- enumValues = prop.items.enum;
40
- }
41
- return {
42
- name,
43
- label: prop.title || name,
44
- type,
45
- description: prop.description,
46
- required: required.includes(name),
47
- enumValues,
48
- };
70
+ const wrapped = wrapAnsi(text, availableWidth, {
71
+ hard: true,
72
+ wordWrap: true,
73
+ trim: false,
49
74
  });
50
- }, [metadata.schema]);
75
+ const rawLines = wrapped.length > 0 ? wrapped.split('\n') : [''];
76
+ const didTruncate = rawLines.length > maxLines;
77
+ const lines = rawLines.slice(0, maxLines);
78
+ if (didTruncate && lines.length > 0) {
79
+ const lastIndex = lines.length - 1;
80
+ const lastLine = (lines[lastIndex] ?? '').replace(/\s+$/, '');
81
+ const safe = lastLine.length > 0
82
+ ? `${lastLine.slice(0, Math.max(0, lastLine.length - 1))}…`
83
+ : '…';
84
+ lines[lastIndex] = safe;
85
+ }
86
+ while (lines.length < maxLines) {
87
+ lines.push('');
88
+ }
89
+ return lines;
90
+ }, [availableWidth]);
51
91
  // Form state
52
92
  const [activeFieldIndex, setActiveFieldIndex] = useState(0);
53
93
  const [formData, setFormData] = useState({});
54
- const [currentInput, setCurrentInput] = useState('');
55
- const [enumIndex, setEnumIndex] = useState(0); // For enum selection
56
- const [arraySelections, setArraySelections] = useState(new Set()); // For array-enum
94
+ const [draftInputs, setDraftInputs] = useState({});
95
+ const [enumIndex, setEnumIndex] = useState(0); // For enum/array-enum focus
96
+ const [arraySelections, setArraySelections] = useState(new Set()); // array-enum
57
97
  const [errors, setErrors] = useState({});
58
- const [isReviewing, setIsReviewing] = useState(false); // Confirmation step before submit
98
+ const [isReviewing, setIsReviewing] = useState(false);
99
+ const [reviewScrollTop, setReviewScrollTop] = useState(0);
59
100
  const activeField = fields[activeFieldIndex];
60
- // Update a field value
101
+ const contentHeight = useMemo(() => {
102
+ const activeHeaderHeight = isReviewing ? reviewHeaderHeight : questionHeaderHeight;
103
+ return Math.max(1, viewportHeight - activeHeaderHeight - footerHeight);
104
+ }, [footerHeight, isReviewing, questionHeaderHeight, reviewHeaderHeight, viewportHeight]);
61
105
  const updateField = useCallback((name, value) => {
62
106
  setFormData((prev) => ({ ...prev, [name]: value }));
63
107
  setErrors((prev) => {
64
- const newErrors = { ...prev };
65
- delete newErrors[name];
66
- return newErrors;
108
+ if (!hasOwn(prev, name))
109
+ return prev;
110
+ const next = { ...prev };
111
+ delete next[name];
112
+ return next;
67
113
  });
68
114
  }, []);
69
- // Validate and enter review mode (or submit if already reviewing)
70
- // Accepts optional currentFieldValue to handle async state update timing
115
+ const goToFieldIndex = useCallback((index, data = formData) => {
116
+ if (index < 0 || index >= fields.length)
117
+ return;
118
+ setActiveFieldIndex(index);
119
+ const field = fields[index];
120
+ if (!field)
121
+ return;
122
+ if (field.type === 'boolean') {
123
+ const currentValue = data[field.name];
124
+ setEnumIndex(currentValue === false ? 1 : 0);
125
+ setArraySelections(new Set());
126
+ return;
127
+ }
128
+ if (field.type === 'enum') {
129
+ const values = field.enumValues ?? [];
130
+ const currentValue = data[field.name];
131
+ const currentIndex = values.findIndex((v) => v === currentValue);
132
+ setEnumIndex(currentIndex >= 0 ? currentIndex : 0);
133
+ setArraySelections(new Set());
134
+ return;
135
+ }
136
+ if (field.type === 'array-enum') {
137
+ const values = field.enumValues ?? [];
138
+ const currentValue = data[field.name];
139
+ const currentValues = Array.isArray(currentValue) ? currentValue : [];
140
+ const selections = new Set();
141
+ for (const selectedValue of currentValues) {
142
+ const selectedIndex = values.findIndex((v) => v === selectedValue);
143
+ if (selectedIndex >= 0)
144
+ selections.add(selectedIndex);
145
+ }
146
+ setArraySelections(selections);
147
+ const firstSelected = selections.values().next().value;
148
+ const maxIndex = Math.max(0, values.length - 1);
149
+ setEnumIndex(Math.min(firstSelected ?? 0, maxIndex));
150
+ return;
151
+ }
152
+ setEnumIndex(0);
153
+ setArraySelections(new Set());
154
+ }, [fields, formData]);
155
+ const nextField = useCallback(() => {
156
+ if (activeFieldIndex < fields.length - 1) {
157
+ goToFieldIndex(activeFieldIndex + 1);
158
+ }
159
+ }, [activeFieldIndex, fields.length, goToFieldIndex]);
160
+ const prevField = useCallback(() => {
161
+ if (activeFieldIndex > 0) {
162
+ goToFieldIndex(activeFieldIndex - 1);
163
+ }
164
+ }, [activeFieldIndex, goToFieldIndex]);
71
165
  const handleSubmit = useCallback((currentFieldValue) => {
72
166
  const newErrors = {};
73
- // Merge current field value since React state update is async
74
167
  const finalFormData = currentFieldValue
75
168
  ? { ...formData, [currentFieldValue.name]: currentFieldValue.value }
76
- : formData;
169
+ : { ...formData };
170
+ // Incorporate draft inputs (wizard UX is forgiving if you navigate without pressing Enter).
171
+ for (const field of fields) {
172
+ if (!hasOwn(draftInputs, field.name))
173
+ continue;
174
+ const rawDraft = draftInputs[field.name] ?? '';
175
+ if (field.type === 'number') {
176
+ const trimmed = rawDraft.trim();
177
+ if (trimmed === '') {
178
+ finalFormData[field.name] = '';
179
+ continue;
180
+ }
181
+ const parsed = Number(trimmed);
182
+ if (Number.isNaN(parsed)) {
183
+ newErrors[field.name] = 'Invalid number';
184
+ continue;
185
+ }
186
+ finalFormData[field.name] = parsed;
187
+ }
188
+ if (field.type === 'string') {
189
+ finalFormData[field.name] = rawDraft;
190
+ }
191
+ }
192
+ // Ensure boolean fields are always present in submitted data.
77
193
  for (const field of fields) {
78
- if (field.required) {
79
- const value = finalFormData[field.name];
80
- if (value === undefined || value === null || value === '') {
194
+ if (!field.required)
195
+ continue;
196
+ const value = finalFormData[field.name];
197
+ if (field.type === 'array-enum') {
198
+ if (!Array.isArray(value) || value.length === 0) {
81
199
  newErrors[field.name] = 'Required';
82
200
  }
201
+ continue;
202
+ }
203
+ if (value === undefined || value === null || value === '') {
204
+ newErrors[field.name] = 'Required';
83
205
  }
84
206
  }
85
207
  if (Object.keys(newErrors).length > 0) {
86
208
  setErrors(newErrors);
87
- // Focus first error field
88
209
  const firstErrorField = fields.findIndex((f) => newErrors[f.name]);
89
210
  if (firstErrorField >= 0) {
90
- setActiveFieldIndex(firstErrorField);
211
+ goToFieldIndex(firstErrorField, finalFormData);
91
212
  }
213
+ setIsReviewing(false);
92
214
  return;
93
215
  }
94
- // Update formData with final value and enter review mode
95
- if (currentFieldValue) {
96
- setFormData(finalFormData);
97
- }
216
+ setFormData(finalFormData);
217
+ setReviewScrollTop(0);
98
218
  setIsReviewing(true);
99
- }, [fields, formData]);
100
- // Final submission after review
219
+ }, [draftInputs, fields, formData, goToFieldIndex]);
101
220
  const confirmSubmit = useCallback(() => {
102
221
  onSubmit(formData);
103
222
  }, [formData, onSubmit]);
104
- // Navigate to next/previous field
105
- const nextField = useCallback(() => {
106
- if (activeFieldIndex < fields.length - 1) {
107
- // Save current input for string/number fields
108
- if (activeField?.type === 'string' || activeField?.type === 'number') {
109
- if (currentInput.trim()) {
110
- const value = activeField.type === 'number' ? Number(currentInput) : currentInput;
111
- updateField(activeField.name, value);
112
- }
113
- }
114
- setActiveFieldIndex((prev) => prev + 1);
115
- setCurrentInput('');
116
- setEnumIndex(0);
117
- setArraySelections(new Set());
118
- }
119
- }, [activeFieldIndex, fields.length, activeField, currentInput, updateField]);
120
- const prevField = useCallback(() => {
121
- if (activeFieldIndex > 0) {
122
- setActiveFieldIndex((prev) => prev - 1);
123
- setCurrentInput('');
124
- setEnumIndex(0);
125
- setArraySelections(new Set());
126
- }
127
- }, [activeFieldIndex]);
128
- // Handle keyboard input
129
223
  useImperativeHandle(ref, () => ({
130
224
  handleInput: (input, key) => {
131
- // Review mode handling
132
225
  if (isReviewing) {
226
+ const maxScrollTop = Math.max(0, fields.length - contentHeight);
133
227
  if (key.return) {
134
228
  confirmSubmit();
135
229
  return true;
136
230
  }
137
- // Backspace to go back to editing
138
231
  if (key.backspace || key.delete) {
139
232
  setIsReviewing(false);
233
+ goToFieldIndex(Math.max(0, fields.length - 1));
234
+ return true;
235
+ }
236
+ if (key.leftArrow || (key.tab && key.shift)) {
237
+ setIsReviewing(false);
238
+ goToFieldIndex(Math.max(0, fields.length - 1));
239
+ return true;
240
+ }
241
+ if (key.upArrow) {
242
+ setReviewScrollTop((prev) => Math.max(0, prev - 1));
243
+ return true;
244
+ }
245
+ if (key.downArrow) {
246
+ setReviewScrollTop((prev) => Math.min(maxScrollTop, prev + 1));
247
+ return true;
248
+ }
249
+ if (key.pageUp) {
250
+ setReviewScrollTop((prev) => Math.max(0, prev - 5));
251
+ return true;
252
+ }
253
+ if (key.pageDown) {
254
+ setReviewScrollTop((prev) => Math.min(maxScrollTop, prev + 5));
140
255
  return true;
141
256
  }
142
- // Esc to cancel entirely
143
257
  if (key.escape) {
144
258
  onCancel();
145
259
  return true;
146
260
  }
147
261
  return false;
148
262
  }
149
- // Escape to cancel
150
263
  if (key.escape) {
151
264
  onCancel();
152
265
  return true;
153
266
  }
154
267
  if (!activeField)
155
268
  return false;
156
- // Shift+Tab or Up to previous field (check BEFORE plain Tab)
157
- if ((key.tab && key.shift) ||
158
- (key.upArrow &&
159
- activeField.type !== 'enum' &&
160
- activeField.type !== 'array-enum')) {
269
+ // Wizard navigation: Left/Right (or Shift+Tab/Tab) changes the question.
270
+ if (key.leftArrow || (key.tab && key.shift)) {
161
271
  prevField();
162
272
  return true;
163
273
  }
164
- // Tab (without Shift) or Down to next field
165
- if ((key.tab && !key.shift) ||
166
- (key.downArrow &&
167
- activeField.type !== 'enum' &&
168
- activeField.type !== 'array-enum')) {
169
- nextField();
274
+ if (key.rightArrow || (key.tab && !key.shift)) {
275
+ if (activeFieldIndex === fields.length - 1) {
276
+ handleSubmit();
277
+ }
278
+ else {
279
+ nextField();
280
+ }
170
281
  return true;
171
282
  }
172
- // Field-specific handling
283
+ const setDraftValue = (updater) => {
284
+ const hadError = hasOwn(errors, activeField.name);
285
+ setDraftInputs((prev) => {
286
+ const current = hasOwn(prev, activeField.name)
287
+ ? (prev[activeField.name] ?? '')
288
+ : (() => {
289
+ const existing = formData[activeField.name];
290
+ return existing === undefined || existing === null
291
+ ? ''
292
+ : String(existing);
293
+ })();
294
+ return { ...prev, [activeField.name]: updater(current) };
295
+ });
296
+ if (hadError) {
297
+ setErrors((prevErrors) => {
298
+ const nextErrors = { ...prevErrors };
299
+ delete nextErrors[activeField.name];
300
+ return nextErrors;
301
+ });
302
+ }
303
+ };
173
304
  switch (activeField.type) {
174
305
  case 'boolean': {
175
- // Space or Enter to toggle
176
- if (input === ' ' || key.return) {
177
- const current = formData[activeField.name] === true;
178
- const newValue = !current;
179
- updateField(activeField.name, newValue);
180
- if (key.return) {
181
- if (activeFieldIndex === fields.length - 1) {
182
- handleSubmit({ name: activeField.name, value: newValue });
183
- }
184
- else {
185
- nextField();
186
- }
187
- }
306
+ if (key.upArrow) {
307
+ setEnumIndex((prev) => clamp(prev - 1, 0, 1));
308
+ return true;
309
+ }
310
+ if (key.downArrow) {
311
+ setEnumIndex((prev) => clamp(prev + 1, 0, 1));
188
312
  return true;
189
313
  }
190
- // Left/Right to toggle
191
- if (key.leftArrow || key.rightArrow) {
192
- const current = formData[activeField.name] === true;
193
- updateField(activeField.name, !current);
314
+ if (input === ' ') {
315
+ setEnumIndex((prev) => (prev === 0 ? 1 : 0));
316
+ return true;
317
+ }
318
+ if (key.return) {
319
+ const nextValue = enumIndex === 0;
320
+ updateField(activeField.name, nextValue);
321
+ if (activeFieldIndex === fields.length - 1) {
322
+ handleSubmit({ name: activeField.name, value: nextValue });
323
+ }
324
+ else {
325
+ nextField();
326
+ }
194
327
  return true;
195
328
  }
196
329
  break;
197
330
  }
198
331
  case 'enum': {
199
332
  const values = activeField.enumValues || [];
200
- // Up/Down to navigate enum
201
333
  if (key.upArrow) {
202
334
  setEnumIndex((prev) => (prev > 0 ? prev - 1 : values.length - 1));
203
335
  return true;
@@ -206,7 +338,6 @@ export const ElicitationForm = forwardRef(({ metadata, onSubmit, onCancel }, ref
206
338
  setEnumIndex((prev) => (prev < values.length - 1 ? prev + 1 : 0));
207
339
  return true;
208
340
  }
209
- // Enter to select and move to next (or submit if last)
210
341
  if (key.return) {
211
342
  const selectedValue = values[enumIndex];
212
343
  updateField(activeField.name, selectedValue);
@@ -222,7 +353,6 @@ export const ElicitationForm = forwardRef(({ metadata, onSubmit, onCancel }, ref
222
353
  }
223
354
  case 'array-enum': {
224
355
  const values = activeField.enumValues || [];
225
- // Up/Down to navigate
226
356
  if (key.upArrow) {
227
357
  setEnumIndex((prev) => (prev > 0 ? prev - 1 : values.length - 1));
228
358
  return true;
@@ -231,26 +361,18 @@ export const ElicitationForm = forwardRef(({ metadata, onSubmit, onCancel }, ref
231
361
  setEnumIndex((prev) => (prev < values.length - 1 ? prev + 1 : 0));
232
362
  return true;
233
363
  }
234
- // Space to toggle selection
235
364
  if (input === ' ') {
236
- setArraySelections((prev) => {
237
- const next = new Set(prev);
238
- if (next.has(enumIndex)) {
239
- next.delete(enumIndex);
240
- }
241
- else {
242
- next.add(enumIndex);
243
- }
244
- // Update form data
245
- const selected = Array.from(next).map((i) => values[i]);
246
- updateField(activeField.name, selected);
247
- return next;
248
- });
365
+ const nextSelections = new Set(arraySelections);
366
+ if (nextSelections.has(enumIndex))
367
+ nextSelections.delete(enumIndex);
368
+ else
369
+ nextSelections.add(enumIndex);
370
+ setArraySelections(nextSelections);
371
+ const selected = Array.from(nextSelections).map((i) => values[i]);
372
+ updateField(activeField.name, selected);
249
373
  return true;
250
374
  }
251
- // Enter to confirm and move to next (or submit if last)
252
375
  if (key.return) {
253
- // Get current selections for submit
254
376
  const selected = Array.from(arraySelections).map((i) => values[i]);
255
377
  if (activeFieldIndex === fields.length - 1) {
256
378
  handleSubmit({ name: activeField.name, value: selected });
@@ -264,18 +386,40 @@ export const ElicitationForm = forwardRef(({ metadata, onSubmit, onCancel }, ref
264
386
  }
265
387
  case 'string':
266
388
  case 'number': {
267
- // Enter to confirm field and move to next (or submit if last)
389
+ const hasDraft = hasOwn(draftInputs, activeField.name);
390
+ const rawInput = hasDraft
391
+ ? (draftInputs[activeField.name] ?? '')
392
+ : (() => {
393
+ const existing = formData[activeField.name];
394
+ return existing === undefined || existing === null
395
+ ? ''
396
+ : String(existing);
397
+ })();
268
398
  if (key.return) {
269
- const value = currentInput.trim()
270
- ? activeField.type === 'number'
271
- ? Number(currentInput)
272
- : currentInput
273
- : formData[activeField.name]; // Use existing value if no new input
274
- if (currentInput.trim()) {
399
+ let value = hasDraft
400
+ ? rawInput
401
+ : formData[activeField.name];
402
+ if (activeField.type === 'number' && hasDraft) {
403
+ const trimmed = rawInput.trim();
404
+ if (trimmed === '') {
405
+ value = '';
406
+ }
407
+ else {
408
+ const parsed = Number(trimmed);
409
+ if (Number.isNaN(parsed)) {
410
+ setErrors((prev) => ({
411
+ ...prev,
412
+ [activeField.name]: 'Invalid number',
413
+ }));
414
+ return true;
415
+ }
416
+ value = parsed;
417
+ }
418
+ }
419
+ if (hasDraft) {
275
420
  updateField(activeField.name, value);
276
421
  }
277
422
  if (activeFieldIndex === fields.length - 1) {
278
- // Last field - submit with current value
279
423
  handleSubmit(value !== undefined
280
424
  ? { name: activeField.name, value }
281
425
  : undefined);
@@ -285,21 +429,20 @@ export const ElicitationForm = forwardRef(({ metadata, onSubmit, onCancel }, ref
285
429
  }
286
430
  return true;
287
431
  }
288
- // Backspace
289
432
  if (key.backspace || key.delete) {
290
- setCurrentInput((prev) => prev.slice(0, -1));
433
+ setDraftValue((prev) => prev.slice(0, -1));
291
434
  return true;
292
435
  }
293
- // Regular character input
294
436
  if (input && !key.ctrl && !key.meta) {
295
- // For number type, only allow digits and decimal
296
437
  if (activeField.type === 'number') {
297
- if (/^[\d.-]$/.test(input)) {
298
- setCurrentInput((prev) => prev + input);
438
+ // Accept either single-key entry or paste: filter to allowed chars.
439
+ const filtered = input.replace(/[^\d.-]/g, '');
440
+ if (filtered.length > 0) {
441
+ setDraftValue((prev) => prev + filtered);
299
442
  }
300
443
  }
301
444
  else {
302
- setCurrentInput((prev) => prev + input);
445
+ setDraftValue((prev) => prev + input);
303
446
  }
304
447
  return true;
305
448
  }
@@ -312,47 +455,138 @@ export const ElicitationForm = forwardRef(({ metadata, onSubmit, onCancel }, ref
312
455
  activeField,
313
456
  activeFieldIndex,
314
457
  arraySelections,
458
+ contentHeight,
315
459
  confirmSubmit,
316
- currentInput,
460
+ draftInputs,
317
461
  enumIndex,
318
- fields.length,
462
+ errors,
463
+ fields,
319
464
  formData,
320
465
  handleSubmit,
321
466
  isReviewing,
467
+ goToFieldIndex,
322
468
  nextField,
323
469
  onCancel,
324
470
  prevField,
471
+ reviewScrollTop,
325
472
  updateField,
326
473
  ]);
327
474
  if (fields.length === 0) {
328
475
  return (_jsx(Box, { flexDirection: "column", paddingX: 1, children: _jsx(Text, { color: "red", children: "Invalid form schema" }) }));
329
476
  }
330
- const prompt = metadata.prompt;
331
- // Review mode - show summary of choices
332
- if (isReviewing) {
333
- return (_jsxs(Box, { flexDirection: "column", paddingX: 0, paddingY: 0, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "green", bold: true, children: "\u2713 Review your answers:" }) }), fields.map((field) => {
334
- const value = formData[field.name];
335
- const displayValue = Array.isArray(value)
336
- ? value.join(', ')
337
- : value === true
338
- ? 'Yes'
339
- : value === false
340
- ? 'No'
341
- : String(value ?? '');
342
- return (_jsx(Box, { marginBottom: 0, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: field.label }), _jsx(Text, { children: ": " }), _jsx(Text, { color: "green", children: displayValue })] }) }, field.name));
343
- }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "Enter to submit \u2022 Backspace to edit \u2022 Esc to cancel" }) })] }));
477
+ const isAnswered = (field) => {
478
+ if (field.type === 'boolean')
479
+ return hasOwn(formData, field.name);
480
+ if (field.type === 'enum')
481
+ return hasOwn(formData, field.name);
482
+ if (field.type === 'array-enum') {
483
+ const value = formData[field.name];
484
+ return Array.isArray(value) && value.length > 0;
485
+ }
486
+ const draft = draftInputs[field.name];
487
+ if (typeof draft === 'string' && draft.trim() !== '')
488
+ return true;
489
+ const value = formData[field.name];
490
+ return value !== undefined && value !== null && value !== '';
491
+ };
492
+ if (!isReviewing && !activeField) {
493
+ return (_jsx(Box, { flexDirection: "column", paddingX: 1, children: _jsx(Text, { color: "red", children: "Invalid form state" }) }));
344
494
  }
345
- return (_jsxs(Box, { flexDirection: "column", paddingX: 0, paddingY: 0, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "yellowBright", bold: true, children: ["\uD83D\uDCDD ", prompt] }) }), fields.map((field, index) => {
346
- const isActive = index === activeFieldIndex;
347
- const value = formData[field.name];
348
- const error = errors[field.name];
349
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: isActive ? 'cyan' : 'white', bold: isActive, children: [isActive ? '▶ ' : ' ', field.label, field.required && _jsx(Text, { color: "red", children: "*" }), ': '] }), field.type === 'boolean' && (_jsxs(Text, { color: value === true ? 'green' : 'gray', children: [value === true ? '[✓] Yes' : '[ ] No', isActive && _jsx(Text, { color: "gray", children: " (Space to toggle)" })] })), field.type === 'string' && !isActive && value !== undefined && (_jsx(Text, { color: "green", children: String(value) })), field.type === 'number' && !isActive && value !== undefined && (_jsx(Text, { color: "green", children: String(value) })), field.type === 'enum' && !isActive && value !== undefined && (_jsx(Text, { color: "green", children: String(value) })), field.type === 'array-enum' &&
350
- !isActive &&
351
- Array.isArray(value) &&
352
- value.length > 0 && (_jsx(Text, { color: "green", children: value.join(', ') }))] }), isActive && (field.type === 'string' || field.type === 'number') && (_jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { color: "cyan", children: "> " }), _jsx(Text, { children: currentInput }), _jsx(Text, { color: "cyan", children: "_" })] })), isActive && field.type === 'enum' && field.enumValues && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: field.enumValues.map((opt, i) => (_jsx(Box, { children: _jsxs(Text, { color: i === enumIndex ? 'green' : 'gray', children: [i === enumIndex ? ' ▶ ' : ' ', String(opt)] }) }, String(opt)))) })), isActive && field.type === 'array-enum' && field.enumValues && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsx(Text, { color: "gray", children: " (Space to select, Enter to confirm)" }), field.enumValues.map((opt, i) => {
353
- const isSelected = arraySelections.has(i);
354
- return (_jsx(Box, { children: _jsxs(Text, { color: i === enumIndex ? 'cyan' : 'gray', children: [i === enumIndex ? ' ▶ ' : ' ', _jsx(Text, { color: isSelected ? 'green' : 'gray', children: isSelected ? '[✓]' : '[ ]' }), ' ', String(opt)] }) }, String(opt)));
355
- })] })), isActive && field.description && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "gray", children: field.description }) })), error && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "red", children: error }) }))] }, field.name));
356
- }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "Tab/\u2193 next field \u2022 Shift+Tab/\u2191 prev \u2022 Enter to confirm \u2022 Esc to cancel" }) })] }));
495
+ const value = activeField ? formData[activeField.name] : undefined;
496
+ const currentInput = activeField && hasOwn(draftInputs, activeField.name)
497
+ ? (draftInputs[activeField.name] ?? '')
498
+ : value === undefined || value === null
499
+ ? ''
500
+ : String(value);
501
+ const errorText = activeField ? (errors[activeField.name] ?? '') : '';
502
+ const renderContent = () => {
503
+ if (isReviewing) {
504
+ return (_jsx(Box, { overflowY: "scroll", overflowX: "hidden", scrollTop: reviewScrollTop, scrollbarThumbColor: "gray", flexDirection: "column", height: contentHeight, paddingRight: 1, children: fields.map((field) => (_jsxs(Text, { wrap: "truncate-end", children: [field.question, ": ", getDisplayValue(formData[field.name])] }, field.name))) }));
505
+ }
506
+ if (!activeField)
507
+ return null;
508
+ if (activeField.type === 'string' || activeField.type === 'number') {
509
+ return (_jsx(Box, { height: contentHeight, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: "> " }), currentInput || _jsx(Text, { color: "gray", children: "Type your answer\u2026" }), _jsx(Text, { color: "cyan", children: "\u258B" })] }) }));
510
+ }
511
+ const options = [];
512
+ if (activeField.type === 'boolean') {
513
+ options.push({
514
+ key: 'yes',
515
+ label: 'Yes',
516
+ isFocused: enumIndex === 0,
517
+ isSelected: false,
518
+ });
519
+ options.push({
520
+ key: 'no',
521
+ label: 'No',
522
+ isFocused: enumIndex === 1,
523
+ isSelected: false,
524
+ });
525
+ }
526
+ if ((activeField.type === 'enum' || activeField.type === 'array-enum') &&
527
+ activeField.enumValues) {
528
+ activeField.enumValues.forEach((opt, i) => {
529
+ const isFocused = i === enumIndex;
530
+ const isSelected = activeField.type === 'enum' ? false : arraySelections.has(i);
531
+ options.push({
532
+ key: `${String(opt)}-${i}`,
533
+ label: String(opt),
534
+ isFocused,
535
+ isSelected,
536
+ });
537
+ });
538
+ }
539
+ const focusedIndex = Math.max(0, options.findIndex((o) => o.isFocused));
540
+ const maxScrollTop = Math.max(0, options.length - contentHeight);
541
+ const targetScrollTop = clamp(focusedIndex - Math.floor(contentHeight / 2), 0, maxScrollTop);
542
+ return (_jsx(Box, { overflowY: "scroll", overflowX: "hidden", scrollbarThumbColor: "gray", height: contentHeight, scrollTop: targetScrollTop, flexDirection: "column", paddingRight: 1, children: options.map((opt) => {
543
+ const prefix = opt.isFocused ? '▶ ' : ' ';
544
+ const mark = activeField.type === 'array-enum'
545
+ ? opt.isSelected
546
+ ? '[✓] '
547
+ : '[ ] '
548
+ : '';
549
+ return (_jsxs(Text, { color: opt.isFocused ? 'cyan' : 'gray', wrap: "truncate-end", children: [prefix, mark, opt.label] }, opt.key));
550
+ }) }));
551
+ };
552
+ const hintLine = (() => {
553
+ if (isReviewing)
554
+ return 'Enter submit • Backspace/← edit • ↑↓ scroll • Esc cancel';
555
+ switch (activeField?.type) {
556
+ case 'string':
557
+ case 'number':
558
+ return 'Type to answer • Enter next • ←/→ question • Esc cancel';
559
+ case 'array-enum':
560
+ return '↑/↓ option • Space toggle • Enter next • ←/→ question • Esc cancel';
561
+ default:
562
+ return '↑/↓ option • Enter select • ←/→ question • Esc cancel';
563
+ }
564
+ })();
565
+ const headerText = isReviewing
566
+ ? '📝 Review your answers'
567
+ : `📝 Please answer ${fields.length === 1 ? 'this' : 'these'} ${fields.length} ${fields.length === 1 ? 'question' : 'questions'}.`;
568
+ const stepText = (() => {
569
+ if (isReviewing) {
570
+ return '';
571
+ }
572
+ if (!activeField)
573
+ return '';
574
+ return `Question ${activeFieldIndex + 1}/${fields.length}: ${activeField.stepLabel}`;
575
+ })();
576
+ const questionText = !isReviewing && activeField
577
+ ? `${activeField.question}${activeField.required ? '*' : ''}`
578
+ : '';
579
+ const helpText = !isReviewing && activeField?.helpText ? activeField.helpText : '';
580
+ const errorLineText = !isReviewing ? errorText : '';
581
+ const headerLines = wrapClampedLines(headerText, headerLineCount);
582
+ const stepLines = wrapClampedLines(stepText, stepHeaderLineCount);
583
+ const questionLines = wrapClampedLines(questionText, questionLineCount);
584
+ const helpLines = wrapClampedLines(helpText, helpLineCount);
585
+ const errorLines = wrapClampedLines(errorLineText, errorLineCount);
586
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 0, height: viewportHeight, children: [headerLines.map((line, index) => (_jsx(Text, { color: "yellowBright", bold: true, wrap: "truncate-end", children: line || ' ' }, `header-${index}`))), !isReviewing &&
587
+ stepLines.map((line, index) => (_jsx(Text, { color: "gray", dimColor: true, wrap: "truncate-end", children: line || ' ' }, `step-${index}`))), Array.from({ length: spacerAfterStepLineCount }, (_, index) => (_jsx(Text, { wrap: "truncate-end", children: ' ' }, `spacer-step-${index}`))), !isReviewing &&
588
+ questionLines.map((line, index) => (_jsx(Text, { color: "white", bold: true, wrap: "truncate-end", children: line || ' ' }, `question-${index}`))), !isReviewing &&
589
+ helpLines.map((line, index) => (_jsx(Text, { color: "gray", dimColor: true, wrap: "truncate-end", children: line || ' ' }, `help-${index}`))), !isReviewing &&
590
+ errorLines.map((line, index) => (_jsx(Text, { color: "red", wrap: "truncate-end", children: line || ' ' }, `error-${index}`))), renderContent(), _jsx(Text, { color: "gray", dimColor: true, wrap: "truncate-end", children: hintLine })] }));
357
591
  });
358
592
  ElicitationForm.displayName = 'ElicitationForm';