@assistant-ui/react-ink 0.0.12 → 0.0.15

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.
@@ -1,49 +1,233 @@
1
+ import { useEffect, useRef } from "react";
1
2
  import type { ComponentProps } from "react";
2
3
  import { Box, Text, useFocus, useInput } from "ink";
3
4
  import { useAui, useAuiState } from "@assistant-ui/store";
5
+ import {
6
+ getGraphemeAt,
7
+ textBufferReducer,
8
+ useTextBuffer,
9
+ } from "./useTextBuffer";
10
+
11
+ // cap dedup map so a store that drops echoes can't grow the counter without bound
12
+ const PENDING_SYNC_CAP = 64;
4
13
 
5
14
  export type ComposerInputProps = ComponentProps<typeof Box> & {
6
- /** Submit the message when Enter is pressed. @default false */
7
15
  submitOnEnter?: boolean | undefined;
8
- /** Placeholder text shown when the input is empty. */
9
16
  placeholder?: string | undefined;
10
- /** Whether this input should receive focus automatically. @default true */
11
17
  autoFocus?: boolean | undefined;
18
+ multiLine?: boolean | undefined;
19
+ onSubmit?: ((text: string) => void) | undefined;
12
20
  };
13
21
 
14
22
  export const ComposerInput = ({
15
23
  submitOnEnter = false,
16
24
  placeholder = "",
17
25
  autoFocus = true,
26
+ multiLine = false,
27
+ onSubmit,
18
28
  ...boxProps
19
29
  }: ComposerInputProps) => {
20
30
  const aui = useAui();
21
- const text = useAuiState((s) => s.composer.text);
31
+ const storeText = useAuiState((s) => s.composer.text);
22
32
  const { isFocused } = useFocus({ autoFocus });
33
+ const { text, cursorOffset, preferredColumn, dispatchAction, setText } =
34
+ useTextBuffer(storeText);
35
+ const bufferStateRef = useRef({ text, cursorOffset, preferredColumn });
36
+ const pendingLocalSyncTextsRef = useRef(new Map<string, number>());
37
+ bufferStateRef.current = { text, cursorOffset, preferredColumn };
38
+
39
+ useEffect(() => {
40
+ const counter = pendingLocalSyncTextsRef.current;
41
+ const pending = counter.get(storeText) ?? 0;
42
+ if (pending > 0) {
43
+ if (pending === 1) counter.delete(storeText);
44
+ else counter.set(storeText, pending - 1);
45
+ return;
46
+ }
47
+ if (storeText === text) return;
48
+
49
+ counter.clear();
50
+ setText(storeText);
51
+ bufferStateRef.current = {
52
+ text: storeText,
53
+ cursorOffset: storeText.length,
54
+ preferredColumn: undefined,
55
+ };
56
+ }, [setText, storeText, text]);
57
+
58
+ const commitAction = (
59
+ action: Parameters<typeof textBufferReducer>[1],
60
+ options?: { syncText?: boolean },
61
+ ) => {
62
+ const currentState = bufferStateRef.current;
63
+ // run the reducer eagerly so submit-after-edit sees post-action state before react commits
64
+ const nextState = textBufferReducer(currentState, action);
65
+ dispatchAction(action);
66
+ bufferStateRef.current = nextState;
67
+
68
+ if (options?.syncText !== false && nextState.text !== currentState.text) {
69
+ const counter = pendingLocalSyncTextsRef.current;
70
+ if (counter.size >= PENDING_SYNC_CAP) counter.clear();
71
+ counter.set(nextState.text, (counter.get(nextState.text) ?? 0) + 1);
72
+ aui.composer().setText(nextState.text);
73
+ }
74
+ };
75
+
76
+ const submit = () => {
77
+ const submittedText = bufferStateRef.current.text;
78
+ if (onSubmit) {
79
+ onSubmit(submittedText);
80
+ return;
81
+ }
82
+
83
+ aui.composer().send();
84
+ };
23
85
 
24
86
  useInput(
25
87
  (input, key) => {
88
+ const extendedKey = key as typeof key & {
89
+ home?: boolean;
90
+ end?: boolean;
91
+ shift?: boolean;
92
+ };
93
+ const lowerInput = input.toLowerCase();
94
+
95
+ if (key.ctrl) {
96
+ // ctrl+j may also report key.return; swallow so single-line never submits
97
+ if (lowerInput === "j") {
98
+ if (multiLine) {
99
+ commitAction({ type: "insert", text: "\n" });
100
+ }
101
+ return;
102
+ }
103
+ if (lowerInput === "a") {
104
+ commitAction({ type: "move-home", multiLine }, { syncText: false });
105
+ return;
106
+ }
107
+ if (lowerInput === "e") {
108
+ commitAction({ type: "move-end", multiLine }, { syncText: false });
109
+ return;
110
+ }
111
+ if (lowerInput === "w") {
112
+ commitAction({ type: "kill-word-backward" });
113
+ return;
114
+ }
115
+ if (lowerInput === "u") {
116
+ commitAction({ type: "kill-start", multiLine });
117
+ return;
118
+ }
119
+ if (lowerInput === "k") {
120
+ commitAction({ type: "kill-end", multiLine });
121
+ return;
122
+ }
123
+ if (lowerInput === "d") {
124
+ commitAction({ type: "delete-forward" });
125
+ return;
126
+ }
127
+ }
128
+
129
+ if (key.meta) {
130
+ if (lowerInput === "b") {
131
+ commitAction({ type: "move-word-left" }, { syncText: false });
132
+ return;
133
+ }
134
+ if (lowerInput === "f") {
135
+ commitAction({ type: "move-word-right" }, { syncText: false });
136
+ return;
137
+ }
138
+ if (lowerInput === "d") {
139
+ commitAction({ type: "kill-word-forward" });
140
+ return;
141
+ }
142
+ }
143
+
26
144
  if (key.return) {
145
+ const shouldInsertNewline =
146
+ multiLine && (!submitOnEnter || extendedKey.shift);
147
+ if (shouldInsertNewline) {
148
+ commitAction({ type: "insert", text: "\n" });
149
+ return;
150
+ }
151
+
27
152
  if (submitOnEnter) {
28
- aui.composer().send();
153
+ submit();
29
154
  }
30
155
  return;
31
156
  }
32
- if (key.backspace || key.delete) {
33
- aui.composer().setText(text.slice(0, -1));
157
+
158
+ if (key.leftArrow) {
159
+ commitAction({ type: "move-left" }, { syncText: false });
160
+ return;
161
+ }
162
+
163
+ if (key.rightArrow) {
164
+ commitAction({ type: "move-right" }, { syncText: false });
165
+ return;
166
+ }
167
+
168
+ if (multiLine && key.upArrow) {
169
+ commitAction({ type: "move-up" }, { syncText: false });
170
+ return;
171
+ }
172
+
173
+ if (multiLine && key.downArrow) {
174
+ commitAction({ type: "move-down" }, { syncText: false });
175
+ return;
176
+ }
177
+
178
+ if (extendedKey.home) {
179
+ commitAction({ type: "move-home", multiLine }, { syncText: false });
180
+ return;
181
+ }
182
+
183
+ if (extendedKey.end) {
184
+ commitAction({ type: "move-end", multiLine }, { syncText: false });
185
+ return;
186
+ }
187
+
188
+ if (key.backspace) {
189
+ commitAction({ type: "delete-backward" });
190
+ return;
191
+ }
192
+
193
+ if (key.delete) {
194
+ commitAction({ type: "delete-forward" });
34
195
  return;
35
196
  }
197
+
36
198
  if (input && !key.ctrl && !key.meta) {
37
- aui.composer().setText(text + input);
199
+ commitAction({ type: "insert", text: input });
38
200
  }
39
201
  },
40
202
  { isActive: isFocused },
41
203
  );
42
204
 
205
+ const hasText = text.length > 0;
206
+ const isShowingPlaceholder = !hasText && placeholder.length > 0;
207
+ const before = hasText ? text.slice(0, cursorOffset) : "";
208
+ const charAtCursor = hasText ? getGraphemeAt(text, cursorOffset) : "";
209
+ const isOnNewline = charAtCursor === "\n";
210
+ // render a space when on a newline so the inverse cursor cell stays visible
211
+ const atCursor = charAtCursor === "" || isOnNewline ? " " : charAtCursor;
212
+ const after = hasText
213
+ ? isOnNewline
214
+ ? text.slice(cursorOffset)
215
+ : text.slice(cursorOffset + charAtCursor.length)
216
+ : placeholder;
217
+
43
218
  return (
44
219
  <Box {...boxProps}>
45
- <Text dimColor={!text && !!placeholder}>{text || placeholder}</Text>
46
- {isFocused && <Text>▋</Text>}
220
+ {!isFocused ? (
221
+ <Text dimColor={isShowingPlaceholder}>
222
+ {hasText ? text : placeholder}
223
+ </Text>
224
+ ) : (
225
+ <Text dimColor={isShowingPlaceholder}>
226
+ {before}
227
+ <Text inverse>{atCursor}</Text>
228
+ {after}
229
+ </Text>
230
+ )}
47
231
  </Box>
48
232
  );
49
233
  };
@@ -0,0 +1,332 @@
1
+ import { useCallback, useReducer } from "react";
2
+
3
+ export type TextBufferState = {
4
+ text: string;
5
+ cursorOffset: number;
6
+ preferredColumn: number | undefined;
7
+ };
8
+
9
+ export type TextBufferAction =
10
+ | { type: "insert"; text: string }
11
+ | { type: "delete-backward" }
12
+ | { type: "delete-forward" }
13
+ | { type: "move-left" }
14
+ | { type: "move-right" }
15
+ | { type: "move-up" }
16
+ | { type: "move-down" }
17
+ | { type: "move-home"; multiLine: boolean }
18
+ | { type: "move-end"; multiLine: boolean }
19
+ | { type: "move-word-left" }
20
+ | { type: "move-word-right" }
21
+ | { type: "kill-word-backward" }
22
+ | { type: "kill-word-forward" }
23
+ | { type: "kill-start"; multiLine: boolean }
24
+ | { type: "kill-end"; multiLine: boolean }
25
+ | { type: "set-text"; text: string }
26
+ | { type: "set-cursor"; cursorOffset: number };
27
+
28
+ const clamp = (value: number, min: number, max: number) =>
29
+ Math.min(Math.max(value, min), max);
30
+
31
+ const graphemeSegmenter = new Intl.Segmenter(undefined, {
32
+ granularity: "grapheme",
33
+ });
34
+ const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });
35
+
36
+ const stepGraphemeLeft = (text: string, offset: number) => {
37
+ if (offset <= 0) return 0;
38
+ let previous = 0;
39
+ for (const { index } of graphemeSegmenter.segment(text)) {
40
+ if (index >= offset) break;
41
+ previous = index;
42
+ }
43
+ return previous;
44
+ };
45
+
46
+ const stepGraphemeRight = (text: string, offset: number) => {
47
+ if (offset >= text.length) return text.length;
48
+ for (const { index, segment } of graphemeSegmenter.segment(text)) {
49
+ const end = index + segment.length;
50
+ if (end > offset) return end;
51
+ }
52
+ return text.length;
53
+ };
54
+
55
+ export const getGraphemeAt = (text: string, offset: number) => {
56
+ if (offset >= text.length) return "";
57
+ for (const { index, segment } of graphemeSegmenter.segment(text)) {
58
+ if (index === offset) return segment;
59
+ if (index > offset) return "";
60
+ }
61
+ return "";
62
+ };
63
+
64
+ const getLineStart = (text: string, cursorOffset: number) => {
65
+ if (cursorOffset === 0) return 0;
66
+ const lineBreakIndex = text.lastIndexOf("\n", cursorOffset - 1);
67
+ return lineBreakIndex === -1 ? 0 : lineBreakIndex + 1;
68
+ };
69
+
70
+ const getLineEnd = (text: string, cursorOffset: number) => {
71
+ const lineBreakIndex = text.indexOf("\n", cursorOffset);
72
+ return lineBreakIndex === -1 ? text.length : lineBreakIndex;
73
+ };
74
+
75
+ const getLineRange = (text: string, cursorOffset: number) => {
76
+ const start = getLineStart(text, cursorOffset);
77
+ const end = getLineEnd(text, cursorOffset);
78
+ return { start, end };
79
+ };
80
+
81
+ const getPreviousWordOffset = (text: string, cursorOffset: number) => {
82
+ let result = 0;
83
+ for (const segment of wordSegmenter.segment(text)) {
84
+ if (segment.index >= cursorOffset) break;
85
+ if (segment.isWordLike) result = segment.index;
86
+ }
87
+ return result;
88
+ };
89
+
90
+ const getNextWordOffset = (text: string, cursorOffset: number) => {
91
+ for (const segment of wordSegmenter.segment(text)) {
92
+ const end = segment.index + segment.segment.length;
93
+ if (end <= cursorOffset) continue;
94
+ if (segment.isWordLike) return end;
95
+ }
96
+ return text.length;
97
+ };
98
+
99
+ const moveVertical = (
100
+ text: string,
101
+ cursorOffset: number,
102
+ preferredColumn: number | undefined,
103
+ direction: -1 | 1,
104
+ ) => {
105
+ const { start, end } = getLineRange(text, cursorOffset);
106
+ const currentColumn = preferredColumn ?? cursorOffset - start;
107
+ const adjacentBreakIndex = direction === -1 ? start - 1 : end;
108
+
109
+ if (adjacentBreakIndex < 0 || adjacentBreakIndex >= text.length) {
110
+ return { cursorOffset, preferredColumn: currentColumn };
111
+ }
112
+
113
+ const adjacentCursorBase =
114
+ direction === -1 ? adjacentBreakIndex : adjacentBreakIndex + 1;
115
+ const adjacentRange = getLineRange(text, adjacentCursorBase);
116
+ const nextCursorOffset = clamp(
117
+ adjacentRange.start + currentColumn,
118
+ adjacentRange.start,
119
+ adjacentRange.end,
120
+ );
121
+
122
+ return {
123
+ cursorOffset: nextCursorOffset,
124
+ preferredColumn: currentColumn,
125
+ };
126
+ };
127
+
128
+ const clearPreferredColumn = (
129
+ state: TextBufferState,
130
+ cursorOffset: number,
131
+ ) => ({
132
+ ...state,
133
+ cursorOffset,
134
+ preferredColumn: undefined,
135
+ });
136
+
137
+ export const textBufferReducer = (
138
+ state: TextBufferState,
139
+ action: TextBufferAction,
140
+ ): TextBufferState => {
141
+ switch (action.type) {
142
+ case "insert": {
143
+ if (!action.text) return state;
144
+
145
+ const nextText =
146
+ state.text.slice(0, state.cursorOffset) +
147
+ action.text +
148
+ state.text.slice(state.cursorOffset);
149
+ const nextCursorOffset = state.cursorOffset + action.text.length;
150
+ return clearPreferredColumn(
151
+ { ...state, text: nextText },
152
+ nextCursorOffset,
153
+ );
154
+ }
155
+
156
+ case "delete-backward": {
157
+ if (state.cursorOffset === 0) return state;
158
+
159
+ const previousOffset = stepGraphemeLeft(state.text, state.cursorOffset);
160
+ const nextText =
161
+ state.text.slice(0, previousOffset) +
162
+ state.text.slice(state.cursorOffset);
163
+ return clearPreferredColumn({ ...state, text: nextText }, previousOffset);
164
+ }
165
+
166
+ case "delete-forward": {
167
+ if (state.cursorOffset >= state.text.length) return state;
168
+
169
+ const nextOffset = stepGraphemeRight(state.text, state.cursorOffset);
170
+ const nextText =
171
+ state.text.slice(0, state.cursorOffset) + state.text.slice(nextOffset);
172
+ return clearPreferredColumn(
173
+ { ...state, text: nextText },
174
+ state.cursorOffset,
175
+ );
176
+ }
177
+
178
+ case "move-left":
179
+ return clearPreferredColumn(
180
+ state,
181
+ stepGraphemeLeft(state.text, state.cursorOffset),
182
+ );
183
+
184
+ case "move-right":
185
+ return clearPreferredColumn(
186
+ state,
187
+ stepGraphemeRight(state.text, state.cursorOffset),
188
+ );
189
+
190
+ case "move-up": {
191
+ const next = moveVertical(
192
+ state.text,
193
+ state.cursorOffset,
194
+ state.preferredColumn,
195
+ -1,
196
+ );
197
+ return { ...state, ...next };
198
+ }
199
+
200
+ case "move-down": {
201
+ const next = moveVertical(
202
+ state.text,
203
+ state.cursorOffset,
204
+ state.preferredColumn,
205
+ 1,
206
+ );
207
+ return { ...state, ...next };
208
+ }
209
+
210
+ case "move-home": {
211
+ const nextCursorOffset = action.multiLine
212
+ ? getLineStart(state.text, state.cursorOffset)
213
+ : 0;
214
+ return clearPreferredColumn(state, nextCursorOffset);
215
+ }
216
+
217
+ case "move-end": {
218
+ const nextCursorOffset = action.multiLine
219
+ ? getLineEnd(state.text, state.cursorOffset)
220
+ : state.text.length;
221
+ return clearPreferredColumn(state, nextCursorOffset);
222
+ }
223
+
224
+ case "move-word-left":
225
+ return clearPreferredColumn(
226
+ state,
227
+ getPreviousWordOffset(state.text, state.cursorOffset),
228
+ );
229
+
230
+ case "move-word-right":
231
+ return clearPreferredColumn(
232
+ state,
233
+ getNextWordOffset(state.text, state.cursorOffset),
234
+ );
235
+
236
+ case "kill-word-backward": {
237
+ const nextCursorOffset = getPreviousWordOffset(
238
+ state.text,
239
+ state.cursorOffset,
240
+ );
241
+ if (nextCursorOffset === state.cursorOffset) return state;
242
+
243
+ const nextText =
244
+ state.text.slice(0, nextCursorOffset) +
245
+ state.text.slice(state.cursorOffset);
246
+ return clearPreferredColumn(
247
+ { ...state, text: nextText },
248
+ nextCursorOffset,
249
+ );
250
+ }
251
+
252
+ case "kill-word-forward": {
253
+ const nextOffset = getNextWordOffset(state.text, state.cursorOffset);
254
+ if (nextOffset === state.cursorOffset) return state;
255
+
256
+ const nextText =
257
+ state.text.slice(0, state.cursorOffset) + state.text.slice(nextOffset);
258
+ return clearPreferredColumn(
259
+ { ...state, text: nextText },
260
+ state.cursorOffset,
261
+ );
262
+ }
263
+
264
+ case "kill-start": {
265
+ const rangeStart = action.multiLine
266
+ ? getLineStart(state.text, state.cursorOffset)
267
+ : 0;
268
+ if (rangeStart === state.cursorOffset) return state;
269
+
270
+ const nextText =
271
+ state.text.slice(0, rangeStart) + state.text.slice(state.cursorOffset);
272
+ return clearPreferredColumn({ ...state, text: nextText }, rangeStart);
273
+ }
274
+
275
+ case "kill-end": {
276
+ const lineEnd = action.multiLine
277
+ ? getLineEnd(state.text, state.cursorOffset)
278
+ : state.text.length;
279
+ // emacs convention: ctrl+k at EOL kills the trailing newline so the next line joins
280
+ const rangeEnd =
281
+ action.multiLine &&
282
+ lineEnd === state.cursorOffset &&
283
+ lineEnd < state.text.length
284
+ ? lineEnd + 1
285
+ : lineEnd;
286
+ if (rangeEnd === state.cursorOffset) return state;
287
+
288
+ const nextText =
289
+ state.text.slice(0, state.cursorOffset) + state.text.slice(rangeEnd);
290
+ return clearPreferredColumn(
291
+ { ...state, text: nextText },
292
+ state.cursorOffset,
293
+ );
294
+ }
295
+
296
+ case "set-text":
297
+ return {
298
+ text: action.text,
299
+ cursorOffset: action.text.length,
300
+ preferredColumn: undefined,
301
+ };
302
+
303
+ case "set-cursor":
304
+ return clearPreferredColumn(
305
+ state,
306
+ clamp(action.cursorOffset, 0, state.text.length),
307
+ );
308
+ }
309
+ };
310
+
311
+ export const createTextBufferState = (text = ""): TextBufferState => ({
312
+ text,
313
+ cursorOffset: text.length,
314
+ preferredColumn: undefined,
315
+ });
316
+
317
+ export const useTextBuffer = (text = "") => {
318
+ const [state, dispatch] = useReducer(
319
+ textBufferReducer,
320
+ createTextBufferState(text),
321
+ );
322
+ const setText = useCallback(
323
+ (nextText: string) => dispatch({ type: "set-text", text: nextText }),
324
+ [],
325
+ );
326
+
327
+ return {
328
+ ...state,
329
+ dispatchAction: dispatch,
330
+ setText,
331
+ };
332
+ };