ink-cartridge 3.8.2 → 3.8.3

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.
package/README.md CHANGED
@@ -109,6 +109,7 @@ npx ink-cartridge init my-tui
109
109
  - [TextInput](docs/components/TextInput/TextInput-API.md)
110
110
  - [UncontrolledTextInput](docs/components/TextInput/UncontrolledTextInput-API.md)
111
111
  - [NumberInput](docs/components/NumberInput/NumberInput-API.md)
112
+ - [SearchBar](docs/components/SearchBar/SearchBar-API.md)
112
113
  - [SearchInput](docs/components/SearchInput/SearchInput-API.md)
113
114
  - [ConfirmDialog](docs/components/ConfirmDialog/ConfirmDialog-API.md)
114
115
  - [Spinner](docs/components/Spinner/Spinner-API.md)
@@ -164,9 +165,9 @@ npx ink-cartridge init my-tui
164
165
  - [closeDevTool](docs/dev-tool/closeDevTool-API.md)
165
166
  </details>
166
167
 
167
- ## Other
168
+ ## Examples
168
169
 
169
- The method `penetration` (formerly `blockedKey`) marks keys as transparent (pass-through). Renamed in v3.8.1 to eliminate the historical naming confusion.
170
+ Runnable demos for every component. See [examples/README.md](examples/README.md) for the full list and run commands.
170
171
 
171
172
  ## License
172
173
 
@@ -0,0 +1,3 @@
1
+ import React from "react";
2
+ import type { SearchBarItem, SearchBarProps } from "./search-bar-types.js";
3
+ export default function SearchBar<T, I extends SearchBarItem<T> = SearchBarItem<T>>({ focusId, width, items, onSubmit, selectBar: SelectBar, }: SearchBarProps<T, I>): React.JSX.Element;
@@ -0,0 +1,62 @@
1
+ import { Box, Text, useWindowSize } from "ink";
2
+ import React, { useState, useMemo, useEffect, useCallback } from "react";
3
+ import { TextInput } from "../text/TextInput.js";
4
+ import { useKeyboard } from "../../keyboard/hook.js";
5
+ /**
6
+ * Filter and sort items by query against their labels.
7
+ * Priority: exact match → prefix match → earlier substring position.
8
+ */
9
+ function filterAndSort(items, query) {
10
+ if (!query.trim())
11
+ return items;
12
+ const lower = query.toLowerCase();
13
+ return items
14
+ .filter((item) => item.label.toLowerCase().includes(lower))
15
+ .sort((a, b) => {
16
+ const aLower = a.label.toLowerCase();
17
+ const bLower = b.label.toLowerCase();
18
+ if (aLower === lower && bLower !== lower)
19
+ return -1;
20
+ if (aLower !== lower && bLower === lower)
21
+ return 1;
22
+ const aStarts = aLower.startsWith(lower);
23
+ const bStarts = bLower.startsWith(lower);
24
+ if (aStarts && !bStarts)
25
+ return -1;
26
+ if (!aStarts && bStarts)
27
+ return 1;
28
+ return aLower.indexOf(lower) - bLower.indexOf(lower);
29
+ });
30
+ }
31
+ export default function SearchBar({ focusId, width, items = [], onSubmit, selectBar: SelectBar, }) {
32
+ const [value, setValue] = useState("");
33
+ const { columns } = useWindowSize();
34
+ const { focusSet, boundKeyboard } = useKeyboard();
35
+ const inputWidth = width ?? Math.max(1, columns - 4);
36
+ const resultsFocusId = `${focusId}-results`;
37
+ // Filter and sort results whenever the query or items change.
38
+ const results = useMemo(() => filterAndSort(items, value), [items, value]);
39
+ /**
40
+ * When selectBar confirms a selection, fire onSubmit and return
41
+ * focus to the TextInput.
42
+ */
43
+ const handleSelect = useCallback((item) => {
44
+ onSubmit?.(item);
45
+ focusSet(focusId);
46
+ }, [onSubmit, focusSet, focusId]);
47
+ // Enter in TextInput switches focus to the selectBar.
48
+ // Registered at screen level so it fires after TextInput's focus-level
49
+ // bindings (TextInput does not bind 'return' when onSubmit is not passed).
50
+ useEffect(() => {
51
+ const unReturn = boundKeyboard(['return'], () => {
52
+ focusSet(resultsFocusId);
53
+ });
54
+ return unReturn;
55
+ }, [boundKeyboard, focusSet, resultsFocusId]);
56
+ return (React.createElement(Box, { flexDirection: "column", width: "100%", height: "100%", borderColor: "white", borderStyle: "bold" },
57
+ React.createElement(Box, { flexDirection: "row" },
58
+ React.createElement(Text, { bold: true }, "> "),
59
+ React.createElement(TextInput, { focusId: focusId, value: value, onChange: setValue, width: inputWidth })),
60
+ React.createElement(Text, { bold: true }, "-".repeat(inputWidth)),
61
+ React.createElement(SelectBar, { items: results, onSelect: handleSelect, focusId: resultsFocusId, query: value })));
62
+ }
@@ -0,0 +1,39 @@
1
+ import type React from "react";
2
+ export interface SearchBarItem<T> {
3
+ /** Display text shown in the results list */
4
+ label: string;
5
+ /** Value returned to onSubmit when this item is selected */
6
+ value: T;
7
+ /** Optional stable key for React reconciliation */
8
+ Key?: string;
9
+ }
10
+ export interface SearchBarProps<T, I extends SearchBarItem<T> = SearchBarItem<T>> {
11
+ /** Focus identifier for the TextInput in the keyboard system */
12
+ focusId: string;
13
+ /**
14
+ * Input area width in characters. When omitted, auto-detected from
15
+ * terminal dimensions (columns - 4 for border + prompt).
16
+ */
17
+ width?: number;
18
+ /** Items to search through. Filtered and sorted by label as the user types. */
19
+ items?: I[];
20
+ /**
21
+ * Called when the user confirms a selection via the selectBar.
22
+ * Receives the selected item.
23
+ */
24
+ onSubmit?: (item: I) => void;
25
+ /**
26
+ * Component that renders the filtered results and handles selection.
27
+ * Receives filtered items, an onSelect callback, a focusId for keyboard
28
+ * integration, and the current query string.
29
+ *
30
+ * Must be a component that registers its own keyboard bindings under the
31
+ * given focusId (e.g. SelectInput).
32
+ */
33
+ selectBar: React.ComponentType<{
34
+ items: I[];
35
+ onSelect: (item: I) => void;
36
+ focusId: string;
37
+ query: string;
38
+ }>;
39
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -3,11 +3,14 @@ import { Box, Text } from 'ink';
3
3
  import { TextInput } from '../text/TextInput.js';
4
4
  import { useKeyboard } from '../../keyboard/index.js';
5
5
  export function SearchInput({ focusId, value, onChange, placeholder, onSubmit, }) {
6
- const { boundKeyboard } = useKeyboard();
6
+ const { boundKeyboard, focusUnregister } = useKeyboard();
7
7
  useEffect(() => {
8
8
  const unEsc = boundKeyboard(['escape'], () => onChange(''), { focusId });
9
- return () => { unEsc(); };
10
- }, [focusId, onChange, boundKeyboard]);
9
+ return () => {
10
+ unEsc();
11
+ focusUnregister(focusId);
12
+ };
13
+ }, [focusId, onChange, boundKeyboard, focusUnregister]);
11
14
  return (React.createElement(Box, null,
12
15
  React.createElement(Text, { color: "blue" }, "Search "),
13
16
  React.createElement(TextInput, { focusId: focusId, value: value, onChange: onChange, placeholder: placeholder, onSubmit: onSubmit }),
@@ -1,39 +1,7 @@
1
1
  import React from 'react';
2
2
  import type { TextInputProps, UncontrolledTextInputProps } from './types.js';
3
- /**
4
- * Controlled text input component integrated with the keyboard focus system.
5
- *
6
- * Supports:
7
- * - Arrow keys to move cursor
8
- * - Backspace/Delete to remove characters
9
- * - Regular character input (via wildcard '*')
10
- * - Optional mask (password mode)
11
- * - Paste highlighting (highlight the whole pasted block)
12
- * - Placeholder
13
- *
14
- * @example
15
- * ```tsx
16
- * const [name, setName] = useState('');
17
- * <TextInput
18
- * focusId="name-field"
19
- * value={name}
20
- * onChange={setName}
21
- * placeholder="Enter your name"
22
- * showCursor
23
- * />
24
- * ```
25
- */
26
- export declare function TextInput({ placeholder, mask, showCursor, highlightPastedText, value: originalValue, onChange, onSubmit, focusId, }: TextInputProps): React.FunctionComponentElement<import("ink").TextProps>;
3
+ export declare function TextInput({ placeholder, mask, showCursor, highlightPastedText, value: originalValue, onChange, onSubmit, focusId, wrap, width, }: TextInputProps): React.JSX.Element;
27
4
  /**
28
5
  * Uncontrolled text input component that manages its own internal state.
29
- *
30
- * @example
31
- * ```tsx
32
- * <UncontrolledTextInput
33
- * focusId="search"
34
- * initialValue="default"
35
- * onSubmit={(val) => console.log(val)}
36
- * />
37
- * ```
38
6
  */
39
- export declare function UncontrolledTextInput({ initialValue, storage, storageKey, ...props }: UncontrolledTextInputProps): React.FunctionComponentElement<TextInputProps>;
7
+ export declare function UncontrolledTextInput({ initialValue, storage, storageKey, ...props }: UncontrolledTextInputProps): React.JSX.Element;
@@ -1,34 +1,53 @@
1
1
  import React, { useState, useEffect, useCallback, useRef } from 'react';
2
- import { Text } from 'ink';
2
+ import { Text, Box, useWindowSize } from 'ink';
3
3
  import chalk from 'chalk';
4
4
  import { useKeyboard, useFocusState } from '../../keyboard/index.js';
5
- /**
6
- * 将给定字符重复多次
7
- */
8
5
  function repeatChar(char, count) {
9
6
  return Array(count + 1).join(char);
10
7
  }
11
8
  /**
12
- * 计算光标在可见字符串中的偏移量,并生成带高亮(反色)的渲染结果
9
+ * Split text into wrapped lines at maxWidth boundaries.
13
10
  */
11
+ function wrapLines(text, maxWidth) {
12
+ if (maxWidth <= 0 || text.length === 0)
13
+ return [''];
14
+ const lines = [];
15
+ for (let i = 0; i < text.length; i += maxWidth) {
16
+ lines.push(text.slice(i, i + maxWidth));
17
+ }
18
+ return lines;
19
+ }
20
+ /**
21
+ * Convert a flat cursor offset to {line, col} in wrap mode.
22
+ */
23
+ function offsetToLineCol(offset, maxWidth) {
24
+ if (maxWidth <= 0)
25
+ return { line: 0, col: 0 };
26
+ return {
27
+ line: Math.floor(offset / maxWidth),
28
+ col: offset % maxWidth,
29
+ };
30
+ }
31
+ /**
32
+ * Convert {line, col} back to a flat cursor offset, clamped to [0, textLength].
33
+ */
34
+ function lineColToOffset(line, col, maxWidth, textLength) {
35
+ const offset = line * maxWidth + col;
36
+ return Math.max(0, Math.min(offset, textLength));
37
+ }
14
38
  function renderWithCursor(value, placeholder, mask, showCursor, isFocused, cursorOffset, cursorWidth, highlightPastedText) {
15
- // 显示用的值:如果设置了掩码则每个字符替换为掩码字符
16
39
  const displayValue = mask ? repeatChar(mask, value.length) : value;
17
- // 未聚焦或不需要显示光标 → 直接返回纯文本(空时显示占位符)
18
40
  if (!showCursor || !isFocused) {
19
41
  if (displayValue.length === 0 && placeholder) {
20
42
  return chalk.grey(placeholder);
21
43
  }
22
44
  return displayValue;
23
45
  }
24
- // 聚焦且显示光标时的处理
25
- // 空值 + 有占位符 → 占位符第一个字符反色,其余灰色
26
46
  if (displayValue.length === 0 && placeholder) {
27
47
  if (placeholder.length === 0)
28
48
  return chalk.inverse(' ');
29
49
  return chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1));
30
50
  }
31
- // 实际高亮宽度(粘贴高亮时可能大于1)
32
51
  const actualHighlightWidth = highlightPastedText ? cursorWidth : 0;
33
52
  let result = '';
34
53
  for (let i = 0; i < displayValue.length; i++) {
@@ -37,84 +56,87 @@ function renderWithCursor(value, placeholder, mask, showCursor, isFocused, curso
37
56
  : i >= cursorOffset - actualHighlightWidth && i <= cursorOffset;
38
57
  result += isInHighlight ? chalk.inverse(displayValue[i]) : displayValue[i];
39
58
  }
40
- // 光标在末尾时追加一个反色空格
41
59
  if (cursorOffset === displayValue.length) {
42
60
  result += chalk.inverse(' ');
43
61
  }
44
62
  return result;
45
63
  }
46
- /**
47
- * Controlled text input component integrated with the keyboard focus system.
48
- *
49
- * Supports:
50
- * - Arrow keys to move cursor
51
- * - Backspace/Delete to remove characters
52
- * - Regular character input (via wildcard '*')
53
- * - Optional mask (password mode)
54
- * - Paste highlighting (highlight the whole pasted block)
55
- * - Placeholder
56
- *
57
- * @example
58
- * ```tsx
59
- * const [name, setName] = useState('');
60
- * <TextInput
61
- * focusId="name-field"
62
- * value={name}
63
- * onChange={setName}
64
- * placeholder="Enter your name"
65
- * showCursor
66
- * />
67
- * ```
68
- */
69
- export function TextInput({ placeholder = '', mask, showCursor = true, highlightPastedText = false, value: originalValue, onChange, onSubmit, focusId, }) {
64
+ export function TextInput({ placeholder = '', mask, showCursor = true, highlightPastedText = false, value: originalValue, onChange, onSubmit, focusId, wrap = false, width, }) {
70
65
  const isFocused = useFocusState(focusId);
71
66
  const { boundKeyboard, focusUnregister, enableWildcardPriority } = useKeyboard();
72
- // 光标位置(字符索引)
67
+ const { columns: terminalColumns } = useWindowSize();
68
+ // Available width: prop override or terminal width.
69
+ const availableWidth = Math.max(1, (width ?? terminalColumns) || 80);
70
+ // Virtual-scroll offset (only used when wrap=false).
71
+ const [scrollOffset, setScrollOffset] = useState(0);
72
+ // Cursor position (flat character index).
73
73
  const [cursorOffset, setCursorOffset] = useState(originalValue.length);
74
- // 粘贴高亮宽度(一次插入的字符数)
74
+ // Paste highlight width.
75
75
  const [cursorWidth, setCursorWidth] = useState(0);
76
- // 通配符优先模式的 disable 函数
76
+ // Wildcard priority disable function.
77
77
  const disablePriorityRef = useRef(null);
78
- // 当外部 value 缩短时,修正光标位置避免越界
78
+ // Clamp cursor when external value shortens.
79
79
  useEffect(() => {
80
80
  setCursorOffset((prev) => Math.min(prev, originalValue.length));
81
81
  }, [originalValue]);
82
+ // When wrap mode changes or text is cleared externally, reset scroll.
83
+ const isTextEmpty = originalValue.length === 0;
84
+ useEffect(() => {
85
+ setScrollOffset(0);
86
+ }, [wrap, isTextEmpty]);
82
87
  /**
83
- * 移动光标(左右箭头调用)
88
+ * Ensure the cursor is visible within the virtual-scroll window.
89
+ * No-op when wrap is enabled.
84
90
  */
91
+ const ensureVisible = useCallback((newOffset) => {
92
+ if (wrap)
93
+ return;
94
+ // Reserve 1 char per side for scroll indicators.
95
+ const visibleW = Math.max(1, availableWidth - 2);
96
+ const maxScroll = Math.max(0, originalValue.length - visibleW);
97
+ setScrollOffset((prev) => {
98
+ let next = prev;
99
+ if (newOffset < next)
100
+ next = newOffset;
101
+ if (newOffset >= next + visibleW)
102
+ next = newOffset - visibleW + 1;
103
+ return Math.max(0, Math.min(next, maxScroll));
104
+ });
105
+ }, [wrap, availableWidth, originalValue.length]);
85
106
  const moveCursor = useCallback((delta) => {
86
107
  if (!showCursor)
87
108
  return;
88
109
  setCursorOffset((prev) => {
89
110
  const next = prev + delta;
90
- if (next < 0)
91
- return 0;
92
- if (next > originalValue.length)
93
- return originalValue.length;
94
- return next;
111
+ const clamped = Math.max(0, Math.min(next, originalValue.length));
112
+ ensureVisible(clamped);
113
+ return clamped;
95
114
  });
96
- // 光标移动后清除粘贴高亮
97
115
  setCursorWidth(0);
98
- }, [showCursor, originalValue.length]);
116
+ }, [showCursor, originalValue.length, ensureVisible]);
99
117
  /**
100
- * 插入文本或删除字符
101
- * @param insertion 要插入的字符串,undefined 表示执行删除操作
102
- * @param isForwardDelete true 表示 Delete(删光标右侧),false/undefined 表示 Backspace(删光标左侧)
118
+ * Vertical cursor movement for wrap mode.
103
119
  */
120
+ const moveCursorVertical = useCallback((delta) => {
121
+ if (!showCursor || !wrap)
122
+ return;
123
+ const { line, col } = offsetToLineCol(cursorOffset, availableWidth);
124
+ const targetLine = Math.max(0, line + delta);
125
+ const newOffset = lineColToOffset(targetLine, col, availableWidth, originalValue.length);
126
+ setCursorOffset(newOffset);
127
+ setCursorWidth(0);
128
+ }, [showCursor, wrap, cursorOffset, availableWidth, originalValue.length]);
104
129
  const modifyText = useCallback((insertion, isForwardDelete = false) => {
105
130
  let newValue = originalValue;
106
131
  let newOffset = cursorOffset;
107
132
  if (insertion === undefined) {
108
- // 删除操作
133
+ // Delete.
109
134
  if (isForwardDelete && cursorOffset < originalValue.length) {
110
- // Delete:删除光标右侧一个字符
111
135
  newValue =
112
136
  originalValue.slice(0, cursorOffset) +
113
137
  originalValue.slice(cursorOffset + 1);
114
- // 光标位置不变
115
138
  }
116
139
  else if (!isForwardDelete && cursorOffset > 0) {
117
- // Backspace:删除光标左侧一个字符
118
140
  newValue =
119
141
  originalValue.slice(0, cursorOffset - 1) +
120
142
  originalValue.slice(cursorOffset);
@@ -125,17 +147,16 @@ export function TextInput({ placeholder = '', mask, showCursor = true, highlight
125
147
  }
126
148
  }
127
149
  else {
128
- // 插入
150
+ // Insert.
129
151
  newValue =
130
152
  originalValue.slice(0, cursorOffset) +
131
153
  insertion +
132
154
  originalValue.slice(cursorOffset);
133
155
  newOffset = cursorOffset + insertion.length;
134
156
  }
135
- // 边界保护
136
157
  newOffset = Math.max(0, Math.min(newOffset, newValue.length));
137
158
  setCursorOffset(newOffset);
138
- // 如果一次插入了多个字符且开启高亮,记录高亮宽度
159
+ ensureVisible(newOffset);
139
160
  if (insertion && insertion.length > 1 && highlightPastedText) {
140
161
  setCursorWidth(insertion.length);
141
162
  }
@@ -145,37 +166,39 @@ export function TextInput({ placeholder = '', mask, showCursor = true, highlight
145
166
  if (newValue !== originalValue) {
146
167
  onChange(newValue);
147
168
  }
148
- }, [originalValue, cursorOffset, onChange, highlightPastedText]);
149
- // 焦点目标生命周期 — focusId 变化时注销旧的,新的由绑定 effect 创建
169
+ }, [originalValue, cursorOffset, onChange, highlightPastedText, ensureVisible]);
170
+ // Focus lifecycle.
150
171
  useEffect(() => {
151
172
  return () => focusUnregister(focusId);
152
173
  }, [focusId, focusUnregister]);
153
- // 注册键盘绑定(仅在获得焦点时生效)
174
+ // Keyboard bindings.
154
175
  useEffect(() => {
155
176
  const fid = focusId;
156
177
  const unbindList = [];
157
- // 开启通配符最高优先级,确保所有普通字符输入优先被 TextInput 捕获
158
178
  disablePriorityRef.current?.();
159
179
  const disablePriority = enableWildcardPriority();
160
180
  disablePriorityRef.current = disablePriority;
161
- // 左右移动光标
181
+ // Left/right.
162
182
  unbindList.push(boundKeyboard(['left'], () => moveCursor(-1), { focusId: fid }));
163
183
  unbindList.push(boundKeyboard(['right'], () => moveCursor(1), { focusId: fid }));
164
- // 退格 / 删除
184
+ // Up/down (wrap mode only).
185
+ if (wrap) {
186
+ unbindList.push(boundKeyboard(['up'], () => moveCursorVertical(-1), { focusId: fid }));
187
+ unbindList.push(boundKeyboard(['down'], () => moveCursorVertical(1), { focusId: fid }));
188
+ }
189
+ // Backspace / Delete.
165
190
  unbindList.push(boundKeyboard(['backspace'], () => modifyText(), { focusId: fid }));
166
191
  unbindList.push(boundKeyboard(['delete'], () => modifyText(undefined, true), { focusId: fid }));
167
- // 回车提交提交时解除通配符最高优先级
192
+ // Entersubmit.
168
193
  if (onSubmit) {
169
194
  unbindList.push(boundKeyboard(['return'], () => {
170
- // 用户提交后解除通配符优先级,恢复正常按键分发
171
195
  disablePriority();
172
196
  disablePriorityRef.current = null;
173
197
  onSubmit(originalValue);
174
198
  }, { focusId: fid }));
175
199
  }
176
- // 通配符 '*':捕获所有普通字符输入
200
+ // Wildcard: capture all character input.
177
201
  unbindList.push(boundKeyboard(['*'], (input) => modifyText(input), { focusId: fid }));
178
- // 清理:解绑所有键盘回调并恢复通配符优先级
179
202
  return () => {
180
203
  unbindList.forEach((fn) => fn());
181
204
  if (disablePriorityRef.current) {
@@ -187,26 +210,55 @@ export function TextInput({ placeholder = '', mask, showCursor = true, highlight
187
210
  focusId,
188
211
  boundKeyboard,
189
212
  moveCursor,
213
+ moveCursorVertical,
190
214
  modifyText,
191
215
  onSubmit,
192
216
  originalValue,
193
217
  enableWildcardPriority,
218
+ wrap,
194
219
  ]);
195
- // 渲染最终显示的文本
196
- const rendered = renderWithCursor(originalValue, placeholder, mask, showCursor, isFocused, cursorOffset, cursorWidth, highlightPastedText);
197
- return React.createElement(Text, null, rendered);
220
+ // ── Render: wrap mode ──────────────────────────────────────────
221
+ if (wrap) {
222
+ const baseLines = wrapLines(originalValue, availableWidth);
223
+ const displayLines = baseLines.length === 0 ? [''] : [...baseLines];
224
+ const { line: cursorLine, col: cursorCol } = offsetToLineCol(cursorOffset, availableWidth);
225
+ // When empty and placeholder is set, show placeholder on first line.
226
+ if (originalValue.length === 0 && placeholder) {
227
+ return (React.createElement(Box, { flexDirection: "column" },
228
+ React.createElement(Text, null, renderWithCursor('', placeholder, mask, showCursor, isFocused, 0, 0, false))));
229
+ }
230
+ // Ensure the cursor always has a line to render on,
231
+ // even when it sits at the exact end of the last wrapped line.
232
+ while (displayLines.length <= cursorLine) {
233
+ displayLines.push('');
234
+ }
235
+ return (React.createElement(Box, { flexDirection: "column" }, displayLines.map((line, i) => {
236
+ if (i === cursorLine) {
237
+ return (React.createElement(Text, { key: i }, renderWithCursor(line, '', mask, showCursor, isFocused, cursorCol, cursorWidth, highlightPastedText)));
238
+ }
239
+ return React.createElement(Text, { key: i }, line);
240
+ })));
241
+ }
242
+ // ── Render: virtual-scroll mode ────────────────────────────────
243
+ if (originalValue.length === 0 && placeholder) {
244
+ return (React.createElement(Text, null, renderWithCursor('', placeholder, mask, showCursor, isFocused, 0, 0, false)));
245
+ }
246
+ if (originalValue.length <= availableWidth) {
247
+ return (React.createElement(Text, null, renderWithCursor(originalValue, '', mask, showCursor, isFocused, cursorOffset, cursorWidth, highlightPastedText)));
248
+ }
249
+ // Text is wider than available width — virtual scroll.
250
+ const visibleW = Math.max(1, availableWidth - 2);
251
+ const maxScroll = Math.max(0, originalValue.length - visibleW);
252
+ const windowStart = Math.max(0, Math.min(scrollOffset, maxScroll));
253
+ const windowText = originalValue.slice(windowStart, windowStart + visibleW);
254
+ const localCursor = cursorOffset - windowStart;
255
+ const textRendered = renderWithCursor(windowText, '', mask, showCursor, isFocused, localCursor, cursorWidth, highlightPastedText);
256
+ const leftIndicator = windowStart > 0 ? chalk.grey('←') : '';
257
+ const rightIndicator = windowStart + visibleW < originalValue.length ? chalk.grey('→') : '';
258
+ return React.createElement(Text, null, leftIndicator + textRendered + rightIndicator);
198
259
  }
199
260
  /**
200
261
  * Uncontrolled text input component that manages its own internal state.
201
- *
202
- * @example
203
- * ```tsx
204
- * <UncontrolledTextInput
205
- * focusId="search"
206
- * initialValue="default"
207
- * onSubmit={(val) => console.log(val)}
208
- * />
209
- * ```
210
262
  */
211
263
  export function UncontrolledTextInput({ initialValue = '', storage, storageKey, ...props }) {
212
264
  const [value, setValue] = useState(initialValue);
@@ -225,9 +277,5 @@ export function UncontrolledTextInput({ initialValue = '', storage, storageKey,
225
277
  setValue(newVal);
226
278
  storage?.write.str(persistKey, newVal);
227
279
  };
228
- return React.createElement(TextInput, {
229
- ...props,
230
- value,
231
- onChange: handleChange,
232
- });
280
+ return (React.createElement(TextInput, { ...props, value: value, onChange: handleChange }));
233
281
  }
@@ -39,6 +39,17 @@ export type TextInputProps = {
39
39
  * Must be unique on the current screen.
40
40
  */
41
41
  readonly focusId: string;
42
+ /**
43
+ * When true, text wraps to the next line instead of virtual-scrolling.
44
+ * Up/down arrow keys navigate between wrapped lines.
45
+ * @default false
46
+ */
47
+ readonly wrap?: boolean;
48
+ /**
49
+ * Available width in characters. When omitted, auto-detected from
50
+ * terminal dimensions via useWindowSize.
51
+ */
52
+ readonly width?: number;
42
53
  };
43
54
  /**
44
55
  * Props for the uncontrolled TextInput component.
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { KeyboardProvider, useKeyboard } from "./keyboard/index.js";
4
4
  export { normalizeKeyNames, isNormalCharacter } from "./keyboard/index.js";
5
5
  export type { KeyHandler, BoundKeyboardOptions, BoundKeyEntry, ScreenKeyboardLayer, KeyboardProviderProps, GlobalKeyEntry, GlobalSequenceEntry, } from "./keyboard/index.js";
6
6
  export type { PenetrationOptions, AllowModalOptions, StopOptions, LayerKind, FocusTarget, SequenceOptions, ShortcutOperationEntry, SequenceOperationEntry, ModalMissEvent, ModalMissCallback, ModalMissOptions, ResolvedGlobalKeyEntry, } from "./keyboard/index.js";
7
- export { useFocusState } from "./keyboard/index.js";
7
+ export { useFocusState, useModalMissListener } from "./keyboard/index.js";
8
8
  export { SelectInput } from "./components/select/SelectInput.js";
9
9
  export type { Item } from "./components/select/types.js";
10
10
  export type { SelectInputProps } from "./components/select/types.js";
@@ -24,6 +24,8 @@ export { Badge } from "./components/badge/Badge.js";
24
24
  export { KeyHint } from "./components/key-hint/KeyHint.js";
25
25
  export { NumberInput } from "./components/number-input/NumberInput.js";
26
26
  export { SearchInput } from "./components/search-input/SearchInput.js";
27
+ export { default as SearchBar } from "./components/search-bar/SearchBar.js";
28
+ export type { SearchBarItem, SearchBarProps } from "./components/search-bar/search-bar-types.js";
27
29
  export { Tabs } from "./components/tabs/Tabs.js";
28
30
  export type { Tab, TabsProps } from "./components/tabs/types.js";
29
31
  export { Fold } from "./components/fold/Fold.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { registerComponent, ScenarioManagementProvider, CurrentScreen, skip, bac
3
3
  // ── Keyboard System ────────────────────────────────────────
4
4
  export { KeyboardProvider, useKeyboard } from "./keyboard/index.js";
5
5
  export { normalizeKeyNames, isNormalCharacter } from "./keyboard/index.js";
6
- export { useFocusState } from "./keyboard/index.js";
6
+ export { useFocusState, useModalMissListener } from "./keyboard/index.js";
7
7
  // Components — SelectInput
8
8
  export { SelectInput } from "./components/select/SelectInput.js";
9
9
  // Components — SelectRow
@@ -28,6 +28,8 @@ export { KeyHint } from "./components/key-hint/KeyHint.js";
28
28
  export { NumberInput } from "./components/number-input/NumberInput.js";
29
29
  // Components — SearchInput
30
30
  export { SearchInput } from "./components/search-input/SearchInput.js";
31
+ // Components — SearchBar
32
+ export { default as SearchBar } from "./components/search-bar/SearchBar.js";
31
33
  // Components — Tabs
32
34
  export { Tabs } from "./components/tabs/Tabs.js";
33
35
  // Components — Fold
@@ -1,18 +1,24 @@
1
1
  import { handleLayer } from '../layer-handler.js';
2
+ function passAllowedKeys(allowedKeys, blockedKeys, eventNames) {
3
+ return allowedKeys.some((k) => !blockedKeys.includes(k.key) && eventNames.includes(k.key));
4
+ }
2
5
  /**
3
6
  * Check whether a key event matches any entry in the allow-list of
4
7
  * the active focus target or the layer itself.
5
8
  */
6
9
  function isAllowed(layer, eventNames) {
7
- if (layer.allowedKeys.some((r) => eventNames.includes(r.key))) {
8
- return true;
9
- }
10
+ const blockedKeys = layer.allowedKeys
11
+ .filter((r) => r.when?.() === false)
12
+ .map((r) => r.key);
10
13
  if (layer.currentFocusId) {
11
14
  const ft = layer.focusTargets.get(layer.currentFocusId);
12
- if (ft?.allowedKeys.some((r) => eventNames.includes(r.key))) {
15
+ if (ft && passAllowedKeys(ft.allowedKeys, blockedKeys, eventNames)) {
13
16
  return true;
14
17
  }
15
18
  }
19
+ if (passAllowedKeys(layer.allowedKeys, blockedKeys, eventNames)) {
20
+ return true;
21
+ }
16
22
  return false;
17
23
  }
18
24
  /**
@@ -434,7 +434,7 @@ export function KeyboardProvider({ children }) {
434
434
  const container = options?.focusId
435
435
  ? getOrCreateFocusTarget(layer, options.focusId)
436
436
  : layer;
437
- return pushKeyEntries(container, 'allowedKeys', keys, (key) => ({ key }));
437
+ return pushKeyEntries(container, 'allowedKeys', keys, (key) => ({ key, when: options?.when }));
438
438
  }, [getCurrentOwner, getLayer, getOrCreateFocusTarget]);
439
439
  /**
440
440
  * Bind keys on the current layer (screen or overlay).
@@ -385,6 +385,8 @@ export interface PenetrationOptions {
385
385
  export interface AllowModalOptions {
386
386
  /** If provided, allows only within the named focus target. */
387
387
  focusId?: string;
388
+ /** Optional condition callback. When provided, the key is only allowed through when this returns `true`. When `false`, the allow rule is ignored and the key is blocked. */
389
+ when?: () => boolean;
388
390
  }
389
391
  /**
390
392
  * A single global key definition.
@@ -51,6 +51,8 @@ export interface ScreenSystemContextValue {
51
51
  closeModal: CloseModalFn;
52
52
  /** Close all open modals at once. */
53
53
  closeAllModals: CloseAllModalsFn;
54
+ /** Whether to turn on full screen effect */
55
+ fullScreen?: boolean;
54
56
  }
55
57
  /**
56
58
  * React context for the screen navigation system.
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { Box } from 'ink';
2
+ import { Box, useWindowSize } from 'ink';
3
3
  import { useScreenSystem } from './hook.js';
4
4
  import { OverlayContext } from './OverlayContext.js';
5
5
  import { ModalContext } from './ModalContext.js';
@@ -18,7 +18,8 @@ import { ModalContext } from './ModalContext.js';
18
18
  * Architecturally symmetric between overlays and modals.
19
19
  */
20
20
  export function CurrentScreen() {
21
- const { currentScreen, currentOverlays, displayedOverlays, currentModals, renderedModalEntries } = useScreenSystem();
21
+ const { currentScreen, currentOverlays, displayedOverlays, currentModals, renderedModalEntries, fullScreen } = useScreenSystem();
22
+ const { rows } = useWindowSize();
22
23
  // Build overlay elements with OverlayContext wrappers
23
24
  const wrappedOverlays = currentOverlays.map((overlayNode, i) => {
24
25
  const entry = displayedOverlays[i];
@@ -34,7 +35,7 @@ export function CurrentScreen() {
34
35
  return modalNode;
35
36
  return React.createElement(ModalContext.Provider, { value: { id: entry.id, originComponent: entry.originComponent }, key: `mdl-ctx-${entry.id}` }, modalNode);
36
37
  });
37
- return (React.createElement(Box, { flexDirection: 'column', width: '100%', height: '100%' },
38
+ return (React.createElement(Box, { flexDirection: 'column', width: '100%', height: fullScreen ? rows : '100%' },
38
39
  currentScreen,
39
40
  wrappedOverlays.map((w) => w),
40
41
  wrappedModals.map((w) => w)));
@@ -111,6 +111,7 @@ export interface ScenarioManagementProviderProps {
111
111
  defaultScreen: React.ComponentType<any>;
112
112
  /** 默认参数(可选,未传则使用注册时的模板参数) */
113
113
  defaultParams?: Record<string, unknown>;
114
+ fullScreen?: boolean;
114
115
  }
115
116
  /**
116
117
  * Screen-management context provider.
@@ -118,4 +119,4 @@ export interface ScenarioManagementProviderProps {
118
119
  * Wraps the application and enables tree-based screen navigation, overlays,
119
120
  * and module-level navigation functions.
120
121
  */
121
- export declare function ScenarioManagementProvider({ children, defaultScreen, defaultParams, }: ScenarioManagementProviderProps): React.JSX.Element;
122
+ export declare function ScenarioManagementProvider({ children, defaultScreen, defaultParams, fullScreen }: ScenarioManagementProviderProps): React.JSX.Element;
@@ -483,7 +483,7 @@ function screenReducer(state, action) {
483
483
  * Wraps the application and enables tree-based screen navigation, overlays,
484
484
  * and module-level navigation functions.
485
485
  */
486
- export function ScenarioManagementProvider({ children, defaultScreen, defaultParams, }) {
486
+ export function ScenarioManagementProvider({ children, defaultScreen, defaultParams, fullScreen }) {
487
487
  if (!hasComponent(defaultScreen)) {
488
488
  throw new Error(`[Ink-Cartridge] defaultScreen "${defaultScreen.displayName || defaultScreen.name || 'anonymous'}" is not registered. Please call registerComponent() first.`);
489
489
  }
@@ -614,6 +614,7 @@ export function ScenarioManagementProvider({ children, defaultScreen, defaultPar
614
614
  openModal: openModalInContext,
615
615
  closeModal: closeModalInContext,
616
616
  closeAllModals: closeAllModalsInContext,
617
+ fullScreen
617
618
  }), [
618
619
  currentScreen,
619
620
  currentOverlays,
@@ -636,6 +637,7 @@ export function ScenarioManagementProvider({ children, defaultScreen, defaultPar
636
637
  closeModalInContext,
637
638
  closeAllModalsInContext,
638
639
  state.activeModalId,
640
+ fullScreen
639
641
  ]);
640
642
  return (React.createElement(ScreenSystemContext.Provider, { value: value }, children));
641
643
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ink-cartridge",
3
- "version": "3.8.2",
3
+ "version": "3.8.3",
4
4
  "description": "Ready-to-use Ink components and screen management system for building terminal UIs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -62,7 +62,7 @@
62
62
  "eslint": "^9.39.4",
63
63
  "eslint-plugin-react": "^7.37.5",
64
64
  "eslint-plugin-react-hooks": "^7.1.1",
65
- "ink": "^7.0.1",
65
+ "ink": "^7.1.0",
66
66
  "ink-testing-library": "^4.0.0",
67
67
  "jsdom": "^26.0.0",
68
68
  "react": "^19.2.4",