Skip to main content

8. 输入框状态:composerAtoms

输入框(composer)这条线一共四篇,从状态开始。tui/src/state/composerAtoms.ts 持有输入框的全部可变状态,并把所有“编辑动作”封装成写原子。它在第一个 commit 里曾是一个 composerReducer,重构 commit 5432e018 把它改写成了 Jotai atoms。

状态形状

export const PROMPT_MAX_BYTES = 64 * 1024;

export type ComposerState = {
text: string;
cursorIndex: number;
validationError: string | null;
};

export const initialComposerState: ComposerState = {
text: '',
cursorIndex: 0,
validationError: null
};

export const composerStateAtom = atom<ComposerState>(initialComposerState);

三个字段就够了:当前文本、光标在文本里的字符索引、以及一条可选的校验错误。PROMPT_MAX_BYTES 是 64 KiB 的 prompt 大小上限,对应主屏计划“提交前用可见反馈拦截超限,而不是发一个超大的 JSON-RPC 请求”。

所有写原子都是 atom(null, writer) 形式(只写),统一通过更新 composerStateAtom 来改状态,且都遵循“无变化就返回原 state”的原则以避免无谓重渲染。

插入文本:insertComposerTextAtom

export const insertComposerTextAtom = atom(
null,
(_get, set, { maxBytes = PROMPT_MAX_BYTES, text: insertedText }: InsertTextOptions) => {
if (insertedText.length === 0) {
return;
}

set(composerStateAtom, (state) => {
const cursorIndex = clampCursorIndex(state.text, state.cursorIndex);
const text = state.text.slice(0, cursorIndex) + insertedText + state.text.slice(cursorIndex);

return {
text,
cursorIndex: cursorIndex + insertedText.length,
validationError: overLimitMessage(text, maxBytes)
};
});
}
);

在光标处插入文本,光标右移到插入内容之后,并实时overLimitMessage 更新校验错误。空插入直接早返回。注意它先 clampCursorIndex 把光标夹回合法范围,防御性地处理任何越界索引。打字、粘贴、插入换行都走这个原子(见第 9 篇)。

退格删除:deleteComposerBackwardAtom

export const deleteComposerBackwardAtom = atom(
null,
(_get, set, { maxBytes = PROMPT_MAX_BYTES }: OptionalMaxBytes = {}) => {
set(composerStateAtom, (state) => {
const cursorIndex = clampCursorIndex(state.text, state.cursorIndex);
if (cursorIndex === 0) {
return state;
}

const previousCursorIndex = previousCodePointStart(state.text, cursorIndex);
const text = state.text.slice(0, previousCursorIndex) + state.text.slice(cursorIndex);
if (text === state.text) {
return state;
}

return {
text,
cursorIndex: previousCursorIndex,
validationError: overLimitMessage(text, maxBytes)
};
});
}
);

删除光标前一个码位(不是一个 UTF-16 码元,下面细说)。光标在 0 时无操作;删除后重算校验错误(删字可能让超限恢复正常)。

光标移动与清空

左右移动光标,分别走两个原子,边界处无操作:

export const moveComposerCursorBackwardAtom = atom(null, (_get, set) => {
set(composerStateAtom, (state) => {
const cursorIndex = clampCursorIndex(state.text, state.cursorIndex);
if (cursorIndex === 0) {
return state;
}
return { ...state, cursorIndex: previousCodePointStart(state.text, cursorIndex) };
});
});

export const moveComposerCursorForwardAtom = atom(null, (_get, set) => {
set(composerStateAtom, (state) => {
const cursorIndex = clampCursorIndex(state.text, state.cursorIndex);
if (cursorIndex >= state.text.length) {
return state;
}
return { ...state, cursorIndex: nextCodePointEnd(state.text, cursorIndex) };
});
});

提交成功后清空,但若本来就是空且无错误则保持原 state(避免无意义更新):

export const clearComposerAtom = atom(null, (_get, set) => {
set(composerStateAtom, (state) => {
if (state.text.length === 0 && state.validationError === null) {
return state;
}
return initialComposerState;
});
});

设置/清除校验错误也单独成原子,同值不更新:

export const setComposerValidationErrorAtom = atom(null, (_get, set, message: string | null) => {
set(composerStateAtom, (state) => {
if (state.validationError === message) {
return state;
}
return { ...state, validationError: message };
});
});

输入清洗:printableInput

export function printableInput(input: string): string {
return input.replace(/[\u0000-\u001f\u007f]/g, '');
}

把 C0 控制字符和 DEL(\u007f)剥掉。粘贴进来的文本里如果夹带控制字符(终端转义、制表序列等),不会被当成可见内容塞进 prompt。第 9 篇会看到打字/粘贴的路径都先过这一层。

提交校验:validateComposerSubmit

export function validateComposerSubmit(text, maxBytes = PROMPT_MAX_BYTES): SubmitValidation {
if (text.trim().length === 0) {
return { ok: false, reason: 'empty', message: '' };
}

const limitMessage = overLimitMessage(text, maxBytes);
if (limitMessage !== null) {
return { ok: false, reason: 'over-limit', message: limitMessage };
}

return { ok: true, text };
}

返回一个可辨识联合(discriminated union):

  • 空或纯空白 → { ok: false, reason: 'empty' }。注意只看 trim() 是否为空来判定“能不能提交”,但提交的是原文 text,保留了非空 prompt 的前导/尾随空格。
  • 超限 → { ok: false, reason: 'over-limit', message }
  • 否则 → { ok: true, text }

第 9 篇的提交逻辑根据 reason 决定:空提交静默忽略,超限则把 message 写进校验错误显示出来。

Unicode 安全的光标移动

输入框可能包含 emoji、CJK 等需要代理对(surrogate pair)的字符。如果按单个 UTF-16 码元移动光标,会把一个代理对劈成两半,渲染出乱码。所以删除和左右移动都走这两个 helper:

function previousCodePointStart(text: string, cursorIndex: number): number {
const previousIndex = cursorIndex - 1;
const previousCodeUnit = text.charCodeAt(previousIndex);
const offset = previousCodeUnit >= 0xdc00 && previousCodeUnit <= 0xdfff ? 2 : 1;
return Math.max(0, cursorIndex - offset);
}

function nextCodePointEnd(text: string, cursorIndex: number): number {
const currentCodeUnit = text.charCodeAt(cursorIndex);
const offset = currentCodeUnit >= 0xd800 && currentCodeUnit <= 0xdbff ? 2 : 1;
return Math.min(text.length, cursorIndex + offset);
}

往前移:看前一个码元是不是低位代理0xDC00–0xDFFF),是就跨 2 个码元,否则 1 个。往后移:看当前码元是不是高位代理0xD800–0xDBFF),是就跨 2,否则 1。两个 helper 都再 Math.max(0, ...) / Math.min(length, ...) 夹住边界。

字节级限长:overLimitMessage

const textEncoder = new TextEncoder();

function overLimitMessage(text: string, maxBytes: number): string | null {
const byteLength = textEncoder.encode(text).length;
if (byteLength <= maxBytes) {
return null;
}
return `Prompt is ${byteLength} bytes; maximum is ${maxBytes} bytes.`;
}

限的是 UTF-8 字节数而不是字符数——因为 64 KiB 上限对应的是传输大小。复用一个模块级 TextEncoder 实例避免重复构造。没超限返回 null,超了返回一条带实际字节数的提示,最终会渲染成输入框下方的 ERROR: 行。

下一篇看这些原子是怎么被键盘事件驱动的——usePromptComposerInput 把按键翻译成对这些原子的调用。