@hunterzhu/pulse-cli 0.2.1 → 0.4.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.
- package/README.md +1 -1
- package/dist/bin.js +0 -5
- package/dist/commands/interactive.js +11 -5
- package/dist/components/App.js +56 -8
- package/dist/components/HelpView.js +2 -1
- package/dist/components/InputArea.d.ts +3 -1
- package/dist/components/InputArea.js +13 -20
- package/dist/components/MessageList.d.ts +2 -1
- package/dist/components/MessageList.js +2 -2
- package/dist/components/MessageViewport.d.ts +2 -1
- package/dist/components/MessageViewport.js +63 -3
- package/dist/components/StatusHud.d.ts +1 -2
- package/dist/components/StatusHud.js +2 -2
- package/dist/config.d.ts +0 -2
- package/dist/config.js +0 -1
- package/dist/hooks/useConversation.d.ts +3 -1
- package/dist/hooks/useConversation.js +3 -1
- package/dist/hooks/useRun.d.ts +3 -1
- package/dist/hooks/useRun.js +138 -39
- package/dist/hooks/useSlashCommands.d.ts +1 -1
- package/dist/hooks/useSlashCommands.js +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/utils/clipboard.d.ts +14 -0
- package/dist/utils/clipboard.js +49 -0
- package/dist/utils/inputHistory.d.ts +11 -0
- package/dist/utils/inputHistory.js +24 -0
- package/dist/utils/mouse.d.ts +10 -5
- package/dist/utils/mouse.js +14 -4
- package/dist/utils/selection.d.ts +13 -0
- package/dist/utils/selection.js +107 -0
- package/dist/utils/transcript.js +43 -10
- package/package.json +2 -2
- package/scripts/postinstall.mjs +50 -3
package/dist/hooks/useRun.js
CHANGED
|
@@ -39,8 +39,14 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
39
39
|
const [isRunning, setIsRunning] = useState(false);
|
|
40
40
|
const [currentStep, setCurrentStep] = useState(null);
|
|
41
41
|
const [error, setError] = useState(null);
|
|
42
|
-
const [
|
|
43
|
-
const
|
|
42
|
+
const [pendingInteractions, setPendingInteractions] = useState([]);
|
|
43
|
+
const enqueueInteraction = useCallback((item) => {
|
|
44
|
+
setPendingInteractions((current) => current.some((entry) => entry.request.effectId === item.request.effectId) ? current : [...current, item]);
|
|
45
|
+
}, []);
|
|
46
|
+
const removeInteraction = useCallback((effectId) => setPendingInteractions((current) => current.filter((entry) => entry.request.effectId !== effectId)), []);
|
|
47
|
+
const activeInteraction = pendingInteractions[0];
|
|
48
|
+
const approvalRequest = activeInteraction?.kind === 'approval' ? activeInteraction.request : null;
|
|
49
|
+
const askRequest = activeInteraction?.kind === 'ask' ? activeInteraction.request : null;
|
|
44
50
|
const [approvalSubmitting, setApprovalSubmitting] = useState(false);
|
|
45
51
|
const [lanes, setLanes] = useState([]);
|
|
46
52
|
const runRef = useRef(null);
|
|
@@ -50,11 +56,30 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
50
56
|
id: run.id,
|
|
51
57
|
role: 'assistant',
|
|
52
58
|
text: '',
|
|
59
|
+
streamStatus: 'streaming',
|
|
53
60
|
createdAt: new Date().toISOString(),
|
|
54
61
|
toolCalls: [],
|
|
55
62
|
runId: run.id,
|
|
56
63
|
};
|
|
57
64
|
let assistantStarted = false;
|
|
65
|
+
let streamedEffectId;
|
|
66
|
+
let streamedAttemptId;
|
|
67
|
+
let lastProgressText = '';
|
|
68
|
+
const addProgress = (key, text) => {
|
|
69
|
+
if (!text || text === lastProgressText)
|
|
70
|
+
return;
|
|
71
|
+
lastProgressText = text;
|
|
72
|
+
addAssistantMessage({
|
|
73
|
+
// One live activity cell is updated in place, like Codex/Claude's
|
|
74
|
+
// active-turn transcript, instead of appending every snapshot.
|
|
75
|
+
id: `run-activity-${run.id}`,
|
|
76
|
+
role: 'system',
|
|
77
|
+
text,
|
|
78
|
+
createdAt: new Date().toISOString(),
|
|
79
|
+
runId: run.id,
|
|
80
|
+
}, key === 'finished' ? { moveToEnd: true } : undefined);
|
|
81
|
+
};
|
|
82
|
+
addProgress('received', '已收到任务,正在理解需求并安排步骤…');
|
|
58
83
|
const ensureAssistant = () => {
|
|
59
84
|
if (assistantStarted)
|
|
60
85
|
return;
|
|
@@ -68,6 +93,16 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
68
93
|
: {};
|
|
69
94
|
const text = typeof data.text === 'string' ? data.text : '';
|
|
70
95
|
if (text) {
|
|
96
|
+
if (data.kind === 'task_progress') {
|
|
97
|
+
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
|
|
98
|
+
const visibleText = tasks.length === 0 ? '正在理解需求并规划执行步骤…' : text;
|
|
99
|
+
setCurrentStep(visibleText);
|
|
100
|
+
const active = tasks.find((task) => task && typeof task === 'object' && ['running', 'verifying'].includes(String(task.status)));
|
|
101
|
+
const stageKey = active ? `${String(active.id)}:${String(active.status)}:${String(active.goal)}` : visibleText.replace(/\s*[·|]\s*(阶段调用|stage calls)\s*\d+.*$/i, '');
|
|
102
|
+
const stageText = visibleText.replace(/\s*[·|]\s*(阶段调用|stage calls)\s*\d+.*$/i, '');
|
|
103
|
+
addProgress(`stage:${stageKey}`, stageText);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
71
106
|
addAssistantMessage({
|
|
72
107
|
id: `notice-${run.id}-${event.seq}`,
|
|
73
108
|
role: 'system',
|
|
@@ -77,10 +112,37 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
77
112
|
}
|
|
78
113
|
continue;
|
|
79
114
|
}
|
|
80
|
-
|
|
115
|
+
if (event.type !== 'complete' && event.type !== 'error')
|
|
116
|
+
ensureAssistant();
|
|
81
117
|
switch (event.type) {
|
|
118
|
+
case 'delta': {
|
|
119
|
+
const delta = event.data && typeof event.data === 'object' && !Array.isArray(event.data)
|
|
120
|
+
? event.data
|
|
121
|
+
: {};
|
|
122
|
+
const effectId = typeof delta.effectId === 'string' ? delta.effectId : undefined;
|
|
123
|
+
const attemptId = typeof delta.attemptId === 'string' ? delta.attemptId : undefined;
|
|
124
|
+
const text = typeof delta.text === 'string' ? delta.text : '';
|
|
125
|
+
if (!text)
|
|
126
|
+
break;
|
|
127
|
+
if (effectId !== streamedEffectId || attemptId !== streamedAttemptId) {
|
|
128
|
+
assistantMessage.text = '';
|
|
129
|
+
streamedEffectId = effectId;
|
|
130
|
+
streamedAttemptId = attemptId;
|
|
131
|
+
}
|
|
132
|
+
assistantMessage.text += text;
|
|
133
|
+
assistantMessage.streamStatus = 'streaming';
|
|
134
|
+
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls?.map((call) => ({ ...call })) });
|
|
135
|
+
setCurrentStep('正在生成回复…');
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
82
138
|
case 'text':
|
|
83
|
-
|
|
139
|
+
if (streamedEffectId !== undefined)
|
|
140
|
+
assistantMessage.text = String(event.data ?? '');
|
|
141
|
+
else
|
|
142
|
+
assistantMessage.text += String(event.data ?? '');
|
|
143
|
+
streamedEffectId = undefined;
|
|
144
|
+
streamedAttemptId = undefined;
|
|
145
|
+
assistantMessage.streamStatus = 'complete';
|
|
84
146
|
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls?.map((call) => ({ ...call })) });
|
|
85
147
|
break;
|
|
86
148
|
case 'observation': {
|
|
@@ -102,6 +164,8 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
102
164
|
? calls.map((call, callIndex) => callIndex === index ? { ...call, ...nextCall } : call)
|
|
103
165
|
: [...calls, nextCall];
|
|
104
166
|
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls.map((call) => ({ ...call })) });
|
|
167
|
+
setCurrentStep(nextCall.status === 'succeeded' ? `已完成 ${nextCall.name},正在整理结果…` : nextCall.status === 'failed' ? `${nextCall.name} 未成功,正在处理错误…` : `正在执行 ${nextCall.name}…`);
|
|
168
|
+
addProgress(`tool-result:${id}:${nextCall.status}`, `${nextCall.status === 'succeeded' ? '已完成' : nextCall.status === 'cancelled' ? '已取消' : '执行失败'} · ${nextCall.name}`);
|
|
105
169
|
}
|
|
106
170
|
break;
|
|
107
171
|
}
|
|
@@ -122,22 +186,21 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
122
186
|
return typeof item.label === 'string' && typeof item.value === 'string' ? [{ label: item.label, value: item.value }] : [];
|
|
123
187
|
})
|
|
124
188
|
: undefined;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
});
|
|
189
|
+
enqueueInteraction({ kind: 'ask', request: {
|
|
190
|
+
effectId,
|
|
191
|
+
toolName: typeof input.toolName === 'string' ? input.toolName : `ask.${input.type}`,
|
|
192
|
+
type: input.type,
|
|
193
|
+
prompt: typeof input.prompt === 'string' ? input.prompt : '请输入你的回答。',
|
|
194
|
+
...(options && options.length ? { options } : {}),
|
|
195
|
+
...(typeof input.min === 'number' ? { min: input.min } : {}),
|
|
196
|
+
...(typeof input.max === 'number' ? { max: input.max } : {}),
|
|
197
|
+
...(typeof input.placeholder === 'string' ? { placeholder: input.placeholder } : {}),
|
|
198
|
+
...(typeof input.defaultValue === 'string' ? { defaultValue: input.defaultValue } : {}),
|
|
199
|
+
} });
|
|
137
200
|
setCurrentStep('等待你的回答...');
|
|
201
|
+
addProgress(`waiting:${effectId}`, '等待你的回答…');
|
|
138
202
|
break;
|
|
139
203
|
}
|
|
140
|
-
setAskRequest(null);
|
|
141
204
|
const tools = Array.isArray(input.tools)
|
|
142
205
|
? input.tools.flatMap((tool) => {
|
|
143
206
|
if (!tool || typeof tool !== 'object' || Array.isArray(tool))
|
|
@@ -153,15 +216,16 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
153
216
|
})
|
|
154
217
|
: [];
|
|
155
218
|
const firstTool = tools[0];
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
219
|
+
enqueueInteraction({ kind: 'approval', request: {
|
|
220
|
+
effectId,
|
|
221
|
+
toolName: tools.length === 1 ? firstTool.name : `${tools.length || 1} 个系统操作`,
|
|
222
|
+
toolArgs: firstTool?.input ?? {},
|
|
223
|
+
prompt: typeof input.prompt === 'string' ? input.prompt : '是否批准执行?',
|
|
224
|
+
...(typeof input.digest === 'string' ? { digest: input.digest } : {}),
|
|
225
|
+
...(tools.length ? { tools } : {}),
|
|
226
|
+
} });
|
|
164
227
|
setCurrentStep('等待用户审批...');
|
|
228
|
+
addProgress(`approval:${effectId}`, `等待审批 · ${tools.map((tool) => tool.name).join('、') || '系统操作'}`);
|
|
165
229
|
break;
|
|
166
230
|
}
|
|
167
231
|
case 'fact':
|
|
@@ -181,6 +245,29 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
181
245
|
...(typeof item.activity === 'string' ? { activity: item.activity } : {}),
|
|
182
246
|
}];
|
|
183
247
|
}));
|
|
248
|
+
const activeLanes = fact.lanes.filter((lane) => typeof lane.status === 'string' && !['succeeded', 'failed', 'cancelled'].includes(lane.status));
|
|
249
|
+
const active = activeLanes.find((lane) => lane.activityKind === 'tool') ?? activeLanes[0];
|
|
250
|
+
if (active && typeof active.goal === 'string') {
|
|
251
|
+
const toolName = active.activityKind === 'tool' && typeof active.activity === 'string' ? active.activity : undefined;
|
|
252
|
+
const activity = toolName ? `正在调用工具 · ${toolName}` : typeof active.activity === 'string'
|
|
253
|
+
? active.activity === 'llm' ? '正在分析需求和已有证据…' : '正在处理任务…'
|
|
254
|
+
: '正在处理任务…';
|
|
255
|
+
setCurrentStep(`${activity}:${active.goal}`);
|
|
256
|
+
const activityId = toolName
|
|
257
|
+
? (typeof active.activityEffectId === 'string' ? active.activityEffectId : active.id)
|
|
258
|
+
: active.activity === 'llm' ? 'analysis' : `${active.id}:${active.status}`;
|
|
259
|
+
addProgress(`activity:${activityId}`, activity);
|
|
260
|
+
if (toolName && typeof active.activityToolCallId === 'string') {
|
|
261
|
+
const id = active.activityToolCallId;
|
|
262
|
+
const calls = assistantMessage.toolCalls ?? [];
|
|
263
|
+
const index = calls.findIndex((call) => call.id === id);
|
|
264
|
+
const nextCall = { id, name: toolName, status: 'running', arguments: {} };
|
|
265
|
+
assistantMessage.toolCalls = index >= 0
|
|
266
|
+
? calls.map((call, callIndex) => callIndex === index ? { ...call, ...nextCall } : call)
|
|
267
|
+
: [...calls, nextCall];
|
|
268
|
+
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls.map((call) => ({ ...call })) });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
184
271
|
break;
|
|
185
272
|
}
|
|
186
273
|
}
|
|
@@ -189,30 +276,43 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
189
276
|
setCurrentStep(describeFactStatus(event.data));
|
|
190
277
|
break;
|
|
191
278
|
case 'error':
|
|
279
|
+
addProgress('finished', '运行遇到错误。');
|
|
280
|
+
if (assistantStarted && assistantMessage.text) {
|
|
281
|
+
assistantMessage.streamStatus = 'incomplete';
|
|
282
|
+
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls?.map((call) => ({ ...call })) });
|
|
283
|
+
}
|
|
192
284
|
setError(String(event.data ?? '发生未知错误'));
|
|
193
285
|
setIsRunning(false);
|
|
194
286
|
setCurrentStep(null);
|
|
195
|
-
|
|
196
|
-
setAskRequest(null);
|
|
287
|
+
setPendingInteractions([]);
|
|
197
288
|
setLanes([]);
|
|
198
289
|
break;
|
|
199
290
|
case 'complete':
|
|
291
|
+
addProgress('finished', event.data && typeof event.data === 'object' && !Array.isArray(event.data) && event.data.status === 'succeeded' ? '任务处理完成。' : '任务已结束。');
|
|
200
292
|
setIsRunning(false);
|
|
201
293
|
setCurrentStep(null);
|
|
202
|
-
|
|
203
|
-
setAskRequest(null);
|
|
294
|
+
setPendingInteractions([]);
|
|
204
295
|
if (event.data && typeof event.data === 'object' && !Array.isArray(event.data)) {
|
|
205
296
|
const completion = event.data;
|
|
297
|
+
const accepted = completion.status === 'succeeded' && (!completion.taskOutcome || (typeof completion.taskOutcome === 'object' && completion.taskOutcome !== null && completion.taskOutcome.status === 'accepted'));
|
|
298
|
+
if (assistantStarted && assistantMessage.text) {
|
|
299
|
+
assistantMessage.streamStatus = accepted ? 'complete' : 'incomplete';
|
|
300
|
+
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls?.map((call) => ({ ...call })) });
|
|
301
|
+
}
|
|
206
302
|
const usage = completion.usage && typeof completion.usage === 'object' && !Array.isArray(completion.usage)
|
|
207
303
|
? completion.usage
|
|
208
304
|
: undefined;
|
|
209
305
|
if (usage) {
|
|
210
306
|
const input = typeof usage.inputTokens === 'number' ? usage.inputTokens.toLocaleString() : '未知';
|
|
211
307
|
const output = typeof usage.outputTokens === 'number' ? usage.outputTokens.toLocaleString() : '未知';
|
|
308
|
+
const reasoning = typeof usage.reasoningTokens === 'number' ? usage.reasoningTokens.toLocaleString() : '未知';
|
|
309
|
+
const visibleChars = typeof usage.visibleOutputChars === 'number' ? usage.visibleOutputChars.toLocaleString() : '未知';
|
|
310
|
+
const modelCalls = typeof usage.modelCalls === 'number' ? usage.modelCalls.toLocaleString() : '未知';
|
|
311
|
+
const truncations = typeof usage.truncationEvents === 'number' ? usage.truncationEvents.toLocaleString() : '未知';
|
|
212
312
|
const costs = Array.isArray(usage.providerCosts) ? usage.providerCosts.filter((item) => Boolean(item && typeof item === 'object' && typeof item.currency === 'string' && typeof item.amount === 'number')) : [];
|
|
213
313
|
const estimate = usage.estimatedCost && typeof usage.estimatedCost === 'object' ? usage.estimatedCost : undefined;
|
|
214
314
|
const costLabel = costs.length ? costs.map((item) => `${item.amount} ${item.currency}`).join(', ') : estimate && typeof estimate.amount === 'number' ? `估算 ${estimate.amount} ${String(estimate.currency)} (价格 ${String(estimate.pricingVersion)})` : '费用未知';
|
|
215
|
-
addAssistantMessage({ id: `run-usage-${run.id}`, role: 'system', text: `用量:输入 ${input} · 输出 ${output} · ${costLabel}${usage.completeness === 'partial' ? ' · 数据不完整' : ''}`, createdAt: new Date().toISOString(), runId: run.id });
|
|
315
|
+
addAssistantMessage({ id: `run-usage-${run.id}`, role: 'system', text: `用量:输入 ${input} · 输出 ${output} · 思考 ${reasoning} · 可见字符 ${visibleChars} · 模型调用 ${modelCalls} · 截断 ${truncations} · ${costLabel}${usage.completeness === 'partial' ? ' · 数据不完整' : ''}`, createdAt: new Date().toISOString(), runId: run.id });
|
|
216
316
|
}
|
|
217
317
|
const taskOutcome = completion.taskOutcome && typeof completion.taskOutcome === 'object' && !Array.isArray(completion.taskOutcome)
|
|
218
318
|
? completion.taskOutcome
|
|
@@ -245,12 +345,11 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
245
345
|
break;
|
|
246
346
|
}
|
|
247
347
|
}
|
|
248
|
-
}, [addAssistantMessage]);
|
|
348
|
+
}, [addAssistantMessage, enqueueInteraction]);
|
|
249
349
|
const finishRun = useCallback(() => {
|
|
250
350
|
setIsRunning(false);
|
|
251
351
|
setCurrentStep(null);
|
|
252
|
-
|
|
253
|
-
setAskRequest(null);
|
|
352
|
+
setPendingInteractions([]);
|
|
254
353
|
runRef.current = null;
|
|
255
354
|
}, []);
|
|
256
355
|
const sendMessage = useCallback(async (text, targetConversationId) => {
|
|
@@ -270,7 +369,7 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
270
369
|
}
|
|
271
370
|
setIsRunning(true);
|
|
272
371
|
setError(null);
|
|
273
|
-
setCurrentStep('
|
|
372
|
+
setCurrentStep('正在理解你的需求并安排执行步骤…');
|
|
274
373
|
try {
|
|
275
374
|
const run = await host.sendMessage(activeConversationId, { text });
|
|
276
375
|
await consumeRun(run);
|
|
@@ -288,7 +387,7 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
288
387
|
return;
|
|
289
388
|
setIsRunning(true);
|
|
290
389
|
setError(null);
|
|
291
|
-
setCurrentStep('
|
|
390
|
+
setCurrentStep('正在恢复任务进度…');
|
|
292
391
|
try {
|
|
293
392
|
const run = await host.resumeRun(activeConversationId);
|
|
294
393
|
await consumeRun(run);
|
|
@@ -311,7 +410,7 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
311
410
|
setApprovalSubmitting(true);
|
|
312
411
|
try {
|
|
313
412
|
await runRef.current.reply(effectId, { approved, ...(approved ? {} : { reason: reason || '拒绝执行' }) });
|
|
314
|
-
|
|
413
|
+
removeInteraction(effectId);
|
|
315
414
|
setCurrentStep('审批已提交,正在继续...');
|
|
316
415
|
setError(null);
|
|
317
416
|
}
|
|
@@ -322,14 +421,14 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
322
421
|
finally {
|
|
323
422
|
setApprovalSubmitting(false);
|
|
324
423
|
}
|
|
325
|
-
}, [approvalSubmitting]);
|
|
424
|
+
}, [approvalSubmitting, removeInteraction]);
|
|
326
425
|
const replyAsk = useCallback(async (effectId, value) => {
|
|
327
426
|
if (!effectId || !runRef.current || approvalSubmitting)
|
|
328
427
|
return;
|
|
329
428
|
setApprovalSubmitting(true);
|
|
330
429
|
try {
|
|
331
430
|
await runRef.current.reply(effectId, value);
|
|
332
|
-
|
|
431
|
+
removeInteraction(effectId);
|
|
333
432
|
setCurrentStep('回答已提交,正在继续...');
|
|
334
433
|
setError(null);
|
|
335
434
|
}
|
|
@@ -340,7 +439,7 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
340
439
|
finally {
|
|
341
440
|
setApprovalSubmitting(false);
|
|
342
441
|
}
|
|
343
|
-
}, [approvalSubmitting]);
|
|
442
|
+
}, [approvalSubmitting, removeInteraction]);
|
|
344
443
|
const cancelRun = useCallback(async () => {
|
|
345
444
|
if (runRef.current) {
|
|
346
445
|
await runRef.current.cancel('USER_CANCELLED');
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { SlashCommand } from '../types.js';
|
|
2
2
|
export interface SlashCommandDependencies {
|
|
3
3
|
onMouse?: (args: string) => void | Promise<void>;
|
|
4
|
+
onCopy?: () => void | Promise<void>;
|
|
4
5
|
onHelp?: () => void | Promise<void>;
|
|
5
6
|
onStatus?: () => void | Promise<void>;
|
|
6
7
|
onTools?: () => void | Promise<void>;
|
|
7
8
|
onArtifacts?: () => void | Promise<void>;
|
|
8
9
|
onExit?: () => void | Promise<void>;
|
|
9
|
-
onQuit?: () => void | Promise<void>;
|
|
10
10
|
onCancel?: () => void | Promise<void>;
|
|
11
11
|
onNew?: () => void | Promise<void>;
|
|
12
12
|
onResume?: () => void | Promise<void>;
|
|
@@ -2,12 +2,12 @@ import { useMemo, useCallback } from 'react';
|
|
|
2
2
|
export function useSlashCommands(deps) {
|
|
3
3
|
const commands = useMemo(() => [
|
|
4
4
|
{ name: '/mouse', description: '滚轮和触控板滚动 [on|off]', execute: async (args) => deps.onMouse?.(args) },
|
|
5
|
+
{ name: '/copy', description: '复制选区或最近一条完整回复', execute: async () => deps.onCopy?.() },
|
|
5
6
|
{ name: '/help', aliases: ['/h'], description: '显示帮助信息', execute: async () => deps.onHelp?.() },
|
|
6
7
|
{ name: '/status', description: '查看状态', execute: async () => deps.onStatus?.() },
|
|
7
8
|
{ name: '/tools', description: '列出可用工具', execute: async () => deps.onTools?.() },
|
|
8
9
|
{ name: '/artifacts', description: '查看当前产物', execute: async () => deps.onArtifacts?.() },
|
|
9
10
|
{ name: '/exit', description: '退出程序', execute: async () => deps.onExit?.() },
|
|
10
|
-
{ name: '/quit', aliases: ['/q'], description: '退出程序', execute: async () => deps.onQuit?.() },
|
|
11
11
|
{ name: '/cancel', aliases: ['/stop'], description: '取消当前运行(保留会话)', execute: async () => deps.onCancel?.() },
|
|
12
12
|
{ name: '/new', description: '开启新会话', execute: async () => deps.onNew?.() },
|
|
13
13
|
{ name: '/resume', description: '恢复上一次会话或未完成运行', execute: async () => deps.onResume?.() },
|
package/dist/types.d.ts
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
interface ClipboardCommand {
|
|
2
|
+
file: string;
|
|
3
|
+
args: string[];
|
|
4
|
+
}
|
|
5
|
+
export declare function isCopyShortcut(input: string, key: {
|
|
6
|
+
ctrl?: boolean;
|
|
7
|
+
meta?: boolean;
|
|
8
|
+
}): boolean;
|
|
9
|
+
export declare function clipboardCommands(platform?: NodeJS.Platform): ClipboardCommand[];
|
|
10
|
+
/** Remove characters that look blank but become troublesome when pasted. */
|
|
11
|
+
export declare function sanitizeClipboardText(text: string): string;
|
|
12
|
+
/** Copy the complete plain-text payload through the host OS clipboard utility. */
|
|
13
|
+
export declare function copyToClipboard(text: string): Promise<void>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { stripTerminalControls } from './ansi.js';
|
|
3
|
+
export function isCopyShortcut(input, key) {
|
|
4
|
+
const value = input.toLowerCase();
|
|
5
|
+
return (key.meta === true && value === 'c') || (key.ctrl === true && (value === 'c' || input === '\u0003'));
|
|
6
|
+
}
|
|
7
|
+
export function clipboardCommands(platform = process.platform) {
|
|
8
|
+
if (platform === 'darwin')
|
|
9
|
+
return [{ file: 'pbcopy', args: [] }];
|
|
10
|
+
if (platform === 'win32')
|
|
11
|
+
return [{ file: 'clip.exe', args: [] }];
|
|
12
|
+
if (platform === 'linux')
|
|
13
|
+
return [{ file: 'wl-copy', args: [] }, { file: 'xclip', args: ['-selection', 'clipboard'] }];
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
/** Remove characters that look blank but become troublesome when pasted. */
|
|
17
|
+
export function sanitizeClipboardText(text) {
|
|
18
|
+
return stripTerminalControls(text)
|
|
19
|
+
.replace(/\r\n?/g, '\n')
|
|
20
|
+
.replace(/[\u00A0\u202F]/g, ' ')
|
|
21
|
+
.replace(/[\u00AD\u200B\u2060\uFEFF]/g, '');
|
|
22
|
+
}
|
|
23
|
+
/** Copy the complete plain-text payload through the host OS clipboard utility. */
|
|
24
|
+
export async function copyToClipboard(text) {
|
|
25
|
+
const cleanText = sanitizeClipboardText(text);
|
|
26
|
+
const commands = clipboardCommands();
|
|
27
|
+
if (!commands.length)
|
|
28
|
+
throw new Error('CLIPBOARD_UNSUPPORTED');
|
|
29
|
+
let lastError;
|
|
30
|
+
for (const command of commands) {
|
|
31
|
+
try {
|
|
32
|
+
await new Promise((resolve, reject) => {
|
|
33
|
+
const child = spawn(command.file, command.args, { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
34
|
+
let stderr = '';
|
|
35
|
+
child.stderr.setEncoding('utf8');
|
|
36
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
37
|
+
child.once('error', reject);
|
|
38
|
+
child.once('close', (code) => code === 0 ? resolve() : reject(new Error(stderr.trim() || `${command.file} exited with ${code}`)));
|
|
39
|
+
child.stdin.once('error', reject);
|
|
40
|
+
child.stdin.end(cleanText);
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
lastError = error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
throw Object.assign(new Error('CLIPBOARD_UNAVAILABLE'), { cause: lastError });
|
|
49
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface InputHistoryState {
|
|
2
|
+
entries: string[];
|
|
3
|
+
position: number | null;
|
|
4
|
+
draft: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const emptyInputHistory: () => InputHistoryState;
|
|
7
|
+
export declare function rememberInput(state: InputHistoryState, value: string): InputHistoryState;
|
|
8
|
+
export declare function navigateInputHistory(state: InputHistoryState, direction: 'up' | 'down', currentValue: string): {
|
|
9
|
+
state: InputHistoryState;
|
|
10
|
+
value?: string;
|
|
11
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const emptyInputHistory = () => ({ entries: [], position: null, draft: '' });
|
|
2
|
+
export function rememberInput(state, value) {
|
|
3
|
+
if (!value.trim())
|
|
4
|
+
return { ...state, position: null, draft: '' };
|
|
5
|
+
return { entries: [...state.entries, value].slice(-50), position: null, draft: '' };
|
|
6
|
+
}
|
|
7
|
+
export function navigateInputHistory(state, direction, currentValue) {
|
|
8
|
+
if (!state.entries.length)
|
|
9
|
+
return { state };
|
|
10
|
+
if (direction === 'up') {
|
|
11
|
+
const position = state.position === null
|
|
12
|
+
? state.entries.length - 1
|
|
13
|
+
: Math.max(0, state.position - 1);
|
|
14
|
+
const draft = state.position === null ? currentValue : state.draft;
|
|
15
|
+
return { state: { ...state, position, draft }, value: state.entries[position] };
|
|
16
|
+
}
|
|
17
|
+
if (state.position === null)
|
|
18
|
+
return { state };
|
|
19
|
+
if (state.position < state.entries.length - 1) {
|
|
20
|
+
const position = state.position + 1;
|
|
21
|
+
return { state: { ...state, position }, value: state.entries[position] };
|
|
22
|
+
}
|
|
23
|
+
return { state: { ...state, position: null, draft: '' }, value: state.draft };
|
|
24
|
+
}
|
package/dist/utils/mouse.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export interface TerminalMouseEvent {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
button: number;
|
|
5
|
+
phase: 'press' | 'drag' | 'release' | 'wheel';
|
|
3
6
|
wheel: number;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export declare
|
|
7
|
+
}
|
|
8
|
+
/** SGR mouse reporting. Ink removes the first ESC before delivering unknown keys. */
|
|
9
|
+
export declare function mouseEvent(input: string): TerminalMouseEvent | undefined;
|
|
10
|
+
export declare const enableMouse = "\u001B[?1002h\u001B[?1006h";
|
|
11
|
+
export declare const disableMouse = "\u001B[?1006l\u001B[?1002l";
|
package/dist/utils/mouse.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
/** SGR mouse reporting. Ink removes the first ESC before delivering unknown keys. */
|
|
2
2
|
export function mouseEvent(input) {
|
|
3
|
-
const match = /^(?:\x1b)?\[<(\d+)
|
|
3
|
+
const match = /^(?:\x1b)?\[<(\d+);(\d+);(\d+)([Mm])$/.exec(input);
|
|
4
4
|
if (!match)
|
|
5
5
|
return undefined;
|
|
6
6
|
const button = Number(match[1]);
|
|
7
|
+
const x = Number(match[2]) - 1;
|
|
8
|
+
const y = Number(match[3]) - 1;
|
|
9
|
+
const final = match[4];
|
|
7
10
|
const base = button & ~28; // Shift, Alt and Ctrl modifiers do not change direction.
|
|
8
|
-
|
|
11
|
+
if (base === 64 || base === 65)
|
|
12
|
+
return { x, y, button, phase: 'wheel', wheel: base === 64 ? -3 : 3 };
|
|
13
|
+
if (final === 'm' || (button & 3) === 3)
|
|
14
|
+
return { x, y, button, phase: 'release', wheel: 0 };
|
|
15
|
+
if ((button & 32) !== 0)
|
|
16
|
+
return { x, y, button, phase: 'drag', wheel: 0 };
|
|
17
|
+
return { x, y, button, phase: 'press', wheel: 0 };
|
|
9
18
|
}
|
|
10
|
-
|
|
11
|
-
export const
|
|
19
|
+
// Button-event tracking reports press/release/drag while retaining wheel events.
|
|
20
|
+
export const enableMouse = '\x1b[?1002h\x1b[?1006h';
|
|
21
|
+
export const disableMouse = '\x1b[?1006l\x1b[?1002l';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface TextPoint {
|
|
2
|
+
line: number;
|
|
3
|
+
column: number;
|
|
4
|
+
}
|
|
5
|
+
export interface TextSelection {
|
|
6
|
+
start: TextPoint;
|
|
7
|
+
end: TextPoint;
|
|
8
|
+
}
|
|
9
|
+
export declare function orderedSelection(selection: TextSelection): TextSelection;
|
|
10
|
+
/** Returns the visible text rows in selection order, with terminal styling removed. */
|
|
11
|
+
export declare function selectedText(lines: readonly string[], selection: TextSelection): string;
|
|
12
|
+
/** Adds a calm, high-contrast selection without terminal reverse-video. */
|
|
13
|
+
export declare function selectedLine(line: string, lineIndex: number, selection?: TextSelection): string;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { stripTerminalControls } from './ansi.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
function pointOrder(a, b) {
|
|
4
|
+
return a.line - b.line || a.column - b.column;
|
|
5
|
+
}
|
|
6
|
+
export function orderedSelection(selection) {
|
|
7
|
+
return pointOrder(selection.start, selection.end) <= 0
|
|
8
|
+
? selection
|
|
9
|
+
: { start: selection.end, end: selection.start };
|
|
10
|
+
}
|
|
11
|
+
function isCombining(character) {
|
|
12
|
+
return /[\p{Mark}\u200D\uFE0E\uFE0F]/u.test(character);
|
|
13
|
+
}
|
|
14
|
+
function isWide(codePoint) {
|
|
15
|
+
return codePoint >= 0x1100 && (codePoint <= 0x115f || codePoint === 0x2329 || codePoint === 0x232a ||
|
|
16
|
+
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
|
|
17
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
18
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
19
|
+
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
|
20
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
|
21
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
22
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
|
23
|
+
(codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
|
|
24
|
+
(codePoint >= 0x20000 && codePoint <= 0x3fffd));
|
|
25
|
+
}
|
|
26
|
+
function width(character) {
|
|
27
|
+
if (isCombining(character))
|
|
28
|
+
return 0;
|
|
29
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
30
|
+
return isWide(codePoint) ? 2 : 1;
|
|
31
|
+
}
|
|
32
|
+
function indexAtColumn(text, column) {
|
|
33
|
+
let cells = 0;
|
|
34
|
+
let index = 0;
|
|
35
|
+
for (const character of text) {
|
|
36
|
+
const next = cells + width(character);
|
|
37
|
+
if (column < next)
|
|
38
|
+
return index;
|
|
39
|
+
cells = next;
|
|
40
|
+
index += character.length;
|
|
41
|
+
}
|
|
42
|
+
return text.length;
|
|
43
|
+
}
|
|
44
|
+
function indexAfterCellAtColumn(text, column) {
|
|
45
|
+
let cells = 0;
|
|
46
|
+
let index = 0;
|
|
47
|
+
for (const character of text) {
|
|
48
|
+
const next = cells + width(character);
|
|
49
|
+
if (column < next)
|
|
50
|
+
return index + character.length;
|
|
51
|
+
cells = next;
|
|
52
|
+
index += character.length;
|
|
53
|
+
}
|
|
54
|
+
return text.length;
|
|
55
|
+
}
|
|
56
|
+
function rawOffsetAtVisibleIndex(raw, target) {
|
|
57
|
+
const control = /^(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\)|[\u001B\u009B][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]|\u001B[@-_])/u;
|
|
58
|
+
let offset = 0;
|
|
59
|
+
let visible = 0;
|
|
60
|
+
while (offset < raw.length) {
|
|
61
|
+
if (visible >= target)
|
|
62
|
+
return offset;
|
|
63
|
+
const remaining = raw.slice(offset);
|
|
64
|
+
const escape = control.exec(remaining);
|
|
65
|
+
if (escape) {
|
|
66
|
+
offset += escape[0].length;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const codePoint = raw.codePointAt(offset);
|
|
70
|
+
const character = String.fromCodePoint(codePoint);
|
|
71
|
+
if (!/[\u0000-\u001f\u007f-\u009f]/u.test(character))
|
|
72
|
+
visible += character.length;
|
|
73
|
+
offset += character.length;
|
|
74
|
+
}
|
|
75
|
+
return raw.length;
|
|
76
|
+
}
|
|
77
|
+
/** Returns the visible text rows in selection order, with terminal styling removed. */
|
|
78
|
+
export function selectedText(lines, selection) {
|
|
79
|
+
const ordered = orderedSelection(selection);
|
|
80
|
+
const firstLine = Math.max(0, Math.min(lines.length - 1, ordered.start.line));
|
|
81
|
+
const lastLine = Math.max(firstLine, Math.min(lines.length - 1, ordered.end.line));
|
|
82
|
+
const result = [];
|
|
83
|
+
for (let line = firstLine; line <= lastLine; line++) {
|
|
84
|
+
const plain = stripTerminalControls(lines[line] ?? '');
|
|
85
|
+
const from = line === firstLine ? ordered.start.column : 0;
|
|
86
|
+
const start = indexAtColumn(plain, from);
|
|
87
|
+
const end = line === lastLine ? indexAfterCellAtColumn(plain, ordered.end.column) : plain.length;
|
|
88
|
+
result.push(plain.slice(Math.min(start, end), Math.max(start, end)));
|
|
89
|
+
}
|
|
90
|
+
return result.join('\n');
|
|
91
|
+
}
|
|
92
|
+
/** Adds a calm, high-contrast selection without terminal reverse-video. */
|
|
93
|
+
export function selectedLine(line, lineIndex, selection) {
|
|
94
|
+
if (!selection)
|
|
95
|
+
return line;
|
|
96
|
+
const plain = stripTerminalControls(line);
|
|
97
|
+
const ordered = orderedSelection(selection);
|
|
98
|
+
if (lineIndex < ordered.start.line || lineIndex > ordered.end.line)
|
|
99
|
+
return line;
|
|
100
|
+
const from = lineIndex === ordered.start.line ? ordered.start.column : 0;
|
|
101
|
+
const start = indexAtColumn(plain, from);
|
|
102
|
+
const end = lineIndex === ordered.end.line ? indexAfterCellAtColumn(plain, ordered.end.column) : plain.length;
|
|
103
|
+
const mark = chalk.bgHex('#DDF4F1').hex('#173B3A');
|
|
104
|
+
const rawStart = rawOffsetAtVisibleIndex(line, start);
|
|
105
|
+
const rawEnd = rawOffsetAtVisibleIndex(line, end);
|
|
106
|
+
return `${line.slice(0, rawStart)}${mark(plain.slice(start, end))}${line.slice(rawEnd)}`;
|
|
107
|
+
}
|