Skip to main content

9. 输入框输入处理:键盘与 Enter 语义

上一篇的状态原子是“能做什么”,这一篇讲“按键怎么映射到这些动作”。核心是 usePromptComposerInput.ts,外加一小撮常量 constants.ts。它对应主屏计划的 U3:可输入、可换行、Enter 提交。

常量:constants.ts

export const PROMPT_PREFIX = '> ';
export const INK_CURSOR_ROW_ORIGIN_OFFSET = 1;
export const COMPOSER_BACKGROUND_PADDING_ROWS = 2;
export const COMPOSER_BACKGROUND_TOP_PADDING_ROWS = 1;

export const MODIFIED_ENTER_INPUTS = new Set([
'\u001B[13;2u',
'\u001B[13;3u',
'\u001B[13;5u',
'\u001B[13;6u'
]);
  • PROMPT_PREFIX 是输入框首行的 >
  • 两个 padding 常量与背景块的上下半行有关(第 4、11 篇都用到)。
  • INK_CURSOR_ROW_ORIGIN_OFFSET 修正 Ink 光标行原点(第 11 篇)。
  • MODIFIED_ENTER_INPUTS 是一组 CSI-u 编码的“修饰 Enter”序列(Shift/Alt/Ctrl/Meta + Enter)。支持 Kitty keyboard protocol 的终端会把组合 Enter 发成这些转义序列,用来表示“插入换行而非提交”。

钩子结构

usePromptComposerInput 把六个写原子的 setter 取出来,然后在 Ink 的 useInput 回调里按优先级派发:

export function usePromptComposerInput({ isActive, maxBytes, onSubmit, state }): void {
const clearComposer = useSetAtom(clearComposerAtom);
const deleteComposerBackward = useSetAtom(deleteComposerBackwardAtom);
const insertComposerText = useSetAtom(insertComposerTextAtom);
const moveComposerCursorBackward = useSetAtom(moveComposerCursorBackwardAtom);
const moveComposerCursorForward = useSetAtom(moveComposerCursorForwardAtom);
const setComposerValidationError = useSetAtom(setComposerValidationErrorAtom);

useInput((input, key) => { /* ... */ }, { isActive });
}

{ isActive } 让钩子在输入框失焦时不抢按键。回调拿到 input(原始字符串)和 key(Ink 解析出的按键标志)。

派发顺序

回调里的判断顺序是有讲究的——越特殊的越先判:

useInput(
(input, key) => {
if (isMouseInput(input)) {
return;
}

const newlineInput = promptNewlineInput(input, key, state);
if (newlineInput === 'replace-backslash') {
deleteComposerBackward({ maxBytes });
insertComposerText({ maxBytes, text: '\n' });
return;
}

if (newlineInput === 'insert-newline') {
insertComposerText({ maxBytes, text: '\n' });
return;
}

if (key.leftArrow) { moveComposerCursorBackward(); return; }
if (key.rightArrow) { moveComposerCursorForward(); return; }

if (key.return) {
submitPrompt({ clearComposer, maxBytes, onSubmit, setComposerValidationError, text: state.text });
return;
}

if (key.backspace || key.delete) { deleteComposerBackward({ maxBytes }); return; }

if (key.tab) { return; }

const printable = printableInput(input);
if (printable.length > 0) {
insertComposerText({ maxBytes, text: printable });
}
},
{ isActive }
);

逐条看:

  1. 鼠标输入先拦掉isMouseInput(第 12 篇)识别 SGR 鼠标序列。如果不拦,滚轮事件会被当成可打印文本插进 prompt。正文滚动靠的就是这些鼠标事件,所以输入框必须放行让它们冒泡到 HomeScreenView,自己不消费。
  2. 换行优先于提交promptNewlineInput 区分两种“插入换行”的情况(下面讲)。
  3. 左右方向键移动光标。
  4. 裸 Enter(key.return)提交。注意它排在换行判断之后——只有不被判定为“换行”的 Enter 才走提交。
  5. 退格/删除
  6. Tab 显式吞掉:U3 阶段 Tab 不导航、不补全,直接 return 什么都不做。
  7. 兜底:可打印文本。先过 printableInput 剥控制字符,非空才插入。

Enter 的三种语义:promptNewlineInput

Enter 是输入框里最微妙的键,要在“提交”和“换行”之间正确区分:

function promptNewlineInput(input, key, state): PromptNewlineInput | null {
if (MODIFIED_ENTER_INPUTS.has(input)) {
return 'insert-newline';
}

if (key.return !== true) {
return null;
}

if (key.shift === true || key.ctrl === true || key.meta === true) {
return 'insert-newline';
}

return state.text.at(state.cursorIndex - 1) === '\\' ? 'replace-backslash' : null;
}

它返回三种结果:

  • insert-newline:来自两个来源——要么 input 命中了 MODIFIED_ENTER_INPUTS(CSI-u 修饰 Enter 序列),要么 Ink 直接给出了 key.shift/ctrl/meta。这覆盖了不同终端对“组合 Enter”的两种上报方式。
  • replace-backslash:裸 Enter,且光标前一个字符是反斜杠 \。这是给不支持组合 Enter 的终端的 fallback——用户敲 \ 再回车,就把这个 \ 替换成换行。派发里对应“先退格删掉 \,再插入 \n”。
  • null:普通裸 Enter → 不是换行,继续走到提交分支。

这套设计让“想换行”有三条路(修饰 Enter 序列、Ink 修饰键、\ + Enter),而“想提交”就是干净的一下回车。

提交:submitPrompt

function submitPrompt({ clearComposer, maxBytes, onSubmit, setComposerValidationError, text }): void {
const validation = validateComposerSubmit(text, maxBytes);
if (!validation.ok) {
if (validation.reason === 'over-limit') {
setComposerValidationError(validation.message);
}
return;
}

onSubmit(validation.text);
clearComposer();
}

调用第 8 篇的 validateComposerSubmit,按结果分流:

  • 校验失败且原因是 over-limit → 把消息写进校验错误(输入框下方红字),不提交
  • 校验失败且原因是 empty → 静默 return,不提交也不报错(空回车就是没反应)。
  • 校验通过 → 调 onSubmit(text)(最终触发第 3 篇的 submitPromptAtom,把消息追加到正文),然后 clearComposer() 清空输入框,等待下一条。

这里把“提交”和“清空”分成两步,意味着提交后输入框立刻可用,符合 U3 的“提交后 composer 清空并接受新输入,而上一条 prompt 交给 App 的队列状态处理”。

下一篇讲输入框怎么把(可能很长、可能有换行的)文本裁成一个可见窗口——promptTextView.ts