ink-cartridge 3.8.1 → 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
@@ -56,7 +56,7 @@ npx ink-cartridge init my-tui
56
56
  - [useKeyboard](docs/keyboard/useKeyboard-API.md)
57
57
  - [boundKeyboard](docs/keyboard/boundKeyboard-API.md)
58
58
  - [boundSequence](docs/keyboard/boundSequence-API.md)
59
- - [blockedKey](docs/keyboard/blockedKey-API.md)
59
+ - [penetration](docs/keyboard/penetration-API.md)
60
60
  - [stop](docs/keyboard/stop-API.md)
61
61
  - [globalKeys](docs/keyboard/globalKeys-API.md)
62
62
  - [globalSequence](docs/keyboard/globalSequence-API.md)
@@ -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 `blockedKey` is poorly named — it means *pass-through*, not "block." The internal name is `penetration`. Too late to rename now.
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.
@@ -18,7 +18,7 @@ import { DevProps } from "./types.js";
18
18
  * The panel registers itself via `registerComponent` so it participates
19
19
  * in the modal keyboard layer automatically.
20
20
  *
21
- * As a modal, DevScreen blocks all keyboard events from reaching overlays
21
+ * As a modal, DevScreen consumes all keyboard events from reaching overlays
22
22
  * and screens while open.
23
23
  *
24
24
  * @param top - Initial vertical position in rows (0 = top of terminal).
@@ -92,7 +92,7 @@ function AllFocusSummary({ currentPath, activeOverlayIds, activeModalId, readLay
92
92
  }
93
93
  /**
94
94
  * Renders a compact summary of the keyboard layer for the top screen
95
- * component: bindings, sequences, stopped keys, and blocked keys.
95
+ * component: bindings, sequences, stopped keys, and penetration keys.
96
96
  *
97
97
  * Focus targets are displayed separately by {@link AllFocusSummary}
98
98
  * so that all layers' targets are visible at once.
@@ -107,7 +107,7 @@ function LayerSummary({ topComponent, readLayer }) {
107
107
  const seqCount = [...layer.sequences.values()].reduce((s, a) => s + a.length, 0);
108
108
  const seqFirstKeys = [...layer.sequences.keys()].join(' ');
109
109
  const stopped = layer.stoppedKeys.map(r => Array.isArray(r.key) ? r.key.join(',') : r.key).join(' ');
110
- const blocked = layer.blockedKeys.map(r => Array.isArray(r.key) ? r.key.join(',') : r.key).join(' ');
110
+ const penetrated = layer.penetrationKeys.map(r => Array.isArray(r.key) ? r.key.join(',') : r.key).join(' ');
111
111
  return (React.createElement(Box, { flexDirection: "column", paddingY: 1 },
112
112
  React.createElement(Box, { flexDirection: "row" },
113
113
  React.createElement(Text, { color: "cyan" },
@@ -128,9 +128,9 @@ function LayerSummary({ topComponent, readLayer }) {
128
128
  stopped && (React.createElement(Box, { flexDirection: "row" },
129
129
  React.createElement(Text, { color: "red" }, "Stopped: "),
130
130
  React.createElement(Text, { dimColor: true }, stopped))),
131
- blocked && (React.createElement(Box, { flexDirection: "row" },
132
- React.createElement(Text, { color: "gray" }, "Blocked: "),
133
- React.createElement(Text, { dimColor: true }, blocked)))));
131
+ penetrated && (React.createElement(Box, { flexDirection: "row" },
132
+ React.createElement(Text, { color: "gray" }, "Penetr: "),
133
+ React.createElement(Text, { dimColor: true }, penetrated)))));
134
134
  }
135
135
  /**
136
136
  * Developer debugging modal for the ink-cartridge screen system.
@@ -150,7 +150,7 @@ function LayerSummary({ topComponent, readLayer }) {
150
150
  * The panel registers itself via `registerComponent` so it participates
151
151
  * in the modal keyboard layer automatically.
152
152
  *
153
- * As a modal, DevScreen blocks all keyboard events from reaching overlays
153
+ * As a modal, DevScreen consumes all keyboard events from reaching overlays
154
154
  * and screens while open.
155
155
  *
156
156
  * @param top - Initial vertical position in rows (0 = top of terminal).
@@ -116,8 +116,8 @@ function FocusTargetDetail({ focusId, target }) {
116
116
  React.createElement(Text, { color: "white" }, focusId)),
117
117
  React.createElement(Row, { label: "Bindings" },
118
118
  React.createElement(Text, { color: "white" }, target.bindings.length)),
119
- React.createElement(Row, { label: "Blocked" },
120
- React.createElement(Text, { color: "white" }, target.blockedKeys.length)),
119
+ React.createElement(Row, { label: "Penetr." },
120
+ React.createElement(Text, { color: "white" }, target.penetrationKeys.length)),
121
121
  React.createElement(Row, { label: "Stopped" },
122
122
  React.createElement(Text, { color: "white" }, target.stoppedKeys.length))),
123
123
  target.bindings.length > 0 && (React.createElement(Box, { paddingTop: 1, flexDirection: "column" },
@@ -126,9 +126,9 @@ function FocusTargetDetail({ focusId, target }) {
126
126
  " [",
127
127
  b.keys.join(', '),
128
128
  "]"))))),
129
- target.blockedKeys.length > 0 && (React.createElement(Box, { paddingTop: 1, flexDirection: "column" },
130
- React.createElement(Text, { color: "gray", dimColor: true }, "Blocked:"),
131
- target.blockedKeys.map((r, i) => (React.createElement(Text, { key: i, color: "gray" },
129
+ target.penetrationKeys.length > 0 && (React.createElement(Box, { paddingTop: 1, flexDirection: "column" },
130
+ React.createElement(Text, { color: "gray", dimColor: true }, "Penetration:"),
131
+ target.penetrationKeys.map((r, i) => (React.createElement(Text, { key: i, color: "gray" },
132
132
  " ",
133
133
  fmtKeys(r.key)))))),
134
134
  target.stoppedKeys.length > 0 && (React.createElement(Box, { paddingTop: 1, flexDirection: "column" },
@@ -213,18 +213,18 @@ export default function LayerKeyDisplayBox({ top: initialTop, left, screenCompon
213
213
  Key: `stop-${i}`,
214
214
  });
215
215
  });
216
- // Blocked keys
217
- layer.blockedKeys.forEach((rule, i) => {
216
+ // Penetration keys
217
+ layer.penetrationKeys.forEach((rule, i) => {
218
218
  items.push({
219
- label: `[Blk] ${keyRuleLabel(rule)}`,
220
- value: { kind: 'blocked', idx: i, rule },
219
+ label: `[Pen] ${keyRuleLabel(rule)}`,
220
+ value: { kind: 'penetrated', idx: i, rule },
221
221
  Key: `blk-${i}`,
222
222
  });
223
223
  });
224
224
  // Focus targets
225
225
  for (const [focusId, target] of layer.focusTargets) {
226
226
  items.push({
227
- label: `[Foc] ${focusId} (bind:${target.bindings.length} blk:${target.blockedKeys.length} stp:${target.stoppedKeys.length})`,
227
+ label: `[Foc] ${focusId} (bind:${target.bindings.length} pen:${target.penetrationKeys.length} stp:${target.stoppedKeys.length})`,
228
228
  value: { kind: 'focusTarget', focusId, target },
229
229
  Key: `focus-${focusId}`,
230
230
  });
@@ -249,8 +249,8 @@ export default function LayerKeyDisplayBox({ top: initialTop, left, screenCompon
249
249
  return React.createElement(SequenceDetail, { firstKey: expandedEntry.firstKey, entry: expandedEntry.entry });
250
250
  case 'stopped':
251
251
  return React.createElement(KeyRuleDetail, { label: "Stopped Key", rule: expandedEntry.rule });
252
- case 'blocked':
253
- return React.createElement(KeyRuleDetail, { label: "Blocked Key (pass-through)", rule: expandedEntry.rule });
252
+ case 'penetrated':
253
+ return React.createElement(KeyRuleDetail, { label: "Penetration Key (pass-through)", rule: expandedEntry.rule });
254
254
  case 'focusTarget':
255
255
  return React.createElement(FocusTargetDetail, { focusId: expandedEntry.focusId, target: expandedEntry.target });
256
256
  }
@@ -266,7 +266,7 @@ export default function LayerKeyDisplayBox({ top: initialTop, left, screenCompon
266
266
  layer.currentFocusId ?? 'none')),
267
267
  React.createElement(Sep, null),
268
268
  expandedEntry ? (renderDetail()) : items.length === 0 ? (React.createElement(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center" },
269
- React.createElement(Text, { color: "gray" }, "No bindings, sequences, stopped/blocked keys, or focus targets."))) : (React.createElement(SelectInput, { items: items, onSelect: handleSelect, focusId: "layerKey-list", limit: 9 })),
269
+ React.createElement(Text, { color: "gray" }, "No bindings, sequences, stopped/penetration keys, or focus targets."))) : (React.createElement(SelectInput, { items: items, onSelect: handleSelect, focusId: "layerKey-list", limit: 9 })),
270
270
  React.createElement(Sep, null),
271
271
  React.createElement(Box, { paddingTop: 1 },
272
272
  React.createElement(Text, { dimColor: true }, expandedEntry
package/dist/index.d.ts CHANGED
@@ -3,8 +3,8 @@ export type { SkipOptions, SkipFn, BackFn, GotoScreenFn, OpenOverlayFn, CloseOve
3
3
  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
- export type { BlockedKeyOptions, AllowModalOptions, StopOptions, LayerKind, FocusTarget, SequenceOptions, ShortcutOperationEntry, SequenceOperationEntry, ModalMissEvent, ModalMissCallback, ModalMissOptions, ResolvedGlobalKeyEntry, } from "./keyboard/index.js";
7
- export { useFocusState } from "./keyboard/index.js";
6
+ export type { PenetrationOptions, AllowModalOptions, StopOptions, LayerKind, FocusTarget, SequenceOptions, ShortcutOperationEntry, SequenceOperationEntry, ModalMissEvent, ModalMissCallback, ModalMissOptions, ResolvedGlobalKeyEntry, } 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,4 +1,4 @@
1
- import type { KeyHandler, BoundKeyboardOptions, BlockedKeyOptions, StopOptions, AllowModalOptions, GlobalKeyEntry, GlobalSequenceEntry, ShortcutOperationEntry, SequenceOperationEntry, SequenceOptions, ModalMissCallback, ModalMissOptions, ResolvedGlobalKeyEntry, ResolvedGlobalSequenceEntry, GlobalPendingSequence, ScreenKeyboardLayer } from "./types.js";
1
+ import type { KeyHandler, BoundKeyboardOptions, PenetrationOptions, StopOptions, AllowModalOptions, GlobalKeyEntry, GlobalSequenceEntry, ShortcutOperationEntry, SequenceOperationEntry, SequenceOptions, ModalMissCallback, ModalMissOptions, ResolvedGlobalKeyEntry, ResolvedGlobalSequenceEntry, GlobalPendingSequence, ScreenKeyboardLayer } from "./types.js";
2
2
  /**
3
3
  * Type for the owner stack used to track overlay context.
4
4
  * Can be a component type (for screens) or a string (for overlay IDs).
@@ -45,7 +45,7 @@ export interface KeyboardContextValue {
45
45
  * @param options If `focusId` is provided, marks transparent only
46
46
  * within that focus target.
47
47
  */
48
- blockedKey: (keys: string[], options?: BlockedKeyOptions) => () => void;
48
+ penetration: (keys: string[], options?: PenetrationOptions) => () => void;
49
49
  /**
50
50
  * Prevent one or more keys from propagating to layers below.
51
51
  *
@@ -3,7 +3,7 @@ import type { ModalMissCallback, ModalMissOptions } from "./types.js";
3
3
  /**
4
4
  * Access the keyboard API from within a React component.
5
5
  *
6
- * Returns `{ boundKeyboard, blockedKey, stop, globalKeys, ... }`.
6
+ * Returns `{ boundKeyboard, penetration, stop, globalKeys, ... }`.
7
7
  *
8
8
  * When called inside an overlay component (wrapped in OverlayContext.Provider),
9
9
  * keyboard bindings are automatically isolated to the overlay's own layer,
@@ -6,7 +6,7 @@ import { useScreenSystem } from "../screen/hook.js";
6
6
  /**
7
7
  * Access the keyboard API from within a React component.
8
8
  *
9
- * Returns `{ boundKeyboard, blockedKey, stop, globalKeys, ... }`.
9
+ * Returns `{ boundKeyboard, penetration, stop, globalKeys, ... }`.
10
10
  *
11
11
  * When called inside an overlay component (wrapped in OverlayContext.Provider),
12
12
  * keyboard bindings are automatically isolated to the overlay's own layer,
@@ -2,4 +2,4 @@ export { KeyboardProvider } from "./provider.js";
2
2
  export type { KeyboardProviderProps } from "./provider.js";
3
3
  export { useKeyboard, useFocusState, useModalMissListener } from "./hook.js";
4
4
  export { normalizeKeyNames, isNormalCharacter } from "./keyNormalizer.js";
5
- export type { KeyHandler, BoundKeyboardOptions, BlockedKeyOptions, AllowModalOptions, StopOptions, BoundKeyEntry, ScreenKeyboardLayer, LayerKind, FocusTarget, GlobalKeyEntry, GlobalSequenceEntry, ShortcutOperationEntry, SequenceOperationEntry, SequenceOptions, SequenceBinding, PendingSequence, ModalMissEvent, ModalMissCallback, ModalMissOptions, ResolvedGlobalKeyEntry, } from "./types.js";
5
+ export type { KeyHandler, BoundKeyboardOptions, PenetrationOptions, AllowModalOptions, StopOptions, BoundKeyEntry, ScreenKeyboardLayer, LayerKind, FocusTarget, GlobalKeyEntry, GlobalSequenceEntry, ShortcutOperationEntry, SequenceOperationEntry, SequenceOptions, SequenceBinding, PendingSequence, ModalMissEvent, ModalMissCallback, ModalMissOptions, ResolvedGlobalKeyEntry, } from "./types.js";
@@ -25,14 +25,14 @@ export declare function keyMatchesRule(keyName: string, rules: KeyRule[]): boole
25
25
  * all must pass for the binding to fire.
26
26
  *
27
27
  * @param bindings Ordered list of key bindings to try.
28
- * @param unblockedKeys Normalized key names not blocked at this layer.
28
+ * @param availableKeys Normalized key names available for matching at this layer.
29
29
  * @param input Raw character from Ink's useInput.
30
30
  * @param key Full Key descriptor from Ink.
31
31
  * @param skipBinding Optional predicate to skip individual bindings
32
32
  * (used for `onlyThis` enforcement).
33
33
  * @returns `true` if a binding matched and consumed the event.
34
34
  */
35
- export declare function tryMatchBindings(bindings: BoundKeyEntry[], unblockedKeys: string[], input: string, key: Key, skipBinding?: (binding: BoundKeyEntry) => boolean): boolean;
35
+ export declare function tryMatchBindings(bindings: BoundKeyEntry[], availableKeys: string[], input: string, key: Key, skipBinding?: (binding: BoundKeyEntry) => boolean): boolean;
36
36
  /**
37
37
  * Built-in Tab / Shift+Tab focus rotation for a given layer.
38
38
  *
@@ -46,7 +46,7 @@ export declare function handleTabNavigation(layer: ScreenKeyboardLayer, eventNam
46
46
  /**
47
47
  * Handle a keyboard event against a single layer.
48
48
  *
49
- * Evaluates tab navigation, blocked keys, wildcard priority, sequence
49
+ * Evaluates tab navigation, penetration keys, wildcard priority, sequence
50
50
  * matching, focus-target bindings, layer-level bindings, and stopped
51
51
  * keys — in that order.
52
52
  *
@@ -33,22 +33,22 @@ export function keyMatchesRule(keyName, rules) {
33
33
  * all must pass for the binding to fire.
34
34
  *
35
35
  * @param bindings Ordered list of key bindings to try.
36
- * @param unblockedKeys Normalized key names not blocked at this layer.
36
+ * @param availableKeys Normalized key names available for matching at this layer.
37
37
  * @param input Raw character from Ink's useInput.
38
38
  * @param key Full Key descriptor from Ink.
39
39
  * @param skipBinding Optional predicate to skip individual bindings
40
40
  * (used for `onlyThis` enforcement).
41
41
  * @returns `true` if a binding matched and consumed the event.
42
42
  */
43
- export function tryMatchBindings(bindings, unblockedKeys, input, key, skipBinding) {
44
- if (unblockedKeys.length === 0)
43
+ export function tryMatchBindings(bindings, availableKeys, input, key, skipBinding) {
44
+ if (availableKeys.length === 0)
45
45
  return false;
46
46
  for (const binding of bindings) {
47
47
  if (skipBinding && skipBinding(binding))
48
48
  continue;
49
49
  if (binding.when?.() === false)
50
50
  continue;
51
- if (binding.keys.some((k) => unblockedKeys.includes(k))) {
51
+ if (binding.keys.some((k) => availableKeys.includes(k))) {
52
52
  binding.handler(input, key);
53
53
  return true;
54
54
  }
@@ -91,7 +91,7 @@ export function handleTabNavigation(layer, eventNames, shift, notifyFocusChange)
91
91
  /**
92
92
  * Handle a keyboard event against a single layer.
93
93
  *
94
- * Evaluates tab navigation, blocked keys, wildcard priority, sequence
94
+ * Evaluates tab navigation, penetration keys, wildcard priority, sequence
95
95
  * matching, focus-target bindings, layer-level bindings, and stopped
96
96
  * keys — in that order.
97
97
  *
@@ -105,8 +105,8 @@ export function handleLayer(layer, eventNames, input, key, isTop, notifyFocusCha
105
105
  // they can also be bound to business-specific keys.
106
106
  if (isTop && handleTabNavigation(layer, eventNames, key.shift, notifyFocusChange))
107
107
  return true;
108
- const blocked = layer.blockedKeys;
109
- const unblocked = eventNames.filter((n) => !keyMatchesRule(n, blocked));
108
+ const penetrated = layer.penetrationKeys;
109
+ const available = eventNames.filter((n) => !keyMatchesRule(n, penetrated));
110
110
  // onlyThis semantics differ between screens and overlays:
111
111
  // - Screen: skip when any overlay is active (activeOverlayCount > 0)
112
112
  // - Overlay: skip only when multiple overlays compete (activeOverlayCount > 1)
@@ -120,14 +120,14 @@ export function handleLayer(layer, eventNames, input, key, isTop, notifyFocusCha
120
120
  // Wildcard priority pre-check: when enabled, wildcard `*` bindings
121
121
  // are evaluated before sequences, exact matches, and everything else.
122
122
  // Only normal characters are affected — special keys fall through.
123
- if (isTop && wildcardFirst && unblocked.length > 0) {
123
+ if (isTop && wildcardFirst && available.length > 0) {
124
124
  // Check focus-target wildcard first
125
125
  if (layer.currentFocusId) {
126
126
  const ft = layer.focusTargets.get(layer.currentFocusId);
127
127
  if (ft) {
128
- const fBlocked = ft.blockedKeys;
129
- const fUnblocked = unblocked.filter(n => !keyMatchesRule(n, fBlocked));
130
- if (fUnblocked.length > 0) {
128
+ const fPenetrated = ft.penetrationKeys;
129
+ const fAvailable = available.filter(n => !keyMatchesRule(n, fPenetrated));
130
+ if (fAvailable.length > 0) {
131
131
  const wb = ft.bindings.find(b => b.keys.includes('*'));
132
132
  if (wb && isNormalCharacter(input, key)) {
133
133
  if (wb.when?.() === false) { /* skip */ }
@@ -151,7 +151,7 @@ export function handleLayer(layer, eventNames, input, key, isTop, notifyFocusCha
151
151
  }
152
152
  // Sequence matching: only for the top layer (isTop).
153
153
  // Sequences have priority over ordinary boundKeyboard bindings.
154
- if (isTop && unblocked.length > 0) {
154
+ if (isTop && available.length > 0) {
155
155
  const pending = layer.pendingSequence;
156
156
  // We already have a pending sequence in progress.
157
157
  if (pending !== null) {
@@ -164,14 +164,14 @@ export function handleLayer(layer, eventNames, input, key, isTop, notifyFocusCha
164
164
  }
165
165
  else {
166
166
  const expectedKey = pending.sequences[pending.nextIndex];
167
- if (unblocked.includes(expectedKey)) {
167
+ if (available.includes(expectedKey)) {
168
168
  // Matched the next key in the sequence.
169
169
  clearTimeout(pending.timer);
170
170
  pending.nextIndex++;
171
171
  // Narrow candidates to only those whose next key also matches.
172
172
  if (pending.candidates && pending.candidates.length > 1) {
173
173
  const nextIdx = pending.nextIndex - 1;
174
- const narrowed = pending.candidates.filter(c => c.keys.length > nextIdx && unblocked.includes(c.keys[nextIdx]));
174
+ const narrowed = pending.candidates.filter(c => c.keys.length > nextIdx && available.includes(c.keys[nextIdx]));
175
175
  pending.candidates = narrowed.length <= 1 ? undefined : narrowed;
176
176
  }
177
177
  if (pending.nextIndex === pending.sequences.length) {
@@ -198,7 +198,7 @@ export function handleLayer(layer, eventNames, input, key, isTop, notifyFocusCha
198
198
  // Non-exclusive with multiple candidates: try the current key
199
199
  // against every candidate's next expected key to disambiguate.
200
200
  const nextIdx = pending.nextIndex;
201
- const stillPossible = pending.candidates.filter(c => c.keys.length > nextIdx && unblocked.includes(c.keys[nextIdx]));
201
+ const stillPossible = pending.candidates.filter(c => c.keys.length > nextIdx && available.includes(c.keys[nextIdx]));
202
202
  if (stillPossible.length === 0) {
203
203
  // No candidate matches — cancel all and fall through.
204
204
  clearTimeout(pending.timer);
@@ -240,11 +240,11 @@ export function handleLayer(layer, eventNames, input, key, isTop, notifyFocusCha
240
240
  }
241
241
  }
242
242
  }
243
- // No pending sequence — try to start a new one from the first unblocked key.
243
+ // No pending sequence — try to start a new one from the first available key.
244
244
  if (layer.pendingSequence === null) {
245
- // Check each unblocked key name (not just the first) to handle
245
+ // Check each available key name (not just the first) to handle
246
246
  // modifier combinations like 'ctrl+w' which appear after 'w'.
247
- for (const keyName of unblocked) {
247
+ for (const keyName of available) {
248
248
  // When ctrl/meta modifier is held (but not shift), a bare key name
249
249
  // (without '+') does not represent the keystroke the user intended.
250
250
  // normalizeKeyNames expands ctrl+d into ['d', 'ctrl+d']; matching 'd'
@@ -311,16 +311,16 @@ export function handleLayer(layer, eventNames, input, key, isTop, notifyFocusCha
311
311
  if (isTop && layer.currentFocusId) {
312
312
  const ft = layer.focusTargets.get(layer.currentFocusId);
313
313
  if (ft) {
314
- const fBlocked = ft.blockedKeys;
315
- const fUnblocked = unblocked.filter((n) => !keyMatchesRule(n, fBlocked));
316
- if (tryMatchBindings(ft.bindings, fUnblocked, input, key, shouldSkipOnlyThis))
314
+ const fPenetrated = ft.penetrationKeys;
315
+ const fAvailable = available.filter((n) => !keyMatchesRule(n, fPenetrated));
316
+ if (tryMatchBindings(ft.bindings, fAvailable, input, key, shouldSkipOnlyThis))
317
317
  return true;
318
318
  if (eventNames.some((n) => keyMatchesRule(n, ft.stoppedKeys))) {
319
319
  return true;
320
320
  }
321
321
  }
322
322
  }
323
- if (tryMatchBindings(layer.bindings, unblocked, input, key, shouldSkipOnlyThis))
323
+ if (tryMatchBindings(layer.bindings, available, input, key, shouldSkipOnlyThis))
324
324
  return true;
325
325
  if (isTop && eventNames.some((n) => keyMatchesRule(n, layer.stoppedKeys))) {
326
326
  return true;
@@ -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
  /**
@@ -46,9 +52,9 @@ function invokeMissIfNeeded(layer, handled, key, input, eventNames) {
46
52
  if (!layer.onMiss)
47
53
  return false;
48
54
  const opts = layer.onMissOptions ?? {};
49
- // fix: The stop API and blockedKey API cases are no longer handled.
55
+ // fix: The stop API and penetration API cases are no longer handled.
50
56
  // Instead, it is left to handlerLayer to handle natural
51
- // So the expectation is that, So the Stop API returns miss: false, but the BlockedKeys API returns miss: true
57
+ // So the expectation is that, So the Stop API returns miss: false, but the penetration API returns miss: true
52
58
  // TODO: You need to modify the corresponding test and do it later.
53
59
  // @2026-06-23 3.6.1
54
60
  if (handled) {
@@ -17,7 +17,7 @@ export interface KeyboardProviderProps {
17
17
  /**
18
18
  * Keyboard context provider for layered key handling.
19
19
  *
20
- * Manages per-screen-layer key bindings, transparent keys (`blockedKey`),
20
+ * Manages per-screen-layer key bindings, transparent keys (`penetration`),
21
21
  * key-stop propagation barriers (`stop`), and global keys (`globalKeys`).
22
22
  * Handles the full event priority chain:
23
23
  * 1. Global keys with `affectOverlay: true`
@@ -196,7 +196,7 @@ function finalizeBoundKeyboard(bindingsArray, actionKeysMap, layer, entry, handl
196
196
  /**
197
197
  * Keyboard context provider for layered key handling.
198
198
  *
199
- * Manages per-screen-layer key bindings, transparent keys (`blockedKey`),
199
+ * Manages per-screen-layer key bindings, transparent keys (`penetration`),
200
200
  * key-stop propagation barriers (`stop`), and global keys (`globalKeys`).
201
201
  * Handles the full event priority chain:
202
202
  * 1. Global keys with `affectOverlay: true`
@@ -346,7 +346,7 @@ export function KeyboardProvider({ children }) {
346
346
  layer = {
347
347
  kind,
348
348
  bindings: [],
349
- blockedKeys: [],
349
+ penetrationKeys: [],
350
350
  stoppedKeys: [],
351
351
  allowedKeys: [],
352
352
  globalKeyOverrides: new Set(),
@@ -408,7 +408,7 @@ export function KeyboardProvider({ children }) {
408
408
  if (!target) {
409
409
  target = {
410
410
  bindings: [],
411
- blockedKeys: [],
411
+ penetrationKeys: [],
412
412
  stoppedKeys: [],
413
413
  allowedKeys: [],
414
414
  actionKeysMap: new Map(),
@@ -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).
@@ -567,14 +567,14 @@ export function KeyboardProvider({ children }) {
567
567
  const penetration = useCallback((keys, options) => {
568
568
  const owner = getCurrentOwner();
569
569
  if (!owner) {
570
- throw new Error('[Ink-Cartridge] blockedKey() must be called inside a screen component or overlay.');
570
+ throw new Error('[Ink-Cartridge] penetration() must be called inside a screen component or overlay.');
571
571
  }
572
572
  const layer = getLayer(owner);
573
573
  const compiledWhen = options?.when;
574
574
  const container = options?.focusId
575
575
  ? getOrCreateFocusTarget(layer, options.focusId)
576
576
  : layer;
577
- return pushKeyEntries(container, 'blockedKeys', keys, (key) => ({
577
+ return pushKeyEntries(container, 'penetrationKeys', keys, (key) => ({
578
578
  key,
579
579
  when: compiledWhen,
580
580
  }));
@@ -970,7 +970,7 @@ export function KeyboardProvider({ children }) {
970
970
  const readLayer = useCallback((owner) => layersRef.current.get(owner), []);
971
971
  const value = useMemo(() => ({
972
972
  boundKeyboard,
973
- blockedKey: penetration,
973
+ penetration,
974
974
  stop,
975
975
  globalKeys,
976
976
  getGlobalKeys,
@@ -3,7 +3,7 @@ import type { OverlayEntry } from "../screen/types.js";
3
3
  /**
4
4
  * A single key rule with an optional when condition.
5
5
  *
6
- * Used internally for blockedKeys and stoppedKeys to support
6
+ * Used internally for penetrationKeys and stoppedKeys to support
7
7
  * conditional transparency and conditional propagation barriers.
8
8
  */
9
9
  export interface KeyRule {
@@ -132,7 +132,7 @@ export interface FocusTarget {
132
132
  /** Registered key bindings (evaluation order). */
133
133
  bindings: BoundKeyEntry[];
134
134
  /** Key rules marked as transparent on this target (pass-through). */
135
- blockedKeys: KeyRule[];
135
+ penetrationKeys: KeyRule[];
136
136
  /** Key rules stopped on this target (propagation barrier). */
137
137
  stoppedKeys: KeyRule[];
138
138
  /**
@@ -256,7 +256,7 @@ export interface ScreenKeyboardLayer {
256
256
  /** Registered screen-level key bindings (evaluation order). */
257
257
  bindings: BoundKeyEntry[];
258
258
  /** Key rules marked as transparent at the screen level (pass-through). */
259
- blockedKeys: KeyRule[];
259
+ penetrationKeys: KeyRule[];
260
260
  /** Key rules stopped at the screen level (propagation barrier). */
261
261
  stoppedKeys: KeyRule[];
262
262
  /**
@@ -303,7 +303,7 @@ export interface ScreenKeyboardLayer {
303
303
  * Event object passed to the {@link ModalMissCallback}.
304
304
  *
305
305
  * When `miss` is `false`, the key was handled (by a binding, Tab
306
- * navigation, sequence, or — depending on options — stop/blockedKey).
306
+ * navigation, sequence, or — depending on options — stop/penetration).
307
307
  * When `miss` is `true`, the remaining fields describe the key that
308
308
  * was not handled by any mechanism visible to the miss detector.
309
309
  */
@@ -365,16 +365,16 @@ export interface StopOptions {
365
365
  when?: () => boolean;
366
366
  }
367
367
  /**
368
- * Options for {@link KeyboardContextValue.blockedKey} when marking keys
368
+ * Options for {@link KeyboardContextValue.penetration} when marking keys
369
369
  * as transparent within a specific focus target.
370
370
  */
371
- export interface BlockedKeyOptions {
372
- /** If provided, blocks only within the named focus target. */
371
+ export interface PenetrationOptions {
372
+ /** If provided, penetrates only within the named focus target. */
373
373
  focusId?: string;
374
374
  /**
375
375
  * Optional condition callback. When provided, the key is only transparent
376
- * (blocked) when this returns `true`. When `false`, the blocked-key rule
377
- * is ignored and the key is not blocked.
376
+ * when this returns `true`. When `false`, the penetration rule
377
+ * is ignored and the key is not passed through.
378
378
  */
379
379
  when?: () => boolean;
380
380
  }
@@ -385,6 +385,8 @@ export interface BlockedKeyOptions {
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;
@@ -251,9 +251,11 @@ function recalcActiveAfterNavigation(persistentOverlays, persistentModals, newTo
251
251
  }
252
252
  }
253
253
  let activeModalId = null;
254
+ let maxZ = -1;
254
255
  for (const m of persistentModals) {
255
- if (m.originComponent === newTopScreen) {
256
+ if (m.originComponent === newTopScreen && m.zIndex > maxZ) {
256
257
  activeModalId = m.id;
258
+ maxZ = m.zIndex;
257
259
  }
258
260
  }
259
261
  return { activeOverlayIds, activeModalId };
@@ -481,7 +483,7 @@ function screenReducer(state, action) {
481
483
  * Wraps the application and enables tree-based screen navigation, overlays,
482
484
  * and module-level navigation functions.
483
485
  */
484
- export function ScenarioManagementProvider({ children, defaultScreen, defaultParams, }) {
486
+ export function ScenarioManagementProvider({ children, defaultScreen, defaultParams, fullScreen }) {
485
487
  if (!hasComponent(defaultScreen)) {
486
488
  throw new Error(`[Ink-Cartridge] defaultScreen "${defaultScreen.displayName || defaultScreen.name || 'anonymous'}" is not registered. Please call registerComponent() first.`);
487
489
  }
@@ -612,6 +614,7 @@ export function ScenarioManagementProvider({ children, defaultScreen, defaultPar
612
614
  openModal: openModalInContext,
613
615
  closeModal: closeModalInContext,
614
616
  closeAllModals: closeAllModalsInContext,
617
+ fullScreen
615
618
  }), [
616
619
  currentScreen,
617
620
  currentOverlays,
@@ -634,6 +637,7 @@ export function ScenarioManagementProvider({ children, defaultScreen, defaultPar
634
637
  closeModalInContext,
635
638
  closeAllModalsInContext,
636
639
  state.activeModalId,
640
+ fullScreen
637
641
  ]);
638
642
  return (React.createElement(ScreenSystemContext.Provider, { value: value }, children));
639
643
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ink-cartridge",
3
- "version": "3.8.1",
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",