tuiuiu.js 1.0.43 → 1.0.44

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.
@@ -64,6 +64,10 @@ export interface TextInputOptions {
64
64
  width?: number;
65
65
  /** Maximum number of lines to display (for textarea mode) */
66
66
  maxLines?: number;
67
+ /** Auto-grow height based on visual line count (up to maxLines) */
68
+ autoGrow?: boolean;
69
+ /** Show scrollbar when content exceeds maxLines (default: true) */
70
+ showScrollbar?: boolean;
67
71
  /** Show character count */
68
72
  showCharCount?: boolean;
69
73
  /** Enable word wrapping */
@@ -90,6 +94,15 @@ export declare function createTextInput(options?: TextInputOptions): {
90
94
  viewportOffset: () => number;
91
95
  isMultiline: () => boolean;
92
96
  handleInput: (input: string, key: Key) => void;
97
+ setLayout: (layout: {
98
+ width?: number;
99
+ wordWrap?: boolean;
100
+ maxLines?: number;
101
+ autoGrow?: boolean;
102
+ }) => void;
103
+ setCursorPosition: (pos: number, options?: {
104
+ scroll?: boolean;
105
+ }) => void;
93
106
  setValue: (v: string) => void;
94
107
  clear: () => void;
95
108
  focus: () => void;
@@ -1 +1 @@
1
- {"version":3,"file":"text-input.d.ts","sourceRoot":"","sources":["../../src/atoms/text-input.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAE/C,OAAO,EAAY,KAAK,GAAG,EAAE,MAAM,mBAAmB,CAAC;AAIvD,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,OAAO,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,oBAAoB;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,6BAA6B;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sCAAsC;IACtC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,yCAAyC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,iBAAiB;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2CAA2C;IAC3C,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,6BAA6B;IAC7B,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,+BAA+B;IAC/B,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,gCAAgC;IAChC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,uFAAuF;IACvF,QAAQ,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,CAAC;IACrC,mBAAmB;IACnB,WAAW,CAAC,EAAE,OAAO,GAAG,WAAW,GAAG,KAAK,CAAC;IAC5C,mBAAmB;IACnB,WAAW,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;IAC9D,gCAAgC;IAChC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,kCAAkC;IAClC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,uBAAuB;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mBAAmB;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,sDAAsD;IACtD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2BAA2B;IAC3B,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qDAAqD;IACrD,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED,UAAU,UAAU;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,UAAU,EAAE,CA8EvF;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,OAAO,GAAE,gBAAqB;;;;;yBAuGhC,MAAM,OAAO,GAAG;kBAyO5B,MAAM;;;EAgBvB;AAED;;GAEG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,UAAU,CAAC,OAAO,eAAe,CAAC,EACzC,OAAO,GAAE,gBAAqB,GAC7B,KAAK,CAgLP;AAED,YAAY,EAAE,gBAAgB,IAAI,cAAc,EAAE,CAAC;AAEnD;;GAEG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,KAAK,CAG1D"}
1
+ {"version":3,"file":"text-input.d.ts","sourceRoot":"","sources":["../../src/atoms/text-input.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,KAAK,EAAE,KAAK,EAAkB,MAAM,mBAAmB,CAAC;AAE/D,OAAO,EAAY,KAAK,GAAG,EAAE,MAAM,mBAAmB,CAAC;AAKvD,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,OAAO,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,oBAAoB;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,6BAA6B;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sCAAsC;IACtC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,yCAAyC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,iBAAiB;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2CAA2C;IAC3C,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,6BAA6B;IAC7B,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,+BAA+B;IAC/B,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,gCAAgC;IAChC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,uFAAuF;IACvF,QAAQ,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,CAAC;IACrC,mBAAmB;IACnB,WAAW,CAAC,EAAE,OAAO,GAAG,WAAW,GAAG,KAAK,CAAC;IAC5C,mBAAmB;IACnB,WAAW,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;IAC9D,gCAAgC;IAChC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,kCAAkC;IAClC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,uBAAuB;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mBAAmB;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,sDAAsD;IACtD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mEAAmE;IACnE,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,2BAA2B;IAC3B,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qDAAqD;IACrD,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED,UAAU,UAAU;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,UAAU,EAAE,CA8EvF;AAiBD;;GAEG;AACH,wBAAgB,eAAe,CAAC,OAAO,GAAE,gBAAqB;;;;;yBAwKhC,MAAM,OAAO,GAAG;wBAjIjB;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;KAAE;6BAgY7E,MAAM,YAAY;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE;kBAGjD,MAAM;;;EAevB;AAED;;GAEG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,UAAU,CAAC,OAAO,eAAe,CAAC,EACzC,OAAO,GAAE,gBAAqB,GAC7B,KAAK,CAsRP;AAED,YAAY,EAAE,gBAAgB,IAAI,cAAc,EAAE,CAAC;AAEnD;;GAEG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,KAAK,CAG1D"}
@@ -18,7 +18,8 @@ import { Box, Text } from '../primitives/nodes.js';
18
18
  import { createSignal } from '../primitives/signal.js';
19
19
  import { useInput } from '../hooks/index.js';
20
20
  import { getTheme, getContrastColor } from '../core/theme.js';
21
- import { getChars } from '../core/capabilities.js';
21
+ import { getChars, getRenderMode } from '../core/capabilities.js';
22
+ import { stringWidth } from '../utils/text-utils.js';
22
23
  /**
23
24
  * Calculate visual lines based on width and wrapping
24
25
  * Preserves strict whitespace for editor behavior
@@ -89,12 +90,31 @@ export function getVisualLines(text, width, wrap) {
89
90
  }
90
91
  return lines;
91
92
  }
93
+ function getCursorIndexFromColumn(text, column) {
94
+ if (column <= 0)
95
+ return 0;
96
+ let width = 0;
97
+ let index = 0;
98
+ for (const char of text) {
99
+ const charWidth = stringWidth(char);
100
+ if (width + charWidth > column)
101
+ break;
102
+ width += charWidth;
103
+ index += char.length;
104
+ }
105
+ return index;
106
+ }
92
107
  /**
93
108
  * Create a TextInput state manager
94
109
  */
95
110
  export function createTextInput(options = {}) {
96
111
  const { initialValue = '', placeholder = '', password = false, maskChar = '*', multiline = false, maxLength, history = [], onChange, onSubmit, onCancel, isActive: isActiveProp = true, width = 80, // Default width for wrapping
97
- maxLines, wordWrap = false, enterCreatesNewline = false, } = options;
112
+ maxLines, autoGrow = false, wordWrap = false, enterCreatesNewline = false, } = options;
113
+ let wrapWidth = width;
114
+ let wrapWordWrap = wordWrap;
115
+ let wrapMaxLines = maxLines;
116
+ let wrapAutoGrow = autoGrow;
117
+ let resolvedMaxLines = wrapAutoGrow ? (wrapMaxLines ?? 5) : wrapMaxLines;
98
118
  // Helper to check if input is currently active
99
119
  // Supports both static boolean and reactive getter function
100
120
  const checkIsActive = () => {
@@ -106,11 +126,61 @@ export function createTextInput(options = {}) {
106
126
  const [historyIndex, setHistoryIndex] = createSignal(-1);
107
127
  const [originalValue, setOriginalValue] = createSignal('');
108
128
  const [viewportOffset, setViewportOffset] = createSignal(0);
129
+ const [preferredColumn, setPreferredColumn] = createSignal(null);
130
+ const setLayout = (layout) => {
131
+ if (typeof layout.width === 'number') {
132
+ wrapWidth = Math.max(1, layout.width);
133
+ }
134
+ if (typeof layout.wordWrap === 'boolean') {
135
+ wrapWordWrap = layout.wordWrap;
136
+ }
137
+ if (layout.maxLines !== undefined) {
138
+ wrapMaxLines = layout.maxLines;
139
+ }
140
+ if (typeof layout.autoGrow === 'boolean') {
141
+ wrapAutoGrow = layout.autoGrow;
142
+ }
143
+ resolvedMaxLines = wrapAutoGrow ? (wrapMaxLines ?? 5) : wrapMaxLines;
144
+ };
145
+ const getCursorLineInfo = (val, cursor) => {
146
+ const visualLines = getVisualLines(val, wrapWidth, wrapWordWrap);
147
+ let lineIndex = 0;
148
+ let lineStart = visualLines[0]?.start ?? 0;
149
+ let lineText = visualLines[0]?.text ?? '';
150
+ for (let i = 0; i < visualLines.length; i++) {
151
+ const line = visualLines[i];
152
+ if (cursor >= line.start && cursor <= line.end) {
153
+ lineIndex = i;
154
+ lineStart = line.start;
155
+ lineText = line.text;
156
+ if (cursor < line.end || i === visualLines.length - 1) {
157
+ break;
158
+ }
159
+ }
160
+ }
161
+ const column = stringWidth(lineText.slice(0, Math.max(0, cursor - lineStart)));
162
+ return { visualLines, lineIndex, lineStart, lineText, column };
163
+ };
164
+ const updatePreferredColumn = (val, cursor) => {
165
+ const { column } = getCursorLineInfo(val, cursor);
166
+ setPreferredColumn(column);
167
+ };
168
+ const setCursorPositionInternal = (pos, options) => {
169
+ const current = value();
170
+ const clamped = Math.max(0, Math.min(pos, current.length));
171
+ setCursorPosition(clamped);
172
+ if (options?.updatePreferredColumn !== false) {
173
+ updatePreferredColumn(current, clamped);
174
+ }
175
+ if (options?.scroll !== false) {
176
+ scrollToCursor(current, clamped);
177
+ }
178
+ };
109
179
  // Scroll to cursor helper
110
180
  const scrollToCursor = (val, cursor) => {
111
- if (!maxLines)
181
+ if (!resolvedMaxLines)
112
182
  return; // No scrolling needed if no max height
113
- const visualLines = getVisualLines(val, width, wordWrap);
183
+ const visualLines = getVisualLines(val, wrapWidth, wrapWordWrap);
114
184
  // Find visual line containing cursor
115
185
  let cursorLineFn = 0;
116
186
  // Fallback if cursor is at very end
@@ -148,8 +218,8 @@ export function createTextInput(options = {}) {
148
218
  if (cursorLineFn < currentOffset) {
149
219
  setViewportOffset(cursorLineFn);
150
220
  }
151
- else if (cursorLineFn >= currentOffset + maxLines) {
152
- setViewportOffset(cursorLineFn - maxLines + 1);
221
+ else if (cursorLineFn >= currentOffset + resolvedMaxLines) {
222
+ setViewportOffset(cursorLineFn - resolvedMaxLines + 1);
153
223
  }
154
224
  };
155
225
  // Word boundary detection
@@ -195,9 +265,8 @@ export function createTextInput(options = {}) {
195
265
  const newValue = currentValue.slice(0, pos) + '\n' + currentValue.slice(pos);
196
266
  setValue(newValue);
197
267
  const newPos = pos + 1;
198
- setCursorPosition(newPos);
268
+ setCursorPositionInternal(newPos);
199
269
  setIsMultilineMode(true);
200
- scrollToCursor(newValue, newPos);
201
270
  onChange?.(newValue);
202
271
  return;
203
272
  }
@@ -209,9 +278,8 @@ export function createTextInput(options = {}) {
209
278
  const newValue = currentValue.slice(0, pos) + '\n' + currentValue.slice(pos);
210
279
  setValue(newValue);
211
280
  const newPos = pos + 1;
212
- setCursorPosition(newPos);
281
+ setCursorPositionInternal(newPos);
213
282
  setIsMultilineMode(true);
214
- scrollToCursor(newValue, newPos);
215
283
  onChange?.(newValue);
216
284
  }
217
285
  else {
@@ -231,13 +299,11 @@ export function createTextInput(options = {}) {
231
299
  if (key.ctrl) {
232
300
  // Move to previous word boundary
233
301
  const newPos = findPrevWordBoundary(currentValue, pos);
234
- setCursorPosition(newPos);
235
- scrollToCursor(currentValue, newPos);
302
+ setCursorPositionInternal(newPos);
236
303
  }
237
304
  else {
238
305
  const newPos = Math.max(0, pos - 1);
239
- setCursorPosition(newPos);
240
- scrollToCursor(currentValue, newPos);
306
+ setCursorPositionInternal(newPos);
241
307
  }
242
308
  return;
243
309
  }
@@ -245,59 +311,83 @@ export function createTextInput(options = {}) {
245
311
  if (key.ctrl) {
246
312
  // Move to next word boundary
247
313
  const newPos = findNextWordBoundary(currentValue, pos);
248
- setCursorPosition(newPos);
249
- scrollToCursor(currentValue, newPos);
314
+ setCursorPositionInternal(newPos);
250
315
  }
251
316
  else {
252
317
  const newPos = Math.min(currentValue.length, pos + 1);
253
- setCursorPosition(newPos);
254
- scrollToCursor(currentValue, newPos);
318
+ setCursorPositionInternal(newPos);
255
319
  }
256
320
  return;
257
321
  }
258
322
  // Home/End or Ctrl+A/E
259
323
  if (key.home || (key.ctrl && input === 'a')) {
260
- setCursorPosition(0);
261
- scrollToCursor(currentValue, 0);
324
+ setCursorPositionInternal(0);
262
325
  return;
263
326
  }
264
327
  if (key.end || (key.ctrl && input === 'e')) {
265
328
  const newPos = currentValue.length;
266
- setCursorPosition(newPos);
267
- scrollToCursor(currentValue, newPos);
268
- return;
269
- }
270
- // History navigation
271
- if (key.upArrow && history.length > 0) {
272
- const currentIndex = historyIndex();
273
- if (currentIndex === -1) {
274
- setOriginalValue(currentValue);
275
- }
276
- const newIndex = Math.min(currentIndex + 1, history.length - 1);
277
- setHistoryIndex(newIndex);
278
- const historyValue = history[history.length - 1 - newIndex];
279
- setValue(historyValue);
280
- setCursorPosition(historyValue.length);
281
- onChange?.(historyValue);
329
+ setCursorPositionInternal(newPos);
282
330
  return;
283
331
  }
284
- if (key.downArrow && historyIndex() >= 0) {
285
- const newIndex = historyIndex() - 1;
286
- setHistoryIndex(newIndex);
287
- if (newIndex < 0) {
288
- const orig = originalValue();
289
- setValue(orig);
290
- setCursorPosition(orig.length);
291
- onChange?.(orig);
332
+ if (key.upArrow) {
333
+ const { visualLines, lineIndex, column } = getCursorLineInfo(currentValue, pos);
334
+ const targetColumn = preferredColumn() ?? column;
335
+ if (lineIndex > 0) {
336
+ const targetLine = visualLines[lineIndex - 1];
337
+ const offset = getCursorIndexFromColumn(targetLine.text, targetColumn);
338
+ const newPos = Math.min(currentValue.length, targetLine.start + offset);
339
+ if (preferredColumn() === null) {
340
+ setPreferredColumn(targetColumn);
341
+ }
342
+ setCursorPositionInternal(newPos, { updatePreferredColumn: false });
343
+ return;
292
344
  }
293
- else {
345
+ if (history.length > 0) {
346
+ const currentIndex = historyIndex();
347
+ if (currentIndex === -1) {
348
+ setOriginalValue(currentValue);
349
+ }
350
+ const newIndex = Math.min(currentIndex + 1, history.length - 1);
351
+ setHistoryIndex(newIndex);
294
352
  const historyValue = history[history.length - 1 - newIndex];
295
353
  setValue(historyValue);
296
- setCursorPosition(historyValue.length);
354
+ setCursorPositionInternal(historyValue.length);
297
355
  onChange?.(historyValue);
298
356
  }
299
357
  return;
300
358
  }
359
+ if (key.downArrow) {
360
+ const { visualLines, lineIndex, column } = getCursorLineInfo(currentValue, pos);
361
+ const targetColumn = preferredColumn() ?? column;
362
+ if (lineIndex < visualLines.length - 1) {
363
+ const targetLine = visualLines[lineIndex + 1];
364
+ const offset = getCursorIndexFromColumn(targetLine.text, targetColumn);
365
+ const newPos = Math.min(currentValue.length, targetLine.start + offset);
366
+ if (preferredColumn() === null) {
367
+ setPreferredColumn(targetColumn);
368
+ }
369
+ setCursorPositionInternal(newPos, { updatePreferredColumn: false });
370
+ return;
371
+ }
372
+ if (historyIndex() >= 0) {
373
+ const newIndex = historyIndex() - 1;
374
+ setHistoryIndex(newIndex);
375
+ if (newIndex < 0) {
376
+ const orig = originalValue();
377
+ setValue(orig);
378
+ setCursorPositionInternal(orig.length);
379
+ onChange?.(orig);
380
+ }
381
+ else {
382
+ const historyValue = history[history.length - 1 - newIndex];
383
+ setValue(historyValue);
384
+ setCursorPositionInternal(historyValue.length);
385
+ onChange?.(historyValue);
386
+ }
387
+ }
388
+ return;
389
+ }
390
+ // History navigation
301
391
  // Deletion
302
392
  if (key.backspace) {
303
393
  if (pos > 0) {
@@ -306,14 +396,14 @@ export function createTextInput(options = {}) {
306
396
  const boundary = findPrevWordBoundary(currentValue, pos);
307
397
  const newValue = currentValue.slice(0, boundary) + currentValue.slice(pos);
308
398
  setValue(newValue);
309
- setCursorPosition(boundary);
399
+ setCursorPositionInternal(boundary);
310
400
  onChange?.(newValue);
311
401
  }
312
402
  else {
313
403
  // Delete single char
314
404
  const newValue = currentValue.slice(0, pos - 1) + currentValue.slice(pos);
315
405
  setValue(newValue);
316
- setCursorPosition(pos - 1);
406
+ setCursorPositionInternal(pos - 1);
317
407
  onChange?.(newValue);
318
408
  }
319
409
  }
@@ -352,7 +442,7 @@ export function createTextInput(options = {}) {
352
442
  const startPos = lineStart >= 0 ? lineStart + 1 : 0;
353
443
  const newValue = currentValue.slice(0, startPos) + currentValue.slice(pos);
354
444
  setValue(newValue);
355
- setCursorPosition(startPos);
445
+ setCursorPositionInternal(startPos);
356
446
  onChange?.(newValue);
357
447
  return;
358
448
  }
@@ -361,14 +451,14 @@ export function createTextInput(options = {}) {
361
451
  const boundary = findPrevWordBoundary(currentValue, pos);
362
452
  const newValue = currentValue.slice(0, boundary) + currentValue.slice(pos);
363
453
  setValue(newValue);
364
- setCursorPosition(boundary);
454
+ setCursorPositionInternal(boundary);
365
455
  onChange?.(newValue);
366
456
  return;
367
457
  }
368
458
  // Ctrl+X - clear all (like Ctrl+C in some terminals)
369
459
  if (key.ctrl && input === 'x') {
370
460
  setValue('');
371
- setCursorPosition(0);
461
+ setCursorPositionInternal(0);
372
462
  onChange?.('');
373
463
  return;
374
464
  }
@@ -383,12 +473,9 @@ export function createTextInput(options = {}) {
383
473
  }
384
474
  const newValue = currentValue.slice(0, pos) + input + currentValue.slice(pos);
385
475
  setValue(newValue);
386
- setCursorPosition(pos + input.length);
476
+ setCursorPositionInternal(pos + input.length);
387
477
  setHistoryIndex(-1);
388
478
  onChange?.(newValue);
389
- // Update scroll on input
390
- const newPos = pos + input.length;
391
- scrollToCursor(newValue, newPos);
392
479
  }
393
480
  };
394
481
  // handleInput is exposed to be registered during render phase
@@ -399,15 +486,18 @@ export function createTextInput(options = {}) {
399
486
  viewportOffset,
400
487
  isMultiline: isMultilineMode,
401
488
  handleInput, // Expose handler to be registered during render
489
+ setLayout,
490
+ setCursorPosition: (pos, options) => {
491
+ setCursorPositionInternal(pos, options);
492
+ },
402
493
  setValue: (v) => {
403
494
  setValue(v);
404
- setCursorPosition(v.length);
405
- scrollToCursor(v, v.length);
495
+ setCursorPositionInternal(v.length);
406
496
  onChange?.(v);
407
497
  },
408
498
  clear: () => {
409
499
  setValue('');
410
- setCursorPosition(0);
500
+ setCursorPositionInternal(0);
411
501
  setHistoryIndex(-1);
412
502
  onChange?.('');
413
503
  },
@@ -422,6 +512,9 @@ export function createTextInput(options = {}) {
422
512
  export function renderTextInput(state, options = {}) {
423
513
  const theme = getTheme();
424
514
  const { placeholder = 'Type here...', password = false, maskChar = '*', cursorStyle = 'block', borderStyle = 'round', focusedBorderColor = theme.accents.info, unfocusedBorderColor = theme.borders.default, prompt = getChars().arrows.right, foreground = theme.accents.info, isActive = true, fullWidth = false, } = options;
515
+ const autoGrow = options.autoGrow ?? false;
516
+ const showScrollbar = options.showScrollbar ?? true;
517
+ const resolvedMaxLines = autoGrow ? (options.maxLines ?? 5) : options.maxLines;
425
518
  // Register input handler during render phase
426
519
  useInput(state.handleInput);
427
520
  const value = state.value();
@@ -431,7 +524,7 @@ export function renderTextInput(state, options = {}) {
431
524
  // Use passed specific wrapping options or defaults
432
525
  const width = options.width ?? 80;
433
526
  const wordWrap = options.wordWrap ?? false;
434
- const maxLines = options.maxLines;
527
+ const maxLines = resolvedMaxLines;
435
528
  const showCharCount = options.showCharCount ?? false;
436
529
  // Build the input display with cursor
437
530
  const beforeCursor = displayValue.slice(0, cursor);
@@ -452,6 +545,13 @@ export function renderTextInput(state, options = {}) {
452
545
  boxStyle.borderColor = isActive ? focusedBorderColor : unfocusedBorderColor;
453
546
  boxStyle.paddingX = 1;
454
547
  }
548
+ const baseContentWidth = Math.max(1, (noBorder ? width - 2 : width - 6));
549
+ state.setLayout?.({
550
+ width: baseContentWidth,
551
+ wordWrap,
552
+ maxLines: options.maxLines,
553
+ autoGrow,
554
+ });
455
555
  const isMultiline = state.isMultiline() || displayValue.includes('\n') || options.multiline;
456
556
  // Use multiline path when any of these conditions are true
457
557
  const shouldUseMultiline = isMultiline || wordWrap || showCharCount;
@@ -461,16 +561,41 @@ export function renderTextInput(state, options = {}) {
461
561
  // Calculate actual content width for wrapping:
462
562
  // - Border: 2 chars (left + right)
463
563
  // - paddingX: 2 chars (left + right)
464
- // - Prompt "❯ " or "│ ": 2 chars
465
- const contentWidth = noBorder ? width - 2 : width - 6;
466
- const lines = getVisualLines(displayValue, contentWidth, wordWrap);
564
+ // - Prompt plus space, or border plus space: 2 chars
565
+ const baseWidth = baseContentWidth;
566
+ let contentWidth = baseWidth;
567
+ let lines = getVisualLines(displayValue, contentWidth, wordWrap);
568
+ const hasOverflow = maxLines !== undefined && lines.length > maxLines;
569
+ const shouldShowScrollbar = showScrollbar && hasOverflow;
570
+ if (shouldShowScrollbar) {
571
+ contentWidth = Math.max(1, baseWidth - 1);
572
+ lines = getVisualLines(displayValue, contentWidth, wordWrap);
573
+ state.setLayout?.({
574
+ width: contentWidth,
575
+ wordWrap,
576
+ maxLines: options.maxLines,
577
+ autoGrow,
578
+ });
579
+ }
467
580
  // Viewport handling
581
+ const visibleCount = maxLines ? Math.min(maxLines, lines.length) : lines.length;
582
+ const maxOffset = Math.max(0, lines.length - visibleCount);
468
583
  let offset = state.viewportOffset();
584
+ if (offset > maxOffset) {
585
+ offset = maxOffset;
586
+ }
587
+ else if (offset < 0) {
588
+ offset = 0;
589
+ }
469
590
  let visibleLines = lines;
470
591
  // If maxLines is set, slice the lines
471
592
  if (maxLines) {
472
- visibleLines = lines.slice(offset, offset + maxLines);
593
+ visibleLines = lines.slice(offset, offset + visibleCount);
473
594
  }
595
+ const padForScrollbar = shouldShowScrollbar;
596
+ const borderSize = noBorder ? 0 : 1;
597
+ const contentStartX = noBorder ? 0 : 2;
598
+ const contentStartY = borderSize;
474
599
  // Determine visual cursor position relative to viewport
475
600
  // We need to match the cursor index to a line and col
476
601
  let cursorLine = -1;
@@ -488,26 +613,66 @@ export function renderTextInput(state, options = {}) {
488
613
  }
489
614
  // Adjust cursorLine to be relative to visible window
490
615
  const relativeCursorLine = cursorLine - offset;
616
+ const chars = getChars();
617
+ const renderMode = getRenderMode();
618
+ const trackChar = renderMode === 'ascii' ? '|' : chars.scrollbar.track;
619
+ const thumbChar = renderMode === 'ascii' ? '#' : chars.scrollbar.thumb;
620
+ const scrollbarChars = [];
621
+ if (shouldShowScrollbar && visibleCount > 0) {
622
+ const thumbHeight = Math.max(1, Math.floor((visibleCount / lines.length) * visibleCount));
623
+ const thumbPosition = maxOffset > 0
624
+ ? Math.floor((offset / maxOffset) * (visibleCount - thumbHeight))
625
+ : 0;
626
+ for (let i = 0; i < visibleCount; i++) {
627
+ scrollbarChars.push(i >= thumbPosition && i < thumbPosition + thumbHeight ? thumbChar : trackChar);
628
+ }
629
+ }
630
+ const renderScrollbar = (index, isThumb) => {
631
+ if (!shouldShowScrollbar)
632
+ return null;
633
+ const color = isThumb
634
+ ? (isActive ? focusedBorderColor : unfocusedBorderColor)
635
+ : unfocusedBorderColor;
636
+ return Text({ color }, scrollbarChars[index] ?? ' ');
637
+ };
638
+ const handleMultilineClick = (event) => {
639
+ const lineIndex = event.y - contentStartY;
640
+ if (lineIndex < 0 || lineIndex >= visibleLines.length)
641
+ return;
642
+ const lineObj = visibleLines[lineIndex];
643
+ const linePrompt = lineIndex === 0 && offset === 0 ? prompt : chars.border.vertical;
644
+ const promptWidth = stringWidth(`${linePrompt} `);
645
+ const column = event.x - contentStartX - promptWidth;
646
+ const cursorOffset = getCursorIndexFromColumn(lineObj.text, column);
647
+ const nextCursor = Math.min(value.length, lineObj.start + cursorOffset);
648
+ state.setCursorPosition(nextCursor, { scroll: false });
649
+ };
491
650
  const renderedLines = visibleLines.map((lineObj, i) => {
492
651
  const lineIndex = offset + i; // Absolute line index
493
652
  const isCursorLine = lineIndex === cursorLine;
494
653
  const line = lineObj.text;
495
- const linePrompt = i === 0 && offset === 0 ? prompt : getChars().border.vertical;
654
+ const linePrompt = i === 0 && offset === 0 ? prompt : chars.border.vertical;
655
+ const scrollbarChar = shouldShowScrollbar ? (scrollbarChars[i] ?? ' ') : '';
656
+ const isThumb = shouldShowScrollbar ? scrollbarChar === thumbChar : false;
496
657
  if (isCursorLine && isActive) {
497
658
  // Ensure cursorCol is within bounds of this line
498
659
  const safeCol = Math.min(Math.max(0, cursorCol), line.length);
499
660
  const before = line.slice(0, safeCol);
500
661
  const char = line[safeCol] || ' ';
501
662
  const after = line.slice(safeCol + 1);
502
- return Box({ flexDirection: 'row' }, Text({ color: foreground }, `${linePrompt} `), Text({}, before), Text({ backgroundColor: cursorBg, color: cursorFg }, char), Text({}, after));
663
+ const lineWidth = stringWidth(before + char + after);
664
+ const padCount = padForScrollbar ? Math.max(0, contentWidth - lineWidth) : 0;
665
+ return Box({ flexDirection: 'row' }, Text({ color: foreground }, `${linePrompt} `), Text({}, before), Text({ backgroundColor: cursorBg, color: cursorFg }, char), Text({}, after), padCount > 0 ? Text({}, ' '.repeat(padCount)) : null, renderScrollbar(i, isThumb));
503
666
  }
504
- return Box({ flexDirection: 'row' }, Text({ color: foreground }, `${linePrompt} `), Text({}, line));
667
+ const lineWidth = stringWidth(line);
668
+ const padCount = padForScrollbar ? Math.max(0, contentWidth - lineWidth) : 0;
669
+ return Box({ flexDirection: 'row' }, Text({ color: foreground }, `${linePrompt} `), Text({}, line), padCount > 0 ? Text({}, ' '.repeat(padCount)) : null, renderScrollbar(i, isThumb));
505
670
  });
506
671
  if (showCharCount) {
507
672
  const countText = `${value.length}${options.maxLength ? '/' + options.maxLength : ''}`;
508
- renderedLines.push(Box({ flexDirection: 'row', justifyContent: 'flex-end', paddingTop: 0 }, Text({ color: 'mutedForeground', dim: true }, countText)));
673
+ renderedLines.push(Box({ flexDirection: 'row', justifyContent: 'flex-end', paddingTop: 0 }, Text({ color: 'mutedForeground', dim: true }, countText), shouldShowScrollbar ? Text({}, ' ') : null));
509
674
  }
510
- return Box(boxStyle, ...renderedLines);
675
+ return Box({ ...boxStyle, onClick: handleMultilineClick }, ...renderedLines);
511
676
  }
512
677
  // Single line - simple row layout
513
678
  const rowStyle = {
@@ -519,7 +684,20 @@ export function renderTextInput(state, options = {}) {
519
684
  rowStyle.borderColor = isActive ? focusedBorderColor : unfocusedBorderColor;
520
685
  rowStyle.paddingX = 1;
521
686
  }
522
- return Box(rowStyle, Text({ color: foreground }, `${prompt} `), showPlaceholder
687
+ const handleSingleLineClick = (event) => {
688
+ const borderSize = noBorder ? 0 : 1;
689
+ const contentStartX = noBorder ? 0 : 2;
690
+ const contentStartY = borderSize;
691
+ const lineIndex = event.y - contentStartY;
692
+ if (lineIndex !== 0)
693
+ return;
694
+ const promptWidth = stringWidth(`${prompt} `);
695
+ const column = event.x - contentStartX - promptWidth;
696
+ const cursorOffset = getCursorIndexFromColumn(displayValue, column);
697
+ const nextCursor = Math.min(displayValue.length, cursorOffset);
698
+ state.setCursorPosition(nextCursor, { scroll: false });
699
+ };
700
+ return Box({ ...rowStyle, onClick: handleSingleLineClick }, Text({ color: foreground }, `${prompt} `), showPlaceholder
523
701
  ? Box({ flexDirection: 'row', flexGrow: fullWidth ? 1 : 0 }, Text({ color: 'mutedForeground', dim: true }, placeholder), isActive ? Text({ backgroundColor: cursorBg, color: cursorFg }, ' ') : Text({}, ''))
524
702
  : Box({ flexDirection: 'row', flexGrow: fullWidth ? 1 : 0 }, Text({}, beforeCursor), isActive
525
703
  ? Text({ backgroundColor: cursorBg, color: cursorFg }, cursorChar)