@autopilot-agent/cli 0.0.0-stage → 0.2.0

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 (144) hide show
  1. package/README.md +52 -2
  2. package/dist/activity-animation.d.ts +59 -0
  3. package/dist/activity-animation.js +148 -0
  4. package/dist/animation-clock.d.ts +22 -0
  5. package/dist/animation-clock.js +76 -0
  6. package/dist/composition-root.d.ts +105 -0
  7. package/dist/composition-root.js +600 -0
  8. package/dist/context-report.d.ts +20 -0
  9. package/dist/context-report.js +173 -0
  10. package/dist/controller.d.ts +79 -0
  11. package/dist/controller.js +741 -0
  12. package/dist/design-tokens.d.ts +38 -0
  13. package/dist/design-tokens.js +38 -0
  14. package/dist/error-diagnostics.d.ts +8 -0
  15. package/dist/error-diagnostics.js +62 -0
  16. package/dist/event-projector.d.ts +26 -0
  17. package/dist/event-projector.js +335 -0
  18. package/dist/file-mention-context.d.ts +34 -0
  19. package/dist/file-mention-context.js +173 -0
  20. package/dist/index.d.ts +19 -0
  21. package/dist/index.js +14 -0
  22. package/dist/main.d.ts +40 -0
  23. package/dist/main.js +543 -0
  24. package/dist/parser.d.ts +17 -0
  25. package/dist/parser.js +400 -0
  26. package/dist/renderer.d.ts +9 -0
  27. package/dist/renderer.js +62 -0
  28. package/dist/scroll-viewport.d.ts +22 -0
  29. package/dist/scroll-viewport.js +62 -0
  30. package/dist/termination-format.d.ts +4 -0
  31. package/dist/termination-format.js +18 -0
  32. package/dist/timeline-lines.d.ts +38 -0
  33. package/dist/timeline-lines.js +104 -0
  34. package/dist/tui/app.d.ts +30 -0
  35. package/dist/tui/app.js +413 -0
  36. package/dist/tui/approval.d.ts +19 -0
  37. package/dist/tui/approval.js +41 -0
  38. package/dist/tui/brand/brand-header.d.ts +20 -0
  39. package/dist/tui/brand/brand-header.js +22 -0
  40. package/dist/tui/brand/intro.d.ts +12 -0
  41. package/dist/tui/brand/intro.js +88 -0
  42. package/dist/tui/brand/pixel.d.ts +8 -0
  43. package/dist/tui/brand/pixel.js +50 -0
  44. package/dist/tui/constants.d.ts +56 -0
  45. package/dist/tui/constants.js +57 -0
  46. package/dist/tui/context.d.ts +11 -0
  47. package/dist/tui/context.js +12 -0
  48. package/dist/tui/diff-stats.d.ts +8 -0
  49. package/dist/tui/diff-stats.js +7 -0
  50. package/dist/tui/format.d.ts +75 -0
  51. package/dist/tui/format.js +178 -0
  52. package/dist/tui/hooks/use-file-mention-index.d.ts +15 -0
  53. package/dist/tui/hooks/use-file-mention-index.js +59 -0
  54. package/dist/tui/hooks/use-pickers.d.ts +47 -0
  55. package/dist/tui/hooks/use-pickers.js +288 -0
  56. package/dist/tui/hooks/use-prompt-input.d.ts +54 -0
  57. package/dist/tui/hooks/use-prompt-input.js +258 -0
  58. package/dist/tui/hooks/use-status-view.d.ts +8 -0
  59. package/dist/tui/hooks/use-status-view.js +13 -0
  60. package/dist/tui/hooks/use-viewport.d.ts +22 -0
  61. package/dist/tui/hooks/use-viewport.js +117 -0
  62. package/dist/tui/input/file-mention.d.ts +33 -0
  63. package/dist/tui/input/file-mention.js +112 -0
  64. package/dist/tui/input/graphemes.d.ts +5 -0
  65. package/dist/tui/input/graphemes.js +10 -0
  66. package/dist/tui/input/keys.d.ts +16 -0
  67. package/dist/tui/input/keys.js +58 -0
  68. package/dist/tui/input/prompt-layout.d.ts +27 -0
  69. package/dist/tui/input/prompt-layout.js +84 -0
  70. package/dist/tui/input-area.d.ts +53 -0
  71. package/dist/tui/input-area.js +89 -0
  72. package/dist/tui/jobs.d.ts +10 -0
  73. package/dist/tui/jobs.js +24 -0
  74. package/dist/tui/lifecycle.d.ts +23 -0
  75. package/dist/tui/lifecycle.js +114 -0
  76. package/dist/tui/markdown/diff.d.ts +8 -0
  77. package/dist/tui/markdown/diff.js +19 -0
  78. package/dist/tui/markdown/inline.d.ts +4 -0
  79. package/dist/tui/markdown/inline.js +32 -0
  80. package/dist/tui/markdown/markdown.d.ts +17 -0
  81. package/dist/tui/markdown/markdown.js +117 -0
  82. package/dist/tui/markdown/table.d.ts +19 -0
  83. package/dist/tui/markdown/table.js +74 -0
  84. package/dist/tui/performance-diagnostics.d.ts +7 -0
  85. package/dist/tui/performance-diagnostics.js +57 -0
  86. package/dist/tui/pickers/effort.d.ts +10 -0
  87. package/dist/tui/pickers/effort.js +12 -0
  88. package/dist/tui/pickers/file-mention.d.ts +11 -0
  89. package/dist/tui/pickers/file-mention.js +22 -0
  90. package/dist/tui/pickers/model.d.ts +10 -0
  91. package/dist/tui/pickers/model.js +12 -0
  92. package/dist/tui/pickers/permission.d.ts +10 -0
  93. package/dist/tui/pickers/permission.js +13 -0
  94. package/dist/tui/pickers/skill.d.ts +13 -0
  95. package/dist/tui/pickers/skill.js +25 -0
  96. package/dist/tui/pickers/slash.d.ts +10 -0
  97. package/dist/tui/pickers/slash.js +11 -0
  98. package/dist/tui/prompt-input.d.ts +11 -0
  99. package/dist/tui/prompt-input.js +73 -0
  100. package/dist/tui/render-mode.d.ts +20 -0
  101. package/dist/tui/render-mode.js +31 -0
  102. package/dist/tui/run.d.ts +6 -0
  103. package/dist/tui/run.js +237 -0
  104. package/dist/tui/status-line.d.ts +16 -0
  105. package/dist/tui/status-line.js +37 -0
  106. package/dist/tui/store.d.ts +4 -0
  107. package/dist/tui/store.js +380 -0
  108. package/dist/tui/timeline/live.d.ts +16 -0
  109. package/dist/tui/timeline/live.js +153 -0
  110. package/dist/tui/timeline/presentation-timeline.d.ts +15 -0
  111. package/dist/tui/timeline/presentation-timeline.js +128 -0
  112. package/dist/tui/timeline/progress.d.ts +12 -0
  113. package/dist/tui/timeline/progress.js +43 -0
  114. package/dist/tui/timeline/static-timeline.d.ts +13 -0
  115. package/dist/tui/timeline/static-timeline.js +131 -0
  116. package/dist/tui/timeline/tool-tree.d.ts +7 -0
  117. package/dist/tui/timeline/tool-tree.js +25 -0
  118. package/dist/tui/timeline/turn-items.d.ts +12 -0
  119. package/dist/tui/timeline/turn-items.js +28 -0
  120. package/dist/tui/timeline/turn.d.ts +19 -0
  121. package/dist/tui/timeline/turn.js +21 -0
  122. package/dist/tui/todo-list.d.ts +50 -0
  123. package/dist/tui/todo-list.js +120 -0
  124. package/dist/tui/transcript.d.ts +24 -0
  125. package/dist/tui/transcript.js +34 -0
  126. package/dist/tui/trust-dialog.d.ts +51 -0
  127. package/dist/tui/trust-dialog.js +127 -0
  128. package/dist/tui/types.d.ts +189 -0
  129. package/dist/tui/types.js +5 -0
  130. package/dist/tui/viewport/selection.d.ts +27 -0
  131. package/dist/tui/viewport/selection.js +79 -0
  132. package/dist/tui/viewport/sources.d.ts +29 -0
  133. package/dist/tui/viewport/sources.js +592 -0
  134. package/dist/tui/viewport/viewport.d.ts +12 -0
  135. package/dist/tui/viewport/viewport.js +91 -0
  136. package/dist/tui-input-history.d.ts +23 -0
  137. package/dist/tui-input-history.js +42 -0
  138. package/dist/tui-input-router.d.ts +24 -0
  139. package/dist/tui-input-router.js +115 -0
  140. package/dist/tui.d.ts +15 -0
  141. package/dist/tui.js +13 -0
  142. package/dist/types.d.ts +467 -0
  143. package/dist/types.js +7 -0
  144. package/package.json +58 -4
@@ -0,0 +1,258 @@
1
+ /** prompt 输入状态与 handleInput:字符串/光标/历史维护与按键分发。 */
2
+ import { useRef, useState } from "react";
3
+ import { parseSlashAction } from "../../parser.js";
4
+ import { canNavigateTuiInputHistory, createTuiInputHistoryState, navigateTuiInputHistory, recordTuiInputHistory, updateTuiInputHistoryDraft, } from "../../tui-input-history.js";
5
+ import { createTuiApprovalOptions } from "../approval.js";
6
+ import { isActiveTuiStatus } from "../format.js";
7
+ import { normalizeTuiInputLineEndings, splitTuiInputGraphemes } from "../input/graphemes.js";
8
+ import { applyFileMentionSuggestion, extractFileMentionToken } from "../input/file-mention.js";
9
+ import { hasTuiUnescapedContinuation, isTuiMultilineEnter, isTuiTerminalFocusSequence, isTuiViewportScrollKey, parseTuiControlAction, resolveTuiBracketedPasteBoundary, resolveTuiDeleteDirection, } from "../input/keys.js";
10
+ import { createTuiPromptLayout, resolveTuiPromptCursorIndex } from "../input/prompt-layout.js";
11
+ import { recordTuiPerformanceDiagnostic } from "../performance-diagnostics.js";
12
+ /** 管理 prompt 输入状态、历史与按键分发,并暴露 handleInput 供 Ink 的 useInput 使用。 */
13
+ export function usePromptInput(input) {
14
+ const { store, columns, busy, keepOpenAfterCancel, lastSubmittedPromptRef, rawInputSequenceRef, dispatchAction, onEof, onRequestInterrupt, onRequestCancel, isPickerActive, dispatchPickerInput, approvalPickerIndexRef, updateApprovalPickerIndex, slashCommandIndexRef, updateSlashCommandIndex, fileMentionIndexRef, updateFileMentionIndex, setFileMentionDismissedInput, derivedRef, } = input;
15
+ const [inputValue, setInput] = useState("");
16
+ const [cursorIndex, setCursorIndex] = useState(0);
17
+ const inputRef = useRef("");
18
+ const cursorIndexRef = useRef(0);
19
+ const verticalColumnRef = useRef(undefined);
20
+ const bracketedPasteRef = useRef(false);
21
+ const pasteCarriageReturnRef = useRef(false);
22
+ const inputHistoryRef = useRef(createTuiInputHistoryState());
23
+ /** 同步维护字符串与光标位置,避免只移动光标时 React 跳过重绘;输入变化即解除 @ 菜单的 Esc 关闭标记。 */
24
+ function updateInput(nextInput, nextCursorIndex) {
25
+ verticalColumnRef.current = undefined;
26
+ inputRef.current = nextInput;
27
+ cursorIndexRef.current = nextCursorIndex;
28
+ setFileMentionDismissedInput(undefined);
29
+ setInput(nextInput);
30
+ setCursorIndex(nextCursorIndex);
31
+ }
32
+ /** 只移动逻辑光标,不清除上下键要保持的目标视觉列。 */
33
+ function updateCursorIndex(nextCursorIndex) {
34
+ cursorIndexRef.current = nextCursorIndex;
35
+ setCursorIndex(nextCursorIndex);
36
+ }
37
+ /** 以当前 slash 菜单选项补全命令;带必填参数的命令保留一个输入空格。 */
38
+ function completeSlashCommand(command) {
39
+ const completion = command.usage.includes("<") ? `${command.name} ` : command.name;
40
+ updateInput(completion, splitTuiInputGraphemes(completion).length);
41
+ }
42
+ /** 按 Claude Code 的 Esc 恢复边界把最后一次发送的 prompt 放回本地输入框。 */
43
+ function restoreLastSubmittedPrompt() {
44
+ const current = store.getSnapshot();
45
+ const prompt = lastSubmittedPromptRef.current;
46
+ if (!prompt || inputRef.current !== "" || current.pendingInputs.length > 0)
47
+ return;
48
+ updateInput(prompt, splitTuiInputGraphemes(prompt).length);
49
+ }
50
+ function handleInput(value, key) {
51
+ recordTuiPerformanceDiagnostic("input_key_arrived", value && !key.ctrl && !key.meta ? "text" : "control");
52
+ const controlAction = parseTuiControlAction(value, key);
53
+ if (controlAction === "eof") {
54
+ onEof();
55
+ return;
56
+ }
57
+ const current = store.getSnapshot();
58
+ if (controlAction === "interrupt") {
59
+ if (isActiveTuiStatus(current.status))
60
+ keepOpenAfterCancel.current = true;
61
+ onRequestInterrupt();
62
+ return;
63
+ }
64
+ if (isTuiTerminalFocusSequence(value))
65
+ return;
66
+ const pasteBoundary = resolveTuiBracketedPasteBoundary(value);
67
+ if (pasteBoundary === "start") {
68
+ bracketedPasteRef.current = true;
69
+ pasteCarriageReturnRef.current = false;
70
+ return;
71
+ }
72
+ if (pasteBoundary === "end") {
73
+ bracketedPasteRef.current = false;
74
+ pasteCarriageReturnRef.current = false;
75
+ return;
76
+ }
77
+ if (isPickerActive) {
78
+ dispatchPickerInput(value, key, controlAction);
79
+ return;
80
+ }
81
+ if (controlAction === "cancel") {
82
+ // D3:@ 菜单激活时 Esc 仅关闭菜单,prompt 保留;再次 Esc 时菜单已关,走正常取消路径。
83
+ if (derivedRef.current.fileMentionSuggestions.length > 0) {
84
+ setFileMentionDismissedInput(inputRef.current);
85
+ return;
86
+ }
87
+ if (derivedRef.current.slashCommandSuggestions.length > 0) {
88
+ updateInput("", 0);
89
+ return;
90
+ }
91
+ if (isActiveTuiStatus(current.status)) {
92
+ keepOpenAfterCancel.current = true;
93
+ restoreLastSubmittedPrompt();
94
+ }
95
+ onRequestCancel();
96
+ return;
97
+ }
98
+ if (derivedRef.current.slashCommandSuggestions.length > 0) {
99
+ const slashCommandSuggestions = derivedRef.current.slashCommandSuggestions;
100
+ const selectedCommand = slashCommandSuggestions[Math.max(0, Math.min(slashCommandIndexRef.current, slashCommandSuggestions.length - 1))];
101
+ if (selectedCommand && (key.upArrow || key.downArrow)) {
102
+ const nextIndex = key.upArrow
103
+ ? Math.max(0, slashCommandIndexRef.current - 1)
104
+ : Math.min(slashCommandSuggestions.length - 1, slashCommandIndexRef.current + 1);
105
+ updateSlashCommandIndex(nextIndex);
106
+ return;
107
+ }
108
+ if (selectedCommand && key.tab) {
109
+ completeSlashCommand(selectedCommand);
110
+ return;
111
+ }
112
+ if (selectedCommand && key.return && inputRef.current.trim() !== selectedCommand.name) {
113
+ completeSlashCommand(selectedCommand);
114
+ return;
115
+ }
116
+ if (selectedCommand && key.return && selectedCommand.usage.includes("<")) {
117
+ completeSlashCommand(selectedCommand);
118
+ return;
119
+ }
120
+ if (isTuiViewportScrollKey(key))
121
+ return;
122
+ }
123
+ // @ 菜单激活时的键位分支(置于 slash 之后、审批之前);↑/↓ 移动优先于历史导航,Tab/Enter 插入选中候选。
124
+ if (derivedRef.current.fileMentionSuggestions.length > 0) {
125
+ const fileMentionSuggestions = derivedRef.current.fileMentionSuggestions;
126
+ if (key.upArrow || key.downArrow) {
127
+ const nextIndex = key.upArrow
128
+ ? Math.max(0, fileMentionIndexRef.current - 1)
129
+ : Math.min(fileMentionSuggestions.length - 1, fileMentionIndexRef.current + 1);
130
+ updateFileMentionIndex(nextIndex);
131
+ return;
132
+ }
133
+ if (key.tab || key.return) {
134
+ const token = extractFileMentionToken(inputRef.current, cursorIndexRef.current);
135
+ const selected = fileMentionSuggestions[Math.max(0, Math.min(fileMentionIndexRef.current, fileMentionSuggestions.length - 1))];
136
+ if (token && selected) {
137
+ const applied = applyFileMentionSuggestion(inputRef.current, token, selected);
138
+ updateInput(applied.input, applied.cursor);
139
+ }
140
+ return;
141
+ }
142
+ if (isTuiViewportScrollKey(key))
143
+ return;
144
+ }
145
+ if (current.status === "waiting_approval" && current.pendingApproval && !busy.current && !bracketedPasteRef.current) {
146
+ const approvalOptions = createTuiApprovalOptions(current.pendingApproval);
147
+ if (key.upArrow) {
148
+ updateApprovalPickerIndex(Math.max(0, approvalPickerIndexRef.current - 1));
149
+ return;
150
+ }
151
+ if (key.downArrow) {
152
+ updateApprovalPickerIndex(Math.min(approvalOptions.length - 1, approvalPickerIndexRef.current + 1));
153
+ return;
154
+ }
155
+ if (key.return) {
156
+ const selected = approvalOptions[approvalPickerIndexRef.current];
157
+ if (selected)
158
+ void dispatchAction({ type: "approve", decision: selected.decision });
159
+ return;
160
+ }
161
+ return;
162
+ }
163
+ if (derivedRef.current.handleTuiViewportKey(key))
164
+ return;
165
+ if (busy.current && !isActiveTuiStatus(current.status))
166
+ return;
167
+ const deleteDirection = resolveTuiDeleteDirection(value, key, rawInputSequenceRef.current);
168
+ if (deleteDirection) {
169
+ const characters = splitTuiInputGraphemes(inputRef.current);
170
+ const cursor = cursorIndexRef.current;
171
+ const removeIndex = deleteDirection === "backspace" ? cursor - 1 : cursor;
172
+ if (removeIndex < 0 || removeIndex >= characters.length)
173
+ return;
174
+ characters.splice(removeIndex, 1);
175
+ const nextInput = characters.join("");
176
+ inputHistoryRef.current = updateTuiInputHistoryDraft(inputHistoryRef.current, nextInput);
177
+ updateInput(nextInput, deleteDirection === "backspace" ? cursor - 1 : cursor);
178
+ return;
179
+ }
180
+ if (key.leftArrow) {
181
+ updateInput(inputRef.current, Math.max(0, cursorIndexRef.current - 1));
182
+ return;
183
+ }
184
+ if (key.rightArrow) {
185
+ updateInput(inputRef.current, Math.min(splitTuiInputGraphemes(inputRef.current).length, cursorIndexRef.current + 1));
186
+ return;
187
+ }
188
+ if (key.upArrow || key.downArrow) {
189
+ const layout = createTuiPromptLayout(inputRef.current, cursorIndexRef.current, columns);
190
+ const direction = key.upArrow ? -1 : 1;
191
+ const targetLine = layout.cursorLine + direction;
192
+ if (targetLine >= 0 && targetLine < layout.lines.length) {
193
+ const targetColumn = verticalColumnRef.current ?? layout.cursorColumn;
194
+ const nextCursorIndex = resolveTuiPromptCursorIndex(inputRef.current, layout, targetLine, targetColumn);
195
+ verticalColumnRef.current = targetColumn;
196
+ updateCursorIndex(nextCursorIndex);
197
+ return;
198
+ }
199
+ const historyDirection = key.upArrow ? "up" : "down";
200
+ const canNavigateHistory = canNavigateTuiInputHistory(historyDirection, layout.cursorLine, layout.lines.length, inputHistoryRef.current.index !== undefined, false);
201
+ const navigation = navigateTuiInputHistory(inputHistoryRef.current, historyDirection, inputRef.current, canNavigateHistory);
202
+ if (navigation.changed) {
203
+ inputHistoryRef.current = navigation.state;
204
+ updateInput(navigation.input, splitTuiInputGraphemes(navigation.input).length);
205
+ }
206
+ return;
207
+ }
208
+ const bracketedPasteReturn = bracketedPasteRef.current && key.return;
209
+ if (bracketedPasteRef.current && value === "\n" && pasteCarriageReturnRef.current) {
210
+ pasteCarriageReturnRef.current = false;
211
+ return;
212
+ }
213
+ const multilineEnter = isTuiMultilineEnter(value, key, rawInputSequenceRef.current);
214
+ const cursor = cursorIndexRef.current;
215
+ const continuation = key.return && hasTuiUnescapedContinuation(inputRef.current, cursor);
216
+ if (bracketedPasteReturn || multilineEnter || continuation) {
217
+ const characters = splitTuiInputGraphemes(inputRef.current);
218
+ const newlineIndex = continuation ? cursor - 1 : cursor;
219
+ if (continuation)
220
+ characters.splice(newlineIndex, 1);
221
+ characters.splice(newlineIndex, 0, "\n");
222
+ const nextInput = characters.join("");
223
+ inputHistoryRef.current = updateTuiInputHistoryDraft(inputHistoryRef.current, nextInput);
224
+ updateInput(nextInput, newlineIndex + 1);
225
+ pasteCarriageReturnRef.current = bracketedPasteReturn;
226
+ return;
227
+ }
228
+ pasteCarriageReturnRef.current = false;
229
+ if (key.return) {
230
+ const content = inputRef.current.trim();
231
+ updateInput("", 0);
232
+ if (!content)
233
+ return;
234
+ void (async function dispatchEnteredLine() {
235
+ const parsed = parseSlashAction(content);
236
+ if (parsed.kind === "error") {
237
+ store.addNotice(parsed.message);
238
+ return;
239
+ }
240
+ const prompt = parsed.action.type === "submit_prompt" ? parsed.action.content : undefined;
241
+ if (prompt)
242
+ inputHistoryRef.current = recordTuiInputHistory(inputHistoryRef.current, prompt);
243
+ await dispatchAction(parsed.action, prompt);
244
+ })();
245
+ return;
246
+ }
247
+ if (value && !key.ctrl && !key.meta) {
248
+ const characters = splitTuiInputGraphemes(inputRef.current);
249
+ const cursor = cursorIndexRef.current;
250
+ const inserted = splitTuiInputGraphemes(normalizeTuiInputLineEndings(value));
251
+ characters.splice(cursor, 0, ...inserted);
252
+ const nextInput = characters.join("");
253
+ inputHistoryRef.current = updateTuiInputHistoryDraft(inputHistoryRef.current, nextInput);
254
+ updateInput(nextInput, cursor + inserted.length);
255
+ }
256
+ }
257
+ return { input: inputValue, cursorIndex, handleInput };
258
+ }
@@ -0,0 +1,8 @@
1
+ /** statusView 异步刷新 hook:从 Controller 拉取脱敏状态栏。 */
2
+ import type { CliController } from "../../controller.js";
3
+ import type { CliStatusView } from "../../types.js";
4
+ /** 管理脱敏状态栏并暴露异步刷新函数;测试 double 未提供 getStatusView 时保持旧值。 */
5
+ export declare function useStatusView(controller: CliController): {
6
+ statusView: CliStatusView | undefined;
7
+ refreshStatusView: () => Promise<void>;
8
+ };
@@ -0,0 +1,13 @@
1
+ /** statusView 异步刷新 hook:从 Controller 拉取脱敏状态栏。 */
2
+ import { useState } from "react";
3
+ /** 管理脱敏状态栏并暴露异步刷新函数;测试 double 未提供 getStatusView 时保持旧值。 */
4
+ export function useStatusView(controller) {
5
+ const [statusView, setStatusView] = useState(undefined);
6
+ /** 从 Controller 刷新脱敏状态栏,测试 double 未提供时保持旧状态行。 */
7
+ async function refreshStatusView() {
8
+ const next = await controller.getStatusView?.();
9
+ if (next)
10
+ setStatusView(next);
11
+ }
12
+ return { statusView, refreshStatusView };
13
+ }
@@ -0,0 +1,22 @@
1
+ /** 备用屏视口滚动/鼠标/划选状态 hook。 */
2
+ import type { Key } from "ink";
3
+ import { type ScrollViewportState } from "../../scroll-viewport.js";
4
+ import type { TimelineVisualLine } from "../../timeline-lines.js";
5
+ import type { TuiSgrMouseEvent } from "../../tui-input-router.js";
6
+ import { type TuiTextSelection } from "../viewport/selection.js";
7
+ /** 管理备用屏视口的滚动、鼠标事件与文字划选状态。 */
8
+ export declare function useViewport(input: {
9
+ isViewportMode: boolean;
10
+ columns: number;
11
+ viewportLines: TimelineVisualLine[];
12
+ viewportHeight: number;
13
+ stdout: {
14
+ write(chunk: string): void;
15
+ };
16
+ onCopyText?: (text: string) => void;
17
+ }): {
18
+ viewportState: ScrollViewportState;
19
+ selection: TuiTextSelection;
20
+ handleTuiMouseEvent: (event: TuiSgrMouseEvent, wheelNotches: number) => void;
21
+ handleTuiViewportKey: (key: Key) => boolean;
22
+ };
@@ -0,0 +1,117 @@
1
+ /** 备用屏视口滚动/鼠标/划选状态 hook。 */
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { createScrollViewportState, resizeScrollViewport, scrollViewportDown, scrollViewportToTop, scrollViewportUp, } from "../../scroll-viewport.js";
4
+ import { copyTuiSelectedText, getTuiSelectedText, resolveTuiVisualLineOffset, } from "../viewport/selection.js";
5
+ /** 管理备用屏视口的滚动、鼠标事件与文字划选状态。 */
6
+ export function useViewport(input) {
7
+ const { isViewportMode, columns, viewportLines, viewportHeight, stdout, onCopyText } = input;
8
+ const [selection, setSelection] = useState({});
9
+ const selectionRef = useRef(selection);
10
+ const scrollbarDraggingRef = useRef(false);
11
+ const selectionDraggingRef = useRef(false);
12
+ const [viewportState, setViewportState] = useState(function createInitialViewportState() {
13
+ return createScrollViewportState(viewportLines.length, viewportHeight);
14
+ });
15
+ const viewportStateRef = useRef(viewportState);
16
+ /** 同步保存视口状态,供 16ms 合并后的鼠标回调读取最新位置。 */
17
+ function updateViewportState(next) {
18
+ viewportStateRef.current = next;
19
+ setViewportState(next);
20
+ }
21
+ /** 同步更新文字划选状态,确保鼠标 release 立即复制本次拖拽的最终范围。 */
22
+ function updateTuiTextSelection(next) {
23
+ selectionRef.current = next;
24
+ setSelection(next);
25
+ }
26
+ /** 将视口内鼠标坐标转换成时间线来源坐标;固定输入区和滚动条不参与划选。 */
27
+ function resolveTuiSelectionPoint(event) {
28
+ if (event.column >= columns || event.row < 1 || event.row > viewportStateRef.current.viewportHeight)
29
+ return undefined;
30
+ const line = viewportLines[viewportStateRef.current.scrollTop + event.row - 1];
31
+ return line ? { itemId: line.itemId, sourceOffset: resolveTuiVisualLineOffset(line, event.column - 1) } : undefined;
32
+ }
33
+ /** 用鼠标滚轮或滚动条拖拽更新备用屏视口;内容区点击保留给文字划选。 */
34
+ function handleTuiMouseEvent(event, wheelNotches) {
35
+ if (!isViewportMode)
36
+ return;
37
+ if (event.kind === "move")
38
+ return;
39
+ const current = viewportStateRef.current;
40
+ if (event.kind === "wheel_up") {
41
+ updateViewportState(scrollViewportUp(current, 3 * wheelNotches));
42
+ return;
43
+ }
44
+ if (event.kind === "wheel_down") {
45
+ updateViewportState(scrollViewportDown(current, 3 * wheelNotches));
46
+ return;
47
+ }
48
+ const hitsScrollbar = event.column >= columns;
49
+ const selectionPoint = resolveTuiSelectionPoint(event);
50
+ if (event.released && selectionDraggingRef.current) {
51
+ if (selectionPoint)
52
+ updateTuiTextSelection({ ...selectionRef.current, focus: selectionPoint });
53
+ selectionDraggingRef.current = false;
54
+ const text = getTuiSelectedText(viewportLines, selectionPoint ? { ...selectionRef.current, focus: selectionPoint } : selectionRef.current);
55
+ if (text) {
56
+ if (onCopyText)
57
+ onCopyText(text);
58
+ else
59
+ copyTuiSelectedText(text, stdout);
60
+ }
61
+ return;
62
+ }
63
+ if (event.released) {
64
+ scrollbarDraggingRef.current = false;
65
+ return;
66
+ }
67
+ if (!hitsScrollbar && event.kind === "button" && selectionPoint) {
68
+ selectionDraggingRef.current = true;
69
+ updateTuiTextSelection({ anchor: selectionPoint, focus: selectionPoint });
70
+ return;
71
+ }
72
+ if (!hitsScrollbar && event.kind === "drag" && selectionDraggingRef.current && selectionPoint) {
73
+ updateTuiTextSelection({ ...selectionRef.current, focus: selectionPoint });
74
+ return;
75
+ }
76
+ if (event.kind === "button")
77
+ scrollbarDraggingRef.current = hitsScrollbar;
78
+ if (!hitsScrollbar && !scrollbarDraggingRef.current)
79
+ return;
80
+ if (event.kind !== "button" && event.kind !== "drag")
81
+ return;
82
+ const maximumTop = Math.max(0, current.totalHeight - current.viewportHeight);
83
+ const ratio = current.viewportHeight <= 1 ? 0 : Math.max(0, Math.min(1, (event.row - 1) / (current.viewportHeight - 1)));
84
+ const scrollTop = Math.round(maximumTop * ratio);
85
+ updateViewportState({ ...current, scrollTop, pinnedToBottom: scrollTop >= maximumTop });
86
+ }
87
+ /** 在真实备用屏中处理半视口与顶底跳转;picker 和审批由前置键位分支优先消费。 */
88
+ function handleTuiViewportKey(key) {
89
+ if (!isViewportMode)
90
+ return false;
91
+ const current = viewportStateRef.current;
92
+ const jump = Math.max(1, Math.floor(current.viewportHeight / 2));
93
+ if (key.pageUp) {
94
+ updateViewportState(scrollViewportUp(current, jump));
95
+ return true;
96
+ }
97
+ if (key.pageDown) {
98
+ updateViewportState(scrollViewportDown(current, jump));
99
+ return true;
100
+ }
101
+ if (key.ctrl && key.home) {
102
+ updateViewportState(scrollViewportToTop(current));
103
+ return true;
104
+ }
105
+ if (key.ctrl && key.end) {
106
+ updateViewportState(scrollViewportDown(current, Number.MAX_SAFE_INTEGER));
107
+ return true;
108
+ }
109
+ return false;
110
+ }
111
+ useEffect(function resizeTuiViewport() {
112
+ if (!isViewportMode)
113
+ return;
114
+ updateViewportState(resizeScrollViewport(viewportStateRef.current, viewportLines.length, viewportHeight));
115
+ }, [isViewportMode, viewportLines.length, viewportHeight]);
116
+ return { viewportState, selection, handleTuiMouseEvent, handleTuiViewportKey };
117
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * TUI @文件提及的输入侧纯函数:从输入与光标(grapheme 索引)提取当前 mention token、
3
+ * 按四档确定性评分过滤候选、把选中候选替换回输入。目录候选以尾随 `/` 标记,`\` 一律归一为 `/`。
4
+ */
5
+ /** @ 候选菜单一次展示的候选集上限。 */
6
+ export declare const FILE_MENTION_MAX_CANDIDATES = 50;
7
+ /** mention token 中的合法字符(Unicode 字母/数字/组合标记与常用路径符号);空白与中文标点等结束 token。 */
8
+ export declare const FILE_MENTION_TOKEN_CHARACTER: RegExp;
9
+ /** extractFileMentionToken 返回的当前 mention 上下文;光标不在 @ token 内时返回 null。 */
10
+ export interface FileMentionToken {
11
+ /** `@` 字符的 grapheme 索引。 */
12
+ start: number;
13
+ /** token 自然结束的 grapheme 索引(光标右侧的 token 残余也计入,插入时整体替换)。 */
14
+ end: number;
15
+ /** `@` 之后、光标之前的查询文本(`\` 已归一为 `/`)。 */
16
+ query: string;
17
+ }
18
+ /** 从输入与光标提取当前 @ 文件 mention token;`@` 必须位于行首或空白后,token 中途遇空白即中断。 */
19
+ export declare function extractFileMentionToken(input: string, cursor: number): FileMentionToken | null;
20
+ /** 按四档确定性评分过滤并排序候选:完整路径前缀 > 文件名前缀 > 路径子串 > 文件名子串(大小写不敏感,同分 localeCompare)。 */
21
+ export declare function filterFileMentionCandidates(candidates: readonly string[], query: string, maxCandidates?: number): string[];
22
+ /** 渲染分段条目;isMention 的片段(含 `@` 与路径)应使用强调色展示。 */
23
+ export interface TuiFileMentionSegment {
24
+ text: string;
25
+ isMention: boolean;
26
+ }
27
+ /** 把 prompt 文本切成普通/mention 交替段,边界与 @ 提取语义一致;用于输入框与时间线的强调展示。 */
28
+ export declare function splitTuiFileMentionSegments(content: string): TuiFileMentionSegment[];
29
+ /** 把选中候选替换回输入的 mention token;文件补尾随空格结束 token,目录保留尾随 `/` 继续下钻。 */
30
+ export declare function applyFileMentionSuggestion(input: string, token: FileMentionToken, suggestion: string): {
31
+ input: string;
32
+ cursor: number;
33
+ };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * TUI @文件提及的输入侧纯函数:从输入与光标(grapheme 索引)提取当前 mention token、
3
+ * 按四档确定性评分过滤候选、把选中候选替换回输入。目录候选以尾随 `/` 标记,`\` 一律归一为 `/`。
4
+ */
5
+ import { splitTuiInputGraphemes } from "./graphemes.js";
6
+ /** @ 候选菜单一次展示的候选集上限。 */
7
+ export const FILE_MENTION_MAX_CANDIDATES = 50;
8
+ /** mention token 中的合法字符(Unicode 字母/数字/组合标记与常用路径符号);空白与中文标点等结束 token。 */
9
+ export const FILE_MENTION_TOKEN_CHARACTER = /^[\p{L}\p{N}\p{M}_\-./\\()[\]~:]$/u;
10
+ /** 判定单个 grapheme 是否属于 mention token 字符集(Unicode 字母/数字/组合标记与常用路径符号)。 */
11
+ function isTokenGrapheme(character) {
12
+ return /^[\p{L}\p{N}\p{M}_\-./\\()[\]~:]$/u.test(character);
13
+ }
14
+ /** 从输入与光标提取当前 @ 文件 mention token;`@` 必须位于行首或空白后,token 中途遇空白即中断。 */
15
+ export function extractFileMentionToken(input, cursor) {
16
+ const characters = splitTuiInputGraphemes(input);
17
+ if (!Number.isInteger(cursor) || cursor <= 0 || cursor > characters.length)
18
+ return null;
19
+ // 从光标向左扫描:全部字符必须是 token 字符,且恰好在空白或行首之前命中 @。
20
+ let atIndex = -1;
21
+ for (let index = cursor - 1; index >= 0; index -= 1) {
22
+ const character = characters[index];
23
+ if (character === "@") {
24
+ const previous = index > 0 ? characters[index - 1] : "";
25
+ if (index > 0 && !/\s/u.test(previous))
26
+ return null;
27
+ atIndex = index;
28
+ break;
29
+ }
30
+ if (!isTokenGrapheme(character))
31
+ return null;
32
+ }
33
+ if (atIndex < 0)
34
+ return null;
35
+ // 向右吞掉光标后的 token 残余,让插入替换覆盖完整 token(如光标停在 main.ts 的 ma 处);
36
+ // 只吞 ASCII 文件名字符:光标右侧紧邻的中文等非 ASCII 极大概率是已输入正文,视为边界不吞,避免整体替换吞字。
37
+ let end = cursor;
38
+ while (end < characters.length) {
39
+ const character = characters[end];
40
+ if (!isTokenGrapheme(character) || !/^[\x00-\x7F]$/u.test(character))
41
+ break;
42
+ end += 1;
43
+ }
44
+ return {
45
+ start: atIndex,
46
+ end,
47
+ query: characters.slice(atIndex + 1, cursor).join("").replace(/\\/g, "/"),
48
+ };
49
+ }
50
+ /** 取候选的最后一段作为参与匹配的名称;目录先去掉尾随斜杠。 */
51
+ function candidateName(candidatePath) {
52
+ const trimmed = candidatePath.endsWith("/") ? candidatePath.slice(0, -1) : candidatePath;
53
+ return trimmed.split("/").pop() ?? trimmed;
54
+ }
55
+ /** 按四档确定性评分过滤并排序候选:完整路径前缀 > 文件名前缀 > 路径子串 > 文件名子串(大小写不敏感,同分 localeCompare)。 */
56
+ export function filterFileMentionCandidates(candidates, query, maxCandidates = FILE_MENTION_MAX_CANDIDATES) {
57
+ const normalizedQuery = query.replace(/\\/g, "/").toLocaleLowerCase();
58
+ const scored = [];
59
+ for (const candidate of candidates) {
60
+ const normalizedPath = candidate.replace(/\\/g, "/");
61
+ const lowerPath = normalizedPath.toLocaleLowerCase();
62
+ const lowerName = candidateName(normalizedPath).toLocaleLowerCase();
63
+ let tier;
64
+ if (lowerPath.startsWith(normalizedQuery))
65
+ tier = 3;
66
+ else if (lowerName.startsWith(normalizedQuery))
67
+ tier = 2;
68
+ else if (lowerPath.includes(normalizedQuery))
69
+ tier = 1;
70
+ else if (lowerName.includes(normalizedQuery))
71
+ tier = 0;
72
+ else
73
+ continue;
74
+ scored.push({ path: normalizedPath, tier });
75
+ }
76
+ return scored
77
+ .sort(function compareCandidates(left, right) {
78
+ return right.tier - left.tier || left.path.localeCompare(right.path);
79
+ })
80
+ .slice(0, Math.max(0, maxCandidates))
81
+ .map(function readPath(candidate) {
82
+ return candidate.path;
83
+ });
84
+ }
85
+ /** 把 prompt 文本切成普通/mention 交替段,边界与 @ 提取语义一致;用于输入框与时间线的强调展示。 */
86
+ export function splitTuiFileMentionSegments(content) {
87
+ const segments = [];
88
+ let plainStart = 0;
89
+ for (const match of content.matchAll(/(^|\s)@/gu)) {
90
+ const atStart = match.index + match[0].length - 1;
91
+ let end = atStart + 1;
92
+ while (end < content.length && FILE_MENTION_TOKEN_CHARACTER.test(content[end]))
93
+ end += 1;
94
+ if (end === atStart + 1)
95
+ continue;
96
+ if (atStart > plainStart)
97
+ segments.push({ text: content.slice(plainStart, atStart), isMention: false });
98
+ segments.push({ text: content.slice(atStart, end), isMention: true });
99
+ plainStart = end;
100
+ }
101
+ if (plainStart < content.length)
102
+ segments.push({ text: content.slice(plainStart), isMention: false });
103
+ return segments;
104
+ }
105
+ /** 把选中候选替换回输入的 mention token;文件补尾随空格结束 token,目录保留尾随 `/` 继续下钻。 */
106
+ export function applyFileMentionSuggestion(input, token, suggestion) {
107
+ const characters = splitTuiInputGraphemes(input);
108
+ const replacement = suggestion.endsWith("/") ? `@${suggestion}` : `@${suggestion} `;
109
+ const replacementGraphemes = splitTuiInputGraphemes(replacement);
110
+ characters.splice(token.start, token.end - token.start, ...replacementGraphemes);
111
+ return { input: characters.join(""), cursor: token.start + replacementGraphemes.length };
112
+ }
@@ -0,0 +1,5 @@
1
+ /** TUI 输入的 Unicode grapheme 纯函数:把用户输入拆成可感知字符并统一换行符。 */
2
+ /** 将单行输入拆为用户可感知字符,避免中文与组合 emoji 的光标索引错位。 */
3
+ export declare function splitTuiInputGraphemes(input: string): string[];
4
+ /** 将终端粘贴的 CRLF 或 CR 统一为 TUI 内部唯一的 LF,避免 CR 在终端渲染时覆盖前文。 */
5
+ export declare function normalizeTuiInputLineEndings(input: string): string;
@@ -0,0 +1,10 @@
1
+ /** TUI 输入的 Unicode grapheme 纯函数:把用户输入拆成可感知字符并统一换行符。 */
2
+ import { TUI_INPUT_SEGMENTER } from "../constants.js";
3
+ /** 将单行输入拆为用户可感知字符,避免中文与组合 emoji 的光标索引错位。 */
4
+ export function splitTuiInputGraphemes(input) {
5
+ return Array.from(TUI_INPUT_SEGMENTER.segment(input), function readSegment(segment) { return segment.segment; });
6
+ }
7
+ /** 将终端粘贴的 CRLF 或 CR 统一为 TUI 内部唯一的 LF,避免 CR 在终端渲染时覆盖前文。 */
8
+ export function normalizeTuiInputLineEndings(input) {
9
+ return input.replace(/\r\n?|\n/g, "\n");
10
+ }
@@ -0,0 +1,16 @@
1
+ /** TUI 按键解析纯函数:归一化控制动作、删除方向、换行/粘贴/focus/滚动判断。 */
2
+ import type { Key } from "ink";
3
+ /** 将 Ink 键对象和终端控制字符归一为 TUI 控制动作,兼容真实 TTY 与测试 stdin。 */
4
+ export declare function parseTuiControlAction(value: string, key: Key): "eof" | "interrupt" | "cancel" | undefined;
5
+ /** 区分 Backspace 与前向 Delete,优先采用终端原始控制字符兼容 macOS Terminal。 */
6
+ export declare function resolveTuiDeleteDirection(value: string, key: Key, rawSequence?: string): "backspace" | "delete" | undefined;
7
+ /** 识别 Claude Code/终端常见的 Shift+Enter 编码;普通回车仍由 key.return 单独处理。 */
8
+ export declare function isTuiMultilineEnter(value: string, key: Key, rawSequence?: string): boolean;
9
+ /** 识别 bracketed paste 边界;Ink 会去掉 CSI 序列的首个 ESC 后再交给 useInput。 */
10
+ export declare function resolveTuiBracketedPasteBoundary(value: string): "start" | "end" | undefined;
11
+ /** 判断终端 focus in/out 报告,避免控制序列被误插入 prompt。 */
12
+ export declare function isTuiTerminalFocusSequence(value: string): boolean;
13
+ /** 判断按键是否属于备用屏滚动兜底,避免与普通 Home/End 输入语义混淆。 */
14
+ export declare function isTuiViewportScrollKey(key: Key): boolean;
15
+ /** 判断光标前的反斜线是否是未被另一枚反斜线转义的续行标记。 */
16
+ export declare function hasTuiUnescapedContinuation(input: string, cursorIndex: number): boolean;