@wukonglabs/wukong 0.0.19 → 0.0.21

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.
Files changed (135) hide show
  1. package/dist/cli/help.js +1 -1
  2. package/dist/cli/session-commands.d.ts.map +1 -1
  3. package/dist/cli/session-commands.js +4 -3
  4. package/dist/cli/session-commands.js.map +1 -1
  5. package/dist/index.js +73 -12
  6. package/dist/index.js.map +1 -1
  7. package/dist/tui/activity-verb.d.ts +29 -0
  8. package/dist/tui/activity-verb.d.ts.map +1 -0
  9. package/dist/tui/activity-verb.js +81 -0
  10. package/dist/tui/activity-verb.js.map +1 -0
  11. package/dist/tui/app.d.ts +29 -28
  12. package/dist/tui/app.d.ts.map +1 -1
  13. package/dist/tui/app.js +259 -132
  14. package/dist/tui/app.js.map +1 -1
  15. package/dist/tui/diff-count.d.ts +14 -0
  16. package/dist/tui/diff-count.d.ts.map +1 -0
  17. package/dist/tui/diff-count.js +21 -0
  18. package/dist/tui/diff-count.js.map +1 -0
  19. package/dist/tui/hint-bar.d.ts +12 -0
  20. package/dist/tui/hint-bar.d.ts.map +1 -0
  21. package/dist/tui/hint-bar.js +14 -0
  22. package/dist/tui/hint-bar.js.map +1 -0
  23. package/dist/tui/history-entry.d.ts +11 -0
  24. package/dist/tui/history-entry.d.ts.map +1 -0
  25. package/dist/tui/history-entry.js +35 -0
  26. package/dist/tui/history-entry.js.map +1 -0
  27. package/dist/tui/history-item.d.ts +40 -0
  28. package/dist/tui/history-item.d.ts.map +1 -0
  29. package/dist/tui/history-item.js +8 -0
  30. package/dist/tui/history-item.js.map +1 -0
  31. package/dist/tui/keys-help.d.ts +5 -0
  32. package/dist/tui/keys-help.d.ts.map +1 -0
  33. package/dist/tui/keys-help.js +36 -0
  34. package/dist/tui/keys-help.js.map +1 -0
  35. package/dist/tui/live-block.d.ts +54 -0
  36. package/dist/tui/live-block.d.ts.map +1 -0
  37. package/dist/tui/live-block.js +72 -0
  38. package/dist/tui/live-block.js.map +1 -0
  39. package/dist/tui/main-area.d.ts +40 -0
  40. package/dist/tui/main-area.d.ts.map +1 -0
  41. package/dist/tui/main-area.js +9 -0
  42. package/dist/tui/main-area.js.map +1 -0
  43. package/dist/tui/markdown.d.ts +14 -0
  44. package/dist/tui/markdown.d.ts.map +1 -0
  45. package/dist/tui/markdown.js +134 -0
  46. package/dist/tui/markdown.js.map +1 -0
  47. package/dist/tui/notice-panel.d.ts +20 -0
  48. package/dist/tui/notice-panel.d.ts.map +1 -0
  49. package/dist/tui/notice-panel.js +19 -0
  50. package/dist/tui/notice-panel.js.map +1 -0
  51. package/dist/tui/response-panel.d.ts +12 -16
  52. package/dist/tui/response-panel.d.ts.map +1 -1
  53. package/dist/tui/response-panel.js +8 -11
  54. package/dist/tui/response-panel.js.map +1 -1
  55. package/dist/tui/run-ink.d.ts +12 -0
  56. package/dist/tui/run-ink.d.ts.map +1 -1
  57. package/dist/tui/run-ink.js +2 -1
  58. package/dist/tui/run-ink.js.map +1 -1
  59. package/dist/tui/slash/menu.d.ts +16 -0
  60. package/dist/tui/slash/menu.d.ts.map +1 -0
  61. package/dist/tui/slash/menu.js +11 -0
  62. package/dist/tui/slash/menu.js.map +1 -0
  63. package/dist/tui/slash/parse.d.ts +21 -0
  64. package/dist/tui/slash/parse.d.ts.map +1 -0
  65. package/dist/tui/slash/parse.js +32 -0
  66. package/dist/tui/slash/parse.js.map +1 -0
  67. package/dist/tui/slash/registry.d.ts +20 -0
  68. package/dist/tui/slash/registry.d.ts.map +1 -0
  69. package/dist/tui/slash/registry.js +28 -0
  70. package/dist/tui/slash/registry.js.map +1 -0
  71. package/dist/tui/spinner.d.ts +11 -0
  72. package/dist/tui/spinner.d.ts.map +1 -0
  73. package/dist/tui/spinner.js +21 -0
  74. package/dist/tui/spinner.js.map +1 -0
  75. package/dist/tui/status-bar.d.ts +42 -0
  76. package/dist/tui/status-bar.d.ts.map +1 -0
  77. package/dist/tui/status-bar.js +32 -0
  78. package/dist/tui/status-bar.js.map +1 -0
  79. package/dist/tui/tail-visual.d.ts +11 -0
  80. package/dist/tui/tail-visual.d.ts.map +1 -0
  81. package/dist/tui/tail-visual.js +30 -0
  82. package/dist/tui/tail-visual.js.map +1 -0
  83. package/dist/tui/text-input.d.ts +20 -22
  84. package/dist/tui/text-input.d.ts.map +1 -1
  85. package/dist/tui/text-input.js +211 -81
  86. package/dist/tui/text-input.js.map +1 -1
  87. package/dist/tui/theme/context.d.ts +16 -0
  88. package/dist/tui/theme/context.d.ts.map +1 -0
  89. package/dist/tui/theme/context.js +28 -0
  90. package/dist/tui/theme/context.js.map +1 -0
  91. package/dist/tui/theme/persist.d.ts +11 -0
  92. package/dist/tui/theme/persist.d.ts.map +1 -0
  93. package/dist/tui/theme/persist.js +41 -0
  94. package/dist/tui/theme/persist.js.map +1 -0
  95. package/dist/tui/theme/theme.d.ts +26 -0
  96. package/dist/tui/theme/theme.d.ts.map +1 -0
  97. package/dist/tui/theme/theme.js +51 -0
  98. package/dist/tui/theme/theme.js.map +1 -0
  99. package/dist/tui/thinking-panel.d.ts +11 -20
  100. package/dist/tui/thinking-panel.d.ts.map +1 -1
  101. package/dist/tui/thinking-panel.js +29 -7
  102. package/dist/tui/thinking-panel.js.map +1 -1
  103. package/dist/tui/tool-cards.d.ts +5 -17
  104. package/dist/tui/tool-cards.d.ts.map +1 -1
  105. package/dist/tui/tool-cards.js +4 -5
  106. package/dist/tui/tool-cards.js.map +1 -1
  107. package/dist/tui/tool-summary.d.ts +2 -0
  108. package/dist/tui/tool-summary.d.ts.map +1 -0
  109. package/dist/tui/tool-summary.js +93 -0
  110. package/dist/tui/tool-summary.js.map +1 -0
  111. package/dist/tui/use-elapsed.d.ts +2 -0
  112. package/dist/tui/use-elapsed.d.ts.map +1 -0
  113. package/dist/tui/use-elapsed.js +22 -0
  114. package/dist/tui/use-elapsed.js.map +1 -0
  115. package/dist/tui/use-plan-state.d.ts +20 -1
  116. package/dist/tui/use-plan-state.d.ts.map +1 -1
  117. package/dist/tui/use-plan-state.js +52 -46
  118. package/dist/tui/use-plan-state.js.map +1 -1
  119. package/dist/tui/use-terminal-width.d.ts +2 -0
  120. package/dist/tui/use-terminal-width.d.ts.map +1 -0
  121. package/dist/tui/use-terminal-width.js +23 -0
  122. package/dist/tui/use-terminal-width.js.map +1 -0
  123. package/dist/tui/use-viewport.d.ts +15 -0
  124. package/dist/tui/use-viewport.d.ts.map +1 -0
  125. package/dist/tui/use-viewport.js +37 -0
  126. package/dist/tui/use-viewport.js.map +1 -0
  127. package/dist/tui/welcome.d.ts +29 -0
  128. package/dist/tui/welcome.d.ts.map +1 -0
  129. package/dist/tui/welcome.js +41 -0
  130. package/dist/tui/welcome.js.map +1 -0
  131. package/package.json +3 -2
  132. package/dist/tui/header.d.ts +0 -24
  133. package/dist/tui/header.d.ts.map +0 -1
  134. package/dist/tui/header.js +0 -26
  135. package/dist/tui/header.js.map +0 -1
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text, Box } from 'ink';
3
+ import { Spinner } from './spinner.js';
4
+ import { useTheme } from './theme/context.js';
5
+ import { useTerminalWidth } from './use-terminal-width.js';
6
+ export function StatusBar({ model, workspaceRoot, busy, lastDurationMs, currentStep = 0, maxSteps, cacheHitRate, runStartAt, aborting, noticeCount = 0, devMode = process.env.WUKONG_DEV === '1', sessionUuidShort, elapsedSec, }) {
7
+ const { theme } = useTheme();
8
+ const cols = useTerminalWidth();
9
+ const elapsedStr = (elapsedSec ?? 0) > 0 ? `${elapsedSec.toFixed(1)}s` : '—';
10
+ const lastStr = lastDurationMs !== undefined ? `${(lastDurationMs / 1000).toFixed(1)}s` : null;
11
+ const shortRoot = shortenPath(workspaceRoot, 36);
12
+ const showCwd = cols >= 60;
13
+ const stepStr = currentStep > 0
14
+ ? maxSteps ? `🎯 ${currentStep}/${maxSteps}` : `🎯 ${currentStep}`
15
+ : '🎯 —';
16
+ const cacheStr = cacheHitRate !== undefined ? `💾 ${(cacheHitRate * 100).toFixed(0)}%` : null;
17
+ const statusText = aborting ? '⛔ 中断中…' : busy ? '思考中…' : '✓ ready';
18
+ const statusColor = aborting ? 'red' : busy ? theme.toolRunning : theme.planDone;
19
+ return (_jsxs(Box, { paddingX: 0, children: [_jsx(Text, { color: theme.status, bold: true, children: "wukong" }), _jsx(Text, { color: theme.dim, children: " \u00B7 " }), _jsx(Text, { color: theme.assistant, children: model }), showCwd && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.dim, children: " \u00B7 " }), _jsx(Text, { color: theme.dim, children: shortRoot })] })), busy && _jsx(Spinner, { color: theme.toolRunning }), _jsx(Text, { color: statusColor, children: statusText }), _jsx(Text, { color: theme.dim, children: " \u00B7 " }), _jsxs(Text, { color: theme.dim, children: ["\u23F1 ", busy ? elapsedStr : lastStr ?? '—'] }), noticeCount > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.dim, children: " \u00B7 " }), _jsxs(Text, { color: "yellow", children: ["\u26A0 ", noticeCount] })] })), devMode && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.dim, children: " \u00B7 " }), _jsx(Text, { color: theme.dim, children: stepStr }), cacheStr && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.dim, children: " \u00B7 " }), _jsx(Text, { color: theme.planDone, children: cacheStr })] })), sessionUuidShort && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.dim, children: " \u00B7 " }), _jsxs(Text, { color: theme.thinking, children: ["\uD83D\uDCC2 ", sessionUuidShort] })] }))] }))] }));
20
+ }
21
+ function shortenPath(p, maxLen) {
22
+ if (p.length <= maxLen)
23
+ return p;
24
+ const parts = p.split('/');
25
+ if (parts.length <= 3)
26
+ return p;
27
+ const tail = parts.slice(-2).join('/');
28
+ const head = parts[0] || '/';
29
+ const shortened = `${head}/…/${tail}`;
30
+ return shortened.length <= maxLen ? shortened : `…/${tail}`;
31
+ }
32
+ //# sourceMappingURL=status-bar.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status-bar.js","sourceRoot":"","sources":["../../src/tui/status-bar.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 按"视觉行数"取文本末尾 N 行(纯函数,便于单测)。
3
+ *
4
+ * M3:流式响应恢复逐字展示时,动态区只显示末 N 行。但一行可能超过终端宽度
5
+ * 被 Ink 折成多行,所以这里先按 cols 把每个物理段落拆成若干视觉行,
6
+ * 再取末尾 maxLines 个视觉行返回。
7
+ *
8
+ * cols <= 0 时退化为按物理行取末尾(测试 / 未知宽度时安全)。
9
+ */
10
+ export declare function tailVisualLines(text: string, maxLines: number, cols: number): string;
11
+ //# sourceMappingURL=tail-visual.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tail-visual.d.ts","sourceRoot":"","sources":["../../src/tui/tail-visual.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAkBpF"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 按"视觉行数"取文本末尾 N 行(纯函数,便于单测)。
3
+ *
4
+ * M3:流式响应恢复逐字展示时,动态区只显示末 N 行。但一行可能超过终端宽度
5
+ * 被 Ink 折成多行,所以这里先按 cols 把每个物理段落拆成若干视觉行,
6
+ * 再取末尾 maxLines 个视觉行返回。
7
+ *
8
+ * cols <= 0 时退化为按物理行取末尾(测试 / 未知宽度时安全)。
9
+ */
10
+ export function tailVisualLines(text, maxLines, cols) {
11
+ if (maxLines <= 0)
12
+ return '';
13
+ if (cols <= 0) {
14
+ const lines = text.split('\n');
15
+ return lines.slice(Math.max(0, lines.length - maxLines)).join('\n');
16
+ }
17
+ const paras = text.split('\n');
18
+ const visualLines = [];
19
+ for (const p of paras) {
20
+ if (p.length === 0) {
21
+ visualLines.push('');
22
+ continue;
23
+ }
24
+ for (let i = 0; i < p.length; i += cols) {
25
+ visualLines.push(p.slice(i, i + cols));
26
+ }
27
+ }
28
+ return visualLines.slice(Math.max(0, visualLines.length - maxLines)).join('\n');
29
+ }
30
+ //# sourceMappingURL=tail-visual.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tail-visual.js","sourceRoot":"","sources":["../../src/tui/tail-visual.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAAC,IAAY,EAAE,QAAgB,EAAE,IAAY;IAC1E,IAAI,QAAQ,IAAI,CAAC;QAAE,OAAO,EAAE,CAAC;IAC7B,IAAI,IAAI,IAAI,CAAC,EAAE,CAAC;QACd,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC/B,OAAO,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACtE,CAAC;IACD,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC/B,MAAM,WAAW,GAAa,EAAE,CAAC;IACjC,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;QACtB,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACnB,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YACrB,SAAS;QACX,CAAC;QACD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC;YACxC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC;QACzC,CAAC;IACH,CAAC;IACD,OAAO,WAAW,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,WAAW,CAAC,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAClF,CAAC"}
@@ -1,27 +1,26 @@
1
1
  /**
2
- * 多行文本输入组件(自研,Ink 自带 ink-text-input 不支持多行 + Ctrl+R)。
2
+ * 多行文本输入组件(自研,Ink 自带 ink-text-input 不支持多行 + Ctrl+R + slash 菜单)。
3
3
  *
4
4
  * ## 交互
5
5
  *
6
6
  * - **Enter**:提交(若 `multiline` 为 false,任何 Enter 都提交)
7
7
  * - **Shift+Enter / Alt+Enter**:换行(仅 multiline=true 时)
8
8
  * - **Ctrl+J**:换行(终端友好,Ctrl+J = LF)
9
- * - **↑ / ↓**(单行时):浏览历史
9
+ * - **↑ / ↓**(单行时):浏览历史(菜单未激活时)
10
10
  * - **↑ / ↓**(多行时):移动光标到上/下一行
11
- * - **Ctrl+R**:进入反查模式;再按跳下一条匹配;Enter 提交当前匹配;Esc 退出
11
+ * - **Ctrl+R**:进入反查模式;再按跳下一条匹配;Enter 提交当前匹配;Esc/Ctrl+G 取消
12
12
  * - **Ctrl+C**:清理后退出(由 App 层接管 graceful shutdown)
13
13
  * - **Ctrl+L**:清屏(Ink 原生 clear)
14
+ * - **Home / End**(或 Ctrl+A / Ctrl+E):行首 / 行尾
15
+ * - **Alt+← / →**(或 Ctrl+← / →):按词跳转
16
+ * - **Ctrl+W**:删除光标前一词;**Ctrl+K / U**:删到行尾 / 行首;**Ctrl+D**:删光标后字符(空输入退出)
17
+ * - 以 / 开头:唤起 Slash 命令菜单(↑↓ 选择、Enter 确认、Esc 取消)
14
18
  *
15
- * ## 粘贴检测
19
+ * ## 插入模型(ref-based,M11c)
16
20
  *
17
- * Ink 没有原生 paste 事件。通过监听 `stdin` data chunk,当一次 chunk 中
18
- * 含多个字符(特别是含换行)时,判定为粘贴,直接整体插入(不走逐字符 input)。
19
- * 这避免了粘贴中文/长文本时每个字符都触发一次 re-render 的性能问题。
20
- *
21
- * ## 可访问性
22
- *
23
- * `multiline` 模式下不依赖光标可视化(终端光标控制在不同终端差异大),
24
- * 用 `▋` 字符标记光标位置;`NO_COLOR` 时仍可见。
21
+ * 旧实现用 useCallback 闭包持有 value/cursor,多字符输入(粘贴 / IME 选词 / 快速连击)
22
+ * 因闭包未 flush 会丢字(实测 "你好" 只留 "好")。M11c 改用 valueRef/cursorRef,
23
+ * 所有可打印字符(单/多)统一走 insertText,彻底消除闭包陷阱,IME 候选可见、可退格改字。
25
24
  */
26
25
  import React from 'react';
27
26
  import { useInputHistory } from './use-input-history.js';
@@ -47,15 +46,14 @@ export interface TextInputProps {
47
46
  * Ink raw mode 拦截 Ctrl+C,不走 process SIGINT,故需显式回调。
48
47
  */
49
48
  onAbort?: () => void;
49
+ /** Ctrl+D 空输入时退出(对齐 readline) */
50
+ onExit?: () => void;
51
+ /** 选中某个 slash 命令时回调(name + 可选参数) */
52
+ onSlash?: (name: string, arg?: string) => void;
50
53
  }
51
- export declare function TextInput({ value, onChange, onSubmit, multiline, history, prompt, placeholder, disabled, onAbort, }: TextInputProps): React.ReactElement;
52
- /**
53
- * 多字符输入是否为粘贴(纯函数,便于单测)
54
- *
55
- * Ink useInput 把整段 stdin chunk 作为单次 input 传入。
56
- * 视为粘贴的特征:含换行(多行粘贴)或长度 >= 5(普通打字不可能一次到 5 字符)。
57
- * 短输入(2-4 字符无换行)可能是 IME 选词 / 快速连击,都按"整体插入"处理
58
- * (不再逐字符,以避免 useCallback 闭包陷阱导致多字符只保留最后一个)。
59
- */
60
- export declare function isPasteInput(input: string): boolean;
54
+ export declare function TextInput({ value, onChange, onSubmit, multiline, history, prompt, placeholder, disabled, onAbort, onExit, onSlash, }: TextInputProps): React.ReactElement;
55
+ /** 找 pos 之前最近的 word 边界(跳过连续 whitespace + 非 word 字符) */
56
+ export declare function prevWordBoundary(value: string, pos: number): number;
57
+ /** 找 pos 之后最近的 word 边界 */
58
+ export declare function nextWordBoundary(value: string, pos: number): number;
61
59
  //# sourceMappingURL=text-input.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"text-input.d.ts","sourceRoot":"","sources":["../../src/tui/text-input.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,KAA2C,MAAM,OAAO,CAAC;AAEhE,OAAO,EACL,eAAe,EAGhB,MAAM,wBAAwB,CAAC;AAEhC,MAAM,WAAW,cAAc;IAC7B,cAAc;IACd,KAAK,EAAE,MAAM,CAAC;IACd,YAAY;IACZ,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,kBAAkB;IAClB,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,sBAAsB;IACtB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oBAAoB;IACpB,OAAO,CAAC,EAAE,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;IAC7C,qBAAqB;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0BAA0B;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yBAAyB;IACzB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACtB;AAeD,wBAAgB,SAAS,CAAC,EACxB,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,SAAgB,EAChB,OAAO,EACP,MAAa,EACb,WAAgB,EAChB,QAAgB,EAChB,OAAO,GACR,EAAE,cAAc,GAAG,KAAK,CAAC,YAAY,CAwQrC;AAiBD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAGnD"}
1
+ {"version":3,"file":"text-input.d.ts","sourceRoot":"","sources":["../../src/tui/text-input.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,KAAmD,MAAM,OAAO,CAAC;AAExE,OAAO,EACL,eAAe,EAGhB,MAAM,wBAAwB,CAAC;AAQhC,MAAM,WAAW,cAAc;IAC7B,cAAc;IACd,KAAK,EAAE,MAAM,CAAC;IACd,YAAY;IACZ,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,kBAAkB;IAClB,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,sBAAsB;IACtB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oBAAoB;IACpB,OAAO,CAAC,EAAE,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;IAC7C,qBAAqB;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0BAA0B;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yBAAyB;IACzB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,iCAAiC;IACjC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,oCAAoC;IACpC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CAChD;AAeD,wBAAgB,SAAS,CAAC,EACxB,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,SAAgB,EAChB,OAAO,EACP,MAAa,EACb,WAAgB,EAChB,QAAgB,EAChB,OAAO,EACP,MAAM,EACN,OAAO,GACR,EAAE,cAAc,GAAG,KAAK,CAAC,YAAY,CA2YrC;AAyCD,uDAAuD;AACvD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAMnE;AAED,0BAA0B;AAC1B,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAMnE"}
@@ -1,41 +1,48 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * 多行文本输入组件(自研,Ink 自带 ink-text-input 不支持多行 + Ctrl+R)。
3
+ * 多行文本输入组件(自研,Ink 自带 ink-text-input 不支持多行 + Ctrl+R + slash 菜单)。
4
4
  *
5
5
  * ## 交互
6
6
  *
7
7
  * - **Enter**:提交(若 `multiline` 为 false,任何 Enter 都提交)
8
8
  * - **Shift+Enter / Alt+Enter**:换行(仅 multiline=true 时)
9
9
  * - **Ctrl+J**:换行(终端友好,Ctrl+J = LF)
10
- * - **↑ / ↓**(单行时):浏览历史
10
+ * - **↑ / ↓**(单行时):浏览历史(菜单未激活时)
11
11
  * - **↑ / ↓**(多行时):移动光标到上/下一行
12
- * - **Ctrl+R**:进入反查模式;再按跳下一条匹配;Enter 提交当前匹配;Esc 退出
12
+ * - **Ctrl+R**:进入反查模式;再按跳下一条匹配;Enter 提交当前匹配;Esc/Ctrl+G 取消
13
13
  * - **Ctrl+C**:清理后退出(由 App 层接管 graceful shutdown)
14
14
  * - **Ctrl+L**:清屏(Ink 原生 clear)
15
+ * - **Home / End**(或 Ctrl+A / Ctrl+E):行首 / 行尾
16
+ * - **Alt+← / →**(或 Ctrl+← / →):按词跳转
17
+ * - **Ctrl+W**:删除光标前一词;**Ctrl+K / U**:删到行尾 / 行首;**Ctrl+D**:删光标后字符(空输入退出)
18
+ * - 以 / 开头:唤起 Slash 命令菜单(↑↓ 选择、Enter 确认、Esc 取消)
15
19
  *
16
- * ## 粘贴检测
20
+ * ## 插入模型(ref-based,M11c)
17
21
  *
18
- * Ink 没有原生 paste 事件。通过监听 `stdin` data chunk,当一次 chunk 中
19
- * 含多个字符(特别是含换行)时,判定为粘贴,直接整体插入(不走逐字符 input)。
20
- * 这避免了粘贴中文/长文本时每个字符都触发一次 re-render 的性能问题。
21
- *
22
- * ## 可访问性
23
- *
24
- * `multiline` 模式下不依赖光标可视化(终端光标控制在不同终端差异大),
25
- * 用 `▋` 字符标记光标位置;`NO_COLOR` 时仍可见。
22
+ * 旧实现用 useCallback 闭包持有 value/cursor,多字符输入(粘贴 / IME 选词 / 快速连击)
23
+ * 因闭包未 flush 会丢字(实测 "你好" 只留 "好")。M11c 改用 valueRef/cursorRef,
24
+ * 所有可打印字符(单/多)统一走 insertText,彻底消除闭包陷阱,IME 候选可见、可退格改字。
26
25
  */
27
- import { useState, useCallback, useEffect } from 'react';
28
- import { Text, useInput, useStdin } from 'ink';
26
+ import { useState, useCallback, useEffect, useRef } from 'react';
27
+ import { Text, useInput, useStdin, useStdout } from 'ink';
29
28
  import { reverseSearch, reverseSearchNext, } from './use-input-history.js';
29
+ import { parseSlashCommand } from './slash/parse.js';
30
+ import { filterSlashCommands } from './slash/registry.js';
31
+ import { SlashMenu } from './slash/menu.js';
32
+ /** 输入长度硬上限(M11c):粘贴大文件也够,防止异常灌入撑爆 Ink */
33
+ const MAX_INPUT_LENGTH = 100_000;
30
34
  const INITIAL_RS = {
31
35
  active: false,
32
36
  query: '',
33
37
  matchIndex: -1,
34
38
  };
35
- export function TextInput({ value, onChange, onSubmit, multiline = true, history, prompt = '> ', placeholder = '', disabled = false, onAbort, }) {
39
+ export function TextInput({ value, onChange, onSubmit, multiline = true, history, prompt = '> ', placeholder = '', disabled = false, onAbort, onExit, onSlash, }) {
36
40
  const [cursor, setCursor] = useState(value.length);
37
41
  const [rs, setRs] = useState(INITIAL_RS);
42
+ const [slashDismissed, setSlashDismissed] = useState(false);
43
+ const [slashHighlight, setSlashHighlight] = useState(0);
38
44
  const { stdin, setRawMode } = useStdin();
45
+ const { stdout } = useStdout();
39
46
  // stdin 原始模式(捕获 Ctrl+C / Ctrl+R 等控制字符)
40
47
  useEffect(() => {
41
48
  if (stdin && typeof setRawMode === 'function') {
@@ -44,15 +51,35 @@ export function TextInput({ value, onChange, onSubmit, multiline = true, history
44
51
  }
45
52
  return;
46
53
  }, [stdin, setRawMode]);
47
- // 插入文本到光标位置
54
+ // slash 菜单取消后,只要输入不再以 / 开头就恢复激活
55
+ useEffect(() => {
56
+ if (!value.startsWith('/'))
57
+ setSlashDismissed(false);
58
+ }, [value]);
59
+ // ── ref-based 插入(M11c)──
60
+ const valueRef = useRef(value);
61
+ const cursorRef = useRef(cursor);
62
+ useEffect(() => { valueRef.current = value; }, [value]);
63
+ useEffect(() => { cursorRef.current = cursor; }, [cursor]);
48
64
  const insertText = useCallback((text) => {
49
- const newValue = value.slice(0, cursor) + text + value.slice(cursor);
65
+ const v = valueRef.current;
66
+ const c = cursorRef.current;
67
+ let t = text;
68
+ if (v.length + t.length > MAX_INPUT_LENGTH) {
69
+ const room = MAX_INPUT_LENGTH - v.length;
70
+ if (room <= 0)
71
+ return;
72
+ t = t.slice(0, room);
73
+ }
74
+ const newValue = v.slice(0, c) + t + v.slice(c);
75
+ valueRef.current = newValue;
76
+ cursorRef.current = c + t.length;
50
77
  onChange(newValue);
51
- setCursor(cursor + text.length);
52
- }, [value, cursor, onChange]);
53
- // 提交当前输入
78
+ setCursor(c + t.length);
79
+ }, [onChange]);
80
+ // 提交当前输入(trim,M11b)
54
81
  const submit = useCallback(() => {
55
- const trimmed = value;
82
+ const trimmed = value.trim();
56
83
  if (!trimmed)
57
84
  return;
58
85
  history?.push(trimmed);
@@ -61,35 +88,51 @@ export function TextInput({ value, onChange, onSubmit, multiline = true, history
61
88
  setCursor(0);
62
89
  setRs(INITIAL_RS);
63
90
  }, [value, history, onSubmit, onChange]);
91
+ // slash 菜单是否激活(输入以 / 开头且未取消、且不在反查模式)
92
+ const slashActive = value.startsWith('/') && !slashDismissed && !rs.active;
93
+ const slashCmds = slashActive ? filterSlashCommands(parseSlashCommand(value).raw) : [];
64
94
  useInput((input, key) => {
65
95
  if (disabled)
66
96
  return;
67
- // ── 多字符输入处理 ──
68
- // Ink 把整段 stdin chunk 作为单次 useInput 调用(粘贴 / IME 选词 /
69
- // 快速连击都会一次性到)。处理:整体插入(等价 paste 行为),不再逐字符。
70
- //
71
- // 原因:逐字符插入会触发 useCallback 闭包陷阱——第一次 insertText 的
72
- // onChange 还没 flush,第二次 insertText 用的是旧 value/cursor,导致
73
- // 多字符只保留最后一个字符(实测 "你好" 选词后只显示 "好")。
74
- if (input && input.length >= 2 && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.leftArrow && !key.rightArrow && !key.return && !key.escape && !key.backspace && !key.delete && !key.tab) {
75
- const cleaned = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
76
- history?.exitNavigation();
77
- insertText(cleaned);
78
- return;
97
+ // ink Key 类型未声明 home/end(Fn+方向键 / 物理 Home/End),这里扩展访问
98
+ const k = key;
99
+ // ── Slash 菜单模式(M1)──
100
+ if (slashActive) {
101
+ if (key.escape) {
102
+ setSlashDismissed(true);
103
+ return;
104
+ }
105
+ if (key.upArrow) {
106
+ setSlashHighlight((h) => Math.max(0, h - 1));
107
+ return;
108
+ }
109
+ if (key.downArrow) {
110
+ setSlashHighlight((h) => Math.min(Math.max(slashCmds.length - 1, 0), h + 1));
111
+ return;
112
+ }
113
+ if (key.return && slashCmds.length > 0) {
114
+ const cmd = slashCmds[Math.min(slashHighlight, slashCmds.length - 1)];
115
+ const parsed = parseSlashCommand(value);
116
+ onSlash?.(cmd.name, parsed.arg);
117
+ onChange('');
118
+ setCursor(0);
119
+ setSlashDismissed(false);
120
+ setSlashHighlight(0);
121
+ return;
122
+ }
123
+ // 其他键:作为普通编辑落到下方处理(菜单随文本实时过滤)
79
124
  }
80
125
  // ── Ctrl+R:反查模式 ──
81
126
  if (key.ctrl && input === 'r') {
82
127
  if (!history || history.entries.length === 0)
83
128
  return;
84
129
  if (!rs.active) {
85
- // 进入反查模式,空 query → 找最近一条
86
130
  const match = reverseSearch(history.entries, '', history.entries.length - 1);
87
131
  setRs({ active: true, query: '', matchIndex: match.index });
88
132
  if (match.text)
89
133
  onChange(match.text);
90
134
  }
91
135
  else {
92
- // 已经在反查模式 → 跳下一条匹配
93
136
  if (rs.matchIndex >= 0) {
94
137
  const next = reverseSearchNext(history.entries, rs.query, rs.matchIndex);
95
138
  setRs({ ...rs, matchIndex: next.index });
@@ -101,20 +144,25 @@ export function TextInput({ value, onChange, onSubmit, multiline = true, history
101
144
  }
102
145
  // ── 反查模式下的其他按键 ──
103
146
  if (rs.active) {
104
- if (key.escape) {
105
- // Esc:退出反查,保留当前匹配作为输入
147
+ if (key.escape || (key.ctrl && input === 'g')) {
148
+ // Esc / Ctrl+G:取消反查,恢复进入前的草稿
106
149
  setRs(INITIAL_RS);
150
+ onChange(history?.draftBeforeNav ?? '');
107
151
  return;
108
152
  }
109
153
  if (key.return) {
110
- // Enter:提交当前匹配
111
154
  setRs(INITIAL_RS);
112
155
  submit();
113
156
  return;
114
157
  }
115
158
  if (key.backspace || key.delete) {
116
- // 删除 query 最后一个字符,重新匹配
117
159
  const newQuery = rs.query.slice(0, -1);
160
+ if (newQuery.length === 0) {
161
+ // 退到空 query:自动退出反查,恢复到进入前草稿
162
+ setRs(INITIAL_RS);
163
+ onChange(history?.draftBeforeNav ?? '');
164
+ return;
165
+ }
118
166
  const match = reverseSearch(history.entries, newQuery);
119
167
  setRs({ ...rs, query: newQuery, matchIndex: match.index });
120
168
  if (match.text)
@@ -123,7 +171,6 @@ export function TextInput({ value, onChange, onSubmit, multiline = true, history
123
171
  onChange(newQuery);
124
172
  return;
125
173
  }
126
- // 可打印字符 → 追加到 query,重新匹配
127
174
  if (input && !key.ctrl && !key.meta && input.length === 1 && input >= ' ') {
128
175
  const newQuery = rs.query + input;
129
176
  const match = reverseSearch(history.entries, newQuery);
@@ -134,22 +181,69 @@ export function TextInput({ value, onChange, onSubmit, multiline = true, history
134
181
  onChange(newQuery);
135
182
  return;
136
183
  }
137
- // 其他控制键在反查模式下忽略
184
+ return;
185
+ }
186
+ // ── Home / End(物理键 + Fn 方向键)──
187
+ if (k.home || (key.meta && key.leftArrow)) {
188
+ const lineStart = value.lastIndexOf('\n', cursor - 1) + 1;
189
+ setCursor(lineStart);
190
+ return;
191
+ }
192
+ if (k.end || (key.meta && key.rightArrow)) {
193
+ const lineEnd = value.indexOf('\n', cursor);
194
+ setCursor(lineEnd === -1 ? value.length : lineEnd);
195
+ return;
196
+ }
197
+ // ── Word jump(Alt+←/→ 或 Ctrl+←/→)──
198
+ if ((key.meta || key.ctrl) && key.leftArrow) {
199
+ setCursor(prevWordBoundary(value, cursor));
200
+ return;
201
+ }
202
+ if ((key.meta || key.ctrl) && key.rightArrow) {
203
+ setCursor(nextWordBoundary(value, cursor));
204
+ return;
205
+ }
206
+ // ── Ctrl+W:删除前一个 word ──
207
+ if (key.ctrl && input === 'w') {
208
+ const wordStart = prevWordBoundary(value, cursor);
209
+ onChange(value.slice(0, wordStart) + value.slice(cursor));
210
+ setCursor(wordStart);
211
+ return;
212
+ }
213
+ // ── Ctrl+K:删到行尾 ──
214
+ if (key.ctrl && input === 'k') {
215
+ const lineEnd = value.indexOf('\n', cursor);
216
+ const end = lineEnd === -1 ? value.length : lineEnd;
217
+ onChange(value.slice(0, cursor) + value.slice(end));
218
+ return;
219
+ }
220
+ // ── Ctrl+U:删到行首 ──
221
+ if (key.ctrl && input === 'u') {
222
+ const lineStart = value.lastIndexOf('\n', cursor - 1) + 1;
223
+ onChange(value.slice(0, lineStart) + value.slice(cursor));
224
+ setCursor(lineStart);
225
+ return;
226
+ }
227
+ // ── Ctrl+D:空输入退出;非空删除光标后字符 ──
228
+ if (key.ctrl && input === 'd') {
229
+ if (value.length === 0) {
230
+ onExit?.();
231
+ }
232
+ else if (cursor < value.length) {
233
+ onChange(value.slice(0, cursor) + value.slice(cursor + 1));
234
+ }
138
235
  return;
139
236
  }
140
237
  // ── 普通模式 ──
141
238
  if (key.return) {
142
- // Shift+Enter / Meta+Enter → 换行(多行模式)
143
239
  if (multiline && (key.shift || key.meta)) {
144
240
  insertText('\n');
145
241
  return;
146
242
  }
147
- // 普通 Enter → 提交
148
243
  submit();
149
244
  return;
150
245
  }
151
246
  if (key.ctrl && input === 'j') {
152
- // Ctrl+J = LF,多行模式下换行
153
247
  if (multiline) {
154
248
  insertText('\n');
155
249
  }
@@ -159,14 +253,11 @@ export function TextInput({ value, onChange, onSubmit, multiline = true, history
159
253
  return;
160
254
  }
161
255
  if (key.ctrl && input === 'c') {
162
- // §3.3 M3:Ctrl+C 交给 App 层处理两次语义(graceful shutdown)
163
- // Ink raw mode拦截 Ctrl+C 不走 process SIGINT,故需显式回调
164
256
  onAbort?.();
165
257
  return;
166
258
  }
167
259
  if (key.backspace || key.delete) {
168
260
  if (cursor > 0) {
169
- // 删除光标前一个字符(含跨行 \n)
170
261
  const newValue = value.slice(0, cursor - 1) + value.slice(cursor);
171
262
  onChange(newValue);
172
263
  setCursor(cursor - 1);
@@ -182,11 +273,9 @@ export function TextInput({ value, onChange, onSubmit, multiline = true, history
182
273
  return;
183
274
  }
184
275
  if (key.upArrow) {
185
- // 多行模式:光标移到上一行(如果有)
186
276
  if (multiline) {
187
277
  const { lineStart, lineIndex, colInLine } = getLineInfo(value, cursor);
188
278
  if (lineIndex > 0) {
189
- // 上一行同列位置
190
279
  const prevLineStart = value.lastIndexOf('\n', lineStart - 1) + 1;
191
280
  const prevLineEnd = lineStart - 1;
192
281
  const prevLineLen = prevLineEnd - prevLineStart;
@@ -195,7 +284,6 @@ export function TextInput({ value, onChange, onSubmit, multiline = true, history
195
284
  return;
196
285
  }
197
286
  }
198
- // 单行模式 或 多行已在第一行 → 浏览历史
199
287
  if (history) {
200
288
  const text = history.navigate('up', value);
201
289
  if (text !== null) {
@@ -217,7 +305,6 @@ export function TextInput({ value, onChange, onSubmit, multiline = true, history
217
305
  return;
218
306
  }
219
307
  }
220
- // 单行 或 多行最后一行 → 浏览历史
221
308
  if (history) {
222
309
  const text = history.navigate('down', value);
223
310
  if (text !== null) {
@@ -225,7 +312,6 @@ export function TextInput({ value, onChange, onSubmit, multiline = true, history
225
312
  setCursor(text.length);
226
313
  }
227
314
  else {
228
- // 到底恢复草稿
229
315
  history.exitNavigation();
230
316
  onChange(history.draftBeforeNav);
231
317
  setCursor(history.draftBeforeNav.length);
@@ -233,58 +319,82 @@ export function TextInput({ value, onChange, onSubmit, multiline = true, history
233
319
  }
234
320
  return;
235
321
  }
236
- // Home / End
237
322
  if (key.ctrl && input === 'a') {
238
- // 行首
239
323
  const lineStart = value.lastIndexOf('\n', cursor - 1) + 1;
240
324
  setCursor(lineStart);
241
325
  return;
242
326
  }
243
327
  if (key.ctrl && input === 'e') {
244
- // 行尾
245
328
  const lineEnd = value.indexOf('\n', cursor);
246
329
  setCursor(lineEnd === -1 ? value.length : lineEnd);
247
330
  return;
248
331
  }
249
- // 可打印字符
250
- if (input && !key.ctrl && !key.meta && input.length === 1 && input >= ' ') {
251
- // 任何编辑都退出历史导航
332
+ // ── 可打印字符(单/多字符统一走 insertText,M11c)──
333
+ if (input && !key.ctrl && !key.meta && input >= ' ') {
334
+ // M9:空输入时按 ? 直接打开键位帮助(等效 /keys)
335
+ if (input === '?' && value.length === 0) {
336
+ onSlash?.('keys');
337
+ return;
338
+ }
339
+ const cleaned = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
252
340
  history?.exitNavigation();
253
- insertText(input);
341
+ insertText(cleaned);
254
342
  return;
255
343
  }
256
344
  });
345
+ // ── 物理光标定位(IME 候选窗跟随)──
346
+ // Ink raw mode 下每帧渲染完物理光标停在帧末尾(HintBar 后),
347
+ // OS 的 IME 候选窗固定在物理光标处弹出,导致候选窗偏离输入框.
348
+ // 用 setTimeout(0) 延迟到 Ink 帧刷完后重定位,避免干扰帧缓冲.
349
+ useEffect(() => {
350
+ if (disabled || !value)
351
+ return;
352
+ const s = stdout;
353
+ if (!s?.write || !s.isTTY || !s.rows)
354
+ return;
355
+ const writeFn = s.write;
356
+ const termRows = s.rows;
357
+ const timer = setTimeout(() => {
358
+ try {
359
+ const lineStart = value.lastIndexOf('\n', cursor - 1) + 1;
360
+ const colInLine = cursor - lineStart;
361
+ let cursorLineIndex = 0;
362
+ for (let i = 0; i < cursor; i++) {
363
+ if (value[i] === '\n')
364
+ cursorLineIndex++;
365
+ }
366
+ /*
367
+ * footer 钉底结构(从屏幕底向上):
368
+ * row termRows : HintBar
369
+ * row termRows - 1 : 输入框下边框 ─
370
+ * row termRows - 2 : 输入框内容行(光标所在)
371
+ * row termRows - 3 : 输入框上边框 ─
372
+ */
373
+ const row = Math.max(1, termRows - 2 - cursorLineIndex);
374
+ const col = Math.max(1, 2 + prompt.length + colInLine); // │ + space + prompt + 行内列
375
+ writeFn(`\x1b[${row};${col}H`);
376
+ }
377
+ catch { /* 非TTY 或流已关闭时静默失败 */ }
378
+ }, 0);
379
+ return () => clearTimeout(timer);
380
+ }, [value, cursor, disabled, prompt, stdout]);
257
381
  // ── 渲染 ──
258
- // 反查模式:显示 (reverse-i-search)`query`: match
382
+ if (slashActive) {
383
+ return _jsx(SlashMenu, { commands: slashCmds, highlight: Math.min(slashHighlight, Math.max(slashCmds.length - 1, 0)) });
384
+ }
259
385
  if (rs.active) {
260
386
  const matchText = rs.matchIndex >= 0 && history ? history.entries[rs.matchIndex] : '';
261
- return (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["(reverse-i-search)`", rs.query, "`: "] }), _jsx(Text, { color: "cyan", children: matchText || '(无匹配)' })] }));
387
+ return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "(reverse-i-search)`" }), _jsx(Text, { children: rs.query }), _jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: "`: " }), _jsx(Text, { color: matchText ? 'cyan' : 'red', children: matchText || '(无匹配 · Esc/Ctrl+G 取消)' })] }));
262
388
  }
263
- // 空输入 + placeholder
264
389
  if (!value && placeholder) {
265
- return (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: prompt }), _jsx(Text, { dimColor: true, children: placeholder })] }));
390
+ return (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: prompt }), _jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] }));
266
391
  }
267
- // 带光标渲染(▋ 标记光标位置)
268
392
  const before = value.slice(0, cursor);
269
393
  const at = value.slice(cursor, cursor + 1);
270
394
  const after = value.slice(cursor + 1);
271
395
  return (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: prompt }), before, _jsx(Text, { inverse: true, children: at || ' ' }), after] }));
272
396
  }
273
- /**
274
- * 多字符输入是否为粘贴(纯函数,便于单测)。
275
- *
276
- * Ink useInput 把整段 stdin chunk 作为单次 input 传入。
277
- * 视为粘贴的特征:含换行(多行粘贴)或长度 >= 5(普通打字不可能一次到 5 字符)。
278
- * 短输入(2-4 字符无换行)可能是 IME 选词 / 快速连击,都按"整体插入"处理
279
- * (不再逐字符,以避免 useCallback 闭包陷阱导致多字符只保留最后一个)。
280
- */
281
- export function isPasteInput(input) {
282
- if (!input || input.length < 2)
283
- return false;
284
- return input.includes('\n') || input.includes('\r') || input.length >= 5;
285
- }
286
397
  function getLineInfo(value, cursor) {
287
- // 找当前行的起始
288
398
  let lineStart = 0;
289
399
  for (let i = cursor - 1; i >= 0; i--) {
290
400
  if (value[i] === '\n') {
@@ -292,7 +402,6 @@ function getLineInfo(value, cursor) {
292
402
  break;
293
403
  }
294
404
  }
295
- // 找当前行的结束
296
405
  let lineEnd = value.length;
297
406
  for (let i = cursor; i < value.length; i++) {
298
407
  if (value[i] === '\n') {
@@ -300,7 +409,6 @@ function getLineInfo(value, cursor) {
300
409
  break;
301
410
  }
302
411
  }
303
- // 行号 + 总行数
304
412
  let lineIndex = 0;
305
413
  for (let i = 0; i < lineStart; i++) {
306
414
  if (value[i] === '\n')
@@ -315,4 +423,26 @@ function getLineInfo(value, cursor) {
315
423
  colInLine: cursor - lineStart,
316
424
  };
317
425
  }
426
+ /** 找 pos 之前最近的 word 边界(跳过连续 whitespace + 非 word 字符) */
427
+ export function prevWordBoundary(value, pos) {
428
+ if (pos <= 0)
429
+ return 0;
430
+ let i = pos - 1;
431
+ while (i > 0 && /\s/.test(value[i]))
432
+ i--;
433
+ while (i > 0 && /[\w一-鿿]/.test(value[i - 1]))
434
+ i--;
435
+ return i;
436
+ }
437
+ /** 找 pos 之后最近的 word 边界 */
438
+ export function nextWordBoundary(value, pos) {
439
+ if (pos >= value.length)
440
+ return value.length;
441
+ let i = pos;
442
+ while (i < value.length && /\s/.test(value[i]))
443
+ i++;
444
+ while (i < value.length && /[\w一-鿿]/.test(value[i]))
445
+ i++;
446
+ return i;
447
+ }
318
448
  //# sourceMappingURL=text-input.js.map