@namzu/cli 14.3.0 → 15.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 (149) hide show
  1. package/README.md +117 -17
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +26 -26
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/acp.d.ts.map +1 -1
  6. package/dist/commands/acp.js +3 -2
  7. package/dist/commands/acp.js.map +1 -1
  8. package/dist/commands/doctor.d.ts.map +1 -1
  9. package/dist/commands/doctor.js +2 -0
  10. package/dist/commands/doctor.js.map +1 -1
  11. package/dist/commands/login.d.ts +2 -0
  12. package/dist/commands/login.d.ts.map +1 -1
  13. package/dist/commands/login.js +45 -7
  14. package/dist/commands/login.js.map +1 -1
  15. package/dist/commands/run.d.ts.map +1 -1
  16. package/dist/commands/run.js +36 -12
  17. package/dist/commands/run.js.map +1 -1
  18. package/dist/commands/{stubs.d.ts → serve.d.ts} +1 -10
  19. package/dist/commands/serve.d.ts.map +1 -0
  20. package/dist/commands/{stubs.js → serve.js} +1 -24
  21. package/dist/commands/serve.js.map +1 -0
  22. package/dist/commands/skills.d.ts +19 -0
  23. package/dist/commands/skills.d.ts.map +1 -0
  24. package/dist/commands/skills.js +107 -0
  25. package/dist/commands/skills.js.map +1 -0
  26. package/dist/integrations/mcp/servers.d.ts +7 -0
  27. package/dist/integrations/mcp/servers.d.ts.map +1 -1
  28. package/dist/integrations/mcp/servers.js +33 -8
  29. package/dist/integrations/mcp/servers.js.map +1 -1
  30. package/dist/integrations/sessions/store.d.ts +8 -0
  31. package/dist/integrations/sessions/store.d.ts.map +1 -1
  32. package/dist/integrations/sessions/store.js +15 -0
  33. package/dist/integrations/sessions/store.js.map +1 -1
  34. package/dist/integrations/subagents/runtime.d.ts +2 -0
  35. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  36. package/dist/integrations/subagents/runtime.js +86 -13
  37. package/dist/integrations/subagents/runtime.js.map +1 -1
  38. package/dist/integrations/updates.d.ts.map +1 -1
  39. package/dist/integrations/updates.js +29 -4
  40. package/dist/integrations/updates.js.map +1 -1
  41. package/dist/tui/App.d.ts +10 -1
  42. package/dist/tui/App.d.ts.map +1 -1
  43. package/dist/tui/App.js +1039 -252
  44. package/dist/tui/App.js.map +1 -1
  45. package/dist/tui/ChoicePicker.d.ts +2 -1
  46. package/dist/tui/ChoicePicker.d.ts.map +1 -1
  47. package/dist/tui/ChoicePicker.js +10 -6
  48. package/dist/tui/ChoicePicker.js.map +1 -1
  49. package/dist/tui/Composer.d.ts +19 -2
  50. package/dist/tui/Composer.d.ts.map +1 -1
  51. package/dist/tui/Composer.js +526 -70
  52. package/dist/tui/Composer.js.map +1 -1
  53. package/dist/tui/CopyPicker.d.ts.map +1 -1
  54. package/dist/tui/CopyPicker.js +3 -4
  55. package/dist/tui/CopyPicker.js.map +1 -1
  56. package/dist/tui/EditPromptPicker.d.ts.map +1 -1
  57. package/dist/tui/EditPromptPicker.js +3 -4
  58. package/dist/tui/EditPromptPicker.js.map +1 -1
  59. package/dist/tui/Markdown.d.ts +3 -1
  60. package/dist/tui/Markdown.d.ts.map +1 -1
  61. package/dist/tui/Markdown.js +11 -9
  62. package/dist/tui/Markdown.js.map +1 -1
  63. package/dist/tui/PermissionOverlay.d.ts +11 -6
  64. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  65. package/dist/tui/PermissionOverlay.js +13 -20
  66. package/dist/tui/PermissionOverlay.js.map +1 -1
  67. package/dist/tui/Picker.d.ts.map +1 -1
  68. package/dist/tui/Picker.js +47 -11
  69. package/dist/tui/Picker.js.map +1 -1
  70. package/dist/tui/ResumePicker.d.ts.map +1 -1
  71. package/dist/tui/ResumePicker.js +6 -1
  72. package/dist/tui/ResumePicker.js.map +1 -1
  73. package/dist/tui/StatusBar.js +3 -3
  74. package/dist/tui/StatusBar.js.map +1 -1
  75. package/dist/tui/TextPrompt.d.ts +21 -0
  76. package/dist/tui/TextPrompt.d.ts.map +1 -0
  77. package/dist/tui/TextPrompt.js +139 -0
  78. package/dist/tui/TextPrompt.js.map +1 -0
  79. package/dist/tui/Transcript.d.ts +9 -10
  80. package/dist/tui/Transcript.d.ts.map +1 -1
  81. package/dist/tui/Transcript.js +11 -12
  82. package/dist/tui/Transcript.js.map +1 -1
  83. package/dist/tui/agent.d.ts +57 -23
  84. package/dist/tui/agent.d.ts.map +1 -1
  85. package/dist/tui/agent.js +136 -86
  86. package/dist/tui/agent.js.map +1 -1
  87. package/dist/tui/exit-summary.d.ts.map +1 -1
  88. package/dist/tui/exit-summary.js +2 -1
  89. package/dist/tui/exit-summary.js.map +1 -1
  90. package/dist/tui/external-editor.d.ts +25 -0
  91. package/dist/tui/external-editor.d.ts.map +1 -0
  92. package/dist/tui/external-editor.js +169 -0
  93. package/dist/tui/external-editor.js.map +1 -0
  94. package/dist/tui/index.d.ts.map +1 -1
  95. package/dist/tui/index.js +4 -0
  96. package/dist/tui/index.js.map +1 -1
  97. package/dist/tui/live-window.d.ts +17 -11
  98. package/dist/tui/live-window.d.ts.map +1 -1
  99. package/dist/tui/live-window.js +64 -20
  100. package/dist/tui/live-window.js.map +1 -1
  101. package/dist/tui/login-prompt.d.ts +2 -0
  102. package/dist/tui/login-prompt.d.ts.map +1 -1
  103. package/dist/tui/login-prompt.js +8 -1
  104. package/dist/tui/login-prompt.js.map +1 -1
  105. package/dist/tui/mentions.d.ts +19 -0
  106. package/dist/tui/mentions.d.ts.map +1 -1
  107. package/dist/tui/mentions.js +96 -5
  108. package/dist/tui/mentions.js.map +1 -1
  109. package/dist/tui/permission-review.d.ts +59 -0
  110. package/dist/tui/permission-review.d.ts.map +1 -0
  111. package/dist/tui/permission-review.js +219 -0
  112. package/dist/tui/permission-review.js.map +1 -0
  113. package/dist/tui/run-interruption.d.ts +14 -0
  114. package/dist/tui/run-interruption.d.ts.map +1 -0
  115. package/dist/tui/run-interruption.js +67 -0
  116. package/dist/tui/run-interruption.js.map +1 -0
  117. package/dist/tui/selection-window.d.ts +18 -0
  118. package/dist/tui/selection-window.d.ts.map +1 -0
  119. package/dist/tui/selection-window.js +37 -0
  120. package/dist/tui/selection-window.js.map +1 -0
  121. package/dist/tui/slashCommands.d.ts +48 -8
  122. package/dist/tui/slashCommands.d.ts.map +1 -1
  123. package/dist/tui/slashCommands.js +129 -47
  124. package/dist/tui/slashCommands.js.map +1 -1
  125. package/dist/tui/terminal-hyperlinks.d.ts +20 -0
  126. package/dist/tui/terminal-hyperlinks.d.ts.map +1 -0
  127. package/dist/tui/terminal-hyperlinks.js +70 -0
  128. package/dist/tui/terminal-hyperlinks.js.map +1 -0
  129. package/dist/tui/types.d.ts +2 -0
  130. package/dist/tui/types.d.ts.map +1 -1
  131. package/dist/tui/use-selection-index.d.ts +8 -0
  132. package/dist/tui/use-selection-index.d.ts.map +1 -0
  133. package/dist/tui/use-selection-index.js +13 -0
  134. package/dist/tui/use-selection-index.js.map +1 -0
  135. package/dist/tui/workspace-review.d.ts +15 -0
  136. package/dist/tui/workspace-review.d.ts.map +1 -0
  137. package/dist/tui/workspace-review.js +67 -0
  138. package/dist/tui/workspace-review.js.map +1 -0
  139. package/dist/version.d.ts +10 -0
  140. package/dist/version.d.ts.map +1 -0
  141. package/dist/version.js +23 -0
  142. package/dist/version.js.map +1 -0
  143. package/package.json +8 -7
  144. package/dist/commands/stubs.d.ts.map +0 -1
  145. package/dist/commands/stubs.js.map +0 -1
  146. package/dist/tui/bottom-spacer.d.ts +0 -95
  147. package/dist/tui/bottom-spacer.d.ts.map +0 -1
  148. package/dist/tui/bottom-spacer.js +0 -83
  149. package/dist/tui/bottom-spacer.js.map +0 -1
@@ -1,42 +1,147 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text, useInput } from 'ink';
2
+ import { Box, Text, useInput, useWindowSize } from 'ink';
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
4
  import { readClipboardImage } from '../integrations/clipboard/image.js';
5
+ import { activeFileMention, matchMentionableFiles } from './mentions.js';
5
6
  import { matchSlashCommands } from './slashCommands.js';
6
7
  import { terminalDisplayText } from './terminal-display.js';
7
8
  import { theme } from './theme.js';
8
- const MAX_SUGGESTIONS = 6;
9
+ const MIN_SUGGESTIONS = 6;
10
+ const MAX_SUGGESTIONS = 12;
11
+ const SUGGESTION_FURNITURE_ROWS = 12;
9
12
  // A single keypress longer than this (with no newline) is treated as a paste.
10
13
  const PASTE_THRESHOLD = 80;
11
14
  // A recalled or branch-restored prompt can be much larger than anything the
12
15
  // operator typed one key at a time. Keep the exact source in state, but never
13
- // hand an unbounded string to Ink's wrapping engine. Eight logical tail lines
14
- // preserve the only edit position this composer exposes: the cursor at the end.
16
+ // hand an unbounded string to Ink's wrapping engine. The bounded view follows
17
+ // the real cursor rather than assuming every edit happens at the end.
15
18
  const COMPOSER_DISPLAY_CODE_UNITS = 2048;
16
19
  const COMPOSER_DISPLAY_LINES = 8;
17
- function composerDisplayValue(source) {
18
- let start = Math.max(0, source.length - COMPOSER_DISPLAY_CODE_UNITS);
19
- // Do not begin the view with the low half of a split surrogate pair. Dropping
20
- // that one half is display-only; the complete pair stays in `source`.
21
- if (start > 0 &&
22
- source.charCodeAt(start) >= 0xdc00 &&
23
- source.charCodeAt(start) <= 0xdfff &&
24
- source.charCodeAt(start - 1) >= 0xd800 &&
25
- source.charCodeAt(start - 1) <= 0xdbff) {
26
- start += 1;
20
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
21
+ /**
22
+ * Visible rows in a completion menu for this terminal height.
23
+ *
24
+ * Each option is budgeted as two rows because a description may wrap. The
25
+ * composer, borders, footer and status area retain twelve rows before the
26
+ * menu grows. A short/unknown terminal keeps the established six-row window;
27
+ * a tall terminal uses the space it actually has, capped so the menu never
28
+ * takes over the whole conversation view.
29
+ */
30
+ export function suggestionWindowSize(rows) {
31
+ if (rows === undefined || !Number.isFinite(rows))
32
+ return MIN_SUGGESTIONS;
33
+ return Math.min(MAX_SUGGESTIONS, Math.max(MIN_SUGGESTIONS, Math.floor((rows - SUGGESTION_FURNITURE_ROWS) / 2)));
34
+ }
35
+ function graphemeBoundaries(source) {
36
+ const boundaries = [0];
37
+ for (const segment of graphemeSegmenter.segment(source)) {
38
+ const end = segment.index + segment.segment.length;
39
+ if (end > (boundaries[boundaries.length - 1] ?? -1))
40
+ boundaries.push(end);
41
+ }
42
+ return boundaries;
43
+ }
44
+ function previousGraphemeBoundary(source, cursor) {
45
+ let previous = 0;
46
+ for (const boundary of graphemeBoundaries(source)) {
47
+ if (boundary >= cursor)
48
+ break;
49
+ previous = boundary;
50
+ }
51
+ return previous;
52
+ }
53
+ function nextGraphemeBoundary(source, cursor) {
54
+ for (const boundary of graphemeBoundaries(source)) {
55
+ if (boundary > cursor)
56
+ return boundary;
57
+ }
58
+ return source.length;
59
+ }
60
+ function lineStart(source, cursor) {
61
+ return cursor <= 0 ? 0 : source.lastIndexOf('\n', cursor - 1) + 1;
62
+ }
63
+ function lineEnd(source, cursor) {
64
+ const newline = source.indexOf('\n', cursor);
65
+ return newline < 0 ? source.length : newline;
66
+ }
67
+ function graphemeColumn(source, start, cursor) {
68
+ let column = 0;
69
+ for (const _segment of graphemeSegmenter.segment(source.slice(start, cursor)))
70
+ column += 1;
71
+ return column;
72
+ }
73
+ function cursorAtGraphemeColumn(source, start, end, column) {
74
+ let current = 0;
75
+ for (const segment of graphemeSegmenter.segment(source.slice(start, end))) {
76
+ if (current === column)
77
+ return start + segment.index;
78
+ current += 1;
79
+ }
80
+ return end;
81
+ }
82
+ function verticalCursor(source, cursor, direction, preferredColumn) {
83
+ const currentStart = lineStart(source, cursor);
84
+ const currentEnd = lineEnd(source, cursor);
85
+ const column = preferredColumn ?? graphemeColumn(source, currentStart, cursor);
86
+ if (direction === -1) {
87
+ if (currentStart === 0)
88
+ return null;
89
+ const previousEnd = currentStart - 1;
90
+ const previousStart = lineStart(source, previousEnd);
91
+ return {
92
+ cursor: cursorAtGraphemeColumn(source, previousStart, previousEnd, column),
93
+ column,
94
+ };
95
+ }
96
+ if (currentEnd === source.length)
97
+ return null;
98
+ const nextStart = currentEnd + 1;
99
+ return {
100
+ cursor: cursorAtGraphemeColumn(source, nextStart, lineEnd(source, nextStart), column),
101
+ column,
102
+ };
103
+ }
104
+ function composerDisplayValue(source, cursor) {
105
+ let start = Math.max(0, cursor - Math.floor(COMPOSER_DISPLAY_CODE_UNITS / 2));
106
+ let end = Math.min(source.length, start + COMPOSER_DISPLAY_CODE_UNITS);
107
+ if (end === source.length)
108
+ start = Math.max(0, end - COMPOSER_DISPLAY_CODE_UNITS);
109
+ if (cursor > end) {
110
+ end = cursor;
111
+ start = Math.max(0, end - COMPOSER_DISPLAY_CODE_UNITS);
27
112
  }
28
- let newlineCount = 0;
29
- for (let index = source.length - 1; index >= start; index -= 1) {
113
+ let afterBreaks = 0;
114
+ for (let index = cursor; index < end; index += 1) {
30
115
  if (source.charCodeAt(index) !== 0x0a)
31
116
  continue;
32
- newlineCount += 1;
33
- if (newlineCount === COMPOSER_DISPLAY_LINES) {
117
+ afterBreaks += 1;
118
+ if (afterBreaks === Math.min(3, COMPOSER_DISPLAY_LINES - 1)) {
119
+ end = index;
120
+ break;
121
+ }
122
+ }
123
+ let beforeBreaks = 0;
124
+ const beforeBudget = COMPOSER_DISPLAY_LINES - afterBreaks;
125
+ for (let index = cursor - 1; index >= start; index -= 1) {
126
+ if (source.charCodeAt(index) !== 0x0a)
127
+ continue;
128
+ beforeBreaks += 1;
129
+ if (beforeBreaks === beforeBudget) {
34
130
  start = index + 1;
35
131
  break;
36
132
  }
37
133
  }
38
- const visible = terminalDisplayText(source.slice(start));
39
- return start > 0 ? `… ${visible}` : visible;
134
+ // Only the view is rounded to complete grapheme boundaries. The exact source
135
+ // remains untouched even when the cap lands inside a combining sequence.
136
+ const boundaries = graphemeBoundaries(source);
137
+ start = boundaries.find((boundary) => boundary >= start) ?? source.length;
138
+ end = [...boundaries].reverse().find((boundary) => boundary <= end) ?? 0;
139
+ return {
140
+ before: terminalDisplayText(source.slice(start, cursor)),
141
+ after: terminalDisplayText(source.slice(cursor, end)),
142
+ leadingEllipsis: start > 0,
143
+ trailingEllipsis: end < source.length,
144
+ };
40
145
  }
41
146
  /**
42
147
  * Terminal Ctrl+W / Unix word-rubout semantics.
@@ -46,17 +151,63 @@ function composerDisplayValue(source) {
46
151
  * operators already bring to this key.
47
152
  */
48
153
  export function deletePreviousWord(source) {
49
- let index = source.length;
50
- while (index > 0 && /\s/u.test(source[index - 1] ?? ''))
51
- index -= 1;
52
- while (index > 0 && !/\s/u.test(source[index - 1] ?? ''))
53
- index -= 1;
54
- return source.slice(0, index);
154
+ return deletePreviousWordAt(source, source.length).value;
55
155
  }
56
- export function Composer({ disabled = false, onSubmit, history, userCommands = [], hidden = false, escapeInterrupts = false, onNotice, onEditPrevious, draftToRestore = null, onDraftRestored, }) {
57
- const [value, setValue] = useState('');
58
- const [historyIndex, setHistoryIndex] = useState(-1);
156
+ function previousWordBoundary(source, cursor) {
157
+ let start = cursor;
158
+ while (start > 0) {
159
+ const previous = previousGraphemeBoundary(source, start);
160
+ if (!/\s/u.test(source.slice(previous, start)))
161
+ break;
162
+ start = previous;
163
+ }
164
+ while (start > 0) {
165
+ const previous = previousGraphemeBoundary(source, start);
166
+ if (/\s/u.test(source.slice(previous, start)))
167
+ break;
168
+ start = previous;
169
+ }
170
+ return start;
171
+ }
172
+ function nextWordBoundary(source, cursor) {
173
+ let end = cursor;
174
+ while (end < source.length) {
175
+ const next = nextGraphemeBoundary(source, end);
176
+ if (!/\s/u.test(source.slice(end, next)))
177
+ break;
178
+ end = next;
179
+ }
180
+ while (end < source.length) {
181
+ const next = nextGraphemeBoundary(source, end);
182
+ if (/\s/u.test(source.slice(end, next)))
183
+ break;
184
+ end = next;
185
+ }
186
+ return end;
187
+ }
188
+ function deletePreviousWordAt(source, cursor) {
189
+ const start = previousWordBoundary(source, cursor);
190
+ return { value: source.slice(0, start) + source.slice(cursor), cursor: start };
191
+ }
192
+ function deleteNextWordAt(source, cursor) {
193
+ const end = nextWordBoundary(source, cursor);
194
+ return { value: source.slice(0, cursor) + source.slice(end), cursor };
195
+ }
196
+ export function Composer({ disabled = false, onSubmit, history, userCommands = [], mentionCandidates = [], hidden = false, escapeInterrupts = false, onNotice, onStepReasoningEffort, onExternalEdit, onEditPrevious, draftToRestore = null, onDraftRestored, }) {
197
+ const terminal = useWindowSize();
198
+ const [value, setValueState] = useState('');
199
+ const valueRef = useRef('');
200
+ const [cursor, setCursorState] = useState(0);
201
+ const cursorRef = useRef(0);
202
+ const [, setHistoryIndexState] = useState(-1);
203
+ const historyIndexRef = useRef(-1);
204
+ const historyDraftRef = useRef(null);
205
+ const [historySearch, setHistorySearchState] = useState(null);
206
+ const historySearchRef = useRef(null);
207
+ const verticalColumnRef = useRef(null);
208
+ const killBufferRef = useRef('');
59
209
  const [selected, setSelected] = useState(0);
210
+ const selectedRef = useRef(0);
60
211
  // Large pastes are held as attachments (shown as chips) instead of being
61
212
  // dumped into the input, then folded into the message on submit.
62
213
  const [pastes, setPastes] = useState([]);
@@ -65,31 +216,82 @@ export function Composer({ disabled = false, onSubmit, history, userCommands = [
65
216
  const [attachments, setAttachments] = useState([]);
66
217
  const [editPreviousArmed, setEditPreviousArmed] = useState(false);
67
218
  const restoredTokenRef = useRef(null);
68
- const suggestions = matchSlashCommands(value, userCommands).slice(0, MAX_SUGGESTIONS);
69
- const showSuggestions = suggestions.length > 0;
70
- const selIdx = Math.min(selected, Math.max(0, suggestions.length - 1));
71
- const displayValue = composerDisplayValue(value);
72
- const commandColumnWidth = Math.min(24, Math.max(10, ...suggestions.map((command) => command.name.length + 4)));
219
+ // Keep the complete match set. The six-row limit belongs to the rendered
220
+ // window, not to navigation: slicing here made every later command
221
+ // unreachable no matter how many times the operator pressed Down.
222
+ const commandSuggestions = matchSlashCommands(value, userCommands);
223
+ const activeMention = activeFileMention(value, cursor);
224
+ const fileSuggestions = activeMention
225
+ ? matchMentionableFiles(activeMention.query, mentionCandidates)
226
+ : [];
227
+ const suggestionKind = commandSuggestions.length > 0 ? 'command' : fileSuggestions.length > 0 ? 'file' : null;
228
+ const suggestionCount = suggestionKind === 'command' ? commandSuggestions.length : fileSuggestions.length;
229
+ const suggestionWindow = suggestionWindowSize(terminal.rows);
230
+ const selIdx = Math.min(selected, Math.max(0, suggestionCount - 1));
231
+ const suggestionStart = Math.min(Math.max(0, selIdx - suggestionWindow + 1), Math.max(0, suggestionCount - suggestionWindow));
232
+ const visibleCommandSuggestions = commandSuggestions.slice(suggestionStart, suggestionStart + suggestionWindow);
233
+ const visibleFileSuggestions = fileSuggestions.slice(suggestionStart, suggestionStart + suggestionWindow);
234
+ const displayValue = composerDisplayValue(value, cursor);
235
+ const commandColumnWidth = Math.min(24, Math.max(10, ...visibleCommandSuggestions.map((command) => command.name.length + 4)));
236
+ const setBuffer = useCallback((nextValue, nextCursor = nextValue.length) => {
237
+ valueRef.current = nextValue;
238
+ cursorRef.current = nextCursor;
239
+ setValueState(nextValue);
240
+ setCursorState(nextCursor);
241
+ }, []);
242
+ const setHistoryIndex = useCallback((next) => {
243
+ historyIndexRef.current = next;
244
+ setHistoryIndexState(next);
245
+ }, []);
246
+ const setHistorySearch = useCallback((next) => {
247
+ historySearchRef.current = next;
248
+ setHistorySearchState(next);
249
+ }, []);
250
+ const setSelectedIndex = useCallback((next) => {
251
+ const resolved = typeof next === 'function' ? next(selectedRef.current) : next;
252
+ selectedRef.current = resolved;
253
+ setSelected(resolved);
254
+ }, []);
255
+ const editBuffer = useCallback((nextValue, nextCursor = nextValue.length) => {
256
+ verticalColumnRef.current = null;
257
+ historyDraftRef.current = null;
258
+ setHistorySearch(null);
259
+ setHistoryIndex(-1);
260
+ setBuffer(nextValue, nextCursor);
261
+ }, [setBuffer, setHistoryIndex, setHistorySearch]);
73
262
  const reset = useCallback(() => {
74
- setValue('');
263
+ verticalColumnRef.current = null;
264
+ historyDraftRef.current = null;
265
+ setHistorySearch(null);
266
+ setBuffer('', 0);
75
267
  setHistoryIndex(-1);
76
- setSelected(0);
268
+ setSelectedIndex(0);
77
269
  setPastes([]);
78
270
  setAttachments([]);
79
271
  setEditPreviousArmed(false);
80
- }, []);
272
+ }, [setBuffer, setHistoryIndex, setHistorySearch, setSelectedIndex]);
81
273
  useEffect(() => {
82
274
  if (!draftToRestore || restoredTokenRef.current === draftToRestore.token)
83
275
  return;
84
276
  restoredTokenRef.current = draftToRestore.token;
85
- setValue(draftToRestore.text);
277
+ setBuffer(draftToRestore.text);
86
278
  setHistoryIndex(-1);
87
- setSelected(0);
279
+ setSelectedIndex(0);
280
+ verticalColumnRef.current = null;
281
+ historyDraftRef.current = null;
282
+ setHistorySearch(null);
88
283
  setPastes([]);
89
284
  setAttachments(draftToRestore.attachments ? [...draftToRestore.attachments] : []);
90
285
  setEditPreviousArmed(false);
91
286
  onDraftRestored?.(draftToRestore.token);
92
- }, [draftToRestore, onDraftRestored]);
287
+ }, [
288
+ draftToRestore,
289
+ onDraftRestored,
290
+ setBuffer,
291
+ setHistoryIndex,
292
+ setHistorySearch,
293
+ setSelectedIndex,
294
+ ]);
93
295
  useInput((input, key) => {
94
296
  // Hidden means the screen belongs to something else, so a keypress
95
297
  // aimed at that must not also land here.
@@ -105,10 +307,56 @@ export function Composer({ disabled = false, onSubmit, history, userCommands = [
105
307
  // strength of a second flag happening to agree with it.
106
308
  if (disabled || hidden)
107
309
  return;
310
+ const liveCommandSuggestions = matchSlashCommands(valueRef.current, userCommands);
311
+ const liveMention = activeFileMention(valueRef.current, cursorRef.current);
312
+ const liveFileSuggestions = liveMention
313
+ ? matchMentionableFiles(liveMention.query, mentionCandidates)
314
+ : [];
315
+ const liveSuggestionKind = liveCommandSuggestions.length > 0
316
+ ? 'command'
317
+ : liveFileSuggestions.length > 0
318
+ ? 'file'
319
+ : null;
320
+ const liveSuggestionCount = liveSuggestionKind === 'command'
321
+ ? liveCommandSuggestions.length
322
+ : liveFileSuggestions.length;
323
+ const hasLiveSuggestions = liveSuggestionKind !== null;
324
+ const liveSelection = Math.min(selectedRef.current, Math.max(0, liveSuggestionCount - 1));
325
+ const acceptSuggestion = () => {
326
+ if (liveSuggestionKind === 'command') {
327
+ editBuffer(`/${liveCommandSuggestions[liveSelection]?.name ?? ''} `);
328
+ setSelectedIndex(0);
329
+ return true;
330
+ }
331
+ if (liveSuggestionKind === 'file' && liveMention) {
332
+ const path = liveFileSuggestions[liveSelection];
333
+ if (!path)
334
+ return false;
335
+ const replacement = `@${path} `;
336
+ editBuffer(valueRef.current.slice(0, liveMention.start) +
337
+ replacement +
338
+ valueRef.current.slice(liveMention.end), liveMention.start + replacement.length);
339
+ setSelectedIndex(0);
340
+ return true;
341
+ }
342
+ return false;
343
+ };
344
+ if (!hasLiveSuggestions &&
345
+ onStepReasoningEffort &&
346
+ ((key.shift && key.downArrow) || (key.meta && input === ','))) {
347
+ onStepReasoningEffort('lower');
348
+ return;
349
+ }
350
+ if (!hasLiveSuggestions &&
351
+ onStepReasoningEffort &&
352
+ ((key.shift && key.upArrow) || (key.meta && input === '.'))) {
353
+ onStepReasoningEffort('raise');
354
+ return;
355
+ }
108
356
  if (!key.escape && editPreviousArmed)
109
357
  setEditPreviousArmed(false);
110
358
  const submit = (mode) => {
111
- const message = [value, ...pastes]
359
+ const message = [valueRef.current, ...pastes]
112
360
  .map((s) => s.trim())
113
361
  .filter(Boolean)
114
362
  .join('\n\n');
@@ -122,23 +370,55 @@ export function Composer({ disabled = false, onSubmit, history, userCommands = [
122
370
  reset();
123
371
  return true;
124
372
  };
373
+ if ((key.return && (key.shift || key.meta)) || input === '\n') {
374
+ const position = cursorRef.current;
375
+ editBuffer(valueRef.current.slice(0, position) + '\n' + valueRef.current.slice(position), position + 1);
376
+ return;
377
+ }
378
+ if (key.ctrl && (input === 'r' || input === 's')) {
379
+ verticalColumnRef.current = null;
380
+ const browsedHistoryDraft = historyDraftRef.current;
381
+ historyDraftRef.current = null;
382
+ setHistoryIndex(-1);
383
+ let search = historySearchRef.current;
384
+ if (!search) {
385
+ const query = valueRef.current;
386
+ const normalizedQuery = query.toLowerCase();
387
+ search = {
388
+ draft: browsedHistoryDraft ?? { value: query, cursor: cursorRef.current },
389
+ query,
390
+ matches: [...history]
391
+ .reverse()
392
+ .filter((entry) => entry.toLowerCase().includes(normalizedQuery)),
393
+ position: -1,
394
+ };
395
+ }
396
+ const position = input === 'r'
397
+ ? Math.min(search.position + 1, search.matches.length - 1)
398
+ : Math.max(search.position - 1, -1);
399
+ const next = { ...search, position };
400
+ setHistorySearch(next);
401
+ if (position < 0)
402
+ setBuffer(next.draft.value, next.draft.cursor);
403
+ else
404
+ setBuffer(next.matches[position] ?? next.draft.value);
405
+ return;
406
+ }
125
407
  if (key.return) {
126
- if (showSuggestions) {
408
+ if (liveSuggestionKind === 'command') {
127
409
  // Run the highlighted command.
128
- onSubmit(`/${suggestions[selIdx]?.name ?? ''}`);
410
+ onSubmit(`/${liveCommandSuggestions[liveSelection]?.name ?? ''}`);
129
411
  reset();
130
412
  return;
131
413
  }
414
+ if (acceptSuggestion())
415
+ return;
132
416
  submit('submit');
133
417
  return;
134
418
  }
135
419
  if (key.tab) {
136
- if (showSuggestions) {
137
- // Complete to the highlighted command, ready for arguments.
138
- setValue(`/${suggestions[selIdx]?.name ?? ''} `);
139
- setSelected(0);
420
+ if (acceptSuggestion())
140
421
  return;
141
- }
142
422
  submit('queue');
143
423
  return;
144
424
  }
@@ -148,7 +428,13 @@ export function Composer({ disabled = false, onSubmit, history, userCommands = [
148
428
  // following the hint the status bar is showing them.
149
429
  if (escapeInterrupts)
150
430
  return;
151
- const empty = value.length === 0 && pastes.length === 0 && attachments.length === 0;
431
+ const activeSearch = historySearchRef.current;
432
+ if (activeSearch) {
433
+ setBuffer(activeSearch.draft.value, activeSearch.draft.cursor);
434
+ setHistorySearch(null);
435
+ return;
436
+ }
437
+ const empty = valueRef.current.length === 0 && pastes.length === 0 && attachments.length === 0;
152
438
  if (empty && onEditPrevious) {
153
439
  if (editPreviousArmed) {
154
440
  setEditPreviousArmed(false);
@@ -161,44 +447,162 @@ export function Composer({ disabled = false, onSubmit, history, userCommands = [
161
447
  reset();
162
448
  return;
163
449
  }
164
- if (key.backspace || key.delete) {
450
+ if (key.backspace && (key.meta || key.ctrl)) {
451
+ const source = valueRef.current;
452
+ const position = cursorRef.current;
453
+ const start = previousWordBoundary(source, position);
454
+ if (start < position)
455
+ killBufferRef.current = source.slice(start, position);
456
+ const result = deletePreviousWordAt(valueRef.current, cursorRef.current);
457
+ editBuffer(result.value, result.cursor);
458
+ return;
459
+ }
460
+ if (key.backspace) {
165
461
  // Backspace on an empty line removes the last durable attachment first,
166
462
  // then pasted text.
167
- if (value.length === 0 && attachments.length > 0) {
463
+ if (valueRef.current.length === 0 && attachments.length > 0) {
168
464
  setAttachments((p) => p.slice(0, -1));
169
465
  return;
170
466
  }
171
- if (value.length === 0 && pastes.length > 0) {
467
+ if (valueRef.current.length === 0 && pastes.length > 0) {
172
468
  setPastes((p) => p.slice(0, -1));
173
469
  return;
174
470
  }
175
- setValue((v) => v.slice(0, -1));
471
+ const position = cursorRef.current;
472
+ const previous = previousGraphemeBoundary(valueRef.current, position);
473
+ editBuffer(valueRef.current.slice(0, previous) + valueRef.current.slice(position), previous);
474
+ return;
475
+ }
476
+ if (key.delete || (key.ctrl && input === 'd')) {
477
+ const position = cursorRef.current;
478
+ const next = nextGraphemeBoundary(valueRef.current, position);
479
+ editBuffer(valueRef.current.slice(0, position) + valueRef.current.slice(next), position);
480
+ return;
481
+ }
482
+ if ((key.leftArrow && (key.meta || key.ctrl)) || (key.meta && input === 'b')) {
483
+ verticalColumnRef.current = null;
484
+ const previous = previousWordBoundary(valueRef.current, cursorRef.current);
485
+ cursorRef.current = previous;
486
+ setCursorState(previous);
487
+ return;
488
+ }
489
+ if ((key.rightArrow && (key.meta || key.ctrl)) || (key.meta && input === 'f')) {
490
+ verticalColumnRef.current = null;
491
+ const next = nextWordBoundary(valueRef.current, cursorRef.current);
492
+ cursorRef.current = next;
493
+ setCursorState(next);
494
+ return;
495
+ }
496
+ if (key.leftArrow || (key.ctrl && input === 'b')) {
497
+ verticalColumnRef.current = null;
498
+ const previous = previousGraphemeBoundary(valueRef.current, cursorRef.current);
499
+ cursorRef.current = previous;
500
+ setCursorState(previous);
501
+ return;
502
+ }
503
+ if (key.rightArrow || (key.ctrl && input === 'f')) {
504
+ verticalColumnRef.current = null;
505
+ const next = nextGraphemeBoundary(valueRef.current, cursorRef.current);
506
+ cursorRef.current = next;
507
+ setCursorState(next);
508
+ return;
509
+ }
510
+ if (hasLiveSuggestions && (key.home || key.end || key.pageUp || key.pageDown)) {
511
+ if (key.home)
512
+ setSelectedIndex(0);
513
+ else if (key.end)
514
+ setSelectedIndex(liveSuggestionCount - 1);
515
+ else if (key.pageUp)
516
+ setSelectedIndex((index) => Math.max(0, index - suggestionWindow));
517
+ else
518
+ setSelectedIndex((index) => Math.min(liveSuggestionCount - 1, index + suggestionWindow));
519
+ return;
520
+ }
521
+ if (key.home || (key.ctrl && input === 'a')) {
522
+ verticalColumnRef.current = null;
523
+ const start = lineStart(valueRef.current, cursorRef.current);
524
+ cursorRef.current = start;
525
+ setCursorState(start);
526
+ return;
527
+ }
528
+ if (key.end || (key.ctrl && input === 'e')) {
529
+ verticalColumnRef.current = null;
530
+ const end = lineEnd(valueRef.current, cursorRef.current);
531
+ cursorRef.current = end;
532
+ setCursorState(end);
176
533
  return;
177
534
  }
178
- if (key.upArrow) {
179
- if (showSuggestions) {
180
- setSelected((i) => Math.max(0, i - 1));
535
+ if (key.upArrow || (key.ctrl && input === 'p')) {
536
+ if (hasLiveSuggestions) {
537
+ setSelectedIndex((i) => Math.max(0, i - 1));
181
538
  return;
182
539
  }
540
+ const vertical = verticalCursor(valueRef.current, cursorRef.current, -1, verticalColumnRef.current ?? undefined);
541
+ if (vertical) {
542
+ verticalColumnRef.current = vertical.column;
543
+ cursorRef.current = vertical.cursor;
544
+ setCursorState(vertical.cursor);
545
+ return;
546
+ }
547
+ verticalColumnRef.current = null;
183
548
  if (history.length === 0)
184
549
  return;
185
- const next = Math.min(historyIndex + 1, history.length - 1);
550
+ if (historyIndexRef.current < 0) {
551
+ historyDraftRef.current = { value: valueRef.current, cursor: cursorRef.current };
552
+ }
553
+ const next = Math.min(historyIndexRef.current + 1, history.length - 1);
186
554
  setHistoryIndex(next);
187
- setValue(history[history.length - 1 - next] ?? '');
555
+ setBuffer(history[history.length - 1 - next] ?? '');
188
556
  return;
189
557
  }
190
- if (key.downArrow) {
191
- if (showSuggestions) {
192
- setSelected((i) => Math.min(suggestions.length - 1, i + 1));
558
+ if (key.downArrow || (key.ctrl && input === 'n')) {
559
+ if (hasLiveSuggestions) {
560
+ setSelectedIndex((i) => Math.min(liveSuggestionCount - 1, i + 1));
193
561
  return;
194
562
  }
195
- if (historyIndex <= 0) {
196
- reset();
563
+ const vertical = verticalCursor(valueRef.current, cursorRef.current, 1, verticalColumnRef.current ?? undefined);
564
+ if (vertical) {
565
+ verticalColumnRef.current = vertical.column;
566
+ cursorRef.current = vertical.cursor;
567
+ setCursorState(vertical.cursor);
197
568
  return;
198
569
  }
199
- const next = historyIndex - 1;
570
+ verticalColumnRef.current = null;
571
+ if (historyIndexRef.current < 0)
572
+ return;
573
+ if (historyIndexRef.current === 0) {
574
+ const draft = historyDraftRef.current;
575
+ historyDraftRef.current = null;
576
+ setHistoryIndex(-1);
577
+ setBuffer(draft?.value ?? '', draft?.cursor ?? 0);
578
+ return;
579
+ }
580
+ const next = historyIndexRef.current - 1;
200
581
  setHistoryIndex(next);
201
- setValue(history[history.length - 1 - next] ?? '');
582
+ setBuffer(history[history.length - 1 - next] ?? '');
583
+ return;
584
+ }
585
+ if (key.ctrl && input === 'g') {
586
+ if (!onExternalEdit) {
587
+ onNotice?.('External editor support is unavailable in this terminal.');
588
+ return;
589
+ }
590
+ const seed = [valueRef.current, ...pastes]
591
+ .map((part) => part.trim())
592
+ .filter(Boolean)
593
+ .join('\n\n');
594
+ void onExternalEdit(seed)
595
+ .then((edited) => {
596
+ const cleaned = edited.trimEnd();
597
+ setPastes([]);
598
+ setHistoryIndex(-1);
599
+ setHistorySearch(null);
600
+ setSelectedIndex(0);
601
+ editBuffer(cleaned, cleaned.length);
602
+ })
603
+ .catch((error) => {
604
+ onNotice?.(`Cannot open external editor: ${error instanceof Error ? error.message : String(error)}`);
605
+ });
202
606
  return;
203
607
  }
204
608
  // Ctrl+V / Alt+V: pull an image off the clipboard and hold it as an attachment.
@@ -221,7 +625,47 @@ export function Composer({ disabled = false, onSubmit, history, userCommands = [
221
625
  return;
222
626
  }
223
627
  if (key.ctrl && input === 'w') {
224
- setValue(deletePreviousWord);
628
+ const source = valueRef.current;
629
+ const position = cursorRef.current;
630
+ const start = previousWordBoundary(source, position);
631
+ if (start < position)
632
+ killBufferRef.current = source.slice(start, position);
633
+ const result = deletePreviousWordAt(valueRef.current, cursorRef.current);
634
+ editBuffer(result.value, result.cursor);
635
+ return;
636
+ }
637
+ if (key.ctrl && input === 'u') {
638
+ const position = cursorRef.current;
639
+ const start = lineStart(valueRef.current, position);
640
+ if (start < position)
641
+ killBufferRef.current = valueRef.current.slice(start, position);
642
+ editBuffer(valueRef.current.slice(0, start) + valueRef.current.slice(position), start);
643
+ return;
644
+ }
645
+ if (key.ctrl && input === 'k') {
646
+ const position = cursorRef.current;
647
+ const end = lineEnd(valueRef.current, position);
648
+ if (position < end)
649
+ killBufferRef.current = valueRef.current.slice(position, end);
650
+ editBuffer(valueRef.current.slice(0, position) + valueRef.current.slice(end), position);
651
+ return;
652
+ }
653
+ if (key.meta && input === 'd') {
654
+ const source = valueRef.current;
655
+ const position = cursorRef.current;
656
+ const end = nextWordBoundary(source, position);
657
+ if (position < end)
658
+ killBufferRef.current = source.slice(position, end);
659
+ const result = deleteNextWordAt(valueRef.current, cursorRef.current);
660
+ editBuffer(result.value, result.cursor);
661
+ return;
662
+ }
663
+ if (key.ctrl && input === 'y') {
664
+ const killed = killBufferRef.current;
665
+ if (killed.length === 0)
666
+ return;
667
+ const position = cursorRef.current;
668
+ editBuffer(valueRef.current.slice(0, position) + killed + valueRef.current.slice(position), position + killed.length);
225
669
  return;
226
670
  }
227
671
  if (key.ctrl || key.meta)
@@ -234,9 +678,10 @@ export function Composer({ disabled = false, onSubmit, history, userCommands = [
234
678
  setPastes((p) => [...p, input]);
235
679
  return;
236
680
  }
237
- setSelected(0);
238
- setValue((v) => v + input);
239
- }, { isActive: true });
681
+ setSelectedIndex(0);
682
+ const position = cursorRef.current;
683
+ editBuffer(valueRef.current.slice(0, position) + input + valueRef.current.slice(position), position + input.length);
684
+ }, { isActive: !disabled && !hidden });
240
685
  // After every hook, so the component keeps its state while it is off screen.
241
686
  // This is the whole fix: React preserves a mounted component's state, and
242
687
  // preserving it is what an unmounting ternary could not do.
@@ -244,7 +689,18 @@ export function Composer({ disabled = false, onSubmit, history, userCommands = [
244
689
  return null;
245
690
  const promptGlyph = disabled ? '…' : '>';
246
691
  const showPlaceholder = !disabled && value.length === 0 && !editPreviousArmed;
247
- return (_jsxs(Box, { flexDirection: "column", children: [pastes.length > 0 || attachments.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingBottom: 1, children: [attachments.map((attachment, i) => (_jsxs(Text, { color: theme.accent.tool, children: ["\u2398 ", attachmentLabel(attachment, i)] }, `attachment-${i}`))), pastes.map((p, i) => (_jsxs(Text, { color: theme.text.secondary, children: ["\u2398 Pasted text #", i + 1, " (+", p.split('\n').length, " lines)"] }, `paste-${i}`)))] })) : null, _jsxs(Box, { paddingX: 1, children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: disabled ? theme.text.muted : theme.accent.user, bold: true, children: promptGlyph }) }), _jsx(Box, { flexGrow: 1, children: showPlaceholder ? (_jsx(Text, { color: theme.text.muted, children: "Type a message\u2026 (/help for commands)" })) : editPreviousArmed ? (_jsx(Text, { color: theme.text.muted, children: "Press Esc again to edit a previous prompt" })) : (_jsxs(Text, { color: disabled ? theme.text.muted : theme.text.primary, wrap: "wrap", children: [displayValue, disabled ? null : _jsx(Text, { color: theme.border.focus, children: "\u258F" })] })) })] }), showSuggestions ? (_jsx(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, width: "100%", children: suggestions.map((cmd, i) => (_jsxs(Box, { width: "100%", flexDirection: "row", children: [_jsx(Box, { width: commandColumnWidth, flexShrink: 0, children: _jsxs(Text, { color: i === selIdx ? theme.accent.user : theme.text.secondary, bold: i === selIdx, children: [i === selIdx ? '› ' : ' ', "/", cmd.name] }) }), _jsx(Box, { flexGrow: 1, minWidth: 0, children: _jsx(Text, { color: theme.text.muted, wrap: "wrap", children: cmd.description }) })] }, cmd.name))) })) : null] }));
692
+ const historySearchQuery = historySearch
693
+ ? composerDisplayValue(historySearch.query, historySearch.query.length)
694
+ : null;
695
+ return (_jsxs(Box, { flexDirection: "column", children: [pastes.length > 0 || attachments.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingBottom: 1, children: [attachments.map((attachment, i) => (_jsxs(Text, { color: theme.accent.tool, children: ["\u2398 ", attachmentLabel(attachment, i)] }, `attachment-${i}`))), pastes.map((p, i) => (_jsxs(Text, { color: theme.text.secondary, children: ["\u2398 Pasted text #", i + 1, " (+", p.split('\n').length, " lines)"] }, `paste-${i}`)))] })) : null, _jsxs(Box, { paddingX: 1, children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: disabled ? theme.text.muted : theme.accent.user, bold: true, children: promptGlyph }) }), _jsx(Box, { flexGrow: 1, children: showPlaceholder ? (_jsx(Text, { color: theme.text.muted, children: "Type a message\u2026 (/help for commands)" })) : editPreviousArmed ? (_jsx(Text, { color: theme.text.muted, children: "Press Esc again to edit a previous prompt" })) : (_jsxs(Text, { color: disabled ? theme.text.muted : theme.text.primary, wrap: "wrap", children: [displayValue.leadingEllipsis ? '… ' : '', displayValue.before, disabled ? null : _jsx(Text, { color: theme.border.focus, children: "\u258F" }), displayValue.after, displayValue.trailingEllipsis ? ' …' : ''] })) })] }), historySearch && historySearchQuery ? (_jsx(Box, { paddingX: 1, paddingTop: 1, children: _jsxs(Text, { color: theme.text.muted, children: ["history \u201C", historySearchQuery.leadingEllipsis ? '… ' : '', historySearchQuery.before, "\u201D \u00B7", ' ', historySearch.position < 0
696
+ ? `draft · ${historySearch.matches.length} match${historySearch.matches.length === 1 ? '' : 'es'}`
697
+ : `${historySearch.position + 1}/${historySearch.matches.length}`, ' ', "\u00B7 Ctrl+R older \u00B7 Ctrl+S newer \u00B7 Esc restore"] }) })) : null, suggestionKind === 'command' ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, width: "100%", children: [visibleCommandSuggestions.map((cmd, i) => {
698
+ const absoluteIndex = suggestionStart + i;
699
+ return (_jsxs(Box, { width: "100%", flexDirection: "row", children: [_jsx(Box, { width: commandColumnWidth, flexShrink: 0, children: _jsxs(Text, { color: absoluteIndex === selIdx ? theme.accent.user : theme.text.secondary, bold: absoluteIndex === selIdx, children: [absoluteIndex === selIdx ? '› ' : ' ', "/", cmd.name] }) }), _jsx(Box, { flexGrow: 1, minWidth: 0, children: _jsx(Text, { color: theme.text.muted, wrap: "wrap", children: cmd.description }) })] }, cmd.name));
700
+ }), _jsxs(Text, { color: theme.text.muted, children: [selIdx + 1, "/", commandSuggestions.length, " \u00B7 \u2191\u2193 navigate \u00B7 PgUp/PgDn jump \u00B7 Enter run \u00B7 Tab complete"] })] })) : suggestionKind === 'file' ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, width: "100%", children: [visibleFileSuggestions.map((path, i) => {
701
+ const absoluteIndex = suggestionStart + i;
702
+ return (_jsxs(Text, { color: absoluteIndex === selIdx ? theme.accent.user : theme.text.secondary, bold: absoluteIndex === selIdx, children: [absoluteIndex === selIdx ? '› ' : ' ', "@", path] }, path));
703
+ }), _jsxs(Text, { color: theme.text.muted, children: [selIdx + 1, "/", fileSuggestions.length, " \u00B7 \u2191\u2193 navigate \u00B7 PgUp/PgDn jump \u00B7 Enter/Tab insert"] })] })) : null] }));
248
704
  }
249
705
  function attachmentLabel(attachment, index) {
250
706
  if (attachment.type === 'stored') {