@hunterzhu/pulse-cli 0.2.1 → 0.3.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/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/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 +103 -5
- 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/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @hunterzhu/pulse-cli
|
|
2
2
|
|
|
3
|
-
**A
|
|
3
|
+
**A neural-signal like AI assistant for coding, research and file tasks.**
|
|
4
4
|
|
|
5
5
|
Pulse runs in your project folder, keeps conversations on your machine, and can resume unfinished work. Requires Node.js 22+.
|
|
6
6
|
|
|
@@ -22,14 +22,20 @@ export async function runInteractive(options, conversationId, initialTask, versi
|
|
|
22
22
|
// Keep Pulse in its own full-screen buffer so the shell's previous
|
|
23
23
|
// command history does not become part of the chat workspace.
|
|
24
24
|
alternateScreen: true,
|
|
25
|
+
// Ctrl+C is a selection-copy shortcut inside the chat. Exit explicitly
|
|
26
|
+
// through /exit so an accidental keypress cannot drop an active task.
|
|
27
|
+
exitOnCtrlC: false,
|
|
25
28
|
});
|
|
26
29
|
let interrupted = false;
|
|
27
|
-
const
|
|
30
|
+
const onInterrupt = () => {
|
|
31
|
+
// Ignore SIGINT while the interactive app is active; /exit is the exit path.
|
|
32
|
+
};
|
|
33
|
+
const onTerminate = () => {
|
|
28
34
|
interrupted = true;
|
|
29
35
|
unmount();
|
|
30
36
|
};
|
|
31
|
-
process.on('SIGINT',
|
|
32
|
-
process.on('SIGTERM',
|
|
37
|
+
process.on('SIGINT', onInterrupt);
|
|
38
|
+
process.on('SIGTERM', onTerminate);
|
|
33
39
|
try {
|
|
34
40
|
await waitUntilExit();
|
|
35
41
|
return interrupted ? 130 : 0;
|
|
@@ -39,8 +45,8 @@ export async function runInteractive(options, conversationId, initialTask, versi
|
|
|
39
45
|
return 1;
|
|
40
46
|
}
|
|
41
47
|
finally {
|
|
42
|
-
process.off('SIGINT',
|
|
43
|
-
process.off('SIGTERM',
|
|
48
|
+
process.off('SIGINT', onInterrupt);
|
|
49
|
+
process.off('SIGTERM', onTerminate);
|
|
44
50
|
await closePendingHosts();
|
|
45
51
|
}
|
|
46
52
|
}
|
package/dist/components/App.js
CHANGED
|
@@ -16,6 +16,8 @@ import { useConversation } from '../hooks/useConversation.js';
|
|
|
16
16
|
import { useRun } from '../hooks/useRun.js';
|
|
17
17
|
import { useTokenStats } from '../hooks/useTokenStats.js';
|
|
18
18
|
import { useSlashCommands } from '../hooks/useSlashCommands.js';
|
|
19
|
+
import { copyToClipboard, isCopyShortcut } from '../utils/clipboard.js';
|
|
20
|
+
import { emptyInputHistory, navigateInputHistory, rememberInput } from '../utils/inputHistory.js';
|
|
19
21
|
export function App({ hostOptions, conversationId: initialConversationId, initialTask, resumeOnStart = false, version, }) {
|
|
20
22
|
const { exit } = useApp();
|
|
21
23
|
const { stdout } = useStdout();
|
|
@@ -23,6 +25,10 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
|
|
|
23
25
|
useEffect(() => { const resize = () => setTerminalRows(stdout.rows || 24); stdout.on('resize', resize); return () => { stdout.off('resize', resize); }; }, [stdout]);
|
|
24
26
|
const [mode, setMode] = useState('chat');
|
|
25
27
|
const [mouseEnabled, setMouseEnabled] = useState(true);
|
|
28
|
+
const [selectedText, setSelectedText] = useState('');
|
|
29
|
+
const [inputHistory, setInputHistory] = useState(emptyInputHistory);
|
|
30
|
+
const inputHistoryRef = useRef(inputHistory);
|
|
31
|
+
inputHistoryRef.current = inputHistory;
|
|
26
32
|
const [showThinking, setShowThinking] = useState(false);
|
|
27
33
|
const [verbosity, setVerbosity] = useState('normal');
|
|
28
34
|
const [sessionsList, setSessionsList] = useState([]);
|
|
@@ -33,12 +39,35 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
|
|
|
33
39
|
const startedResume = useRef(false);
|
|
34
40
|
const { host, error: hostError, ready: hostReady } = useHost(hostOptions);
|
|
35
41
|
const { conversation, messages, addUserMessage, addAssistantMessage, clearMessages, switchConversation, newConversation, createConversation, error: conversationError, } = useConversation({ host, conversationId: initialConversationId });
|
|
36
|
-
const { isRunning,
|
|
42
|
+
const { isRunning, error: runError, approvalRequest, askRequest, lanes, approvalSubmitting, sendMessage, resumeActive, approveAction, replyAsk, cancelRun, } = useRun({
|
|
37
43
|
host,
|
|
38
44
|
conversationId: conversation?.id ?? null,
|
|
39
45
|
addAssistantMessage,
|
|
40
46
|
});
|
|
41
47
|
const { cumulative } = useTokenStats();
|
|
48
|
+
const rememberUserInput = useCallback((text) => {
|
|
49
|
+
const next = rememberInput(inputHistoryRef.current, text);
|
|
50
|
+
inputHistoryRef.current = next;
|
|
51
|
+
setInputHistory(next);
|
|
52
|
+
}, []);
|
|
53
|
+
const navigateUserInputHistory = useCallback((direction, currentValue) => {
|
|
54
|
+
const result = navigateInputHistory(inputHistoryRef.current, direction, currentValue);
|
|
55
|
+
inputHistoryRef.current = result.state;
|
|
56
|
+
setInputHistory(result.state);
|
|
57
|
+
return result.value;
|
|
58
|
+
}, []);
|
|
59
|
+
useEffect(() => { setSelectedText(''); }, [conversation?.id]);
|
|
60
|
+
const copySelectedText = useCallback(async () => {
|
|
61
|
+
if (!selectedText)
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
await copyToClipboard(selectedText);
|
|
65
|
+
addAssistantMessage({ id: `copy-${Date.now()}`, role: 'system', text: `已复制选中的内容(${selectedText.length.toLocaleString()} 个字符)。`, createdAt: new Date().toISOString() });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
addAssistantMessage({ id: `copy-${Date.now()}`, role: 'system', text: '复制失败:当前系统没有可用的剪贴板工具。', createdAt: new Date().toISOString() });
|
|
69
|
+
}
|
|
70
|
+
}, [selectedText, addAssistantMessage]);
|
|
42
71
|
useEffect(() => {
|
|
43
72
|
if (approvalRequest) {
|
|
44
73
|
setApprovalChoiceFocused(true);
|
|
@@ -100,6 +129,25 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
|
|
|
100
129
|
}, [hostReady, conversation, initialTask, isRunning, addUserMessage, addAssistantMessage, createConversation, sendMessage]);
|
|
101
130
|
const { executeCommand, isSlashCommand } = useSlashCommands({
|
|
102
131
|
onMouse: (arg) => setMouseEnabled((current) => arg === 'off' ? false : arg === 'on' ? true : !current),
|
|
132
|
+
onCopy: async () => {
|
|
133
|
+
if (selectedText) {
|
|
134
|
+
await copySelectedText();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const latest = [...messages].reverse().find((message) => message.role === 'assistant' && message.text.trim() && message.streamStatus !== 'streaming' && message.streamStatus !== 'incomplete');
|
|
138
|
+
const payload = latest?.text || '';
|
|
139
|
+
if (!payload) {
|
|
140
|
+
addAssistantMessage({ id: `copy-${Date.now()}`, role: 'system', text: '请先拖动选择内容,或发送一条消息后再复制回复。', createdAt: new Date().toISOString() });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
await copyToClipboard(payload);
|
|
145
|
+
addAssistantMessage({ id: `copy-${Date.now()}`, role: 'system', text: `已复制最近一条完整回复(${payload.length.toLocaleString()} 个字符)。`, createdAt: new Date().toISOString() });
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
addAssistantMessage({ id: `copy-${Date.now()}`, role: 'system', text: '复制失败:当前系统没有可用的剪贴板工具。可试试 /mouse off 后用终端选择文本。', createdAt: new Date().toISOString() });
|
|
149
|
+
}
|
|
150
|
+
},
|
|
103
151
|
onHelp: () => setMode('help'),
|
|
104
152
|
onSessions: async () => {
|
|
105
153
|
await loadSessions();
|
|
@@ -156,7 +204,6 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
|
|
|
156
204
|
}
|
|
157
205
|
},
|
|
158
206
|
onExit: () => exit(),
|
|
159
|
-
onQuit: () => exit(),
|
|
160
207
|
onCancel: async () => {
|
|
161
208
|
if (!isRunning) {
|
|
162
209
|
addAssistantMessage({
|
|
@@ -425,8 +472,9 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
|
|
|
425
472
|
}
|
|
426
473
|
}, [isSlashCommand, executeCommand, conversation, createConversation, addUserMessage, sendMessage]);
|
|
427
474
|
useInput((input, key) => {
|
|
428
|
-
if ((
|
|
429
|
-
|
|
475
|
+
if (isCopyShortcut(input, key)) {
|
|
476
|
+
if (selectedText)
|
|
477
|
+
void copySelectedText();
|
|
430
478
|
return;
|
|
431
479
|
}
|
|
432
480
|
if (conversationError && !conversation && input === 'n') {
|
|
@@ -452,13 +500,13 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
|
|
|
452
500
|
}
|
|
453
501
|
});
|
|
454
502
|
if (hostError) {
|
|
455
|
-
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["Pulse \u521D\u59CB\u5316\u5931\u8D25\uFF1A", hostError] }), _jsx(Text, { dimColor: true, children: "\u8BF7\u68C0\u67E5\u5DE5\u4F5C\u76EE\u5F55\u3001\u914D\u7F6E\u6587\u4EF6\u548C\u6570\u636E\u76EE\u5F55\u6743\u9650\u540E\u91CD\u8BD5\u3002" }), _jsx(Text, { dimColor: true, children: "\
|
|
503
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["Pulse \u521D\u59CB\u5316\u5931\u8D25\uFF1A", hostError] }), _jsx(Text, { dimColor: true, children: "\u8BF7\u68C0\u67E5\u5DE5\u4F5C\u76EE\u5F55\u3001\u914D\u7F6E\u6587\u4EF6\u548C\u6570\u636E\u76EE\u5F55\u6743\u9650\u540E\u91CD\u8BD5\u3002" }), _jsx(Text, { dimColor: true, children: "\u8F93\u5165 /exit \u9000\u51FA\u3002" })] }));
|
|
456
504
|
}
|
|
457
505
|
if (!hostReady) {
|
|
458
506
|
return (_jsx(Box, { padding: 1, children: _jsx(Spinner, { label: "\u6B63\u5728\u521D\u59CB\u5316 Pulse \u8FD0\u884C\u65F6..." }) }));
|
|
459
507
|
}
|
|
460
508
|
if (conversationError && !conversation) {
|
|
461
|
-
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["\u4F1A\u8BDD\u52A0\u8F7D\u5931\u8D25\uFF1A", conversationError] }), _jsx(Text, { dimColor: true, children: "\u6309 n \u65B0\u5EFA\u4F1A\u8BDD\uFF0C\
|
|
509
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["\u4F1A\u8BDD\u52A0\u8F7D\u5931\u8D25\uFF1A", conversationError] }), _jsx(Text, { dimColor: true, children: "\u6309 n \u65B0\u5EFA\u4F1A\u8BDD\uFF0C\u8F93\u5165 /exit \u9000\u51FA\u3002" })] }));
|
|
462
510
|
}
|
|
463
511
|
if (mode === 'sessions') {
|
|
464
512
|
return (_jsx(SessionList, { sessions: sessionsList, notice: sessionError, onSelect: (id) => {
|
|
@@ -486,13 +534,13 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
|
|
|
486
534
|
const title = conversation?.summary.title ?? 'New conversation';
|
|
487
535
|
const modelName = host?.getModel() || hostOptions.provider?.defaultModel || hostOptions.provider?.provider || 'default';
|
|
488
536
|
const providerName = host?.getProvider() || hostOptions.activeProviderCode || hostOptions.provider?.provider || 'default';
|
|
489
|
-
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: terminalRows, children: [_jsx(Header, { title: title, cwd: cwd, model: modelName, approvalMode: hostOptions.approvalMode ?? 'ask' }), messages.length === 0 && !isRunning && (_jsx(Welcome, { cwd: cwd, model: modelName, version: version })), messages.length > 0 && (_jsx(MessageList, { mouseEnabled: mouseEnabled, messages: messages, showThinking: showThinking, verbosity: verbosity, isRunning: isRunning })), runError && (_jsx(Box, { marginY: 1, children: _jsxs(Text, { color: "red", children: ["\u9519\u8BEF: ", runError] }) })), conversationError && (_jsx(Box, { marginY: 1, children: _jsxs(Text, { color: "red", children: ["\u4F1A\u8BDD\u6D88\u606F\u52A0\u8F7D\u5931\u8D25: ", conversationError] }) })), _jsx(StatusHud, { isRunning: isRunning, cwd: cwd, model: modelName, provider: providerName, approvalMode: hostOptions.approvalMode ?? 'ask',
|
|
537
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: terminalRows, children: [_jsx(Header, { title: title, cwd: cwd, model: modelName, approvalMode: hostOptions.approvalMode ?? 'ask' }), messages.length === 0 && !isRunning && (_jsx(Welcome, { cwd: cwd, model: modelName, version: version })), messages.length > 0 && (_jsx(MessageList, { mouseEnabled: mouseEnabled, messages: messages, showThinking: showThinking, verbosity: verbosity, isRunning: isRunning, onSelectionChange: setSelectedText })), runError && (_jsx(Box, { marginY: 1, children: _jsxs(Text, { color: "red", children: ["\u9519\u8BEF: ", runError] }) })), conversationError && (_jsx(Box, { marginY: 1, children: _jsxs(Text, { color: "red", children: ["\u4F1A\u8BDD\u6D88\u606F\u52A0\u8F7D\u5931\u8D25: ", conversationError] }) })), _jsx(StatusHud, { isRunning: isRunning, cwd: cwd, model: modelName, provider: providerName, approvalMode: hostOptions.approvalMode ?? 'ask', lanes: lanes }), approvalRequest ? (_jsx(ApprovalPrompt, { request: approvalRequest, inputMode: approvalInputMode, isFocused: approvalChoiceFocused && !approvalSubmitting, onApprove: () => {
|
|
490
538
|
void approveAction(approvalRequest.effectId, true);
|
|
491
539
|
}, onDeny: (reason) => {
|
|
492
540
|
void approveAction(approvalRequest.effectId, false, reason);
|
|
493
541
|
}, onInput: () => {
|
|
494
542
|
setApprovalChoiceFocused(false);
|
|
495
543
|
setApprovalInputMode(true);
|
|
496
|
-
} })) : askRequest ? (_jsx(AskPrompt, { request: askRequest, disabled: approvalSubmitting, onReply: (value) => void replyAsk(askRequest.effectId, value) }, askRequest.effectId)) : (_jsx(Box, { marginTop: 1, children: _jsx(InputArea, { onSubmit: (txt) => void handleSubmit(txt), disabled: approvalSubmitting, focus: !approvalSubmitting, placeholder: isRunning ? '运行中也可以输入;/cancel 可取消当前运行...' : '输入消息或 /help...' }) }))] }));
|
|
544
|
+
} })) : askRequest ? (_jsx(AskPrompt, { request: askRequest, disabled: approvalSubmitting, onReply: (value) => void replyAsk(askRequest.effectId, value) }, askRequest.effectId)) : (_jsx(Box, { marginTop: 1, children: _jsx(InputArea, { onSubmit: (txt) => void handleSubmit(txt), onRememberInput: rememberUserInput, onNavigateHistory: navigateUserInputHistory, disabled: approvalSubmitting, focus: !approvalSubmitting, placeholder: isRunning ? '运行中也可以输入;/cancel 可取消当前运行...' : '输入消息或 /help...' }) }))] }));
|
|
497
545
|
}
|
|
498
546
|
export default App;
|
|
@@ -6,11 +6,12 @@ const COMMANDS = [
|
|
|
6
6
|
category: 'General',
|
|
7
7
|
commands: [
|
|
8
8
|
{ name: '/mouse on|off', desc: '开关滚轮/触控板滚动;关闭后可原生选择文本' },
|
|
9
|
+
{ name: '/copy', desc: '复制当前选区或最近一条完整回复' },
|
|
9
10
|
{ name: '/help', desc: '显示帮助信息' },
|
|
10
11
|
{ name: '/status', desc: '查看会话状态' },
|
|
11
12
|
{ name: '/tools', desc: '列出可用工具' },
|
|
12
13
|
{ name: '/artifacts', desc: '查看当前产物列表' },
|
|
13
|
-
{ name: '/exit
|
|
14
|
+
{ name: '/exit', desc: '退出 CLI' },
|
|
14
15
|
{ name: '/cancel, /stop', desc: '取消当前运行并保留会话' },
|
|
15
16
|
],
|
|
16
17
|
},
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
interface Props {
|
|
2
2
|
onSubmit: (text: string) => void;
|
|
3
|
+
onRememberInput: (text: string) => void;
|
|
4
|
+
onNavigateHistory: (direction: 'up' | 'down', currentValue: string) => string | undefined;
|
|
3
5
|
disabled?: boolean;
|
|
4
6
|
focus?: boolean;
|
|
5
7
|
placeholder?: string;
|
|
6
8
|
}
|
|
7
|
-
export declare function InputArea({ onSubmit, disabled, focus, placeholder }: Props): import("react").JSX.Element;
|
|
9
|
+
export declare function InputArea({ onSubmit, onRememberInput, onNavigateHistory, disabled, focus, placeholder }: Props): import("react").JSX.Element;
|
|
8
10
|
export {};
|
|
@@ -3,11 +3,9 @@ import { Box, Text, useInput } from 'ink';
|
|
|
3
3
|
import TextInput from './TextInput.js';
|
|
4
4
|
import { useState } from 'react';
|
|
5
5
|
import { theme } from '../theme.js';
|
|
6
|
-
export function InputArea({ onSubmit, disabled, focus = true, placeholder }) {
|
|
6
|
+
export function InputArea({ onSubmit, onRememberInput, onNavigateHistory, disabled, focus = true, placeholder }) {
|
|
7
7
|
const [value, setValue] = useState('');
|
|
8
8
|
const [accumulatedLines, setAccumulatedLines] = useState([]);
|
|
9
|
-
const [history, setHistory] = useState([]);
|
|
10
|
-
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
11
9
|
useInput((input, key) => {
|
|
12
10
|
if (disabled || !focus)
|
|
13
11
|
return;
|
|
@@ -24,24 +22,21 @@ export function InputArea({ onSubmit, disabled, focus = true, placeholder }) {
|
|
|
24
22
|
return;
|
|
25
23
|
}
|
|
26
24
|
if (key.upArrow) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
setValue(history[history.length - 1 - nextIndex] || '');
|
|
31
|
-
}
|
|
25
|
+
const previous = onNavigateHistory('up', [...accumulatedLines, value].join('\n'));
|
|
26
|
+
if (previous !== undefined)
|
|
27
|
+
restoreInput(previous);
|
|
32
28
|
}
|
|
33
29
|
else if (key.downArrow) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
setValue(history[history.length - 1 - nextIndex] || '');
|
|
38
|
-
}
|
|
39
|
-
else if (historyIndex === 0) {
|
|
40
|
-
setHistoryIndex(-1);
|
|
41
|
-
setValue('');
|
|
42
|
-
}
|
|
30
|
+
const next = onNavigateHistory('down', [...accumulatedLines, value].join('\n'));
|
|
31
|
+
if (next !== undefined)
|
|
32
|
+
restoreInput(next);
|
|
43
33
|
}
|
|
44
34
|
});
|
|
35
|
+
const restoreInput = (text) => {
|
|
36
|
+
const lines = text.split('\n');
|
|
37
|
+
setAccumulatedLines(lines.slice(0, -1));
|
|
38
|
+
setValue(lines.at(-1) ?? '');
|
|
39
|
+
};
|
|
45
40
|
const handleSubmit = (text) => {
|
|
46
41
|
if (text.endsWith('\\')) {
|
|
47
42
|
const newLine = text.slice(0, -1);
|
|
@@ -52,10 +47,8 @@ export function InputArea({ onSubmit, disabled, focus = true, placeholder }) {
|
|
|
52
47
|
const finalLines = [...accumulatedLines, text];
|
|
53
48
|
const fullText = finalLines.join('\n');
|
|
54
49
|
if (fullText.trim()) {
|
|
55
|
-
|
|
56
|
-
setHistory(newHistory);
|
|
50
|
+
onRememberInput(fullText);
|
|
57
51
|
}
|
|
58
|
-
setHistoryIndex(-1);
|
|
59
52
|
setAccumulatedLines([]);
|
|
60
53
|
setValue('');
|
|
61
54
|
onSubmit(fullText);
|
|
@@ -5,6 +5,7 @@ interface Props {
|
|
|
5
5
|
showThinking?: boolean | undefined;
|
|
6
6
|
verbosity?: Verbosity | undefined;
|
|
7
7
|
isRunning?: boolean | undefined;
|
|
8
|
+
onSelectionChange?: ((text: string) => void) | undefined;
|
|
8
9
|
}
|
|
9
|
-
export declare function MessageList({ mouseEnabled, messages, showThinking, verbosity, isRunning }: Props): import("react").JSX.Element;
|
|
10
|
+
export declare function MessageList({ mouseEnabled, messages, showThinking, verbosity, isRunning, onSelectionChange }: Props): import("react").JSX.Element;
|
|
10
11
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { MessageViewport } from './MessageViewport.js';
|
|
3
|
-
export function MessageList({ mouseEnabled = true, messages, showThinking, verbosity = 'normal', isRunning = false }) {
|
|
4
|
-
return _jsx(MessageViewport, { mouseEnabled: mouseEnabled, messages: messages, ...(showThinking === undefined ? {} : { showThinking }), verbosity: verbosity, isRunning: isRunning });
|
|
3
|
+
export function MessageList({ mouseEnabled = true, messages, showThinking, verbosity = 'normal', isRunning = false, onSelectionChange }) {
|
|
4
|
+
return _jsx(MessageViewport, { mouseEnabled: mouseEnabled, messages: messages, ...(showThinking === undefined ? {} : { showThinking }), verbosity: verbosity, isRunning: isRunning, ...(onSelectionChange === undefined ? {} : { onSelectionChange }) });
|
|
5
5
|
}
|
|
@@ -5,6 +5,7 @@ interface Props {
|
|
|
5
5
|
showThinking?: boolean;
|
|
6
6
|
verbosity?: Verbosity;
|
|
7
7
|
isRunning?: boolean;
|
|
8
|
+
onSelectionChange?: (text: string) => void;
|
|
8
9
|
}
|
|
9
|
-
export declare function MessageViewport({ messages, verbosity, mouseEnabled }: Props): import("react").JSX.Element;
|
|
10
|
+
export declare function MessageViewport({ messages, verbosity, mouseEnabled, onSelectionChange }: Props): import("react").JSX.Element;
|
|
10
11
|
export {};
|
|
@@ -4,7 +4,9 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
|
4
4
|
import { mouseEvent, enableMouse, disableMouse } from '../utils/mouse.js';
|
|
5
5
|
import { theme } from '../theme.js';
|
|
6
6
|
import { transcriptLines, scrollAction } from '../utils/transcript.js';
|
|
7
|
-
|
|
7
|
+
import { selectedLine, selectedText } from '../utils/selection.js';
|
|
8
|
+
import { copyToClipboard } from '../utils/clipboard.js';
|
|
9
|
+
export function MessageViewport({ messages, verbosity = 'normal', mouseEnabled = true, onSelectionChange }) {
|
|
8
10
|
const { stdout } = useStdout();
|
|
9
11
|
useEffect(() => {
|
|
10
12
|
if (!mouseEnabled || !stdout.isTTY)
|
|
@@ -15,6 +17,10 @@ export function MessageViewport({ messages, verbosity = 'normal', mouseEnabled =
|
|
|
15
17
|
const box = useRef(null);
|
|
16
18
|
const [size, setSize] = useState({ width: stdout.columns || 80, height: 12 });
|
|
17
19
|
const [start, setStart] = useState(null);
|
|
20
|
+
const [selection, setSelection] = useState();
|
|
21
|
+
const selectionRef = useRef(undefined);
|
|
22
|
+
const selecting = useRef(false);
|
|
23
|
+
const selectionAnchor = useRef(undefined);
|
|
18
24
|
useLayoutEffect(() => {
|
|
19
25
|
if (!box.current)
|
|
20
26
|
return;
|
|
@@ -26,14 +32,68 @@ export function MessageViewport({ messages, verbosity = 'normal', mouseEnabled =
|
|
|
26
32
|
const page = Math.max(1, size.height - 1);
|
|
27
33
|
const bottom = Math.max(0, lines.length - page);
|
|
28
34
|
const first = start === null ? bottom : Math.min(start, bottom);
|
|
35
|
+
const pointAt = (x, y) => {
|
|
36
|
+
if (!box.current)
|
|
37
|
+
return undefined;
|
|
38
|
+
const layout = measureElement(box.current);
|
|
39
|
+
const localY = y - layout.y;
|
|
40
|
+
if (localY < 0 || localY >= page)
|
|
41
|
+
return undefined;
|
|
42
|
+
return { line: Math.max(0, Math.min(lines.length - 1, first + localY)), column: Math.max(0, x - layout.x) };
|
|
43
|
+
};
|
|
44
|
+
const publishSelection = (next) => {
|
|
45
|
+
selectionRef.current = next;
|
|
46
|
+
setSelection(next);
|
|
47
|
+
onSelectionChange?.(next ? selectedText(lines, next) : '');
|
|
48
|
+
};
|
|
29
49
|
useInput((input, key) => {
|
|
30
50
|
const mouse = mouseEvent(input);
|
|
31
51
|
if (mouse) {
|
|
32
|
-
if (mouseEnabled && mouse.wheel)
|
|
52
|
+
if (mouseEnabled && mouse.phase === 'wheel')
|
|
33
53
|
setStart((previous) => {
|
|
34
54
|
const next = Math.max(0, Math.min(bottom, (previous === null ? bottom : Math.min(previous, bottom)) + mouse.wheel));
|
|
35
55
|
return next === bottom ? null : next;
|
|
36
56
|
});
|
|
57
|
+
const isLeftButton = (mouse.button & 3) === 0;
|
|
58
|
+
if (mouseEnabled && isLeftButton && mouse.phase === 'press') {
|
|
59
|
+
const point = pointAt(mouse.x, mouse.y);
|
|
60
|
+
if (point) {
|
|
61
|
+
selecting.current = true;
|
|
62
|
+
selectionAnchor.current = point;
|
|
63
|
+
publishSelection({ start: point, end: point });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else if (mouseEnabled && selecting.current && mouse.phase === 'drag') {
|
|
67
|
+
const layout = box.current ? measureElement(box.current) : undefined;
|
|
68
|
+
if (layout && mouse.y < layout.y)
|
|
69
|
+
setStart((previous) => Math.max(0, (previous === null ? bottom : previous) - 1));
|
|
70
|
+
if (layout && mouse.y >= layout.y + page)
|
|
71
|
+
setStart((previous) => Math.min(bottom, (previous === null ? bottom : previous) + 1));
|
|
72
|
+
const localY = layout ? Math.max(0, Math.min(page - 1, mouse.y - layout.y)) : mouse.y;
|
|
73
|
+
const point = pointAt(mouse.x, (layout?.y ?? 0) + localY);
|
|
74
|
+
if (point && selectionAnchor.current)
|
|
75
|
+
publishSelection({ start: selectionAnchor.current, end: point });
|
|
76
|
+
}
|
|
77
|
+
else if (selecting.current && mouse.phase === 'release') {
|
|
78
|
+
selecting.current = false;
|
|
79
|
+
const completed = selectionRef.current;
|
|
80
|
+
if (selectionAnchor.current && completed && completed.start.line === completed.end.line && completed.start.column === completed.end.column) {
|
|
81
|
+
selectionAnchor.current = undefined;
|
|
82
|
+
publishSelection(undefined);
|
|
83
|
+
}
|
|
84
|
+
else if (completed) {
|
|
85
|
+
// Terminal emulators reserve Command+C before the CLI can see it.
|
|
86
|
+
// Sync the custom selection on mouse release so the standard copy
|
|
87
|
+
// shortcut always finds the selected text in the system clipboard.
|
|
88
|
+
void copyToClipboard(selectedText(lines, completed)).catch(() => { });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (key.escape && selection) {
|
|
94
|
+
selecting.current = false;
|
|
95
|
+
selectionAnchor.current = undefined;
|
|
96
|
+
publishSelection(undefined);
|
|
37
97
|
return;
|
|
38
98
|
}
|
|
39
99
|
const action = scrollAction(input, key);
|
|
@@ -46,5 +106,5 @@ export function MessageViewport({ messages, verbosity = 'normal', mouseEnabled =
|
|
|
46
106
|
if (action === 'bottom')
|
|
47
107
|
setStart(null);
|
|
48
108
|
});
|
|
49
|
-
return (_jsxs(Box, { ref: box, flexDirection: "column", flexGrow: 1, flexShrink: 1, flexBasis: 0, minHeight: 3, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", height: page, overflow: "hidden", children: _jsx(Text, { children: lines.slice(first, first + page).join('\n') }) }), _jsx(Text, { color: theme.dim, children: start === null ? (mouseEnabled ? '滚轮 / 双指滚动 · Ctrl+G 回到底部' : 'Ctrl+P 上翻 · Ctrl+N 下翻') : `历史 ${first + 1}–${Math.min(lines.length, first + page)}/${lines.length} · 滚动浏览 · Ctrl+G 回到底部` })] }));
|
|
109
|
+
return (_jsxs(Box, { ref: box, flexDirection: "column", flexGrow: 1, flexShrink: 1, flexBasis: 0, minHeight: 3, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", height: page, overflow: "hidden", children: _jsx(Text, { children: lines.slice(first, first + page).map((line, index) => selectedLine(line, first + index, selection)).join('\n') }) }), _jsx(Text, { color: theme.dim, children: selection ? '已选中 · 松开鼠标已复制 · Ctrl+C / /copy 复制 · Esc 取消选择' : start === null ? (mouseEnabled ? '滚轮 / 双指滚动 · 拖动选择后自动复制 · Ctrl+C 复制选区 · /copy 复制回复 · Ctrl+G 回到底部' : 'Ctrl+P 上翻 · Ctrl+N 下翻 · 终端可原生选择文字') : `历史 ${first + 1}–${Math.min(lines.length, first + page)}/${lines.length} · 滚动浏览 · 拖动选择后自动复制 · Ctrl+C / /copy 复制 · Ctrl+G 回到底部` })] }));
|
|
50
110
|
}
|
|
@@ -5,8 +5,7 @@ interface Props {
|
|
|
5
5
|
model: string;
|
|
6
6
|
provider: string;
|
|
7
7
|
approvalMode: 'read-only' | 'ask' | 'auto';
|
|
8
|
-
currentStep?: string | null;
|
|
9
8
|
lanes: LaneDisplay[];
|
|
10
9
|
}
|
|
11
|
-
export declare function StatusHud({ isRunning, approvalMode,
|
|
10
|
+
export declare function StatusHud({ isRunning, approvalMode, lanes }: Props): import("react").JSX.Element;
|
|
12
11
|
export {};
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { Spinner } from './Spinner.js';
|
|
4
4
|
import { theme } from '../theme.js';
|
|
5
|
-
export function StatusHud({ isRunning, approvalMode,
|
|
5
|
+
export function StatusHud({ isRunning, approvalMode, lanes }) {
|
|
6
6
|
const active = lanes.filter((lane) => !['succeeded', 'failed', 'cancelled'].includes(lane.status));
|
|
7
|
-
return _jsxs(Box, { flexShrink: 0, gap: 1, children: [isRunning && _jsx(Spinner, {}), _jsxs(Text, { color: theme.dim, wrap: "truncate-end", children: [isRunning ?
|
|
7
|
+
return _jsxs(Box, { flexShrink: 0, gap: 1, children: [isRunning && _jsx(Spinner, {}), _jsxs(Text, { color: theme.dim, wrap: "truncate-end", children: [isRunning ? '任务进行中 · 详细进度显示在对话里 · /cancel 停止' : '就绪', " \u00B7 ", approvalMode === 'auto' ? '自动审批' : approvalMode === 'read-only' ? '只读' : '操作前询问', active.length > 1 ? ` · ${active.length} 个子任务` : '', " \u00B7 /help \u5E2E\u52A9 \u00B7 /exit \u9000\u51FA"] })] });
|
|
8
8
|
}
|
|
@@ -8,7 +8,9 @@ export declare function useConversation({ host, conversationId, }: {
|
|
|
8
8
|
messages: DisplayMessage[];
|
|
9
9
|
error: string | null;
|
|
10
10
|
addUserMessage: (text: string) => void;
|
|
11
|
-
addAssistantMessage: (msg: DisplayMessage
|
|
11
|
+
addAssistantMessage: (msg: DisplayMessage, options?: {
|
|
12
|
+
moveToEnd?: boolean;
|
|
13
|
+
}) => void;
|
|
12
14
|
clearMessages: () => void;
|
|
13
15
|
switchConversation: (id: string) => Promise<void>;
|
|
14
16
|
newConversation: () => Promise<ConversationHandle | null>;
|
|
@@ -63,10 +63,12 @@ export function useConversation({ host, conversationId, }) {
|
|
|
63
63
|
};
|
|
64
64
|
setMessages((prev) => [...prev, newMessage]);
|
|
65
65
|
}, []);
|
|
66
|
-
const addAssistantMessage = useCallback((msg) => {
|
|
66
|
+
const addAssistantMessage = useCallback((msg, options) => {
|
|
67
67
|
setMessages((prev) => {
|
|
68
68
|
const index = prev.findIndex((m) => m.id === msg.id);
|
|
69
69
|
if (index >= 0) {
|
|
70
|
+
if (options?.moveToEnd)
|
|
71
|
+
return [...prev.filter((m) => m.id !== msg.id), msg];
|
|
70
72
|
const next = [...prev];
|
|
71
73
|
next[index] = msg;
|
|
72
74
|
return next;
|
package/dist/hooks/useRun.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ export declare function describeFactStatus(data: unknown): string;
|
|
|
4
4
|
export declare function useRun({ host, conversationId, addAssistantMessage, }: {
|
|
5
5
|
host: LocalHost | null;
|
|
6
6
|
conversationId: string | null;
|
|
7
|
-
addAssistantMessage: (msg: DisplayMessage
|
|
7
|
+
addAssistantMessage: (msg: DisplayMessage, options?: {
|
|
8
|
+
moveToEnd?: boolean;
|
|
9
|
+
}) => void;
|
|
8
10
|
}): {
|
|
9
11
|
isRunning: boolean;
|
|
10
12
|
currentStep: string | null;
|
package/dist/hooks/useRun.js
CHANGED
|
@@ -50,11 +50,30 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
50
50
|
id: run.id,
|
|
51
51
|
role: 'assistant',
|
|
52
52
|
text: '',
|
|
53
|
+
streamStatus: 'streaming',
|
|
53
54
|
createdAt: new Date().toISOString(),
|
|
54
55
|
toolCalls: [],
|
|
55
56
|
runId: run.id,
|
|
56
57
|
};
|
|
57
58
|
let assistantStarted = false;
|
|
59
|
+
let streamedEffectId;
|
|
60
|
+
let streamedAttemptId;
|
|
61
|
+
let lastProgressText = '';
|
|
62
|
+
const addProgress = (key, text) => {
|
|
63
|
+
if (!text || text === lastProgressText)
|
|
64
|
+
return;
|
|
65
|
+
lastProgressText = text;
|
|
66
|
+
addAssistantMessage({
|
|
67
|
+
// One live activity cell is updated in place, like Codex/Claude's
|
|
68
|
+
// active-turn transcript, instead of appending every snapshot.
|
|
69
|
+
id: `run-activity-${run.id}`,
|
|
70
|
+
role: 'system',
|
|
71
|
+
text,
|
|
72
|
+
createdAt: new Date().toISOString(),
|
|
73
|
+
runId: run.id,
|
|
74
|
+
}, key === 'finished' ? { moveToEnd: true } : undefined);
|
|
75
|
+
};
|
|
76
|
+
addProgress('received', '已收到任务,正在理解需求并安排步骤…');
|
|
58
77
|
const ensureAssistant = () => {
|
|
59
78
|
if (assistantStarted)
|
|
60
79
|
return;
|
|
@@ -68,6 +87,16 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
68
87
|
: {};
|
|
69
88
|
const text = typeof data.text === 'string' ? data.text : '';
|
|
70
89
|
if (text) {
|
|
90
|
+
if (data.kind === 'task_progress') {
|
|
91
|
+
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
|
|
92
|
+
const visibleText = tasks.length === 0 ? '正在理解需求并规划执行步骤…' : text;
|
|
93
|
+
setCurrentStep(visibleText);
|
|
94
|
+
const active = tasks.find((task) => task && typeof task === 'object' && ['running', 'verifying'].includes(String(task.status)));
|
|
95
|
+
const stageKey = active ? `${String(active.id)}:${String(active.status)}:${String(active.goal)}` : visibleText.replace(/\s*[·|]\s*(阶段调用|stage calls)\s*\d+.*$/i, '');
|
|
96
|
+
const stageText = visibleText.replace(/\s*[·|]\s*(阶段调用|stage calls)\s*\d+.*$/i, '');
|
|
97
|
+
addProgress(`stage:${stageKey}`, stageText);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
71
100
|
addAssistantMessage({
|
|
72
101
|
id: `notice-${run.id}-${event.seq}`,
|
|
73
102
|
role: 'system',
|
|
@@ -77,10 +106,37 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
77
106
|
}
|
|
78
107
|
continue;
|
|
79
108
|
}
|
|
80
|
-
|
|
109
|
+
if (event.type !== 'complete' && event.type !== 'error')
|
|
110
|
+
ensureAssistant();
|
|
81
111
|
switch (event.type) {
|
|
112
|
+
case 'delta': {
|
|
113
|
+
const delta = event.data && typeof event.data === 'object' && !Array.isArray(event.data)
|
|
114
|
+
? event.data
|
|
115
|
+
: {};
|
|
116
|
+
const effectId = typeof delta.effectId === 'string' ? delta.effectId : undefined;
|
|
117
|
+
const attemptId = typeof delta.attemptId === 'string' ? delta.attemptId : undefined;
|
|
118
|
+
const text = typeof delta.text === 'string' ? delta.text : '';
|
|
119
|
+
if (!text)
|
|
120
|
+
break;
|
|
121
|
+
if (effectId !== streamedEffectId || attemptId !== streamedAttemptId) {
|
|
122
|
+
assistantMessage.text = '';
|
|
123
|
+
streamedEffectId = effectId;
|
|
124
|
+
streamedAttemptId = attemptId;
|
|
125
|
+
}
|
|
126
|
+
assistantMessage.text += text;
|
|
127
|
+
assistantMessage.streamStatus = 'streaming';
|
|
128
|
+
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls?.map((call) => ({ ...call })) });
|
|
129
|
+
setCurrentStep('正在生成回复…');
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
82
132
|
case 'text':
|
|
83
|
-
|
|
133
|
+
if (streamedEffectId !== undefined)
|
|
134
|
+
assistantMessage.text = String(event.data ?? '');
|
|
135
|
+
else
|
|
136
|
+
assistantMessage.text += String(event.data ?? '');
|
|
137
|
+
streamedEffectId = undefined;
|
|
138
|
+
streamedAttemptId = undefined;
|
|
139
|
+
assistantMessage.streamStatus = 'complete';
|
|
84
140
|
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls?.map((call) => ({ ...call })) });
|
|
85
141
|
break;
|
|
86
142
|
case 'observation': {
|
|
@@ -102,6 +158,8 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
102
158
|
? calls.map((call, callIndex) => callIndex === index ? { ...call, ...nextCall } : call)
|
|
103
159
|
: [...calls, nextCall];
|
|
104
160
|
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls.map((call) => ({ ...call })) });
|
|
161
|
+
setCurrentStep(nextCall.status === 'succeeded' ? `已完成 ${nextCall.name},正在整理结果…` : nextCall.status === 'failed' ? `${nextCall.name} 未成功,正在处理错误…` : `正在执行 ${nextCall.name}…`);
|
|
162
|
+
addProgress(`tool-result:${id}:${nextCall.status}`, `${nextCall.status === 'succeeded' ? '已完成' : nextCall.status === 'cancelled' ? '已取消' : '执行失败'} · ${nextCall.name}`);
|
|
105
163
|
}
|
|
106
164
|
break;
|
|
107
165
|
}
|
|
@@ -135,6 +193,7 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
135
193
|
...(typeof input.defaultValue === 'string' ? { defaultValue: input.defaultValue } : {}),
|
|
136
194
|
});
|
|
137
195
|
setCurrentStep('等待你的回答...');
|
|
196
|
+
addProgress(`waiting:${effectId}`, '等待你的回答…');
|
|
138
197
|
break;
|
|
139
198
|
}
|
|
140
199
|
setAskRequest(null);
|
|
@@ -162,6 +221,7 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
162
221
|
...(tools.length ? { tools } : {}),
|
|
163
222
|
});
|
|
164
223
|
setCurrentStep('等待用户审批...');
|
|
224
|
+
addProgress(`approval:${effectId}`, `等待审批 · ${tools.map((tool) => tool.name).join('、') || '系统操作'}`);
|
|
165
225
|
break;
|
|
166
226
|
}
|
|
167
227
|
case 'fact':
|
|
@@ -181,6 +241,29 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
181
241
|
...(typeof item.activity === 'string' ? { activity: item.activity } : {}),
|
|
182
242
|
}];
|
|
183
243
|
}));
|
|
244
|
+
const activeLanes = fact.lanes.filter((lane) => typeof lane.status === 'string' && !['succeeded', 'failed', 'cancelled'].includes(lane.status));
|
|
245
|
+
const active = activeLanes.find((lane) => lane.activityKind === 'tool') ?? activeLanes[0];
|
|
246
|
+
if (active && typeof active.goal === 'string') {
|
|
247
|
+
const toolName = active.activityKind === 'tool' && typeof active.activity === 'string' ? active.activity : undefined;
|
|
248
|
+
const activity = toolName ? `正在调用工具 · ${toolName}` : typeof active.activity === 'string'
|
|
249
|
+
? active.activity === 'llm' ? '正在分析需求和已有证据…' : '正在处理任务…'
|
|
250
|
+
: '正在处理任务…';
|
|
251
|
+
setCurrentStep(`${activity}:${active.goal}`);
|
|
252
|
+
const activityId = toolName
|
|
253
|
+
? (typeof active.activityEffectId === 'string' ? active.activityEffectId : active.id)
|
|
254
|
+
: active.activity === 'llm' ? 'analysis' : `${active.id}:${active.status}`;
|
|
255
|
+
addProgress(`activity:${activityId}`, activity);
|
|
256
|
+
if (toolName && typeof active.activityToolCallId === 'string') {
|
|
257
|
+
const id = active.activityToolCallId;
|
|
258
|
+
const calls = assistantMessage.toolCalls ?? [];
|
|
259
|
+
const index = calls.findIndex((call) => call.id === id);
|
|
260
|
+
const nextCall = { id, name: toolName, status: 'running', arguments: {} };
|
|
261
|
+
assistantMessage.toolCalls = index >= 0
|
|
262
|
+
? calls.map((call, callIndex) => callIndex === index ? { ...call, ...nextCall } : call)
|
|
263
|
+
: [...calls, nextCall];
|
|
264
|
+
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls.map((call) => ({ ...call })) });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
184
267
|
break;
|
|
185
268
|
}
|
|
186
269
|
}
|
|
@@ -189,6 +272,11 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
189
272
|
setCurrentStep(describeFactStatus(event.data));
|
|
190
273
|
break;
|
|
191
274
|
case 'error':
|
|
275
|
+
addProgress('finished', '运行遇到错误。');
|
|
276
|
+
if (assistantStarted && assistantMessage.text) {
|
|
277
|
+
assistantMessage.streamStatus = 'incomplete';
|
|
278
|
+
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls?.map((call) => ({ ...call })) });
|
|
279
|
+
}
|
|
192
280
|
setError(String(event.data ?? '发生未知错误'));
|
|
193
281
|
setIsRunning(false);
|
|
194
282
|
setCurrentStep(null);
|
|
@@ -197,22 +285,32 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
197
285
|
setLanes([]);
|
|
198
286
|
break;
|
|
199
287
|
case 'complete':
|
|
288
|
+
addProgress('finished', event.data && typeof event.data === 'object' && !Array.isArray(event.data) && event.data.status === 'succeeded' ? '任务处理完成。' : '任务已结束。');
|
|
200
289
|
setIsRunning(false);
|
|
201
290
|
setCurrentStep(null);
|
|
202
291
|
setApprovalRequest(null);
|
|
203
292
|
setAskRequest(null);
|
|
204
293
|
if (event.data && typeof event.data === 'object' && !Array.isArray(event.data)) {
|
|
205
294
|
const completion = event.data;
|
|
295
|
+
const accepted = completion.status === 'succeeded' && (!completion.taskOutcome || (typeof completion.taskOutcome === 'object' && completion.taskOutcome !== null && completion.taskOutcome.status === 'accepted'));
|
|
296
|
+
if (assistantStarted && assistantMessage.text) {
|
|
297
|
+
assistantMessage.streamStatus = accepted ? 'complete' : 'incomplete';
|
|
298
|
+
addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls?.map((call) => ({ ...call })) });
|
|
299
|
+
}
|
|
206
300
|
const usage = completion.usage && typeof completion.usage === 'object' && !Array.isArray(completion.usage)
|
|
207
301
|
? completion.usage
|
|
208
302
|
: undefined;
|
|
209
303
|
if (usage) {
|
|
210
304
|
const input = typeof usage.inputTokens === 'number' ? usage.inputTokens.toLocaleString() : '未知';
|
|
211
305
|
const output = typeof usage.outputTokens === 'number' ? usage.outputTokens.toLocaleString() : '未知';
|
|
306
|
+
const reasoning = typeof usage.reasoningTokens === 'number' ? usage.reasoningTokens.toLocaleString() : '未知';
|
|
307
|
+
const visibleChars = typeof usage.visibleOutputChars === 'number' ? usage.visibleOutputChars.toLocaleString() : '未知';
|
|
308
|
+
const modelCalls = typeof usage.modelCalls === 'number' ? usage.modelCalls.toLocaleString() : '未知';
|
|
309
|
+
const truncations = typeof usage.truncationEvents === 'number' ? usage.truncationEvents.toLocaleString() : '未知';
|
|
212
310
|
const costs = Array.isArray(usage.providerCosts) ? usage.providerCosts.filter((item) => Boolean(item && typeof item === 'object' && typeof item.currency === 'string' && typeof item.amount === 'number')) : [];
|
|
213
311
|
const estimate = usage.estimatedCost && typeof usage.estimatedCost === 'object' ? usage.estimatedCost : undefined;
|
|
214
312
|
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 });
|
|
313
|
+
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
314
|
}
|
|
217
315
|
const taskOutcome = completion.taskOutcome && typeof completion.taskOutcome === 'object' && !Array.isArray(completion.taskOutcome)
|
|
218
316
|
? completion.taskOutcome
|
|
@@ -270,7 +368,7 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
270
368
|
}
|
|
271
369
|
setIsRunning(true);
|
|
272
370
|
setError(null);
|
|
273
|
-
setCurrentStep('
|
|
371
|
+
setCurrentStep('正在理解你的需求并安排执行步骤…');
|
|
274
372
|
try {
|
|
275
373
|
const run = await host.sendMessage(activeConversationId, { text });
|
|
276
374
|
await consumeRun(run);
|
|
@@ -288,7 +386,7 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
|
|
|
288
386
|
return;
|
|
289
387
|
setIsRunning(true);
|
|
290
388
|
setError(null);
|
|
291
|
-
setCurrentStep('
|
|
389
|
+
setCurrentStep('正在恢复任务进度…');
|
|
292
390
|
try {
|
|
293
391
|
const run = await host.resumeRun(activeConversationId);
|
|
294
392
|
await consumeRun(run);
|
|
@@ -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
|
+
}
|
package/dist/utils/transcript.js
CHANGED
|
@@ -6,25 +6,58 @@ import { renderMarkdownToAnsi } from './markdown.js';
|
|
|
6
6
|
export function transcriptLines(messages, width, verbosity = 'normal') {
|
|
7
7
|
const blocks = messages.map((message) => {
|
|
8
8
|
const text = stripTerminalControls(message.text);
|
|
9
|
-
|
|
10
|
-
return chalk.cyan(`你 › ${text}`);
|
|
9
|
+
const contentWidth = Math.max(8, width - 2);
|
|
11
10
|
if (message.role === 'system')
|
|
12
|
-
return chalk.dim(text);
|
|
11
|
+
return { id: message.id, role: message.role, lines: wrapAnsi(chalk.dim(`· ${text}`), Math.max(8, width), { hard: true, trim: true }).split('\n') };
|
|
12
|
+
if (message.role === 'user') {
|
|
13
|
+
const body = wrapAnsi(text, contentWidth, { hard: true, trim: false }).split('\n');
|
|
14
|
+
return { id: message.id, role: message.role, lines: [chalk.blue.bold('❯ 你发来'), ...body.map((line) => ` ${line}`)] };
|
|
15
|
+
}
|
|
13
16
|
const parts = [];
|
|
14
17
|
const tools = message.toolCalls ?? [];
|
|
15
18
|
if (verbosity !== 'quiet' && tools.length) {
|
|
16
19
|
const failed = tools.filter((tool) => tool.status === 'failed' || tool.status === 'cancelled');
|
|
17
20
|
parts.push(chalk.dim(`工具 · ${tools.filter((tool) => tool.status === 'succeeded').length}/${tools.length} 已完成${failed.length ? ` · ${failed.length} 未成功` : ''}`));
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
const statusLabel = { running: '正在调用', succeeded: '已完成', failed: '失败', cancelled: '已取消' };
|
|
22
|
+
if (verbosity === 'verbose') {
|
|
23
|
+
for (const tool of tools) {
|
|
24
|
+
const detail = `${tool.name} · ${statusLabel[tool.status]}\n${JSON.stringify(tool.arguments ?? {}, null, 2)}${tool.result === undefined ? '' : `\n${typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2)}`}`;
|
|
25
|
+
parts.push(chalk.dim(stripTerminalControls(detail)));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
const groups = new Map();
|
|
30
|
+
for (const tool of tools) {
|
|
31
|
+
const result = tool.result && typeof tool.result === 'object' && !Array.isArray(tool.result) ? tool.result : undefined;
|
|
32
|
+
const error = tool.status === 'failed' || tool.status === 'cancelled'
|
|
33
|
+
? [result?.code, result?.message].filter((value) => typeof value === 'string').at(-1)
|
|
34
|
+
: undefined;
|
|
35
|
+
const key = `${tool.name}\0${tool.status}\0${error ?? ''}`;
|
|
36
|
+
const group = groups.get(key);
|
|
37
|
+
if (group)
|
|
38
|
+
group.count++;
|
|
39
|
+
else
|
|
40
|
+
groups.set(key, { name: tool.name, status: tool.status, count: 1, ...(error ? { error } : {}) });
|
|
41
|
+
}
|
|
42
|
+
for (const group of groups.values()) {
|
|
43
|
+
parts.push(chalk.dim(`${group.name}${group.count > 1 ? ` × ${group.count}` : ''} · ${statusLabel[group.status]}${group.error ? ` · ${group.error}` : ''}`));
|
|
44
|
+
}
|
|
21
45
|
}
|
|
22
46
|
}
|
|
23
47
|
if (text)
|
|
24
|
-
parts.push(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
48
|
+
parts.push(renderMarkdownToAnsi(text));
|
|
49
|
+
const body = wrapAnsi(parts.join('\n'), contentWidth, { hard: true, trim: false }).split('\n');
|
|
50
|
+
const streamLabel = message.streamStatus === 'streaming' ? chalk.yellow(' · 正在生成') : message.streamStatus === 'incomplete' ? chalk.red(' · 未完成') : '';
|
|
51
|
+
return { id: message.id, role: message.role, lines: [chalk.cyan.bold('Pulse') + streamLabel, ...body.map((line) => ` ${line}`)] };
|
|
52
|
+
}).filter((block) => block.lines.length > 0);
|
|
53
|
+
return blocks.flatMap((block, index) => {
|
|
54
|
+
if (index === blocks.length - 1)
|
|
55
|
+
return block.lines;
|
|
56
|
+
const next = blocks[index + 1];
|
|
57
|
+
const isLiveActivity = (item) => item.role === 'system' && item.id.startsWith('run-activity-');
|
|
58
|
+
const separator = isLiveActivity(block) || isLiveActivity(next) ? [] : [''];
|
|
59
|
+
return [...block.lines, ...separator];
|
|
60
|
+
});
|
|
28
61
|
}
|
|
29
62
|
export function scrollAction(input, key) {
|
|
30
63
|
if (key.pageUp || (key.ctrl && input === 'p'))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hunterzhu/pulse-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A local-first AI task assistant for coding, research, and file workflows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"registry": "https://registry.npmjs.org"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@hunterzhu/pulse-server": "0.
|
|
38
|
+
"@hunterzhu/pulse-server": "0.3.0",
|
|
39
39
|
"chalk": "^5.4.1",
|
|
40
40
|
"highlight.js": "^11.10.0",
|
|
41
41
|
"ink": "^7.1.1",
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -1,11 +1,58 @@
|
|
|
1
1
|
import { ensurePulseUserConfig, defaultPulseConfigPath } from '../dist/config.js'
|
|
2
2
|
|
|
3
|
+
// Install-time welcome screen. Colors stay off for piped or captured install
|
|
4
|
+
// logs and can be forced with FORCE_COLOR for debugging.
|
|
5
|
+
const colorEnabled =
|
|
6
|
+
process.env.NO_COLOR === undefined &&
|
|
7
|
+
process.env.FORCE_COLOR !== '0' &&
|
|
8
|
+
(process.stdout.isTTY === true || process.env.FORCE_COLOR !== undefined)
|
|
9
|
+
|
|
10
|
+
const paint = (code) => (text) => (colorEnabled ? `\u001b[${code}m${text}\u001b[0m` : text)
|
|
11
|
+
const cyan = paint('36')
|
|
12
|
+
const bold = paint('1')
|
|
13
|
+
const dim = paint('2')
|
|
14
|
+
|
|
15
|
+
const banner = [
|
|
16
|
+
'██████╗ ██╗ ██╗██╗ ███████╗███████╗',
|
|
17
|
+
'██╔══██╗██║ ██║██║ ██╔════╝██╔════╝',
|
|
18
|
+
'██████╔╝██║ ██║██║ ███████╗█████╗ ',
|
|
19
|
+
'██╔═══╝ ██║ ██║██║ ╚════██║██╔══╝ ',
|
|
20
|
+
'██║ ╚██████╔╝███████╗███████║███████╗',
|
|
21
|
+
'╚═╝ ╚═════╝ ╚══════╝╚══════╝╚══════╝',
|
|
22
|
+
].map((line) => cyan(line)).join('\n')
|
|
23
|
+
|
|
24
|
+
function printWelcome({ configPath, error } = {}) {
|
|
25
|
+
const lines = [
|
|
26
|
+
'',
|
|
27
|
+
banner,
|
|
28
|
+
'',
|
|
29
|
+
` ${bold('Pulse')} - a neural-signal like AI assistant for coding, research and file tasks.`,
|
|
30
|
+
'',
|
|
31
|
+
` ${dim('Get started:')}`,
|
|
32
|
+
` ${cyan('cd project_dir')}`,
|
|
33
|
+
` ${cyan('pulse')}`,
|
|
34
|
+
'',
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
if (configPath) {
|
|
38
|
+
// Print the fully resolved absolute path on its own, uncolored line so it
|
|
39
|
+
// can be copied straight out of the terminal without ANSI escape codes.
|
|
40
|
+
lines.push(` ${dim('Config ready at')}`)
|
|
41
|
+
lines.push(` ${configPath}`)
|
|
42
|
+
} else {
|
|
43
|
+
lines.push(` ${dim('Config not created:')} ${error}`)
|
|
44
|
+
lines.push(` ${dim('Run "pulse setup" later to create it.')}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
lines.push('')
|
|
48
|
+
console.log(lines.join('\n'))
|
|
49
|
+
}
|
|
50
|
+
|
|
3
51
|
try {
|
|
4
52
|
const configPath = defaultPulseConfigPath()
|
|
5
53
|
await ensurePulseUserConfig(configPath)
|
|
6
|
-
|
|
54
|
+
printWelcome({ configPath })
|
|
7
55
|
} catch (error) {
|
|
8
56
|
const message = error instanceof Error ? error.message : String(error)
|
|
9
|
-
|
|
10
|
-
console.warn('Run "pulse setup" later to create it.')
|
|
57
|
+
printWelcome({ error: message })
|
|
11
58
|
}
|